/** * Instagram 업로드 서비스 * * Python Instagram 마이크로서비스와 통신하여 * Instagram 계정 연결 및 영상 업로드 기능을 제공합니다. * * 주요 기능: * 1. Instagram 계정 연결 (로그인) * 2. 계정 연결 해제 * 3. 릴스/영상 업로드 * 4. 업로드 히스토리 관리 * * @module instagramService */ const db = require('./db'); const path = require('path'); // ============================================ // 설정 // ============================================ // Python Instagram 서비스 URL const INSTAGRAM_SERVICE_URL = process.env.INSTAGRAM_SERVICE_URL || 'http://localhost:5001'; // 주당 최대 업로드 횟수 (안전한 사용을 위해) const MAX_UPLOADS_PER_WEEK = 1; // ============================================ // 유틸리티 함수 // ============================================ /** * Python Instagram 서비스에 HTTP 요청 * * @param {string} endpoint - API 엔드포인트 (예: '/connect') * @param {Object} data - 요청 본문 데이터 * @returns {Promise} 응답 데이터 */ async function callInstagramService(endpoint, data = {}) { const url = `${INSTAGRAM_SERVICE_URL}${endpoint}`; try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); const result = await response.json(); return result; } catch (error) { console.error(`[Instagram 서비스 호출 실패] ${endpoint}:`, error.message); // 서비스 연결 실패 if (error.code === 'ECONNREFUSED') { throw new Error('Instagram 서비스에 연결할 수 없습니다. 서비스가 실행 중인지 확인하세요.'); } throw error; } } /** * Instagram 서비스 헬스 체크 * * @returns {Promise} 서비스 정상 여부 */ async function checkServiceHealth() { try { const response = await fetch(`${INSTAGRAM_SERVICE_URL}/health`); const data = await response.json(); return data.status === 'ok'; } catch (error) { console.error('[Instagram 서비스 헬스 체크 실패]', error.message); return false; } } // ============================================ // 계정 연결 관리 // ============================================ /** * Instagram 계정 연결 * * 사용자의 Instagram 계정을 연결하고 인증 정보를 암호화하여 저장합니다. * * @param {number} userId - 사용자 ID * @param {string} username - Instagram 사용자명 * @param {string} password - Instagram 비밀번호 * @param {string} [verificationCode] - 2FA 인증 코드 (선택) * @returns {Promise} 연결 결과 */ async function connectAccount(userId, username, password, verificationCode = null) { console.log(`[Instagram 연결 시도] 사용자 ID: ${userId}, Instagram: ${username}`); // Python 서비스에 로그인 요청 const loginResult = await callInstagramService('/connect', { username, password, verification_code: verificationCode }); if (!loginResult.success) { return loginResult; } // DB에 연결 정보 저장 return new Promise((resolve, reject) => { db.run(` INSERT OR REPLACE INTO instagram_connections (user_id, instagram_username, encrypted_password, encrypted_session, is_active, last_login_at, connected_at, updated_at) VALUES (?, ?, ?, ?, 1, datetime('now'), datetime('now'), datetime('now')) `, [ userId, loginResult.username, loginResult.encrypted_password, loginResult.encrypted_session ], function(err) { if (err) { console.error('[Instagram 연결 정보 저장 실패]', err); reject(err); return; } // 기본 설정도 생성 db.run(` INSERT OR IGNORE INTO instagram_settings (user_id, auto_upload, upload_as_reel, max_uploads_per_week) VALUES (?, 0, 1, ?) `, [userId, MAX_UPLOADS_PER_WEEK]); console.log(`[Instagram 연결 성공] 사용자 ID: ${userId}`); resolve({ success: true, message: 'Instagram 계정이 연결되었습니다.', username: loginResult.username, user_id: loginResult.user_id, full_name: loginResult.full_name, profile_pic_url: loginResult.profile_pic_url }); }); }); } /** * Instagram 계정 연결 해제 * * @param {number} userId - 사용자 ID * @returns {Promise} 해제 결과 */ async function disconnectAccount(userId) { console.log(`[Instagram 연결 해제] 사용자 ID: ${userId}`); return new Promise((resolve, reject) => { // 먼저 저장된 세션 조회 db.get( 'SELECT encrypted_session FROM instagram_connections WHERE user_id = ?', [userId], async (err, row) => { if (err) { reject(err); return; } // Python 서비스에 로그아웃 요청 (실패해도 무시) if (row && row.encrypted_session) { try { await callInstagramService('/disconnect', { encrypted_session: row.encrypted_session }); } catch (e) { // 무시 } } // DB에서 삭제 db.run( 'DELETE FROM instagram_connections WHERE user_id = ?', [userId], function(err) { if (err) { reject(err); return; } // 설정도 삭제 db.run( 'DELETE FROM instagram_settings WHERE user_id = ?', [userId] ); resolve({ success: true, message: 'Instagram 계정 연결이 해제되었습니다.' }); } ); } ); }); } /** * Instagram 연결 상태 조회 * * @param {number} userId - 사용자 ID * @returns {Promise} 연결 상태 정보 */ async function getConnectionStatus(userId) { return new Promise((resolve, reject) => { db.get(` SELECT ic.instagram_username, ic.is_active, ic.two_factor_required, ic.connected_at, ic.last_login_at, ic.encrypted_session, inst.auto_upload, inst.upload_as_reel, inst.default_caption_template, inst.default_hashtags, inst.max_uploads_per_week, inst.notify_on_upload FROM instagram_connections ic LEFT JOIN instagram_settings inst ON ic.user_id = inst.user_id WHERE ic.user_id = ? `, [userId], async (err, row) => { if (err) { reject(err); return; } if (!row) { resolve({ connected: false, message: 'Instagram 계정이 연결되지 않았습니다.' }); return; } // 계정 정보 조회 시도 (세션 유효성 확인) let accountInfo = null; if (row.encrypted_session) { try { const info = await callInstagramService('/account-info', { encrypted_session: row.encrypted_session }); if (info.success) { accountInfo = info; } } catch (e) { // 세션 만료 가능성 console.warn('[Instagram 계정 정보 조회 실패]', e.message); } } resolve({ connected: true, username: row.instagram_username, is_active: row.is_active === 1, two_factor_required: row.two_factor_required === 1, connected_at: row.connected_at, last_login_at: row.last_login_at, settings: { auto_upload: row.auto_upload === 1, upload_as_reel: row.upload_as_reel === 1, default_caption_template: row.default_caption_template, default_hashtags: row.default_hashtags, max_uploads_per_week: row.max_uploads_per_week || MAX_UPLOADS_PER_WEEK, notify_on_upload: row.notify_on_upload === 1 }, account_info: accountInfo ? { full_name: accountInfo.full_name, profile_pic_url: accountInfo.profile_pic_url, follower_count: accountInfo.follower_count, media_count: accountInfo.media_count } : null }); }); }); } // ============================================ // 설정 관리 // ============================================ /** * Instagram 업로드 설정 업데이트 * * @param {number} userId - 사용자 ID * @param {Object} settings - 설정 객체 * @returns {Promise} 업데이트 결과 */ async function updateSettings(userId, settings) { return new Promise((resolve, reject) => { const { auto_upload, upload_as_reel, default_caption_template, default_hashtags, max_uploads_per_week, notify_on_upload } = settings; db.run(` INSERT OR REPLACE INTO instagram_settings (user_id, auto_upload, upload_as_reel, default_caption_template, default_hashtags, max_uploads_per_week, notify_on_upload, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now')) `, [ userId, auto_upload ? 1 : 0, upload_as_reel !== false ? 1 : 0, // 기본값 true default_caption_template || null, default_hashtags || null, max_uploads_per_week || MAX_UPLOADS_PER_WEEK, notify_on_upload !== false ? 1 : 0 // 기본값 true ], function(err) { if (err) { reject(err); return; } resolve({ success: true, message: '설정이 저장되었습니다.' }); }); }); } // ============================================ // 영상 업로드 // ============================================ /** * 주간 업로드 횟수 확인 * * @param {number} userId - 사용자 ID * @returns {Promise} 이번 주 업로드 정보 */ async function getWeeklyUploadCount(userId) { return new Promise((resolve, reject) => { // 이번 주 월요일 기준 db.get(` SELECT COUNT(*) as count, MAX(uploaded_at) as last_upload FROM instagram_upload_history WHERE user_id = ? AND status = 'success' AND uploaded_at >= date('now', 'weekday 0', '-6 days') `, [userId], (err, row) => { if (err) { reject(err); return; } db.get( 'SELECT max_uploads_per_week FROM instagram_settings WHERE user_id = ?', [userId], (err, settings) => { resolve({ count: row?.count || 0, max: settings?.max_uploads_per_week || MAX_UPLOADS_PER_WEEK, last_upload: row?.last_upload }); } ); }); }); } /** * Instagram에 영상 업로드 * * @param {number} userId - 사용자 ID * @param {number} historyId - 영상 히스토리 ID * @param {string} videoPath - 영상 파일 경로 * @param {string} caption - 게시물 캡션 * @param {Object} [options] - 추가 옵션 * @param {string} [options.thumbnailPath] - 썸네일 이미지 경로 * @param {boolean} [options.forceUpload] - 주간 제한 무시 여부 * @returns {Promise} 업로드 결과 */ async function uploadVideo(userId, historyId, videoPath, caption, options = {}) { console.log(`[Instagram 업로드 시작] 사용자 ID: ${userId}, 히스토리: ${historyId}`); const { thumbnailPath, forceUpload = false } = options; // 1. 주간 업로드 횟수 확인 if (!forceUpload) { const weeklyStats = await getWeeklyUploadCount(userId); if (weeklyStats.count >= weeklyStats.max) { console.log(`[Instagram 업로드 제한] 주간 최대 ${weeklyStats.max}회 초과`); return { success: false, error: `이번 주 업로드 제한(${weeklyStats.max}회)을 초과했습니다. 다음 주에 다시 시도해주세요.`, error_code: 'WEEKLY_LIMIT_EXCEEDED', weekly_count: weeklyStats.count, weekly_max: weeklyStats.max, last_upload: weeklyStats.last_upload }; } } // 2. 연결 정보 조회 const connection = await new Promise((resolve, reject) => { db.get(` SELECT * FROM instagram_connections WHERE user_id = ? AND is_active = 1 `, [userId], (err, row) => { if (err) reject(err); else resolve(row); }); }); if (!connection) { return { success: false, error: 'Instagram 계정이 연결되지 않았습니다.', error_code: 'NOT_CONNECTED' }; } // 3. 설정 조회 const settings = await new Promise((resolve, reject) => { db.get( 'SELECT * FROM instagram_settings WHERE user_id = ?', [userId], (err, row) => { if (err) reject(err); else resolve(row || {}); } ); }); // 4. 업로드 히스토리 레코드 생성 (pending 상태) const uploadHistoryId = await new Promise((resolve, reject) => { db.run(` INSERT INTO instagram_upload_history (user_id, history_id, caption, upload_type, status, createdAt) VALUES (?, ?, ?, ?, 'pending', datetime('now')) `, [ userId, historyId, caption, settings.upload_as_reel ? 'reel' : 'video' ], function(err) { if (err) reject(err); else resolve(this.lastID); }); }); // 5. Python 서비스에 업로드 요청 try { const result = await callInstagramService('/upload', { encrypted_session: connection.encrypted_session, encrypted_password: connection.encrypted_password, username: connection.instagram_username, video_path: videoPath, caption: caption, thumbnail_path: thumbnailPath, upload_as_reel: settings.upload_as_reel !== 0 }); if (result.success) { // 성공 - 히스토리 업데이트 await new Promise((resolve, reject) => { db.run(` UPDATE instagram_upload_history SET instagram_media_id = ?, instagram_post_code = ?, permalink = ?, status = 'success', uploaded_at = datetime('now') WHERE id = ? `, [ result.media_id, result.post_code, result.permalink, uploadHistoryId ], (err) => { if (err) reject(err); else resolve(); }); }); // 세션 갱신된 경우 DB 업데이트 if (result.new_session) { db.run(` UPDATE instagram_connections SET encrypted_session = ?, last_login_at = datetime('now'), updated_at = datetime('now') WHERE user_id = ? `, [result.new_session, userId]); } console.log(`[Instagram 업로드 성공] 미디어 ID: ${result.media_id}`); return { success: true, media_id: result.media_id, post_code: result.post_code, permalink: result.permalink, upload_history_id: uploadHistoryId }; } else { // 실패 - 히스토리 업데이트 await new Promise((resolve, reject) => { db.run(` UPDATE instagram_upload_history SET status = 'failed', error_message = ?, retry_count = retry_count + 1 WHERE id = ? `, [result.error, uploadHistoryId], (err) => { if (err) reject(err); else resolve(); }); }); console.error(`[Instagram 업로드 실패] ${result.error}`); return result; } } catch (error) { // 에러 - 히스토리 업데이트 await new Promise((resolve) => { db.run(` UPDATE instagram_upload_history SET status = 'failed', error_message = ?, retry_count = retry_count + 1 WHERE id = ? `, [error.message, uploadHistoryId], () => resolve()); }); console.error(`[Instagram 업로드 에러]`, error); throw error; } } // ============================================ // 히스토리 조회 // ============================================ /** * Instagram 업로드 히스토리 조회 * * @param {number} userId - 사용자 ID * @param {number} [limit=20] - 최대 조회 개수 * @param {number} [offset=0] - 시작 위치 * @returns {Promise} 업로드 히스토리 목록 */ async function getUploadHistory(userId, limit = 20, offset = 0) { return new Promise((resolve, reject) => { db.all(` SELECT iuh.*, h.business_name, h.details as video_details, pp.brand_name as pension_name FROM instagram_upload_history iuh LEFT JOIN history h ON iuh.history_id = h.id LEFT JOIN pension_profiles pp ON iuh.pension_id = pp.id WHERE iuh.user_id = ? ORDER BY iuh.createdAt DESC LIMIT ? OFFSET ? `, [userId, limit, offset], (err, rows) => { if (err) { reject(err); return; } resolve(rows || []); }); }); } // ============================================ // 모듈 익스포트 // ============================================ module.exports = { // 서비스 상태 checkServiceHealth, // 계정 관리 connectAccount, disconnectAccount, getConnectionStatus, // 설정 updateSettings, // 업로드 uploadVideo, getWeeklyUploadCount, getUploadHistory, // 상수 INSTAGRAM_SERVICE_URL, MAX_UPLOADS_PER_WEEK };