diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py index 32419a7f7..c5628f21b 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py @@ -3,7 +3,7 @@ - `GET /api/projects/{id}/plan/terrain/extent` — 받기 전 범위(상자 · 도엽 · 칸 수 · 받은 범위) - `POST …/plan/terrain` — 사업 전체 지형 한 벌 받기(뒤에서 · 누를 때만) · 끝나면 `surface_models` 행 - `GET …/plan/progress` — 일의 진행률 -- `GET …/plan/tile/{z}/{x}/{y}` — 위성 조각 대리(586-1 GL 바탕 시험) +- `GET …/plan/tile/{z}/{x}/{y}` — 위성 조각 대리 · 서버 기억(586 GL 바탕) - `GET …/plan/layers/sheet?key=` · `…/cadastral` · `…/raster?kind=slope|hillshade|landslide` · `…/zones` · `…/soils` · `…/bands` · `…/lines?kind=` · `…/zero-order` · `…/forest-roads`(GET · POST 올리기) · `…/landslide-zones` — 지도 겹 @@ -21,7 +21,7 @@ import json import logging from pathlib import Path from typing import Any -from uuid import UUID +from uuid import UUID, uuid4 from fastapi import APIRouter, File, HTTPException, Request, Response, UploadFile from pydantic import BaseModel @@ -350,22 +350,53 @@ async def plan_layer_landslide_zones(project_id: UUID) -> dict[str, Any]: return {"status": "success", **value} +# 위성 조각 기억 — 프로젝트 → 실경로(조각마다 DB 를 안 부름) +_TILE_ROOTS: dict[UUID, Path] = {} +TILE_KIND = "plan_tile" + + +async def _tile_root(project_id: UUID) -> Path: + root = _TILE_ROOTS.get(project_id) + if root is None: + async with get_db_pool().acquire() as connection: + stored = await get_project_storage_relative_path(connection, project_id) + if not stored: + raise HTTPException(status_code=404, detail="프로젝트 저장 경로를 찾을 수 없습니다.") + root = _TILE_ROOTS[project_id] = Path(resolve_stored_project_path(stored)) + return root + + @router.get("/{project_id}/plan/tile/{z}/{x}/{y}", response_model=None) async def plan_satellite_tile(project_id: UUID, z: int, x: int, y: int) -> Response: - """위성 조각(PLAN 586-1 GL 바탕 시험 · 브이월드 Satellite z6~19) — 키가 화면에 안 새게 대리.""" + """위성 조각(PLAN 586 GL 바탕 · 브이월드 Satellite z6~19) — 키가 화면에 안 새게 대리 · + 받은 조각 = `_derived/plan_tile/Satellite/{z}/{x}_{y}.jpeg`(지문 = 겹 · 조각 번호 · + 지워도 다시 받음).""" import urllib.request from B04_PreProcess.B04_PreProcess_Engine_VWorld import VWORLD_API_KEY + from common_util.common_util_derived_store import derived_dir - _ = project_id if not (6 <= z <= 19 and 0 <= x < 2**z and 0 <= y < 2**z): raise HTTPException(status_code=400, detail="조각 번호가 범위를 벗어났습니다.") + path = ( + derived_dir(await _tile_root(project_id), TILE_KIND) + / "Satellite" + / str(z) + / f"{x}_{y}.jpeg" + ) url = f"http://api.vworld.kr/req/wmts/1.0.0/{VWORLD_API_KEY}/Satellite/{z}/{y}/{x}.jpeg" def fetch() -> bytes: + if path.is_file(): + return path.read_bytes() request = urllib.request.Request(url, headers={"User-Agent": "Aislo/1.0"}) with urllib.request.urlopen(request, timeout=8) as response: - return response.read() + tile = response.read() + path.parent.mkdir(parents=True, exist_ok=True) + part = path.with_suffix(f".{uuid4().hex}.part") + part.write_bytes(tile) + part.replace(path) # 반쯤 쓴 조각을 남기지 않음 + return tile try: tile = await asyncio.to_thread(fetch) diff --git a/resources/tester/test_586_tm_frame.py b/resources/tester/test_586_tm_frame.py index 65ea8ed8f..99010ec38 100644 --- a/resources/tester/test_586_tm_frame.py +++ b/resources/tester/test_586_tm_frame.py @@ -134,3 +134,35 @@ def test_위성_조각_대리는_범위_밖을_막음(): with pytest.raises(HTTPException) as error: asyncio.run(plan_satellite_tile(uuid.uuid4(), z, x, y)) assert error.value.status_code == 400 + + +def test_위성_조각은_서버에_기억해_다시_안_받음(tmp_path, monkeypatch): + """586-2 — 받은 조각 = 프로젝트 `_derived/plan_tile` · 두 번째는 브이월드에 안 물음.""" + import asyncio + import urllib.request + import uuid + + from D03_PlanAnalysis import D03_PlanAnalysis_Router as router + + project = uuid.uuid4() + monkeypatch.setitem(router._TILE_ROOTS, project, tmp_path) + calls = [] + + class Reply: + def __enter__(self): + return self + + def __exit__(self, *_): + return False + + def read(self): + return b"jpeg" + + monkeypatch.setattr(urllib.request, "urlopen", lambda *a, **k: calls.append(1) or Reply()) + for _ in range(2): + reply = asyncio.run(router.plan_satellite_tile(project, 14, 13990, 6380)) + assert reply.body == b"jpeg" + assert len(calls) == 1 + saved = tmp_path / "_derived" / "plan_tile" / "Satellite" / "14" / "13990_6380.jpeg" + assert saved.read_bytes() == b"jpeg" + assert not list(saved.parent.glob("*.part"))