feat: 작업실 저장 개편 — 전체문서 PUT을 프로젝트 단위 PUT으로 + LOW 2건

저장 개편(리뷰 #4 근본치료):
- PUT /api/workshop/project/:id — 서버가 디스크의 현재 문서에 해당 프로젝트만
  upsert. 대시보드 저장과 채팅 도구 저장이 겹쳐도 서로 다른 프로젝트 편집이
  통째로 사라지지 않음. DELETE /api/workshop/project/:id,
  PUT /api/workshop/active(포인터 단독) 신설. 옛 전체문서 PUT은 스테일 탭
  호환으로 유지
- 클라이언트: dirtyProjects 추적 → saveProject는 바뀐 프로젝트만 순차 PUT,
  활성 포인터는 putActive() 즉시 저장, 프로젝트 삭제는 DELETE 직접 호출,
  beforeunload flush도 프로젝트 단위 keepalive PUT으로 전환
- upsertProject/removeProject를 순수함수로 추출, 단위테스트 4건

LOW 2건:
- loadFiles에 요청 일련번호 — 빠른 프로젝트 전환 시 옛 응답이 나중에 도착해
  이전 프로젝트 파일을 새 판에 그리는 레이스 제거
- 슬라이서 인계 ?load=를 JSON 배열 인코딩으로 전환(파일명에 쉼표 있으면
  옛 쉼표 join이 깨지던 문제). 슬라이서 쪽은 JSON 우선, 옛 형식 폴백 유지

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-24 07:38:53 +09:00
co-authored by Claude Code
parent f42a09b038
commit eb516e2b74
5 changed files with 170 additions and 9 deletions
+50 -1
View File
@@ -14,7 +14,7 @@ import express from 'express';
import fs from 'fs';
import path from 'path';
import { getUserWorkspace } from '../../config/config';
import { loadWorkshop, saveWorkshop } from './workshop-storage';
import { loadWorkshop, saveWorkshop, upsertProject, removeProject } from './workshop-storage';
import { caseFilesDir, resolveUploadPath, sanitizePathSegment, walkCaseFiles, fileCategory } from './case-storage';
const APP_TYPE = 'workshop';
@@ -54,6 +54,55 @@ export function registerWorkshopRoutes(
res.json({ success: true });
});
// ── 프로젝트 단위 저장 API(2026-09-24) ─────────────────────────────────────
// 예전의 전체문서 PUT(last-write-wins)은 대시보드 저장과 채팅 도구 저장이 겹치면
// 한쪽 편집이 통째로 사라졌다. 편집 단위는 프로젝트 하나이므로 이제 프로젝트만
// 부분 저장한다 — 서버가 디스크의 현재 문서를 읽어 해당 프로젝트만 upsert하므로
// 다른 프로젝트(다른 작성자가 방금 저장한 것 포함)는 절대 덮이지 않는다.
app.put('/api/workshop/project/:id', (req, res) => {
const session = getSessionUser(req);
if (!session) return res.status(401).json({ error: 'Unauthorized' });
const id = String(req.params.id || '');
if (!/^[a-zA-Z0-9_-]+$/.test(id)) return res.status(400).json({ error: 'invalid projectId' });
const incoming = req.body || {};
if (incoming.id !== id) return res.status(400).json({ error: 'body.id must match :id' });
if (!Array.isArray(incoming.parts) || !Array.isArray(incoming.phases) || !String(incoming.name || '').trim()) {
return res.status(400).json({ error: 'name/parts/phases required' });
}
const workspace = getUserWorkspace(session.username);
const data = loadWorkshop(workspace);
upsertProject(data, { ...incoming, id });
saveWorkshop(workspace, data);
res.json({ success: true });
});
app.delete('/api/workshop/project/:id', (req, res) => {
const session = getSessionUser(req);
if (!session) return res.status(401).json({ error: 'Unauthorized' });
const id = String(req.params.id || '');
if (!/^[a-zA-Z0-9_-]+$/.test(id)) return res.status(400).json({ error: 'invalid projectId' });
const workspace = getUserWorkspace(session.username);
const data = loadWorkshop(workspace);
if (!removeProject(data, id)) return res.status(404).json({ error: 'not found' });
saveWorkshop(workspace, data);
res.json({ success: true });
});
// 활성 프로젝트 포인터만 단독 저장 — 포인터는 값 하나라 last-write-wins로 충분하다.
app.put('/api/workshop/active', (req, res) => {
const session = getSessionUser(req);
if (!session) return res.status(401).json({ error: 'Unauthorized' });
const workspace = getUserWorkspace(session.username);
const data = loadWorkshop(workspace);
const wanted = String(req.body?.activeProjectId || '');
if (wanted && !data.projects.some(p => p.id === wanted)) {
return res.status(400).json({ error: 'unknown activeProjectId' });
}
data.activeProjectId = wanted || data.projects[0]?.id || '';
saveWorkshop(workspace, data);
res.json({ success: true, activeProjectId: data.activeProjectId });
});
app.get('/api/workshop/files', (req, res) => {
const session = getSessionUser(req);
if (!session) return res.status(401).json({ error: 'Unauthorized' });
+23
View File
@@ -50,6 +50,29 @@ export interface WorkshopData {
activeProjectId: string;
}
// 프로젝트 단위 저장 API(routes-workshop.ts)의 핵심 로직 — 순수함수로 뽑아 단위테스트한다.
// 대시보드의 편집 단위는 프로젝트 하나이므로 서버는 디스크의 현재 문서에 해당 프로젝트만
// upsert한다. 다른 프로젝트는 건드리지 않으므로 채팅 도구와 대시보드가 동시에 저장해도
// 서로 다른 프로젝트 편집이 통째로 사라지는 일(옛 전체문서 PUT의 last-write-wins)이 없다.
export function upsertProject(data: WorkshopData, project: WorkshopProject): void {
const idx = data.projects.findIndex(p => p.id === project.id);
if (idx >= 0) data.projects[idx] = project;
else data.projects.push(project);
if (!data.activeProjectId || !data.projects.some(p => p.id === data.activeProjectId)) {
data.activeProjectId = data.projects[0]?.id || '';
}
}
// 프로젝트 삭제. 활성 포인터가 삭제된 프로젝트를 가리키면 첫 프로젝트로 되돌린다.
// 대상이 없으면 false(라우트가 404로 응답).
export function removeProject(data: WorkshopData, id: string): boolean {
const before = data.projects.length;
data.projects = data.projects.filter(p => p.id !== id);
if (data.projects.length === before) return false;
if (data.activeProjectId === id) data.activeProjectId = data.projects[0]?.id || '';
return true;
}
export function workshopPath(workspaceRoot: string): string {
return path.join(workspaceRoot, '.smallclaw', 'workshop.json');
}
+40 -1
View File
@@ -36,4 +36,43 @@ test('projectToReadme: 개요 섹션은 description이 있을 때만', () => {
test('projectToReadme: 부품 링크를 들여쓰기로 실음', () => {
const md = projectToReadme(sampleProject());
assert.ok(md.includes(' - 구매처: https://example.com'));
});
});
// 프로젝트 단위 저장 API(2026-09-24)의 upsert/remove — 서버는 디스크의 현재 문서에
// 해당 프로젝트만 반영하므로 다른 프로젝트는 절대 덮이지 않아야 한다.
import { upsertProject, removeProject, WorkshopData } from '../src/gateway/routes/workshop-storage.js';
function sampleData(): WorkshopData {
const a = sampleProject({ id: 'proj_a', name: 'A' });
const b = sampleProject({ id: 'proj_b', name: 'B' });
return { projects: [a, b], activeProjectId: 'proj_a' };
}
test('upsertProject: 기존 프로젝트는 교체, 나머지는 보존', () => {
const data = sampleData();
const edited = sampleProject({ id: 'proj_b', name: 'B-수정됨', notes: '채팅 도구가 저장' });
upsertProject(data, edited);
assert.equal(data.projects.length, 2);
assert.equal(data.projects[1].name, 'B-수정됨');
// 다른 프로젝트(A)의 내용은 그대로 — 전체문서 PUT이던 시절엔 여기가 유실됐다
assert.equal(data.projects[0].name, 'A');
assert.equal(data.projects[0].parts[0].name, 'STS3215 서보');
});
test('upsertProject: 없는 프로젝트는 추가, 활성 포인터가 무효하면 복구', () => {
const data = sampleData();
data.activeProjectId = 'proj_gone';
upsertProject(data, sampleProject({ id: 'proj_new', name: '새것' }));
assert.equal(data.projects.length, 3);
assert.equal(data.activeProjectId, 'proj_a');
});
test('removeProject: 삭제되면 true, 활성 포인터가 가리키던 프로젝트면 첫 프로젝트로', () => {
const data = sampleData();
assert.equal(removeProject(data, 'proj_a'), true);
assert.equal(data.projects.length, 1);
assert.equal(data.activeProjectId, 'proj_b');
});
test('removeProject: 없는 프로젝트면 false(404 응답용)', () => {
assert.equal(removeProject(sampleData(), 'proj_none'), false);
});
+47 -4
View File
@@ -331,6 +331,11 @@ let notes='';
let overviewDesc='';
let saveTimer=null;
let saveDebounce=null;
// 저장 개편(2026-09-24): 전체문서 PUT 대신 바뀐 프로젝트만 개별 PUT한다 — 서버가
// 디스크의 현재 문서에 해당 프로젝트만 upsert하므로, 채팅 도구가 다른 프로젝트를
// 동시에 저장해도 한쪽 편집이 통째로 사라지는 일이 없다. 어디가 더러운지는
// dirtyProjects로 추적하고 saveProject()가 그 대상만 순차 전송한다.
const dirtyProjects=new Set();
function currentProject(){
return workshopData.projects.find(p=>p.id===workshopData.activeProjectId)||workshopData.projects[0];
@@ -363,6 +368,7 @@ function syncLocalToProject(){
proj.phases=phases;
proj.notes=notes;
proj.description=overviewDesc;
dirtyProjects.add(proj.id);
}
function renderProjectSelector(){
@@ -390,6 +396,7 @@ function switchProject(id){
clearStlSelection();
refreshAllPanes();
scheduleSave();
putActive();
refreshChatForProject();
if(document.getElementById('pane-files').classList.contains('active'))loadFiles();
}
@@ -404,10 +411,12 @@ function createProjectPrompt(){
const id=genId('proj');
workshopData.projects.push({id,name,parts:[],phases:[],notes:'',description:''});
workshopData.activeProjectId=id;
dirtyProjects.add(id); // 새 프로젝트도 서버에 upsert돼야 함 — 직전 프로젝트는 sync가 dirty 마킹
loadLocalFromProject();
clearStlSelection();
refreshAllPanes();
scheduleSave();
putActive();
refreshChatForProject();
if(document.getElementById('pane-files').classList.contains('active'))loadFiles();
}
@@ -417,12 +426,17 @@ function deleteProjectPrompt(){
if(!proj)return;
if(workshopData.projects.length<=1){alert('마지막 남은 프로젝트는 삭제할 수 없습니다.');return;}
if(!confirm(`"${proj.name}" 프로젝트를 삭제할까요? 되돌릴 수 없습니다.`))return;
dirtyProjects.delete(proj.id);
workshopData.projects=workshopData.projects.filter(p=>p.id!==proj.id);
workshopData.activeProjectId=workshopData.projects[0].id;
// 삭제는 프로젝트 단위 저장 API로 직접 반영 — 저장(debounce)이 못 건드리는
// 이미 사라진 프로젝트를 PUT하면 404 소음만 생긴다.
fetch('/api/workshop/project/'+encodeURIComponent(proj.id),{method:'DELETE',headers:authH()}).catch(()=>{});
loadLocalFromProject();
clearStlSelection();
refreshAllPanes();
scheduleSave();
putActive();
refreshChatForProject();
if(document.getElementById('pane-files').classList.contains('active'))loadFiles();
}
@@ -454,11 +468,17 @@ function scheduleSave(){
// 닫아버리면 마지막 편집이 그대로 사라진다. unload 중엔 일반 fetch가 버려질 수 있어
// keepalive:true로 브라우저에 전송을 보장받는다. beforeunload(닫기 직전)와
// pagehide(실제 내비게이션 확정) 둘 다에 걸고, saveDebounce 널 처리로 이중 실행을 막는다.
// 저장 개편에 맞춰 더러운 프로젝트마다 개별 upsert로 보낸다(응답을 기다리지 않음).
function flushPendingSave(){
if(!saveDebounce)return;
clearTimeout(saveDebounce);saveDebounce=null;
syncLocalToProject();
fetch('/api/workshop/data',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({projects:workshopData.projects,activeProjectId:workshopData.activeProjectId}),keepalive:true});
for(const id of dirtyProjects){
const p=workshopData.projects.find(x=>x.id===id);
if(!p)continue;
fetch('/api/workshop/project/'+encodeURIComponent(id),{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(p),keepalive:true});
}
dirtyProjects.clear();
}
window.addEventListener('beforeunload',flushPendingSave);
window.addEventListener('pagehide',flushPendingSave);
@@ -505,12 +525,28 @@ async function softRefreshWorkshop(){
async function saveProject(){
syncLocalToProject();
const ids=[...dirtyProjects];
dirtyProjects.clear();
if(!ids.length)return;
try{
await fetch('/api/workshop/data',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({projects:workshopData.projects,activeProjectId:workshopData.activeProjectId})});
// 바뀐 프로젝트만 개별 upsert — 다른 프로젝트는 서버 디스크 값 그대로 보존.
for(const id of ids){
const p=workshopData.projects.find(x=>x.id===id);
if(!p)continue; // 이번 턴에 삭제된 프로젝트 — deleteProjectPrompt가 서버 DELETE로 처리
const r=await fetch('/api/workshop/project/'+encodeURIComponent(id),{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(p)});
if(!r.ok)throw new Error('HTTP '+r.status);
}
showSaveIndicator('저장됨');
}catch{showSaveIndicator('저장 실패');}
}
// 활성 프로젝트 포인터는 값 하나라 별도 즉시 저장(debounce 없음) — switch/create/delete에서 호출.
async function putActive(){
try{
await fetch('/api/workshop/active',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({activeProjectId:workshopData.activeProjectId})});
}catch{}
}
function showSaveIndicator(msg){
const el=document.getElementById('save-indicator');
if(!el)return;
@@ -874,19 +910,24 @@ function deleteTask(phaseId,taskId){
const FILE_ICONS={photo:'🖼️',video:'🎬',document:'📄',other:'📦'};
let filesState={files:[],folders:[]};
let stlSelection=new Set(); // 슬라이서로 보낼 STL relPath 다중선택(09-22, K2 슬라이서 연동)
// 파일 목록 요청 일련번호 — 빠른 프로젝트 전환 시 오래된 응답이 나중에 도착해
// 이전 프로젝트 파일 목록을 새 판에 그리는 레이스를 막는다(응답 도착 시점 검사).
let filesLoadSeq=0;
async function loadFiles(){
const proj=currentProject();
if(!proj)return;
const seq=++filesLoadSeq;
const container=document.getElementById('files-list-container');
try{
const r=await fetch('/api/workshop/files?projectId='+encodeURIComponent(proj.id),{headers:authH()});
if(!r.ok)throw new Error('불러오기 실패');
const d=await r.json();
if(seq!==filesLoadSeq)return; // 도착 사이에 더 새로운 요청이 갔다 — 옛 프로젝트 데이터라 폐기
filesState.files=Array.isArray(d.files)?d.files:[];
filesState.folders=Array.isArray(d.folders)?d.folders:[];
}catch{
container.innerHTML='<div class="rb-files-empty">파일 목록을 불러오지 못했습니다.</div>';
if(seq===filesLoadSeq)container.innerHTML='<div class="rb-files-empty">파일 목록을 불러오지 못했습니다.</div>';
return;
}
// 삭제된 파일이 남아있지 않게 선택 상태 정리.
@@ -1005,7 +1046,9 @@ function openSlicerForSelected(){
if(!paths.length)return;
const w=1320,h=860;
const left=Math.max(0,(screen.width-w)/2), top=Math.max(0,(screen.height-h)/2);
window.open('/html/slicer-app.html?load='+encodeURIComponent(paths.join(',')),'k2-slicer',`width=${w},height=${h},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no,resizable=yes`);
// 쉼표가 파일명에 있으면 join(',') 구분자와 겹쳐 슬라이서 쪽 split(',')에서 깨지므로
// JSON 배열로 인코딩한다(슬라이서 쪽은 JSON 우선, 옛 쉼표 형식도 폴백 해석).
window.open('/html/slicer-app.html?load='+encodeURIComponent(JSON.stringify(paths)),'k2-slicer',`width=${w},height=${h},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no,resizable=yes`);
}
function fmtFileSize(n){
+10 -3
View File
@@ -1144,13 +1144,20 @@
}
// 다른 앱(작업실 파일 탭 등)에서 워크스페이스 STL을 바로 슬라이서 판에 얹어주도록
// ?load=<워크스페이스 상대경로>[,경로2,...] 지원(09-22, cad-editor-view.js의 ?path=
// 패턴과 동일한 취지). /api/cad/file?path= 로 다운로드해 loadModel()에 그대로 넣는다 —
// ?load=<경로들> 지원(09-22, cad-editor-view.js의 ?path= 패턴과 동일한 취지).
// 경로 구분은 JSON 배열 우선(2026-09-24) — 파일명에 쉼표가 있으면 옛 쉼표 join이
// 깨지므로. JSON이 아니면 예전 형식(쉼표 join)으로 폴백 해석해 옛 링크도 유지.
// /api/cad/file?path= 로 다운로드해 loadModel()에 그대로 넣는다 —
// 파일 input change 핸들러(1013줄 근처)와 배치 로직을 공유.
async function loadFromUrlParams() {
const loadParam = new URLSearchParams(location.search).get('load');
if (!loadParam) return;
const paths = loadParam.split(',').map((s) => s.trim()).filter(Boolean);
let paths;
try {
const parsed = JSON.parse(loadParam);
paths = Array.isArray(parsed) ? parsed.map(String) : null;
} catch { paths = null; }
if (!paths) paths = loadParam.split(',').map((s) => s.trim()).filter(Boolean);
if (!paths.length) return;
setInfo(`📂 서버에서 ${paths.length}개 파일 불러오는 중…`);
let placed = 0;