fromis_9/backend/src/routes/admin/x.js
caadiq 1f1d6987d1 feat(backend): 관리자/봇 라우트에 logActivity 호출 추가
12개 관리자 라우트와 3개 봇 서비스 파일에 활동 로그 기록 추가.
관리자 작업(일정/앨범/멤버/봇 CRUD)과 봇 동기화(완료/에러)를
logs 테이블에 fire-and-forget으로 기록.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:04:07 +09:00

164 lines
5.1 KiB
JavaScript

import { fetchSingleTweet, extractTitle } from '../../services/x/scraper.js';
import { addOrUpdateSchedule } 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: '',
});
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);
}
});
}