const path = require('path'); // .env 파일에서 환경 변수를 로드합니다. (경로: 상위 디렉토리) - 다른 모듈보다 먼저 로드해야 함! require('dotenv').config({ path: path.join(__dirname, '../.env') }); const express = require('express'); const puppeteer = require('puppeteer'); const { PuppeteerScreenRecorder } = require('puppeteer-screen-recorder'); const cors = require('cors'); const fs = require('fs'); const axios = require('axios'); const { pipeline } = require('stream'); const { promisify } = require('util'); const { exec } = require('child_process'); const streamPipeline = promisify(pipeline); const { // Legacy functions uploadVideo, getAuthenticatedClient, SCOPES, createPlaylist, getPlaylists, // New multi-user functions generateAuthUrl, exchangeCodeForTokens, getConnectionStatus, disconnectYouTube, uploadVideoForUser, getPlaylistsForUser, createPlaylistForUser, getUserYouTubeSettings, updateUserYouTubeSettings, getUploadHistory } = require('./youtubeService'); const { google } = require('googleapis'); const jwt = require('jsonwebtoken'); const bcrypt = require('bcrypt'); const crypto = require('crypto'); const db = require('./db'); // SQLite DB const { sendVerificationEmail, sendPasswordResetEmail, sendWelcomeEmail } = require('./emailService'); const { generateCreativeContent, generateAdvancedSpeech, generateAdPoster, generateImageGallery, filterBestImages, enrichDescriptionWithReviews, extractTextEffectFromImage, generateVideoBackground, generateYouTubeSEO } = require('./geminiBackendService'); // Gemini Backend Service 임포트 // TikTok Service 임포트 const tiktokService = require('./tiktokService'); // Statistics Service 임포트 const statisticsService = require('./statisticsService'); const JWT_SECRET = process.env.JWT_SECRET || 'bizvibe-secret-key-change-this'; const app = express(); const PORT = process.env.PORT || 3001; // 미들웨어 설정 app.use(cors()); app.use(express.json({ limit: '500mb' })); // 임시 디렉토리 설정 (temp) const TEMP_DIR = path.join(__dirname, 'temp'); if (!fs.existsSync(TEMP_DIR)) { fs.mkdirSync(TEMP_DIR); } // 다운로드 저장소 설정 (downloads) - 영구 저장용 const DOWNLOADS_DIR = path.join(__dirname, 'downloads'); if (!fs.existsSync(DOWNLOADS_DIR)) { fs.mkdirSync(DOWNLOADS_DIR); } // 정적 파일 제공 app.use('/temp', express.static(TEMP_DIR)); app.use('/downloads', express.static(DOWNLOADS_DIR)); app.use(express.static(path.join(__dirname, '../dist'))); // React 빌드 결과물 // DB 마이그레이션: render_status 컬럼 확인 및 추가 const ensureRenderStatusColumn = () => { db.all("PRAGMA table_info(history)", [], (err, rows) => { if (err) { console.error("DB 스키마 확인 실패:", err); return; } const hasColumn = rows.some(row => row.name === 'render_status'); if (!hasColumn) { console.log("DB: render_status 컬럼 추가 중..."); db.run("ALTER TABLE history ADD COLUMN render_status TEXT DEFAULT 'pending'", (err) => { if (err) console.error("컬럼 추가 실패:", err); else console.log("DB: render_status 컬럼 추가 완료."); }); } }); }; ensureRenderStatusColumn(); // --- AUTH MIDDLEWARE --- const authenticateToken = (req, res, next) => { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; if (!token) return res.sendStatus(401); jwt.verify(token, JWT_SECRET, (err, user) => { if (err) return res.sendStatus(403); req.user = user; next(); }); }; const requireAdmin = (req, res, next) => { if (req.user && req.user.role === 'admin') { next(); } else { res.status(403).json({ error: "관리자 권한이 필요합니다." }); } }; // --- ASSET UPLOAD API (Auto-Save) --- app.post('/api/assets/upload', authenticateToken, async (req, res) => { try { const { businessName, posterBase64, audioBase64 } = req.body; const safeName = (businessName || 'project').replace(/[^a-z0-9가-힣]/gi, '_'); const timestamp = Date.now(); const folderName = `${timestamp}_${safeName}`; const projectDir = path.join(DOWNLOADS_DIR, folderName); if (!fs.existsSync(projectDir)) { fs.mkdirSync(projectDir, { recursive: true }); } const result = { posterUrl: '', audioUrl: '', folderName }; if (posterBase64) { const posterPath = path.join(projectDir, 'source_poster.jpg'); fs.writeFileSync(posterPath, Buffer.from(posterBase64, 'base64')); result.posterUrl = `/downloads/${folderName}/source_poster.jpg`; } if (audioBase64) { const audioPath = path.join(projectDir, 'source_audio.mp3'); fs.writeFileSync(audioPath, Buffer.from(audioBase64, 'base64')); result.audioUrl = `/downloads/${folderName}/source_audio.mp3`; } res.json(result); } catch (e) { console.error("에셋 업로드 실패:", e); res.status(500).json({ error: "파일 저장 실패" }); } }); // --- AUTH API --- // 1. 회원가입 (이메일 인증 필요) app.post('/api/auth/register', async (req, res) => { const { username, email, password, name, phone, businessName } = req.body; // 필수 필드 검증 if (!username || !email || !password) { return res.status(400).json({ error: "ID, 이메일, 비밀번호는 필수입니다." }); } // 이메일 형식 검증 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { return res.status(400).json({ error: "올바른 이메일 형식이 아닙니다." }); } try { const salt = await bcrypt.genSalt(10); const hash = await bcrypt.hash(password, salt); const verificationToken = crypto.randomBytes(32).toString('hex'); db.run( `INSERT INTO users (username, email, password, name, phone, business_name, approved, email_verified, verification_token) VALUES (?, ?, ?, ?, ?, ?, 1, 0, ?)`, [username, email, hash, name, phone, businessName, verificationToken], async function(err) { if (err) { if (err.message.includes('UNIQUE constraint failed')) { if (err.message.includes('email')) { return res.status(400).json({ error: "이미 사용 중인 이메일입니다." }); } return res.status(400).json({ error: "이미 존재하는 ID입니다." }); } return res.status(500).json({ error: err.message }); } // 인증 이메일 발송 const emailResult = await sendVerificationEmail(email, name, verificationToken); if (emailResult.success) { res.json({ message: "회원가입이 완료되었습니다. 이메일을 확인하여 인증을 완료해주세요.", userId: this.lastID, requireVerification: true }); } else { // 이메일 발송 실패해도 회원가입은 완료 (재발송 가능) console.error('인증 이메일 발송 실패:', emailResult.error); res.json({ message: "회원가입이 완료되었습니다. 이메일 발송에 실패했습니다. 로그인 후 인증 메일을 재발송해주세요.", userId: this.lastID, requireVerification: true, emailError: true }); } } ); } catch (error) { console.error('회원가입 오류:', error); res.status(500).json({ error: "회원가입 처리 중 오류가 발생했습니다." }); } }); // 2. 이메일 인증 확인 app.get('/api/auth/verify-email', (req, res) => { const { token } = req.query; if (!token) { return res.status(400).json({ error: "인증 토큰이 필요합니다." }); } db.get("SELECT * FROM users WHERE verification_token = ?", [token], async (err, user) => { if (err) return res.status(500).json({ error: err.message }); if (!user) return res.status(400).json({ error: "유효하지 않은 인증 토큰입니다." }); if (user.email_verified === 1) { return res.json({ message: "이미 인증된 이메일입니다." }); } db.run( "UPDATE users SET email_verified = 1, verification_token = NULL WHERE id = ?", [user.id], async function(err) { if (err) return res.status(500).json({ error: err.message }); // 환영 이메일 발송 await sendWelcomeEmail(user.email, user.name); res.json({ message: "이메일 인증이 완료되었습니다. 이제 로그인할 수 있습니다." }); } ); }); }); // 3. 인증 이메일 재발송 app.post('/api/auth/resend-verification', async (req, res) => { const { email } = req.body; if (!email) { return res.status(400).json({ error: "이메일이 필요합니다." }); } db.get("SELECT * FROM users WHERE email = ?", [email], async (err, user) => { if (err) return res.status(500).json({ error: err.message }); if (!user) return res.status(400).json({ error: "등록되지 않은 이메일입니다." }); if (user.email_verified === 1) { return res.json({ message: "이미 인증된 이메일입니다." }); } const newToken = crypto.randomBytes(32).toString('hex'); db.run("UPDATE users SET verification_token = ? WHERE id = ?", [newToken, user.id], async function(err) { if (err) return res.status(500).json({ error: err.message }); const emailResult = await sendVerificationEmail(user.email, user.name, newToken); if (emailResult.success) { res.json({ message: "인증 이메일을 다시 보냈습니다. 이메일을 확인해주세요." }); } else { res.status(500).json({ error: "이메일 발송에 실패했습니다. 잠시 후 다시 시도해주세요." }); } }); }); }); // 4. 로그인 app.post('/api/auth/login', (req, res) => { const { username, password } = req.body; // username 또는 email로 로그인 가능 db.get( "SELECT * FROM users WHERE username = ? OR email = ?", [username, username], async (err, user) => { if (err) return res.status(500).json({ error: err.message }); if (!user) return res.status(400).json({ error: "사용자를 찾을 수 없습니다." }); const match = await bcrypt.compare(password, user.password); if (!match) return res.status(400).json({ error: "비밀번호가 일치하지 않습니다." }); if (user.approved !== 1) { return res.status(403).json({ error: "계정이 아직 승인되지 않았습니다. 관리자에게 문의하세요." }); } // 이메일 미인증 시 경고 (로그인은 허용하되 제한된 기능) const emailVerified = user.email_verified === 1; const token = jwt.sign( { id: user.id, username: user.username, email: user.email, role: user.role, name: user.name, emailVerified }, JWT_SECRET, { expiresIn: '12h' } ); res.json({ token, user: { id: user.id, username: user.username, email: user.email, role: user.role, name: user.name, emailVerified }, requireVerification: !emailVerified }); } ); }); // 5. 비밀번호 재설정 요청 app.post('/api/auth/forgot-password', async (req, res) => { const { email } = req.body; if (!email) { return res.status(400).json({ error: "이메일이 필요합니다." }); } db.get("SELECT * FROM users WHERE email = ?", [email], async (err, user) => { if (err) return res.status(500).json({ error: err.message }); // 보안상 이메일 존재 여부를 알리지 않음 if (!user) { return res.json({ message: "등록된 이메일이면 비밀번호 재설정 링크가 발송됩니다." }); } const resetToken = crypto.randomBytes(32).toString('hex'); const expiry = new Date(Date.now() + 60 * 60 * 1000); // 1시간 후 만료 db.run( "UPDATE users SET reset_token = ?, reset_token_expiry = ? WHERE id = ?", [resetToken, expiry.toISOString(), user.id], async function(err) { if (err) return res.status(500).json({ error: err.message }); const emailResult = await sendPasswordResetEmail(user.email, user.name, resetToken); // 성공/실패와 관계없이 동일한 응답 (보안) res.json({ message: "등록된 이메일이면 비밀번호 재설정 링크가 발송됩니다." }); } ); }); }); // 6. 비밀번호 재설정 실행 app.post('/api/auth/reset-password', async (req, res) => { const { token, newPassword } = req.body; if (!token || !newPassword) { return res.status(400).json({ error: "토큰과 새 비밀번호가 필요합니다." }); } if (newPassword.length < 6) { return res.status(400).json({ error: "비밀번호는 6자 이상이어야 합니다." }); } db.get("SELECT * FROM users WHERE reset_token = ?", [token], async (err, user) => { if (err) return res.status(500).json({ error: err.message }); if (!user) return res.status(400).json({ error: "유효하지 않은 토큰입니다." }); // 토큰 만료 확인 if (user.reset_token_expiry && new Date(user.reset_token_expiry) < new Date()) { return res.status(400).json({ error: "토큰이 만료되었습니다. 다시 비밀번호 재설정을 요청해주세요." }); } const salt = await bcrypt.genSalt(10); const hash = await bcrypt.hash(newPassword, salt); db.run( "UPDATE users SET password = ?, reset_token = NULL, reset_token_expiry = NULL WHERE id = ?", [hash, user.id], function(err) { if (err) return res.status(500).json({ error: err.message }); res.json({ message: "비밀번호가 성공적으로 변경되었습니다. 새 비밀번호로 로그인해주세요." }); } ); }); }); // ============================================ // OAuth 소셜 로그인 (Google, Naver) // ============================================ const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID; const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET; const NAVER_CLIENT_ID = process.env.NAVER_CLIENT_ID; const NAVER_CLIENT_SECRET = process.env.NAVER_CLIENT_SECRET; const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:5173'; const BACKEND_URL = process.env.BACKEND_URL || `http://localhost:${PORT}`; // Google OAuth 시작 app.get('/api/auth/google', (req, res) => { if (!GOOGLE_CLIENT_ID) { return res.status(500).json({ error: 'Google OAuth is not configured' }); } const redirectUri = `${BACKEND_URL}/api/auth/google/callback`; const scope = encodeURIComponent('openid email profile'); const state = crypto.randomBytes(16).toString('hex'); const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?` + `client_id=${GOOGLE_CLIENT_ID}&` + `redirect_uri=${encodeURIComponent(redirectUri)}&` + `response_type=code&` + `scope=${scope}&` + `state=${state}&` + `access_type=offline&` + `prompt=consent`; res.redirect(authUrl); }); // Google OAuth 콜백 app.get('/api/auth/google/callback', async (req, res) => { const { code, error } = req.query; if (error) { return res.redirect(`${FRONTEND_URL}/login?error=${encodeURIComponent(error)}`); } if (!code) { return res.redirect(`${FRONTEND_URL}/login?error=no_code`); } try { const redirectUri = `${BACKEND_URL}/api/auth/google/callback`; // Exchange code for tokens const tokenRes = await axios.post('https://oauth2.googleapis.com/token', { code, client_id: GOOGLE_CLIENT_ID, client_secret: GOOGLE_CLIENT_SECRET, redirect_uri: redirectUri, grant_type: 'authorization_code' }); const { access_token, id_token } = tokenRes.data; // Get user info const userInfoRes = await axios.get('https://www.googleapis.com/oauth2/v2/userinfo', { headers: { Authorization: `Bearer ${access_token}` } }); const { id: googleId, email, name, picture } = userInfoRes.data; // Find or create user db.get( "SELECT * FROM users WHERE oauth_provider = 'google' AND oauth_provider_id = ?", [googleId], async (err, existingUser) => { if (err) { console.error('DB error:', err); return res.redirect(`${FRONTEND_URL}/login?error=db_error`); } if (existingUser) { // Existing user - generate token const token = jwt.sign( { id: existingUser.id, username: existingUser.username, email: existingUser.email, role: existingUser.role, name: existingUser.name, emailVerified: true }, JWT_SECRET, { expiresIn: '12h' } ); return res.redirect(`${FRONTEND_URL}/oauth/callback?token=${token}`); } // Check if email already exists db.get("SELECT * FROM users WHERE email = ?", [email], async (err, emailUser) => { if (err) { console.error('DB error:', err); return res.redirect(`${FRONTEND_URL}/login?error=db_error`); } if (emailUser) { // Link Google account to existing user db.run( "UPDATE users SET oauth_provider = 'google', oauth_provider_id = ?, profile_image = ?, email_verified = 1 WHERE id = ?", [googleId, picture, emailUser.id], function(err) { if (err) { console.error('DB error:', err); return res.redirect(`${FRONTEND_URL}/login?error=db_error`); } const token = jwt.sign( { id: emailUser.id, username: emailUser.username, email: emailUser.email, role: emailUser.role, name: emailUser.name || name, emailVerified: true }, JWT_SECRET, { expiresIn: '12h' } ); return res.redirect(`${FRONTEND_URL}/oauth/callback?token=${token}`); } ); } else { // Create new user const username = `google_${googleId.substring(0, 8)}`; const randomPassword = crypto.randomBytes(32).toString('hex'); const salt = await bcrypt.genSalt(10); const hash = await bcrypt.hash(randomPassword, salt); db.run( `INSERT INTO users (username, email, password, name, role, approved, email_verified, oauth_provider, oauth_provider_id, profile_image) VALUES (?, ?, ?, ?, 'user', 1, 1, 'google', ?, ?)`, [username, email, hash, name, googleId, picture], function(err) { if (err) { console.error('DB error:', err); return res.redirect(`${FRONTEND_URL}/login?error=db_error`); } const token = jwt.sign( { id: this.lastID, username, email, role: 'user', name, emailVerified: true }, JWT_SECRET, { expiresIn: '12h' } ); return res.redirect(`${FRONTEND_URL}/oauth/callback?token=${token}`); } ); } }); } ); } catch (error) { console.error('Google OAuth error:', error.response?.data || error.message); return res.redirect(`${FRONTEND_URL}/login?error=oauth_error`); } }); // Naver OAuth 시작 app.get('/api/auth/naver', (req, res) => { if (!NAVER_CLIENT_ID) { return res.status(500).json({ error: 'Naver OAuth is not configured' }); } const redirectUri = `${req.protocol}://${req.get('host')}/api/auth/naver/callback`; const state = crypto.randomBytes(16).toString('hex'); const authUrl = `https://nid.naver.com/oauth2.0/authorize?` + `client_id=${NAVER_CLIENT_ID}&` + `redirect_uri=${encodeURIComponent(redirectUri)}&` + `response_type=code&` + `state=${state}`; res.redirect(authUrl); }); // Naver OAuth 콜백 app.get('/api/auth/naver/callback', async (req, res) => { const { code, error, state } = req.query; if (error) { return res.redirect(`${FRONTEND_URL}/login?error=${encodeURIComponent(error)}`); } if (!code) { return res.redirect(`${FRONTEND_URL}/login?error=no_code`); } try { const redirectUri = `${req.protocol}://${req.get('host')}/api/auth/naver/callback`; // Exchange code for tokens const tokenRes = await axios.post('https://nid.naver.com/oauth2.0/token', null, { params: { grant_type: 'authorization_code', client_id: NAVER_CLIENT_ID, client_secret: NAVER_CLIENT_SECRET, code, state } }); const { access_token } = tokenRes.data; // Get user info const userInfoRes = await axios.get('https://openapi.naver.com/v1/nid/me', { headers: { Authorization: `Bearer ${access_token}` } }); const { response: naverUser } = userInfoRes.data; const { id: naverId, email, name, profile_image } = naverUser; // Find or create user db.get( "SELECT * FROM users WHERE oauth_provider = 'naver' AND oauth_provider_id = ?", [naverId], async (err, existingUser) => { if (err) { console.error('DB error:', err); return res.redirect(`${FRONTEND_URL}/login?error=db_error`); } if (existingUser) { // Existing user - generate token const token = jwt.sign( { id: existingUser.id, username: existingUser.username, email: existingUser.email, role: existingUser.role, name: existingUser.name, emailVerified: true }, JWT_SECRET, { expiresIn: '12h' } ); return res.redirect(`${FRONTEND_URL}/oauth/callback?token=${token}`); } // Check if email already exists (if email is provided) if (email) { db.get("SELECT * FROM users WHERE email = ?", [email], async (err, emailUser) => { if (err) { console.error('DB error:', err); return res.redirect(`${FRONTEND_URL}/login?error=db_error`); } if (emailUser) { // Link Naver account to existing user db.run( "UPDATE users SET oauth_provider = 'naver', oauth_provider_id = ?, profile_image = ?, email_verified = 1 WHERE id = ?", [naverId, profile_image, emailUser.id], function(err) { if (err) { console.error('DB error:', err); return res.redirect(`${FRONTEND_URL}/login?error=db_error`); } const token = jwt.sign( { id: emailUser.id, username: emailUser.username, email: emailUser.email, role: emailUser.role, name: emailUser.name || name, emailVerified: true }, JWT_SECRET, { expiresIn: '12h' } ); return res.redirect(`${FRONTEND_URL}/oauth/callback?token=${token}`); } ); return; } // Create new user with email createNaverUser(naverId, email, name, profile_image, res); }); } else { // Create new user without email createNaverUser(naverId, null, name, profile_image, res); } } ); } catch (error) { console.error('Naver OAuth error:', error.response?.data || error.message); return res.redirect(`${FRONTEND_URL}/login?error=oauth_error`); } }); // Helper function to create Naver user async function createNaverUser(naverId, email, name, profile_image, res) { const username = `naver_${naverId.substring(0, 8)}`; const randomPassword = crypto.randomBytes(32).toString('hex'); const salt = await bcrypt.genSalt(10); const hash = await bcrypt.hash(randomPassword, salt); db.run( `INSERT INTO users (username, email, password, name, role, approved, email_verified, oauth_provider, oauth_provider_id, profile_image) VALUES (?, ?, ?, ?, 'user', 1, 1, 'naver', ?, ?)`, [username, email, hash, name || 'Naver User', naverId, profile_image], function(err) { if (err) { console.error('DB error:', err); return res.redirect(`${FRONTEND_URL}/login?error=db_error`); } const token = jwt.sign( { id: this.lastID, username, email, role: 'user', name: name || 'Naver User', emailVerified: true }, JWT_SECRET, { expiresIn: '12h' } ); return res.redirect(`${FRONTEND_URL}/oauth/callback?token=${token}`); } ); } // 7. 내 정보 확인 app.get('/api/auth/me', authenticateToken, (req, res) => { db.get( "SELECT id, username, email, name, phone, role, email_verified FROM users WHERE id = ?", [req.user.id], (err, user) => { if (err) return res.status(500).json({ error: err.message }); if (!user) return res.status(404).json({ error: "사용자를 찾을 수 없습니다." }); res.json(user); } ); }); // 8. 프로필 업데이트 app.put('/api/auth/profile', authenticateToken, async (req, res) => { const { name, phone } = req.body; db.run( "UPDATE users SET name = ?, phone = ? WHERE id = ?", [name, phone, req.user.id], function(err) { if (err) return res.status(500).json({ error: err.message }); res.json({ message: "프로필이 업데이트되었습니다." }); } ); }); // 9. 비밀번호 변경 (로그인 상태) app.put('/api/auth/change-password', authenticateToken, async (req, res) => { const { currentPassword, newPassword } = req.body; if (!currentPassword || !newPassword) { return res.status(400).json({ error: "현재 비밀번호와 새 비밀번호가 필요합니다." }); } if (newPassword.length < 6) { return res.status(400).json({ error: "비밀번호는 6자 이상이어야 합니다." }); } db.get("SELECT * FROM users WHERE id = ?", [req.user.id], async (err, user) => { if (err) return res.status(500).json({ error: err.message }); if (!user) return res.status(404).json({ error: "사용자를 찾을 수 없습니다." }); const match = await bcrypt.compare(currentPassword, user.password); if (!match) return res.status(400).json({ error: "현재 비밀번호가 일치하지 않습니다." }); const salt = await bcrypt.genSalt(10); const hash = await bcrypt.hash(newPassword, salt); db.run("UPDATE users SET password = ? WHERE id = ?", [hash, user.id], function(err) { if (err) return res.status(500).json({ error: err.message }); res.json({ message: "비밀번호가 변경되었습니다." }); }); }); }); // --- ADMIN API --- // 1. 사용자 목록 조회 (승인 대기 포함) app.get('/api/admin/users', authenticateToken, requireAdmin, (req, res) => { db.all("SELECT id, username, name, phone, business_name, role, approved, plan_type, credits, max_pensions, monthly_credits, createdAt FROM users ORDER BY createdAt DESC", [], (err, rows) => { if (err) return res.status(500).json({ error: err.message }); res.json(rows); }); }); // 1.5. 비밀번호 초기화 (New) app.post('/api/admin/users/:id/reset-password', authenticateToken, requireAdmin, async (req, res) => { const userId = req.params.id; const defaultPw = 'ado4!!!'; const salt = await bcrypt.genSalt(10); const hash = await bcrypt.hash(defaultPw, salt); db.run("UPDATE users SET password = ? WHERE id = ?", [hash, userId], function(err) { if (err) return res.status(500).json({ error: err.message }); res.json({ message: `비밀번호가 초기화되었습니다.` }); }); }); // 2. 사용자 승인/반려 app.post('/api/admin/approve', authenticateToken, requireAdmin, (req, res) => { const { userId, approve } = req.body; // approve: true(승인), false(반려/미승인) const status = approve ? 1 : 0; db.run("UPDATE users SET approved = ? WHERE id = ?", [status, userId], function(err) { if (err) return res.status(500).json({ error: err.message }); res.json({ message: `사용자 ID ${userId} 처리 완료 (상태: ${status})` }); }); }); // 3. 사용자 삭제 (New) app.delete('/api/admin/users/:id', authenticateToken, requireAdmin, (req, res) => { const userId = req.params.id; // 관리자 자신은 삭제 불가 if (parseInt(userId) === req.user.id) { return res.status(400).json({ error: "자기 자신은 삭제할 수 없습니다." }); } db.run("DELETE FROM users WHERE id = ?", [userId], function(err) { if (err) return res.status(500).json({ error: err.message }); res.json({ message: "사용자가 삭제되었습니다." }); }); }); // 4. 사용자 추가 (New) app.post('/api/admin/users', authenticateToken, requireAdmin, async (req, res) => { const { username, password, name, phone, role, businessName } = req.body; if (!username || !password) return res.status(400).json({ error: "ID와 비밀번호는 필수입니다." }); const salt = await bcrypt.genSalt(10); const hash = await bcrypt.hash(password, salt); const userRole = role || 'user'; db.run(`INSERT INTO users (username, password, name, phone, role, approved, business_name) VALUES (?, ?, ?, ?, ?, 1, ?)`, [username, hash, name, phone, userRole, businessName], function(err) { if (err) { if (err.message.includes('UNIQUE constraint failed')) { return res.status(400).json({ error: "이미 존재하는 ID입니다." }); } return res.status(500).json({ error: err.message }); } res.json({ message: "사용자가 생성되었습니다.", userId: this.lastID }); } ); }); // 5. 전체 히스토리 조회 app.get('/api/admin/history', authenticateToken, requireAdmin, (req, res) => { const query = ` SELECT h.*, u.username, u.name FROM history h JOIN users u ON h.user_id = u.id ORDER BY h.createdAt DESC `; db.all(query, [], (err, rows) => { if (err) return res.status(500).json({ error: err.message }); const parsed = rows.map(row => ({ ...row, details: JSON.parse(row.details) })); res.json(parsed); }); }); // --- USER HISTORY API --- // 1. 히스토리 저장 app.post('/api/history', authenticateToken, (req, res) => { const { businessName, details, pensionId } = req.body; const detailsStr = JSON.stringify(details); db.run("INSERT INTO history (user_id, business_name, details, render_status, pension_id) VALUES (?, ?, ?, 'pending', ?)", [req.user.id, businessName, detailsStr, pensionId || null], function(err) { if (err) return res.status(500).json({ error: err.message }); res.json({ message: "히스토리 저장 완료", id: this.lastID }); } ); }); // 2. 내 히스토리 조회 app.get('/api/history', authenticateToken, (req, res) => { db.all("SELECT * FROM history WHERE user_id = ? ORDER BY createdAt DESC", [req.user.id], (err, rows) => { if (err) return res.status(500).json({ error: err.message }); const parsed = rows.map(row => ({ ...row, details: JSON.parse(row.details) })); res.json(parsed); }); }); // 3. 히스토리 삭제 (단일) - 사용자 본인 것만 app.delete('/api/history/:id', authenticateToken, async (req, res) => { const historyId = req.params.id; const userId = req.user.id; try { // 먼저 해당 레코드가 사용자의 것인지 확인 const row = await new Promise((resolve, reject) => { db.get("SELECT * FROM history WHERE id = ? AND user_id = ?", [historyId, userId], (err, row) => { if (err) reject(err); else resolve(row); }); }); if (!row) { return res.status(404).json({ error: '히스토리를 찾을 수 없거나 권한이 없습니다.' }); } // 관련 파일 삭제 await deleteHistoryFiles(row); // DB에서 삭제 await new Promise((resolve, reject) => { db.run("DELETE FROM history WHERE id = ?", [historyId], (err) => { if (err) reject(err); else resolve(); }); }); console.log(`[History] 삭제 완료: ID ${historyId} (사용자: ${userId})`); res.json({ success: true, message: '삭제되었습니다.', deletedId: historyId }); } catch (error) { console.error('[History] 삭제 오류:', error); res.status(500).json({ error: '삭제 중 오류가 발생했습니다.', details: error.message }); } }); // 4. 히스토리 일괄 삭제 - 사용자 본인 것만 app.delete('/api/history', authenticateToken, async (req, res) => { const { ids } = req.body; const userId = req.user.id; if (!ids || !Array.isArray(ids) || ids.length === 0) { return res.status(400).json({ error: '삭제할 ID 목록이 필요합니다.' }); } try { // 사용자의 히스토리만 조회 const placeholders = ids.map(() => '?').join(','); const rows = await new Promise((resolve, reject) => { db.all( `SELECT * FROM history WHERE id IN (${placeholders}) AND user_id = ?`, [...ids, userId], (err, rows) => { if (err) reject(err); else resolve(rows); } ); }); if (rows.length === 0) { return res.status(404).json({ error: '삭제할 히스토리가 없습니다.' }); } // 파일 삭제 for (const row of rows) { await deleteHistoryFiles(row); } // DB에서 삭제 const validIds = rows.map(r => r.id); const deletePlaceholders = validIds.map(() => '?').join(','); await new Promise((resolve, reject) => { db.run(`DELETE FROM history WHERE id IN (${deletePlaceholders})`, validIds, (err) => { if (err) reject(err); else resolve(); }); }); console.log(`[History] 일괄 삭제 완료: ${validIds.length}개 (사용자: ${userId})`); res.json({ success: true, message: `${validIds.length}개 항목이 삭제되었습니다.`, deletedIds: validIds }); } catch (error) { console.error('[History] 일괄 삭제 오류:', error); res.status(500).json({ error: '삭제 중 오류가 발생했습니다.', details: error.message }); } }); // 5. 관리자용 히스토리 삭제 (단일) - 모든 사용자 app.delete('/api/admin/history/:id', authenticateToken, requireAdmin, async (req, res) => { const historyId = req.params.id; try { const row = await new Promise((resolve, reject) => { db.get("SELECT * FROM history WHERE id = ?", [historyId], (err, row) => { if (err) reject(err); else resolve(row); }); }); if (!row) { return res.status(404).json({ error: '히스토리를 찾을 수 없습니다.' }); } await deleteHistoryFiles(row); await new Promise((resolve, reject) => { db.run("DELETE FROM history WHERE id = ?", [historyId], (err) => { if (err) reject(err); else resolve(); }); }); console.log(`[Admin] 히스토리 삭제 완료: ID ${historyId}`); res.json({ success: true, message: '삭제되었습니다.', deletedId: historyId }); } catch (error) { console.error('[Admin] 히스토리 삭제 오류:', error); res.status(500).json({ error: '삭제 중 오류가 발생했습니다.', details: error.message }); } }); // 6. 관리자용 히스토리 일괄 삭제 - 모든 사용자 app.delete('/api/admin/history', authenticateToken, requireAdmin, async (req, res) => { const { ids } = req.body; if (!ids || !Array.isArray(ids) || ids.length === 0) { return res.status(400).json({ error: '삭제할 ID 목록이 필요합니다.' }); } try { const placeholders = ids.map(() => '?').join(','); const rows = await new Promise((resolve, reject) => { db.all(`SELECT * FROM history WHERE id IN (${placeholders})`, ids, (err, rows) => { if (err) reject(err); else resolve(rows); }); }); if (rows.length === 0) { return res.status(404).json({ error: '삭제할 히스토리가 없습니다.' }); } for (const row of rows) { await deleteHistoryFiles(row); } const validIds = rows.map(r => r.id); const deletePlaceholders = validIds.map(() => '?').join(','); await new Promise((resolve, reject) => { db.run(`DELETE FROM history WHERE id IN (${deletePlaceholders})`, validIds, (err) => { if (err) reject(err); else resolve(); }); }); console.log(`[Admin] 히스토리 일괄 삭제 완료: ${validIds.length}개`); res.json({ success: true, message: `${validIds.length}개 항목이 삭제되었습니다.`, deletedIds: validIds }); } catch (error) { console.error('[Admin] 히스토리 일괄 삭제 오류:', error); res.status(500).json({ error: '삭제 중 오류가 발생했습니다.', details: error.message }); } }); /** * 유틸리티 함수: 히스토리 관련 파일 삭제 * DB 레코드와 연관된 모든 파일/폴더를 삭제합니다. */ async function deleteHistoryFiles(historyRow) { const fsPromises = require('fs').promises; try { // final_video_path에서 폴더 경로 추출 if (historyRow.final_video_path) { let videoPath = historyRow.final_video_path; // URL에서 localhost 제거 if (videoPath.includes('localhost:3001')) { videoPath = videoPath.replace('http://localhost:3001', ''); } // 상대 경로를 절대 경로로 const absolutePath = path.join(__dirname, '..', videoPath); const folderPath = path.dirname(absolutePath); // downloads 폴더 내의 프로젝트 폴더 전체 삭제 if (folderPath.includes('downloads') && fs.existsSync(folderPath)) { await fsPromises.rm(folderPath, { recursive: true, force: true }); console.log(`[File] 폴더 삭제됨: ${folderPath}`); } else if (fs.existsSync(absolutePath)) { await fsPromises.unlink(absolutePath); console.log(`[File] 파일 삭제됨: ${absolutePath}`); } } // poster_path 삭제 (별도 파일인 경우) if (historyRow.poster_path && !historyRow.final_video_path?.includes(path.dirname(historyRow.poster_path))) { let posterPath = historyRow.poster_path; if (posterPath.includes('localhost:3001')) { posterPath = posterPath.replace('http://localhost:3001', ''); } const absolutePosterPath = path.join(__dirname, '..', posterPath); if (fs.existsSync(absolutePosterPath)) { await fsPromises.unlink(absolutePosterPath); console.log(`[File] 포스터 삭제됨: ${absolutePosterPath}`); } } } catch (error) { console.error('[File] 파일 삭제 오류:', error.message); // 파일 삭제 실패해도 계속 진행 (DB 삭제는 수행) } } /** * 유틸리티 함수: 파일 다운로드 * URL에서 파일을 다운로드하여 로컬 경로에 저장합니다. */ async function downloadFile(url, outputPath) { if (!url || url.startsWith('blob:')) { console.warn(`[다운로드] 유효하지 않거나 Blob URL입니다: ${url}`); return false; } console.log(`[다운로드] 파일 가져오는 중: ${url} -> ${outputPath}`); try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 60000); const response = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }, signal: controller.signal }); clearTimeout(timeout); if (!response.ok) throw new Error(`실패: ${response.status} ${response.statusText}`); const stream = fs.createWriteStream(outputPath); await streamPipeline(response.body, stream); console.log(`[다운로드] 저장 완료.`); return true; } catch (e) { console.error(`[다운로드] 오류 발생 ${url}:`, e); return false; } } // --- NAVER API (크롤링) --- const GRAPHQL_URL = "https://pcmap-api.place.naver.com/graphql"; const REQUEST_HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", "Referer": "https://map.naver.com/", "Origin": "https://map.naver.com", "Content-Type": "application/json" }; async function retry(fn, retries = 3, delay = 1000) { try { return await fn(); } catch (error) { if (retries > 0 && (error.response?.status === 429 || error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT')) { console.warn(`[Retry] 요청 실패 (상태: ${error.response?.status || error.code}), ${delay / 1000}초 후 재시도... (남은 횟수: ${retries})`); await new Promise(resolve => setTimeout(resolve, delay)); return retry(fn, retries - 1, delay * 2); } throw error; } } const OVERVIEW_QUERY = ` query getAccommodation($id: String!, $deviceType: String) { business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) { base { id name category roadAddress address phone virtualPhone microReviews conveniences visitorReviewsTotal } images { images { origin url } } cpImages(source: [ugcImage]) { images { origin url } } } } `; // Fisher-Yates 셔플 알고리즘 function shuffleArray(array) { const shuffled = [...array]; for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } return shuffled; } app.post('/api/naver/crawl', async (req, res) => { const { url } = req.body; if (!url) return res.status(400).json({ error: "URL이 필요합니다." }); console.log(`[Naver] 크롤링 시작: ${url}`); try { let placeId = ""; const match = url.match(/\/place\/(\d+)/); if (match && match[1]) { placeId = match[1]; } else if (/^\d+$/.test(url)) { placeId = url; } else { return res.status(400).json({ error: "URL에서 장소 ID를 찾을 수 없습니다. 예: https://map.naver.com/p/entry/place/12345678" }); } console.log(`[Naver] ID 추출됨: ${placeId}`); const headers = { ...REQUEST_HEADERS }; if (process.env.NAVER_COOKIES) { headers['Cookie'] = process.env.NAVER_COOKIES.trim().replace(/^"|"$/g, ''); } const response = await retry(async () => { return await axios.post(GRAPHQL_URL, { "operationName": "getAccommodation", "variables": { "id": placeId, "deviceType": "pc" }, "query": OVERVIEW_QUERY, }, { headers, timeout: 5000 }); }, 3, 1000); const business = response.data.data?.business; if (!business) return res.status(404).json({ error: "업체 정보를 찾을 수 없습니다." }); const base = business.base || {}; // 이미지 추출 (공식 이미지 + 사용자 제공 이미지) const rawImgs = [...(business.images?.images || []), ...(business.cpImages?.images || [])]; const images = []; const seen = new Set(); // 중복 제거하며 URL 수집 for (const img of rawImgs) { const u = img.origin || img.url; if (u && !seen.has(u)) { seen.add(u); images.push(u); } } // 셔플하여 랜덤 순서로 반환 const shuffledImages = shuffleArray(images); // 상세 설명 구성 const descParts = []; if (base.name) descParts.push(`상호: ${base.name}`); if (base.category) descParts.push(`업종: ${base.category}`); if (base.roadAddress) descParts.push(`주소: ${base.roadAddress}`); if (base.phone || base.virtualPhone) descParts.push(`전화: ${base.phone || base.virtualPhone}`); if (base.microReviews) descParts.push(`키워드: ${base.microReviews.slice(0, 5).join(', ')}`); console.log(`[Naver] ${base.name}: 총 ${images.length}장 이미지 발견`); res.json({ name: base.name, description: descParts.join('\n'), images: shuffledImages, // 모든 이미지 반환 (제한 없음) totalImages: images.length, place_id: placeId, address: base.roadAddress || base.address, category: base.category }); } catch (e) { console.error("[Naver] 오류:", e.message); res.status(500).json({ error: "크롤링 실패", details: e.message }); } }); // --- GENERIC IMAGE PROXY (for Naver etc.) --- app.get('/api/proxy/image', async (req, res) => { try { const imageUrl = req.query.url; if (!imageUrl) { return res.status(400).send("URL parameter is required"); } const response = await axios({ method: 'get', url: imageUrl, responseType: 'stream' }); if (response.headers['content-type']) { res.setHeader('Content-Type', response.headers['content-type']); } response.data.pipe(res); } catch (e) { console.error("[Proxy] Image download failed:", e.message); res.status(500).send("Image Proxy Error"); } }); // --- AUDIO PROXY (for Suno music URLs) --- app.get('/api/proxy/audio', async (req, res) => { try { const audioUrl = req.query.url; if (!audioUrl) { return res.status(400).send("URL parameter is required"); } console.log(`[Proxy] Audio download: ${audioUrl}`); const response = await axios({ method: 'get', url: audioUrl, responseType: 'stream', timeout: 60000 // 60초 타임아웃 }); if (response.headers['content-type']) { res.setHeader('Content-Type', response.headers['content-type']); } res.setHeader('Content-Disposition', 'attachment; filename="audio.mp3"'); response.data.pipe(res); } catch (e) { console.error("[Proxy] Audio download failed:", e.message); res.status(500).send("Audio Proxy Error"); } }); // --- GOOGLE PLACES API PROXY (여전히 프론트엔드에서 API 키를 보내야 함) --- // Google Places API는 프론트엔드에서도 직접 호출할 수 있으나, CORS 문제 때문에 백엔드 프록시로 제공합니다. // API Key는 여전히 프론트엔드에서 헤더로 전달받거나, 서버 .env에서 직접 사용 가능합니다. // 여기서는 VITE_GEMINI_API_KEY를 Google Places API 키로 활용하고 있습니다. // 1. 장소 검색 (Text Search) app.post('/api/google/places/search', authenticateToken, async (req, res) => { try { const { textQuery, languageCode } = req.body; const apiKey = process.env.VITE_GEMINI_API_KEY; // 서버에서 직접 키 사용 if (!apiKey) return res.status(500).json({ error: "Google Places API Key not configured on server." }); const response = await axios.post('https://places.googleapis.com/v1/places:searchText', { textQuery, languageCode }, { headers: { 'Content-Type': 'application/json', 'X-Goog-Api-Key': apiKey, 'X-Goog-FieldMask': 'places.name,places.displayName,places.formattedAddress' } }); res.json(response.data); } catch (e) { console.error("[Google Proxy] 검색 실패:", e.message); res.status(e.response?.status || 500).json(e.response?.data || { error: e.message }); } }); // 2. 장소 상세 (Details) app.post('/api/google/places/details', authenticateToken, async (req, res) => { try { const { placeId, fieldMask } = req.body; const apiKey = process.env.VITE_GEMINI_API_KEY; if (!apiKey) return res.status(500).json({ error: "Google Places API Key not configured on server." }); const response = await axios.get(`https://places.googleapis.com/v1/places/${placeId}`, { headers: { 'X-Goog-Api-Key': apiKey, 'X-Goog-FieldMask': fieldMask || '*', 'X-Goog-Place-Language-Code': 'ko' } }); res.json(response.data); } catch (e) { console.error("[Google Proxy] 상세 조회 실패:", e.message); res.status(e.response?.status || 500).json(e.response?.data || { error: e.message }); } }); // 3. 사진 다운로드 (Photo) app.post('/api/google/places/photo', authenticateToken, async (req, res) => { try { const { photoName, maxWidthPx } = req.body; const apiKey = process.env.VITE_GEMINI_API_KEY; if (!apiKey) return res.status(500).json({ error: "Google Places API Key not configured on server." }); const url = `https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=${maxWidthPx || 800}&maxWidthPx=${maxWidthPx || 800}&key=${apiKey}`; const response = await axios({ method: 'get', url: url, responseType: 'stream' }); if (response.headers['content-type']) { res.setHeader('Content-Type', response.headers['content-type']); } response.data.pipe(res); } catch (e) { console.error("[Google Proxy] 사진 다운로드 실패:", e.message); res.status(500).send("Photo Error"); } }); // --- SUNO API PROXY --- const SUNO_API_KEY = process.env.SUNO_API_KEY; const SUNO_BASE_URL = "https://api.sunoapi.org/api/v1"; app.post('/api/suno/generate', authenticateToken, async (req, res) => { const payload = req.body; // console.log(`[Suno] 생성 요청: ${payload.title}`); // 디버그 로그 제거 // console.log(`[Suno] API Key 존재 여부: ${!!SUNO_API_KEY}`); // 디버그 로그 제거 try { if (!SUNO_API_KEY) throw new Error("Suno API Key가 서버에 설정되지 않았습니다."); const generateRes = await axios.post(`${SUNO_BASE_URL}/generate`, payload, { headers: { 'Authorization': `Bearer ${SUNO_API_KEY}`, 'Content-Type': 'application/json' } }); if (generateRes.status !== 200) { throw new Error(`Suno API 요청 실패: ${generateRes.status} - ${JSON.stringify(generateRes.data)}`); } const taskId = generateRes.data.data?.taskId; if (!taskId) throw new Error("Task ID를 받지 못했습니다."); // console.log(`[Suno] 작업 시작: ${taskId}`); // 디버그 로그 제거 let audioUrl = ''; let attempts = 0; const maxAttempts = 100; while (attempts < maxAttempts) { await new Promise(r => setTimeout(r, 3000)); try { const statusRes = await axios.get(`${SUNO_BASE_URL}/generate/record-info?taskId=${taskId}`, { headers: { 'Authorization': `Bearer ${SUNO_API_KEY}` } }); const statusData = statusRes.data; const innerResponse = statusData.data?.response; const sunoData = innerResponse?.sunoData; const status = statusData.data?.status || "UNKNOWN"; // console.log(`[Suno] 상태 폴링 [${attempts+1}/${maxAttempts}]: ${status}`); // 디버그 로그 제거 if (sunoData && Array.isArray(sunoData) && sunoData.length > 0) { const track = sunoData[0]; if (['SUCCESS', 'FIRST_SUCCESS', 'TEXT_SUCCESS', 'complete', 'streaming'].includes(status)) { if (track.audioUrl) { audioUrl = track.audioUrl; // console.log(`[Suno] 생성 완료: ${audioUrl}`); // 디버그 로그 제거 break; } } else if (['em', 'error', 'REJECTED'].includes(status)) { throw new Error(`Suno 생성 실패 상태: ${status}`); } } } catch (pollErr) { console.error(`[Suno] 폴링 중 일시적 오류:`, pollErr.message); } attempts++; } if (!audioUrl) { throw new Error("Suno 생성 시간 초과"); } res.json({ audioUrl }); } catch (e) { console.error("[Suno] 최종 실패:", e.message); res.status(500).json({ error: e.message, details: e.response?.data || null }); } }); // --- Gemini API Proxies (Frontend에서 API Key 숨기기) --- // 모든 Gemini API 요청은 이 백엔드 프록시를 통해 이루어집니다. app.post('/api/gemini/creative-content', authenticateToken, async (req, res) => { try { const result = await generateCreativeContent(req.body); res.json(result); } catch (error) { console.error("[Gemini Proxy] Creative Content Error:", error); res.status(500).json({ error: error.message }); } }); app.post('/api/gemini/speech', authenticateToken, async (req, res) => { try { const { text, config } = req.body; const base64Audio = await generateAdvancedSpeech(text, config); res.json({ base64Audio }); } catch (error) { console.error("[Gemini Proxy] TTS Error:", error); res.status(500).json({ error: error.message }); } }); app.post('/api/gemini/ad-poster', authenticateToken, async (req, res) => { try { const { info } = req.body; const { base64, mimeType } = await generateAdPoster(info); res.json({ base64, mimeType }); } catch (error) { console.error("[Gemini Proxy] Ad Poster Error:", error); res.status(500).json({ error: error.message }); } }); app.post('/api/gemini/image-gallery', authenticateToken, async (req, res) => { try { const { info, count } = req.body; const images = await generateImageGallery(info, count); res.json({ images }); } catch (error) { console.error("[Gemini Proxy] Image Gallery Error:", error); res.status(500).json({ error: error.message }); } }); app.post('/api/gemini/video-background', authenticateToken, async (req, res) => { try { const { posterBase64, posterMimeType, aspectRatio } = req.body; const videoUrl = await generateVideoBackground(posterBase64, posterMimeType, aspectRatio); res.json({ videoUrl }); } catch (error) { console.error("[Gemini Proxy] Video Background Error:", error); res.status(500).json({ error: error.message }); } }); app.post('/api/gemini/text-effect', authenticateToken, async (req, res) => { try { const { imageFile } = req.body; const cssCode = await extractTextEffectFromImage(imageFile); res.json({ cssCode }); } catch (error) { console.error("[Gemini Proxy] Text Effect Error:", error); res.status(500).json({ error: error.message }); } }); app.post('/api/gemini/filter-images', authenticateToken, async (req, res) => { try { const { imagesData } = req.body; const filteredImages = await filterBestImages(imagesData); res.json({ filteredImages }); } catch (error) { console.error("[Gemini Proxy] Filter Images Error:", error); res.status(500).json({ error: error.message }); } }); app.post('/api/gemini/enrich-description', authenticateToken, async (req, res) => { try { const { name, rawDescription, reviews, rating } = req.body; const enrichedDescription = await enrichDescriptionWithReviews(name, rawDescription, reviews, rating); res.json({ enrichedDescription }); } catch (error) { console.error("[Gemini Proxy] Enrich Description Error:", error); res.status(500).json({ error: error.message }); } }); app.post('/api/gemini/search-business', authenticateToken, async (req, res) => { try { const { query } = req.body; const result = await searchBusinessInfo(query, process.env.VITE_GEMINI_API_KEY); // Google Maps Tool은 API 키 필요 res.json(result); } catch (error) { console.error("[Gemini Proxy] Search Business Error:", error); res.status(500).json({ error: error.message }); } }); // --- VIDEO RENDER API --- /** * 서버 사이드 영상 렌더링 엔드포인트 * Puppeteer로 슬라이드쇼를 녹화하고 FFmpeg로 오디오를 합성합니다. */ app.post('/render', authenticateToken, async (req, res) => { const startTime = Date.now(); const projectFolder = `render_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const projectPath = path.join(DOWNLOADS_DIR, projectFolder); console.log(`[Render] 시작: ${projectFolder}`); // 크레딧 체크 (관리자는 무제한) try { const user = await new Promise((resolve, reject) => { db.get("SELECT credits, role FROM users WHERE id = ?", [req.user.id], (err, row) => { if (err) reject(err); else resolve(row); }); }); if (user.role !== 'admin' && (user.credits === null || user.credits <= 0)) { return res.status(403).json({ error: '크레딧이 부족합니다. 추가 크레딧을 요청해주세요.', errorCode: 'INSUFFICIENT_CREDITS', credits: user.credits || 0 }); } } catch (creditErr) { console.error('[Render] 크레딧 확인 오류:', creditErr); return res.status(500).json({ error: '크레딧 확인 중 오류가 발생했습니다.' }); } try { const { posterBase64, audioBase64, imagesBase64, adCopy = [], textEffect = 'effect-fade', businessName = 'CastAD', aspectRatio = '9:16', historyId } = req.body; // 프로젝트 폴더 생성 fs.mkdirSync(projectPath, { recursive: true }); // 1. 파일 저장 const audioPath = path.join(projectPath, 'audio.mp3'); const videoPath = path.join(projectPath, 'video.webm'); const finalPath = path.join(projectPath, 'final.mp4'); // 오디오 저장 if (audioBase64) { fs.writeFileSync(audioPath, Buffer.from(audioBase64, 'base64')); console.log(`[Render] 오디오 저장 완료: ${audioPath}`); } // 이미지 저장 const imagePaths = []; if (imagesBase64 && imagesBase64.length > 0) { for (let i = 0; i < imagesBase64.length; i++) { const imgPath = path.join(projectPath, `image_${i}.jpg`); const imgData = imagesBase64[i].replace(/^data:image\/\w+;base64,/, ''); fs.writeFileSync(imgPath, Buffer.from(imgData, 'base64')); imagePaths.push(imgPath); } console.log(`[Render] ${imagePaths.length}개 이미지 저장 완료`); } // 포스터 저장 (이미지가 없을 경우 대체) if (posterBase64) { const posterPath = path.join(projectPath, 'poster.jpg'); const posterData = posterBase64.replace(/^data:image\/\w+;base64,/, ''); fs.writeFileSync(posterPath, Buffer.from(posterData, 'base64')); if (imagePaths.length === 0) { imagePaths.push(posterPath); } } // 비디오 크기 결정 const isVertical = aspectRatio === '9:16'; const width = isVertical ? 540 : 960; const height = isVertical ? 960 : 540; // 2. HTML 템플릿 생성 const htmlContent = generateRenderHTML({ imagePaths: imagePaths.map(p => `file://${p}`), adCopy, textEffect, businessName, width, height }); const htmlPath = path.join(projectPath, 'render.html'); fs.writeFileSync(htmlPath, htmlContent); console.log(`[Render] HTML 템플릿 생성 완료`); // 3. Puppeteer로 영상 녹화 const browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-web-security'] }); const page = await browser.newPage(); await page.setViewport({ width, height }); // 녹화 설정 const recorder = new PuppeteerScreenRecorder(page, { fps: 30, ffmpeg_Path: null, // 시스템 ffmpeg 사용 videoFrame: { width, height }, aspectRatio: isVertical ? '9:16' : '16:9' }); await page.goto(`file://${htmlPath}`, { waitUntil: 'networkidle0' }); // 애니메이션 시작 및 녹화 await recorder.start(videoPath); console.log(`[Render] 녹화 시작`); // 슬라이드쇼 재생 시간 계산 (각 슬라이드 5초 + 텍스트 4초) const durationMs = Math.max(30000, adCopy.length * 5000, imagePaths.length * 5000); await page.evaluate((duration) => { window.startAnimation && window.startAnimation(); }, durationMs); await new Promise(resolve => setTimeout(resolve, durationMs)); await recorder.stop(); await browser.close(); console.log(`[Render] 녹화 완료: ${videoPath}`); // 4. FFmpeg로 오디오 합성 if (fs.existsSync(audioPath)) { await new Promise((resolve, reject) => { const ffmpegCmd = `ffmpeg -y -i "${videoPath}" -i "${audioPath}" -c:v libx264 -preset fast -crf 23 -c:a aac -b:a 128k -shortest "${finalPath}"`; exec(ffmpegCmd, (error, stdout, stderr) => { if (error) { console.error('[FFmpeg] 오류:', stderr); reject(error); } else { console.log('[Render] FFmpeg 합성 완료'); resolve(); } }); }); } else { // 오디오 없이 비디오만 변환 await new Promise((resolve, reject) => { const ffmpegCmd = `ffmpeg -y -i "${videoPath}" -c:v libx264 -preset fast -crf 23 "${finalPath}"`; exec(ffmpegCmd, (error) => { if (error) reject(error); else resolve(); }); }); } // 5. DB 업데이트 (히스토리에 파일 경로 저장) if (historyId) { const relativePath = `/downloads/${projectFolder}/final.mp4`; db.run("UPDATE history SET final_video_path = ?, render_status = 'completed' WHERE id = ?", [relativePath, historyId]); // 5.1 에셋 자동 저장 (렌더링된 영상 + 포스터 + 오디오 + 소스 이미지) try { let totalAssetSize = 0; const videoStats = fs.statSync(finalPath); totalAssetSize += videoStats.size; // 렌더링된 영상 저장 db.run(` INSERT INTO user_assets (user_id, history_id, asset_type, source_type, file_name, file_path, file_size, mime_type) VALUES (?, ?, 'video', 'rendered', ?, ?, ?, 'video/mp4') `, [req.user.id, historyId, `final.mp4`, relativePath, videoStats.size]); // 포스터 저장 (있으면) const posterFilePath = path.join(projectPath, 'poster.jpg'); if (fs.existsSync(posterFilePath)) { const posterStats = fs.statSync(posterFilePath); totalAssetSize += posterStats.size; db.run(` INSERT INTO user_assets (user_id, history_id, asset_type, source_type, file_name, file_path, file_size, mime_type) VALUES (?, ?, 'image', 'ai_generated', ?, ?, ?, 'image/jpeg') `, [req.user.id, historyId, 'poster.jpg', `/downloads/${projectFolder}/poster.jpg`, posterStats.size]); } // 오디오 저장 (있으면) const audioFilePath = path.join(projectPath, 'audio.mp3'); if (fs.existsSync(audioFilePath)) { const audioStats = fs.statSync(audioFilePath); totalAssetSize += audioStats.size; db.run(` INSERT INTO user_assets (user_id, history_id, asset_type, source_type, file_name, file_path, file_size, mime_type) VALUES (?, ?, 'audio', 'ai_generated', ?, ?, ?, 'audio/mpeg') `, [req.user.id, historyId, 'audio.mp3', `/downloads/${projectFolder}/audio.mp3`, audioStats.size]); } // 소스 이미지들 저장 (크롤링/업로드된 이미지 - 영상에 사용된 것) if (imagePaths && imagePaths.length > 0) { for (let i = 0; i < imagePaths.length; i++) { const imgPath = imagePaths[i]; if (fs.existsSync(imgPath)) { const imgStats = fs.statSync(imgPath); totalAssetSize += imgStats.size; db.run(` INSERT INTO user_assets (user_id, history_id, asset_type, source_type, file_name, file_path, file_size, mime_type) VALUES (?, ?, 'image', 'crawl', ?, ?, ?, 'image/jpeg') `, [req.user.id, historyId, `image_${i}.jpg`, `/downloads/${projectFolder}/image_${i}.jpg`, imgStats.size]); } } } // 스토리지 사용량 업데이트 db.run(`UPDATE users SET storage_used = storage_used + ? WHERE id = ?`, [totalAssetSize, req.user.id]); console.log(`[Render] 에셋 저장 완료: video + poster + audio + ${imagePaths?.length || 0} images (${(totalAssetSize / 1024 / 1024).toFixed(2)} MB)`); } catch (assetErr) { console.error('[Render] 에셋 저장 오류 (무시):', assetErr); } } const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); console.log(`[Render] 완료! (${elapsed}초)`); // 5.5. 크레딧 차감 (관리자 제외) try { const userInfo = await new Promise((resolve, reject) => { db.get("SELECT credits, role FROM users WHERE id = ?", [req.user.id], (err, row) => { if (err) reject(err); else resolve(row); }); }); if (userInfo.role !== 'admin') { const newBalance = Math.max(0, (userInfo.credits || 0) - 1); await new Promise((resolve, reject) => { db.run("UPDATE users SET credits = ? WHERE id = ?", [newBalance, req.user.id], (err) => { if (err) reject(err); else resolve(); }); }); // 크레딧 히스토리 기록 await new Promise((resolve, reject) => { db.run(` INSERT INTO credit_history (user_id, amount, type, description, balance_after) VALUES (?, -1, 'video_render', ?, ?) `, [req.user.id, `영상 생성: ${businessName}`, newBalance], (err) => { if (err) reject(err); else resolve(); }); }); console.log(`[Render] 크레딧 차감: ${req.user.username} (잔액: ${newBalance})`); } } catch (creditErr) { console.error('[Render] 크레딧 차감 오류 (무시):', creditErr); // 크레딧 차감 실패해도 영상은 전달 } // 6. 파일 응답 (한글 파일명 RFC 5987 인코딩) res.setHeader('Content-Type', 'video/mp4'); const safeFilename = `CastAD_${businessName}.mp4`; const encodedFilename = encodeURIComponent(safeFilename); res.setHeader('Content-Disposition', `attachment; filename="CastAD_video.mp4"; filename*=UTF-8''${encodedFilename}`); res.setHeader('X-Project-Folder', encodeURIComponent(projectFolder)); res.sendFile(finalPath); } catch (error) { console.error('[Render] 오류:', error); // 실패 시 폴더 정리 try { if (fs.existsSync(projectPath)) { fs.rmSync(projectPath, { recursive: true }); } } catch (e) {} res.status(500).json({ error: error.message || '영상 렌더링 실패' }); } }); /** * 렌더링용 HTML 템플릿 생성 */ function generateRenderHTML({ imagePaths, adCopy, textEffect, businessName, width, height }) { const slideCount = Math.max(imagePaths.length, 1); const slideDuration = 5; // 각 슬라이드 5초 (천천히) const totalDuration = slideCount * slideDuration; // 텍스트 이펙트 CSS const textEffectCSS = getTextEffectCSS(textEffect); return `
`; } /** * 텍스트 이펙트별 CSS 반환 */ function getTextEffectCSS(effect) { const effects = { 'effect-fade': '', 'effect-bounce': ` .effect-bounce.show { animation: bounceIn 0.6s cubic-bezier(0.68, -0.55, 0.265, 1.55); } @keyframes bounceIn { 0% { transform: translateX(-50%) scale(0.3); opacity: 0; } 50% { transform: translateX(-50%) scale(1.05); } 100% { transform: translateX(-50%) scale(1); opacity: 1; } } `, 'effect-typewriter': ` .effect-typewriter { overflow: hidden; white-space: nowrap; border-right: 3px solid white; animation: typing 2s steps(40, end), blink-caret 0.75s step-end infinite; } @keyframes typing { from { width: 0 } to { width: 100% } } @keyframes blink-caret { from, to { border-color: transparent } 50% { border-color: white } } `, 'effect-glow': ` .effect-glow { text-shadow: 0 0 10px #fff, 0 0 20px #fff, 0 0 30px #a855f7, 0 0 40px #a855f7; } `, 'effect-neon': ` .effect-neon { text-shadow: 0 0 5px #fff, 0 0 10px #fff, 0 0 20px #ff00de, 0 0 30px #ff00de, 0 0 40px #ff00de; animation: neonPulse 1.5s ease-in-out infinite alternate; } @keyframes neonPulse { from { text-shadow: 0 0 5px #fff, 0 0 10px #fff, 0 0 20px #ff00de; } to { text-shadow: 0 0 10px #fff, 0 0 20px #fff, 0 0 40px #ff00de, 0 0 60px #ff00de; } } ` }; return effects[effect] || ''; } // ==================== PENSION PROFILE API ROUTES (다중 펜션 지원) ==================== /** * 모든 펜션 프로필 조회 * GET /api/profile/pensions */ app.get('/api/profile/pensions', authenticateToken, (req, res) => { const userId = req.user.id; db.all(`SELECT * FROM pension_profiles WHERE user_id = ? ORDER BY is_default DESC, createdAt DESC`, [userId], (err, rows) => { if (err) return res.status(500).json({ error: err.message }); res.json(rows || []); }); }); /** * 기본 펜션 프로필 조회 (하위 호환) * GET /api/profile/pension */ app.get('/api/profile/pension', authenticateToken, (req, res) => { const userId = req.user.id; // 먼저 기본 펜션을 찾고, 없으면 첫 번째 펜션 반환 db.get(`SELECT * FROM pension_profiles WHERE user_id = ? ORDER BY is_default DESC, createdAt ASC LIMIT 1`, [userId], (err, row) => { if (err) return res.status(500).json({ error: err.message }); res.json(row || null); }); }); /** * 특정 펜션 프로필 조회 * GET /api/profile/pension/:id */ app.get('/api/profile/pension/:id', authenticateToken, (req, res) => { const userId = req.user.id; const pensionId = req.params.id; db.get(`SELECT * FROM pension_profiles WHERE id = ? AND user_id = ?`, [pensionId, userId], (err, row) => { if (err) return res.status(500).json({ error: err.message }); if (!row) return res.status(404).json({ error: '펜션을 찾을 수 없습니다.' }); res.json(row); }); }); /** * 새 펜션 프로필 생성 * POST /api/profile/pension */ app.post('/api/profile/pension', authenticateToken, (req, res) => { const userId = req.user.id; const { brand_name, brand_name_en, region, address, pension_types, target_customers, key_features, nearby_attractions, booking_url, homepage_url, kakao_channel, instagram_handle, languages, price_range, description, is_default } = req.body; // 사용자 플랜 확인 및 펜션 제한 체크 db.get(`SELECT plan_type, max_pensions FROM users WHERE id = ?`, [userId], (err, userRow) => { if (err) return res.status(500).json({ error: err.message }); const maxPensions = userRow?.max_pensions || 1; // 현재 펜션 수 확인 db.get(`SELECT COUNT(*) as count FROM pension_profiles WHERE user_id = ?`, [userId], (err, countRow) => { if (err) return res.status(500).json({ error: err.message }); // 플랜 제한 체크 if (countRow.count >= maxPensions) { return res.status(403).json({ error: '펜션 등록 한도에 도달했습니다.', limit: maxPensions, current: countRow.count, upgrade_required: true, message: `현재 플랜에서는 최대 ${maxPensions}개의 펜션만 등록할 수 있습니다. 더 많은 펜션을 관리하려면 Pro 플랜으로 업그레이드하세요.` }); } // 첫 번째 펜션이면 기본값으로 설정 proceedWithCreation(countRow.count); }); }); function proceedWithCreation(currentCount) { const shouldBeDefault = is_default || currentCount === 0 ? 1 : 0; // 만약 이 펜션이 기본값이면 다른 펜션들의 기본값 해제 const setDefault = () => { if (shouldBeDefault) { db.run(`UPDATE pension_profiles SET is_default = 0 WHERE user_id = ?`, [userId], () => { insertPension(); }); } else { insertPension(); } }; const insertPension = () => { db.run(` INSERT INTO pension_profiles (user_id, is_default, brand_name, brand_name_en, region, address, pension_types, target_customers, key_features, nearby_attractions, booking_url, homepage_url, kakao_channel, instagram_handle, languages, price_range, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ userId, shouldBeDefault, brand_name || null, brand_name_en || null, region || null, address || null, JSON.stringify(pension_types || []), JSON.stringify(target_customers || []), JSON.stringify(key_features || []), JSON.stringify(nearby_attractions || []), booking_url || null, homepage_url || null, kakao_channel || null, instagram_handle || null, languages || 'KO', price_range || null, description || null ], function(err) { if (err) return res.status(500).json({ error: err.message }); res.json({ success: true, id: this.lastID, is_default: shouldBeDefault }); }); }; setDefault(); } }); /** * 펜션 프로필 업데이트 * PUT /api/profile/pension/:id */ app.put('/api/profile/pension/:id', authenticateToken, (req, res) => { const userId = req.user.id; const pensionId = req.params.id; const { brand_name, brand_name_en, region, address, pension_types, target_customers, key_features, nearby_attractions, booking_url, homepage_url, kakao_channel, instagram_handle, languages, price_range, description } = req.body; // 먼저 해당 펜션이 사용자의 것인지 확인 db.get(`SELECT id FROM pension_profiles WHERE id = ? AND user_id = ?`, [pensionId, userId], (err, row) => { if (err) return res.status(500).json({ error: err.message }); if (!row) return res.status(404).json({ error: '펜션을 찾을 수 없습니다.' }); db.run(` UPDATE pension_profiles SET brand_name = ?, brand_name_en = ?, region = ?, address = ?, pension_types = ?, target_customers = ?, key_features = ?, nearby_attractions = ?, booking_url = ?, homepage_url = ?, kakao_channel = ?, instagram_handle = ?, languages = ?, price_range = ?, description = ?, updatedAt = datetime('now') WHERE id = ? `, [ brand_name || null, brand_name_en || null, region || null, address || null, JSON.stringify(pension_types || []), JSON.stringify(target_customers || []), JSON.stringify(key_features || []), JSON.stringify(nearby_attractions || []), booking_url || null, homepage_url || null, kakao_channel || null, instagram_handle || null, languages || 'KO', price_range || null, description || null, pensionId ], function(err) { if (err) return res.status(500).json({ error: err.message }); res.json({ success: true, id: pensionId }); }); }); }); /** * 펜션 프로필 삭제 * DELETE /api/profile/pension/:id */ app.delete('/api/profile/pension/:id', authenticateToken, (req, res) => { const userId = req.user.id; const pensionId = req.params.id; db.get(`SELECT is_default FROM pension_profiles WHERE id = ? AND user_id = ?`, [pensionId, userId], (err, row) => { if (err) return res.status(500).json({ error: err.message }); if (!row) return res.status(404).json({ error: '펜션을 찾을 수 없습니다.' }); db.run(`DELETE FROM pension_profiles WHERE id = ?`, [pensionId], function(err) { if (err) return res.status(500).json({ error: err.message }); // 삭제된 펜션이 기본값이었으면 다른 펜션을 기본값으로 설정 if (row.is_default) { db.run(` UPDATE pension_profiles SET is_default = 1 WHERE user_id = ? AND id = (SELECT id FROM pension_profiles WHERE user_id = ? ORDER BY createdAt ASC LIMIT 1) `, [userId, userId]); } res.json({ success: true, deletedId: pensionId }); }); }); }); /** * 기본 펜션 설정 * POST /api/profile/pension/:id/default */ app.post('/api/profile/pension/:id/default', authenticateToken, (req, res) => { const userId = req.user.id; const pensionId = req.params.id; db.get(`SELECT id FROM pension_profiles WHERE id = ? AND user_id = ?`, [pensionId, userId], (err, row) => { if (err) return res.status(500).json({ error: err.message }); if (!row) return res.status(404).json({ error: '펜션을 찾을 수 없습니다.' }); // 모든 펜션의 기본값 해제 db.run(`UPDATE pension_profiles SET is_default = 0 WHERE user_id = ?`, [userId], (err) => { if (err) return res.status(500).json({ error: err.message }); // 선택한 펜션을 기본값으로 설정 db.run(`UPDATE pension_profiles SET is_default = 1 WHERE id = ?`, [pensionId], function(err) { if (err) return res.status(500).json({ error: err.message }); res.json({ success: true, defaultPensionId: pensionId }); }); }); }); }); // ==================== USER ASSETS MANAGEMENT ==================== // 사용자별 에셋 디렉토리 생성 함수 const ensureUserAssetDir = (userId) => { const userDir = path.join(DOWNLOADS_DIR, 'users', userId.toString()); if (!fs.existsSync(userDir)) { fs.mkdirSync(userDir, { recursive: true }); } return userDir; }; /** * 스토리지 사용량 통계 조회 * GET /api/user-assets/stats */ app.get('/api/user-assets/stats', authenticateToken, (req, res) => { const userId = req.user.id; db.get(`SELECT storage_limit FROM users WHERE id = ?`, [userId], (err, userRow) => { if (err) return res.status(500).json({ error: err.message }); const storageLimit = userRow?.storage_limit || 500; // MB db.all(` SELECT asset_type, COUNT(*) as count, SUM(file_size) as total_size FROM user_assets WHERE user_id = ? AND is_deleted = 0 GROUP BY asset_type `, [userId], (err, rows) => { if (err) return res.status(500).json({ error: err.message }); const stats = { totalUsed: 0, storageLimit, imageCount: 0, audioCount: 0, videoCount: 0, imageSize: 0, audioSize: 0, videoSize: 0 }; rows.forEach(row => { const size = row.total_size || 0; stats.totalUsed += size; if (row.asset_type === 'image') { stats.imageCount = row.count; stats.imageSize = size; } else if (row.asset_type === 'audio') { stats.audioCount = row.count; stats.audioSize = size; } else if (row.asset_type === 'video') { stats.videoCount = row.count; stats.videoSize = size; } }); res.json(stats); }); }); }); /** * 에셋 목록 조회 * GET /api/user-assets?type=image|audio|video&source=upload|crawl|ai_generated|rendered */ app.get('/api/user-assets', authenticateToken, (req, res) => { const userId = req.user.id; const { type, source, pensionId, limit = 100, offset = 0 } = req.query; let query = `SELECT * FROM user_assets WHERE user_id = ? AND is_deleted = 0`; const params = [userId]; if (type) { query += ` AND asset_type = ?`; params.push(type); } if (source) { query += ` AND source_type = ?`; params.push(source); } if (pensionId) { query += ` AND pension_id = ?`; params.push(pensionId); } query += ` ORDER BY createdAt DESC LIMIT ? OFFSET ?`; params.push(parseInt(limit), parseInt(offset)); db.all(query, params, (err, rows) => { if (err) return res.status(500).json({ error: err.message }); res.json(rows); }); }); /** * 에셋 업로드 (이미지) * POST /api/user-assets/upload */ const multer = require('multer'); const uploadStorage = multer.diskStorage({ destination: (req, file, cb) => { const userDir = ensureUserAssetDir(req.user.id); cb(null, userDir); }, filename: (req, file, cb) => { const ext = path.extname(file.originalname); const safeName = file.originalname.replace(/[^a-z0-9가-힣.]/gi, '_'); cb(null, `${Date.now()}_${safeName}`); } }); const uploadMiddleware = multer({ storage: uploadStorage, limits: { fileSize: 50 * 1024 * 1024 }, // 50MB limit fileFilter: (req, file, cb) => { const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'audio/mpeg', 'audio/wav', 'audio/mp3', 'video/mp4', 'video/webm']; if (allowedTypes.includes(file.mimetype)) { cb(null, true); } else { cb(new Error('지원하지 않는 파일 형식입니다.'), false); } } }); app.post('/api/user-assets/upload', authenticateToken, uploadMiddleware.array('files', 20), (req, res) => { const userId = req.user.id; const { pensionId } = req.body; if (!req.files || req.files.length === 0) { return res.status(400).json({ error: '파일이 없습니다.' }); } const savedAssets = []; let completed = 0; req.files.forEach(file => { let assetType = 'image'; if (file.mimetype.startsWith('audio/')) assetType = 'audio'; else if (file.mimetype.startsWith('video/')) assetType = 'video'; const filePath = `/downloads/users/${userId}/${file.filename}`; db.run(` INSERT INTO user_assets (user_id, pension_id, asset_type, source_type, file_name, file_path, file_size, mime_type) VALUES (?, ?, ?, 'upload', ?, ?, ?, ?) `, [userId, pensionId || null, assetType, file.originalname, filePath, file.size, file.mimetype], function(err) { completed++; if (!err) { savedAssets.push({ id: this.lastID, asset_type: assetType, file_name: file.originalname, file_path: filePath, file_size: file.size }); } if (completed === req.files.length) { // 사용자 스토리지 사용량 업데이트 const totalSize = req.files.reduce((sum, f) => sum + f.size, 0); db.run(`UPDATE users SET storage_used = storage_used + ? WHERE id = ?`, [totalSize, userId]); res.json({ success: true, assets: savedAssets }); } }); }); }); /** * 에셋 삭제 * DELETE /api/user-assets/:id */ app.delete('/api/user-assets/:id', authenticateToken, (req, res) => { const userId = req.user.id; const assetId = req.params.id; db.get(`SELECT * FROM user_assets WHERE id = ? AND user_id = ?`, [assetId, userId], (err, asset) => { if (err) return res.status(500).json({ error: err.message }); if (!asset) return res.status(404).json({ error: '에셋을 찾을 수 없습니다.' }); // 소프트 삭제 (실제 파일은 유지, 필요시 하드 삭제 구현 가능) db.run(`UPDATE user_assets SET is_deleted = 1 WHERE id = ?`, [assetId], function(err) { if (err) return res.status(500).json({ error: err.message }); // 스토리지 사용량 업데이트 db.run(`UPDATE users SET storage_used = storage_used - ? WHERE id = ?`, [asset.file_size, userId]); res.json({ success: true, deletedId: assetId }); }); }); }); /** * 에셋 저장 (내부 사용 - 크롤링/AI 생성 시 호출) * 이 함수는 다른 API에서 호출하는 헬퍼 함수입니다 */ const saveUserAsset = (userId, assetData) => { return new Promise((resolve, reject) => { const { pensionId, historyId, assetType, sourceType, fileName, filePath, fileSize, mimeType, thumbnailPath, duration, width, height, metadata } = assetData; db.run(` INSERT INTO user_assets ( user_id, pension_id, history_id, asset_type, source_type, file_name, file_path, file_size, mime_type, thumbnail_path, duration, width, height, metadata ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ userId, pensionId || null, historyId || null, assetType, sourceType, fileName, filePath, fileSize || 0, mimeType || null, thumbnailPath || null, duration || null, width || null, height || null, metadata ? JSON.stringify(metadata) : null ], function(err) { if (err) reject(err); else { // 스토리지 사용량 업데이트 if (fileSize) { db.run(`UPDATE users SET storage_used = storage_used + ? WHERE id = ?`, [fileSize, userId]); } resolve({ id: this.lastID, ...assetData }); } }); }); }; // 전역으로 사용할 수 있도록 export (다른 파일에서 require 시) module.exports = { saveUserAsset }; // ==================== YOUTUBE OAUTH & SETTINGS ROUTES ==================== /** * YouTube OAuth 인증 URL 생성 * GET /api/youtube/oauth/url */ app.get('/api/youtube/oauth/url', authenticateToken, (req, res) => { try { const userId = req.user.id; const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; const redirectUri = `${frontendUrl.replace(/:\d+$/, '')}:3001/api/youtube/oauth/callback`; const authUrl = generateAuthUrl(userId, redirectUri); res.json({ authUrl }); } catch (error) { console.error('[YouTube OAuth] URL 생성 오류:', error.message); res.status(500).json({ error: error.message }); } }); /** * YouTube OAuth 콜백 처리 * GET /api/youtube/oauth/callback */ app.get('/api/youtube/oauth/callback', async (req, res) => { const { code, state, error } = req.query; const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; if (error) { return res.redirect(`${frontendUrl}/settings?youtube_error=${encodeURIComponent(error)}`); } if (!code || !state) { return res.redirect(`${frontendUrl}/settings?youtube_error=missing_params`); } try { const { userId } = JSON.parse(state); const redirectUri = `${frontendUrl.replace(/:\d+$/, '')}:3001/api/youtube/oauth/callback`; const result = await exchangeCodeForTokens(code, userId, redirectUri); // 성공 시 프론트엔드로 리다이렉트 res.redirect(`${frontendUrl}/settings?youtube_connected=true&channel=${encodeURIComponent(result.channelTitle || '')}`); } catch (error) { console.error('[YouTube OAuth] 콜백 오류:', error.message); res.redirect(`${frontendUrl}/settings?youtube_error=${encodeURIComponent(error.message)}`); } }); /** * YouTube 연결 상태 확인 * GET /api/youtube/connection */ app.get('/api/youtube/connection', authenticateToken, async (req, res) => { try { const userId = req.user.id; const connection = await getConnectionStatus(userId); res.json({ connected: !!connection, ...connection }); } catch (error) { res.status(500).json({ error: error.message }); } }); /** * YouTube 연결 해제 * DELETE /api/youtube/connection */ app.delete('/api/youtube/connection', authenticateToken, async (req, res) => { try { const userId = req.user.id; await disconnectYouTube(userId); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); /** * YouTube 설정 조회 * GET /api/youtube/settings */ app.get('/api/youtube/settings', authenticateToken, async (req, res) => { try { const userId = req.user.id; const settings = await getUserYouTubeSettings(userId); res.json(settings); } catch (error) { res.status(500).json({ error: error.message }); } }); /** * YouTube 설정 업데이트 * POST /api/youtube/settings */ app.post('/api/youtube/settings', authenticateToken, async (req, res) => { try { const userId = req.user.id; await updateUserYouTubeSettings(userId, req.body); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); /** * 사용자의 플레이리스트 목록 (새 API) * GET /api/youtube/my-playlists */ app.get('/api/youtube/my-playlists', authenticateToken, async (req, res) => { try { const userId = req.user.id; const playlists = await getPlaylistsForUser(userId); res.json(playlists); } catch (error) { res.status(500).json({ error: error.message }); } }); /** * 플레이리스트 생성 (사용자 채널에) * POST /api/youtube/my-playlists */ app.post('/api/youtube/my-playlists', authenticateToken, async (req, res) => { try { const userId = req.user.id; const { title, description, privacyStatus, pensionId } = req.body; const playlist = await createPlaylistForUser(userId, title, description, privacyStatus); // 펜션에 플레이리스트 연결 if (pensionId && playlist.id) { db.run(` INSERT OR REPLACE INTO youtube_playlists (user_id, pension_id, playlist_id, title, item_count, cached_at) VALUES (?, ?, ?, ?, 0, datetime('now')) `, [userId, pensionId, playlist.id, title]); } res.json(playlist); } catch (error) { res.status(500).json({ error: error.message }); } }); /** * 펜션별 플레이리스트 조회 * GET /api/youtube/pension/:pensionId/playlists */ app.get('/api/youtube/pension/:pensionId/playlists', authenticateToken, (req, res) => { const userId = req.user.id; const pensionId = req.params.pensionId; db.all(` SELECT * FROM youtube_playlists WHERE user_id = ? AND pension_id = ? ORDER BY cached_at DESC `, [userId, pensionId], (err, rows) => { if (err) return res.status(500).json({ error: err.message }); res.json(rows || []); }); }); /** * 플레이리스트를 펜션에 연결 * POST /api/youtube/pension/:pensionId/playlists */ app.post('/api/youtube/pension/:pensionId/playlists', authenticateToken, (req, res) => { const userId = req.user.id; const pensionId = req.params.pensionId; const { playlistId, title } = req.body; if (!playlistId) { return res.status(400).json({ error: '플레이리스트 ID가 필요합니다.' }); } // 펜션이 사용자의 것인지 확인 db.get(`SELECT id FROM pension_profiles WHERE id = ? AND user_id = ?`, [pensionId, userId], (err, pension) => { if (err) return res.status(500).json({ error: err.message }); if (!pension) return res.status(404).json({ error: '펜션을 찾을 수 없습니다.' }); db.run(` INSERT OR REPLACE INTO youtube_playlists (user_id, pension_id, playlist_id, title, cached_at) VALUES (?, ?, ?, ?, datetime('now')) `, [userId, pensionId, playlistId, title || ''], function(err) { if (err) return res.status(500).json({ error: err.message }); res.json({ success: true }); }); }); }); /** * 플레이리스트 펜션 연결 해제 * DELETE /api/youtube/pension/:pensionId/playlists/:playlistId */ app.delete('/api/youtube/pension/:pensionId/playlists/:playlistId', authenticateToken, (req, res) => { const userId = req.user.id; const { pensionId, playlistId } = req.params; db.run(` DELETE FROM youtube_playlists WHERE user_id = ? AND pension_id = ? AND playlist_id = ? `, [userId, pensionId, playlistId], function(err) { if (err) return res.status(500).json({ error: err.message }); res.json({ success: true }); }); }); /** * 사용자 채널에 업로드 (새 API) * POST /api/youtube/my-upload */ app.post('/api/youtube/my-upload', authenticateToken, async (req, res) => { try { const userId = req.user.id; const { videoPath, seoData, historyId, playlistId, privacyStatus, categoryId } = req.body; if (!videoPath) { return res.status(400).json({ error: '비디오 경로가 필요합니다.' }); } const fullVideoPath = videoPath.startsWith('/') ? path.join(__dirname, '..', videoPath) : path.join(__dirname, videoPath); if (!fs.existsSync(fullVideoPath)) { return res.status(404).json({ error: '비디오 파일을 찾을 수 없습니다.' }); } const result = await uploadVideoForUser(userId, fullVideoPath, seoData || {}, { historyId, playlistId, privacyStatus, categoryId }); res.json({ success: true, youtubeUrl: result.url, videoId: result.videoId }); } catch (error) { console.error('[YouTube Upload] 오류:', error.message); res.status(500).json({ error: error.message }); } }); /** * 업로드 히스토리 조회 * GET /api/youtube/upload-history */ app.get('/api/youtube/upload-history', authenticateToken, async (req, res) => { try { const userId = req.user.id; const limit = parseInt(req.query.limit) || 20; const history = await getUploadHistory(userId, limit); res.json(history); } catch (error) { res.status(500).json({ error: error.message }); } }); // ==================== YOUTUBE API ROUTES (Legacy) ==================== /** * YouTube SEO 메타데이터 생성 (다국어 지원) * POST /api/youtube/seo */ app.post('/api/youtube/seo', authenticateToken, async (req, res) => { try { const { businessName, businessNameEn, description, categories, address, region, regionEn, targetAudience, mainStrengths, nearbyAttractions, bookingUrl, videoDuration, seasonTheme, priceRange, language } = req.body; if (!businessName) { return res.status(400).json({ error: '비즈니스 이름이 필요합니다.' }); } const seoData = await generateYouTubeSEO({ businessName, businessNameEn: businessNameEn || businessName, description: description || '', categories: categories || [], address: address || '', region: region || '', regionEn: regionEn || '', targetAudience: targetAudience || '', mainStrengths: mainStrengths || [], nearbyAttractions: nearbyAttractions || [], bookingUrl: bookingUrl || '', videoDuration: videoDuration || 60, seasonTheme: seasonTheme || '', priceRange: priceRange || '', language: language || 'KO' }); res.json(seoData); } catch (error) { console.error('[YouTube SEO API] 오류:', error.message); res.status(500).json({ error: 'SEO 메타데이터 생성 실패', details: error.message }); } }); /** * YouTube 영상 업로드 (SEO 포함) * POST /api/youtube/upload */ app.post('/api/youtube/upload', authenticateToken, async (req, res) => { try { const { videoPath, seoData, businessName, addToPlaylist, privacyStatus } = req.body; if (!videoPath) { return res.status(400).json({ error: '비디오 경로가 필요합니다.' }); } // 전체 경로 생성 const fullVideoPath = videoPath.startsWith('/') ? path.join(__dirname, '..', videoPath) : path.join(__dirname, videoPath); if (!fs.existsSync(fullVideoPath)) { return res.status(404).json({ error: '비디오 파일을 찾을 수 없습니다.', path: fullVideoPath }); } let playlistId = null; // 비즈니스명으로 플레이리스트 자동 생성/연결 if (addToPlaylist && businessName) { const playlistResult = await getOrCreatePlaylistByBusiness(businessName); playlistId = playlistResult.playlistId; console.log(`[YouTube] 플레이리스트 연결: ${playlistResult.title} (신규: ${playlistResult.isNew})`); } const result = await uploadVideo( fullVideoPath, seoData || { title: `${businessName || 'CastAD'} 홍보영상` }, playlistId, privacyStatus || 'public' ); res.json({ success: true, videoId: result.videoId, url: result.url, youtubeUrl: result.url, // 호환성을 위해 추가 playlistId: playlistId }); } catch (error) { console.error('[YouTube Upload API] 오류:', error.message); res.status(500).json({ error: 'YouTube 업로드 실패', details: error.message }); } }); /** * 플레이리스트 목록 조회 * GET /api/youtube/playlists */ app.get('/api/youtube/playlists', authenticateToken, async (req, res) => { try { const playlists = await getPlaylists(); res.json(playlists); } catch (error) { console.error('[YouTube Playlists API] 오류:', error.message); res.status(500).json({ error: '플레이리스트 조회 실패', details: error.message }); } }); /** * 플레이리스트 생성 * POST /api/youtube/playlists */ app.post('/api/youtube/playlists', authenticateToken, async (req, res) => { try { const { title, description, privacyStatus } = req.body; if (!title) { return res.status(400).json({ error: '플레이리스트 제목이 필요합니다.' }); } const result = await createPlaylist(title, description || '', privacyStatus || 'public'); res.json(result); } catch (error) { console.error('[YouTube Create Playlist API] 오류:', error.message); res.status(500).json({ error: '플레이리스트 생성 실패', details: error.message }); } }); /** * 플레이리스트의 영상 목록 조회 * GET /api/youtube/playlists/:playlistId/videos */ app.get('/api/youtube/playlists/:playlistId/videos', authenticateToken, async (req, res) => { try { const { playlistId } = req.params; const videos = await getPlaylistVideos(playlistId); res.json(videos); } catch (error) { console.error('[YouTube Playlist Videos API] 오류:', error.message); res.status(500).json({ error: '플레이리스트 영상 조회 실패', details: error.message }); } }); /** * 비즈니스명으로 플레이리스트 찾기/생성 * POST /api/youtube/playlists/business */ app.post('/api/youtube/playlists/business', authenticateToken, async (req, res) => { try { const { businessName } = req.body; if (!businessName) { return res.status(400).json({ error: '비즈니스 이름이 필요합니다.' }); } const result = await getOrCreatePlaylistByBusiness(businessName); res.json(result); } catch (error) { console.error('[YouTube Business Playlist API] 오류:', error.message); res.status(500).json({ error: '플레이리스트 처리 실패', details: error.message }); } }); // ==================== END YOUTUBE API ROUTES ==================== // ==================== INSTAGRAM API ROUTES ==================== /** * Instagram 자동 업로드 기능 API * * 주요 엔드포인트: * - POST /api/instagram/connect - 계정 연결 * - POST /api/instagram/disconnect - 계정 연결 해제 * - GET /api/instagram/status - 연결 상태 조회 * - PUT /api/instagram/settings - 설정 업데이트 * - POST /api/instagram/upload - 영상 업로드 * - GET /api/instagram/history - 업로드 히스토리 * - GET /api/instagram/health - 서비스 상태 확인 */ const instagramService = require('./instagramService'); /** * Instagram 서비스 상태 확인 * GET /api/instagram/health */ app.get('/api/instagram/health', async (req, res) => { try { const isHealthy = await instagramService.checkServiceHealth(); res.json({ service: 'instagram-upload', status: isHealthy ? 'ok' : 'unavailable', message: isHealthy ? 'Instagram 서비스가 정상 작동 중입니다.' : 'Instagram 서비스에 연결할 수 없습니다. Python 서비스가 실행 중인지 확인하세요.' }); } catch (error) { res.status(500).json({ service: 'instagram-upload', status: 'error', message: error.message }); } }); /** * Instagram 계정 연결 * POST /api/instagram/connect * * Body: { username, password, verification_code? } */ app.post('/api/instagram/connect', authenticateToken, async (req, res) => { try { const { username, password, verification_code } = req.body; const userId = req.user.id; if (!username || !password) { return res.status(400).json({ success: false, error: 'Instagram 아이디와 비밀번호를 입력해주세요.' }); } const result = await instagramService.connectAccount( userId, username, password, verification_code ); if (result.success) { res.json(result); } else { res.status(result.requires_2fa ? 200 : 401).json(result); } } catch (error) { console.error('[Instagram Connect API] 오류:', error.message); res.status(500).json({ success: false, error: 'Instagram 연결 중 오류가 발생했습니다.', details: error.message }); } }); /** * Instagram 계정 연결 해제 * POST /api/instagram/disconnect */ app.post('/api/instagram/disconnect', authenticateToken, async (req, res) => { try { const userId = req.user.id; const result = await instagramService.disconnectAccount(userId); res.json(result); } catch (error) { console.error('[Instagram Disconnect API] 오류:', error.message); res.status(500).json({ success: false, error: '연결 해제 중 오류가 발생했습니다.', details: error.message }); } }); /** * Instagram 연결 상태 조회 * GET /api/instagram/status */ app.get('/api/instagram/status', authenticateToken, async (req, res) => { try { const userId = req.user.id; const status = await instagramService.getConnectionStatus(userId); res.json(status); } catch (error) { console.error('[Instagram Status API] 오류:', error.message); res.status(500).json({ connected: false, error: error.message }); } }); /** * Instagram 설정 업데이트 * PUT /api/instagram/settings * * Body: { * auto_upload: boolean, * upload_as_reel: boolean, * default_caption_template: string, * default_hashtags: string, * max_uploads_per_week: number, * notify_on_upload: boolean * } */ app.put('/api/instagram/settings', authenticateToken, async (req, res) => { try { const userId = req.user.id; const settings = req.body; const result = await instagramService.updateSettings(userId, settings); res.json(result); } catch (error) { console.error('[Instagram Settings API] 오류:', error.message); res.status(500).json({ success: false, error: '설정 저장 중 오류가 발생했습니다.', details: error.message }); } }); /** * Instagram에 영상 업로드 * POST /api/instagram/upload * * Body: { * history_id: number, * caption: string, * hashtags?: string, * thumbnail_path?: string, * force_upload?: boolean (주간 제한 무시) * } */ app.post('/api/instagram/upload', authenticateToken, async (req, res) => { try { const userId = req.user.id; const { history_id, caption, hashtags, thumbnail_path, force_upload } = req.body; if (!history_id) { return res.status(400).json({ success: false, error: '업로드할 영상 ID가 필요합니다.' }); } // 영상 정보 조회 const video = await new Promise((resolve, reject) => { db.get( 'SELECT final_video_path FROM history WHERE id = ? AND user_id = ?', [history_id, userId], (err, row) => { if (err) reject(err); else resolve(row); } ); }); if (!video || !video.final_video_path) { return res.status(404).json({ success: false, error: '업로드할 영상을 찾을 수 없습니다.' }); } // 영상 경로 (상대경로를 절대경로로) let videoPath = video.final_video_path; if (videoPath.startsWith('/downloads/')) { videoPath = path.join(__dirname, videoPath); } // 캡션 조합 const fullCaption = hashtags ? `${caption}\n\n${hashtags}` : caption; const result = await instagramService.uploadVideo( userId, history_id, videoPath, fullCaption, { thumbnailPath: thumbnail_path, forceUpload: force_upload } ); if (result.success) { res.json(result); } else { res.status(400).json(result); } } catch (error) { console.error('[Instagram Upload API] 오류:', error.message); res.status(500).json({ success: false, error: '업로드 중 오류가 발생했습니다.', details: error.message }); } }); /** * 주간 업로드 통계 조회 * GET /api/instagram/weekly-stats */ app.get('/api/instagram/weekly-stats', authenticateToken, async (req, res) => { try { const userId = req.user.id; const stats = await instagramService.getWeeklyUploadCount(userId); res.json(stats); } catch (error) { console.error('[Instagram Weekly Stats API] 오류:', error.message); res.status(500).json({ error: error.message }); } }); /** * Instagram 업로드 히스토리 조회 * GET /api/instagram/history * * Query: { limit?: number, offset?: number } */ app.get('/api/instagram/history', authenticateToken, async (req, res) => { try { const userId = req.user.id; const limit = parseInt(req.query.limit) || 20; const offset = parseInt(req.query.offset) || 0; const history = await instagramService.getUploadHistory(userId, limit, offset); res.json(history); } catch (error) { console.error('[Instagram History API] 오류:', error.message); res.status(500).json({ error: error.message }); } }); // ==================== END INSTAGRAM API ROUTES ==================== // ==================== ENHANCED ADMIN API ROUTES ==================== /** * Admin 대시보드 통계 * GET /api/admin/stats */ app.get('/api/admin/stats', authenticateToken, requireAdmin, async (req, res) => { try { const stats = {}; // 사용자 통계 const userStats = await new Promise((resolve, reject) => { db.get(` SELECT COUNT(*) as total, SUM(CASE WHEN approved = 1 THEN 1 ELSE 0 END) as active, SUM(CASE WHEN approved = 0 THEN 1 ELSE 0 END) as pending, SUM(CASE WHEN role = 'admin' THEN 1 ELSE 0 END) as admins, SUM(CASE WHEN email_verified = 1 THEN 1 ELSE 0 END) as verified, SUM(CASE WHEN DATE(createdAt) = DATE('now') THEN 1 ELSE 0 END) as today, SUM(CASE WHEN DATE(createdAt) >= DATE('now', '-7 days') THEN 1 ELSE 0 END) as thisWeek FROM users `, (err, row) => err ? reject(err) : resolve(row)); }); stats.users = userStats; // 콘텐츠 통계 const contentStats = await new Promise((resolve, reject) => { db.get(` SELECT COUNT(*) as total, SUM(CASE WHEN render_status = 'completed' THEN 1 ELSE 0 END) as completed, SUM(CASE WHEN render_status = 'failed' THEN 1 ELSE 0 END) as failed, SUM(CASE WHEN render_status = 'pending' OR render_status IS NULL THEN 1 ELSE 0 END) as pending, SUM(CASE WHEN DATE(createdAt) = DATE('now') THEN 1 ELSE 0 END) as today, SUM(CASE WHEN DATE(createdAt) >= DATE('now', '-7 days') THEN 1 ELSE 0 END) as thisWeek FROM history `, (err, row) => err ? reject(err) : resolve(row)); }); stats.content = contentStats; // YouTube 업로드 통계 const youtubeStats = await new Promise((resolve, reject) => { db.get(` SELECT COUNT(*) as total, SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed, SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed, SUM(CASE WHEN DATE(uploaded_at) = DATE('now') THEN 1 ELSE 0 END) as today FROM upload_history `, (err, row) => err ? reject(err) : resolve(row)); }); stats.youtube = youtubeStats; // Instagram 업로드 통계 const instagramStats = await new Promise((resolve, reject) => { db.get(` SELECT COUNT(*) as total, SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed, SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed, SUM(CASE WHEN DATE(createdAt) = DATE('now') THEN 1 ELSE 0 END) as today FROM instagram_upload_history `, (err, row) => err ? reject(err) : resolve(row)); }); stats.instagram = instagramStats; // 펜션 프로필 통계 const pensionStats = await new Promise((resolve, reject) => { db.get(` SELECT COUNT(*) as total FROM pension_profiles `, (err, row) => err ? reject(err) : resolve(row)); }); stats.pensions = pensionStats; // 최근 7일간 일별 생성 추이 const dailyTrend = await new Promise((resolve, reject) => { db.all(` SELECT DATE(createdAt) as date, COUNT(*) as count FROM history WHERE DATE(createdAt) >= DATE('now', '-7 days') GROUP BY DATE(createdAt) ORDER BY date ASC `, (err, rows) => err ? reject(err) : resolve(rows)); }); stats.dailyTrend = dailyTrend; res.json(stats); } catch (error) { console.error('[Admin Stats API] 오류:', error); res.status(500).json({ error: error.message }); } }); /** * 시스템 헬스 체크 * GET /api/admin/system-health */ app.get('/api/admin/system-health', authenticateToken, requireAdmin, async (req, res) => { try { const health = { server: { status: 'healthy', uptime: process.uptime(), memory: process.memoryUsage(), nodeVersion: process.version, platform: process.platform }, database: { status: 'unknown' }, services: { instagram: { status: 'unknown' }, youtube: { status: 'unknown' } }, storage: { downloads: { status: 'unknown' }, temp: { status: 'unknown' } } }; // DB 체크 try { await new Promise((resolve, reject) => { db.get("SELECT 1", (err) => err ? reject(err) : resolve()); }); health.database.status = 'healthy'; } catch (e) { health.database.status = 'error'; health.database.error = e.message; } // Instagram 서비스 체크 try { const instagramHealthResponse = await axios.get(`${process.env.INSTAGRAM_SERVICE_URL || 'http://localhost:5001'}/health`, { timeout: 3000 }); health.services.instagram.status = instagramHealthResponse.data.status === 'ok' ? 'healthy' : 'degraded'; } catch (e) { health.services.instagram.status = 'offline'; } // YouTube 연결 수 try { const ytConnections = await new Promise((resolve, reject) => { db.get("SELECT COUNT(*) as count FROM youtube_connections WHERE access_token IS NOT NULL", (err, row) => { err ? reject(err) : resolve(row?.count || 0); }); }); health.services.youtube.status = 'healthy'; health.services.youtube.connections = ytConnections; } catch (e) { health.services.youtube.status = 'error'; } // 스토리지 체크 try { const downloadsPath = path.join(__dirname, 'downloads'); const tempPath = path.join(__dirname, 'temp'); if (fs.existsSync(downloadsPath)) { const downloadFiles = fs.readdirSync(downloadsPath); health.storage.downloads.status = 'healthy'; health.storage.downloads.itemCount = downloadFiles.length; } if (fs.existsSync(tempPath)) { const tempFiles = fs.readdirSync(tempPath); health.storage.temp.status = 'healthy'; health.storage.temp.itemCount = tempFiles.length; } } catch (e) { health.storage.downloads.status = 'error'; health.storage.temp.status = 'error'; } res.json(health); } catch (error) { console.error('[System Health API] 오류:', error); res.status(500).json({ error: error.message }); } }); /** * 활동 로그 테이블 생성 (서버 시작 시) */ db.run(`CREATE TABLE IF NOT EXISTS activity_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, username TEXT, action TEXT NOT NULL, target_type TEXT, target_id INTEGER, details TEXT, ip_address TEXT, user_agent TEXT, createdAt DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL )`); /** * 활동 로그 기록 함수 */ const logActivity = (userId, username, action, targetType = null, targetId = null, details = null, req = null) => { const ip = req ? (req.headers['x-forwarded-for'] || req.connection.remoteAddress) : null; const userAgent = req ? req.headers['user-agent'] : null; db.run(` INSERT INTO activity_logs (user_id, username, action, target_type, target_id, details, ip_address, user_agent) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `, [userId, username, action, targetType, targetId, details ? JSON.stringify(details) : null, ip, userAgent]); }; /** * 활동 로그 조회 * GET /api/admin/logs */ app.get('/api/admin/logs', authenticateToken, requireAdmin, async (req, res) => { try { const { limit = 100, offset = 0, action, userId, startDate, endDate } = req.query; let query = `SELECT * FROM activity_logs WHERE 1=1`; const params = []; if (action) { query += ` AND action LIKE ?`; params.push(`%${action}%`); } if (userId) { query += ` AND user_id = ?`; params.push(userId); } if (startDate) { query += ` AND DATE(createdAt) >= DATE(?)`; params.push(startDate); } if (endDate) { query += ` AND DATE(createdAt) <= DATE(?)`; params.push(endDate); } query += ` ORDER BY createdAt DESC LIMIT ? OFFSET ?`; params.push(parseInt(limit), parseInt(offset)); const logs = await new Promise((resolve, reject) => { db.all(query, params, (err, rows) => err ? reject(err) : resolve(rows)); }); // 총 개수 let countQuery = `SELECT COUNT(*) as total FROM activity_logs WHERE 1=1`; const countParams = []; if (action) { countQuery += ` AND action LIKE ?`; countParams.push(`%${action}%`); } if (userId) { countQuery += ` AND user_id = ?`; countParams.push(userId); } const totalCount = await new Promise((resolve, reject) => { db.get(countQuery, countParams, (err, row) => err ? reject(err) : resolve(row?.total || 0)); }); res.json({ logs, total: totalCount }); } catch (error) { console.error('[Activity Logs API] 오류:', error); res.status(500).json({ error: error.message }); } }); /** * 사용자 역할 변경 * PUT /api/admin/users/:id/role */ app.put('/api/admin/users/:id/role', authenticateToken, requireAdmin, async (req, res) => { try { const { id } = req.params; const { role } = req.body; if (!['user', 'admin'].includes(role)) { return res.status(400).json({ error: '유효하지 않은 역할입니다.' }); } // 자기 자신의 역할은 변경 불가 if (parseInt(id) === req.user.id) { return res.status(400).json({ error: '자신의 역할은 변경할 수 없습니다.' }); } await new Promise((resolve, reject) => { db.run("UPDATE users SET role = ? WHERE id = ?", [role, id], function(err) { if (err) reject(err); else resolve(this.changes); }); }); // 로그 기록 logActivity(req.user.id, req.user.username, 'USER_ROLE_CHANGE', 'user', parseInt(id), { newRole: role }, req); res.json({ success: true, message: '역할이 변경되었습니다.' }); } catch (error) { console.error('[User Role Change API] 오류:', error); res.status(500).json({ error: error.message }); } }); /** * 사용자 플랜/크레딧 변경 (어드민 전용) * PUT /api/admin/users/:id/plan */ app.put('/api/admin/users/:id/plan', authenticateToken, requireAdmin, async (req, res) => { try { const { id } = req.params; const { plan_type, credits, max_pensions, monthly_credits } = req.body; // 유효한 플랜 타입 확인 const validPlans = ['free', 'basic', 'pro', 'business']; if (plan_type && !validPlans.includes(plan_type)) { return res.status(400).json({ error: '유효하지 않은 플랜입니다.' }); } // 플랜별 기본값 설정 const planDefaults = { free: { max_pensions: 1, monthly_credits: 10 }, basic: { max_pensions: 1, monthly_credits: 15 }, pro: { max_pensions: 5, monthly_credits: 75 }, business: { max_pensions: 999, monthly_credits: 999 } }; // 업데이트할 필드 구성 const updates = []; const values = []; if (plan_type) { updates.push('plan_type = ?'); values.push(plan_type); // 플랜 변경 시 기본값 적용 (별도 값이 없으면) if (max_pensions === undefined) { updates.push('max_pensions = ?'); values.push(planDefaults[plan_type].max_pensions); } if (monthly_credits === undefined) { updates.push('monthly_credits = ?'); values.push(planDefaults[plan_type].monthly_credits); } } if (credits !== undefined) { updates.push('credits = ?'); values.push(credits); } if (max_pensions !== undefined) { updates.push('max_pensions = ?'); values.push(max_pensions); } if (monthly_credits !== undefined) { updates.push('monthly_credits = ?'); values.push(monthly_credits); } if (updates.length === 0) { return res.status(400).json({ error: '변경할 내용이 없습니다.' }); } values.push(id); await new Promise((resolve, reject) => { db.run(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, values, function(err) { if (err) reject(err); else resolve(this.changes); }); }); // 변경된 사용자 정보 조회 const updatedUser = await new Promise((resolve, reject) => { db.get("SELECT id, username, name, plan_type, credits, max_pensions, monthly_credits FROM users WHERE id = ?", [id], (err, row) => err ? reject(err) : resolve(row)); }); // 로그 기록 logActivity(req.user.id, req.user.username, 'USER_PLAN_CHANGE', 'user', parseInt(id), { plan_type, credits, max_pensions, monthly_credits }, req); res.json({ success: true, message: '플랜 정보가 변경되었습니다.', user: updatedUser }); } catch (error) { console.error('[User Plan Change API] 오류:', error); res.status(500).json({ error: error.message }); } }); /** * 사용자 상세 정보 (펜션 프로필, 업로드 내역 포함) * GET /api/admin/users/:id/detail */ app.get('/api/admin/users/:id/detail', authenticateToken, requireAdmin, async (req, res) => { try { const { id } = req.params; // 사용자 기본 정보 const user = await new Promise((resolve, reject) => { db.get("SELECT id, username, email, name, phone, role, approved, email_verified, plan_type, credits, max_pensions, monthly_credits, createdAt FROM users WHERE id = ?", [id], (err, row) => err ? reject(err) : resolve(row)); }); if (!user) { return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' }); } // 펜션 프로필 const pensions = await new Promise((resolve, reject) => { db.all("SELECT * FROM pension_profiles WHERE user_id = ?", [id], (err, rows) => err ? reject(err) : resolve(rows)); }); // 콘텐츠 히스토리 (최근 10개) const history = await new Promise((resolve, reject) => { db.all("SELECT id, business_name, createdAt, render_status FROM history WHERE user_id = ? ORDER BY createdAt DESC LIMIT 10", [id], (err, rows) => err ? reject(err) : resolve(rows)); }); // YouTube 연결 상태 const youtubeConnection = await new Promise((resolve, reject) => { db.get("SELECT youtube_channel_title, google_email, connected_at FROM youtube_connections WHERE user_id = ?", [id], (err, row) => err ? reject(err) : resolve(row)); }); // Instagram 연결 상태 const instagramConnection = await new Promise((resolve, reject) => { db.get("SELECT instagram_username, is_active, connected_at FROM instagram_connections WHERE user_id = ?", [id], (err, row) => err ? reject(err) : resolve(row)); }); res.json({ user, pensions, history, youtubeConnection, instagramConnection }); } catch (error) { console.error('[User Detail API] 오류:', error); res.status(500).json({ error: error.message }); } }); /** * 전체 업로드 통계 (YouTube + Instagram) * GET /api/admin/uploads */ app.get('/api/admin/uploads', authenticateToken, requireAdmin, async (req, res) => { try { const { limit = 50, offset = 0, platform, status } = req.query; // YouTube 업로드 let ytQuery = ` SELECT uh.*, u.username, u.name as user_name, 'youtube' as platform FROM upload_history uh LEFT JOIN users u ON uh.user_id = u.id WHERE 1=1 `; const ytParams = []; if (status) { ytQuery += ` AND uh.status = ?`; ytParams.push(status); } // Instagram 업로드 let igQuery = ` SELECT iuh.*, u.username, u.name as user_name, 'instagram' as platform FROM instagram_upload_history iuh LEFT JOIN users u ON iuh.user_id = u.id WHERE 1=1 `; const igParams = []; if (status) { igQuery += ` AND iuh.status = ?`; igParams.push(status); } let uploads = []; if (!platform || platform === 'youtube') { const ytUploads = await new Promise((resolve, reject) => { db.all(ytQuery, ytParams, (err, rows) => err ? reject(err) : resolve(rows || [])); }); uploads = uploads.concat(ytUploads); } if (!platform || platform === 'instagram') { const igUploads = await new Promise((resolve, reject) => { db.all(igQuery, igParams, (err, rows) => err ? reject(err) : resolve(rows || [])); }); uploads = uploads.concat(igUploads); } // 날짜순 정렬 uploads.sort((a, b) => new Date(b.uploaded_at || b.createdAt) - new Date(a.uploaded_at || a.createdAt)); // 페이지네이션 const total = uploads.length; uploads = uploads.slice(parseInt(offset), parseInt(offset) + parseInt(limit)); res.json({ uploads, total }); } catch (error) { console.error('[Admin Uploads API] 오류:', error); res.status(500).json({ error: error.message }); } }); /** * 시스템 설정 조회/저장 * GET/PUT /api/admin/settings */ db.run(`CREATE TABLE IF NOT EXISTS system_settings ( key TEXT PRIMARY KEY, value TEXT, updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP )`); app.get('/api/admin/settings', authenticateToken, requireAdmin, async (req, res) => { try { const settings = await new Promise((resolve, reject) => { db.all("SELECT key, value FROM system_settings", (err, rows) => { if (err) reject(err); else { const obj = {}; (rows || []).forEach(row => { try { obj[row.key] = JSON.parse(row.value); } catch { obj[row.key] = row.value; } }); resolve(obj); } }); }); res.json(settings); } catch (error) { res.status(500).json({ error: error.message }); } }); app.put('/api/admin/settings', authenticateToken, requireAdmin, async (req, res) => { try { const settings = req.body; for (const [key, value] of Object.entries(settings)) { const valueStr = typeof value === 'object' ? JSON.stringify(value) : String(value); await new Promise((resolve, reject) => { db.run(` INSERT OR REPLACE INTO system_settings (key, value, updatedAt) VALUES (?, ?, CURRENT_TIMESTAMP) `, [key, valueStr], (err) => err ? reject(err) : resolve()); }); } logActivity(req.user.id, req.user.username, 'SETTINGS_UPDATE', 'system', null, settings, req); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); // ==================== END ENHANCED ADMIN API ROUTES ==================== // ==================== CREDIT MANAGEMENT API ROUTES ==================== // 사용자 크레딧 조회 app.get('/api/credits', authenticateToken, async (req, res) => { try { const user = await new Promise((resolve, reject) => { db.get("SELECT credits FROM users WHERE id = ?", [req.user.id], (err, row) => { if (err) reject(err); else resolve(row); }); }); res.json({ credits: user?.credits ?? 10 }); } catch (error) { res.status(500).json({ error: error.message }); } }); // 크레딧 사용 내역 조회 app.get('/api/credits/history', authenticateToken, async (req, res) => { try { const history = await new Promise((resolve, reject) => { db.all(` SELECT * FROM credit_history WHERE user_id = ? ORDER BY createdAt DESC LIMIT 50 `, [req.user.id], (err, rows) => { if (err) reject(err); else resolve(rows || []); }); }); res.json(history); } catch (error) { res.status(500).json({ error: error.message }); } }); // 크레딧 요청 생성 (사용자) app.post('/api/credits/request', authenticateToken, async (req, res) => { try { const { reason } = req.body; const requestedCredits = 10; // 기본 10개 // 이미 대기 중인 요청이 있는지 확인 const pendingRequest = await new Promise((resolve, reject) => { db.get(` SELECT * FROM credit_requests WHERE user_id = ? AND status = 'pending' `, [req.user.id], (err, row) => { if (err) reject(err); else resolve(row); }); }); if (pendingRequest) { return res.status(400).json({ error: '이미 대기 중인 크레딧 요청이 있습니다.' }); } // 새 요청 생성 const result = await new Promise((resolve, reject) => { db.run(` INSERT INTO credit_requests (user_id, requested_credits, reason, status) VALUES (?, ?, ?, 'pending') `, [req.user.id, requestedCredits, reason || '추가 크레딧 요청'], function(err) { if (err) reject(err); else resolve({ id: this.lastID }); }); }); // 활동 로그 logActivity(req.user.id, req.user.username, 'CREDIT_REQUEST', 'credit_request', result.id, { requestedCredits, reason }, req); res.json({ success: true, requestId: result.id, message: '크레딧 요청이 접수되었습니다. 관리자 승인을 기다려주세요.' }); } catch (error) { res.status(500).json({ error: error.message }); } }); // 내 크레딧 요청 목록 조회 app.get('/api/credits/requests', authenticateToken, async (req, res) => { try { const requests = await new Promise((resolve, reject) => { db.all(` SELECT cr.*, u.username as processed_by_username FROM credit_requests cr LEFT JOIN users u ON cr.processed_by = u.id WHERE cr.user_id = ? ORDER BY cr.createdAt DESC `, [req.user.id], (err, rows) => { if (err) reject(err); else resolve(rows || []); }); }); res.json(requests); } catch (error) { res.status(500).json({ error: error.message }); } }); // ===== ADMIN CREDIT MANAGEMENT ===== // 관리자: 모든 크레딧 요청 목록 app.get('/api/admin/credits/requests', authenticateToken, requireAdmin, async (req, res) => { try { const { status, page = 1, limit = 20 } = req.query; const offset = (page - 1) * limit; let whereClause = ''; const params = []; if (status && status !== 'all') { whereClause = 'WHERE cr.status = ?'; params.push(status); } const requests = await new Promise((resolve, reject) => { db.all(` SELECT cr.*, u.username, u.name, u.email, u.credits as current_credits, p.username as processed_by_username FROM credit_requests cr JOIN users u ON cr.user_id = u.id LEFT JOIN users p ON cr.processed_by = p.id ${whereClause} ORDER BY CASE WHEN cr.status = 'pending' THEN 0 ELSE 1 END, cr.createdAt DESC LIMIT ? OFFSET ? `, [...params, limit, offset], (err, rows) => { if (err) reject(err); else resolve(rows || []); }); }); const total = await new Promise((resolve, reject) => { db.get(` SELECT COUNT(*) as count FROM credit_requests cr ${whereClause} `, params, (err, row) => { if (err) reject(err); else resolve(row?.count || 0); }); }); const pendingCount = await new Promise((resolve, reject) => { db.get("SELECT COUNT(*) as count FROM credit_requests WHERE status = 'pending'", (err, row) => { if (err) reject(err); else resolve(row?.count || 0); }); }); res.json({ requests, total, pendingCount }); } catch (error) { res.status(500).json({ error: error.message }); } }); // 관리자: 크레딧 요청 승인/거절 app.post('/api/admin/credits/requests/:id/process', authenticateToken, requireAdmin, async (req, res) => { try { const { id } = req.params; const { action, adminNote } = req.body; // action: 'approve' or 'reject' if (!['approve', 'reject'].includes(action)) { return res.status(400).json({ error: '유효하지 않은 액션입니다.' }); } // 요청 정보 조회 const request = await new Promise((resolve, reject) => { db.get(` SELECT cr.*, u.credits as current_credits, u.username FROM credit_requests cr JOIN users u ON cr.user_id = u.id WHERE cr.id = ? `, [id], (err, row) => { if (err) reject(err); else resolve(row); }); }); if (!request) { return res.status(404).json({ error: '요청을 찾을 수 없습니다.' }); } if (request.status !== 'pending') { return res.status(400).json({ error: '이미 처리된 요청입니다.' }); } const newStatus = action === 'approve' ? 'approved' : 'rejected'; // 요청 상태 업데이트 await new Promise((resolve, reject) => { db.run(` UPDATE credit_requests SET status = ?, admin_note = ?, processed_by = ?, processed_at = CURRENT_TIMESTAMP WHERE id = ? `, [newStatus, adminNote, req.user.id, id], (err) => { if (err) reject(err); else resolve(); }); }); if (action === 'approve') { // 크레딧 추가 const newBalance = (request.current_credits || 0) + request.requested_credits; await new Promise((resolve, reject) => { db.run("UPDATE users SET credits = ? WHERE id = ?", [newBalance, request.user_id], (err) => { if (err) reject(err); else resolve(); }); }); // 크레딧 히스토리 기록 await new Promise((resolve, reject) => { db.run(` INSERT INTO credit_history (user_id, amount, type, description, balance_after, related_request_id) VALUES (?, ?, 'request_approved', ?, ?, ?) `, [request.user_id, request.requested_credits, '크레딧 요청 승인', newBalance, id], (err) => { if (err) reject(err); else resolve(); }); }); } // 활동 로그 logActivity(req.user.id, req.user.username, action === 'approve' ? 'CREDIT_APPROVE' : 'CREDIT_REJECT', 'credit_request', id, { requestedCredits: request.requested_credits, username: request.username, adminNote }, req); res.json({ success: true, message: action === 'approve' ? '크레딧이 충전되었습니다.' : '요청이 거절되었습니다.' }); } catch (error) { res.status(500).json({ error: error.message }); } }); // 관리자: 사용자 크레딧 직접 조정 app.post('/api/admin/users/:id/credits', authenticateToken, requireAdmin, async (req, res) => { try { const { id } = req.params; const { amount, reason } = req.body; // amount: positive to add, negative to deduct if (typeof amount !== 'number' || amount === 0) { return res.status(400).json({ error: '유효한 크레딧 수량을 입력해주세요.' }); } // 사용자 조회 const user = await new Promise((resolve, reject) => { db.get("SELECT id, username, credits FROM users WHERE id = ?", [id], (err, row) => { if (err) reject(err); else resolve(row); }); }); if (!user) { return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' }); } const newBalance = Math.max(0, (user.credits || 0) + amount); // 크레딧 업데이트 await new Promise((resolve, reject) => { db.run("UPDATE users SET credits = ? WHERE id = ?", [newBalance, id], (err) => { if (err) reject(err); else resolve(); }); }); // 크레딧 히스토리 기록 await new Promise((resolve, reject) => { db.run(` INSERT INTO credit_history (user_id, amount, type, description, balance_after) VALUES (?, ?, ?, ?, ?) `, [id, amount, amount > 0 ? 'admin_add' : 'admin_deduct', reason || '관리자 조정', newBalance], (err) => { if (err) reject(err); else resolve(); }); }); // 활동 로그 logActivity(req.user.id, req.user.username, 'CREDIT_ADJUST', 'user', id, { amount, reason, username: user.username, newBalance }, req); res.json({ success: true, newBalance, message: `${user.username}님의 크레딧이 ${amount > 0 ? amount + '개 추가' : Math.abs(amount) + '개 차감'}되었습니다.` }); } catch (error) { res.status(500).json({ error: error.message }); } }); // 관리자: 크레딧 통계 app.get('/api/admin/credits/stats', authenticateToken, requireAdmin, async (req, res) => { try { const stats = await new Promise((resolve, reject) => { db.get(` SELECT SUM(CASE WHEN credits > 0 THEN credits ELSE 0 END) as total_credits, AVG(credits) as avg_credits, COUNT(CASE WHEN credits = 0 THEN 1 END) as zero_credit_users, COUNT(CASE WHEN credits > 0 AND credits <= 3 THEN 1 END) as low_credit_users FROM users WHERE role != 'admin' `, (err, row) => { if (err) reject(err); else resolve(row); }); }); const pendingRequests = await new Promise((resolve, reject) => { db.get("SELECT COUNT(*) as count FROM credit_requests WHERE status = 'pending'", (err, row) => { if (err) reject(err); else resolve(row?.count || 0); }); }); const recentActivity = await new Promise((resolve, reject) => { db.all(` SELECT type, COUNT(*) as count FROM credit_history WHERE createdAt > datetime('now', '-7 days') GROUP BY type `, (err, rows) => { if (err) reject(err); else resolve(rows || []); }); }); res.json({ ...stats, pendingRequests, recentActivity }); } catch (error) { res.status(500).json({ error: error.message }); } }); // ==================== END CREDIT MANAGEMENT API ROUTES ==================== // ==================== PENSION YOUTUBE & ANALYTICS ROUTES ==================== /** * 사용자 플랜 정보 조회 * GET /api/user/plan */ app.get('/api/user/plan', authenticateToken, (req, res) => { const userId = req.user.id; db.get(` SELECT plan_type, max_pensions, monthly_credits, credits, subscription_started_at, subscription_expires_at FROM users WHERE id = ? `, [userId], (err, row) => { if (err) return res.status(500).json({ error: err.message }); // 현재 펜션 수 조회 db.get(`SELECT COUNT(*) as pension_count FROM pension_profiles WHERE user_id = ?`, [userId], (err, countRow) => { if (err) return res.status(500).json({ error: err.message }); res.json({ plan_type: row?.plan_type || 'free', max_pensions: row?.max_pensions || 1, monthly_credits: row?.monthly_credits || 3, current_credits: row?.credits || 0, current_pensions: countRow?.pension_count || 0, subscription_started_at: row?.subscription_started_at, subscription_expires_at: row?.subscription_expires_at }); }); }); }); /** * 펜션에 YouTube 플레이리스트 연결 * POST /api/profile/pension/:id/youtube-playlist */ app.post('/api/profile/pension/:id/youtube-playlist', authenticateToken, async (req, res) => { const userId = req.user.id; const pensionId = req.params.id; const { playlist_id, playlist_title, create_new } = req.body; try { // 펜션 소유권 확인 const pension = await new Promise((resolve, reject) => { db.get(`SELECT * FROM pension_profiles WHERE id = ? AND user_id = ?`, [pensionId, userId], (err, row) => { if (err) reject(err); else resolve(row); }); }); if (!pension) { return res.status(404).json({ error: '펜션을 찾을 수 없습니다.' }); } // YouTube 연결 확인 const ytConnection = await getConnectionStatus(userId); if (!ytConnection) { return res.status(400).json({ error: 'YouTube 계정이 연결되어 있지 않습니다.' }); } let finalPlaylistId = playlist_id; let finalPlaylistTitle = playlist_title; // 새 플레이리스트 생성 요청인 경우 if (create_new && pension.brand_name) { const newPlaylist = await createPlaylistForUser(userId, { title: `${pension.brand_name} - 홍보영상`, description: `${pension.brand_name}의 AI 생성 마케팅 영상 컬렉션`, privacyStatus: 'public' }); finalPlaylistId = newPlaylist.id; finalPlaylistTitle = newPlaylist.title; } // 펜션에 플레이리스트 연결 await new Promise((resolve, reject) => { db.run(` UPDATE pension_profiles SET youtube_playlist_id = ?, youtube_playlist_title = ?, updatedAt = datetime('now') WHERE id = ? `, [finalPlaylistId, finalPlaylistTitle, pensionId], function(err) { if (err) reject(err); else resolve(this.changes); }); }); res.json({ success: true, pension_id: pensionId, playlist_id: finalPlaylistId, playlist_title: finalPlaylistTitle }); } catch (error) { console.error('[Pension YouTube] 플레이리스트 연결 오류:', error); res.status(500).json({ error: error.message }); } }); /** * 펜션의 YouTube 플레이리스트 연결 해제 * DELETE /api/profile/pension/:id/youtube-playlist */ app.delete('/api/profile/pension/:id/youtube-playlist', authenticateToken, async (req, res) => { const userId = req.user.id; const pensionId = req.params.id; try { const result = await new Promise((resolve, reject) => { db.run(` UPDATE pension_profiles SET youtube_playlist_id = NULL, youtube_playlist_title = NULL, updatedAt = datetime('now') WHERE id = ? AND user_id = ? `, [pensionId, userId], function(err) { if (err) reject(err); else resolve(this.changes); }); }); if (result === 0) { return res.status(404).json({ error: '펜션을 찾을 수 없습니다.' }); } res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); /** * 펜션별 분석 데이터 조회 * GET /api/profile/pension/:id/analytics */ app.get('/api/profile/pension/:id/analytics', authenticateToken, async (req, res) => { const userId = req.user.id; const pensionId = req.params.id; const { start_date, end_date } = req.query; try { // 펜션 소유권 및 플레이리스트 확인 const pension = await new Promise((resolve, reject) => { db.get(` SELECT id, brand_name, youtube_playlist_id, youtube_playlist_title FROM pension_profiles WHERE id = ? AND user_id = ? `, [pensionId, userId], (err, row) => { if (err) reject(err); else resolve(row); }); }); if (!pension) { return res.status(404).json({ error: '펜션을 찾을 수 없습니다.' }); } if (!pension.youtube_playlist_id) { return res.json({ pension_id: pensionId, brand_name: pension.brand_name, playlist_connected: false, message: 'YouTube 플레이리스트가 연결되어 있지 않습니다.', analytics: null }); } // 캐시된 분석 데이터 조회 const cachedAnalytics = await new Promise((resolve, reject) => { let query = `SELECT * FROM youtube_analytics WHERE pension_id = ?`; let params = [pensionId]; if (start_date && end_date) { query += ` AND date BETWEEN ? AND ?`; params.push(start_date, end_date); } query += ` ORDER BY date DESC LIMIT 30`; db.all(query, params, (err, rows) => { if (err) reject(err); else resolve(rows || []); }); }); // 월간 통계 조회 const monthlyStats = await new Promise((resolve, reject) => { db.all(` SELECT * FROM pension_monthly_stats WHERE pension_id = ? ORDER BY year_month DESC LIMIT 6 `, [pensionId], (err, rows) => { if (err) reject(err); else resolve(rows || []); }); }); // 업로드 히스토리 조회 const uploadHistory = await new Promise((resolve, reject) => { db.all(` SELECT uh.*, h.business_name FROM upload_history uh LEFT JOIN history h ON uh.history_id = h.id WHERE uh.pension_id = ? AND uh.status = 'completed' ORDER BY uh.uploaded_at DESC LIMIT 20 `, [pensionId], (err, rows) => { if (err) reject(err); else resolve(rows || []); }); }); // 요약 통계 계산 const summary = { total_views: cachedAnalytics.reduce((sum, a) => sum + (a.views || 0), 0), total_watch_time: cachedAnalytics.reduce((sum, a) => sum + (a.estimated_minutes_watched || 0), 0), total_likes: cachedAnalytics.reduce((sum, a) => sum + (a.likes || 0), 0), total_comments: cachedAnalytics.reduce((sum, a) => sum + (a.comments || 0), 0), total_videos: uploadHistory.length, avg_views_per_video: uploadHistory.length > 0 ? Math.round(cachedAnalytics.reduce((sum, a) => sum + (a.views || 0), 0) / uploadHistory.length) : 0 }; res.json({ pension_id: pensionId, brand_name: pension.brand_name, playlist_connected: true, playlist_id: pension.youtube_playlist_id, playlist_title: pension.youtube_playlist_title, summary, daily_analytics: cachedAnalytics, monthly_stats: monthlyStats, recent_uploads: uploadHistory }); } catch (error) { console.error('[Pension Analytics] 조회 오류:', error); res.status(500).json({ error: error.message }); } }); /** * 모든 펜션의 분석 요약 (대시보드용) * GET /api/profile/pensions/analytics-summary */ app.get('/api/profile/pensions/analytics-summary', authenticateToken, async (req, res) => { const userId = req.user.id; try { // 모든 펜션 조회 const pensions = await new Promise((resolve, reject) => { db.all(` SELECT id, brand_name, youtube_playlist_id, youtube_playlist_title FROM pension_profiles WHERE user_id = ? ORDER BY is_default DESC, createdAt ASC `, [userId], (err, rows) => { if (err) reject(err); else resolve(rows || []); }); }); // 각 펜션별 요약 통계 조회 const summaries = await Promise.all(pensions.map(async (pension) => { // 최근 30일 분석 데이터 const recentAnalytics = await new Promise((resolve, reject) => { db.get(` SELECT SUM(views) as total_views, SUM(estimated_minutes_watched) as total_watch_time, SUM(likes) as total_likes, SUM(subscribers_gained) as total_subscribers_gained FROM youtube_analytics WHERE pension_id = ? AND date >= date('now', '-30 days') `, [pension.id], (err, row) => { if (err) reject(err); else resolve(row); }); }); // 업로드된 영상 수 const uploadCount = await new Promise((resolve, reject) => { db.get(` SELECT COUNT(*) as count FROM upload_history WHERE pension_id = ? AND status = 'completed' `, [pension.id], (err, row) => { if (err) reject(err); else resolve(row?.count || 0); }); }); return { pension_id: pension.id, brand_name: pension.brand_name, playlist_connected: !!pension.youtube_playlist_id, playlist_title: pension.youtube_playlist_title, last_30_days: { views: recentAnalytics?.total_views || 0, watch_time_minutes: Math.round(recentAnalytics?.total_watch_time || 0), likes: recentAnalytics?.total_likes || 0, subscribers_gained: recentAnalytics?.total_subscribers_gained || 0 }, total_videos: uploadCount }; })); // 전체 요약 const totalSummary = { total_pensions: pensions.length, connected_pensions: pensions.filter(p => p.youtube_playlist_id).length, total_views: summaries.reduce((sum, s) => sum + s.last_30_days.views, 0), total_videos: summaries.reduce((sum, s) => sum + s.total_videos, 0) }; res.json({ summary: totalSummary, pensions: summaries }); } catch (error) { console.error('[Pensions Analytics Summary] 조회 오류:', error); res.status(500).json({ error: error.message }); } }); // ==================== END PENSION YOUTUBE & ANALYTICS ROUTES ==================== // ==================== TIKTOK INTEGRATION ROUTES ==================== /** * TikTok OAuth 인증 URL 생성 * GET /api/tiktok/oauth/url */ app.get('/api/tiktok/oauth/url', authenticateToken, (req, res) => { try { const userId = req.user.id; const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173'; const redirectUri = `${process.env.BACKEND_URL || 'http://localhost:3001'}/api/tiktok/oauth/callback`; const authUrl = tiktokService.generateAuthUrl(userId, redirectUri); res.json({ authUrl }); } catch (error) { console.error('[TikTok OAuth URL] 오류:', error.message); res.status(500).json({ error: error.message }); } }); /** * TikTok OAuth 콜백 * GET /api/tiktok/oauth/callback */ app.get('/api/tiktok/oauth/callback', async (req, res) => { try { const { code, state, error: authError, error_description } = req.query; if (authError) { console.error('[TikTok OAuth Callback] 인증 실패:', authError, error_description); const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173'; return res.redirect(`${frontendUrl}/dashboard?tiktok_error=${encodeURIComponent(error_description || authError)}`); } if (!code || !state) { return res.status(400).json({ error: 'code 또는 state 파라미터가 없습니다.' }); } const { userId } = JSON.parse(state); const redirectUri = `${process.env.BACKEND_URL || 'http://localhost:3001'}/api/tiktok/oauth/callback`; const result = await tiktokService.exchangeCodeForTokens(code, userId, redirectUri); const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173'; res.redirect(`${frontendUrl}/dashboard?tiktok_connected=true&tiktok_name=${encodeURIComponent(result.displayName || '')}`); } catch (error) { console.error('[TikTok OAuth Callback] 오류:', error.message); const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173'; res.redirect(`${frontendUrl}/dashboard?tiktok_error=${encodeURIComponent(error.message)}`); } }); /** * TikTok 연결 상태 조회 * GET /api/tiktok/status */ app.get('/api/tiktok/status', authenticateToken, async (req, res) => { try { const userId = req.user.id; const status = await tiktokService.getConnectionStatus(userId); if (status) { res.json({ connected: true, openId: status.open_id, displayName: status.display_name, avatarUrl: status.avatar_url, followerCount: status.follower_count, followingCount: status.following_count, connectedAt: status.connected_at }); } else { res.json({ connected: false }); } } catch (error) { console.error('[TikTok Status] 오류:', error.message); res.status(500).json({ connected: false, error: error.message }); } }); /** * TikTok 연결 해제 * POST /api/tiktok/disconnect */ app.post('/api/tiktok/disconnect', authenticateToken, async (req, res) => { try { const userId = req.user.id; const result = await tiktokService.disconnectTikTok(userId); res.json(result); } catch (error) { console.error('[TikTok Disconnect] 오류:', error.message); res.status(500).json({ success: false, error: error.message }); } }); /** * TikTok 설정 조회 * GET /api/tiktok/settings */ app.get('/api/tiktok/settings', authenticateToken, async (req, res) => { try { const userId = req.user.id; const settings = await tiktokService.getUserTikTokSettings(userId); res.json(settings); } catch (error) { console.error('[TikTok Settings] 오류:', error.message); res.status(500).json({ error: error.message }); } }); /** * TikTok 설정 업데이트 * PUT /api/tiktok/settings */ app.put('/api/tiktok/settings', authenticateToken, async (req, res) => { try { const userId = req.user.id; const result = await tiktokService.updateUserTikTokSettings(userId, req.body); res.json(result); } catch (error) { console.error('[TikTok Settings Update] 오류:', error.message); res.status(500).json({ success: false, error: error.message }); } }); /** * TikTok 비디오 업로드 (Direct Post) * POST /api/tiktok/upload * * Body: { history_id, title, privacy_level, disable_duet, disable_comment, disable_stitch } */ app.post('/api/tiktok/upload', authenticateToken, async (req, res) => { try { const userId = req.user.id; const { history_id, title, privacy_level, disable_duet, disable_comment, disable_stitch } = req.body; if (!history_id) { return res.status(400).json({ success: false, error: '업로드할 영상 ID가 필요합니다.' }); } // 영상 정보 조회 const video = await new Promise((resolve, reject) => { db.get( 'SELECT final_video_path, business_name FROM history WHERE id = ? AND user_id = ?', [history_id, userId], (err, row) => { if (err) reject(err); else resolve(row); } ); }); if (!video || !video.final_video_path) { return res.status(404).json({ success: false, error: '업로드할 영상을 찾을 수 없습니다.' }); } let videoPath = video.final_video_path; if (videoPath.startsWith('/downloads/')) { videoPath = path.join(__dirname, videoPath); } const result = await tiktokService.uploadVideo( userId, videoPath, { title: title || video.business_name || 'CaStAD Video' }, { historyId: history_id, privacyLevel: privacy_level || 'SELF_ONLY', disableDuet: disable_duet || false, disableComment: disable_comment || false, disableStitch: disable_stitch || false } ); res.json({ success: true, ...result }); } catch (error) { console.error('[TikTok Upload] 오류:', error.message); res.status(500).json({ success: false, error: 'TikTok 업로드 중 오류가 발생했습니다.', details: error.message }); } }); /** * TikTok Inbox 업로드 (Draft 방식) * POST /api/tiktok/upload-to-inbox * * Body: { history_id } */ app.post('/api/tiktok/upload-to-inbox', authenticateToken, async (req, res) => { try { const userId = req.user.id; const { history_id } = req.body; if (!history_id) { return res.status(400).json({ success: false, error: '업로드할 영상 ID가 필요합니다.' }); } // 영상 정보 조회 const video = await new Promise((resolve, reject) => { db.get( 'SELECT final_video_path, business_name FROM history WHERE id = ? AND user_id = ?', [history_id, userId], (err, row) => { if (err) reject(err); else resolve(row); } ); }); if (!video || !video.final_video_path) { return res.status(404).json({ success: false, error: '업로드할 영상을 찾을 수 없습니다.' }); } let videoPath = video.final_video_path; if (videoPath.startsWith('/downloads/')) { videoPath = path.join(__dirname, videoPath); } const result = await tiktokService.uploadVideoToInbox( userId, videoPath, { title: video.business_name || 'CaStAD Video' }, { historyId: history_id } ); res.json({ success: true, ...result }); } catch (error) { console.error('[TikTok Upload to Inbox] 오류:', error.message); res.status(500).json({ success: false, error: 'TikTok 업로드 중 오류가 발생했습니다.', details: error.message }); } }); /** * TikTok 업로드 히스토리 조회 * GET /api/tiktok/history */ app.get('/api/tiktok/history', authenticateToken, async (req, res) => { try { const userId = req.user.id; const limit = parseInt(req.query.limit) || 20; const history = await tiktokService.getUploadHistory(userId, limit); res.json(history); } catch (error) { console.error('[TikTok History] 오류:', error.message); res.status(500).json({ error: error.message }); } }); /** * TikTok 통계 조회 * GET /api/tiktok/stats */ app.get('/api/tiktok/stats', authenticateToken, async (req, res) => { try { const userId = req.user.id; const stats = await tiktokService.getTikTokStats(userId); res.json(stats); } catch (error) { console.error('[TikTok Stats] 오류:', error.message); res.status(500).json({ error: error.message }); } }); // ==================== END TIKTOK ROUTES ==================== // ==================== ADVANCED STATISTICS ROUTES (ADMIN) ==================== /** * 대시보드 요약 통계 * GET /api/admin/analytics/summary */ app.get('/api/admin/analytics/summary', authenticateToken, requireAdmin, async (req, res) => { try { const summary = await statisticsService.getDashboardSummary(); res.json(summary); } catch (error) { console.error('[Admin Analytics Summary] 오류:', error); res.status(500).json({ error: error.message }); } }); /** * 사용자 성장 트렌드 * GET /api/admin/analytics/user-growth * Query: { days?: number } */ app.get('/api/admin/analytics/user-growth', authenticateToken, requireAdmin, async (req, res) => { try { const days = parseInt(req.query.days) || 30; const data = await statisticsService.getUserGrowthTrend(days); res.json(data); } catch (error) { console.error('[Admin User Growth] 오류:', error); res.status(500).json({ error: error.message }); } }); /** * 영상 생성 트렌드 * GET /api/admin/analytics/video-trend * Query: { days?: number } */ app.get('/api/admin/analytics/video-trend', authenticateToken, requireAdmin, async (req, res) => { try { const days = parseInt(req.query.days) || 30; const data = await statisticsService.getVideoGenerationTrend(days); res.json(data); } catch (error) { console.error('[Admin Video Trend] 오류:', error); res.status(500).json({ error: error.message }); } }); /** * 플랫폼별 업로드 통계 * GET /api/admin/analytics/platform-uploads * Query: { days?: number } */ app.get('/api/admin/analytics/platform-uploads', authenticateToken, requireAdmin, async (req, res) => { try { const days = parseInt(req.query.days) || 30; const data = await statisticsService.getPlatformUploadStats(days); res.json(data); } catch (error) { console.error('[Admin Platform Uploads] 오류:', error); res.status(500).json({ error: error.message }); } }); /** * 크레딧 사용 통계 * GET /api/admin/analytics/credit-usage * Query: { days?: number } */ app.get('/api/admin/analytics/credit-usage', authenticateToken, requireAdmin, async (req, res) => { try { const days = parseInt(req.query.days) || 30; const data = await statisticsService.getCreditUsageStats(days); res.json(data); } catch (error) { console.error('[Admin Credit Usage] 오류:', error); res.status(500).json({ error: error.message }); } }); /** * 플랜별 사용자 분포 * GET /api/admin/analytics/plan-distribution */ app.get('/api/admin/analytics/plan-distribution', authenticateToken, requireAdmin, async (req, res) => { try { const data = await statisticsService.getPlanDistribution(); res.json(data); } catch (error) { console.error('[Admin Plan Distribution] 오류:', error); res.status(500).json({ error: error.message }); } }); /** * 톱 사용자 (가장 많은 영상 생성) * GET /api/admin/analytics/top-users * Query: { limit?: number } */ app.get('/api/admin/analytics/top-users', authenticateToken, requireAdmin, async (req, res) => { try { const limit = parseInt(req.query.limit) || 10; const data = await statisticsService.getTopUsers(limit); res.json(data); } catch (error) { console.error('[Admin Top Users] 오류:', error); res.status(500).json({ error: error.message }); } }); /** * 시간대별 사용 패턴 * GET /api/admin/analytics/usage-pattern */ app.get('/api/admin/analytics/usage-pattern', authenticateToken, requireAdmin, async (req, res) => { try { const data = await statisticsService.getUsagePattern(); res.json(data); } catch (error) { console.error('[Admin Usage Pattern] 오류:', error); res.status(500).json({ error: error.message }); } }); /** * 지역별 분포 * GET /api/admin/analytics/regional */ app.get('/api/admin/analytics/regional', authenticateToken, requireAdmin, async (req, res) => { try { const data = await statisticsService.getRegionalDistribution(); res.json(data); } catch (error) { console.error('[Admin Regional] 오류:', error); res.status(500).json({ error: error.message }); } }); /** * 수익 예측 * GET /api/admin/analytics/revenue */ app.get('/api/admin/analytics/revenue', authenticateToken, requireAdmin, async (req, res) => { try { const data = await statisticsService.getRevenueProjection(); res.json(data); } catch (error) { console.error('[Admin Revenue] 오류:', error); res.status(500).json({ error: error.message }); } }); /** * 최근 활동 로그 * GET /api/admin/analytics/activity-logs * Query: { limit?: number } */ app.get('/api/admin/analytics/activity-logs', authenticateToken, requireAdmin, async (req, res) => { try { const limit = parseInt(req.query.limit) || 50; const data = await statisticsService.getRecentActivityLogs(limit); res.json(data); } catch (error) { console.error('[Admin Activity Logs] 오류:', error); res.status(500).json({ error: error.message }); } }); /** * 시스템 헬스 체크 (확장) * GET /api/admin/analytics/system-health */ app.get('/api/admin/analytics/system-health', authenticateToken, requireAdmin, async (req, res) => { try { const data = await statisticsService.getSystemHealth(); res.json(data); } catch (error) { console.error('[Admin System Health] 오류:', error); res.status(500).json({ error: error.message }); } }); /** * 일별 통계 업데이트 (수동 트리거) * POST /api/admin/analytics/update-daily */ app.post('/api/admin/analytics/update-daily', authenticateToken, requireAdmin, async (req, res) => { try { await statisticsService.updateDailyStats(); res.json({ success: true, message: '일별 통계가 업데이트되었습니다.' }); } catch (error) { console.error('[Admin Update Daily Stats] 오류:', error); res.status(500).json({ error: error.message }); } }); /** * 종합 분석 리포트 (모든 통계 한번에) * GET /api/admin/analytics/full-report * Query: { days?: number } */ app.get('/api/admin/analytics/full-report', authenticateToken, requireAdmin, async (req, res) => { try { const days = parseInt(req.query.days) || 30; const [ summary, userGrowth, videoTrend, platformUploads, creditUsage, planDistribution, topUsers, usagePattern, regional, revenue, systemHealth ] = await Promise.all([ statisticsService.getDashboardSummary(), statisticsService.getUserGrowthTrend(days), statisticsService.getVideoGenerationTrend(days), statisticsService.getPlatformUploadStats(days), statisticsService.getCreditUsageStats(days), statisticsService.getPlanDistribution(), statisticsService.getTopUsers(10), statisticsService.getUsagePattern(), statisticsService.getRegionalDistribution(), statisticsService.getRevenueProjection(), statisticsService.getSystemHealth() ]); res.json({ generatedAt: new Date().toISOString(), period: `${days}일`, summary, userGrowth, videoTrend, platformUploads, creditUsage, planDistribution, topUsers, usagePattern, regional, revenue, systemHealth }); } catch (error) { console.error('[Admin Full Report] 오류:', error); res.status(500).json({ error: error.message }); } }); // ==================== END ADVANCED STATISTICS ROUTES ==================== // 모든 기타 요청은 React 앱으로 전달 (SPA 라우팅 지원) app.get('*', (req, res) => { res.sendFile(path.join(__dirname, '../dist/index.html')); }); app.listen(PORT, () => { console.log(`서버가 http://localhost:${PORT} 에서 실행 중입니다.`); });