"""그림 조각 대리 — 바깥 WMS 를 웹 메르카토르 z/x/y 256 px PNG 로(PLAN 622-1 · 요구 529). - 겹 = `SOURCES`(ecology 생태자연도 오픈API · landslide 생활안전지도 산사태위험지도) · 조각 상자 = EPSG:3857 bbox 그대로 요청(두 곳 모두 3857 받음 · 옮겨 그리기 없음). - 처음 받고 기억 = `storage/_shared/wms_tiles//z/x/y.png`(영구 · `.part` → 바꿈) · 빈 조각 (전부 투명) · 오류 조각 = 서버 메모리에 `SHORT_S` 초만 기억하고 투명 PNG. - 키 = `.env`(`SOURCES` 의 key_env) · 없으면 `MissingKeyError` · 키 값은 어떤 로그 · 응답에도 안 냄 (오류 글에 주소를 넣지 않음). 길 = `D03_PlanAnalysis_Router_Tiles` `/plan/wmt/{name}/{z}/{x}/{y}.png`. """ from __future__ import annotations import functools import io import logging import os import threading import time import urllib.parse import urllib.request from dataclasses import dataclass, field from pathlib import Path from uuid import uuid4 from common_util.common_util_contour_tiles import tile_bounds from config.config_system import STORAGE_BASE_DIR from D03_PlanAnalysis.D03_PlanAnalysis_Landslide import ( LANDSLIDE_KEY_ENV, LANDSLIDE_LAYER, LANDSLIDE_URL, PNG_SIGNATURE, ) logger = logging.getLogger(__name__) WMS_ROOT = Path(STORAGE_BASE_DIR) / "_shared" / "wms_tiles" MIN_ZOOM, MAX_ZOOM = 6, 19 TILE_PX = 256 SHORT_S = 300 TIMEOUT_S = 30 # 생태자연도 한 조각이 8~15 s 걸릴 때 있음 @dataclass(frozen=True) class Source: """WMS 하나 — 주소 · 키 칸 · 고정 인자 · 상자 좌표계 칸 이름.""" url: str key_env: str params: dict[str, str] = field(default_factory=dict) srs_param: str = "srs" SOURCES: dict[str, Source] = { "ecology": Source( "https://apis.data.go.kr/B553084/ecoapi/EcologyzmpService/wms/getEcologyzmpWMS", "DATA_GO_KR_API_KEY", {"format": "image/png", "transparent": "TRUE"}, srs_param="crs", ), "landslide": Source( LANDSLIDE_URL, LANDSLIDE_KEY_ENV, {"layers": LANDSLIDE_LAYER, "styles": "", "format": "image/png", "transparent": "TRUE"}, ), } class MissingKeyError(RuntimeError): """`.env` 에 키 없음.""" _SHORT: dict[tuple[str, int, int, int], tuple[float, str]] = {} _SHORT_LOCK = threading.Lock() @functools.cache def blank() -> bytes: """투명 256 px PNG.""" from PIL import Image buffer = io.BytesIO() Image.new("RGBA", (TILE_PX, TILE_PX), (0, 0, 0, 0)).save(buffer, format="PNG") return buffer.getvalue() def _key(source: Source) -> str: """키 — 인코딩된 키(`%` 있음)는 그대로 · 아니면 주소용으로 바꿈 · 없으면 `MissingKeyError`.""" key = os.environ.get(source.key_env, "").strip() if not key: raise MissingKeyError(f"키 없음 — .env {source.key_env}") return key if "%" in key else urllib.parse.quote(key, safe="") def fetch(name: str, z: int, x: int, y: int) -> bytes: """바깥 WMS 에서 조각 하나(PNG) — 키 없음 = `MissingKeyError` · 실패 = `RuntimeError`.""" source = SOURCES[name] query = { **source.params, source.srs_param: "EPSG:3857", "bbox": ",".join(f"{v:.3f}" for v in tile_bounds(z, x, y)), "width": str(TILE_PX), "height": str(TILE_PX), } url = f"{source.url}?serviceKey={_key(source)}&{urllib.parse.urlencode(query)}" request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) try: with urllib.request.urlopen(request, timeout=TIMEOUT_S) as response: body = response.read() except OSError as error: raise RuntimeError(f"{name} 조각을 받지 못했습니다 — {type(error).__name__}") from None if not body.startswith(PNG_SIGNATURE): raise RuntimeError(f"{name} 조각을 받지 못했습니다 — 그림이 아닌 응답") return body def is_blank(png: bytes) -> bool: """전부 투명한 그림인가.""" from PIL import Image image = Image.open(io.BytesIO(png)) return image.convert("RGBA").getchannel("A").getextrema()[1] == 0 def ensure_tile(name: str, z: int, x: int, y: int) -> tuple[bytes, str]: """(PNG, 상태) — 상태 "" = 받은 그림(창고) · "empty" · "error" = 투명 PNG(짧게 기억). 모르는 name · 범위 밖 번호 = `KeyError` · 키 없음 = `MissingKeyError`.""" if name not in SOURCES: raise KeyError(name) if not (MIN_ZOOM <= z <= MAX_ZOOM) or not (0 <= x < 2**z and 0 <= y < 2**z): raise KeyError(f"{z}/{x}/{y}") path = WMS_ROOT / name / str(z) / str(x) / f"{y}.png" if path.is_file(): return path.read_bytes(), "" tile = (name, z, x, y) with _SHORT_LOCK: until, status = _SHORT.get(tile, (0.0, "")) if until > time.monotonic(): return blank(), status try: png = fetch(name, z, x, y) status = "empty" if is_blank(png) else "" except MissingKeyError: raise except (RuntimeError, OSError) as error: # OSError = 그림을 못 읽음 logger.warning("%s", error) status = "error" if status: with _SHORT_LOCK: _SHORT[tile] = (time.monotonic() + SHORT_S, status) return blank(), status path.parent.mkdir(parents=True, exist_ok=True) part = path.with_suffix(f".{uuid4().hex}.part") part.write_bytes(png) part.replace(path) # 반쯤 쓴 조각을 남기지 않음 return png, ""