perf(영구저장): 343-2 도엽 3D 면 열쇠 · 지표면 모델 id 유지 · 비용면 메모리 기억 · 추천 노선 열쇠 · 횡단 상세 ETag

- 도엽 3D 면 — 결과 옆 dtm_sheet_{방식}.key.json(등고선 이름:mtime:크기 · 노선 · CRS · 격자 · 여유 · 스무딩 · 화면 상자 · ALGO) 같으면 건너뜀
- 지표면 모델 — (방식 · 필터 · 파일) + 파일 지문 같으면 행 id 살려 고쳐 씀 · 이번 결과에 없는 옛 행만 지움
- 비용면 · 계류 메모리 기억(비용면 서명 · 계류 파일 지문 · 좌표계) · 추천 열쇠 = 서명(배열 통째 해시 없앰)
- 추천 저장본 종류(갈래 · 지형)당 최근 2벌 · 꺼내면 최근으로
- 횡단 상세 GET — 측점 파일 안 지움(valid_cross_names) · 본문 해시 ETag 304
- 시험 test_343_2_persist_keys 13건

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
This commit is contained in:
2026-10-16 11:10:13 +09:00
co-authored by Claude Opus 5.5
parent cf5de56418
commit 1fcdf5aa4c
9 changed files with 706 additions and 92 deletions
+26 -8
View File
@@ -1,6 +1,7 @@
"""B06 종횡단 생성 FastAPI 라우터."""
import asyncio
import hashlib
import json
import logging
from pathlib import Path
@@ -8,15 +9,15 @@ from typing import Any
from uuid import UUID
import aiomysql
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse, Response
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
from B05_Profile.B05_Profile_Engine_Sections import (
prune_stale_cross_files,
relabel_pipe_stations,
run_section_generation,
valid_cross_names,
)
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
from B06_Section.B06_Section_Engine_Attach import attach_saved_shapes
@@ -85,6 +86,7 @@ from B06_Section.B06_Section_Schema import (
)
from B06_Section.B06_Section_Server_Calc_Prebuild import conversion_factors_for, haul_limits_for
from common_util.common_util_auth import verify_session
from common_util.common_util_http_cache import CACHE_CONTROL
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
from common_util.common_util_workflow_state import get_workflow_state
@@ -266,7 +268,8 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic
longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8"))
stations = longitudinal.get("stations") if isinstance(longitudinal, dict) else None
valid_names = prune_stale_cross_files(cross_dir, stations if isinstance(stations, list) else [])
# 조회는 지우지 않음 — 잔재는 걸러 읽기만(지우기는 측점을 쓰는 쪽 몫)
valid_names = valid_cross_names(cross_dir, stations if isinstance(stations, list) else [])
cross_sections = [
json.loads(path.read_text(encoding="utf-8"))
for path in sorted(cross_dir.glob("cross_*.json"))
@@ -282,6 +285,15 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic
return {"longitudinal": longitudinal, "cross_sections": cross_sections}
def _etag_json_response(request: Request, body: bytes) -> Response:
"""본문 해시 ETag JSON 응답 — 브라우저가 같은 ETag 를 들고 오면 304 만."""
etag = f'"{hashlib.blake2b(body, digest_size=16).hexdigest()}"'
headers = {"ETag": etag, "Cache-Control": CACHE_CONTROL}
if request.headers.get("if-none-match") == etag:
return Response(status_code=304, headers=headers)
return Response(content=body, media_type="application/json", headers=headers)
def _read_balloon_offsets(row: dict[str, Any]) -> dict[str, list[float]] | None:
"""확정 시 저장해 둔 유토곡선 balloon 위치를 꺼낸다. 없거나 형태가 깨졌으면 None."""
data = row.get("data")
@@ -307,9 +319,12 @@ def _read_balloon_offsets(row: dict[str, Any]) -> dict[str, list[float]] | None:
@router.get("/{project_id}/sections/{route_id}/detail", response_model=SectionDetailResponse)
async def get_section_detail(
project_id: UUID, route_id: int
) -> SectionDetailResponse | JSONResponse:
"""경로의 SVG 렌더링용 종단·횡단 원시 샘플을 반환한다."""
project_id: UUID, route_id: int, request: Request
) -> SectionDetailResponse | JSONResponse | Response:
"""경로의 SVG 렌더링용 종단·횡단 원시 샘플을 반환한다.
ETag = 응답 본문 해시 — 내용이 같으면 304(1.5 MB 다시 안 받음 · 브라우저 HTTP 캐시).
"""
try:
# 서로 기다릴 이유가 없는 읽기 셋 — 원격 DB 라 순차로 내면 왕복이 그대로 더해진다
# (질의 하나 약 12ms, 2026-09-06 실측). 같이 보내 가장 느린 하나의 시간만 쓴다.
@@ -350,7 +365,10 @@ async def get_section_detail(
project_root,
standard,
)
return SectionDetailResponse(**detail, balloon_offsets=_read_balloon_offsets(longitudinal))
body = SectionDetailResponse(
**detail, balloon_offsets=_read_balloon_offsets(longitudinal)
).model_dump_json()
return _etag_json_response(request, body.encode("utf-8"))
except FileNotFoundError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except (OSError, ValueError, json.JSONDecodeError) as exc: