ERP-node/backend-node/src/controllers/fileController.ts

665 lines
21 KiB
TypeScript
Raw Normal View History

2025-09-05 21:52:19 +09:00
import { Request, Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import multer from "multer";
import path from "path";
import fs from "fs";
import { PrismaClient } from "@prisma/client";
import { generateUUID } from "../utils/generateId";
const prisma = new PrismaClient();
// 업로드 디렉토리 설정 (회사별로 분리)
const baseUploadDir = path.join(process.cwd(), "uploads");
2025-09-18 18:38:13 +09:00
// 디렉토리 생성 함수 (에러 핸들링 포함)
const ensureUploadDir = () => {
try {
if (!fs.existsSync(baseUploadDir)) {
fs.mkdirSync(baseUploadDir, { recursive: true });
}
} catch (error) {
console.warn(
`업로드 디렉토리 생성 실패: ${error}. 기존 디렉토리를 사용합니다.`
);
}
};
// 초기화 시 디렉토리 확인
ensureUploadDir();
2025-09-05 21:52:19 +09:00
// 회사별 + 날짜별 디렉토리 생성 함수
const getCompanyUploadDir = (companyCode: string, dateFolder?: string) => {
// 회사코드가 *인 경우 company_*로 변환
const actualCompanyCode = companyCode === "*" ? "company_*" : companyCode;
// 날짜 폴더가 제공되지 않은 경우 오늘 날짜 사용 (YYYY/MM/DD 형식)
if (!dateFolder) {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, "0");
const day = String(today.getDate()).padStart(2, "0");
dateFolder = `${year}/${month}/${day}`;
}
const companyDir = path.join(baseUploadDir, actualCompanyCode, dateFolder);
if (!fs.existsSync(companyDir)) {
fs.mkdirSync(companyDir, { recursive: true });
}
return companyDir;
};
// Multer 설정
const storage = multer.diskStorage({
destination: (req, file, cb) => {
// 임시 디렉토리에 저장 (나중에 올바른 위치로 이동)
const tempDir = path.join(baseUploadDir, "temp");
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
cb(null, tempDir);
},
filename: (req, file, cb) => {
// 타임스탬프_원본파일명 형태로 저장 (회사코드는 디렉토리로 분리됨)
const timestamp = Date.now();
console.log("📁 파일명 처리:", {
originalname: file.originalname,
encoding: file.encoding,
mimetype: file.mimetype
});
// UTF-8 인코딩 문제 해결: Buffer를 통한 올바른 디코딩
let decodedName;
try {
// 파일명이 깨진 경우 Buffer를 통해 올바르게 디코딩
const buffer = Buffer.from(file.originalname, 'latin1');
decodedName = buffer.toString('utf8');
console.log("📁 파일명 디코딩:", { original: file.originalname, decoded: decodedName });
} catch (error) {
// 디코딩 실패 시 원본 사용
decodedName = file.originalname;
console.log("📁 파일명 디코딩 실패, 원본 사용:", file.originalname);
}
// 한국어를 포함한 유니코드 문자 보존하면서 안전한 파일명 생성
// 위험한 문자만 제거: / \ : * ? " < > |
const sanitizedName = decodedName
.replace(/[\/\\:*?"<>|]/g, "_") // 파일시스템에서 금지된 문자만 치환
.replace(/\s+/g, "_") // 공백을 언더스코어로 치환
.replace(/_{2,}/g, "_"); // 연속된 언더스코어를 하나로 축약
2025-09-05 21:52:19 +09:00
const savedFileName = `${timestamp}_${sanitizedName}`;
console.log("📁 파일명 변환:", {
original: file.originalname,
sanitized: sanitizedName,
saved: savedFileName
});
2025-09-05 21:52:19 +09:00
cb(null, savedFileName);
},
});
const upload = multer({
storage: storage,
limits: {
fileSize: 50 * 1024 * 1024, // 50MB 제한
},
fileFilter: (req, file, cb) => {
// 프론트엔드에서 전송된 accept 정보 확인
const acceptHeader = req.body?.accept;
// 프론트엔드에서 */* 또는 * 허용한 경우 모든 파일 허용
if (
acceptHeader &&
(acceptHeader.includes("*/*") || acceptHeader.includes("*"))
) {
cb(null, true);
return;
}
// 기본 허용 파일 타입
const defaultAllowedTypes = [
// 이미지 파일
2025-09-05 21:52:19 +09:00
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/svg+xml",
// 텍스트 파일
"text/html",
"text/plain",
"text/markdown",
"text/csv",
"application/json",
"application/xml",
// PDF 파일
2025-09-05 21:52:19 +09:00
"application/pdf",
// Microsoft Office 파일
"application/msword", // .doc
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
"application/vnd.ms-excel", // .xls
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // .xlsx
"application/vnd.ms-powerpoint", // .ppt
"application/vnd.openxmlformats-officedocument.presentationml.presentation", // .pptx
// 한컴오피스 파일
"application/x-hwp", // .hwp (한글)
"application/haansofthwp", // .hwp (다른 MIME 타입)
"application/vnd.hancom.hwp", // .hwp (또 다른 MIME 타입)
"application/vnd.hancom.hwpx", // .hwpx (한글 2014+)
"application/x-hwpml", // .hwpml (한글 XML)
"application/vnd.hancom.hcdt", // .hcdt (한셀)
"application/vnd.hancom.hpt", // .hpt (한쇼)
"application/octet-stream", // .hwp, .hwpx (일반적인 바이너리 파일)
// 압축 파일
"application/zip",
"application/x-zip-compressed",
"application/x-rar-compressed",
"application/x-7z-compressed",
// 미디어 파일
"video/mp4",
"video/webm",
"video/ogg",
"audio/mp3",
"audio/mpeg",
"audio/wav",
"audio/ogg",
// Apple/맥 파일
"application/vnd.apple.pages", // .pages (Pages)
"application/vnd.apple.numbers", // .numbers (Numbers)
"application/vnd.apple.keynote", // .keynote (Keynote)
"application/x-iwork-pages-sffpages", // .pages (다른 MIME)
"application/x-iwork-numbers-sffnumbers", // .numbers (다른 MIME)
"application/x-iwork-keynote-sffkey", // .keynote (다른 MIME)
"application/vnd.apple.installer+xml", // .pkg (맥 설치 파일)
"application/x-apple-diskimage", // .dmg (맥 디스크 이미지)
// 기타 문서
"application/rtf", // .rtf
"application/vnd.oasis.opendocument.text", // .odt
"application/vnd.oasis.opendocument.spreadsheet", // .ods
"application/vnd.oasis.opendocument.presentation", // .odp
2025-09-05 21:52:19 +09:00
];
if (defaultAllowedTypes.includes(file.mimetype)) {
2025-09-05 21:52:19 +09:00
cb(null, true);
} else {
cb(new Error("허용되지 않는 파일 타입입니다."));
}
},
});
/**
* attach_file_info
*/
export const uploadFiles = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
if (!req.files || (req.files as Express.Multer.File[]).length === 0) {
res.status(400).json({
success: false,
message: "업로드할 파일이 없습니다.",
});
return;
}
const files = req.files as Express.Multer.File[];
2025-09-05 21:52:19 +09:00
const {
docType = "DOCUMENT",
docTypeName = "일반 문서",
targetObjid,
parentTargetObjid,
// 테이블 연결 정보 (새로 추가)
linkedTable,
linkedField,
recordId,
autoLink,
// 가상 파일 컬럼 정보
columnName,
isVirtualFileColumn,
2025-09-05 21:52:19 +09:00
} = req.body;
// 회사코드와 작성자 정보 결정 (우선순위: 요청 body > 사용자 토큰 정보 > 기본값)
const companyCode =
req.body.companyCode || (req.user as any)?.companyCode || "DEFAULT";
const writer = req.body.writer || (req.user as any)?.userId || "system";
// 자동 연결 로직 - target_objid 자동 생성
let finalTargetObjid = targetObjid;
if (autoLink === "true" && linkedTable && recordId) {
// 가상 파일 컬럼의 경우 컬럼명도 포함한 target_objid 생성
if (isVirtualFileColumn === "true" && columnName) {
finalTargetObjid = `${linkedTable}:${recordId}:${columnName}`;
} else {
finalTargetObjid = `${linkedTable}:${recordId}`;
}
}
2025-09-05 21:52:19 +09:00
const savedFiles = [];
for (const file of files) {
// 파일명 디코딩 (파일 저장 시와 동일한 로직)
let decodedOriginalName;
try {
const buffer = Buffer.from(file.originalname, 'latin1');
decodedOriginalName = buffer.toString('utf8');
console.log("💾 DB 저장용 파일명 디코딩:", { original: file.originalname, decoded: decodedOriginalName });
} catch (error) {
decodedOriginalName = file.originalname;
console.log("💾 DB 저장용 파일명 디코딩 실패, 원본 사용:", file.originalname);
}
2025-09-05 21:52:19 +09:00
// 파일 확장자 추출
const fileExt = path
.extname(decodedOriginalName)
2025-09-05 21:52:19 +09:00
.toLowerCase()
.replace(".", "");
// 파일 경로 설정 (회사별 + 날짜별 디렉토리 구조 반영)
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, "0");
const day = String(today.getDate()).padStart(2, "0");
const dateFolder = `${year}/${month}/${day}`;
// 회사코드가 *인 경우 company_*로 변환
const actualCompanyCode = companyCode === "*" ? "company_*" : companyCode;
const relativePath = `/${actualCompanyCode}/${dateFolder}/${file.filename}`;
const fullFilePath = `/uploads${relativePath}`;
// 임시 파일을 최종 위치로 이동
const tempFilePath = file.path; // Multer가 저장한 임시 파일 경로
const finalUploadDir = getCompanyUploadDir(companyCode, dateFolder);
const finalFilePath = path.join(finalUploadDir, file.filename);
// 파일 이동
fs.renameSync(tempFilePath, finalFilePath);
// attach_file_info 테이블에 저장
const fileRecord = await prisma.attach_file_info.create({
data: {
objid: parseInt(
generateUUID().replace(/-/g, "").substring(0, 15),
16
),
target_objid: finalTargetObjid,
2025-09-05 21:52:19 +09:00
saved_file_name: file.filename,
real_file_name: decodedOriginalName,
2025-09-05 21:52:19 +09:00
doc_type: docType,
doc_type_name: docTypeName,
file_size: file.size,
file_ext: fileExt,
file_path: fullFilePath, // 회사별 디렉토리 포함된 경로
company_code: companyCode, // 회사코드 추가
writer: writer,
regdate: new Date(),
status: "ACTIVE",
parent_target_objid: parentTargetObjid,
},
});
savedFiles.push({
objid: fileRecord.objid.toString(),
savedFileName: fileRecord.saved_file_name,
realFileName: fileRecord.real_file_name,
fileSize: Number(fileRecord.file_size),
fileExt: fileRecord.file_ext,
filePath: fileRecord.file_path,
docType: fileRecord.doc_type,
docTypeName: fileRecord.doc_type_name,
targetObjid: fileRecord.target_objid,
parentTargetObjid: fileRecord.parent_target_objid,
companyCode: companyCode, // 실제 전달받은 회사코드
writer: fileRecord.writer,
regdate: fileRecord.regdate?.toISOString(),
status: fileRecord.status,
});
}
res.json({
success: true,
message: `${files.length}개 파일 업로드 완료`,
files: savedFiles,
});
} catch (error) {
console.error("파일 업로드 오류:", error);
res.status(500).json({
success: false,
message: "파일 업로드 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
};
/**
* ( )
*/
export const deleteFile = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { objid } = req.params;
const { writer = "system" } = req.body;
// 파일 상태를 DELETED로 변경 (논리적 삭제)
const deletedFile = await prisma.attach_file_info.update({
where: {
objid: parseInt(objid),
},
data: {
status: "DELETED",
},
});
res.json({
success: true,
message: "파일이 삭제되었습니다.",
});
} catch (error) {
console.error("파일 삭제 오류:", error);
res.status(500).json({
success: false,
message: "파일 삭제 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
};
/**
*
*/
export const getLinkedFiles = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { tableName, recordId } = req.params;
// target_objid 생성 (테이블명:레코드ID 형식)
const baseTargetObjid = `${tableName}:${recordId}`;
// 기본 target_objid와 파일 컬럼 패턴 모두 조회 (tableName:recordId% 패턴)
const files = await prisma.attach_file_info.findMany({
where: {
target_objid: {
startsWith: baseTargetObjid, // tableName:recordId로 시작하는 모든 파일
},
status: "ACTIVE",
},
orderBy: {
regdate: "desc",
},
});
const fileList = files.map((file: any) => ({
objid: file.objid.toString(),
savedFileName: file.saved_file_name,
realFileName: file.real_file_name,
fileSize: Number(file.file_size),
fileExt: file.file_ext,
filePath: file.file_path,
docType: file.doc_type,
docTypeName: file.doc_type_name,
targetObjid: file.target_objid,
parentTargetObjid: file.parent_target_objid,
writer: file.writer,
regdate: file.regdate?.toISOString(),
status: file.status,
}));
res.json({
success: true,
files: fileList,
totalCount: fileList.length,
targetObjid: baseTargetObjid, // 기준 target_objid 반환
});
} catch (error) {
console.error("연결된 파일 조회 오류:", error);
res.status(500).json({
success: false,
message: "연결된 파일 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
};
2025-09-05 21:52:19 +09:00
/**
*
*/
export const getFileList = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { targetObjid, docType, companyCode } = req.query;
const where: any = {
status: "ACTIVE",
};
if (targetObjid) {
where.target_objid = targetObjid as string;
}
if (docType) {
where.doc_type = docType as string;
}
const files = await prisma.attach_file_info.findMany({
where,
orderBy: {
regdate: "desc",
},
});
const fileList = files.map((file: any) => ({
objid: file.objid.toString(),
savedFileName: file.saved_file_name,
realFileName: file.real_file_name,
fileSize: Number(file.file_size),
fileExt: file.file_ext,
filePath: file.file_path,
docType: file.doc_type,
docTypeName: file.doc_type_name,
targetObjid: file.target_objid,
parentTargetObjid: file.parent_target_objid,
writer: file.writer,
regdate: file.regdate?.toISOString(),
status: file.status,
}));
res.json({
success: true,
files: fileList,
});
} catch (error) {
console.error("파일 목록 조회 오류:", error);
res.status(500).json({
success: false,
message: "파일 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
};
2025-09-08 13:10:09 +09:00
/**
* ( )
*/
export const previewFile = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { objid } = req.params;
const { serverFilename } = req.query;
const fileRecord = await prisma.attach_file_info.findUnique({
where: {
objid: parseInt(objid),
},
});
if (!fileRecord || fileRecord.status !== "ACTIVE") {
res.status(404).json({
success: false,
message: "파일을 찾을 수 없습니다.",
});
return;
}
// 파일 경로에서 회사코드와 날짜 폴더 추출
const filePathParts = fileRecord.file_path!.split("/");
const companyCode = filePathParts[2] || "DEFAULT";
const fileName = fileRecord.saved_file_name!;
// 파일 경로에 날짜 구조가 있는지 확인 (YYYY/MM/DD)
let dateFolder = "";
if (filePathParts.length >= 6) {
dateFolder = `${filePathParts[3]}/${filePathParts[4]}/${filePathParts[5]}`;
}
const companyUploadDir = getCompanyUploadDir(
companyCode,
dateFolder || undefined
);
const filePath = path.join(companyUploadDir, fileName);
if (!fs.existsSync(filePath)) {
console.error("❌ 파일 없음:", filePath);
res.status(404).json({
success: false,
message: `실제 파일을 찾을 수 없습니다: ${filePath}`,
});
return;
}
// MIME 타입 설정
const ext = path.extname(fileName).toLowerCase();
let mimeType = "application/octet-stream";
switch (ext) {
case ".jpg":
case ".jpeg":
mimeType = "image/jpeg";
break;
case ".png":
mimeType = "image/png";
break;
case ".gif":
mimeType = "image/gif";
break;
case ".webp":
mimeType = "image/webp";
break;
case ".pdf":
mimeType = "application/pdf";
break;
default:
mimeType = "application/octet-stream";
}
// CORS 헤더 설정 (더 포괄적으로)
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, OPTIONS"
);
res.setHeader(
"Access-Control-Allow-Headers",
"Content-Type, Authorization, X-Requested-With, Accept, Origin"
);
res.setHeader("Access-Control-Allow-Credentials", "true");
// 캐시 헤더 설정
res.setHeader("Cache-Control", "public, max-age=3600");
res.setHeader("Content-Type", mimeType);
// 파일 스트림으로 전송
const fileStream = fs.createReadStream(filePath);
fileStream.pipe(res);
} catch (error) {
console.error("파일 미리보기 오류:", error);
res.status(500).json({
success: false,
message: "파일 미리보기 중 오류가 발생했습니다.",
});
}
};
2025-09-05 21:52:19 +09:00
/**
*
*/
export const downloadFile = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { objid } = req.params;
const fileRecord = await prisma.attach_file_info.findUnique({
where: {
objid: parseInt(objid),
},
});
if (!fileRecord || fileRecord.status !== "ACTIVE") {
res.status(404).json({
success: false,
message: "파일을 찾을 수 없습니다.",
});
return;
}
// 파일 경로에서 회사코드와 날짜 폴더 추출 (예: /uploads/company_*/2025/09/05/timestamp_filename.ext)
const filePathParts = fileRecord.file_path!.split("/");
const companyCode = filePathParts[2] || "DEFAULT"; // /uploads/company_*/2025/09/05/filename.ext에서 company_* 추출
const fileName = fileRecord.saved_file_name!;
// 파일 경로에 날짜 구조가 있는지 확인 (YYYY/MM/DD)
let dateFolder = "";
if (filePathParts.length >= 6) {
// /uploads/company_*/2025/09/05/filename.ext 형태
dateFolder = `${filePathParts[3]}/${filePathParts[4]}/${filePathParts[5]}`;
}
const companyUploadDir = getCompanyUploadDir(
companyCode,
dateFolder || undefined
);
const filePath = path.join(companyUploadDir, fileName);
if (!fs.existsSync(filePath)) {
console.error("❌ 파일 없음:", filePath);
res.status(404).json({
success: false,
message: `실제 파일을 찾을 수 없습니다: ${filePath}`,
});
return;
}
// 파일 다운로드 헤더 설정
res.setHeader(
"Content-Disposition",
`attachment; filename="${encodeURIComponent(fileRecord.real_file_name!)}"`
);
res.setHeader("Content-Type", "application/octet-stream");
// 파일 스트림 전송
const fileStream = fs.createReadStream(filePath);
fileStream.pipe(res);
} catch (error) {
console.error("파일 다운로드 오류:", error);
res.status(500).json({
success: false,
message: "파일 다운로드 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
};
// Multer 미들웨어 export
export const uploadMiddleware = upload.array("files", 10); // 최대 10개 파일