refactor(backend): 트랜잭션 헬퍼, JSON 스키마 추가 및 스키마 파일 분리
- src/utils/transaction.js: withTransaction 헬퍼 함수 추가 - src/schemas/: 도메인별 스키마 파일 분리 (common, album, schedule, admin, member, auth) - 라우트에 JSON Schema 검증 및 Swagger 문서화 적용 - 트랜잭션 패턴을 withTransaction 헬퍼로 추상화 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
84113a8c48
commit
f483f2cf53
20 changed files with 707 additions and 152 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
querystring: xPostInfoQuery,
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
postId: { type: 'string', description: '게시글 ID' },
|
||||
username: { type: 'string', description: '사용자명 (기본: realfromis_9)' },
|
||||
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: {
|
||||
body: xScheduleCreate,
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
postId: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
content: { type: 'string' },
|
||||
imageUrls: { type: 'array', items: { type: 'string' } },
|
||||
date: { type: 'string' },
|
||||
time: { type: 'string' },
|
||||
success: { type: 'boolean' },
|
||||
scheduleId: { type: 'integer' },
|
||||
},
|
||||
required: ['postId', 'title', 'date'],
|
||||
},
|
||||
409: errorResponse,
|
||||
500: errorResponse,
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
querystring: youtubeVideoInfo,
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', description: 'YouTube URL' },
|
||||
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: {
|
||||
body: youtubeScheduleCreate,
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
videoId: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
channelId: { type: 'string' },
|
||||
channelName: { type: 'string' },
|
||||
date: { type: 'string' },
|
||||
time: { type: 'string' },
|
||||
videoType: { type: 'string' },
|
||||
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: {
|
||||
params: idParam,
|
||||
body: youtubeScheduleUpdate,
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
success: { type: 'boolean' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
memberIds: { type: 'array', items: { type: 'integer' } },
|
||||
videoType: { type: 'string', enum: ['video', 'shorts'] },
|
||||
},
|
||||
404: errorResponse,
|
||||
500: errorResponse,
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
deleteAlbumPhoto,
|
||||
uploadAlbumVideo,
|
||||
} from '../../services/image.js';
|
||||
import { withTransaction } from '../../utils/transaction.js';
|
||||
|
||||
/**
|
||||
* 앨범 사진 라우트
|
||||
|
|
@ -215,12 +216,9 @@ 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 connection.query(
|
||||
// 사진 존재 여부 먼저 확인
|
||||
const [photos] = await db.query(
|
||||
`SELECT p.*, a.folder_name
|
||||
FROM album_photos p
|
||||
JOIN albums a ON p.album_id = a.id
|
||||
|
|
@ -235,17 +233,12 @@ export default async function photosRoutes(fastify) {
|
|||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {
|
|||
deleteAlbumPhoto,
|
||||
deleteAlbumVideo,
|
||||
} from '../../services/image.js';
|
||||
import { withTransaction } from '../../utils/transaction.js';
|
||||
|
||||
/**
|
||||
* 앨범 티저 라우트
|
||||
|
|
@ -49,12 +50,9 @@ 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 connection.query(
|
||||
// 티저 존재 여부 먼저 확인
|
||||
const [teasers] = await db.query(
|
||||
`SELECT t.*, a.folder_name
|
||||
FROM album_teasers t
|
||||
JOIN albums a ON t.album_id = a.id
|
||||
|
|
@ -69,6 +67,7 @@ export default async function teasersRoutes(fastify) {
|
|||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
59
backend/src/schemas/admin.js
Normal file
59
backend/src/schemas/admin.js
Normal file
|
|
@ -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'],
|
||||
};
|
||||
75
backend/src/schemas/album.js
Normal file
75
backend/src/schemas/album.js
Normal file
|
|
@ -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' } },
|
||||
},
|
||||
};
|
||||
20
backend/src/schemas/auth.js
Normal file
20
backend/src/schemas/auth.js
Normal file
|
|
@ -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: '만료 시간' },
|
||||
},
|
||||
};
|
||||
34
backend/src/schemas/common.js
Normal file
34
backend/src/schemas/common.js
Normal file
|
|
@ -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'],
|
||||
};
|
||||
11
backend/src/schemas/index.js
Normal file
11
backend/src/schemas/index.js
Normal file
|
|
@ -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';
|
||||
15
backend/src/schemas/member.js
Normal file
15
backend/src/schemas/member.js
Normal file
|
|
@ -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' },
|
||||
},
|
||||
};
|
||||
57
backend/src/schemas/schedule.js
Normal file
57
backend/src/schemas/schedule.js
Normal file
|
|
@ -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' },
|
||||
},
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
|
||||
// 기존 앨범 확인
|
||||
const [existingAlbums] = await connection.query('SELECT * FROM albums WHERE id = ?', [id]);
|
||||
// 앨범 존재 여부 먼저 확인 (트랜잭션 외부)
|
||||
const [existingAlbums] = await db.query('SELECT * FROM albums WHERE id = ?', [id]);
|
||||
if (existingAlbums.length === 0) {
|
||||
connection.release();
|
||||
return null; // 앨범 없음
|
||||
return null;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
|
||||
const [existingAlbums] = await connection.query('SELECT * FROM albums WHERE id = ?', [id]);
|
||||
// 앨범 존재 여부 먼저 확인 (트랜잭션 외부)
|
||||
const [existingAlbums] = await db.query('SELECT * FROM albums WHERE id = ?', [id]);
|
||||
if (existingAlbums.length === 0) {
|
||||
connection.release();
|
||||
return null;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
34
backend/src/utils/transaction.js
Normal file
34
backend/src/utils/transaction.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* 트랜잭션 헬퍼 유틸리티
|
||||
* 반복되는 트랜잭션 패턴 추상화
|
||||
*/
|
||||
|
||||
/**
|
||||
* 트랜잭션 래퍼 함수
|
||||
* @param {object} db - 데이터베이스 연결 풀
|
||||
* @param {function} callback - 트랜잭션 내에서 실행할 함수 (connection을 인자로 받음)
|
||||
* @returns {Promise<any>} 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -352,7 +352,7 @@ function AlbumDetail() {
|
|||
transition={{ delay: 0.2, duration: 0.4 }}
|
||||
>
|
||||
<h2 className="text-xl font-bold mb-4">수록곡</h2>
|
||||
<div className="bg-white rounded-2xl shadow-lg overflow-hidden">
|
||||
<div className="bg-white rounded-2xl shadow-md overflow-hidden">
|
||||
{album.tracks?.map((track, index) => (
|
||||
<div
|
||||
key={track.id}
|
||||
|
|
|
|||
|
|
@ -621,7 +621,7 @@ function Schedule() {
|
|||
|
||||
return (
|
||||
<div className="h-[calc(100vh-64px)] overflow-hidden flex flex-col">
|
||||
<div className="flex-1 flex flex-col overflow-hidden max-w-7xl mx-auto px-6 py-8 w-full">
|
||||
<div className="flex-1 flex flex-col overflow-hidden max-w-7xl mx-auto px-6 pt-16 pb-8 w-full">
|
||||
{/* 헤더 */}
|
||||
<div className="flex-shrink-0 text-center mb-8">
|
||||
<motion.h1
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue