fix(B03·공용): 지워진 프로젝트를 기억하면 빈 화면 대신 안내 · 서버 404(73-16)

- 라우터가 B03~B09 · 준비 화면 진입 전 현재 프로젝트를 한 번 확인 — 404 · 403 이면 기억을 비우고 「프로젝트가 없어졌습니다」 안내 + [대시보드로]
- workflow-state 는 지워진 프로젝트에 404(전: 200 빈 단계)
- 저장 경로 조회 실패는 ProjectNotFoundError(LookupError 하위) — 잡지 않은 자리도 앱 처리기가 404(전: 노선 API 등 500)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
This commit is contained in:
2026-10-07 09:45:00 +09:00
co-authored by Claude Opus 5.5
parent 25a4dae744
commit f15104c607
9 changed files with 171 additions and 2 deletions
+16
View File
@@ -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 등)
* 좌측 패널·우측 뷰어 모두 준비 중 상태로 채운다.
+19 -1
View File
@@ -55,7 +55,9 @@ export async function fetchWorkflowState(projectId: string): Promise<WorkflowSta
credentials: "include",
});
if (!response.ok) {
throw new Error(`Workflow state request failed: ${response.status}`);
throw Object.assign(new Error(`Workflow state request failed: ${response.status}`), {
status: response.status,
});
}
const data = await response.json();
return data.workflow_state ?? data;
@@ -65,6 +67,22 @@ export async function fetchWorkflowState(projectId: string): Promise<WorkflowSta
return value;
}
/** 기억한 현재 프로젝트가 서버에 없으면(지워짐 · 권한 없음) 기억을 비우고 true.
* 그 밖의 실패(서버 오류 · 끊김)는 false — 기억을 지우지 않고 페이지가 하던 대로 둔다. */
export async function dropGoneProject(): Promise<boolean> {
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;
}
}
/**
* 담아 둔 지표면 자료를 실제로 쓰는 화면.
*
+24
View File
@@ -81,6 +81,18 @@ export const setLeaveGuard = (guard: (() => Promise<boolean>) | null): void => {
};
export const takeLeaveGuard = (): (() => Promise<boolean>) | 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<void> {
}
}
// 기억한 현재 프로젝트가 지워졌으면 페이지 대신 안내 — 모든 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);
+5 -1
View File
@@ -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:
+9
View File
@@ -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}
@@ -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)})
+3
View File
@@ -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 미들웨어
@@ -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
+6
View File
@@ -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"],