파일 컴포넌트 분리
This commit is contained in:
@@ -181,20 +181,38 @@ export class AuthController {
|
||||
return;
|
||||
}
|
||||
|
||||
const userInfoResponse: UserInfo = {
|
||||
// DB에서 조회한 원본 사용자 정보 로그
|
||||
console.log("🔍 DB에서 조회한 사용자 정보:", {
|
||||
userId: dbUserInfo.userId,
|
||||
companyCode: dbUserInfo.companyCode,
|
||||
deptCode: dbUserInfo.deptCode,
|
||||
dbUserInfoKeys: Object.keys(dbUserInfo),
|
||||
});
|
||||
|
||||
// 프론트엔드 호환성을 위해 더 많은 사용자 정보 반환
|
||||
const userInfoResponse: any = {
|
||||
userId: dbUserInfo.userId,
|
||||
userName: dbUserInfo.userName || "",
|
||||
deptName: dbUserInfo.deptName || "",
|
||||
companyCode: dbUserInfo.companyCode || "ILSHIN",
|
||||
company_code: dbUserInfo.companyCode || "ILSHIN", // 프론트엔드 호환성
|
||||
userType: dbUserInfo.userType || "USER",
|
||||
userTypeName: dbUserInfo.userTypeName || "일반사용자",
|
||||
email: dbUserInfo.email || "",
|
||||
photo: dbUserInfo.photo,
|
||||
locale: dbUserInfo.locale || "KR", // locale 정보 추가
|
||||
deptCode: dbUserInfo.deptCode, // 추가 필드
|
||||
isAdmin:
|
||||
dbUserInfo.userType === "ADMIN" || dbUserInfo.userId === "plm_admin",
|
||||
};
|
||||
|
||||
console.log("📤 프론트엔드로 전송할 사용자 정보:", {
|
||||
companyCode: userInfoResponse.companyCode,
|
||||
company_code: userInfoResponse.company_code,
|
||||
deptCode: userInfoResponse.deptCode,
|
||||
responseKeys: Object.keys(userInfoResponse),
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "사용자 정보 조회 성공",
|
||||
|
||||
444
backend-node/src/controllers/fileController.ts
Normal file
444
backend-node/src/controllers/fileController.ts
Normal file
@@ -0,0 +1,444 @@
|
||||
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개 파일
|
||||
@@ -1,497 +1,45 @@
|
||||
import express from "express";
|
||||
import multer from "multer";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { Router } from "express";
|
||||
import {
|
||||
uploadFiles,
|
||||
deleteFile,
|
||||
getFileList,
|
||||
downloadFile,
|
||||
uploadMiddleware,
|
||||
} from "../controllers/fileController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { logger } from "../utils/logger";
|
||||
import { FileSystemManager } from "../utils/fileSystemManager";
|
||||
|
||||
const router = express.Router();
|
||||
const router = Router();
|
||||
|
||||
// 파일 저장 경로 설정
|
||||
const UPLOAD_PATH = path.join(process.cwd(), "uploads");
|
||||
|
||||
// uploads 디렉토리가 없으면 생성
|
||||
if (!fs.existsSync(UPLOAD_PATH)) {
|
||||
fs.mkdirSync(UPLOAD_PATH, { recursive: true });
|
||||
}
|
||||
|
||||
// Multer 설정 - 회사별 폴더 구조 지원
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
try {
|
||||
// 사용자의 회사 코드 가져오기
|
||||
const user = (req as AuthenticatedRequest).user;
|
||||
const companyCode = user?.companyCode || "default";
|
||||
|
||||
// 회사별 날짜별 폴더 생성
|
||||
const uploadPath = FileSystemManager.createCompanyUploadPath(companyCode);
|
||||
|
||||
logger.info("파일 업로드 대상 폴더", {
|
||||
companyCode,
|
||||
uploadPath,
|
||||
userId: user?.userId,
|
||||
});
|
||||
|
||||
return cb(null, uploadPath);
|
||||
} catch (error) {
|
||||
logger.error("업로드 폴더 생성 실패", error);
|
||||
return cb(error as Error, "");
|
||||
}
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
try {
|
||||
// 사용자의 회사 코드 가져오기
|
||||
const user = (req as AuthenticatedRequest).user;
|
||||
const companyCode = user?.companyCode || "default";
|
||||
|
||||
// 회사코드가 포함된 안전한 파일명 생성
|
||||
const safeFileName = FileSystemManager.generateSafeFileName(
|
||||
file.originalname,
|
||||
companyCode
|
||||
);
|
||||
|
||||
logger.info("파일명 생성", {
|
||||
originalName: file.originalname,
|
||||
safeFileName,
|
||||
companyCode,
|
||||
userId: user?.userId,
|
||||
});
|
||||
|
||||
return cb(null, safeFileName);
|
||||
} catch (error) {
|
||||
logger.error("파일명 생성 실패", error);
|
||||
return cb(error as Error, "");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: {
|
||||
fileSize: 50 * 1024 * 1024, // 50MB 제한
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
// 허용된 파일 타입 검사 (필요시 확장)
|
||||
const allowedTypes = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"text/plain",
|
||||
"text/csv",
|
||||
];
|
||||
|
||||
if (allowedTypes.includes(file.mimetype)) {
|
||||
return cb(null, true);
|
||||
} else {
|
||||
return cb(new Error(`허용되지 않는 파일 타입입니다: ${file.mimetype}`));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 모든 라우트에 인증 미들웨어 적용
|
||||
// 모든 파일 API는 인증 필요
|
||||
router.use(authenticateToken);
|
||||
|
||||
/**
|
||||
* 파일 업로드
|
||||
* POST /api/files/upload
|
||||
* @route POST /api/files/upload
|
||||
* @desc 파일 업로드 (attach_file_info 테이블에 저장)
|
||||
* @access Private
|
||||
*/
|
||||
router.post(
|
||||
"/upload",
|
||||
upload.array("files", 10),
|
||||
async (req: AuthenticatedRequest, res): Promise<void> => {
|
||||
try {
|
||||
const files = req.files as Express.Multer.File[];
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "업로드할 파일이 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const fileInfos = files.map((file) => ({
|
||||
id: `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: Buffer.from(file.originalname, "latin1").toString("utf8"),
|
||||
size: file.size,
|
||||
type: file.mimetype,
|
||||
extension: path.extname(file.originalname).toLowerCase().substring(1),
|
||||
uploadedAt: new Date().toISOString(),
|
||||
lastModified: new Date().toISOString(),
|
||||
serverPath: file.path,
|
||||
serverFilename: file.filename,
|
||||
}));
|
||||
|
||||
logger.info("파일 업로드 완료", {
|
||||
userId: req.user?.userId,
|
||||
fileCount: files.length,
|
||||
files: fileInfos.map((f) => ({ name: f.name, size: f.size })),
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `${files.length}개 파일이 성공적으로 업로드되었습니다.`,
|
||||
files: fileInfos,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("파일 업로드 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "파일 업로드 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
router.post("/upload", uploadMiddleware, uploadFiles);
|
||||
|
||||
/**
|
||||
* 파일 다운로드
|
||||
* GET /api/files/download/:fileId
|
||||
* @route GET /api/files
|
||||
* @desc 파일 목록 조회
|
||||
* @query targetObjid, docType, companyCode
|
||||
* @access Private
|
||||
*/
|
||||
router.get(
|
||||
"/download/:fileId",
|
||||
async (req: AuthenticatedRequest, res): Promise<void> => {
|
||||
try {
|
||||
const { fileId } = req.params;
|
||||
const { serverFilename, originalName } = req.query;
|
||||
|
||||
if (!serverFilename || !originalName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"파일 정보가 부족합니다. (serverFilename, originalName 필요)",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 회사별 폴더 구조를 고려하여 파일 경로 찾기
|
||||
const user = req.user;
|
||||
const companyCode = user?.companyCode || "default";
|
||||
|
||||
// 먼저 회사별 폴더에서 찾기
|
||||
let filePath = FileSystemManager.findFileInCompanyFolders(
|
||||
companyCode,
|
||||
serverFilename as string
|
||||
);
|
||||
|
||||
// 찾지 못하면 기본 uploads 폴더에서 찾기 (하위 호환성)
|
||||
if (!filePath) {
|
||||
filePath = path.join(UPLOAD_PATH, serverFilename as string);
|
||||
}
|
||||
|
||||
// 파일 존재 확인
|
||||
if (!fs.existsSync(filePath)) {
|
||||
logger.warn("파일을 찾을 수 없음", {
|
||||
fileId,
|
||||
serverFilename,
|
||||
filePath,
|
||||
companyCode,
|
||||
userId: user?.userId,
|
||||
});
|
||||
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "요청한 파일을 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 정보 확인
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
logger.info("파일 다운로드 요청", {
|
||||
fileId,
|
||||
originalName,
|
||||
serverFilename,
|
||||
fileSize: stats.size,
|
||||
userId: req.user?.userId,
|
||||
});
|
||||
|
||||
// 파일명 인코딩 (한글 파일명 지원)
|
||||
const encodedFilename = encodeURIComponent(originalName as string);
|
||||
|
||||
// 응답 헤더 설정
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename*=UTF-8''${encodedFilename}`
|
||||
);
|
||||
res.setHeader("Content-Type", "application/octet-stream");
|
||||
res.setHeader("Content-Length", stats.size);
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
|
||||
// 파일 스트림으로 전송
|
||||
const fileStream = fs.createReadStream(filePath);
|
||||
|
||||
fileStream.on("error", (error) => {
|
||||
logger.error("파일 스트림 오류:", error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "파일 전송 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
fileStream.pipe(res);
|
||||
} catch (error) {
|
||||
logger.error("파일 다운로드 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "파일 다운로드 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
router.get("/", getFileList);
|
||||
|
||||
/**
|
||||
* 파일 삭제
|
||||
* DELETE /api/files/:fileId
|
||||
* @route DELETE /api/files/:objid
|
||||
* @desc 파일 삭제 (논리적 삭제)
|
||||
* @access Private
|
||||
*/
|
||||
router.delete(
|
||||
"/:fileId",
|
||||
async (req: AuthenticatedRequest, res): Promise<void> => {
|
||||
try {
|
||||
const { fileId } = req.params;
|
||||
const { serverFilename } = req.body;
|
||||
|
||||
if (!serverFilename) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "서버 파일명이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 회사별 폴더 구조를 고려하여 파일 경로 찾기
|
||||
const user = req.user;
|
||||
const companyCode = user?.companyCode || "default";
|
||||
|
||||
// 먼저 회사별 폴더에서 찾기
|
||||
let filePath = FileSystemManager.findFileInCompanyFolders(
|
||||
companyCode,
|
||||
serverFilename
|
||||
);
|
||||
|
||||
// 찾지 못하면 기본 uploads 폴더에서 찾기 (하위 호환성)
|
||||
if (!filePath) {
|
||||
filePath = path.join(UPLOAD_PATH, serverFilename);
|
||||
}
|
||||
|
||||
// 파일 존재 확인 및 삭제
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
logger.info("파일 삭제 완료", {
|
||||
fileId,
|
||||
serverFilename,
|
||||
filePath,
|
||||
companyCode,
|
||||
userId: user?.userId,
|
||||
});
|
||||
} else {
|
||||
logger.warn("삭제할 파일을 찾을 수 없음", {
|
||||
fileId,
|
||||
serverFilename,
|
||||
companyCode,
|
||||
userId: user?.userId,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "파일이 성공적으로 삭제되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("파일 삭제 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "파일 삭제 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
router.delete("/:objid", deleteFile);
|
||||
|
||||
/**
|
||||
* 이미지 미리보기
|
||||
* GET /api/files/preview/:fileId
|
||||
* @route GET /api/files/download/:objid
|
||||
* @desc 파일 다운로드
|
||||
* @access Private
|
||||
*/
|
||||
router.get(
|
||||
"/preview/:fileId",
|
||||
async (req: AuthenticatedRequest, res): Promise<void> => {
|
||||
try {
|
||||
const { fileId } = req.params;
|
||||
const { serverFilename } = req.query;
|
||||
|
||||
if (!serverFilename) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "서버 파일명이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 회사별 폴더 구조를 고려하여 파일 경로 찾기
|
||||
const user = req.user;
|
||||
const companyCode = user?.companyCode || "default";
|
||||
|
||||
// 먼저 회사별 폴더에서 찾기
|
||||
let filePath = FileSystemManager.findFileInCompanyFolders(
|
||||
companyCode,
|
||||
serverFilename as string
|
||||
);
|
||||
|
||||
// 찾지 못하면 기본 uploads 폴더에서 찾기
|
||||
if (!filePath) {
|
||||
filePath = path.join(UPLOAD_PATH, serverFilename as string);
|
||||
}
|
||||
|
||||
// 파일 존재 확인
|
||||
if (!fs.existsSync(filePath)) {
|
||||
logger.warn("이미지 파일을 찾을 수 없음", {
|
||||
fileId,
|
||||
serverFilename,
|
||||
filePath,
|
||||
userId: user?.userId,
|
||||
});
|
||||
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "요청한 이미지 파일을 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 확장자로 MIME 타입 결정
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const mimeTypes: { [key: string]: string } = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
".svg": "image/svg+xml",
|
||||
};
|
||||
|
||||
const mimeType = mimeTypes[ext] || "application/octet-stream";
|
||||
|
||||
// 이미지 파일이 아닌 경우 에러 반환
|
||||
if (!mimeType.startsWith("image/")) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "이미지 파일이 아닙니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 정보 확인
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
logger.info("이미지 미리보기 요청", {
|
||||
fileId,
|
||||
serverFilename,
|
||||
mimeType,
|
||||
fileSize: stats.size,
|
||||
userId: user?.userId,
|
||||
});
|
||||
|
||||
// 캐시 헤더 설정 (이미지는 캐시 가능)
|
||||
res.setHeader("Content-Type", mimeType);
|
||||
res.setHeader("Content-Length", stats.size);
|
||||
res.setHeader("Cache-Control", "public, max-age=86400"); // 24시간 캐시
|
||||
res.setHeader("Last-Modified", stats.mtime.toUTCString());
|
||||
|
||||
// If-Modified-Since 헤더 확인
|
||||
const ifModifiedSince = req.headers["if-modified-since"];
|
||||
if (ifModifiedSince && new Date(ifModifiedSince) >= stats.mtime) {
|
||||
res.status(304).end();
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 스트림으로 전송
|
||||
const fileStream = fs.createReadStream(filePath);
|
||||
|
||||
fileStream.on("error", (error) => {
|
||||
logger.error("이미지 스트림 오류:", error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "이미지 전송 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
fileStream.pipe(res);
|
||||
} catch (error) {
|
||||
logger.error("이미지 미리보기 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "이미지 미리보기 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 파일 정보 조회
|
||||
* GET /api/files/info/:fileId
|
||||
*/
|
||||
router.get(
|
||||
"/info/:fileId",
|
||||
async (req: AuthenticatedRequest, res): Promise<void> => {
|
||||
try {
|
||||
const { fileId } = req.params;
|
||||
const { serverFilename } = req.query;
|
||||
|
||||
if (!serverFilename) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "서버 파일명이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = path.join(UPLOAD_PATH, serverFilename as string);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "파일을 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
fileId,
|
||||
serverFilename,
|
||||
size: stats.size,
|
||||
lastModified: stats.mtime.toISOString(),
|
||||
exists: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("파일 정보 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "파일 정보 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
router.get("/download/:objid", downloadFile);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -185,6 +185,19 @@ export class AuthService {
|
||||
},
|
||||
});
|
||||
|
||||
// DB에서 조회한 원본 사용자 정보 상세 로그
|
||||
console.log("🔍 AuthService - DB 원본 사용자 정보:", {
|
||||
userId: userInfo.user_id,
|
||||
company_code: userInfo.company_code,
|
||||
company_code_type: typeof userInfo.company_code,
|
||||
company_code_is_null: userInfo.company_code === null,
|
||||
company_code_is_undefined: userInfo.company_code === undefined,
|
||||
company_code_is_empty: userInfo.company_code === "",
|
||||
dept_code: userInfo.dept_code,
|
||||
allUserFields: Object.keys(userInfo),
|
||||
companyInfo: companyInfo?.company_name,
|
||||
});
|
||||
|
||||
// PersonBean 형태로 변환 (null 값을 undefined로 변환)
|
||||
const personBean: PersonBean = {
|
||||
userId: userInfo.user_id,
|
||||
@@ -209,6 +222,12 @@ export class AuthService {
|
||||
locale: userInfo.locale || "KR",
|
||||
};
|
||||
|
||||
console.log("📦 AuthService - 최종 PersonBean:", {
|
||||
userId: personBean.userId,
|
||||
companyCode: personBean.companyCode,
|
||||
deptCode: personBean.deptCode,
|
||||
});
|
||||
|
||||
logger.info(`사용자 정보 조회 완료: ${userId}`);
|
||||
return personBean;
|
||||
} catch (error) {
|
||||
|
||||
@@ -529,6 +529,121 @@ export class TableManagementService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 데이터 보강 (attach_file_info에서 파일 정보 가져오기)
|
||||
*/
|
||||
private async enrichFileData(
|
||||
data: any[],
|
||||
fileColumns: string[],
|
||||
tableName: string
|
||||
): Promise<any[]> {
|
||||
try {
|
||||
logger.info(
|
||||
`파일 데이터 보강 시작: ${tableName}, ${fileColumns.join(", ")}`
|
||||
);
|
||||
|
||||
// 각 행의 파일 정보를 보강
|
||||
const enrichedData = await Promise.all(
|
||||
data.map(async (row) => {
|
||||
const enrichedRow = { ...row };
|
||||
|
||||
// 각 파일 컬럼에 대해 처리
|
||||
for (const fileColumn of fileColumns) {
|
||||
const filePath = row[fileColumn];
|
||||
if (filePath && typeof filePath === "string") {
|
||||
// 파일 경로에서 실제 파일 정보 조회
|
||||
const fileInfo = await this.getFileInfoByPath(filePath);
|
||||
if (fileInfo) {
|
||||
// 파일 정보를 JSON 형태로 저장
|
||||
enrichedRow[fileColumn] = JSON.stringify({
|
||||
files: [fileInfo],
|
||||
totalCount: 1,
|
||||
totalSize: fileInfo.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return enrichedRow;
|
||||
})
|
||||
);
|
||||
|
||||
logger.info(`파일 데이터 보강 완료: ${enrichedData.length}개 행 처리`);
|
||||
return enrichedData;
|
||||
} catch (error) {
|
||||
logger.error("파일 데이터 보강 실패:", error);
|
||||
return data; // 실패 시 원본 데이터 반환
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 경로로 파일 정보 조회
|
||||
*/
|
||||
private async getFileInfoByPath(filePath: string): Promise<any | null> {
|
||||
try {
|
||||
const fileInfo = await prisma.attach_file_info.findFirst({
|
||||
where: {
|
||||
file_path: filePath,
|
||||
status: "ACTIVE",
|
||||
},
|
||||
select: {
|
||||
objid: true,
|
||||
real_file_name: true,
|
||||
file_size: true,
|
||||
file_ext: true,
|
||||
file_path: true,
|
||||
doc_type: true,
|
||||
doc_type_name: true,
|
||||
regdate: true,
|
||||
writer: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!fileInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: fileInfo.real_file_name,
|
||||
path: fileInfo.file_path,
|
||||
size: Number(fileInfo.file_size) || 0,
|
||||
type: fileInfo.file_ext,
|
||||
objid: fileInfo.objid.toString(),
|
||||
docType: fileInfo.doc_type,
|
||||
docTypeName: fileInfo.doc_type_name,
|
||||
regdate: fileInfo.regdate?.toISOString(),
|
||||
writer: fileInfo.writer,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(`파일 정보 조회 실패: ${filePath}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 타입 컬럼 조회
|
||||
*/
|
||||
private async getFileTypeColumns(tableName: string): Promise<string[]> {
|
||||
try {
|
||||
const fileColumns = await prisma.column_labels.findMany({
|
||||
where: {
|
||||
table_name: tableName,
|
||||
web_type: "file",
|
||||
},
|
||||
select: {
|
||||
column_name: true,
|
||||
},
|
||||
});
|
||||
|
||||
const columnNames = fileColumns.map((col: any) => col.column_name);
|
||||
logger.info(`파일 타입 컬럼 감지: ${tableName}`, columnNames);
|
||||
return columnNames;
|
||||
} catch (error) {
|
||||
logger.warn(`파일 타입 컬럼 조회 실패: ${tableName}`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 데이터 조회 (페이징 + 검색)
|
||||
*/
|
||||
@@ -554,6 +669,9 @@ export class TableManagementService {
|
||||
|
||||
logger.info(`테이블 데이터 조회: ${tableName}`, options);
|
||||
|
||||
// 🎯 파일 타입 컬럼 감지
|
||||
const fileColumns = await this.getFileTypeColumns(tableName);
|
||||
|
||||
// WHERE 조건 구성
|
||||
let whereConditions: string[] = [];
|
||||
let searchValues: any[] = [];
|
||||
@@ -610,13 +728,18 @@ export class TableManagementService {
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
|
||||
const data = await prisma.$queryRawUnsafe<any[]>(
|
||||
let data = await prisma.$queryRawUnsafe<any[]>(
|
||||
dataQuery,
|
||||
...searchValues,
|
||||
size,
|
||||
offset
|
||||
);
|
||||
|
||||
// 🎯 파일 컬럼이 있으면 파일 정보 보강
|
||||
if (fileColumns.length > 0) {
|
||||
data = await this.enrichFileData(data, fileColumns, safeTableName);
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / size);
|
||||
|
||||
logger.info(
|
||||
|
||||
Reference in New Issue
Block a user