5022 lines
179 KiB
JavaScript
5022 lines
179 KiB
JavaScript
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 `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
width: ${width}px;
|
|
height: ${height}px;
|
|
overflow: hidden;
|
|
background: #000;
|
|
font-family: 'Noto Sans KR', sans-serif;
|
|
}
|
|
.slideshow {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
.slide {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
opacity: 0;
|
|
transition: opacity 1.5s ease-in-out;
|
|
}
|
|
.slide.active { opacity: 1; }
|
|
.slide img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
.text-overlay {
|
|
position: absolute;
|
|
bottom: 15%;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
width: 90%;
|
|
text-align: center;
|
|
color: white;
|
|
font-size: ${Math.floor(height / 20)}px;
|
|
font-weight: bold;
|
|
text-shadow: 2px 2px 8px rgba(0,0,0,0.8);
|
|
opacity: 0;
|
|
}
|
|
.text-overlay.show {
|
|
opacity: 1;
|
|
animation: textIn 1.2s ease-out;
|
|
}
|
|
@keyframes textIn {
|
|
from { opacity: 0; transform: translateX(-50%) translateY(30px); }
|
|
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
}
|
|
${textEffectCSS}
|
|
.brand-watermark {
|
|
position: absolute;
|
|
bottom: 20px;
|
|
right: 20px;
|
|
color: rgba(255,255,255,0.5);
|
|
font-size: 14px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="slideshow">
|
|
${imagePaths.map((img, i) => `
|
|
<div class="slide ${i === 0 ? 'active' : ''}" data-index="${i}">
|
|
<img src="${img}" alt="slide ${i}">
|
|
</div>
|
|
`).join('')}
|
|
<div class="text-overlay ${textEffect}" id="textOverlay"></div>
|
|
<div class="brand-watermark">CastAD</div>
|
|
</div>
|
|
<script>
|
|
const slides = document.querySelectorAll('.slide');
|
|
const textOverlay = document.getElementById('textOverlay');
|
|
const adCopy = ${JSON.stringify(adCopy)};
|
|
const slideDuration = ${slideDuration * 1000};
|
|
let currentSlide = 0;
|
|
let currentText = 0;
|
|
|
|
window.startAnimation = function() {
|
|
// 슬라이드쇼 시작
|
|
setInterval(() => {
|
|
slides[currentSlide].classList.remove('active');
|
|
currentSlide = (currentSlide + 1) % slides.length;
|
|
slides[currentSlide].classList.add('active');
|
|
}, slideDuration);
|
|
|
|
// 텍스트 애니메이션
|
|
if (adCopy.length > 0) {
|
|
textOverlay.textContent = adCopy[0];
|
|
textOverlay.classList.add('show');
|
|
|
|
setInterval(() => {
|
|
textOverlay.classList.remove('show');
|
|
setTimeout(() => {
|
|
currentText = (currentText + 1) % adCopy.length;
|
|
textOverlay.textContent = adCopy[currentText];
|
|
textOverlay.classList.add('show');
|
|
}, 300);
|
|
}, slideDuration);
|
|
}
|
|
};
|
|
|
|
// 자동 시작
|
|
window.startAnimation();
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
/**
|
|
* 텍스트 이펙트별 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} 에서 실행 중입니다.`);
|
|
}); |