Files
Aislo/common_util/common_util_storage.py
eomsangdonandClaude Opus 5 b1593bce83 feat(B07): 표제란에 로고·서명 자리 신설 + 시행청·담당자 DB 확장
사용자 지시(2026-09-02) — 로고·서명을 임의로 만들어 표제란에 넣고, 위치·크기는
도각을 고치면 따라오게 하며, 시행청·과업책임자 등은 DB를 넓혀 채울 것.

도각(표제란)
- `00_template_A1.json` 에 그림 자리 2개 추가 — 회사 로고는 용역회사 칸 왼쪽
  (316~348 × 20~36mm), 설계자 서명은 설계 칸 아래(638~680 × 17.5~25.5mm).
  자리·크기가 **템플릿 좌표로만** 정해지므로 도각을 고치면 그대로 따라옴.
- `_fill_placeholders` 가 그림 자리(`imageData`)의 `{{키}}` 도 채움. 값을 못 구하면
  빈 문자열을 남기지 않고 **엔티티째 제거** — 빈 값은 CAD 가 깨진 그림으로 그림.
- `_transform_entity` 가 `points` 배열도 옮김. 그림(로고·서명)과 띠(Hatch)가 이 키를
  쓰는데 여태 변환 대상이 아니라 도각을 옮기면 제자리에 남았음.

DB (`011_title_block.sql`, 전부 ADD COLUMN·NULL 허용)
- `projects` — `client_org`(시행청), `pm_user_id`·`field_lead_user_id`·`designer_user_id`
- `companies.logo_path` · `users.signature_path` — 그림은 파일로 두고 경로만 담음
  (기존 `storage_path`·`input_files` 와 같은 방식)
- FK 는 걸지 않음 — 사유를 파일 머리말에 적음(소프트 삭제·LEFT JOIN·4환경 공유).

배선
- `_title_block_fields` 가 시행청·과업책임자·분야별책임자·설계자(배정 없으면 소유자)와
  로고·서명 data URL 까지 실어 보냄.
- `read_stored_asset()` 신설 — `storage/` 기준 상대 경로의 **파일**을 읽음.
  `resolve_stored_project_path()` 는 폴더를 만드는 프로젝트 루트용이라 파일에 못 씀.

CAD 결함 1건 (이번 작업에서 드러남)
- `screenCanvas.drawController.drawImage` 가 `(width, height)` 를 좌표처럼 변환해
  화면 오프셋과 y 뒤집기가 섞여 들어갔음 — 그림이 제 자리를 벗어나고 비율이 무너짐.
  크기는 배율만 곱하고 자리는 세계 중심으로 잡도록 고침(SVG 컨트롤러와 같은 방식).
- 검증 창구 `__aisloCad.screen(x, y)` 추가 — 세계→화면 좌표. 그림·글자가 제 자리에
  그려졌는지 픽셀로 판정할 때 씀.

검증: `pytest tmp/tests/ -q` 135 passed / 0 failed(그림 자리 3건 신규),
`npx vitest run` 87 passed / 0 failed, `check-types`·`build` 통과.
화면 실측(5174, wdw): 표준도 API Text 24개 `{{` 잔존 0, Image 2개가 도각 좌표
(316,20)-(348,36)·(638,18)-(680,26)에 실림. 캔버스 픽셀 판정 — 두 자리 모두 배경색
외 픽셀이 그려짐(로고 88px·서명 64px), 로고 파랑(20,70,140)이 슬롯 안에서만 검출.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 08:35:25 +09:00

148 lines
7.2 KiB
Python

