From 99db748a7b2392703c21a20839a0902b342ebc2d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 29 Oct 2026 20:03:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(D03):=20=EB=85=B8=EC=84=A0=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EC=9C=84=EC=84=B1=20=EC=A1=B0=EA=B0=81=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EA=B8=B0=EC=96=B5=20=E2=80=94=20=EB=B0=9B=EC=9D=80?= =?UTF-8?q?=20=EC=A1=B0=EA=B0=81=20=5Fderived/plan=5Ftile=20=EC=97=90=20?= =?UTF-8?q?=EB=91=90=EA=B3=A0=20=EB=8B=A4=EC=8B=9C=20=EC=95=88=20=EB=B0=9B?= =?UTF-8?q?=EC=9D=8C=20(PLAN=20586-2=20=E2=91=A3=20=C2=B7=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20448)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET …/plan/tile/{z}/{x}/{y} = 프로젝트 _derived/plan_tile/Satellite/{z}/{x}_{y}.jpeg 먼저 · 없으면 브이월드에서 받아 원자 쓰기 - 프로젝트 실경로 기억(조각마다 DB 안 부름) - 시험: test_586_tm_frame 두 번 받기 = 브이월드 한 번 · 반쯤 쓴 조각 없음 Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_Router.py | 41 ++++++++++++++++++--- resources/tester/test_586_tm_frame.py | 32 ++++++++++++++++ 2 files changed, 68 insertions(+), 5 deletions(-) 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"))