Merge branch 'dev' of http://39.117.244.52:3000/kjs/ERP-node into dataflowMng
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user