From f931c496d84ba504db0f79670e01b7fc64c1a182 Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 10 Jul 2026 00:27:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(realestate):=20=EB=82=B4=EB=B6=80=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20+=20naver=20=EC=9B=8C=EC=BB=A4=20targets?= =?UTF-8?q?=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20+=20nginx=20?= =?UTF-8?q?internal=20=EB=B8=94=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9 --- nginx/default.conf | 17 +++++++++++ realestate-lab/app/auth.py | 11 ++++++++ realestate-lab/app/internal_router.py | 27 ++++++++++++++++++ realestate-lab/app/main.py | 2 ++ realestate-lab/tests/test_internal_targets.py | 28 +++++++++++++++++++ 5 files changed, 85 insertions(+) create mode 100644 realestate-lab/app/auth.py create mode 100644 realestate-lab/app/internal_router.py create mode 100644 realestate-lab/tests/test_internal_targets.py diff --git a/nginx/default.conf b/nginx/default.conf index 8f49bc6..b080a0f 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -119,6 +119,23 @@ server { proxy_connect_timeout 10s; } + # naver-fetch 워커 → NAS realestate-lab internal webhook (targets/listings-ingest) + # Layer 1·2: nginx IP 화이트리스트 (LAN + Tailscale) + # Layer 3: X-Internal-Key (FastAPI dependency) + location /api/internal/realestate/ { + allow 192.168.45.0/24; + allow 100.64.0.0/10; + allow 127.0.0.1; + deny all; + + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Internal-Key $http_x_internal_key; + proxy_pass http://realestate-lab:8000/api/internal/realestate/; + } + # realestate API location /api/realestate/ { proxy_http_version 1.1; diff --git a/realestate-lab/app/auth.py b/realestate-lab/app/auth.py new file mode 100644 index 0000000..019f083 --- /dev/null +++ b/realestate-lab/app/auth.py @@ -0,0 +1,11 @@ +"""Windows naver-fetch 워커 → NAS realestate-lab 내부 엔드포인트 인증.""" +import os +from fastapi import Header, HTTPException + + +def verify_internal_key(x_internal_key: str = Header(...)): + expected = os.getenv("INTERNAL_API_KEY") + if not expected: + raise HTTPException(401, "INTERNAL_API_KEY not configured on server") + if x_internal_key != expected: + raise HTTPException(401, "Invalid X-Internal-Key") diff --git a/realestate-lab/app/internal_router.py b/realestate-lab/app/internal_router.py new file mode 100644 index 0000000..bb363db --- /dev/null +++ b/realestate-lab/app/internal_router.py @@ -0,0 +1,27 @@ +"""naver-fetch 워커 내부 계약: targets 조회 + listings ingest.""" +import os +import logging +from fastapi import APIRouter, Depends + +from .auth import verify_internal_key +from .db import get_listing_criteria +from .lawd_codes import naver_cortar + +logger = logging.getLogger("realestate-lab") +router = APIRouter() + +NAVER_PAGE_LIMIT = int(os.getenv("NAVER_PAGE_LIMIT", "2")) + + +@router.get("/api/internal/realestate/targets", dependencies=[Depends(verify_internal_key)]) +def listing_targets(): + crit = get_listing_criteria() + dongs = [] + for d in crit.get("dongs", []): + code = naver_cortar(d) + if not code: + logger.warning("naver cortarNo 매핑 없음 — 대상 제외: %s", d) + continue + dongs.append({"dong": d, "cortar_no": code}) + return {"dongs": dongs, "deal_types": crit.get("deal_types", []), + "page_limit": NAVER_PAGE_LIMIT} diff --git a/realestate-lab/app/main.py b/realestate-lab/app/main.py index 28542df..122e1b5 100644 --- a/realestate-lab/app/main.py +++ b/realestate-lab/app/main.py @@ -29,6 +29,7 @@ from .models import ( AnnouncementCreate, AnnouncementUpdate, ProfileUpdate, ListingCriteriaUpdate, SafetyCheckRequest, BudgetRequest, ) +from .internal_router import router as internal_router logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s") logger = logging.getLogger("realestate-lab") @@ -96,6 +97,7 @@ async def lifespan(app: FastAPI): app = FastAPI(lifespan=lifespan) install_access_log(app) +app.include_router(internal_router) _cors_origins = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080").split(",") app.add_middleware( diff --git a/realestate-lab/tests/test_internal_targets.py b/realestate-lab/tests/test_internal_targets.py new file mode 100644 index 0000000..61e9477 --- /dev/null +++ b/realestate-lab/tests/test_internal_targets.py @@ -0,0 +1,28 @@ +import os +from fastapi.testclient import TestClient + +def _client(monkeypatch): + monkeypatch.setenv("INTERNAL_API_KEY", "SECRET") + import importlib + from app import auth as _a + importlib.reload(_a) # env 반영 + from app.main import app + return TestClient(app) + +def test_targets_requires_key(monkeypatch): + c = _client(monkeypatch) + # 헤더 없음 — FastAPI Header(...)는 dependency 실행 전 자체 검증하므로 422. + # image-lab/tests/test_internal_router.py의 동일 패턴과 일치(sibling 서비스 선례). + assert c.get("/api/internal/realestate/targets").status_code in (401, 422) + assert c.get("/api/internal/realestate/targets", + headers={"X-Internal-Key": "WRONG"}).status_code == 401 + +def test_targets_returns_dongs_with_cortar(monkeypatch): + c = _client(monkeypatch) + r = c.get("/api/internal/realestate/targets", headers={"X-Internal-Key": "SECRET"}) + assert r.status_code == 200 + body = r.json() + dongs = {d["dong"]: d["cortar_no"] for d in body["dongs"]} + assert dongs.get("신대방동") == "1159010100" # criteria seed 6동 + cortar join + assert "전세" in body["deal_types"] + assert isinstance(body["page_limit"], int)