feat(D03): 노선분석 위성 조각 서버 기억 — 받은 조각 _derived/plan_tile 에 두고 다시 안 받음 (PLAN 586-2 ④ · 요구 448)
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user