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

607 lines
20 KiB
JavaScript

/**
* 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
};