캘린더(최다 호출 공개 엔드포인트) getMonthlySchedules를 getOrSet으로 캐시(TTL 60s). 무효화는 모든 쓰기가 수렴하는 3개 meili 동기화 함수 (addOrUpdateSchedule/syncScheduleById/deleteSchedule)에 redis 전달 시 schedule:monthly:* 무효화. 관리자 라우트·봇 경로에서 redis 전달(즉시 반영), festival/누락 경로는 60s TTL로 자동 치유. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
282 lines
9.3 KiB
JavaScript
282 lines
9.3 KiB
JavaScript
import { fetchSingleTweet, extractTitle } from '../../services/x/scraper.js';
|
|
import { addOrUpdateSchedule, syncScheduleById } from '../../services/meilisearch/index.js';
|
|
import { formatDate, formatTime } from '../../utils/date.js';
|
|
import config, { CATEGORY_IDS } from '../../config/index.js';
|
|
import {
|
|
errorResponse,
|
|
xPostInfoQuery,
|
|
xScheduleCreate,
|
|
} from '../../schemas/index.js';
|
|
import { badRequest, conflict, serverError } from '../../utils/error.js';
|
|
import { logActivity } from '../../utils/log.js';
|
|
|
|
const X_CATEGORY_ID = CATEGORY_IDS.X;
|
|
const NITTER_URL = config.nitter?.url || process.env.NITTER_URL || 'http://nitter:8080';
|
|
const DEFAULT_USERNAME = config.x.defaultUsername;
|
|
|
|
/**
|
|
* X(Twitter) 관련 관리자 라우트
|
|
*/
|
|
export default async function xRoutes(fastify) {
|
|
const { db, meilisearch } = fastify;
|
|
|
|
/**
|
|
* GET /api/admin/x/post-info
|
|
* X 게시글 정보 조회
|
|
*/
|
|
fastify.get('/post-info', {
|
|
schema: {
|
|
tags: ['admin/x'],
|
|
summary: 'X 게시글 정보 조회',
|
|
description: 'X(Twitter) 게시글 ID로 정보를 조회합니다.',
|
|
security: [{ bearerAuth: [] }],
|
|
querystring: xPostInfoQuery,
|
|
response: {
|
|
200: {
|
|
type: 'object',
|
|
properties: {
|
|
postId: { type: 'string' },
|
|
username: { type: 'string' },
|
|
text: { type: 'string' },
|
|
title: { type: 'string' },
|
|
imageUrls: { type: 'array', items: { type: 'string' } },
|
|
date: { type: 'string' },
|
|
time: { type: 'string' },
|
|
postUrl: { type: 'string' },
|
|
profile: {
|
|
type: 'object',
|
|
properties: {
|
|
username: { type: 'string' },
|
|
displayName: { type: 'string' },
|
|
avatarUrl: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
400: errorResponse,
|
|
500: errorResponse,
|
|
},
|
|
},
|
|
preHandler: [fastify.authenticate],
|
|
}, async (request, reply) => {
|
|
const { postId, username = DEFAULT_USERNAME } = request.query;
|
|
|
|
// 게시글 ID 유효성 검사
|
|
if (!/^\d+$/.test(postId)) {
|
|
return badRequest(reply, '유효하지 않은 게시글 ID입니다.');
|
|
}
|
|
|
|
try {
|
|
const tweet = await fetchSingleTweet(NITTER_URL, username, postId);
|
|
|
|
return {
|
|
postId: tweet.id,
|
|
username,
|
|
text: tweet.text,
|
|
title: extractTitle(tweet.text),
|
|
imageUrls: tweet.imageUrls,
|
|
date: tweet.time ? formatDate(tweet.time) : null,
|
|
time: tweet.time ? formatTime(tweet.time) : null,
|
|
postUrl: tweet.url,
|
|
profile: tweet.profile,
|
|
};
|
|
} catch (err) {
|
|
fastify.log.error(`X 게시글 조회 오류: ${err.message}`);
|
|
return serverError(reply, err.message);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/admin/x/schedule
|
|
* X 일정 저장
|
|
*/
|
|
fastify.post('/schedule', {
|
|
schema: {
|
|
tags: ['admin/x'],
|
|
summary: 'X 일정 저장',
|
|
description: 'X(Twitter) 게시글을 일정으로 등록합니다.',
|
|
security: [{ bearerAuth: [] }],
|
|
body: xScheduleCreate,
|
|
response: {
|
|
200: {
|
|
type: 'object',
|
|
properties: {
|
|
success: { type: 'boolean' },
|
|
scheduleId: { type: 'integer' },
|
|
},
|
|
},
|
|
409: errorResponse,
|
|
500: errorResponse,
|
|
},
|
|
},
|
|
preHandler: [fastify.authenticate],
|
|
}, async (request, reply) => {
|
|
const { postId, title, content, imageUrls, date, time } = request.body;
|
|
|
|
try {
|
|
// 중복 체크
|
|
const [existing] = await db.query(
|
|
'SELECT id FROM schedule_x WHERE post_id = ?',
|
|
[postId]
|
|
);
|
|
if (existing.length > 0) {
|
|
return conflict(reply, '이미 등록된 게시글입니다.');
|
|
}
|
|
|
|
// schedules 테이블에 저장
|
|
const [result] = await db.query(
|
|
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
|
|
[X_CATEGORY_ID, title, date, time || null]
|
|
);
|
|
const scheduleId = result.insertId;
|
|
|
|
// schedule_x 테이블에 저장
|
|
await db.query(
|
|
'INSERT INTO schedule_x (schedule_id, post_id, content, image_urls) VALUES (?, ?, ?, ?)',
|
|
[scheduleId, postId, content || null, imageUrls?.length > 0 ? JSON.stringify(imageUrls) : null]
|
|
);
|
|
|
|
// Meilisearch 동기화
|
|
const [categoryRows] = await db.query(
|
|
'SELECT name, color FROM schedule_categories WHERE id = ?',
|
|
[X_CATEGORY_ID]
|
|
);
|
|
const category = categoryRows[0] || {};
|
|
|
|
await addOrUpdateSchedule(meilisearch, {
|
|
id: scheduleId,
|
|
title,
|
|
date,
|
|
time: time || '',
|
|
category_id: X_CATEGORY_ID,
|
|
category_name: category.name || '',
|
|
category_color: category.color || '',
|
|
source_name: '',
|
|
}, fastify.redis);
|
|
|
|
logActivity(db, { actor: 'admin', action: 'create', category: 'schedule', targetType: 'x_schedule', targetId: scheduleId, summary: `X 일정 생성: ${title}` });
|
|
return { success: true, scheduleId };
|
|
} catch (err) {
|
|
fastify.log.error(`X 일정 저장 오류: ${err.message}`);
|
|
return serverError(reply, err.message);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/admin/x/refetch-retweets
|
|
* 리트윗 데이터 재수집 (잘못된 content/image_urls 수정)
|
|
*/
|
|
fastify.post('/refetch-retweets', {
|
|
schema: {
|
|
tags: ['admin/x'],
|
|
summary: '리트윗 데이터 재수집',
|
|
description: '잘못 저장된 리트윗 일정을 Nitter에서 다시 가져와 수정합니다.',
|
|
security: [{ bearerAuth: [] }],
|
|
body: {
|
|
type: 'object',
|
|
properties: {
|
|
scheduleIds: {
|
|
type: 'array',
|
|
items: { type: 'integer' },
|
|
description: '재수집할 일정 ID 목록 (비어있으면 전체 리트윗 대상)',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
preHandler: [fastify.authenticate],
|
|
}, async (request, reply) => {
|
|
try {
|
|
let rows;
|
|
const { scheduleIds } = request.body || {};
|
|
|
|
if (scheduleIds && scheduleIds.length > 0) {
|
|
// 특정 일정만
|
|
[rows] = await db.query(
|
|
`SELECT sx.schedule_id, sx.post_id, sx.username, sx.content
|
|
FROM schedule_x sx
|
|
WHERE sx.schedule_id IN (?)`,
|
|
[scheduleIds]
|
|
);
|
|
} else {
|
|
// content가 "RT @"로 시작하거나, image_urls가 NULL이면서 nitter 링크가 있는 일정
|
|
[rows] = await db.query(
|
|
`SELECT sx.schedule_id, sx.post_id, sx.username, sx.content
|
|
FROM schedule_x sx
|
|
WHERE sx.content LIKE 'RT @%'
|
|
OR (sx.content LIKE '%nitter%t.co%')
|
|
OR (sx.image_urls IS NULL AND sx.content LIKE 'RT @%')`
|
|
);
|
|
}
|
|
|
|
if (rows.length === 0) {
|
|
return { success: true, message: '재수집 대상이 없습니다.', updated: 0 };
|
|
}
|
|
|
|
let updated = 0;
|
|
const errors = [];
|
|
|
|
for (const row of rows) {
|
|
try {
|
|
// content에서 원본 작성자 추출 (RT @username: 형식)
|
|
let fetchUsername = row.username || DEFAULT_USERNAME;
|
|
const rtMatch = row.content?.match(/^RT @(\w+):/);
|
|
if (rtMatch) {
|
|
fetchUsername = rtMatch[1];
|
|
}
|
|
|
|
// 원본 작성자의 개별 트윗 페이지에서 가져오기
|
|
const tweet = await fetchSingleTweet(NITTER_URL, fetchUsername, row.post_id);
|
|
|
|
// fetchSingleTweet이 RT @ 형식을 반환하면 RT 프리픽스 제거
|
|
let newContent = tweet.text;
|
|
const rtPrefixMatch = newContent.match(/^RT @\w+:\s*/);
|
|
if (rtPrefixMatch) {
|
|
newContent = newContent.slice(rtPrefixMatch[0].length);
|
|
}
|
|
// 끝의 … 제거
|
|
newContent = newContent.replace(/…$/, '').trim();
|
|
|
|
const newTitle = extractTitle(newContent);
|
|
const newImageUrls = tweet.imageUrls.length > 0 ? JSON.stringify(tweet.imageUrls) : null;
|
|
|
|
// schedules 테이블 업데이트
|
|
await db.query(
|
|
'UPDATE schedules SET title = ? WHERE id = ?',
|
|
[newTitle, row.schedule_id]
|
|
);
|
|
|
|
// schedule_x 테이블 업데이트 (원본 작성자 username도 수정)
|
|
await db.query(
|
|
'UPDATE schedule_x SET username = ?, content = ?, image_urls = ? WHERE schedule_id = ?',
|
|
[fetchUsername, newContent, newImageUrls, row.schedule_id]
|
|
);
|
|
|
|
// Meilisearch 동기화 + 월별 캐시 무효화
|
|
await syncScheduleById(meilisearch, db, row.schedule_id, fastify.redis);
|
|
|
|
updated++;
|
|
fastify.log.info(`리트윗 재수집 완료: schedule_id=${row.schedule_id}, post_id=${row.post_id}`);
|
|
|
|
// Nitter 부하 방지
|
|
await new Promise(r => setTimeout(r, 500));
|
|
} catch (err) {
|
|
errors.push({ scheduleId: row.schedule_id, postId: row.post_id, error: err.message });
|
|
fastify.log.error(`리트윗 재수집 실패 (${row.schedule_id}): ${err.message}`);
|
|
}
|
|
}
|
|
|
|
logActivity(db, {
|
|
actor: 'admin',
|
|
action: 'update',
|
|
category: 'schedule',
|
|
targetType: 'x_schedule',
|
|
summary: `리트윗 재수집: ${updated}/${rows.length}건 완료`,
|
|
});
|
|
|
|
return { success: true, total: rows.length, updated, errors };
|
|
} catch (err) {
|
|
fastify.log.error(`리트윗 재수집 오류: ${err.message}`);
|
|
return serverError(reply, err.message);
|
|
}
|
|
});
|
|
}
|