Merge branch 'dev' of http://39.117.244.52:3000/kjs/ERP-node into dataflowMng

This commit is contained in:
hyeonsu
2025-09-08 16:47:58 +09:00
22 changed files with 2910 additions and 618 deletions

View File

@@ -1,497 +1,53 @@
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,
getLinkedFiles,
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 GET /api/files/linked/:tableName/:recordId
* @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.get("/linked/:tableName/:recordId", getLinkedFiles);
/**
* 이미지 미리보기
* GET /api/files/preview/:fileId
* @route DELETE /api/files/: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 : "알 수 없는 오류",
});
}
}
);
router.delete("/:objid", deleteFile);
/**
* 파일 정보 조회
* GET /api/files/info/:fileId
* @route GET /api/files/download/:objid
* @desc 파일 다운로드
* @access Private
*/
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;