fix(B05): 크로스체크 5건 반영 — 마이그레이션 문자열 판별·서버 검증·기본값 원칙·404·STALE 정합
외부 AI 교차검증 미통과 지적을 전부 수정한다. 1. 마이그레이션: 확정 저장분은 structure 문자열뿐(structureType 없음) — 라벨 파싱 판별 추가(기성막이/대피로 X.Xm/관종 D직경). 명시 필드가 라벨 파싱보다 우선. 2. 서버 검증 강화(_validate_types 확장): 레지스트리 배치형태 대조, 미정의 옵션 거절, number 옵션 유한·0 이상, select 선택지 검사, required 옵션 누락 거절, structure_id 중복 거절, 노선 연장 범위 검증(라우터가 get_latest_route로 총연장 주입, 없으면 생략). 3. 기본값 원칙: 법정 명시값(별표2 측구 30cm·대피소 5/15m 등)·사용자 기확정값(골막이)만 default 유지. 옹벽·돌쌓기 높이, 사토장·토취장 면적/용량, 포장·쇄석 두께 등 미확정 수치는 default 제거 + required (화면 placeholder "필수 입력"+빈 값 추가 차단, 서버도 거절). 4. 404: get_project_storage_relative_path는 없는 프로젝트에서 LookupError를 던짐 — _project_root에서 잡아 None, 라우터 예외 사다리에도 LookupError→404 분기. 5. STALE 정합: _invalidate_downstream이 성공 여부 반환 — invalidated_downstream은 실제 전파 성공 시에만 true. pytest 42건(tmp/tests) 통과 · tsc 0 · ruff 통과. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Repository import get_latest_route
|
||||
from B05_Profile.B05_Profile_Structures_Repository import (
|
||||
StructureRevisionConflict,
|
||||
load_structures,
|
||||
@@ -56,19 +57,41 @@ async def read_structure_types() -> StructureTypesResponse:
|
||||
|
||||
|
||||
async def _project_root(project_id: UUID) -> str | None:
|
||||
"""프로젝트 저장 경로 — 없는 프로젝트는 None (호출부가 404로 답한다).
|
||||
|
||||
`get_project_storage_relative_path`는 없는 프로젝트에서 LookupError를 **던진다**
|
||||
(None 반환이 아님) — 잡지 않으면 500으로 샌다(2026-08-16 크로스체크 지적 5).
|
||||
"""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
except LookupError:
|
||||
return None
|
||||
if not stored_path:
|
||||
return None
|
||||
return str(Path(resolve_stored_project_path(stored_path)))
|
||||
|
||||
|
||||
async def _invalidate_downstream(project_id: UUID) -> None:
|
||||
async def _route_length(project_id: UUID) -> float | None:
|
||||
"""최신 노선 총연장(m). 노선이 없거나 조회 실패면 None — 범위 검증만 생략된다."""
|
||||
try:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
latest = await get_latest_route(connection, project_id)
|
||||
length = latest.get("total_length_m") if latest else None
|
||||
return float(length) if length else None
|
||||
except Exception:
|
||||
logger.exception("B05 노선 연장 조회 실패: project_id=%s", project_id)
|
||||
return None
|
||||
|
||||
|
||||
async def _invalidate_downstream(project_id: UUID) -> bool:
|
||||
"""구조물이 바뀌었으니 B06(stage 3) 이후의 완료 단계를 STALE로 되돌린다.
|
||||
|
||||
실패해도 저장은 이미 끝났다 — 무효화를 못 했다고 저장을 되돌리면 정본과 화면이
|
||||
어긋난다. 로그만 남기고 넘어간다.
|
||||
어긋난다. 대신 성공 여부를 돌려줘 응답이 사실만 말하게 한다(성공한 척 금지 —
|
||||
2026-08-16 크로스체크 지적 5).
|
||||
"""
|
||||
try:
|
||||
pool = get_db_pool()
|
||||
@@ -83,8 +106,10 @@ async def _invalidate_downstream(project_id: UUID) -> None:
|
||||
(str(project_id), ROUTE_STAGE_NO),
|
||||
)
|
||||
await connection.commit()
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("B05 구조물 변경 후속 단계 무효화 실패: project_id=%s", project_id)
|
||||
return False
|
||||
|
||||
|
||||
@router.get("/{project_id}/route/structures", response_model=StructureListResponse)
|
||||
@@ -98,6 +123,9 @@ async def read_structures(project_id: UUID) -> StructureListResponse | JSONRespo
|
||||
return StructureListResponse(
|
||||
project_id=str(project_id), revision=revision, structures=structures
|
||||
)
|
||||
except LookupError:
|
||||
# 저장 경로 조회가 예외로 알려온 "프로젝트 없음" — 500이 아니라 404다.
|
||||
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
|
||||
except Exception:
|
||||
logger.exception("B05 구조물 조회 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
@@ -116,18 +144,25 @@ async def write_structures(
|
||||
if root is None:
|
||||
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
|
||||
_, previous = load_structures(root)
|
||||
revision = save_structures(root, payload.structures, base_revision=payload.base_revision)
|
||||
revision = save_structures(
|
||||
root,
|
||||
payload.structures,
|
||||
base_revision=payload.base_revision,
|
||||
max_chainage_m=await _route_length(project_id),
|
||||
)
|
||||
# 설계에 영향을 주는 변경일 때만 B06 이후를 STALE로 돌린다 — 메모만 고쳐도
|
||||
# 횡단·수량을 다시 돌리게 만들지 않기 위함이다.
|
||||
invalidated = requires_downstream_invalidation(previous, payload.structures)
|
||||
if invalidated:
|
||||
await _invalidate_downstream(project_id)
|
||||
# 횡단·수량을 다시 돌리게 만들지 않기 위함이다. 응답 플래그는 실제로 STALE
|
||||
# 전파가 **성공했을 때만** true(실패를 성공처럼 알리지 않는다).
|
||||
needs_invalidation = requires_downstream_invalidation(previous, payload.structures)
|
||||
invalidated = needs_invalidation and await _invalidate_downstream(project_id)
|
||||
return StructureSaveResponse(
|
||||
project_id=str(project_id),
|
||||
revision=revision,
|
||||
count=len(payload.structures),
|
||||
invalidated_downstream=invalidated,
|
||||
)
|
||||
except LookupError:
|
||||
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
|
||||
except StructureRevisionConflict as conflict:
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
|
||||
Reference in New Issue
Block a user