-
Notifications
You must be signed in to change notification settings - Fork 0
[FEAT] API 연동 공통 구조 구축 #5
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
Changes from all commits
a66409a
a48a531
2d75cb7
296434d
4126665
978e60c
0b3ad86
24c37b7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| VITE_API_BASE_URL= |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| ## 📌 PR 설명 | ||
|
|
||
| <br> | ||
|
|
||
| ## ✅ 완료한 기능 명세 | ||
|
|
||
| - [x] | ||
|
|
||
| <br> | ||
|
|
||
| ## 📸 스크린샷 | ||
|
|
||
| <br> | ||
|
|
||
| ## 💭 고민과 해결과정 | ||
|
|
||
| <br> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,5 +23,9 @@ dist-ssr | |
| *.sln | ||
| *.sw? | ||
|
|
||
| # Environment | ||
| .env* | ||
| !.env.example | ||
|
|
||
| # Claude | ||
| CLAUDE.md | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| import { z } from 'zod' | ||
|
|
||
| import { apiResponseSchema } from '@/shared/api/api-response' | ||
| import { baseApi } from '@/shared/api/base-api' | ||
|
|
||
| const collectMarketResponseSchema = apiResponseSchema(z.string()) | ||
|
|
||
| export type CollectMarketResponse = z.infer<typeof collectMarketResponseSchema> | ||
|
|
||
| export async function collectMarketData(): Promise<CollectMarketResponse> { | ||
| const response = await baseApi.post('/admin/market/collect') | ||
| return collectMarketResponseSchema.parse(response.data) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| import { z } from 'zod' | ||
|
|
||
| import { apiResponseSchema } from '@/shared/api/api-response' | ||
| import { baseApi } from '@/shared/api/base-api' | ||
|
|
||
| const marketSnapshotSchema = z.object({ | ||
| metricType: z.string(), | ||
| label: z.string(), | ||
| value: z.number(), | ||
| changeRate: z.number().nullable(), | ||
| changeValue: z.number().nullable(), | ||
| unit: z.string(), | ||
| changeDirection: z.string(), | ||
| createdAt: z.string(), | ||
| updatedAt: z.string().nullable() | ||
| }) | ||
|
|
||
| const marketSnapshotsResponseSchema = apiResponseSchema(z.array(marketSnapshotSchema)) | ||
|
|
||
| export type MarketSnapshot = z.infer<typeof marketSnapshotSchema> | ||
|
|
||
| export async function fetchMarketSnapshots(): Promise<MarketSnapshot[]> { | ||
| const response = await baseApi.get('/admin/market/snapshots') | ||
| const parsed = marketSnapshotsResponseSchema.parse(response.data) | ||
| return parsed.data | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| import { useMutation, useQueryClient } from '@tanstack/react-query' | ||
|
|
||
| import { collectMarketData } from '../api/collect-market-data' | ||
|
|
||
| export function useCollectMarket() { | ||
| const queryClient = useQueryClient() | ||
|
|
||
| return useMutation({ | ||
| mutationFn: collectMarketData, | ||
| onSuccess: () => { | ||
| queryClient.invalidateQueries({ queryKey: ['market', 'snapshots'] }) | ||
| } | ||
| }) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import { useQuery } from '@tanstack/react-query' | ||
|
|
||
| import { fetchMarketSnapshots } from '../api/fetch-market-snapshots' | ||
|
|
||
| export function useMarketSnapshotsQuery() { | ||
| return useQuery({ | ||
| queryKey: ['market', 'snapshots'], | ||
| queryFn: fetchMarketSnapshots | ||
| }) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import { useCollectMarket } from '../model/use-collect-market' | ||
|
|
||
| function CollectMarketButton() { | ||
| const { mutate, isPending, isSuccess, isError, error } = useCollectMarket() | ||
|
|
||
| return ( | ||
| <div className="space-y-2"> | ||
| <button | ||
| type="button" | ||
| onClick={() => mutate()} | ||
| disabled={isPending} | ||
| className="rounded-lg bg-wefin-mint px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-wefin-mint/90 disabled:opacity-50" | ||
| > | ||
| {isPending ? '수집 중...' : '시장 지표 수집'} | ||
| </button> | ||
| {isSuccess && <p className="text-sm text-green-600">수집 완료</p>} | ||
| {isError && <p className="text-sm text-red-600">수집 실패: {error.message}</p>} | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| export default CollectMarketButton |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| import { useMarketSnapshotsQuery } from '../model/use-market-snapshots-query' | ||
|
|
||
| function MarketSnapshotTable() { | ||
| const { data, isLoading, isError } = useMarketSnapshotsQuery() | ||
|
|
||
| if (isLoading) return <p className="text-sm text-wefin-subtle">로딩 중...</p> | ||
| if (isError) return <p className="text-sm text-red-600">조회 실패</p> | ||
| if (!data || data.length === 0) return <p className="text-sm text-wefin-subtle">데이터 없음</p> | ||
|
|
||
| return ( | ||
| <table className="w-full text-sm"> | ||
| <thead> | ||
| <tr className="border-b border-wefin-line text-left text-wefin-subtle"> | ||
| <th className="py-2">지표</th> | ||
| <th className="py-2 text-right">현재 값</th> | ||
| <th className="py-2 text-right">변동률</th> | ||
| <th className="py-2 text-right">변동 값</th> | ||
| <th className="py-2 text-center">방향</th> | ||
| </tr> | ||
| </thead> | ||
| <tbody> | ||
| {data.map((snapshot) => ( | ||
| <tr key={snapshot.metricType} className="border-b border-wefin-line/50"> | ||
| <td className="py-3 font-medium text-wefin-text">{snapshot.label}</td> | ||
| <td className="py-3 text-right">{snapshot.value.toLocaleString()}</td> | ||
| <td className="py-3 text-right"> | ||
| {snapshot.changeRate != null ? `${snapshot.changeRate}%` : '-'} | ||
| </td> | ||
| <td className="py-3 text-right"> | ||
| {snapshot.changeValue != null ? snapshot.changeValue.toLocaleString() : '-'} | ||
| </td> | ||
| <td className="py-3 text-center"> | ||
| <span | ||
| className={ | ||
| snapshot.changeDirection === 'UP' | ||
| ? 'text-red-600' | ||
| : snapshot.changeDirection === 'DOWN' | ||
| ? 'text-blue-600' | ||
| : 'text-wefin-subtle' | ||
| } | ||
| > | ||
| {snapshot.changeDirection === 'UP' | ||
| ? '▲' | ||
| : snapshot.changeDirection === 'DOWN' | ||
| ? '▼' | ||
| : '-'} | ||
| </span> | ||
| </td> | ||
| </tr> | ||
| ))} | ||
| </tbody> | ||
| </table> | ||
| ) | ||
| } | ||
|
|
||
| export default MarketSnapshotTable |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import { z } from 'zod' | ||
|
|
||
| import { apiResponseSchema } from '@/shared/api/api-response' | ||
| import { baseApi } from '@/shared/api/base-api' | ||
|
|
||
| const collectNewsResponseSchema = apiResponseSchema(z.string()) | ||
|
|
||
| export async function collectNews() { | ||
| const response = await baseApi.post('/admin/news/collect') | ||
| return collectNewsResponseSchema.parse(response.data) | ||
| } | ||
|
|
||
| export async function crawlNews() { | ||
| const response = await baseApi.post('/admin/news/crawl') | ||
| return collectNewsResponseSchema.parse(response.data) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| import { useMutation } from '@tanstack/react-query' | ||
|
|
||
| import { collectNews, crawlNews } from '../api/collect-news' | ||
|
|
||
| export function useCollectNews() { | ||
| return useMutation({ mutationFn: collectNews }) | ||
| } | ||
|
|
||
| export function useCrawlNews() { | ||
| return useMutation({ mutationFn: crawlNews }) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| import { useCollectNews, useCrawlNews } from '../model/use-collect-news' | ||
|
|
||
| function NewsCollectButtons() { | ||
| const collect = useCollectNews() | ||
| const crawl = useCrawlNews() | ||
|
|
||
| return ( | ||
| <div className="space-y-2"> | ||
| <div className="flex gap-2"> | ||
| <button | ||
| type="button" | ||
| onClick={() => collect.mutate()} | ||
| disabled={collect.isPending} | ||
| className="rounded-lg bg-wefin-mint px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-wefin-mint/90 disabled:opacity-50" | ||
| > | ||
| {collect.isPending ? '수집 중...' : '뉴스 수집'} | ||
| </button> | ||
| <button | ||
| type="button" | ||
| onClick={() => crawl.mutate()} | ||
| disabled={crawl.isPending} | ||
| className="rounded-lg bg-wefin-mint px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-wefin-mint/90 disabled:opacity-50" | ||
| > | ||
| {crawl.isPending ? '크롤링 중...' : '뉴스 크롤링'} | ||
| </button> | ||
| </div> | ||
| {collect.isSuccess && <p className="text-sm text-green-600">뉴스 수집 완료</p>} | ||
| {collect.isError && ( | ||
| <p className="text-sm text-red-600">뉴스 수집 실패: {collect.error.message}</p> | ||
| )} | ||
| {crawl.isSuccess && <p className="text-sm text-green-600">뉴스 크롤링 완료</p>} | ||
| {crawl.isError && ( | ||
| <p className="text-sm text-red-600">뉴스 크롤링 실패: {crawl.error.message}</p> | ||
| )} | ||
|
Comment on lines
+27
to
+34
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. 비동기 결과 메시지 영역에 현재 성공/실패 텍스트가 시각적으로만 갱신되어 보조기기 사용자에게는 변경이 즉시 전달되지 않을 수 있습니다. 수정 예시- {collect.isSuccess && <p className="text-sm text-green-600">뉴스 수집 완료</p>}
- {collect.isError && (
- <p className="text-sm text-red-600">뉴스 수집 실패: {collect.error.message}</p>
- )}
- {crawl.isSuccess && <p className="text-sm text-green-600">뉴스 크롤링 완료</p>}
- {crawl.isError && (
- <p className="text-sm text-red-600">뉴스 크롤링 실패: {crawl.error.message}</p>
- )}
+ <div aria-live="polite" role="status">
+ {collect.isSuccess && <p className="text-sm text-green-600">뉴스 수집 완료</p>}
+ {collect.isError && (
+ <p className="text-sm text-red-600">뉴스 수집 실패: {collect.error.message}</p>
+ )}
+ {crawl.isSuccess && <p className="text-sm text-green-600">뉴스 크롤링 완료</p>}
+ {crawl.isError && (
+ <p className="text-sm text-red-600">뉴스 크롤링 실패: {crawl.error.message}</p>
+ )}
+ </div>🤖 Prompt for AI Agents |
||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| export default NewsCollectButtons | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| import CollectMarketButton from '@/features/admin-market/ui/collect-market-button' | ||
| import MarketSnapshotTable from '@/features/admin-market/ui/market-snapshot-table' | ||
| import NewsCollectButtons from '@/features/admin-news/ui/news-collect-buttons' | ||
|
|
||
| function AdminPage() { | ||
| return ( | ||
| <div className="space-y-6"> | ||
| <h1 className="text-2xl font-bold text-wefin-text">관리자</h1> | ||
|
|
||
| <section className="rounded-xl border border-wefin-line bg-white p-6"> | ||
| <h2 className="mb-4 text-lg font-semibold text-wefin-text">뉴스</h2> | ||
| <NewsCollectButtons /> | ||
| </section> | ||
|
|
||
| <section className="rounded-xl border border-wefin-line bg-white p-6"> | ||
| <h2 className="mb-4 text-lg font-semibold text-wefin-text">시장 지표</h2> | ||
| <div className="space-y-4"> | ||
| <CollectMarketButton /> | ||
| <MarketSnapshotTable /> | ||
| </div> | ||
| </section> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| export default AdminPage |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import { z, type ZodType } from 'zod' | ||
|
|
||
| export function apiResponseSchema<T extends ZodType>(dataSchema: T) { | ||
| return z.object({ | ||
| status: z.number(), | ||
| code: z.string().nullable(), | ||
| message: z.string().nullable(), | ||
| data: dataSchema | ||
| }) | ||
| } | ||
|
|
||
| export type ApiResponse<T> = { | ||
| status: number | ||
| code: string | null | ||
| message: string | null | ||
| data: T | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,31 @@ | ||
| import axios from 'axios' | ||
|
|
||
| export const baseApi = axios.create({ | ||
| baseURL: '/', | ||
| baseURL: import.meta.env.VITE_API_BASE_URL || '/api', | ||
|
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. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# baseApi로 /mock 경로를 호출하는 지점 확인
rg -nP --type ts --type tsx "baseApi\.(get|post|put|patch|delete)\(\s*['\"]/mock/" -C2
# '/api' prefix 변경 영향 후보 확인 (필요 시 패턴 조정)
rg -nP --type ts --type tsx "baseApi\.(get|post|put|patch|delete)\(\s*['\"]/(?!admin|api)[^'\"]+" -C1Repository: S0L-V/wefin-fe Length of output: 113 🏁 Script executed: #!/bin/bash
# First, check the base-api.ts file to confirm the change
echo "=== Checking src/shared/api/base-api.ts ==="
cat -n src/shared/api/base-api.ts | head -20
echo ""
echo "=== Finding baseApi usages with non-API paths ==="
# Search for baseApi method calls without using unsupported file types
rg "baseApi\.(get|post|put|patch|delete)" --max-count=50 -A2
echo ""
echo "=== Specifically checking fetch-login-dialog-data.ts ==="
if [ -f "src/features/auth-dialog/api/fetch-login-dialog-data.ts" ]; then
cat -n src/features/auth-dialog/api/fetch-login-dialog-data.ts
fiRepository: S0L-V/wefin-fe Length of output: 2507 🏁 Script executed: #!/bin/bash
echo "=== Checking for public/mock directory ==="
find . -type d -name "mock" -o -type d -name "public" | head -20
echo ""
echo "=== Looking for mock JSON files ==="
find . -name "*.json" -path "*mock*" 2>/dev/null | head -20
echo ""
echo "=== Complete baseApi method calls (exhaustive search) ==="
rg "baseApi\.(get|post|put|patch|delete|request)" -A1 | wc -l
echo ""
echo "=== All baseApi calls with their paths ==="
rg "baseApi\.(get|post|put|patch|delete)" -o --only-matching | sort | uniq -cRepository: S0L-V/wefin-fe Length of output: 589 기본 Line 4 변경으로
해결 방향 예시 export const baseApi = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 5_000
})
+
+export const staticApi = axios.create({
+ baseURL: '/',
+ timeout: 5_000
+})// src/features/auth-dialog/api/fetch-login-dialog-data.ts
-import { baseApi } from '@/shared/api/base-api'
+import { staticApi } from '@/shared/api/base-api'
-const response = await baseApi.get('/mock/login-dialog.json')
+const response = await staticApi.get('/mock/login-dialog.json')🤖 Prompt for AI Agents |
||
| timeout: 5_000 | ||
| }) | ||
|
|
||
| export class ApiError extends Error { | ||
| readonly status: number | ||
| readonly code: string | ||
|
|
||
| constructor(status: number, code: string, message: string) { | ||
| super(message) | ||
| this.name = 'ApiError' | ||
| this.status = status | ||
| this.code = code | ||
| } | ||
| } | ||
|
|
||
| baseApi.interceptors.response.use( | ||
| (response) => response, | ||
| (error) => { | ||
| if (axios.isAxiosError(error) && error.response?.data) { | ||
| const { status, code, message } = error.response.data | ||
| if (code && message) { | ||
| return Promise.reject(new ApiError(status, code, message)) | ||
| } | ||
| } | ||
| return Promise.reject(error) | ||
| } | ||
| ) | ||
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.
🧩 Analysis chain
🏁 Script executed:
Repository: S0L-V/wefin-fe
Length of output: 85
🏁 Script executed:
Repository: S0L-V/wefin-fe
Length of output: 4353
🏁 Script executed:
Repository: S0L-V/wefin-fe
Length of output: 2735
🏁 Script executed:
Repository: S0L-V/wefin-fe
Length of output: 237
🏁 Script executed:
Repository: S0L-V/wefin-fe
Length of output: 2690
🏁 Script executed:
Repository: S0L-V/wefin-fe
Length of output: 860
관리자 페이지에 인증/인가 보호가 필요합니다.
/admin라우트가 인증 없이 누구나 접근할 수 있습니다. 시장 데이터 수집, 뉴스 크롤링 같은 관리자 기능이 보호되지 않아 보안 위험이 있습니다.ProtectedRoute컴포넌트나 인증 가드를 추가하여 로그인한 관리자만 접근할 수 있도록 구현해야 합니다.🤖 Prompt for AI Agents