- Updated fileController to include Cross-Origin-Resource-Policy headers for improved security and file handling. - Added error handling for file streams to ensure robust responses in case of read errors. - Modified materialStatusController to correctly map material IDs to their respective codes for inventory stock queries. - Enhanced moldController to include warranty shot count in mold creation and update processes. - Improved item inspection page by adding inspection method category loading and mapping, ensuring accurate display of method labels in the UI. These changes aim to enhance the overall functionality and user experience across multiple companies by ensuring proper file handling, data mapping, and error management.
1387 lines
44 KiB
TypeScript
1387 lines
44 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 { generateUUID } from "../utils/generateId";
|
|
import { query, queryOne } from "../database/db";
|
|
|
|
// 임시 토큰 저장소 (메모리 기반, 실제 운영에서는 Redis 사용 권장)
|
|
const tempTokens = new Map<string, { objid: string; expires: number }>();
|
|
|
|
// 업로드 디렉토리 설정 (회사별로 분리)
|
|
const baseUploadDir = path.join(process.cwd(), "uploads");
|
|
|
|
// 디렉토리 생성 함수 (에러 핸들링 포함)
|
|
const ensureUploadDir = () => {
|
|
try {
|
|
if (!fs.existsSync(baseUploadDir)) {
|
|
fs.mkdirSync(baseUploadDir, { recursive: true });
|
|
}
|
|
} catch (error) {
|
|
console.warn(
|
|
`업로드 디렉토리 생성 실패: ${error}. 기존 디렉토리를 사용합니다.`
|
|
);
|
|
}
|
|
};
|
|
|
|
// 초기화 시 디렉토리 확인
|
|
ensureUploadDir();
|
|
|
|
// 회사별 + 날짜별 디렉토리 생성 함수
|
|
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 });
|
|
}
|
|
cb(null, tempDir);
|
|
},
|
|
filename: (req, file, cb) => {
|
|
// 타임스탬프_원본파일명 형태로 저장 (회사코드는 디렉토리로 분리됨)
|
|
const timestamp = Date.now();
|
|
|
|
console.log("📁 파일명 처리:", {
|
|
originalname: file.originalname,
|
|
encoding: file.encoding,
|
|
mimetype: file.mimetype,
|
|
});
|
|
|
|
// UTF-8 인코딩 문제 해결: Buffer를 통한 올바른 디코딩
|
|
let decodedName;
|
|
try {
|
|
// 파일명이 깨진 경우 Buffer를 통해 올바르게 디코딩
|
|
const buffer = Buffer.from(file.originalname, "latin1");
|
|
decodedName = buffer.toString("utf8");
|
|
console.log("📁 파일명 디코딩:", {
|
|
original: file.originalname,
|
|
decoded: decodedName,
|
|
});
|
|
} catch (error) {
|
|
// 디코딩 실패 시 원본 사용
|
|
decodedName = file.originalname;
|
|
console.log("📁 파일명 디코딩 실패, 원본 사용:", file.originalname);
|
|
}
|
|
|
|
// 한국어를 포함한 유니코드 문자 보존하면서 안전한 파일명 생성
|
|
// 위험한 문자만 제거: / \ : * ? " < > |
|
|
const sanitizedName = decodedName
|
|
.replace(/[\/\\:*?"<>|]/g, "_") // 파일시스템에서 금지된 문자만 치환
|
|
.replace(/\s+/g, "_") // 공백을 언더스코어로 치환
|
|
.replace(/_{2,}/g, "_"); // 연속된 언더스코어를 하나로 축약
|
|
|
|
const savedFileName = `${timestamp}_${sanitizedName}`;
|
|
|
|
console.log("📁 파일명 변환:", {
|
|
original: file.originalname,
|
|
sanitized: sanitizedName,
|
|
saved: savedFileName,
|
|
});
|
|
|
|
cb(null, savedFileName);
|
|
},
|
|
});
|
|
|
|
const upload = multer({
|
|
storage: storage,
|
|
limits: {
|
|
fileSize: 50 * 1024 * 1024, // 50MB 제한
|
|
},
|
|
fileFilter: (req, file, cb) => {
|
|
// 프론트엔드에서 전송된 accept 정보 확인
|
|
const acceptHeader = req.body?.accept;
|
|
|
|
// 프론트엔드에서 */* 또는 * 허용한 경우 모든 파일 허용
|
|
if (
|
|
acceptHeader &&
|
|
(acceptHeader.includes("*/*") || acceptHeader.includes("*"))
|
|
) {
|
|
cb(null, true);
|
|
return;
|
|
}
|
|
|
|
// 기본 허용 파일 타입
|
|
const defaultAllowedTypes = [
|
|
// 이미지 파일
|
|
"image/jpeg",
|
|
"image/png",
|
|
"image/gif",
|
|
"image/webp",
|
|
"image/svg+xml",
|
|
// 텍스트 파일
|
|
"text/html",
|
|
"text/plain",
|
|
"text/markdown",
|
|
"text/csv",
|
|
"application/json",
|
|
"application/xml",
|
|
// PDF 파일
|
|
"application/pdf",
|
|
// Microsoft Office 파일
|
|
"application/msword", // .doc
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
|
|
"application/vnd.ms-excel", // .xls
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // .xlsx
|
|
"application/vnd.ms-powerpoint", // .ppt
|
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation", // .pptx
|
|
// 한컴오피스 파일
|
|
"application/x-hwp", // .hwp (한글)
|
|
"application/haansofthwp", // .hwp (다른 MIME 타입)
|
|
"application/vnd.hancom.hwp", // .hwp (또 다른 MIME 타입)
|
|
"application/vnd.hancom.hwpx", // .hwpx (한글 2014+)
|
|
"application/x-hwpml", // .hwpml (한글 XML)
|
|
"application/vnd.hancom.hcdt", // .hcdt (한셀)
|
|
"application/vnd.hancom.hpt", // .hpt (한쇼)
|
|
"application/octet-stream", // .hwp, .hwpx (일반적인 바이너리 파일)
|
|
// 압축 파일
|
|
"application/zip",
|
|
"application/x-zip-compressed",
|
|
"application/x-rar-compressed",
|
|
"application/x-7z-compressed",
|
|
// 미디어 파일
|
|
"video/mp4",
|
|
"video/webm",
|
|
"video/ogg",
|
|
"audio/mp3",
|
|
"audio/mpeg",
|
|
"audio/wav",
|
|
"audio/ogg",
|
|
// Apple/맥 파일
|
|
"application/vnd.apple.pages", // .pages (Pages)
|
|
"application/vnd.apple.numbers", // .numbers (Numbers)
|
|
"application/vnd.apple.keynote", // .keynote (Keynote)
|
|
"application/x-iwork-pages-sffpages", // .pages (다른 MIME)
|
|
"application/x-iwork-numbers-sffnumbers", // .numbers (다른 MIME)
|
|
"application/x-iwork-keynote-sffkey", // .keynote (다른 MIME)
|
|
"application/vnd.apple.installer+xml", // .pkg (맥 설치 파일)
|
|
"application/x-apple-diskimage", // .dmg (맥 디스크 이미지)
|
|
// 기타 문서
|
|
"application/rtf", // .rtf
|
|
"application/vnd.oasis.opendocument.text", // .odt
|
|
"application/vnd.oasis.opendocument.spreadsheet", // .ods
|
|
"application/vnd.oasis.opendocument.presentation", // .odp
|
|
];
|
|
|
|
if (defaultAllowedTypes.includes(file.mimetype)) {
|
|
cb(null, true);
|
|
} else {
|
|
cb(new Error("허용되지 않는 파일 타입입니다."));
|
|
}
|
|
},
|
|
});
|
|
|
|
/**
|
|
* 파일 업로드 및 attach_file_info 테이블에 저장
|
|
*/
|
|
export const uploadFiles = async (
|
|
req: AuthenticatedRequest,
|
|
res: Response
|
|
): Promise<void> => {
|
|
try {
|
|
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,
|
|
// 테이블 연결 정보 (새로 추가)
|
|
linkedTable,
|
|
linkedField,
|
|
recordId,
|
|
autoLink,
|
|
// 가상 파일 컬럼 정보
|
|
columnName,
|
|
isVirtualFileColumn,
|
|
} = 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";
|
|
|
|
// 자동 연결 로직 - target_objid 자동 생성
|
|
let finalTargetObjid = targetObjid;
|
|
|
|
// 🔑 템플릿 파일(screen_files:)이나 temp_ 파일은 autoLink 무시
|
|
const isTemplateFile = targetObjid && (targetObjid.startsWith('screen_files:') || targetObjid.startsWith('temp_'));
|
|
|
|
if (!isTemplateFile && autoLink === "true" && linkedTable && recordId) {
|
|
// 가상 파일 컬럼의 경우 컬럼명도 포함한 target_objid 생성
|
|
if (isVirtualFileColumn === "true" && columnName) {
|
|
finalTargetObjid = `${linkedTable}:${recordId}:${columnName}`;
|
|
} else {
|
|
finalTargetObjid = `${linkedTable}:${recordId}`;
|
|
}
|
|
}
|
|
|
|
const savedFiles = [];
|
|
|
|
for (const file of files) {
|
|
// 파일명 디코딩 (파일 저장 시와 동일한 로직)
|
|
let decodedOriginalName;
|
|
try {
|
|
const buffer = Buffer.from(file.originalname, "latin1");
|
|
decodedOriginalName = buffer.toString("utf8");
|
|
console.log("💾 DB 저장용 파일명 디코딩:", {
|
|
original: file.originalname,
|
|
decoded: decodedOriginalName,
|
|
});
|
|
} catch (error) {
|
|
decodedOriginalName = file.originalname;
|
|
console.log(
|
|
"💾 DB 저장용 파일명 디코딩 실패, 원본 사용:",
|
|
file.originalname
|
|
);
|
|
}
|
|
|
|
// 파일 확장자 추출
|
|
const fileExt = path
|
|
.extname(decodedOriginalName)
|
|
.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 tempFilePath = file.path; // Multer가 저장한 임시 파일 경로
|
|
const finalUploadDir = getCompanyUploadDir(companyCode, dateFolder);
|
|
const finalFilePath = path.join(finalUploadDir, file.filename);
|
|
|
|
// 파일 이동
|
|
fs.renameSync(tempFilePath, finalFilePath);
|
|
|
|
// DB에 저장할 경로 (실제 파일 위치와 일치)
|
|
const relativePath = `/${actualCompanyCode}/${dateFolder}/${file.filename}`;
|
|
const fullFilePath = `/uploads${relativePath}`;
|
|
|
|
// attach_file_info 테이블에 저장
|
|
const objidValue = parseInt(
|
|
generateUUID().replace(/-/g, "").substring(0, 15),
|
|
16
|
|
);
|
|
|
|
const [fileRecord] = await query<any>(
|
|
`INSERT INTO attach_file_info (
|
|
objid, target_objid, saved_file_name, real_file_name, doc_type, doc_type_name,
|
|
file_size, file_ext, file_path, company_code, writer, regdate, status, parent_target_objid
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
|
RETURNING *`,
|
|
[
|
|
objidValue,
|
|
finalTargetObjid,
|
|
file.filename,
|
|
decodedOriginalName,
|
|
docType,
|
|
docTypeName,
|
|
file.size,
|
|
fileExt,
|
|
fullFilePath,
|
|
companyCode,
|
|
writer,
|
|
new Date(),
|
|
"ACTIVE",
|
|
parentTargetObjid,
|
|
]
|
|
);
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
// 🆕 레코드 모드: 해당 행의 attachments 컬럼 자동 업데이트
|
|
const isRecordMode = req.body.isRecordMode === "true" || req.body.isRecordMode === true;
|
|
|
|
// 🔍 디버깅: 레코드 모드 조건 확인
|
|
console.log("🔍 [파일 업로드] 레코드 모드 조건 확인:", {
|
|
isRecordMode,
|
|
linkedTable,
|
|
recordId,
|
|
columnName,
|
|
finalTargetObjid,
|
|
"req.body.isRecordMode": req.body.isRecordMode,
|
|
"req.body.linkedTable": req.body.linkedTable,
|
|
"req.body.recordId": req.body.recordId,
|
|
"req.body.columnName": req.body.columnName,
|
|
});
|
|
|
|
if (isRecordMode && linkedTable && recordId && columnName) {
|
|
try {
|
|
// 해당 레코드의 모든 첨부파일 조회
|
|
const allFiles = await query<any>(
|
|
`SELECT objid, real_file_name, file_size, file_ext, file_path, regdate
|
|
FROM attach_file_info
|
|
WHERE target_objid = $1 AND status = 'ACTIVE'
|
|
ORDER BY regdate DESC`,
|
|
[finalTargetObjid]
|
|
);
|
|
|
|
// attachments JSONB 형태로 변환
|
|
const attachmentsJson = allFiles.map((f: any) => ({
|
|
objid: f.objid.toString(),
|
|
realFileName: f.real_file_name,
|
|
fileSize: Number(f.file_size),
|
|
fileExt: f.file_ext,
|
|
filePath: f.file_path,
|
|
regdate: f.regdate?.toISOString(),
|
|
}));
|
|
|
|
// 해당 테이블의 attachments 컬럼 업데이트
|
|
// 🔒 멀티테넌시: company_code 필터 추가
|
|
await query(
|
|
`UPDATE ${linkedTable}
|
|
SET ${columnName} = $1::jsonb, updated_date = NOW()
|
|
WHERE id = $2 AND company_code = $3`,
|
|
[JSON.stringify(attachmentsJson), recordId, companyCode]
|
|
);
|
|
|
|
console.log("📎 [레코드 모드] attachments 컬럼 자동 업데이트:", {
|
|
tableName: linkedTable,
|
|
recordId: recordId,
|
|
columnName: columnName,
|
|
fileCount: attachmentsJson.length,
|
|
});
|
|
} catch (updateError) {
|
|
// attachments 컬럼 업데이트 실패해도 파일 업로드는 성공으로 처리
|
|
console.warn("⚠️ attachments 컬럼 업데이트 실패 (무시):", updateError);
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
// 🔒 멀티테넌시: 현재 사용자의 회사 코드
|
|
const companyCode = req.user?.companyCode;
|
|
|
|
// 파일 정보 조회
|
|
const fileRecord = await queryOne<any>(
|
|
`SELECT * FROM attach_file_info WHERE objid = $1`,
|
|
[objid]
|
|
);
|
|
|
|
if (!fileRecord) {
|
|
res.status(404).json({
|
|
success: false,
|
|
message: "파일을 찾을 수 없습니다.",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 제외)
|
|
if (companyCode !== "*" && fileRecord.company_code !== companyCode) {
|
|
console.warn("⚠️ 다른 회사 파일 삭제 시도:", {
|
|
userId: req.user?.userId,
|
|
userCompanyCode: companyCode,
|
|
fileCompanyCode: fileRecord.company_code,
|
|
objid,
|
|
});
|
|
res.status(403).json({
|
|
success: false,
|
|
message: "접근 권한이 없습니다.",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 파일 상태를 DELETED로 변경 (논리적 삭제)
|
|
await query<any>(
|
|
"UPDATE attach_file_info SET status = $1 WHERE objid = $2",
|
|
["DELETED", objid]
|
|
);
|
|
|
|
// 🆕 레코드 모드: 해당 행의 attachments 컬럼 자동 업데이트
|
|
const targetObjid = fileRecord.target_objid;
|
|
if (targetObjid && !targetObjid.startsWith('screen_files:') && !targetObjid.startsWith('temp_')) {
|
|
// targetObjid 파싱: tableName:recordId:columnName 형식
|
|
const parts = targetObjid.split(':');
|
|
if (parts.length >= 3) {
|
|
const [tableName, recordId, columnName] = parts;
|
|
|
|
try {
|
|
// 해당 레코드의 남은 첨부파일 조회
|
|
const remainingFiles = await query<any>(
|
|
`SELECT objid, real_file_name, file_size, file_ext, file_path, regdate
|
|
FROM attach_file_info
|
|
WHERE target_objid = $1 AND status = 'ACTIVE'
|
|
ORDER BY regdate DESC`,
|
|
[targetObjid]
|
|
);
|
|
|
|
// attachments JSONB 형태로 변환
|
|
const attachmentsJson = remainingFiles.map((f: any) => ({
|
|
objid: f.objid.toString(),
|
|
realFileName: f.real_file_name,
|
|
fileSize: Number(f.file_size),
|
|
fileExt: f.file_ext,
|
|
filePath: f.file_path,
|
|
regdate: f.regdate?.toISOString(),
|
|
}));
|
|
|
|
// 해당 테이블의 attachments 컬럼 업데이트
|
|
// 🔒 멀티테넌시: company_code 필터 추가
|
|
await query(
|
|
`UPDATE ${tableName}
|
|
SET ${columnName} = $1::jsonb, updated_date = NOW()
|
|
WHERE id = $2 AND company_code = $3`,
|
|
[JSON.stringify(attachmentsJson), recordId, fileRecord.company_code]
|
|
);
|
|
|
|
console.log("📎 [파일 삭제] attachments 컬럼 자동 업데이트:", {
|
|
tableName,
|
|
recordId,
|
|
columnName,
|
|
remainingFiles: attachmentsJson.length,
|
|
});
|
|
} catch (updateError) {
|
|
// attachments 컬럼 업데이트 실패해도 파일 삭제는 성공으로 처리
|
|
console.warn("⚠️ attachments 컬럼 업데이트 실패 (무시):", updateError);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 getLinkedFiles = async (
|
|
req: AuthenticatedRequest,
|
|
res: Response
|
|
): Promise<void> => {
|
|
try {
|
|
const { tableName, recordId } = req.params;
|
|
|
|
// target_objid 생성 (테이블명:레코드ID 형식)
|
|
const baseTargetObjid = `${tableName}:${recordId}`;
|
|
|
|
// 기본 target_objid와 파일 컬럼 패턴 모두 조회 (tableName:recordId% 패턴)
|
|
const files = await query<any>(
|
|
`SELECT * FROM attach_file_info
|
|
WHERE target_objid LIKE $1 AND status = $2
|
|
ORDER BY regdate DESC`,
|
|
[`${baseTargetObjid}%`, "ACTIVE"]
|
|
);
|
|
|
|
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,
|
|
totalCount: fileList.length,
|
|
targetObjid: baseTargetObjid, // 기준 target_objid 반환
|
|
});
|
|
} 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;
|
|
|
|
const whereConditions: string[] = ["status = $1"];
|
|
const queryParams: any[] = ["ACTIVE"];
|
|
let paramIndex = 2;
|
|
|
|
if (targetObjid) {
|
|
whereConditions.push(`target_objid = $${paramIndex}`);
|
|
queryParams.push(targetObjid as string);
|
|
paramIndex++;
|
|
}
|
|
|
|
if (docType) {
|
|
whereConditions.push(`doc_type = $${paramIndex}`);
|
|
queryParams.push(docType as string);
|
|
paramIndex++;
|
|
}
|
|
|
|
const files = await query<any>(
|
|
`SELECT * FROM attach_file_info
|
|
WHERE ${whereConditions.join(" AND ")}
|
|
ORDER BY regdate DESC`,
|
|
queryParams
|
|
);
|
|
|
|
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 getComponentFiles = async (
|
|
req: AuthenticatedRequest,
|
|
res: Response
|
|
): Promise<void> => {
|
|
try {
|
|
const { screenId, componentId, tableName, recordId, columnName } =
|
|
req.query;
|
|
|
|
// 🔒 멀티테넌시: 현재 사용자의 회사 코드 가져오기
|
|
const companyCode = req.user?.companyCode;
|
|
|
|
console.log("📂 [getComponentFiles] API 호출:", {
|
|
screenId,
|
|
componentId,
|
|
tableName,
|
|
recordId,
|
|
columnName,
|
|
user: req.user?.userId,
|
|
companyCode, // 🔒 멀티테넌시 로그
|
|
});
|
|
|
|
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,
|
|
});
|
|
|
|
// 🔒 멀티테넌시: 회사별 필터링 추가
|
|
const templateFiles = await query<any>(
|
|
`SELECT * FROM attach_file_info
|
|
WHERE target_objid = $1 AND status = $2 AND company_code = $3
|
|
ORDER BY regdate DESC`,
|
|
[templateTargetObjid, "ACTIVE", companyCode]
|
|
);
|
|
|
|
console.log(
|
|
"📁 [getComponentFiles] 템플릿 파일 결과 (회사별 필터링):",
|
|
templateFiles.length
|
|
);
|
|
|
|
// 2. 데이터 파일 조회 (실제 레코드와 연결된 파일들)
|
|
let dataFiles: any[] = [];
|
|
if (tableName && recordId && columnName) {
|
|
const dataTargetObjid = `${tableName}:${recordId}:${columnName}`;
|
|
// 🔒 멀티테넌시: 회사별 필터링 추가
|
|
dataFiles = await query<any>(
|
|
`SELECT * FROM attach_file_info
|
|
WHERE target_objid = $1 AND status = $2 AND company_code = $3
|
|
ORDER BY regdate DESC`,
|
|
[dataTargetObjid, "ACTIVE", companyCode]
|
|
);
|
|
}
|
|
|
|
// 3. 레코드의 컬럼 값으로 파일 직접 조회 (수정 모달에서 기존 파일 로드)
|
|
// target_objid 매칭이 안 될 때, 테이블 레코드의 컬럼 값(파일 objid)으로 직접 찾기
|
|
if (dataFiles.length === 0 && templateFiles.length === 0 && tableName && recordId && columnName) {
|
|
try {
|
|
// 레코드에서 해당 컬럼 값 조회 (파일 objid가 저장되어 있을 수 있음)
|
|
const safeTable = String(tableName).replace(/[^a-zA-Z0-9_]/g, "");
|
|
const safeColumn = String(columnName).replace(/[^a-zA-Z0-9_]/g, "");
|
|
const recordResult = await query<any>(
|
|
`SELECT "${safeColumn}" FROM "${safeTable}" WHERE id = $1 LIMIT 1`,
|
|
[recordId]
|
|
);
|
|
|
|
if (recordResult.length > 0 && recordResult[0][safeColumn]) {
|
|
const columnValue = String(recordResult[0][safeColumn]);
|
|
// 숫자값인 경우 파일 objid로 간주하고 조회
|
|
if (/^\d+$/.test(columnValue)) {
|
|
console.log("🔍 [getComponentFiles] 레코드 컬럼 값으로 파일 조회:", { table: safeTable, column: safeColumn, fileObjid: columnValue });
|
|
const directFiles = await query<any>(
|
|
`SELECT * FROM attach_file_info
|
|
WHERE objid = $1 AND status = $2
|
|
ORDER BY regdate DESC`,
|
|
[columnValue, "ACTIVE"]
|
|
);
|
|
if (directFiles.length > 0) {
|
|
console.log("✅ [getComponentFiles] 레코드 컬럼 값으로 파일 찾음:", directFiles.length, "건");
|
|
dataFiles = directFiles;
|
|
}
|
|
}
|
|
}
|
|
} catch (lookupError) {
|
|
console.warn("⚠️ [getComponentFiles] 레코드 컬럼 값 조회 실패:", lookupError);
|
|
}
|
|
}
|
|
|
|
// 파일 정보 포맷팅 함수
|
|
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, // 템플릿 파일 여부 표시
|
|
isRepresentative: file.is_representative || false, // 대표 파일 여부
|
|
});
|
|
|
|
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 : "알 수 없는 오류",
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 파일 미리보기 (이미지 등)
|
|
*/
|
|
export const previewFile = async (
|
|
req: AuthenticatedRequest,
|
|
res: Response
|
|
): Promise<void> => {
|
|
try {
|
|
const { objid } = req.params;
|
|
const { serverFilename } = req.query;
|
|
|
|
// 🔒 멀티테넌시: 현재 사용자의 회사 코드
|
|
const companyCode = req.user?.companyCode;
|
|
|
|
const fileRecord = await queryOne<any>(
|
|
"SELECT * FROM attach_file_info WHERE objid = $1 LIMIT 1",
|
|
[objid]
|
|
);
|
|
|
|
if (!fileRecord || fileRecord.status !== "ACTIVE") {
|
|
res.status(404).json({
|
|
success: false,
|
|
message: "파일을 찾을 수 없습니다.",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 및 공개 접근 제외)
|
|
// 공개 접근(req.user가 없는 경우)은 미리보기 허용 (이미지 표시용)
|
|
if (companyCode && companyCode !== "*" && fileRecord.company_code !== companyCode) {
|
|
console.warn("⚠️ 다른 회사 파일 접근 시도:", {
|
|
userId: req.user?.userId,
|
|
userCompanyCode: companyCode,
|
|
fileCompanyCode: fileRecord.company_code,
|
|
objid,
|
|
});
|
|
res.status(403).json({
|
|
success: false,
|
|
message: "접근 권한이 없습니다.",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// file_path의 /uploads/ 이후를 baseUploadDir과 직접 결합
|
|
const fileName = fileRecord.saved_file_name!;
|
|
const dbFilePath = fileRecord.file_path || "";
|
|
const uploadsIdx = dbFilePath.indexOf("/uploads/");
|
|
let finalPath: string;
|
|
if (uploadsIdx !== -1) {
|
|
const relativePath = dbFilePath.substring(uploadsIdx + "/uploads/".length);
|
|
finalPath = path.join(baseUploadDir, relativePath);
|
|
} else {
|
|
// fallback: 기존 방식
|
|
const filePathParts = dbFilePath.split("/");
|
|
let fileCompanyCode = filePathParts[2] || "DEFAULT";
|
|
if (fileCompanyCode === "company_*") {
|
|
fileCompanyCode = "company_*";
|
|
}
|
|
let dateFolder = "";
|
|
if (filePathParts.length >= 6) {
|
|
dateFolder = `${filePathParts[3]}/${filePathParts[4]}/${filePathParts[5]}`;
|
|
}
|
|
const companyUploadDir = getCompanyUploadDir(
|
|
fileCompanyCode,
|
|
dateFolder || undefined
|
|
);
|
|
finalPath = path.join(companyUploadDir, fileName);
|
|
}
|
|
|
|
console.log("🔍 파일 미리보기 경로 확인:", {
|
|
objid: objid,
|
|
filePathFromDB: fileRecord.file_path,
|
|
companyCode: companyCode,
|
|
finalFilePath: finalPath,
|
|
fileExists: fs.existsSync(finalPath),
|
|
});
|
|
|
|
if (!fs.existsSync(finalPath)) {
|
|
console.error("❌ 파일 없음:", finalPath);
|
|
res.status(404).json({
|
|
success: false,
|
|
message: `실제 파일을 찾을 수 없습니다: ${finalPath}`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// MIME 타입 설정
|
|
const ext = path.extname(fileName).toLowerCase();
|
|
let mimeType = "application/octet-stream";
|
|
|
|
switch (ext) {
|
|
case ".jpg":
|
|
case ".jpeg":
|
|
mimeType = "image/jpeg";
|
|
break;
|
|
case ".png":
|
|
mimeType = "image/png";
|
|
break;
|
|
case ".gif":
|
|
mimeType = "image/gif";
|
|
break;
|
|
case ".webp":
|
|
mimeType = "image/webp";
|
|
break;
|
|
case ".pdf":
|
|
mimeType = "application/pdf";
|
|
break;
|
|
default:
|
|
mimeType = "application/octet-stream";
|
|
}
|
|
|
|
// CORS 헤더 설정 (credentials 모드에서는 구체적인 origin 필요)
|
|
const origin = req.headers.origin || "http://localhost:9771";
|
|
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
res.setHeader(
|
|
"Access-Control-Allow-Methods",
|
|
"GET, POST, PUT, DELETE, OPTIONS"
|
|
);
|
|
res.setHeader(
|
|
"Access-Control-Allow-Headers",
|
|
"Content-Type, Authorization, X-Requested-With, Accept, Origin"
|
|
);
|
|
res.setHeader("Access-Control-Allow-Credentials", "true");
|
|
|
|
// Cross-Origin-Resource-Policy: cross-origin 설정
|
|
// helmet 기본값(same-origin)을 오버라이드하여 v1.vexplor.com에서 api.vexplor.com 이미지 로드 허용
|
|
res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
|
|
|
|
// 파일 크기 및 캐시 헤더 설정
|
|
const stat = fs.statSync(finalPath);
|
|
res.setHeader("Content-Length", stat.size);
|
|
res.setHeader("Cache-Control", "public, max-age=3600");
|
|
res.setHeader("Content-Type", mimeType);
|
|
|
|
// 파일 스트림으로 전송
|
|
const fileStream = fs.createReadStream(finalPath);
|
|
fileStream.on("error", (err) => {
|
|
console.error("파일 스트림 오류:", err);
|
|
if (!res.headersSent) {
|
|
res.status(500).json({ success: false, message: "파일 읽기 오류" });
|
|
} else {
|
|
res.end();
|
|
}
|
|
});
|
|
fileStream.pipe(res);
|
|
} catch (error) {
|
|
console.error("파일 미리보기 오류:", error);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: "파일 미리보기 중 오류가 발생했습니다.",
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 파일 다운로드
|
|
*/
|
|
export const downloadFile = async (
|
|
req: AuthenticatedRequest,
|
|
res: Response
|
|
): Promise<void> => {
|
|
try {
|
|
const { objid } = req.params;
|
|
|
|
// 🔒 멀티테넌시: 현재 사용자의 회사 코드
|
|
const companyCode = req.user?.companyCode;
|
|
|
|
const fileRecord = await queryOne<any>(
|
|
`SELECT * FROM attach_file_info WHERE objid = $1`,
|
|
[objid]
|
|
);
|
|
|
|
if (!fileRecord || fileRecord.status !== "ACTIVE") {
|
|
res.status(404).json({
|
|
success: false,
|
|
message: "파일을 찾을 수 없습니다.",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 제외)
|
|
if (companyCode !== "*" && fileRecord.company_code !== companyCode) {
|
|
console.warn("⚠️ 다른 회사 파일 다운로드 시도:", {
|
|
userId: req.user?.userId,
|
|
userCompanyCode: companyCode,
|
|
fileCompanyCode: fileRecord.company_code,
|
|
objid,
|
|
});
|
|
res.status(403).json({
|
|
success: false,
|
|
message: "접근 권한이 없습니다.",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 파일 경로에서 회사코드와 날짜 폴더 추출 (예: /uploads/company_*/2025/09/05/timestamp_filename.ext)
|
|
const filePathParts = fileRecord.file_path!.split("/");
|
|
let fileCompanyCode = filePathParts[2] || "DEFAULT"; // /uploads/company_*/2025/09/05/filename.ext에서 company_* 추출
|
|
|
|
// company_* 처리 (실제 회사 코드로 변환)
|
|
if (fileCompanyCode === "company_*") {
|
|
fileCompanyCode = "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(
|
|
fileCompanyCode,
|
|
dateFolder || undefined
|
|
);
|
|
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({
|
|
success: false,
|
|
message: `실제 파일을 찾을 수 없습니다: ${filePath}`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 파일 다운로드 헤더 설정
|
|
res.setHeader(
|
|
"Content-Disposition",
|
|
`attachment; filename="${encodeURIComponent(fileRecord.real_file_name!)}"`
|
|
);
|
|
res.setHeader("Content-Type", "application/octet-stream");
|
|
res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
|
|
const stat = fs.statSync(filePath);
|
|
res.setHeader("Content-Length", stat.size);
|
|
|
|
// 파일 스트림 전송
|
|
const fileStream = fs.createReadStream(filePath);
|
|
fileStream.on("error", (err) => {
|
|
console.error("파일 스트림 오류:", err);
|
|
if (!res.headersSent) {
|
|
res.status(500).json({ success: false, message: "파일 읽기 오류" });
|
|
} else {
|
|
res.end();
|
|
}
|
|
});
|
|
fileStream.pipe(res);
|
|
} catch (error) {
|
|
console.error("파일 다운로드 오류:", error);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: "파일 다운로드 중 오류가 발생했습니다.",
|
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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 queryOne<any>(
|
|
"SELECT * FROM attach_file_info WHERE objid = $1 LIMIT 1",
|
|
[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 queryOne<any>(
|
|
"SELECT * FROM attach_file_info WHERE objid = $1 LIMIT 1",
|
|
[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("Cross-Origin-Resource-Policy", "cross-origin");
|
|
const stat = fs.statSync(filePath);
|
|
res.setHeader("Content-Length", stat.size);
|
|
res.setHeader("Cache-Control", "public, max-age=300"); // 5분 캐시
|
|
|
|
// 파일 스트림 전송
|
|
const fileStream = fs.createReadStream(filePath);
|
|
fileStream.on("error", (err) => {
|
|
console.error("파일 스트림 오류:", err);
|
|
if (!res.headersSent) {
|
|
res.status(500).json({ success: false, message: "파일 읽기 오류" });
|
|
} else {
|
|
res.end();
|
|
}
|
|
});
|
|
fileStream.pipe(res);
|
|
} catch (error) {
|
|
console.error("❌ 토큰 파일 접근 오류:", error);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: "파일 접근에 실패했습니다.",
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 대표 파일 설정
|
|
*/
|
|
export const setRepresentativeFile = async (
|
|
req: AuthenticatedRequest,
|
|
res: Response
|
|
): Promise<void> => {
|
|
try {
|
|
const { objid } = req.params;
|
|
const companyCode = req.user?.companyCode;
|
|
|
|
// 파일 존재 여부 및 권한 확인
|
|
const fileRecord = await queryOne<any>(
|
|
`SELECT * FROM attach_file_info WHERE objid = $1 AND status = $2`,
|
|
[objid, "ACTIVE"]
|
|
);
|
|
|
|
if (!fileRecord) {
|
|
res.status(404).json({
|
|
success: false,
|
|
message: "파일을 찾을 수 없습니다.",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 멀티테넌시: 회사 코드 확인
|
|
if (companyCode !== "*" && fileRecord.company_code !== companyCode) {
|
|
res.status(403).json({
|
|
success: false,
|
|
message: "접근 권한이 없습니다.",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 같은 target_objid의 다른 파일들의 is_representative를 false로 설정
|
|
await query<any>(
|
|
`UPDATE attach_file_info
|
|
SET is_representative = false
|
|
WHERE target_objid = $1 AND objid != $2`,
|
|
[fileRecord.target_objid, objid]
|
|
);
|
|
|
|
// 선택한 파일을 대표 파일로 설정
|
|
await query<any>(
|
|
`UPDATE attach_file_info
|
|
SET is_representative = true
|
|
WHERE objid = $1`,
|
|
[objid]
|
|
);
|
|
|
|
res.json({
|
|
success: true,
|
|
message: "대표 파일이 설정되었습니다.",
|
|
});
|
|
} catch (error) {
|
|
console.error("대표 파일 설정 오류:", error);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: "대표 파일 설정 중 오류가 발생했습니다.",
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 파일 정보 조회 (메타데이터만, 파일 내용 없음)
|
|
* 공개 접근 허용
|
|
*/
|
|
export const getFileInfo = async (req: Request, res: Response) => {
|
|
try {
|
|
const { objid } = req.params;
|
|
|
|
if (!objid) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: "파일 ID가 필요합니다.",
|
|
});
|
|
}
|
|
|
|
// 파일 정보 조회
|
|
const fileRecord = await queryOne<any>(
|
|
`SELECT objid, real_file_name, file_size, file_ext, file_path, regdate, is_representative
|
|
FROM attach_file_info
|
|
WHERE objid = $1 AND status = 'ACTIVE'`,
|
|
[objid]
|
|
);
|
|
|
|
if (!fileRecord) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
message: "파일을 찾을 수 없습니다.",
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
objid: fileRecord.objid.toString(),
|
|
realFileName: fileRecord.real_file_name,
|
|
fileSize: fileRecord.file_size,
|
|
fileExt: fileRecord.file_ext,
|
|
filePath: fileRecord.file_path,
|
|
regdate: fileRecord.regdate,
|
|
isRepresentative: fileRecord.is_representative,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error("파일 정보 조회 오류:", error);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: "파일 정보 조회 중 오류가 발생했습니다.",
|
|
});
|
|
}
|
|
};
|
|
|
|
// Multer 미들웨어 export
|
|
export const uploadMiddleware = upload.array("files", 10); // 최대 10개 파일
|