diff --git a/A00_Common/b_page_scaffold.ts b/A00_Common/b_page_scaffold.ts index 15c7d6bc2..ab46c241d 100644 --- a/A00_Common/b_page_scaffold.ts +++ b/A00_Common/b_page_scaffold.ts @@ -14,6 +14,7 @@ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; +import { createButton } from "@ui/ui_template_elements"; import { fetchWorkflowState, goToWorkflowStage, @@ -71,6 +72,21 @@ export function renderPendingContent(root: HTMLElement, opts: PendingContentOpti root.append(page); } +/** 기억한 현재 프로젝트가 서버에 없을 때 — 빈 화면 대신 안내와 대시보드 단추(B03~B09 공용). */ +export function renderProjectGone(root: HTMLElement, toDashboard: () => void): void { + injectScaffoldStyles(); + const page = document.createElement("div"); + page.className = "b-scaffold b-project-gone"; + const block = document.createElement("div"); + block.className = "b-pending"; + const msg = document.createElement("p"); + msg.className = "b-pending__msg"; + msg.textContent = L("B_Project_Gone"); + block.append(msg, createButton({ label: L("B_Project_Gone_ToDashboard"), onClick: toDashboard })); + page.append(block); + root.append(page); +} + /* ----------------------------------------------------------------------------- * 2. 워크플로우 셸(3단 레이아웃) + 준비 중 안내 (B04~B06 등) * 좌측 패널·우측 뷰어 모두 준비 중 상태로 채운다. diff --git a/A00_Common/b_workflow_nav.ts b/A00_Common/b_workflow_nav.ts index 52a2b6136..94f204bcf 100644 --- a/A00_Common/b_workflow_nav.ts +++ b/A00_Common/b_workflow_nav.ts @@ -55,7 +55,9 @@ export async function fetchWorkflowState(projectId: string): Promise { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + if (!projectId) return false; + try { + await fetchWorkflowState(projectId); + return false; + } catch (error) { + const status = (error as { status?: number }).status; + if (status !== 403 && status !== 404) return false; + localStorage.removeItem(CURRENT_PROJECT_ID_KEY); + return true; + } +} + /** * 담아 둔 지표면 자료를 실제로 쓰는 화면. * diff --git a/A00_Common/router.ts b/A00_Common/router.ts index b90ffc2c3..f41d74244 100644 --- a/A00_Common/router.ts +++ b/A00_Common/router.ts @@ -81,6 +81,18 @@ export const setLeaveGuard = (guard: (() => Promise) | null): void => { }; export const takeLeaveGuard = (): (() => Promise) | null => leaveGuard; +/** 현재 프로젝트(기억한 id)로 여는 화면 — 그 프로젝트가 서버에 없으면 안내를 띄운다. */ +const PROJECT_ROUTES: readonly RoutePath[] = [ + ROUTES.B03_FILE_INPUT, + ROUTES.B04_PREPROCESS, + ROUTES.B05_PROFILE, + ROUTES.B06_SECTION, + ROUTES.B07_DESIGN_DETAIL, + ROUTES.B08_QUANTITY, + ROUTES.B09_ESTIMATION, + ROUTES.B11_LOADING, +]; + /** 지금 떠 있는 여러 주소 페이지 · 렌더 차례(늦게 끝난 옛 렌더를 버림). */ let mounted: MountedPage | null = null; let renderSeq = 0; @@ -184,6 +196,18 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise { } } + // 기억한 현재 프로젝트가 지워졌으면 페이지 대신 안내 — 모든 B 단계 화면이 여기 한 곳을 지난다. + if (PROJECT_ROUTES.includes(route)) { + const { dropGoneProject } = await import("./b_workflow_nav"); + if (await dropGoneProject()) { + if (seq !== renderSeq) return; + const { renderProjectGone } = await import("./b_page_scaffold"); + outlet.innerHTML = ""; + renderProjectGone(outlet, () => navigateTo(ROUTES.B01_ACCOUNT)); + return; + } + } + const loader = routeTable[route]; if (!loader) { renderPlaceholder(outlet, route); diff --git a/B03_FileInput/B03_FileInput_Repository.py b/B03_FileInput/B03_FileInput_Repository.py index 1c73b7dc8..8d295ba50 100644 --- a/B03_FileInput/B03_FileInput_Repository.py +++ b/B03_FileInput/B03_FileInput_Repository.py @@ -109,6 +109,10 @@ async def get_project_input_readiness( return file_types, point_cloud_id, route_id +class ProjectNotFoundError(LookupError): + """지워졌거나 없는 프로젝트 — 잡지 않은 자리는 앱 처리기가 404 로 답한다(500 아님).""" + + async def get_project_storage_relative_path( connection: aiomysql.Connection, project_id: UUID, @@ -126,7 +130,7 @@ async def get_project_storage_relative_path( row = await cursor.fetchone() if not row or not row[0]: - raise LookupError("프로젝트 또는 프로젝트 저장 경로를 찾을 수 없습니다.") + raise ProjectNotFoundError("프로젝트 또는 프로젝트 저장 경로를 찾을 수 없습니다.") normalized_path = PurePosixPath(str(row[0]).replace("\\", "/")) if normalized_path.is_absolute() or ".." in normalized_path.parts: diff --git a/B03_FileInput/B03_FileInput_Router.py b/B03_FileInput/B03_FileInput_Router.py index 345e4d6ed..9c49fa47b 100644 --- a/B03_FileInput/B03_FileInput_Router.py +++ b/B03_FileInput/B03_FileInput_Router.py @@ -405,5 +405,14 @@ async def get_project_upload_overview( async def get_project_workflow_state(project_id: str): pool = get_db_pool() async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + # 지워진 프로젝트는 404 — 화면이 기억한 옛 id 를 알아채고 대시보드로 안내한다. + await cursor.execute( + "SELECT 1 FROM projects WHERE id = %s AND deleted_at IS NULL", (project_id,) + ) + if not await cursor.fetchone(): + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트를 찾을 수 없습니다."}, + ) state = await get_workflow_state(cursor, project_id) return {"status": "success", "workflow_state": state} diff --git a/B03_FileInput/B03_FileInput_Router_Errors.py b/B03_FileInput/B03_FileInput_Router_Errors.py index a8fad5269..53ef9fae6 100644 --- a/B03_FileInput/B03_FileInput_Router_Errors.py +++ b/B03_FileInput/B03_FileInput_Router_Errors.py @@ -9,6 +9,7 @@ import logging from typing import Any +from fastapi import Request from fastapi.responses import JSONResponse @@ -29,3 +30,8 @@ def lookup_error_response( content={"status": "error", "message": fallback_message}, ) return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + + +async def project_not_found_response(_request: Request, exc: Exception) -> JSONResponse: + """`ProjectNotFoundError` 앱 처리기(`main.py` 가 닮) — 잡지 않은 자리도 500 이 아닌 404.""" + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) diff --git a/main.py b/main.py index b97b6e0a1..9f9334317 100644 --- a/main.py +++ b/main.py @@ -32,7 +32,9 @@ from A08_Support.A08_Support_Router import router as a08_support_router from A09_Security.A09_Security_Router import router as a09_security_router from B01_Dashboard.B01_Dashboard_Router import router as b01_dashboard_router from B02_ProjRegister.B02_ProjRegister_Router import router as b02_proj_register_router +from B03_FileInput.B03_FileInput_Repository import ProjectNotFoundError from B03_FileInput.B03_FileInput_Router import router as b03_file_input_router +from B03_FileInput.B03_FileInput_Router_Errors import project_not_found_response from B03_FileInput.B03_FileInput_Router_Temp import ( attach_router as b03_temp_attach_router, ) @@ -442,6 +444,7 @@ app = FastAPI( debug=DEBUG, lifespan=lifespan, ) +app.add_exception_handler(ProjectNotFoundError, project_not_found_response) # 지워진 프로젝트 = 404 # ───────────────────────────────────────────────────────────────────────── # CORS 미들웨어 diff --git a/resources/tester/test_73_16_project_gone.py b/resources/tester/test_73_16_project_gone.py new file mode 100644 index 000000000..b11b75317 --- /dev/null +++ b/resources/tester/test_73_16_project_gone.py @@ -0,0 +1,83 @@ +"""지워진 프로젝트 — 서버가 500 이 아닌 404 로 답함(73-16). + +화면(라우터)은 workflow-state 404 · 403 을 보고 기억한 현재 프로젝트를 비우고 안내를 띄운다. +""" + +import asyncio +import json + +import B03_FileInput.B03_FileInput_Router as b03_router +from B03_FileInput.B03_FileInput_Repository import ( + ProjectNotFoundError, + get_project_storage_relative_path, +) +from B03_FileInput.B03_FileInput_Router_Errors import project_not_found_response + +GONE = "fa76c162-71c7-46e5-a95d-fb3930665a45" + + +class _Cursor: + def __init__(self, row): + self.row = row + self.sql = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + return False + + async def execute(self, sql, args=None): + self.sql.append(sql) + + async def fetchone(self): + return self.row + + +class _Conn: + def __init__(self, row): + self.row = row + + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + return False + + def cursor(self, *_): + return _Cursor(self.row) + + +class _Pool: + def __init__(self, row): + self.row = row + + def acquire(self): + return _Conn(self.row) + + +def test_저장_경로_조회는_ProjectNotFoundError_이고_LookupError_로도_잡힘(): + try: + asyncio.run(get_project_storage_relative_path(_Conn(None), GONE)) + except ProjectNotFoundError as exc: + assert isinstance(exc, LookupError) + else: + raise AssertionError("없는 프로젝트인데 예외가 안 남") + + +def test_앱_처리기는_404(): + response = asyncio.run(project_not_found_response(None, ProjectNotFoundError("없음"))) + assert response.status_code == 404 + assert json.loads(response.body)["status"] == "error" + + +def test_main_앱에_처리기가_달림(): + import main + + assert main.app.exception_handlers.get(ProjectNotFoundError) is project_not_found_response + + +def test_workflow_state_는_지워진_프로젝트에_404(monkeypatch): + monkeypatch.setattr(b03_router, "get_db_pool", lambda: _Pool(None)) + response = asyncio.run(b03_router.get_project_workflow_state(GONE)) + assert response.status_code == 404 diff --git a/ui_template/ui_template_locale_b1.ts b/ui_template/ui_template_locale_b1.ts index ddf13ae38..bd455c923 100644 --- a/ui_template/ui_template_locale_b1.ts +++ b/ui_template/ui_template_locale_b1.ts @@ -16,6 +16,12 @@ export const ui_locales_b1 = { "이 영역의 상세 기능은 준비 중입니다.", "The detailed features for this area are in preparation.", ], + /* --- 공통: 기억한 현재 프로젝트가 서버에 없음 (B03~B09) --- */ + B_Project_Gone: [ + "프로젝트가 없어졌습니다 · 대시보드에서 다시 고르세요.", + "This project no longer exists. Choose a project again on the dashboard.", + ], + B_Project_Gone_ToDashboard: ["대시보드로", "Go to dashboard"], /* --- B01_Dashboard 계정 관리 공통 문구 --- */ B01_Account_Title: ["내 계정", "My Account"],