"use server"; import fs from "fs/promises"; import { createReadStream, createWriteStream } from "fs"; import { Readable } from "stream"; import { pipeline } from "stream/promises"; import path from "path"; import { v4 as uuidv4 } from "uuid"; import db from "@/db/db"; import { dolceSyncList } from "@/db/schema/dolce/dolce"; import { eq, and } from "drizzle-orm"; import { dolceApiCall, uploadFilesToDetailDrawing as apiUploadFiles, saveB4MappingBatch as apiSaveB4Mapping, DetailDwgEditRequest, B4MappingSaveItem } from "@/lib/dolce/actions"; // 기존 API 호출 로직 재사용 (타입 등) const LOCAL_UPLOAD_DIR = process.env.DOLCE_LOCAL_UPLOAD_ABSOLUTE_DIRECTORY || "/evcp/data/dolce"; // 파일 저장 결과 인터페이스 interface SavedFile { originalName: string; localPath: string; size: number; mimeType?: string; } /** * 로컬 디렉토리 준비 */ async function ensureUploadDir() { try { await fs.access(LOCAL_UPLOAD_DIR); } catch { await fs.mkdir(LOCAL_UPLOAD_DIR, { recursive: true }); } } /** * 로컬에 파일 저장 */ async function saveFileToLocal(file: File): Promise { await ensureUploadDir(); const uniqueName = `${uuidv4()}_${file.name}`; const localPath = path.join(LOCAL_UPLOAD_DIR, uniqueName); // Stream: Web Stream (file.stream()) -> Node Writable Stream (fs.createWriteStream) // Readable.fromWeb requires Node 18+ const readable = Readable.fromWeb(file.stream() as any); const writable = createWriteStream(localPath); await pipeline(readable, writable); return { originalName: file.name, localPath, size: file.size, mimeType: file.type, }; } /** * 동기화 아이템 DB 저장 (버퍼링) */ export async function saveToLocalBuffer(params: { type: "ADD_DETAIL" | "MOD_DETAIL" | "ADD_FILE" | "B4_BULK"; projectNo: string; userId: string; userName?: string; // [추가] vendorCode?: string; // [추가] drawingNo?: string; uploadId?: string; // 상세도면 추가/수정/파일추가 시 필수 metaData: any; // API 호출에 필요한 데이터 files?: File[]; // 업로드할 파일들 (있으면 로컬 저장) }) { const { type, projectNo, userId, userName, vendorCode, drawingNo, uploadId, metaData, files } = params; // 1. 파일 로컬 저장 처리 const savedFiles: SavedFile[] = []; if (files && files.length > 0) { for (const file of files) { const saved = await saveFileToLocal(file); savedFiles.push(saved); } } // 2. Payload 구성 const payload = { meta: metaData, files: savedFiles, }; // 3. DB 저장 const [inserted] = await db.insert(dolceSyncList).values({ type, projectNo, drawingNo, uploadId, userId, userName, // [추가] vendorCode, // [추가] payload, isSynced: false, }).returning(); return inserted; } /** * 개별 아이템 동기화 실행 */ export async function syncItem(id: string) { // 1. 아이템 조회 const item = await db.query.dolceSyncList.findFirst({ where: eq(dolceSyncList.id, id), }); if (!item) throw new Error("Item not found"); if (item.isSynced) return { success: true, message: "Already synced" }; const payload = item.payload as { meta: any; files: SavedFile[] }; const { meta, files } = payload; try { // 2. 타입별 API 호출 수행 if (item.type === "ADD_DETAIL" || item.type === "MOD_DETAIL") { // 상세도면 추가/수정 // meta: { dwgList: DetailDwgEditRequest[], userId, userNm, vendorCode, email } // 상세도면 메타데이터 전송 await dolceApiCall("DetailDwgReceiptMgmtEdit", { DwgList: meta.dwgList, UserID: meta.userId, UserNM: meta.userNm, VENDORCODE: meta.vendorCode, EMAIL: meta.email, }); // 파일이 있다면 전송 (ADD_DETAIL의 경우) if (files && files.length > 0) { // uploadId는 meta.dwgList[0].UploadId 에 있다고 가정 const uploadId = meta.dwgList[0]?.UploadId; if (uploadId) { await uploadLocalFiles(uploadId, meta.userId, files); } } } else if (item.type === "ADD_FILE") { // 파일 추가 // meta: { uploadId, userId } await uploadLocalFiles(meta.uploadId, meta.userId, files); } else if (item.type === "B4_BULK") { // B4 일괄 업로드 (메타데이터 + 파일) // meta: { mappingSaveLists: B4MappingSaveItem[], userInfo: {...} } // 파일 먼저 업로드 (각 파일별로 uploadId가 다를 수 있음 - payload 구조에 따라 다름) // B4 Bulk의 경우, meta.mappingSaveLists에 UploadId가 있고, files와 1:1 매칭되거나 그룹핑되어야 함. // 여기서는 복잡도를 줄이기 위해, payload.files 순서와 mappingSaveLists 순서가 같거나 // meta 정보 안에 파일 매핑 정보가 있다고 가정해야 함. // *설계 단순화*: B4 Bulk의 경우 파일별로 saveToLocalBuffer를 따로 부르지 않고 한방에 불렀다면, // 여기서 순회하며 처리. // 1. 파일 업로드 // B4 일괄 업로드 로직은 파일 업로드 -> 결과 수신 -> 매핑 저장 순서임. // 하지만 여기서는 이미 메타데이터가 만들어져 있으므로, // 파일 업로드(UploadId 기준) -> 매핑 저장 순으로 진행. // 파일마다 UploadId가 다를 수 있으므로 Grouping 필요 const fileMap = new Map(); files.forEach(f => fileMap.set(f.originalName, f)); // UploadId별 파일 그룹핑 const uploadGroups = new Map(); for (const mapping of meta.mappingSaveLists as B4MappingSaveItem[]) { if (!uploadGroups.has(mapping.UploadId)) { uploadGroups.set(mapping.UploadId, { userId: meta.userInfo.userId, files: [] }); } const savedFile = fileMap.get(mapping.FileNm); if (savedFile) { uploadGroups.get(mapping.UploadId)!.files.push(savedFile); } } // 그룹별 파일 업로드 수행 for (const [uploadId, group] of uploadGroups.entries()) { if (group.files.length > 0) { await uploadLocalFiles(uploadId, group.userId, group.files); } } // 2. 매핑 정보 저장 await apiSaveB4Mapping(meta.mappingSaveLists, meta.userInfo); } // 3. 성공 처리 (DB 업데이트 + 로컬 파일 삭제) await db.update(dolceSyncList) .set({ isSynced: true, syncAttempts: (item.syncAttempts || 0) + 1, responseCode: "200", response: "Success", updatedAt: new Date() }) .where(eq(dolceSyncList.id, id)); // 로컬 파일 삭제 if (files && files.length > 0) { for (const file of files) { try { await fs.unlink(file.localPath); } catch (e) { console.error(`Failed to delete local file: ${file.localPath}`, e); } } } return { success: true }; } catch (error) { console.error(`Sync failed for item ${id}:`, error); // 실패 처리 await db.update(dolceSyncList) .set({ syncAttempts: (item.syncAttempts || 0) + 1, lastError: error instanceof Error ? error.message : "Unknown error", updatedAt: new Date() }) .where(eq(dolceSyncList.id, id)); throw error; } } /** * 로컬 파일들을 실제 서버로 업로드하는 헬퍼 함수 * (기존 uploadFilesToDetailDrawing 로직을 로컬 파일용으로 변형) */ async function uploadLocalFiles(uploadId: string, userId: string, files: SavedFile[]) { // 1. 기존 파일 시퀀스 확인 등은 생략하고 바로 PWPUploadService 호출 // (기존 API 액션 재사용이 어려우므로 여기서 fetch로 직접 구현) // 기존 파일 개수 조회 (Seq 생성을 위해) const existingFiles = await dolceApiCall<{ FileInfoListResult: Array<{ FileSeq: string }>; }>("FileInfoList", { uploadId: uploadId, }); const startSeq = existingFiles.FileInfoListResult.length + 1; const uploadResults = []; const DOLCE_API_URL = process.env.DOLCE_API_URL || "http://60.100.99.217:1111"; for (let i = 0; i < files.length; i++) { const file = files[i]; const fileId = uuidv4(); // 로컬 파일 읽기 (Stream) const fileStream = createReadStream(file.localPath); // 업로드 API 호출 const uploadUrl = `${DOLCE_API_URL}/PWPUploadService.ashx?UploadId=${uploadId}&FileId=${fileId}`; const uploadResponse = await fetch(uploadUrl, { method: "POST", headers: { "Content-Type": "application/octet-stream", "Content-Length": file.size.toString() }, body: fileStream as any, // Node stream as body duplex: "half", // Required for Node streams } as RequestInit & { duplex?: string }); if (!uploadResponse.ok) throw new Error(`File upload failed: ${uploadResponse.status}`); const fileRelativePath = await uploadResponse.text(); uploadResults.push({ FileId: fileId, UploadId: uploadId, FileSeq: startSeq + i, FileName: file.originalName, FileRelativePath: fileRelativePath, FileSize: file.size, FileCreateDT: new Date().toISOString(), FileWriteDT: new Date().toISOString(), OwnerUserId: userId, }); } // 결과 통보 const resultServiceUrl = `${DOLCE_API_URL}/PWPUploadResultService.ashx?`; const resultResponse = await fetch(resultServiceUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(uploadResults), }); if (!resultResponse.ok) throw new Error("Upload notification failed"); const resultText = await resultResponse.text(); if (resultText !== "Success") throw new Error(`Upload notification failed: ${resultText}`); } /** * 로컬 파일 다운로드 (View용) */ export async function getLocalFile(fileId: string): Promise<{ buffer: Buffer; fileName: string }> { // Format: LOCAL_{id}_{index} const parts = fileId.replace("LOCAL_", "").split("_"); if (parts.length < 2) throw new Error("Invalid file ID format"); const id = parts[0]; const index = parseInt(parts[1]); const item = await db.query.dolceSyncList.findFirst({ where: eq(dolceSyncList.id, id), }); if (!item) throw new Error("Item not found"); // eslint-disable-next-line @typescript-eslint/no-explicit-any const payload = item.payload as { files: SavedFile[]; meta: any }; if (!payload.files || !payload.files[index]) { throw new Error("File not found in item"); } const file = payload.files[index]; try { const buffer = await fs.readFile(file.localPath); return { buffer, fileName: file.originalName }; } catch (e) { console.error(`Failed to read local file: ${file.localPath}`, e); throw new Error("Failed to read local file"); } } /** * 로컬 아이템 삭제 (상세도면 삭제용) * 관련 파일도 로컬 디스크에서 삭제 */ export async function deleteLocalItem(id: string) { const item = await db.query.dolceSyncList.findFirst({ where: eq(dolceSyncList.id, id), }); if (!item) return; // Delete files from disk // eslint-disable-next-line @typescript-eslint/no-explicit-any const payload = item.payload as { files?: SavedFile[] }; if (payload.files) { for (const file of payload.files) { try { await fs.unlink(file.localPath); } catch (e) { console.warn(`Failed to delete local file: ${file.localPath}`, e); } } } // Delete DB entry await db.delete(dolceSyncList).where(eq(dolceSyncList.id, id)); } /** * 로컬 파일 삭제 (개별 파일 삭제용) */ export async function deleteLocalFileFromItem(fileId: string) { // Format: LOCAL_{id}_{index} const parts = fileId.replace("LOCAL_", "").split("_"); if (parts.length < 2) throw new Error("Invalid file ID format"); const id = parts[0]; const index = parseInt(parts[1]); const item = await db.query.dolceSyncList.findFirst({ where: eq(dolceSyncList.id, id), }); if (!item) throw new Error("Item not found"); // eslint-disable-next-line @typescript-eslint/no-explicit-any const payload = item.payload as { files: SavedFile[]; meta: any }; if (!payload.files || !payload.files[index]) { return; } // Delete file from disk const fileToDelete = payload.files[index]; try { await fs.unlink(fileToDelete.localPath); } catch (e) { console.warn(`Failed to delete local file: ${fileToDelete.localPath}`, e); } // Remove from payload const newFiles = [...payload.files]; newFiles.splice(index, 1); // Remove at index // Update DB await db.update(dolceSyncList) .set({ payload: { ...payload, files: newFiles }, updatedAt: new Date() }) .where(eq(dolceSyncList.id, id)); // If no files left and it was ADD_FILE type, delete the item if (newFiles.length === 0 && item.type === "ADD_FILE") { await db.delete(dolceSyncList).where(eq(dolceSyncList.id, id)); } }