feat(B03): 업로드 잠금에 이름을 붙임 — 지금 누가 올리는 중인지 화면에 뜸

계획서 0-8 의 남은 하나. 서버는 분석이 도는 동안 새 자료를 이미 막고 있었고
(`is_analysis_running`), 없던 것은 **막힌 까닭을 사람에게 보이는 것**이었음.
여럿이 한 프로젝트를 보면 「왜 안 올라가지」가 됨.

- 분석을 띄우는 **경로 넷 모두** 시작한 사람을 `params.started_by` 로 담음.
- `analysis_lock_owner` 가 그 id 로 이름을 붙여 냄 — 옛 자료는 id 가 없어
  **이름 없이 잠금만** 돎(막는 것이 먼저).
- `/upload-overview` 응답에 `analysis_lock` 을 실어 B03 화면이 띠로 보임.

화면 실측 — 「엄상돈 님이 올린 자료를 분석하는 중입니다」 띠가 경고색으로 뜸(응답을
잠깐 가로채 확인하고 곧바로 되돌림). 지금 도는 분석이 없어 실제 잠금 화면은 다음 업로드 때 볼 것.
시험 넷 추가(도는 중 아님·이름 붙음·옛 자료·경로 넷 대조), 전체 1211 통과·실패 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-09 22:43:28 +09:00
co-authored by Claude Opus 5
parent e2380ea538
commit 018ebe7f40
10 changed files with 198 additions and 0 deletions
+11
View File
@@ -211,12 +211,23 @@ export interface UploadOverviewSession {
updated_at: string | null;
}
/** 지금 분석이 도는 중이면 **누가 시작했는지**. 도는 중이 아니면 `null`. */
export interface UploadLockInfo {
running: boolean;
user_id: number | null;
name: string | null;
email: string | null;
started_at: string | null;
}
export interface UploadOverviewResponse {
status: string;
files: UploadOverviewFile[];
pending_sessions: UploadOverviewSession[];
required_complete: boolean;
analysis_complete: boolean;
/** 서버가 새 자료를 막는 동안 **왜·누구 때문에** 막혔는지(2026-09-09, 계획서 0-8). */
analysis_lock?: UploadLockInfo | null;
}
/** 재접속 시 업로드 현황(정본) — 완료 파일 목록 + 중단 세션 + 완료 여부. */
+6
View File
@@ -106,6 +106,7 @@ from B03_FileInput.B03_FileInput_Schema import (
FileUploadDescriptor,
FileUploadResponse,
UploadedFileResult,
UploadLockInfo,
UploadOverviewFile,
UploadOverviewResponse,
UploadOverviewSession,
@@ -116,6 +117,7 @@ from common_util.common_util_json import atomic_write_json
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_workflow import load_project_workflow
from common_util.common_util_workflow_state import (
analysis_lock_owner,
get_workflow_state,
is_analysis_running,
)
@@ -283,6 +285,7 @@ async def upload_project_files(
project_id=project_id,
input_file_id=point_cloud_input_id,
user_role=str(session["role"]),
started_by=session.get("user_id"),
),
task_name=f"b04-preprocess-auto-{project_id}",
)
@@ -343,6 +346,8 @@ async def get_project_upload_overview(
)
async with connection.cursor(aiomysql.DictCursor) as cursor:
state = await get_workflow_state(cursor, str(project_id))
# 「누가 올리는 중인지」 — 서버가 막는 것과 화면이 알리는 것이 한 값에서 나온다.
lock = await analysis_lock_owner(cursor, str(project_id))
stages = (state or {}).get("stages") or []
analysis_complete = any(
int(stage.get("stage_no", -1)) == 1 and str(stage.get("state")) == "COMPLETE"
@@ -386,6 +391,7 @@ async def get_project_upload_overview(
required_complete=stage0_complete
or (_REQUIRED_FILE_TYPES <= file_types and point_cloud_id is not None),
analysis_complete=analysis_complete,
analysis_lock=UploadLockInfo(**lock) if lock else None,
)
except Exception:
logger.exception("B03 업로드 현황 조회 실패: project_id=%s", project_id)
@@ -135,6 +135,7 @@ async def create_project_upload_session(
project_id=project_id,
input_file_id=point_cloud_input_id,
user_role=str(session["role"]),
started_by=session.get("user_id"),
),
task_name=f"b04-preprocess-auto-{project_id}",
)
@@ -333,6 +334,7 @@ async def finalize_project_upload(
project_id=project_id,
input_file_id=point_cloud_input_id,
user_role=str(session["role"]),
started_by=session.get("user_id"),
),
task_name=f"b04-preprocess-auto-{project_id}",
)
@@ -484,6 +484,7 @@ async def attach_temp_batch(
project_id=project_id,
input_file_id=point_cloud_input_id,
user_role=str(session["role"]),
started_by=session.get("user_id"),
),
task_name=f"b04-preprocess-auto-{project_id}",
)
+13
View File
@@ -154,6 +154,16 @@ class UploadOverviewSession(BaseModel):
updated_at: str | None = None
class UploadLockInfo(BaseModel):
"""지금 분석이 도는 중이면 **누가 시작했는지**. 도는 중이 아니면 응답에 안 실린다."""
running: bool = True
user_id: int | None = None
name: str | None = None
email: str | None = None
started_at: str | None = None
class UploadOverviewResponse(BaseModel):
"""B03 재접속 시 업로드 현황 — localStorage가 아니라 이 응답이 정본이다.
@@ -167,3 +177,6 @@ class UploadOverviewResponse(BaseModel):
pending_sessions: list[UploadOverviewSession]
required_complete: bool
analysis_complete: bool
# 잠금 표시(2026-09-09, 계획서 0-8) — 서버가 새 자료를 막는 동안 화면이 **왜·누구 때문에**
# 막혔는지 보이게 한다. 도는 중이 아니면 `None`.
analysis_lock: UploadLockInfo | None = None
@@ -53,6 +53,7 @@ async def trigger_wf1_analysis_and_email(
project_id: UUID,
input_file_id: int,
user_role: str,
started_by: int | None = None,
) -> None:
"""WF1 분석·DB 저장 후 자동 확정하고 이메일을 발송한다.
@@ -72,6 +73,9 @@ async def trigger_wf1_analysis_and_email(
"source_filters": None,
"methods": methods,
"force": False,
# 잠금에 **이름을 붙이는 값** — 「누가 올리는 중인지」를 화면이 이것으로 읽는다
# (2026-09-09, 계획서 0-8). 없으면 잠금만 돌고 이름은 안 뜬다.
"started_by": started_by,
}
async with pool.acquire() as connection:
async with connection.cursor() as cursor:
+13
View File
@@ -405,6 +405,19 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
for (const slot of slots.keys()) renderSlot(slot);
const notes: HTMLElement[] = [];
// 잠금 표시 — 서버가 「분석 중이라 못 받는다」로 막는 자리를 **화면이 먼저 알린다**.
// 여럿이 한 프로젝트를 볼 때 「왜 안 올라가지」가 아니라 「누가 올리는 중이구나」가
// 되게 이름을 함께 적는다(2026-09-09, 계획서 0-8).
const lock = overview.analysis_lock;
if (lock?.running) {
const who = lock.name ?? lock.email ?? null;
const busy = document.createElement("p");
busy.className = "b03-file__overview-lock";
busy.textContent = who
? `${who} 님이 올린 자료를 분석하는 중입니다 — 끝난 뒤에 새 자료를 올릴 수 있습니다.`
: "다른 자리에서 올린 자료를 분석하는 중입니다 — 끝난 뒤에 새 자료를 올릴 수 있습니다.";
notes.push(busy);
}
if (overview.required_complete && overview.analysis_complete) {
const complete = document.createElement("p");
complete.className = "b03-file__overview-complete";
+7
View File
@@ -179,6 +179,13 @@
color: var(--color-text-secondary);
}
/* 잠금 표시 — 지금 누가 올리는 중인지. 막힌 까닭이라 눈에 띄게 둔다. */
.b03-file__overview-lock {
margin: 0;
color: var(--color-warning, #d98324);
font-weight: 600;
}
/* 완료 슬롯 재업로드 확인 모달 — 페이지 위를 덮는 단순 확인창. */
.b03-file__modal-backdrop {
position: fixed;