diff --git a/backend/src/routes/schedules/index.js b/backend/src/routes/schedules/index.js index ece2f73..fc5eb4e 100644 --- a/backend/src/routes/schedules/index.js +++ b/backend/src/routes/schedules/index.js @@ -4,8 +4,13 @@ */ 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 { CATEGORY_IDS } from '../../config/index.js'; +import { + getCategories, + getScheduleDetail, + getMonthlySchedules, + getUpcomingSchedules, +} from '../../services/schedule.js'; import { errorResponse, scheduleSearchQuery, @@ -34,10 +39,7 @@ export default async function schedulesRoutes(fastify) { }, }, async (request, reply) => { try { - const [categories] = await db.query( - 'SELECT id, name, color, sort_order FROM schedule_categories ORDER BY sort_order ASC, id ASC' - ); - return categories; + return await getCategories(db); } catch (err) { fastify.log.error(err); return reply.code(500).send({ error: '카테고리 목록 조회 실패' }); @@ -132,89 +134,16 @@ export default async function schedulesRoutes(fastify) { }, }, async (request, reply) => { try { - const { id } = request.params; + const result = await getScheduleDetail( + db, + request.params.id, + (username) => fastify.xBot.getProfile(username) + ); - const [schedules] = await db.query(` - SELECT - s.*, - c.name as category_name, - c.color as category_color, - sy.channel_name as youtube_channel, - sy.video_id as youtube_video_id, - sy.video_type as youtube_video_type, - sx.post_id as x_post_id, - sx.content as x_content, - sx.image_urls as x_image_urls - FROM schedules s - LEFT JOIN schedule_categories c ON s.category_id = c.id - LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id - LEFT JOIN schedule_x sx ON s.id = sx.schedule_id - WHERE s.id = ? - `, [id]); - - if (schedules.length === 0) { + if (!result) { return reply.code(404).send({ error: '일정을 찾을 수 없습니다.' }); } - const s = schedules[0]; - - // 멤버 정보 조회 - const [members] = await db.query(` - SELECT m.id, m.name - FROM schedule_members sm - JOIN members m ON sm.member_id = m.id - WHERE sm.schedule_id = ? - ORDER BY m.id - `, [id]); - - // datetime 생성 (date + time) - const dateStr = s.date instanceof Date ? s.date.toISOString().split('T')[0] : s.date?.split('T')[0]; - const timeStr = s.time ? s.time.slice(0, 5) : null; - const datetime = timeStr ? `${dateStr} ${timeStr}` : dateStr; - - // 공통 필드 - const result = { - id: s.id, - title: s.title, - datetime, - category: { - id: s.category_id, - name: s.category_name, - color: s.category_color, - }, - members, - createdAt: s.created_at, - updatedAt: s.updated_at, - }; - - // 카테고리별 추가 필드 - if (s.category_id === CATEGORY_IDS.YOUTUBE && s.youtube_video_id) { - // YouTube - result.videoId = s.youtube_video_id; - result.videoType = s.youtube_video_type; - result.channelName = s.youtube_channel; - result.videoUrl = s.youtube_video_type === 'shorts' - ? `https://www.youtube.com/shorts/${s.youtube_video_id}` - : `https://www.youtube.com/watch?v=${s.youtube_video_id}`; - } else if (s.category_id === CATEGORY_IDS.X && s.x_post_id) { - // X (Twitter) - const username = config.x.defaultUsername; - result.postId = s.x_post_id; - result.content = s.x_content || null; - result.imageUrls = s.x_image_urls ? JSON.parse(s.x_image_urls) : []; - result.postUrl = `https://x.com/${username}/status/${s.x_post_id}`; - - // 프로필 정보 (Redis 캐시 → DB) - const profile = await fastify.xBot.getProfile(username); - if (profile) { - result.profile = { - username: profile.username, - displayName: profile.displayName, - avatarUrl: profile.avatarUrl, - }; - } - } - return result; } catch (err) { fastify.log.error(err); diff --git a/backend/src/services/schedule.js b/backend/src/services/schedule.js index c5de8db..637f3ee 100644 --- a/backend/src/services/schedule.js +++ b/backend/src/services/schedule.js @@ -4,6 +4,112 @@ */ import config, { CATEGORY_IDS } from '../config/index.js'; +/** + * 카테고리 목록 조회 + * @param {object} db - 데이터베이스 연결 + * @returns {array} 카테고리 목록 + */ +export async function getCategories(db) { + const [categories] = await db.query( + 'SELECT id, name, color, sort_order FROM schedule_categories ORDER BY sort_order ASC, id ASC' + ); + return categories; +} + +/** + * 일정 상세 조회 + * @param {object} db - 데이터베이스 연결 + * @param {number} id - 일정 ID + * @param {Function} getXProfile - X 프로필 조회 함수 (선택적) + * @returns {object|null} 일정 상세 또는 null + */ +export async function getScheduleDetail(db, id, getXProfile = null) { + const [schedules] = await db.query(` + SELECT + s.*, + c.name as category_name, + c.color as category_color, + sy.channel_name as youtube_channel, + sy.video_id as youtube_video_id, + sy.video_type as youtube_video_type, + sx.post_id as x_post_id, + sx.content as x_content, + sx.image_urls as x_image_urls + FROM schedules s + LEFT JOIN schedule_categories c ON s.category_id = c.id + LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id + LEFT JOIN schedule_x sx ON s.id = sx.schedule_id + WHERE s.id = ? + `, [id]); + + if (schedules.length === 0) { + return null; + } + + const s = schedules[0]; + + // 멤버 정보 조회 + const [members] = await db.query(` + SELECT m.id, m.name + FROM schedule_members sm + JOIN members m ON sm.member_id = m.id + WHERE sm.schedule_id = ? + ORDER BY m.id + `, [id]); + + // datetime 생성 (date + time) + const dateStr = s.date instanceof Date ? s.date.toISOString().split('T')[0] : s.date?.split('T')[0]; + const timeStr = s.time ? s.time.slice(0, 5) : null; + const datetime = timeStr ? `${dateStr} ${timeStr}` : dateStr; + + // 공통 필드 + const result = { + id: s.id, + title: s.title, + datetime, + category: { + id: s.category_id, + name: s.category_name, + color: s.category_color, + }, + members, + createdAt: s.created_at, + updatedAt: s.updated_at, + }; + + // 카테고리별 추가 필드 + if (s.category_id === CATEGORY_IDS.YOUTUBE && s.youtube_video_id) { + // YouTube + result.videoId = s.youtube_video_id; + result.videoType = s.youtube_video_type; + result.channelName = s.youtube_channel; + result.videoUrl = s.youtube_video_type === 'shorts' + ? `https://www.youtube.com/shorts/${s.youtube_video_id}` + : `https://www.youtube.com/watch?v=${s.youtube_video_id}`; + } else if (s.category_id === CATEGORY_IDS.X && s.x_post_id) { + // X (Twitter) + const username = config.x.defaultUsername; + result.postId = s.x_post_id; + result.content = s.x_content || null; + result.imageUrls = s.x_image_urls ? JSON.parse(s.x_image_urls) : []; + result.postUrl = `https://x.com/${username}/status/${s.x_post_id}`; + + // 프로필 정보 (선택적) + if (getXProfile) { + const profile = await getXProfile(username); + if (profile) { + result.profile = { + username: profile.username, + displayName: profile.displayName, + avatarUrl: profile.avatarUrl, + }; + } + } + } + + return result; +} + /** * 월별 일정 조회 (생일 포함) * @param {object} db - 데이터베이스 연결 diff --git a/docs/refactoring.md b/docs/refactoring.md index 9851336..4a980c5 100644 --- a/docs/refactoring.md +++ b/docs/refactoring.md @@ -211,13 +211,13 @@ --- -### 20단계: 서비스 레이어 확대 🔄 진행 예정 -- [ ] schedules 라우트의 DB 쿼리를 서비스로 분리 -- [ ] 일관된 서비스 패턴 적용 +### 20단계: 서비스 레이어 확대 ✅ 완료 +- [x] schedules 라우트의 DB 쿼리를 서비스로 분리 +- [x] 일관된 서비스 패턴 적용 -**대상 파일:** -- `src/services/schedule.js` - 함수 추가 -- `src/routes/schedules/index.js` - 서비스 호출로 변경 +**수정된 파일:** +- `src/services/schedule.js` - getCategories, getScheduleDetail 함수 추가 +- `src/routes/schedules/index.js` - 서비스 호출로 변경 (약 70줄 감소) --- @@ -253,7 +253,7 @@ | 17단계 | 중복 코드 제거 (멤버 조회) | ✅ 완료 | | 18단계 | 이미지 처리 최적화 | ✅ 완료 | | 19단계 | Redis 캐시 확대 | ✅ 완료 | -| 20단계 | 서비스 레이어 확대 | 🔄 진행 예정 | +| 20단계 | 서비스 레이어 확대 | ✅ 완료 | | 21단계 | 검색 페이징 최적화 | 🔄 진행 예정 | ---