-
Notifications
You must be signed in to change notification settings - Fork 0
AI News 페이지 구현 #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
AI News 페이지 구현 #13
Changes from all commits
0d08a3e
b822dd4
7aaefe4
c3b1744
07db577
503910b
65e569d
fd18c63
9b9b33d
234e16c
6d71180
00e9728
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import { NextRequest, NextResponse } from "next/server"; | ||
|
|
||
| import { newsPaginationHandler } from "@/entities/news/handler/news-pagination.handler"; | ||
|
|
||
| export async function GET(request: NextRequest) { | ||
| try { | ||
| // URL에서 page 파라미터 추출 | ||
| const searchParams = request.nextUrl.searchParams; | ||
| const pageParam = searchParams.get("page"); | ||
| const page = pageParam ? parseInt(pageParam, 10) : 0; | ||
|
|
||
| // 페이지 번호 유효성 검사 | ||
| if (isNaN(page) || page < 0) { | ||
| return NextResponse.json( | ||
| { | ||
| status: "error", | ||
| data: null, | ||
| error: "Invalid page parameter", | ||
| }, | ||
| { status: 400 }, | ||
| ); | ||
| } | ||
|
|
||
| // 핸들러 호출 | ||
| const data = await newsPaginationHandler(page); | ||
|
|
||
| return NextResponse.json( | ||
| { | ||
| status: "success", | ||
| data, | ||
| }, | ||
| { status: 200 }, | ||
| ); | ||
| } catch (error) { | ||
| console.error("News API error:", error); | ||
| return NextResponse.json( | ||
| { | ||
| status: "error", | ||
| data: null, | ||
| error: error instanceof Error ? error.message : "Internal server error", | ||
| }, | ||
| { status: 500 }, | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| @import "tailwindcss"; | ||
| @import "tailwind-scrollbar-hide/v4"; | ||
| @import "tw-animate-css"; | ||
|
|
||
| @custom-variant dark (&:is(.dark *)); | ||
|
|
@@ -132,7 +133,7 @@ | |
|
|
||
| @layer base { | ||
| * { | ||
| @apply border-border outline-ring/50; | ||
| @apply scrollbar-hide border-border outline-ring/50; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } | ||
| body { | ||
| @apply bg-background text-text-dark; | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,3 +1,36 @@ | ||||||||||||||||||||||||||||||||||||
| "use client"; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| import { useGetNewsList } from "@/entities"; | ||||||||||||||||||||||||||||||||||||
| import { NewsListSection, SummaryNewsSection } from "@/features"; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| export default function NewsPage() { | ||||||||||||||||||||||||||||||||||||
| return <div>News Page</div>; | ||||||||||||||||||||||||||||||||||||
| const { data: newsResponse, isLoading } = useGetNewsList(0); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const newsData = newsResponse?.data; | ||||||||||||||||||||||||||||||||||||
| const { newsDate, totalNews, investmentIndex, summary, keywords, newsAnalysis } = newsData || { | ||||||||||||||||||||||||||||||||||||
| newsDate: "", | ||||||||||||||||||||||||||||||||||||
| totalNews: 0, | ||||||||||||||||||||||||||||||||||||
| investmentIndex: 0, | ||||||||||||||||||||||||||||||||||||
| summary: { positive: 0, negative: 0, neutral: 0 }, | ||||||||||||||||||||||||||||||||||||
| keywords: [], | ||||||||||||||||||||||||||||||||||||
| newsAnalysis: [], | ||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+10
to
+17
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||||||||
| <div className='flex w-full max-w-4xl flex-col gap-8 px-5 py-10'> | ||||||||||||||||||||||||||||||||||||
| <h1 className='text-2xl font-bold text-white'>뉴스 목록</h1> | ||||||||||||||||||||||||||||||||||||
| <p>AI가 분석한 코인 관련 뉴스입니다.</p> | ||||||||||||||||||||||||||||||||||||
| <div className='flex w-full flex-col gap-8 pb-20'> | ||||||||||||||||||||||||||||||||||||
| <SummaryNewsSection | ||||||||||||||||||||||||||||||||||||
| newsDate={newsDate} | ||||||||||||||||||||||||||||||||||||
| totalNews={totalNews} | ||||||||||||||||||||||||||||||||||||
| investmentIndex={investmentIndex} | ||||||||||||||||||||||||||||||||||||
| summary={summary} | ||||||||||||||||||||||||||||||||||||
| keywords={keywords} | ||||||||||||||||||||||||||||||||||||
| isLoading={isLoading} | ||||||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||||||
| <NewsListSection newsAnalysis={newsAnalysis} isLoading={isLoading} /> | ||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| export * from "./auth"; | ||
| export * from "./market"; | ||
| export * from "./user"; | ||
| export * from "./news"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| import { unstable_cache } from "next/cache"; | ||
|
|
||
| import { | ||
| AnalysisAPIResponseSchema, | ||
| NewsAPIResponseSchema, | ||
| NewsAnalysisData, | ||
| NewsAnalysisItem, | ||
| PaginatedNewsResponse, | ||
| analysisAPI, | ||
| newsAPI, | ||
| } from "../model"; | ||
|
|
||
| const ITEMS_PER_PAGE = 5; | ||
| const REVALIDATE_TIME = 300; // 5분 (초 단위) | ||
|
|
||
| const getMergedNewsData = unstable_cache( | ||
| async () => { | ||
| // 두 API를 병렬로 호출 | ||
| const [newsResponse, analysisResponse] = await Promise.all([newsAPI(), analysisAPI()]); | ||
|
|
||
| // Zod를 사용한 API 응답 검증 | ||
| const validatedNewsResponse = NewsAPIResponseSchema.parse(newsResponse); | ||
| const validatedAnalysisResponse = AnalysisAPIResponseSchema.parse(analysisResponse); | ||
|
|
||
| if (!validatedNewsResponse.success || !validatedAnalysisResponse.success) { | ||
| throw new Error("Failed to fetch news data"); | ||
| } | ||
|
|
||
| const newsData = validatedNewsResponse.data; | ||
| const analysisData = validatedAnalysisResponse.data; | ||
|
|
||
| // newsId를 기준으로 analysis 데이터를 Map으로 변환 (빠른 조회를 위해) | ||
| const analysisMap = new Map<number, NewsAnalysisData>(analysisData.newsAnalysis.map((item) => [item.newsId, item])); | ||
|
|
||
| // newsData와 analysisData.newsAnalysis를 newsId 기준으로 매칭 | ||
| const combinedNewsAnalysis: NewsAnalysisItem[] = newsData.map((news) => { | ||
| const analysis: NewsAnalysisData = analysisMap.get(news.id) || { | ||
| newsId: news.id, | ||
| reason: "", | ||
| keywords: [], | ||
| sentiment: "neutral" as const, | ||
| confidence: 0, | ||
| }; | ||
|
|
||
| return { | ||
| newsId: news.id, | ||
| title: news.title, | ||
| content: news.content, | ||
| url: news.url, | ||
| source: news.source, | ||
| reason: analysis.reason, | ||
| keywords: analysis.keywords, | ||
|
Comment on lines
+45
to
+52
|
||
| sentiment: analysis.sentiment, | ||
| confidence: analysis.confidence, | ||
| }; | ||
| }); | ||
|
|
||
| // 전체 데이터 반환 (페이지네이션 되지 않음) | ||
| return { | ||
| meta: { | ||
| newsDate: analysisData.date, | ||
| totalNews: analysisData.totalNews, | ||
| investmentIndex: analysisData.investmentIndex, | ||
| summary: analysisData.summary, | ||
| keywords: analysisData.keywords, | ||
| }, | ||
| items: combinedNewsAnalysis, | ||
| }; | ||
| }, | ||
| ["combined-news-data"], | ||
| { revalidate: REVALIDATE_TIME, tags: ["news"] }, | ||
| ); | ||
|
|
||
| export const newsPaginationHandler = async (page: number = 0): Promise<PaginatedNewsResponse> => { | ||
| try { | ||
| // 캐시된 전체 데이터를 가져옴 (API 호출 X, 메모리/파일시스템에서 로드 O) | ||
| const cachedData = await getMergedNewsData(); | ||
|
|
||
| const { items, meta } = cachedData; | ||
|
|
||
| // 페이지네이션 계산 | ||
| const totalItems = items.length; | ||
| const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE); | ||
| const startIndex = page * ITEMS_PER_PAGE; | ||
| const endIndex = startIndex + ITEMS_PER_PAGE; | ||
|
|
||
| // 현재 페이지의 데이터만 추출 | ||
| const paginatedNewsAnalysis = items.slice(startIndex, endIndex); | ||
|
|
||
| return { | ||
| newsDate: meta.newsDate, | ||
| totalNews: meta.totalNews, | ||
| investmentIndex: meta.investmentIndex, | ||
| summary: meta.summary, | ||
| keywords: meta.keywords, | ||
| newsAnalysis: paginatedNewsAnalysis, | ||
| pagination: { | ||
| currentPage: page, | ||
| totalPages, | ||
| totalItems, | ||
| hasNext: page < totalPages - 1, | ||
| hasPrev: page > 0, | ||
| }, | ||
| }; | ||
| } catch (error) { | ||
| if (error instanceof Error) { | ||
| console.error("News pagination handler error:", error.message); | ||
| throw new Error(`Failed to process news data: ${error.message}`); | ||
| } | ||
| throw new Error("Failed to process news data: Unknown error"); | ||
| } | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export * from "./model"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,3 @@ | ||
| export * from "./news.api"; | ||
| export * from "./analysis.api"; | ||
| export * from "./news-list.api"; | ||
| export * from "./news.api"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import { APIResponse } from "@/shared"; | ||
|
|
||
| import { PaginatedNewsResponse } from "../types"; | ||
|
|
||
| export type NewsListParams = { | ||
| page?: number; | ||
| }; | ||
|
|
||
| export type NewsListResponse = APIResponse<PaginatedNewsResponse>; | ||
|
|
||
| export const newsListAPI = async ({ page = 0 }: NewsListParams): Promise<NewsListResponse> => { | ||
| const response = await fetch(`/api/news?page=${page}`); | ||
|
|
||
| if (!response.ok) { | ||
| throw new Error("Failed to fetch news list"); | ||
| } | ||
|
|
||
| return response.json(); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export * from "./useGetNewsList"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| "use client"; | ||
|
|
||
| import { useQuery } from "@tanstack/react-query"; | ||
|
|
||
| import { newsListAPI } from "../apis"; | ||
|
|
||
| export const useGetNewsList = (page: number) => { | ||
| return useQuery({ | ||
| queryKey: ["news-list", page], | ||
| queryFn: () => newsListAPI({ page }), | ||
| }); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,4 @@ | ||
| export * from "./apis"; | ||
| export * from "./types"; | ||
| export * from "./hooks"; | ||
| export * from "./schema"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export * from "./news.schema"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
API 응답을 생성하는 로직이 중복되고 있습니다. 성공 및 오류 응답을 생성하는 헬퍼 함수를 만들어 사용하면 코드 가독성과 유지보수성을 높일 수 있습니다. 예를 들어,
successResponse(data)와errorResponse(message, status)같은 함수를shared영역에 만들어 재사용하는 것을 권장합니다.