Compare commits
No commits in common. "0c6d250a9d9ae4eec957eb853d8afd726feb26a8" and "218b825878112dd5e398f7fec25ed29c16a54f79" have entirely different histories.
0c6d250a9d
...
218b825878
272 changed files with 27379 additions and 5041 deletions
22
backend/package-lock.json
generated
22
backend/package-lock.json
generated
|
|
@ -12,7 +12,6 @@
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/jwt": "^10.0.0",
|
"@fastify/jwt": "^10.0.0",
|
||||||
"@fastify/multipart": "^9.3.0",
|
"@fastify/multipart": "^9.3.0",
|
||||||
"@fastify/rate-limit": "^10.3.0",
|
|
||||||
"@fastify/static": "^8.0.0",
|
"@fastify/static": "^8.0.0",
|
||||||
"@fastify/swagger": "^9.0.0",
|
"@fastify/swagger": "^9.0.0",
|
||||||
"@scalar/fastify-api-reference": "^1.25.0",
|
"@scalar/fastify-api-reference": "^1.25.0",
|
||||||
|
|
@ -1152,27 +1151,6 @@
|
||||||
"ipaddr.js": "^2.1.0"
|
"ipaddr.js": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fastify/rate-limit": {
|
|
||||||
"version": "10.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz",
|
|
||||||
"integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/fastify"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/fastify"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@lukeed/ms": "^2.0.2",
|
|
||||||
"fastify-plugin": "^5.0.0",
|
|
||||||
"toad-cache": "^3.7.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@fastify/send": {
|
"node_modules/@fastify/send": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/jwt": "^10.0.0",
|
"@fastify/jwt": "^10.0.0",
|
||||||
"@fastify/multipart": "^9.3.0",
|
"@fastify/multipart": "^9.3.0",
|
||||||
"@fastify/rate-limit": "^10.3.0",
|
|
||||||
"@fastify/static": "^8.0.0",
|
"@fastify/static": "^8.0.0",
|
||||||
"@fastify/swagger": "^9.0.0",
|
"@fastify/swagger": "^9.0.0",
|
||||||
"@scalar/fastify-api-reference": "^1.25.0",
|
"@scalar/fastify-api-reference": "^1.25.0",
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,8 @@ import fastifyCors from '@fastify/cors';
|
||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
import fastifySwagger from '@fastify/swagger';
|
import fastifySwagger from '@fastify/swagger';
|
||||||
import multipart from '@fastify/multipart';
|
import multipart from '@fastify/multipart';
|
||||||
import rateLimit from '@fastify/rate-limit';
|
|
||||||
import config from './config/index.js';
|
import config from './config/index.js';
|
||||||
import * as schemas from './schemas/index.js';
|
import * as schemas from './schemas/index.js';
|
||||||
import { nowKST } from './utils/date.js';
|
|
||||||
|
|
||||||
// 플러그인
|
// 플러그인
|
||||||
import dbPlugin from './plugins/db.js';
|
import dbPlugin from './plugins/db.js';
|
||||||
|
|
@ -52,11 +50,6 @@ export async function buildApp(opts = {}) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// rate-limit 플러그인 등록 (특정 라우트에만 적용)
|
|
||||||
await fastify.register(rateLimit, {
|
|
||||||
global: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 플러그인 등록 (순서 중요)
|
// 플러그인 등록 (순서 중요)
|
||||||
await fastify.register(dbPlugin);
|
await fastify.register(dbPlugin);
|
||||||
await fastify.register(redisPlugin);
|
await fastify.register(redisPlugin);
|
||||||
|
|
@ -117,7 +110,7 @@ export async function buildApp(opts = {}) {
|
||||||
|
|
||||||
// 헬스 체크 엔드포인트
|
// 헬스 체크 엔드포인트
|
||||||
fastify.get('/api/health', async () => {
|
fastify.get('/api/health', async () => {
|
||||||
return { status: 'ok', timestamp: nowKST() };
|
return { status: 'ok', timestamp: new Date().toISOString() };
|
||||||
});
|
});
|
||||||
|
|
||||||
// 봇 상태 조회 엔드포인트
|
// 봇 상태 조회 엔드포인트
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,4 @@
|
||||||
export default [
|
export default [
|
||||||
{
|
|
||||||
id: 'meilisearch-sync',
|
|
||||||
type: 'meilisearch',
|
|
||||||
name: 'Meilisearch 동기화',
|
|
||||||
cron: '0 4 * * *', // 4시부터 5분간 버전 체크, 변경 시 동기화
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'youtube-fromis9',
|
id: 'youtube-fromis9',
|
||||||
type: 'youtube',
|
type: 'youtube',
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,6 @@ export const CATEGORY_IDS = {
|
||||||
BIRTHDAY: 8,
|
BIRTHDAY: 8,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 필수 환경변수 검증
|
|
||||||
const requiredEnvVars = ['JWT_SECRET'];
|
|
||||||
for (const envVar of requiredEnvVars) {
|
|
||||||
if (!process.env[envVar]) {
|
|
||||||
throw new Error(`필수 환경변수 ${envVar}가 설정되지 않았습니다.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
server: {
|
server: {
|
||||||
port: parseInt(process.env.PORT) || 80,
|
port: parseInt(process.env.PORT) || 80,
|
||||||
|
|
@ -42,7 +34,7 @@ export default {
|
||||||
apiKey: process.env.YOUTUBE_API_KEY,
|
apiKey: process.env.YOUTUBE_API_KEY,
|
||||||
},
|
},
|
||||||
jwt: {
|
jwt: {
|
||||||
secret: process.env.JWT_SECRET,
|
secret: process.env.JWT_SECRET || 'fromis9-admin-secret-key-2026',
|
||||||
expiresIn: '30d',
|
expiresIn: '30d',
|
||||||
},
|
},
|
||||||
s3: {
|
s3: {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,8 @@
|
||||||
import fp from 'fastify-plugin';
|
import fp from 'fastify-plugin';
|
||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import bots from '../config/bots.js';
|
import bots from '../config/bots.js';
|
||||||
import { syncWithRetry, getVersion } from '../services/meilisearch/index.js';
|
|
||||||
import { nowKST } from '../utils/date.js';
|
|
||||||
|
|
||||||
const REDIS_PREFIX = 'bot:status:';
|
const REDIS_PREFIX = 'bot:status:';
|
||||||
const TIMEZONE = 'Asia/Seoul';
|
|
||||||
|
|
||||||
async function schedulerPlugin(fastify, opts) {
|
async function schedulerPlugin(fastify, opts) {
|
||||||
const tasks = new Map();
|
const tasks = new Map();
|
||||||
|
|
@ -15,7 +12,7 @@ async function schedulerPlugin(fastify, opts) {
|
||||||
*/
|
*/
|
||||||
async function updateStatus(botId, status) {
|
async function updateStatus(botId, status) {
|
||||||
const current = await getStatus(botId);
|
const current = await getStatus(botId);
|
||||||
const updated = { ...current, ...status, updatedAt: nowKST() };
|
const updated = { ...current, ...status, updatedAt: new Date().toISOString() };
|
||||||
await fastify.redis.set(`${REDIS_PREFIX}${botId}`, JSON.stringify(updated));
|
await fastify.redis.set(`${REDIS_PREFIX}${botId}`, JSON.stringify(updated));
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
@ -33,7 +30,6 @@ async function schedulerPlugin(fastify, opts) {
|
||||||
lastCheckAt: null,
|
lastCheckAt: null,
|
||||||
lastAddedCount: 0,
|
lastAddedCount: 0,
|
||||||
totalAdded: 0,
|
totalAdded: 0,
|
||||||
lastSyncDuration: null,
|
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -46,123 +42,10 @@ async function schedulerPlugin(fastify, opts) {
|
||||||
return fastify.youtubeBot.syncNewVideos;
|
return fastify.youtubeBot.syncNewVideos;
|
||||||
} else if (bot.type === 'x') {
|
} else if (bot.type === 'x') {
|
||||||
return fastify.xBot.syncNewTweets;
|
return fastify.xBot.syncNewTweets;
|
||||||
} else if (bot.type === 'meilisearch') {
|
|
||||||
return async () => {
|
|
||||||
const count = await syncWithRetry(fastify.meilisearch, fastify.db);
|
|
||||||
return { addedCount: count, total: count };
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Meilisearch 버전 체크 및 동기화 (업데이트 감지용)
|
|
||||||
*/
|
|
||||||
async function startMeilisearchVersionCheck(botId, bot) {
|
|
||||||
const REDIS_VERSION_KEY = 'meilisearch:version';
|
|
||||||
const CHECK_INTERVAL = 60 * 1000; // 1분
|
|
||||||
const CHECK_DURATION = 5 * 60 * 1000; // 5분간 체크
|
|
||||||
|
|
||||||
// 체크 시작 cron (매일 4시 KST)
|
|
||||||
const task = cron.schedule(bot.cron, async () => {
|
|
||||||
fastify.log.info(`[${botId}] 버전 체크 시작 (5분간 1분 간격)`);
|
|
||||||
await updateStatus(botId, { status: 'running' });
|
|
||||||
|
|
||||||
const startTime = Date.now();
|
|
||||||
let synced = false;
|
|
||||||
let checkCount = 0;
|
|
||||||
|
|
||||||
// 초기 버전 저장
|
|
||||||
const initialVersion = await getVersion(fastify.meilisearch);
|
|
||||||
if (!initialVersion) {
|
|
||||||
fastify.log.error(`[${botId}] Meilisearch 연결 실패`);
|
|
||||||
await updateStatus(botId, { status: 'error', errorMessage: 'Meilisearch 연결 실패' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const savedVersion = await fastify.redis.get(REDIS_VERSION_KEY);
|
|
||||||
fastify.log.info(`[${botId}] 현재 버전: ${initialVersion}, 저장된 버전: ${savedVersion || '없음'}`);
|
|
||||||
|
|
||||||
// 버전이 이미 다르면 즉시 동기화
|
|
||||||
if (savedVersion && savedVersion !== initialVersion) {
|
|
||||||
fastify.log.info(`[${botId}] 버전 변경 감지! ${savedVersion} → ${initialVersion}`);
|
|
||||||
await performSync(botId, initialVersion, REDIS_VERSION_KEY);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5분간 1분 간격으로 체크
|
|
||||||
const intervalId = setInterval(async () => {
|
|
||||||
checkCount++;
|
|
||||||
const elapsed = Date.now() - startTime;
|
|
||||||
|
|
||||||
if (synced || elapsed >= CHECK_DURATION) {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
if (!synced) {
|
|
||||||
fastify.log.info(`[${botId}] 버전 변경 없음, 체크 종료`);
|
|
||||||
await updateStatus(botId, {
|
|
||||||
status: 'running',
|
|
||||||
lastCheckAt: nowKST(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentVersion = await getVersion(fastify.meilisearch);
|
|
||||||
fastify.log.info(`[${botId}] 체크 #${checkCount}: 버전 ${currentVersion}`);
|
|
||||||
|
|
||||||
if (currentVersion && currentVersion !== initialVersion) {
|
|
||||||
synced = true;
|
|
||||||
clearInterval(intervalId);
|
|
||||||
fastify.log.info(`[${botId}] 버전 변경 감지! ${initialVersion} → ${currentVersion}`);
|
|
||||||
await performSync(botId, currentVersion, REDIS_VERSION_KEY);
|
|
||||||
}
|
|
||||||
}, CHECK_INTERVAL);
|
|
||||||
}, { timezone: TIMEZONE });
|
|
||||||
|
|
||||||
tasks.set(botId, task);
|
|
||||||
await updateStatus(botId, { status: 'running' });
|
|
||||||
fastify.log.info(`[${botId}] 버전 체크 스케줄 시작 (cron: ${bot.cron})`);
|
|
||||||
|
|
||||||
// 초기 버전 저장 (최초 실행 시)
|
|
||||||
const currentVersion = await getVersion(fastify.meilisearch);
|
|
||||||
if (currentVersion) {
|
|
||||||
const savedVersion = await fastify.redis.get(REDIS_VERSION_KEY);
|
|
||||||
if (!savedVersion) {
|
|
||||||
await fastify.redis.set(REDIS_VERSION_KEY, currentVersion);
|
|
||||||
fastify.log.info(`[${botId}] 초기 버전 저장: ${currentVersion}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 동기화 실행 및 상태 업데이트
|
|
||||||
*/
|
|
||||||
async function performSync(botId, newVersion, versionKey) {
|
|
||||||
const startTime = Date.now();
|
|
||||||
try {
|
|
||||||
const count = await syncWithRetry(fastify.meilisearch, fastify.db);
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
await fastify.redis.set(versionKey, newVersion);
|
|
||||||
await updateStatus(botId, {
|
|
||||||
status: 'running',
|
|
||||||
lastCheckAt: nowKST(),
|
|
||||||
lastAddedCount: count,
|
|
||||||
lastSyncDuration: duration,
|
|
||||||
errorMessage: null,
|
|
||||||
});
|
|
||||||
fastify.log.info(`[${botId}] 동기화 완료: ${count}개, ${duration}ms, 새 버전: ${newVersion}`);
|
|
||||||
} catch (err) {
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
await updateStatus(botId, {
|
|
||||||
status: 'error',
|
|
||||||
lastCheckAt: nowKST(),
|
|
||||||
lastSyncDuration: duration,
|
|
||||||
errorMessage: err.message,
|
|
||||||
});
|
|
||||||
fastify.log.error(`[${botId}] 동기화 오류: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 동기화 결과 처리 (중복 코드 제거)
|
* 동기화 결과 처리 (중복 코드 제거)
|
||||||
*/
|
*/
|
||||||
|
|
@ -170,7 +53,7 @@ async function schedulerPlugin(fastify, opts) {
|
||||||
const { setRunningStatus = false, setErrorOnFail = false } = options;
|
const { setRunningStatus = false, setErrorOnFail = false } = options;
|
||||||
const status = await getStatus(botId);
|
const status = await getStatus(botId);
|
||||||
const updateData = {
|
const updateData = {
|
||||||
lastCheckAt: nowKST(),
|
lastCheckAt: new Date().toISOString(),
|
||||||
totalAdded: (status.totalAdded || 0) + result.addedCount,
|
totalAdded: (status.totalAdded || 0) + result.addedCount,
|
||||||
};
|
};
|
||||||
if (setRunningStatus) {
|
if (setRunningStatus) {
|
||||||
|
|
@ -199,18 +82,12 @@ async function schedulerPlugin(fastify, opts) {
|
||||||
tasks.delete(botId);
|
tasks.delete(botId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Meilisearch는 버전 체크 방식 사용
|
|
||||||
if (bot.type === 'meilisearch') {
|
|
||||||
await startMeilisearchVersionCheck(botId, bot);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const syncFn = getSyncFunction(bot);
|
const syncFn = getSyncFunction(bot);
|
||||||
if (!syncFn) {
|
if (!syncFn) {
|
||||||
throw new Error(`지원하지 않는 봇 타입: ${bot.type}`);
|
throw new Error(`지원하지 않는 봇 타입: ${bot.type}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// cron 태스크 등록 (한국 시간 기준)
|
// cron 태스크 등록
|
||||||
const task = cron.schedule(bot.cron, async () => {
|
const task = cron.schedule(bot.cron, async () => {
|
||||||
fastify.log.info(`[${botId}] 동기화 시작`);
|
fastify.log.info(`[${botId}] 동기화 시작`);
|
||||||
try {
|
try {
|
||||||
|
|
@ -220,26 +97,24 @@ async function schedulerPlugin(fastify, opts) {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await updateStatus(botId, {
|
await updateStatus(botId, {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
lastCheckAt: nowKST(),
|
lastCheckAt: new Date().toISOString(),
|
||||||
errorMessage: err.message,
|
errorMessage: err.message,
|
||||||
});
|
});
|
||||||
fastify.log.error(`[${botId}] 동기화 오류: ${err.message}`);
|
fastify.log.error(`[${botId}] 동기화 오류: ${err.message}`);
|
||||||
}
|
}
|
||||||
}, { timezone: TIMEZONE });
|
});
|
||||||
|
|
||||||
tasks.set(botId, task);
|
tasks.set(botId, task);
|
||||||
await updateStatus(botId, { status: 'running' });
|
await updateStatus(botId, { status: 'running' });
|
||||||
fastify.log.info(`[${botId}] 스케줄 시작 (cron: ${bot.cron})`);
|
fastify.log.info(`[${botId}] 스케줄 시작 (cron: ${bot.cron})`);
|
||||||
|
|
||||||
// 즉시 1회 실행 (meilisearch는 스케줄 시간에만 실행)
|
// 즉시 1회 실행
|
||||||
if (bot.type !== 'meilisearch') {
|
try {
|
||||||
try {
|
const result = await syncFn(bot);
|
||||||
const result = await syncFn(bot);
|
const addedCount = await handleSyncResult(botId, result);
|
||||||
const addedCount = await handleSyncResult(botId, result);
|
fastify.log.info(`[${botId}] 초기 동기화 완료: ${addedCount}개 추가`);
|
||||||
fastify.log.info(`[${botId}] 초기 동기화 완료: ${addedCount}개 추가`);
|
} catch (err) {
|
||||||
} catch (err) {
|
fastify.log.error(`[${botId}] 초기 동기화 오류: ${err.message}`);
|
||||||
fastify.log.error(`[${botId}] 초기 동기화 오류: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -300,5 +175,5 @@ async function schedulerPlugin(fastify, opts) {
|
||||||
|
|
||||||
export default fp(schedulerPlugin, {
|
export default fp(schedulerPlugin, {
|
||||||
name: 'scheduler',
|
name: 'scheduler',
|
||||||
dependencies: ['db', 'redis', 'meilisearch', 'youtubeBot', 'xBot'],
|
dependencies: ['db', 'redis', 'youtubeBot', 'xBot'],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
import bots from '../../config/bots.js';
|
import bots from '../../config/bots.js';
|
||||||
import { errorResponse } from '../../schemas/index.js';
|
import { errorResponse } from '../../schemas/index.js';
|
||||||
import { syncAllSchedules } from '../../services/meilisearch/index.js';
|
|
||||||
import { badRequest, notFound, serverError } from '../../utils/error.js';
|
|
||||||
import { nowKST } from '../../utils/date.js';
|
|
||||||
|
|
||||||
// 봇 관련 스키마
|
// 봇 관련 스키마
|
||||||
const botResponse = {
|
const botResponse = {
|
||||||
|
|
@ -10,11 +7,10 @@ const botResponse = {
|
||||||
properties: {
|
properties: {
|
||||||
id: { type: 'string' },
|
id: { type: 'string' },
|
||||||
name: { type: 'string' },
|
name: { type: 'string' },
|
||||||
type: { type: 'string', enum: ['youtube', 'x', 'meilisearch'] },
|
type: { type: 'string', enum: ['youtube', 'x'] },
|
||||||
status: { type: 'string', enum: ['running', 'stopped', 'error'] },
|
status: { type: 'string', enum: ['running', 'stopped', 'error'] },
|
||||||
last_check_at: { type: 'string', format: 'date-time' },
|
last_check_at: { type: 'string', format: 'date-time' },
|
||||||
last_added_count: { type: 'integer' },
|
last_added_count: { type: 'integer' },
|
||||||
last_sync_duration: { type: 'integer', description: '마지막 동기화 소요 시간 (ms)' },
|
|
||||||
schedules_added: { type: 'integer' },
|
schedules_added: { type: 'integer' },
|
||||||
check_interval: { type: 'integer' },
|
check_interval: { type: 'integer' },
|
||||||
error_message: { type: 'string' },
|
error_message: { type: 'string' },
|
||||||
|
|
@ -69,27 +65,18 @@ export default async function botsRoutes(fastify) {
|
||||||
checkInterval = parseInt(cronMatch[1]);
|
checkInterval = parseInt(cronMatch[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const botData = {
|
result.push({
|
||||||
id: bot.id,
|
id: bot.id,
|
||||||
name: bot.name || bot.channelName || bot.username || bot.id,
|
name: bot.channelName || bot.username || bot.id,
|
||||||
type: bot.type,
|
type: bot.type,
|
||||||
status: status.status,
|
status: status.status,
|
||||||
last_check_at: status.lastCheckAt,
|
last_check_at: status.lastCheckAt,
|
||||||
last_added_count: status.lastAddedCount,
|
last_added_count: status.lastAddedCount,
|
||||||
last_sync_duration: status.lastSyncDuration,
|
|
||||||
schedules_added: status.totalAdded,
|
schedules_added: status.totalAdded,
|
||||||
check_interval: checkInterval,
|
check_interval: checkInterval,
|
||||||
error_message: status.errorMessage,
|
error_message: status.errorMessage,
|
||||||
enabled: bot.enabled,
|
enabled: bot.enabled,
|
||||||
};
|
});
|
||||||
|
|
||||||
// Meilisearch 봇인 경우 버전 정보 추가
|
|
||||||
if (bot.type === 'meilisearch') {
|
|
||||||
const version = await redis.get('meilisearch:version');
|
|
||||||
botData.version = version || '-';
|
|
||||||
}
|
|
||||||
|
|
||||||
result.push(botData);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -125,7 +112,7 @@ export default async function botsRoutes(fastify) {
|
||||||
await scheduler.startBot(id);
|
await scheduler.startBot(id);
|
||||||
return { success: true, message: '봇이 시작되었습니다.' };
|
return { success: true, message: '봇이 시작되었습니다.' };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return badRequest(reply, err.message);
|
return reply.code(400).send({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -159,7 +146,7 @@ export default async function botsRoutes(fastify) {
|
||||||
await scheduler.stopBot(id);
|
await scheduler.stopBot(id);
|
||||||
return { success: true, message: '봇이 정지되었습니다.' };
|
return { success: true, message: '봇이 정지되었습니다.' };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return badRequest(reply, err.message);
|
return reply.code(400).send({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -194,34 +181,27 @@ export default async function botsRoutes(fastify) {
|
||||||
|
|
||||||
const bot = bots.find(b => b.id === id);
|
const bot = bots.find(b => b.id === id);
|
||||||
if (!bot) {
|
if (!bot) {
|
||||||
return notFound(reply, '봇을 찾을 수 없습니다.');
|
return reply.code(404).send({ error: '봇을 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const startTime = Date.now();
|
|
||||||
try {
|
try {
|
||||||
let result;
|
let result;
|
||||||
if (bot.type === 'youtube') {
|
if (bot.type === 'youtube') {
|
||||||
result = await fastify.youtubeBot.syncAllVideos(bot);
|
result = await fastify.youtubeBot.syncAllVideos(bot);
|
||||||
} else if (bot.type === 'x') {
|
} else if (bot.type === 'x') {
|
||||||
result = await fastify.xBot.syncAllTweets(bot);
|
result = await fastify.xBot.syncAllTweets(bot);
|
||||||
} else if (bot.type === 'meilisearch') {
|
|
||||||
const count = await syncAllSchedules(fastify.meilisearch, fastify.db);
|
|
||||||
result = { addedCount: count, total: count };
|
|
||||||
} else {
|
} else {
|
||||||
return badRequest(reply, '지원하지 않는 봇 타입입니다.');
|
return reply.code(400).send({ error: '지원하지 않는 봇 타입입니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
|
|
||||||
// 상태 업데이트
|
// 상태 업데이트
|
||||||
const status = await scheduler.getStatus(id);
|
const status = await scheduler.getStatus(id);
|
||||||
await fastify.redis.set(`bot:status:${id}`, JSON.stringify({
|
await fastify.redis.set(`bot:status:${id}`, JSON.stringify({
|
||||||
...status,
|
...status,
|
||||||
lastCheckAt: nowKST(),
|
lastCheckAt: new Date().toISOString(),
|
||||||
lastAddedCount: result.addedCount,
|
lastAddedCount: result.addedCount,
|
||||||
lastSyncDuration: duration,
|
|
||||||
totalAdded: (status.totalAdded || 0) + result.addedCount,
|
totalAdded: (status.totalAdded || 0) + result.addedCount,
|
||||||
updatedAt: nowKST(),
|
updatedAt: new Date().toISOString(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -231,7 +211,7 @@ export default async function botsRoutes(fastify) {
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fastify.log.error(`[${id}] 전체 동기화 오류:`, err);
|
fastify.log.error(`[${id}] 전체 동기화 오류:`, err);
|
||||||
return serverError(reply, err.message);
|
return reply.code(500).send({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import {
|
||||||
xPostInfoQuery,
|
xPostInfoQuery,
|
||||||
xScheduleCreate,
|
xScheduleCreate,
|
||||||
} from '../../schemas/index.js';
|
} from '../../schemas/index.js';
|
||||||
import { badRequest, conflict, serverError } from '../../utils/error.js';
|
|
||||||
|
|
||||||
const X_CATEGORY_ID = CATEGORY_IDS.X;
|
const X_CATEGORY_ID = CATEGORY_IDS.X;
|
||||||
const NITTER_URL = config.nitter?.url || process.env.NITTER_URL || 'http://nitter:8080';
|
const NITTER_URL = config.nitter?.url || process.env.NITTER_URL || 'http://nitter:8080';
|
||||||
|
|
@ -62,7 +61,7 @@ export default async function xRoutes(fastify) {
|
||||||
|
|
||||||
// 게시글 ID 유효성 검사
|
// 게시글 ID 유효성 검사
|
||||||
if (!/^\d+$/.test(postId)) {
|
if (!/^\d+$/.test(postId)) {
|
||||||
return badRequest(reply, '유효하지 않은 게시글 ID입니다.');
|
return reply.code(400).send({ error: '유효하지 않은 게시글 ID입니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -81,7 +80,7 @@ export default async function xRoutes(fastify) {
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fastify.log.error(`X 게시글 조회 오류: ${err.message}`);
|
fastify.log.error(`X 게시글 조회 오류: ${err.message}`);
|
||||||
return serverError(reply, err.message);
|
return reply.code(500).send({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -119,7 +118,7 @@ export default async function xRoutes(fastify) {
|
||||||
[postId]
|
[postId]
|
||||||
);
|
);
|
||||||
if (existing.length > 0) {
|
if (existing.length > 0) {
|
||||||
return conflict(reply, '이미 등록된 게시글입니다.');
|
return reply.code(409).send({ error: '이미 등록된 게시글입니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// schedules 테이블에 저장
|
// schedules 테이블에 저장
|
||||||
|
|
@ -156,7 +155,7 @@ export default async function xRoutes(fastify) {
|
||||||
return { success: true, scheduleId };
|
return { success: true, scheduleId };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fastify.log.error(`X 일정 저장 오류: ${err.message}`);
|
fastify.log.error(`X 일정 저장 오류: ${err.message}`);
|
||||||
return serverError(reply, err.message);
|
return reply.code(500).send({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import {
|
||||||
youtubeScheduleUpdate,
|
youtubeScheduleUpdate,
|
||||||
idParam,
|
idParam,
|
||||||
} from '../../schemas/index.js';
|
} from '../../schemas/index.js';
|
||||||
import { badRequest, notFound, conflict, serverError } from '../../utils/error.js';
|
|
||||||
|
|
||||||
const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE;
|
const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE;
|
||||||
|
|
||||||
|
|
@ -55,13 +54,13 @@ export default async function youtubeRoutes(fastify) {
|
||||||
// YouTube URL에서 video ID 추출
|
// YouTube URL에서 video ID 추출
|
||||||
const videoId = extractVideoId(url);
|
const videoId = extractVideoId(url);
|
||||||
if (!videoId) {
|
if (!videoId) {
|
||||||
return badRequest(reply, '유효하지 않은 YouTube URL입니다.');
|
return reply.code(400).send({ error: '유효하지 않은 YouTube URL입니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const video = await fetchVideoInfo(videoId);
|
const video = await fetchVideoInfo(videoId);
|
||||||
if (!video) {
|
if (!video) {
|
||||||
return notFound(reply, '영상을 찾을 수 없습니다.');
|
return reply.code(404).send({ error: '영상을 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -77,7 +76,7 @@ export default async function youtubeRoutes(fastify) {
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fastify.log.error(`YouTube 영상 조회 오류: ${err.message}`);
|
fastify.log.error(`YouTube 영상 조회 오류: ${err.message}`);
|
||||||
return serverError(reply, err.message);
|
return reply.code(500).send({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -115,7 +114,7 @@ export default async function youtubeRoutes(fastify) {
|
||||||
[videoId]
|
[videoId]
|
||||||
);
|
);
|
||||||
if (existing.length > 0) {
|
if (existing.length > 0) {
|
||||||
return conflict(reply, '이미 등록된 영상입니다.');
|
return reply.code(409).send({ error: '이미 등록된 영상입니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// schedules 테이블에 저장
|
// schedules 테이블에 저장
|
||||||
|
|
@ -152,7 +151,7 @@ export default async function youtubeRoutes(fastify) {
|
||||||
return { success: true, scheduleId };
|
return { success: true, scheduleId };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fastify.log.error(`YouTube 일정 저장 오류: ${err.message}`);
|
fastify.log.error(`YouTube 일정 저장 오류: ${err.message}`);
|
||||||
return serverError(reply, err.message);
|
return reply.code(500).send({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -191,7 +190,7 @@ export default async function youtubeRoutes(fastify) {
|
||||||
[id, YOUTUBE_CATEGORY_ID]
|
[id, YOUTUBE_CATEGORY_ID]
|
||||||
);
|
);
|
||||||
if (schedules.length === 0) {
|
if (schedules.length === 0) {
|
||||||
return notFound(reply, 'YouTube 일정을 찾을 수 없습니다.');
|
return reply.code(404).send({ error: 'YouTube 일정을 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 영상 유형 수정
|
// 영상 유형 수정
|
||||||
|
|
@ -255,7 +254,7 @@ export default async function youtubeRoutes(fastify) {
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fastify.log.error(`YouTube 일정 수정 오류: ${err.message}`);
|
fastify.log.error(`YouTube 일정 수정 오류: ${err.message}`);
|
||||||
return serverError(reply, err.message);
|
return reply.code(500).send({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import {
|
||||||
import photosRoutes from './photos.js';
|
import photosRoutes from './photos.js';
|
||||||
import teasersRoutes from './teasers.js';
|
import teasersRoutes from './teasers.js';
|
||||||
import { errorResponse, successResponse, idParam } from '../../schemas/index.js';
|
import { errorResponse, successResponse, idParam } from '../../schemas/index.js';
|
||||||
import { notFound, badRequest } from '../../utils/error.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 앨범 라우트
|
* 앨범 라우트
|
||||||
|
|
@ -68,7 +67,7 @@ export default async function albumsRoutes(fastify) {
|
||||||
|
|
||||||
const album = await getAlbumByName(db, albumName);
|
const album = await getAlbumByName(db, albumName);
|
||||||
if (!album) {
|
if (!album) {
|
||||||
return notFound(reply, '앨범을 찾을 수 없습니다.');
|
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const [tracks] = await db.query(
|
const [tracks] = await db.query(
|
||||||
|
|
@ -77,7 +76,7 @@ export default async function albumsRoutes(fastify) {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (tracks.length === 0) {
|
if (tracks.length === 0) {
|
||||||
return notFound(reply, '트랙을 찾을 수 없습니다.');
|
return reply.code(404).send({ error: '트랙을 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const track = tracks[0];
|
const track = tracks[0];
|
||||||
|
|
@ -124,7 +123,7 @@ export default async function albumsRoutes(fastify) {
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const album = await getAlbumByName(db, decodeURIComponent(request.params.name));
|
const album = await getAlbumByName(db, decodeURIComponent(request.params.name));
|
||||||
if (!album) {
|
if (!album) {
|
||||||
return notFound(reply, '앨범을 찾을 수 없습니다.');
|
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
return getAlbumDetails(db, album, redis);
|
return getAlbumDetails(db, album, redis);
|
||||||
});
|
});
|
||||||
|
|
@ -145,7 +144,7 @@ export default async function albumsRoutes(fastify) {
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const album = await getAlbumById(db, request.params.id);
|
const album = await getAlbumById(db, request.params.id);
|
||||||
if (!album) {
|
if (!album) {
|
||||||
return notFound(reply, '앨범을 찾을 수 없습니다.');
|
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
return getAlbumDetails(db, album, redis);
|
return getAlbumDetails(db, album, redis);
|
||||||
});
|
});
|
||||||
|
|
@ -183,22 +182,18 @@ export default async function albumsRoutes(fastify) {
|
||||||
if (part.type === 'file' && part.fieldname === 'cover') {
|
if (part.type === 'file' && part.fieldname === 'cover') {
|
||||||
coverBuffer = await part.toBuffer();
|
coverBuffer = await part.toBuffer();
|
||||||
} else if (part.fieldname === 'data') {
|
} else if (part.fieldname === 'data') {
|
||||||
try {
|
data = JSON.parse(part.value);
|
||||||
data = JSON.parse(part.value);
|
|
||||||
} catch {
|
|
||||||
return badRequest(reply, '잘못된 JSON 형식입니다.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return badRequest(reply, '데이터가 필요합니다.');
|
return reply.code(400).send({ error: '데이터가 필요합니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, album_type, release_date, folder_name } = data;
|
const { title, album_type, release_date, folder_name } = data;
|
||||||
|
|
||||||
if (!title || !album_type || !release_date || !folder_name) {
|
if (!title || !album_type || !release_date || !folder_name) {
|
||||||
return badRequest(reply, '필수 필드를 모두 입력해주세요.');
|
return reply.code(400).send({ error: '필수 필드를 모두 입력해주세요.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await createAlbum(db, data, coverBuffer);
|
const result = await createAlbum(db, data, coverBuffer);
|
||||||
|
|
@ -234,21 +229,17 @@ export default async function albumsRoutes(fastify) {
|
||||||
if (part.type === 'file' && part.fieldname === 'cover') {
|
if (part.type === 'file' && part.fieldname === 'cover') {
|
||||||
coverBuffer = await part.toBuffer();
|
coverBuffer = await part.toBuffer();
|
||||||
} else if (part.fieldname === 'data') {
|
} else if (part.fieldname === 'data') {
|
||||||
try {
|
data = JSON.parse(part.value);
|
||||||
data = JSON.parse(part.value);
|
|
||||||
} catch {
|
|
||||||
return badRequest(reply, '잘못된 JSON 형식입니다.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return badRequest(reply, '데이터가 필요합니다.');
|
return reply.code(400).send({ error: '데이터가 필요합니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await updateAlbum(db, id, data, coverBuffer);
|
const result = await updateAlbum(db, id, data, coverBuffer);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return notFound(reply, '앨범을 찾을 수 없습니다.');
|
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
await invalidateAlbumCache(redis, id);
|
await invalidateAlbumCache(redis, id);
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -274,7 +265,7 @@ export default async function albumsRoutes(fastify) {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
const result = await deleteAlbum(db, id);
|
const result = await deleteAlbum(db, id);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return notFound(reply, '앨범을 찾을 수 없습니다.');
|
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
await invalidateAlbumCache(redis, id);
|
await invalidateAlbumCache(redis, id);
|
||||||
return result;
|
return result;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import {
|
||||||
uploadAlbumVideo,
|
uploadAlbumVideo,
|
||||||
} from '../../services/image.js';
|
} from '../../services/image.js';
|
||||||
import { withTransaction } from '../../utils/transaction.js';
|
import { withTransaction } from '../../utils/transaction.js';
|
||||||
import { notFound } from '../../utils/error.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 앨범 사진 라우트
|
* 앨범 사진 라우트
|
||||||
|
|
@ -26,7 +25,7 @@ export default async function photosRoutes(fastify) {
|
||||||
|
|
||||||
const [albums] = await db.query('SELECT folder_name FROM albums WHERE id = ?', [albumId]);
|
const [albums] = await db.query('SELECT folder_name FROM albums WHERE id = ?', [albumId]);
|
||||||
if (albums.length === 0) {
|
if (albums.length === 0) {
|
||||||
return notFound(reply, '앨범을 찾을 수 없습니다.');
|
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const [photos] = await db.query(
|
const [photos] = await db.query(
|
||||||
|
|
@ -97,13 +96,7 @@ export default async function photosRoutes(fastify) {
|
||||||
const buffer = await part.toBuffer();
|
const buffer = await part.toBuffer();
|
||||||
files.push({ buffer, mimetype: part.mimetype });
|
files.push({ buffer, mimetype: part.mimetype });
|
||||||
} else if (part.fieldname === 'metadata') {
|
} else if (part.fieldname === 'metadata') {
|
||||||
try {
|
metadata = JSON.parse(part.value);
|
||||||
metadata = JSON.parse(part.value);
|
|
||||||
} catch {
|
|
||||||
reply.raw.write(`data: ${JSON.stringify({ error: '잘못된 metadata JSON 형식입니다.' })}\n\n`);
|
|
||||||
reply.raw.end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else if (part.fieldname === 'startNumber') {
|
} else if (part.fieldname === 'startNumber') {
|
||||||
startNumber = parseInt(part.value) || null;
|
startNumber = parseInt(part.value) || null;
|
||||||
} else if (part.fieldname === 'photoType') {
|
} else if (part.fieldname === 'photoType') {
|
||||||
|
|
@ -234,7 +227,7 @@ export default async function photosRoutes(fastify) {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (photos.length === 0) {
|
if (photos.length === 0) {
|
||||||
return notFound(reply, '사진을 찾을 수 없습니다.');
|
return reply.code(404).send({ error: '사진을 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const photo = photos[0];
|
const photo = photos[0];
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import {
|
||||||
deleteAlbumVideo,
|
deleteAlbumVideo,
|
||||||
} from '../../services/image.js';
|
} from '../../services/image.js';
|
||||||
import { withTransaction } from '../../utils/transaction.js';
|
import { withTransaction } from '../../utils/transaction.js';
|
||||||
import { notFound } from '../../utils/error.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 앨범 티저 라우트
|
* 앨범 티저 라우트
|
||||||
|
|
@ -25,7 +24,7 @@ export default async function teasersRoutes(fastify) {
|
||||||
|
|
||||||
const [albums] = await db.query('SELECT folder_name FROM albums WHERE id = ?', [albumId]);
|
const [albums] = await db.query('SELECT folder_name FROM albums WHERE id = ?', [albumId]);
|
||||||
if (albums.length === 0) {
|
if (albums.length === 0) {
|
||||||
return notFound(reply, '앨범을 찾을 수 없습니다.');
|
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const [teasers] = await db.query(
|
const [teasers] = await db.query(
|
||||||
|
|
@ -62,7 +61,7 @@ export default async function teasersRoutes(fastify) {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (teasers.length === 0) {
|
if (teasers.length === 0) {
|
||||||
return notFound(reply, '티저를 찾을 수 없습니다.');
|
return reply.code(404).send({ error: '티저를 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const teaser = teasers[0];
|
const teaser = teasers[0];
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import { badRequest, unauthorized, serverError } from '../utils/error.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 인증 라우트
|
* 인증 라우트
|
||||||
|
|
@ -11,19 +10,6 @@ export default async function authRoutes(fastify, opts) {
|
||||||
* 관리자 로그인
|
* 관리자 로그인
|
||||||
*/
|
*/
|
||||||
fastify.post('/login', {
|
fastify.post('/login', {
|
||||||
config: {
|
|
||||||
rateLimit: {
|
|
||||||
max: 5,
|
|
||||||
timeWindow: '1 minute',
|
|
||||||
continueExceeding: true, // 차단 중 시도하면 타이머 리셋 (마지막 시도 기준 1분)
|
|
||||||
keyGenerator: (request) => request.ip,
|
|
||||||
errorResponseBuilder: () => ({
|
|
||||||
statusCode: 429,
|
|
||||||
error: 'Too Many Requests',
|
|
||||||
message: '로그인 시도가 너무 많습니다. 잠시 후 다시 시도해주세요.',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
schema: {
|
schema: {
|
||||||
tags: ['auth'],
|
tags: ['auth'],
|
||||||
summary: '관리자 로그인',
|
summary: '관리자 로그인',
|
||||||
|
|
@ -50,21 +36,13 @@ export default async function authRoutes(fastify, opts) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
429: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
statusCode: { type: 'integer' },
|
|
||||||
error: { type: 'string' },
|
|
||||||
message: { type: 'string' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { username, password } = request.body || {};
|
const { username, password } = request.body || {};
|
||||||
|
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
return badRequest(reply, '아이디와 비밀번호를 입력해주세요.');
|
return reply.code(400).send({ error: '아이디와 비밀번호를 입력해주세요.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -74,14 +52,14 @@ export default async function authRoutes(fastify, opts) {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (users.length === 0) {
|
if (users.length === 0) {
|
||||||
return unauthorized(reply, '아이디 또는 비밀번호가 올바르지 않습니다.');
|
return reply.code(401).send({ error: '아이디 또는 비밀번호가 올바르지 않습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = users[0];
|
const user = users[0];
|
||||||
const isValidPassword = await bcrypt.compare(password, user.password_hash);
|
const isValidPassword = await bcrypt.compare(password, user.password_hash);
|
||||||
|
|
||||||
if (!isValidPassword) {
|
if (!isValidPassword) {
|
||||||
return unauthorized(reply, '아이디 또는 비밀번호가 올바르지 않습니다.');
|
return reply.code(401).send({ error: '아이디 또는 비밀번호가 올바르지 않습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// JWT 토큰 생성
|
// JWT 토큰 생성
|
||||||
|
|
@ -97,7 +75,7 @@ export default async function authRoutes(fastify, opts) {
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fastify.log.error(err);
|
fastify.log.error(err);
|
||||||
return serverError(reply, '로그인 처리 중 오류가 발생했습니다.');
|
return reply.code(500).send({ error: '로그인 처리 중 오류가 발생했습니다.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { uploadMemberImage } from '../../services/image.js';
|
import { uploadMemberImage } from '../../services/image.js';
|
||||||
import { getAllMembers, getMemberByName, getMemberBasicByName, invalidateMemberCache } from '../../services/member.js';
|
import { getAllMembers, getMemberByName, getMemberBasicByName, invalidateMemberCache } from '../../services/member.js';
|
||||||
import { notFound, serverError } from '../../utils/error.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 멤버 라우트
|
* 멤버 라우트
|
||||||
|
|
@ -23,7 +22,7 @@ export default async function membersRoutes(fastify, opts) {
|
||||||
return await getAllMembers(db, redis);
|
return await getAllMembers(db, redis);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fastify.log.error(err);
|
fastify.log.error(err);
|
||||||
return serverError(reply, '멤버 목록 조회 실패');
|
return reply.code(500).send({ error: '멤버 목록 조회 실패' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -46,12 +45,12 @@ export default async function membersRoutes(fastify, opts) {
|
||||||
try {
|
try {
|
||||||
const member = await getMemberByName(db, decodeURIComponent(request.params.name));
|
const member = await getMemberByName(db, decodeURIComponent(request.params.name));
|
||||||
if (!member) {
|
if (!member) {
|
||||||
return notFound(reply, '멤버를 찾을 수 없습니다');
|
return reply.code(404).send({ error: '멤버를 찾을 수 없습니다' });
|
||||||
}
|
}
|
||||||
return member;
|
return member;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fastify.log.error(err);
|
fastify.log.error(err);
|
||||||
return serverError(reply, '멤버 조회 실패');
|
return reply.code(500).send({ error: '멤버 조회 실패' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -81,7 +80,7 @@ export default async function membersRoutes(fastify, opts) {
|
||||||
// 기존 멤버 조회
|
// 기존 멤버 조회
|
||||||
const existing = await getMemberBasicByName(db, decodedName);
|
const existing = await getMemberBasicByName(db, decodedName);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
return notFound(reply, '멤버를 찾을 수 없습니다');
|
return reply.code(404).send({ error: '멤버를 찾을 수 없습니다' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const memberId = existing.id;
|
const memberId = existing.id;
|
||||||
|
|
@ -162,7 +161,7 @@ export default async function membersRoutes(fastify, opts) {
|
||||||
return { message: '멤버 정보가 수정되었습니다', id: memberId };
|
return { message: '멤버 정보가 수정되었습니다', id: memberId };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fastify.log.error(err);
|
fastify.log.error(err);
|
||||||
return serverError(reply, '멤버 수정 실패: ' + err.message);
|
return reply.code(500).send({ error: '멤버 수정 실패: ' + err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
* GET: 공개, POST/PUT/DELETE: 인증 필요
|
* GET: 공개, POST/PUT/DELETE: 인증 필요
|
||||||
*/
|
*/
|
||||||
import suggestionsRoutes from './suggestions.js';
|
import suggestionsRoutes from './suggestions.js';
|
||||||
import { searchSchedules, syncAllSchedules, deleteSchedule } from '../../services/meilisearch/index.js';
|
import { searchSchedules, syncAllSchedules } from '../../services/meilisearch/index.js';
|
||||||
import { CATEGORY_IDS } from '../../config/index.js';
|
import { CATEGORY_IDS } from '../../config/index.js';
|
||||||
import {
|
import {
|
||||||
getCategories,
|
getCategories,
|
||||||
|
|
@ -17,8 +17,6 @@ import {
|
||||||
scheduleSearchResponse,
|
scheduleSearchResponse,
|
||||||
idParam,
|
idParam,
|
||||||
} from '../../schemas/index.js';
|
} from '../../schemas/index.js';
|
||||||
import { badRequest, notFound, serverError } from '../../utils/error.js';
|
|
||||||
import { withTransaction } from '../../utils/transaction.js';
|
|
||||||
|
|
||||||
export default async function schedulesRoutes(fastify) {
|
export default async function schedulesRoutes(fastify) {
|
||||||
const { db, meilisearch, redis } = fastify;
|
const { db, meilisearch, redis } = fastify;
|
||||||
|
|
@ -44,7 +42,7 @@ export default async function schedulesRoutes(fastify) {
|
||||||
return await getCategories(db, redis);
|
return await getCategories(db, redis);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fastify.log.error(err);
|
fastify.log.error(err);
|
||||||
return serverError(reply, '카테고리 목록 조회 실패');
|
return reply.code(500).send({ error: '카테고리 목록 조회 실패' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -84,13 +82,13 @@ export default async function schedulesRoutes(fastify) {
|
||||||
|
|
||||||
// 월별 조회 모드
|
// 월별 조회 모드
|
||||||
if (!year || !month) {
|
if (!year || !month) {
|
||||||
return badRequest(reply, 'search, startDate, 또는 year/month는 필수입니다.');
|
return reply.code(400).send({ error: 'search, startDate, 또는 year/month는 필수입니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
return await getMonthlySchedules(db, parseInt(year), parseInt(month));
|
return await getMonthlySchedules(db, parseInt(year), parseInt(month));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fastify.log.error(err);
|
fastify.log.error(err);
|
||||||
return serverError(reply, '일정 조회 실패');
|
return reply.code(500).send({ error: '일정 조회 실패' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -121,7 +119,7 @@ export default async function schedulesRoutes(fastify) {
|
||||||
return { success: true, synced: count };
|
return { success: true, synced: count };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fastify.log.error(err);
|
fastify.log.error(err);
|
||||||
return serverError(reply, '동기화 실패');
|
return reply.code(500).send({ error: '동기화 실패' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -148,13 +146,13 @@ export default async function schedulesRoutes(fastify) {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return notFound(reply, '일정을 찾을 수 없습니다.');
|
return reply.code(404).send({ error: '일정을 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fastify.log.error(err);
|
fastify.log.error(err);
|
||||||
return serverError(reply, '일정 상세 조회 실패');
|
return reply.code(500).send({ error: '일정 상세 조회 실패' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -184,31 +182,33 @@ export default async function schedulesRoutes(fastify) {
|
||||||
try {
|
try {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
|
|
||||||
// 일정 존재 확인 - 트랜잭션 전에 수행
|
// 일정 존재 확인
|
||||||
const [existing] = await db.query('SELECT id FROM schedules WHERE id = ?', [id]);
|
const [existing] = await db.query('SELECT id FROM schedules WHERE id = ?', [id]);
|
||||||
if (existing.length === 0) {
|
if (existing.length === 0) {
|
||||||
return notFound(reply, '일정을 찾을 수 없습니다.');
|
return reply.code(404).send({ error: '일정을 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 트랜잭션으로 DELETE 작업 수행
|
// 관련 테이블 삭제 (외래 키)
|
||||||
await withTransaction(db, async (connection) => {
|
await db.query('DELETE FROM schedule_youtube WHERE schedule_id = ?', [id]);
|
||||||
// 관련 테이블 삭제 (외래 키)
|
await db.query('DELETE FROM schedule_x WHERE schedule_id = ?', [id]);
|
||||||
await connection.query('DELETE FROM schedule_youtube WHERE schedule_id = ?', [id]);
|
await db.query('DELETE FROM schedule_members WHERE schedule_id = ?', [id]);
|
||||||
await connection.query('DELETE FROM schedule_x WHERE schedule_id = ?', [id]);
|
await db.query('DELETE FROM schedule_images WHERE schedule_id = ?', [id]);
|
||||||
await connection.query('DELETE FROM schedule_members WHERE schedule_id = ?', [id]);
|
|
||||||
await connection.query('DELETE FROM schedule_images WHERE schedule_id = ?', [id]);
|
|
||||||
|
|
||||||
// 메인 테이블 삭제
|
// 메인 테이블 삭제
|
||||||
await connection.query('DELETE FROM schedules WHERE id = ?', [id]);
|
await db.query('DELETE FROM schedules WHERE id = ?', [id]);
|
||||||
});
|
|
||||||
|
|
||||||
// Meilisearch에서도 삭제 (트랜잭션 외부, 실패해도 무시)
|
// Meilisearch에서도 삭제
|
||||||
await deleteSchedule(meilisearch, id);
|
try {
|
||||||
|
const { deleteSchedule } = await import('../../services/meilisearch/index.js');
|
||||||
|
await deleteSchedule(meilisearch, id);
|
||||||
|
} catch (meiliErr) {
|
||||||
|
fastify.log.error(`Meilisearch 삭제 오류: ${meiliErr.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fastify.log.error(err);
|
fastify.log.error(err);
|
||||||
return serverError(reply, '일정 삭제 실패');
|
return reply.code(500).send({ error: '일정 삭제 실패' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
/**
|
/**
|
||||||
* 추천 검색어 API 라우트
|
* 추천 검색어 API 라우트
|
||||||
*/
|
*/
|
||||||
import { readFile, writeFile } from 'fs/promises';
|
import { readFileSync, writeFileSync } from 'fs';
|
||||||
import { SuggestionService } from '../../services/suggestions/index.js';
|
import { SuggestionService } from '../../services/suggestions/index.js';
|
||||||
import { reloadMorpheme, getUserDictPath } from '../../services/suggestions/morpheme.js';
|
import { reloadMorpheme, getUserDictPath } from '../../services/suggestions/morpheme.js';
|
||||||
import { badRequest, serverError } from '../../utils/error.js';
|
|
||||||
|
|
||||||
let suggestionService = null;
|
let suggestionService = null;
|
||||||
|
|
||||||
|
|
@ -110,7 +109,7 @@ export default async function suggestionsRoutes(fastify) {
|
||||||
const { query } = request.body;
|
const { query } = request.body;
|
||||||
|
|
||||||
if (!query || query.trim().length === 0) {
|
if (!query || query.trim().length === 0) {
|
||||||
return badRequest(reply, '검색어가 필요합니다.');
|
return reply.code(400).send({ error: '검색어가 필요합니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
await suggestionService.saveSearchQuery(query);
|
await suggestionService.saveSearchQuery(query);
|
||||||
|
|
@ -139,7 +138,7 @@ export default async function suggestionsRoutes(fastify) {
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const dictPath = getUserDictPath();
|
const dictPath = getUserDictPath();
|
||||||
const content = await readFile(dictPath, 'utf-8');
|
const content = readFileSync(dictPath, 'utf-8');
|
||||||
return { content };
|
return { content };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === 'ENOENT') {
|
if (error.code === 'ENOENT') {
|
||||||
|
|
@ -180,7 +179,7 @@ export default async function suggestionsRoutes(fastify) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const dictPath = getUserDictPath();
|
const dictPath = getUserDictPath();
|
||||||
await writeFile(dictPath, content, 'utf-8');
|
writeFileSync(dictPath, content, 'utf-8');
|
||||||
|
|
||||||
// 형태소 분석기 리로드
|
// 형태소 분석기 리로드
|
||||||
await reloadMorpheme();
|
await reloadMorpheme();
|
||||||
|
|
@ -188,7 +187,7 @@ export default async function suggestionsRoutes(fastify) {
|
||||||
return { message: '사전이 저장되었습니다.' };
|
return { message: '사전이 저장되었습니다.' };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fastify.log.error(`[Suggestions] 사전 저장 오류: ${error.message}`);
|
fastify.log.error(`[Suggestions] 사전 저장 오류: ${error.message}`);
|
||||||
return serverError(reply, '사전 저장 중 오류가 발생했습니다.');
|
return reply.code(500).send({ error: '사전 저장 중 오류가 발생했습니다.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@
|
||||||
* 통계 라우트
|
* 통계 라우트
|
||||||
* 인증 필요
|
* 인증 필요
|
||||||
*/
|
*/
|
||||||
import { serverError } from '../../utils/error.js';
|
|
||||||
|
|
||||||
export default async function statsRoutes(fastify, opts) {
|
export default async function statsRoutes(fastify, opts) {
|
||||||
const { db } = fastify;
|
const { db } = fastify;
|
||||||
|
|
||||||
|
|
@ -72,7 +70,7 @@ export default async function statsRoutes(fastify, opts) {
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fastify.log.error(err);
|
fastify.log.error(err);
|
||||||
return serverError(reply, '통계 조회 실패');
|
return reply.code(500).send({ error: '통계 조회 실패' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,7 @@ export const scheduleResponse = {
|
||||||
properties: {
|
properties: {
|
||||||
id: { type: 'integer' },
|
id: { type: 'integer' },
|
||||||
title: { type: 'string' },
|
title: { type: 'string' },
|
||||||
date: { type: 'string', format: 'date' },
|
datetime: { type: 'string' },
|
||||||
time: { type: 'string', nullable: true },
|
|
||||||
category: scheduleCategory,
|
category: scheduleCategory,
|
||||||
members: { type: 'array', items: scheduleMember },
|
members: { type: 'array', items: scheduleMember },
|
||||||
createdAt: { type: 'string', format: 'date-time' },
|
createdAt: { type: 'string', format: 'date-time' },
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* 앨범 서비스
|
* 앨범 서비스
|
||||||
* 앨범 관련 비즈니스 로직
|
* 앨범 관련 비즈니스 로직
|
||||||
*/
|
*/
|
||||||
import { uploadAlbumCover, deleteAlbumCover, deleteAlbumPhoto, deleteAlbumVideo } from './image.js';
|
import { uploadAlbumCover, deleteAlbumCover } from './image.js';
|
||||||
import { withTransaction } from '../utils/transaction.js';
|
import { withTransaction } from '../utils/transaction.js';
|
||||||
import { getOrSet, invalidate, invalidatePattern, cacheKeys, TTL } from '../utils/cache.js';
|
import { getOrSet, invalidate, invalidatePattern, cacheKeys, TTL } from '../utils/cache.js';
|
||||||
|
|
||||||
|
|
@ -283,17 +283,6 @@ export async function updateAlbum(db, id, data, coverBuffer) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* URL에서 파일명 추출
|
|
||||||
* @param {string} url - S3 URL
|
|
||||||
* @returns {string|null} 파일명
|
|
||||||
*/
|
|
||||||
function extractFilenameFromUrl(url) {
|
|
||||||
if (!url) return null;
|
|
||||||
const parts = url.split('/');
|
|
||||||
return parts[parts.length - 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 앨범 삭제
|
* 앨범 삭제
|
||||||
* @param {object} db - 데이터베이스 연결 풀
|
* @param {object} db - 데이터베이스 연결 풀
|
||||||
|
|
@ -308,52 +297,14 @@ export async function deleteAlbum(db, id) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const album = existingAlbums[0];
|
const album = existingAlbums[0];
|
||||||
const folderName = album.folder_name;
|
|
||||||
|
|
||||||
// S3 파일 삭제를 위해 사진/티저 목록 조회 (트랜잭션 외부)
|
|
||||||
const [[photos], [teasers]] = await Promise.all([
|
|
||||||
db.query('SELECT original_url FROM album_photos WHERE album_id = ?', [id]),
|
|
||||||
db.query('SELECT original_url, video_url FROM album_teasers WHERE album_id = ?', [id]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return withTransaction(db, async (connection) => {
|
return withTransaction(db, async (connection) => {
|
||||||
// S3 파일 삭제 (병렬 처리)
|
|
||||||
const s3DeletePromises = [];
|
|
||||||
|
|
||||||
// 커버 이미지 삭제
|
// 커버 이미지 삭제
|
||||||
if (album.cover_original_url && folderName) {
|
if (album.cover_original_url && album.folder_name) {
|
||||||
s3DeletePromises.push(deleteAlbumCover(folderName));
|
await deleteAlbumCover(album.folder_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 사진 삭제
|
// 관련 데이터 삭제
|
||||||
for (const photo of photos) {
|
|
||||||
const filename = extractFilenameFromUrl(photo.original_url);
|
|
||||||
if (filename && folderName) {
|
|
||||||
s3DeletePromises.push(deleteAlbumPhoto(folderName, 'photo', filename));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 티저 삭제 (이미지 + 비디오)
|
|
||||||
for (const teaser of teasers) {
|
|
||||||
const filename = extractFilenameFromUrl(teaser.original_url);
|
|
||||||
if (filename && folderName) {
|
|
||||||
s3DeletePromises.push(deleteAlbumPhoto(folderName, 'teaser', filename));
|
|
||||||
}
|
|
||||||
const videoFilename = extractFilenameFromUrl(teaser.video_url);
|
|
||||||
if (videoFilename && folderName) {
|
|
||||||
s3DeletePromises.push(deleteAlbumVideo(folderName, videoFilename));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(s3DeletePromises);
|
|
||||||
|
|
||||||
// DB 관련 데이터 삭제 (순서 중요: FK 제약조건)
|
|
||||||
await connection.query(
|
|
||||||
'DELETE FROM album_photo_members WHERE photo_id IN (SELECT id FROM album_photos WHERE album_id = ?)',
|
|
||||||
[id]
|
|
||||||
);
|
|
||||||
await connection.query('DELETE FROM album_photos WHERE album_id = ?', [id]);
|
|
||||||
await connection.query('DELETE FROM album_teasers WHERE album_id = ?', [id]);
|
|
||||||
await connection.query('DELETE FROM album_tracks WHERE album_id = ?', [id]);
|
await connection.query('DELETE FROM album_tracks WHERE album_id = ?', [id]);
|
||||||
await connection.query('DELETE FROM albums WHERE id = ?', [id]);
|
await connection.query('DELETE FROM albums WHERE id = ?', [id]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
import Inko from 'inko';
|
import Inko from 'inko';
|
||||||
import config, { CATEGORY_IDS } from '../../config/index.js';
|
import config, { CATEGORY_IDS } from '../../config/index.js';
|
||||||
import { createLogger } from '../../utils/logger.js';
|
import { createLogger } from '../../utils/logger.js';
|
||||||
|
import { buildDatetime } from '../schedule.js';
|
||||||
|
|
||||||
const inko = new Inko();
|
const inko = new Inko();
|
||||||
const logger = createLogger('Meilisearch');
|
const logger = createLogger('Meilisearch');
|
||||||
|
|
@ -165,8 +166,7 @@ function formatScheduleResponse(hit) {
|
||||||
return {
|
return {
|
||||||
id: hit.id,
|
id: hit.id,
|
||||||
title: hit.title,
|
title: hit.title,
|
||||||
date: hit.date,
|
datetime: buildDatetime(hit.date, hit.time),
|
||||||
time: hit.time || null,
|
|
||||||
category: {
|
category: {
|
||||||
id: hit.category_id,
|
id: hit.category_id,
|
||||||
name: hit.category_name,
|
name: hit.category_name,
|
||||||
|
|
@ -251,7 +251,10 @@ export async function syncAllSchedules(meilisearch, db) {
|
||||||
|
|
||||||
const index = meilisearch.index(INDEX_NAME);
|
const index = meilisearch.index(INDEX_NAME);
|
||||||
|
|
||||||
// 문서 변환 (addDocuments는 같은 ID면 자동 업데이트)
|
// 기존 문서 모두 삭제
|
||||||
|
await index.deleteAllDocuments();
|
||||||
|
|
||||||
|
// 문서 변환
|
||||||
const documents = schedules.map(s => ({
|
const documents = schedules.map(s => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
title: s.title,
|
title: s.title,
|
||||||
|
|
@ -275,76 +278,3 @@ export async function syncAllSchedules(meilisearch, db) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Meilisearch 버전 조회
|
|
||||||
*/
|
|
||||||
export async function getVersion(meilisearch) {
|
|
||||||
try {
|
|
||||||
const version = await meilisearch.getVersion();
|
|
||||||
return version.pkgVersion;
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`버전 조회 오류: ${err.message}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 인덱스 삭제 후 재생성
|
|
||||||
*/
|
|
||||||
async function recreateIndex(meilisearch) {
|
|
||||||
const index = meilisearch.index(INDEX_NAME);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 인덱스 삭제
|
|
||||||
const deleteTask = await meilisearch.deleteIndex(INDEX_NAME);
|
|
||||||
await meilisearch.waitForTask(deleteTask.taskUid);
|
|
||||||
logger.info('기존 인덱스 삭제 완료');
|
|
||||||
} catch (err) {
|
|
||||||
// 인덱스가 없으면 무시
|
|
||||||
}
|
|
||||||
|
|
||||||
// 인덱스 재생성
|
|
||||||
const createTask = await meilisearch.createIndex(INDEX_NAME, { primaryKey: 'id' });
|
|
||||||
await meilisearch.waitForTask(createTask.taskUid);
|
|
||||||
|
|
||||||
// 설정 복원
|
|
||||||
await index.updateSearchableAttributes([
|
|
||||||
'title', 'member_names', 'description', 'source_name', 'category_name',
|
|
||||||
]);
|
|
||||||
await index.updateFilterableAttributes(['category_id', 'date']);
|
|
||||||
await index.updateSortableAttributes(['date', 'time']);
|
|
||||||
await index.updateRankingRules([
|
|
||||||
'words', 'typo', 'proximity', 'attribute', 'exactness', 'date:desc',
|
|
||||||
]);
|
|
||||||
await index.updateTypoTolerance({
|
|
||||||
enabled: true,
|
|
||||||
minWordSizeForTypos: { oneTypo: 2, twoTypos: 4 },
|
|
||||||
});
|
|
||||||
await index.updatePagination({ maxTotalHits: 10000 });
|
|
||||||
|
|
||||||
logger.info('인덱스 재생성 완료');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 동기화 (오류 시 인덱스 재생성 후 재시도)
|
|
||||||
*/
|
|
||||||
export async function syncWithRetry(meilisearch, db) {
|
|
||||||
try {
|
|
||||||
const count = await syncAllSchedules(meilisearch, db);
|
|
||||||
if (count > 0) return count;
|
|
||||||
|
|
||||||
// 0개면 오류일 수 있으므로 재시도
|
|
||||||
throw new Error('동기화 결과 0개');
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(`동기화 실패, 인덱스 재생성 후 재시도: ${err.message}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await recreateIndex(meilisearch);
|
|
||||||
return await syncAllSchedules(meilisearch, db);
|
|
||||||
} catch (retryErr) {
|
|
||||||
logger.error(`재시도 실패: ${retryErr.message}`);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -69,8 +69,7 @@ export function formatSchedule(rawSchedule, members = []) {
|
||||||
return {
|
return {
|
||||||
id: rawSchedule.id,
|
id: rawSchedule.id,
|
||||||
title: rawSchedule.title,
|
title: rawSchedule.title,
|
||||||
date: normalizeDate(rawSchedule.date),
|
datetime: buildDatetime(rawSchedule.date, rawSchedule.time),
|
||||||
time: rawSchedule.time || null,
|
|
||||||
category: {
|
category: {
|
||||||
id: rawSchedule.category_id,
|
id: rawSchedule.category_id,
|
||||||
name: rawSchedule.category_name,
|
name: rawSchedule.category_name,
|
||||||
|
|
@ -221,8 +220,7 @@ export async function getScheduleDetail(db, id, getXProfile = null) {
|
||||||
const result = {
|
const result = {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
title: s.title,
|
title: s.title,
|
||||||
date: normalizeDate(s.date),
|
datetime: buildDatetime(s.date, s.time),
|
||||||
time: s.time || null,
|
|
||||||
category: {
|
category: {
|
||||||
id: s.category_id,
|
id: s.category_id,
|
||||||
name: s.category_name,
|
name: s.category_name,
|
||||||
|
|
@ -326,8 +324,7 @@ export async function getMonthlySchedules(db, year, month) {
|
||||||
schedules.push({
|
schedules.push({
|
||||||
id: `birthday-${member.id}`,
|
id: `birthday-${member.id}`,
|
||||||
title: `HAPPY ${member.name_en} DAY`,
|
title: `HAPPY ${member.name_en} DAY`,
|
||||||
date: birthdayDate.toISOString().split('T')[0],
|
datetime: birthdayDate.toISOString().split('T')[0],
|
||||||
time: null,
|
|
||||||
category: {
|
category: {
|
||||||
id: CATEGORY_IDS.BIRTHDAY,
|
id: CATEGORY_IDS.BIRTHDAY,
|
||||||
name: '생일',
|
name: '생일',
|
||||||
|
|
@ -341,7 +338,7 @@ export async function getMonthlySchedules(db, year, month) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 날짜순 정렬
|
// 날짜순 정렬
|
||||||
schedules.sort((a, b) => a.date.localeCompare(b.date));
|
schedules.sort((a, b) => a.datetime.localeCompare(b.datetime));
|
||||||
|
|
||||||
return { schedules };
|
return { schedules };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import fp from 'fastify-plugin';
|
import fp from 'fastify-plugin';
|
||||||
import { fetchTweets, fetchAllTweets, extractTitle, extractYoutubeVideoIds, extractProfile } from './scraper.js';
|
import { fetchTweets, fetchAllTweets, extractTitle, extractYoutubeVideoIds, extractProfile } from './scraper.js';
|
||||||
import { fetchVideoInfo } from '../youtube/api.js';
|
import { fetchVideoInfo } from '../youtube/api.js';
|
||||||
import { formatDate, formatTime, nowKST } from '../../utils/date.js';
|
import { formatDate, formatTime } from '../../utils/date.js';
|
||||||
import bots from '../../config/bots.js';
|
import bots from '../../config/bots.js';
|
||||||
import { withTransaction } from '../../utils/transaction.js';
|
|
||||||
|
|
||||||
const X_CATEGORY_ID = 3;
|
const X_CATEGORY_ID = 3;
|
||||||
const YOUTUBE_CATEGORY_ID = 2;
|
const YOUTUBE_CATEGORY_ID = 2;
|
||||||
|
|
@ -41,7 +40,7 @@ async function xBotPlugin(fastify, opts) {
|
||||||
username,
|
username,
|
||||||
displayName: profile.displayName,
|
displayName: profile.displayName,
|
||||||
avatarUrl: profile.avatarUrl,
|
avatarUrl: profile.avatarUrl,
|
||||||
updatedAt: nowKST(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
await fastify.redis.setex(
|
await fastify.redis.setex(
|
||||||
`${PROFILE_CACHE_PREFIX}${username}`,
|
`${PROFILE_CACHE_PREFIX}${username}`,
|
||||||
|
|
@ -54,7 +53,7 @@ async function xBotPlugin(fastify, opts) {
|
||||||
* 트윗을 DB에 저장
|
* 트윗을 DB에 저장
|
||||||
*/
|
*/
|
||||||
async function saveTweet(tweet) {
|
async function saveTweet(tweet) {
|
||||||
// 중복 체크 (post_id로) - 트랜잭션 전에 수행
|
// 중복 체크 (post_id로)
|
||||||
const [existing] = await fastify.db.query(
|
const [existing] = await fastify.db.query(
|
||||||
'SELECT id FROM schedule_x WHERE post_id = ?',
|
'SELECT id FROM schedule_x WHERE post_id = ?',
|
||||||
[tweet.id]
|
[tweet.id]
|
||||||
|
|
@ -67,35 +66,32 @@ async function xBotPlugin(fastify, opts) {
|
||||||
const time = formatTime(tweet.time);
|
const time = formatTime(tweet.time);
|
||||||
const title = extractTitle(tweet.text);
|
const title = extractTitle(tweet.text);
|
||||||
|
|
||||||
// 트랜잭션으로 INSERT 작업 수행
|
// schedules 테이블에 저장
|
||||||
return withTransaction(fastify.db, async (connection) => {
|
const [result] = await fastify.db.query(
|
||||||
// schedules 테이블에 저장
|
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
|
||||||
const [result] = await connection.query(
|
[X_CATEGORY_ID, title, date, time]
|
||||||
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
|
);
|
||||||
[X_CATEGORY_ID, title, date, time]
|
const scheduleId = result.insertId;
|
||||||
);
|
|
||||||
const scheduleId = result.insertId;
|
|
||||||
|
|
||||||
// schedule_x 테이블에 저장
|
// schedule_x 테이블에 저장
|
||||||
await connection.query(
|
await fastify.db.query(
|
||||||
'INSERT INTO schedule_x (schedule_id, post_id, content, image_urls) VALUES (?, ?, ?, ?)',
|
'INSERT INTO schedule_x (schedule_id, post_id, content, image_urls) VALUES (?, ?, ?, ?)',
|
||||||
[
|
[
|
||||||
scheduleId,
|
scheduleId,
|
||||||
tweet.id,
|
tweet.id,
|
||||||
tweet.text,
|
tweet.text,
|
||||||
tweet.imageUrls.length > 0 ? JSON.stringify(tweet.imageUrls) : null,
|
tweet.imageUrls.length > 0 ? JSON.stringify(tweet.imageUrls) : null,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
return scheduleId;
|
return scheduleId;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* YouTube 영상을 DB에 저장 (트윗에서 감지된 링크)
|
* YouTube 영상을 DB에 저장 (트윗에서 감지된 링크)
|
||||||
*/
|
*/
|
||||||
async function saveYoutubeFromTweet(video) {
|
async function saveYoutubeFromTweet(video) {
|
||||||
// 중복 체크 - 트랜잭션 전에 수행
|
// 중복 체크
|
||||||
const [existing] = await fastify.db.query(
|
const [existing] = await fastify.db.query(
|
||||||
'SELECT id FROM schedule_youtube WHERE video_id = ?',
|
'SELECT id FROM schedule_youtube WHERE video_id = ?',
|
||||||
[video.videoId]
|
[video.videoId]
|
||||||
|
|
@ -104,23 +100,20 @@ async function xBotPlugin(fastify, opts) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 트랜잭션으로 INSERT 작업 수행
|
// schedules 테이블에 저장
|
||||||
return withTransaction(fastify.db, async (connection) => {
|
const [result] = await fastify.db.query(
|
||||||
// schedules 테이블에 저장
|
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
|
||||||
const [result] = await connection.query(
|
[YOUTUBE_CATEGORY_ID, video.title, video.date, video.time]
|
||||||
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
|
);
|
||||||
[YOUTUBE_CATEGORY_ID, video.title, video.date, video.time]
|
const scheduleId = result.insertId;
|
||||||
);
|
|
||||||
const scheduleId = result.insertId;
|
|
||||||
|
|
||||||
// schedule_youtube 테이블에 저장
|
// schedule_youtube 테이블에 저장
|
||||||
await connection.query(
|
await fastify.db.query(
|
||||||
'INSERT INTO schedule_youtube (schedule_id, video_id, video_type, channel_id, channel_name) VALUES (?, ?, ?, ?, ?)',
|
'INSERT INTO schedule_youtube (schedule_id, video_id, video_type, channel_id, channel_name) VALUES (?, ?, ?, ?, ?)',
|
||||||
[scheduleId, video.videoId, video.videoType, video.channelId, video.channelTitle]
|
[scheduleId, video.videoId, video.videoType, video.channelId, video.channelTitle]
|
||||||
);
|
);
|
||||||
|
|
||||||
return scheduleId;
|
return scheduleId;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,5 @@
|
||||||
import { parseNitterDateTime } from '../../utils/date.js';
|
import { parseNitterDateTime } from '../../utils/date.js';
|
||||||
|
|
||||||
const FETCH_TIMEOUT = 10000; // 10초
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 타임아웃이 적용된 fetch
|
|
||||||
*/
|
|
||||||
async function fetchWithTimeout(url, timeout = FETCH_TIMEOUT) {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(url, { signal: controller.signal });
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res;
|
|
||||||
} catch (err) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
if (err.name === 'AbortError') {
|
|
||||||
throw new Error('요청 타임아웃');
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 트윗 텍스트에서 첫 문단 추출 (title용)
|
* 트윗 텍스트에서 첫 문단 추출 (title용)
|
||||||
*/
|
*/
|
||||||
|
|
@ -223,7 +196,7 @@ export async function fetchSingleTweet(nitterUrl, username, postId) {
|
||||||
*/
|
*/
|
||||||
export async function fetchTweets(nitterUrl, username) {
|
export async function fetchTweets(nitterUrl, username) {
|
||||||
const url = `${nitterUrl}/${username}`;
|
const url = `${nitterUrl}/${username}`;
|
||||||
const res = await fetchWithTimeout(url);
|
const res = await fetch(url);
|
||||||
const html = await res.text();
|
const html = await res.text();
|
||||||
|
|
||||||
// 프로필 정보
|
// 프로필 정보
|
||||||
|
|
@ -252,7 +225,7 @@ export async function fetchAllTweets(nitterUrl, username, log) {
|
||||||
log?.info(`[페이지 ${pageNum}] 스크래핑 중...`);
|
log?.info(`[페이지 ${pageNum}] 스크래핑 중...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetchWithTimeout(url);
|
const res = await fetch(url);
|
||||||
const html = await res.text();
|
const html = await res.text();
|
||||||
const tweets = parseTweets(html, username);
|
const tweets = parseTweets(html, username);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import fp from 'fastify-plugin';
|
||||||
import { fetchRecentVideos, fetchAllVideos, getUploadsPlaylistId } from './api.js';
|
import { fetchRecentVideos, fetchAllVideos, getUploadsPlaylistId } from './api.js';
|
||||||
import bots from '../../config/bots.js';
|
import bots from '../../config/bots.js';
|
||||||
import { CATEGORY_IDS } from '../../config/index.js';
|
import { CATEGORY_IDS } from '../../config/index.js';
|
||||||
import { withTransaction } from '../../utils/transaction.js';
|
|
||||||
|
|
||||||
const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE;
|
const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE;
|
||||||
const PLAYLIST_CACHE_PREFIX = 'yt_uploads:';
|
const PLAYLIST_CACHE_PREFIX = 'yt_uploads:';
|
||||||
|
|
@ -57,7 +56,7 @@ async function youtubeBotPlugin(fastify, opts) {
|
||||||
* 영상을 DB에 저장
|
* 영상을 DB에 저장
|
||||||
*/
|
*/
|
||||||
async function saveVideo(video, bot) {
|
async function saveVideo(video, bot) {
|
||||||
// 중복 체크 (video_id로) - 트랜잭션 전에 수행
|
// 중복 체크 (video_id로)
|
||||||
const [existing] = await fastify.db.query(
|
const [existing] = await fastify.db.query(
|
||||||
'SELECT id FROM schedule_youtube WHERE video_id = ?',
|
'SELECT id FROM schedule_youtube WHERE video_id = ?',
|
||||||
[video.videoId]
|
[video.videoId]
|
||||||
|
|
@ -71,48 +70,40 @@ async function youtubeBotPlugin(fastify, opts) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 멤버 이름 맵 미리 조회 (트랜잭션 전에)
|
// schedules 테이블에 저장
|
||||||
let nameMap = null;
|
const [result] = await fastify.db.query(
|
||||||
if (bot.extractMembersFromDesc) {
|
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
|
||||||
nameMap = await getMemberNameMap();
|
[YOUTUBE_CATEGORY_ID, video.title, video.date, video.time]
|
||||||
|
);
|
||||||
|
const scheduleId = result.insertId;
|
||||||
|
|
||||||
|
// schedule_youtube 테이블에 저장
|
||||||
|
await fastify.db.query(
|
||||||
|
'INSERT INTO schedule_youtube (schedule_id, video_id, video_type, channel_id, channel_name) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
[scheduleId, video.videoId, video.videoType, video.channelId, bot.channelName]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 멤버 연결 (커스텀 설정)
|
||||||
|
if (bot.defaultMemberId || bot.extractMembersFromDesc) {
|
||||||
|
const memberIds = [];
|
||||||
|
if (bot.defaultMemberId) {
|
||||||
|
memberIds.push(bot.defaultMemberId);
|
||||||
|
}
|
||||||
|
if (bot.extractMembersFromDesc) {
|
||||||
|
const nameMap = await getMemberNameMap();
|
||||||
|
memberIds.push(...extractMemberIds(video.description, nameMap));
|
||||||
|
}
|
||||||
|
if (memberIds.length > 0) {
|
||||||
|
const uniqueIds = [...new Set(memberIds)];
|
||||||
|
const values = uniqueIds.map(id => [scheduleId, id]);
|
||||||
|
await fastify.db.query(
|
||||||
|
'INSERT INTO schedule_members (schedule_id, member_id) VALUES ?',
|
||||||
|
[values]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 트랜잭션으로 INSERT 작업 수행
|
return scheduleId;
|
||||||
return withTransaction(fastify.db, async (connection) => {
|
|
||||||
// schedules 테이블에 저장
|
|
||||||
const [result] = await connection.query(
|
|
||||||
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
|
|
||||||
[YOUTUBE_CATEGORY_ID, video.title, video.date, video.time]
|
|
||||||
);
|
|
||||||
const scheduleId = result.insertId;
|
|
||||||
|
|
||||||
// schedule_youtube 테이블에 저장
|
|
||||||
await connection.query(
|
|
||||||
'INSERT INTO schedule_youtube (schedule_id, video_id, video_type, channel_id, channel_name) VALUES (?, ?, ?, ?, ?)',
|
|
||||||
[scheduleId, video.videoId, video.videoType, video.channelId, bot.channelName]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 멤버 연결 (커스텀 설정)
|
|
||||||
if (bot.defaultMemberId || bot.extractMembersFromDesc) {
|
|
||||||
const memberIds = [];
|
|
||||||
if (bot.defaultMemberId) {
|
|
||||||
memberIds.push(bot.defaultMemberId);
|
|
||||||
}
|
|
||||||
if (nameMap) {
|
|
||||||
memberIds.push(...extractMemberIds(video.description, nameMap));
|
|
||||||
}
|
|
||||||
if (memberIds.length > 0) {
|
|
||||||
const uniqueIds = [...new Set(memberIds)];
|
|
||||||
const values = uniqueIds.map(id => [scheduleId, id]);
|
|
||||||
await connection.query(
|
|
||||||
'INSERT INTO schedule_members (schedule_id, member_id) VALUES ?',
|
|
||||||
[values]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return scheduleId;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -41,19 +41,15 @@ export async function invalidate(redis, keys) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 패턴으로 캐시 무효화 (SCAN 사용으로 블로킹 방지)
|
* 패턴으로 캐시 무효화
|
||||||
* @param {object} redis - Redis 클라이언트
|
* @param {object} redis - Redis 클라이언트
|
||||||
* @param {string} pattern - 키 패턴 (예: 'schedule:*')
|
* @param {string} pattern - 키 패턴 (예: 'schedule:*')
|
||||||
*/
|
*/
|
||||||
export async function invalidatePattern(redis, pattern) {
|
export async function invalidatePattern(redis, pattern) {
|
||||||
let cursor = '0';
|
const keys = await redis.keys(pattern);
|
||||||
do {
|
if (keys.length > 0) {
|
||||||
const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
|
await redis.del(...keys);
|
||||||
cursor = nextCursor;
|
}
|
||||||
if (keys.length > 0) {
|
|
||||||
await redis.del(...keys);
|
|
||||||
}
|
|
||||||
} while (cursor !== '0');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 캐시 키 생성 헬퍼
|
// 캐시 키 생성 헬퍼
|
||||||
|
|
|
||||||
|
|
@ -28,14 +28,6 @@ export function formatTime(date) {
|
||||||
return dayjs(date).tz(KST).format('HH:mm:ss');
|
return dayjs(date).tz(KST).format('HH:mm:ss');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 현재 KST 시간을 ISO 형식으로 반환
|
|
||||||
* 예: "2025-01-23T13:05:00+09:00"
|
|
||||||
*/
|
|
||||||
export function nowKST() {
|
|
||||||
return dayjs().tz(KST).format();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nitter 날짜 문자열 파싱
|
* Nitter 날짜 문자열 파싱
|
||||||
* 예: "Jan 15, 2026 · 10:30 PM UTC"
|
* 예: "Jan 15, 2026 · 10:30 PM UTC"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
* 로거 유틸리티
|
* 로거 유틸리티
|
||||||
* 서비스 레이어에서 사용할 수 있는 간단한 로깅 유틸리티
|
* 서비스 레이어에서 사용할 수 있는 간단한 로깅 유틸리티
|
||||||
*/
|
*/
|
||||||
import { nowKST } from './date.js';
|
|
||||||
|
|
||||||
const PREFIX = {
|
const PREFIX = {
|
||||||
info: '[INFO]',
|
info: '[INFO]',
|
||||||
|
|
@ -12,7 +11,7 @@ const PREFIX = {
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatMessage(level, context, message) {
|
function formatMessage(level, context, message) {
|
||||||
const timestamp = nowKST();
|
const timestamp = new Date().toISOString();
|
||||||
return `${timestamp} ${PREFIX[level]} [${context}] ${message}`;
|
return `${timestamp} ${PREFIX[level]} [${context}] ${message}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,19 @@ services:
|
||||||
- app
|
- app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
fromis9-frontend-dev:
|
||||||
|
build: ./frontend-temp
|
||||||
|
container_name: fromis9-frontend-dev
|
||||||
|
labels:
|
||||||
|
- "com.centurylinklabs.watchtower.enable=false"
|
||||||
|
volumes:
|
||||||
|
- ./frontend-temp:/app
|
||||||
|
depends_on:
|
||||||
|
- fromis9-backend
|
||||||
|
networks:
|
||||||
|
- app
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
fromis9-backend:
|
fromis9-backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
container_name: fromis9-backend
|
container_name: fromis9-backend
|
||||||
|
|
@ -27,7 +40,7 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
meilisearch:
|
meilisearch:
|
||||||
image: getmeili/meilisearch:latest
|
image: getmeili/meilisearch:v1.6
|
||||||
container_name: fromis9-meilisearch
|
container_name: fromis9-meilisearch
|
||||||
environment:
|
environment:
|
||||||
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
|
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
|
||||||
|
|
|
||||||
92
docs/api.md
92
docs/api.md
|
|
@ -7,8 +7,6 @@ Base URL: `/api`
|
||||||
### POST /auth/login
|
### POST /auth/login
|
||||||
로그인 (JWT 토큰 발급)
|
로그인 (JWT 토큰 발급)
|
||||||
|
|
||||||
**Rate Limit:** 1분당 5회 (IP 기준)
|
|
||||||
|
|
||||||
### GET /auth/verify
|
### GET /auth/verify
|
||||||
토큰 검증 및 사용자 정보 (인증 필요)
|
토큰 검증 및 사용자 정보 (인증 필요)
|
||||||
|
|
||||||
|
|
@ -50,24 +48,25 @@ Base URL: `/api`
|
||||||
**월별 조회 응답:**
|
**월별 조회 응답:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"schedules": [
|
"2026-01-18": {
|
||||||
{
|
"categories": [
|
||||||
"id": 123,
|
{ "id": 2, "name": "유튜브", "color": "#ff0033", "count": 3 }
|
||||||
"title": "...",
|
],
|
||||||
"date": "2026-01-18",
|
"schedules": [
|
||||||
"time": "19:00:00",
|
{
|
||||||
"category": { "id": 2, "name": "유튜브", "color": "#ff0033" },
|
"id": 123,
|
||||||
"source": {
|
"title": "...",
|
||||||
"name": "fromis_9",
|
"time": "19:00:00",
|
||||||
"url": "https://www.youtube.com/watch?v=VIDEO_ID"
|
"category": { "id": 2, "name": "유튜브", "color": "#ff0033" },
|
||||||
},
|
"source": {
|
||||||
"members": ["송하영"]
|
"name": "fromis_9",
|
||||||
}
|
"url": "https://www.youtube.com/watch?v=VIDEO_ID"
|
||||||
]
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
※ `time`: 시간이 없는 일정은 `null`, 00:00 시간은 `"00:00:00"`으로 반환
|
|
||||||
```
|
|
||||||
|
|
||||||
**source 객체 (카테고리별):**
|
**source 객체 (카테고리별):**
|
||||||
- YouTube (category_id=2): `{ name: "채널명", url: "https://www.youtube.com/..." }`
|
- YouTube (category_id=2): `{ name: "채널명", url: "https://www.youtube.com/..." }`
|
||||||
|
|
@ -76,22 +75,20 @@ Base URL: `/api`
|
||||||
|
|
||||||
**다가오는 일정 응답 (startDate):**
|
**다가오는 일정 응답 (startDate):**
|
||||||
```json
|
```json
|
||||||
{
|
[
|
||||||
"schedules": [
|
{
|
||||||
{
|
"id": 123,
|
||||||
"id": 123,
|
"title": "...",
|
||||||
"title": "...",
|
"date": "2026-01-18",
|
||||||
"date": "2026-01-18",
|
"time": "19:00:00",
|
||||||
"time": "19:00:00",
|
"category_id": 2,
|
||||||
"category": { "id": 2, "name": "유튜브", "color": "#ff0033" },
|
"category_name": "유튜브",
|
||||||
"source": { "name": "fromis_9", "url": "https://..." },
|
"category_color": "#ff0033",
|
||||||
"members": ["송하영"]
|
"members": [{ "name": "송하영" }]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
|
||||||
```
|
```
|
||||||
※ 현재 활동 멤버 전원인 경우 `["프로미스나인"]` 반환 (탈퇴 멤버 제외)
|
※ 현재 활동 멤버 전원인 경우 `[{ "name": "프로미스나인" }]` 반환 (탈퇴 멤버 제외)
|
||||||
※ `time`: 시간이 없는 일정은 `null`, 00:00 시간은 `"00:00:00"`으로 반환
|
|
||||||
|
|
||||||
**검색 응답:**
|
**검색 응답:**
|
||||||
```json
|
```json
|
||||||
|
|
@ -100,8 +97,7 @@ Base URL: `/api`
|
||||||
{
|
{
|
||||||
"id": 123,
|
"id": 123,
|
||||||
"title": "...",
|
"title": "...",
|
||||||
"date": "2026-01-18",
|
"datetime": "2026-01-18T19:00:00",
|
||||||
"time": "19:00:00",
|
|
||||||
"category": { "id": 2, "name": "유튜브", "color": "#ff0033" },
|
"category": { "id": 2, "name": "유튜브", "color": "#ff0033" },
|
||||||
"source": { "name": "fromis_9", "url": "https://..." },
|
"source": { "name": "fromis_9", "url": "https://..." },
|
||||||
"members": ["송하영"],
|
"members": ["송하영"],
|
||||||
|
|
@ -114,8 +110,6 @@ Base URL: `/api`
|
||||||
"hasMore": true
|
"hasMore": true
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
※ `time`: 시간이 없는 일정은 `null`, 00:00 시간은 `"00:00:00"`으로 반환
|
|
||||||
```
|
|
||||||
|
|
||||||
### GET /schedules/categories
|
### GET /schedules/categories
|
||||||
카테고리 목록 조회
|
카테고리 목록 조회
|
||||||
|
|
@ -213,36 +207,16 @@ Meilisearch 전체 동기화 (인증 필요)
|
||||||
"name": "fromis_9",
|
"name": "fromis_9",
|
||||||
"type": "youtube",
|
"type": "youtube",
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"last_check_at": "2026-01-18T19:30:00+09:00",
|
"last_check_at": "2026-01-18T10:30:00Z",
|
||||||
"last_added_count": 2,
|
"last_added_count": 2,
|
||||||
"last_sync_duration": 1234,
|
|
||||||
"schedules_added": 150,
|
"schedules_added": 150,
|
||||||
"check_interval": 2,
|
"check_interval": 2,
|
||||||
"error_message": null,
|
"error_message": null,
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "meilisearch-sync",
|
|
||||||
"name": "Meilisearch 동기화",
|
|
||||||
"type": "meilisearch",
|
|
||||||
"status": "running",
|
|
||||||
"last_check_at": "2026-01-18T04:00:00+09:00",
|
|
||||||
"last_added_count": 500,
|
|
||||||
"last_sync_duration": 2500,
|
|
||||||
"schedules_added": 500,
|
|
||||||
"check_interval": 0,
|
|
||||||
"error_message": null,
|
|
||||||
"enabled": true,
|
|
||||||
"version": "1.6.0"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
**필드 설명:**
|
|
||||||
- `last_check_at`: 마지막 동기화 시간 (KST, +09:00)
|
|
||||||
- `last_sync_duration`: 마지막 동기화 소요 시간 (ms)
|
|
||||||
- `version`: Meilisearch 버전 (meilisearch 타입만)
|
|
||||||
|
|
||||||
### POST /admin/bots/:id/start
|
### POST /admin/bots/:id/start
|
||||||
봇 시작
|
봇 시작
|
||||||
|
|
||||||
|
|
@ -269,7 +243,7 @@ YouTube API 할당량 경고 조회
|
||||||
{
|
{
|
||||||
"active": true,
|
"active": true,
|
||||||
"message": "YouTube API 할당량 초과",
|
"message": "YouTube API 할당량 초과",
|
||||||
"timestamp": "2026-01-18T19:00:00+09:00"
|
"timestamp": "2026-01-18T10:00:00Z"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
```
|
```
|
||||||
fromis_9/
|
fromis_9/
|
||||||
├── backend/ # Fastify 백엔드
|
├── backend/ # Fastify 백엔드 (현재 사용)
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── config/
|
│ │ ├── config/
|
||||||
│ │ │ ├── index.js # 환경변수 통합 관리
|
│ │ │ ├── index.js # 환경변수 통합 관리
|
||||||
|
|
@ -38,26 +38,33 @@ fromis_9/
|
||||||
│ │ │ ├── x/ # X(Twitter) 봇
|
│ │ │ ├── x/ # X(Twitter) 봇
|
||||||
│ │ │ ├── meilisearch/ # 검색 서비스
|
│ │ │ ├── meilisearch/ # 검색 서비스
|
||||||
│ │ │ └── suggestions/ # 추천 검색어
|
│ │ │ └── suggestions/ # 추천 검색어
|
||||||
│ │ ├── utils/ # 유틸리티
|
|
||||||
│ │ │ ├── cache.js # Redis 캐시 헬퍼 (SCAN 사용)
|
|
||||||
│ │ │ ├── date.js # 날짜 유틸 (KST 변환)
|
|
||||||
│ │ │ ├── error.js # 에러 응답 헬퍼
|
|
||||||
│ │ │ ├── logger.js # 로깅 유틸
|
|
||||||
│ │ │ └── transaction.js # DB 트랜잭션 래퍼
|
|
||||||
│ │ ├── app.js # Fastify 앱 설정
|
│ │ ├── app.js # Fastify 앱 설정
|
||||||
│ │ └── server.js # 진입점
|
│ │ └── server.js # 진입점
|
||||||
│ ├── Dockerfile # 백엔드 컨테이너
|
│ ├── Dockerfile # 백엔드 컨테이너
|
||||||
│ └── package.json
|
│ └── package.json
|
||||||
│
|
│
|
||||||
├── frontend/ # React 프론트엔드
|
├── backend-backup/ # Express 백엔드 (참조용, 마이그레이션 원본)
|
||||||
|
│
|
||||||
|
├── frontend/ # React 프론트엔드 (레거시, frontend-temp로 대체 예정)
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── api/ # API 클라이언트
|
│ │ ├── api/
|
||||||
|
│ │ │ ├── public/ # 공개 API
|
||||||
|
│ │ │ └── admin/ # 어드민 API
|
||||||
|
│ │ ├── pages/
|
||||||
|
│ │ │ ├── pc/ # PC 페이지
|
||||||
|
│ │ │ └── mobile/ # 모바일 페이지
|
||||||
|
│ │ └── ...
|
||||||
|
│ └── package.json
|
||||||
|
│
|
||||||
|
├── frontend-temp/ # React 프론트엔드 (신규, Strangler Fig 마이그레이션)
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── api/ # API 클라이언트 (공유)
|
||||||
│ │ │ ├── index.js
|
│ │ │ ├── index.js
|
||||||
│ │ │ ├── client.js # fetchApi, fetchAuthApi
|
│ │ │ ├── client.js # fetchApi, fetchAuthApi
|
||||||
│ │ │ ├── public/ # 공개 API
|
│ │ │ ├── albums.js
|
||||||
│ │ │ │ ├── albums.js
|
│ │ │ ├── members.js
|
||||||
│ │ │ │ ├── members.js
|
│ │ │ ├── schedules.js
|
||||||
│ │ │ │ └── schedules.js
|
│ │ │ ├── auth.js
|
||||||
│ │ │ └── admin/ # 관리자 API
|
│ │ │ └── admin/ # 관리자 API
|
||||||
│ │ │ ├── albums.js
|
│ │ │ ├── albums.js
|
||||||
│ │ │ ├── members.js
|
│ │ │ ├── members.js
|
||||||
|
|
@ -65,174 +72,131 @@ fromis_9/
|
||||||
│ │ │ ├── categories.js
|
│ │ │ ├── categories.js
|
||||||
│ │ │ ├── stats.js
|
│ │ │ ├── stats.js
|
||||||
│ │ │ ├── bots.js
|
│ │ │ ├── bots.js
|
||||||
│ │ │ ├── auth.js
|
|
||||||
│ │ │ └── suggestions.js
|
│ │ │ └── suggestions.js
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├── hooks/ # 커스텀 훅
|
│ │ ├── hooks/ # 커스텀 훅 (공유)
|
||||||
│ │ │ ├── index.js
|
│ │ │ ├── index.js
|
||||||
│ │ │ ├── common/ # 공통 훅
|
│ │ │ ├── useAlbumData.js
|
||||||
│ │ │ │ └── useToast.js
|
│ │ │ ├── useMemberData.js
|
||||||
│ │ │ └── pc/
|
│ │ │ ├── useScheduleData.js
|
||||||
│ │ │ └── admin/ # 관리자 훅
|
│ │ │ ├── useScheduleSearch.js
|
||||||
│ │ │ ├── useAdminAuth.js
|
│ │ │ ├── useCalendar.js
|
||||||
│ │ │ └── useScheduleSearch.js
|
│ │ │ ├── useToast.js
|
||||||
|
│ │ │ └── useAdminAuth.js
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├── stores/ # Zustand 스토어
|
│ │ ├── stores/ # Zustand 스토어 (공유)
|
||||||
│ │ │ ├── index.js
|
│ │ │ ├── index.js
|
||||||
│ │ │ ├── useScheduleStore.js
|
│ │ │ ├── useScheduleStore.js
|
||||||
│ │ │ └── useAuthStore.js
|
│ │ │ └── useAuthStore.js
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├── utils/ # 유틸리티
|
│ │ ├── utils/ # 유틸리티 (공유)
|
||||||
│ │ │ ├── index.js
|
│ │ │ ├── index.js
|
||||||
│ │ │ ├── cn.js # className 병합
|
│ │ │ ├── date.js
|
||||||
│ │ │ ├── color.js # 색상 상수/유틸
|
│ │ │ └── format.js
|
||||||
│ │ │ ├── confetti.js # 생일 축하 효과
|
|
||||||
│ │ │ ├── date.js # 날짜 포맷
|
|
||||||
│ │ │ ├── format.js # 문자열 포맷
|
|
||||||
│ │ │ ├── schedule.js # 일정 관련 유틸
|
|
||||||
│ │ │ └── youtube.js # YouTube URL 파싱
|
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├── constants/
|
│ │ ├── constants/
|
||||||
│ │ │ └── index.js # 상수 정의
|
│ │ │ └── index.js
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├── components/
|
│ │ ├── components/
|
||||||
│ │ │ ├── index.js
|
│ │ │ ├── index.js
|
||||||
│ │ │ ├── common/ # 공통 컴포넌트
|
│ │ │ ├── common/ # 디바이스 무관 공통 컴포넌트
|
||||||
│ │ │ │ ├── Loading.jsx
|
│ │ │ │ ├── Loading.jsx
|
||||||
│ │ │ │ ├── ErrorBoundary.jsx
|
│ │ │ │ ├── ErrorBoundary.jsx
|
||||||
│ │ │ │ ├── ErrorMessage.jsx
|
|
||||||
│ │ │ │ ├── Toast.jsx
|
│ │ │ │ ├── Toast.jsx
|
||||||
│ │ │ │ ├── Tooltip.jsx
|
|
||||||
│ │ │ │ ├── Lightbox.jsx
|
│ │ │ │ ├── Lightbox.jsx
|
||||||
│ │ │ │ ├── MobileLightbox.jsx
|
|
||||||
│ │ │ │ ├── LightboxIndicator.jsx
|
│ │ │ │ ├── LightboxIndicator.jsx
|
||||||
│ │ │ │ ├── AnimatedNumber.jsx
|
│ │ │ │ ├── Tooltip.jsx
|
||||||
│ │ │ │ └── ScrollToTop.jsx
|
│ │ │ │ └── ScrollToTop.jsx
|
||||||
│ │ │ │
|
│ │ │ ├── pc/ # PC 레이아웃 컴포넌트
|
||||||
│ │ │ ├── pc/
|
│ │ │ │ ├── Layout.jsx
|
||||||
│ │ │ │ ├── public/ # PC 공개 컴포넌트
|
│ │ │ │ ├── Header.jsx
|
||||||
│ │ │ │ │ ├── layout/
|
│ │ │ │ └── Footer.jsx
|
||||||
│ │ │ │ │ │ ├── Layout.jsx
|
│ │ │ ├── mobile/ # Mobile 레이아웃 컴포넌트
|
||||||
│ │ │ │ │ │ ├── Header.jsx
|
│ │ │ │ ├── Layout.jsx
|
||||||
│ │ │ │ │ │ └── Footer.jsx
|
│ │ │ │ └── MobileNav.jsx
|
||||||
│ │ │ │ │ └── schedule/
|
│ │ │ └── admin/ # 관리자 컴포넌트
|
||||||
│ │ │ │ │ ├── Calendar.jsx
|
│ │ │ ├── AdminLayout.jsx
|
||||||
│ │ │ │ │ ├── ScheduleCard.jsx
|
│ │ │ ├── AdminHeader.jsx
|
||||||
│ │ │ │ │ ├── BirthdayCard.jsx
|
│ │ │ ├── ConfirmDialog.jsx
|
||||||
│ │ │ │ │ └── CategoryFilter.jsx
|
│ │ │ ├── CustomDatePicker.jsx
|
||||||
│ │ │ │ │
|
│ │ │ ├── CustomTimePicker.jsx
|
||||||
│ │ │ │ └── admin/ # PC 관리자 컴포넌트
|
│ │ │ └── NumberPicker.jsx
|
||||||
│ │ │ │ ├── layout/
|
|
||||||
│ │ │ │ │ ├── Layout.jsx
|
|
||||||
│ │ │ │ │ └── Header.jsx
|
|
||||||
│ │ │ │ ├── common/
|
|
||||||
│ │ │ │ │ ├── ConfirmDialog.jsx
|
|
||||||
│ │ │ │ │ ├── DatePicker.jsx
|
|
||||||
│ │ │ │ │ ├── TimePicker.jsx
|
|
||||||
│ │ │ │ │ ├── NumberPicker.jsx
|
|
||||||
│ │ │ │ │ └── CustomSelect.jsx
|
|
||||||
│ │ │ │ ├── schedule/
|
|
||||||
│ │ │ │ │ ├── AdminScheduleCard.jsx
|
|
||||||
│ │ │ │ │ ├── ScheduleItem.jsx
|
|
||||||
│ │ │ │ │ ├── CategorySelector.jsx
|
|
||||||
│ │ │ │ │ ├── CategoryFormModal.jsx
|
|
||||||
│ │ │ │ │ ├── MemberSelector.jsx
|
|
||||||
│ │ │ │ │ ├── ImageUploader.jsx
|
|
||||||
│ │ │ │ │ ├── LocationSearchDialog.jsx
|
|
||||||
│ │ │ │ │ └── WordItem.jsx
|
|
||||||
│ │ │ │ ├── album/
|
|
||||||
│ │ │ │ │ ├── TrackItem.jsx
|
|
||||||
│ │ │ │ │ ├── PhotoGrid.jsx
|
|
||||||
│ │ │ │ │ ├── PhotoPreviewModal.jsx
|
|
||||||
│ │ │ │ │ ├── PendingFileItem.jsx
|
|
||||||
│ │ │ │ │ └── BulkEditPanel.jsx
|
|
||||||
│ │ │ │ └── bot/
|
|
||||||
│ │ │ │ └── BotCard.jsx
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ └── mobile/ # 모바일 컴포넌트
|
|
||||||
│ │ │ ├── layout/
|
|
||||||
│ │ │ │ ├── Layout.jsx
|
|
||||||
│ │ │ │ ├── Header.jsx
|
|
||||||
│ │ │ │ └── BottomNav.jsx
|
|
||||||
│ │ │ └── schedule/
|
|
||||||
│ │ │ ├── Calendar.jsx
|
|
||||||
│ │ │ ├── ScheduleCard.jsx
|
|
||||||
│ │ │ ├── ScheduleListCard.jsx
|
|
||||||
│ │ │ ├── ScheduleSearchCard.jsx
|
|
||||||
│ │ │ └── BirthdayCard.jsx
|
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├── pages/
|
│ │ ├── pages/
|
||||||
│ │ │ ├── pc/
|
│ │ │ ├── index.js
|
||||||
│ │ │ │ ├── public/ # PC 공개 페이지
|
|
||||||
│ │ │ │ │ ├── home/
|
|
||||||
│ │ │ │ │ │ └── Home.jsx
|
|
||||||
│ │ │ │ │ ├── members/
|
|
||||||
│ │ │ │ │ │ └── Members.jsx
|
|
||||||
│ │ │ │ │ ├── album/
|
|
||||||
│ │ │ │ │ │ ├── Album.jsx
|
|
||||||
│ │ │ │ │ │ ├── AlbumDetail.jsx
|
|
||||||
│ │ │ │ │ │ ├── AlbumGallery.jsx
|
|
||||||
│ │ │ │ │ │ └── TrackDetail.jsx
|
|
||||||
│ │ │ │ │ ├── schedule/
|
|
||||||
│ │ │ │ │ │ ├── Schedule.jsx
|
|
||||||
│ │ │ │ │ │ ├── ScheduleDetail.jsx
|
|
||||||
│ │ │ │ │ │ ├── Birthday.jsx
|
|
||||||
│ │ │ │ │ │ └── sections/
|
|
||||||
│ │ │ │ │ │ ├── DefaultSection.jsx
|
|
||||||
│ │ │ │ │ │ ├── YoutubeSection.jsx
|
|
||||||
│ │ │ │ │ │ └── XSection.jsx
|
|
||||||
│ │ │ │ │ └── common/
|
|
||||||
│ │ │ │ │ └── NotFound.jsx
|
|
||||||
│ │ │ │ │
|
|
||||||
│ │ │ │ └── admin/ # PC 관리자 페이지
|
|
||||||
│ │ │ │ ├── Login.jsx
|
|
||||||
│ │ │ │ ├── Dashboard.jsx
|
|
||||||
│ │ │ │ ├── members/
|
|
||||||
│ │ │ │ │ ├── Members.jsx
|
|
||||||
│ │ │ │ │ └── MemberEdit.jsx
|
|
||||||
│ │ │ │ ├── albums/
|
|
||||||
│ │ │ │ │ ├── Albums.jsx
|
|
||||||
│ │ │ │ │ ├── AlbumForm.jsx
|
|
||||||
│ │ │ │ │ └── AlbumPhotos.jsx
|
|
||||||
│ │ │ │ └── schedules/
|
|
||||||
│ │ │ │ ├── Schedules.jsx
|
|
||||||
│ │ │ │ ├── ScheduleForm.jsx
|
|
||||||
│ │ │ │ ├── ScheduleDict.jsx
|
|
||||||
│ │ │ │ ├── ScheduleBots.jsx
|
|
||||||
│ │ │ │ ├── ScheduleCategory.jsx
|
|
||||||
│ │ │ │ ├── form/
|
|
||||||
│ │ │ │ │ ├── YouTubeForm.jsx
|
|
||||||
│ │ │ │ │ └── XForm.jsx
|
|
||||||
│ │ │ │ └── edit/
|
|
||||||
│ │ │ │ └── YouTubeEdit.jsx
|
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ │ │ └── mobile/ # 모바일 페이지
|
│ │ │ ├── home/
|
||||||
│ │ │ ├── home/
|
│ │ │ │ ├── index.js # export { PCHome, MobileHome }
|
||||||
│ │ │ │ └── Home.jsx
|
│ │ │ │ ├── pc/
|
||||||
|
│ │ │ │ │ └── Home.jsx
|
||||||
|
│ │ │ │ └── mobile/
|
||||||
|
│ │ │ │ └── Home.jsx
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ ├── members/
|
||||||
|
│ │ │ │ ├── index.js
|
||||||
|
│ │ │ │ ├── pc/
|
||||||
|
│ │ │ │ │ └── Members.jsx
|
||||||
|
│ │ │ │ └── mobile/
|
||||||
|
│ │ │ │ └── Members.jsx
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ ├── album/
|
||||||
|
│ │ │ │ ├── index.js
|
||||||
|
│ │ │ │ ├── pc/
|
||||||
|
│ │ │ │ │ ├── Album.jsx
|
||||||
|
│ │ │ │ │ ├── AlbumDetail.jsx
|
||||||
|
│ │ │ │ │ ├── AlbumGallery.jsx
|
||||||
|
│ │ │ │ │ └── TrackDetail.jsx
|
||||||
|
│ │ │ │ └── mobile/
|
||||||
|
│ │ │ │ ├── Album.jsx
|
||||||
|
│ │ │ │ ├── AlbumDetail.jsx
|
||||||
|
│ │ │ │ ├── AlbumGallery.jsx
|
||||||
|
│ │ │ │ └── TrackDetail.jsx
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ ├── schedule/
|
||||||
|
│ │ │ │ ├── index.js
|
||||||
|
│ │ │ │ ├── sections/ # 일정 상세 섹션 (PC 전용)
|
||||||
|
│ │ │ │ │ ├── DefaultSection.jsx
|
||||||
|
│ │ │ │ │ ├── XSection.jsx
|
||||||
|
│ │ │ │ │ └── YoutubeSection.jsx
|
||||||
|
│ │ │ │ ├── pc/
|
||||||
|
│ │ │ │ │ ├── Schedule.jsx
|
||||||
|
│ │ │ │ │ ├── ScheduleDetail.jsx
|
||||||
|
│ │ │ │ │ └── Birthday.jsx
|
||||||
|
│ │ │ │ └── mobile/
|
||||||
|
│ │ │ │ ├── Schedule.jsx
|
||||||
|
│ │ │ │ └── ScheduleDetail.jsx
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ ├── common/
|
||||||
|
│ │ │ │ ├── pc/
|
||||||
|
│ │ │ │ │ └── NotFound.jsx
|
||||||
|
│ │ │ │ └── mobile/
|
||||||
|
│ │ │ │ └── NotFound.jsx
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ └── admin/ # 관리자 페이지 (PC 전용)
|
||||||
|
│ │ │ ├── index.js
|
||||||
|
│ │ │ ├── Login.jsx
|
||||||
|
│ │ │ ├── Dashboard.jsx
|
||||||
│ │ │ ├── members/
|
│ │ │ ├── members/
|
||||||
│ │ │ │ └── Members.jsx
|
│ │ │ │ ├── List.jsx
|
||||||
│ │ │ ├── album/
|
│ │ │ │ └── Edit.jsx
|
||||||
│ │ │ │ ├── Album.jsx
|
│ │ │ ├── albums/
|
||||||
│ │ │ │ ├── AlbumDetail.jsx
|
│ │ │ │ ├── List.jsx
|
||||||
│ │ │ │ ├── AlbumGallery.jsx
|
│ │ │ │ ├── Form.jsx
|
||||||
│ │ │ │ └── TrackDetail.jsx
|
│ │ │ │ └── Photos.jsx
|
||||||
│ │ │ ├── schedule/
|
│ │ │ ├── schedules/
|
||||||
│ │ │ │ ├── Schedule.jsx
|
│ │ │ │ ├── List.jsx
|
||||||
│ │ │ │ └── ScheduleDetail.jsx
|
│ │ │ │ ├── Form.jsx
|
||||||
│ │ │ └── common/
|
│ │ │ │ ├── YouTubeForm.jsx
|
||||||
│ │ │ └── NotFound.jsx
|
│ │ │ │ ├── XForm.jsx
|
||||||
|
│ │ │ │ └── YouTubeEditForm.jsx
|
||||||
|
│ │ │ ├── categories/
|
||||||
|
│ │ │ │ └── List.jsx
|
||||||
|
│ │ │ ├── bots/
|
||||||
|
│ │ │ │ └── Manager.jsx
|
||||||
|
│ │ │ └── dict/
|
||||||
|
│ │ │ └── Manager.jsx
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├── routes/ # 라우트 정의
|
│ │ ├── App.jsx # BrowserView/MobileView 라우팅
|
||||||
│ │ │ ├── index.js # 라우트 export
|
|
||||||
│ │ │ ├── pc/
|
|
||||||
│ │ │ │ ├── admin/
|
|
||||||
│ │ │ │ │ └── index.jsx # PC 관리자 라우트
|
|
||||||
│ │ │ │ └── public/
|
|
||||||
│ │ │ │ └── index.jsx # PC 공개 라우트
|
|
||||||
│ │ │ └── mobile/
|
|
||||||
│ │ │ └── index.jsx # 모바일 라우트
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── App.jsx # PC/모바일 분기
|
|
||||||
│ │ └── main.jsx
|
│ │ └── main.jsx
|
||||||
│ │
|
│ │
|
||||||
│ ├── vite.config.js
|
│ ├── vite.config.js
|
||||||
|
|
|
||||||
|
|
@ -170,8 +170,7 @@ docker exec caddy caddy reload --config /etc/caddy/Caddyfile
|
||||||
|
|
||||||
```
|
```
|
||||||
src/api/
|
src/api/
|
||||||
├── index.js # 전체 export
|
├── index.js # fetchApi 유틸 (에러 처리, 토큰 주입)
|
||||||
├── client.js # api, authApi 헬퍼 (에러 처리, 토큰 주입)
|
|
||||||
├── public/ # 공개 API (인증 불필요)
|
├── public/ # 공개 API (인증 불필요)
|
||||||
│ ├── albums.js # getAlbums, getAlbumByName, getTrack
|
│ ├── albums.js # getAlbums, getAlbumByName, getTrack
|
||||||
│ ├── members.js # getMembers
|
│ ├── members.js # getMembers
|
||||||
|
|
@ -180,37 +179,20 @@ src/api/
|
||||||
├── auth.js # login, verifyToken
|
├── auth.js # login, verifyToken
|
||||||
├── albums.js # createAlbum, updateAlbum, deleteAlbum, ...
|
├── albums.js # createAlbum, updateAlbum, deleteAlbum, ...
|
||||||
├── bots.js # getBots, startBot, stopBot, syncBot
|
├── bots.js # getBots, startBot, stopBot, syncBot
|
||||||
├── categories.js # getCategories, createCategory, updateCategory, ...
|
├── categories.js # getCategories
|
||||||
├── members.js # updateMember
|
├── members.js # updateMember
|
||||||
├── schedules.js # getYoutubeInfo, saveYoutube, getXInfo, saveX, ...
|
├── schedules.js # getYoutubeInfo, saveYoutube, getXInfo, saveX, ...
|
||||||
├── stats.js # getStats
|
├── stats.js # getStats
|
||||||
└── suggestions.js # getDict, saveDict
|
└── suggestions.js # getDict, saveDict
|
||||||
```
|
```
|
||||||
|
|
||||||
**client.js 헬퍼:**
|
|
||||||
```jsx
|
|
||||||
// 공개 API 헬퍼 (인증 불필요)
|
|
||||||
import { api } from '@/api/client';
|
|
||||||
|
|
||||||
api.get('/albums');
|
|
||||||
api.post('/schedules/suggestions/save', { query: '검색어' });
|
|
||||||
|
|
||||||
// 인증 API 헬퍼 (토큰 자동 주입)
|
|
||||||
import { authApi } from '@/api/client';
|
|
||||||
|
|
||||||
authApi.get('/admin/stats');
|
|
||||||
authApi.post('/admin/schedules', data);
|
|
||||||
authApi.put('/admin/albums/1', data);
|
|
||||||
authApi.del('/admin/schedules/1');
|
|
||||||
```
|
|
||||||
|
|
||||||
**사용 예시:**
|
**사용 예시:**
|
||||||
```jsx
|
```jsx
|
||||||
// 공개 API
|
// 공개 API
|
||||||
import { getSchedules, getSchedule } from '@/api/public/schedules';
|
import { getSchedules, getSchedule } from '@/api/public/schedules';
|
||||||
|
|
||||||
// 관리자 API
|
// 관리자 API
|
||||||
import * as botsApi from '@/api/admin/bots';
|
import { getBots, startBot } from '@/api/admin/bots';
|
||||||
```
|
```
|
||||||
|
|
||||||
### React Query 사용 (데이터 페칭)
|
### React Query 사용 (데이터 페칭)
|
||||||
|
|
@ -271,6 +253,6 @@ docker compose down && docker compose up -d --build
|
||||||
curl -X POST https://fromis9.caadiq.co.kr/api/schedules/sync-search \
|
curl -X POST https://fromis9.caadiq.co.kr/api/schedules/sync-search \
|
||||||
-H "Authorization: Bearer <token>"
|
-H "Authorization: Bearer <token>"
|
||||||
|
|
||||||
# Redis 확인 (SCAN 사용 권장)
|
# Redis 확인
|
||||||
docker exec fromis9-redis redis-cli SCAN 0 MATCH "*" COUNT 100
|
docker exec fromis9-redis redis-cli KEYS "*"
|
||||||
```
|
```
|
||||||
|
|
|
||||||
273
docs/frontend-improvement.md
Normal file
273
docs/frontend-improvement.md
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
# 일정 관리 페이지 개선 계획
|
||||||
|
|
||||||
|
## 대상 파일
|
||||||
|
|
||||||
|
| 파일 | 라인 수 | 역할 |
|
||||||
|
|------|---------|------|
|
||||||
|
| Schedules.jsx | 1159 | 일정 목록/검색 |
|
||||||
|
| ScheduleForm.jsx | 765 | 일정 추가/수정 폼 |
|
||||||
|
| ScheduleDict.jsx | 572 | 사전 관리 |
|
||||||
|
| ScheduleBots.jsx | 570 | 봇 관리 |
|
||||||
|
| ScheduleCategory.jsx | 466 | 카테고리 관리 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 공통 코드 중복 문제
|
||||||
|
|
||||||
|
### 1.1 colorMap / getColorStyle 중복
|
||||||
|
|
||||||
|
**현황:** 3개 파일에서 동일한 코드 반복
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Schedules.jsx:206-224
|
||||||
|
// ScheduleForm.jsx:97-117
|
||||||
|
// ScheduleCategory.jsx:24-36
|
||||||
|
const colorMap = {
|
||||||
|
blue: 'bg-blue-500',
|
||||||
|
green: 'bg-green-500',
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
|
||||||
|
const getColorStyle = (color) => {
|
||||||
|
if (!color) return { className: 'bg-gray-500' };
|
||||||
|
if (color.startsWith('#')) {
|
||||||
|
return { style: { backgroundColor: color } };
|
||||||
|
}
|
||||||
|
return { className: colorMap[color] || 'bg-gray-500' };
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**개선안:**
|
||||||
|
```
|
||||||
|
utils/color.js 생성
|
||||||
|
├── COLOR_MAP (상수)
|
||||||
|
├── COLOR_OPTIONS (ScheduleCategory에서 사용하는 색상 옵션)
|
||||||
|
└── getColorStyle(color) (함수)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 colorOptions 상수
|
||||||
|
|
||||||
|
**현황:** ScheduleCategory.jsx에만 있지만 확장성 고려
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ScheduleCategory.jsx:13-22
|
||||||
|
const colorOptions = [
|
||||||
|
{ id: 'blue', name: '파란색', bg: 'bg-blue-500', hex: '#3b82f6' },
|
||||||
|
// ...
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
**개선안:** `constants/colors.js` 또는 `utils/color.js`에 통합
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 파일별 개선 사항
|
||||||
|
|
||||||
|
### 2.1 Schedules.jsx (1159줄)
|
||||||
|
|
||||||
|
#### 검색 관련 상태/로직 복잡
|
||||||
|
|
||||||
|
**현황:** 검색 관련 상태가 10개 이상, useEffect 5개
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 검색 관련 상태 (55-65줄)
|
||||||
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
|
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
|
||||||
|
const [originalSearchQuery, setOriginalSearchQuery] = useState('');
|
||||||
|
const [suggestions, setSuggestions] = useState([]);
|
||||||
|
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
|
||||||
|
```
|
||||||
|
|
||||||
|
**개선안:** `useScheduleSearch` 커스텀 훅 분리
|
||||||
|
```javascript
|
||||||
|
// hooks/pc/admin/useScheduleSearch.js
|
||||||
|
export function useScheduleSearch() {
|
||||||
|
// 검색 상태 및 로직 캡슐화
|
||||||
|
return {
|
||||||
|
searchInput, setSearchInput,
|
||||||
|
suggestions, isLoadingSuggestions,
|
||||||
|
handleSearch, handleSuggestionSelect,
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 달력 로직 분리 가능
|
||||||
|
|
||||||
|
**현황:** 달력 관련 계산이 컴포넌트 내부에 산재
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 161-181줄
|
||||||
|
const year = currentDate.getFullYear();
|
||||||
|
const month = currentDate.getMonth();
|
||||||
|
const firstDay = new Date(year, month, 1).getDay();
|
||||||
|
// ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**개선안:** 기존 `utils/date.js`에 달력 헬퍼 함수 추가 또는 `useCalendar` 훅 생성
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 ScheduleForm.jsx (765줄)
|
||||||
|
|
||||||
|
#### fetchSchedule 함수 중복 설정
|
||||||
|
|
||||||
|
**현황:** formData를 두 번 설정 (비효율)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 140-158줄: 첫 번째 setFormData
|
||||||
|
setFormData({
|
||||||
|
title: data.title || '',
|
||||||
|
startDate: data.date ? formatDate(data.date) : '',
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
|
||||||
|
// 163-184줄: 두 번째 setFormData (기존 이미지 처리 시)
|
||||||
|
if (data.images && data.images.length > 0) {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
title: data.title || '', // 중복!
|
||||||
|
// ...
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**개선안:** 하나의 setFormData로 통합
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const initialFormData = {
|
||||||
|
title: data.title || '',
|
||||||
|
startDate: data.date ? formatDate(data.date) : '',
|
||||||
|
// ...
|
||||||
|
images: data.images?.map((img) => ({ id: img.id, url: img.image_url })) || [],
|
||||||
|
};
|
||||||
|
setFormData(initialFormData);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3 ScheduleDict.jsx (572줄)
|
||||||
|
|
||||||
|
#### generateId 일관성
|
||||||
|
|
||||||
|
**현황:** `generateId`를 useCallback으로 정의했지만, `parseDict` 내부에서는 인라인으로 같은 로직 사용
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 113-116줄
|
||||||
|
const generateId = useCallback(
|
||||||
|
() => `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 128줄 (parseDict 내부)
|
||||||
|
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
```
|
||||||
|
|
||||||
|
**개선안:** `generateId`를 외부 유틸 함수로 분리하거나, parseDict에서 generateId 참조
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.4 ScheduleBots.jsx (570줄)
|
||||||
|
|
||||||
|
#### 인라인 컴포넌트 분리
|
||||||
|
|
||||||
|
**현황:** AnimatedNumber, XIcon, MeilisearchIcon이 파일 내부에 정의
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 42-65줄
|
||||||
|
function AnimatedNumber({ value, className = '' }) { ... }
|
||||||
|
|
||||||
|
// 68-72줄
|
||||||
|
const XIcon = ({ size = 20, fill = 'currentColor' }) => ( ... );
|
||||||
|
|
||||||
|
// 75-128줄
|
||||||
|
const MeilisearchIcon = ({ size = 20 }) => ( ... );
|
||||||
|
```
|
||||||
|
|
||||||
|
**개선안:**
|
||||||
|
```
|
||||||
|
components/common/
|
||||||
|
├── AnimatedNumber.jsx (재사용 가능한 애니메이션 숫자)
|
||||||
|
└── icons/
|
||||||
|
├── XIcon.jsx
|
||||||
|
└── MeilisearchIcon.jsx
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 봇 카드 컴포넌트 분리
|
||||||
|
|
||||||
|
**현황:** 봇 카드 렌더링이 414-559줄로 약 145줄
|
||||||
|
|
||||||
|
**개선안:**
|
||||||
|
```javascript
|
||||||
|
// components/pc/admin/bot/BotCard.jsx
|
||||||
|
function BotCard({ bot, onToggle, onSync, syncing }) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.5 ScheduleCategory.jsx (466줄)
|
||||||
|
|
||||||
|
#### 모달 컴포넌트 인라인
|
||||||
|
|
||||||
|
**현황:** 카테고리 추가/수정 모달이 284-445줄로 약 160줄
|
||||||
|
|
||||||
|
**개선안:**
|
||||||
|
```javascript
|
||||||
|
// components/pc/admin/schedule/CategoryFormModal.jsx
|
||||||
|
function CategoryFormModal({ isOpen, onClose, category, onSave }) {
|
||||||
|
// 색상 선택, 폼 로직 포함
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 개선 우선순위
|
||||||
|
|
||||||
|
### Phase 1: 중복 코드 제거 (빠른 효과) ✅ 완료
|
||||||
|
1. [x] `utils/color.js` 생성 - COLOR_MAP, COLOR_OPTIONS, getColorStyle 통합
|
||||||
|
2. [x] 3개 파일에서 import로 교체
|
||||||
|
- Schedules.jsx: 1159줄 → 1139줄 (20줄 감소)
|
||||||
|
- ScheduleForm.jsx: 765줄 → 743줄 (22줄 감소)
|
||||||
|
- ScheduleCategory.jsx: 466줄 → 441줄 (25줄 감소)
|
||||||
|
|
||||||
|
### Phase 2: 커스텀 훅 분리 (복잡도 감소) ✅ 완료
|
||||||
|
1. [x] `useScheduleSearch` 훅 생성 - Schedules.jsx 검색 로직 분리
|
||||||
|
- 검색어 자동완성, 무한 스크롤, 키보드 네비게이션 캡슐화
|
||||||
|
- Schedules.jsx: 1139줄 → 1009줄 (130줄 감소)
|
||||||
|
2. [ ] 달력 관련 로직 정리 (선택사항, 현재 규모 적절)
|
||||||
|
|
||||||
|
### Phase 3: 컴포넌트 분리 (재사용성) ✅ 완료
|
||||||
|
1. [x] `AnimatedNumber` 공통 컴포넌트화 → components/common/AnimatedNumber.jsx (32줄)
|
||||||
|
2. [x] `BotCard` 컴포넌트 분리 → components/pc/admin/bot/BotCard.jsx (233줄)
|
||||||
|
3. [x] `CategoryFormModal` 컴포넌트 분리 → components/pc/admin/schedule/CategoryFormModal.jsx (195줄)
|
||||||
|
4. [x] SVG 아이콘 분리 (XIcon, MeilisearchIcon) → BotCard.jsx에 포함
|
||||||
|
- ScheduleBots.jsx: 570줄 → 339줄 (231줄 감소)
|
||||||
|
- ScheduleCategory.jsx: 441줄 → 289줄 (152줄 감소)
|
||||||
|
|
||||||
|
### Phase 4: 코드 정리
|
||||||
|
1. [ ] ScheduleForm.jsx - fetchSchedule 중복 제거
|
||||||
|
2. [ ] ScheduleDict.jsx - generateId 일관성
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 개선 결과
|
||||||
|
|
||||||
|
| 파일 | 개선 전 | 개선 후 | 감소 |
|
||||||
|
|------|---------|---------|------|
|
||||||
|
| Schedules.jsx | 1159줄 | 1009줄 | 150줄 |
|
||||||
|
| ScheduleForm.jsx | 765줄 | 743줄 | 22줄 |
|
||||||
|
| ScheduleDict.jsx | 572줄 | 572줄 | - |
|
||||||
|
| ScheduleBots.jsx | 570줄 | 339줄 | 231줄 |
|
||||||
|
| ScheduleCategory.jsx | 466줄 | 289줄 | 177줄 |
|
||||||
|
| **합계** | **3532줄** | **2952줄** | **580줄** |
|
||||||
|
|
||||||
|
### 새로 생성된 파일
|
||||||
|
| 파일 | 라인 수 | 역할 |
|
||||||
|
|------|---------|------|
|
||||||
|
| utils/color.js | 35줄 | 색상 상수/유틸 |
|
||||||
|
| hooks/pc/admin/useScheduleSearch.js | 217줄 | 검색 로직 훅 |
|
||||||
|
| components/common/AnimatedNumber.jsx | 32줄 | 숫자 애니메이션 |
|
||||||
|
| components/pc/admin/bot/BotCard.jsx | 233줄 | 봇 카드 |
|
||||||
|
| components/pc/admin/schedule/CategoryFormModal.jsx | 195줄 | 카테고리 폼 모달 |
|
||||||
4
frontend-temp/Dockerfile
Normal file
4
frontend-temp/Dockerfile
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
# 개발 모드
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
CMD ["sh", "-c", "npm install --include=dev && npm run dev -- --host 0.0.0.0"]
|
||||||
22
frontend-temp/index.html
Normal file
22
frontend-temp/index.html
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||||
|
/>
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
|
<title>fromis_9 - 프로미스나인</title>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
as="style"
|
||||||
|
crossorigin
|
||||||
|
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2106
frontend-temp/package-lock.json
generated
Normal file
2106
frontend-temp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
44
frontend-temp/package.json
Normal file
44
frontend-temp/package.json
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
{
|
||||||
|
"name": "fromis9-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "2.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.28.6",
|
||||||
|
"@tanstack/react-query": "^5.90.16",
|
||||||
|
"@tanstack/react-virtual": "^3.13.18",
|
||||||
|
"canvas-confetti": "^1.9.4",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"dayjs": "^1.11.19",
|
||||||
|
"framer-motion": "^11.0.8",
|
||||||
|
"lucide-react": "^0.344.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-calendar": "^6.0.0",
|
||||||
|
"react-colorful": "^5.6.1",
|
||||||
|
"react-device-detect": "^2.2.3",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-infinite-scroll-component": "^6.1.1",
|
||||||
|
"react-intersection-observer": "^10.0.0",
|
||||||
|
"react-ios-time-picker": "^0.2.2",
|
||||||
|
"react-linkify": "^1.0.0-alpha",
|
||||||
|
"react-photo-album": "^3.4.0",
|
||||||
|
"react-router-dom": "^6.22.3",
|
||||||
|
"react-window": "^2.2.3",
|
||||||
|
"swiper": "^12.0.3",
|
||||||
|
"zustand": "^5.0.9"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.4.22",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^3.4.18",
|
||||||
|
"vite": "^5.4.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend-temp/postcss.config.js
Normal file
6
frontend-temp/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
BIN
frontend-temp/public/favicon.ico
Normal file
BIN
frontend-temp/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 234 KiB |
193
frontend-temp/src/App.jsx
Normal file
193
frontend-temp/src/App.jsx
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
|
import { BrowserView, MobileView } from 'react-device-detect';
|
||||||
|
|
||||||
|
// 공통 컴포넌트
|
||||||
|
import { ScrollToTop } from '@/components/common';
|
||||||
|
|
||||||
|
// PC 레이아웃
|
||||||
|
import { Layout as PCLayout } from '@/components/pc/public';
|
||||||
|
|
||||||
|
// Mobile 레이아웃
|
||||||
|
import { Layout as MobileLayout } from '@/components/mobile';
|
||||||
|
|
||||||
|
// PC 공개 페이지
|
||||||
|
import PCHome from '@/pages/pc/public/home/Home';
|
||||||
|
import PCMembers from '@/pages/pc/public/members/Members';
|
||||||
|
import PCSchedule from '@/pages/pc/public/schedule/Schedule';
|
||||||
|
import PCScheduleDetail from '@/pages/pc/public/schedule/ScheduleDetail';
|
||||||
|
import PCBirthday from '@/pages/pc/public/schedule/Birthday';
|
||||||
|
import PCAlbum from '@/pages/pc/public/album/Album';
|
||||||
|
import PCAlbumDetail from '@/pages/pc/public/album/AlbumDetail';
|
||||||
|
import PCTrackDetail from '@/pages/pc/public/album/TrackDetail';
|
||||||
|
import PCAlbumGallery from '@/pages/pc/public/album/AlbumGallery';
|
||||||
|
import PCNotFound from '@/pages/pc/public/common/NotFound';
|
||||||
|
|
||||||
|
// PC 관리자 페이지
|
||||||
|
import AdminLogin from '@/pages/pc/admin/login/Login';
|
||||||
|
import AdminDashboard from '@/pages/pc/admin/dashboard/Dashboard';
|
||||||
|
import AdminMembers from '@/pages/pc/admin/members/Members';
|
||||||
|
import AdminMemberEdit from '@/pages/pc/admin/members/MemberEdit';
|
||||||
|
import AdminAlbums from '@/pages/pc/admin/albums/Albums';
|
||||||
|
import AdminAlbumForm from '@/pages/pc/admin/albums/AlbumForm';
|
||||||
|
import AdminAlbumPhotos from '@/pages/pc/admin/albums/AlbumPhotos';
|
||||||
|
import AdminSchedules from '@/pages/pc/admin/schedules/Schedules';
|
||||||
|
import AdminScheduleForm from '@/pages/pc/admin/schedules/ScheduleForm';
|
||||||
|
import AdminScheduleFormPage from '@/pages/pc/admin/schedules/form';
|
||||||
|
import AdminYouTubeEditForm from '@/pages/pc/admin/schedules/edit/YouTubeEditForm';
|
||||||
|
import AdminScheduleCategory from '@/pages/pc/admin/schedules/ScheduleCategory';
|
||||||
|
import AdminScheduleDict from '@/pages/pc/admin/schedules/ScheduleDict';
|
||||||
|
import AdminScheduleBots from '@/pages/pc/admin/schedules/ScheduleBots';
|
||||||
|
import AdminNotFound from '@/pages/pc/admin/common/NotFound';
|
||||||
|
|
||||||
|
// Mobile 페이지
|
||||||
|
import MobileHome from '@/pages/mobile/home/Home';
|
||||||
|
import MobileMembers from '@/pages/mobile/members/Members';
|
||||||
|
import MobileSchedule from '@/pages/mobile/schedule/Schedule';
|
||||||
|
import MobileScheduleDetail from '@/pages/mobile/schedule/ScheduleDetail';
|
||||||
|
import MobileBirthday from '@/pages/mobile/schedule/Birthday';
|
||||||
|
import MobileAlbum from '@/pages/mobile/album/Album';
|
||||||
|
import MobileAlbumDetail from '@/pages/mobile/album/AlbumDetail';
|
||||||
|
import MobileTrackDetail from '@/pages/mobile/album/TrackDetail';
|
||||||
|
import MobileAlbumGallery from '@/pages/mobile/album/AlbumGallery';
|
||||||
|
import MobileNotFound from '@/pages/mobile/common/NotFound';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PC 환경에서 body에 클래스 추가하는 래퍼
|
||||||
|
*/
|
||||||
|
function PCWrapper({ children }) {
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.classList.add('is-pc');
|
||||||
|
return () => document.body.classList.remove('is-pc');
|
||||||
|
}, []);
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로미스나인 팬사이트 메인 앱
|
||||||
|
* react-device-detect를 사용한 PC/Mobile 분리
|
||||||
|
*/
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||||
|
<ScrollToTop />
|
||||||
|
|
||||||
|
{/* PC 뷰 */}
|
||||||
|
<BrowserView>
|
||||||
|
<PCWrapper>
|
||||||
|
<Routes>
|
||||||
|
{/* 관리자 페이지 (자체 레이아웃 사용) */}
|
||||||
|
<Route path="/admin" element={<AdminLogin />} />
|
||||||
|
<Route path="/admin/dashboard" element={<AdminDashboard />} />
|
||||||
|
<Route path="/admin/members" element={<AdminMembers />} />
|
||||||
|
<Route path="/admin/members/:name/edit" element={<AdminMemberEdit />} />
|
||||||
|
<Route path="/admin/albums" element={<AdminAlbums />} />
|
||||||
|
<Route path="/admin/albums/new" element={<AdminAlbumForm />} />
|
||||||
|
<Route path="/admin/albums/:id/edit" element={<AdminAlbumForm />} />
|
||||||
|
<Route path="/admin/albums/:albumId/photos" element={<AdminAlbumPhotos />} />
|
||||||
|
<Route path="/admin/schedule" element={<AdminSchedules />} />
|
||||||
|
<Route path="/admin/schedule/new" element={<AdminScheduleFormPage />} />
|
||||||
|
<Route path="/admin/schedule/new-legacy" element={<AdminScheduleForm />} />
|
||||||
|
<Route path="/admin/schedule/:id/edit" element={<AdminScheduleForm />} />
|
||||||
|
<Route path="/admin/schedule/:id/edit/youtube" element={<AdminYouTubeEditForm />} />
|
||||||
|
<Route path="/admin/schedule/categories" element={<AdminScheduleCategory />} />
|
||||||
|
<Route path="/admin/schedule/dict" element={<AdminScheduleDict />} />
|
||||||
|
<Route path="/admin/schedule/bots" element={<AdminScheduleBots />} />
|
||||||
|
{/* 관리자 404 페이지 */}
|
||||||
|
<Route path="/admin/*" element={<AdminNotFound />} />
|
||||||
|
|
||||||
|
{/* 일반 페이지 (레이아웃 포함) */}
|
||||||
|
<Route
|
||||||
|
path="/*"
|
||||||
|
element={
|
||||||
|
<PCLayout>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<PCHome />} />
|
||||||
|
<Route path="/members" element={<PCMembers />} />
|
||||||
|
<Route path="/schedule" element={<PCSchedule />} />
|
||||||
|
<Route path="/schedule/:id" element={<PCScheduleDetail />} />
|
||||||
|
<Route path="/birthday/:memberName/:year" element={<PCBirthday />} />
|
||||||
|
<Route path="/album" element={<PCAlbum />} />
|
||||||
|
<Route path="/album/:name" element={<PCAlbumDetail />} />
|
||||||
|
<Route path="/album/:name/track/:trackTitle" element={<PCTrackDetail />} />
|
||||||
|
<Route path="/album/:name/gallery" element={<PCAlbumGallery />} />
|
||||||
|
{/* 404 페이지 */}
|
||||||
|
<Route path="*" element={<PCNotFound />} />
|
||||||
|
</Routes>
|
||||||
|
</PCLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</PCWrapper>
|
||||||
|
</BrowserView>
|
||||||
|
|
||||||
|
{/* Mobile 뷰 */}
|
||||||
|
<MobileView>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<MobileLayout>
|
||||||
|
<MobileHome />
|
||||||
|
</MobileLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/members"
|
||||||
|
element={
|
||||||
|
<MobileLayout pageTitle="멤버" noShadow>
|
||||||
|
<MobileMembers />
|
||||||
|
</MobileLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/schedule"
|
||||||
|
element={
|
||||||
|
<MobileLayout pageTitle="일정" useCustomLayout>
|
||||||
|
<MobileSchedule />
|
||||||
|
</MobileLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/schedule/:id" element={<MobileScheduleDetail />} />
|
||||||
|
<Route path="/birthday/:memberName/:year" element={<MobileBirthday />} />
|
||||||
|
<Route
|
||||||
|
path="/album"
|
||||||
|
element={
|
||||||
|
<MobileLayout pageTitle="앨범">
|
||||||
|
<MobileAlbum />
|
||||||
|
</MobileLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/album/:name"
|
||||||
|
element={
|
||||||
|
<MobileLayout pageTitle="앨범">
|
||||||
|
<MobileAlbumDetail />
|
||||||
|
</MobileLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/album/:name/track/:trackTitle"
|
||||||
|
element={
|
||||||
|
<MobileLayout pageTitle="곡 상세">
|
||||||
|
<MobileTrackDetail />
|
||||||
|
</MobileLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/album/:name/gallery"
|
||||||
|
element={
|
||||||
|
<MobileLayout pageTitle="앨범">
|
||||||
|
<MobileAlbumGallery />
|
||||||
|
</MobileLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/* 404 페이지 */}
|
||||||
|
<Route path="*" element={<MobileNotFound />} />
|
||||||
|
</Routes>
|
||||||
|
</MobileView>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
97
frontend-temp/src/api/admin/albums.js
Normal file
97
frontend-temp/src/api/admin/albums.js
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
/**
|
||||||
|
* 관리자 앨범 API
|
||||||
|
*/
|
||||||
|
import { fetchAuthApi, fetchFormData } from '@/api/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 앨범 목록 조회
|
||||||
|
* @returns {Promise<Array>}
|
||||||
|
*/
|
||||||
|
export async function getAlbums() {
|
||||||
|
return fetchAuthApi('/albums');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 앨범 상세 조회
|
||||||
|
* @param {number} id - 앨범 ID
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
|
export async function getAlbum(id) {
|
||||||
|
return fetchAuthApi(`/albums/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 앨범 생성
|
||||||
|
* @param {FormData} formData - 앨범 데이터
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
|
export async function createAlbum(formData) {
|
||||||
|
return fetchFormData('/albums', formData, 'POST');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 앨범 수정
|
||||||
|
* @param {number} id - 앨범 ID
|
||||||
|
* @param {FormData} formData - 앨범 데이터
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
|
export async function updateAlbum(id, formData) {
|
||||||
|
return fetchFormData(`/albums/${id}`, formData, 'PUT');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 앨범 삭제
|
||||||
|
* @param {number} id - 앨범 ID
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function deleteAlbum(id) {
|
||||||
|
return fetchAuthApi(`/albums/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 앨범 사진 목록 조회
|
||||||
|
* @param {number} albumId - 앨범 ID
|
||||||
|
* @returns {Promise<Array>}
|
||||||
|
*/
|
||||||
|
export async function getAlbumPhotos(albumId) {
|
||||||
|
return fetchAuthApi(`/albums/${albumId}/photos`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 앨범 사진 업로드
|
||||||
|
* @param {number} albumId - 앨범 ID
|
||||||
|
* @param {FormData} formData - 사진 데이터
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
|
export async function uploadAlbumPhotos(albumId, formData) {
|
||||||
|
return fetchFormData(`/albums/${albumId}/photos`, formData, 'POST');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 앨범 사진 삭제
|
||||||
|
* @param {number} albumId - 앨범 ID
|
||||||
|
* @param {number} photoId - 사진 ID
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function deleteAlbumPhoto(albumId, photoId) {
|
||||||
|
return fetchAuthApi(`/albums/${albumId}/photos/${photoId}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 앨범 티저 목록 조회
|
||||||
|
* @param {number} albumId - 앨범 ID
|
||||||
|
* @returns {Promise<Array>}
|
||||||
|
*/
|
||||||
|
export async function getAlbumTeasers(albumId) {
|
||||||
|
return fetchAuthApi(`/albums/${albumId}/teasers`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 앨범 티저 삭제
|
||||||
|
* @param {number} albumId - 앨범 ID
|
||||||
|
* @param {number} teaserId - 티저 ID
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function deleteAlbumTeaser(albumId, teaserId) {
|
||||||
|
return fetchAuthApi(`/albums/${albumId}/teasers/${teaserId}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
37
frontend-temp/src/api/admin/auth.js
Normal file
37
frontend-temp/src/api/admin/auth.js
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
/**
|
||||||
|
* 관리자 인증 API
|
||||||
|
*/
|
||||||
|
import { fetchApi, fetchAuthApi } from '@/api/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인
|
||||||
|
* @param {string} username - 사용자명
|
||||||
|
* @param {string} password - 비밀번호
|
||||||
|
* @returns {Promise<{token: string, user: object}>}
|
||||||
|
*/
|
||||||
|
export async function login(username, password) {
|
||||||
|
return fetchApi('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 토큰 검증
|
||||||
|
* @returns {Promise<{valid: boolean, user: object}>}
|
||||||
|
*/
|
||||||
|
export async function verifyToken() {
|
||||||
|
return fetchAuthApi('/auth/verify');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 변경
|
||||||
|
* @param {string} currentPassword - 현재 비밀번호
|
||||||
|
* @param {string} newPassword - 새 비밀번호
|
||||||
|
*/
|
||||||
|
export async function changePassword(currentPassword, newPassword) {
|
||||||
|
return fetchAuthApi('/auth/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ currentPassword, newPassword }),
|
||||||
|
});
|
||||||
|
}
|
||||||
55
frontend-temp/src/api/admin/bots.js
Normal file
55
frontend-temp/src/api/admin/bots.js
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
/**
|
||||||
|
* 관리자 봇 관리 API
|
||||||
|
*/
|
||||||
|
import { fetchAuthApi } from '@/api/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 봇 목록 조회
|
||||||
|
* @returns {Promise<Array>}
|
||||||
|
*/
|
||||||
|
export async function getBots() {
|
||||||
|
return fetchAuthApi('/admin/bots');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 봇 시작
|
||||||
|
* @param {string} id - 봇 ID
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
|
export async function startBot(id) {
|
||||||
|
return fetchAuthApi(`/admin/bots/${id}/start`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 봇 정지
|
||||||
|
* @param {string} id - 봇 ID
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
|
export async function stopBot(id) {
|
||||||
|
return fetchAuthApi(`/admin/bots/${id}/stop`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 봇 전체 동기화
|
||||||
|
* @param {string} id - 봇 ID
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
|
export async function syncAllVideos(id) {
|
||||||
|
return fetchAuthApi(`/admin/bots/${id}/sync-all`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 할당량 경고 조회
|
||||||
|
* @returns {Promise<{warning: boolean, message: string}>}
|
||||||
|
*/
|
||||||
|
export async function getQuotaWarning() {
|
||||||
|
return fetchAuthApi('/admin/bots/quota-warning');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 할당량 경고 해제
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function dismissQuotaWarning() {
|
||||||
|
return fetchAuthApi('/admin/bots/quota-warning', { method: 'DELETE' });
|
||||||
|
}
|
||||||
60
frontend-temp/src/api/admin/categories.js
Normal file
60
frontend-temp/src/api/admin/categories.js
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
/**
|
||||||
|
* 관리자 카테고리 API
|
||||||
|
*/
|
||||||
|
import { fetchAuthApi } from '@/api/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 목록 조회
|
||||||
|
* @returns {Promise<Array>}
|
||||||
|
*/
|
||||||
|
export async function getCategories() {
|
||||||
|
return fetchAuthApi('/schedules/categories');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 생성
|
||||||
|
* @param {object} data - 카테고리 데이터
|
||||||
|
* @param {string} data.name - 카테고리 이름
|
||||||
|
* @param {string} data.color - 색상 코드
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
|
export async function createCategory(data) {
|
||||||
|
return fetchAuthApi('/admin/schedule-categories', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 수정
|
||||||
|
* @param {number} id - 카테고리 ID
|
||||||
|
* @param {object} data - 카테고리 데이터
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
|
export async function updateCategory(id, data) {
|
||||||
|
return fetchAuthApi(`/admin/schedule-categories/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 삭제
|
||||||
|
* @param {number} id - 카테고리 ID
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function deleteCategory(id) {
|
||||||
|
return fetchAuthApi(`/admin/schedule-categories/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 순서 변경
|
||||||
|
* @param {Array<{id: number, sort_order: number}>} orders - 순서 데이터
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function reorderCategories(orders) {
|
||||||
|
return fetchAuthApi('/admin/schedule-categories-order', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ orders }),
|
||||||
|
});
|
||||||
|
}
|
||||||
31
frontend-temp/src/api/admin/members.js
Normal file
31
frontend-temp/src/api/admin/members.js
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
/**
|
||||||
|
* 관리자 멤버 API
|
||||||
|
*/
|
||||||
|
import { fetchAuthApi, fetchFormData } from '@/api/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 멤버 목록 조회
|
||||||
|
* @returns {Promise<Array>}
|
||||||
|
*/
|
||||||
|
export async function getMembers() {
|
||||||
|
return fetchAuthApi('/members');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 멤버 상세 조회
|
||||||
|
* @param {number} id - 멤버 ID
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
|
export async function getMember(id) {
|
||||||
|
return fetchAuthApi(`/members/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 멤버 수정
|
||||||
|
* @param {number} id - 멤버 ID
|
||||||
|
* @param {FormData} formData - 멤버 데이터
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
|
export async function updateMember(id, formData) {
|
||||||
|
return fetchFormData(`/members/${id}`, formData, 'PUT');
|
||||||
|
}
|
||||||
102
frontend-temp/src/api/admin/schedules.js
Normal file
102
frontend-temp/src/api/admin/schedules.js
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
/**
|
||||||
|
* 관리자 일정 API
|
||||||
|
*/
|
||||||
|
import { fetchAuthApi, fetchFormData } from '@/api/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 응답을 프론트엔드 형식으로 변환
|
||||||
|
* - datetime → date, time 분리
|
||||||
|
* - category 객체 → category_id, category_name, category_color 플랫화
|
||||||
|
* - members 배열 → member_names 문자열
|
||||||
|
*/
|
||||||
|
function transformSchedule(schedule) {
|
||||||
|
const category = schedule.category || {};
|
||||||
|
|
||||||
|
// datetime에서 date와 time 분리
|
||||||
|
let date = '';
|
||||||
|
let time = null;
|
||||||
|
if (schedule.datetime) {
|
||||||
|
const parts = schedule.datetime.split('T');
|
||||||
|
date = parts[0];
|
||||||
|
time = parts[1] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// members 배열을 문자열로 (기존 코드 호환성)
|
||||||
|
const memberNames = Array.isArray(schedule.members) ? schedule.members.join(',') : '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
...schedule,
|
||||||
|
date,
|
||||||
|
time,
|
||||||
|
category_id: category.id,
|
||||||
|
category_name: category.name,
|
||||||
|
category_color: category.color,
|
||||||
|
member_names: memberNames,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일정 목록 조회 (월별)
|
||||||
|
* @param {number} year - 년도
|
||||||
|
* @param {number} month - 월
|
||||||
|
* @returns {Promise<Array>}
|
||||||
|
*/
|
||||||
|
export async function getSchedules(year, month) {
|
||||||
|
const data = await fetchAuthApi(`/schedules?year=${year}&month=${month}`);
|
||||||
|
return (data.schedules || []).map(transformSchedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일정 검색 (Meilisearch)
|
||||||
|
* @param {string} query - 검색어
|
||||||
|
* @param {object} options - 페이지네이션 옵션
|
||||||
|
* @param {number} options.offset - 시작 위치
|
||||||
|
* @param {number} options.limit - 조회 개수
|
||||||
|
* @returns {Promise<{schedules: Array, total: number}>}
|
||||||
|
*/
|
||||||
|
export async function searchSchedules(query, { offset = 0, limit = 20 } = {}) {
|
||||||
|
const data = await fetchAuthApi(
|
||||||
|
`/schedules?search=${encodeURIComponent(query)}&offset=${offset}&limit=${limit}`
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
schedules: (data.schedules || []).map(transformSchedule),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일정 상세 조회
|
||||||
|
* @param {number} id - 일정 ID
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
|
export async function getSchedule(id) {
|
||||||
|
return fetchAuthApi(`/schedules/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일정 생성
|
||||||
|
* @param {FormData} formData - 일정 데이터
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
|
export async function createSchedule(formData) {
|
||||||
|
return fetchFormData('/admin/schedules', formData, 'POST');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일정 수정
|
||||||
|
* @param {number} id - 일정 ID
|
||||||
|
* @param {FormData} formData - 일정 데이터
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
|
export async function updateSchedule(id, formData) {
|
||||||
|
return fetchFormData(`/admin/schedules/${id}`, formData, 'PUT');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일정 삭제
|
||||||
|
* @param {number} id - 일정 ID
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function deleteSchedule(id) {
|
||||||
|
return fetchAuthApi(`/schedules/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
12
frontend-temp/src/api/admin/stats.js
Normal file
12
frontend-temp/src/api/admin/stats.js
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
/**
|
||||||
|
* 관리자 통계 API
|
||||||
|
*/
|
||||||
|
import { fetchAuthApi } from '@/api/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 통계 조회
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
|
export async function getStats() {
|
||||||
|
return fetchAuthApi('/stats');
|
||||||
|
}
|
||||||
24
frontend-temp/src/api/admin/suggestions.js
Normal file
24
frontend-temp/src/api/admin/suggestions.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
/**
|
||||||
|
* 관리자 추천 검색어 API
|
||||||
|
*/
|
||||||
|
import { fetchAuthApi } from '@/api/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사전 내용 조회
|
||||||
|
* @returns {Promise<{content: string}>}
|
||||||
|
*/
|
||||||
|
export async function getDict() {
|
||||||
|
return fetchAuthApi('/schedules/suggestions/dict');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사전 저장
|
||||||
|
* @param {string} content - 사전 내용
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function saveDict(content) {
|
||||||
|
return fetchAuthApi('/schedules/suggestions/dict', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -143,3 +143,13 @@ export const api = createMethodHelpers(fetchApi);
|
||||||
* @example authApi.get('/admin/stats'), authApi.post('/admin/schedules', data)
|
* @example authApi.get('/admin/stats'), authApi.post('/admin/schedules', data)
|
||||||
*/
|
*/
|
||||||
export const authApi = createMethodHelpers(fetchAuthApi);
|
export const authApi = createMethodHelpers(fetchAuthApi);
|
||||||
|
|
||||||
|
// 기존 호환성을 위한 개별 export (점진적 마이그레이션 후 삭제 예정)
|
||||||
|
export const get = api.get;
|
||||||
|
export const post = api.post;
|
||||||
|
export const put = api.put;
|
||||||
|
export const del = api.del;
|
||||||
|
export const authGet = authApi.get;
|
||||||
|
export const authPost = authApi.post;
|
||||||
|
export const authPut = authApi.put;
|
||||||
|
export const authDel = authApi.del;
|
||||||
16
frontend-temp/src/api/index.js
Normal file
16
frontend-temp/src/api/index.js
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
/**
|
||||||
|
* API 통합 export
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 공통 유틸리티
|
||||||
|
export * from './client';
|
||||||
|
|
||||||
|
// 공개 API
|
||||||
|
export * from './public';
|
||||||
|
export * as scheduleApi from './public/schedules';
|
||||||
|
export * as albumApi from './public/albums';
|
||||||
|
export * as memberApi from './public/members';
|
||||||
|
|
||||||
|
// 관리자 API
|
||||||
|
export * from './admin';
|
||||||
|
export * as authApi from './admin/auth';
|
||||||
101
frontend-temp/src/api/public/albums.js
Normal file
101
frontend-temp/src/api/public/albums.js
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
/**
|
||||||
|
* 앨범 API
|
||||||
|
*/
|
||||||
|
import { fetchApi, fetchAuthApi, fetchFormData } from '@/api/client';
|
||||||
|
|
||||||
|
// ==================== 공개 API ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 앨범 목록 조회
|
||||||
|
*/
|
||||||
|
export async function getAlbums() {
|
||||||
|
return fetchApi('/albums');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 앨범 상세 조회 (ID)
|
||||||
|
*/
|
||||||
|
export async function getAlbum(id) {
|
||||||
|
return fetchApi(`/albums/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 앨범 상세 조회 (이름)
|
||||||
|
*/
|
||||||
|
export async function getAlbumByName(name) {
|
||||||
|
return fetchApi(`/albums/by-name/${encodeURIComponent(name)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 앨범 사진 조회
|
||||||
|
*/
|
||||||
|
export async function getAlbumPhotos(albumId) {
|
||||||
|
return fetchApi(`/albums/${albumId}/photos`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 앨범 트랙 조회
|
||||||
|
*/
|
||||||
|
export async function getAlbumTracks(albumId) {
|
||||||
|
return fetchApi(`/albums/${albumId}/tracks`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트랙 상세 조회 (앨범명, 트랙명으로)
|
||||||
|
*/
|
||||||
|
export async function getTrack(albumName, trackTitle) {
|
||||||
|
return fetchApi(
|
||||||
|
`/albums/by-name/${encodeURIComponent(albumName)}/track/${encodeURIComponent(trackTitle)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 앨범 티저 조회
|
||||||
|
*/
|
||||||
|
export async function getAlbumTeasers(albumId) {
|
||||||
|
return fetchApi(`/albums/${albumId}/teasers`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 어드민 API ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Admin] 앨범 생성
|
||||||
|
*/
|
||||||
|
export async function createAlbum(formData) {
|
||||||
|
return fetchFormData('/albums', formData, 'POST');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Admin] 앨범 수정
|
||||||
|
*/
|
||||||
|
export async function updateAlbum(id, formData) {
|
||||||
|
return fetchFormData(`/albums/${id}`, formData, 'PUT');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Admin] 앨범 삭제
|
||||||
|
*/
|
||||||
|
export async function deleteAlbum(id) {
|
||||||
|
return fetchAuthApi(`/albums/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Admin] 앨범 사진 업로드
|
||||||
|
*/
|
||||||
|
export async function uploadAlbumPhotos(albumId, formData) {
|
||||||
|
return fetchFormData(`/albums/${albumId}/photos`, formData, 'POST');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Admin] 앨범 사진 삭제
|
||||||
|
*/
|
||||||
|
export async function deleteAlbumPhoto(albumId, photoId) {
|
||||||
|
return fetchAuthApi(`/albums/${albumId}/photos/${photoId}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Admin] 앨범 티저 삭제
|
||||||
|
*/
|
||||||
|
export async function deleteAlbumTeaser(albumId, teaserId) {
|
||||||
|
return fetchAuthApi(`/albums/${albumId}/teasers/${teaserId}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
43
frontend-temp/src/api/public/members.js
Normal file
43
frontend-temp/src/api/public/members.js
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
/**
|
||||||
|
* 멤버 API
|
||||||
|
*/
|
||||||
|
import { fetchApi, fetchAuthApi, fetchFormData } from '@/api/client';
|
||||||
|
|
||||||
|
// ==================== 공개 API ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 멤버 목록 조회
|
||||||
|
*/
|
||||||
|
export async function getMembers() {
|
||||||
|
return fetchApi('/members');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 멤버 상세 조회
|
||||||
|
*/
|
||||||
|
export async function getMember(id) {
|
||||||
|
return fetchApi(`/members/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 어드민 API ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Admin] 멤버 생성
|
||||||
|
*/
|
||||||
|
export async function createMember(formData) {
|
||||||
|
return fetchFormData('/admin/members', formData, 'POST');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Admin] 멤버 수정
|
||||||
|
*/
|
||||||
|
export async function updateMember(id, formData) {
|
||||||
|
return fetchFormData(`/admin/members/${id}`, formData, 'PUT');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Admin] 멤버 삭제
|
||||||
|
*/
|
||||||
|
export async function deleteMember(id) {
|
||||||
|
return fetchAuthApi(`/admin/members/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
169
frontend-temp/src/api/public/schedules.js
Normal file
169
frontend-temp/src/api/public/schedules.js
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
/**
|
||||||
|
* 스케줄 API
|
||||||
|
*/
|
||||||
|
import { fetchApi, fetchAuthApi, fetchFormData } from '@/api/client';
|
||||||
|
import { getTodayKST, dayjs } from '@/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 응답을 프론트엔드 형식으로 변환
|
||||||
|
* - datetime → date, time 분리
|
||||||
|
* - category 객체 → category_id, category_name, category_color 플랫화
|
||||||
|
* - members 배열 → member_names 문자열
|
||||||
|
*/
|
||||||
|
function transformSchedule(schedule) {
|
||||||
|
const category = schedule.category || {};
|
||||||
|
|
||||||
|
// datetime에서 date와 time 분리
|
||||||
|
let date = '';
|
||||||
|
let time = null;
|
||||||
|
if (schedule.datetime) {
|
||||||
|
const dt = dayjs(schedule.datetime);
|
||||||
|
date = dt.format('YYYY-MM-DD');
|
||||||
|
// datetime에 T가 포함되어 있으면 시간이 있는 것
|
||||||
|
time = schedule.datetime.includes('T') ? dt.format('HH:mm:ss') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// members 배열을 문자열로 (기존 코드 호환성)
|
||||||
|
const memberNames = Array.isArray(schedule.members)
|
||||||
|
? schedule.members.join(',')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
...schedule,
|
||||||
|
date,
|
||||||
|
time,
|
||||||
|
category_id: category.id,
|
||||||
|
category_name: category.name,
|
||||||
|
category_color: category.color,
|
||||||
|
member_names: memberNames,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 공개 API ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄 목록 조회 (월별)
|
||||||
|
*/
|
||||||
|
export async function getSchedules(year, month) {
|
||||||
|
const data = await fetchApi(`/schedules?year=${year}&month=${month}`);
|
||||||
|
return (data.schedules || []).map(transformSchedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다가오는 스케줄 조회
|
||||||
|
*/
|
||||||
|
export async function getUpcomingSchedules(limit = 3) {
|
||||||
|
const today = getTodayKST();
|
||||||
|
const data = await fetchApi(`/schedules?startDate=${today}&limit=${limit}`);
|
||||||
|
return (data.schedules || []).map(transformSchedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄 검색 (Meilisearch)
|
||||||
|
*/
|
||||||
|
export async function searchSchedules(query, { offset = 0, limit = 20 } = {}) {
|
||||||
|
const data = await fetchApi(
|
||||||
|
`/schedules?search=${encodeURIComponent(query)}&offset=${offset}&limit=${limit}`
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
schedules: (data.schedules || []).map(transformSchedule),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄 상세 조회
|
||||||
|
*/
|
||||||
|
export async function getSchedule(id) {
|
||||||
|
return fetchApi(`/schedules/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* X 프로필 정보 조회
|
||||||
|
*/
|
||||||
|
export async function getXProfile(username) {
|
||||||
|
return fetchApi(`/schedules/x-profile/${encodeURIComponent(username)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 목록 조회
|
||||||
|
*/
|
||||||
|
export async function getCategories() {
|
||||||
|
return fetchApi('/schedules/categories');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 어드민 API ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Admin] 스케줄 검색
|
||||||
|
*/
|
||||||
|
export async function adminSearchSchedules(query) {
|
||||||
|
return fetchAuthApi(`/admin/schedules/search?q=${encodeURIComponent(query)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Admin] 스케줄 상세 조회
|
||||||
|
*/
|
||||||
|
export async function adminGetSchedule(id) {
|
||||||
|
return fetchAuthApi(`/admin/schedules/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Admin] 스케줄 생성
|
||||||
|
*/
|
||||||
|
export async function createSchedule(formData) {
|
||||||
|
return fetchFormData('/admin/schedules', formData, 'POST');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Admin] 스케줄 수정
|
||||||
|
*/
|
||||||
|
export async function updateSchedule(id, formData) {
|
||||||
|
return fetchFormData(`/admin/schedules/${id}`, formData, 'PUT');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Admin] 스케줄 삭제
|
||||||
|
*/
|
||||||
|
export async function deleteSchedule(id) {
|
||||||
|
return fetchAuthApi(`/schedules/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 카테고리 어드민 API ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Admin] 카테고리 생성
|
||||||
|
*/
|
||||||
|
export async function createCategory(data) {
|
||||||
|
return fetchAuthApi('/admin/schedule-categories', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Admin] 카테고리 수정
|
||||||
|
*/
|
||||||
|
export async function updateCategory(id, data) {
|
||||||
|
return fetchAuthApi(`/admin/schedule-categories/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Admin] 카테고리 삭제
|
||||||
|
*/
|
||||||
|
export async function deleteCategory(id) {
|
||||||
|
return fetchAuthApi(`/admin/schedule-categories/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Admin] 카테고리 순서 변경
|
||||||
|
*/
|
||||||
|
export async function reorderCategories(orders) {
|
||||||
|
return fetchAuthApi('/admin/schedule-categories-order', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ orders }),
|
||||||
|
});
|
||||||
|
}
|
||||||
290
frontend-temp/src/components/common/Lightbox.jsx
Normal file
290
frontend-temp/src/components/common/Lightbox.jsx
Normal file
|
|
@ -0,0 +1,290 @@
|
||||||
|
import { useState, useEffect, useCallback, memo } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { X, ChevronLeft, ChevronRight, Download } from 'lucide-react';
|
||||||
|
import LightboxIndicator from './LightboxIndicator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 라이트박스 공통 컴포넌트
|
||||||
|
* 이미지/비디오 갤러리를 전체 화면으로 표시
|
||||||
|
*
|
||||||
|
* @param {string[]} images - 이미지/비디오 URL 배열
|
||||||
|
* @param {Object[]} photos - 메타데이터 포함 사진 배열 (선택적)
|
||||||
|
* @param {string} photos[].title - 컨셉 이름
|
||||||
|
* @param {string} photos[].members - 멤버 이름 (쉼표 구분)
|
||||||
|
* @param {Object[]} teasers - 티저 정보 배열 (비디오 여부 확인용)
|
||||||
|
* @param {string} teasers[].media_type - 'video' 또는 'image'
|
||||||
|
* @param {number} currentIndex - 현재 인덱스
|
||||||
|
* @param {boolean} isOpen - 열림 상태
|
||||||
|
* @param {function} onClose - 닫기 콜백
|
||||||
|
* @param {function} onIndexChange - 인덱스 변경 콜백
|
||||||
|
* @param {boolean} showCounter - 카운터 표시 여부 (기본: true)
|
||||||
|
* @param {boolean} showDownload - 다운로드 버튼 표시 여부 (기본: true)
|
||||||
|
*/
|
||||||
|
function Lightbox({
|
||||||
|
images,
|
||||||
|
photos,
|
||||||
|
teasers,
|
||||||
|
currentIndex,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onIndexChange,
|
||||||
|
showCounter = true,
|
||||||
|
showDownload = true,
|
||||||
|
}) {
|
||||||
|
const [imageLoaded, setImageLoaded] = useState(false);
|
||||||
|
const [slideDirection, setSlideDirection] = useState(0);
|
||||||
|
|
||||||
|
// 이전/다음 네비게이션
|
||||||
|
const goToPrev = useCallback(() => {
|
||||||
|
if (images.length <= 1) return;
|
||||||
|
setImageLoaded(false);
|
||||||
|
setSlideDirection(-1);
|
||||||
|
onIndexChange((currentIndex - 1 + images.length) % images.length);
|
||||||
|
}, [images.length, currentIndex, onIndexChange]);
|
||||||
|
|
||||||
|
const goToNext = useCallback(() => {
|
||||||
|
if (images.length <= 1) return;
|
||||||
|
setImageLoaded(false);
|
||||||
|
setSlideDirection(1);
|
||||||
|
onIndexChange((currentIndex + 1) % images.length);
|
||||||
|
}, [images.length, currentIndex, onIndexChange]);
|
||||||
|
|
||||||
|
const goToIndex = useCallback(
|
||||||
|
(index) => {
|
||||||
|
if (index === currentIndex) return;
|
||||||
|
setImageLoaded(false);
|
||||||
|
setSlideDirection(index > currentIndex ? 1 : -1);
|
||||||
|
onIndexChange(index);
|
||||||
|
},
|
||||||
|
[currentIndex, onIndexChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 이미지 다운로드
|
||||||
|
const downloadImage = useCallback(async () => {
|
||||||
|
const imageUrl = images[currentIndex];
|
||||||
|
if (!imageUrl) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(imageUrl);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `image_${currentIndex + 1}.jpg`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('이미지 다운로드 실패:', error);
|
||||||
|
}
|
||||||
|
}, [images, currentIndex]);
|
||||||
|
|
||||||
|
// 라이트박스 열릴 때 body 스크롤 숨기기
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.documentElement.style.overflow = 'hidden';
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.documentElement.style.overflow = '';
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.documentElement.style.overflow = '';
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// 키보드 이벤트 핸들러
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowLeft':
|
||||||
|
goToPrev();
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
goToNext();
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
onClose();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isOpen, goToPrev, goToNext, onClose]);
|
||||||
|
|
||||||
|
// 이미지가 바뀔 때 로딩 상태 리셋
|
||||||
|
useEffect(() => {
|
||||||
|
setImageLoaded(false);
|
||||||
|
}, [currentIndex]);
|
||||||
|
|
||||||
|
// 현재 사진의 메타데이터
|
||||||
|
const currentPhoto = photos?.[currentIndex];
|
||||||
|
const photoTitle = currentPhoto?.title;
|
||||||
|
const hasValidTitle = photoTitle && photoTitle.trim() && photoTitle !== 'Default';
|
||||||
|
const photoMembers = currentPhoto?.members;
|
||||||
|
const hasMembers = photoMembers && String(photoMembers).trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && images.length > 0 && (
|
||||||
|
<motion.div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="이미지 뷰어"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="fixed inset-0 bg-black/95 z-50 overflow-scroll"
|
||||||
|
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
{/* 내부 컨테이너 */}
|
||||||
|
<div className="min-w-[1400px] min-h-[1200px] w-full h-full relative flex items-center justify-center">
|
||||||
|
{/* 카운터 */}
|
||||||
|
{showCounter && images.length > 1 && (
|
||||||
|
<div className="absolute top-6 left-6 text-white/70 text-sm z-10">
|
||||||
|
{currentIndex + 1} / {images.length}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 상단 버튼들 */}
|
||||||
|
<div className="absolute top-6 right-6 flex gap-3 z-10">
|
||||||
|
{showDownload && (
|
||||||
|
<button
|
||||||
|
aria-label="다운로드"
|
||||||
|
className="text-white/70 hover:text-white transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
downloadImage();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Download size={28} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
aria-label="닫기"
|
||||||
|
className="text-white/70 hover:text-white transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={32} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 이전 버튼 */}
|
||||||
|
{images.length > 1 && (
|
||||||
|
<button
|
||||||
|
aria-label="이전 이미지"
|
||||||
|
className="absolute left-6 p-2 text-white/70 hover:text-white transition-colors z-10"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
goToPrev();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={48} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 로딩 스피너 */}
|
||||||
|
{!imageLoaded && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white border-t-transparent"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 이미지/비디오 + 메타데이터 */}
|
||||||
|
<div className="flex flex-col items-center mx-24">
|
||||||
|
{teasers?.[currentIndex]?.media_type === 'video' ? (
|
||||||
|
<motion.video
|
||||||
|
key={currentIndex}
|
||||||
|
src={images[currentIndex]}
|
||||||
|
className={`max-w-[1100px] max-h-[900px] object-contain transition-opacity duration-200 ${
|
||||||
|
imageLoaded ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onCanPlay={() => setImageLoaded(true)}
|
||||||
|
initial={{ x: slideDirection * 100 }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<motion.img
|
||||||
|
key={currentIndex}
|
||||||
|
src={images[currentIndex]}
|
||||||
|
alt={`이미지 ${currentIndex + 1}`}
|
||||||
|
className={`max-w-[1100px] max-h-[900px] object-contain transition-opacity duration-200 ${
|
||||||
|
imageLoaded ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onLoad={() => setImageLoaded(true)}
|
||||||
|
initial={{ x: slideDirection * 100 }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 컨셉/멤버 정보 */}
|
||||||
|
{imageLoaded && (hasValidTitle || hasMembers) && (
|
||||||
|
<div className="mt-6 flex flex-col items-center gap-2">
|
||||||
|
{hasValidTitle && (
|
||||||
|
<span className="px-4 py-2 bg-white/10 backdrop-blur-sm rounded-full text-white font-medium text-base">
|
||||||
|
{photoTitle}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hasMembers && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{String(photoMembers)
|
||||||
|
.split(',')
|
||||||
|
.map((member, idx) => (
|
||||||
|
<span key={idx} className="px-3 py-1.5 bg-primary/80 rounded-full text-white text-sm">
|
||||||
|
{member.trim()}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 다음 버튼 */}
|
||||||
|
{images.length > 1 && (
|
||||||
|
<button
|
||||||
|
aria-label="다음 이미지"
|
||||||
|
className="absolute right-6 p-2 text-white/70 hover:text-white transition-colors z-10"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
goToNext();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronRight size={48} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 인디케이터 */}
|
||||||
|
{images.length > 1 && (
|
||||||
|
<LightboxIndicator
|
||||||
|
count={images.length}
|
||||||
|
currentIndex={currentIndex}
|
||||||
|
goToIndex={goToIndex}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Lightbox;
|
||||||
57
frontend-temp/src/components/common/LightboxIndicator.jsx
Normal file
57
frontend-temp/src/components/common/LightboxIndicator.jsx
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 라이트박스 인디케이터 컴포넌트
|
||||||
|
* 이미지 갤러리에서 현재 위치를 표시하는 슬라이딩 점 인디케이터
|
||||||
|
* CSS transition 사용으로 GPU 가속
|
||||||
|
*/
|
||||||
|
const LightboxIndicator = memo(function LightboxIndicator({
|
||||||
|
count,
|
||||||
|
currentIndex,
|
||||||
|
goToIndex,
|
||||||
|
width = 200,
|
||||||
|
}) {
|
||||||
|
const halfWidth = width / 2;
|
||||||
|
const translateX = -(currentIndex * 18) + halfWidth - 6;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-6 left-1/2 -translate-x-1/2 overflow-hidden"
|
||||||
|
style={{ width: `${width}px` }}
|
||||||
|
>
|
||||||
|
{/* 양옆 페이드 그라데이션 */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 pointer-events-none z-10"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(to right, rgba(0,0,0,1) 0%, transparent 20%, transparent 80%, rgba(0,0,0,1) 100%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* 슬라이딩 컨테이너 - CSS transition으로 GPU 가속 */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 justify-center"
|
||||||
|
style={{
|
||||||
|
width: `${count * 18}px`,
|
||||||
|
transform: `translateX(${translateX}px)`,
|
||||||
|
transition: 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
aria-label={`이미지 ${i + 1}/${count}`}
|
||||||
|
aria-current={i === currentIndex ? 'true' : undefined}
|
||||||
|
className={`rounded-full flex-shrink-0 transition-all duration-300 ${
|
||||||
|
i === currentIndex
|
||||||
|
? 'w-3 h-3 bg-white'
|
||||||
|
: 'w-2.5 h-2.5 bg-white/40 hover:bg-white/60'
|
||||||
|
}`}
|
||||||
|
onClick={() => goToIndex(i)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default LightboxIndicator;
|
||||||
|
|
@ -14,7 +14,7 @@ const ScheduleSearchCard = memo(function ScheduleSearchCard({
|
||||||
delay = 0,
|
delay = 0,
|
||||||
className = '',
|
className = '',
|
||||||
}) {
|
}) {
|
||||||
const scheduleDate = new Date(schedule.date);
|
const scheduleDate = new Date(schedule.date || schedule.datetime);
|
||||||
const categoryInfo = getCategoryInfo(schedule);
|
const categoryInfo = getCategoryInfo(schedule);
|
||||||
const timeStr = getScheduleTime(schedule);
|
const timeStr = getScheduleTime(schedule);
|
||||||
const displayMembers = getDisplayMembers(schedule);
|
const displayMembers = getDisplayMembers(schedule);
|
||||||
|
|
@ -145,23 +145,15 @@ const BotCard = memo(function BotCard({
|
||||||
{bot.type === 'meilisearch' ? (
|
{bot.type === 'meilisearch' ? (
|
||||||
<>
|
<>
|
||||||
<div className="p-3 text-center">
|
<div className="p-3 text-center">
|
||||||
<div className="text-lg font-bold text-gray-900">
|
<div className="text-lg font-bold text-gray-900">{bot.schedules_added || 0}</div>
|
||||||
{bot.last_added_count?.toLocaleString() || '-'}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-400">동기화 수</div>
|
<div className="text-xs text-gray-400">동기화 수</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 text-center">
|
<div className="p-3 text-center">
|
||||||
<div className="text-lg font-bold text-gray-900">
|
<div className="text-lg font-bold text-gray-900">
|
||||||
{bot.last_sync_duration != null
|
{bot.last_added_count ? `${(bot.last_added_count / 1000 || 0).toFixed(1)}초` : '-'}
|
||||||
? `${(bot.last_sync_duration / 1000).toFixed(1)}초`
|
|
||||||
: '-'}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-400">소요 시간</div>
|
<div className="text-xs text-gray-400">소요 시간</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 text-center">
|
|
||||||
<div className="text-lg font-bold text-gray-900">{bot.version || '-'}</div>
|
|
||||||
<div className="text-xs text-gray-400">버전</div>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
@ -177,12 +169,12 @@ const BotCard = memo(function BotCard({
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-400">마지막</div>
|
<div className="text-xs text-gray-400">마지막</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 text-center">
|
|
||||||
<div className="text-lg font-bold text-gray-900">{formatInterval(bot.check_interval)}</div>
|
|
||||||
<div className="text-xs text-gray-400">업데이트 간격</div>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<div className="p-3 text-center">
|
||||||
|
<div className="text-lg font-bold text-gray-900">{formatInterval(bot.check_interval)}</div>
|
||||||
|
<div className="text-xs text-gray-400">업데이트 간격</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 오류 메시지 */}
|
{/* 오류 메시지 */}
|
||||||
|
|
@ -3,17 +3,15 @@
|
||||||
* 모든 Admin 페이지에서 공통으로 사용하는 헤더
|
* 모든 Admin 페이지에서 공통으로 사용하는 헤더
|
||||||
* 로고, Admin 배지, 사용자 정보, 로그아웃 버튼 포함
|
* 로고, Admin 배지, 사용자 정보, 로그아웃 버튼 포함
|
||||||
*/
|
*/
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { LogOut } from 'lucide-react';
|
import { LogOut } from 'lucide-react';
|
||||||
import { useAuthStore } from '@/stores';
|
import { useAuthStore } from '@/stores';
|
||||||
|
|
||||||
function AdminHeader({ user }) {
|
function AdminHeader({ user }) {
|
||||||
const navigate = useNavigate();
|
|
||||||
const logout = useAuthStore((state) => state.logout);
|
const logout = useAuthStore((state) => state.logout);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
navigate('/admin', { replace: true });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -4,20 +4,13 @@
|
||||||
* 헤더 고정 + 본문 스크롤 구조
|
* 헤더 고정 + 본문 스크롤 구조
|
||||||
*/
|
*/
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useAuthStore } from '@/stores';
|
|
||||||
import Header from './Header';
|
import Header from './Header';
|
||||||
|
|
||||||
function AdminLayout({ user, children }) {
|
function AdminLayout({ user, children }) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { token } = useAuthStore();
|
|
||||||
|
|
||||||
// 토큰이 없으면 아무것도 렌더링하지 않음 (useAdminAuth에서 리다이렉트 처리)
|
// 일정 관리 페이지는 내부 스크롤 처리
|
||||||
if (!token) {
|
const isSchedulePage = location.pathname.includes('/admin/schedule');
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 일정 목록 페이지만 내부 스크롤 처리 (하위 페이지는 레이아웃 스크롤 사용)
|
|
||||||
const isSchedulePage = location.pathname === '/admin/schedule';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen overflow-hidden flex flex-col bg-gray-50">
|
<div className="h-screen overflow-hidden flex flex-col bg-gray-50">
|
||||||
|
|
@ -12,7 +12,7 @@ function AdminScheduleCard({
|
||||||
onDelete,
|
onDelete,
|
||||||
className = '',
|
className = '',
|
||||||
}) {
|
}) {
|
||||||
const scheduleDate = new Date(schedule.date);
|
const scheduleDate = new Date(schedule.date || schedule.datetime);
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const currentYear = today.getFullYear();
|
const currentYear = today.getFullYear();
|
||||||
const currentMonth = today.getMonth();
|
const currentMonth = today.getMonth();
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue