Dev to Main - #168
Conversation
* refactor: 복수전공 기능 책임 분산 및 중복 코드 개선
- sortAreasByCompletion 순수 함수 추출 (courseAreaUtils)
- useAreaProgress 반환값을 { mainMajorAreas, dualMajorAreas }로 변경
- AreaProgressSection 내 중복 useMemo split 제거
- RequirementCard 공통 프레젠테이션 컴포넌트 추출
- DualMajorRequirementCard source of truth를 hasDualMajor로 통일
- profileUtils 활용으로 admissionYear/departmentName 계산 통일
- vitest 설정 및 23개 단위 테스트 추가
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: API 스키마 업데이트
- ScrapingResponse에 status 필드 추가
- StudentProfileResponse에 dualMajorName 필드 추가
- SemesterGradeResponse → SemesterSummaryResponse 타입명 변경
- checkcontroller 도메인 API 클라이언트 추가
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat: 복수전공 카드 대시보드 추가 및 UI 개선
- 메인 페이지에 DualMajorRequirementCard 추가
- StudentProfileSchema에 dualMajorName 필드 추가
- AreaProgressSection SCSS에 복수전공 섹션 헤더 스타일 추가
- GraduationRequirementCard height → min-height 수정
- ProfileCard 긴 이름 text-overflow ellipsis 처리
- graduation.ts 타입 alias 업데이트
- .claude/ gitignore 추가
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: ReadonlyArray → readonly T[] ESLint 오류 수정
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat: UI 개선 - 배경색, 보더, 패딩 수정 - 페이지 배경색 white로 통일 (app-layout mixin, graduation-progress) - GradeCard, SemesterGradeCard, CourseAreaTrigger, GraduationRequirementCard 보더 추가 (1px gray-300) - CourseAreaTrigger 완료 상태(dark)는 보더/그림자 제거 - ProfileCard 이미지 object-fit cover → contain - graduation-progress 스크롤 영역 하단 패딩 추가 (safe-area + 48px) - main 페이지 하단 패딩 추가 (safe-area + 36px) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: ProfileCard 이미지 컨테이너 배경 gray-300으로 변경 페이지 배경 white 전환 후 흰 컨테이너 박스가 배경에 묻히는 문제 수정 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: 졸업 상태 프로필 이미지를 4학년 이미지로 변경 DefaultProfile.png(408x408)가 다른 이미지(528x528)와 크기 불일치 졸업 상태에서 Profile_Enrolled_4.png 사용 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Revert "fix: ProfileCard 이미지 컨테이너 배경 gray-300으로 변경" This reverts commit 58a65d5. * feat: 프로필 캐릭터 이미지 교체 및 배경색 회색으로 복원 - 1~4학년 프로필 이미지 신규 캐릭터로 교체 - app-layout 및 graduation-progress 배경색 background-color($e8e9ef)로 복원 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * style: 프로필 이미지 교체 및 UI 배경색 개선 - 휴학 프로필 이미지 추가 (Profile_OnLeave.png) - 프로필 이미지 mix-blend-mode: multiply 적용으로 흰 배경 동화 - 프로필 이미지 컨테이너 배경색 gray-300 적용 - 졸업진행 페이지 및 앱 레이아웃 배경색 white로 통일 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…ink job system - Replace synchronous SuwonScraping API with async PortalLink job-based flow - Add portal-link feature module (hooks, services, error mapping) - Implement job polling (2s interval) with succeeded/failed status handling - Add credential-based idempotency key generation (SHA-256) - Add debug logging across portal login flow - Update API clients from latest OpenAPI spec - Clean up legacy auth service files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- .github/workflows/deploy-cloudflare.yml 추가: main push 시 production, PR 시 preview 자동 배포. opennext + wrangler-action 기반 - .gitignore: 내부 기술 분석 문서용 docs/ 디렉토리 추적 제외
layout.module.scss에서 full-height-layout → app-layout mixin으로 교체. app-layout은 mobile-container(max-width: 600px; margin: 0 auto)를 포함하므로 두 페이지가 동일한 컨테이너 폭과 중앙정렬을 갖게 됨.
- push 트리거에 dev 브랜치 추가 (dev push 시 dev.cchaksa.pages.dev 갱신) - NEXT_PUBLIC_DEPLOY_ENV 3단 분기: main=production, dev=staging, 그 외=preview - CF 프로젝트명을 cchaksa로 교체 (chukchuk-haksa → cchaksa)
# Conflicts: # src/app/(main)/graduation-progress/layout.module.scss # src/shared/api/data-contracts.ts # swagger/openapi-dev.json
- Regenerate API clients via yarn api:update to align with backend improvements
(tag grouping, server description, parameter signatures)
- Fix portalLinkService: getJobStatus/getJobSummary now take jobId directly
instead of { jobId } object (backend changed parameter extraction style)
…ssets @opennextjs/cloudflare v1 targets Workers (not Pages). Align the config and deploy workflow: - open-next.config.ts: use defineCloudflareConfig() helper so required fields (proxyExternalRequest, incrementalCache, etc.) are populated - wrangler.jsonc: Workers Static Assets layout (main: .open-next/worker.js, assets.directory); env.production → worker name "cchaksa", env.staging → "cchaksa-staging" - workflow: wrangler pages deploy → wrangler deploy --env, removed redundant `yarn build` step (build:worker runs it internally) and removed PR trigger (no native Pages-style preview on Workers)
- graduation-progress/layout.module.scss: drop background-color duplicated by utilities.app-layout mixin; remove now-unused color import - errorMapping: drop raw backend error_message fallback; unknown codes now return DEFAULT_ERROR_MESSAGE so internal texts never surface to users - idempotency.ts: move out of src/shared/api/ (api:gen boundary) to src/shared/utils/; update imports in PortalLoginForm and resync/login - scraping/page.tsx: stop sending raw studentCode (학번) to Sentry.setUser; use server-issued jobId as opaque user id for issue grouping
Why: default branch(dev)에 Workers 워크플로우가 아직 없어 "Run workflow" 버튼이 노출되지 않음. 임시로 현재 브랜치를 push trigger에 포함해 첫 staging 배포를 수동/자동 양쪽으로 확보. PR 머지 전 해당 라인은 제거 예정. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
/api/session GET/DELETE (세션 조회/파괴)와 /api/session/refresh POST (세션의 refreshToken으로 backend /api/auth/refresh 호출 후 rotation된 토큰을 세션에 재실링) 추가. 쿠키는 cchaksa_session (HttpOnly, Secure, SameSite=Lax). 기존 session.ts 스켈레톤은 사용처가 없어 리팩터링. 이 커밋 단독으로는 클라이언트 동작 변화 없음 (엔드포인트만 추가). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…cookie /auth/callback에서 accessToken/isPortalLinked를 URL 쿼리 파라미터로 내려보내던 기존 방식을 제거. 대신 iron-session을 통해 accessToken, refreshToken, isPortalLinked를 암호화된 HttpOnly 세션 쿠키에 저장하고 /auth/success로는 redirect 경로만 전달. /auth/success 페이지는 URL 파싱 대신 /api/session을 호출해 인증 상태를 확인하고 isPortalLinked 만 기존 sessionStorage 헬퍼에 동기화 (sessionStorage 의존성은 후속 단계에서 Context로 이관). 완료된 state/nonce 쿠키는 콜백에서 즉시 제거. Why: URL 쿼리 파라미터를 통한 토큰 전달은 RFC 6749, OWASP 기준 금지 패턴. 브라우저 히스토리/Referer/서버 로그에 평문 노출. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
tokenStore: 모듈 레벨 토큰 저장소 + 단일 in-flight refresh promise로 동시 refresh race condition 방지. 구독자 알림을 통해 Context와 sync. AuthContext: 마운트 시 /api/session을 호출해 메모리 상태를 hydrate, subscribeAccessTokenStore로 store 변경사항 반영. clearAuth는 /api/session DELETE 후 메모리 정리. refresh는 refreshAccessTokenStore 위임 (단계 4의 401 인터셉터에서도 동일 함수를 재사용하기 위함). app/layout.tsx: AuthProvider로 QueryProvider 감싸기. 이 커밋 단독으로는 기존 sessionStorage 기반 흐름과 병행 동작 (httpConfig 교체는 단계 4). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
httpConfig.ts의 securityWorker가 이제 tokenStore를 통해 인메모리 accessToken을 읽음. customFetch 래퍼가 백엔드 응답 401을 감지하면 /api/session/refresh 를 한 번 호출해 새 accessToken을 받고 원 요청을 재시도. Authorization 헤더가 없던 요청은 재시도 대상 아님 (무한 루프 방지). refresh race는 tokenStore의 단일 in-flight promise로 이미 해결됨. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Drop the legacy token storage layer now that iron-session + AuthContext own auth state. Delete lib/auth/token.ts (sessionStorage access token, non-HttpOnly document.cookie refresh token, sessionStorage portal flag) and the associated constants. Simplify authService to just the login call and strip dead methods. auth/success no longer touches client-side storage; AuthContext hydrates isPortalLinked from /api/session. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Client components (KakaoProvider, login pages) import from '@/lib/auth' for kakaoLogin/initializeKakao. Re-exporting session pulled next/headers into the client bundle and broke the build. Server routes already import '@/lib/auth/session' directly, so removing the re-export has no impact. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Next.js build on Vercel flags router as a missing queryKey dep, but router is only used for a redirect side effect and putting it in the queryKey would churn the cache on every render. Silence the rule here and leave a TODO to lift the redirect out of queryFn in a follow-up. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…efix Add resolve-env job that computes the deploy environment once, then use PROD_* or STAGING_* secrets via ternary so staging/production builds no longer share the same secret values. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
TEST_TOKEN and TEST_TOKEN2 were declared in env.ts and environment.ts but read nowhere in the codebase. Drop the declarations and their workflow injections. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Refactor/async api
The button shows "마지막 학사 연동 일시" and triggers re-sync, so the displayed timestamp should be lastSyncedAt (학사 정보 마지막 연동 일시), not lastUpdatedAt (마지막 업데이트 일시). The local variable was already named formattedLastSyncedAt, indicating the original intent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the backend stalls without ever returning succeeded/failed, users were stuck on the LoadingScreen forever. Add a 3-minute timeout (configurable via NEXT_PUBLIC_PORTAL_LINK_TIMEOUT_MS) that flips the polling hook into a timed-out state, stops further requests, and surfaces TIMEOUT_ERROR_MESSAGE through the existing error boundary so the user sees the "다시 시도하기" recovery flow. The timer lives inside the polling hook as a startedAtRef checked on each refetchInterval tick, avoiding a separate setTimeout that StrictMode would double-fire. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /mpa WebView screen needs the same dashboard cards but routes clicks through a JS bridge instead of the Next.js router. Add an optional onNavigate prop to SyncUpdateButton, GraduationRequirementCard, and DualMajorRequirementCard. When omitted (the existing /main flow), the cards keep their internal router.push behavior unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds src/lib/webview/bridge.ts with isInWebView/postBridgeMessage/ navigateNative helpers. The protocol is a single string of the form "navigate:<url>" delivered via whichever native bridge is present (React Native WebView, iOS WKWebView messageHandler "bridge", or Android JavaScript interface). When no bridge is detected (e.g. the page is opened in a regular browser), messages are dropped with a dev-only console warning so /mpa pages remain inspectable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the WebView-only routes that the native shell embeds without its own top navigation. /mpa/home reuses the dashboard cards from /main but routes every click through the JS bridge instead of Next.js navigation, and /mpa/me clones the existing /setting menu (탈퇴하기) so the native banner's profile icon has a target URL. Routes the bridge will deliver to the native side: - navigate:/mpa/me (banner profile icon) - navigate:/mpa/graduation-progress (졸업요건 cards) - navigate:/mpa/resync-login (업데이트 버튼) - navigate:/mpa/delete (/mpa/me 탈퇴하기) Of these, only /mpa/home and /mpa/me ship as Next.js pages in this PR; the rest are URLs the native team will route or that follow-up PRs will back with /mpa pages. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add explicit braces to early-return guards in webview/bridge.ts
(curly rule), and narrow usePortalLinkJobPolling's return to
{ data, isTimedOut } so React Query's rest-destructuring lint
warning no longer fires. Both consumers already only read .data.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WebView 가 살아있는 채 복귀할 때 React Query 의 5분 staleTime 으로 옛 lastSyncedAt 이 그대로 노출되던 문제. visibilitychange 가 visible 로 전환될 때 profile 키만 invalidate. 적용: /mpa/home, /main. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ridge robustness) - environment: PORTAL_LINK_TIMEOUT_MS 를 양의 유한수일 때만 채택, 비정상 값(-1, NaN, 0.5 등)은 기본 3분으로 폴백. - SyncUpdateButton: lastSyncedAt 이 비정상 ISO 문자열일 때 RangeError 로 컴포넌트 크래시되던 경로를 isValid + parseISO 로 방어. - useRefreshProfileOnVisible: 상대 경로 import 를 @/ 별칭으로 통일 (CLAUDE.md 컨벤션). - errorMapping: TIMEOUT_ERROR_MESSAGE 의 "3분" 문구를 ENV.PORTAL_LINK_TIMEOUT_MS 에서 파생. - bridge: isInWebView 가 postMessage 의 실제 callable 여부까지 검증, postBridgeMessage 호출을 try/catch 로 감싸고 Sentry.captureException 으로 추적. usePortalLinkJobPolling 의 wall-clock setTimeout 분리와 에러 노출 제안은 본 PR 스코프 밖이라 별도 이슈로 다룬다. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
feat(mpa): add /mpa/home, /mpa/me routes with WebView bridge
- POST /api/session 추가: 네이티브 앱이 들고 있는 ac/re 토큰을 cchaksa_session
쿠키로 익스체인지. 토큰 진위 검증은 백엔드 엔드포인트 도입 후 추가 (TODO).
- /mpa/resync/login, /mpa/resync/scraping 신규 페이지: 기존 /resync/* 흐름을
(mpa) 그룹으로 복제. (mpa) layout 의 ProtectedRoute(requirePortalLinked=false)
를 활용해 학교 미연동 사용자도 진입 가능.
- scraping 완료 시 postBridgeMessage('done:portal-link') 송출. 네이티브가
webview 닫고 dashboard 갱신.
- ROUTES.MPA.RESYNC_SCRAPING 추가, 주석에 신규 페이지 반영.
- docs/mpa-school-link-handoff.md: 백엔드(토큰 검증), 모바일(WKAppBoundDomains,
bridge 명세, 호출 시퀀스, 로그아웃 동기화) 작업 명세.
- .gitignore: 핸드오프 문서가 추적되도록 docs/* 패턴 + negation 으로 변경.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- POST /api/session 시크릿 헤더 게이트 추가
(X-Native-Bridge-Secret + NATIVE_SESSION_EXCHANGE_SECRET 환경변수).
미설정 시 503, 헤더 불일치 시 403 으로 외부 호출자 차단. 토큰 자체의
진위 검증은 여전히 백엔드 엔드포인트 도입 후 추가 (TODO 유지).
- /mpa/resync/scraping:
- throw new Error() 제거하고 ErrorScreen + 다시 시도 버튼 인라인 렌더로 전환
- succeeded 시 isInWebView() 분기. webview 면 postBridgeMessage,
아니면 /main 으로 fallback (브릿지 미감지로 영구 로딩 방지).
- 두 mpa 페이지의 deep-relative import 를 @/ alias 로 통일.
- handoff 문서: 시크릿 게이트, 헤더 명세, 응답 코드, 보안 표, 인프라
체크리스트 갱신.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
feat(mpa): MPA webview integration — home/me + school-link flow
PR #166 의 e6bec97 에서 추가한 NATIVE_SESSION_EXCHANGE_SECRET 환경변수 + x-native-bridge-secret 헤더 게이트 제거. 위조 토큰 차단은 백엔드 토큰 진위 검증(B1) 단독 책임으로 통합. 근거: - 시크릿이 모바일 바이너리에 박혀 디컴파일·정적 분석으로 곧바로 추출 가능 → 보안 효용 제한적 - 위조 방어는 어차피 B1 의 역할이며 게이트가 있어도 B1 도입 시 중복 - 모바일·인프라 양쪽에 시크릿 공유·회전 절차 부담을 강제하기엔 1차 출시 시급도 대비 비용 큼 변경: - src/app/api/session/route.ts: ENV import, NATIVE_BRIDGE_SECRET_HEADER 상수, 503/403 분기 제거. 주석 축약 - src/config/environment.ts: NATIVE_SESSION_EXCHANGE_SECRET 슬롯 제거 - docs/mpa-school-link-handoff.md: 호출 시퀀스 ① 헤더 라인 제거, '운영 결정 (2026-05-05): 시크릿 게이트 폐기' 섹션 추가, 프론트 작업 표·M4·보안 고려사항 표·검증 체크리스트 갱신 재도입 트리거가 발생하면 git history 에서 복원하거나 B1 도입 시까지 보류. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- POST /api/session: isPortalLinked 를 요청 바디 신뢰에서 분리, 항상 false 강제. B1 도입 전까지 위조 호출이 portal-link 통과 상태로 세션 승격하는 경로를 차단. 실제 연동 상태는 재로그인(auth callback) 또는 백엔드 me/profile 응답으로만 갱신. - 핸드오프 문서 보안 설명 정정: SameSite=Lax + HttpOnly 가 /api/session POST 의 CSRF 1차 방어선이라는 표현은 부정확. 이 엔드포인트는 쿠키를 *발급* 하지 *소비* 하지 않으므로 SameSite/HttpOnly 는 후속 보호. 실제 발급 시점 방어는 JSON 본문 요구 + same-origin/CORS preflight + 향후 서버측 Origin 검사. - M4 호출 예시에서 isPortalLinked 바디 필드 제거, 응답은 항상 false 명시. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…gate refactor(mpa): drop native bridge secret gate from POST /api/session
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Caution Review failedPull request was closed or merged during review 워크스루이 PR은 모바일 네이티브 앱과 웹뷰 간 세션 교환을 통해 포탈링크 기반 인증을 구현하고, 클라이언트 저장소에서 서버 기반 Iron Session으로 마이그레이션하며, Cloudflare Workers 배포를 추가합니다. 기존 사용자 인증 흐름에서 직접 API 호출 방식에서 비동기 작업 폴링 기반 흐름으로 전환되며, 새로운 변경 사항MPA WebView 포탈링크 통합 및 인증
Sequence DiagramsequenceDiagram
participant NativeApp
participant BrowserWebView
participant BFF as Backend
participant PortalAPI as PortalJobService
NativeApp->>BrowserWebView: open /mpa/resync/login (with session exchange)
BrowserWebView->>BFF: POST /api/session {accessToken, refreshToken}
BFF-->>BrowserWebView: 200 {ok}
BrowserWebView->>BFF: POST /portal/link (idempotency key)
BFF->>PortalAPI: createPortalLinkJob
PortalAPI-->>BFF: {job_id}
BrowserWebView->>BFF: poll /portal/link/jobs/{job_id}
BFF->>PortalAPI: getJobStatus / getJobSummary
PortalAPI-->>BFF: {status / studentInfo}
BFF-->>BrowserWebView: final redirect or bridge message (done:portal-link)
예상 코드 리뷰 노력🎯 4 (Complex) | ⏱️ ~60 minutes 관련된 가능성 있는 PR
시
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
|
There was a problem hiding this comment.
Actionable comments posted: 25
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/shared/api/http-client.ts (1)
1-11: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win생성된 API 파일의 수동 수정 방지를 위한 CI/CD 검증 추가를 고려하세요.
현재 프로젝트는
yarn api:update스크립트로 자동 생성된 API 클라이언트를 관리하고 있습니다. 다만 CI/CD 파이프라인에서 생성된 파일의 무결성을 검증하는 단계가 없으므로, GitHub Actions에 다음과 같은 검증 단계를 추가하면 실수로 인한 수동 수정을 방지할 수 있습니다:
- 풀 리퀘스트 시
src/shared/api/파일 변경 감지 후yarn api:update실행- 생성된 파일과 실제 커밋된 파일 비교하여 일치 여부 확인
- 불일치 시 자동 수정 또는 PR 빌드 실패 처리
현재 autofix.ci 워크플로우에 이 검증 로직을 추가하는 것이 가장 효과적입니다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/shared/api/http-client.ts` around lines 1 - 11, Add a CI validation step to ensure generated API files under src/shared/api are not manually edited: in the autofix.ci workflow add a job that runs yarn api:update, then diffs the regenerated output against the committed files in src/shared/api (fail the job or auto-commit fixes when differences are found); reference the existing script name yarn api:update and the src/shared/api path so the workflow explicitly detects PRs that modify those files and either fails the build or applies the regenerated changes.src/app/(funnel)/contexts/FunnelContext.tsx (1)
27-41: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win중복된 훅을 정리하세요.
useStudentInfo()와useFunnelContext()가 동일한 컨텍스트를 반환하고 있습니다. 두 훅이 공존하면 어느 것을 사용해야 할지 혼란을 줄 수 있습니다.
useStudentInfo()는 이제studentInfo만 제공하는 것이 아니라jobId도 포함하므로 이름이 부정확합니다. 코드베이스 전체에서useFunnelContext()로 마이그레이션하고useStudentInfo()를 제거하거나, 유지해야 한다면 deprecated 처리를 권장합니다.♻️ 제안: 중복 훅 제거
-export function useStudentInfo() { - const context = useContext(FunnelContext); - if (!context) { - throw new Error('useStudentInfo must be used within a FunnelProvider'); - } - return context; -} - export function useFunnelContext() { const context = useContext(FunnelContext); if (!context) { throw new Error('useFunnelContext must be used within a FunnelProvider'); } return context; }그 후
index.ts에서도 제거:-export { FunnelProvider, useStudentInfo, useFunnelContext } from './FunnelContext'; +export { FunnelProvider, useFunnelContext } from './FunnelContext';🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app/`(funnel)/contexts/FunnelContext.tsx around lines 27 - 41, The two hooks useStudentInfo and useFunnelContext both return the same FunnelContext and are redundant; remove useStudentInfo (or mark it deprecated) and update exports/imports to use only useFunnelContext. Locate useStudentInfo, useFunnelContext and the FunnelContext provider in FunnelContext.tsx, delete the useStudentInfo function (or replace it with a thin deprecated wrapper that calls useFunnelContext and logs a deprecation warning), and update index.ts exports to stop exporting useStudentInfo and ensure all internal imports are migrated to useFunnelContext.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.github/workflows/deploy-cloudflare.yml:
- Around line 35-53: The current secret mapping repeatedly uses ternary-style
expressions with needs.resolve-env.outputs.name; to simplify switch the job to
use GitHub Environments and reference environment-scoped secrets directly by
setting the job's environment to needs.resolve-env.outputs.name (keep
NEXT_PUBLIC_DEPLOY_ENV as is), then replace expressions like
NEXT_PUBLIC_API_BASE_URL: ${{ needs.resolve-env.outputs.name == 'production' &&
secrets.PROD_NEXT_PUBLIC_API_BASE_URL ||
secrets.STAGING_NEXT_PUBLIC_API_BASE_URL }} with a single secret reference such
as NEXT_PUBLIC_API_BASE_URL: ${{ secrets.NEXT_PUBLIC_API_BASE_URL }} (do this
for each variable: NEXT_PUBLIC_BASE_URL, NEXT_PUBLIC_SUPABASE_URL,
NEXT_PUBLIC_SUPABASE_ANON_KEY, NEXT_PUBLIC_JAVASCRIPT_KEY,
NEXT_PUBLIC_SENTRY_DSN, REST_API_KEY, CLIENT_SECRET, SUPABASE_PROJECT_ID,
SESSION_SECRET, SENTRY_AUTH_TOKEN, UPSTASH_REDIS_REST_URL,
UPSTASH_REDIS_REST_TOKEN, LAMBDA_DEV_URL, AWS_URL) after creating matching
secrets in the production and staging Environments.
In `@docs/mpa-school-link-handoff.md`:
- Around line 14-31: The fenced code blocks in the document are missing language
tags and missing blank lines before/after fences and headings, causing
markdown-lint warnings; update each triple-backtick block (the examples showing
the MPA flow) to include an appropriate language tag (e.g., ```text or ```bash)
and ensure there is a blank line above and below each fenced block and any
headings adjacent to them (apply the same fixes to the other occurrences you
noted around the blocks at ~86-92, 129-137, 157-171); run the markdown linter to
confirm warnings are resolved.
- Around line 17-19: Unify the POST /api/session contract so the request
example, parameter description, and response/security notes agree: decide
whether the server accepts the isPortalLinked boolean or ignores it and always
sets it to false, then update the request body example (accessToken,
refreshToken, isPortalLinked), the textual description, and the
response/security sentence about forced false to match that decision; ensure
references to POST /api/session, the isPortalLinked field, and the BFF behavior
(sealData → Set-Cookie: cchaksa_session) are consistent across the doc so mobile
implementers get one clear contract.
In `@next.config.mjs`:
- Around line 13-15: The images.unoptimized = true setting disables Next.js
image optimization; either configure Cloudflare Images integration or a CDN
image service: add the Cloudflare IMAGES binding in your Workers config, set
next.config.mjs images to use a custom loader (e.g., replace unoptimized:true
with loader:'custom' and appropriate loader configuration), and enable image
optimization options in open-next.config.ts (or alternatively wire a different
CDN/image service and update next.config.mjs accordingly). Ensure references to
IMAGES binding, next.config.mjs images, loader:'custom', and open-next.config.ts
are updated so the `@opennextjs/cloudflare` integration (or chosen CDN) performs
image optimization.
In `@package.json`:
- Line 60: The dependency version for `@opennextjs/cloudflare` is too loose
("^1"); update package.json to a stricter pin to prevent unexpected upgrades by
replacing "^1" with either a tilde-pinned version "~1.19.8" or an exact version
"1.19.8" (use the exact string "1.19.8" if you want full immutability), then run
your package manager install to update lockfiles; locate the dependency entry
for "@opennextjs/cloudflare" in package.json to make this change.
- Line 83: The package.json dependency for "wrangler" uses a permissive semver
range ("wrangler": "^4") that allows vulnerable 4.x releases; update the
constraint to require a minimum fixed version by changing the version specifier
for "wrangler" to ">=4.59.1" or a caret range that starts at the patched release
(for example "wrangler": "^4.59.1") so installs will not pick vulnerable
4.0–4.59.0 releases; update package.json accordingly and run your lockfile
update (npm/yarn/pnpm install) to regenerate the lockfile.
In
`@src/app/`(funnel)/portal-login/components/PortalLoginForm/PortalLoginForm.tsx:
- Around line 30-44: The code currently shows an error UI but does not notify
the parent via onError when mutateAsync resolves successfully but returns no
job_id; update the success branch after calling linkMutation.mutateAsync (where
newJobId is checked) to call onError with an Error (e.g., new Error('no job_id
in response')) in the else path after setErrorMessage, so the parent receives
the failure (keep existing setJobId/onSuccess path unchanged), and change the
catch parameter from err: any to err: unknown and derive the message with err
instanceof Error ? err.message : String(err) before calling setErrorMessage and
onError to improve type safety; reference functions: linkMutation.mutateAsync,
setJobId, onSuccess, setErrorMessage, onError.
In `@src/app/`(funnel)/scraping/page.tsx:
- Around line 23-31: The effect currently triggers on any new summaryData
reference and can call setStudentInfo and router.push multiple times; add a
guard (e.g., a useRef like processedStudentRef) to record the last-handled
student identifier or a serialized studentInfo and only run the body when
summaryData.data.studentInfo exists and differs from
processedStudentRef.current; inside the guarded block call setStudentInfo,
optionally setUser({ id: jobId }) if jobId, call
router.push(ROUTES.FUNNEL.AGREEMENT), and update processedStudentRef.current to
the current student key so subsequent background refetches with identical data
won’t re-trigger actions (keep dependencies unchanged except the ref).
In `@src/app/api/session/refresh/route.ts`:
- Around line 16-20: The fetch call to `${getApiBaseUrl()}/api/auth/refresh` in
the route handler has no timeout and can hang indefinitely; wrap the request in
an AbortController with a timer (e.g., setTimeout to call controller.abort()
after a reasonable ms value), pass controller.signal into the fetch options, and
clear the timer on success or error; update the fetch invocation site (the POST
calling fetch with JSON.stringify({ refreshToken: session.refreshToken })) to
use this AbortController pattern and handle the abort error path cleanly.
In `@src/app/auth/callback/route.ts`:
- Around line 30-32: The current guard allows null for isPortalLinked; update
the validation in route.ts so isPortalLinked is explicitly validated as a
boolean before proceeding: in the block that throws AuthError for
missing/malformed user, check that typeof isPortalLinked === 'boolean' (or use
isPortalLinked != null && typeof isPortalLinked === 'boolean') and that
refreshToken exists, and throw AuthError('User is missing or malformed.') when
the boolean check fails; reference the isPortalLinked variable and the existing
AuthError throw to locate and replace the condition.
In `@src/app/auth/success/page.tsx`:
- Around line 14-28: The component is using inline styles/Tailwind instead of
SCSS modules; create a corresponding SCSS module (e.g., success.module.scss),
move all styling from inline styles or Tailwind classNames into named CSS
classes in that module, import it into src/app/auth/success/page.tsx (e.g.,
import styles from './success.module.scss') and replace the inline
style/className usages with className={styles.someClass}; keep the existing
hydrate function and router.replace logic unchanged (refer to hydrate and
router.replace in the file to locate where to swap className usages).
- Around line 14-28: The hydrate function currently performs
fetch('/api/session', { credentials: 'include' }) and immediately redirects via
router.replace without any user feedback; add short-lived UI state (e.g.,
isLoading and errorMessage via useState) and set isLoading = true before calling
hydrate (or at start of hydrate), set isLoading = false after the fetch
completes, and set errorMessage on catch or non-ok response; update the page
component to render a loading spinner when isLoading is true and an inline error
message with a retry option when errorMessage is set instead of silently
redirecting; keep using router.replace(redirect) on success but only after
toggling state so the spinner is visible during network delays and errors are
shown when fetch('/api/session') fails.
In `@src/app/resync/scraping/page.tsx`:
- Around line 14-19: The component currently reads sessionStorage during initial
render (the useState initializer that sets jobId from RESYNC_JOB_ID_KEY),
causing an SSR/client hydration mismatch: on the server jobId is null and
triggers the throw new Error(...) path that renders error.tsx, while the client
may render LoadingScreen when sessionStorage has a value. Fix by removing
synchronous sessionStorage access from the state initializer (keep jobId
initialised to null via useState(null)) and add a useEffect that runs on mount
to read sessionStorage.getItem(RESYNC_JOB_ID_KEY) and call setJobId(...) so
server and client initial HTML match; alternatively, if you prefer to skip
server rendering, wrap the page component with dynamic(..., { ssr: false }).
Ensure references to jobId, RESYNC_JOB_ID_KEY, LoadingScreen and the throw new
Error(...) logic are updated so the error is only thrown after jobId is settled
by the effect.
In `@src/app/terms/page.tsx`:
- Around line 3-121: The TermsPage component currently hardcodes the entire
privacy/terms content in JSX (inside TermsPage), which makes updates and
localization cumbersome; extract the static content into a managed data source
(e.g., a markdown/MDX file or CMS entry) and load it in TermsPage instead (use
getStaticProps/getServerSideProps or a client fetch depending on update
frequency), render the loaded markdown/HTML inside the existing container (refer
to TermsPage and the surrounding classNames like styles.container and
styles.content) and add a simple version/locale metadata field so future changes
and i18n can be tracked without redeploying.
- Line 1: The import in page.tsx currently pulls
'../privacy-policy/PrivacyPolicy.module.scss' via a relative path; either make
the intention explicit by moving the shared stylesheet into a canonical shared
styles location and update the import to use the path alias (e.g., import styles
from '@/styles/PrivacyPolicy.module.scss') for consistency with the guideline,
or create a page-specific module (e.g., TermsPage.module.scss) and update the
import in page.tsx to './TermsPage.module.scss'; update any references to the
imported symbol (styles) accordingly and ensure the project path-alias config
supports the chosen '@/...' import.
In `@src/features/dashboard/components/SyncUpdateButton/SyncUpdateButton.tsx`:
- Around line 20-22: The current fallback returns an empty string for
formattedLastSyncedAt (computed from parsedLastSyncedAt/data.lastSyncedAt) which
makes the UI show ambiguous "업데이트" only; update the logic in SyncUpdateButton so
that when data.lastSyncedAt is missing or invalid formattedLastSyncedAt becomes
an explicit message like '최근 동기화 없음' (or null and switch the button label to
'동기화 시작' when formattedLastSyncedAt is falsy) and keep using
parsedLastSyncedAt/isValid to decide validity; adjust the component rendering to
display that fallback text instead of showing an empty date string.
In `@src/features/portal-link/hooks/usePortalLinkJobPolling.ts`:
- Around line 21-31: In usePortalLinkJobPolling, don't call setIsTimedOut inside
the refetchInterval callback (avoid state mutation during React Query's
scheduling); instead remove setIsTimedOut(true) from refetchInterval and have it
only return false when timed out, and add a separate useEffect inside
usePortalLinkJobPolling that watches startedAtRef.current and the query status
(q.state.data?.data?.status) to call setIsTimedOut(true) when Date.now() -
startedAtRef.current >= ENV.PORTAL_LINK_TIMEOUT_MS; keep refetchInterval
returning POLLING_INTERVAL_MS otherwise and reference the same symbols
(refetchInterval, startedAtRef, setIsTimedOut, ENV.PORTAL_LINK_TIMEOUT_MS,
POLLING_INTERVAL_MS) so behavior is unchanged but state updates happen outside
the React Query callback.
- Line 34: usePortalLinkJobPolling currently only returns { data, isTimedOut },
which hides important request states; update the hook to also expose the React
Query fields (e.g., query.isLoading, query.isFetching, query.isError,
query.error, query.isSuccess) so callers can handle loading and error UI. Locate
the query variable inside usePortalLinkJobPolling and either return {...query,
isTimedOut} or explicitly add the listed properties to the returned object
(preserving data and isTimedOut) so consumers have full network state telemetry.
In `@src/features/portal-link/hooks/usePortalLinkSummary.ts`:
- Around line 4-10: The hook usePortalLinkSummary currently calls useQuery;
update it to use useSuspenseQuery (replace useQuery with useSuspenseQuery) so
data fetching uses React Query Suspense semantics and lets consuming components
rely on Suspense/ErrorBoundary for loading/error handling; keep the same
queryKey (['portal-link-summary', jobId]) and queryFn (getJobSummary(jobId!))
and preserve the conditional activation logic around enabled (Boolean(jobId) &&
jobStatus === 'succeeded')—if you decide to remove conditional activation then
call useSuspenseQuery unconditionally and ensure all components using
usePortalLinkSummary are wrapped in React.Suspense and an ErrorBoundary.
- Line 7: Replace the non-null assertion on jobId in the queryFn with an
explicit runtime type guard: inside the queryFn used in usePortalLinkSummary,
check if jobId is undefined/null and throw a clear error (or return a rejected
Promise) before calling getJobSummary(jobId); this preserves the existing
enabled guard but makes the null-safety explicit to TypeScript and future
readers (referencing queryFn, jobId, and getJobSummary).
In `@src/lib/auth/session.ts`:
- Around line 7-21: The session currently stores both accessToken and
refreshToken in SessionData via sessionOptions (cookieName SESSION_COOKIE_NAME,
password ENV.SESSION_SECRET) which risks exceeding the ~4KB cookie limit; change
to store only a minimal identifier (e.g., sessionId or userId) in the cookie and
move accessToken/refreshToken to server-side storage (DB or in-memory store)
keyed by that identifier, update codepaths that read/write SessionData to fetch
tokens from the server store, and optionally add a size check before setting the
cookie in the session handling logic to prevent oversized iron-session cookies.
In `@src/lib/webview/bridge.ts`:
- Around line 39-67: postBridgeMessage currently swallows missing-bridge and
postMessage errors and returns void, preventing callers from performing
fallbacks; change postBridgeMessage to return boolean: return false when
getWebViewWindow() or resolveBridgePostMessage(w) is falsy (preserve the dev
console warn), return true on successful post, and on exceptions call
captureException(err, { extra: { bridgeMessage: message } }) then return false
(preserve the dev warn). Update navigateNative to return the boolean result of
postBridgeMessage(`navigate:${url}`) so callers can fallback (e.g., router.push)
when false. Use the existing symbols postBridgeMessage, navigateNative,
getWebViewWindow, resolveBridgePostMessage, and captureException to locate and
modify the logic.
In `@src/shared/api/http-client.ts`:
- Line 65: The generated HttpClient class currently has a hardcoded public
baseUrl value which must not be edited manually; revert this manual change and
instead update the swagger-typescript-api generator/template so baseUrl is set
from configuration/runtime (e.g., accept a constructor option or read an
injected value), then regenerate the client with the yarn api:* commands; if you
need runtime injection immediately, create a small wrapper/factory that
instantiates HttpClient with baseUrl taken from process.env (or app config) and
use that wrapper everywhere rather than modifying the generated
HttpClient.baseUrl.
In `@swagger/openapi-dev.json`:
- Line 1: The root-level "security": [{"bearerAuth": []}] is being inherited by
all operations (e.g., operationId signInUser, refreshResponse, health,
handleCallback, createPortalLinkJob, getJobStatus, getJobSummary, sentryTest),
incorrectly marking public endpoints as authenticated; fix by removing the
top-level security entry and instead add "security": [{"bearerAuth": []}] only
to operations that require authentication (e.g., getProfile, getSemesterRecord,
getSemesterGrades, getGraduationProgress, getAcademicSummary, getAcademicRecord,
deleteUser, setTargetGpa, resetStudentData, etc.), or alternatively keep the
root security and explicitly mark the listed public operations' paths (POST
/portal/link createPortalLinkJob, POST /internal/scrape-results handleCallback,
POST /api/users/signin signInUser, POST /api/auth/refresh refreshResponse, GET
/sentry-test sentryTest, GET /portal/link/jobs/{jobId} getJobStatus, GET
/portal/link/jobs/{jobId}/summary getJobSummary, GET /health health) with
"security": [] to override inheritance; choose one approach and apply
consistently across the OpenAPI document.
In `@wrangler.jsonc`:
- Line 5: Update the "compatibility_date" value to a recent date (e.g.,
"2026-05-10") so the Workers runtime uses the latest features and fixes; locate
the "compatibility_date" key in wrangler.jsonc and replace the old "2024-09-23"
value with the new ISO date string, then run any build/validation steps to
ensure the config is accepted.
---
Outside diff comments:
In `@src/app/`(funnel)/contexts/FunnelContext.tsx:
- Around line 27-41: The two hooks useStudentInfo and useFunnelContext both
return the same FunnelContext and are redundant; remove useStudentInfo (or mark
it deprecated) and update exports/imports to use only useFunnelContext. Locate
useStudentInfo, useFunnelContext and the FunnelContext provider in
FunnelContext.tsx, delete the useStudentInfo function (or replace it with a thin
deprecated wrapper that calls useFunnelContext and logs a deprecation warning),
and update index.ts exports to stop exporting useStudentInfo and ensure all
internal imports are migrated to useFunnelContext.
In `@src/shared/api/http-client.ts`:
- Around line 1-11: Add a CI validation step to ensure generated API files under
src/shared/api are not manually edited: in the autofix.ci workflow add a job
that runs yarn api:update, then diffs the regenerated output against the
committed files in src/shared/api (fail the job or auto-commit fixes when
differences are found); reference the existing script name yarn api:update and
the src/shared/api path so the workflow explicitly detects PRs that modify those
files and either fails the build or applies the regenerated changes.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: e227f477-26eb-4631-9339-c3b5ecfdd576
⛔ Files ignored due to path filters (1)
yarn.lockis excluded by!**/yarn.lock,!**/*.lock
📒 Files selected for processing (73)
.github/workflows/deploy-cloudflare.yml.gitignoredocs/mpa-school-link-handoff.mdnext.config.mjsopen-next.config.tspackage.jsonsrc/app/(funnel)/agreement/page.tsxsrc/app/(funnel)/contexts/FunnelContext.tsxsrc/app/(funnel)/contexts/index.tssrc/app/(funnel)/portal-login/components/PortalLoginForm/PortalLoginForm.tsxsrc/app/(funnel)/portal-login/page.tsxsrc/app/(funnel)/scraping/error.tsxsrc/app/(funnel)/scraping/page.tsxsrc/app/(main)/graduation-progress/layout.module.scsssrc/app/(main)/main/page.tsxsrc/app/(mpa)/layout.module.scsssrc/app/(mpa)/layout.tsxsrc/app/(mpa)/mpa/home/page.tsxsrc/app/(mpa)/mpa/me/page.tsxsrc/app/(mpa)/mpa/resync/login/page.tsxsrc/app/(mpa)/mpa/resync/scraping/page.tsxsrc/app/api/session/refresh/route.tssrc/app/api/session/route.tssrc/app/auth/callback/route.tssrc/app/auth/success/page.tsxsrc/app/layout.tsxsrc/app/resync/login/page.tsxsrc/app/resync/scraping/error.tsxsrc/app/resync/scraping/page.tsxsrc/app/terms/page.tsxsrc/config/env.tssrc/config/environment.tssrc/constants/index.tssrc/constants/portal-link.tssrc/constants/routes.tssrc/constants/token.tssrc/features/auth/apis/queries/usePortalLoginMutation.tssrc/features/auth/apis/service.tssrc/features/auth/contexts/AuthContext.tsxsrc/features/auth/hooks/useAuthCheck.tssrc/features/auth/services/authService.tssrc/features/auth/tokenStore.tssrc/features/dashboard/apis/queries/useProfileQuery.tssrc/features/dashboard/components/DualMajorRequirementCard/DualMajorRequirementCard.tsxsrc/features/dashboard/components/GraduationRequirementCard/GraduationRequirementCard.tsxsrc/features/dashboard/components/SyncUpdateButton/SyncUpdateButton.tsxsrc/features/dashboard/hooks/useRefreshProfileOnVisible.tssrc/features/portal-link/hooks/index.tssrc/features/portal-link/hooks/usePortalLinkJobPolling.tssrc/features/portal-link/hooks/usePortalLinkMutation.tssrc/features/portal-link/hooks/usePortalLinkSummary.tssrc/features/portal-link/services/portalLinkService.tssrc/features/portal-link/utils/errorMapping.tssrc/lib/auth/index.tssrc/lib/auth/session.tssrc/lib/auth/token.tssrc/lib/webview/bridge.tssrc/lib/webview/index.tssrc/shared/api/client.tssrc/shared/api/configs/httpConfig.tssrc/shared/api/data-contracts.tssrc/shared/api/domain/index.tssrc/shared/api/domain/internalscraperesultcontroller/InternalScrapeResultController.tssrc/shared/api/domain/portaljobquerycontroller/PortalJobQueryController.tssrc/shared/api/domain/portallink/PortalLink.tssrc/shared/api/domain/portallinkcontroller/PortalLinkController.tssrc/shared/api/domain/suwonscraping/SuwonScraping.tssrc/shared/api/domain/user/User.tssrc/shared/api/http-client.tssrc/shared/components/AsyncBoundary.tsxsrc/shared/utils/idempotency.tsswagger/openapi-dev.jsonwrangler.jsonc
💤 Files with no reviewable changes (11)
- src/constants/token.ts
- src/app/resync/scraping/error.tsx
- src/lib/auth/index.ts
- src/app/(funnel)/scraping/error.tsx
- src/features/auth/apis/queries/usePortalLoginMutation.ts
- src/features/auth/apis/service.ts
- src/constants/index.ts
- src/shared/components/AsyncBoundary.tsx
- src/lib/auth/token.ts
- src/config/env.ts
- src/shared/api/domain/suwonscraping/SuwonScraping.ts
| # production 이면 PROD_* secret, 그 외(staging)면 STAGING_* secret 을 사용 | ||
| NEXT_PUBLIC_DEPLOY_ENV: ${{ needs.resolve-env.outputs.name }} | ||
|
|
||
| NEXT_PUBLIC_API_BASE_URL: ${{ needs.resolve-env.outputs.name == 'production' && secrets.PROD_NEXT_PUBLIC_API_BASE_URL || secrets.STAGING_NEXT_PUBLIC_API_BASE_URL }} | ||
| NEXT_PUBLIC_BASE_URL: ${{ needs.resolve-env.outputs.name == 'production' && secrets.PROD_NEXT_PUBLIC_BASE_URL || secrets.STAGING_NEXT_PUBLIC_BASE_URL }} | ||
| NEXT_PUBLIC_SUPABASE_URL: ${{ needs.resolve-env.outputs.name == 'production' && secrets.PROD_NEXT_PUBLIC_SUPABASE_URL || secrets.STAGING_NEXT_PUBLIC_SUPABASE_URL }} | ||
| NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ needs.resolve-env.outputs.name == 'production' && secrets.PROD_NEXT_PUBLIC_SUPABASE_ANON_KEY || secrets.STAGING_NEXT_PUBLIC_SUPABASE_ANON_KEY }} | ||
| NEXT_PUBLIC_JAVASCRIPT_KEY: ${{ needs.resolve-env.outputs.name == 'production' && secrets.PROD_NEXT_PUBLIC_JAVASCRIPT_KEY || secrets.STAGING_NEXT_PUBLIC_JAVASCRIPT_KEY }} | ||
| NEXT_PUBLIC_SENTRY_DSN: ${{ needs.resolve-env.outputs.name == 'production' && secrets.PROD_NEXT_PUBLIC_SENTRY_DSN || secrets.STAGING_NEXT_PUBLIC_SENTRY_DSN }} | ||
|
|
||
| REST_API_KEY: ${{ needs.resolve-env.outputs.name == 'production' && secrets.PROD_REST_API_KEY || secrets.STAGING_REST_API_KEY }} | ||
| CLIENT_SECRET: ${{ needs.resolve-env.outputs.name == 'production' && secrets.PROD_CLIENT_SECRET || secrets.STAGING_CLIENT_SECRET }} | ||
| SUPABASE_PROJECT_ID: ${{ needs.resolve-env.outputs.name == 'production' && secrets.PROD_SUPABASE_PROJECT_ID || secrets.STAGING_SUPABASE_PROJECT_ID }} | ||
| SESSION_SECRET: ${{ needs.resolve-env.outputs.name == 'production' && secrets.PROD_SESSION_SECRET || secrets.STAGING_SESSION_SECRET }} | ||
| SENTRY_AUTH_TOKEN: ${{ needs.resolve-env.outputs.name == 'production' && secrets.PROD_SENTRY_AUTH_TOKEN || secrets.STAGING_SENTRY_AUTH_TOKEN }} | ||
| UPSTASH_REDIS_REST_URL: ${{ needs.resolve-env.outputs.name == 'production' && secrets.PROD_UPSTASH_REDIS_REST_URL || secrets.STAGING_UPSTASH_REDIS_REST_URL }} | ||
| UPSTASH_REDIS_REST_TOKEN: ${{ needs.resolve-env.outputs.name == 'production' && secrets.PROD_UPSTASH_REDIS_REST_TOKEN || secrets.STAGING_UPSTASH_REDIS_REST_TOKEN }} | ||
| LAMBDA_DEV_URL: ${{ needs.resolve-env.outputs.name == 'production' && secrets.PROD_LAMBDA_DEV_URL || secrets.STAGING_LAMBDA_DEV_URL }} | ||
| AWS_URL: ${{ needs.resolve-env.outputs.name == 'production' && secrets.PROD_AWS_URL || secrets.STAGING_AWS_URL }} |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial | 💤 Low value
환경 변수 매핑이 반복적이지만 명확함
production/staging 환경 변수를 삼항 연산자로 매핑하는 패턴이 반복됩니다. GitHub Actions의 Environment 기능을 사용하면 더 간결하게 작성할 수 있지만, 현재 방식도 명시적이고 추적하기 쉽다는 장점이 있습니다.
💡 Environment 기능을 사용한 대안 (선택사항)
GitHub Repository Settings → Environments에서 production과 staging 환경을 생성하고 각 환경별로 시크릿을 등록한 후:
deploy:
needs: resolve-env
runs-on: ubuntu-latest
environment: ${{ needs.resolve-env.outputs.name }}
env:
NEXT_PUBLIC_DEPLOY_ENV: ${{ needs.resolve-env.outputs.name }}
NEXT_PUBLIC_API_BASE_URL: ${{ secrets.NEXT_PUBLIC_API_BASE_URL }}
# ... 각 시크릿을 환경별로 관리현재 방식이 더 명시적이므로 변경은 선택사항입니다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.github/workflows/deploy-cloudflare.yml around lines 35 - 53, The current
secret mapping repeatedly uses ternary-style expressions with
needs.resolve-env.outputs.name; to simplify switch the job to use GitHub
Environments and reference environment-scoped secrets directly by setting the
job's environment to needs.resolve-env.outputs.name (keep NEXT_PUBLIC_DEPLOY_ENV
as is), then replace expressions like NEXT_PUBLIC_API_BASE_URL: ${{
needs.resolve-env.outputs.name == 'production' &&
secrets.PROD_NEXT_PUBLIC_API_BASE_URL ||
secrets.STAGING_NEXT_PUBLIC_API_BASE_URL }} with a single secret reference such
as NEXT_PUBLIC_API_BASE_URL: ${{ secrets.NEXT_PUBLIC_API_BASE_URL }} (do this
for each variable: NEXT_PUBLIC_BASE_URL, NEXT_PUBLIC_SUPABASE_URL,
NEXT_PUBLIC_SUPABASE_ANON_KEY, NEXT_PUBLIC_JAVASCRIPT_KEY,
NEXT_PUBLIC_SENTRY_DSN, REST_API_KEY, CLIENT_SECRET, SUPABASE_PROJECT_ID,
SESSION_SECRET, SENTRY_AUTH_TOKEN, UPSTASH_REDIS_REST_URL,
UPSTASH_REDIS_REST_TOKEN, LAMBDA_DEV_URL, AWS_URL) after creating matching
secrets in the production and staging Environments.
| images: { | ||
| formats: ['image/avif', 'image/webp'], | ||
| minimumCacheTTL: 60 * 60 * 24 * 365, // 1년 | ||
| unoptimized: true, | ||
| }, |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Check for alternative image optimization solutions
# Check for Cloudflare Images configuration
rg -i 'cloudflare.*image|cf.*image' --type ts --type tsx --type js -C 3
# Check for image loader configuration or external image domains
rg 'loader.*=|images.*domains|images.*remotePatterns' next.config -A 5
# Look for image optimization services in dependencies
cat package.json | jq '.dependencies, .devDependencies' | grep -i 'image\|cloudflare'Repository: gyumong/chukchuk-haksa
Length of output: 198
🏁 Script executed:
#!/bin/bash
# Find next.config file
fd -e mjs -e js -e ts -e cjs | grep -i 'next\.config'Repository: gyumong/chukchuk-haksa
Length of output: 102
🏁 Script executed:
#!/bin/bash
# Once we locate next.config.mjs, read its content
git ls-files | grep -i 'next\.config' | head -5Repository: gyumong/chukchuk-haksa
Length of output: 102
🏁 Script executed:
#!/bin/bash
# Search for image-related configuration in the codebase
rg -i 'images\s*[:{]|loader|imageOptimization' -A 3 | head -50Repository: gyumong/chukchuk-haksa
Length of output: 2886
🏁 Script executed:
#!/bin/bash
# Check what `@opennextjs/cloudflare` provides regarding image handling
cat package.json | jq '.dependencies, .devDependencies' | head -30Repository: gyumong/chukchuk-haksa
Length of output: 916
🏁 Script executed:
#!/bin/bash
# Read next.config.mjs to see the image configuration
cat -n next.config.mjsRepository: gyumong/chukchuk-haksa
Length of output: 4215
🏁 Script executed:
#!/bin/bash
# Check open-next.config.ts for image-related settings
cat -n open-next.config.tsRepository: gyumong/chukchuk-haksa
Length of output: 194
🏁 Script executed:
#!/bin/bash
# Search for image loader, custom image handler, or Cloudflare Images configuration
rg -i 'loader|cloudflareImages|imageOptimization|customImageLoader' -A 3 -B 1Repository: gyumong/chukchuk-haksa
Length of output: 50379
🌐 Web query:
@opennextjs/cloudflare image optimization support
💡 Result:
Yes, @opennextjs/cloudflare (OpenNext Cloudflare adapter) supports Next.js image optimization via the <Image /> component. It provides a Next.js-compatible image optimization API powered by Cloudflare Images.[1][2] To enable it: - Define an IMAGES binding in your Cloudflare Worker configuration for Cloudflare Images.[1] - Configure next.config.js as needed (e.g., remotePatterns, formats like PNG/JPEG/WEBP/AVIF/GIF/SVG).[1] - Images are optimized at the edge; unsupported formats return unchanged.[1] Alternatively, use a custom loader for Cloudflare Images: - Enable Cloudflare Images for your zone. - Create image-loader.ts with a cloudflareLoader function. - Set images: { loader: 'custom', loaderFile: './image-loader.ts' } in next.config.js.[1] Limitations: No minimumCacheTTL support (uses immutable caching); local IPs always allowed if in remotePatterns; custom loader bypasses remotePatterns—use Cloudflare dashboard for origin restrictions.[1] Official guide: https://opennext.js.org/cloudflare/howtos/image[1]
Citations:
이미지 최적화 대체 방안이 구성되지 않았습니다.
images.unoptimized = true 설정으로 Next.js 자동 이미지 최적화가 비활성화되어 있습니다. @opennextjs/cloudflare는 Cloudflare Images를 통한 이미지 최적화를 지원하지만, 현재 codebase에서는 다음이 구성되지 않았습니다:
- Cloudflare Workers 설정의 IMAGES 바인딩
next.config.mjs의 커스텀 이미지 로더 (loader: 'custom')open-next.config.ts의 이미지 최적화 설정
성능 최적화를 위해 다음 중 하나를 구성하세요:
- Cloudflare Images 바인딩 추가 및 커스텀 이미지 로더 설정
- 다른 CDN 기반 이미지 최적화 서비스 구성
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@next.config.mjs` around lines 13 - 15, The images.unoptimized = true setting
disables Next.js image optimization; either configure Cloudflare Images
integration or a CDN image service: add the Cloudflare IMAGES binding in your
Workers config, set next.config.mjs images to use a custom loader (e.g., replace
unoptimized:true with loader:'custom' and appropriate loader configuration), and
enable image optimization options in open-next.config.ts (or alternatively wire
a different CDN/image service and update next.config.mjs accordingly). Ensure
references to IMAGES binding, next.config.mjs images, loader:'custom', and
open-next.config.ts are updated so the `@opennextjs/cloudflare` integration (or
chosen CDN) performs image optimization.
| "zod": "^3.24.4" | ||
| }, | ||
| "devDependencies": { | ||
| "@opennextjs/cloudflare": "^1", |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial | ⚡ Quick win
🧩 Analysis chain
🌐 Web query:
@opennextjs/cloudflare latest stable version npm
💡 Result:
Latest stable version on npm (as of 2026-05-10): 1.19.8. [1][2] How to install: - npm i @opennextjs/cloudflare@1.19.8 [1] - npm i @opennextjs/cloudflare (will install the latest stable tag) [1] Notes: - The package’s npm page shows “1.19.8 · Published May 7, 2026”. [1] - The GitHub repository indicates the latest release is 1.19.7, which may lag one patch behind npm’s latest published stable version. [3]
Citations:
- 1: https://www.npmjs.com/package/@opennextjs/cloudflare
- 2: https://registry.npmjs.org/@opennextjs/cloudflare
- 3: https://github.com/opennextjs/opennextjs-cloudflare
@opennextjs/cloudflare 버전 지정 검토
현재 @opennextjs/cloudflare는 ^1로 지정되어 있어 1.0.0부터 1.x까지의 모든 버전이 허용됩니다. 최신 안정 버전은 1.19.8(2026년 5월 7일 기준)입니다.
^1은 npm 생태계의 표준 관행이지만, 더 엄격한 버전 고정이 필요한 경우 ~1.19.8 또는 1.19.8으로 변경하여 마이너 버전 이상의 자동 업데이트를 방지할 수 있습니다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@package.json` at line 60, The dependency version for `@opennextjs/cloudflare`
is too loose ("^1"); update package.json to a stricter pin to prevent unexpected
upgrades by replacing "^1" with either a tilde-pinned version "~1.19.8" or an
exact version "1.19.8" (use the exact string "1.19.8" if you want full
immutability), then run your package manager install to update lockfiles; locate
the dependency entry for "@opennextjs/cloudflare" in package.json to make this
change.
| export interface SessionData { | ||
| username: string; | ||
| password: string; | ||
| accessToken?: string; | ||
| refreshToken?: string; | ||
| isPortalLinked?: boolean; | ||
| } | ||
|
|
||
| export const sessionOptions: SessionOptions = { | ||
| cookieName: 'session', | ||
| password: ENV.SESSION_SECRET, | ||
| cookieName: SESSION_COOKIE_NAME, | ||
| cookieOptions: { | ||
| httpOnly: true, | ||
| secure: process.env.NODE_ENV === 'production', | ||
| maxAge: 600, // 10분 | ||
| sameSite: 'lax', | ||
| path: '/', | ||
| maxAge: 60 * 60 * 24 * 30, |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Does iron-session store the full session payload inside the encrypted cookie, and what is the practical per-cookie size limit enforced by major browsers and common proxies?
💡 Result:
Yes. iron-session stores the entire session data inside the cookie itself by “sealing” (signing and encrypting) your session payload into an encrypted cookie value that the server later decodes (“unseal[s]”) on requests. [1][2] The repo calls this “stateless” because “the data is passed around in cookies.” [1][2] Practical per-cookie size limit (major browsers + common proxies) 1) Browser-enforced cookie size: ~4 KiB per cookie - iron-session explicitly enforces a 4096-byte limit: it throws if the serialized cookie value exceeds 4096 bytes, noting “browsers will refuse it.” [3] - RFC 6265 says user agents should support “at least 4096 bytes per cookie (as measured by the sum of the length of the cookie's name, value, and attributes).” [4] - Therefore, in practice you should target a cookie value around 4,096 bytes or less. iron-session’s own guidance/expectations and error message are aligned with this. [3][5] 2) Proxy/server limits on request headers: can be lower than cookie-only limits - Even if a browser can store a ~4 KiB cookie, the cookie will be sent on every request, increasing request header size. The HTTP cookie drafts warn that server/proxy header field limits (commonly defaulting to ~8 KiB) can cause requests to fail and advise avoiding large cookies such that the final cookie-string would exceed header field limits. [6] - So the practical limit becomes the minimum of: browser per-cookie limit (~4 KiB) and your edge/proxy/server request header limits (often ~8 KiB default in common servers), especially if you have multiple cookies or multiple cookie headers combined. [6][7] One-number practical guidance - Keep each iron-session cookie to <= 4096 bytes (iron-session’s own check) and, additionally, ensure your total Cookie header size stays comfortably below typical proxy/server header limits (often ~8 KiB default), otherwise requests can fail even though the browser accepted the cookie. [3][6]
Citations:
- 1: https://github.com/vvo/iron-session/blob/main/README.md
- 2: https://github.com/vvo/iron-session
- 3: https://github.com/vvo/iron-session/blob/main/src/core.ts
- 4: http://www.irt.org/rfc/rfc6265.htm
- 5: Add cookie chunking support for sessions exceeding 4KB limit vvo/iron-session#937
- 6: https://httpwg.org/http-extensions/draft-ietf-httpbis-layered-cookies.html
- 7: https://dt.ietf.org/doc/draft-ietf-httpbis-rfc6265bis/
세션 쿠키 크기: accessToken과 refreshToken 함께 저장 시 4KB 한도 초과 위험
현재 구현은 accessToken과 refreshToken 두 개의 JWT를 모두 iron-session의 암호화 쿠키에 저장합니다. iron-session은 암호화된 쿠키 값이 4096바이트를 초과하면 요청을 실패시키며, 브라우저도 RFC 6265에 따라 쿠키당 ~4KiB 한도를 강제합니다. 두 JWT의 길이 + sealing 오버헤드를 고려하면 이 한도를 초과할 가능성이 높으며, 이 경우 세션이 간헐적으로 저장되지 않는 문제가 발생합니다.
권장 해결 방안:
- 쿠키에는 최소 식별자만 저장하고 토큰은 서버 저장소에서 관리하기
- 또는 저장 직전에 암호화된 쿠키 크기를 강제로 검증하기
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/auth/session.ts` around lines 7 - 21, The session currently stores
both accessToken and refreshToken in SessionData via sessionOptions (cookieName
SESSION_COOKIE_NAME, password ENV.SESSION_SECRET) which risks exceeding the ~4KB
cookie limit; change to store only a minimal identifier (e.g., sessionId or
userId) in the cookie and move accessToken/refreshToken to server-side storage
(DB or in-memory store) keyed by that identifier, update codepaths that
read/write SessionData to fetch tokens from the server store, and optionally add
a size check before setting the cookie in the session handling logic to prevent
oversized iron-session cookies.
| @@ -1 +1 @@ | |||
| {"openapi":"3.0.1","info":{"title":"척척학사 API","description":"API 명세서","version":"v1"},"servers":[{"url":"https://dev.api.cchaksa.com","description":"Dev Server"}],"security":[{"bearerAuth":[]}],"tags":[{"name":"Student","description":"학생 설정 관련 API"},{"name":"Auth","description":"인증 관련 API"},{"name":"User","description":"사용자 관련 API"},{"name":"Graduation","description":"졸업 요건 및 진행 현황 관련 API"},{"name":"Suwon Scraping","description":"수원대학교 포털 크롤링 관련 API"}],"paths":{"/api/users/signin":{"post":{"tags":["User"],"summary":"회원 가입 및 로그인","description":"사용자가 카카오 소셜 로그인으로 회원가입 및 로그인을 진행합니다.","operationId":"signInUser","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignInRequest"}}},"required":true},"responses":{"200":{"description":"회원가입/로그인 성공","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SignInApiResponse"}}}},"401":{"description":"만료된 토큰 오류 (ErrorCode: T04, TOKEN_EXPIRED)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}},"500":{"description":"서버 내부 오류 (ErrorCode: INTERNAL_ERROR)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}}}}},"/api/suwon-scrape/start":{"post":{"tags":["Suwon Scraping"],"summary":"포털 데이터 크롤링 및 동기화","description":"Redis에 저장된 포털 로그인 정보를 사용하여 데이터를 크롤링하고 초기화 및 학업 이력을 동기화합니다.","operationId":"startScraping","responses":{"202":{"description":"동기화 성공","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScrapingApiResponse"}}}},"400":{"description":"이미 연동된 사용자 (ErrorCode: U03)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}},"401":{"description":"아이디 또는 비밀번호 불일치 (ErrorCode: P01)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}},"423":{"description":"계정 잠김 (ErrorCode: P03)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}},"500":{"description":"포털 크롤링 실패 (ErrorCode: P02)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}}},"security":[{"bearerAuth":[]}]}},"/api/suwon-scrape/refresh":{"post":{"tags":["Suwon Scraping"],"summary":"포털 정보 재연동 및 학업 이력 동기화","description":"포털 정보를 재연동하고 학업 이력을 다시 동기화합니다.","operationId":"refreshAndSync","responses":{"202":{"description":"재연동 및 동기화 성공","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScrapingApiResponse"}}}},"400":{"description":"아직 포털 연동이 되지 않은 사용자 (ErrorCode: U04)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}},"401":{"description":"로그인 필요 (ErrorCode: C01, 세션 만료)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}},"500":{"description":"서버 내부 오류 (ErrorCode: C03, 재연동 실패)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}}},"security":[{"bearerAuth":[]}]}},"/api/suwon-scrape/login":{"post":{"tags":["Suwon Scraping"],"summary":"포털 로그인","description":"수원대학교 포털 로그인 후 Redis에 계정 정보를 저장합니다.","operationId":"login","parameters":[{"name":"username","in":"query","description":"포털 로그인 ID","required":true,"schema":{"type":"string"}},{"name":"password","in":"query","description":"포털 로그인 비밀번호","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"로그인 성공","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PortalLoginApiResponse"}}}},"401":{"description":"로그인 실패 (ErrorCode: P01, 포털 로그인 실패)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}},"500":{"description":"서버 내부 오류 (ErrorCode: INTERNAL_ERROR, 서버 오류)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}}},"security":[{"bearerAuth":[]}]}},"/api/student/target-gpa":{"post":{"tags":["Student"],"summary":"목표 GPA 설정","description":"로그인된 사용자의 목표 GPA를 저장합니다.","operationId":"setTargetGpa","parameters":[{"name":"targetGpa","in":"query","description":"목표 GPA","required":false,"schema":{"maximum":4.5,"exclusiveMaximum":false,"minimum":0.0,"exclusiveMinimum":false,"type":"number","format":"double"},"example":3.8}],"responses":{"200":{"description":"목표 GPA 설정 성공","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TargetGpaApiResponse"}}}},"400":{"description":"잘못된 GPA 입력 (ErrorCode: C01, INVALID_ARGUMENT)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}},"404":{"description":"학생 정보 없음 (ErrorCode: S01, STUDENT_NOT_FOUND)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}}},"security":[{"bearerAuth":[]}]}},"/api/student/reset":{"post":{"tags":["Student"],"summary":"사용자 정보 초기화","description":"로그인된 사용자의 정보를 초기화합니다.","operationId":"resetStudentData","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SuccessResponseMessageOnlyResponse"}}}}},"security":[{"bearerAuth":[]}]}},"/api/auth/refresh":{"post":{"tags":["Auth"],"summary":"토큰 재발급","description":"리프레시 토큰을 사용해 새로운 액세스 토큰과 리프레시 토큰을 발급합니다.","operationId":"refreshResponse","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshRequest"}}},"required":true},"responses":{"200":{"description":"토큰 재발급 성공","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshTokenApiResponse"}}}},"401":{"description":"인증 실패 (ErrorCode: T04, T10, T12, T11)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}},"404":{"description":"사용자 정보 없음 (ErrorCode: U01)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}}}}},"/sentry-test":{"get":{"tags":["check-controller"],"operationId":"sentryTest","responses":{"200":{"description":"OK"}}}},"/health":{"get":{"tags":["check-controller"],"operationId":"health","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"string"}}}}}}},"/api/student/profile":{"get":{"tags":["Student"],"summary":"사용자 프로필 조회","description":"로그인된 사용자의 프로필 정보를 조회합니다.","operationId":"getProfile","responses":{"200":{"description":"사용자 프로필 정보 조회 성공","content":{"*/*":{"schema":{"$ref":"#/components/schemas/StudentProfileApiResponse"}}}},"404":{"description":"학생 정보 없음 (ErrorCode: S01, STUDENT_NOT_FOUND)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}}},"security":[{"bearerAuth":[]}]}},"/api/semester":{"get":{"tags":["semester-controller"],"summary":"사용자 학기 목록 조회","description":"사용자의 모든 학기 정보를 조회합니다.","operationId":"getSemesterRecord","responses":{"200":{"description":"사용자의 모든 학기 정보 조회 성공","content":{"*/*":{"schema":{"$ref":"#/components/schemas/StudentSemesterListApiResponse"}}}},"404":{"description":"학기 데이터 없음 (ErrorCode: A03, FRESHMAN_NO_SEMESTER)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}},"500":{"description":"서버 내부 오류 (ErrorCode: INTERNAL_ERROR)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}}},"security":[{"bearerAuth":[]}]}},"/api/semester/grades":{"get":{"tags":["semester-controller"],"summary":"사용자 학기 별 성적 조회","description":"사용자의 학기 별 성적 정보를 조회합니다.","operationId":"getSemesterGrades","responses":{"200":{"description":"사용자의 학기 별 성적 정보 조회 성공","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SemesterGradesApiResponse"}}}},"404":{"description":"학기 성적 데이터 없음 (ErrorCode: A02, SEMESTER_RECORD_EMPTY)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}},"500":{"description":"서버 내부 오류 (ErrorCode: INTERNAL_ERROR)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}}},"security":[{"bearerAuth":[]}]}},"/api/graduation/progress":{"get":{"tags":["Graduation"],"summary":"졸업 요건 진행 상황 조회","description":"로그인된 사용자의 졸업 요건 충족 여부를 조회합니다.","operationId":"getGraduationProgress","responses":{"200":{"description":"졸업 요건 충족 여부 조회 성공","content":{"*/*":{"schema":{"$ref":"#/components/schemas/GraduationProgressApiResponse"}}}},"404":{"description":"졸업 요건 정보 없음 (ErrorCode: G01, GRADUATION_REQUIREMENTS_NOT_FOUND)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}}},"security":[{"bearerAuth":[]}]}},"/api/academic/summary":{"get":{"tags":["academic-record-controller"],"summary":"사용자 학업 요약 정보 조회","description":"로그인된 사용자의 학업 요약 정보를 조회합니다.","operationId":"getAcademicSummary","responses":{"200":{"description":"학업 요약 정보 조회 성공","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AcademicSummaryApiResponse"}}}},"401":{"description":"인증 실패","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}},"404":{"description":"학업 요약 정보 없음 또는 사용자 정보 없음 (U02, S01)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}},"500":{"description":"서버 내부 오류","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}}},"security":[{"bearerAuth":[]}]}},"/api/academic/record":{"get":{"tags":["academic-record-controller"],"summary":"학기별 성적 및 수강 과목 정보 조회","description":"지정한 학기(year, semester)에 해당하는 성적 및 수강 과목 정보를 조회합니다.","operationId":"getAcademicRecord","parameters":[{"name":"year","in":"query","description":"연도","required":true,"schema":{"type":"integer","format":"int32"},"example":2024},{"name":"semester","in":"query","description":"학기","required":true,"schema":{"type":"integer","format":"int32"},"example":"10, 15, 20 ..."}],"responses":{"200":{"description":"지정 학기 성적 및 수강 과목 조회 성공","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AcademicRecordApiResponse"}}}},"401":{"description":"인증 실패","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}},"404":{"description":"해당 학기 성적 데이터 없음 (A01)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}},"500":{"description":"서버 내부 오류","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}}},"security":[{"bearerAuth":[]}]}},"/api/users/delete":{"delete":{"tags":["User"],"summary":"회원 탈퇴","description":"로그인된 사용자의 계정을 삭제합니다.","operationId":"deleteUser","responses":{"200":{"description":"회원 탈퇴 성공","content":{"*/*":{"schema":{"$ref":"#/components/schemas/DeleteUserApiResponse"}}}},"404":{"description":"사용자 정보 없음 (ErrorCode: U01, USER_NOT_FOUND)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}}},"security":[{"bearerAuth":[]}]}}},"components":{"schemas":{"SignInApiResponse":{"required":["data","success"],"type":"object","properties":{"success":{"type":"boolean","description":"성공 여부","example":true},"data":{"$ref":"#/components/schemas/SignInResponse"},"message":{"type":"string","description":"메시지","example":"요청 성공"}},"description":"회원가입 및 로그인 응답"},"SignInResponse":{"required":["accessToken","isPortalLinked","refreshToken"],"type":"object","properties":{"accessToken":{"type":"string","description":"Access Token","example":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."},"refreshToken":{"type":"string","description":"Refresh Token","example":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."},"isPortalLinked":{"type":"boolean","description":"포털 연동 여부","example":true}},"description":"회원가입 및 로그인 응답"},"ErrorDetail":{"required":["code","message"],"type":"object","properties":{"code":{"type":"string","description":"에러 코드","example":"U01"},"message":{"type":"string","description":"에러 메시지","example":"해당 사용자를 찾을 수 없습니다."},"details":{"type":"object","description":"에러 추가 정보","nullable":true}},"description":"에러 상세 정보"},"ErrorResponseWrapper":{"required":["success"],"type":"object","properties":{"success":{"type":"boolean","description":"성공 여부","example":false},"error":{"$ref":"#/components/schemas/ErrorDetail"}},"description":"API 에러 응답 포맷"},"SignInRequest":{"required":["id_token","nonce"],"type":"object","properties":{"id_token":{"type":"string","description":"카카오에서 발급받은 ID 토큰"},"nonce":{"type":"string","description":"로그인 시 사용한 nonce 값","example":"random_nonce_value"}},"description":"카카오 로그인 요청 정보"},"ScrapingApiResponse":{"required":["data","success"],"type":"object","properties":{"success":{"type":"boolean","description":"성공 여부","example":true},"data":{"$ref":"#/components/schemas/ScrapingResponse"},"message":{"type":"string","description":"메시지","example":"요청 성공"}},"description":"포털 데이터 크롤링 응답"},"ScrapingResponse":{"type":"object","properties":{"taskId":{"type":"string","description":"작업 ID","example":"4aabf0d0-1c23-4f3d-845e-24c9c943deed"},"studentInfo":{"$ref":"#/components/schemas/StudentInfo"},"status":{"type":"string","description":"포털 연동 상태","example":"SUCCESS / ALREADY_CONNECTED"}},"description":"포털 연동 또는 재연동 및 학업 이력 동기화 성공 응답"},"StudentInfo":{"type":"object","properties":{"name":{"type":"string"},"school":{"type":"string"},"majorName":{"type":"string"},"studentCode":{"type":"string"},"gradeLevel":{"type":"integer","format":"int32"},"status":{"type":"string"},"completedSemesterType":{"type":"integer","format":"int32"}},"description":"학생 정보 요약"},"PortalLoginApiResponse":{"required":["data","success"],"type":"object","properties":{"success":{"type":"boolean","description":"성공 여부","example":true},"data":{"$ref":"#/components/schemas/PortalLoginResponse"},"message":{"type":"string","description":"메시지","example":"요청 성공"}},"description":"포털 로그인 응답"},"PortalLoginResponse":{"type":"object","description":"포털 로그인 응답"},"MessageOnlyResponse":{"type":"object","properties":{"message":{"type":"string","description":"결과 메시지","example":"목표 학점 저장 완료"}},"description":"메시지 응답 DTO"},"TargetGpaApiResponse":{"required":["data","success"],"type":"object","properties":{"success":{"type":"boolean","description":"성공 여부","example":true},"data":{"$ref":"#/components/schemas/MessageOnlyResponse"},"message":{"type":"string","description":"메시지","example":"요청 성공"}},"description":"목표 GPA 설정 응답"},"SuccessResponseMessageOnlyResponse":{"required":["data","success"],"type":"object","properties":{"success":{"type":"boolean","description":"성공 여부","example":true},"data":{"$ref":"#/components/schemas/MessageOnlyResponse"},"message":{"type":"string","description":"메시지","example":"요청 성공"}},"description":"성공 응답 포맷"},"RefreshResponse":{"required":["accessToken","refreshToken"],"type":"object","properties":{"accessToken":{"type":"string","description":"액세스 토큰"},"refreshToken":{"type":"string","description":"리프레시 토큰"}},"description":"Refresh Response DTO"},"RefreshTokenApiResponse":{"required":["data","success"],"type":"object","properties":{"success":{"type":"boolean","description":"성공 여부","example":true},"data":{"$ref":"#/components/schemas/RefreshResponse"},"message":{"type":"string","description":"메시지","example":"요청 성공"}},"description":"토큰 재발급 응답 포맷"},"RefreshRequest":{"required":["refreshToken"],"type":"object","properties":{"refreshToken":{"type":"string","description":"Refresh Token"}},"description":"리프레시 토큰 요청 DTO"},"StudentProfileApiResponse":{"required":["data","success"],"type":"object","properties":{"success":{"type":"boolean","description":"성공 여부","example":true},"data":{"$ref":"#/components/schemas/StudentProfileResponse"},"message":{"type":"string","description":"메시지","example":"요청 성공"}},"description":"학생 프로필 응답"},"StudentProfileResponse":{"required":["currentSemester","departmentName","gradeLevel","lastSyncedAt","lastUpdatedAt","majorName","name","reconnectionRequired","status","studentCode"],"type":"object","properties":{"name":{"type":"string","description":"이름"},"studentCode":{"type":"string","description":"학번"},"departmentName":{"type":"string","description":"학과 이름"},"majorName":{"type":"string","description":"전공 이름"},"dualMajorName":{"type":"string","description":"복수전공 이름"},"gradeLevel":{"type":"integer","description":"학년","format":"int32"},"currentSemester":{"type":"integer","description":"현재 학기","format":"int32"},"status":{"type":"string","description":"재학 상태","enum":["재학","휴학","졸업","제적","수료"]},"lastUpdatedAt":{"type":"string","description":"마지막 업데이트 일시"},"lastSyncedAt":{"type":"string","description":"학사 정보 마지막 연동 일시"},"reconnectionRequired":{"type":"boolean","description":"재연동 필요 여부"}},"description":"학생 프로필 정보"},"StudentSemesterInfoResponse":{"required":["semester","year"],"type":"object","properties":{"year":{"type":"integer","description":"이수 연도","format":"int32","example":2023},"semester":{"type":"integer","description":"이수 학기 코드 (10: 1학기, 15: 여름학기, 20: 2학기, 25: 겨울학기)","format":"int32","example":10}},"description":"학생의 이수 학기 정보"},"StudentSemesterListApiResponse":{"required":["data","success"],"type":"object","properties":{"success":{"type":"boolean","description":"성공 여부","example":true},"data":{"type":"array","description":"응답 데이터","items":{"$ref":"#/components/schemas/StudentSemesterInfoResponse"}},"message":{"type":"string","description":"메시지","example":"요청 성공"}},"description":"사용자 학기 목록 응답"},"SemesterGradesApiResponse":{"required":["data","success"],"type":"object","properties":{"success":{"type":"boolean","description":"성공 여부","example":true},"data":{"type":"array","description":"응답 데이터","items":{"$ref":"#/components/schemas/SemesterSummaryResponse"}},"message":{"type":"string","description":"메시지","example":"요청 성공"}},"description":"학기별 성적 목록 응답"},"SemesterSummaryResponse":{"required":["semester","year"],"type":"object","properties":{"year":{"type":"integer","description":"이수 연도","format":"int32","example":2023},"semester":{"type":"integer","description":"학기 코드 (10: 1학기, 15: 여름학기, 20: 2학기, 25: 겨울학기)","format":"int32","example":10},"earnedCredits":{"type":"integer","description":"취득 학점","format":"int32","nullable":true,"example":15},"attemptedCredits":{"type":"integer","description":"신청 학점","format":"int32","nullable":true,"example":18},"semesterGpa":{"type":"number","description":"학기 GPA (평점 평균)","nullable":true,"example":3.85},"classRank":{"type":"integer","description":"석차","format":"int32","nullable":true,"example":5},"totalStudents":{"type":"integer","description":"전체 학생 수","format":"int32","nullable":true,"example":150},"percentile":{"type":"number","description":"백분율","nullable":true,"example":92.4}},"description":"학기 요약 정보 (성적 포함)"},"AreaProgressDto":{"required":["areaType","completedElectiveCourses","courses","earnedCredits","requiredCredits","requiredElectiveCourses","totalElectiveCourses"],"type":"object","properties":{"areaType":{"type":"string","description":"영역 유형 (예: 전핵, 전선, 선교 등)","enum":["중핵","기교","선교","소교","전교","전취","전핵","전선","일선","복선","복핵","복교"]},"requiredCredits":{"type":"integer","description":"해당 영역에서 필요한 학점","format":"int32","example":60},"earnedCredits":{"type":"integer","description":"학생이 이수한 학점","format":"int32","example":45},"requiredElectiveCourses":{"type":"integer","description":"필수 선택 과목 수","format":"int32","example":2},"completedElectiveCourses":{"type":"integer","description":"학생이 이수한 선택 과목 수","format":"int32","example":1},"totalElectiveCourses":{"type":"integer","description":"총 선택 과목 수","format":"int32","example":3},"courses":{"type":"array","description":"학생이 이수한 과목 목록","items":{"$ref":"#/components/schemas/CourseDto"}}},"description":"졸업 요건 영역별 이수 현황"},"CourseDto":{"required":["courseName","credits","grade","semester","year"],"type":"object","properties":{"year":{"type":"integer","description":"이수 연도","format":"int32","example":2023},"courseName":{"type":"string","description":"과목명","example":"자료구조"},"credits":{"type":"integer","description":"학점","format":"int32","example":3},"grade":{"type":"string","description":"성적","example":"A+"},"semester":{"type":"integer","description":"이수 학기","format":"int32","example":10}},"description":"이수 과목 정보"},"GraduationProgressApiResponse":{"required":["data","success"],"type":"object","properties":{"success":{"type":"boolean","description":"성공 여부","example":true},"data":{"$ref":"#/components/schemas/GraduationProgressResponse"},"message":{"type":"string","description":"메시지","example":"요청 성공"}},"description":"졸업 요건 진행 상황 응답"},"GraduationProgressResponse":{"required":["graduationProgress","hasDifferentGraduationRequirement"],"type":"object","properties":{"graduationProgress":{"type":"array","description":"졸업 요건 영역별 이수 현황","items":{"$ref":"#/components/schemas/AreaProgressDto"}},"hasDifferentGraduationRequirement":{"type":"boolean","description":"특정 학과/연도 예외로 기존과 다른 졸업요건이 적용되는지 여부"}},"description":"졸업 요건 진행 상황 응답"},"AcademicSummaryApiResponse":{"required":["data","success"],"type":"object","properties":{"success":{"type":"boolean","description":"성공 여부","example":true},"data":{"$ref":"#/components/schemas/AcademicSummaryResponse"},"message":{"type":"string","description":"메시지","example":"요청 성공"}},"description":"학업 요약 정보 응답"},"AcademicSummaryResponse":{"required":["cumulativeGpa","percentile","requiredCredits","totalEarnedCredits"],"type":"object","properties":{"totalEarnedCredits":{"type":"integer","description":"총 취득 학점","format":"int32","example":120},"cumulativeGpa":{"type":"number","description":"누적 GPA","example":3.76},"percentile":{"type":"number","description":"전체 백분위","example":87.5},"requiredCredits":{"type":"integer","description":"필요 졸업 학점","format":"int32","example":130}},"description":"학업 성적 요약 정보"},"AcademicRecordApiResponse":{"required":["data","success"],"type":"object","properties":{"success":{"type":"boolean","description":"성공 여부","example":true},"data":{"$ref":"#/components/schemas/AcademicRecordResponse"},"message":{"type":"string","description":"메시지","example":"요청 성공"}},"description":"학기별 성적 및 수강 과목 정보 응답"},"AcademicRecordResponse":{"required":["courses","semesterGrade"],"type":"object","properties":{"semesterGrade":{"$ref":"#/components/schemas/SemesterGradeResponse"},"courses":{"$ref":"#/components/schemas/Courses"}},"description":"학기별 성적 및 수강 과목 응답"},"CourseDetailDto":{"type":"object","properties":{"id":{"type":"string","description":"수강 ID"},"courseName":{"type":"string","description":"과목명"},"courseCode":{"type":"string","description":"학수번호"},"areaType":{"type":"string","description":"영역 (전공/교양 등)","enum":["중핵","기교","선교","소교","전교","전취","전핵","전선","일선","복선","복핵","복교"]},"credits":{"type":"integer","description":"학점","format":"int32"},"professor":{"type":"string","description":"교수명"},"grade":{"type":"string","description":"성적"},"score":{"type":"integer","description":"실 점수","format":"int32"},"isRetake":{"type":"boolean","description":"재수강 여부"},"isOnline":{"type":"boolean","description":"사이버 강의 여부"},"year":{"type":"integer","description":"이수 연도","format":"int32"},"semester":{"type":"integer","description":"이수 학기","format":"int32"},"originalScore":{"type":"integer","description":"원점수","format":"int32"},"isRetakeDelete":{"type":"boolean","description":"재수강 삭제 과목 여부"}},"description":"수강 과목 상세 정보"},"Courses":{"required":["liberal","major"],"type":"object","properties":{"major":{"type":"array","description":"전공 과목 목록","items":{"$ref":"#/components/schemas/CourseDetailDto"}},"liberal":{"type":"array","description":"교양 과목 목록","items":{"$ref":"#/components/schemas/CourseDetailDto"}}},"description":"수강 과목 목록"},"SemesterGradeResponse":{"required":["attemptedCredits","classRank","earnedCredits","percentile","semester","semesterGpa","totalStudents","year"],"type":"object","properties":{"year":{"type":"integer","description":"이수 연도","format":"int32","example":2024},"semester":{"type":"integer","description":"학기 코드 (10: 1학기, 15: 여름학기, 20: 2학기, 25: 겨울학기)","format":"int32","example":10},"earnedCredits":{"type":"integer","description":"취득 학점","format":"int32","example":15},"attemptedCredits":{"type":"integer","description":"신청 학점","format":"int32","example":18},"semesterGpa":{"type":"number","description":"학기 GPA (평점 평균)","example":3.85},"classRank":{"type":"integer","description":"석차","format":"int32","nullable":true,"example":5},"totalStudents":{"type":"integer","description":"전체 학생 수","format":"int32","nullable":true,"example":150},"percentile":{"type":"number","description":"백분율","example":92.4}},"description":"학기 성적 요약 정보"},"DeleteUserApiResponse":{"required":["data","success"],"type":"object","properties":{"success":{"type":"boolean","description":"성공 여부","example":true},"data":{"$ref":"#/components/schemas/MessageOnlyResponse"},"message":{"type":"string","description":"메시지","example":"요청 성공"}},"description":"회원 탈퇴 응답 포맷"}},"securitySchemes":{"bearerAuth":{"type":"http","description":"JWT 인증 토큰을 입력하세요","scheme":"bearer","bearerFormat":"JWT"}}}} No newline at end of file | |||
| {"openapi":"3.0.1","info":{"title":"척척학사 API","description":"API 명세서","version":"v1"},"servers":[{"url":"https://dev.api.cchaksa.com","description":"Develop-shadow Server"}],"security":[{"bearerAuth":[]}],"tags":[{"name":"Student","description":"학생 설정 관련 API"},{"name":"Auth","description":"인증 관련 API"},{"name":"User","description":"사용자 관련 API"},{"name":"Portal Link","description":"비동기 포털 연동 job 생성 및 폴링 안내 | [Internal] Callback"},{"name":"Graduation","description":"졸업 요건 및 진행 현황 관련 API"}],"paths":{"/portal/link":{"post":{"tags":["portal-link-controller"],"operationId":"createPortalLinkJob","parameters":[{"name":"Idempotency-Key","in":"header","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LinkRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SuccessResponseAcceptedResponse"}}}}}}},"/internal/scrape-results":{"post":{"tags":["Portal Link"],"summary":"[Internal] 스크래핑 결과 콜백 수신","description":"스크래핑 워커가 API Gateway를 통해 전달하는 job 상태와 result_s3_key를 검증하고 DB에 반영합니다.\n실제 결과 payload는 result_s3_key 기반으로 백엔드가 S3에서 조회합니다.\nHMAC 서명 규칙: signature = HMAC_SHA256(\"{timestamp}.{rawBody}\").","operationId":"handleCallback","parameters":[{"name":"X-Timestamp","in":"header","description":"epoch millis (UTC)","required":true,"schema":{"type":"string"}},{"name":"X-Signature","in":"header","description":"HMAC-SHA256 서명 값","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"string"}}},"required":true},"responses":{"200":{"description":"콜백 처리 완료","content":{"*/*":{"schema":{"$ref":"#/components/schemas/MessageOnlyResponse"}}}},"400":{"description":"잘못된 콜백 요청 (SCRAPE_INVALID_CALLBACK_REQUEST)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}},"404":{"description":"job 미존재 (PORTAL_JOB_NOT_FOUND)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}}}}},"/api/users/signin":{"post":{"tags":["User"],"summary":"회원 가입 및 로그인","description":"사용자가 OIDC 소셜 로그인으로 회원가입 및 로그인을 진행합니다.","operationId":"signInUser","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignInRequest"}}},"required":true},"responses":{"200":{"description":"회원가입/로그인 성공","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SignInApiResponse"}}}},"401":{"description":"만료된 토큰 오류 (ErrorCode: T04, TOKEN_EXPIRED)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}},"500":{"description":"서버 내부 오류 (ErrorCode: INTERNAL_ERROR)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}}}}},"/api/student/target-gpa":{"post":{"tags":["Student"],"summary":"목표 GPA 설정","description":"로그인된 사용자의 목표 GPA를 저장합니다.","operationId":"setTargetGpa","parameters":[{"name":"targetGpa","in":"query","description":"목표 GPA","required":false,"schema":{"maximum":4.5,"exclusiveMaximum":false,"minimum":0.0,"exclusiveMinimum":false,"type":"number","format":"double"},"example":3.8}],"responses":{"200":{"description":"목표 GPA 설정 성공","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TargetGpaApiResponse"}}}},"400":{"description":"잘못된 GPA 입력 (ErrorCode: C01, INVALID_ARGUMENT)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}},"404":{"description":"학생 정보 없음 (ErrorCode: S01, STUDENT_NOT_FOUND)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}}},"security":[{"bearerAuth":[]}]}},"/api/student/reset":{"post":{"tags":["Student"],"summary":"사용자 정보 초기화","description":"로그인된 사용자의 정보를 초기화합니다.","operationId":"resetStudentData","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SuccessResponseMessageOnlyResponse"}}}}},"security":[{"bearerAuth":[]}]}},"/api/auth/refresh":{"post":{"tags":["Auth"],"summary":"토큰 재발급","description":"리프레시 토큰을 사용해 새로운 액세스 토큰과 리프레시 토큰을 발급합니다.","operationId":"refreshResponse","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshRequest"}}},"required":true},"responses":{"200":{"description":"토큰 재발급 성공","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshTokenApiResponse"}}}},"401":{"description":"인증 실패 (ErrorCode: T04, T10, T12, T11)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}},"404":{"description":"사용자 정보 없음 (ErrorCode: U01)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}}}}},"/sentry-test":{"get":{"tags":["check-controller"],"operationId":"sentryTest","responses":{"200":{"description":"OK"}}}},"/portal/link/jobs/{jobId}":{"get":{"tags":["portal-job-query-controller"],"operationId":"getJobStatus","parameters":[{"name":"jobId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SuccessResponseJobStatusResponse"}}}}}}},"/portal/link/jobs/{jobId}/summary":{"get":{"tags":["portal-job-query-controller"],"operationId":"getJobSummary","parameters":[{"name":"jobId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SuccessResponseJobSummaryResponse"}}}}}}},"/health":{"get":{"tags":["check-controller"],"operationId":"health","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"string"}}}}}}},"/api/student/profile":{"get":{"tags":["Student"],"summary":"사용자 프로필 조회","description":"로그인된 사용자의 프로필 정보를 조회합니다.","operationId":"getProfile","responses":{"200":{"description":"사용자 프로필 정보 조회 성공","content":{"*/*":{"schema":{"$ref":"#/components/schemas/StudentProfileApiResponse"}}}},"404":{"description":"학생 정보 없음 (ErrorCode: S01, STUDENT_NOT_FOUND)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}}},"security":[{"bearerAuth":[]}]}},"/api/semester":{"get":{"tags":["semester-controller"],"summary":"사용자 학기 목록 조회","description":"사용자의 모든 학기 정보를 조회합니다.","operationId":"getSemesterRecord","responses":{"200":{"description":"사용자의 모든 학기 정보 조회 성공","content":{"*/*":{"schema":{"$ref":"#/components/schemas/StudentSemesterListApiResponse"}}}},"404":{"description":"학기 데이터 없음 (ErrorCode: A03, FRESHMAN_NO_SEMESTER)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}},"500":{"description":"서버 내부 오류 (ErrorCode: INTERNAL_ERROR)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}}},"security":[{"bearerAuth":[]}]}},"/api/semester/grades":{"get":{"tags":["semester-controller"],"summary":"사용자 학기 별 성적 조회","description":"사용자의 학기 별 성적 정보를 조회합니다.","operationId":"getSemesterGrades","responses":{"200":{"description":"사용자의 학기 별 성적 정보 조회 성공","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SemesterGradesApiResponse"}}}},"404":{"description":"학기 성적 데이터 없음 (ErrorCode: A02, SEMESTER_RECORD_EMPTY)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}},"500":{"description":"서버 내부 오류 (ErrorCode: INTERNAL_ERROR)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}}},"security":[{"bearerAuth":[]}]}},"/api/graduation/progress":{"get":{"tags":["Graduation"],"summary":"졸업 요건 진행 상황 조회","description":"로그인된 사용자의 졸업 요건 충족 여부를 조회합니다.","operationId":"getGraduationProgress","responses":{"200":{"description":"졸업 요건 충족 여부 조회 성공","content":{"*/*":{"schema":{"$ref":"#/components/schemas/GraduationProgressApiResponse"}}}},"404":{"description":"졸업 요건 정보 없음 (ErrorCode: G01, GRADUATION_REQUIREMENTS_NOT_FOUND)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}}},"security":[{"bearerAuth":[]}]}},"/api/academic/summary":{"get":{"tags":["academic-record-controller"],"summary":"사용자 학업 요약 정보 조회","description":"로그인된 사용자의 학업 요약 정보를 조회합니다.","operationId":"getAcademicSummary","responses":{"200":{"description":"학업 요약 정보 조회 성공","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AcademicSummaryApiResponse"}}}},"401":{"description":"인증 실패","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}},"404":{"description":"학업 요약 정보 없음 또는 사용자 정보 없음 (U02, S01)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}},"500":{"description":"서버 내부 오류","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}}},"security":[{"bearerAuth":[]}]}},"/api/academic/record":{"get":{"tags":["academic-record-controller"],"summary":"학기별 성적 및 수강 과목 정보 조회","description":"지정한 학기(year, semester)에 해당하는 성적 및 수강 과목 정보를 조회합니다.","operationId":"getAcademicRecord","parameters":[{"name":"year","in":"query","description":"연도","required":true,"schema":{"type":"integer","format":"int32"},"example":2024},{"name":"semester","in":"query","description":"학기","required":true,"schema":{"type":"integer","format":"int32"},"example":"10, 15, 20 ..."}],"responses":{"200":{"description":"지정 학기 성적 및 수강 과목 조회 성공","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AcademicRecordApiResponse"}}}},"401":{"description":"인증 실패","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}},"404":{"description":"해당 학기 성적 데이터 없음 (A01)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}},"500":{"description":"서버 내부 오류","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}}},"security":[{"bearerAuth":[]}]}},"/api/users/delete":{"delete":{"tags":["User"],"summary":"회원 탈퇴","description":"로그인된 사용자의 계정을 삭제합니다.","operationId":"deleteUser","responses":{"200":{"description":"회원 탈퇴 성공","content":{"*/*":{"schema":{"$ref":"#/components/schemas/DeleteUserApiResponse"}}}},"404":{"description":"사용자 정보 없음 (ErrorCode: U01, USER_NOT_FOUND)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponseWrapper"}}}}},"security":[{"bearerAuth":[]}]}}},"components":{"schemas":{"LinkRequest":{"required":["password","portal_type","username"],"type":"object","properties":{"portal_type":{"type":"string","description":"포털 타입","example":"suwon"},"username":{"type":"string","description":"포털 아이디","example":"17019013"},"password":{"type":"string","description":"포털 비밀번호","example":"pw"}},"description":"포털 연동 job 생성 요청"},"AcceptedResponse":{"type":"object","properties":{"job_id":{"type":"string","description":"job id","example":"job-123"},"polling_endpoint":{"type":"string","description":"상태 조회 경로","example":"/portal/link/jobs/job-123"},"status":{"type":"string","description":"수락 상태","example":"accepted"}},"description":"스크래핑 job 수락 응답"},"SuccessResponseAcceptedResponse":{"required":["data","success"],"type":"object","properties":{"success":{"type":"boolean","description":"성공 여부","example":true},"data":{"$ref":"#/components/schemas/AcceptedResponse"},"message":{"type":"string","description":"메시지","example":"요청 성공"}},"description":"성공 응답 포맷"},"MessageOnlyResponse":{"type":"object","properties":{"message":{"type":"string","description":"결과 메시지","example":"목표 학점 저장 완료"}},"description":"메시지 응답 DTO"},"ErrorDetail":{"required":["code","message"],"type":"object","properties":{"code":{"type":"string","description":"에러 코드","example":"U01"},"message":{"type":"string","description":"에러 메시지","example":"해당 사용자를 찾을 수 없습니다."},"details":{"type":"object","description":"에러 추가 정보","nullable":true}},"description":"에러 상세 정보"},"ErrorResponseWrapper":{"required":["success"],"type":"object","properties":{"success":{"type":"boolean","description":"성공 여부","example":false},"error":{"$ref":"#/components/schemas/ErrorDetail"}},"description":"API 에러 응답 포맷"},"SignInApiResponse":{"required":["data","success"],"type":"object","properties":{"success":{"type":"boolean","description":"성공 여부","example":true},"data":{"$ref":"#/components/schemas/SignInResponse"},"message":{"type":"string","description":"메시지","example":"요청 성공"}},"description":"회원가입 및 로그인 응답"},"SignInResponse":{"required":["accessToken","isPortalLinked","refreshToken"],"type":"object","properties":{"accessToken":{"type":"string","description":"Access Token","example":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."},"refreshToken":{"type":"string","description":"Refresh Token","example":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."},"isPortalLinked":{"type":"boolean","description":"포털 연동 여부","example":true}},"description":"회원가입 및 로그인 응답"},"SignInRequest":{"required":["id_token","nonce","provider"],"type":"object","properties":{"provider":{"type":"string","description":"OIDC Provider","example":"KAKAO","enum":["KAKAO","APPLE"]},"id_token":{"type":"string","description":"OIDC Provider에서 발급받은 ID 토큰"},"nonce":{"type":"string","description":"로그인 시 사용한 nonce 값","example":"random_nonce_value"}},"description":"소셜 로그인 요청 정보"},"TargetGpaApiResponse":{"required":["data","success"],"type":"object","properties":{"success":{"type":"boolean","description":"성공 여부","example":true},"data":{"$ref":"#/components/schemas/MessageOnlyResponse"},"message":{"type":"string","description":"메시지","example":"요청 성공"}},"description":"목표 GPA 설정 응답"},"SuccessResponseMessageOnlyResponse":{"required":["data","success"],"type":"object","properties":{"success":{"type":"boolean","description":"성공 여부","example":true},"data":{"$ref":"#/components/schemas/MessageOnlyResponse"},"message":{"type":"string","description":"메시지","example":"요청 성공"}},"description":"성공 응답 포맷"},"RefreshResponse":{"required":["accessToken","refreshToken"],"type":"object","properties":{"accessToken":{"type":"string","description":"액세스 토큰"},"refreshToken":{"type":"string","description":"리프레시 토큰"}},"description":"Refresh Response DTO"},"RefreshTokenApiResponse":{"required":["data","success"],"type":"object","properties":{"success":{"type":"boolean","description":"성공 여부","example":true},"data":{"$ref":"#/components/schemas/RefreshResponse"},"message":{"type":"string","description":"메시지","example":"요청 성공"}},"description":"토큰 재발급 응답 포맷"},"RefreshRequest":{"required":["refreshToken"],"type":"object","properties":{"refreshToken":{"type":"string","description":"Refresh Token"}},"description":"리프레시 토큰 요청 DTO"},"JobStatusResponse":{"type":"object","properties":{"job_id":{"type":"string"},"portal_type":{"type":"string"},"error_code":{"type":"string"},"error_message":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"finished_at":{"type":"string","format":"date-time"},"status":{"type":"string"},"retryable":{"type":"boolean"}},"description":"스크래핑 job 상태 응답"},"SuccessResponseJobStatusResponse":{"required":["data","success"],"type":"object","properties":{"success":{"type":"boolean","description":"성공 여부","example":true},"data":{"$ref":"#/components/schemas/JobStatusResponse"},"message":{"type":"string","description":"메시지","example":"요청 성공"}},"description":"성공 응답 포맷"},"JobSummaryResponse":{"type":"object","properties":{"job_id":{"type":"string"},"studentInfo":{"$ref":"#/components/schemas/StudentInfoSummary"},"finished_at":{"type":"string","format":"date-time"},"status":{"type":"string"}},"description":"스크래핑 job 요약 응답"},"StudentInfoSummary":{"type":"object","properties":{"name":{"type":"string"},"school":{"type":"string"},"majorName":{"type":"string"},"studentCode":{"type":"string"},"gradeLevel":{"type":"integer","format":"int32"},"status":{"type":"string"},"completedSemesterType":{"type":"integer","format":"int32"}},"description":"포털 학생 요약 정보"},"SuccessResponseJobSummaryResponse":{"required":["data","success"],"type":"object","properties":{"success":{"type":"boolean","description":"성공 여부","example":true},"data":{"$ref":"#/components/schemas/JobSummaryResponse"},"message":{"type":"string","description":"메시지","example":"요청 성공"}},"description":"성공 응답 포맷"},"StudentProfileApiResponse":{"required":["data","success"],"type":"object","properties":{"success":{"type":"boolean","description":"성공 여부","example":true},"data":{"$ref":"#/components/schemas/StudentProfileResponse"},"message":{"type":"string","description":"메시지","example":"요청 성공"}},"description":"학생 프로필 응답"},"StudentProfileResponse":{"required":["currentSemester","departmentName","gradeLevel","lastSyncedAt","lastUpdatedAt","majorName","name","reconnectionRequired","status","studentCode"],"type":"object","properties":{"name":{"type":"string","description":"이름"},"studentCode":{"type":"string","description":"학번"},"departmentName":{"type":"string","description":"학과 이름"},"majorName":{"type":"string","description":"전공 이름"},"dualMajorName":{"type":"string","description":"복수전공 이름"},"gradeLevel":{"type":"integer","description":"학년","format":"int32"},"currentSemester":{"type":"integer","description":"현재 학기","format":"int32"},"status":{"type":"string","description":"재학 상태","enum":["재학","휴학","졸업","제적","수료"]},"lastUpdatedAt":{"type":"string","description":"마지막 업데이트 일시"},"lastSyncedAt":{"type":"string","description":"학사 정보 마지막 연동 일시"},"reconnectionRequired":{"type":"boolean","description":"재연동 필요 여부"}},"description":"학생 프로필 정보"},"StudentSemesterInfoResponse":{"required":["semester","year"],"type":"object","properties":{"year":{"type":"integer","description":"이수 연도","format":"int32","example":2023},"semester":{"type":"integer","description":"이수 학기 코드 (10: 1학기, 15: 여름학기, 20: 2학기, 25: 겨울학기)","format":"int32","example":10}},"description":"학생의 이수 학기 정보"},"StudentSemesterListApiResponse":{"required":["data","success"],"type":"object","properties":{"success":{"type":"boolean","description":"성공 여부","example":true},"data":{"type":"array","description":"응답 데이터","items":{"$ref":"#/components/schemas/StudentSemesterInfoResponse"}},"message":{"type":"string","description":"메시지","example":"요청 성공"}},"description":"사용자 학기 목록 응답"},"SemesterGradesApiResponse":{"required":["data","success"],"type":"object","properties":{"success":{"type":"boolean","description":"성공 여부","example":true},"data":{"type":"array","description":"응답 데이터","items":{"$ref":"#/components/schemas/SemesterSummaryResponse"}},"message":{"type":"string","description":"메시지","example":"요청 성공"}},"description":"학기별 성적 목록 응답"},"SemesterSummaryResponse":{"required":["semester","year"],"type":"object","properties":{"year":{"type":"integer","description":"이수 연도","format":"int32","example":2023},"semester":{"type":"integer","description":"학기 코드 (10: 1학기, 15: 여름학기, 20: 2학기, 25: 겨울학기)","format":"int32","example":10},"earnedCredits":{"type":"integer","description":"취득 학점","format":"int32","nullable":true,"example":15},"attemptedCredits":{"type":"integer","description":"신청 학점","format":"int32","nullable":true,"example":18},"semesterGpa":{"type":"number","description":"학기 GPA (평점 평균)","nullable":true,"example":3.85},"classRank":{"type":"integer","description":"석차","format":"int32","nullable":true,"example":5},"totalStudents":{"type":"integer","description":"전체 학생 수","format":"int32","nullable":true,"example":150},"percentile":{"type":"number","description":"백분율","nullable":true,"example":92.4}},"description":"학기 요약 정보 (성적 포함)"},"AreaProgressDto":{"required":["areaType","completedElectiveCourses","courses","earnedCredits","requiredCredits","requiredElectiveCourses","totalElectiveCourses"],"type":"object","properties":{"areaType":{"type":"string","description":"영역 유형 (예: 전핵, 전선, 선교 등)","enum":["중핵","기교","선교","소교","전교","전취","전핵","전선","일선","복선","복핵","복교"]},"requiredCredits":{"type":"integer","description":"해당 영역에서 필요한 학점","format":"int32","example":60},"earnedCredits":{"type":"integer","description":"학생이 이수한 학점","format":"int32","example":45},"requiredElectiveCourses":{"type":"integer","description":"필수 선택 과목 수","format":"int32","example":2},"completedElectiveCourses":{"type":"integer","description":"학생이 이수한 선택 과목 수","format":"int32","example":1},"totalElectiveCourses":{"type":"integer","description":"총 선택 과목 수","format":"int32","example":3},"courses":{"type":"array","description":"학생이 이수한 과목 목록","items":{"$ref":"#/components/schemas/CourseDto"}}},"description":"졸업 요건 영역별 이수 현황"},"CourseDto":{"required":["courseName","credits","grade","semester","year"],"type":"object","properties":{"year":{"type":"integer","description":"이수 연도","format":"int32","example":2023},"courseName":{"type":"string","description":"과목명","example":"자료구조"},"credits":{"type":"integer","description":"학점","format":"int32","example":3},"grade":{"type":"string","description":"성적","example":"A+"},"semester":{"type":"integer","description":"이수 학기","format":"int32","example":10}},"description":"이수 과목 정보"},"GraduationProgressApiResponse":{"required":["data","success"],"type":"object","properties":{"success":{"type":"boolean","description":"성공 여부","example":true},"data":{"$ref":"#/components/schemas/GraduationProgressResponse"},"message":{"type":"string","description":"메시지","example":"요청 성공"}},"description":"졸업 요건 진행 상황 응답"},"GraduationProgressResponse":{"required":["graduationProgress","hasDifferentGraduationRequirement"],"type":"object","properties":{"graduationProgress":{"type":"array","description":"졸업 요건 영역별 이수 현황","items":{"$ref":"#/components/schemas/AreaProgressDto"}},"hasDifferentGraduationRequirement":{"type":"boolean","description":"특정 학과/연도 예외로 기존과 다른 졸업요건이 적용되는지 여부"}},"description":"졸업 요건 진행 상황 응답"},"AcademicSummaryApiResponse":{"required":["data","success"],"type":"object","properties":{"success":{"type":"boolean","description":"성공 여부","example":true},"data":{"$ref":"#/components/schemas/AcademicSummaryResponse"},"message":{"type":"string","description":"메시지","example":"요청 성공"}},"description":"학업 요약 정보 응답"},"AcademicSummaryResponse":{"required":["cumulativeGpa","percentile","requiredCredits","totalEarnedCredits"],"type":"object","properties":{"totalEarnedCredits":{"type":"integer","description":"총 취득 학점","format":"int32","example":120},"cumulativeGpa":{"type":"number","description":"누적 GPA","example":3.76},"percentile":{"type":"number","description":"전체 백분위","example":87.5},"requiredCredits":{"type":"integer","description":"필요 졸업 학점","format":"int32","example":130}},"description":"학업 성적 요약 정보"},"AcademicRecordApiResponse":{"required":["data","success"],"type":"object","properties":{"success":{"type":"boolean","description":"성공 여부","example":true},"data":{"$ref":"#/components/schemas/AcademicRecordResponse"},"message":{"type":"string","description":"메시지","example":"요청 성공"}},"description":"학기별 성적 및 수강 과목 정보 응답"},"AcademicRecordResponse":{"required":["courses","semesterGrade"],"type":"object","properties":{"semesterGrade":{"$ref":"#/components/schemas/SemesterGradeResponse"},"courses":{"$ref":"#/components/schemas/Courses"}},"description":"학기별 성적 및 수강 과목 응답"},"CourseDetailDto":{"type":"object","properties":{"id":{"type":"string","description":"수강 ID"},"courseName":{"type":"string","description":"과목명"},"courseCode":{"type":"string","description":"학수번호"},"areaType":{"type":"string","description":"영역 (전공/교양 등)","enum":["중핵","기교","선교","소교","전교","전취","전핵","전선","일선","복선","복핵","복교"]},"credits":{"type":"integer","description":"학점","format":"int32"},"professor":{"type":"string","description":"교수명"},"grade":{"type":"string","description":"성적"},"score":{"type":"integer","description":"실 점수","format":"int32"},"isRetake":{"type":"boolean","description":"재수강 여부"},"isOnline":{"type":"boolean","description":"사이버 강의 여부"},"year":{"type":"integer","description":"이수 연도","format":"int32"},"semester":{"type":"integer","description":"이수 학기","format":"int32"},"originalScore":{"type":"integer","description":"원점수","format":"int32"},"isRetakeDelete":{"type":"boolean","description":"재수강 삭제 과목 여부"}},"description":"수강 과목 상세 정보"},"Courses":{"required":["liberal","major"],"type":"object","properties":{"major":{"type":"array","description":"전공 과목 목록","items":{"$ref":"#/components/schemas/CourseDetailDto"}},"liberal":{"type":"array","description":"교양 과목 목록","items":{"$ref":"#/components/schemas/CourseDetailDto"}}},"description":"수강 과목 목록"},"SemesterGradeResponse":{"required":["attemptedCredits","classRank","earnedCredits","percentile","semester","semesterGpa","totalStudents","year"],"type":"object","properties":{"year":{"type":"integer","description":"이수 연도","format":"int32","example":2024},"semester":{"type":"integer","description":"학기 코드 (10: 1학기, 15: 여름학기, 20: 2학기, 25: 겨울학기)","format":"int32","example":10},"earnedCredits":{"type":"integer","description":"취득 학점","format":"int32","example":15},"attemptedCredits":{"type":"integer","description":"신청 학점","format":"int32","example":18},"semesterGpa":{"type":"number","description":"학기 GPA (평점 평균)","example":3.85},"classRank":{"type":"integer","description":"석차","format":"int32","nullable":true,"example":5},"totalStudents":{"type":"integer","description":"전체 학생 수","format":"int32","nullable":true,"example":150},"percentile":{"type":"number","description":"백분율","example":92.4}},"description":"학기 성적 요약 정보"},"DeleteUserApiResponse":{"required":["data","success"],"type":"object","properties":{"success":{"type":"boolean","description":"성공 여부","example":true},"data":{"$ref":"#/components/schemas/MessageOnlyResponse"},"message":{"type":"string","description":"메시지","example":"요청 성공"}},"description":"회원 탈퇴 응답 포맷"}},"securitySchemes":{"bearerAuth":{"type":"http","description":"JWT 인증 토큰을 입력하세요","scheme":"bearer","bearerFormat":"JWT"}}}} No newline at end of file | |||
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
python - <<'PY'
import json
from pathlib import Path
spec = json.loads(Path("swagger/openapi-dev.json").read_text())
global_security = spec.get("security")
methods = {"get", "post", "put", "patch", "delete", "options", "head", "trace"}
print("Global security:", global_security)
print("Operations inheriting global security:\n")
for path, path_item in spec.get("paths", {}).items():
for method, operation in path_item.items():
if method.lower() not in methods:
continue
if "security" not in operation and global_security:
print(f"{method.upper():6} {path}")
PYRepository: gyumong/chukchuk-haksa
Length of output: 355
루트 레벨 security 선언이 공개 엔드포인트를 포함한 모든 operation으로 상속되고 있습니다.
OpenAPI 3.0에서는 최상위 security 정의가 각 operation으로 상속됩니다. 현재 스펙에서 {"bearerAuth":[]} 요구사항이 /api/users/signin, /api/auth/refresh, /health, /internal/scrape-results 등 인증 없이 호출되어야 하는 엔드포인트까지 전파되어, 문서와 생성된 클라이언트에 잘못된 계약을 나타냅니다.
다음 8개 operation이 영향을 받습니다:
- POST /portal/link
- POST /internal/scrape-results
- POST /api/users/signin
- POST /api/auth/refresh
- GET /sentry-test
- GET /portal/link/jobs/{jobId}
- GET /portal/link/jobs/{jobId}/summary
- GET /health
공개 엔드포인트에는 명시적으로 "security": []를 추가하거나, 반대로 루트 레벨 security를 제거하고 인증이 필요한 operation만 opt-in 방식으로 "security": [{"bearerAuth":[]}]를 선언하는 방식으로 수정이 필요합니다.
🧰 Tools
🪛 Checkov (3.2.526)
[medium] 1: Ensure that arrays have a maximum number of items
(CKV_OPENAPI_21)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@swagger/openapi-dev.json` at line 1, The root-level "security":
[{"bearerAuth": []}] is being inherited by all operations (e.g., operationId
signInUser, refreshResponse, health, handleCallback, createPortalLinkJob,
getJobStatus, getJobSummary, sentryTest), incorrectly marking public endpoints
as authenticated; fix by removing the top-level security entry and instead add
"security": [{"bearerAuth": []}] only to operations that require authentication
(e.g., getProfile, getSemesterRecord, getSemesterGrades, getGraduationProgress,
getAcademicSummary, getAcademicRecord, deleteUser, setTargetGpa,
resetStudentData, etc.), or alternatively keep the root security and explicitly
mark the listed public operations' paths (POST /portal/link createPortalLinkJob,
POST /internal/scrape-results handleCallback, POST /api/users/signin signInUser,
POST /api/auth/refresh refreshResponse, GET /sentry-test sentryTest, GET
/portal/link/jobs/{jobId} getJobStatus, GET /portal/link/jobs/{jobId}/summary
getJobSummary, GET /health health) with "security": [] to override inheritance;
choose one approach and apply consistently across the OpenAPI document.
dev → main 통합 PR(#168) CodeRabbit 리뷰의 actionable 항목 일괄 반영. 주요 변경 - shared/api/http-client.ts: 수동 편집된 baseUrl 하드코딩 제거 (생성 파일 원복). 실제 baseUrl 은 httpConfig 의 createApiConfig() 가 환경 기반으로 주입. - lib/webview/bridge.ts: postBridgeMessage / navigateNative 가 boolean 반환. /mpa/home 의 navigate 호출에 router.push fallback 추가. - features/portal-link/hooks/usePortalLinkJobPolling.ts: setIsTimedOut 을 refetchInterval 콜백 밖(useEffect+setTimeout)으로 이동. isLoading / isError / error 도 함께 노출. - app/(funnel)/scraping/page.tsx: handledRef 가드로 중복 setStudentInfo/router.push 차단. - app/resync/scraping/page.tsx, app/(mpa)/mpa/resync/scraping/page.tsx: sessionStorage 동기 초기화로 인한 SSR/CSR hydration mismatch 해소 (useEffect 로 옮기고 isJobIdResolved 가드 추가). - app/api/session/refresh/route.ts: 10s AbortController 타임아웃 추가, 504 응답 처리. - app/auth/callback/route.ts: isPortalLinked 검증을 typeof === 'boolean' 으로 강화. - app/auth/success/page.tsx: Tailwind 클래스 → SCSS 모듈로 전환, 로딩/에러 UX 추가. - app/(funnel)/portal-login/components/PortalLoginForm: job_id 누락 케이스에서도 onError 호출, catch param 을 unknown 으로 좁힘. - app/(funnel)/contexts: 중복 hook useStudentInfo 제거하고 useFunnelContext 로 통합. - features/dashboard/components/SyncUpdateButton: lastSyncedAt 없을 때 '정보 업데이트' fallback. - features/portal-link/hooks/usePortalLinkSummary: jobId! non-null assertion 제거, 명시적 가드. - app/terms/page.tsx: 상대경로 SCSS 임포트를 @/app/... alias 로 변경. - docs/mpa-school-link-handoff.md: 시퀀스 다이어그램의 isPortalLinked 표기를 실제 계약과 일치 (서버 강제 false). fenced code block 에 language tag 추가. - package.json: wrangler ^4 → ^4.59.1 (CVE), @opennextjs/cloudflare ^1 → ^1.19.8. - wrangler.jsonc: compatibility_date 2024-09-23 → 2026-05-10. 검증 - yarn type-check: PASS - yarn lint: 0 errors (warnings 는 기존 잔여) - yarn build: 24 pages 정상 생성 스킵 - swagger/openapi-dev.json security 상속: 백엔드(Spring Boot)에서 fetch 되므로 로컬 패치는 다음 api:fetch 시 유실. @securityrequirement 어노테이션 단으로 이관 필요. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #168 리뷰 반영 중 과하게 적용된 두 항목 되돌림. - @opennextjs/cloudflare ^1.19.8 → ^1 원복. CodeRabbit 자신이 선택사항이라 표시했고, ^1 도 어차피 최신 1.x 로 해석되어 install 결과가 사실상 동일. 차이는 package.json 표기뿐. - src/app/auth/success/page.tsx: 1.5s setTimeout 후 redirect 와 스피너/에러 UI 제거. CodeRabbit 권유 범위를 넘어 기존 동작(즉시 router.replace)을 바꾸는 새 UX 였음. SCSS 모듈 전환(인라인/Tailwind 금지 가이드라인)만 유지하고, 마크업은 원래 동작과 동일하게 빈 컨테이너 한 줄로 단순화. success.module.scss 도 .container 만 남기고 사용하지 않는 클래스 제거. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chore: address PR #168 CodeRabbit review
주요 반영 사항
서버리스 구조 반영
백엔드 인프라 이전에 맞춰 해당 프로젝트에 반영
JWT 이슈 해결
라우터 보안 취약점 해결, iron-session 처리하여 토큰 암호화 진행
뒤로가기 버튼 없는 Privacy Policy 화면 작업
경로 - /terms 에 관련 내용 반영
추후 /terms 형식이 아닌 Agent 기반으로 처리 고려
Cloudflare Pages(workers)로 프론트 배포 이전
현재 dev(staging)에는 반영되어있으나, main(release)에는 반영되어 있지 않음.
해당 부분 깃허브 CI/CD에 변수 PROD 로 반영되어있음.
배너 부분을 제외한 나머지 부분의 웹뷰화면 처리
기존 /mpa 반영 계획에서, 대안 반영 폐기의 일환으로 위 상태를 제거한 아래 웹뷰화면 처리 완료
/mpa/~~ 형식으로 접근 가능
업데이트 시간 반영
서버리스 구조 반영에서 바뀐 동기화 방식으로 인해 업데이트 시간의 실시간화가 떨어짐.
목표하는 성능을 요구하고자 이에 대해 동기화 시간을 좀 더 짧게 주는 방식으로 해결
Minor Fix
/graduation-progress
웹에서도 모바일 화면처럼 보이게 max-width을 조정했습니다.
Check-list
Summary by CodeRabbit
릴리스 노트
New Features
Improvements
Documentation