/** * 파일 업로드 컨트롤러 * 이벤트 관련 파일 업로드 및 처리를 담당하는 컨트롤러 */ const multer = require('multer'); const path = require('path'); const fs = require('fs'); const xlsx = require('xlsx'); const csv = require('csv-parser'); const Tesseract = require('tesseract.js'); const sharp = require('sharp'); const models = require('../models'); const { Event, EventParticipant, Member, EventScore, Club, EventTeam, ClubMember, EventTeamMember } = models; const { Op, Sequelize } = require('sequelize'); const { sequelize } = require('../models'); const averageService = require('../services/averageService'); // 업로드 디렉토리 설정 const uploadDir = path.join(__dirname, '../uploads'); // 업로드 디렉토리가 없으면 생성 if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }); } // 파일 업로드를 위한 multer 설정 const storage = multer.diskStorage({ destination: function (req, file, cb) { cb(null, uploadDir); }, filename: function (req, file, cb) { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); cb(null, uniqueSuffix + path.extname(file.originalname)); } }); // 파일 필터 (허용된 파일 형식만 업로드) const fileFilter = (req, file, cb) => { const allowedTypes = [ 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'text/csv', 'image/jpeg', 'image/png' ]; if (allowedTypes.includes(file.mimetype)) { cb(null, true); } else { cb(new Error('지원되지 않는 파일 형식입니다. Excel, CSV, JPG, PNG 파일만 업로드 가능합니다.'), false); } }; const upload = multer({ storage: storage, fileFilter: fileFilter, limits: { fileSize: 10 * 1024 * 1024 } // 10MB 제한 }); // 파일 업로드 처리 exports.uploadEventFile = (req, res) => { upload.single('file')(req, res, async function (err) { if (err instanceof multer.MulterError) { // Multer 에러 처리 return res.status(400).json({ message: `파일 업로드 오류: ${err.message}` }); } else if (err) { // 기타 에러 처리 return res.status(400).json({ message: err.message }); } // 파일이 없는 경우 if (!req.file) { return res.status(400).json({ message: '업로드할 파일이 없습니다.' }); } // 클럽 ID 확인 const clubId = req.body.clubId; if (!clubId) { // 파일 삭제 if (fs.existsSync(req.file.path)) { fs.unlinkSync(req.file.path); } return res.status(400).json({ message: '클럽 ID가 필요합니다.' }); } try { // 클럽 존재 여부 확인 const club = await Club.findOne({ where: { id: clubId, status: 'active' } }); if (!club) { // 파일 삭제 if (fs.existsSync(req.file.path)) { fs.unlinkSync(req.file.path); } return res.status(404).json({ message: '클럽을 찾을 수 없습니다.' }); } // 이미지 파일 전처리 (OCR용) let ocrImagePath = req.file.path; if (req.file.mimetype.startsWith('image/')) { const preprocessedPath = req.file.path.replace(/(\.[^.]+)$/, '_preprocessed$1'); await sharp(req.file.path) .grayscale() .sharpen() .toFile(preprocessedPath); ocrImagePath = preprocessedPath; } // 파일 정보 반환 res.json({ message: '파일이 업로드되었습니다.', file: { filename: req.file.filename, originalname: req.file.originalname, mimetype: req.file.mimetype, size: req.file.size, path: req.file.path, clubId: clubId } }); } catch (error) { // 오류 발생 시 파일 삭제 if (fs.existsSync(req.file.path)) { fs.unlinkSync(req.file.path); } console.error('파일 업로드 오류:', error); res.status(500).json({ message: `파일 업로드 중 오류가 발생했습니다: ${error.message}` }); } }); }; // 업로드된 파일 파싱 및 미리보기 데이터 제공 exports.parseEventFile = async (req, res) => { try { const { filename, clubId, sheetName } = req.body; if (!filename) { return res.status(400).json({ message: '파일명이 필요합니다.' }); } if (!clubId) { return res.status(400).json({ message: '클럽 ID가 필요합니다.' }); } const filePath = path.join(uploadDir, filename); // 파일 존재 여부 확인 if (!fs.existsSync(filePath)) { return res.status(404).json({ message: '파일을 찾을 수 없습니다.' }); } // 파일 확장자 확인 const fileExt = path.extname(filename).toLowerCase(); let parsedData = []; // 클럽 회원 목록 조회 (참가자 매핑용) const club = await Club.findOne({ where: { id: clubId, status: 'active' } }); if (!club) { return res.status(404).json({ message: '클럽을 찾을 수 없습니다.' }); } const clubMembers = await club.getMembers({ attributes: ['id', 'name', 'gender', 'handicap'], through: { where: { status: 'active' } } }); // 각 회원의 동적 에버리지 계산 const memberMap = new Map(); for (const member of clubMembers) { if (member && member.name) { let dynamicAverage = 0; try { const averageData = await averageService.calculateMemberAverageByClubSetting( member.id, clubId ); dynamicAverage = averageData.average; } catch (error) { console.error(`회원 ${member.id}의 에버리지 계산 오류:`, error); } memberMap.set(member.name, { id: member.id, name: member.name, gender: member.gender, average: dynamicAverage, handicap: member.handicap }); } }; // 파일 형식에 따른 처리 if (fileExt === '.xlsx' || fileExt === '.xls') { // Excel 파일 처리 parsedData = await parseExcelFile(filePath, memberMap, sheetName); } else if (fileExt === '.csv') { // CSV 파일 처리 parsedData = await parseCsvFile(filePath, memberMap); } else if (fileExt === '.jpg' || fileExt === '.jpeg' || fileExt === '.png') { // 이미지 파일 OCR 처리 parsedData = await parseImageFile(filePath, memberMap); } else { return res.status(400).json({ message: '지원되지 않는 파일 형식입니다.' }); } // 참가자별 동적 에버리지 계산 호출 (memberId가 있을 때만) if (parsedData && parsedData.participants) { for (const p of parsedData.participants) { if (p.memberId) { await averageService.calculateMemberAverageByClubSetting(p.memberId, clubId); } } } res.json({ message: '파일 파싱 완료', data: parsedData }); } catch (error) { console.error('파일 파싱 오류:', error); res.status(500).json({ message: `파일 파싱 중 오류가 발생했습니다: ${error.message}` }); } }; // Excel 파일 파싱 함수 async function parseExcelFile(filePath, memberMap, requestedSheetName = null) { const workbook = xlsx.readFile(filePath, { type: 'binary', cellDates: true, codepage: 949 }); // 요청된 시트명이 있고 해당 시트가 존재하면 그것을 사용, 아니면 첫 번째 시트 사용 const sheetName = (requestedSheetName && workbook.SheetNames.includes(requestedSheetName)) ? requestedSheetName : workbook.SheetNames[0]; const worksheet = workbook.Sheets[sheetName]; const jsonData = xlsx.utils.sheet_to_json(worksheet, { header: 1, defval: '' }); return processRawData(jsonData, memberMap); } // CSV 파일 파싱 함수 async function parseCsvFile(filePath, memberMap) { return new Promise((resolve, reject) => { const results = []; fs.createReadStream(filePath, { encoding: 'utf8' }) .pipe(csv({ headers: false, skipEmptyLines: true })) .on('data', (data) => { // 각 행을 배열로 변환 results.push(Object.values(data).map(cell => (cell || '').trim())); }) .on('end', () => { try { const processedData = processRawData(results, memberMap); resolve(processedData); } catch (error) { reject(error); } }) .on('error', (error) => reject(error)); }); } // 이미지 파일 OCR 처리 함수 async function parseImageFile(filePath, memberMap) { try { // 한글 인식을 위한 Tesseract 설정 const { data } = await Tesseract.recognize(filePath, 'kor+eng', { logger: m => console.log(m), tessedit_char_whitelist: '0123456789가-힣이름팀핸디총핀에버Gg.,:()[]- ', preserve_interword_spaces: 1, psm: 6 // 표/블록: 6, 한줄: 7, 단어: 8. 상황에 따라 실험 가능 }); // OCR 원본 결과 로그 console.log('[OCR 원본 결과]', data.text); // OCR 결과 텍스트 처리 const lines = data.text.split('\n') .filter(line => line.trim() !== '') .map(line => line.split(/\s+/)); return processRawData(lines, memberMap); } catch (error) { console.error('OCR 처리 오류:', error); throw new Error('이미지 OCR 처리 중 오류가 발생했습니다.'); } } // 원시 데이터 처리 및 정규화 함수 function processRawData(rawData, memberMap) { // 이벤트 정보 추론 const inferredEventInfo = inferEventInfo(rawData); // 다양한 표 헤더 키워드 정의 const headerKeywords = { name: ['이름', '성명', '참가자'], team: ['팀', '팀명'], handicap: ['핸디', '핸디캡'], total: ['총핀', '총점', '합계'], avg: ['에버', '평균'], // 점수 컬럼: 1게임, 2게임, 1G, 2G, 16, 26 등 다양한 형태 score: [/^(\d+)[Gg]$/, /^(\d{2,})$/, /^\d+게임$/] }; // 헤더 행 찾기 및 컬럼 인덱스 매핑 let headerRow = -1; let colMap = {}; for (let i = 0; i < Math.min(10, rawData.length); i++) { const row = rawData[i]; row.forEach((cell, idx) => { if (typeof cell !== 'string') return; for (const key in headerKeywords) { const patterns = headerKeywords[key]; for (const pattern of patterns) { if ((typeof pattern === 'string' && cell.replace(/\s/g, '').includes(pattern)) || (pattern instanceof RegExp && pattern.test(cell.replace(/\s/g, '')))) { if (key === 'score') { if (!colMap.scores) colMap.scores = []; colMap.scores.push(idx); } else { colMap[key] = idx; } headerRow = i; break; } } } }); // 헤더가 최소 이름, 점수, 팀 중 하나라도 있으면 헤더로 간주 if (Object.keys(colMap).length > 1) break; } if (headerRow === -1) return { participants: [], gameCount: 0, inferredEventInfo }; // 데이터 행 파싱 const participants = []; for (let i = headerRow + 1; i < rawData.length; i++) { const row = rawData[i]; if (row.length < 2) continue; // 이름: 한글 2자 이상 const name = colMap.name !== undefined ? row[colMap.name] : row.find(cell => /^[가-힣a-z]{2,}$/i.test(cell)); if (!name) continue; const teamNumber = colMap.team !== undefined ? row[colMap.team] : ''; const scores = (colMap.scores || []).map(idx => parseInt(row[idx], 10)).filter(n => !isNaN(n)); const handicap = colMap.handicap !== undefined ? parseInt(row[colMap.handicap], 10) : null; const total = colMap.total !== undefined ? parseInt(row[colMap.total], 10) : null; const avg = colMap.avg !== undefined ? parseFloat(row[colMap.avg]) : null; // 회원 매핑 (이름 기준) const memberMatch = memberMap && memberMap.get && name ? memberMap.get(name) : null; participants.push({ name, teamNumber, scores, handicap, total, avg, memberId: memberMatch ? memberMatch.id : null, memberInfo: memberMatch || null }); } return { participants, gameCount: Math.max(...participants.map(p => p.scores.length), 0), inferredEventInfo }; } // 파일에서 이벤트 정보 추론 function inferEventInfo(rawData) { const result = { title: null, titleCandidates: [], startDate: null, location: null }; // 파일의 처음 10줄을 검색하여 이벤트 정보 추론 const maxRows = Math.min(10, rawData.length); // 제목 추론 패턴 (키워드 포함 전체 구문 추출) const titlePatterns = [ /(.+?정기전)/i, /(.+?대회)/i, /(.+?이벤트)/i, /(.+?토너먼트)/i, /(.+?경기)/i, /(.+?오픈)/i, /제\s*\d+\s*회\s*.+/i, /제\s*\d+\s*차\s*.+/i, /\d+\s*회\s*.+/i, /\d+\s*차\s*.+/i, /정기전\s*[:\-]?\s*.+/i, /대회\s*[:\-]?\s*.+/i, /이벤트\s*[:\-]?\s*.+/i ]; // 날짜 추론 패턴 const datePatterns = [ // YYYY-MM-DD 형식 /(\d{4})[-\/\.]\s*(\d{1,2})[-\/\.]\s*(\d{1,2})/, // YYYY년 MM월 DD일 형식 /(\d{4})\s*년\s*(\d{1,2})\s*월\s*(\d{1,2})\s*일/, // MM월 DD일 형식 (현재 연도 가정) /(\d{1,2})\s*월\s*(\d{1,2})\s*일/, // 날짜: YYYY-MM-DD 형식 /날짜\s*[:\-]?\s*(\d{4})[-\/\.]\s*(\d{1,2})[-\/\.]\s*(\d{1,2})/, // 일시: YYYY-MM-DD 형식 /일시\s*[:\-]?\s*(\d{4})[-\/\.]\s*(\d{1,2})[-\/\.]\s*(\d{1,2})/, // 시작일: YYYY-MM-DD 형식 /시작일\s*[:\-]?\s*(\d{4})[-\/\.]\s*(\d{1,2})[-\/\.]\s*(\d{1,2})/, // YYYYMMDD 형식 /\s*(\d{4})\s*(\d{2})\s*(\d{2})\s*/, // YYMMDD, YY.MM.DD, YY-MM-DD 형식 (25.02.04, 25-02-04, 250204) /(\d{2})[.\-\/\s]?(\d{2})[.\-\/\s]?(\d{2})/ ]; // 파일명에서 정보 추론 (originalname이 있는 경우) if (rawData.originalname) { const filename = rawData.originalname; // 제목 추론 (모든 후보 수집) for (const pattern of titlePatterns) { const match = filename.match(pattern); if (match && match[1]) { const candidate = match[1].trim(); if (!result.titleCandidates.includes(candidate)) { result.titleCandidates.push(candidate); } } } if (result.titleCandidates.length > 0) { result.title = result.titleCandidates[0]; } // 날짜 추론 for (const pattern of datePatterns) { const match = filename.match(pattern); if (match) { // 매칭 그룹의 수에 따라 다르게 처리 if (match.length >= 4) { // YYYY-MM-DD 형식 const year = parseInt(match[1]); const month = parseInt(match[2]) - 1; // JavaScript의 월은 0부터 시작 const day = parseInt(match[3]); result.startDate = new Date(year, month, day); } else if (match.length >= 3) { // MM월 DD일 형식 const currentYear = new Date().getFullYear(); const month = parseInt(match[1]) - 1; const day = parseInt(match[2]); result.startDate = new Date(currentYear, month, day); } break; } } } // 파일 내용에서 정보 추론 for (let i = 0; i < maxRows; i++) { const row = rawData[i]; if (!row || !Array.isArray(row)) continue; // 행을 문자열로 변환하여 패턴 매칭 const rowText = row.join(' '); // 제목 후보 수집 for (const pattern of titlePatterns) { const match = rowText.match(pattern); if (match && match[1]) { const candidate = match[1].trim(); if (!result.titleCandidates.includes(candidate)) { result.titleCandidates.push(candidate); } } } if (!result.title && result.titleCandidates.length > 0) { result.title = result.titleCandidates[0]; } // 날짜가 아직 추론되지 않은 경우 if (!result.startDate) { for (const pattern of datePatterns) { const match = rowText.match(pattern); if (match) { // 매칭 그룹의 수에 따라 다르게 처리 if (match.length >= 4) { // YYYY-MM-DD, YYYYMMDD, YYMMDD, YY.MM.DD, YY-MM-DD 모두 처리 let year = parseInt(match[1], 10); const month = parseInt(match[2], 10) - 1; const day = parseInt(match[3], 10); // 2자리 연도라면 보정 if (match[1].length === 2) { year += 2000; } result.startDate = new Date(year, month, day); } else if (match.length === 3) { // MM월 DD일 형식 (현재 연도 가정) const currentYear = new Date().getFullYear(); const month = parseInt(match[1]) - 1; const day = parseInt(match[2]); result.startDate = new Date(currentYear, month, day); } break; } } } // 모든 정보가 추론되었다면 중단 if (result.title && result.startDate) { break; } } return result; } // 이벤트에 파일 데이터 저장 exports.saveEventFileData = async (req, res) => { const { eventData, participants, clubId } = req.body; if (!eventData || !participants || !clubId) { return res.status(400).json({ message: '이벤트 정보, 참가자 정보, 클럽 ID가 필요합니다.' }); } try { // 트랜잭션 시작 const result = await sequelize.transaction(async (t) => { // 1. 이벤트 생성 또는 업데이트 let event; if (eventData.id) { // 기존 이벤트 업데이트 event = await Event.findOne({ where: { id: eventData.id, clubId }, transaction: t }); if (!event) { throw new Error('이벤트를 찾을 수 없습니다.'); } await event.update({ title: eventData.title, description: eventData.description || '', eventType: eventData.eventType, startDate: new Date(eventData.startDate), endDate: eventData.endDate ? new Date(eventData.endDate) : null, location: eventData.location || '', gameCount: eventData.gameCount || 3, maxParticipants: eventData.maxParticipants || 0, status: eventData.status || '완료' }, { transaction: t }); } else { // 새 이벤트 생성 event = await Event.create({ clubId, title: eventData.title, description: eventData.description || '', eventType: eventData.eventType, startDate: new Date(eventData.startDate), endDate: eventData.endDate ? new Date(eventData.endDate) : null, location: eventData.location || '', gameCount: eventData.gameCount || 3, maxParticipants: eventData.maxParticipants || 0, status: eventData.status || '완료', publicHash: generateRandomHash() }, { transaction: t }); } // 1.5 팀 정보 처리 - 참가자 처리 전에 팀 생성 // 팀 번호별로 그룹화 const teamGroups = {}; participants.forEach(p => { if (p.teamNumber) { if (!teamGroups[p.teamNumber]) { teamGroups[p.teamNumber] = []; } teamGroups[p.teamNumber].push(p); } }); // 팀 생성 const createdTeams = {}; for (const teamNumber in teamGroups) { // 기존 팀 확인 let eventTeam = await EventTeam.findOne({ where: { eventId: event.id, teamNumber: parseInt(teamNumber) }, transaction: t }); // 팀이 없으면 생성 if (!eventTeam) { eventTeam = await EventTeam.create({ eventId: event.id, teamNumber: parseInt(teamNumber), name: `팀 ${teamNumber}`, handicap: 0 // 기본 핸디캡 값 }, { transaction: t }); } createdTeams[teamNumber] = eventTeam; } // 2. 참가자 및 점수 처리 for (const participant of participants) { let eventParticipant; // 참가자 생성 또는 업데이트 if (participant.memberId) { // 기존 회원인 경우 const existingParticipant = await EventParticipant.findOne({ where: { eventId: event.id, memberId: participant.memberId }, transaction: t }); // 팀 ID 가져오기 let teamId = null; if (participant.teamNumber && createdTeams[participant.teamNumber]) { teamId = createdTeams[participant.teamNumber].id; } if (existingParticipant) { // 기존 참가자 업데이트 (팀 정보 포함) await existingParticipant.update({ teamId: teamId }, { transaction: t }); eventParticipant = existingParticipant; } else { eventParticipant = await EventParticipant.create({ eventId: event.id, memberId: participant.memberId, status: '참가확정', paymentStatus: '납부완료', teamId: teamId, comment: '' }, { transaction: t }); } } else { // 새 회원 생성 및 참가자 등록 const newMember = await Member.create({ name: participant.name, gender: participant.gender || '남성', memberType: '정회원', status: '활성' }, { transaction: t }); // 클럽 회원으로 등록 await ClubMember.create({ clubId, memberId: newMember.id, joinDate: new Date(), status: 'active' }, { transaction: t }); // 팀 ID 가져오기 let teamId = null; if (participant.teamNumber && createdTeams[participant.teamNumber]) { teamId = createdTeams[participant.teamNumber].id; } eventParticipant = await EventParticipant.create({ eventId: event.id, memberId: newMember.id, status: '참가확정', teamId: teamId, comment: '' }, { transaction: t }); } // 팀에 속한 참가자인 경우 팀 멤버로 등록 if (participant.teamNumber && createdTeams[participant.teamNumber]) { // 기존 팀 멤버 확인 const existingTeamMember = await EventTeamMember.findOne({ where: { eventTeamId: createdTeams[participant.teamNumber].id, eventParticipantId: eventParticipant.id }, transaction: t }); // 팀 멤버가 없으면 생성 if (!existingTeamMember) { await EventTeamMember.create({ eventTeamId: createdTeams[participant.teamNumber].id, eventParticipantId: eventParticipant.id, order: 1 }, { transaction: t }); } } // 점수 저장 if (participant.scores && participant.scores.length > 0) { // 기존 점수 삭제 await EventScore.destroy({ where: { eventId: event.id, participantId: eventParticipant.id }, transaction: t }); // 새 점수 등록 for (const scoreData of participant.scores) { // 핵디캡 값이 없는 경우 참가자의 핵디캡 값 사용 const handicap = scoreData.handicap !== undefined ? scoreData.handicap : (participant.handicap || 0); // 총점수가 없는 경우 원점수 사용 const totalScore = scoreData.totalScore !== undefined ? scoreData.totalScore : scoreData.score; await EventScore.create({ eventId: event.id, participantId: eventParticipant.id, gameNumber: scoreData.gameNumber, score: scoreData.score, handicap: handicap, totalScore: totalScore, teamNumber: participant.teamNumber || null }, { transaction: t }); } } } return event; }); res.json({ message: '이벤트 데이터가 성공적으로 저장되었습니다.', event: { id: result.id, title: result.title, eventType: result.eventType, startDate: result.startDate } }); } catch (error) { console.error('이벤트 데이터 저장 오류:', error); res.status(500).json({ message: `이벤트 데이터 저장 중 오류가 발생했습니다: ${error.message}` }); } }; // 랜덤 해시 생성 함수 function generateRandomHash() { const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; return Array.from({ length: 16 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); } // 임시 파일 삭제 exports.deleteUploadedFile = async (req, res) => { try { const { filename } = req.body; if (!filename) { return res.status(400).json({ message: '파일명이 필요합니다.' }); } const filePath = path.join(uploadDir, filename); // 파일 존재 여부 확인 if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); } res.json({ message: '파일이 삭제되었습니다.' }); } catch (error) { console.error('파일 삭제 오류:', error); res.status(500).json({ message: `파일 삭제 중 오류가 발생했습니다: ${error.message}` }); } };