2025-09-05 21:52:19 +09:00
|
|
|
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";
|
2025-10-01 14:33:08 +09:00
|
|
|
import { query, queryOne } from "../database/db";
|
2025-09-05 21:52:19 +09:00
|
|
|
|
2025-09-29 13:29:03 +09:00
|
|
|
// 임시 토큰 저장소 (메모리 기반, 실제 운영에서는 Redis 사용 권장)
|
|
|
|
|
const tempTokens = new Map<string, { objid: string; expires: number }>();
|
|
|
|
|
|
2025-09-05 21:52:19 +09:00
|
|
|
// 업로드 디렉토리 설정 (회사별로 분리)
|
|
|
|
|
const baseUploadDir = path.join(process.cwd(), "uploads");
|
2025-09-18 18:38:13 +09:00
|
|
|
|
|
|
|
|
// 디렉토리 생성 함수 (에러 핸들링 포함)
|
|
|
|
|
const ensureUploadDir = () => {
|
|
|
|
|
try {
|
|
|
|
|
if (!fs.existsSync(baseUploadDir)) {
|
|
|
|
|
fs.mkdirSync(baseUploadDir, { recursive: true });
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn(
|
|
|
|
|
`업로드 디렉토리 생성 실패: ${error}. 기존 디렉토리를 사용합니다.`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 초기화 시 디렉토리 확인
|
|
|
|
|
ensureUploadDir();
|
2025-09-05 21:52:19 +09:00
|
|
|
|
|
|
|
|
// 회사별 + 날짜별 디렉토리 생성 함수
|
|
|
|
|
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();
|
2025-10-01 14:37:33 +09:00
|
|
|
|
2025-09-26 13:11:34 +09:00
|
|
|
console.log("📁 파일명 처리:", {
|
|
|
|
|
originalname: file.originalname,
|
|
|
|
|
encoding: file.encoding,
|
2025-10-01 14:37:33 +09:00
|
|
|
mimetype: file.mimetype,
|
2025-09-26 13:11:34 +09:00
|
|
|
});
|
2025-10-01 14:37:33 +09:00
|
|
|
|
2025-09-26 13:11:34 +09:00
|
|
|
// UTF-8 인코딩 문제 해결: Buffer를 통한 올바른 디코딩
|
|
|
|
|
let decodedName;
|
|
|
|
|
try {
|
|
|
|
|
// 파일명이 깨진 경우 Buffer를 통해 올바르게 디코딩
|
2025-10-01 14:37:33 +09:00
|
|
|
const buffer = Buffer.from(file.originalname, "latin1");
|
|
|
|
|
decodedName = buffer.toString("utf8");
|
|
|
|
|
console.log("📁 파일명 디코딩:", {
|
|
|
|
|
original: file.originalname,
|
|
|
|
|
decoded: decodedName,
|
|
|
|
|
});
|
2025-09-26 13:11:34 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
// 디코딩 실패 시 원본 사용
|
|
|
|
|
decodedName = file.originalname;
|
|
|
|
|
console.log("📁 파일명 디코딩 실패, 원본 사용:", file.originalname);
|
|
|
|
|
}
|
2025-10-01 14:37:33 +09:00
|
|
|
|
2025-09-26 13:11:34 +09:00
|
|
|
// 한국어를 포함한 유니코드 문자 보존하면서 안전한 파일명 생성
|
|
|
|
|
// 위험한 문자만 제거: / \ : * ? " < > |
|
|
|
|
|
const sanitizedName = decodedName
|
2025-10-01 14:37:33 +09:00
|
|
|
.replace(/[\/\\:*?"<>|]/g, "_") // 파일시스템에서 금지된 문자만 치환
|
|
|
|
|
.replace(/\s+/g, "_") // 공백을 언더스코어로 치환
|
|
|
|
|
.replace(/_{2,}/g, "_"); // 연속된 언더스코어를 하나로 축약
|
|
|
|
|
|
2025-09-05 21:52:19 +09:00
|
|
|
const savedFileName = `${timestamp}_${sanitizedName}`;
|
2025-10-01 14:37:33 +09:00
|
|
|
|
2025-09-26 13:11:34 +09:00
|
|
|
console.log("📁 파일명 변환:", {
|
|
|
|
|
original: file.originalname,
|
|
|
|
|
sanitized: sanitizedName,
|
2025-10-01 14:37:33 +09:00
|
|
|
saved: savedFileName,
|
2025-09-26 13:11:34 +09:00
|
|
|
});
|
2025-10-01 14:37:33 +09:00
|
|
|
|
2025-09-05 21:52:19 +09:00
|
|
|
cb(null, savedFileName);
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const upload = multer({
|
|
|
|
|
storage: storage,
|
|
|
|
|
limits: {
|
|
|
|
|
fileSize: 50 * 1024 * 1024, // 50MB 제한
|
|
|
|
|
},
|
|
|
|
|
fileFilter: (req, file, cb) => {
|
2025-09-06 00:16:27 +09:00
|
|
|
// 프론트엔드에서 전송된 accept 정보 확인
|
|
|
|
|
const acceptHeader = req.body?.accept;
|
|
|
|
|
|
|
|
|
|
// 프론트엔드에서 */* 또는 * 허용한 경우 모든 파일 허용
|
|
|
|
|
if (
|
|
|
|
|
acceptHeader &&
|
|
|
|
|
(acceptHeader.includes("*/*") || acceptHeader.includes("*"))
|
|
|
|
|
) {
|
|
|
|
|
cb(null, true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 기본 허용 파일 타입
|
|
|
|
|
const defaultAllowedTypes = [
|
2025-09-26 13:11:34 +09:00
|
|
|
// 이미지 파일
|
2025-09-05 21:52:19 +09:00
|
|
|
"image/jpeg",
|
|
|
|
|
"image/png",
|
|
|
|
|
"image/gif",
|
2025-09-26 13:11:34 +09:00
|
|
|
"image/webp",
|
|
|
|
|
"image/svg+xml",
|
|
|
|
|
// 텍스트 파일
|
|
|
|
|
"text/html",
|
|
|
|
|
"text/plain",
|
|
|
|
|
"text/markdown",
|
|
|
|
|
"text/csv",
|
|
|
|
|
"application/json",
|
|
|
|
|
"application/xml",
|
|
|
|
|
// PDF 파일
|
2025-09-05 21:52:19 +09:00
|
|
|
"application/pdf",
|
2025-09-26 13:11:34 +09:00
|
|
|
// 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)
|
2025-10-01 14:37:33 +09:00
|
|
|
"application/vnd.apple.numbers", // .numbers (Numbers)
|
2025-09-26 13:11:34 +09:00
|
|
|
"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
|
2025-09-05 21:52:19 +09:00
|
|
|
];
|
|
|
|
|
|
2025-09-06 00:16:27 +09:00
|
|
|
if (defaultAllowedTypes.includes(file.mimetype)) {
|
2025-09-05 21:52:19 +09:00
|
|
|
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[];
|
2025-09-06 00:16:27 +09:00
|
|
|
|
2025-09-05 21:52:19 +09:00
|
|
|
const {
|
|
|
|
|
docType = "DOCUMENT",
|
|
|
|
|
docTypeName = "일반 문서",
|
|
|
|
|
targetObjid,
|
|
|
|
|
parentTargetObjid,
|
2025-09-06 00:16:27 +09:00
|
|
|
// 테이블 연결 정보 (새로 추가)
|
|
|
|
|
linkedTable,
|
|
|
|
|
linkedField,
|
|
|
|
|
recordId,
|
|
|
|
|
autoLink,
|
|
|
|
|
// 가상 파일 컬럼 정보
|
|
|
|
|
columnName,
|
|
|
|
|
isVirtualFileColumn,
|
2025-09-05 21:52:19 +09:00
|
|
|
} = 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";
|
|
|
|
|
|
2025-09-06 00:16:27 +09:00
|
|
|
// 자동 연결 로직 - target_objid 자동 생성
|
|
|
|
|
let finalTargetObjid = targetObjid;
|
2025-11-04 17:57:28 +09:00
|
|
|
|
|
|
|
|
// 🔑 템플릿 파일(screen_files:)이나 temp_ 파일은 autoLink 무시
|
|
|
|
|
const isTemplateFile = targetObjid && (targetObjid.startsWith('screen_files:') || targetObjid.startsWith('temp_'));
|
|
|
|
|
|
|
|
|
|
if (!isTemplateFile && autoLink === "true" && linkedTable && recordId) {
|
2025-09-06 00:16:27 +09:00
|
|
|
// 가상 파일 컬럼의 경우 컬럼명도 포함한 target_objid 생성
|
|
|
|
|
if (isVirtualFileColumn === "true" && columnName) {
|
|
|
|
|
finalTargetObjid = `${linkedTable}:${recordId}:${columnName}`;
|
|
|
|
|
} else {
|
|
|
|
|
finalTargetObjid = `${linkedTable}:${recordId}`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-05 21:52:19 +09:00
|
|
|
const savedFiles = [];
|
|
|
|
|
|
|
|
|
|
for (const file of files) {
|
2025-09-26 13:11:34 +09:00
|
|
|
// 파일명 디코딩 (파일 저장 시와 동일한 로직)
|
|
|
|
|
let decodedOriginalName;
|
|
|
|
|
try {
|
2025-10-01 14:37:33 +09:00
|
|
|
const buffer = Buffer.from(file.originalname, "latin1");
|
|
|
|
|
decodedOriginalName = buffer.toString("utf8");
|
|
|
|
|
console.log("💾 DB 저장용 파일명 디코딩:", {
|
|
|
|
|
original: file.originalname,
|
|
|
|
|
decoded: decodedOriginalName,
|
|
|
|
|
});
|
2025-09-26 13:11:34 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
decodedOriginalName = file.originalname;
|
2025-10-01 14:37:33 +09:00
|
|
|
console.log(
|
|
|
|
|
"💾 DB 저장용 파일명 디코딩 실패, 원본 사용:",
|
|
|
|
|
file.originalname
|
|
|
|
|
);
|
2025-09-26 13:11:34 +09:00
|
|
|
}
|
2025-10-01 14:37:33 +09:00
|
|
|
|
2025-09-05 21:52:19 +09:00
|
|
|
// 파일 확장자 추출
|
|
|
|
|
const fileExt = path
|
2025-09-26 13:11:34 +09:00
|
|
|
.extname(decodedOriginalName)
|
2025-09-05 21:52:19 +09:00
|
|
|
.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;
|
2025-10-01 14:37:33 +09:00
|
|
|
|
2025-09-05 21:52:19 +09:00
|
|
|
// 임시 파일을 최종 위치로 이동
|
|
|
|
|
const tempFilePath = file.path; // Multer가 저장한 임시 파일 경로
|
|
|
|
|
const finalUploadDir = getCompanyUploadDir(companyCode, dateFolder);
|
|
|
|
|
const finalFilePath = path.join(finalUploadDir, file.filename);
|
|
|
|
|
|
|
|
|
|
// 파일 이동
|
|
|
|
|
fs.renameSync(tempFilePath, finalFilePath);
|
|
|
|
|
|
2025-09-29 13:29:03 +09:00
|
|
|
// DB에 저장할 경로 (실제 파일 위치와 일치)
|
|
|
|
|
const relativePath = `/${actualCompanyCode}/${dateFolder}/${file.filename}`;
|
|
|
|
|
const fullFilePath = `/uploads${relativePath}`;
|
|
|
|
|
|
2025-09-05 21:52:19 +09:00
|
|
|
// attach_file_info 테이블에 저장
|
2025-10-01 14:33:08 +09:00
|
|
|
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 *`,
|
|
|
|
|
[
|
2025-10-01 14:37:33 +09:00
|
|
|
objidValue,
|
|
|
|
|
finalTargetObjid,
|
|
|
|
|
file.filename,
|
|
|
|
|
decodedOriginalName,
|
|
|
|
|
docType,
|
|
|
|
|
docTypeName,
|
|
|
|
|
file.size,
|
|
|
|
|
fileExt,
|
|
|
|
|
fullFilePath,
|
|
|
|
|
companyCode,
|
|
|
|
|
writer,
|
|
|
|
|
new Date(),
|
|
|
|
|
"ACTIVE",
|
|
|
|
|
parentTargetObjid,
|
2025-10-01 14:33:08 +09:00
|
|
|
]
|
|
|
|
|
);
|
2025-09-05 21:52:19 +09:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
2025-11-04 17:57:28 +09:00
|
|
|
// 🔒 멀티테넌시: 현재 사용자의 회사 코드
|
|
|
|
|
const companyCode = req.user?.companyCode;
|
|
|
|
|
|
|
|
|
|
// 파일 정보 조회
|
|
|
|
|
const fileRecord = await queryOne<any>(
|
|
|
|
|
`SELECT * FROM attach_file_info WHERE objid = $1`,
|
|
|
|
|
[parseInt(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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-05 21:52:19 +09:00
|
|
|
// 파일 상태를 DELETED로 변경 (논리적 삭제)
|
2025-10-01 14:33:08 +09:00
|
|
|
await query<any>(
|
|
|
|
|
"UPDATE attach_file_info SET status = $1 WHERE objid = $2",
|
|
|
|
|
["DELETED", parseInt(objid)]
|
|
|
|
|
);
|
2025-09-05 21:52:19 +09:00
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
message: "파일이 삭제되었습니다.",
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("파일 삭제 오류:", error);
|
|
|
|
|
res.status(500).json({
|
|
|
|
|
success: false,
|
|
|
|
|
message: "파일 삭제 중 오류가 발생했습니다.",
|
|
|
|
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-06 00:16:27 +09:00
|
|
|
/**
|
|
|
|
|
* 테이블 연결된 파일 조회
|
|
|
|
|
*/
|
|
|
|
|
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% 패턴)
|
2025-10-01 14:33:08 +09:00
|
|
|
const files = await query<any>(
|
|
|
|
|
`SELECT * FROM attach_file_info
|
|
|
|
|
WHERE target_objid LIKE $1 AND status = $2
|
|
|
|
|
ORDER BY regdate DESC`,
|
|
|
|
|
[`${baseTargetObjid}%`, "ACTIVE"]
|
|
|
|
|
);
|
2025-09-06 00:16:27 +09:00
|
|
|
|
|
|
|
|
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 : "알 수 없는 오류",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-05 21:52:19 +09:00
|
|
|
/**
|
|
|
|
|
* 파일 목록 조회
|
|
|
|
|
*/
|
|
|
|
|
export const getFileList = async (
|
|
|
|
|
req: AuthenticatedRequest,
|
|
|
|
|
res: Response
|
|
|
|
|
): Promise<void> => {
|
|
|
|
|
try {
|
|
|
|
|
const { targetObjid, docType, companyCode } = req.query;
|
|
|
|
|
|
2025-10-01 14:33:08 +09:00
|
|
|
const whereConditions: string[] = ["status = $1"];
|
|
|
|
|
const queryParams: any[] = ["ACTIVE"];
|
|
|
|
|
let paramIndex = 2;
|
2025-09-05 21:52:19 +09:00
|
|
|
|
|
|
|
|
if (targetObjid) {
|
2025-10-01 14:33:08 +09:00
|
|
|
whereConditions.push(`target_objid = $${paramIndex}`);
|
|
|
|
|
queryParams.push(targetObjid as string);
|
|
|
|
|
paramIndex++;
|
2025-09-05 21:52:19 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (docType) {
|
2025-10-01 14:33:08 +09:00
|
|
|
whereConditions.push(`doc_type = $${paramIndex}`);
|
|
|
|
|
queryParams.push(docType as string);
|
|
|
|
|
paramIndex++;
|
2025-09-05 21:52:19 +09:00
|
|
|
}
|
|
|
|
|
|
2025-10-01 14:33:08 +09:00
|
|
|
const files = await query<any>(
|
|
|
|
|
`SELECT * FROM attach_file_info
|
|
|
|
|
WHERE ${whereConditions.join(" AND ")}
|
|
|
|
|
ORDER BY regdate DESC`,
|
|
|
|
|
queryParams
|
|
|
|
|
);
|
2025-09-05 21:52:19 +09:00
|
|
|
|
|
|
|
|
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 : "알 수 없는 오류",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-29 13:29:03 +09:00
|
|
|
/**
|
|
|
|
|
* 컴포넌트의 템플릿 파일과 데이터 파일을 모두 조회
|
|
|
|
|
*/
|
|
|
|
|
export const getComponentFiles = async (
|
|
|
|
|
req: AuthenticatedRequest,
|
|
|
|
|
res: Response
|
|
|
|
|
): Promise<void> => {
|
|
|
|
|
try {
|
2025-10-01 14:37:33 +09:00
|
|
|
const { screenId, componentId, tableName, recordId, columnName } =
|
|
|
|
|
req.query;
|
|
|
|
|
|
2025-11-04 17:57:28 +09:00
|
|
|
// 🔒 멀티테넌시: 현재 사용자의 회사 코드 가져오기
|
|
|
|
|
const companyCode = req.user?.companyCode;
|
|
|
|
|
|
2025-09-29 13:29:03 +09:00
|
|
|
console.log("📂 [getComponentFiles] API 호출:", {
|
|
|
|
|
screenId,
|
|
|
|
|
componentId,
|
|
|
|
|
tableName,
|
|
|
|
|
recordId,
|
|
|
|
|
columnName,
|
2025-10-01 14:37:33 +09:00
|
|
|
user: req.user?.userId,
|
2025-11-04 17:57:28 +09:00
|
|
|
companyCode, // 🔒 멀티테넌시 로그
|
2025-09-29 13:29:03 +09:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!screenId || !componentId) {
|
|
|
|
|
console.log("❌ [getComponentFiles] 필수 파라미터 누락");
|
|
|
|
|
res.status(400).json({
|
|
|
|
|
success: false,
|
|
|
|
|
message: "screenId와 componentId가 필요합니다.",
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 1. 템플릿 파일 조회 (화면 설계 시 업로드한 파일들)
|
2025-10-01 14:37:33 +09:00
|
|
|
const templateTargetObjid = `screen_files:${screenId}:${componentId}:${columnName || "field_1"}`;
|
|
|
|
|
console.log("🔍 [getComponentFiles] 템플릿 파일 조회:", {
|
|
|
|
|
templateTargetObjid,
|
|
|
|
|
});
|
|
|
|
|
|
2025-11-04 17:57:28 +09:00
|
|
|
// 🔒 멀티테넌시: 회사별 필터링 추가
|
2025-10-01 14:33:08 +09:00
|
|
|
const templateFiles = await query<any>(
|
|
|
|
|
`SELECT * FROM attach_file_info
|
2025-11-04 17:57:28 +09:00
|
|
|
WHERE target_objid = $1 AND status = $2 AND company_code = $3
|
2025-10-01 14:33:08 +09:00
|
|
|
ORDER BY regdate DESC`,
|
2025-11-04 17:57:28 +09:00
|
|
|
[templateTargetObjid, "ACTIVE", companyCode]
|
2025-10-01 14:33:08 +09:00
|
|
|
);
|
2025-10-01 14:37:33 +09:00
|
|
|
|
|
|
|
|
console.log(
|
2025-11-04 17:57:28 +09:00
|
|
|
"📁 [getComponentFiles] 템플릿 파일 결과 (회사별 필터링):",
|
2025-10-01 14:37:33 +09:00
|
|
|
templateFiles.length
|
|
|
|
|
);
|
2025-09-29 13:29:03 +09:00
|
|
|
|
|
|
|
|
// 2. 데이터 파일 조회 (실제 레코드와 연결된 파일들)
|
|
|
|
|
let dataFiles: any[] = [];
|
|
|
|
|
if (tableName && recordId && columnName) {
|
|
|
|
|
const dataTargetObjid = `${tableName}:${recordId}:${columnName}`;
|
2025-11-04 17:57:28 +09:00
|
|
|
// 🔒 멀티테넌시: 회사별 필터링 추가
|
2025-10-01 14:33:08 +09:00
|
|
|
dataFiles = await query<any>(
|
|
|
|
|
`SELECT * FROM attach_file_info
|
2025-11-04 17:57:28 +09:00
|
|
|
WHERE target_objid = $1 AND status = $2 AND company_code = $3
|
2025-10-01 14:33:08 +09:00
|
|
|
ORDER BY regdate DESC`,
|
2025-11-04 17:57:28 +09:00
|
|
|
[dataTargetObjid, "ACTIVE", companyCode]
|
2025-10-01 14:33:08 +09:00
|
|
|
);
|
2025-09-29 13:29:03 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 파일 정보 포맷팅 함수
|
|
|
|
|
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, // 템플릿 파일 여부 표시
|
2025-11-05 15:50:29 +09:00
|
|
|
isRepresentative: file.is_representative || false, // 대표 파일 여부
|
2025-09-29 13:29:03 +09:00
|
|
|
});
|
|
|
|
|
|
2025-10-01 14:37:33 +09:00
|
|
|
const formattedTemplateFiles = templateFiles.map((file) =>
|
|
|
|
|
formatFileInfo(file, true)
|
|
|
|
|
);
|
|
|
|
|
const formattedDataFiles = dataFiles.map((file) =>
|
|
|
|
|
formatFileInfo(file, false)
|
|
|
|
|
);
|
2025-09-29 13:29:03 +09:00
|
|
|
|
|
|
|
|
// 3. 전체 파일 목록 (데이터 파일 우선, 없으면 템플릿 파일 표시)
|
2025-10-01 14:37:33 +09:00
|
|
|
const totalFiles =
|
|
|
|
|
formattedDataFiles.length > 0
|
|
|
|
|
? formattedDataFiles
|
|
|
|
|
: formattedTemplateFiles;
|
2025-09-29 13:29:03 +09:00
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
templateFiles: formattedTemplateFiles,
|
|
|
|
|
dataFiles: formattedDataFiles,
|
|
|
|
|
totalFiles,
|
|
|
|
|
summary: {
|
|
|
|
|
templateCount: formattedTemplateFiles.length,
|
|
|
|
|
dataCount: formattedDataFiles.length,
|
|
|
|
|
totalCount: totalFiles.length,
|
|
|
|
|
templateTargetObjid,
|
2025-10-01 14:37:33 +09:00
|
|
|
dataTargetObjid:
|
|
|
|
|
tableName && recordId && columnName
|
|
|
|
|
? `${tableName}:${recordId}:${columnName}`
|
|
|
|
|
: null,
|
2025-09-29 13:29:03 +09:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("컴포넌트 파일 조회 오류:", error);
|
|
|
|
|
res.status(500).json({
|
|
|
|
|
success: false,
|
|
|
|
|
message: "컴포넌트 파일 조회 중 오류가 발생했습니다.",
|
|
|
|
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-08 13:10:09 +09:00
|
|
|
/**
|
|
|
|
|
* 파일 미리보기 (이미지 등)
|
|
|
|
|
*/
|
|
|
|
|
export const previewFile = async (
|
|
|
|
|
req: AuthenticatedRequest,
|
|
|
|
|
res: Response
|
|
|
|
|
): Promise<void> => {
|
|
|
|
|
try {
|
|
|
|
|
const { objid } = req.params;
|
|
|
|
|
const { serverFilename } = req.query;
|
|
|
|
|
|
2025-11-04 17:57:28 +09:00
|
|
|
// 🔒 멀티테넌시: 현재 사용자의 회사 코드
|
|
|
|
|
const companyCode = req.user?.companyCode;
|
|
|
|
|
|
2025-10-01 14:33:08 +09:00
|
|
|
const fileRecord = await queryOne<any>(
|
|
|
|
|
"SELECT * FROM attach_file_info WHERE objid = $1 LIMIT 1",
|
|
|
|
|
[parseInt(objid)]
|
|
|
|
|
);
|
2025-09-08 13:10:09 +09:00
|
|
|
|
|
|
|
|
if (!fileRecord || fileRecord.status !== "ACTIVE") {
|
|
|
|
|
res.status(404).json({
|
|
|
|
|
success: false,
|
|
|
|
|
message: "파일을 찾을 수 없습니다.",
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-04 17:57:28 +09:00
|
|
|
// 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 제외)
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-08 13:10:09 +09:00
|
|
|
// 파일 경로에서 회사코드와 날짜 폴더 추출
|
|
|
|
|
const filePathParts = fileRecord.file_path!.split("/");
|
2025-11-04 17:57:28 +09:00
|
|
|
let fileCompanyCode = filePathParts[2] || "DEFAULT";
|
2025-10-01 14:37:33 +09:00
|
|
|
|
2025-09-29 13:29:03 +09:00
|
|
|
// company_* 처리 (실제 회사 코드로 변환)
|
2025-11-04 17:57:28 +09:00
|
|
|
if (fileCompanyCode === "company_*") {
|
|
|
|
|
fileCompanyCode = "company_*"; // 실제 디렉토리명 유지
|
2025-09-29 13:29:03 +09:00
|
|
|
}
|
2025-10-01 14:37:33 +09:00
|
|
|
|
2025-09-08 13:10:09 +09:00
|
|
|
const fileName = fileRecord.saved_file_name!;
|
|
|
|
|
|
|
|
|
|
// 파일 경로에 날짜 구조가 있는지 확인 (YYYY/MM/DD)
|
|
|
|
|
let dateFolder = "";
|
|
|
|
|
if (filePathParts.length >= 6) {
|
|
|
|
|
dateFolder = `${filePathParts[3]}/${filePathParts[4]}/${filePathParts[5]}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const companyUploadDir = getCompanyUploadDir(
|
2025-11-04 17:57:28 +09:00
|
|
|
fileCompanyCode,
|
2025-09-08 13:10:09 +09:00
|
|
|
dateFolder || undefined
|
|
|
|
|
);
|
|
|
|
|
const filePath = path.join(companyUploadDir, fileName);
|
|
|
|
|
|
2025-09-29 13:29:03 +09:00
|
|
|
console.log("🔍 파일 미리보기 경로 확인:", {
|
|
|
|
|
objid: objid,
|
|
|
|
|
filePathFromDB: fileRecord.file_path,
|
|
|
|
|
companyCode: companyCode,
|
|
|
|
|
dateFolder: dateFolder,
|
|
|
|
|
fileName: fileName,
|
|
|
|
|
companyUploadDir: companyUploadDir,
|
|
|
|
|
finalFilePath: filePath,
|
2025-10-01 14:37:33 +09:00
|
|
|
fileExists: fs.existsSync(filePath),
|
2025-09-29 13:29:03 +09:00
|
|
|
});
|
|
|
|
|
|
2025-09-08 13:10:09 +09:00
|
|
|
if (!fs.existsSync(filePath)) {
|
|
|
|
|
console.error("❌ 파일 없음:", filePath);
|
|
|
|
|
res.status(404).json({
|
|
|
|
|
success: false,
|
|
|
|
|
message: `실제 파일을 찾을 수 없습니다: ${filePath}`,
|
|
|
|
|
});
|
|
|
|
|
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";
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-05 15:39:02 +09:00
|
|
|
// CORS 헤더 설정 (credentials 모드에서는 구체적인 origin 필요)
|
|
|
|
|
const origin = req.headers.origin || "http://localhost:9771";
|
|
|
|
|
res.setHeader("Access-Control-Allow-Origin", origin);
|
2025-09-08 13:10:09 +09:00
|
|
|
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");
|
|
|
|
|
|
|
|
|
|
// 캐시 헤더 설정
|
|
|
|
|
res.setHeader("Cache-Control", "public, max-age=3600");
|
|
|
|
|
res.setHeader("Content-Type", mimeType);
|
|
|
|
|
|
|
|
|
|
// 파일 스트림으로 전송
|
|
|
|
|
const fileStream = fs.createReadStream(filePath);
|
|
|
|
|
fileStream.pipe(res);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("파일 미리보기 오류:", error);
|
|
|
|
|
res.status(500).json({
|
|
|
|
|
success: false,
|
|
|
|
|
message: "파일 미리보기 중 오류가 발생했습니다.",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-05 21:52:19 +09:00
|
|
|
/**
|
|
|
|
|
* 파일 다운로드
|
|
|
|
|
*/
|
|
|
|
|
export const downloadFile = async (
|
|
|
|
|
req: AuthenticatedRequest,
|
|
|
|
|
res: Response
|
|
|
|
|
): Promise<void> => {
|
|
|
|
|
try {
|
|
|
|
|
const { objid } = req.params;
|
|
|
|
|
|
2025-11-04 17:57:28 +09:00
|
|
|
// 🔒 멀티테넌시: 현재 사용자의 회사 코드
|
|
|
|
|
const companyCode = req.user?.companyCode;
|
|
|
|
|
|
2025-10-01 14:36:36 +09:00
|
|
|
const fileRecord = await queryOne<any>(
|
|
|
|
|
`SELECT * FROM attach_file_info WHERE objid = $1`,
|
|
|
|
|
[parseInt(objid)]
|
|
|
|
|
);
|
2025-09-05 21:52:19 +09:00
|
|
|
|
|
|
|
|
if (!fileRecord || fileRecord.status !== "ACTIVE") {
|
|
|
|
|
res.status(404).json({
|
|
|
|
|
success: false,
|
|
|
|
|
message: "파일을 찾을 수 없습니다.",
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-04 17:57:28 +09:00
|
|
|
// 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 제외)
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-05 21:52:19 +09:00
|
|
|
// 파일 경로에서 회사코드와 날짜 폴더 추출 (예: /uploads/company_*/2025/09/05/timestamp_filename.ext)
|
|
|
|
|
const filePathParts = fileRecord.file_path!.split("/");
|
2025-11-04 17:57:28 +09:00
|
|
|
let fileCompanyCode = filePathParts[2] || "DEFAULT"; // /uploads/company_*/2025/09/05/filename.ext에서 company_* 추출
|
2025-10-01 14:37:33 +09:00
|
|
|
|
2025-09-29 13:29:03 +09:00
|
|
|
// company_* 처리 (실제 회사 코드로 변환)
|
2025-11-04 17:57:28 +09:00
|
|
|
if (fileCompanyCode === "company_*") {
|
|
|
|
|
fileCompanyCode = "company_*"; // 실제 디렉토리명 유지
|
2025-09-29 13:29:03 +09:00
|
|
|
}
|
2025-10-01 14:37:33 +09:00
|
|
|
|
2025-09-05 21:52:19 +09:00
|
|
|
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(
|
2025-11-04 17:57:28 +09:00
|
|
|
fileCompanyCode,
|
2025-09-05 21:52:19 +09:00
|
|
|
dateFolder || undefined
|
|
|
|
|
);
|
|
|
|
|
const filePath = path.join(companyUploadDir, fileName);
|
|
|
|
|
|
2025-09-29 13:29:03 +09:00
|
|
|
console.log("🔍 파일 다운로드 경로 확인:", {
|
|
|
|
|
objid: objid,
|
|
|
|
|
filePathFromDB: fileRecord.file_path,
|
|
|
|
|
companyCode: companyCode,
|
|
|
|
|
dateFolder: dateFolder,
|
|
|
|
|
fileName: fileName,
|
|
|
|
|
companyUploadDir: companyUploadDir,
|
|
|
|
|
finalFilePath: filePath,
|
2025-10-01 14:37:33 +09:00
|
|
|
fileExists: fs.existsSync(filePath),
|
2025-09-29 13:29:03 +09:00
|
|
|
});
|
|
|
|
|
|
2025-09-05 21:52:19 +09:00
|
|
|
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);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("파일 다운로드 오류:", error);
|
|
|
|
|
res.status(500).json({
|
|
|
|
|
success: false,
|
|
|
|
|
message: "파일 다운로드 중 오류가 발생했습니다.",
|
|
|
|
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-29 13:29:03 +09:00
|
|
|
/**
|
|
|
|
|
* Google Docs Viewer용 임시 공개 토큰 생성
|
|
|
|
|
*/
|
2025-10-01 14:37:33 +09:00
|
|
|
export const generateTempToken = async (
|
|
|
|
|
req: AuthenticatedRequest,
|
|
|
|
|
res: Response
|
|
|
|
|
) => {
|
2025-09-29 13:29:03 +09:00
|
|
|
try {
|
|
|
|
|
const { objid } = req.params;
|
|
|
|
|
|
|
|
|
|
if (!objid) {
|
|
|
|
|
res.status(400).json({
|
|
|
|
|
success: false,
|
|
|
|
|
message: "파일 ID가 필요합니다.",
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 파일 존재 확인
|
2025-10-01 14:33:08 +09:00
|
|
|
const fileRecord = await queryOne<any>(
|
|
|
|
|
"SELECT * FROM attach_file_info WHERE objid = $1 LIMIT 1",
|
|
|
|
|
[objid]
|
|
|
|
|
);
|
2025-09-29 13:29:03 +09:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 파일 정보 조회
|
2025-10-01 14:33:08 +09:00
|
|
|
const fileRecord = await queryOne<any>(
|
|
|
|
|
"SELECT * FROM attach_file_info WHERE objid = $1 LIMIT 1",
|
|
|
|
|
[tokenData.objid]
|
|
|
|
|
);
|
2025-09-29 13:29:03 +09:00
|
|
|
|
|
|
|
|
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]}`;
|
|
|
|
|
}
|
2025-10-01 14:37:33 +09:00
|
|
|
const companyUploadDir = getCompanyUploadDir(
|
|
|
|
|
companyCode,
|
|
|
|
|
dateFolder || undefined
|
|
|
|
|
);
|
2025-09-29 13:29:03 +09:00
|
|
|
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";
|
2025-10-01 14:37:33 +09:00
|
|
|
|
2025-09-29 13:29:03 +09:00
|
|
|
const mimeTypes: { [key: string]: string } = {
|
|
|
|
|
".pdf": "application/pdf",
|
|
|
|
|
".doc": "application/msword",
|
2025-10-01 14:37:33 +09:00
|
|
|
".docx":
|
|
|
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
2025-09-29 13:29:03 +09:00
|
|
|
".xls": "application/vnd.ms-excel",
|
2025-10-01 14:37:33 +09:00
|
|
|
".xlsx":
|
|
|
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
2025-09-29 13:29:03 +09:00
|
|
|
".ppt": "application/vnd.ms-powerpoint",
|
2025-10-01 14:37:33 +09:00
|
|
|
".pptx":
|
|
|
|
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
2025-09-29 13:29:03 +09:00
|
|
|
".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);
|
2025-10-01 14:37:33 +09:00
|
|
|
res.setHeader(
|
|
|
|
|
"Content-Disposition",
|
|
|
|
|
`inline; filename="${encodeURIComponent(fileRecord.real_file_name!)}"`
|
|
|
|
|
);
|
2025-09-29 13:29:03 +09:00
|
|
|
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: "파일 접근에 실패했습니다.",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-05 15:50:29 +09:00
|
|
|
/**
|
|
|
|
|
* 대표 파일 설정
|
|
|
|
|
*/
|
|
|
|
|
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`,
|
|
|
|
|
[parseInt(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, parseInt(objid)]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 선택한 파일을 대표 파일로 설정
|
|
|
|
|
await query<any>(
|
|
|
|
|
`UPDATE attach_file_info
|
|
|
|
|
SET is_representative = true
|
|
|
|
|
WHERE objid = $1`,
|
|
|
|
|
[parseInt(objid)]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
message: "대표 파일이 설정되었습니다.",
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("대표 파일 설정 오류:", error);
|
|
|
|
|
res.status(500).json({
|
|
|
|
|
success: false,
|
|
|
|
|
message: "대표 파일 설정 중 오류가 발생했습니다.",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-05 21:52:19 +09:00
|
|
|
// Multer 미들웨어 export
|
|
|
|
|
export const uploadMiddleware = upload.array("files", 10); // 최대 10개 파일
|