From f483f2cf533e1cb8a515a50a435049bba18c80da Mon Sep 17 00:00:00 2001 From: caadiq Date: Wed, 21 Jan 2026 14:58:07 +0900 Subject: [PATCH] =?UTF-8?q?refactor(backend):=20=ED=8A=B8=EB=9E=9C?= =?UTF-8?q?=EC=9E=AD=EC=85=98=20=ED=97=AC=ED=8D=BC,=20JSON=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=20=ED=8C=8C=EC=9D=BC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/utils/transaction.js: withTransaction 헬퍼 함수 추가 - src/schemas/: 도메인별 스키마 파일 분리 (common, album, schedule, admin, member, auth) - 라우트에 JSON Schema 검증 및 Swagger 문서화 적용 - 트랜잭션 패턴을 withTransaction 헬퍼로 추상화 Co-Authored-By: Claude Opus 4.5 --- backend/src/app.js | 12 +++ backend/src/routes/admin/bots.js | 92 ++++++++++++++++++++ backend/src/routes/admin/x.js | 57 ++++++++---- backend/src/routes/admin/youtube.js | 76 ++++++++++------ backend/src/routes/albums/index.js | 59 +++++++++++++ backend/src/routes/albums/photos.js | 39 ++++----- backend/src/routes/albums/teasers.js | 39 ++++----- backend/src/routes/schedules/index.js | 50 ++++++++--- backend/src/schemas/admin.js | 59 +++++++++++++ backend/src/schemas/album.js | 75 ++++++++++++++++ backend/src/schemas/auth.js | 20 +++++ backend/src/schemas/common.js | 34 ++++++++ backend/src/schemas/index.js | 11 +++ backend/src/schemas/member.js | 15 ++++ backend/src/schemas/schedule.js | 57 ++++++++++++ backend/src/services/album.js | 70 +++++---------- backend/src/utils/transaction.js | 34 ++++++++ docs/refactoring.md | 56 ++++++++++++ frontend/src/pages/pc/public/AlbumDetail.jsx | 2 +- frontend/src/pages/pc/public/Schedule.jsx | 2 +- 20 files changed, 707 insertions(+), 152 deletions(-) create mode 100644 backend/src/schemas/admin.js create mode 100644 backend/src/schemas/album.js create mode 100644 backend/src/schemas/auth.js create mode 100644 backend/src/schemas/common.js create mode 100644 backend/src/schemas/index.js create mode 100644 backend/src/schemas/member.js create mode 100644 backend/src/schemas/schedule.js create mode 100644 backend/src/utils/transaction.js diff --git a/backend/src/app.js b/backend/src/app.js index bec8bcc..259eaff 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -7,6 +7,7 @@ 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'; @@ -50,6 +51,14 @@ export async function buildApp(opts = {}) { 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: { @@ -66,6 +75,9 @@ export async function buildApp(opts = {}) { { 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: { diff --git a/backend/src/routes/admin/bots.js b/backend/src/routes/admin/bots.js index a1b0750..913080a 100644 --- a/backend/src/routes/admin/bots.js +++ b/backend/src/routes/admin/bots.js @@ -1,4 +1,30 @@ import bots from '../../config/bots.js'; +import { errorResponse } from '../../schemas/index.js'; + +// 봇 관련 스키마 +const botResponse = { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + type: { type: 'string', enum: ['youtube', 'x'] }, + status: { type: 'string', enum: ['running', 'stopped', 'error'] }, + last_check_at: { type: 'string', format: 'date-time' }, + last_added_count: { type: 'integer' }, + schedules_added: { type: 'integer' }, + check_interval: { type: 'integer' }, + error_message: { type: 'string' }, + enabled: { type: 'boolean' }, + }, +}; + +const botIdParam = { + type: 'object', + properties: { + id: { type: 'string', description: '봇 ID' }, + }, + required: ['id'], +}; /** * 봇 관리 라우트 @@ -16,7 +42,14 @@ export default async function botsRoutes(fastify) { schema: { tags: ['admin/bots'], summary: '봇 목록 조회', + description: '등록된 모든 봇(YouTube, X)의 상태를 조회합니다.', security: [{ bearerAuth: [] }], + response: { + 200: { + type: 'array', + items: botResponse, + }, + }, }, preHandler: [fastify.authenticate], }, async (request, reply) => { @@ -57,7 +90,19 @@ export default async function botsRoutes(fastify) { schema: { tags: ['admin/bots'], summary: '봇 시작', + description: '지정된 봇의 스케줄러를 시작합니다.', security: [{ bearerAuth: [] }], + params: botIdParam, + response: { + 200: { + type: 'object', + properties: { + success: { type: 'boolean' }, + message: { type: 'string' }, + }, + }, + 400: errorResponse, + }, }, preHandler: [fastify.authenticate], }, async (request, reply) => { @@ -79,7 +124,19 @@ export default async function botsRoutes(fastify) { schema: { tags: ['admin/bots'], summary: '봇 정지', + description: '지정된 봇의 스케줄러를 정지합니다.', security: [{ bearerAuth: [] }], + params: botIdParam, + response: { + 200: { + type: 'object', + properties: { + success: { type: 'boolean' }, + message: { type: 'string' }, + }, + }, + 400: errorResponse, + }, }, preHandler: [fastify.authenticate], }, async (request, reply) => { @@ -101,7 +158,22 @@ export default async function botsRoutes(fastify) { schema: { tags: ['admin/bots'], summary: '봇 전체 동기화', + description: '봇이 관리하는 모든 콘텐츠를 다시 동기화합니다.', security: [{ bearerAuth: [] }], + params: botIdParam, + response: { + 200: { + type: 'object', + properties: { + success: { type: 'boolean' }, + addedCount: { type: 'integer', description: '추가된 일정 수' }, + total: { type: 'integer', description: '총 처리 수' }, + }, + }, + 400: errorResponse, + 404: errorResponse, + 500: errorResponse, + }, }, preHandler: [fastify.authenticate], }, async (request, reply) => { @@ -151,7 +223,18 @@ export default async function botsRoutes(fastify) { schema: { tags: ['admin/bots'], summary: '할당량 경고 조회', + description: 'YouTube API 할당량 경고 상태를 조회합니다.', security: [{ bearerAuth: [] }], + response: { + 200: { + type: 'object', + properties: { + active: { type: 'boolean' }, + message: { type: 'string' }, + timestamp: { type: 'string', format: 'date-time' }, + }, + }, + }, }, preHandler: [fastify.authenticate], }, async (request, reply) => { @@ -170,7 +253,16 @@ export default async function botsRoutes(fastify) { schema: { tags: ['admin/bots'], summary: '할당량 경고 해제', + description: 'YouTube API 할당량 경고를 해제합니다.', security: [{ bearerAuth: [] }], + response: { + 200: { + type: 'object', + properties: { + success: { type: 'boolean' }, + }, + }, + }, }, preHandler: [fastify.authenticate], }, async (request, reply) => { diff --git a/backend/src/routes/admin/x.js b/backend/src/routes/admin/x.js index 21d54de..d993e09 100644 --- a/backend/src/routes/admin/x.js +++ b/backend/src/routes/admin/x.js @@ -2,6 +2,11 @@ import { fetchSingleTweet, extractTitle } from '../../services/x/scraper.js'; import { addOrUpdateSchedule } from '../../services/meilisearch/index.js'; import { formatDate, formatTime } from '../../utils/date.js'; import config, { CATEGORY_IDS } from '../../config/index.js'; +import { + errorResponse, + xPostInfoQuery, + xScheduleCreate, +} from '../../schemas/index.js'; const X_CATEGORY_ID = CATEGORY_IDS.X; const NITTER_URL = config.nitter?.url || process.env.NITTER_URL || 'http://nitter:8080'; @@ -21,14 +26,33 @@ export default async function xRoutes(fastify) { schema: { tags: ['admin/x'], summary: 'X 게시글 정보 조회', + description: 'X(Twitter) 게시글 ID로 정보를 조회합니다.', security: [{ bearerAuth: [] }], - querystring: { - type: 'object', - properties: { - postId: { type: 'string', description: '게시글 ID' }, - username: { type: 'string', description: '사용자명 (기본: realfromis_9)' }, + querystring: xPostInfoQuery, + response: { + 200: { + type: 'object', + properties: { + postId: { type: 'string' }, + username: { type: 'string' }, + text: { type: 'string' }, + title: { type: 'string' }, + imageUrls: { type: 'array', items: { type: 'string' } }, + date: { type: 'string' }, + time: { type: 'string' }, + postUrl: { type: 'string' }, + profile: { + type: 'object', + properties: { + username: { type: 'string' }, + displayName: { type: 'string' }, + avatarUrl: { type: 'string' }, + }, + }, + }, }, - required: ['postId'], + 400: errorResponse, + 500: errorResponse, }, }, preHandler: [fastify.authenticate], @@ -68,18 +92,19 @@ export default async function xRoutes(fastify) { schema: { tags: ['admin/x'], summary: 'X 일정 저장', + description: 'X(Twitter) 게시글을 일정으로 등록합니다.', security: [{ bearerAuth: [] }], - body: { - type: 'object', - properties: { - postId: { type: 'string' }, - title: { type: 'string' }, - content: { type: 'string' }, - imageUrls: { type: 'array', items: { type: 'string' } }, - date: { type: 'string' }, - time: { type: 'string' }, + body: xScheduleCreate, + response: { + 200: { + type: 'object', + properties: { + success: { type: 'boolean' }, + scheduleId: { type: 'integer' }, + }, }, - required: ['postId', 'title', 'date'], + 409: errorResponse, + 500: errorResponse, }, }, preHandler: [fastify.authenticate], diff --git a/backend/src/routes/admin/youtube.js b/backend/src/routes/admin/youtube.js index cabf0ca..2cc80f1 100644 --- a/backend/src/routes/admin/youtube.js +++ b/backend/src/routes/admin/youtube.js @@ -1,6 +1,13 @@ import { fetchVideoInfo } from '../../services/youtube/api.js'; import { addOrUpdateSchedule } from '../../services/meilisearch/index.js'; import { CATEGORY_IDS } from '../../config/index.js'; +import { + errorResponse, + youtubeVideoInfo, + youtubeScheduleCreate, + youtubeScheduleUpdate, + idParam, +} from '../../schemas/index.js'; const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE; @@ -17,13 +24,27 @@ export default async function youtubeRoutes(fastify) { schema: { tags: ['admin/youtube'], summary: 'YouTube 영상 정보 조회', + description: 'YouTube URL에서 영상 정보를 추출합니다.', security: [{ bearerAuth: [] }], - querystring: { - type: 'object', - properties: { - url: { type: 'string', description: 'YouTube URL' }, + querystring: youtubeVideoInfo, + response: { + 200: { + type: 'object', + properties: { + videoId: { type: 'string' }, + title: { type: 'string' }, + channelId: { type: 'string' }, + channelName: { type: 'string' }, + publishedAt: { type: 'string' }, + date: { type: 'string' }, + time: { type: 'string' }, + videoType: { type: 'string' }, + videoUrl: { type: 'string' }, + }, }, - required: ['url'], + 400: errorResponse, + 404: errorResponse, + 500: errorResponse, }, }, preHandler: [fastify.authenticate], @@ -67,19 +88,19 @@ export default async function youtubeRoutes(fastify) { schema: { tags: ['admin/youtube'], summary: 'YouTube 일정 저장', + description: 'YouTube 영상을 일정으로 등록합니다.', security: [{ bearerAuth: [] }], - body: { - type: 'object', - properties: { - videoId: { type: 'string' }, - title: { type: 'string' }, - channelId: { type: 'string' }, - channelName: { type: 'string' }, - date: { type: 'string' }, - time: { type: 'string' }, - videoType: { type: 'string' }, + body: youtubeScheduleCreate, + response: { + 200: { + type: 'object', + properties: { + success: { type: 'boolean' }, + scheduleId: { type: 'integer' }, + }, }, - required: ['videoId', 'title', 'date'], + 409: errorResponse, + 500: errorResponse, }, }, preHandler: [fastify.authenticate], @@ -142,20 +163,19 @@ export default async function youtubeRoutes(fastify) { schema: { tags: ['admin/youtube'], summary: 'YouTube 일정 수정', + description: 'YouTube 일정의 멤버와 영상 유형을 수정합니다.', security: [{ bearerAuth: [] }], - params: { - type: 'object', - properties: { - id: { type: 'integer' }, - }, - required: ['id'], - }, - body: { - type: 'object', - properties: { - memberIds: { type: 'array', items: { type: 'integer' } }, - videoType: { type: 'string', enum: ['video', 'shorts'] }, + params: idParam, + body: youtubeScheduleUpdate, + response: { + 200: { + type: 'object', + properties: { + success: { type: 'boolean' }, + }, }, + 404: errorResponse, + 500: errorResponse, }, }, preHandler: [fastify.authenticate], diff --git a/backend/src/routes/albums/index.js b/backend/src/routes/albums/index.js index 8cfda28..49ed4f3 100644 --- a/backend/src/routes/albums/index.js +++ b/backend/src/routes/albums/index.js @@ -7,6 +7,7 @@ import { } from '../../services/album.js'; import photosRoutes from './photos.js'; import teasersRoutes from './teasers.js'; +import { errorResponse, successResponse, idParam } from '../../schemas/index.js'; /** * 앨범 라우트 @@ -28,6 +29,10 @@ export default async function albumsRoutes(fastify) { schema: { tags: ['albums'], summary: '전체 앨범 목록 조회', + description: '모든 앨범과 트랙 목록을 조회합니다.', + response: { + 200: { type: 'array', items: { type: 'object', additionalProperties: true } }, + }, }, }, async () => { return await getAlbumsWithTracks(db); @@ -40,6 +45,18 @@ export default async function albumsRoutes(fastify) { schema: { tags: ['albums'], summary: '앨범명과 트랙명으로 트랙 조회', + description: '앨범명(또는 폴더명)과 트랙명으로 트랙 상세 정보를 조회합니다.', + params: { + type: 'object', + properties: { + albumName: { type: 'string', description: '앨범명 또는 폴더명' }, + trackTitle: { type: 'string', description: '트랙 제목' }, + }, + required: ['albumName', 'trackTitle'], + }, + response: { + 404: errorResponse, + }, }, }, async (request, reply) => { const albumName = decodeURIComponent(request.params.albumName); @@ -94,6 +111,17 @@ export default async function albumsRoutes(fastify) { schema: { tags: ['albums'], summary: '앨범명으로 앨범 조회', + description: '앨범명(또는 폴더명)으로 앨범 상세 정보를 조회합니다.', + params: { + type: 'object', + properties: { + name: { type: 'string', description: '앨범명 또는 폴더명' }, + }, + required: ['name'], + }, + response: { + 200: { type: 'object', additionalProperties: true }, + }, }, }, async (request, reply) => { const name = decodeURIComponent(request.params.name); @@ -117,6 +145,11 @@ export default async function albumsRoutes(fastify) { schema: { tags: ['albums'], summary: 'ID로 앨범 조회', + description: '앨범 ID로 상세 정보(트랙, 티저, 컨셉포토 포함)를 조회합니다.', + params: idParam, + response: { + 200: { type: 'object', additionalProperties: true }, + }, }, }, async (request, reply) => { const [albums] = await db.query('SELECT * FROM albums WHERE id = ?', [ @@ -139,7 +172,19 @@ export default async function albumsRoutes(fastify) { schema: { tags: ['albums'], summary: '앨범 생성', + description: 'multipart/form-data로 앨범을 생성합니다. data 필드에 JSON, cover 필드에 이미지 파일.', security: [{ bearerAuth: [] }], + consumes: ['multipart/form-data'], + response: { + 200: { + type: 'object', + properties: { + message: { type: 'string' }, + albumId: { type: 'integer' }, + }, + }, + 400: errorResponse, + }, }, preHandler: [fastify.authenticate], }, async (request, reply) => { @@ -175,7 +220,15 @@ export default async function albumsRoutes(fastify) { schema: { tags: ['albums'], summary: '앨범 수정', + description: 'multipart/form-data로 앨범을 수정합니다.', security: [{ bearerAuth: [] }], + consumes: ['multipart/form-data'], + params: idParam, + response: { + 200: successResponse, + 400: errorResponse, + 404: errorResponse, + }, }, preHandler: [fastify.authenticate], }, async (request, reply) => { @@ -210,7 +263,13 @@ export default async function albumsRoutes(fastify) { schema: { tags: ['albums'], summary: '앨범 삭제', + description: '앨범과 관련 데이터(트랙, 커버 이미지)를 삭제합니다.', security: [{ bearerAuth: [] }], + params: idParam, + response: { + 200: successResponse, + 404: errorResponse, + }, }, preHandler: [fastify.authenticate], }, async (request, reply) => { diff --git a/backend/src/routes/albums/photos.js b/backend/src/routes/albums/photos.js index a1cd529..4238f8d 100644 --- a/backend/src/routes/albums/photos.js +++ b/backend/src/routes/albums/photos.js @@ -3,6 +3,7 @@ import { deleteAlbumPhoto, uploadAlbumVideo, } from '../../services/image.js'; +import { withTransaction } from '../../utils/transaction.js'; /** * 앨범 사진 라우트 @@ -215,37 +216,29 @@ export default async function photosRoutes(fastify) { preHandler: [fastify.authenticate], }, async (request, reply) => { const { albumId, photoId } = request.params; - const connection = await db.getConnection(); - try { - await connection.beginTransaction(); + // 사진 존재 여부 먼저 확인 + const [photos] = await db.query( + `SELECT p.*, a.folder_name + FROM album_photos p + JOIN albums a ON p.album_id = a.id + WHERE p.id = ? AND p.album_id = ?`, + [photoId, albumId] + ); - const [photos] = await connection.query( - `SELECT p.*, a.folder_name - FROM album_photos p - JOIN albums a ON p.album_id = a.id - WHERE p.id = ? AND p.album_id = ?`, - [photoId, albumId] - ); + if (photos.length === 0) { + return reply.code(404).send({ error: '사진을 찾을 수 없습니다.' }); + } - if (photos.length === 0) { - return reply.code(404).send({ error: '사진을 찾을 수 없습니다.' }); - } - - const photo = photos[0]; - const filename = photo.original_url.split('/').pop(); + const photo = photos[0]; + const filename = photo.original_url.split('/').pop(); + return withTransaction(db, async (connection) => { await deleteAlbumPhoto(photo.folder_name, 'photo', filename); await connection.query('DELETE FROM album_photo_members WHERE photo_id = ?', [photoId]); await connection.query('DELETE FROM album_photos WHERE id = ?', [photoId]); - await connection.commit(); return { message: '사진이 삭제되었습니다.' }; - } catch (error) { - await connection.rollback(); - throw error; - } finally { - connection.release(); - } + }); }); } diff --git a/backend/src/routes/albums/teasers.js b/backend/src/routes/albums/teasers.js index 7ba256b..6bd5f87 100644 --- a/backend/src/routes/albums/teasers.js +++ b/backend/src/routes/albums/teasers.js @@ -2,6 +2,7 @@ import { deleteAlbumPhoto, deleteAlbumVideo, } from '../../services/image.js'; +import { withTransaction } from '../../utils/transaction.js'; /** * 앨범 티저 라우트 @@ -49,26 +50,24 @@ export default async function teasersRoutes(fastify) { preHandler: [fastify.authenticate], }, async (request, reply) => { const { albumId, teaserId } = request.params; - const connection = await db.getConnection(); - try { - await connection.beginTransaction(); + // 티저 존재 여부 먼저 확인 + const [teasers] = await db.query( + `SELECT t.*, a.folder_name + FROM album_teasers t + JOIN albums a ON t.album_id = a.id + WHERE t.id = ? AND t.album_id = ?`, + [teaserId, albumId] + ); - const [teasers] = await connection.query( - `SELECT t.*, a.folder_name - FROM album_teasers t - JOIN albums a ON t.album_id = a.id - WHERE t.id = ? AND t.album_id = ?`, - [teaserId, albumId] - ); + if (teasers.length === 0) { + return reply.code(404).send({ error: '티저를 찾을 수 없습니다.' }); + } - if (teasers.length === 0) { - return reply.code(404).send({ error: '티저를 찾을 수 없습니다.' }); - } - - const teaser = teasers[0]; - const filename = teaser.original_url.split('/').pop(); + const teaser = teasers[0]; + const filename = teaser.original_url.split('/').pop(); + return withTransaction(db, async (connection) => { await deleteAlbumPhoto(teaser.folder_name, 'teaser', filename); if (teaser.video_url) { @@ -78,13 +77,7 @@ export default async function teasersRoutes(fastify) { await connection.query('DELETE FROM album_teasers WHERE id = ?', [teaserId]); - await connection.commit(); return { message: '티저가 삭제되었습니다.' }; - } catch (error) { - await connection.rollback(); - throw error; - } finally { - connection.release(); - } + }); }); } diff --git a/backend/src/routes/schedules/index.js b/backend/src/routes/schedules/index.js index 086d832..e21889a 100644 --- a/backend/src/routes/schedules/index.js +++ b/backend/src/routes/schedules/index.js @@ -6,6 +6,12 @@ import suggestionsRoutes from './suggestions.js'; import { searchSchedules, syncAllSchedules } from '../../services/meilisearch/index.js'; import config, { CATEGORY_IDS } from '../../config/index.js'; import { getMonthlySchedules, getUpcomingSchedules } from '../../services/schedule.js'; +import { + errorResponse, + scheduleSearchQuery, + scheduleSearchResponse, + idParam, +} from '../../schemas/index.js'; export default async function schedulesRoutes(fastify) { const { db, meilisearch, redis } = fastify; @@ -21,6 +27,10 @@ export default async function schedulesRoutes(fastify) { schema: { tags: ['schedules'], summary: '카테고리 목록 조회', + description: '일정 카테고리 목록을 조회합니다.', + response: { + 200: { type: 'array', items: { type: 'object', additionalProperties: true } }, + }, }, }, async (request, reply) => { const [categories] = await db.query( @@ -38,16 +48,10 @@ export default async function schedulesRoutes(fastify) { schema: { tags: ['schedules'], summary: '일정 조회 (검색 또는 월별)', - querystring: { - type: 'object', - properties: { - search: { type: 'string', description: '검색어' }, - year: { type: 'integer', description: '년도' }, - month: { type: 'integer', minimum: 1, maximum: 12, description: '월' }, - startDate: { type: 'string', description: '시작 날짜 (YYYY-MM-DD)' }, - offset: { type: 'integer', default: 0, description: '페이지 오프셋' }, - limit: { type: 'integer', default: 100, description: '결과 개수' }, - }, + description: 'search 파라미터로 검색, year/month로 월별 조회, startDate로 다가오는 일정 조회', + querystring: scheduleSearchQuery, + response: { + 200: { type: 'object', additionalProperties: true }, }, }, }, async (request, reply) => { @@ -79,7 +83,17 @@ export default async function schedulesRoutes(fastify) { schema: { tags: ['schedules'], summary: 'Meilisearch 전체 동기화', + description: 'DB의 모든 일정을 Meilisearch에 동기화합니다.', security: [{ bearerAuth: [] }], + response: { + 200: { + type: 'object', + properties: { + success: { type: 'boolean' }, + synced: { type: 'integer', description: '동기화된 일정 수' }, + }, + }, + }, }, preHandler: [fastify.authenticate], }, async (request, reply) => { @@ -95,6 +109,11 @@ export default async function schedulesRoutes(fastify) { schema: { tags: ['schedules'], summary: '일정 상세 조회', + description: '일정 ID로 상세 정보를 조회합니다. 카테고리에 따라 추가 정보(YouTube/X)가 포함됩니다.', + params: idParam, + response: { + 200: { type: 'object', additionalProperties: true }, + }, }, }, async (request, reply) => { const { id } = request.params; @@ -191,7 +210,18 @@ export default async function schedulesRoutes(fastify) { schema: { tags: ['schedules'], summary: '일정 삭제', + description: '일정과 관련 데이터(YouTube/X 정보, 멤버, 이미지)를 삭제합니다.', security: [{ bearerAuth: [] }], + params: idParam, + response: { + 200: { + type: 'object', + properties: { + success: { type: 'boolean' }, + }, + }, + 404: errorResponse, + }, }, preHandler: [fastify.authenticate], }, async (request, reply) => { diff --git a/backend/src/schemas/admin.js b/backend/src/schemas/admin.js new file mode 100644 index 0000000..305257b --- /dev/null +++ b/backend/src/schemas/admin.js @@ -0,0 +1,59 @@ +/** + * 관리자 API 스키마 (YouTube, X) + */ + +// ==================== YouTube ==================== + +export const youtubeVideoInfo = { + type: 'object', + properties: { + url: { type: 'string', description: 'YouTube URL' }, + }, + required: ['url'], +}; + +export const youtubeScheduleCreate = { + type: 'object', + properties: { + videoId: { type: 'string', minLength: 11, maxLength: 11, description: 'YouTube 영상 ID' }, + title: { type: 'string', minLength: 1, maxLength: 500, description: '제목' }, + channelId: { type: 'string', description: '채널 ID' }, + channelName: { type: 'string', maxLength: 200, description: '채널명' }, + date: { type: 'string', format: 'date', description: '날짜 (YYYY-MM-DD)' }, + time: { type: 'string', pattern: '^\\d{2}:\\d{2}(:\\d{2})?$', description: '시간 (HH:MM 또는 HH:MM:SS)' }, + videoType: { type: 'string', enum: ['video', 'shorts'], default: 'video', description: '영상 유형' }, + }, + required: ['videoId', 'title', 'date'], +}; + +export const youtubeScheduleUpdate = { + type: 'object', + properties: { + memberIds: { type: 'array', items: { type: 'integer' }, description: '멤버 ID 목록' }, + videoType: { type: 'string', enum: ['video', 'shorts'], description: '영상 유형' }, + }, +}; + +// ==================== X (Twitter) ==================== + +export const xPostInfoQuery = { + type: 'object', + properties: { + postId: { type: 'string', pattern: '^\\d+$', description: '게시글 ID' }, + username: { type: 'string', default: 'realfromis_9', description: '사용자명' }, + }, + required: ['postId'], +}; + +export const xScheduleCreate = { + type: 'object', + properties: { + postId: { type: 'string', pattern: '^\\d+$', description: '게시글 ID' }, + title: { type: 'string', minLength: 1, maxLength: 500, description: '제목' }, + content: { type: 'string', maxLength: 5000, description: '게시글 내용' }, + imageUrls: { type: 'array', items: { type: 'string', format: 'uri' }, description: '이미지 URL 목록' }, + date: { type: 'string', format: 'date', description: '날짜 (YYYY-MM-DD)' }, + time: { type: 'string', pattern: '^\\d{2}:\\d{2}(:\\d{2})?$', description: '시간' }, + }, + required: ['postId', 'title', 'date'], +}; diff --git a/backend/src/schemas/album.js b/backend/src/schemas/album.js new file mode 100644 index 0000000..8d277ad --- /dev/null +++ b/backend/src/schemas/album.js @@ -0,0 +1,75 @@ +/** + * 앨범 스키마 + */ + +export const albumTrack = { + type: 'object', + properties: { + track_number: { type: 'integer', minimum: 1, description: '트랙 번호' }, + title: { type: 'string', minLength: 1, maxLength: 200, description: '트랙 제목' }, + duration: { type: 'string', pattern: '^\\d{1,2}:\\d{2}$', description: '재생 시간 (M:SS 또는 MM:SS)' }, + is_title_track: { type: 'boolean', description: '타이틀곡 여부' }, + lyricist: { type: 'string', maxLength: 500, description: '작사가' }, + composer: { type: 'string', maxLength: 500, description: '작곡가' }, + arranger: { type: 'string', maxLength: 500, description: '편곡가' }, + lyrics: { type: 'string', description: '가사' }, + music_video_url: { type: 'string', format: 'uri', description: '뮤직비디오 URL' }, + }, + required: ['track_number', 'title'], +}; + +export const albumCreate = { + type: 'object', + properties: { + title: { type: 'string', minLength: 1, maxLength: 200, description: '앨범 제목' }, + album_type: { type: 'string', description: '앨범 유형 (정규, 미니, 싱글 등)' }, + album_type_short: { type: 'string', maxLength: 20, description: '앨범 유형 약자' }, + release_date: { type: 'string', format: 'date', description: '발매일 (YYYY-MM-DD)' }, + folder_name: { type: 'string', pattern: '^[a-zA-Z0-9_-]+$', description: '폴더명 (영문, 숫자, -, _만 허용)' }, + description: { type: 'string', maxLength: 2000, description: '앨범 설명' }, + tracks: { type: 'array', items: albumTrack, description: '트랙 목록' }, + }, + required: ['title', 'album_type', 'release_date', 'folder_name'], +}; + +export const albumResponse = { + type: 'object', + properties: { + id: { type: 'integer' }, + title: { type: 'string' }, + album_type: { type: 'string' }, + album_type_short: { type: 'string' }, + release_date: { type: 'string' }, + folder_name: { type: 'string' }, + cover_original_url: { type: 'string' }, + cover_medium_url: { type: 'string' }, + cover_thumb_url: { type: 'string' }, + description: { type: 'string' }, + tracks: { type: 'array', items: albumTrack }, + }, +}; + +export const photoMetadata = { + type: 'object', + properties: { + conceptName: { type: 'string', maxLength: 100, description: '컨셉 이름' }, + groupType: { type: 'string', enum: ['group', 'unit', 'solo'], description: '사진 유형' }, + members: { type: 'array', items: { type: 'integer' }, description: '멤버 ID 목록' }, + }, +}; + +export const photoResponse = { + type: 'object', + properties: { + id: { type: 'integer' }, + original_url: { type: 'string' }, + medium_url: { type: 'string' }, + thumb_url: { type: 'string' }, + photo_type: { type: 'string' }, + concept_name: { type: 'string' }, + sort_order: { type: 'integer' }, + width: { type: 'integer' }, + height: { type: 'integer' }, + members: { type: 'array', items: { type: 'integer' } }, + }, +}; diff --git a/backend/src/schemas/auth.js b/backend/src/schemas/auth.js new file mode 100644 index 0000000..7bd5aa1 --- /dev/null +++ b/backend/src/schemas/auth.js @@ -0,0 +1,20 @@ +/** + * 인증 스키마 + */ + +export const loginRequest = { + type: 'object', + properties: { + username: { type: 'string', minLength: 1, maxLength: 50, description: '사용자명' }, + password: { type: 'string', minLength: 1, maxLength: 100, description: '비밀번호' }, + }, + required: ['username', 'password'], +}; + +export const loginResponse = { + type: 'object', + properties: { + token: { type: 'string', description: 'JWT 토큰' }, + expiresAt: { type: 'string', format: 'date-time', description: '만료 시간' }, + }, +}; diff --git a/backend/src/schemas/common.js b/backend/src/schemas/common.js new file mode 100644 index 0000000..e760f37 --- /dev/null +++ b/backend/src/schemas/common.js @@ -0,0 +1,34 @@ +/** + * 공통 스키마 + */ + +export const errorResponse = { + type: 'object', + properties: { + error: { type: 'string', description: '에러 메시지' }, + }, + required: ['error'], +}; + +export const successResponse = { + type: 'object', + properties: { + message: { type: 'string', description: '성공 메시지' }, + }, +}; + +export const paginationQuery = { + type: 'object', + properties: { + offset: { type: 'integer', default: 0, minimum: 0, description: '페이지 오프셋' }, + limit: { type: 'integer', default: 20, minimum: 1, maximum: 100, description: '결과 개수' }, + }, +}; + +export const idParam = { + type: 'object', + properties: { + id: { type: 'integer', minimum: 1, description: 'ID' }, + }, + required: ['id'], +}; diff --git a/backend/src/schemas/index.js b/backend/src/schemas/index.js new file mode 100644 index 0000000..00fd8ad --- /dev/null +++ b/backend/src/schemas/index.js @@ -0,0 +1,11 @@ +/** + * JSON Schema 정의 + * 입력 검증 및 Swagger 문서화에 사용 + */ + +export * from './common.js'; +export * from './album.js'; +export * from './schedule.js'; +export * from './admin.js'; +export * from './member.js'; +export * from './auth.js'; diff --git a/backend/src/schemas/member.js b/backend/src/schemas/member.js new file mode 100644 index 0000000..c51d324 --- /dev/null +++ b/backend/src/schemas/member.js @@ -0,0 +1,15 @@ +/** + * 멤버 스키마 + */ + +export const memberResponse = { + type: 'object', + properties: { + id: { type: 'integer' }, + name: { type: 'string' }, + name_en: { type: 'string' }, + birth_date: { type: 'string' }, + position: { type: 'string' }, + is_former: { type: 'boolean' }, + }, +}; diff --git a/backend/src/schemas/schedule.js b/backend/src/schemas/schedule.js new file mode 100644 index 0000000..efd01e1 --- /dev/null +++ b/backend/src/schemas/schedule.js @@ -0,0 +1,57 @@ +/** + * 일정 스키마 + */ + +export const scheduleCategory = { + type: 'object', + properties: { + id: { type: 'integer' }, + name: { type: 'string' }, + color: { type: 'string' }, + sort_order: { type: 'integer' }, + }, +}; + +export const scheduleMember = { + type: 'object', + properties: { + id: { type: 'integer' }, + name: { type: 'string' }, + }, +}; + +export const scheduleResponse = { + type: 'object', + properties: { + id: { type: 'integer' }, + title: { type: 'string' }, + datetime: { type: 'string' }, + category: scheduleCategory, + members: { type: 'array', items: scheduleMember }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { type: 'string', format: 'date-time' }, + }, +}; + +export const scheduleSearchQuery = { + type: 'object', + properties: { + search: { type: 'string', description: '검색어' }, + year: { type: 'integer', minimum: 2000, maximum: 2100, description: '년도' }, + month: { type: 'integer', minimum: 1, maximum: 12, description: '월' }, + startDate: { type: 'string', format: 'date', description: '시작 날짜' }, + offset: { type: 'integer', default: 0, minimum: 0, description: '페이지 오프셋' }, + limit: { type: 'integer', default: 100, minimum: 1, maximum: 1000, description: '결과 개수' }, + }, +}; + +export const scheduleSearchResponse = { + type: 'object', + properties: { + schedules: { type: 'array', items: scheduleResponse }, + total: { type: 'integer' }, + offset: { type: 'integer' }, + limit: { type: 'integer' }, + hasMore: { type: 'boolean' }, + }, +}; diff --git a/backend/src/services/album.js b/backend/src/services/album.js index 57b08bf..10ca61d 100644 --- a/backend/src/services/album.js +++ b/backend/src/services/album.js @@ -3,6 +3,7 @@ * 앨범 관련 비즈니스 로직 */ import { uploadAlbumCover, deleteAlbumCover } from './image.js'; +import { withTransaction } from '../utils/transaction.js'; /** * 앨범 상세 정보 조회 (트랙, 티저, 컨셉포토 포함) @@ -143,11 +144,7 @@ async function insertTracks(connection, albumId, tracks) { export async function createAlbum(db, data, coverBuffer) { const { title, album_type, album_type_short, release_date, folder_name, description, tracks } = data; - const connection = await db.getConnection(); - - try { - await connection.beginTransaction(); - + return withTransaction(db, async (connection) => { // 커버 이미지 업로드 let coverOriginalUrl = null; let coverMediumUrl = null; @@ -174,14 +171,8 @@ export async function createAlbum(db, data, coverBuffer) { // 트랙 일괄 삽입 await insertTracks(connection, albumId, tracks); - await connection.commit(); return { message: '앨범이 생성되었습니다.', albumId }; - } catch (error) { - await connection.rollback(); - throw error; - } finally { - connection.release(); - } + }); } /** @@ -190,25 +181,20 @@ export async function createAlbum(db, data, coverBuffer) { * @param {number} id - 앨범 ID * @param {object} data - 앨범 데이터 * @param {Buffer|null} coverBuffer - 커버 이미지 버퍼 - * @returns {object} 결과 메시지 + * @returns {object|null} 결과 메시지 또는 null(앨범 없음) */ export async function updateAlbum(db, id, data, coverBuffer) { const { title, album_type, album_type_short, release_date, folder_name, description, tracks } = data; - const connection = await db.getConnection(); + // 앨범 존재 여부 먼저 확인 (트랜잭션 외부) + const [existingAlbums] = await db.query('SELECT * FROM albums WHERE id = ?', [id]); + if (existingAlbums.length === 0) { + return null; + } - try { - await connection.beginTransaction(); - - // 기존 앨범 확인 - const [existingAlbums] = await connection.query('SELECT * FROM albums WHERE id = ?', [id]); - if (existingAlbums.length === 0) { - connection.release(); - return null; // 앨범 없음 - } - - const existing = existingAlbums[0]; + const existing = existingAlbums[0]; + return withTransaction(db, async (connection) => { // 커버 이미지 처리 let coverOriginalUrl = existing.cover_original_url; let coverMediumUrl = existing.cover_medium_url; @@ -235,14 +221,8 @@ export async function updateAlbum(db, id, data, coverBuffer) { await connection.query('DELETE FROM album_tracks WHERE album_id = ?', [id]); await insertTracks(connection, id, tracks); - await connection.commit(); return { message: '앨범이 수정되었습니다.' }; - } catch (error) { - await connection.rollback(); - throw error; - } finally { - connection.release(); - } + }); } /** @@ -252,19 +232,15 @@ export async function updateAlbum(db, id, data, coverBuffer) { * @returns {object|null} 결과 메시지 또는 null(앨범 없음) */ export async function deleteAlbum(db, id) { - const connection = await db.getConnection(); + // 앨범 존재 여부 먼저 확인 (트랜잭션 외부) + const [existingAlbums] = await db.query('SELECT * FROM albums WHERE id = ?', [id]); + if (existingAlbums.length === 0) { + return null; + } - try { - await connection.beginTransaction(); - - const [existingAlbums] = await connection.query('SELECT * FROM albums WHERE id = ?', [id]); - if (existingAlbums.length === 0) { - connection.release(); - return null; - } - - const album = existingAlbums[0]; + const album = existingAlbums[0]; + return withTransaction(db, async (connection) => { // 커버 이미지 삭제 if (album.cover_original_url && album.folder_name) { await deleteAlbumCover(album.folder_name); @@ -274,12 +250,6 @@ export async function deleteAlbum(db, id) { await connection.query('DELETE FROM album_tracks WHERE album_id = ?', [id]); await connection.query('DELETE FROM albums WHERE id = ?', [id]); - await connection.commit(); return { message: '앨범이 삭제되었습니다.' }; - } catch (error) { - await connection.rollback(); - throw error; - } finally { - connection.release(); - } + }); } diff --git a/backend/src/utils/transaction.js b/backend/src/utils/transaction.js new file mode 100644 index 0000000..e6b2462 --- /dev/null +++ b/backend/src/utils/transaction.js @@ -0,0 +1,34 @@ +/** + * 트랜잭션 헬퍼 유틸리티 + * 반복되는 트랜잭션 패턴 추상화 + */ + +/** + * 트랜잭션 래퍼 함수 + * @param {object} db - 데이터베이스 연결 풀 + * @param {function} callback - 트랜잭션 내에서 실행할 함수 (connection을 인자로 받음) + * @returns {Promise} callback의 반환값 + * @throws callback에서 발생한 에러 (자동 롤백 후 재throw) + * + * @example + * const result = await withTransaction(db, async (connection) => { + * await connection.query('INSERT INTO ...'); + * await connection.query('UPDATE ...'); + * return { success: true }; + * }); + */ +export async function withTransaction(db, callback) { + const connection = await db.getConnection(); + + try { + await connection.beginTransaction(); + const result = await callback(connection); + await connection.commit(); + return result; + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } +} diff --git a/docs/refactoring.md b/docs/refactoring.md index 169ce8e..c535680 100644 --- a/docs/refactoring.md +++ b/docs/refactoring.md @@ -112,6 +112,57 @@ --- +### 12단계: 트랜잭션 헬퍼 추상화 ✅ 완료 +- [x] `src/utils/transaction.js` 생성 - withTransaction 함수 +- [x] 반복되는 트랜잭션 패턴 추상화 적용 + +**수정된 파일:** +- `src/utils/transaction.js` - 트랜잭션 헬퍼 유틸리티 생성 +- `src/services/album.js` - createAlbum, updateAlbum, deleteAlbum에 withTransaction 적용 +- `src/routes/albums/photos.js` - DELETE 핸들러에 withTransaction 적용 +- `src/routes/albums/teasers.js` - DELETE 핸들러에 withTransaction 적용 + +--- + +### 13단계: Swagger/OpenAPI 문서화 개선 ✅ 완료 +- [x] `src/schemas/index.js` 생성 - 공통 스키마 정의 +- [x] `src/app.js` - Swagger components에 스키마 등록 +- [x] 태그 추가 (admin/youtube, admin/x, admin/bots) + +**수정된 파일:** +- `src/schemas/index.js` - JSON Schema 정의 (Error, Success, Album, Schedule 등) +- `src/app.js` - Swagger 설정에 스키마 컴포넌트 추가 + +--- + +### 14단계: 입력 검증 강화 (JSON Schema) ✅ 완료 +- [x] 라우트에 params, querystring, body, response 스키마 추가 +- [x] 상세 description 추가로 API 문서 품질 향상 + +**수정된 파일:** +- `src/routes/albums/index.js` - GET/POST/PUT/DELETE 스키마 추가 +- `src/routes/schedules/index.js` - 검색/조회/삭제 스키마 추가 +- `src/routes/admin/youtube.js` - 영상 조회/일정 등록/수정 스키마 추가 +- `src/routes/admin/x.js` - 게시글 조회/일정 등록 스키마 추가 +- `src/routes/admin/bots.js` - 봇 관리 스키마 추가 + +--- + +### 15단계: 스키마 파일 분리 ✅ 완료 +- [x] 단일 스키마 파일을 도메인별로 분리 +- [x] Re-export 패턴으로 기존 import 호환성 유지 + +**생성된 파일:** +- `src/schemas/common.js` - 공통 스키마 (errorResponse, successResponse, paginationQuery, idParam) +- `src/schemas/album.js` - 앨범 관련 스키마 +- `src/schemas/schedule.js` - 일정 관련 스키마 +- `src/schemas/admin.js` - 관리자 API 스키마 (YouTube, X) +- `src/schemas/member.js` - 멤버 스키마 +- `src/schemas/auth.js` - 인증 스키마 +- `src/schemas/index.js` - 모든 스키마 re-export + +--- + ## 진행 상황 | 단계 | 작업 | 상태 | @@ -127,6 +178,10 @@ | 9단계 | 응답 형식 통일 | ✅ 완료 | | 10단계 | 로거 통일 | ✅ 완료 | | 11단계 | 대형 핸들러 분리 | ✅ 완료 | +| 12단계 | 트랜잭션 헬퍼 추상화 | ✅ 완료 | +| 13단계 | Swagger/OpenAPI 문서화 | ✅ 완료 | +| 14단계 | 입력 검증 강화 (JSON Schema) | ✅ 완료 | +| 15단계 | 스키마 파일 분리 | ✅ 완료 | --- @@ -135,3 +190,4 @@ - 각 단계별로 커밋 후 다음 단계 진행 - 기존 API 응답 형식은 유지 - 프론트엔드 수정 불필요하도록 진행 +- API 문서는 `/docs`에서 확인 가능 (Scalar API Reference) diff --git a/frontend/src/pages/pc/public/AlbumDetail.jsx b/frontend/src/pages/pc/public/AlbumDetail.jsx index d7532e9..57ed556 100644 --- a/frontend/src/pages/pc/public/AlbumDetail.jsx +++ b/frontend/src/pages/pc/public/AlbumDetail.jsx @@ -352,7 +352,7 @@ function AlbumDetail() { transition={{ delay: 0.2, duration: 0.4 }} >

수록곡

-
+
{album.tracks?.map((track, index) => (
-
+
{/* 헤더 */}