Merge pull request 'dev' (#74) from dev into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/74
This commit is contained in:
@@ -52,7 +52,14 @@ import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
const app = express();
|
||||
|
||||
// 기본 미들웨어
|
||||
app.use(helmet());
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
...helmet.contentSecurityPolicy.getDefaultDirectives(),
|
||||
"frame-ancestors": ["'self'", "http://localhost:9771", "http://localhost:3000"], // 프론트엔드 도메인 허용
|
||||
},
|
||||
},
|
||||
}));
|
||||
app.use(compression());
|
||||
app.use(express.json({ limit: "10mb" }));
|
||||
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
|
||||
|
||||
@@ -8,6 +8,9 @@ import { generateUUID } from "../utils/generateId";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 임시 토큰 저장소 (메모리 기반, 실제 운영에서는 Redis 사용 권장)
|
||||
const tempTokens = new Map<string, { objid: string; expires: number }>();
|
||||
|
||||
// 업로드 디렉토리 설정 (회사별로 분리)
|
||||
const baseUploadDir = path.join(process.cwd(), "uploads");
|
||||
|
||||
@@ -266,9 +269,7 @@ export const uploadFiles = async (
|
||||
|
||||
// 회사코드가 *인 경우 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);
|
||||
@@ -277,6 +278,10 @@ export const uploadFiles = async (
|
||||
// 파일 이동
|
||||
fs.renameSync(tempFilePath, finalFilePath);
|
||||
|
||||
// DB에 저장할 경로 (실제 파일 위치와 일치)
|
||||
const relativePath = `/${actualCompanyCode}/${dateFolder}/${file.filename}`;
|
||||
const fullFilePath = `/uploads${relativePath}`;
|
||||
|
||||
// attach_file_info 테이블에 저장
|
||||
const fileRecord = await prisma.attach_file_info.create({
|
||||
data: {
|
||||
@@ -485,6 +490,133 @@ export const getFileList = async (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 컴포넌트의 템플릿 파일과 데이터 파일을 모두 조회
|
||||
*/
|
||||
export const getComponentFiles = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { screenId, componentId, tableName, recordId, columnName } = req.query;
|
||||
|
||||
console.log("📂 [getComponentFiles] API 호출:", {
|
||||
screenId,
|
||||
componentId,
|
||||
tableName,
|
||||
recordId,
|
||||
columnName,
|
||||
user: req.user?.userId
|
||||
});
|
||||
|
||||
if (!screenId || !componentId) {
|
||||
console.log("❌ [getComponentFiles] 필수 파라미터 누락");
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "screenId와 componentId가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 템플릿 파일 조회 (화면 설계 시 업로드한 파일들)
|
||||
const templateTargetObjid = `screen_files:${screenId}:${componentId}:${columnName || 'field_1'}`;
|
||||
console.log("🔍 [getComponentFiles] 템플릿 파일 조회:", { templateTargetObjid });
|
||||
|
||||
// 모든 파일 조회해서 실제 저장된 target_objid 패턴 확인
|
||||
const allFiles = await prisma.attach_file_info.findMany({
|
||||
where: {
|
||||
status: "ACTIVE",
|
||||
},
|
||||
select: {
|
||||
target_objid: true,
|
||||
real_file_name: true,
|
||||
regdate: true,
|
||||
},
|
||||
orderBy: {
|
||||
regdate: "desc",
|
||||
},
|
||||
take: 10,
|
||||
});
|
||||
console.log("🗂️ [getComponentFiles] 최근 저장된 파일들의 target_objid:", allFiles.map(f => ({ target_objid: f.target_objid, name: f.real_file_name })));
|
||||
|
||||
const templateFiles = await prisma.attach_file_info.findMany({
|
||||
where: {
|
||||
target_objid: templateTargetObjid,
|
||||
status: "ACTIVE",
|
||||
},
|
||||
orderBy: {
|
||||
regdate: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
console.log("📁 [getComponentFiles] 템플릿 파일 결과:", templateFiles.length);
|
||||
|
||||
// 2. 데이터 파일 조회 (실제 레코드와 연결된 파일들)
|
||||
let dataFiles: any[] = [];
|
||||
if (tableName && recordId && columnName) {
|
||||
const dataTargetObjid = `${tableName}:${recordId}:${columnName}`;
|
||||
dataFiles = await prisma.attach_file_info.findMany({
|
||||
where: {
|
||||
target_objid: dataTargetObjid,
|
||||
status: "ACTIVE",
|
||||
},
|
||||
orderBy: {
|
||||
regdate: "desc",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 파일 정보 포맷팅 함수
|
||||
const formatFileInfo = (file: any, isTemplate: boolean = false) => ({
|
||||
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,
|
||||
isTemplate, // 템플릿 파일 여부 표시
|
||||
});
|
||||
|
||||
const formattedTemplateFiles = templateFiles.map(file => formatFileInfo(file, true));
|
||||
const formattedDataFiles = dataFiles.map(file => formatFileInfo(file, false));
|
||||
|
||||
// 3. 전체 파일 목록 (데이터 파일 우선, 없으면 템플릿 파일 표시)
|
||||
const totalFiles = formattedDataFiles.length > 0
|
||||
? formattedDataFiles
|
||||
: formattedTemplateFiles;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
templateFiles: formattedTemplateFiles,
|
||||
dataFiles: formattedDataFiles,
|
||||
totalFiles,
|
||||
summary: {
|
||||
templateCount: formattedTemplateFiles.length,
|
||||
dataCount: formattedDataFiles.length,
|
||||
totalCount: totalFiles.length,
|
||||
templateTargetObjid,
|
||||
dataTargetObjid: tableName && recordId && columnName
|
||||
? `${tableName}:${recordId}:${columnName}`
|
||||
: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("컴포넌트 파일 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "컴포넌트 파일 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 파일 미리보기 (이미지 등)
|
||||
*/
|
||||
@@ -512,7 +644,13 @@ export const previewFile = async (
|
||||
|
||||
// 파일 경로에서 회사코드와 날짜 폴더 추출
|
||||
const filePathParts = fileRecord.file_path!.split("/");
|
||||
const companyCode = filePathParts[2] || "DEFAULT";
|
||||
let companyCode = filePathParts[2] || "DEFAULT";
|
||||
|
||||
// company_* 처리 (실제 회사 코드로 변환)
|
||||
if (companyCode === "company_*") {
|
||||
companyCode = "company_*"; // 실제 디렉토리명 유지
|
||||
}
|
||||
|
||||
const fileName = fileRecord.saved_file_name!;
|
||||
|
||||
// 파일 경로에 날짜 구조가 있는지 확인 (YYYY/MM/DD)
|
||||
@@ -527,6 +665,17 @@ export const previewFile = async (
|
||||
);
|
||||
const filePath = path.join(companyUploadDir, fileName);
|
||||
|
||||
console.log("🔍 파일 미리보기 경로 확인:", {
|
||||
objid: objid,
|
||||
filePathFromDB: fileRecord.file_path,
|
||||
companyCode: companyCode,
|
||||
dateFolder: dateFolder,
|
||||
fileName: fileName,
|
||||
companyUploadDir: companyUploadDir,
|
||||
finalFilePath: filePath,
|
||||
fileExists: fs.existsSync(filePath)
|
||||
});
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error("❌ 파일 없음:", filePath);
|
||||
res.status(404).json({
|
||||
@@ -615,7 +764,13 @@ export const downloadFile = async (
|
||||
|
||||
// 파일 경로에서 회사코드와 날짜 폴더 추출 (예: /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_* 추출
|
||||
let companyCode = filePathParts[2] || "DEFAULT"; // /uploads/company_*/2025/09/05/filename.ext에서 company_* 추출
|
||||
|
||||
// company_* 처리 (실제 회사 코드로 변환)
|
||||
if (companyCode === "company_*") {
|
||||
companyCode = "company_*"; // 실제 디렉토리명 유지
|
||||
}
|
||||
|
||||
const fileName = fileRecord.saved_file_name!;
|
||||
|
||||
// 파일 경로에 날짜 구조가 있는지 확인 (YYYY/MM/DD)
|
||||
@@ -631,6 +786,17 @@ export const downloadFile = async (
|
||||
);
|
||||
const filePath = path.join(companyUploadDir, fileName);
|
||||
|
||||
console.log("🔍 파일 다운로드 경로 확인:", {
|
||||
objid: objid,
|
||||
filePathFromDB: fileRecord.file_path,
|
||||
companyCode: companyCode,
|
||||
dateFolder: dateFolder,
|
||||
fileName: fileName,
|
||||
companyUploadDir: companyUploadDir,
|
||||
finalFilePath: filePath,
|
||||
fileExists: fs.existsSync(filePath)
|
||||
});
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error("❌ 파일 없음:", filePath);
|
||||
res.status(404).json({
|
||||
@@ -660,5 +826,178 @@ export const downloadFile = async (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Google Docs Viewer용 임시 공개 토큰 생성
|
||||
*/
|
||||
export const generateTempToken = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { objid } = req.params;
|
||||
|
||||
if (!objid) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "파일 ID가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 존재 확인
|
||||
const fileRecord = await prisma.attach_file_info.findUnique({
|
||||
where: { objid: objid },
|
||||
});
|
||||
|
||||
if (!fileRecord) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "파일을 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 임시 토큰 생성 (30분 유효)
|
||||
const token = generateUUID();
|
||||
const expires = Date.now() + 30 * 60 * 1000; // 30분
|
||||
|
||||
tempTokens.set(token, {
|
||||
objid: objid,
|
||||
expires: expires,
|
||||
});
|
||||
|
||||
// 만료된 토큰 정리 (메모리 누수 방지)
|
||||
const now = Date.now();
|
||||
for (const [key, value] of tempTokens.entries()) {
|
||||
if (value.expires < now) {
|
||||
tempTokens.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
token: token,
|
||||
publicUrl: `${req.protocol}://${req.get("host")}/api/files/public/${token}`,
|
||||
expires: new Date(expires).toISOString(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ 임시 토큰 생성 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "임시 토큰 생성에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 임시 토큰으로 파일 접근 (인증 불필요)
|
||||
*/
|
||||
export const getFileByToken = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { token } = req.params;
|
||||
|
||||
if (!token) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "토큰이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 토큰 확인
|
||||
const tokenData = tempTokens.get(token);
|
||||
if (!tokenData) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 토큰입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 토큰 만료 확인
|
||||
if (tokenData.expires < Date.now()) {
|
||||
tempTokens.delete(token);
|
||||
res.status(410).json({
|
||||
success: false,
|
||||
message: "토큰이 만료되었습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 정보 조회
|
||||
const fileRecord = await prisma.attach_file_info.findUnique({
|
||||
where: { objid: tokenData.objid },
|
||||
});
|
||||
|
||||
if (!fileRecord) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "파일을 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 경로 구성
|
||||
const filePathParts = fileRecord.file_path!.split("/");
|
||||
let companyCode = filePathParts[2] || "DEFAULT";
|
||||
if (companyCode === "company_*") {
|
||||
companyCode = "company_*"; // 실제 디렉토리명 유지
|
||||
}
|
||||
const fileName = fileRecord.saved_file_name!;
|
||||
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)) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "실제 파일을 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// MIME 타입 설정
|
||||
const ext = path.extname(fileName).toLowerCase();
|
||||
let contentType = "application/octet-stream";
|
||||
|
||||
const mimeTypes: { [key: string]: string } = {
|
||||
".pdf": "application/pdf",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".ppt": "application/vnd.ms-powerpoint",
|
||||
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".txt": "text/plain",
|
||||
};
|
||||
|
||||
if (mimeTypes[ext]) {
|
||||
contentType = mimeTypes[ext];
|
||||
}
|
||||
|
||||
// 파일 헤더 설정
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader("Content-Disposition", `inline; filename="${encodeURIComponent(fileRecord.real_file_name!)}"`);
|
||||
res.setHeader("Cache-Control", "public, max-age=300"); // 5분 캐시
|
||||
|
||||
// 파일 스트림 전송
|
||||
const fileStream = fs.createReadStream(filePath);
|
||||
fileStream.pipe(res);
|
||||
} catch (error) {
|
||||
console.error("❌ 토큰 파일 접근 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "파일 접근에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Multer 미들웨어 export
|
||||
export const uploadMiddleware = upload.array("files", 10); // 최대 10개 파일
|
||||
|
||||
@@ -3,15 +3,26 @@ import {
|
||||
uploadFiles,
|
||||
deleteFile,
|
||||
getFileList,
|
||||
getComponentFiles,
|
||||
downloadFile,
|
||||
previewFile,
|
||||
getLinkedFiles,
|
||||
uploadMiddleware,
|
||||
generateTempToken,
|
||||
getFileByToken,
|
||||
} from "../controllers/fileController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 공개 접근 라우트 (인증 불필요)
|
||||
/**
|
||||
* @route GET /api/files/public/:token
|
||||
* @desc 임시 토큰으로 파일 접근 (Google Docs Viewer용)
|
||||
* @access Public
|
||||
*/
|
||||
router.get("/public/:token", getFileByToken);
|
||||
|
||||
// 모든 파일 API는 인증 필요
|
||||
router.use(authenticateToken);
|
||||
|
||||
@@ -30,6 +41,14 @@ router.post("/upload", uploadMiddleware, uploadFiles);
|
||||
*/
|
||||
router.get("/", getFileList);
|
||||
|
||||
/**
|
||||
* @route GET /api/files/component-files
|
||||
* @desc 컴포넌트의 템플릿 파일과 데이터 파일 모두 조회
|
||||
* @query screenId, componentId, tableName, recordId, columnName
|
||||
* @access Private
|
||||
*/
|
||||
router.get("/component-files", getComponentFiles);
|
||||
|
||||
/**
|
||||
* @route GET /api/files/linked/:tableName/:recordId
|
||||
* @desc 테이블 연결된 파일 조회
|
||||
@@ -58,4 +77,11 @@ router.get("/preview/:objid", previewFile);
|
||||
*/
|
||||
router.get("/download/:objid", downloadFile);
|
||||
|
||||
/**
|
||||
* @route POST /api/files/temp-token/:objid
|
||||
* @desc Google Docs Viewer용 임시 공개 토큰 생성
|
||||
* @access Private
|
||||
*/
|
||||
router.post("/temp-token/:objid", generateTempToken);
|
||||
|
||||
export default router;
|
||||
|
||||
Reference in New Issue
Block a user