mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Add lh-notice-search skill and /v1/lh-notice/{search,detail} proxy routes
Wraps the official data.go.kr LH (Korea Land & Housing Corporation) 청약 공고 Open API (B552555/lhLeaseNoticeInfo1/*) so agents can look up LH 임대/분양/주거복지/토지/상가 공고 by region, status, category, keyword, and notice ID without asking users for a ServiceKey. Reuses the shared DATA_GO_KR_API_KEY the proxy already manages; users see '불필요'. Adapter handles both the LH-specific [CMN, dsList] JSON envelope and the standard data.go.kr <OpenAPI_ServiceResponse> XML error envelope; refuses to cache failure responses so transient upstream errors self-heal. Closes #145.
This commit is contained in:
parent
b8f928be0b
commit
617a025931
12 changed files with 1837 additions and 2 deletions
5
.changeset/lh-notice-search.md
Normal file
5
.changeset/lh-notice-search.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"k-skill-proxy": minor
|
||||
---
|
||||
|
||||
Add `/v1/lh-notice/search` and `/v1/lh-notice/detail` routes plus matching `lh-notice-search` skill. Proxies the official LH 청약 (Korea Land & Housing Corporation lease/subscription) notice API on `apis.data.go.kr/B552555/lhLeaseNoticeInfo1/*`, reuses the existing `DATA_GO_KR_API_KEY`, and keeps the user-facing credential surface empty ("불필요"). Handles the LH-specific `[CMN, dsList]` JSON envelope plus the standard data.go.kr XML auth-error envelope, does not cache upstream failures, and exposes `lhNoticeConfigured` on `/health`. Closes #145.
|
||||
|
|
@ -32,6 +32,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
| 한국 법령 검색 | 한국 법령/조문/판례/유권해석 검색 | 불필요 | [한국 법령 검색 가이드](docs/features/korean-law-search.md) |
|
||||
| 한국 개인정보처리방침·이용약관 자동 생성 | Next.js 프로젝트에 개인정보보호법·약관규제법·전자상거래법 기반 개인정보처리방침/이용약관/쿠키 배너/동의 모달을 생성하는 `kimlawtech/korean-privacy-terms` (Apache-2.0) thin wrapper | 불필요 | [한국 개인정보처리방침·이용약관 자동 생성 가이드](docs/features/korean-privacy-terms.md) |
|
||||
| 한국 부동산 실거래가 조회 | 아파트/오피스텔/빌라/단독주택 실거래가·전월세·지역코드 조회 | 불필요 | [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md) |
|
||||
| LH 청약 공고문 조회 | 한국토지주택공사(LH) 임대/분양/주거복지(신혼희망타운)/토지/상가 공고를 지역·상태·공고유형·키워드로 조회하고 마감 여부를 KST 기준으로 표시 | 불필요 | [LH 청약 공고문 조회 가이드](docs/features/lh-notice-search.md) |
|
||||
| 장학금 검색 및 조회 | 한국장학재단·전국 대학교·재단·기업 장학 공고를 검색해 금액·자격·지원구간·링크를 정리하고 KST 기준 현재 날짜 마감 상태와 조건별 필터링까지 제공 | 불필요 | [장학금 검색 및 조회 가이드](docs/features/korean-scholarship-search.md) |
|
||||
| 생활쓰레기 배출정보 조회 | 시군구 기준 생활쓰레기·음식물·재활용 배출요일·시간·장소·관리부서 확인 | 불필요 | [생활쓰레기 배출정보 조회 가이드](docs/features/household-waste-info.md) |
|
||||
| 학교 급식 식단 조회 | 교육청·학교명으로 NEIS 학교 검색·급식 식단 조회 | 불필요 | [학교 급식 식단 조회 가이드](docs/features/k-schoollunch-menu.md) |
|
||||
|
|
@ -110,6 +111,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
- [한국 법령 검색 가이드](docs/features/korean-law-search.md)
|
||||
- [한국 개인정보처리방침·이용약관 자동 생성 가이드](docs/features/korean-privacy-terms.md)
|
||||
- [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md)
|
||||
- [LH 청약 공고문 조회 가이드](docs/features/lh-notice-search.md)
|
||||
- [장학금 검색 및 조회 가이드](docs/features/korean-scholarship-search.md)
|
||||
- [생활쓰레기 배출정보 조회 가이드](docs/features/household-waste-info.md)
|
||||
- [학교 급식 식단 조회 가이드](docs/features/k-schoollunch-menu.md)
|
||||
|
|
|
|||
137
docs/features/lh-notice-search.md
Normal file
137
docs/features/lh-notice-search.md
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# LH 청약 공고문 조회 가이드
|
||||
|
||||
한국토지주택공사(LH)가 `apply.lh.or.kr` 로 공고하는 임대주택·분양주택·주거복지(신혼희망타운)·토지·상가 공고를 공공데이터포털 공식 LH 공고 Open API(`http://apis.data.go.kr/B552555/lhLeaseNoticeInfo1/...`)로 조회한다. 프록시 서버가 `serviceKey` 주입, 캐시, 율속을 맡는다.
|
||||
|
||||
## 이 기능으로 할 수 있는 일
|
||||
|
||||
- 공고중·접수중·접수마감 등 상태별 LH 공고 목록 조회
|
||||
- 지역(시/도), 공고 유형(영구임대·행복주택·전세임대·국민임대·매입임대·분양주택·신혼희망타운 등) 필터링
|
||||
- 공고명 키워드 검색 (예: "행복주택", "든든주택", "청년", "신혼희망")
|
||||
- 공고 게시일·접수 마감일 구간 필터
|
||||
- 공고 상세(주택형별 공급 정보, 공식 링크) 확인
|
||||
|
||||
## 가장 중요한 규칙
|
||||
|
||||
기본 경로는 `https://k-skill-proxy.nomadamas.org/v1/lh-notice/...` 이다. 사용자는 **공공데이터포털 ServiceKey 를 준비할 필요가 없다**. upstream key(`DATA_GO_KR_API_KEY`)는 프록시 서버에서만 주입한다.
|
||||
|
||||
마감 여부는 **KST(Asia/Seoul)** 기준으로 판정한다. host local time 을 쓰지 않는다.
|
||||
|
||||
본 스킬은 LH 공고 전용이다. SH(서울), GH(경기), iH(인천) 등 지방 주택공사 공고는 포함되지 않는다. 사용자가 그런 공고를 찾으면 본 스킬 범위가 아님을 분명히 말한다.
|
||||
|
||||
## 먼저 필요한 것
|
||||
|
||||
- 인터넷 연결
|
||||
- `curl` 또는 HTTP 호출이 가능한 도구
|
||||
- (프록시 운영자 전용) `DATA_GO_KR_API_KEY` 환경변수
|
||||
|
||||
## 지원 엔드포인트
|
||||
|
||||
| Route | 설명 |
|
||||
| --- | --- |
|
||||
| `GET /v1/lh-notice/search` | 공고 목록 조회. 필터 전부 선택사항. |
|
||||
| `GET /v1/lh-notice/detail` | 특정 공고 상세 + 주택형별 공급 정보. `panId`/`ccrCnntSysDsCd`/`splInfTpCd` 모두 필수. |
|
||||
|
||||
### `/v1/lh-notice/search` 파라미터
|
||||
|
||||
| 파라미터 | 타입 | 기본값 | 설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| `panSs` / `status` | string | (없음) | `공고중`, `접수중`, `접수마감`, `당첨자발표`, `추정공고` |
|
||||
| `uppAisTpCd` / `category` | string | (없음) | `01`=토지, `05`=분양주택, `06`=임대주택, `13`=주거복지, `22`=상가 |
|
||||
| `aisTpCd` | digits | (없음) | 세부 분류 코드. 예: `09`=영구임대, `10`=행복주택, `17`=전세임대 |
|
||||
| `cnpCdNm` / `region` | string | (없음) | 지역명(시/도). 예: `서울특별시`, `부산광역시`, `전국` |
|
||||
| `panNm` / `q` / `keyword` | string | (없음) | 공고명 부분 검색 |
|
||||
| `panNtStDt` / `startDate` | date | (없음) | 공고 게시일 시작. `YYYY-MM-DD` / `YYYYMMDD` / `YYYY.MM.DD` |
|
||||
| `clsgDt` / `endDate` | date | (없음) | 접수 마감일 종료 |
|
||||
| `page` | int | 1 | 페이지 (최대 1000) |
|
||||
| `pageSize` / `PG_SZ` / `numOfRows` / `limit` | int | 50 | 페이지당 건수 (최대 1000) |
|
||||
|
||||
### `/v1/lh-notice/detail` 파라미터
|
||||
|
||||
| 파라미터 | 타입 | 필수 | 설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| `panId` | digits | ✅ | 공고 ID. 목록 응답의 `pan_id` 와 동일 |
|
||||
| `ccrCnntSysDsCd` | digits | ✅ | 연계시스템 구분 코드. 목록 응답의 `ccr_cnnt_sys_ds_cd` |
|
||||
| `splInfTpCd` | digits | ✅ | 공급 정보 유형 코드. 목록 응답의 `spl_inf_tp_cd` |
|
||||
|
||||
## 기본 흐름
|
||||
|
||||
1. 사용자 요청의 지역·공고 유형·상태를 추출한다.
|
||||
2. 필터를 붙여 `/v1/lh-notice/search` 를 호출한다.
|
||||
3. KST 오늘 날짜로 마감 여부(D-day)를 표시한다.
|
||||
4. 공고 상위 3-5건(공고명 / 지역 / 공고일 / 마감일 / 상태 / 링크)을 요약한다.
|
||||
5. 필요하면 `/v1/lh-notice/detail` 로 주택형별 공급 정보를 추가 조회한다.
|
||||
|
||||
## CLI 예시
|
||||
|
||||
공고중인 부산 영구임대 공고 목록:
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/lh-notice/search' \
|
||||
--data-urlencode 'panSs=공고중' \
|
||||
--data-urlencode 'uppAisTpCd=06' \
|
||||
--data-urlencode 'cnpCdNm=부산광역시' \
|
||||
--data-urlencode 'pageSize=20'
|
||||
```
|
||||
|
||||
키워드 검색 (행복주택, 접수중만):
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/lh-notice/search' \
|
||||
--data-urlencode 'q=행복주택' \
|
||||
--data-urlencode 'status=접수중'
|
||||
```
|
||||
|
||||
공고 상세:
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/lh-notice/detail' \
|
||||
--data-urlencode 'panId=2015122300019828' \
|
||||
--data-urlencode 'ccrCnntSysDsCd=03' \
|
||||
--data-urlencode 'splInfTpCd=051'
|
||||
```
|
||||
|
||||
## 응답 예시 (목록)
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"pan_id": "2015122300019828",
|
||||
"pan_nm": "2026년 상반기 부산광역시 영구임대주택 예비입주자 모집 공고",
|
||||
"upp_ais_tp_cd": "06",
|
||||
"ais_tp_cd": "09",
|
||||
"ais_tp_cd_nm": "영구임대",
|
||||
"cnp_cd_nm": "부산광역시",
|
||||
"pan_ss": "공고중",
|
||||
"pan_dt": "2026-04-21",
|
||||
"clsg_dt": "2026-05-06",
|
||||
"spl_inf_tp_cd": "051",
|
||||
"ccr_cnnt_sys_ds_cd": "03",
|
||||
"detail_url": "https://apply.lh.or.kr/lhapply/apply/wt/wrtanc/selectWrtancInfo.do?panId=2015122300019828&..."
|
||||
}
|
||||
],
|
||||
"summary": { "page": 1, "page_size": 20, "returned_count": 1, "total_count": 1 },
|
||||
"query": { "pan_ss": "공고중", "upp_ais_tp_cd": "06", "cnp_cd_nm": "부산광역시" },
|
||||
"proxy": { "name": "k-skill-proxy", "cache": { "hit": false, "ttl_ms": 300000 } }
|
||||
}
|
||||
```
|
||||
|
||||
## 실패 모드
|
||||
|
||||
- `400 bad_request`: `panSs` 값이 허용되지 않거나, 날짜 포맷이 잘못된 경우 등. 메시지를 그대로 사용자에게 노출한다.
|
||||
- `503 upstream_not_configured`: 프록시 서버에 `DATA_GO_KR_API_KEY` 가 없는 경우. 운영자가 키를 등록해야 한다.
|
||||
- `502 upstream_error`: 공공데이터포털 서버 오류 또는 XML 에러 envelope. `upstream_code` 에 원본 코드가 들어간다 (예: `30` = 등록되지 않은 서비스키).
|
||||
- `502 upstream_invalid_payload`: 응답이 JSON 이 아닌 경우 (보통 HTML 장애 페이지).
|
||||
|
||||
## Done when
|
||||
|
||||
- 공식 LH 공고 목록을 조회했다.
|
||||
- 마감 여부를 KST 기준으로 판정해 표시했다.
|
||||
- 각 결과에 공고 상세 링크(`detail_url`)를 포함했다.
|
||||
- 필요한 경우 `panId`/`ccrCnntSysDsCd`/`splInfTpCd` 를 제공해 상세 조회로 이어갈 수 있도록 안내했다.
|
||||
|
||||
## 출처/참고
|
||||
|
||||
- LH 청약플러스 공고 목록: `https://apply.lh.or.kr/lhapply/apply/wt/wrtanc/selectWrtancList.do?mi=1026`
|
||||
- 공공데이터포털 LH 임대공고문 정보 API: `https://www.data.go.kr/data/15058530/openapi.do`
|
||||
- 레퍼런스 오픈소스: `heereal/Bunyang_MoeumZip` (Next.js 기반 LH/SH 공고 모음집 샘플 구현)
|
||||
|
|
@ -20,6 +20,7 @@
|
|||
- 한국 법령 검색 스킬 출시
|
||||
- 한국 개인정보처리방침·이용약관 스킬 출시 (kimlawtech/korean-privacy-terms Apache-2.0 업스트림 기반 thin wrapper)
|
||||
- 한국 부동산 실거래가 조회 스킬 출시
|
||||
- LH 청약 공고문 조회 스킬 출시
|
||||
- 의약품 안전 체크 스킬 출시
|
||||
- 식품 안전 체크 스킬 출시
|
||||
- 장학금 검색 및 조회 스킬 출시
|
||||
|
|
|
|||
|
|
@ -56,6 +56,11 @@
|
|||
- MOLIT 단독/다가구 매매 API: https://apis.data.go.kr/1613000/RTMSDataSvcSHTrade/getRTMSDataSvcSHTrade
|
||||
- MOLIT 단독/다가구 전월세 API: https://apis.data.go.kr/1613000/RTMSDataSvcSHRent/getRTMSDataSvcSHRent
|
||||
- MOLIT 상업업무용 매매 API: https://apis.data.go.kr/1613000/RTMSDataSvcNrgTrade/getRTMSDataSvcNrgTrade
|
||||
- LH 청약플러스 공고 목록: https://apply.lh.or.kr/lhapply/apply/wt/wrtanc/selectWrtancList.do?mi=1026
|
||||
- 공공데이터포털 한국토지주택공사 임대공고문 정보 API: https://www.data.go.kr/data/15058530/openapi.do
|
||||
- LH 임대공고문 목록 endpoint: http://apis.data.go.kr/B552555/lhLeaseNoticeInfo1/lhLeaseNoticeInfo1
|
||||
- LH 임대공고문 상세(공급정보) endpoint: http://apis.data.go.kr/B552555/lhLeaseNoticeDtlInfo1/getLeaseNoticeDtlInfo1
|
||||
- LH 청약 샘플 reference 구현(heereal/Bunyang_MoeumZip): https://github.com/heereal/Bunyang_MoeumZip
|
||||
- beopmang: https://api.beopmang.org
|
||||
- `silver-flight-group/kakaocli`: https://github.com/silver-flight-group/kakaocli
|
||||
- KakaoTalk Mac 설치 참고(`mas`): https://velog.io/@bonjugi/%EB%A7%A5%EB%B6%81-M1%EC%97%90-homebrew%EB%A1%9C-node-vscode-%EC%B9%B4%EC%B9%B4%EC%98%A4%ED%86%A1-%EC%84%A4%EC%B9%98%ED%95%98%EA%B8%B0
|
||||
|
|
|
|||
216
lh-notice-search/SKILL.md
Normal file
216
lh-notice-search/SKILL.md
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
---
|
||||
name: lh-notice-search
|
||||
description: Search official LH 청약 (Korea Land & Housing Corporation lease/subscription) 공고 lists through k-skill-proxy. Use when a user asks about 청년/행복/영구/국민/매입/전세임대, 분양주택, 신혼희망타운 공고 marketed on apply.lh.or.kr.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: real-estate
|
||||
locale: ko-KR
|
||||
phase: v1
|
||||
---
|
||||
|
||||
# LH 청약 공고문 조회
|
||||
|
||||
## What this skill does
|
||||
|
||||
한국토지주택공사(LH)가 `apply.lh.or.kr` 로 공고하는 **임대주택·분양주택·주거복지(신혼희망타운 등)·토지·상가 공고**를 공공데이터포털(`data.go.kr`)의 공식 LH 공고 Open API로 조회한다. 요청은 `k-skill-proxy` 의 `/v1/lh-notice/*` 라우트로 보내고, 결과는 공고 목록·공고 상세(주택형별 공급 정보)로 정리한다.
|
||||
|
||||
본 스킬은 사회초년생, 청년, 신혼부부처럼 **LH 공고 존재 자체를 모르는 사용자**가 공고 마감 전에 공고문을 빠르게 찾을 수 있도록 돕는다.
|
||||
|
||||
## When to use
|
||||
|
||||
- "LH 영구임대 공고 지금 뭐 올라와 있어?"
|
||||
- "신혼희망타운 공고 요즘 나온 거 정리해줘"
|
||||
- "부산광역시 LH 임대주택 공고중인 것 보여줘"
|
||||
- "전세임대 공고 중 마감 임박한 거 찾아줘"
|
||||
- "공고번호 2015122300019828 상세 정보 보여줘"
|
||||
- "행복주택 청년 모집 공고 요약해줘"
|
||||
|
||||
## When not to use
|
||||
|
||||
- 개별 사용자의 자격 심사, 당첨 예측, 가점 계산
|
||||
- 청약 신청 자동화/자동 제출 (본 스킬은 read-only 조회다)
|
||||
- 청약통장·주택도시기금 계좌 업무 (해당 범위는 LH 공고와 별개다)
|
||||
- SH(서울주택도시공사)·GH(경기주택도시공사)·iH(인천도시공사) 전용 공고 (본 스킬은 LH 공고만 다룬다)
|
||||
|
||||
## Inputs
|
||||
|
||||
- `panSs` (또는 `status`): 공고 상태. `공고중`, `접수중`, `접수마감`, `당첨자발표`, `추정공고` 중 하나. 비우면 전체.
|
||||
- `uppAisTpCd` (또는 `category`): 주택 대분류. `01`(토지), `05`(분양주택), `06`(임대주택), `13`(주거복지·신혼희망타운), `22`(상가).
|
||||
- `aisTpCd`: 세부 분류 코드 (숫자). 예: `09`=영구임대, `10`=행복주택, `17`=전세임대.
|
||||
- `cnpCdNm` (또는 `region`): 지역명. 예: `서울특별시`, `부산광역시`, `전국`.
|
||||
- `panNm` (또는 `q`, `keyword`): 공고명 부분 검색 키워드. 예: `행복주택`, `청년`, `든든주택`.
|
||||
- `panNtStDt` (또는 `startDate`): 공고 게시일 시작. YYYY-MM-DD / YYYYMMDD / YYYY.MM.DD 모두 허용.
|
||||
- `clsgDt` (또는 `endDate`): 접수 마감일 종료. 날짜 포맷 동일.
|
||||
- `page` (기본 1, 최대 1000), `pageSize` (기본 50, 최대 1000).
|
||||
|
||||
상세 조회 (`/v1/lh-notice/detail`) 는 `panId`, `ccrCnntSysDsCd`, `splInfTpCd` 세 값 모두 필수다. 이 값은 목록 응답의 `pan_id`, `ccr_cnnt_sys_ds_cd`, `spl_inf_tp_cd` 를 그대로 쓰면 된다.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- 인터넷 연결
|
||||
- `curl` (또는 동일한 HTTP 호출이 가능한 도구)
|
||||
|
||||
사용자에게 필요한 시크릿은 없다. 공공데이터포털 `DATA_GO_KR_API_KEY` 는 `k-skill-proxy` 서버 쪽에만 둔다.
|
||||
|
||||
## Default path
|
||||
|
||||
`KSKILL_PROXY_BASE_URL` 환경변수가 있으면 그 값, 없으면 기본 `https://k-skill-proxy.nomadamas.org` 를 사용한다.
|
||||
|
||||
```bash
|
||||
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
|
||||
BASE="${BASE%/}"
|
||||
```
|
||||
|
||||
## Supported endpoints
|
||||
|
||||
### 공고 목록 조회
|
||||
|
||||
```
|
||||
GET /v1/lh-notice/search
|
||||
```
|
||||
|
||||
필터는 선택사항이다. 필터 없이 호출하면 최근 공고를 상태 무관하게 최대 50건 돌려준다. 마감 임박한 공고만 보고 싶다면 `panSs=공고중` 과 `clsgDt` 로 구간을 좁힌다.
|
||||
|
||||
### 공고 상세 조회
|
||||
|
||||
```
|
||||
GET /v1/lh-notice/detail?panId={공고ID}&ccrCnntSysDsCd={연계시스템코드}&splInfTpCd={공급정보유형코드}
|
||||
```
|
||||
|
||||
상세 응답은 `notice`(공고 요약) + `supply_infos`(주택형/필지/상가호 별 공급 정보 배열) 를 돌려준다.
|
||||
|
||||
## Example requests
|
||||
|
||||
### 목록 — 부산 영구임대 공고중
|
||||
|
||||
```bash
|
||||
curl -fsS --get "${BASE}/v1/lh-notice/search" \
|
||||
--data-urlencode 'panSs=공고중' \
|
||||
--data-urlencode 'uppAisTpCd=06' \
|
||||
--data-urlencode 'cnpCdNm=부산광역시' \
|
||||
--data-urlencode 'pageSize=20'
|
||||
```
|
||||
|
||||
### 목록 — 키워드 "행복주택" 으로 접수중
|
||||
|
||||
```bash
|
||||
curl -fsS --get "${BASE}/v1/lh-notice/search" \
|
||||
--data-urlencode 'q=행복주택' \
|
||||
--data-urlencode 'status=접수중'
|
||||
```
|
||||
|
||||
### 상세 — 특정 공고
|
||||
|
||||
```bash
|
||||
curl -fsS --get "${BASE}/v1/lh-notice/detail" \
|
||||
--data-urlencode 'panId=2015122300019828' \
|
||||
--data-urlencode 'ccrCnntSysDsCd=03' \
|
||||
--data-urlencode 'splInfTpCd=051'
|
||||
```
|
||||
|
||||
## Response shape
|
||||
|
||||
### 목록 응답
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"pan_id": "2015122300019828",
|
||||
"pan_nm": "2026년 상반기 부산광역시 영구임대주택 예비입주자 모집 공고",
|
||||
"upp_ais_tp_cd": "06",
|
||||
"ais_tp_cd": "09",
|
||||
"ais_tp_cd_nm": "영구임대",
|
||||
"cnp_cd_nm": "부산광역시",
|
||||
"pan_ss": "공고중",
|
||||
"pan_dt": "2026-04-21",
|
||||
"clsg_dt": "2026-05-06",
|
||||
"rcrit_pblanc_dt": null,
|
||||
"spl_inf_tp_cd": "051",
|
||||
"ccr_cnnt_sys_ds_cd": "03",
|
||||
"detail_url": "https://apply.lh.or.kr/lhapply/apply/wt/wrtanc/selectWrtancInfo.do?panId=2015122300019828&..."
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"returned_count": 1,
|
||||
"total_count": 1
|
||||
},
|
||||
"query": {
|
||||
"pan_ss": "공고중",
|
||||
"upp_ais_tp_cd": "06",
|
||||
"cnp_cd_nm": "부산광역시"
|
||||
},
|
||||
"proxy": {
|
||||
"name": "k-skill-proxy",
|
||||
"cache": { "hit": false, "ttl_ms": 300000 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 상세 응답
|
||||
|
||||
```json
|
||||
{
|
||||
"notice": {
|
||||
"pan_id": "2015122300019828",
|
||||
"pan_nm": "2026년 상반기 부산광역시 영구임대주택 예비입주자 모집 공고",
|
||||
"ais_tp_cd_nm": "영구임대",
|
||||
"...": "목록 응답과 동일한 필드"
|
||||
},
|
||||
"supply_infos": [
|
||||
{ "HOUSE_TY": "영구임대 29㎡", "SPL_CNT": "120" },
|
||||
{ "HOUSE_TY": "영구임대 39㎡", "SPL_CNT": "80" }
|
||||
],
|
||||
"query": {
|
||||
"pan_id": "2015122300019828",
|
||||
"ccr_cnnt_sys_ds_cd": "03",
|
||||
"spl_inf_tp_cd": "051"
|
||||
},
|
||||
"proxy": { "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
## Response policy
|
||||
|
||||
- 공식 LH 공고(`apply.lh.or.kr`) 정보만 사용한다. 커뮤니티 요약, 블로그 후기, 사설 부동산 정보는 섞지 않는다.
|
||||
- **마감 여부는 KST 기준 현재 날짜와 `clsg_dt` 를 비교해 판정**한다. 오늘 = 마감일이면 "오늘 마감"으로 표기한다.
|
||||
- 상세 응답의 `detail_url` 을 항상 함께 보여 준다. 사용자는 공고문 원본으로 바로 접근할 수 있어야 한다.
|
||||
- 공고번호(`pan_id`) 를 숨기지 말고 요약에 포함한다. 이후 상세 조회에 그대로 쓴다.
|
||||
- **본 스킬은 SH·GH·iH 공고를 포함하지 않는다.** 사용자가 서울시·경기도·인천시 공사 공고를 찾으면 본 스킬로는 못 찾는다는 점을 분명히 말한다.
|
||||
|
||||
## Keep the answer compact
|
||||
|
||||
사용자에게 돌려줄 때는 이렇게 압축한다.
|
||||
|
||||
- 필터 요약: 지역 + 공고 유형 + 상태
|
||||
- 결과 건수 (`summary.total_count` 와 `returned_count`)
|
||||
- 상위 3-5건 대표 공고: 공고명, 지역, 공고일, 마감일, 상태, 링크
|
||||
- 마감 임박(D-3 이하) 공고는 별도로 강조
|
||||
- 상세 조회 제안: "공고번호 X 상세 보고 싶으면 `lh-notice/detail` 로 조회"
|
||||
|
||||
## Failure modes
|
||||
|
||||
- 필터 값이 잘못되면 `400 bad_request` 가 돌아온다. 오류 메시지를 그대로 노출해 사용자가 교정하게 한다.
|
||||
- 프록시 서버에 `DATA_GO_KR_API_KEY` 가 없으면 `503 upstream_not_configured` 가 돌아온다.
|
||||
- upstream(공공데이터포털) 이 일시 장애이면 `502 upstream_error` + `upstream_code` 를 돌려준다. 재시도는 캐시되지 않으므로 바로 다시 호출해도 된다.
|
||||
- upstream 이 XML 에러 envelope(`OpenAPI_ServiceResponse`) 를 돌려주면 `502 upstream_error` + `upstream_code`(예: `30` = 등록되지 않은 서비스키) 로 변환한다.
|
||||
- 응답이 JSON 이 아니면 `502 upstream_invalid_payload` 로 내려간다.
|
||||
|
||||
## Done when
|
||||
|
||||
- 사용자의 지역·공고 유형·상태 의도에 맞춰 적어도 한 번 `/v1/lh-notice/search` 를 호출했다.
|
||||
- 결과에 공고명, 지역, 공고일/마감일, 상태, 공식 링크가 모두 포함되어 있다.
|
||||
- 마감 여부를 KST 기준으로 판정해 표기했다.
|
||||
- 필요하면 상세 조회로 이어가거나 사용자가 스스로 상세를 조회할 수 있도록 `panId`/`ccrCnntSysDsCd`/`splInfTpCd` 를 함께 안내했다.
|
||||
|
||||
## Notes
|
||||
|
||||
- 공식 LH 청약플러스 포털: `https://apply.lh.or.kr/lhapply/apply/wt/wrtanc/selectWrtancList.do?mi=1026`
|
||||
- 공공데이터포털 카탈로그: `https://www.data.go.kr/data/15058530/openapi.do` (LH 임대공고문 정보)
|
||||
- upstream `panSs` 값은 한국어로 정확히 맞춰서 보낸다. 영문/공백 변형은 받지 않는다.
|
||||
- 대분류/세부분류 코드 매핑 참고:
|
||||
- `06` 임대주택: `09` 영구임대, `10` 행복주택, `17` 전세임대, `08` 국민임대, `26` 매입임대 등
|
||||
- `13` 주거복지: `17` 전세임대, 신혼희망타운 등
|
||||
- `05` 분양주택, `01` 토지, `22` 상가
|
||||
|
|
@ -24,6 +24,8 @@
|
|||
- `GET /v1/data4library/book-detail` — 도서관 정보나루 도서 상세 조회(`DATA4LIBRARY_AUTH_KEY`)
|
||||
- `GET /v1/data4library/libraries-by-book` — 도서 소장 도서관 조회(`DATA4LIBRARY_AUTH_KEY`)
|
||||
- `GET /v1/data4library/book-exists` — 도서관별 도서 소장여부(`DATA4LIBRARY_AUTH_KEY`)
|
||||
- `GET /v1/lh-notice/search` — LH 청약 공고 목록(`DATA_GO_KR_API_KEY`)
|
||||
- `GET /v1/lh-notice/detail` — LH 청약 공고 상세(`DATA_GO_KR_API_KEY`)
|
||||
|
||||
## 환경변수
|
||||
|
||||
|
|
@ -41,7 +43,7 @@
|
|||
- `KSKILL_PROXY_CACHE_TTL_MS` — 기본 `300000`
|
||||
- `KSKILL_PROXY_RATE_LIMIT_WINDOW_MS` — 기본 `60000`
|
||||
- `KSKILL_PROXY_RATE_LIMIT_MAX` — 기본 `60`
|
||||
- `DATA_GO_KR_API_KEY` - 공공데이터포털 에서 쓰이는 API 인증키 (`household-waste`, `parking-lots`, `real-estate`, `mfds-drug-safety`, `mfds-food-safety`)
|
||||
- `DATA_GO_KR_API_KEY` - 공공데이터포털 에서 쓰이는 API 인증키 (`household-waste`, `parking-lots`, `real-estate`, `mfds-drug-safety`, `mfds-food-safety`, `lh-notice`). 각 서비스는 공공데이터포털에서 별도 "활용신청" 승인이 필요하다. 키를 발급받은 뒤에는 [LH 임대공고문 정보](https://www.data.go.kr/data/15058530/openapi.do) 페이지에서도 활용신청을 눌러 동일 키를 활성화해야 `lh-notice` 라우트가 성공한다. 미활성 상태에서는 upstream이 HTTP 403 Forbidden을 돌려주고 proxy는 `upstream_error`로 변환한다.
|
||||
|
||||
기본 정책은 **무료 API 공개 프록시 = 무인증** 이다. 대신 endpoint scope 를 좁게 유지하고, cache + rate limit 으로 남용을 늦춘다.
|
||||
|
||||
|
|
@ -171,6 +173,25 @@ curl -fsS --get 'http://127.0.0.1:4020/v1/korean-stock/search' \
|
|||
--data-urlencode 'bas_dd=20260408'
|
||||
```
|
||||
|
||||
LH 청약 공고 목록 예시 (`DATA_GO_KR_API_KEY` 필요):
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'http://127.0.0.1:4020/v1/lh-notice/search' \
|
||||
--data-urlencode 'panSs=공고중' \
|
||||
--data-urlencode 'uppAisTpCd=06' \
|
||||
--data-urlencode 'cnpCdNm=부산광역시' \
|
||||
--data-urlencode 'pageSize=20'
|
||||
```
|
||||
|
||||
LH 청약 공고 상세:
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'http://127.0.0.1:4020/v1/lh-notice/detail' \
|
||||
--data-urlencode 'panId=2015122300019828' \
|
||||
--data-urlencode 'ccrCnntSysDsCd=03' \
|
||||
--data-urlencode 'splInfTpCd=051'
|
||||
```
|
||||
|
||||
프록시는 내부적으로 `waterlevel/info.json` 으로 관측소를 해석하고, `waterlevel/list/10M/{WLOBSCD}.json` 으로 최신 수위/유량을 조회합니다. 한국 주식 route는 KRX Open API에 `AUTH_KEY` 헤더를 서버 쪽에서만 주입합니다.
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"node": ">=18"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "node --check src/airkorea.js && node --check src/bluer.js && node --check src/hrfco.js && node --check src/krx-stock.js && node --check src/mfds.js && node --check src/molit.js && node --check src/naver-shopping.js && node --check src/parking-lots.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/molit.test.js && node --check test/naver-shopping.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
|
||||
"lint": "node --check src/airkorea.js && node --check src/bluer.js && node --check src/hrfco.js && node --check src/krx-stock.js && node --check src/lh-notice.js && node --check src/mfds.js && node --check src/molit.js && node --check src/naver-shopping.js && node --check src/parking-lots.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/lh-notice.test.js && node --check test/molit.test.js && node --check test/naver-shopping.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
531
packages/k-skill-proxy/src/lh-notice.js
Normal file
531
packages/k-skill-proxy/src/lh-notice.js
Normal file
|
|
@ -0,0 +1,531 @@
|
|||
// LH 청약 (Korea Land & Housing Corporation lease/subscription notice) API wrapper.
|
||||
// Proxies the official data.go.kr LH Lease Notice endpoint so the user never has to
|
||||
// manage ServiceKey. The upstream base is:
|
||||
// http://apis.data.go.kr/B552555/lhLeaseNoticeInfo1/lhLeaseNoticeInfo1 (list)
|
||||
// http://apis.data.go.kr/B552555/lhLeaseNoticeDtlInfo1/getLeaseNoticeDtlInfo1 (detail)
|
||||
//
|
||||
// The upstream responds with a JSON array whose shape is:
|
||||
// [
|
||||
// { "CMN": { "CODE": "SUCCESS", "ERR_MSG": "", "TOTAL_CNT": 123 } },
|
||||
// { "dsList": [ { PAN_ID, PAN_NM, ... }, ... ] }
|
||||
// ]
|
||||
// Error payloads can also arrive as XML from the common data.go.kr error path, e.g.
|
||||
// unregistered ServiceKey, so both JSON and XML fault paths must be handled.
|
||||
|
||||
const LH_UPSTREAM_BASE_URL = "http://apis.data.go.kr/B552555";
|
||||
const LH_LIST_PATH = "lhLeaseNoticeInfo1/lhLeaseNoticeInfo1";
|
||||
const LH_DETAIL_PATH = "lhLeaseNoticeDtlInfo1/getLeaseNoticeDtlInfo1";
|
||||
|
||||
const LH_DEFAULT_LIST_URL = `${LH_UPSTREAM_BASE_URL}/${LH_LIST_PATH}`;
|
||||
const LH_DEFAULT_DETAIL_URL = `${LH_UPSTREAM_BASE_URL}/${LH_DETAIL_PATH}`;
|
||||
|
||||
// Valid `PAN_SS` filter values (공고 상태). Users pass these in Korean exactly the
|
||||
// way the upstream catalog documents them. We do NOT guess variants — if the user
|
||||
// omits this filter, we pass nothing and the upstream returns every status.
|
||||
const VALID_PAN_SS_VALUES = new Set([
|
||||
"공고중",
|
||||
"접수중",
|
||||
"접수마감",
|
||||
"당첨자발표",
|
||||
"추정공고"
|
||||
]);
|
||||
|
||||
// Known `UPP_AIS_TP_CD` categories (주택 대분류).
|
||||
// 06 = 임대주택, 05 = 분양주택, 13 = 주거복지 (신혼희망타운 포함), 01 = 토지, 22 = 상가
|
||||
const VALID_UPP_AIS_TP_CD_VALUES = new Set([
|
||||
"01", "05", "06", "13", "22"
|
||||
]);
|
||||
|
||||
function trimOrNull(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = String(value).trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function parseBoundedInt(value, { defaultValue, min, max, label }) {
|
||||
if (value === undefined || value === null || String(value).trim() === "") {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const text = String(value).trim();
|
||||
if (!/^\d+$/.test(text)) {
|
||||
throw new Error(`Provide valid ${label}.`);
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(text, 10);
|
||||
if (parsed < min) {
|
||||
return min;
|
||||
}
|
||||
if (parsed > max) {
|
||||
return max;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeDateString(value, label) {
|
||||
const trimmed = trimOrNull(value);
|
||||
if (trimmed === null) {
|
||||
return null;
|
||||
}
|
||||
// Accept YYYY-MM-DD, YYYY.MM.DD, YYYYMMDD; normalize to YYYY-MM-DD per LH upstream docs.
|
||||
const digits = trimmed.replace(/[.\-\/]/g, "");
|
||||
if (!/^\d{8}$/.test(digits)) {
|
||||
throw new Error(`Provide ${label} as YYYY-MM-DD (8 digits).`);
|
||||
}
|
||||
return `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6, 8)}`;
|
||||
}
|
||||
|
||||
function normalizeLhNoticeSearchQuery(query) {
|
||||
const normalized = {};
|
||||
|
||||
const panSs = trimOrNull(query.panSs ?? query.PAN_SS ?? query.status);
|
||||
if (panSs) {
|
||||
if (!VALID_PAN_SS_VALUES.has(panSs)) {
|
||||
throw new Error(
|
||||
`panSs must be one of: ${Array.from(VALID_PAN_SS_VALUES).join(", ")}.`
|
||||
);
|
||||
}
|
||||
normalized.panSs = panSs;
|
||||
}
|
||||
|
||||
const uppAisTpCd = trimOrNull(
|
||||
query.uppAisTpCd ?? query.UPP_AIS_TP_CD ?? query.category
|
||||
);
|
||||
if (uppAisTpCd) {
|
||||
if (!VALID_UPP_AIS_TP_CD_VALUES.has(uppAisTpCd)) {
|
||||
throw new Error(
|
||||
`uppAisTpCd must be one of: ${Array.from(VALID_UPP_AIS_TP_CD_VALUES).join(", ")}.`
|
||||
);
|
||||
}
|
||||
normalized.uppAisTpCd = uppAisTpCd;
|
||||
}
|
||||
|
||||
const aisTpCd = trimOrNull(query.aisTpCd ?? query.AIS_TP_CD);
|
||||
if (aisTpCd) {
|
||||
if (!/^[0-9]{1,4}$/.test(aisTpCd)) {
|
||||
throw new Error("aisTpCd must be digits only (1-4 chars).");
|
||||
}
|
||||
normalized.aisTpCd = aisTpCd;
|
||||
}
|
||||
|
||||
const cnpCdNm = trimOrNull(
|
||||
query.cnpCdNm ?? query.CNP_CD_NM ?? query.region ?? query.regionName
|
||||
);
|
||||
if (cnpCdNm) {
|
||||
normalized.cnpCdNm = cnpCdNm;
|
||||
}
|
||||
|
||||
const panNm = trimOrNull(
|
||||
query.panNm ?? query.PAN_NM ?? query.q ?? query.query ?? query.keyword
|
||||
);
|
||||
if (panNm) {
|
||||
normalized.panNm = panNm;
|
||||
}
|
||||
|
||||
const panNtStDt = normalizeDateString(
|
||||
query.panNtStDt ?? query.PAN_NT_ST_DT ?? query.startDate,
|
||||
"panNtStDt"
|
||||
);
|
||||
if (panNtStDt) {
|
||||
normalized.panNtStDt = panNtStDt;
|
||||
}
|
||||
|
||||
const clsgDt = normalizeDateString(
|
||||
query.clsgDt ?? query.CLSG_DT ?? query.endDate,
|
||||
"clsgDt"
|
||||
);
|
||||
if (clsgDt) {
|
||||
normalized.clsgDt = clsgDt;
|
||||
}
|
||||
|
||||
normalized.page = parseBoundedInt(query.page ?? query.PAGE ?? query.pageNo, {
|
||||
defaultValue: 1,
|
||||
min: 1,
|
||||
max: 1000,
|
||||
label: "page"
|
||||
});
|
||||
|
||||
normalized.pageSize = parseBoundedInt(
|
||||
query.pageSize ?? query.PG_SZ ?? query.numOfRows ?? query.limit,
|
||||
{
|
||||
defaultValue: 50,
|
||||
min: 1,
|
||||
max: 1000,
|
||||
label: "pageSize"
|
||||
}
|
||||
);
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeLhNoticeDetailQuery(query) {
|
||||
const panId = trimOrNull(query.panId ?? query.PAN_ID);
|
||||
if (!panId) {
|
||||
throw new Error("Provide panId.");
|
||||
}
|
||||
if (!/^[0-9]{4,20}$/.test(panId)) {
|
||||
throw new Error("panId must be digits only.");
|
||||
}
|
||||
|
||||
const ccrCnntSysDsCd = trimOrNull(
|
||||
query.ccrCnntSysDsCd ?? query.CCR_CNNT_SYS_DS_CD ?? query.systemCode
|
||||
);
|
||||
if (!ccrCnntSysDsCd) {
|
||||
throw new Error("Provide ccrCnntSysDsCd.");
|
||||
}
|
||||
if (!/^[0-9]{1,4}$/.test(ccrCnntSysDsCd)) {
|
||||
throw new Error("ccrCnntSysDsCd must be digits only (1-4 chars).");
|
||||
}
|
||||
|
||||
const splInfTpCd = trimOrNull(
|
||||
query.splInfTpCd ?? query.SPL_INF_TP_CD ?? query.supplyTypeCode
|
||||
);
|
||||
if (!splInfTpCd) {
|
||||
throw new Error("Provide splInfTpCd.");
|
||||
}
|
||||
if (!/^[0-9]{1,6}$/.test(splInfTpCd)) {
|
||||
throw new Error("splInfTpCd must be digits only (1-6 chars).");
|
||||
}
|
||||
|
||||
return { panId, ccrCnntSysDsCd, splInfTpCd };
|
||||
}
|
||||
|
||||
function normalizeNoticeItem(raw) {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Upstream delivers keys in UPPER_SNAKE_CASE. Map to lower snake_case so the
|
||||
// proxy response matches other k-skill-proxy JSON shapes.
|
||||
const panId = trimOrNull(raw.PAN_ID ?? raw.panId);
|
||||
const panNm = trimOrNull(raw.PAN_NM ?? raw.panNm);
|
||||
if (!panId && !panNm) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const uppAisTpCd = trimOrNull(raw.UPP_AIS_TP_CD ?? raw.uppAisTpCd);
|
||||
const aisTpCd = trimOrNull(raw.AIS_TP_CD ?? raw.aisTpCd);
|
||||
const aisTpCdNm = trimOrNull(raw.AIS_TP_CD_NM ?? raw.aisTpCdNm);
|
||||
const cnpCdNm = trimOrNull(raw.CNP_CD_NM ?? raw.cnpCdNm);
|
||||
const panSs = trimOrNull(raw.PAN_SS ?? raw.panSs);
|
||||
const panDt = trimOrNull(raw.PAN_DT ?? raw.panDt ?? raw.PAN_NT_ST_DT ?? raw.panNtStDt);
|
||||
const clsgDt = trimOrNull(raw.CLSG_DT ?? raw.clsgDt ?? raw.PAN_NT_ED_DT ?? raw.panNtEdDt);
|
||||
const rcritPblancDt = trimOrNull(raw.RCRIT_PBLANC_DT ?? raw.rcritPblancDt);
|
||||
const dtlUrl = trimOrNull(raw.DTL_URL ?? raw.dtlUrl);
|
||||
const splInfTpCd = trimOrNull(raw.SPL_INF_TP_CD ?? raw.splInfTpCd);
|
||||
const ccrCnntSysDsCd = trimOrNull(raw.CCR_CNNT_SYS_DS_CD ?? raw.ccrCnntSysDsCd);
|
||||
|
||||
return {
|
||||
pan_id: panId,
|
||||
pan_nm: panNm,
|
||||
upp_ais_tp_cd: uppAisTpCd,
|
||||
ais_tp_cd: aisTpCd,
|
||||
ais_tp_cd_nm: aisTpCdNm,
|
||||
cnp_cd_nm: cnpCdNm,
|
||||
pan_ss: panSs,
|
||||
pan_dt: panDt,
|
||||
clsg_dt: clsgDt,
|
||||
rcrit_pblanc_dt: rcritPblancDt,
|
||||
spl_inf_tp_cd: splInfTpCd,
|
||||
ccr_cnnt_sys_ds_cd: ccrCnntSysDsCd,
|
||||
detail_url: dtlUrl,
|
||||
raw
|
||||
};
|
||||
}
|
||||
|
||||
// data.go.kr services return XML on common auth/parameter errors even when JSON
|
||||
// is requested. Pull resultCode/resultMsg and surface them as a structured error.
|
||||
function parseXmlErrorEnvelope(xmlText) {
|
||||
if (typeof xmlText !== "string" || xmlText.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!/<OpenAPI_ServiceResponse>|<response[\s>]|<returnAuthMsg>|<errMsg>/i.test(xmlText)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const codeMatch = xmlText.match(/<resultCode>([^<]*)<\/resultCode>/i)
|
||||
|| xmlText.match(/<returnReasonCode>([^<]*)<\/returnReasonCode>/i);
|
||||
const msgMatch = xmlText.match(/<resultMsg>([^<]*)<\/resultMsg>/i)
|
||||
|| xmlText.match(/<returnAuthMsg>([^<]*)<\/returnAuthMsg>/i)
|
||||
|| xmlText.match(/<errMsg>([^<]*)<\/errMsg>/i);
|
||||
|
||||
const code = codeMatch ? codeMatch[1].trim() : null;
|
||||
const message = msgMatch ? msgMatch[1].trim() : "LH upstream returned an error envelope.";
|
||||
|
||||
if (!code && !msgMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
code: code || "unknown",
|
||||
message
|
||||
};
|
||||
}
|
||||
|
||||
function buildError({ message, statusCode, code, upstreamCode }) {
|
||||
const error = new Error(message);
|
||||
error.statusCode = statusCode;
|
||||
error.code = code;
|
||||
if (upstreamCode) {
|
||||
error.upstreamCode = upstreamCode;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
// Parse the LH JSON envelope into { totalCount, items, raw }.
|
||||
// The upstream envelope shape is a 2-element array:
|
||||
// [ { CMN: { CODE, ERR_MSG, TOTAL_CNT } }, { dsList: [...] } ]
|
||||
// but the proxy is resilient to the more common data.go.kr shape
|
||||
// `{ response: { body: { items: [...], totalCount } } }` as well.
|
||||
function extractNoticeEnvelope(parsed) {
|
||||
if (Array.isArray(parsed)) {
|
||||
const head = parsed[0] || {};
|
||||
const body = parsed[1] || {};
|
||||
const cmn = head.CMN || head.cmn || {};
|
||||
const code = trimOrNull(cmn.CODE ?? cmn.code);
|
||||
const errMsg = trimOrNull(cmn.ERR_MSG ?? cmn.errMsg);
|
||||
|
||||
if (code && code !== "SUCCESS" && code !== "0" && code !== "00" && code !== "000") {
|
||||
throw buildError({
|
||||
message: errMsg || `LH upstream rejected the request (${code}).`,
|
||||
statusCode: 502,
|
||||
code: "upstream_error",
|
||||
upstreamCode: code
|
||||
});
|
||||
}
|
||||
|
||||
const totalCount = Number.isFinite(Number(cmn.TOTAL_CNT ?? cmn.totalCount))
|
||||
? Number(cmn.TOTAL_CNT ?? cmn.totalCount)
|
||||
: null;
|
||||
|
||||
const rawItems = Array.isArray(body.dsList)
|
||||
? body.dsList
|
||||
: Array.isArray(body.DS_LIST)
|
||||
? body.DS_LIST
|
||||
: [];
|
||||
|
||||
return { totalCount, items: rawItems };
|
||||
}
|
||||
|
||||
if (parsed && typeof parsed === "object") {
|
||||
const response = parsed.response || parsed;
|
||||
const header = response.header || {};
|
||||
const headerCode = trimOrNull(header.resultCode);
|
||||
if (headerCode && !["00", "000", "0"].includes(headerCode)) {
|
||||
throw buildError({
|
||||
message: trimOrNull(header.resultMsg) || `LH upstream rejected the request (${headerCode}).`,
|
||||
statusCode: 502,
|
||||
code: "upstream_error",
|
||||
upstreamCode: headerCode
|
||||
});
|
||||
}
|
||||
|
||||
const body = response.body || {};
|
||||
const rawItems = Array.isArray(body.items)
|
||||
? body.items
|
||||
: Array.isArray(body.item)
|
||||
? body.item
|
||||
: Array.isArray(body.items?.item)
|
||||
? body.items.item
|
||||
: [];
|
||||
|
||||
const totalCount = Number.isFinite(Number(body.totalCount))
|
||||
? Number(body.totalCount)
|
||||
: null;
|
||||
|
||||
return { totalCount, items: rawItems };
|
||||
}
|
||||
|
||||
return { totalCount: null, items: [] };
|
||||
}
|
||||
|
||||
function buildNoticeListResponseBody(envelope, { page, pageSize, filters }) {
|
||||
const items = [];
|
||||
for (const raw of envelope.items) {
|
||||
const normalized = normalizeNoticeItem(raw);
|
||||
if (normalized) {
|
||||
items.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
summary: {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
returned_count: items.length,
|
||||
total_count: envelope.totalCount
|
||||
},
|
||||
query: filters
|
||||
};
|
||||
}
|
||||
|
||||
function buildNoticeDetailResponseBody(envelope, filters) {
|
||||
// Detail envelope can return multiple supply rows (per 주택형). Keep them all as
|
||||
// `supply_infos` plus a normalized `notice` summary using the first row.
|
||||
const supplyInfos = envelope.items.slice();
|
||||
const first = supplyInfos[0] || {};
|
||||
const notice = normalizeNoticeItem(first) || {
|
||||
pan_id: filters.panId,
|
||||
pan_nm: null,
|
||||
upp_ais_tp_cd: null,
|
||||
ais_tp_cd: null,
|
||||
ais_tp_cd_nm: null,
|
||||
cnp_cd_nm: null,
|
||||
pan_ss: null,
|
||||
pan_dt: null,
|
||||
clsg_dt: null,
|
||||
rcrit_pblanc_dt: null,
|
||||
spl_inf_tp_cd: filters.splInfTpCd,
|
||||
ccr_cnnt_sys_ds_cd: filters.ccrCnntSysDsCd,
|
||||
detail_url: null,
|
||||
raw: first
|
||||
};
|
||||
|
||||
return {
|
||||
notice,
|
||||
supply_infos: supplyInfos,
|
||||
query: filters
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchLhUpstream({ url, fetchImpl = global.fetch, timeoutMs = 20000 }) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl(url.toString(), {
|
||||
headers: { Accept: "application/json" },
|
||||
signal: AbortSignal.timeout(timeoutMs)
|
||||
});
|
||||
} catch (err) {
|
||||
throw buildError({
|
||||
message: `LH upstream request failed: ${err.message}`,
|
||||
statusCode: 502,
|
||||
code: "upstream_fetch_failed"
|
||||
});
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
const xmlError = parseXmlErrorEnvelope(text);
|
||||
if (xmlError) {
|
||||
throw buildError({
|
||||
message: xmlError.message,
|
||||
statusCode: response.status === 401 ? 503 : 502,
|
||||
code: response.status === 401 ? "upstream_not_authorized" : "upstream_error",
|
||||
upstreamCode: xmlError.code
|
||||
});
|
||||
}
|
||||
throw buildError({
|
||||
message: `LH upstream responded with HTTP ${response.status}: ${text.slice(0, 200)}`,
|
||||
statusCode: response.status === 401 ? 503 : 502,
|
||||
code: response.status === 401 ? "upstream_not_authorized" : "upstream_error"
|
||||
});
|
||||
}
|
||||
|
||||
// data.go.kr sometimes returns 200 + XML envelope carrying the real error.
|
||||
const xmlError = parseXmlErrorEnvelope(text);
|
||||
if (xmlError) {
|
||||
throw buildError({
|
||||
message: xmlError.message,
|
||||
statusCode: 502,
|
||||
code: "upstream_error",
|
||||
upstreamCode: xmlError.code
|
||||
});
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (err) {
|
||||
throw buildError({
|
||||
message: `LH upstream returned non-JSON payload (first 200 chars): ${text.slice(0, 200)}`,
|
||||
statusCode: 502,
|
||||
code: "upstream_invalid_payload"
|
||||
});
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function fetchLhNoticeList({ serviceKey, filters, fetchImpl = global.fetch }) {
|
||||
const url = new URL(LH_DEFAULT_LIST_URL);
|
||||
url.searchParams.set("serviceKey", serviceKey);
|
||||
// Upstream uses SCREAMING_SNAKE_CASE param names (PG_SZ, PAGE, PAN_SS, etc.).
|
||||
url.searchParams.set("PG_SZ", String(filters.pageSize));
|
||||
url.searchParams.set("PAGE", String(filters.page));
|
||||
if (filters.panSs) {
|
||||
url.searchParams.set("PAN_SS", filters.panSs);
|
||||
}
|
||||
if (filters.uppAisTpCd) {
|
||||
url.searchParams.set("UPP_AIS_TP_CD", filters.uppAisTpCd);
|
||||
}
|
||||
if (filters.aisTpCd) {
|
||||
url.searchParams.set("AIS_TP_CD", filters.aisTpCd);
|
||||
}
|
||||
if (filters.cnpCdNm) {
|
||||
url.searchParams.set("CNP_CD_NM", filters.cnpCdNm);
|
||||
}
|
||||
if (filters.panNm) {
|
||||
url.searchParams.set("PAN_NM", filters.panNm);
|
||||
}
|
||||
if (filters.panNtStDt) {
|
||||
url.searchParams.set("PAN_NT_ST_DT", filters.panNtStDt);
|
||||
}
|
||||
if (filters.clsgDt) {
|
||||
url.searchParams.set("CLSG_DT", filters.clsgDt);
|
||||
}
|
||||
|
||||
const parsed = await fetchLhUpstream({ url, fetchImpl });
|
||||
const envelope = extractNoticeEnvelope(parsed);
|
||||
return buildNoticeListResponseBody(envelope, {
|
||||
page: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
filters: {
|
||||
pan_ss: filters.panSs || null,
|
||||
upp_ais_tp_cd: filters.uppAisTpCd || null,
|
||||
ais_tp_cd: filters.aisTpCd || null,
|
||||
cnp_cd_nm: filters.cnpCdNm || null,
|
||||
pan_nm: filters.panNm || null,
|
||||
pan_nt_st_dt: filters.panNtStDt || null,
|
||||
clsg_dt: filters.clsgDt || null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchLhNoticeDetail({ serviceKey, filters, fetchImpl = global.fetch }) {
|
||||
const url = new URL(LH_DEFAULT_DETAIL_URL);
|
||||
url.searchParams.set("serviceKey", serviceKey);
|
||||
url.searchParams.set("PAN_ID", filters.panId);
|
||||
url.searchParams.set("CCR_CNNT_SYS_DS_CD", filters.ccrCnntSysDsCd);
|
||||
url.searchParams.set("SPL_INF_TP_CD", filters.splInfTpCd);
|
||||
|
||||
const parsed = await fetchLhUpstream({ url, fetchImpl });
|
||||
const envelope = extractNoticeEnvelope(parsed);
|
||||
return buildNoticeDetailResponseBody(envelope, {
|
||||
pan_id: filters.panId,
|
||||
ccr_cnnt_sys_ds_cd: filters.ccrCnntSysDsCd,
|
||||
spl_inf_tp_cd: filters.splInfTpCd
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LH_UPSTREAM_BASE_URL,
|
||||
LH_LIST_PATH,
|
||||
LH_DETAIL_PATH,
|
||||
LH_DEFAULT_LIST_URL,
|
||||
LH_DEFAULT_DETAIL_URL,
|
||||
VALID_PAN_SS_VALUES,
|
||||
VALID_UPP_AIS_TP_CD_VALUES,
|
||||
normalizeLhNoticeSearchQuery,
|
||||
normalizeLhNoticeDetailQuery,
|
||||
normalizeNoticeItem,
|
||||
parseXmlErrorEnvelope,
|
||||
extractNoticeEnvelope,
|
||||
buildNoticeListResponseBody,
|
||||
buildNoticeDetailResponseBody,
|
||||
fetchLhNoticeList,
|
||||
fetchLhNoticeDetail
|
||||
};
|
||||
|
|
@ -13,6 +13,12 @@ const {
|
|||
normalizeMfdsDrugLookupQuery,
|
||||
normalizeMfdsFoodSafetyQuery
|
||||
} = require("./mfds");
|
||||
const {
|
||||
fetchLhNoticeDetail,
|
||||
fetchLhNoticeList,
|
||||
normalizeLhNoticeDetailQuery,
|
||||
normalizeLhNoticeSearchQuery
|
||||
} = require("./lh-notice");
|
||||
const { fetchTransactions, VALID_ASSET_TYPES, VALID_DEAL_TYPES } = require("./molit");
|
||||
const { fetchNaverShoppingSearch, normalizeNaverShoppingSearchQuery } = require("./naver-shopping");
|
||||
const { fetchNearbyParkingLots } = require("./parking-lots");
|
||||
|
|
@ -1281,6 +1287,7 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
|
|||
hrfcoConfigured: Boolean(config.hrfcoApiKey),
|
||||
opinetConfigured: Boolean(config.opinetApiKey),
|
||||
molitConfigured: Boolean(config.molitApiKey),
|
||||
lhNoticeConfigured: Boolean(config.molitApiKey),
|
||||
data4libraryConfigured: Boolean(config.data4libraryAuthKey),
|
||||
foodsafetyKoreaConfigured: Boolean(config.foodsafetyKoreaApiKey),
|
||||
neisSchoolMealConfigured: Boolean(config.keduInfoKey),
|
||||
|
|
@ -2091,6 +2098,168 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
|
|||
return payload;
|
||||
});
|
||||
|
||||
app.get("/v1/lh-notice/search", async (request, reply) => {
|
||||
let normalized;
|
||||
|
||||
try {
|
||||
normalized = normalizeLhNoticeSearchQuery(request.query || {});
|
||||
} catch (error) {
|
||||
reply.code(400);
|
||||
return {
|
||||
error: "bad_request",
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
|
||||
const cacheKey = makeCacheKey({
|
||||
route: "lh-notice-search",
|
||||
...normalized
|
||||
});
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) {
|
||||
return {
|
||||
...cached,
|
||||
proxy: {
|
||||
...cached.proxy,
|
||||
cache: {
|
||||
hit: true,
|
||||
ttl_ms: config.cacheTtlMs
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!config.molitApiKey) {
|
||||
reply.code(503);
|
||||
return {
|
||||
error: "upstream_not_configured",
|
||||
message: "DATA_GO_KR_API_KEY is not configured on the proxy server.",
|
||||
proxy: {
|
||||
name: config.proxyName,
|
||||
cache: {
|
||||
hit: false,
|
||||
ttl_ms: config.cacheTtlMs
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await fetchLhNoticeList({
|
||||
serviceKey: config.molitApiKey,
|
||||
filters: normalized
|
||||
});
|
||||
} catch (error) {
|
||||
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
|
||||
return {
|
||||
error: error.code || "proxy_error",
|
||||
message: error.message,
|
||||
upstream_code: error.upstreamCode || undefined,
|
||||
proxy: {
|
||||
name: config.proxyName,
|
||||
cache: { hit: false, ttl_ms: config.cacheTtlMs }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...body,
|
||||
proxy: {
|
||||
name: config.proxyName,
|
||||
cache: {
|
||||
hit: false,
|
||||
ttl_ms: config.cacheTtlMs
|
||||
},
|
||||
requested_at: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
cache.set(cacheKey, payload, config.cacheTtlMs);
|
||||
return payload;
|
||||
});
|
||||
|
||||
app.get("/v1/lh-notice/detail", async (request, reply) => {
|
||||
let normalized;
|
||||
|
||||
try {
|
||||
normalized = normalizeLhNoticeDetailQuery(request.query || {});
|
||||
} catch (error) {
|
||||
reply.code(400);
|
||||
return {
|
||||
error: "bad_request",
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
|
||||
const cacheKey = makeCacheKey({
|
||||
route: "lh-notice-detail",
|
||||
...normalized
|
||||
});
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) {
|
||||
return {
|
||||
...cached,
|
||||
proxy: {
|
||||
...cached.proxy,
|
||||
cache: {
|
||||
hit: true,
|
||||
ttl_ms: config.cacheTtlMs
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!config.molitApiKey) {
|
||||
reply.code(503);
|
||||
return {
|
||||
error: "upstream_not_configured",
|
||||
message: "DATA_GO_KR_API_KEY is not configured on the proxy server.",
|
||||
proxy: {
|
||||
name: config.proxyName,
|
||||
cache: {
|
||||
hit: false,
|
||||
ttl_ms: config.cacheTtlMs
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await fetchLhNoticeDetail({
|
||||
serviceKey: config.molitApiKey,
|
||||
filters: normalized
|
||||
});
|
||||
} catch (error) {
|
||||
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
|
||||
return {
|
||||
error: error.code || "proxy_error",
|
||||
message: error.message,
|
||||
upstream_code: error.upstreamCode || undefined,
|
||||
proxy: {
|
||||
name: config.proxyName,
|
||||
cache: { hit: false, ttl_ms: config.cacheTtlMs }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...body,
|
||||
proxy: {
|
||||
name: config.proxyName,
|
||||
cache: {
|
||||
hit: false,
|
||||
ttl_ms: config.cacheTtlMs
|
||||
},
|
||||
requested_at: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
cache.set(cacheKey, payload, config.cacheTtlMs);
|
||||
return payload;
|
||||
});
|
||||
|
||||
app.get("/v1/mfds/drug-safety/lookup", async (request, reply) => {
|
||||
let normalized;
|
||||
|
||||
|
|
@ -3207,6 +3376,8 @@ module.exports = {
|
|||
normalizeKmaForecastQuery,
|
||||
normalizeKoreanStockLookupQuery,
|
||||
normalizeKoreanStockSearchQuery,
|
||||
normalizeLhNoticeDetailQuery,
|
||||
normalizeLhNoticeSearchQuery,
|
||||
normalizeOpinetAroundQuery,
|
||||
normalizeOpinetDetailQuery,
|
||||
normalizeNeisSchoolMealQuery,
|
||||
|
|
|
|||
530
packages/k-skill-proxy/test/lh-notice.test.js
Normal file
530
packages/k-skill-proxy/test/lh-notice.test.js
Normal file
|
|
@ -0,0 +1,530 @@
|
|||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
LH_DEFAULT_LIST_URL,
|
||||
LH_DEFAULT_DETAIL_URL,
|
||||
VALID_PAN_SS_VALUES,
|
||||
VALID_UPP_AIS_TP_CD_VALUES,
|
||||
normalizeLhNoticeSearchQuery,
|
||||
normalizeLhNoticeDetailQuery,
|
||||
normalizeNoticeItem,
|
||||
parseXmlErrorEnvelope,
|
||||
extractNoticeEnvelope,
|
||||
buildNoticeListResponseBody,
|
||||
buildNoticeDetailResponseBody,
|
||||
fetchLhNoticeList,
|
||||
fetchLhNoticeDetail
|
||||
} = require("../src/lh-notice");
|
||||
|
||||
const SAMPLE_LIST_PAYLOAD = [
|
||||
{
|
||||
CMN: {
|
||||
CODE: "SUCCESS",
|
||||
ERR_MSG: "",
|
||||
TOTAL_CNT: 3
|
||||
}
|
||||
},
|
||||
{
|
||||
dsList: [
|
||||
{
|
||||
PAN_ID: "2015122300019828",
|
||||
PAN_NM: "2026년 상반기 부산광역시 영구임대주택 예비입주자 모집 공고",
|
||||
UPP_AIS_TP_CD: "06",
|
||||
AIS_TP_CD: "09",
|
||||
AIS_TP_CD_NM: "영구임대",
|
||||
CNP_CD_NM: "부산광역시",
|
||||
PAN_SS: "공고중",
|
||||
PAN_DT: "2026-04-21",
|
||||
CLSG_DT: "2026-05-06",
|
||||
SPL_INF_TP_CD: "051",
|
||||
CCR_CNNT_SYS_DS_CD: "03",
|
||||
DTL_URL:
|
||||
"https://apply.lh.or.kr/lhapply/apply/wt/wrtanc/selectWrtancInfo.do?ccrCnntSysDsCd=03&panId=2015122300019828&aisTpCd=09&uppAisTpCd=06&mi=1026"
|
||||
},
|
||||
{
|
||||
PAN_ID: "2015122300019816",
|
||||
PAN_NM: "2026 전세임대형 든든주택 1, 2순위 입주자 모집 공고",
|
||||
UPP_AIS_TP_CD: "13",
|
||||
AIS_TP_CD: "17",
|
||||
AIS_TP_CD_NM: "전세임대",
|
||||
CNP_CD_NM: "전국",
|
||||
PAN_SS: "공고중",
|
||||
PAN_DT: "2026-04-21",
|
||||
CLSG_DT: "2026-05-08",
|
||||
SPL_INF_TP_CD: "072",
|
||||
CCR_CNNT_SYS_DS_CD: "03",
|
||||
DTL_URL: "https://apply.lh.or.kr/lhapply/apply/wt/wrtanc/selectWrtancInfo.do?panId=2015122300019816"
|
||||
},
|
||||
{
|
||||
PAN_ID: "",
|
||||
PAN_NM: ""
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const SAMPLE_DETAIL_PAYLOAD = [
|
||||
{
|
||||
CMN: {
|
||||
CODE: "SUCCESS",
|
||||
ERR_MSG: "",
|
||||
TOTAL_CNT: 2
|
||||
}
|
||||
},
|
||||
{
|
||||
dsList: [
|
||||
{
|
||||
PAN_ID: "2015122300019828",
|
||||
PAN_NM: "2026년 상반기 부산광역시 영구임대주택 예비입주자 모집 공고",
|
||||
UPP_AIS_TP_CD: "06",
|
||||
AIS_TP_CD: "09",
|
||||
AIS_TP_CD_NM: "영구임대",
|
||||
CNP_CD_NM: "부산광역시",
|
||||
PAN_SS: "공고중",
|
||||
PAN_DT: "2026-04-21",
|
||||
CLSG_DT: "2026-05-06",
|
||||
SPL_INF_TP_CD: "051",
|
||||
CCR_CNNT_SYS_DS_CD: "03",
|
||||
HOUSE_TY: "영구임대 29㎡",
|
||||
SPL_CNT: "120"
|
||||
},
|
||||
{
|
||||
PAN_ID: "2015122300019828",
|
||||
HOUSE_TY: "영구임대 39㎡",
|
||||
SPL_CNT: "80"
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const XML_AUTH_ERROR = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<OpenAPI_ServiceResponse>
|
||||
<cmmMsgHeader>
|
||||
<errMsg>SERVICE ERROR</errMsg>
|
||||
<returnAuthMsg>SERVICE_KEY_IS_NOT_REGISTERED_ERROR</returnAuthMsg>
|
||||
<returnReasonCode>30</returnReasonCode>
|
||||
</cmmMsgHeader>
|
||||
</OpenAPI_ServiceResponse>`;
|
||||
|
||||
const STANDARD_JSON_ENVELOPE = {
|
||||
response: {
|
||||
header: { resultCode: "00", resultMsg: "NORMAL SERVICE." },
|
||||
body: {
|
||||
totalCount: 1,
|
||||
items: [
|
||||
{
|
||||
PAN_ID: "9999",
|
||||
PAN_NM: "standard envelope sample"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
test("VALID_PAN_SS_VALUES contains the five documented statuses", () => {
|
||||
for (const status of ["공고중", "접수중", "접수마감", "당첨자발표", "추정공고"]) {
|
||||
assert.equal(VALID_PAN_SS_VALUES.has(status), true, `missing ${status}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("VALID_UPP_AIS_TP_CD_VALUES covers the main LH categories", () => {
|
||||
for (const code of ["01", "05", "06", "13", "22"]) {
|
||||
assert.equal(VALID_UPP_AIS_TP_CD_VALUES.has(code), true, `missing ${code}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("normalizeLhNoticeSearchQuery accepts empty input and applies defaults", () => {
|
||||
const normalized = normalizeLhNoticeSearchQuery({});
|
||||
assert.equal(normalized.page, 1);
|
||||
assert.equal(normalized.pageSize, 50);
|
||||
assert.equal(normalized.panSs, undefined);
|
||||
assert.equal(normalized.uppAisTpCd, undefined);
|
||||
assert.equal(normalized.cnpCdNm, undefined);
|
||||
});
|
||||
|
||||
test("normalizeLhNoticeSearchQuery accepts camelCase, snake_case, and short aliases", () => {
|
||||
const normalized = normalizeLhNoticeSearchQuery({
|
||||
status: "공고중",
|
||||
category: "06",
|
||||
region: "서울특별시",
|
||||
keyword: "행복주택",
|
||||
startDate: "2026-01-01",
|
||||
endDate: "2026.12.31",
|
||||
page: "2",
|
||||
limit: "10"
|
||||
});
|
||||
assert.equal(normalized.panSs, "공고중");
|
||||
assert.equal(normalized.uppAisTpCd, "06");
|
||||
assert.equal(normalized.cnpCdNm, "서울특별시");
|
||||
assert.equal(normalized.panNm, "행복주택");
|
||||
assert.equal(normalized.panNtStDt, "2026-01-01");
|
||||
assert.equal(normalized.clsgDt, "2026-12-31");
|
||||
assert.equal(normalized.page, 2);
|
||||
assert.equal(normalized.pageSize, 10);
|
||||
});
|
||||
|
||||
test("normalizeLhNoticeSearchQuery rejects invalid panSs", () => {
|
||||
assert.throws(
|
||||
() => normalizeLhNoticeSearchQuery({ status: "대기중" }),
|
||||
/panSs must be one of/
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeLhNoticeSearchQuery rejects invalid uppAisTpCd", () => {
|
||||
assert.throws(
|
||||
() => normalizeLhNoticeSearchQuery({ category: "77" }),
|
||||
/uppAisTpCd must be one of/
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeLhNoticeSearchQuery rejects invalid aisTpCd (letters)", () => {
|
||||
assert.throws(
|
||||
() => normalizeLhNoticeSearchQuery({ aisTpCd: "abc" }),
|
||||
/aisTpCd must be digits/
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeLhNoticeSearchQuery clamps page/pageSize to documented bounds", () => {
|
||||
const high = normalizeLhNoticeSearchQuery({ page: "99999", pageSize: "99999" });
|
||||
assert.equal(high.page, 1000);
|
||||
assert.equal(high.pageSize, 1000);
|
||||
|
||||
const low = normalizeLhNoticeSearchQuery({ page: "0", pageSize: "0" });
|
||||
assert.equal(low.page, 1);
|
||||
assert.equal(low.pageSize, 1);
|
||||
});
|
||||
|
||||
test("normalizeLhNoticeSearchQuery accepts YYYYMMDD and YYYY.MM.DD", () => {
|
||||
const a = normalizeLhNoticeSearchQuery({ startDate: "20260101" });
|
||||
assert.equal(a.panNtStDt, "2026-01-01");
|
||||
|
||||
const b = normalizeLhNoticeSearchQuery({ startDate: "2026.01.02" });
|
||||
assert.equal(b.panNtStDt, "2026-01-02");
|
||||
});
|
||||
|
||||
test("normalizeLhNoticeSearchQuery rejects malformed dates", () => {
|
||||
assert.throws(
|
||||
() => normalizeLhNoticeSearchQuery({ startDate: "2026" }),
|
||||
/panNtStDt as YYYY-MM-DD/
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeLhNoticeDetailQuery requires all three codes", () => {
|
||||
assert.throws(
|
||||
() => normalizeLhNoticeDetailQuery({}),
|
||||
/Provide panId/
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeLhNoticeDetailQuery({ panId: "2015122300019828" }),
|
||||
/Provide ccrCnntSysDsCd/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeLhNoticeDetailQuery({
|
||||
panId: "2015122300019828",
|
||||
ccrCnntSysDsCd: "03"
|
||||
}),
|
||||
/Provide splInfTpCd/
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeLhNoticeDetailQuery accepts the production payload", () => {
|
||||
const normalized = normalizeLhNoticeDetailQuery({
|
||||
panId: "2015122300019828",
|
||||
ccrCnntSysDsCd: "03",
|
||||
splInfTpCd: "051"
|
||||
});
|
||||
assert.deepEqual(normalized, {
|
||||
panId: "2015122300019828",
|
||||
ccrCnntSysDsCd: "03",
|
||||
splInfTpCd: "051"
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizeLhNoticeDetailQuery rejects non-numeric panId", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeLhNoticeDetailQuery({
|
||||
panId: "abc123",
|
||||
ccrCnntSysDsCd: "03",
|
||||
splInfTpCd: "051"
|
||||
}),
|
||||
/panId must be digits/
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeNoticeItem maps upstream SCREAMING_SNAKE_CASE keys to snake_case", () => {
|
||||
const result = normalizeNoticeItem({
|
||||
PAN_ID: "2015122300019828",
|
||||
PAN_NM: "샘플 공고",
|
||||
UPP_AIS_TP_CD: "06",
|
||||
AIS_TP_CD: "09",
|
||||
AIS_TP_CD_NM: "영구임대",
|
||||
CNP_CD_NM: "부산광역시",
|
||||
PAN_SS: "공고중",
|
||||
PAN_DT: "2026-04-21",
|
||||
CLSG_DT: "2026-05-06",
|
||||
SPL_INF_TP_CD: "051",
|
||||
CCR_CNNT_SYS_DS_CD: "03",
|
||||
DTL_URL: "https://apply.lh.or.kr/..."
|
||||
});
|
||||
|
||||
assert.equal(result.pan_id, "2015122300019828");
|
||||
assert.equal(result.pan_nm, "샘플 공고");
|
||||
assert.equal(result.upp_ais_tp_cd, "06");
|
||||
assert.equal(result.ais_tp_cd_nm, "영구임대");
|
||||
assert.equal(result.cnp_cd_nm, "부산광역시");
|
||||
assert.equal(result.pan_ss, "공고중");
|
||||
assert.equal(result.pan_dt, "2026-04-21");
|
||||
assert.equal(result.clsg_dt, "2026-05-06");
|
||||
assert.equal(result.spl_inf_tp_cd, "051");
|
||||
assert.equal(result.ccr_cnnt_sys_ds_cd, "03");
|
||||
assert.equal(result.detail_url, "https://apply.lh.or.kr/...");
|
||||
assert.ok(result.raw, "raw pass-through must be kept");
|
||||
});
|
||||
|
||||
test("normalizeNoticeItem drops rows with no panId AND no panNm", () => {
|
||||
assert.equal(normalizeNoticeItem({}), null);
|
||||
assert.equal(normalizeNoticeItem({ PAN_ID: "" }), null);
|
||||
assert.equal(normalizeNoticeItem({ PAN_NM: " " }), null);
|
||||
});
|
||||
|
||||
test("normalizeNoticeItem keeps rows that have panId only", () => {
|
||||
const result = normalizeNoticeItem({ PAN_ID: "123" });
|
||||
assert.equal(result.pan_id, "123");
|
||||
assert.equal(result.pan_nm, null);
|
||||
});
|
||||
|
||||
test("parseXmlErrorEnvelope pulls code+msg from the OpenAPI_ServiceResponse form", () => {
|
||||
const err = parseXmlErrorEnvelope(XML_AUTH_ERROR);
|
||||
assert.ok(err);
|
||||
assert.equal(err.code, "30");
|
||||
assert.match(err.message, /SERVICE_KEY/);
|
||||
});
|
||||
|
||||
test("parseXmlErrorEnvelope returns null for non-XML payloads", () => {
|
||||
assert.equal(parseXmlErrorEnvelope(""), null);
|
||||
assert.equal(parseXmlErrorEnvelope("Unauthorized"), null);
|
||||
assert.equal(parseXmlErrorEnvelope('{"a":1}'), null);
|
||||
});
|
||||
|
||||
test("extractNoticeEnvelope handles the LH [CMN,dsList] array envelope", () => {
|
||||
const envelope = extractNoticeEnvelope(SAMPLE_LIST_PAYLOAD);
|
||||
assert.equal(envelope.totalCount, 3);
|
||||
assert.equal(envelope.items.length, 3);
|
||||
assert.equal(envelope.items[0].PAN_ID, "2015122300019828");
|
||||
});
|
||||
|
||||
test("extractNoticeEnvelope throws an error for CMN.CODE != SUCCESS", () => {
|
||||
const payload = [
|
||||
{ CMN: { CODE: "FAIL", ERR_MSG: "Service key invalid", TOTAL_CNT: 0 } },
|
||||
{ dsList: [] }
|
||||
];
|
||||
assert.throws(() => extractNoticeEnvelope(payload), /Service key invalid/);
|
||||
});
|
||||
|
||||
test("extractNoticeEnvelope also handles the standard data.go.kr response/body shape", () => {
|
||||
const envelope = extractNoticeEnvelope(STANDARD_JSON_ENVELOPE);
|
||||
assert.equal(envelope.totalCount, 1);
|
||||
assert.equal(envelope.items.length, 1);
|
||||
assert.equal(envelope.items[0].PAN_ID, "9999");
|
||||
});
|
||||
|
||||
test("extractNoticeEnvelope returns empty list for unknown shapes", () => {
|
||||
assert.deepEqual(extractNoticeEnvelope(null), { totalCount: null, items: [] });
|
||||
assert.deepEqual(extractNoticeEnvelope({ foo: "bar" }), { totalCount: null, items: [] });
|
||||
});
|
||||
|
||||
test("buildNoticeListResponseBody produces the proxy-facing JSON shape", () => {
|
||||
const envelope = extractNoticeEnvelope(SAMPLE_LIST_PAYLOAD);
|
||||
const body = buildNoticeListResponseBody(envelope, {
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
filters: { pan_ss: "공고중" }
|
||||
});
|
||||
assert.equal(body.items.length, 2, "empty row must be skipped");
|
||||
assert.equal(body.summary.page, 1);
|
||||
assert.equal(body.summary.page_size, 50);
|
||||
assert.equal(body.summary.returned_count, 2);
|
||||
assert.equal(body.summary.total_count, 3);
|
||||
assert.equal(body.query.pan_ss, "공고중");
|
||||
assert.equal(body.items[0].pan_id, "2015122300019828");
|
||||
});
|
||||
|
||||
test("buildNoticeDetailResponseBody returns notice + supply_infos", () => {
|
||||
const envelope = extractNoticeEnvelope(SAMPLE_DETAIL_PAYLOAD);
|
||||
const body = buildNoticeDetailResponseBody(envelope, {
|
||||
panId: "2015122300019828",
|
||||
ccrCnntSysDsCd: "03",
|
||||
splInfTpCd: "051"
|
||||
});
|
||||
assert.equal(body.notice.pan_id, "2015122300019828");
|
||||
assert.equal(body.notice.ais_tp_cd_nm, "영구임대");
|
||||
assert.equal(body.supply_infos.length, 2);
|
||||
assert.equal(body.supply_infos[0].HOUSE_TY, "영구임대 29㎡");
|
||||
assert.equal(body.query.panId, "2015122300019828");
|
||||
});
|
||||
|
||||
test("fetchLhNoticeList builds the expected data.go.kr URL and returns parsed items", async () => {
|
||||
const calls = [];
|
||||
const mockFetch = async (url) => {
|
||||
calls.push(url);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => "application/json;charset=UTF-8" },
|
||||
text: async () => JSON.stringify(SAMPLE_LIST_PAYLOAD)
|
||||
};
|
||||
};
|
||||
|
||||
const body = await fetchLhNoticeList({
|
||||
serviceKey: "test-key",
|
||||
filters: {
|
||||
page: 2,
|
||||
pageSize: 25,
|
||||
panSs: "공고중",
|
||||
uppAisTpCd: "06",
|
||||
aisTpCd: "09",
|
||||
cnpCdNm: "부산광역시",
|
||||
panNm: "영구임대",
|
||||
panNtStDt: "2026-04-01",
|
||||
clsgDt: "2026-05-31"
|
||||
},
|
||||
fetchImpl: mockFetch
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
const requested = calls[0];
|
||||
assert.ok(requested.startsWith(LH_DEFAULT_LIST_URL), `URL must hit list endpoint: ${requested}`);
|
||||
assert.match(requested, /PG_SZ=25/);
|
||||
assert.match(requested, /PAGE=2/);
|
||||
assert.match(requested, /PAN_SS=%EA%B3%B5%EA%B3%A0%EC%A4%91/);
|
||||
assert.match(requested, /UPP_AIS_TP_CD=06/);
|
||||
assert.match(requested, /AIS_TP_CD=09/);
|
||||
assert.match(requested, /CNP_CD_NM=%EB%B6%80%EC%82%B0/);
|
||||
assert.match(requested, /PAN_NT_ST_DT=2026-04-01/);
|
||||
assert.match(requested, /CLSG_DT=2026-05-31/);
|
||||
assert.match(requested, /serviceKey=test-key/);
|
||||
|
||||
assert.equal(body.items.length, 2);
|
||||
assert.equal(body.summary.page, 2);
|
||||
assert.equal(body.summary.page_size, 25);
|
||||
assert.equal(body.summary.total_count, 3);
|
||||
});
|
||||
|
||||
test("fetchLhNoticeList surfaces upstream 401 as upstream_not_authorized (statusCode 503)", async () => {
|
||||
const mockFetch = async () => ({
|
||||
ok: false,
|
||||
status: 401,
|
||||
headers: { get: () => "text/plain" },
|
||||
text: async () => "Unauthorized"
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
fetchLhNoticeList({
|
||||
serviceKey: "bad",
|
||||
filters: { page: 1, pageSize: 50 },
|
||||
fetchImpl: mockFetch
|
||||
}),
|
||||
(err) => {
|
||||
assert.equal(err.code, "upstream_not_authorized");
|
||||
assert.equal(err.statusCode, 503);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("fetchLhNoticeList surfaces XML SERVICE_KEY error envelopes with upstreamCode", async () => {
|
||||
const mockFetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => "text/xml" },
|
||||
text: async () => XML_AUTH_ERROR
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
fetchLhNoticeList({
|
||||
serviceKey: "bad",
|
||||
filters: { page: 1, pageSize: 50 },
|
||||
fetchImpl: mockFetch
|
||||
}),
|
||||
(err) => {
|
||||
assert.equal(err.code, "upstream_error");
|
||||
assert.equal(err.statusCode, 502);
|
||||
assert.equal(err.upstreamCode, "30");
|
||||
assert.match(err.message, /SERVICE_KEY/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("fetchLhNoticeList surfaces non-JSON payloads as upstream_invalid_payload", async () => {
|
||||
const mockFetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => "text/html" },
|
||||
text: async () => "<html>not json</html>"
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
fetchLhNoticeList({
|
||||
serviceKey: "x",
|
||||
filters: { page: 1, pageSize: 50 },
|
||||
fetchImpl: mockFetch
|
||||
}),
|
||||
(err) => {
|
||||
assert.equal(err.code, "upstream_invalid_payload");
|
||||
assert.equal(err.statusCode, 502);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("fetchLhNoticeList surfaces fetch failures as upstream_fetch_failed", async () => {
|
||||
const mockFetch = async () => {
|
||||
throw new Error("socket hang up");
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
fetchLhNoticeList({
|
||||
serviceKey: "x",
|
||||
filters: { page: 1, pageSize: 50 },
|
||||
fetchImpl: mockFetch
|
||||
}),
|
||||
(err) => {
|
||||
assert.equal(err.code, "upstream_fetch_failed");
|
||||
assert.equal(err.statusCode, 502);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("fetchLhNoticeDetail calls the detail endpoint with PAN_ID + SPL_INF_TP_CD + CCR_CNNT_SYS_DS_CD", async () => {
|
||||
const calls = [];
|
||||
const mockFetch = async (url) => {
|
||||
calls.push(url);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => "application/json" },
|
||||
text: async () => JSON.stringify(SAMPLE_DETAIL_PAYLOAD)
|
||||
};
|
||||
};
|
||||
|
||||
const body = await fetchLhNoticeDetail({
|
||||
serviceKey: "test-key",
|
||||
filters: { panId: "2015122300019828", ccrCnntSysDsCd: "03", splInfTpCd: "051" },
|
||||
fetchImpl: mockFetch
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
const requested = calls[0];
|
||||
assert.ok(requested.startsWith(LH_DEFAULT_DETAIL_URL), `URL must hit detail endpoint: ${requested}`);
|
||||
assert.match(requested, /PAN_ID=2015122300019828/);
|
||||
assert.match(requested, /CCR_CNNT_SYS_DS_CD=03/);
|
||||
assert.match(requested, /SPL_INF_TP_CD=051/);
|
||||
|
||||
assert.equal(body.notice.pan_id, "2015122300019828");
|
||||
assert.equal(body.supply_infos.length, 2);
|
||||
});
|
||||
|
|
@ -3602,3 +3602,219 @@ test("parking lot search endpoint reports missing Data.go.kr key", async (t) =>
|
|||
assert.equal(response.json().error, "upstream_not_configured");
|
||||
assert.match(response.json().message, /DATA_GO_KR_API_KEY/);
|
||||
});
|
||||
|
||||
test("lh-notice search endpoint stays public and caches normalized queries", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
const fetchCalls = [];
|
||||
global.fetch = async (url, options = {}) => {
|
||||
fetchCalls.push(String(url));
|
||||
return new Response(
|
||||
JSON.stringify([
|
||||
{ CMN: { CODE: "SUCCESS", ERR_MSG: "", TOTAL_CNT: 2 } },
|
||||
{
|
||||
dsList: [
|
||||
{
|
||||
PAN_ID: "2015122300019828",
|
||||
PAN_NM: "2026년 상반기 부산광역시 영구임대주택 예비입주자 모집 공고",
|
||||
UPP_AIS_TP_CD: "06",
|
||||
AIS_TP_CD: "09",
|
||||
AIS_TP_CD_NM: "영구임대",
|
||||
CNP_CD_NM: "부산광역시",
|
||||
PAN_SS: "공고중",
|
||||
PAN_DT: "2026-04-21",
|
||||
CLSG_DT: "2026-05-06",
|
||||
SPL_INF_TP_CD: "051",
|
||||
CCR_CNNT_SYS_DS_CD: "03",
|
||||
DTL_URL: "https://apply.lh.or.kr/detail?panId=2015122300019828"
|
||||
},
|
||||
{
|
||||
PAN_ID: "2015122300019816",
|
||||
PAN_NM: "2026 전세임대형 든든주택 모집 공고",
|
||||
UPP_AIS_TP_CD: "13",
|
||||
AIS_TP_CD: "17",
|
||||
AIS_TP_CD_NM: "전세임대",
|
||||
CNP_CD_NM: "전국",
|
||||
PAN_SS: "공고중"
|
||||
}
|
||||
]
|
||||
}
|
||||
]),
|
||||
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
|
||||
);
|
||||
};
|
||||
|
||||
const app = buildServer({
|
||||
env: {
|
||||
DATA_GO_KR_API_KEY: "data-go-key",
|
||||
KSKILL_PROXY_CACHE_TTL_MS: "60000"
|
||||
}
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const first = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/lh-notice/search?panSs=%EA%B3%B5%EA%B3%A0%EC%A4%91&limit=10"
|
||||
});
|
||||
const firstBody = first.json();
|
||||
assert.equal(first.statusCode, 200);
|
||||
assert.equal(firstBody.proxy.cache.hit, false);
|
||||
assert.equal(firstBody.items.length, 2);
|
||||
assert.equal(firstBody.items[0].pan_id, "2015122300019828");
|
||||
assert.equal(firstBody.items[0].ais_tp_cd_nm, "영구임대");
|
||||
assert.equal(firstBody.summary.returned_count, 2);
|
||||
assert.equal(firstBody.summary.total_count, 2);
|
||||
assert.equal(firstBody.query.pan_ss, "공고중");
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.match(fetchCalls[0], /PG_SZ=10/);
|
||||
assert.match(fetchCalls[0], /PAGE=1/);
|
||||
assert.match(fetchCalls[0], /serviceKey=data-go-key/);
|
||||
|
||||
const second = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/lh-notice/search?status=%EA%B3%B5%EA%B3%A0%EC%A4%91&pageSize=10"
|
||||
});
|
||||
const secondBody = second.json();
|
||||
assert.equal(second.statusCode, 200);
|
||||
assert.equal(secondBody.proxy.cache.hit, true, "alias-normalized second call must reuse cache");
|
||||
assert.equal(fetchCalls.length, 1, "cache hit must not hit upstream again");
|
||||
});
|
||||
|
||||
test("lh-notice search returns 400 for unsupported panSs", async (t) => {
|
||||
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
|
||||
t.after(async () => { await app.close(); });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/lh-notice/search?panSs=%EB%8C%80%EA%B8%B0%EC%A4%91"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 400);
|
||||
assert.equal(response.json().error, "bad_request");
|
||||
assert.match(response.json().message, /panSs must be one of/);
|
||||
});
|
||||
|
||||
test("lh-notice search returns 503 when DATA_GO_KR_API_KEY is missing", async (t) => {
|
||||
const app = buildServer();
|
||||
t.after(async () => { await app.close(); });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/lh-notice/search"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 503);
|
||||
assert.equal(response.json().error, "upstream_not_configured");
|
||||
assert.match(response.json().message, /DATA_GO_KR_API_KEY/);
|
||||
});
|
||||
|
||||
test("lh-notice search does not cache upstream XML auth errors so retries self-heal", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
const xmlError = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<OpenAPI_ServiceResponse>
|
||||
<cmmMsgHeader>
|
||||
<errMsg>SERVICE ERROR</errMsg>
|
||||
<returnAuthMsg>SERVICE_KEY_IS_NOT_REGISTERED_ERROR</returnAuthMsg>
|
||||
<returnReasonCode>30</returnReasonCode>
|
||||
</cmmMsgHeader>
|
||||
</OpenAPI_ServiceResponse>`;
|
||||
|
||||
let mode = "fail";
|
||||
const successPayload = [
|
||||
{ CMN: { CODE: "SUCCESS", ERR_MSG: "", TOTAL_CNT: 1 } },
|
||||
{ dsList: [{ PAN_ID: "111", PAN_NM: "recovered notice" }] }
|
||||
];
|
||||
|
||||
global.fetch = async () => {
|
||||
if (mode === "fail") {
|
||||
return new Response(xmlError, { status: 200, headers: { "content-type": "text/xml" } });
|
||||
}
|
||||
return new Response(JSON.stringify(successPayload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" }
|
||||
});
|
||||
};
|
||||
|
||||
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
|
||||
t.after(async () => { global.fetch = originalFetch; await app.close(); });
|
||||
|
||||
const first = await app.inject({ method: "GET", url: "/v1/lh-notice/search" });
|
||||
assert.equal(first.statusCode, 502);
|
||||
assert.equal(first.json().error, "upstream_error");
|
||||
assert.equal(first.json().upstream_code, "30");
|
||||
|
||||
mode = "ok";
|
||||
|
||||
const second = await app.inject({ method: "GET", url: "/v1/lh-notice/search" });
|
||||
assert.equal(second.statusCode, 200);
|
||||
assert.equal(second.json().proxy.cache.hit, false, "failure must not have been cached");
|
||||
assert.equal(second.json().items[0].pan_id, "111");
|
||||
});
|
||||
|
||||
test("lh-notice detail endpoint requires all three codes and caches successful lookups", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
const fetchCalls = [];
|
||||
global.fetch = async (url) => {
|
||||
fetchCalls.push(String(url));
|
||||
return new Response(
|
||||
JSON.stringify([
|
||||
{ CMN: { CODE: "SUCCESS", ERR_MSG: "", TOTAL_CNT: 1 } },
|
||||
{
|
||||
dsList: [
|
||||
{
|
||||
PAN_ID: "2015122300019828",
|
||||
PAN_NM: "영구임대 예비입주자 모집",
|
||||
UPP_AIS_TP_CD: "06",
|
||||
AIS_TP_CD_NM: "영구임대",
|
||||
HOUSE_TY: "29㎡",
|
||||
SPL_CNT: "120"
|
||||
}
|
||||
]
|
||||
}
|
||||
]),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
|
||||
t.after(async () => { global.fetch = originalFetch; await app.close(); });
|
||||
|
||||
const missing = await app.inject({ method: "GET", url: "/v1/lh-notice/detail" });
|
||||
assert.equal(missing.statusCode, 400);
|
||||
assert.match(missing.json().message, /Provide panId/);
|
||||
|
||||
const first = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/lh-notice/detail?panId=2015122300019828&ccrCnntSysDsCd=03&splInfTpCd=051"
|
||||
});
|
||||
assert.equal(first.statusCode, 200);
|
||||
assert.equal(first.json().proxy.cache.hit, false);
|
||||
assert.equal(first.json().notice.pan_id, "2015122300019828");
|
||||
assert.equal(first.json().notice.ais_tp_cd_nm, "영구임대");
|
||||
assert.equal(first.json().supply_infos.length, 1);
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.match(fetchCalls[0], /lhLeaseNoticeDtlInfo1\/getLeaseNoticeDtlInfo1/);
|
||||
assert.match(fetchCalls[0], /PAN_ID=2015122300019828/);
|
||||
assert.match(fetchCalls[0], /CCR_CNNT_SYS_DS_CD=03/);
|
||||
assert.match(fetchCalls[0], /SPL_INF_TP_CD=051/);
|
||||
|
||||
const second = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/lh-notice/detail?panId=2015122300019828&ccrCnntSysDsCd=03&splInfTpCd=051"
|
||||
});
|
||||
assert.equal(second.statusCode, 200);
|
||||
assert.equal(second.json().proxy.cache.hit, true);
|
||||
assert.equal(fetchCalls.length, 1, "cached detail must not retrigger upstream");
|
||||
});
|
||||
|
||||
test("health endpoint reports lhNoticeConfigured when DATA_GO_KR_API_KEY is set", async (t) => {
|
||||
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
|
||||
t.after(async () => { await app.close(); });
|
||||
|
||||
const response = await app.inject({ method: "GET", url: "/health" });
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.json().upstreams.lhNoticeConfigured, true);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue