445 lines
13 KiB
TypeScript
445 lines
13 KiB
TypeScript
|
|
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");
|
||
|
|
if (!fs.existsSync(baseUploadDir)) {
|
||
|
|
fs.mkdirSync(baseUploadDir, { recursive: true });
|
||
|
|
}
|
||
|
|
|
||
|
|
// 회사별 + 날짜별 디렉토리 생성 함수
|
||
|
|
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 });
|
||
|
|
}
|
||
|
|
console.log(`📁 임시 업로드 디렉토리: ${tempDir}`);
|
||
|
|
cb(null, tempDir);
|
||
|
|
},
|
||
|
|
filename: (req, file, cb) => {
|
||
|
|
// 타임스탬프_원본파일명 형태로 저장 (회사코드는 디렉토리로 분리됨)
|
||
|
|
const timestamp = Date.now();
|
||
|
|
const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, "_");
|
||
|
|
const savedFileName = `${timestamp}_${sanitizedName}`;
|
||
|
|
console.log(`📄 저장 파일명: ${savedFileName}`);
|
||
|
|
cb(null, savedFileName);
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const upload = multer({
|
||
|
|
storage: storage,
|
||
|
|
limits: {
|
||
|
|
fileSize: 50 * 1024 * 1024, // 50MB 제한
|
||
|
|
},
|
||
|
|
fileFilter: (req, file, cb) => {
|
||
|
|
// 파일 타입 검증
|
||
|
|
const allowedTypes = [
|
||
|
|
"image/jpeg",
|
||
|
|
"image/png",
|
||
|
|
"image/gif",
|
||
|
|
"application/pdf",
|
||
|
|
"application/msword",
|
||
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||
|
|
"application/vnd.ms-excel",
|
||
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
|
|
];
|
||
|
|
|
||
|
|
if (allowedTypes.includes(file.mimetype)) {
|
||
|
|
cb(null, true);
|
||
|
|
} else {
|
||
|
|
cb(new Error("허용되지 않는 파일 타입입니다."));
|
||
|
|
}
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 파일 업로드 및 attach_file_info 테이블에 저장
|
||
|
|
*/
|
||
|
|
export const uploadFiles = async (
|
||
|
|
req: AuthenticatedRequest,
|
||
|
|
res: Response
|
||
|
|
): Promise<void> => {
|
||
|
|
try {
|
||
|
|
console.log("📤 파일 업로드 요청 수신:", {
|
||
|
|
body: req.body,
|
||
|
|
companyCode: req.body.companyCode,
|
||
|
|
writer: req.body.writer,
|
||
|
|
docType: req.body.docType,
|
||
|
|
user: req.user
|
||
|
|
? {
|
||
|
|
userId: req.user.userId,
|
||
|
|
companyCode: req.user.companyCode,
|
||
|
|
deptCode: req.user.deptCode,
|
||
|
|
}
|
||
|
|
: "no user",
|
||
|
|
files: req.files
|
||
|
|
? (req.files as Express.Multer.File[]).map((f) => f.originalname)
|
||
|
|
: "none",
|
||
|
|
});
|
||
|
|
|
||
|
|
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[];
|
||
|
|
const {
|
||
|
|
docType = "DOCUMENT",
|
||
|
|
docTypeName = "일반 문서",
|
||
|
|
targetObjid,
|
||
|
|
parentTargetObjid,
|
||
|
|
} = 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";
|
||
|
|
|
||
|
|
console.log("🔍 사용자 정보 결정:", {
|
||
|
|
bodyCompanyCode: req.body.companyCode,
|
||
|
|
userCompanyCode: (req.user as any)?.companyCode,
|
||
|
|
finalCompanyCode: companyCode,
|
||
|
|
bodyWriter: req.body.writer,
|
||
|
|
userWriter: (req.user as any)?.userId,
|
||
|
|
finalWriter: writer,
|
||
|
|
});
|
||
|
|
|
||
|
|
const savedFiles = [];
|
||
|
|
|
||
|
|
for (const file of files) {
|
||
|
|
// 파일 확장자 추출
|
||
|
|
const fileExt = path
|
||
|
|
.extname(file.originalname)
|
||
|
|
.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}`;
|
||
|
|
|
||
|
|
console.log("📂 파일 경로 설정:", {
|
||
|
|
companyCode,
|
||
|
|
filename: file.filename,
|
||
|
|
relativePath,
|
||
|
|
fullFilePath,
|
||
|
|
});
|
||
|
|
|
||
|
|
// 임시 파일을 최종 위치로 이동
|
||
|
|
const tempFilePath = file.path; // Multer가 저장한 임시 파일 경로
|
||
|
|
const finalUploadDir = getCompanyUploadDir(companyCode, dateFolder);
|
||
|
|
const finalFilePath = path.join(finalUploadDir, file.filename);
|
||
|
|
|
||
|
|
console.log("📦 파일 이동:", {
|
||
|
|
from: tempFilePath,
|
||
|
|
to: finalFilePath,
|
||
|
|
});
|
||
|
|
|
||
|
|
// 파일 이동
|
||
|
|
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: targetObjid,
|
||
|
|
saved_file_name: file.filename,
|
||
|
|
real_file_name: file.originalname,
|
||
|
|
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,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
console.log("💾 파일 정보 DB 저장 완료:", {
|
||
|
|
objid: fileRecord.objid.toString(),
|
||
|
|
saved_file_name: fileRecord.saved_file_name,
|
||
|
|
real_file_name: fileRecord.real_file_name,
|
||
|
|
file_size: fileRecord.file_size?.toString(),
|
||
|
|
});
|
||
|
|
|
||
|
|
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,
|
||
|
|
});
|
||
|
|
|
||
|
|
console.log("✅ 파일 저장 결과:", {
|
||
|
|
objid: fileRecord.objid.toString(),
|
||
|
|
company_code: companyCode,
|
||
|
|
file_path: fileRecord.file_path,
|
||
|
|
writer: fileRecord.writer,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
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;
|
||
|
|
|
||
|
|
console.log("🗑️ 파일 삭제 요청:", { objid, writer });
|
||
|
|
|
||
|
|
// 파일 상태를 DELETED로 변경 (논리적 삭제)
|
||
|
|
const deletedFile = await prisma.attach_file_info.update({
|
||
|
|
where: {
|
||
|
|
objid: parseInt(objid),
|
||
|
|
},
|
||
|
|
data: {
|
||
|
|
status: "DELETED",
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
console.log("✅ 파일 삭제 완료 (논리적):", {
|
||
|
|
objid: deletedFile.objid.toString(),
|
||
|
|
status: deletedFile.status,
|
||
|
|
});
|
||
|
|
|
||
|
|
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 getFileList = async (
|
||
|
|
req: AuthenticatedRequest,
|
||
|
|
res: Response
|
||
|
|
): Promise<void> => {
|
||
|
|
try {
|
||
|
|
const { targetObjid, docType, companyCode } = req.query;
|
||
|
|
|
||
|
|
console.log("📋 파일 목록 조회 요청:", {
|
||
|
|
targetObjid,
|
||
|
|
docType,
|
||
|
|
companyCode,
|
||
|
|
});
|
||
|
|
|
||
|
|
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 : "알 수 없는 오류",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 파일 다운로드
|
||
|
|
*/
|
||
|
|
export const downloadFile = async (
|
||
|
|
req: AuthenticatedRequest,
|
||
|
|
res: Response
|
||
|
|
): Promise<void> => {
|
||
|
|
try {
|
||
|
|
const { objid } = req.params;
|
||
|
|
|
||
|
|
console.log("📥 파일 다운로드 요청:", { objid });
|
||
|
|
|
||
|
|
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);
|
||
|
|
|
||
|
|
console.log("📥 파일 다운로드 경로 확인:", {
|
||
|
|
stored_file_path: fileRecord.file_path,
|
||
|
|
company_code: companyCode,
|
||
|
|
company_upload_dir: companyUploadDir,
|
||
|
|
final_file_path: filePath,
|
||
|
|
});
|
||
|
|
|
||
|
|
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);
|
||
|
|
|
||
|
|
console.log("✅ 파일 다운로드 시작:", {
|
||
|
|
objid: fileRecord.objid.toString(),
|
||
|
|
real_file_name: fileRecord.real_file_name,
|
||
|
|
});
|
||
|
|
} 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개 파일
|