feat(b08): 거리이정표 장소 그림 영구 저장 — place/<상자 id>.png · 저장 뒤 바뀐 상자만 캡처 · place_images (256-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
This commit is contained in:
@@ -11,6 +11,7 @@ page.route 로 가로채 채움(카카오 JS 키 도메인 검사만 통과 · v
|
||||
캡처는 자식 파이썬 프로세스(이 파일을 스크립트로 · 입력 = JSON stdin · 출력 = 결과 JSON 한 줄 ·
|
||||
키는 환경 그대로) — 서버(uvicorn · Windows Selector 루프) 안 스레드에선 Playwright 가
|
||||
드라이버 하위 프로세스를 못 만듦(249-1).
|
||||
장소 그림(256-1) = 같은 길 · 작은 지도(480×320 · 표지 하나 · 주소 둘레 줌) → `place/<상자 id>.png`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -37,6 +38,7 @@ NO_BROWSER = "서버에서 브라우저(Edge · Chrome)를 못 띄움"
|
||||
CHANNELS = ("msedge", "chrome") # 시스템 브라우저 — 번들 chromium 은 안 받아 둠
|
||||
LINE_COLOR = "#E60046" # 화면 지도(`…_UI_Milestone_KakaoMap`) 길 선과 같은 색
|
||||
TAIL = " — [그림 변경]으로 넣으세요."
|
||||
PLACE_VIEW = {"w": 480, "h": 320, "level": 4} # 장소 그림(256-1) — 표지 가운데 · 고정 줌
|
||||
|
||||
_lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 브라우저 한 번에 하나(2~3초)
|
||||
|
||||
@@ -45,17 +47,22 @@ class CaptureError(Exception):
|
||||
"""까닭 한 줄(키 · 예외 원문 없음)."""
|
||||
|
||||
|
||||
def page_html(key: str, lines: list[Any], marks: list[dict[str, Any]]) -> str:
|
||||
"""카카오 지도 + 길 선 + 표지 + 경로 맞춤 · 다 그림 = window.__done · SDK 못 받음 = __fail."""
|
||||
def page_html(
|
||||
key: str, lines: list[Any], marks: list[dict[str, Any]], view: dict[str, int] | None = None
|
||||
) -> str:
|
||||
"""카카오 지도 + 길 선 + 표지 + 경로 맞춤 · 다 그림 = window.__done · SDK 못 받음 = __fail.
|
||||
view = 크기 · 줌 고정(장소 그림 · 첫 표지 가운데 · 경로 맞춤 안 함)."""
|
||||
w, h = (view["w"], view["h"]) if view else (WIDTH, HEIGHT)
|
||||
level = view["level"] if view else 7
|
||||
data = json.dumps({"lines": lines, "marks": marks}, ensure_ascii=False).replace("<", "\\u003c")
|
||||
sdk = f"https://dapi.kakao.com/v2/maps/sdk.js?appkey={urllib.parse.quote(key)}&autoload=false"
|
||||
return f"""<!doctype html><html><head><meta charset="utf-8"></head><body style="margin:0">
|
||||
<div id="m" style="width:{WIDTH}px;height:{HEIGHT}px"></div>
|
||||
<div id="m" style="width:{w}px;height:{h}px"></div>
|
||||
<script src="{sdk}" onerror="window.__fail=1"></script>
|
||||
<script>window.kakao && kakao.maps.load(() => {{
|
||||
const D = {data}, K = kakao.maps, at = (p) => new K.LatLng(p[0], p[1]);
|
||||
const pts = [...D.marks.map((m) => [m.lat, m.lon]), ...D.lines.flat()].map(at);
|
||||
const map = new K.Map(document.getElementById("m"), {{ center: pts[0], level: 7 }});
|
||||
const map = new K.Map(document.getElementById("m"), {{ center: pts[0], level: {level} }});
|
||||
D.lines.forEach((l) => new K.Polyline({{ map, path: l.map(at), strokeWeight: 6,
|
||||
strokeColor: "{LINE_COLOR}", strokeOpacity: 0.9 }}));
|
||||
D.marks.forEach((m) => {{
|
||||
@@ -68,9 +75,11 @@ def page_html(key: str, lines: list[Any], marks: list[dict[str, Any]]) -> str:
|
||||
new K.CustomOverlay({{ map, position: p, content: s, yAnchor: 2.6 }});
|
||||
}});
|
||||
K.event.addListener(map, "tilesloaded", () => (window.__done = true));
|
||||
const b = new K.LatLngBounds();
|
||||
pts.forEach((p) => b.extend(p));
|
||||
map.setBounds(b, 60, 60, 60, 60);
|
||||
if ({"false" if view else "true"}) {{
|
||||
const b = new K.LatLngBounds();
|
||||
pts.forEach((p) => b.extend(p));
|
||||
map.setBounds(b, 60, 60, 60, 60);
|
||||
}}
|
||||
}});</script></body></html>"""
|
||||
|
||||
|
||||
@@ -129,7 +138,7 @@ def capture(job: dict[str, Any]) -> dict[str, Any]:
|
||||
"""자식 프로세스 몸통 — 그림 찍어 `<out>/<route_id>.png` 저장 → {saved, reason}."""
|
||||
key = os.environ.get(KAKAO_JS, "").strip()
|
||||
try:
|
||||
png = shoot(page_html(key, job["lines"], job["marks"]), job["origin"])
|
||||
png = shoot(page_html(key, job["lines"], job["marks"], job.get("view")), job["origin"])
|
||||
except CaptureError as error:
|
||||
return {"saved": False, "reason": str(error)}
|
||||
out_dir = Path(job["out"])
|
||||
@@ -160,25 +169,36 @@ def spawn(job: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
|
||||
def make_route_image(
|
||||
out_dir: Path, route_id: str, lines: list[Any], marks: list[dict[str, Any]]
|
||||
out_dir: Path,
|
||||
route_id: str,
|
||||
lines: list[Any],
|
||||
marks: list[dict[str, Any]],
|
||||
view: dict[str, int] | None = None,
|
||||
what: str = "경로 그림",
|
||||
tail: str = TAIL,
|
||||
) -> dict[str, Any]:
|
||||
"""그 경로 자동 그림 → {saved, reason} — 좌표는 그림에만 쓰고 버림."""
|
||||
key = os.environ.get(KAKAO_JS, "").strip()
|
||||
if not key:
|
||||
return {
|
||||
"saved": False,
|
||||
"reason": f"카카오 지도 JavaScript 키가 없습니다(.env {KAKAO_JS}){TAIL}",
|
||||
"reason": f"카카오 지도 JavaScript 키가 없습니다(.env {KAKAO_JS}){tail}",
|
||||
}
|
||||
if not any(lines) and not marks:
|
||||
return {"saved": False, "reason": f"길 선 · 표지가 없어 지도 그림을 못 만듦{TAIL}"}
|
||||
return {"saved": False, "reason": f"길 선 · 표지가 없어 지도 그림을 못 만듦{tail}"}
|
||||
origin = (os.environ.get(ORIGIN_KEY, "").strip() or DEFAULT_ORIGIN).rstrip("/")
|
||||
job = {"out": str(out_dir), "route_id": route_id, "lines": lines, "marks": marks}
|
||||
job = {"out": str(out_dir), "route_id": route_id, "lines": lines, "marks": marks, "view": view}
|
||||
with _lock:
|
||||
got = spawn({**job, "origin": origin})
|
||||
if not got.get("saved"):
|
||||
return {"saved": False, "reason": f"경로 그림 자동 캡처 실패 — {got.get('reason')}{TAIL}"}
|
||||
return {"saved": False, "reason": f"{what} 자동 캡처 실패 — {got.get('reason')}{tail}"}
|
||||
return {"saved": True, "reason": None}
|
||||
|
||||
|
||||
def make_place_image(out_dir: Path, place_id: str, mark: dict[str, Any]) -> dict[str, Any]:
|
||||
"""장소 그림(256-1) — 작은 지도 · 표지 하나 → `<out_dir>/<상자 id>.png`."""
|
||||
return make_route_image(out_dir, place_id, [], [mark], PLACE_VIEW, "장소 그림", "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(json.dumps(capture(json.loads(sys.stdin.buffer.read()))))
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
POST /api/projects/{id}/quantity/milestone/image/auto?route= 경로 그림 자동(244-11) — 길 선 ·
|
||||
표지 → 서버 헤드리스 캡처(`B08_Quantity_Milestone_Capture`) → `auto/<경로 id>.png` ·
|
||||
좌표는 그림 뒤 버림 · 실패 = 까닭 한 줄
|
||||
POST /api/projects/{id}/quantity/milestone/place-image?id= 장소 그림(256-1) — 상자 좌표 ·
|
||||
이름 → 작은 지도 캡처 → `place/<상자 id>.png` · 등록 · 수정 · 끌기 뒤 화면이 부름
|
||||
GET /api/projects/{id}/quantity/milestone/file?name= 그림 파일(256-1) — `place/<id>.png`
|
||||
· `auto/<id>.png` · `<id>.<확장자>`(하위 폴더 둘만 · 경로 넘기 막음)
|
||||
GET /api/projects/{id}/quantity/milestone/site-address 시점 모달(244-4) — 등록 주소 ·
|
||||
시점 · 중점 VWorld 주소(좌표 → 주소 · 서버 단독 · 저장 안 함) · ?mid_m= 다이어그램
|
||||
지금 중점 측점(244-12 · 상자 작은 글씨)
|
||||
@@ -24,6 +28,10 @@
|
||||
그림 · `auto/<경로 id>.png` 자동 그림 · 폴더가 목록 · B09 견적서 파일 길 꼴) · 보이는 그림 = 사용자
|
||||
그림 우선. 초기값 층 없음(자동설계가 낳는 값 아님).
|
||||
그림은 올리는 즉시 서버에([저장]과 따로) · [저장] 때 없는 경로의 그림은 지움.
|
||||
장소 그림(256-1) = `place/<상자 id>.png` · [저장] 뒤 서버가 좌표가 바뀐(또는 그림 없는) 상자만
|
||||
다시 캡처(뒤에서 · 지문 = `.prints.json` · 좌표는 해시만) · 없는 상자 그림은 [저장] 때 지움 ·
|
||||
GET 답 `place_images` {상자 id: "place/<id>.png"} · `place_times` {상자 id: 바뀐 시각 ms}
|
||||
(캐시 깨기).
|
||||
공사지 = 시점(노선 첫 점 · 측점 0) · 중점 기본 = 노선 연장 ÷ 2 — 노선은 설계 노선 정본 차례
|
||||
(수정본 → 초기 폴리라인 → 예상노선 → 초기값 · `load_design_route` 와 같은 차례).
|
||||
표 합 = `B08_Quantity_Milestone_Calc`(파이썬 · TS 짝).
|
||||
@@ -32,9 +40,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
@@ -42,7 +52,7 @@ from pathlib import Path
|
||||
from typing import Annotated, Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, Query, UploadFile
|
||||
from fastapi import APIRouter, BackgroundTasks, File, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field, ValidationError, model_validator
|
||||
|
||||
@@ -51,7 +61,7 @@ from B02_ProjRegister.B02_ProjRegister_Repository import get_project_by_id
|
||||
from B03_FileInput.B03_FileInput_Engine import save_upload_stream
|
||||
from B08_Quantity.B08_Quantity_Haul import haul_groups, with_haul_routes
|
||||
from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone, r3
|
||||
from B08_Quantity.B08_Quantity_Milestone_Capture import make_route_image
|
||||
from B08_Quantity.B08_Quantity_Milestone_Capture import make_place_image, make_route_image
|
||||
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
|
||||
from B08_Quantity.B08_Quantity_Router_Material import material_sheets
|
||||
from common_util.common_util_initial_snapshot import design_route_csv_path
|
||||
@@ -69,6 +79,8 @@ PATH = "/{project_id}/quantity/milestone"
|
||||
FOLDER = Path("B08_Quantity") / "milestone"
|
||||
DATA_FILE = "milestone.json"
|
||||
AUTO = "auto" # 자동 그림 하위 폴더(244-11)
|
||||
PLACE = "place" # 장소 그림 하위 폴더(256-1)
|
||||
PRINTS = ".prints.json" # 그림 지문 {"place/<id>": 해시} — 바뀐 것만 다시 캡처
|
||||
IMAGE_EXT = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp")
|
||||
ROUTE_ID = r"^[A-Za-z0-9_-]{1,40}$"
|
||||
|
||||
@@ -212,15 +224,29 @@ def images(root: str | Path, sub: str = "") -> dict[str, str]:
|
||||
}
|
||||
|
||||
|
||||
def places(data: dict[str, Any]) -> list[tuple[str, str, float, float]]:
|
||||
"""좌표가 정해진 상자 → [(상자 id, 이름, 경도, 위도)] — 거래처 = 경로 id · 기준점 = "b1"."""
|
||||
# ponytail: 단일 기준점 — 254-1 bases[] 합친 뒤 bases[].id 로
|
||||
boxes = [(r["id"], r.get("name") or "", r.get("lon"), r.get("lat")) for r in data["routes"]]
|
||||
base = data.get("base") or {}
|
||||
boxes.append(("b1", base.get("name") or "기준점", base.get("lon"), base.get("lat")))
|
||||
return [(i, n, lon, lat) for i, n, lon, lat in boxes if lon is not None and lat is not None]
|
||||
|
||||
|
||||
def payload(root: str | Path, data: dict[str, Any]) -> dict[str, Any]:
|
||||
site = site_info(root, data["site"]["mid_station_m"])
|
||||
auto = images(root, AUTO)
|
||||
place = images(root, PLACE)
|
||||
return {
|
||||
"data": data,
|
||||
"site": site,
|
||||
"table": compute_milestone(data, site["mid_default_m"]),
|
||||
"images": {**auto, **images(root)}, # 보이는 그림 = 사용자 그림 우선
|
||||
"auto_images": auto,
|
||||
"place_images": place,
|
||||
"place_times": {
|
||||
stem: int((folder(root) / name).stat().st_mtime * 1000) for stem, name in place.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -230,13 +256,60 @@ def save_data(root: str | Path, body: Milestone) -> dict[str, Any]:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(path / DATA_FILE, data)
|
||||
keep = {route["id"] for route in data["routes"]}
|
||||
for sub in ("", AUTO):
|
||||
for sub, ids in (("", keep), (AUTO, keep), (PLACE, {p[0] for p in places(data)})):
|
||||
for stem, name in images(root, sub).items():
|
||||
if stem not in keep:
|
||||
if stem not in ids:
|
||||
(path / name).unlink(missing_ok=True)
|
||||
return payload(root, data)
|
||||
|
||||
|
||||
def _print(*parts: Any) -> str:
|
||||
"""지문 — 좌표 · 이름 해시(좌표 원문은 안 남김)."""
|
||||
return hashlib.sha1(json.dumps(parts, ensure_ascii=False).encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def _read_prints(root: str | Path) -> dict[str, str]:
|
||||
try:
|
||||
return json.loads((folder(root) / PRINTS).read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _set_print(root: str | Path, key: str, value: str | None) -> None:
|
||||
"""지문 한 칸 적음 · None = 지움(다음 [저장]에 다시 캡처)."""
|
||||
with _prints_lock:
|
||||
prints = _read_prints(root)
|
||||
if prints.get(key) == value:
|
||||
return
|
||||
if value is None:
|
||||
prints.pop(key, None)
|
||||
else:
|
||||
prints[key] = value
|
||||
atomic_write_json(folder(root) / PRINTS, prints)
|
||||
|
||||
|
||||
_prints_lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 지문 파일 읽고 쓰기 한 번에 하나
|
||||
|
||||
|
||||
def place_image(root: str | Path, place_id: str, name: str, lon: float, lat: float) -> dict:
|
||||
"""장소 그림 한 장 캡처 → 지문 적음 → {saved, reason}."""
|
||||
got = make_place_image(folder(root) / PLACE, place_id, {"name": name, "lon": lon, "lat": lat})
|
||||
if got["saved"]:
|
||||
_set_print(root, f"{PLACE}/{place_id}", _print(name, lon, lat))
|
||||
return got
|
||||
|
||||
|
||||
def refresh_images(root: str | Path) -> None:
|
||||
"""[저장] 뒤(뒤에서) — 지문이 바뀌었거나 그림이 없는 상자만 다시 캡처 ·
|
||||
실패 = 다음 [저장]에 다시."""
|
||||
data = read_data(root)
|
||||
prints = _read_prints(root)
|
||||
have = images(root, PLACE)
|
||||
for place_id, name, lon, lat in places(data):
|
||||
if prints.get(f"{PLACE}/{place_id}") != _print(name, lon, lat) or place_id not in have:
|
||||
place_image(root, place_id, name, lon, lat)
|
||||
|
||||
|
||||
def _route_id(route: str) -> str:
|
||||
try:
|
||||
return Route(id=route).id
|
||||
@@ -260,9 +333,13 @@ async def milestone_read(project_id: UUID) -> dict[str, Any]:
|
||||
|
||||
|
||||
@router.put(PATH)
|
||||
async def milestone_save(project_id: UUID, body: Milestone) -> dict[str, Any]:
|
||||
async def milestone_save(
|
||||
project_id: UUID, body: Milestone, tasks: BackgroundTasks
|
||||
) -> dict[str, Any]:
|
||||
root = await _project_root(project_id)
|
||||
return await asyncio.to_thread(save_data, root, body)
|
||||
out = await asyncio.to_thread(save_data, root, body)
|
||||
tasks.add_task(refresh_images, root) # 답 뒤 캡처(상자마다 2~3초) — 다음 GET 에 보임
|
||||
return out
|
||||
|
||||
|
||||
@router.get(PATH + "/site-address")
|
||||
@@ -375,6 +452,32 @@ async def milestone_image_auto(
|
||||
return {"name": f"{AUTO}/{route_id}.png" if got["saved"] else None, "reason": got["reason"]}
|
||||
|
||||
|
||||
FILE_NAME = r"^(?:(?:auto|place)/)?[A-Za-z0-9_-]{1,40}\.(?:png|jpe?g|gif|webp|bmp)$"
|
||||
|
||||
|
||||
@router.post(PATH + "/place-image")
|
||||
async def milestone_place_image(project_id: UUID, body: Mark, id: str = Query(...)) -> dict:
|
||||
"""장소 그림(256-1) — 등록 · 수정 · 끌기 뒤 화면이 부름 → place/<id>.png · 좌표는 저장 안 함."""
|
||||
place_id = _route_id(id)
|
||||
root = await _project_root(project_id)
|
||||
got = await asyncio.to_thread(place_image, root, place_id, body.name, body.lon, body.lat)
|
||||
if not got["saved"]:
|
||||
return {"name": None, "time": None, "reason": got["reason"]}
|
||||
name = f"{PLACE}/{place_id}.png"
|
||||
time_ms = int((folder(root) / name).stat().st_mtime * 1000)
|
||||
return {"name": name, "time": time_ms, "reason": None}
|
||||
|
||||
|
||||
@router.get(PATH + "/file")
|
||||
async def milestone_file(project_id: UUID, name: str = Query(..., pattern=FILE_NAME)):
|
||||
"""그림 파일(256-1) — 하위 폴더 auto · place 만(패턴이 경로 넘기 막음)."""
|
||||
root = await _project_root(project_id)
|
||||
path = folder(root) / name
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="그림이 없습니다.")
|
||||
return FileResponse(path)
|
||||
|
||||
|
||||
KAKAO_URL = "https://apis-navi.kakaomobility.com/v1/directions"
|
||||
KAKAO_KEY = "KAKAO_REST_API_KEY"
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
* (뒤에서 · 화면 안 잠금) · 사용자 그림 우선 · 실패 = 까닭 한 줄(알림 · 경로 탭) · 좌표는 서버도 안 남김
|
||||
* 길찾기 = 연결선 한 구간씩(244-13 · `findSegmentKm` · 거래처 → 기준점(또는 시점) · 기준점 → 시점) ·
|
||||
* 자동 그림 = 거래처 쪽 구간 길 선 · 표지 둘(거래처 · 기준점 또는 시점)
|
||||
* 장소 그림(256-1) = 상자 좌표가 정해지면 `capturePlace` → 서버 캡처 `place/<상자 id>.png` ·
|
||||
* [저장] 뒤엔 서버가 바뀐 상자만 다시(다음 읽기에 보임) · `placeImageKey` · `placeImageBlobUrl`
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
@@ -35,6 +37,8 @@ interface Payload {
|
||||
site: SiteInfo;
|
||||
images: Record<string, string>;
|
||||
auto_images: Record<string, string>;
|
||||
place_images: Record<string, string>;
|
||||
place_times: Record<string, number>;
|
||||
}
|
||||
|
||||
export const EMPTY: MilestoneData = {
|
||||
@@ -204,6 +208,43 @@ export async function autoRouteImage(
|
||||
changes.dispatchEvent(new Event("change"));
|
||||
}
|
||||
|
||||
/** 장소 그림(256-1) 다시 읽을 표 — 이름 + 바뀐 시각 · 없으면 null */
|
||||
export const placeImageKey = (placeId: string): string | null => {
|
||||
const name = saved?.place_images?.[placeId];
|
||||
return name ? `${name}#${saved?.place_times?.[placeId] ?? 0}` : null;
|
||||
};
|
||||
|
||||
/** 장소 그림(256-1) — 상자 좌표 · 이름 → 서버 캡처 → 목록 고쳐 알림(뒤에서 · 실패 = 알림 한 줄) */
|
||||
export async function capturePlace(
|
||||
projectId: string,
|
||||
placeId: string,
|
||||
mark: { name: string; lon: number; lat: number },
|
||||
): Promise<void> {
|
||||
let reason: string | null;
|
||||
try {
|
||||
const res = await request<{ name: string | null; time: number | null; reason: string | null }>(
|
||||
`/projects/${projectId}/quantity/milestone/place-image?id=${encodeURIComponent(placeId)}`,
|
||||
{ method: "POST", body: JSON.stringify(mark) },
|
||||
);
|
||||
reason = res.reason;
|
||||
if (res.name && saved) {
|
||||
saved.place_images = { ...saved.place_images, [placeId]: res.name };
|
||||
saved.place_times = { ...saved.place_times, [placeId]: res.time ?? Date.now() };
|
||||
}
|
||||
} catch (error) {
|
||||
reason = `장소 그림 캡처 요청 실패(${String(error)})`;
|
||||
}
|
||||
if (reason) showToast(reason, "warning");
|
||||
changes.dispatchEvent(new Event("change"));
|
||||
}
|
||||
|
||||
/** 장소 그림 → 화면 주소(blob · 쿠키 포함) */
|
||||
export async function placeImageBlobUrl(projectId: string, placeId: string): Promise<string> {
|
||||
const name = saved?.place_images?.[placeId] ?? `place/${placeId}.png`;
|
||||
const url = `${API_BASE_URL}/projects/${projectId}/quantity/milestone/file?name=${encodeURIComponent(name)}`;
|
||||
return URL.createObjectURL(await (await call(url)).blob());
|
||||
}
|
||||
|
||||
/** 그림 → 화면 주소(blob · 쿠키 포함) */
|
||||
export async function imageBlobUrl(projectId: string, routeId: string): Promise<string> {
|
||||
return URL.createObjectURL(await (await call(imageUrl(projectId, routeId))).blob());
|
||||
|
||||
@@ -29,7 +29,7 @@ LINES = [[[35.6866, 127.9095], [35.66, 127.85]]]
|
||||
MARKS = [{"name": "거창군청", "lon": 127.9095, "lat": 35.6866}]
|
||||
|
||||
if __name__ == "__main__": # 자식 프로세스 — 카카오 페이지만 단색 페이지로
|
||||
cap.page_html = lambda key, lines, marks: HTML
|
||||
cap.page_html = lambda key, lines, marks, view=None: HTML
|
||||
print(json.dumps(cap.capture(json.loads(sys.stdin.buffer.read()))))
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""256-1 B08 거리이정표 장소 그림 영구 저장(사용자 162-6 · 7).
|
||||
|
||||
캡처는 가짜(make_place_image 자리)로:
|
||||
① 장소 그림 페이지 = 480×320 · 고정 줌 · 경로 맞춤 안 함
|
||||
② [저장] 뒤 좌표 있는 상자(거래처 = 경로 id · 기준점 = b1)만 캡처 → place/<id>.png ·
|
||||
GET 답 place_images · place_times
|
||||
③ 다시 [저장] = 바뀐 상자 · 그림 없는 상자만 · 실패 = 다음에 다시 · 지문에 좌표 원문 없음
|
||||
④ 지운 상자 그림 = [저장] 때 지움 · 화면 캡처 길(place-image) · 그림 파일 길(file · 경로 넘기 막음)
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B08_Quantity import B08_Quantity_Milestone_Capture as cap # noqa: E402
|
||||
from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402
|
||||
|
||||
PNG = b"\x89PNG\r\n\x1a\nfake-place"
|
||||
DATA = {
|
||||
"base": {"enabled": True, "name": "거창IC", "lon": 127.91, "lat": 35.68},
|
||||
"routes": [
|
||||
{"id": "r1", "name": "거창레미콘", "lon": 127.9095, "lat": 35.6866},
|
||||
{"id": "r2", "name": "좌표 없음"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class Fake:
|
||||
"""가짜 캡처 — 부른 상자 기록 · ok=False 면 실패."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.ok = True
|
||||
|
||||
def __call__(self, out_dir, place_id, mark):
|
||||
self.calls.append((place_id, mark["name"], mark["lon"], mark["lat"]))
|
||||
if not self.ok:
|
||||
return {"saved": False, "reason": "장소 그림 자동 캡처 실패 — 시험"}
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
(out_dir / f"{place_id}.png").write_bytes(PNG)
|
||||
return {"saved": True, "reason": None}
|
||||
|
||||
|
||||
def _save(root, data):
|
||||
return ms.save_data(root, ms.Milestone.model_validate(data))
|
||||
|
||||
|
||||
def _fake(monkeypatch):
|
||||
fake = Fake()
|
||||
monkeypatch.setattr(ms, "make_place_image", fake)
|
||||
return fake
|
||||
|
||||
|
||||
def test_장소_그림_페이지_작은_지도():
|
||||
html = cap.page_html(
|
||||
"k", [], [{"name": "거창레미콘", "lon": 127.9, "lat": 35.6}], cap.PLACE_VIEW
|
||||
)
|
||||
assert "width:480px;height:320px" in html and "level: 4" in html
|
||||
assert "if (false)" in html # 경로 맞춤 안 함 — 표지 가운데 · 고정 줌
|
||||
assert "if (true)" in cap.page_html("k", [], []) # 경로 그림은 그대로
|
||||
|
||||
|
||||
def test_저장_뒤_좌표_있는_상자만_캡처(tmp_path, monkeypatch):
|
||||
fake = _fake(monkeypatch)
|
||||
_save(tmp_path, DATA)
|
||||
ms.refresh_images(tmp_path)
|
||||
assert sorted(c[0] for c in fake.calls) == ["b1", "r1"]
|
||||
got = ms.payload(tmp_path, ms.read_data(tmp_path))
|
||||
assert got["place_images"] == {"b1": "place/b1.png", "r1": "place/r1.png"}
|
||||
assert set(got["place_times"]) == {"b1", "r1"}
|
||||
assert all(isinstance(t, int) and t > 0 for t in got["place_times"].values())
|
||||
assert (tmp_path / "B08_Quantity/milestone/place/r1.png").read_bytes() == PNG
|
||||
prints = (tmp_path / "B08_Quantity/milestone/.prints.json").read_text(encoding="utf-8")
|
||||
assert "127.9095" not in prints and "35.6866" not in prints # 해시만
|
||||
|
||||
|
||||
def test_바뀐_상자_그림_없는_상자만_다시(tmp_path, monkeypatch):
|
||||
fake = _fake(monkeypatch)
|
||||
_save(tmp_path, DATA)
|
||||
ms.refresh_images(tmp_path)
|
||||
fake.calls.clear()
|
||||
ms.refresh_images(tmp_path)
|
||||
assert fake.calls == [] # 안 바뀜 = 캡처 0
|
||||
moved = {**DATA, "routes": [{**DATA["routes"][0], "lat": 35.7}, DATA["routes"][1]]}
|
||||
_save(tmp_path, moved)
|
||||
ms.refresh_images(tmp_path)
|
||||
assert [c[0] for c in fake.calls] == ["r1"]
|
||||
fake.calls.clear()
|
||||
(tmp_path / "B08_Quantity/milestone/place/b1.png").unlink()
|
||||
ms.refresh_images(tmp_path)
|
||||
assert [c[0] for c in fake.calls] == ["b1"]
|
||||
# 실패 = 지문 안 적음 → 다음에 다시
|
||||
fake.calls.clear()
|
||||
fake.ok = False
|
||||
_save(tmp_path, {**moved, "base": {**DATA["base"], "lon": 127.95}})
|
||||
ms.refresh_images(tmp_path)
|
||||
ms.refresh_images(tmp_path)
|
||||
assert [c[0] for c in fake.calls] == ["b1", "b1"]
|
||||
|
||||
|
||||
def test_지운_상자_그림은_저장_때_지움(tmp_path, monkeypatch):
|
||||
_fake(monkeypatch)
|
||||
_save(tmp_path, DATA)
|
||||
ms.refresh_images(tmp_path)
|
||||
got = _save(tmp_path, {"routes": [DATA["routes"][1]]}) # r1 · 기준점 좌표 지움
|
||||
assert got["place_images"] == {} and got["place_times"] == {}
|
||||
assert not list((tmp_path / "B08_Quantity/milestone/place").iterdir())
|
||||
|
||||
|
||||
def test_화면_캡처_길_그림_파일_길(tmp_path, monkeypatch):
|
||||
fake = _fake(monkeypatch)
|
||||
|
||||
async def root(project_id):
|
||||
return tmp_path
|
||||
|
||||
monkeypatch.setattr(ms, "_project_root", root)
|
||||
app = FastAPI()
|
||||
app.include_router(ms.router)
|
||||
client = TestClient(app)
|
||||
base = f"/api/projects/{uuid4()}/quantity/milestone"
|
||||
# [저장] = 답 뒤 캡처(BackgroundTasks — TestClient 는 답 전에 끝냄)
|
||||
assert client.put(base, json=DATA).status_code == 200
|
||||
assert sorted(c[0] for c in fake.calls) == ["b1", "r1"]
|
||||
assert client.get(base).json()["place_images"]["r1"] == "place/r1.png"
|
||||
# 등록 · 끌기 뒤 화면이 부름
|
||||
fake.calls.clear()
|
||||
mark = {"name": "새 거래처", "lon": 127.8, "lat": 35.5}
|
||||
res = client.post(base + "/place-image", params={"id": "r9"}, json=mark).json()
|
||||
assert res["name"] == "place/r9.png" and res["reason"] is None and res["time"] > 0
|
||||
assert fake.calls == [("r9", "새 거래처", 127.8, 35.5)]
|
||||
fake.ok = False
|
||||
bad = client.post(base + "/place-image", params={"id": "r8"}, json=mark).json()
|
||||
assert bad == {"name": None, "time": None, "reason": "장소 그림 자동 캡처 실패 — 시험"}
|
||||
assert client.post(base + "/place-image", params={"id": "../x"}, json=mark).status_code == 400
|
||||
# 그림 파일 길 — 하위 폴더 둘 · 경로 넘기 막음
|
||||
assert client.get(base + "/file", params={"name": "place/r1.png"}).content == PNG
|
||||
assert client.get(base + "/file", params={"name": "place/zz.png"}).status_code == 404
|
||||
for name in ("../milestone.json", "place/../x.png", "x/r1.png", "place/r1.json", "/etc/x.png"):
|
||||
assert client.get(base + "/file", params={"name": name}).status_code == 422, name
|
||||
Reference in New Issue
Block a user