castad-pre-v0.3/castad-data/server/instagramService.js

612 lines
20 KiB
JavaScript

/**
* 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<Object>} 응답 데이터
*/
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<boolean>} 서비스 정상 여부
*/
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<Object>} 연결 결과
*/
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<Object>} 해제 결과
*/
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<Object>} 연결 상태 정보
*/
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<Object>} 업데이트 결과
*/
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<Object>} 이번 주 업로드 정보
*/
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<Object>} 업로드 결과
*/
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<Array>} 업로드 히스토리 목록
*/
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
};