/** * TikTok Content Posting API Service * CaStAD v3.0.0 * * TikTok API Reference: https://developers.tiktok.com/doc/content-posting-api-get-started/ */ const fs = require('fs'); const path = require('path'); const axios = require('axios'); const db = require('./db'); // TikTok API Endpoints const TIKTOK_AUTH_URL = 'https://www.tiktok.com/v2/auth/authorize/'; const TIKTOK_TOKEN_URL = 'https://open.tiktokapis.com/v2/oauth/token/'; const TIKTOK_REVOKE_URL = 'https://open.tiktokapis.com/v2/oauth/revoke/'; const TIKTOK_USER_INFO_URL = 'https://open.tiktokapis.com/v2/user/info/'; const TIKTOK_UPLOAD_INIT_URL = 'https://open.tiktokapis.com/v2/post/publish/video/init/'; const TIKTOK_UPLOAD_INBOX_URL = 'https://open.tiktokapis.com/v2/post/publish/inbox/video/init/'; const TIKTOK_PUBLISH_STATUS_URL = 'https://open.tiktokapis.com/v2/post/publish/status/fetch/'; // Scopes const TIKTOK_SCOPES = [ 'user.info.basic', 'user.info.profile', 'user.info.stats', 'video.publish', 'video.upload' ]; // TikTok Client credentials (from environment or config) const TIKTOK_CLIENT_KEY = process.env.TIKTOK_CLIENT_KEY; const TIKTOK_CLIENT_SECRET = process.env.TIKTOK_CLIENT_SECRET; /** * TikTok OAuth 인증 URL 생성 * @param {number} userId - 사용자 ID * @param {string} redirectUri - 콜백 URI */ function generateAuthUrl(userId, redirectUri = null) { if (!TIKTOK_CLIENT_KEY) { throw new Error('TIKTOK_CLIENT_KEY 환경변수가 설정되지 않았습니다.'); } const finalRedirectUri = redirectUri || `${process.env.BACKEND_URL || 'http://localhost:3001'}/api/tiktok/oauth/callback`; const params = new URLSearchParams({ client_key: TIKTOK_CLIENT_KEY, scope: TIKTOK_SCOPES.join(','), response_type: 'code', redirect_uri: finalRedirectUri, state: JSON.stringify({ userId }) }); return `${TIKTOK_AUTH_URL}?${params.toString()}`; } /** * 인증 코드로 토큰 교환 * @param {string} code - 인증 코드 * @param {number} userId - 사용자 ID * @param {string} redirectUri - 콜백 URI */ async function exchangeCodeForTokens(code, userId, redirectUri = null) { if (!TIKTOK_CLIENT_KEY || !TIKTOK_CLIENT_SECRET) { throw new Error('TikTok 클라이언트 자격증명이 설정되지 않았습니다.'); } const finalRedirectUri = redirectUri || `${process.env.BACKEND_URL || 'http://localhost:3001'}/api/tiktok/oauth/callback`; try { // 토큰 요청 const tokenResponse = await axios.post(TIKTOK_TOKEN_URL, null, { params: { client_key: TIKTOK_CLIENT_KEY, client_secret: TIKTOK_CLIENT_SECRET, code, grant_type: 'authorization_code', redirect_uri: finalRedirectUri } }); const tokens = tokenResponse.data; if (tokens.error) { throw new Error(tokens.error.message || 'Token exchange failed'); } const accessToken = tokens.access_token; const refreshToken = tokens.refresh_token; const expiresIn = tokens.expires_in; const openId = tokens.open_id; const tokenExpiry = new Date(Date.now() + expiresIn * 1000).toISOString(); // 사용자 정보 가져오기 const userInfo = await getTikTokUserInfo(accessToken, openId); // DB에 저장 return new Promise((resolve, reject) => { db.run(` INSERT OR REPLACE INTO tiktok_connections (user_id, open_id, display_name, avatar_url, follower_count, following_count, access_token, refresh_token, token_expiry, scopes, connected_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) `, [ userId, openId, userInfo.display_name || null, userInfo.avatar_url || null, userInfo.follower_count || 0, userInfo.following_count || 0, accessToken, refreshToken, tokenExpiry, TIKTOK_SCOPES.join(',') ], function (err) { if (err) { console.error('[TikTok OAuth] 토큰 저장 실패:', err); reject(err); } else { // 기본 설정 생성 db.run(`INSERT OR IGNORE INTO tiktok_settings (user_id) VALUES (?)`, [userId]); resolve({ success: true, openId, displayName: userInfo.display_name, avatarUrl: userInfo.avatar_url, followerCount: userInfo.follower_count }); } }); }); } catch (error) { console.error('[TikTok OAuth] 토큰 교환 실패:', error.response?.data || error.message); throw error; } } /** * TikTok 사용자 정보 조회 * @param {string} accessToken - 액세스 토큰 * @param {string} openId - TikTok Open ID */ async function getTikTokUserInfo(accessToken, openId) { try { const response = await axios.get(TIKTOK_USER_INFO_URL, { headers: { 'Authorization': `Bearer ${accessToken}` }, params: { fields: 'open_id,display_name,avatar_url,follower_count,following_count,bio_description' } }); return response.data.data?.user || {}; } catch (error) { console.error('[TikTok] 사용자 정보 조회 실패:', error.response?.data || error.message); return {}; } } /** * 토큰 갱신 * @param {string} refreshToken - 리프레시 토큰 */ async function refreshAccessToken(refreshToken) { try { const response = await axios.post(TIKTOK_TOKEN_URL, null, { params: { client_key: TIKTOK_CLIENT_KEY, client_secret: TIKTOK_CLIENT_SECRET, grant_type: 'refresh_token', refresh_token: refreshToken } }); return response.data; } catch (error) { console.error('[TikTok] 토큰 갱신 실패:', error.response?.data || error.message); throw error; } } /** * 사용자별 인증된 클라이언트 정보 가져오기 * @param {number} userId - 사용자 ID */ async function getAuthenticatedCredentials(userId) { return new Promise((resolve, reject) => { db.get(`SELECT * FROM tiktok_connections WHERE user_id = ?`, [userId], async (err, row) => { if (err) { reject(err); return; } if (!row) { reject(new Error('TikTok 계정이 연결되지 않았습니다. 설정에서 계정을 연결해주세요.')); return; } // 토큰 만료 체크 const tokenExpiry = new Date(row.token_expiry); const now = new Date(); if (tokenExpiry <= now) { // 토큰 갱신 시도 try { const newTokens = await refreshAccessToken(row.refresh_token); const newExpiry = new Date(Date.now() + newTokens.expires_in * 1000).toISOString(); // DB 업데이트 db.run(` UPDATE tiktok_connections SET access_token = ?, refresh_token = ?, token_expiry = ? WHERE user_id = ? `, [newTokens.access_token, newTokens.refresh_token, newExpiry, userId]); resolve({ accessToken: newTokens.access_token, openId: row.open_id, displayName: row.display_name }); } catch (refreshError) { console.error('[TikTok] 토큰 갱신 실패:', refreshError); // 연결 해제 db.run(`DELETE FROM tiktok_connections WHERE user_id = ?`, [userId]); reject(new Error('TikTok 인증이 만료되었습니다. 다시 연결해주세요.')); } } else { resolve({ accessToken: row.access_token, openId: row.open_id, displayName: row.display_name }); } }); }); } /** * 사용자의 TikTok 연결 상태 확인 */ function getConnectionStatus(userId) { return new Promise((resolve, reject) => { db.get(` SELECT open_id, display_name, avatar_url, follower_count, following_count, connected_at FROM tiktok_connections WHERE user_id = ? `, [userId], (err, row) => { if (err) reject(err); else resolve(row || null); }); }); } /** * TikTok 연결 해제 */ async function disconnectTikTok(userId) { return new Promise((resolve, reject) => { db.get(`SELECT access_token FROM tiktok_connections WHERE user_id = ?`, [userId], async (err, row) => { if (err) { reject(err); return; } // 토큰 폐기 시도 if (row?.access_token) { try { await axios.post(TIKTOK_REVOKE_URL, null, { params: { client_key: TIKTOK_CLIENT_KEY, client_secret: TIKTOK_CLIENT_SECRET, token: row.access_token } }); } catch (revokeError) { console.error('[TikTok] 토큰 폐기 실패 (무시):', revokeError.message); } } // DB에서 삭제 db.run(`DELETE FROM tiktok_connections WHERE user_id = ?`, [userId], function (delErr) { if (delErr) reject(delErr); else resolve({ success: true, deleted: this.changes }); }); }); }); } /** * 비디오 업로드 (Direct Post) * @param {number} userId - 사용자 ID * @param {string} videoPath - 비디오 파일 경로 * @param {object} metadata - 메타데이터 (title, description, etc.) * @param {object} options - 업로드 옵션 */ async function uploadVideo(userId, videoPath, metadata, options = {}) { try { const credentials = await getAuthenticatedCredentials(userId); const fileSize = fs.statSync(videoPath).size; // Step 1: Initialize upload const initResponse = await axios.post( TIKTOK_UPLOAD_INIT_URL, { post_info: { title: (metadata.title || 'CaStAD Video').substring(0, 150), privacy_level: options.privacyLevel || 'SELF_ONLY', // SELF_ONLY, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, PUBLIC_TO_EVERYONE disable_duet: options.disableDuet || false, disable_comment: options.disableComment || false, disable_stitch: options.disableStitch || false, video_cover_timestamp_ms: options.coverTimestamp || 1000 }, source_info: { source: 'FILE_UPLOAD', video_size: fileSize, chunk_size: Math.min(fileSize, 10 * 1024 * 1024), // 10MB chunks total_chunk_count: Math.ceil(fileSize / (10 * 1024 * 1024)) } }, { headers: { 'Authorization': `Bearer ${credentials.accessToken}`, 'Content-Type': 'application/json; charset=UTF-8' } } ); if (initResponse.data.error?.code) { throw new Error(initResponse.data.error.message || 'Upload initialization failed'); } const uploadUrl = initResponse.data.data?.upload_url; const publishId = initResponse.data.data?.publish_id; if (!uploadUrl) { throw new Error('Upload URL not received from TikTok'); } // Step 2: Upload video file in chunks const chunkSize = 10 * 1024 * 1024; // 10MB const totalChunks = Math.ceil(fileSize / chunkSize); const fileStream = fs.createReadStream(videoPath); let uploadedBytes = 0; let chunkIndex = 0; for await (const chunk of fileStream) { const start = uploadedBytes; const end = Math.min(uploadedBytes + chunk.length - 1, fileSize - 1); await axios.put(uploadUrl, chunk, { headers: { 'Content-Type': 'video/mp4', 'Content-Length': chunk.length, 'Content-Range': `bytes ${start}-${end}/${fileSize}` } }); uploadedBytes += chunk.length; chunkIndex++; if (options.onProgress) { options.onProgress((uploadedBytes / fileSize) * 100); } } console.log(`[TikTok] 업로드 완료! Publish ID: ${publishId}`); // Step 3: Check publish status const status = await checkPublishStatus(credentials.accessToken, publishId); // 업로드 히스토리 저장 db.run(` INSERT INTO tiktok_upload_history (user_id, history_id, publish_id, title, privacy_level, status, uploaded_at) VALUES (?, ?, ?, ?, ?, ?, datetime('now')) `, [userId, options.historyId || null, publishId, metadata.title, options.privacyLevel || 'SELF_ONLY', status.status]); return { publishId, status: status.status, videoId: status.video_id }; } catch (error) { console.error('[TikTok] 업로드 오류:', error.response?.data || error.message); // 에러 기록 db.run(` INSERT INTO tiktok_upload_history (user_id, history_id, status, error_message, uploaded_at) VALUES (?, ?, 'failed', ?, datetime('now')) `, [userId, options.historyId || null, error.message]); throw error; } } /** * 비디오 업로드 (Inbox/Draft 방식) * @param {number} userId - 사용자 ID * @param {string} videoPath - 비디오 파일 경로 * @param {object} metadata - 메타데이터 * @param {object} options - 업로드 옵션 */ async function uploadVideoToInbox(userId, videoPath, metadata, options = {}) { try { const credentials = await getAuthenticatedCredentials(userId); const fileSize = fs.statSync(videoPath).size; // Initialize inbox upload const initResponse = await axios.post( TIKTOK_UPLOAD_INBOX_URL, { source_info: { source: 'FILE_UPLOAD', video_size: fileSize, chunk_size: Math.min(fileSize, 10 * 1024 * 1024), total_chunk_count: Math.ceil(fileSize / (10 * 1024 * 1024)) } }, { headers: { 'Authorization': `Bearer ${credentials.accessToken}`, 'Content-Type': 'application/json; charset=UTF-8' } } ); if (initResponse.data.error?.code) { throw new Error(initResponse.data.error.message || 'Inbox upload initialization failed'); } const uploadUrl = initResponse.data.data?.upload_url; const publishId = initResponse.data.data?.publish_id; // Upload file const fileBuffer = fs.readFileSync(videoPath); await axios.put(uploadUrl, fileBuffer, { headers: { 'Content-Type': 'video/mp4', 'Content-Length': fileSize, 'Content-Range': `bytes 0-${fileSize - 1}/${fileSize}` } }); console.log(`[TikTok] Inbox 업로드 완료! Publish ID: ${publishId}`); return { publishId, status: 'uploaded_to_inbox', message: 'TikTok 앱에서 영상을 확인하고 게시해주세요.' }; } catch (error) { console.error('[TikTok] Inbox 업로드 오류:', error.response?.data || error.message); throw error; } } /** * 게시 상태 확인 * @param {string} accessToken - 액세스 토큰 * @param {string} publishId - 게시 ID */ async function checkPublishStatus(accessToken, publishId) { try { const response = await axios.post( TIKTOK_PUBLISH_STATUS_URL, { publish_id: publishId }, { headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' } } ); return response.data.data || { status: 'unknown' }; } catch (error) { console.error('[TikTok] 상태 확인 오류:', error.response?.data || error.message); return { status: 'unknown', error: error.message }; } } /** * 사용자 TikTok 설정 조회 */ function getUserTikTokSettings(userId) { return new Promise((resolve, reject) => { db.get(`SELECT * FROM tiktok_settings WHERE user_id = ?`, [userId], (err, row) => { if (err) reject(err); else resolve(row || { default_privacy: 'SELF_ONLY', disable_duet: 0, disable_comment: 0, disable_stitch: 0, auto_upload: 0, upload_to_inbox: 1 }); }); }); } /** * 사용자 TikTok 설정 업데이트 */ function updateUserTikTokSettings(userId, settings) { return new Promise((resolve, reject) => { const fields = []; const values = []; const allowedFields = [ 'default_privacy', 'disable_duet', 'disable_comment', 'disable_stitch', 'auto_upload', 'upload_to_inbox', 'default_hashtags' ]; for (const field of allowedFields) { if (settings[field] !== undefined) { fields.push(`${field} = ?`); values.push(settings[field]); } } if (fields.length === 0) { resolve({ success: true }); return; } values.push(userId); db.run(` UPDATE tiktok_settings SET ${fields.join(', ')}, updated_at = datetime('now') WHERE user_id = ? `, values, function (err) { if (err) { // INSERT 시도 db.run(` INSERT INTO tiktok_settings (user_id, ${fields.map(f => f.split(' = ')[0]).join(', ')}) VALUES (?, ${fields.map(() => '?').join(', ')}) `, [userId, ...values.slice(0, -1)], function (err2) { if (err2) reject(err2); else resolve({ success: true }); }); } else { resolve({ success: true }); } }); }); } /** * 업로드 히스토리 조회 */ function getUploadHistory(userId, limit = 20) { return new Promise((resolve, reject) => { db.all(` SELECT * FROM tiktok_upload_history WHERE user_id = ? ORDER BY uploaded_at DESC LIMIT ? `, [userId, limit], (err, rows) => { if (err) reject(err); else resolve(rows || []); }); }); } /** * TikTok 통계 조회 (사용자별) */ function getTikTokStats(userId) { return new Promise((resolve, reject) => { db.get(` SELECT COUNT(*) as total_uploads, SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as successful_uploads, SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed_uploads FROM tiktok_upload_history WHERE user_id = ? `, [userId], (err, row) => { if (err) reject(err); else resolve(row || { total_uploads: 0, successful_uploads: 0, failed_uploads: 0 }); }); }); } module.exports = { generateAuthUrl, exchangeCodeForTokens, getAuthenticatedCredentials, getConnectionStatus, disconnectTikTok, uploadVideo, uploadVideoToInbox, checkPublishStatus, getUserTikTokSettings, updateUserTikTokSettings, getUploadHistory, getTikTokStats, TIKTOK_SCOPES };