From 018ebe7f404f489d3ce0c46e3b4c2fda684c26c8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 22:43:28 +0900 Subject: [PATCH] =?UTF-8?q?feat(B03):=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=9E=A0=EA=B8=88=EC=97=90=20=EC=9D=B4=EB=A6=84=EC=9D=84=20?= =?UTF-8?q?=EB=B6=99=EC=9E=84=20=E2=80=94=20=EC=A7=80=EA=B8=88=20=EB=88=84?= =?UTF-8?q?=EA=B0=80=20=EC=98=AC=EB=A6=AC=EB=8A=94=20=EC=A4=91=EC=9D=B8?= =?UTF-8?q?=EC=A7=80=20=ED=99=94=EB=A9=B4=EC=97=90=20=EB=9C=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 계획서 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) --- B03_FileInput/B03_FileInput_Api_Fetch.ts | 11 +++ B03_FileInput/B03_FileInput_Router.py | 6 ++ B03_FileInput/B03_FileInput_Router_Chunks.py | 2 + B03_FileInput/B03_FileInput_Router_Temp.py | 1 + B03_FileInput/B03_FileInput_Schema.py | 13 +++ B03_FileInput/B03_FileInput_Service_WF1.py | 4 + B03_FileInput/B03_FileInput_UI_Page.ts | 13 +++ B03_FileInput/B03_FileInput_UI_Style.css | 7 ++ common_util/common_util_workflow_state.py | 51 +++++++++++ resources/tester/test_b03_upload_lock.py | 90 ++++++++++++++++++++ 10 files changed, 198 insertions(+) create mode 100644 resources/tester/test_b03_upload_lock.py diff --git a/B03_FileInput/B03_FileInput_Api_Fetch.ts b/B03_FileInput/B03_FileInput_Api_Fetch.ts index 8f693855..5cd23d9e 100644 --- a/B03_FileInput/B03_FileInput_Api_Fetch.ts +++ b/B03_FileInput/B03_FileInput_Api_Fetch.ts @@ -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; } /** 재접속 시 업로드 현황(정본) — 완료 파일 목록 + 중단 세션 + 완료 여부. */ diff --git a/B03_FileInput/B03_FileInput_Router.py b/B03_FileInput/B03_FileInput_Router.py index fcf2511f..345e4d6e 100644 --- a/B03_FileInput/B03_FileInput_Router.py +++ b/B03_FileInput/B03_FileInput_Router.py @@ -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) diff --git a/B03_FileInput/B03_FileInput_Router_Chunks.py b/B03_FileInput/B03_FileInput_Router_Chunks.py index 5cc09368..81cef6b1 100644 --- a/B03_FileInput/B03_FileInput_Router_Chunks.py +++ b/B03_FileInput/B03_FileInput_Router_Chunks.py @@ -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}", ) diff --git a/B03_FileInput/B03_FileInput_Router_Temp.py b/B03_FileInput/B03_FileInput_Router_Temp.py index 1551e903..5bd3a590 100644 --- a/B03_FileInput/B03_FileInput_Router_Temp.py +++ b/B03_FileInput/B03_FileInput_Router_Temp.py @@ -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}", ) diff --git a/B03_FileInput/B03_FileInput_Schema.py b/B03_FileInput/B03_FileInput_Schema.py index 64dc3701..f9ab3a63 100644 --- a/B03_FileInput/B03_FileInput_Schema.py +++ b/B03_FileInput/B03_FileInput_Schema.py @@ -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 diff --git a/B03_FileInput/B03_FileInput_Service_WF1.py b/B03_FileInput/B03_FileInput_Service_WF1.py index 053b97ac..65e3d218 100644 --- a/B03_FileInput/B03_FileInput_Service_WF1.py +++ b/B03_FileInput/B03_FileInput_Service_WF1.py @@ -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: diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index d4d4d6e2..cd9a87b9 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -405,6 +405,19 @@ export async function renderB03FileInput(root: HTMLElement): Promise { 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"; diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index 086bbfad..2f4b0d40 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -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; diff --git a/common_util/common_util_workflow_state.py b/common_util/common_util_workflow_state.py index 258fd6da..aaac39d9 100644 --- a/common_util/common_util_workflow_state.py +++ b/common_util/common_util_workflow_state.py @@ -268,6 +268,57 @@ async def is_analysis_running(cursor: aiomysql.DictCursor, project_id: str) -> b return not await _surface_run_settled(cursor, project_id) +async def analysis_lock_owner( + cursor: aiomysql.DictCursor, project_id: str +) -> Dict[str, Any] | None: + """**지금 누가 올리는 중인지** — 도는 중이 아니면 `None`. + + 잠금 자체는 `is_analysis_running` 이 판정한다. 여기서는 그 잠금에 **이름을 붙인다** — + 한 프로젝트를 여럿이 볼 때 「왜 못 올리지」가 아니라 「누가 올리는 중이구나」가 되게. + 시작한 사람은 `start_stage` 가 담아 둔 `params.started_by`(사용자 id)에서 읽고, + 이름은 그때그때 `users` 에서 가져온다(옛 자료는 id 가 없어 이름 없이 잠금만 돈다). + """ + if not await is_analysis_running(cursor, project_id): + return None + await cursor.execute( + """ + SELECT params, started_at + FROM project_workflow_stages + WHERE project_id = %s AND stage_no = 1 + """, + (project_id,), + ) + row = await cursor.fetchone() + if not row: + return None + raw = row["params"] if isinstance(row, dict) else row[0] + started_at = row["started_at"] if isinstance(row, dict) else row[1] + params: Dict[str, Any] = {} + if isinstance(raw, str): + try: + params = json.loads(raw) + except json.JSONDecodeError: + params = {} + elif isinstance(raw, dict): + params = raw + user_id = params.get("started_by") + name: str | None = None + email: str | None = None + if user_id is not None: + await cursor.execute("SELECT name, email FROM users WHERE id = %s", (user_id,)) + user = await cursor.fetchone() + if user: + name = user["name"] if isinstance(user, dict) else user[0] + email = user["email"] if isinstance(user, dict) else user[1] + return { + "running": True, + "user_id": user_id, + "name": name, + "email": email, + "started_at": started_at.isoformat() if started_at else None, + } + + # 계산이 끝나 사용자의 다음 조작을 기다리는 진행 단계 — 도는 중이 아니다. _SETTLED_SURFACE_STAGES = frozenset({"awaiting_confirmation", "completed", "failed"}) diff --git a/resources/tester/test_b03_upload_lock.py b/resources/tester/test_b03_upload_lock.py new file mode 100644 index 00000000..b04806d1 --- /dev/null +++ b/resources/tester/test_b03_upload_lock.py @@ -0,0 +1,90 @@ +"""업로드 잠금 표시 — **지금 누가 올리는 중인지** (2026-09-09, 계획서 0-8 남은 하나). + +서버는 분석이 도는 동안 새 자료를 이미 막고 있었다(`is_analysis_running`). 없던 것은 +**막힌 까닭을 화면에 보이는 것**이다 — 여럿이 한 프로젝트를 보면 「왜 안 올라가지」가 된다. + +⇒ 시작한 사람을 `start_stage` 의 `params.started_by` 에 담고, `analysis_lock_owner` 가 + 그 id 로 이름을 붙여 낸다. 옛 자료는 id 가 없어 **이름 없이 잠금만** 돈다(막는 것이 먼저). +""" + +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path +from typing import Any + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from common_util import common_util_workflow_state as state_mod # noqa: E402 + + +class _Cursor: + """질의 한 벌만 흉내 낸다 — 단계 행 하나와 사용자 한 명.""" + + def __init__(self, stage: dict[str, Any] | None, user: dict[str, Any] | None) -> None: + self.stage = stage + self.user = user + self._last = "" + + async def execute(self, sql: str, args: tuple[Any, ...] = ()) -> None: + self._last = sql + + async def fetchone(self) -> dict[str, Any] | None: + return self.user if "FROM users" in self._last else self.stage + + +@pytest.fixture +def _running(monkeypatch: pytest.MonkeyPatch): + async def _yes(cursor: Any, project_id: str) -> bool: + return True + + monkeypatch.setattr(state_mod, "is_analysis_running", _yes) + + +def test_도는_중이_아니면_잠금이_없다(monkeypatch: pytest.MonkeyPatch) -> None: + async def _no(cursor: Any, project_id: str) -> bool: + return False + + monkeypatch.setattr(state_mod, "is_analysis_running", _no) + assert asyncio.run(state_mod.analysis_lock_owner(_Cursor(None, None), "p1")) is None + + +def test_시작한_사람의_이름이_붙는다(_running) -> None: + stage = {"params": json.dumps({"started_by": 7}), "started_at": None} + cursor = _Cursor(stage, {"name": "엄상돈", "email": "a@b.c"}) + lock = asyncio.run(state_mod.analysis_lock_owner(cursor, "p1")) + assert lock is not None + assert lock["running"] is True and lock["name"] == "엄상돈" and lock["user_id"] == 7 + + +def test_옛_자료는_이름_없이_잠금만_돈다(_running) -> None: + """`started_by` 가 없던 시절 자료 — 막는 것이 먼저다. 이름이 없다고 잠금을 풀지 않는다.""" + cursor = _Cursor({"params": json.dumps({"force": False}), "started_at": None}, None) + lock = asyncio.run(state_mod.analysis_lock_owner(cursor, "p1")) + assert lock is not None and lock["running"] is True and lock["name"] is None + + +def test_올리는_경로_넷이_모두_시작한_사람을_싣는다() -> None: + """한 경로만 빠져도 그 경로로 올린 잠금은 이름이 안 뜬다 — 옛 사고와 같은 결.""" + sources = [ + ROOT / "B03_FileInput" / name + for name in ( + "B03_FileInput_Router.py", + "B03_FileInput_Router_Chunks.py", + "B03_FileInput_Router_Temp.py", + ) + ] + calls = sum( + source.read_text(encoding="utf-8").count("trigger_wf1_analysis_and_email(") + for source in sources + ) + carried = sum( + source.read_text(encoding="utf-8").count("started_by=session.get") for source in sources + ) + assert calls == carried, f"분석을 띄우는 자리 {calls} 곳 중 {carried} 곳만 시작한 사람을 싣는다"