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:
@@ -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 등)
|
||||
* 좌측 패널·우측 뷰어 모두 준비 중 상태로 채운다.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 담아 둔 지표면 자료를 실제로 쓰는 화면.
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user