import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import Fastify from 'fastify'; import fastifyStatic from '@fastify/static'; import fastifySwagger from '@fastify/swagger'; import scalarApiReference from '@scalar/fastify-api-reference'; import multipart from '@fastify/multipart'; import config from './config/index.js'; import * as schemas from './schemas/index.js'; // 플러그인 import dbPlugin from './plugins/db.js'; import redisPlugin from './plugins/redis.js'; import authPlugin from './plugins/auth.js'; import meilisearchPlugin from './plugins/meilisearch.js'; import youtubeBotPlugin from './services/youtube/index.js'; import xBotPlugin from './services/x/index.js'; import schedulerPlugin from './plugins/scheduler.js'; // 라우트 import routes from './routes/index.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); export async function buildApp(opts = {}) { const fastify = Fastify({ logger: { level: opts.logLevel || 'info', }, ...opts, }); // config 데코레이터 등록 fastify.decorate('config', config); // multipart 플러그인 등록 (파일 업로드용) await fastify.register(multipart, { limits: { fileSize: 10 * 1024 * 1024, // 10MB }, }); // 플러그인 등록 (순서 중요) await fastify.register(dbPlugin); await fastify.register(redisPlugin); await fastify.register(authPlugin); await fastify.register(meilisearchPlugin); await fastify.register(youtubeBotPlugin); await fastify.register(xBotPlugin); await fastify.register(schedulerPlugin); // 공유 스키마 등록 (라우트에서 $ref로 참조 가능) fastify.addSchema({ $id: 'Album', ...schemas.albumResponse }); fastify.addSchema({ $id: 'AlbumTrack', ...schemas.albumTrack }); fastify.addSchema({ $id: 'Schedule', ...schemas.scheduleResponse }); fastify.addSchema({ $id: 'ScheduleCategory', ...schemas.scheduleCategory }); fastify.addSchema({ $id: 'Member', ...schemas.memberResponse }); fastify.addSchema({ $id: 'Photo', ...schemas.photoResponse }); // Swagger (OpenAPI) 설정 await fastify.register(fastifySwagger, { openapi: { info: { title: 'fromis_9 API', description: 'fromis_9 팬사이트 백엔드 API', version: '2.0.0', }, servers: [ { url: '/', description: 'Current server' }, ], tags: [ { name: 'auth', description: '인증 API' }, { name: 'members', description: '멤버 API' }, { name: 'albums', description: '앨범 API' }, { name: 'schedules', description: '일정 API' }, { name: 'admin/youtube', description: 'YouTube 관리 API' }, { name: 'admin/x', description: 'X (Twitter) 관리 API' }, { name: 'admin/bots', description: '봇 관리 API' }, { name: 'stats', description: '통계 API' }, ], components: { securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', }, }, }, }, }); // Scalar API Reference UI await fastify.register(scalarApiReference, { routePrefix: '/docs', configuration: { theme: 'purple', spec: { url: '/docs/json', }, }, }); // OpenAPI JSON 엔드포인트 fastify.get('/docs/json', { schema: { hide: true } }, async () => { return fastify.swagger(); }); // 라우트 등록 await fastify.register(routes, { prefix: '/api' }); // 헬스 체크 엔드포인트 fastify.get('/api/health', async () => { return { status: 'ok', timestamp: new Date().toISOString() }; }); // 봇 상태 조회 엔드포인트 fastify.get('/api/bots', async () => { const bots = fastify.scheduler.getBots(); const statuses = await Promise.all( bots.map(async bot => { const status = await fastify.scheduler.getStatus(bot.id); return { ...bot, ...status }; }) ); return statuses; }); // 정적 파일 서빙 (프론트엔드 빌드 결과물) - 프로덕션 모드에서만 const distPath = path.join(__dirname, '../dist'); if (fs.existsSync(distPath)) { await fastify.register(fastifyStatic, { root: distPath, prefix: '/', }); // SPA fallback - API 라우트가 아닌 모든 요청에 index.html 반환 fastify.setNotFoundHandler((request, reply) => { if (request.url.startsWith('/api/')) { return reply.code(404).send({ error: 'Not found' }); } return reply.sendFile('index.html'); }); } return fastify; }