612 lines
20 KiB
JavaScript
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
|
|
};
|