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