"""프로젝트 영구저장소 경로 유틸리티."""
import os
from pathlib import PurePosixPath
from config.config_system import (
PROJECT_STORAGE_STAGE_DIRS,
STORAGE_BASE_DIR,
TEMP_UPLOAD_DIR_NAME,
)
PROJECT_STORAGE_LAYOUT_V2 = (
("B03_FileInput", "input"),
("B04_PreProcess", "processed"),
("B04_PreProcess", "models"),
("B05_Profile", "route"),
("B06_Section", "longitudinal"),
("B06_Section", "cross_sections"),
("B07_DesignDetail", "structures"),
("B08_Quantity", "quantities"),
("B09_Estimation", "v1"),
)
def get_project_stage_path(project_root: str, stage: str) -> str:
"""프로젝트 루트 아래의 허용된 워크플로우 단계 폴더를 생성한다."""
if stage not in PROJECT_STORAGE_STAGE_DIRS:
raise ValueError(f"허용되지 않은 프로젝트 저장 단계입니다: {stage}")
root = os.path.abspath(project_root)
path = os.path.abspath(os.path.join(root, stage))
if os.path.commonpath((root, path)) != root:
raise ValueError("단계 저장 경로가 프로젝트 루트를 벗어났습니다.")
os.makedirs(path, exist_ok=True)
return path
def ensure_project_storage_layout(project_root: str) -> None:
"""신규 워크플로우 저장소 하위 폴더를 누락분만 생성한다."""
root = os.path.abspath(project_root)
for stage, subdir in PROJECT_STORAGE_LAYOUT_V2:
if stage not in PROJECT_STORAGE_STAGE_DIRS:
raise ValueError(f"허용되지 않은 프로젝트 저장 단계입니다: {stage}")
path = os.path.abspath(os.path.join(root, stage, subdir))
if os.path.commonpath((root, path)) != root:
raise ValueError("단계 저장 경로가 프로젝트 루트를 벗어났습니다.")
os.makedirs(path, exist_ok=True)
def resolve_temp_batch_path(user_id: int, batch_id: str, *, create: bool = True) -> str:
"""임시 보관함 묶음 폴더(`storage/tmp/{user_id}/{batch_id}`)를 돌려준다.
내부 구조는 프로젝트 저장소와 똑같이 `B03_FileInput/input/...`을 쓴다 — 그래야 청크
저장·병합 엔진(`resolve_upload_destination`, `resolve_chunk_session_dir`)을 그대로
재사용할 수 있고, 나중에 프로젝트로 옮길 때도 같은 상대 경로로 이어 붙기만 하면 된다.
"""
if not str(user_id).isdigit():
raise ValueError("임시 보관함 사용자 식별자가 올바르지 않습니다.")
if not batch_id or any(sep in batch_id for sep in ("/", "\\", "..")):
raise ValueError("임시 보관함 묶음 식별자가 올바르지 않습니다.")
# 실경로로 맞춘다 — 저장 엔진이 `Path.resolve()`를 쓰므로 기준이 같아야 한다.
temp_root = os.path.realpath(os.path.join(STORAGE_BASE_DIR, TEMP_UPLOAD_DIR_NAME))
path = os.path.realpath(os.path.join(temp_root, str(user_id), batch_id))
if os.path.commonpath((temp_root, path)) != temp_root or path == temp_root:
raise ValueError("임시 보관함 경로가 보관함 루트를 벗어났습니다.")
if create:
os.makedirs(path, exist_ok=True)
return path
def temp_upload_root() -> str:
"""임시 보관함 루트(`storage/tmp`) 실경로."""
return os.path.realpath(os.path.join(STORAGE_BASE_DIR, TEMP_UPLOAD_DIR_NAME))
def resolve_project_root_for_delete(relative_path: str, project_id: str) -> str:
"""삭제용 프로젝트 루트를 해석한다 — 폴더를 만들지 않는다.
`resolve_stored_project_path()`는 끝에서 `makedirs`와 레이아웃 생성을 한다. 지우기
직전에 그걸 쓰면 폴더를 되살려 놓고 지우는 꼴이 된다. 그래서 검증만 하는 짝을 둔다.
DB의 `storage_path`가 오염돼도 남의 폴더를 지우지 못하게 네 겹으로 막는다:
저장소 루트 안, 세그먼트 정확히 4개, 마지막 세그먼트가 요청받은 프로젝트 ID와 일치.
"""
normalized = PurePosixPath(relative_path.replace("\\", "/"))
if normalized.is_absolute() or ".." in normalized.parts:
raise ValueError("프로젝트 저장 경로는 안전한 상대 경로여야 합니다.")
# storage/{회사}/{사용자}/{프로젝트ID} — 상위 폴더를 통째로 지우는 사고를 막는다.
if len(normalized.parts) != 4 or normalized.parts[0] != "storage":
raise ValueError("삭제 대상 경로는 storage/회사/사용자/프로젝트ID 형태여야 합니다.")
if normalized.parts[3] != str(project_id):
raise ValueError("저장 경로의 프로젝트 ID가 삭제 요청과 일치하지 않습니다.")
storage_root = os.path.abspath(STORAGE_BASE_DIR)
path = os.path.abspath(os.path.join(storage_root, *normalized.parts[1:]))
if os.path.commonpath((storage_root, path)) != storage_root or path == storage_root:
raise ValueError("프로젝트 저장 경로가 저장소 루트를 벗어났습니다.")
return path
def resolve_stored_project_path(relative_path: str) -> str:
"""DB의 storage 기준 상대 경로를 검증해 실제 프로젝트 경로로 변환한다.
실경로(`realpath`)로 돌려준다. `storage/`가 심볼릭 링크·정션일 수 있고(워크트리를
나눠 쓰면 실제로 그렇다), 저장 엔진 쪽은 `Path.resolve()`로 링크를 따라간다. 두
경로의 기준이 다르면 `chunk_path.relative_to(project_root)`가 터진다.
"""
normalized = PurePosixPath(relative_path.replace("\\", "/"))
if normalized.is_absolute() or ".." in normalized.parts:
raise ValueError("프로젝트 저장 경로는 안전한 상대 경로여야 합니다.")
if not normalized.parts or normalized.parts[0] != "storage":
raise ValueError("프로젝트 저장 경로는 storage/로 시작해야 합니다.")
storage_root = os.path.realpath(STORAGE_BASE_DIR)
path = os.path.realpath(os.path.join(storage_root, *normalized.parts[1:]))
if os.path.commonpath((storage_root, path)) != storage_root or path == storage_root:
raise ValueError("프로젝트 저장 경로가 저장소 루트를 벗어났습니다.")
os.makedirs(path, exist_ok=True)
ensure_project_storage_layout(path)
return path
def read_stored_asset(relative_path: str | None) -> bytes | None:
"""`storage/` 기준 상대 경로의 **파일**을 읽는다. 없거나 수상하면 None.
회사 로고·개인 서명처럼 DB에 경로만 담아 두는 자산용이다.
`resolve_stored_project_path()`는 폴더를 만들어 주는 프로젝트 루트용이라 파일에는
쓸 수 없다. 검증 규칙(상대 경로·`storage/` 시작·루트 밖 금지)은 같다.
"""
if not relative_path:
return None
normalized = PurePosixPath(str(relative_path).replace("\\", "/"))
if normalized.is_absolute() or ".." in normalized.parts:
return None
if not normalized.parts or normalized.parts[0] != "storage":
return None
storage_root = os.path.realpath(STORAGE_BASE_DIR)
path = os.path.realpath(os.path.join(storage_root, *normalized.parts[1:]))
if os.path.commonpath((storage_root, path)) != storage_root or path == storage_root:
return None
if not os.path.isfile(path):
return None
with open(path, "rb") as file:
return file.read()