fix(388-2 보안): [확정] 면적 · 관 길이 = 서버 셈만 · 재계산 실패 경고

- 화면 [저장] · [확정]이 면적 · 관 길이 9칸을 안 보냄
- 서버가 받아도 _AREA_KEYS 걷음(옛 화면 · 위조 값 정본에 안 남음)
- 서버 재계산 실패(상세 못 받음 · Node 실패)를 삼키지 않음 → 응답 recomputed:false
- 화면 경고 한 줄(저장은 됨 · 옛 서버 값 남음 · 다시 [저장])
- 새 test_384_s_area_keys · 옛 시험 셋 새 동작에 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
This commit is contained in:
2026-10-17 17:21:38 +09:00
co-authored by Claude Opus 5.5
parent 1b327b3613
commit 23c6645839
12 changed files with 191 additions and 55 deletions
+25 -8
View File
@@ -19,6 +19,7 @@
import { clearState, readState, stateKey, writeState } from "../A00_Common/b_page_state";
import { seedPageHistory } from "../B05_Profile/B05_Profile_UI_Profile_History";
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
import { t as L } from "@ui/ui_template_locale";
import type {
CrossDesign,
CrossDesignRequest,
@@ -184,10 +185,24 @@ export async function confirmSections(
if (standardCrossSection) body.standard_cross_section = standardCrossSection;
if (crossPatches?.length) body.cross_patches = crossPatches;
if (massHaul) body.mass_haul = massHaul;
return requestJson<SectionConfirmResponse>(`/projects/${projectId}/sections/${routeId}/confirm`, {
method: "POST",
body: JSON.stringify(body),
});
return warnUnrecomputed(
requestJson<SectionConfirmResponse>(`/projects/${projectId}/sections/${routeId}/confirm`, {
method: "POST",
body: JSON.stringify(body),
}),
);
}
/** 서버 정본 셈이 실패했으면 경고 한 줄(388-2) — 저장은 남음 · 면적 · 관 길이는 옛 서버 값. */
async function warnUnrecomputed(
request: Promise<SectionConfirmResponse>,
): Promise<SectionConfirmResponse> {
const result = await request;
if (result.recomputed === false)
void import("@ui/ui_template_elements").then(({ showToast }) =>
showToast(L("B06_Recompute_Failed"), "warning", 8000),
);
return result;
}
/**
@@ -205,10 +220,12 @@ export async function saveSections(
if (standardCrossSection) body.standard_cross_section = standardCrossSection;
if (crossPatches?.length) body.cross_patches = crossPatches;
if (massHaul) body.mass_haul = massHaul;
return requestJson<SectionConfirmResponse>(`/projects/${projectId}/sections/${routeId}/save`, {
method: "POST",
body: JSON.stringify(body),
});
return warnUnrecomputed(
requestJson<SectionConfirmResponse>(`/projects/${projectId}/sections/${routeId}/save`, {
method: "POST",
body: JSON.stringify(body),
}),
);
}
/** 같은 회사에서 표준횡단 설계값을 불러올 수 있는 프로젝트 항목. */
+2
View File
@@ -358,6 +358,8 @@ export interface SectionConfirmResponse {
project_id: string;
route_id: number;
confirmed: boolean;
/** 서버 정본 셈(면적 · 관 길이 · 유토곡선)이 됐나 — false 면 경고(388-2). */
recomputed?: boolean;
}
/** 측점 표준횡단 설계 지정값 (버튼 상태). */
+14 -7
View File
@@ -46,6 +46,7 @@ from B06_Section.B06_Section_Schema import (
SectionConfirmRequest,
SectionConfirmResponse,
)
from B06_Section.B06_Section_Server_Calc_Prebuild import _AREA_KEYS
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_workflow_state import complete_stage, start_stage
from config.config_db import get_db_pool
@@ -147,10 +148,11 @@ async def _apply_section_edits(
dumped = patch_item.model_dump()
if dumped.pop("structure_reset", None):
resets.append((patch_item.chainage_m, dict.fromkeys(STRUCTURE_ADJUST_KEYS)))
# 면적 · 관 길이(`_AREA_KEYS`)는 서버 셈만 — 브라우저가 보내도 안 받음(388-2)
patch: dict[str, Any] = {
key: value
for key, value in dumped.items()
if key != "chainage_m" and value is not None
if key != "chainage_m" and key not in _AREA_KEYS and value is not None
}
if patch:
patches.append((patch_item.chainage_m, patch))
@@ -160,7 +162,7 @@ async def _apply_section_edits(
)
async def _recompute_stored_designs(project_id: UUID, route_id: int) -> None:
async def _recompute_stored_designs(project_id: UUID, route_id: int) -> bool:
"""정본을 **서버가 다시 계산**한다 — 사용자 편집이 들어간 **뒤**에 돈다.
포장 구간·세월교 노면 하강 보정에 더해 구조물 면적·유토곡선(배분·운반거리 포함)까지
@@ -172,18 +174,21 @@ async def _recompute_stored_designs(project_id: UUID, route_id: int) -> None:
850ms → 약 980ms 인데(Node 131ms) 기다리는 조작이라 허용한다.
사용자가 끌어 옮긴 balloon 위치는 서버가 만들지 않으므로 저장분에서 떼어 도로 붙인다
(`B06_Section_Server_Calc_Prebuild`). 실패는 비치명적이다 — 저장은 그대로 남는다.
(`B06_Section_Server_Calc_Prebuild`). 실패해도 저장은 남음 — False 를 돌려
응답 `recomputed: false` 로 화면에 알림(388-2).
"""
try:
from B06_Section.B06_Section_Server_Calc_Prebuild import recompute_server_side
await recompute_server_side(project_id, route_id)
return True
except Exception:
logger.exception(
"저장분 서버 재계산 실패 (저장은 유지): project_id=%s route_id=%s",
project_id,
route_id,
)
return False
def _prune_cross_files(project_root: Path, longitudinal_file_path: str) -> None:
@@ -304,10 +309,10 @@ async def save_sections(
await connection.rollback()
raise
# 편집이 들어간 **뒤** 서버가 정본을 다시 낸다 — 바뀐 벽·측점이 면적·유토곡선에 실린다.
await _recompute_stored_designs(project_id, route_id)
recomputed = await _recompute_stored_designs(project_id, route_id)
await _prebuild_corridor(project_id, route_id, project_root)
return SectionConfirmResponse(
project_id=str(project_id), route_id=route_id, confirmed=False
project_id=str(project_id), route_id=route_id, confirmed=False, recomputed=recomputed
)
except Exception:
logger.exception(
@@ -407,7 +412,7 @@ async def confirm_sections(
marks.append(("확정 저장(트랜잭션)", time.perf_counter()))
# 편집이 들어간 **뒤** 서버가 정본을 다시 낸다(임시저장과 같은 자리).
await _recompute_stored_designs(project_id, route_id)
recomputed = await _recompute_stored_designs(project_id, route_id)
marks.append(("서버 재계산", time.perf_counter()))
# 측구 방향(design.ditch_side) 변경을 B05 종단 정본 stations.uphill_side에 역반영한다(E-7).
@@ -439,7 +444,9 @@ async def confirm_sections(
await _prebuild_corridor(project_id, route_id, project_root)
marks.append(("3D 길 모형 서버 짓기", time.perf_counter()))
_log_steps("B06 확정", marks)
return SectionConfirmResponse(project_id=str(project_id), route_id=route_id)
return SectionConfirmResponse(
project_id=str(project_id), route_id=route_id, recomputed=recomputed
)
except Exception:
logger.exception("B06 종횡단 확정 실패: project_id=%s", project_id)
return JSONResponse(
+4 -3
View File
@@ -234,9 +234,8 @@ class CrossSectionPatch(BaseModel):
# 측구를 둘지 **사용자 선택**(결과 `ditch_enabled` 와 다른 칸, 2026-09-09). None 은
# 「안 보냄」이라 병합에서 걷힌다 — 「자동」을 patch 로 되돌릴 길은 아직 없다.
ditch_choice: bool | None = None
# 구조물이 선 측점의 폐회로 절·성토 면적(㎡) — 브라우저가 계산해 보낸다
# (2026-09-06 사용자 확정: 「일단은 브라우저 계산으로」). 서버는 초기값을 만들 때만
# 같은 코드를 Node 로 돌린다(`B06_Section_Server_Calc_Node.ts`).
# 구조물이 선 측점의 폐회로 절·성토 면적(㎡) · 아래 관 길이 묶음 — 옛 화면 호환으로 받기만 하고
# 저장에서 걷음(388-2 · `_AREA_KEYS`) · 정본은 서버가 Node 로 셈(`B06_Section_Server_Calc_Node.ts`).
cut_area_m2: float | None = Field(default=None, ge=0)
fill_area_m2: float | None = Field(default=None, ge=0)
cut_soil_area_m2: float | None = Field(default=None, ge=0)
@@ -283,6 +282,8 @@ class SectionConfirmResponse(BaseModel):
project_id: str
route_id: int
confirmed: bool = True
# 서버 정본 셈(면적 · 관 길이 · 유토곡선)이 됐나 — False 면 화면이 경고 한 줄(388-2)
recomputed: bool = True
class SectionOptionDefaults(BaseModel):
@@ -17,7 +17,7 @@
으로 감싸 그대로 돌린다. 파이썬으로 포팅하면 같은 기하가 두 벌이 되어 「그림은 이런데
수량은 저렇다」가 생긴다.
실패는 비치명적이다 — 보정 전(표준) 값이 그대로 남고 화면은 예전처럼 스스로 고친다.
실패는 던진다 — 부르는 쪽([저장] · [확정])이 `recomputed: false` 로 화면에 알린다(388-2).
"""
from __future__ import annotations
@@ -258,9 +258,8 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
)
marks.append(("종횡단 상세+DB 조회(병렬)", time.perf_counter()))
payload = getattr(response, "model_dump", None)
if payload is None: # JSONResponse = 실패
logger.warning("서버 재계산: 종횡단 상세를 못 받음 (route_id=%s)", route_id)
return 0
if payload is None: # JSONResponse = 실패 — 삼키지 않음(388-2)
raise RuntimeError(f"서버 재계산: 종횡단 상세를 못 받음 (route_id={route_id})")
detail = payload(mode="json")
sections = detail.get("cross_sections") or []
project_root = Path(resolve_stored_project_path(stored_path))
@@ -307,8 +306,8 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
},
)
marks.append(("Node 번들(면적·유토곡선)", time.perf_counter()))
if not isinstance(output, dict):
output = {}
if not isinstance(output, dict): # Node 실패 — 삼키지 않음(388-2)
raise RuntimeError(f"서버 재계산: Node 면적 · 유토곡선 실패 (route_id={route_id})")
rows = output.get("areas")
mass_haul = output.get("mass_haul")
# 선 다단 벽 목록(④) — 목록째 얹음(수가 아니라 `_AREA_KEYS` 로는 못 거름). 주인 측점엔 빈 목록도.
+2 -26
View File
@@ -32,11 +32,6 @@ import {
BERM_DEFAULT_SLOPE_DEG,
BERM_DEFAULT_WIDTH_M,
} from "@util/common_util_cross_berm";
import {
applyStructureAreaRows,
STRUCTURE_ROW_KEYS,
structureAreaRows,
} from "./B06_Section_Structure_Layouts";
import {
crossDesignChoices,
readBermDepths,
@@ -429,10 +424,7 @@ export function collectSectionEdits(ctx: SectionPersistContext): {
massHaul: Record<string, unknown> | undefined;
} {
const crossPatches = buildCrossPatches(ctx.patchSources());
const detail = ctx.detail();
// 구조물 폐회로 면적 — **카드를 그리지 않은 측점까지** 여기서 다 계산해 싣는다
// (2026-09-06 사용자 확정: 「일단은 브라우저 계산으로」). 카드 그리기가 고쳐 둔 값에
// 기대면 화면에 안 뜬 측점이 표준값으로 남는다.
// 측점별 patch — 없는 측점은 새로 만듦
const byChainage = new Map(crossPatches.map((patch) => [patch.chainage_m, patch]));
const patchFor = (chainageM: number): CrossSectionPatch => {
const existing = byChainage.get(chainageM);
@@ -486,23 +478,7 @@ export function collectSectionEdits(ctx: SectionPersistContext): {
});
// 관 종류를 바꾼 측점(318-4) — 서버가 옛 종류 조정값을 먼저 비우고 이 patch 값만 얹음
for (const at of kindChangedChainages(ctx.projectId)) patchFor(at).structure_reset = true;
if (detail) {
// 구조물 폐회로 면적 — **카드를 그리지 않은 측점까지** 여기서 다 계산한다
// (2026-09-06 사용자 확정: 「일단은 브라우저 계산으로」).
applyStructureAreaRows(detail.cross_sections, structureAreaRows(detail.cross_sections));
// 면적은 **전 측점**을 싣는다 — 브라우저가 만든 값이 곧 작업본이다. 카드 버튼을
// 바꾸면 구조물이 없는 측점의 면적도 달라지므로 구조물 측점만 보내면 수량이 어긋난다.
for (const section of detail.cross_sections) {
const design = section.design as Record<string, unknown> | undefined;
if (!design) continue;
let patch: CrossSectionPatch | null = null;
for (const key of STRUCTURE_ROW_KEYS) {
if (typeof design[key] !== "number") continue;
patch = patch ?? patchFor(section.chainage_m);
patch[key] = design[key] as number;
}
}
}
// 면적 · 관 길이는 안 보냄 — 서버가 [저장] · [확정] 때 정본으로 다시 셈(388-2)
// 유토곡선 **정본은 서버가 낸다**(2026-09-06 사용자 확정) — 저장 뒤 `recompute_server_side`
// 가 Node 로 다시 계산해 덮어쓴다. 그래서 여기서는 곡선도 배분도 만들지 않는다.
// 서버가 만들 수 없는 것 하나만 보낸다: 사용자가 끌어 옮긴 balloon 위치(화면값).
+125
View File
@@ -0,0 +1,125 @@
"""388-2 [저장] · [확정] 면적 · 관 길이 = 서버 셈만(요구 242 · 분석 1장 S3).
- 브라우저가 면적 · 관 길이 9칸(`_AREA_KEYS`)을 보내도 정본에 안 적음 · 다른 칸은 그대로
- 서버 재계산 실패를 삼키지 않음 — 상세 못 받음 · Node 실패 = 던짐 → 응답 `recomputed: false`
- 화면은 면적 · 관 길이를 안 보냄 · `recomputed: false` 면 경고 한 줄
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from types import SimpleNamespace
from uuid import uuid4
import pytest
import B06_Section.B06_Section_Router_Confirm as confirm
import B06_Section.B06_Section_Server_Calc_Prebuild as prebuild
from B06_Section.B06_Section_Schema import SectionConfirmRequest, SectionConfirmResponse
ROOT = Path(__file__).resolve().parents[2]
def test_면적_보내도_정본에_안_적음(monkeypatch):
written: list = []
async def merge(connection, *, route_id, entries, replace, project_id=None):
written.append((replace, entries))
return 0
monkeypatch.setattr(confirm, "merge_cross_section_designs", merge)
forged = {key: 999.0 for key in prebuild._AREA_KEYS}
request = SectionConfirmRequest(
cross_patches=[{"chainage_m": 20.0, "ground_type": "rock", **forged}]
)
asyncio.run(confirm._apply_section_edits(None, 3, request, []))
patches = written[-1][1]
assert patches == [(20.0, {"ground_type": "rock"})]
def test_면적만_보낸_측점은_patch_없음(monkeypatch):
written: list = []
async def merge(connection, *, route_id, entries, replace, project_id=None):
written.append(entries)
return 0
monkeypatch.setattr(confirm, "merge_cross_section_designs", merge)
request = SectionConfirmRequest(cross_patches=[{"chainage_m": 40.0, "cut_area_m2": 12.5}])
asyncio.run(confirm._apply_section_edits(None, 3, request, []))
assert written[-1] == []
def test_재계산_실패는_False(monkeypatch):
async def boom(project_id, route_id):
raise RuntimeError("Node 실패")
async def ok(project_id, route_id):
return 1
monkeypatch.setattr(prebuild, "recompute_server_side", boom)
assert asyncio.run(confirm._recompute_stored_designs(uuid4(), 3)) is False
monkeypatch.setattr(prebuild, "recompute_server_side", ok)
assert asyncio.run(confirm._recompute_stored_designs(uuid4(), 3)) is True
def test_상세_못_받으면_던짐(monkeypatch):
import B06_Section.B06_Section_Router as b06_router
async def no_detail(project_id, route_id):
return SimpleNamespace() # model_dump 없음 = JSONResponse 실패
async def run_with(fn, *args):
return None
async def nothing(project_id):
return {}
monkeypatch.setattr(b06_router, "get_section_detail", no_detail)
monkeypatch.setattr(prebuild, "run_with_connection", run_with)
monkeypatch.setattr(prebuild, "haul_inputs_for", nothing)
with pytest.raises(RuntimeError):
asyncio.run(prebuild.recompute_server_side(uuid4(), 3))
def test_Node_실패면_던짐(monkeypatch, tmp_path):
import B06_Section.B06_Section_Router as b06_router
import B06_Section.B06_Section_Router_Design as b06_design
detail = {"cross_sections": [{"chainage_m": 0.0, "design": {}}], "longitudinal": {}}
async def got(project_id, route_id):
return SimpleNamespace(model_dump=lambda mode: detail)
async def run_with(fn, *args):
return "p" if fn is prebuild.get_project_storage_relative_path else {}
async def nothing(project_id):
return {}
monkeypatch.setattr(b06_router, "get_section_detail", got)
monkeypatch.setattr(prebuild, "run_with_connection", run_with)
monkeypatch.setattr(prebuild, "haul_inputs_for", nothing)
monkeypatch.setattr(prebuild, "resolve_stored_project_path", lambda path: str(tmp_path))
monkeypatch.setattr(b06_design, "enforce_design_lines", lambda *a: None)
monkeypatch.setattr(prebuild, "_enforce_stored_designs", lambda *a: None)
monkeypatch.setattr(prebuild, "quantity_settings", lambda root: {})
monkeypatch.setattr(prebuild, "mixed_conversion_factors", lambda settings: {})
monkeypatch.setattr(prebuild, "haul_equipment_limits", lambda settings: [])
monkeypatch.setattr(prebuild, "_node_output", lambda node_input: None)
with pytest.raises(RuntimeError):
asyncio.run(prebuild.recompute_server_side(uuid4(), 3))
def test_응답에_recomputed():
assert SectionConfirmResponse(project_id="p", route_id=1).recomputed is True
assert SectionConfirmResponse(project_id="p", route_id=1, recomputed=False).recomputed is False
def test_화면은_면적_안_보내고_경고():
persist = (ROOT / "B06_Section/B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
assert "STRUCTURE_ROW_KEYS" not in persist
api = (ROOT / "B06_Section/B06_Section_Api_Fetch.ts").read_text(encoding="utf-8")
assert api.count("return warnUnrecomputed(") == 2
assert "result.recomputed === false" in api
+2 -1
View File
@@ -63,8 +63,9 @@ def _wire(monkeypatch: pytest.MonkeyPatch, latest: int) -> list[str]:
async def _edits(*_: Any, **__: Any) -> None:
called.append("edits")
async def _recompute(*_: Any, **__: Any) -> None:
async def _recompute(*_: Any, **__: Any) -> bool:
called.append("recompute")
return True # 388-2 — 재계산 됐나(응답 recomputed)
monkeypatch.setattr(confirm, "get_db_pool", lambda: _Pool())
monkeypatch.setattr(
@@ -99,7 +99,7 @@ def api(tmp_path, monkeypatch):
monkeypatch.setattr(sections, "confirm_route_status", rec.fake("confirm_route"))
monkeypatch.setattr(sections, "complete_stage", rec.fake("complete_stage"))
monkeypatch.setattr(sections, "start_stage", rec.fake("start_stage"))
monkeypatch.setattr(sections, "_recompute_stored_designs", rec.fake("recompute"))
monkeypatch.setattr(sections, "_recompute_stored_designs", rec.fake("recompute", True))
# 272 — 측점을 다시 맞춘 뒤 정본에 없는 측점의 횡단 행 지우기
monkeypatch.setattr(route_confirm, "sync_cross_section_rows", rec.fake("sync_rows", 0))
monkeypatch.setattr(
+2 -2
View File
@@ -45,7 +45,8 @@ def test_같은_길로_정본에_실린다():
block = source[source.index("STRUCTURE_ROW_KEYS = [") :][:300]
assert '"pipe_length_m"' in block, "관 길이가 정본에 얹는 키 목록에 없다"
assert '"cut_area_m2"' in block, "면적과 같은 목록이어야 한다(길이 하나만 따로 나르지 않는다)"
assert "STRUCTURE_ROW_KEYS" in _PERSIST.read_text(encoding="utf-8")
# 388-2 — 화면 [저장]은 면적 · 관 길이를 안 보냄(서버 셈만)
assert "STRUCTURE_ROW_KEYS" not in _PERSIST.read_text(encoding="utf-8")
def test_면적을_못_내는_측점에서도_길이는_낸다():
@@ -65,7 +66,6 @@ def test_이름이_한_벌이다():
assert "pipe_length_m" in path.read_text(encoding="utf-8"), path.name
persist = _PERSIST.read_text(encoding="utf-8")
assert "pipe_length_m" not in persist, "이름을 또 적으면 갈릴 자리가 하나 더 생긴다"
assert "STRUCTURE_ROW_KEYS" in persist
def test_서버_화이트리스트가_TS_목록과_짝이다():
@@ -78,7 +78,7 @@ def _run_save(monkeypatch: pytest.MonkeyPatch, missing: list[float]) -> list[flo
monkeypatch.setattr(confirm, "_compute_default_designs", _spy)
monkeypatch.setattr(confirm, "_apply_section_edits", _async(None))
monkeypatch.setattr(confirm, "_recompute_stored_designs", _async(None))
monkeypatch.setattr(confirm, "_recompute_stored_designs", _async(True)) # 388-2 recomputed
from uuid import UUID
+8
View File
@@ -114,4 +114,12 @@ export const ui_locales_b4 = {
"Unsaved edits in B05 · B06 — save there first, then link.",
],
B05_Design_Leave: ["B05 · B06 에 저장 안 한 조작이 있음", "Unsaved edits in B05 · B06"],
B06_Recompute_Failed: [
"저장은 됨 — 서버 면적 · 관 길이 다시 셈 실패(옛 값 남음) · 다시 [저장]",
"Saved — server area · pipe length recompute failed (old values kept) · save again",
],
Session_Storage_Full: [
"저장 공간 부족 — [저장]을 눌러 고친 것을 서버에 남기세요",
"Browser storage full — press [Save] to keep your edits on the server",
],
} as const;