diff --git a/.github/workflows/PROJECT-FLUTTER-CI.yaml b/.github/workflows/PROJECT-FLUTTER-CI.yaml index 8c309e1..f279c15 100644 --- a/.github/workflows/PROJECT-FLUTTER-CI.yaml +++ b/.github/workflows/PROJECT-FLUTTER-CI.yaml @@ -1,174 +1,693 @@ -name: Project Flutter CI +# =================================================================== +# Flutter CI (코드 분석 및 빌드 검증) 워크플로우 +# =================================================================== +# +# 이 워크플로우는 PR 생성 또는 main 브랜치 푸시 시 +# 코드 정적 분석(flutter analyze)과 Android/iOS 앱 빌드를 검증합니다. +# +# 주요 특징: +# - PR 생성/업데이트 시 자동 실행 +# - main 브랜치 푸시 시 자동 실행 +# - flutter analyze로 코드 품질 검사 +# - Android/iOS 빌드 개별 활성화/비활성화 가능 +# - Analyze Only 모드: 빌드 없이 코드 분석만 실행 가능 +# - PR인 경우 진행 상황을 댓글로 실시간 업데이트 +# - 빌드 실패 시 상세 에러 정보 제공 +# - Analyze와 빌드 병렬 실행으로 시간 단축 +# +# 사용 방법: +# 1. 프로젝트에 맞게 상단 env 섹션의 값들을 수정하세요 +# 2. ANALYZE_ONLY를 true로 설정하면 빌드 없이 코드 분석만 실행 +# 3. ENABLE_ANDROID, ENABLE_IOS로 빌드 대상 플랫폼 선택 +# 4. ENV_FILE_PATH로 환경변수 파일 경로 커스터마이징 +# +# =================================================================== +# 🔧 프로젝트별 설정 +# =================================================================== +# +# ANALYZE_ONLY : true면 analyze만 실행, 빌드 스킵 (기본: false) +# ENABLE_ANDROID : Android 빌드 활성화 여부 (ANALYZE_ONLY=false일 때) +# ENABLE_IOS : iOS 빌드 활성화 여부 (ANALYZE_ONLY=false일 때) +# FLUTTER_VERSION : Flutter SDK 버전 +# JAVA_VERSION : Java 버전 (Android 빌드용) +# XCODE_VERSION : Xcode 버전 (iOS 빌드용) +# ENV_FILE_PATH : .env 파일 경로 (기본값: 루트의 .env) +# +# =================================================================== +# 📋 필요한 GitHub Secrets (선택) +# =================================================================== +# +# 📝 환경 설정: +# - ENV_FILE (또는 ENV) : .env 파일 내용 (앱에서 사용하는 환경변수) +# +# ※ 참고: CI는 빌드 검증 목적이므로 서명/배포 관련 Secrets 불필요 +# 배포용 빌드는 별도 CD 워크플로우 사용 +# +# =================================================================== + +name: PROJECT-Flutter-CI -# PR과 main/develop 브랜치 push 시 자동 실행 -# Android, iOS 플랫폼만 검증 (macOS, Linux, Web 제외) on: - push: - branches: [main] pull_request: branches: [main] - workflow_dispatch: # 수동 실행 허용 + types: [opened, synchronize, reopened] + push: + branches: [main] + workflow_dispatch: + inputs: + analyze_only: + description: "Analyze Only 모드 (빌드 없이 코드 분석만)" + type: boolean + default: false + enable_android: + description: "Android 빌드 활성화 (Analyze Only=false일 때)" + type: boolean + default: true + enable_ios: + description: "iOS 빌드 활성화 (Analyze Only=false일 때)" + type: boolean + default: true permissions: contents: read - pull-requests: write # PR에 댓글을 달기 위한 권한 + pull-requests: write + +# ============================================ +# 🔧 프로젝트별 설정 (아래 값들을 수정하세요) +# ============================================ +env: + # 🎯 CI 모드 설정 + ANALYZE_ONLY: "false" # true: analyze만 실행 (빌드 스킵), false: analyze + 빌드 + + # 🎯 빌드 대상 플랫폼 설정 (ANALYZE_ONLY=false일 때만 적용) + ENABLE_ANDROID: "true" # Android 빌드 활성화 (true/false) + ENABLE_IOS: "true" # iOS 빌드 활성화 (true/false) + + # 🔧 프로젝트별 설정 + FLUTTER_VERSION: "3.35.5" + JAVA_VERSION: "17" + XCODE_VERSION: "26.0" + ENV_FILE_PATH: ".env" # 환경변수 파일 경로 (커스터마이징 가능) + +concurrency: + group: flutter-ci-${{ github.ref }} + cancel-in-progress: true jobs: - # 코드 품질 검증 작업 + # ============================================ + # Job 1: 준비 단계 + # ============================================ + prepare: + name: CI 준비 + runs-on: ubuntu-latest + + outputs: + comment_id: ${{ steps.comment.outputs.comment_id }} + start_time: ${{ steps.comment.outputs.start_time }} + branch_name: ${{ steps.info.outputs.branch_name }} + commit_hash: ${{ steps.info.outputs.commit_hash }} + analyze_only: ${{ steps.config.outputs.analyze_only }} + enable_android: ${{ steps.config.outputs.enable_android }} + enable_ios: ${{ steps.config.outputs.enable_ios }} + + steps: + # 설정 값 outputs으로 전달 + # workflow_dispatch 입력값이 있으면 우선 사용, 없으면 env 값 사용 + - name: 설정 값 전달 + id: config + run: | + # workflow_dispatch 입력값 확인 + if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + ANALYZE_ONLY="${{ github.event.inputs.analyze_only }}" + ENABLE_ANDROID="${{ github.event.inputs.enable_android }}" + ENABLE_IOS="${{ github.event.inputs.enable_ios }}" + else + ANALYZE_ONLY="${{ env.ANALYZE_ONLY }}" + ENABLE_ANDROID="${{ env.ENABLE_ANDROID }}" + ENABLE_IOS="${{ env.ENABLE_IOS }}" + fi + + echo "analyze_only=$ANALYZE_ONLY" >> $GITHUB_OUTPUT + echo "enable_android=$ENABLE_ANDROID" >> $GITHUB_OUTPUT + echo "enable_ios=$ENABLE_IOS" >> $GITHUB_OUTPUT + echo "📋 CI 설정:" + echo " Analyze Only: $ANALYZE_ONLY" + echo " Android: $ENABLE_ANDROID" + echo " iOS: $ENABLE_IOS" + echo " 트리거: ${{ github.event_name }}" + + # 빌드 정보 수집 + - name: 빌드 정보 수집 + id: info + run: | + if [ "${{ github.event_name }}" == "pull_request" ]; then + BRANCH_NAME="${{ github.head_ref }}" + else + BRANCH_NAME="${{ github.ref_name }}" + fi + COMMIT_HASH=$(echo "${{ github.sha }}" | cut -c1-7) + + echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT + echo "commit_hash=$COMMIT_HASH" >> $GITHUB_OUTPUT + + echo "📋 빌드 정보:" + echo " 브랜치: $BRANCH_NAME" + echo " 커밋: $COMMIT_HASH" + + # PR인 경우 진행 상황 댓글 생성 + - name: 진행 상황 댓글 생성 + id: comment + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const prNumber = context.payload.pull_request.number; + const branchName = '${{ steps.info.outputs.branch_name }}'; + const commitHash = '${{ steps.info.outputs.commit_hash }}'; + const analyzeOnly = '${{ steps.config.outputs.analyze_only }}' === 'true'; + const enableAndroid = '${{ steps.config.outputs.enable_android }}' === 'true'; + const enableIos = '${{ steps.config.outputs.enable_ios }}' === 'true'; + const runId = '${{ github.run_id }}'; + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; + const startTime = Date.now(); + + // 헤더 결정 + const header = analyzeOnly + ? '## 🔍 Flutter CI (Analyze Only) 진행 중...' + : '## 🔨 Flutter CI 진행 중...'; + + // 플랫폼 상태 결정 + let androidStatus, iosStatus; + if (analyzeOnly) { + androidStatus = '⏸️ 스킵'; + iosStatus = '⏸️ 스킵'; + } else { + androidStatus = enableAndroid ? '⏳ 진행 중...' : '⏸️ 비활성화'; + iosStatus = enableIos ? '⏳ 진행 중...' : '⏸️ 비활성화'; + } + + const bodyParts = [ + header, + '', + '| 검사 항목 | 상태 | 소요 시간 |', + '|----------|------|----------|', + '| 🔍 Analyze | ⏳ 진행 중... | - |', + `| 🤖 Android 빌드 | ${androidStatus} | - |`, + `| 🍎 iOS 빌드 | ${iosStatus} | - |`, + '', + '| 항목 | 값 |', + '|------|-----|', + `| **브랜치** | \`${branchName}\` |`, + `| **커밋** | \`${commitHash}\` |` + ]; + + if (analyzeOnly) { + bodyParts.push(''); + bodyParts.push('ℹ️ **Analyze Only 모드**: 빌드 없이 코드 분석만 실행됩니다.'); + } + + bodyParts.push(''); + bodyParts.push(`**[📋 실시간 로그 보기](${runUrl})**`); + bodyParts.push(''); + bodyParts.push('---'); + bodyParts.push('*🤖 이 댓글은 자동으로 업데이트됩니다.*'); + + const body = bodyParts.join('\n'); + + const { data: comment } = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: body + }); + + console.log(`✅ 진행 상황 댓글 생성 완료: #${comment.id}`); + core.setOutput('comment_id', comment.id); + core.setOutput('start_time', startTime); + + # ============================================ + # Job 2: 코드 분석 (병렬) + # ============================================ analyze: - name: Code Analysis + name: 코드 분석 runs-on: ubuntu-latest + needs: prepare + + outputs: + status: ${{ steps.result.outputs.status }} + duration: ${{ steps.result.outputs.duration }} steps: - # 1. 저장소 코드 체크아웃 - - name: Checkout code + - name: 분석 시작 시간 기록 + id: analyze_start + uses: actions/github-script@v7 + with: + script: | + core.setOutput('time', Date.now().toString()); + + - name: Checkout repository uses: actions/checkout@v4 - # 2. Flutter SDK 설치 - - name: Setup Flutter + # .env 파일 생성 + - name: Create .env file + run: | + cat << 'EOF' > ${{ env.ENV_FILE_PATH }} + ${{ secrets.ENV_FILE || secrets.ENV }} + EOF + echo "✅ ${{ env.ENV_FILE_PATH }} file created" + + # Flutter 설정 + - name: Set up Flutter uses: subosito/flutter-action@v2 with: - flutter-version: "3.35.5" - channel: "stable" + flutter-version: ${{ env.FLUTTER_VERSION }} cache: true - # 3. 의존성 설치 + - name: Verify Flutter version + run: | + echo "✅ Flutter setup completed" + flutter --version + + # Flutter 캐시 + - name: Cache Flutter dependencies + uses: actions/cache@v4 + with: + path: ~/.pub-cache + key: ${{ runner.os }}-flutter-pub-${{ hashFiles('**/pubspec.lock') }} + restore-keys: ${{ runner.os }}-flutter-pub- + + # 프로젝트 의존성 설치 - name: Install dependencies - run: flutter pub get + run: | + flutter pub get + echo "✅ Dependencies installed" + + # Flutter Analyze 실행 + - name: Run Flutter Analyze + id: analyze + run: | + echo "🔍 Flutter 코드 분석 시작..." + flutter analyze --no-fatal-infos + echo "✅ 코드 분석 완료" + + # 분석 결과 기록 + - name: 분석 결과 기록 + id: result + if: always() + uses: actions/github-script@v7 + with: + script: | + const analyzeStart = parseInt('${{ steps.analyze_start.outputs.time }}'); + const now = Date.now(); + const elapsed = now - analyzeStart; + const minutes = Math.floor(elapsed / 60000); + const seconds = Math.floor((elapsed % 60000) / 1000); + const duration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`; + + const status = '${{ steps.analyze.outcome }}' === 'success' ? 'success' : 'failure'; + + core.setOutput('status', status); + core.setOutput('duration', duration); + console.log(`📋 코드 분석 결과: ${status} (${duration})`); + + # ============================================ + # Job 3: Android 빌드 (조건부, 병렬) + # ============================================ + build-android: + name: Android 빌드 + if: | + needs.prepare.outputs.analyze_only != 'true' && + needs.prepare.outputs.enable_android == 'true' + runs-on: ubuntu-latest + needs: prepare - # 4. 환경 변수 파일 생성 (.env 파일 - analyze 에러 방지) + outputs: + status: ${{ steps.result.outputs.status }} + duration: ${{ steps.result.outputs.duration }} + + steps: + - name: 빌드 시작 시간 기록 + id: build_start + uses: actions/github-script@v7 + with: + script: | + core.setOutput('time', Date.now().toString()); + + - name: Checkout repository + uses: actions/checkout@v4 + + # .env 파일 생성 - name: Create .env file run: | - cat > .env << 'EOF' - ${{ secrets.ENV }} + cat << 'EOF' > ${{ env.ENV_FILE_PATH }} + ${{ secrets.ENV_FILE || secrets.ENV }} EOF + echo "✅ ${{ env.ENV_FILE_PATH }} file created" - # 5. 코드 포맷팅 검사 - - name: Check formatting - id: format_check - continue-on-error: true + # Flutter 설정 + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + cache: true + + - name: Verify Flutter version run: | - dart format --set-exit-if-changed . 2>&1 | tee format_output.txt - exit ${PIPESTATUS[0]} + echo "✅ Flutter setup completed" + flutter --version - # 6. 정적 분석 - - name: Analyze code - id: analyze_check - continue-on-error: true + # Flutter 캐시 + - name: Cache Flutter dependencies + uses: actions/cache@v4 + with: + path: ~/.pub-cache + key: ${{ runner.os }}-flutter-pub-${{ hashFiles('**/pubspec.lock') }} + restore-keys: ${{ runner.os }}-flutter-pub- + + # Gradle 캐시 + - name: Cache Gradle dependencies + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/build.gradle', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + # 프로젝트 의존성 설치 + - name: Install dependencies run: | - flutter analyze 2>&1 | tee analyze_output.txt - exit ${PIPESTATUS[0]} + flutter pub get + echo "✅ Dependencies installed" + + # Java 설정 + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: ${{ env.JAVA_VERSION }} - # 7. PR에 결과 댓글 달기 (실패 시) - - name: Comment on PR with issues - if: failure() && github.event_name == 'pull_request' + - name: Verify Java version + run: | + echo "✅ Java setup completed" + java -version + + # Gradle 셋업 + - name: Setup Gradle + working-directory: android + run: | + chmod +x gradlew + echo "✅ Gradle wrapper permissions set" + + # APK 빌드 + - name: Build APK + id: build + run: | + echo "📦 Flutter APK 빌드 시작..." + flutter build apk --release + echo "✅ APK 빌드 완료" + ls -la ./build/app/outputs/flutter-apk/ || true + + # 빌드 결과 기록 + - name: 빌드 결과 기록 + id: result + if: always() + uses: actions/github-script@v7 + with: + script: | + const buildStart = parseInt('${{ steps.build_start.outputs.time }}'); + const now = Date.now(); + const elapsed = now - buildStart; + const minutes = Math.floor(elapsed / 60000); + const seconds = Math.floor((elapsed % 60000) / 1000); + const duration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`; + + const status = '${{ steps.build.outcome }}' === 'success' ? 'success' : 'failure'; + + core.setOutput('status', status); + core.setOutput('duration', duration); + console.log(`📋 Android 빌드 결과: ${status} (${duration})`); + + # ============================================ + # Job 4: iOS 빌드 (조건부, 병렬) + # ============================================ + build-ios: + name: iOS 빌드 + if: | + needs.prepare.outputs.analyze_only != 'true' && + needs.prepare.outputs.enable_ios == 'true' + runs-on: macos-15 + needs: prepare + + outputs: + status: ${{ steps.result.outputs.status }} + duration: ${{ steps.result.outputs.duration }} + + steps: + - name: 빌드 시작 시간 기록 + id: build_start + uses: actions/github-script@v7 + with: + script: | + core.setOutput('time', Date.now().toString()); + + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Select Xcode version + run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app/Contents/Developer + + # .env 파일 생성 + - name: Create .env file + run: | + cat << 'EOF' > ${{ env.ENV_FILE_PATH }} + ${{ secrets.ENV_FILE || secrets.ENV }} + EOF + echo "✅ ${{ env.ENV_FILE_PATH }} file created" + + # Flutter 설정 + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + cache: true + + - name: Verify Flutter version + run: | + echo "✅ Flutter setup completed" + flutter --version + + # Flutter 캐시 + - name: Cache Flutter dependencies + uses: actions/cache@v4 + with: + path: ~/.pub-cache + key: ${{ runner.os }}-flutter-pub-${{ hashFiles('**/pubspec.lock') }} + restore-keys: ${{ runner.os }}-flutter-pub- + + # 프로젝트 의존성 설치 + - name: Install dependencies + run: | + flutter pub get + echo "✅ Dependencies installed" + + # Ruby 및 CocoaPods 설정 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.1" + bundler-cache: true + + - name: Install CocoaPods + run: | + gem install cocoapods + cd ios && pod install --repo-update || pod install + echo "✅ CocoaPods installed" + + # iOS 빌드 (서명 없이) + - name: Build iOS + id: build + run: | + echo "📦 Flutter iOS 빌드 시작..." + flutter build ios --release --no-codesign + echo "✅ iOS 빌드 완료" + + # 빌드 결과 기록 + - name: 빌드 결과 기록 + id: result + if: always() + uses: actions/github-script@v7 + with: + script: | + const buildStart = parseInt('${{ steps.build_start.outputs.time }}'); + const now = Date.now(); + const elapsed = now - buildStart; + const minutes = Math.floor(elapsed / 60000); + const seconds = Math.floor((elapsed % 60000) / 1000); + const duration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`; + + const status = '${{ steps.build.outcome }}' === 'success' ? 'success' : 'failure'; + + core.setOutput('status', status); + core.setOutput('duration', duration); + console.log(`📋 iOS 빌드 결과: ${status} (${duration})`); + + # ============================================ + # Job 5: 결과 보고 + # ============================================ + report: + name: 결과 보고 + if: always() && github.event_name == 'pull_request' + runs-on: ubuntu-latest + needs: [prepare, analyze, build-android, build-ios] + + steps: + - name: 최종 결과 댓글 업데이트 uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | - const fs = require('fs'); - let commentBody = '## 🚨 Flutter CI 검사 실패\n\n'; - let hasIssues = false; - - // 포맷팅 검사 결과 - if ('${{ steps.format_check.outcome }}' === 'failure') { - hasIssues = true; - commentBody += '### ❌ 코드 포맷팅 검사 실패\n\n'; - commentBody += '다음 명령어로 자동 포맷팅을 적용하세요:\n'; - commentBody += '```bash\n'; - commentBody += 'dart format .\n'; - commentBody += '```\n\n'; - - try { - const formatOutput = fs.readFileSync('format_output.txt', 'utf8'); - if (formatOutput.trim()) { - commentBody += '
\n포맷팅 오류 상세 내용\n\n'; - commentBody += '```\n' + formatOutput.trim() + '\n```\n\n'; - commentBody += '
\n\n'; - } - } catch (e) { - console.log('포맷팅 출력 파일을 읽을 수 없습니다.'); - } + const commentId = parseInt('${{ needs.prepare.outputs.comment_id }}'); + const startTime = parseInt('${{ needs.prepare.outputs.start_time }}'); + const branchName = '${{ needs.prepare.outputs.branch_name }}'; + const commitHash = '${{ needs.prepare.outputs.commit_hash }}'; + const analyzeOnly = '${{ needs.prepare.outputs.analyze_only }}' === 'true'; + const enableAndroid = '${{ needs.prepare.outputs.enable_android }}' === 'true'; + const enableIos = '${{ needs.prepare.outputs.enable_ios }}' === 'true'; + const runId = '${{ github.run_id }}'; + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; + + // 결과 수집 + const analyzeStatus = '${{ needs.analyze.outputs.status }}'; + const analyzeDuration = '${{ needs.analyze.outputs.duration }}' || '-'; + const androidStatus = '${{ needs.build-android.outputs.status }}'; + const androidDuration = '${{ needs.build-android.outputs.duration }}' || '-'; + const iosStatus = '${{ needs.build-ios.outputs.status }}'; + const iosDuration = '${{ needs.build-ios.outputs.duration }}' || '-'; + + // 전체 소요 시간 계산 + const now = Date.now(); + const totalElapsed = now - startTime; + const totalMin = Math.floor(totalElapsed / 60000); + const totalSec = Math.floor((totalElapsed % 60000) / 1000); + const totalDuration = totalMin > 0 ? `${totalMin}분 ${totalSec}초` : `${totalSec}초`; + + // 상태 결정 + let overallSuccess = true; + let analyzeDisplay, androidDisplay, iosDisplay; + + // Analyze 상태 + if (analyzeStatus === 'success') { + analyzeDisplay = '✅ 성공'; + } else { + analyzeDisplay = '❌ 실패'; + overallSuccess = false; } - // 정적 분석 결과 - if ('${{ steps.analyze_check.outcome }}' === 'failure') { - hasIssues = true; - commentBody += '### ❌ 정적 분석 검사 실패\n\n'; - - try { - const analyzeOutput = fs.readFileSync('analyze_output.txt', 'utf8'); - if (analyzeOutput.trim()) { - // 오류/경고 라인 추출 - const lines = analyzeOutput.split('\n'); - const issues = lines.filter(line => - line.includes('error •') || - line.includes('warning •') || - line.includes('info •') - ); - - if (issues.length > 0) { - commentBody += '발견된 문제:\n\n'; - commentBody += '```\n'; - commentBody += issues.slice(0, 20).join('\n'); // 최대 20개만 표시 - if (issues.length > 20) { - commentBody += '\n... 그 외 ' + (issues.length - 20) + '개의 문제'; - } - commentBody += '\n```\n\n'; - } - - commentBody += '
\n전체 분석 결과 보기\n\n'; - commentBody += '```\n' + analyzeOutput.trim() + '\n```\n\n'; - commentBody += '
\n\n'; - } - } catch (e) { - console.log('분석 출력 파일을 읽을 수 없습니다.'); - } + // Android 상태 + if (analyzeOnly) { + androidDisplay = '⏸️ 스킵'; + } else if (!enableAndroid) { + androidDisplay = '⏸️ 비활성화'; + } else if (androidStatus === 'success') { + androidDisplay = '✅ 성공'; + } else { + androidDisplay = '❌ 실패'; + overallSuccess = false; } - if (hasIssues) { - commentBody += '---\n'; - commentBody += '💡 **수정 방법:**\n'; - commentBody += '1. 로컬에서 `dart format .` 명령어로 포맷팅 적용\n'; - commentBody += '2. `flutter analyze` 명령어로 오류 확인\n'; - commentBody += '3. 오류를 수정한 후 다시 커밋하세요\n'; + // iOS 상태 + if (analyzeOnly) { + iosDisplay = '⏸️ 스킵'; + } else if (!enableIos) { + iosDisplay = '⏸️ 비활성화'; + } else if (iosStatus === 'success') { + iosDisplay = '✅ 성공'; + } else { + iosDisplay = '❌ 실패'; + overallSuccess = false; + } - // 기존 봇 댓글 찾기 - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); + // 헤더 결정 + let header; + if (analyzeOnly) { + header = overallSuccess + ? '## ✅ Flutter CI (Analyze Only) 성공!' + : '## ❌ Flutter CI (Analyze Only) 실패'; + } else { + header = overallSuccess + ? '## ✅ Flutter CI 성공!' + : '## ❌ Flutter CI 실패'; + } - const botComment = comments.find(comment => - comment.user.type === 'Bot' && - comment.body.includes('🚨 Flutter CI 검사 실패') + // 댓글 본문 생성 + const bodyParts = [ + header, + '', + '| 검사 항목 | 상태 | 소요 시간 |', + '|----------|------|----------|', + `| 🔍 Analyze | ${analyzeDisplay} | ${analyzeDuration} |`, + `| 🤖 Android 빌드 | ${androidDisplay} | ${analyzeOnly || !enableAndroid ? '-' : androidDuration} |`, + `| 🍎 iOS 빌드 | ${iosDisplay} | ${analyzeOnly || !enableIos ? '-' : iosDuration} |`, + '', + '| 항목 | 값 |', + '|------|-----|', + `| **브랜치** | \`${branchName}\` |`, + `| **커밋** | \`${commitHash}\` |`, + `| **총 소요 시간** | ${totalDuration} |`, + '' + ]; + + // Analyze Only 모드 안내 + if (analyzeOnly && overallSuccess) { + bodyParts.push('ℹ️ **Analyze Only 모드**: 빌드 없이 코드 분석만 실행되었습니다.'); + bodyParts.push(''); + } + + // 실패 시 추가 안내 + if (!overallSuccess) { + bodyParts.push( + '### 💡 확인 사항', + '' ); - if (botComment) { - // 기존 댓글 업데이트 - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: botComment.id, - body: commentBody - }); - } else { - // 새 댓글 작성 - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: commentBody - }); + if (analyzeStatus !== 'success') { + bodyParts.push('**Analyze 실패 시:**'); + bodyParts.push('- `flutter analyze` 로컬에서 실행하여 lint 오류 확인'); + bodyParts.push('- 코드 스타일 및 타입 오류 수정'); + bodyParts.push(''); + } + + if (!analyzeOnly && enableAndroid && androidStatus !== 'success') { + bodyParts.push('**Android 빌드 실패 시:**'); + bodyParts.push('- Gradle 빌드 로그에서 에러 확인'); + bodyParts.push('- 의존성 버전 호환성 확인'); + bodyParts.push(''); + } + + if (!analyzeOnly && enableIos && iosStatus !== 'success') { + bodyParts.push('**iOS 빌드 실패 시:**'); + bodyParts.push('- Xcode 빌드 로그에서 에러 확인'); + bodyParts.push('- CocoaPods 의존성 확인'); + bodyParts.push(''); } } - # 8. 최종 결과 확인 (실패 시 워크플로우 실패) - - name: Check final status - if: steps.format_check.outcome == 'failure' || steps.analyze_check.outcome == 'failure' - run: | - echo "❌ CI 검사 실패!" - echo "포맷팅 검사: ${{ steps.format_check.outcome }}" - echo "정적 분석: ${{ steps.analyze_check.outcome }}" - exit 1 + bodyParts.push(`**[📋 워크플로우 로그](${runUrl})**`); + + const body = bodyParts.join('\n'); + + // 댓글 업데이트 + if (commentId) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: body + }); + console.log(`✅ 결과 댓글 업데이트 완료`); + } else { + console.log('⚠️ 댓글 ID가 없어 업데이트를 건너뜁니다.'); + } diff --git a/CHANGELOG.json b/CHANGELOG.json index 5bfc314..33571dc 100644 --- a/CHANGELOG.json +++ b/CHANGELOG.json @@ -1,11 +1,50 @@ { "metadata": { - "lastUpdated": "2026-02-10T00:58:00Z", - "currentVersion": "1.0.31", + "lastUpdated": "2026-02-10T22:24:23Z", + "currentVersion": "1.0.32", "projectType": "flutter", - "totalReleases": 8 + "totalReleases": 9 }, "releases": [ + { + "version": "1.0.32", + "project_type": "flutter", + "date": "2026-02-10", + "pr_number": 32, + "raw_summary": "## Summary by CodeRabbit\n\n## 릴리스 노트\n\n* **New Features**\n * 회원 가입 단계에서 서버에서 제공한 닉네임 지원 추가\n * 성별 선택 UI 개선: \"기타\" 옵션을 \"선택 안 함\"으로 변경\n * 닉네임 중복 확인 버튼 추가로 명시적 검증 지원\n\n* **Improvements**\n * 회원 가입 단계에서 뒤로 가기 네비게이션 제거하여 일관된 진행 경험 제공\n * Google/Apple 로그인 시 더 자세한 오류 메시지 표시\n * 로그아웃 시 더 상세한 기기 정보 전송\n\n* **Documentation**\n * API 사양 및 OpenAPI 명세 문서 추가\n\n* **Chores**\n * 버전 1.0.32로 업데이트\n * CI/CD 파이프라인 개선으로 빠른 빌드 피드백 제공", + "parsed_changes": { + "new_features": { + "title": "New Features", + "items": [ + "회원 가입 단계에서 서버에서 제공한 닉네임 지원 추가", + "성별 선택 UI 개선: \"기타\" 옵션을 \"선택 안 함\"으로 변경", + "닉네임 중복 확인 버튼 추가로 명시적 검증 지원" + ] + }, + "improvements": { + "title": "Improvements", + "items": [ + "회원 가입 단계에서 뒤로 가기 네비게이션 제거하여 일관된 진행 경험 제공", + "Google/Apple 로그인 시 더 자세한 오류 메시지 표시", + "로그아웃 시 더 상세한 기기 정보 전송" + ] + }, + "documentation": { + "title": "Documentation", + "items": [ + "API 사양 및 OpenAPI 명세 문서 추가" + ] + }, + "chores": { + "title": "Chores", + "items": [ + "버전 1.0.32로 업데이트", + "CI/CD 파이프라인 개선으로 빠른 빌드 피드백 제공" + ] + } + }, + "parse_method": "markdown" + }, { "version": "1.0.31", "project_type": "flutter", diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b432fc..cfc7d01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,30 @@ # Changelog -**현재 버전:** 1.0.31 -**마지막 업데이트:** 2026-02-10T00:58:00Z +**현재 버전:** 1.0.32 +**마지막 업데이트:** 2026-02-10T22:24:23Z + +--- + +## [1.0.32] - 2026-02-10 + +**PR:** #32 + +**New Features** +- 회원 가입 단계에서 서버에서 제공한 닉네임 지원 추가 +- 성별 선택 UI 개선: "기타" 옵션을 "선택 안 함"으로 변경 +- 닉네임 중복 확인 버튼 추가로 명시적 검증 지원 + +**Improvements** +- 회원 가입 단계에서 뒤로 가기 네비게이션 제거하여 일관된 진행 경험 제공 +- Google/Apple 로그인 시 더 자세한 오류 메시지 표시 +- 로그아웃 시 더 상세한 기기 정보 전송 + +**Documentation** +- API 사양 및 OpenAPI 명세 문서 추가 + +**Chores** +- 버전 1.0.32로 업데이트 +- CI/CD 파이프라인 개선으로 빠른 빌드 피드백 제공 --- diff --git a/README.md b/README.md index 8365933..c5ea66e 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,6 @@ samples, guidance on mobile development, and a full API reference. --- -## 최신 버전 : v1.0.30 (2026-02-09) +## 최신 버전 : v1.0.31 (2026-02-10) [전체 버전 기록 보기](CHANGELOG.md) diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/docs/API_SPECIFICATION.md b/docs/API_SPECIFICATION.md new file mode 100644 index 0000000..de1b75f --- /dev/null +++ b/docs/API_SPECIFICATION.md @@ -0,0 +1,1040 @@ +# Mapsy API 명세서 + +> OpenAPI 3.1.0 기반 | 최종 업데이트: 2026-02-10 +> 소스: `docs/api-docs.json` + +--- + +## 목차 + +1. [개요](#1-개요) +2. [인증 방식](#2-인증-방식) +3. [인증 API](#3-인증-api) +4. [회원 관리 API](#4-회원-관리-api) +5. [콘텐츠 API](#5-콘텐츠-api) +6. [장소 API](#6-장소-api) +7. [AI 서버 API](#7-ai-서버-api) +8. [공통 스키마](#8-공통-스키마) +9. [에러코드 총정리](#9-에러코드-총정리) + +--- + +## 1. 개요 + +| 항목 | 값 | +|------|-----| +| **서비스명** | Mapsy | +| **API 버전** | OpenAPI 3.1.0 | +| **Content-Type** | `application/json;charset=UTF-8` | +| **인증 방식** | Bearer Token (JWT) | + +### 서버 정보 + +| 환경 | URL | 설명 | +|------|-----|------| +| Production | (비공개) | 메인 서버 | +| Local | (비공개) | 로컬 서버 | + +### API 도메인 분류 + +| 태그 | 설명 | +|------|------| +| **인증 API** | 회원 인증(소셜 로그인) 관련 API | +| **회원 관리** | 회원 생성, 조회 등의 기능 API | +| **content-controller** | SNS 콘텐츠 관리 API | +| **place-controller** | 장소 관리 API | +| **AI 서버 API** | AI 서버 연동 관련 API | + +--- + +## 2. 인증 방식 + +### JWT Bearer Token + +``` +Authorization: Bearer {accessToken} +``` + +- **AccessToken 유효기간**: 1시간 +- **RefreshToken 유효기간**: 7일 +- 만료 시 `/api/auth/reissue`로 재발급 + +### API Key (AI 서버 전용) + +``` +X-API-Key: {api-key} +``` + +- AI Webhook Callback 전용 인증 + +--- + +## 3. 인증 API + +### 3.1 소셜 로그인 + +``` +POST /api/auth/sign-in +``` + +**인증**: 불필요 + +#### Request Body (`SignInRequest`) + +| 필드 | 타입 | 필수 | 설명 | +|------|------|------|------| +| `firebaseIdToken` | `string` | O | Firebase ID Token (클라이언트에서 Firebase 인증 후 전달) | +| `fcmToken` | `string` | X | FCM 푸시 알림 토큰 | +| `deviceType` | `string` | X* | 디바이스 타입. enum: `IOS`, `ANDROID` | +| `deviceId` | `string` | X* | 디바이스 고유 식별자 (UUID) | + +> *fcmToken 제공 시 deviceType, deviceId 모두 필수 + +#### Response (`SignInResponse`) + +| 필드 | 타입 | 설명 | +|------|------|------| +| `accessToken` | `string` | 액세스 토큰 | +| `refreshToken` | `string` | 리프레시 토큰 | +| `isFirstLogin` | `boolean` | 첫 로그인 여부 | +| `requiresOnboarding` | `boolean` | 온보딩 필요 여부 | +| `onboardingStep` | `string` | 현재 온보딩 단계 (예: `TERMS`) | + +#### 에러코드 + +| 코드 | 설명 | +|------|------| +| `INVALID_SOCIAL_TOKEN` | 유효하지 않은 소셜 인증 토큰 | +| `SOCIAL_AUTH_FAILED` | 소셜 로그인 인증 실패 | +| `MEMBER_NOT_FOUND` | 회원 정보를 찾을 수 없음 | +| `INVALID_INPUT_VALUE` | FCM 토큰 관련 필드 오류 | + +--- + +### 3.2 토큰 재발급 + +``` +POST /api/auth/reissue +``` + +**인증**: 불필요 + +#### Request Body (`ReissueRequest`) + +| 필드 | 타입 | 필수 | 설명 | +|------|------|------|------| +| `refreshToken` | `string` | O | 리프레시 토큰 | + +#### Response (`ReissueResponse`) + +| 필드 | 타입 | 설명 | +|------|------|------| +| `accessToken` | `string` | 재발급된 액세스 토큰 | +| `refreshToken` | `string` | 리프레시 토큰 (변경되지 않음) | +| `isFirstLogin` | `boolean` | 첫 로그인 여부 | + +#### 에러코드 + +| 코드 | 설명 | +|------|------| +| `REFRESH_TOKEN_NOT_FOUND` | 리프레시 토큰을 찾을 수 없음 | +| `INVALID_REFRESH_TOKEN` | 유효하지 않은 리프레시 토큰 | +| `EXPIRED_REFRESH_TOKEN` | 만료된 리프레시 토큰 | +| `MEMBER_NOT_FOUND` | 회원 정보를 찾을 수 없음 | + +--- + +### 3.3 로그아웃 + +``` +POST /api/auth/logout +``` + +**인증**: JWT 필요 + +#### Headers + +| 이름 | 타입 | 필수 | 설명 | +|------|------|------|------| +| `Authorization` | `string` | X | Bearer 토큰 (자동 추출) | + +#### Request Body (`AuthRequest`) + +| 필드 | 타입 | 필수 | 설명 | +|------|------|------|------| +| `socialPlatform` | `string` | X | 로그인 플랫폼. enum: `NORMAL`, `KAKAO`, `GOOGLE` | +| `email` | `string` | X | 이메일 | +| `name` | `string` | X | 닉네임 | +| `profileUrl` | `string` | X | 프로필 URL | +| `fcmToken` | `string` | X | FCM 토큰 | +| `deviceType` | `string` | X | 디바이스 타입. enum: `IOS`, `ANDROID` | +| `deviceId` | `string` | X | 디바이스 고유 식별자 | + +#### Response + +- **200 OK**: 빈 응답 본문 + +#### 동작 + +- 액세스 토큰을 블랙리스트에 등록하여 무효화 +- Redis에 저장된 리프레시 토큰 삭제 + +#### 에러코드 + +| 코드 | 설명 | +|------|------| +| `INVALID_TOKEN` | 유효하지 않은 토큰 | +| `UNAUTHORIZED` | 인증이 필요한 요청 | + +--- + +### 3.4 회원 탈퇴 + +``` +DELETE /api/auth/withdraw +``` + +**인증**: JWT 필요 + +#### Headers + +| 이름 | 타입 | 필수 | 설명 | +|------|------|------|------| +| `Authorization` | `string` | X | Bearer 토큰 | + +#### Response + +- **204 No Content**: 탈퇴 성공 + +#### 동작 + +- 소프트 삭제 방식 +- 이메일/닉네임에 타임스탬프 추가 (예: `email_2025_01_19_143022`) +- 동일 이메일/닉네임으로 재가입 가능 +- 회원의 관심사 함께 소프트 삭제 +- AccessToken 블랙리스트 등록, RefreshToken Redis에서 삭제 + +#### 에러코드 + +| 코드 | 설명 | +|------|------| +| `MEMBER_NOT_FOUND` | 회원을 찾을 수 없음 | +| `MEMBER_ALREADY_WITHDRAWN` | 이미 탈퇴한 회원 | +| `UNAUTHORIZED` | 인증이 필요 | + +--- + +## 4. 회원 관리 API + +### 4.1 전체 회원 목록 조회 + +``` +GET /api/members +``` + +**인증**: 불필요 + +#### Response + +- **200 OK**: `MemberDto[]` (전체 회원 목록) + +> 삭제되지 않은 회원만 조회 + +--- + +### 4.2 회원 생성 + +``` +POST /api/members +``` + +**인증**: 불필요 + +#### Request Body (`MemberDto`) + +| 필드 | 타입 | 필수 | 설명 | +|------|------|------|------| +| `email` | `string` | O | 회원 이메일 | +| `name` | `string` | O | 회원 닉네임 (2~50자) | +| `profileImageUrl` | `string` | X | 프로필 이미지 URL | +| `socialPlatform` | `string` | X | 소셜 플랫폼 (`KAKAO`, `GOOGLE`) | +| `memberRole` | `string` | X | 회원 권한 (`ROLE_USER`, `ROLE_ADMIN`) | +| `status` | `string` | X | 회원 상태 (`ACTIVE`, `INACTIVE`, `DELETED`) | + +#### Response (`MemberDto`) + +`MemberDto` 객체 반환 (8. 공통 스키마 참조) + +#### 에러코드 + +| 코드 | 설명 | +|------|------| +| `EMAIL_ALREADY_EXISTS` | 이미 가입된 이메일 | +| `INVALID_INPUT_VALUE` | 유효하지 않은 입력값 | + +--- + +### 4.3 회원 단건 조회 (ID) + +``` +GET /api/members/{memberId} +``` + +**인증**: 불필요 + +#### Path Parameters + +| 파라미터 | 타입 | 필수 | 설명 | +|----------|------|------|------| +| `memberId` | `string (uuid)` | O | 회원 ID | + +#### Response (`MemberDto`) + +`MemberDto` 객체 반환 + +#### 에러코드 + +| 코드 | 설명 | +|------|------| +| `MEMBER_NOT_FOUND` | 회원을 찾을 수 없음 | +| `INVALID_INPUT_VALUE` | 유효하지 않은 입력값 | + +--- + +### 4.4 회원 단건 조회 (Email) + +``` +GET /api/members/email/{email} +``` + +**인증**: 불필요 + +#### Path Parameters + +| 파라미터 | 타입 | 필수 | 설명 | +|----------|------|------|------| +| `email` | `string` | O | 회원 이메일 | + +#### Response (`MemberDto`) + +`MemberDto` 객체 반환 + +#### 에러코드 + +| 코드 | 설명 | +|------|------| +| `MEMBER_NOT_FOUND` | 회원을 찾을 수 없음 | +| `INVALID_INPUT_VALUE` | 유효하지 않은 입력값 | + +--- + +### 4.5 닉네임 중복 확인 + +``` +GET /api/members/check-name +``` + +**인증**: 불필요 + +#### Query Parameters + +| 파라미터 | 타입 | 필수 | 설명 | +|----------|------|------|------| +| `name` | `string` | O | 확인할 닉네임 (2~50자) | + +#### Response (`CheckNameResponse`) + +| 필드 | 타입 | 설명 | +|------|------|------| +| `isAvailable` | `boolean` | 사용 가능 여부 (`true`: 가능, `false`: 중복) | +| `name` | `string` | 확인한 닉네임 | + +#### 에러코드 + +| 코드 | 설명 | +|------|------| +| `INVALID_NAME_LENGTH` | 닉네임은 2자 이상 50자 이하 | +| `INVALID_INPUT_VALUE` | 유효하지 않은 입력값 | + +--- + +### 4.6 회원 프로필 설정(수정) + +``` +POST /api/members/profile +``` + +**인증**: JWT 필요 + +#### Request Body (`ProfileUpdateRequest`) + +| 필드 | 타입 | 필수 | 설명 | +|------|------|------|------| +| `name` | `string` | O | 이름 | +| `gender` | `string` | X | 성별. enum: `MALE`, `FEMALE`, `NOT_SELECTED` | +| `birthDate` | `string (date)` | X | 생년월일 (LocalDate) | + +#### Response (`MemberDto`) + +업데이트된 회원 정보 반환 + +#### 에러코드 + +| 코드 | 설명 | +|------|------| +| `MEMBER_NOT_FOUND` | 회원을 찾을 수 없음 | +| `NAME_ALREADY_EXISTS` | 이미 사용 중인 이름 | +| `INVALID_INPUT_VALUE` | 유효하지 않은 입력값 | + +--- + +### 4.7 온보딩 - 약관 동의 + +``` +POST /api/members/onboarding/terms +``` + +**인증**: JWT 필요 + +#### Request Body (`UpdateServiceAgreementTermsRequest`) + +| 필드 | 타입 | 필수 | 설명 | +|------|------|------|------| +| `isServiceTermsAndPrivacyAgreed` | `boolean` | O | 서비스 이용약관 및 개인정보처리방침 동의 | +| `isMarketingAgreed` | `boolean` | X | 마케팅 수신 동의 | + +#### Response (`UpdateServiceAgreementTermsResponse`) + +| 필드 | 타입 | 설명 | +|------|------|------| +| `currentStep` | `string` | 현재 온보딩 단계. enum: `TERMS`, `BIRTH_DATE`, `GENDER`, `COMPLETED` | +| `onboardingStatus` | `string` | 온보딩 상태 (예: `IN_PROGRESS`) | +| `member` | `MemberDto` | 회원 정보 (디버깅용) | + +#### 에러코드 + +| 코드 | 설명 | +|------|------| +| `MEMBER_NOT_FOUND` | 회원을 찾을 수 없음 | +| `MEMBER_TERMS_REQUIRED_NOT_AGREED` | 필수 약관 미동의 | + +--- + +### 4.8 온보딩 - 생년월일 설정 + +``` +POST /api/members/onboarding/birth-date +``` + +**인증**: JWT 필요 + +#### Request Body (`UpdateBirthDateRequest`) + +| 필드 | 타입 | 필수 | 설명 | +|------|------|------|------| +| `birthDate` | `string (date)` | O | 생년월일 (예: `1990-01-01`) | + +#### Response (`OnboardingResponse`) + +| 필드 | 타입 | 설명 | +|------|------|------| +| `currentStep` | `string` | 현재 온보딩 단계. enum: `TERMS`, `BIRTH_DATE`, `GENDER`, `COMPLETED` | +| `onboardingStatus` | `string` | 온보딩 상태 | +| `member` | `MemberDto` | 회원 정보 (디버깅용) | + +#### 에러코드 + +| 코드 | 설명 | +|------|------| +| `MEMBER_NOT_FOUND` | 회원을 찾을 수 없음 | +| `INVALID_INPUT_VALUE` | 유효하지 않은 입력값 | + +--- + +### 4.9 온보딩 - 성별 설정 + +``` +POST /api/members/onboarding/gender +``` + +**인증**: JWT 필요 + +#### Request Body (`UpdateGenderRequest`) + +| 필드 | 타입 | 필수 | 설명 | +|------|------|------|------| +| `gender` | `string` | O | 성별. enum: `MALE`, `FEMALE`, `NOT_SELECTED` | + +#### Response (`OnboardingResponse`) + +`OnboardingResponse` 객체 반환 (4.8과 동일 구조) + +#### 에러코드 + +| 코드 | 설명 | +|------|------| +| `MEMBER_NOT_FOUND` | 회원을 찾을 수 없음 | +| `INVALID_INPUT_VALUE` | 유효하지 않은 입력값 | + +--- + +## 5. 콘텐츠 API + +### 5.1 SNS URL로 콘텐츠 생성 및 장소 추출 요청 + +``` +POST /api/content/analyze +``` + +**인증**: JWT 필요 + +#### Request Body (`RequestPlaceExtractionRequest`) + +| 필드 | 타입 | 필수 | 설명 | +|------|------|------|------| +| `snsUrl` | `string` | O | SNS URL (Instagram, YouTube Shorts 등) | + +#### Response (`RequestPlaceExtractionResponse`) + +| 필드 | 타입 | 설명 | +|------|------|------| +| `contentId` | `string (uuid)` | 생성된 콘텐츠 ID | +| `memberId` | `string (uuid)` | 회원 ID | +| `status` | `string` | 상태. enum: `PENDING`, `ANALYZING`, `COMPLETED`, `FAILED`, `DELETED` | + +#### 동작 + +- SNS URL을 받아 콘텐츠를 생성하고 AI 서버에 장소 추출 요청 +- 초기 상태는 `PENDING` +- AI 서버 처리 완료 시 Webhook으로 상태 업데이트 + +--- + +### 5.2 단일 SNS 콘텐츠 정보 조회 + +``` +GET /api/content/{contentId} +``` + +**인증**: JWT 필요 + +#### Path Parameters + +| 파라미터 | 타입 | 필수 | 설명 | +|----------|------|------|------| +| `contentId` | `string (uuid)` | O | 콘텐츠 ID | + +#### Response (`GetContentInfoResponse`) + +| 필드 | 타입 | 설명 | +|------|------|------| +| `content` | `ContentDto` | 콘텐츠 상세 정보 | +| `places` | `PlaceDto[]` | 연관된 장소 목록 (position 순서) | + +--- + +### 5.3 최근 SNS 콘텐츠 목록 조회 + +``` +GET /api/content/recent +``` + +**인증**: JWT 필요 + +#### Response (`GetRecentContentResponse`) + +| 필드 | 타입 | 설명 | +|------|------|------| +| `contents` | `ContentDto[]` | 최근 10개 콘텐츠 목록 (생성일시 내림차순) | + +#### 에러코드 + +| 코드 | 설명 | +|------|------| +| `MEMBER_NOT_FOUND` | 회원을 찾을 수 없음 | + +--- + +### 5.4 회원 콘텐츠 목록 조회 (페이지네이션) + +``` +GET /api/content/member +``` + +**인증**: JWT 필요 + +#### Query Parameters + +| 파라미터 | 타입 | 필수 | 기본값 | 설명 | +|----------|------|------|--------|------| +| `pageSize` | `integer` | X | 10 | 페이지 크기 | + +#### Response (`GetMemberContentPageResponse`) + +| 필드 | 타입 | 설명 | +|------|------|------| +| `contentPage` | `PageContentDto` | 콘텐츠 페이지 정보 (최신순) | + +**PageContentDto 구조:** + +| 필드 | 타입 | 설명 | +|------|------|------| +| `content` | `ContentDto[]` | 콘텐츠 목록 | +| `totalElements` | `integer (int64)` | 전체 콘텐츠 개수 | +| `totalPages` | `integer` | 전체 페이지 수 | +| `number` | `integer` | 현재 페이지 번호 (0부터) | +| `size` | `integer` | 페이지 크기 | +| `first` | `boolean` | 첫 페이지 여부 | +| `last` | `boolean` | 마지막 페이지 여부 | +| `empty` | `boolean` | 비어있는지 여부 | + +--- + +### 5.5 최근 장소 목록 조회 (콘텐츠 기반) + +``` +GET /api/content/place/saved +``` + +**인증**: JWT 필요 + +#### Response (`GetSavedPlacesResponse`) + +| 필드 | 타입 | 설명 | +|------|------|------| +| `places` | `PlaceDto[]` | 장소 목록 (최신순, 최대 10개) | + +> 장소의 사진 URL은 최대 10개까지 반환 + +#### 에러코드 + +| 코드 | 설명 | +|------|------| +| `MEMBER_NOT_FOUND` | 회원을 찾을 수 없음 | + +--- + +## 6. 장소 API + +### 6.1 장소 세부정보 조회 + +``` +GET /api/place/{placeId} +``` + +**인증**: JWT 필요 + +#### Path Parameters + +| 파라미터 | 타입 | 필수 | 설명 | +|----------|------|------|------| +| `placeId` | `string (uuid)` | O | 장소 ID | + +#### Response (`PlaceDetailDto`) + +| 필드 | 타입 | 설명 | +|------|------|------| +| `id` | `string (uuid)` | 장소 ID | +| `name` | `string` | 장소명 | +| `address` | `string` | 주소 | +| `country` | `string` | 국가 코드 (ISO 3166-1 alpha-2) | +| `latitude` | `number` | 위도 | +| `longitude` | `number` | 경도 | +| `businessType` | `string` | 업종 | +| `phone` | `string` | 전화번호 | +| `description` | `string` | 장소 설명 | +| `types` | `string[]` | 장소 유형 배열 | +| `businessStatus` | `string` | 영업 상태 | +| `iconUrl` | `string` | Google 아이콘 URL | +| `rating` | `number` | 평점 (0.0~5.0) | +| `userRatingsTotal` | `integer` | 리뷰 수 | +| `photoUrls` | `string[]` | 사진 URL 배열 | +| `platformReferences` | `PlacePlatformReferenceDto[]` | 플랫폼별 참조 정보 | +| `businessHours` | `PlaceBusinessHourDto[]` | 영업시간 목록 | +| `medias` | `PlaceMediaDto[]` | 추가 미디어 목록 | + +#### 에러코드 + +| 코드 | 설명 | +|------|------| +| `PLACE_NOT_FOUND` | 장소를 찾을 수 없음 | + +--- + +### 6.2 장소 저장 + +``` +POST /api/place/{placeId}/save +``` + +**인증**: JWT 필요 + +#### Path Parameters + +| 파라미터 | 타입 | 필수 | 설명 | +|----------|------|------|------| +| `placeId` | `string (uuid)` | O | 저장할 장소 ID | + +#### Response (`SavePlaceResponse`) + +| 필드 | 타입 | 설명 | +|------|------|------| +| `memberPlaceId` | `string (uuid)` | 회원 장소 ID | +| `placeId` | `string (uuid)` | 장소 ID | +| `savedStatus` | `string` | 저장 상태 (`SAVED`) | +| `savedAt` | `string (date-time)` | 저장 일시 | + +#### 동작 + +- 임시 저장 상태(`TEMPORARY`)의 장소를 저장 상태(`SAVED`)로 변경 + +#### 에러코드 + +| 코드 | 설명 | +|------|------| +| `PLACE_NOT_FOUND` | 장소를 찾을 수 없음 | +| `MEMBER_PLACE_NOT_FOUND` | 회원의 장소 정보를 찾을 수 없음 | +| `MEMBER_NOT_FOUND` | 회원을 찾을 수 없음 | + +--- + +### 6.3 임시 저장 장소 목록 조회 + +``` +GET /api/place/temporary +``` + +**인증**: JWT 필요 + +#### Response (`GetTemporaryPlacesResponse`) + +| 필드 | 타입 | 설명 | +|------|------|------| +| `places` | `PlaceDto[]` | 임시 저장 장소 목록 (최신순) | + +> AI 분석으로 자동 생성된 장소들. 사용자가 아직 저장 여부를 결정하지 않은 상태 + +#### 에러코드 + +| 코드 | 설명 | +|------|------| +| `MEMBER_NOT_FOUND` | 회원을 찾을 수 없음 | + +--- + +### 6.4 저장한 장소 목록 조회 + +``` +GET /api/place/saved +``` + +**인증**: JWT 필요 + +#### Response (`GetSavedPlacesResponse`) + +| 필드 | 타입 | 설명 | +|------|------|------| +| `places` | `PlaceDto[]` | 저장한 장소 목록 (최신순) | + +> MemberPlace 기반 조회. `/api/content/place/saved`와는 다른 조회 방식 + +#### 에러코드 + +| 코드 | 설명 | +|------|------| +| `MEMBER_NOT_FOUND` | 회원을 찾을 수 없음 | + +--- + +### 6.5 임시 저장 장소 삭제 + +``` +DELETE /api/place/{placeId}/temporary +``` + +**인증**: JWT 필요 + +#### Path Parameters + +| 파라미터 | 타입 | 필수 | 설명 | +|----------|------|------|------| +| `placeId` | `string (uuid)` | O | 삭제할 장소 ID | + +#### Response + +- **204 No Content**: 삭제 성공 + +#### 동작 + +- 임시 저장 상태(`TEMPORARY`)의 장소만 삭제 가능 +- 저장된 상태(`SAVED`)의 장소는 삭제 불가 +- Soft Delete 방식 + +#### 에러코드 + +| 코드 | 설명 | +|------|------| +| `PLACE_NOT_FOUND` | 장소를 찾을 수 없음 | +| `MEMBER_PLACE_NOT_FOUND` | 회원의 장소 정보를 찾을 수 없음 | +| `CANNOT_DELETE_SAVED_PLACE` | 임시 저장된 장소만 삭제 가능 | +| `MEMBER_NOT_FOUND` | 회원을 찾을 수 없음 | + +--- + +## 7. AI 서버 API + +### 7.1 AI 서버 Webhook Callback + +``` +POST /api/ai/callback +``` + +**인증**: API Key 필요 (`X-API-Key` 헤더) + +#### Headers + +| 이름 | 타입 | 필수 | 설명 | +|------|------|------|------| +| `X-API-Key` | `string` | O | API Key | + +#### Request Body (`AiCallbackRequest`) + +| 필드 | 타입 | 필수 | 설명 | +|------|------|------|------| +| `contentId` | `string (uuid)` | O | Content UUID | +| `resultStatus` | `string` | O | 처리 결과. enum: `SUCCESS`, `FAILED` | +| `snsInfo` | `SnsInfoCallback` | X* | SNS 콘텐츠 정보 (*SUCCESS 시 필수) | +| `placeDetails` | `PlaceDetailCallback[]` | X | 추출된 장소 상세 목록 | +| `statistics` | `ExtractionStatistics` | X | 추출 처리 통계 | +| `errorMessage` | `string` | X | 실패 사유 (FAILED 시) | + +**SnsInfoCallback:** + +| 필드 | 타입 | 필수 | 설명 | +|------|------|------|------| +| `platform` | `string` | O | SNS 플랫폼. enum: `INSTAGRAM`, `YOUTUBE`, `YOUTUBE_SHORTS`, `TIKTOK`, `FACEBOOK`, `TWITTER` | +| `contentType` | `string` | O | 콘텐츠 타입 (예: `reel`) | +| `url` | `string` | O | 원본 SNS URL | +| `author` | `string` | X | 작성자 ID | +| `caption` | `string` | X | 게시물 본문 | +| `likesCount` | `integer` | X | 좋아요 수 | +| `commentsCount` | `integer` | X | 댓글 수 | +| `postedAt` | `string` | X | 게시 날짜 (ISO 8601) | +| `hashtags` | `string[]` | X | 해시태그 리스트 | +| `thumbnailUrl` | `string` | X | 썸네일 URL | +| `imageUrls` | `string[]` | X | 이미지 URL 리스트 | +| `authorProfileImageUrl` | `string` | X | 작성자 프로필 이미지 URL | + +**PlaceDetailCallback:** + +| 필드 | 타입 | 필수 | 설명 | +|------|------|------|------| +| `placeId` | `string` | O | 네이버 Place ID | +| `name` | `string` | O | 장소명 | +| `category` | `string` | X | 카테고리 | +| `description` | `string` | X | 한줄 설명 | +| `latitude` | `number (double)` | X | 위도 | +| `longitude` | `number (double)` | X | 경도 | +| `address` | `string` | X | 지번 주소 | +| `roadAddress` | `string` | X | 도로명 주소 | +| `subwayInfo` | `string` | X | 지하철 정보 | +| `directionsText` | `string` | X | 찾아가는 길 | +| `rating` | `number (double)` | X | 별점 (0.0~5.0) | +| `visitorReviewCount` | `integer` | X | 방문자 리뷰 수 | +| `blogReviewCount` | `integer` | X | 블로그 리뷰 수 | +| `businessStatus` | `string` | X | 영업 상태 | +| `businessHours` | `string` | X | 영업 시간 요약 | +| `openHoursDetail` | `string[]` | X | 요일별 상세 영업시간 | +| `holidayInfo` | `string` | X | 휴무일 정보 | +| `phoneNumber` | `string` | X | 전화번호 | +| `homepageUrl` | `string` | X | 홈페이지 URL | +| `naverMapUrl` | `string` | X | 네이버 지도 URL | +| `reservationAvailable` | `boolean` | X | 예약 가능 여부 | +| `amenities` | `string[]` | X | 편의시설 목록 | +| `keywords` | `string[]` | X | 키워드/태그 | +| `tvAppearances` | `string[]` | X | TV 방송 출연 정보 | +| `menuInfo` | `string[]` | X | 대표 메뉴 | +| `imageUrl` | `string` | X | 대표 이미지 URL | +| `imageUrls` | `string[]` | X | 이미지 URL 목록 | + +**ExtractionStatistics:** + +| 필드 | 타입 | 설명 | +|------|------|------| +| `extractedPlaceNames` | `string[]` | LLM이 추출한 장소명 리스트 | +| `totalExtracted` | `integer` | LLM이 추출한 장소 수 | +| `totalFound` | `integer` | 네이버 지도에서 찾은 장소 수 | +| `failedSearches` | `string[]` | 검색 실패한 장소명 | + +#### Response (`AiCallbackResponse`) + +| 필드 | 타입 | 설명 | +|------|------|------| +| `received` | `boolean` | 수신 여부 (`true`) | +| `contentId` | `string (uuid)` | Content UUID | + +#### 동작 + +- AI 서버가 장소 추출 분석 완료 후 Webhook 호출 +- Content 상태: `ANALYZING` → `COMPLETED` / `FAILED` +- SUCCESS 시: + - ContentInfo로 Content 메타데이터 업데이트 + - Place 생성 및 Content-Place 연결 + - snsPlatform 값으로 Content.platform 동기화 + +#### 에러코드 + +| 코드 | 설명 | +|------|------| +| `INVALID_API_KEY` | 유효하지 않은 API Key | +| `CONTENT_NOT_FOUND` | 콘텐츠를 찾을 수 없음 | +| `INVALID_REQUEST` | 잘못된 요청 | + +--- + +## 8. 공통 스키마 + +### MemberDto + +| 필드 | 타입 | 필수 | 설명 | +|------|------|------|------| +| `id` | `string (uuid)` | - | 회원 ID | +| `email` | `string` | O | 이메일 | +| `name` | `string` | O | 닉네임 (2~50자) | +| `onboardingStatus` | `string` | - | 온보딩 상태 (예: `NOT_STARTED`) | +| `isServiceTermsAndPrivacyAgreed` | `boolean` | - | 서비스 약관 동의 여부 | +| `isMarketingAgreed` | `boolean` | - | 마케팅 수신 동의 여부 | +| `birthDate` | `string (date)` | O | 생년월일 | +| `gender` | `string` | - | 성별. enum: `MALE`, `FEMALE`, `NOT_SELECTED` | + +### ContentDto + +| 필드 | 타입 | 설명 | +|------|------|------| +| `id` | `string (uuid)` | 콘텐츠 ID | +| `platform` | `string` | 플랫폼. enum: `INSTAGRAM`, `TIKTOK`, `YOUTUBE`, `YOUTUBE_SHORTS`, `FACEBOOK`, `TWITTER` | +| `status` | `string` | 상태. enum: `PENDING`, `ANALYZING`, `COMPLETED`, `FAILED`, `DELETED` | +| `platformUploader` | `string` | 업로더 이름 | +| `caption` | `string` | 캡션 | +| `thumbnailUrl` | `string` | 썸네일 URL | +| `originalUrl` | `string` | 원본 SNS URL | +| `title` | `string` | 제목 | +| `summary` | `string` | 요약 설명 | +| `lastCheckedAt` | `string (date-time)` | 마지막 확인 시각 | + +### PlaceDto + +| 필드 | 타입 | 설명 | +|------|------|------| +| `placeId` | `string (uuid)` | 장소 ID | +| `name` | `string` | 장소명 | +| `address` | `string` | 주소 | +| `rating` | `number` | 별점 (0.0~5.0) | +| `userRatingsTotal` | `integer` | 리뷰 수 | +| `photoUrls` | `string[]` | 사진 URL 배열 (최대 10개) | +| `description` | `string` | 장소 요약 설명 | + +### PlacePlatformReferenceDto + +| 필드 | 타입 | 설명 | +|------|------|------| +| `placePlatform` | `string` | 플랫폼 타입. enum: `NAVER`, `GOOGLE`, `KAKAO` | +| `placePlatformId` | `string` | 플랫폼 장소 ID | + +### PlaceBusinessHourDto + +| 필드 | 타입 | 설명 | +|------|------|------| +| `weekday` | `string` | 요일. enum: `MON`, `TUE`, `WED`, `THU`, `FRI`, `SAT`, `SUN` | +| `openTime` | `LocalTime` | 오픈 시간 | +| `closeTime` | `LocalTime` | 마감 시간 | + +### PlaceMediaDto + +| 필드 | 타입 | 설명 | +|------|------|------| +| `id` | `string (uuid)` | 미디어 ID | +| `url` | `string` | 미디어 URL | +| `mimeType` | `string` | MIME 타입 | +| `position` | `integer` | 정렬 순서 | + +--- + +## 9. 에러코드 총정리 + +### 인증 관련 + +| 코드 | 설명 | 발생 API | +|------|------|----------| +| `INVALID_SOCIAL_TOKEN` | 유효하지 않은 소셜 인증 토큰 | 소셜 로그인 | +| `SOCIAL_AUTH_FAILED` | 소셜 로그인 인증 실패 | 소셜 로그인 | +| `INVALID_TOKEN` | 유효하지 않은 토큰 | 로그아웃 | +| `UNAUTHORIZED` | 인증이 필요한 요청 | 로그아웃, 회원 탈퇴 | +| `REFRESH_TOKEN_NOT_FOUND` | 리프레시 토큰을 찾을 수 없음 | 토큰 재발급 | +| `INVALID_REFRESH_TOKEN` | 유효하지 않은 리프레시 토큰 | 토큰 재발급 | +| `EXPIRED_REFRESH_TOKEN` | 만료된 리프레시 토큰 | 토큰 재발급 | + +### 회원 관련 + +| 코드 | 설명 | 발생 API | +|------|------|----------| +| `MEMBER_NOT_FOUND` | 회원을 찾을 수 없음 | 대부분 API | +| `EMAIL_ALREADY_EXISTS` | 이미 가입된 이메일 | 회원 생성 | +| `NAME_ALREADY_EXISTS` | 이미 사용 중인 이름 | 프로필 수정 | +| `INVALID_NAME_LENGTH` | 닉네임 길이 오류 (2~50자) | 닉네임 중복 확인 | +| `MEMBER_ALREADY_WITHDRAWN` | 이미 탈퇴한 회원 | 회원 탈퇴 | +| `MEMBER_TERMS_REQUIRED_NOT_AGREED` | 필수 약관 미동의 | 약관 동의 | + +### 장소 관련 + +| 코드 | 설명 | 발생 API | +|------|------|----------| +| `PLACE_NOT_FOUND` | 장소를 찾을 수 없음 | 장소 조회/저장/삭제 | +| `MEMBER_PLACE_NOT_FOUND` | 회원의 장소 정보를 찾을 수 없음 | 장소 저장/삭제 | +| `CANNOT_DELETE_SAVED_PLACE` | 임시 저장 장소만 삭제 가능 | 임시 장소 삭제 | + +### AI 서버 관련 + +| 코드 | 설명 | 발생 API | +|------|------|----------| +| `INVALID_API_KEY` | 유효하지 않은 API Key | AI Callback | +| `CONTENT_NOT_FOUND` | 콘텐츠를 찾을 수 없음 | AI Callback | +| `INVALID_REQUEST` | 잘못된 요청 | AI Callback | + +### 공통 + +| 코드 | 설명 | 발생 API | +|------|------|----------| +| `INVALID_INPUT_VALUE` | 유효하지 않은 입력값 | 전체 | +| `INTERNAL_SERVER_ERROR` | 서버 내부 오류 | 전체 | + +--- + +## API 엔드포인트 요약 (Quick Reference) + +| Method | Endpoint | 인증 | 설명 | +|--------|----------|------|------| +| `POST` | `/api/auth/sign-in` | X | 소셜 로그인 | +| `POST` | `/api/auth/reissue` | X | 토큰 재발급 | +| `POST` | `/api/auth/logout` | JWT | 로그아웃 | +| `DELETE` | `/api/auth/withdraw` | JWT | 회원 탈퇴 | +| `GET` | `/api/members` | X | 전체 회원 목록 조회 | +| `POST` | `/api/members` | X | 회원 생성 | +| `GET` | `/api/members/{memberId}` | X | 회원 단건 조회 (ID) | +| `GET` | `/api/members/email/{email}` | X | 회원 단건 조회 (Email) | +| `GET` | `/api/members/check-name` | X | 닉네임 중복 확인 | +| `POST` | `/api/members/profile` | JWT | 프로필 설정/수정 | +| `POST` | `/api/members/onboarding/terms` | JWT | 약관 동의 | +| `POST` | `/api/members/onboarding/birth-date` | JWT | 생년월일 설정 | +| `POST` | `/api/members/onboarding/gender` | JWT | 성별 설정 | +| `POST` | `/api/content/analyze` | JWT | SNS URL 분석 요청 | +| `GET` | `/api/content/{contentId}` | JWT | 콘텐츠 정보 조회 | +| `GET` | `/api/content/recent` | JWT | 최근 콘텐츠 목록 | +| `GET` | `/api/content/member` | JWT | 회원 콘텐츠 목록 | +| `GET` | `/api/content/place/saved` | JWT | 최근 장소 목록 (콘텐츠 기반) | +| `GET` | `/api/place/{placeId}` | JWT | 장소 세부정보 조회 | +| `POST` | `/api/place/{placeId}/save` | JWT | 장소 저장 | +| `GET` | `/api/place/temporary` | JWT | 임시 저장 장소 목록 | +| `GET` | `/api/place/saved` | JWT | 저장한 장소 목록 | +| `DELETE` | `/api/place/{placeId}/temporary` | JWT | 임시 장소 삭제 | +| `POST` | `/api/ai/callback` | API Key | AI Webhook Callback | diff --git a/docs/api-docs.json b/docs/api-docs.json new file mode 100644 index 0000000..850d45e --- /dev/null +++ b/docs/api-docs.json @@ -0,0 +1,1868 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "📚 Mapsy 📚", + "description": "" + }, + "servers": [ + { + "url": "", + "description": "메인 서버" + }, + { + "url": "", + "description": "로컬 서버" + } + ], + "security": [ + { + "Bearer Token": [] + } + ], + "tags": [ + { + "name": "AI 서버 API", + "description": "AI 서버 연동 관련 API 제공" + }, + { + "name": "회원 관리", + "description": "회원 생성, 조회 등의 기능을 제공하는 API" + }, + { + "name": "인증 API", + "description": "회원 인증(소셜 로그인) 관련 API 제공" + } + ], + "paths": { + "/api/place/{placeId}/save": { + "post": { + "tags": ["place-controller"], + "summary": "장소 저장", + "description": "## 인증(JWT): **필요**\n\n## 요청 파라미터\n- **`placeId`**: 저장할 장소 ID (필수, Path Variable)\n\n## 반환값 (SavePlaceResponse)\n- **`memberPlaceId`**: 회원 장소 ID\n- **`placeId`**: 장소 ID\n- **`savedStatus`**: 저장 상태 (SAVED)\n- **`savedAt`**: 저장 일시\n\n## 특이사항\n- 임시 저장 상태(TEMPORARY)의 장소를 저장 상태(SAVED)로 변경합니다.\n- 저장 시점의 시간이 기록됩니다.\n\n## 에러코드\n- **`PLACE_NOT_FOUND`**: 장소를 찾을 수 없습니다.\n- **`MEMBER_PLACE_NOT_FOUND`**: 회원의 장소 정보를 찾을 수 없습니다.\n- **`MEMBER_NOT_FOUND`**: 회원을 찾을 수 없습니다.\n\n\n**API 변경 이력:**\n\u003Ctable\u003E\u003Cthead\u003E\u003Ctr\u003E\u003Cth\u003E날짜\u003C/th\u003E\u003Cth\u003E작성자\u003C/th\u003E\u003Cth\u003E이슈번호\u003C/th\u003E\u003Cth\u003E이슈 제목\u003C/th\u003E\u003Cth\u003E변경 내용\u003C/th\u003E\u003C/tr\u003E\u003C/thead\u003E\u003Ctbody\u003E\u003Ctr\u003E\u003Ctd\u003E2025.11.24\u003C/td\u003E\u003Ctd\u003E서새찬\u003C/td\u003E\u003Ctd\u003E\u003Ca href=\"https://github.com/MapSee-Lab/MapSy-BE/issues/103\" target=\"_blank\"\u003E#103\u003C/a\u003E\u003C/td\u003E\u003Ctd\u003E로딩 실패\u003C/td\u003E\u003Ctd\u003E장소 저장 API 추가\u003C/td\u003E\u003C/tr\u003E\u003C/tbody\u003E\u003C/table\u003E", + "operationId": "savePlace", + "parameters": [ + { + "name": "placeId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json;charset=UTF-8": { + "schema": { + "$ref": "#/components/schemas/SavePlaceResponse" + } + } + } + } + } + } + }, + "/api/members": { + "get": { + "tags": ["회원 관리"], + "summary": "전체 회원 목록 조회", + "description": "## 인증(JWT): **불필요**\n\n## 요청 파라미터\n- 없음\n\n## 반환값 (List\u003CMemberDto\u003E)\n- 전체 회원 목록을 반환합니다.\n- 각 회원의 상세 정보가 포함됩니다.\n\n## 특이사항\n- 모든 회원 데이터를 조회합니다.\n- 삭제되지 않은 회원만 조회됩니다.\n\n## 에러코드\n- **`INTERNAL_SERVER_ERROR`**: 서버에 문제가 발생했습니다.\n\n\n**API 변경 이력:**\n\u003Ctable\u003E\u003Cthead\u003E\u003Ctr\u003E\u003Cth\u003E날짜\u003C/th\u003E\u003Cth\u003E작성자\u003C/th\u003E\u003Cth\u003E이슈번호\u003C/th\u003E\u003Cth\u003E이슈 제목\u003C/th\u003E\u003Cth\u003E변경 내용\u003C/th\u003E\u003C/tr\u003E\u003C/thead\u003E\u003Ctbody\u003E\u003Ctr\u003E\u003Ctd\u003E2025.10.16\u003C/td\u003E\u003Ctd\u003E서새찬\u003C/td\u003E\u003Ctd\u003E\u003Ca href=\"https://github.com/MapSee-Lab/MapSy-BE/issues/22\" target=\"_blank\"\u003E#22\u003C/a\u003E\u003C/td\u003E\u003Ctd\u003E북마크 관련 API 추가 필요\u003C/td\u003E\u003Ctd\u003E회원 관리 API 문서화\u003C/td\u003E\u003C/tr\u003E\u003C/tbody\u003E\u003C/table\u003E", + "operationId": "getAllMembers", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json;charset=UTF-8": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemberDto" + } + } + } + } + } + } + }, + "post": { + "tags": ["회원 관리"], + "summary": "회원 생성", + "description": "## 인증(JWT): **불필요**\n\n## 요청 파라미터 (MemberDto)\n- **`email`**: 회원 이메일 (필수)\n- **`name`**: 회원 닉네임 (필수)\n- **`profileImageUrl`**: 프로필 이미지 URL (선택)\n- **`socialPlatform`**: 소셜 플랫폼 (KAKAO, GOOGLE)\n- **`memberRole`**: 회원 권한 (ROLE_USER, ROLE_ADMIN)\n- **`status`**: 회원 상태 (ACTIVE, INACTIVE, DELETED)\n\n## 반환값 (MemberDto)\n- **`memberId`**: 생성된 회원 ID\n- **`email`**: 회원 이메일\n- **`name`**: 회원 닉네임\n- **`profileImageUrl`**: 프로필 이미지 URL\n- **`socialPlatform`**: 소셜 플랫폼\n- **`memberRole`**: 회원 권한\n- **`status`**: 회원 상태\n- **`createdAt`**: 생성일시\n- **`updatedAt`**: 수정일시\n\n## 특이사항\n- 새로운 회원을 생성합니다.\n- 이메일 중복 검사가 수행됩니다.\n\n## 에러코드\n- **`EMAIL_ALREADY_EXISTS`**: 이미 가입된 이메일입니다.\n- **`INVALID_INPUT_VALUE`**: 유효하지 않은 입력값입니다.\n\n\n**API 변경 이력:**\n\u003Ctable\u003E\u003Cthead\u003E\u003Ctr\u003E\u003Cth\u003E날짜\u003C/th\u003E\u003Cth\u003E작성자\u003C/th\u003E\u003Cth\u003E이슈번호\u003C/th\u003E\u003Cth\u003E이슈 제목\u003C/th\u003E\u003Cth\u003E변경 내용\u003C/th\u003E\u003C/tr\u003E\u003C/thead\u003E\u003Ctbody\u003E\u003Ctr\u003E\u003Ctd\u003E2025.10.16\u003C/td\u003E\u003Ctd\u003E서새찬\u003C/td\u003E\u003Ctd\u003E\u003Ca href=\"https://github.com/MapSee-Lab/MapSy-BE/issues/22\" target=\"_blank\"\u003E#22\u003C/a\u003E\u003C/td\u003E\u003Ctd\u003E북마크 관련 API 추가 필요\u003C/td\u003E\u003Ctd\u003E회원 관리 API 문서화\u003C/td\u003E\u003C/tr\u003E\u003C/tbody\u003E\u003C/table\u003E", + "operationId": "createMember", + "requestBody": { + "content": { + "application/json;charset=UTF-8": { + "schema": { + "$ref": "#/components/schemas/MemberDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json;charset=UTF-8": { + "schema": { + "$ref": "#/components/schemas/MemberDto" + } + } + } + } + } + } + }, + "/api/members/profile": { + "post": { + "tags": ["회원 관리"], + "summary": "회원 프로필 설정(수정)", + "description": "## 인증(JWT): **필요**\n\n## 요청 파라미터 (ProfileUpdateRequest)\n- **`name`**: 이름 (필수)\n- **`gender`**: 성별 (MALE, FEMALE, NONE)\n- **`birthDate`**: 생년월일 (LocalDate 형식)\n\n## 반환값 (MemberDto)\n- **`memberId`**: 회원 ID\n- **`email`**: 회원 이메일\n- **`name`**: 회원 이름\n- **`gender`**: 성별\n- **`birthDate`**: 생년월일\n- **`onboardingStatus`**: 온보딩 상태\n\n## 특이사항\n- 회원 프로필 정보를 업데이트합니다.\n- 이름 중복 검사가 수행됩니다.\n\n## 에러코드\n- **`MEMBER_NOT_FOUND`**: 회원을 찾을 수 없습니다.\n- **`NAME_ALREADY_EXISTS`**: 이미 사용 중인 이름입니다.\n- **`INVALID_INPUT_VALUE`**: 유효하지 않은 입력값입니다.\n\n\n**API 변경 이력:**\n\u003Ctable\u003E\u003Cthead\u003E\u003Ctr\u003E\u003Cth\u003E날짜\u003C/th\u003E\u003Cth\u003E작성자\u003C/th\u003E\u003Cth\u003E이슈번호\u003C/th\u003E\u003Cth\u003E이슈 제목\u003C/th\u003E\u003Cth\u003E변경 내용\u003C/th\u003E\u003C/tr\u003E\u003C/thead\u003E\u003Ctbody\u003E\u003Ctr\u003E\u003Ctd\u003E2025.10.16\u003C/td\u003E\u003Ctd\u003E서새찬\u003C/td\u003E\u003Ctd\u003E\u003Ca href=\"https://github.com/MapSee-Lab/MapSy-BE/issues/22\" target=\"_blank\"\u003E#22\u003C/a\u003E\u003C/td\u003E\u003Ctd\u003E북마크 관련 API 추가 필요\u003C/td\u003E\u003Ctd\u003E회원 관리 API 문서화\u003C/td\u003E\u003C/tr\u003E\u003C/tbody\u003E\u003C/table\u003E", + "operationId": "updateProfile", + "requestBody": { + "content": { + "application/json;charset=UTF-8": { + "schema": { + "$ref": "#/components/schemas/ProfileUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json;charset=UTF-8": { + "schema": { + "$ref": "#/components/schemas/MemberDto" + } + } + } + } + } + } + }, + "/api/members/onboarding/terms": { + "post": { + "tags": ["회원 관리"], + "summary": "약관 동의", + "description": "## 인증(JWT): **필요**\n\n## 요청 파라미터 (TermAgreementRequest)\n- **`isServiceTermsAndPrivacyAgreed`**: 서비스 이용약관 및 개인정보처리방침 동의 여부 (필수, true)\n- **`isMarketingAgreed`**: 마케팅 수신 동의 여부 (선택)\n\n## 반환값 (UpdateServiceAgreementTermsResponse)\n- **`currentStep`**: 현재 온보딩 단계 (TERMS, BIRTH_DATE, GENDER, COMPLETED)\n- **`onboardingStatus`**: 온보딩 상태 (NOT_STARTED, IN_PROGRESS, COMPLETED)\n- **`member`**: 회원 정보 (디버깅용)\n\n## 특이사항\n- 서비스 이용약관 및 개인정보처리방침 동의는 필수입니다.\n- 마케팅 수신 동의는 선택 사항입니다.\n- 약관 동의 후 온보딩 상태가 IN_PROGRESS로 변경됩니다.\n\n## 에러코드\n- **`MEMBER_NOT_FOUND`**: 회원을 찾을 수 없습니다.\n- **`MEMBER_TERMS_REQUIRED_NOT_AGREED`**: 필수 약관에 동의하지 않았습니다.\n\n\n**API 변경 이력:**\n\u003Ctable\u003E\u003Cthead\u003E\u003Ctr\u003E\u003Cth\u003E날짜\u003C/th\u003E\u003Cth\u003E작성자\u003C/th\u003E\u003Cth\u003E이슈번호\u003C/th\u003E\u003Cth\u003E이슈 제목\u003C/th\u003E\u003Cth\u003E변경 내용\u003C/th\u003E\u003C/tr\u003E\u003C/thead\u003E\u003Ctbody\u003E\u003Ctr\u003E\u003Ctd\u003E2025.01.15\u003C/td\u003E\u003Ctd\u003E서새찬\u003C/td\u003E\u003Ctd\u003E\u003Ca href=\"https://github.com/MapSee-Lab/MapSy-BE/issues/22\" target=\"_blank\"\u003E#22\u003C/a\u003E\u003C/td\u003E\u003Ctd\u003E북마크 관련 API 추가 필요\u003C/td\u003E\u003Ctd\u003E온보딩 약관 동의 API 추가\u003C/td\u003E\u003C/tr\u003E\u003C/tbody\u003E\u003C/table\u003E", + "operationId": "agreeMemberTerms", + "requestBody": { + "content": { + "application/json;charset=UTF-8": { + "schema": { + "$ref": "#/components/schemas/UpdateServiceAgreementTermsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json;charset=UTF-8": { + "schema": { + "$ref": "#/components/schemas/UpdateServiceAgreementTermsResponse" + } + } + } + } + } + } + }, + "/api/members/onboarding/gender": { + "post": { + "tags": ["회원 관리"], + "summary": "성별 설정", + "description": "## 인증(JWT): **필요**\n\n## 요청 파라미터 (UpdateGenderRequest)\n- **`gender`**: 성별 (필수, MALE 또는 FEMALE)\n\n## 반환값 (OnboardingResponse)\n- **`currentStep`**: 현재 온보딩 단계\n- **`onboardingStatus`**: 온보딩 상태\n- **`member`**: 회원 정보 (디버깅용)\n\n## 특이사항\n- 온보딩 단계 중 성별 설정 단계를 완료합니다.\n\n## 에러코드\n- **`MEMBER_NOT_FOUND`**: 회원을 찾을 수 없습니다.\n- **`INVALID_INPUT_VALUE`**: 유효하지 않은 입력값입니다.\n\n\n**API 변경 이력:**\n\u003Ctable\u003E\u003Cthead\u003E\u003Ctr\u003E\u003Cth\u003E날짜\u003C/th\u003E\u003Cth\u003E작성자\u003C/th\u003E\u003Cth\u003E이슈번호\u003C/th\u003E\u003Cth\u003E이슈 제목\u003C/th\u003E\u003Cth\u003E변경 내용\u003C/th\u003E\u003C/tr\u003E\u003C/thead\u003E\u003Ctbody\u003E\u003Ctr\u003E\u003Ctd\u003E2025.01.15\u003C/td\u003E\u003Ctd\u003E서새찬\u003C/td\u003E\u003Ctd\u003E\u003Ca href=\"https://github.com/MapSee-Lab/MapSy-BE/issues/22\" target=\"_blank\"\u003E#22\u003C/a\u003E\u003C/td\u003E\u003Ctd\u003E북마크 관련 API 추가 필요\u003C/td\u003E\u003Ctd\u003E온보딩 성별 설정 API 추가\u003C/td\u003E\u003C/tr\u003E\u003C/tbody\u003E\u003C/table\u003E", + "operationId": "updateGender", + "requestBody": { + "content": { + "application/json;charset=UTF-8": { + "schema": { + "$ref": "#/components/schemas/UpdateGenderRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json;charset=UTF-8": { + "schema": { + "$ref": "#/components/schemas/OnboardingResponse" + } + } + } + } + } + } + }, + "/api/members/onboarding/birth-date": { + "post": { + "tags": ["회원 관리"], + "summary": "생년월일 설정", + "description": "## 인증(JWT): **필요**\n\n## 요청 파라미터 (UpdateBirthDateRequest)\n- **`birthDate`**: 생년월일 (필수, LocalDate 형식)\n\n## 반환값 (OnboardingResponse)\n- **`currentStep`**: 현재 온보딩 단계\n- **`onboardingStatus`**: 온보딩 상태\n- **`member`**: 회원 정보 (디버깅용)\n\n## 특이사항\n- 온보딩 단계 중 생년월일 설정 단계를 완료합니다.\n\n## 에러코드\n- **`MEMBER_NOT_FOUND`**: 회원을 찾을 수 없습니다.\n- **`INVALID_INPUT_VALUE`**: 유효하지 않은 입력값입니다.\n\n\n**API 변경 이력:**\n\u003Ctable\u003E\u003Cthead\u003E\u003Ctr\u003E\u003Cth\u003E날짜\u003C/th\u003E\u003Cth\u003E작성자\u003C/th\u003E\u003Cth\u003E이슈번호\u003C/th\u003E\u003Cth\u003E이슈 제목\u003C/th\u003E\u003Cth\u003E변경 내용\u003C/th\u003E\u003C/tr\u003E\u003C/thead\u003E\u003Ctbody\u003E\u003Ctr\u003E\u003Ctd\u003E2025.01.15\u003C/td\u003E\u003Ctd\u003E서새찬\u003C/td\u003E\u003Ctd\u003E\u003Ca href=\"https://github.com/MapSee-Lab/MapSy-BE/issues/22\" target=\"_blank\"\u003E#22\u003C/a\u003E\u003C/td\u003E\u003Ctd\u003E북마크 관련 API 추가 필요\u003C/td\u003E\u003Ctd\u003E온보딩 생년월일 설정 API 추가\u003C/td\u003E\u003C/tr\u003E\u003C/tbody\u003E\u003C/table\u003E", + "operationId": "updateBirthDate", + "requestBody": { + "content": { + "application/json;charset=UTF-8": { + "schema": { + "$ref": "#/components/schemas/UpdateBirthDateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json;charset=UTF-8": { + "schema": { + "$ref": "#/components/schemas/OnboardingResponse" + } + } + } + } + } + } + }, + "/api/content/analyze": { + "post": { + "tags": ["content-controller"], + "summary": "SNS URL로 콘텐츠 생성 및 장소 추출 요청", + "description": "## 인증(JWT): **필요**\n\n## 요청 파라미터\n- **`snsUrl`**: SNS URL (Instagram, YouTube Shorts 등)\n\n## 반환값\n- **`contentId`**: 생성된 콘텐츠 ID\n- **`status`**: 장소 추출 상태 (PENDING, ANALYZING, COMPLETED, FAILED, DELETED)\n\n## 동작 방식\n- SNS URL을 받아 콘텐츠를 생성하고 AI 서버에 장소 추출을 요청합니다.\n- 초기 상태는 `PENDING`이며, AI 서버 처리 완료 시 Webhook으로 상태가 업데이트됩니다.\n\n\n**API 변경 이력:**\n\u003Ctable\u003E\u003Cthead\u003E\u003Ctr\u003E\u003Cth\u003E날짜\u003C/th\u003E\u003Cth\u003E작성자\u003C/th\u003E\u003Cth\u003E이슈번호\u003C/th\u003E\u003Cth\u003E이슈 제목\u003C/th\u003E\u003Cth\u003E변경 내용\u003C/th\u003E\u003C/tr\u003E\u003C/thead\u003E\u003Ctbody\u003E\u003Ctr\u003E\u003Ctd\u003E2025.11.21\u003C/td\u003E\u003Ctd\u003E서새찬\u003C/td\u003E\u003Ctd\u003E\u003Ca href=\"https://github.com/MapSee-Lab/MapSy-BE/issues/88\" target=\"_blank\"\u003E#88\u003C/a\u003E\u003C/td\u003E\u003Ctd\u003E로딩 실패\u003C/td\u003E\u003Ctd\u003EContentController 리팩토링에 따른 DOCS 간소화\u003C/td\u003E\u003C/tr\u003E\u003Ctr\u003E\u003Ctd\u003E2025.11.02\u003C/td\u003E\u003Ctd\u003E강지윤\u003C/td\u003E\u003Ctd\u003E\u003Ca href=\"https://github.com/MapSee-Lab/MapSy-BE/issues/54\" target=\"_blank\"\u003E#54\u003C/a\u003E\u003C/td\u003E\u003Ctd\u003E로딩 실패\u003C/td\u003E\u003Ctd\u003E콘텐츠 Docs 추가 및 리팩토링\u003C/td\u003E\u003C/tr\u003E\u003Ctr\u003E\u003Ctd\u003E2025.10.15\u003C/td\u003E\u003Ctd\u003E서새찬\u003C/td\u003E\u003Ctd\u003E\u003Ca href=\"https://github.com/MapSee-Lab/MapSy-BE/issues/22\" target=\"_blank\"\u003E#22\u003C/a\u003E\u003C/td\u003E\u003Ctd\u003E북마크 관련 API 추가 필요\u003C/td\u003E\u003Ctd\u003E온보딩 성별 설정 API 추가\u003C/td\u003E\u003C/tr\u003E\u003C/tbody\u003E\u003C/table\u003E", + "operationId": "requestPlaceExtraction", + "requestBody": { + "content": { + "application/json;charset=UTF-8": { + "schema": { + "$ref": "#/components/schemas/RequestPlaceExtractionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json;charset=UTF-8": { + "schema": { + "$ref": "#/components/schemas/RequestPlaceExtractionResponse" + } + } + } + } + } + } + }, + "/api/auth/sign-in": { + "post": { + "tags": ["인증 API"], + "summary": "소셜 로그인", + "description": "## 인증(JWT): **불필요**\n\n## 요청 파라미터 (SignInRequest)\n- **`socialPlatform`**: 로그인 플랫폼 (KAKAO, GOOGLE)\n- **`email`**: 사용자 이메일 (필수)\n- **`name`**: 사용자 닉네임 (필수)\n- **`profileUrl`**: 사용자 프로필 url (선택)\n- **`fcmToken`**: FCM 푸시 알림 토큰 (선택)\n- **`deviceType`**: 디바이스 타입 - IOS, ANDROID (fcmToken 제공 시 필수)\n- **`deviceId`**: 디바이스 고유 식별자 UUID (fcmToken 제공 시 필수)\n\n## 반환값 (SignInResponse)\n- **`accessToken`**: 발급된 AccessToken\n- **`refreshToken`**: 발급된 RefreshToken\n- **`isFirstLogin`**: 최초 로그인 여부\n- **`requiresOnboarding`**: 온보딩 필요 여부\n- **`onboardingStep`**: 현재 온보딩 단계\n\n## 특이사항\n- 클라이언트에서 Kakao/Google OAuth 처리 후 받은 사용자 정보로 서버에 JWT 토큰을 요청합니다.\n- 액세스 토큰은 1시간, 리프레시 토큰은 7일 유효합니다.\n- **FCM 토큰을 전송하면 푸시 알림을 받을 수 있습니다. (멀티 디바이스 지원)**\n- fcmToken, deviceType, deviceId는 3개 모두 함께 전송하거나 모두 전송하지 않아야 합니다.\n- **@Valid 검증이 적용됩니다**: email, name은 필수 필드입니다.\n\n## 에러코드\n- **`INVALID_SOCIAL_TOKEN`**: 유효하지 않은 소셜 인증 토큰입니다.\n- **`SOCIAL_AUTH_FAILED`**: 소셜 로그인 인증에 실패하였습니다.\n- **`MEMBER_NOT_FOUND`**: 회원 정보를 찾을 수 없습니다.\n- **`INVALID_INPUT_VALUE`**: FCM 토큰 관련 필드가 올바르지 않습니다.\n\n\n**API 변경 이력:**\n\u003Ctable\u003E\u003Cthead\u003E\u003Ctr\u003E\u003Cth\u003E날짜\u003C/th\u003E\u003Cth\u003E작성자\u003C/th\u003E\u003Cth\u003E이슈번호\u003C/th\u003E\u003Cth\u003E이슈 제목\u003C/th\u003E\u003Cth\u003E변경 내용\u003C/th\u003E\u003C/tr\u003E\u003C/thead\u003E\u003Ctbody\u003E\u003Ctr\u003E\u003Ctd\u003E2025.11.23\u003C/td\u003E\u003Ctd\u003E서새찬\u003C/td\u003E\u003Ctd\u003E\u003C/td\u003E\u003Ctd\u003E\u003C/td\u003E\u003Ctd\u003EFCM 토큰 멀티 디바이스 지원 추가\u003C/td\u003E\u003C/tr\u003E\u003Ctr\u003E\u003Ctd\u003E2025.10.16\u003C/td\u003E\u003Ctd\u003E서새찬\u003C/td\u003E\u003Ctd\u003E\u003Ca href=\"https://github.com/MapSee-Lab/MapSy-BE/issues/22\" target=\"_blank\"\u003E#22\u003C/a\u003E\u003C/td\u003E\u003Ctd\u003E북마크 관련 API 추가 필요\u003C/td\u003E\u003Ctd\u003E인증 모듈 추가 및 기본 OAuth 로그인 구현\u003C/td\u003E\u003C/tr\u003E\u003C/tbody\u003E\u003C/table\u003E", + "operationId": "signIn", + "requestBody": { + "content": { + "application/json;charset=UTF-8": { + "schema": { + "$ref": "#/components/schemas/SignInRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json;charset=UTF-8": { + "schema": { + "$ref": "#/components/schemas/SignInResponse" + } + } + } + } + } + } + }, + "/api/auth/reissue": { + "post": { + "tags": ["인증 API"], + "summary": "토큰 재발급", + "description": "## 인증(JWT): **불필요**\n\n## 요청 파라미터 (ReissueRequest)\n- **`refreshToken`**: 리프레시 토큰 (필수)\n\n## 반환값 (ReissueResponse)\n- **`accessToken`**: 재발급된 AccessToken\n- **`refreshToken`**: 리프레시 토큰 (변경되지 않음)\n- **`isFirstLogin`**: 최초 로그인 여부\n\n## 특이사항\n- 만료된 액세스 토큰을 리프레시 토큰으로 재발급합니다.\n- **@Valid 검증이 적용됩니다**: refreshToken은 필수 필드입니다.\n\n## 에러코드\n- **`REFRESH_TOKEN_NOT_FOUND`**: 리프레시 토큰을 찾을 수 없습니다.\n- **`INVALID_REFRESH_TOKEN`**: 유효하지 않은 리프레시 토큰입니다.\n- **`EXPIRED_REFRESH_TOKEN`**: 만료된 리프레시 토큰입니다.\n- **`MEMBER_NOT_FOUND`**: 회원 정보를 찾을 수 없습니다.\n\n\n**API 변경 이력:**\n\u003Ctable\u003E\u003Cthead\u003E\u003Ctr\u003E\u003Cth\u003E날짜\u003C/th\u003E\u003Cth\u003E작성자\u003C/th\u003E\u003Cth\u003E이슈번호\u003C/th\u003E\u003Cth\u003E이슈 제목\u003C/th\u003E\u003Cth\u003E변경 내용\u003C/th\u003E\u003C/tr\u003E\u003C/thead\u003E\u003Ctbody\u003E\u003Ctr\u003E\u003Ctd\u003E2025.10.16\u003C/td\u003E\u003Ctd\u003E서새찬\u003C/td\u003E\u003Ctd\u003E\u003Ca href=\"https://github.com/MapSee-Lab/MapSy-BE/issues/22\" target=\"_blank\"\u003E#22\u003C/a\u003E\u003C/td\u003E\u003Ctd\u003E북마크 관련 API 추가 필요\u003C/td\u003E\u003Ctd\u003E토큰 재발급 기능 구현\u003C/td\u003E\u003C/tr\u003E\u003C/tbody\u003E\u003C/table\u003E", + "operationId": "reissue", + "requestBody": { + "content": { + "application/json;charset=UTF-8": { + "schema": { + "$ref": "#/components/schemas/ReissueRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json;charset=UTF-8": { + "schema": { + "$ref": "#/components/schemas/ReissueResponse" + } + } + } + } + } + } + }, + "/api/auth/logout": { + "post": { + "tags": ["인증 API"], + "summary": "로그아웃", + "description": "## 인증(JWT): **필요**\n\n## 요청 파라미터 (AuthRequest)\n- **`accessToken`**: 엑세스 토큰 (Header에서 자동 추출)\n- **`refreshToken`**: 리프레시 토큰\n\n## 반환값\n- 성공 시 상태코드 200 (OK)와 빈 응답 본문\n\n## 동작 설명\n- 액세스 토큰을 블랙리스트에 등록하여 무효화 처리\n- Redis에 저장된 리프레시 토큰 삭제\n\n## 에러코드\n- **`INVALID_TOKEN`**: 유효하지 않은 토큰입니다.\n- **`UNAUTHORIZED`**: 인증이 필요한 요청입니다.\n\n\n**API 변경 이력:**\n\u003Ctable\u003E\u003Cthead\u003E\u003Ctr\u003E\u003Cth\u003E날짜\u003C/th\u003E\u003Cth\u003E작성자\u003C/th\u003E\u003Cth\u003E이슈번호\u003C/th\u003E\u003Cth\u003E이슈 제목\u003C/th\u003E\u003Cth\u003E변경 내용\u003C/th\u003E\u003C/tr\u003E\u003C/thead\u003E\u003Ctbody\u003E\u003Ctr\u003E\u003Ctd\u003E2025.10.16\u003C/td\u003E\u003Ctd\u003E서새찬\u003C/td\u003E\u003Ctd\u003E\u003Ca href=\"https://github.com/MapSee-Lab/MapSy-BE/issues/22\" target=\"_blank\"\u003E#22\u003C/a\u003E\u003C/td\u003E\u003Ctd\u003E북마크 관련 API 추가 필요\u003C/td\u003E\u003Ctd\u003E로그아웃 기능 구현\u003C/td\u003E\u003C/tr\u003E\u003C/tbody\u003E\u003C/table\u003E", + "operationId": "logout", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=UTF-8": { + "schema": { + "$ref": "#/components/schemas/AuthRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/ai/callback": { + "post": { + "tags": ["AI 서버 API"], + "summary": "AI 서버 Webhook Callback", + "description": "## 인증(API Key): **필요** (Header: X-API-Key)\n\n## 요청 파라미터 (AiCallbackRequest)\n- **`contentId`**: Content UUID (필수)\n- **`resultStatus`**: 처리 결과 상태 (SUCCESS/FAILED) (필수)\n- **`snsPlatform`**: SNS 플랫폼 (INSTAGRAM/YOUTUBE/YOUTUBE_SHORTS/TIKTOK/FACEBOOK/TWITTER) (필수)\n- **`contentInfo`**: 콘텐츠 정보 (SUCCESS 시 필수)\n - **`title`**: 콘텐츠 제목\n - **`contentUrl`**: 콘텐츠 URL\n - **`thumbnailUrl`**: 썸네일 URL\n - **`platformUploader`**: 업로더 아이디\n - **`summary`**: AI 콘텐츠 요약 (선택)\n- **`places`**: 추출된 장소 목록 (SUCCESS 시)\n - **`name`**: 장소명\n - **`address`**: 주소\n - **`country`**: 국가 코드 (ISO 3166-1 alpha-2)\n - **`latitude`**: 위도\n - **`longitude`**: 경도\n - **`description`**: 장소 설명\n - **`rawData`**: AI 추출 원본 데이터\n\n## 반환값 (AiCallbackResponse)\n- **`received`**: 수신 여부 (true)\n- **`contentId`**: Content UUID\n\n## 특이사항\n- AI 서버가 장소 추출 분석 완료 후 이 Webhook을 호출합니다.\n- API Key는 환경변수를 통해 설정되며, 반드시 일치해야 합니다.\n- Content 상태를 ANALYZING → COMPLETED/FAILED로 변경합니다.\n- SUCCESS인 경우:\n - ContentInfo로 Content 메타데이터 업데이트 (title, contentUrl, thumbnailUrl, platformUploader, summary)\n - Place 생성 및 Content-Place 연결 수행\n - snsPlatform 값으로 Content.platform 동기화\n\n## 에러코드\n- **`INVALID_API_KEY`**: 유효하지 않은 API Key입니다.\n- **`CONTENT_NOT_FOUND`**: 콘텐츠를 찾을 수 없습니다.\n- **`INVALID_REQUEST`**: 잘못된 요청입니다.\n\n\n**API 변경 이력:**\n\u003Ctable\u003E\u003Cthead\u003E\u003Ctr\u003E\u003Cth\u003E날짜\u003C/th\u003E\u003Cth\u003E작성자\u003C/th\u003E\u003Cth\u003E이슈번호\u003C/th\u003E\u003Cth\u003E이슈 제목\u003C/th\u003E\u003Cth\u003E변경 내용\u003C/th\u003E\u003C/tr\u003E\u003C/thead\u003E\u003Ctbody\u003E\u003Ctr\u003E\u003Ctd\u003E2025.01.15\u003C/td\u003E\u003Ctd\u003E서새찬\u003C/td\u003E\u003Ctd\u003E\u003Ca href=\"https://github.com/MapSee-Lab/MapSy-BE/issues/129\" target=\"_blank\"\u003E#129\u003C/a\u003E\u003C/td\u003E\u003Ctd\u003E로딩 실패\u003C/td\u003E\u003Ctd\u003EAI 서버 Callback API ContentInfo 파라미터 추가 (contentUrl, platformUploader 필드)\u003C/td\u003E\u003C/tr\u003E\u003Ctr\u003E\u003Ctd\u003E2025.11.18\u003C/td\u003E\u003Ctd\u003E서새찬\u003C/td\u003E\u003Ctd\u003E\u003Ca href=\"https://github.com/MapSee-Lab/MapSy-BE/issues/83\" target=\"_blank\"\u003E#83\u003C/a\u003E\u003C/td\u003E\u003Ctd\u003E로딩 실패\u003C/td\u003E\u003Ctd\u003EAI 서버 Callback API ContentInfo 파라미터 추가 (summary 필드)\u003C/td\u003E\u003C/tr\u003E\u003Ctr\u003E\u003Ctd\u003E2025.11.12\u003C/td\u003E\u003Ctd\u003E서새찬\u003C/td\u003E\u003Ctd\u003E\u003Ca href=\"https://github.com/MapSee-Lab/MapSy-BE/issues/70\" target=\"_blank\"\u003E#70\u003C/a\u003E\u003C/td\u003E\u003Ctd\u003E로딩 실패\u003C/td\u003E\u003Ctd\u003E명세 변경, 기존 전체정보 > 상호명으로만 받음\u003C/td\u003E\u003C/tr\u003E\u003Ctr\u003E\u003Ctd\u003E2025.11.02\u003C/td\u003E\u003Ctd\u003E강지윤\u003C/td\u003E\u003Ctd\u003E\u003Ca href=\"https://github.com/MapSee-Lab/MapSy-BE/issues/48\" target=\"_blank\"\u003E#48\u003C/a\u003E\u003C/td\u003E\u003Ctd\u003E로딩 실패\u003C/td\u003E\u003Ctd\u003EAI 서버 Webhook Callback 리팩터링\u003C/td\u003E\u003C/tr\u003E\u003Ctr\u003E\u003Ctd\u003E2025.10.31\u003C/td\u003E\u003Ctd\u003E서새찬\u003C/td\u003E\u003Ctd\u003E\u003Ca href=\"https://github.com/MapSee-Lab/MapSy-BE/issues/48\" target=\"_blank\"\u003E#48\u003C/a\u003E\u003C/td\u003E\u003Ctd\u003E로딩 실패\u003C/td\u003E\u003Ctd\u003EAI 서버 Webhook Callback 처리 API 구현\u003C/td\u003E\u003C/tr\u003E\u003C/tbody\u003E\u003C/table\u003E", + "operationId": "handleCallback", + "parameters": [ + { + "name": "X-API-Key", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=UTF-8": { + "schema": { + "$ref": "#/components/schemas/AiCallbackRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json;charset=UTF-8": { + "schema": { + "$ref": "#/components/schemas/AiCallbackResponse" + } + } + } + } + } + } + }, + "/api/place/{placeId}": { + "get": { + "tags": ["place-controller"], + "summary": "장소 세부정보 조회", + "description": "## 인증(JWT): **필요**\n\n## 요청 파라미터\n- **`placeId`**: 조회할 장소 ID (필수, Path Variable)\n\n## 반환값 (PlaceDetailDto)\n- **`id`**: 장소 ID\n- **`name`**: 장소명\n- **`address`**: 주소\n- **`country`**: 국가 코드 (ISO 3166-1 alpha-2)\n- **`latitude`**: 위도\n- **`longitude`**: 경도\n- **`businessType`**: 업종\n- **`phone`**: 전화번호\n- **`description`**: 장소 설명\n- **`types`**: 장소 유형 배열\n- **`businessStatus`**: 영업 상태\n- **`iconUrl`**: Google 아이콘 URL\n- **`rating`**: 평점 (0.0 ~ 5.0)\n- **`userRatingsTotal`**: 리뷰 수\n- **`photoUrls`**: 사진 URL 배열\n- **`platformReferences`**: 플랫폼별 참조 정보 (Google Place ID 등)\n- **`businessHours`**: 영업시간 목록\n- **`medias`**: 추가 미디어 목록\n\n## 특이사항\n- Google Place ID를 포함한 플랫폼 참조 정보를 제공합니다.\n- 영업시간과 추가 미디어 정보가 포함됩니다.\n\n## 에러코드\n- **`PLACE_NOT_FOUND`**: 장소를 찾을 수 없습니다.\n\n\n**API 변경 이력:**\n\u003Ctable\u003E\u003Cthead\u003E\u003Ctr\u003E\u003Cth\u003E날짜\u003C/th\u003E\u003Cth\u003E작성자\u003C/th\u003E\u003Cth\u003E이슈번호\u003C/th\u003E\u003Cth\u003E이슈 제목\u003C/th\u003E\u003Cth\u003E변경 내용\u003C/th\u003E\u003C/tr\u003E\u003C/thead\u003E\u003Ctbody\u003E\u003Ctr\u003E\u003Ctd\u003E2025.10.25\u003C/td\u003E\u003Ctd\u003E서새찬\u003C/td\u003E\u003Ctd\u003E\u003Ca href=\"https://github.com/MapSee-Lab/MapSy-BE/issues/36\" target=\"_blank\"\u003E#36\u003C/a\u003E\u003C/td\u003E\u003Ctd\u003E로딩 실패\u003C/td\u003E\u003Ctd\u003E장소 상세 정보 조회 API 추가\u003C/td\u003E\u003C/tr\u003E\u003C/tbody\u003E\u003C/table\u003E", + "operationId": "getPlaceDetail", + "parameters": [ + { + "name": "placeId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json;charset=UTF-8": { + "schema": { + "$ref": "#/components/schemas/PlaceDetailDto" + } + } + } + } + } + } + }, + "/api/place/temporary": { + "get": { + "tags": ["place-controller"], + "summary": "임시 저장 장소 목록 조회", + "description": "## 인증(JWT): **필요**\n\n## 반환값 (GetTemporaryPlacesResponse)\n- **`places`**: 임시 저장 장소 목록 (List\u003CPlaceDto\u003E)\n - **`placeId`**: 장소 ID\n - **`name`**: 장소명\n - **`address`**: 주소\n - **`rating`**: 별점 (0.0 ~ 5.0)\n - **`photoUrls`**: 사진 URL 배열\n - **`description`**: 장소 요약 설명\n\n## 특이사항\n- AI 분석으로 자동 생성된 장소들을 조회합니다.\n- 사용자가 아직 저장 여부를 결정하지 않은 상태입니다.\n- 최신순으로 정렬되어 반환됩니다.\n\n## 에러코드\n- **`MEMBER_NOT_FOUND`**: 회원을 찾을 수 없습니다.\n\n\n**API 변경 이력:**\n\u003Ctable\u003E\u003Cthead\u003E\u003Ctr\u003E\u003Cth\u003E날짜\u003C/th\u003E\u003Cth\u003E작성자\u003C/th\u003E\u003Cth\u003E이슈번호\u003C/th\u003E\u003Cth\u003E이슈 제목\u003C/th\u003E\u003Cth\u003E변경 내용\u003C/th\u003E\u003C/tr\u003E\u003C/thead\u003E\u003Ctbody\u003E\u003Ctr\u003E\u003Ctd\u003E2025.11.24\u003C/td\u003E\u003Ctd\u003E서새찬\u003C/td\u003E\u003Ctd\u003E\u003Ca href=\"https://github.com/MapSee-Lab/MapSy-BE/issues/103\" target=\"_blank\"\u003E#103\u003C/a\u003E\u003C/td\u003E\u003Ctd\u003E로딩 실패\u003C/td\u003E\u003Ctd\u003E임시 저장 장소 목록 조회 API 추가\u003C/td\u003E\u003C/tr\u003E\u003C/tbody\u003E\u003C/table\u003E", + "operationId": "getTemporaryPlaces", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json;charset=UTF-8": { + "schema": { + "$ref": "#/components/schemas/GetTemporaryPlacesResponse" + } + } + } + } + } + } + }, + "/api/place/saved": { + "get": { + "tags": ["place-controller"], + "summary": "저장한 장소 목록 조회", + "description": "## 인증(JWT): **필요**\n\n## 반환값 (GetSavedPlacesResponse)\n- **`places`**: 저장한 장소 목록 (List\u003CPlaceDto\u003E)\n - **`placeId`**: 장소 ID\n - **`name`**: 장소명\n - **`address`**: 주소\n - **`rating`**: 별점 (0.0 ~ 5.0)\n - **`userRatingsTotal`**: 리뷰 수\n - **`photoUrls`**: 사진 URL 배열\n - **`description`**: 장소 요약 설명\n\n## 특이사항\n- 사용자가 명시적으로 저장한 장소들을 조회합니다.\n- 최신순으로 정렬되어 반환됩니다.\n- `/api/content/place/saved`와는 다른 MemberPlace 기반 조회입니다.\n\n## 에러코드\n- **`MEMBER_NOT_FOUND`**: 회원을 찾을 수 없습니다.\n\n\n**API 변경 이력:**\n\u003Ctable\u003E\u003Cthead\u003E\u003Ctr\u003E\u003Cth\u003E날짜\u003C/th\u003E\u003Cth\u003E작성자\u003C/th\u003E\u003Cth\u003E이슈번호\u003C/th\u003E\u003Cth\u003E이슈 제목\u003C/th\u003E\u003Cth\u003E변경 내용\u003C/th\u003E\u003C/tr\u003E\u003C/thead\u003E\u003Ctbody\u003E\u003Ctr\u003E\u003Ctd\u003E2025.01.15\u003C/td\u003E\u003Ctd\u003E서새찬\u003C/td\u003E\u003Ctd\u003E\u003Ca href=\"https://github.com/MapSee-Lab/MapSy-BE/issues/129\" target=\"_blank\"\u003E#129\u003C/a\u003E\u003C/td\u003E\u003Ctd\u003E로딩 실패\u003C/td\u003E\u003Ctd\u003E저장한 장소 목록 조회 API 응답에 userRatingsTotal 필드 추가\u003C/td\u003E\u003C/tr\u003E\u003Ctr\u003E\u003Ctd\u003E2025.11.24\u003C/td\u003E\u003Ctd\u003E서새찬\u003C/td\u003E\u003Ctd\u003E\u003Ca href=\"https://github.com/MapSee-Lab/MapSy-BE/issues/103\" target=\"_blank\"\u003E#103\u003C/a\u003E\u003C/td\u003E\u003Ctd\u003E로딩 실패\u003C/td\u003E\u003Ctd\u003E저장한 장소 목록 조회 API 추가\u003C/td\u003E\u003C/tr\u003E\u003C/tbody\u003E\u003C/table\u003E", + "operationId": "getSavedPlaces", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json;charset=UTF-8": { + "schema": { + "$ref": "#/components/schemas/GetSavedPlacesResponse" + } + } + } + } + } + } + }, + "/api/members/{memberId}": { + "get": { + "tags": ["회원 관리"], + "summary": "회원 단건 조회 (ID)", + "description": "## 인증(JWT): **불필요**\n\n## 요청 파라미터\n- **`memberId`**: 회원 ID (Path Variable)\n\n## 반환값 (MemberDto)\n- **`memberId`**: 회원 ID\n- **`email`**: 회원 이메일\n- **`nickname`**: 회원 닉네임\n- **`profileImageUrl`**: 프로필 이미지 URL\n- **`socialPlatform`**: 소셜 플랫폼\n- **`memberRole`**: 회원 권한\n- **`status`**: 회원 상태\n- **`createdAt`**: 생성일시\n- **`updatedAt`**: 수정일시\n\n## 특이사항\n- 회원 ID로 특정 회원을 조회합니다.\n- 삭제된 회원은 조회되지 않습니다.\n\n## 에러코드\n- **`MEMBER_NOT_FOUND`**: 회원을 찾을 수 없습니다.\n- **`INVALID_INPUT_VALUE`**: 유효하지 않은 입력값입니다.\n\n\n**API 변경 이력:**\n\u003Ctable\u003E\u003Cthead\u003E\u003Ctr\u003E\u003Cth\u003E날짜\u003C/th\u003E\u003Cth\u003E작성자\u003C/th\u003E\u003Cth\u003E이슈번호\u003C/th\u003E\u003Cth\u003E이슈 제목\u003C/th\u003E\u003Cth\u003E변경 내용\u003C/th\u003E\u003C/tr\u003E\u003C/thead\u003E\u003Ctbody\u003E\u003Ctr\u003E\u003Ctd\u003E2025.10.16\u003C/td\u003E\u003Ctd\u003E서새찬\u003C/td\u003E\u003Ctd\u003E\u003Ca href=\"https://github.com/MapSee-Lab/MapSy-BE/issues/22\" target=\"_blank\"\u003E#22\u003C/a\u003E\u003C/td\u003E\u003Ctd\u003E북마크 관련 API 추가 필요\u003C/td\u003E\u003Ctd\u003E회원 관리 API 문서화\u003C/td\u003E\u003C/tr\u003E\u003C/tbody\u003E\u003C/table\u003E", + "operationId": "getMemberById", + "parameters": [ + { + "name": "memberId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json;charset=UTF-8": { + "schema": { + "$ref": "#/components/schemas/MemberDto" + } + } + } + } + } + } + }, + "/api/members/email/{email}": { + "get": { + "tags": ["회원 관리"], + "summary": "회원 단건 조회 (Email)", + "description": "## 인증(JWT): **불필요**\n\n## 요청 파라미터\n- **`email`**: 회원 이메일 (Path Variable)\n\n## 반환값 (MemberDto)\n- **`memberId`**: 회원 ID\n- **`email`**: 회원 이메일\n- **`nickname`**: 회원 닉네임\n- **`profileImageUrl`**: 프로필 이미지 URL\n- **`socialPlatform`**: 소셜 플랫폼\n- **`memberRole`**: 회원 권한\n- **`status`**: 회원 상태\n- **`createdAt`**: 생성일시\n- **`updatedAt`**: 수정일시\n\n## 특이사항\n- 이메일로 특정 회원을 조회합니다.\n- 삭제된 회원은 조회되지 않습니다.\n\n## 에러코드\n- **`MEMBER_NOT_FOUND`**: 회원을 찾을 수 없습니다.\n- **`INVALID_INPUT_VALUE`**: 유효하지 않은 입력값입니다.\n\n\n**API 변경 이력:**\n\u003Ctable\u003E\u003Cthead\u003E\u003Ctr\u003E\u003Cth\u003E날짜\u003C/th\u003E\u003Cth\u003E작성자\u003C/th\u003E\u003Cth\u003E이슈번호\u003C/th\u003E\u003Cth\u003E이슈 제목\u003C/th\u003E\u003Cth\u003E변경 내용\u003C/th\u003E\u003C/tr\u003E\u003C/thead\u003E\u003Ctbody\u003E\u003Ctr\u003E\u003Ctd\u003E2025.10.16\u003C/td\u003E\u003Ctd\u003E서새찬\u003C/td\u003E\u003Ctd\u003E\u003Ca href=\"https://github.com/MapSee-Lab/MapSy-BE/issues/22\" target=\"_blank\"\u003E#22\u003C/a\u003E\u003C/td\u003E\u003Ctd\u003E북마크 관련 API 추가 필요\u003C/td\u003E\u003Ctd\u003E회원 관리 API 문서화\u003C/td\u003E\u003C/tr\u003E\u003C/tbody\u003E\u003C/table\u003E", + "operationId": "getMemberByEmail", + "parameters": [ + { + "name": "email", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json;charset=UTF-8": { + "schema": { + "$ref": "#/components/schemas/MemberDto" + } + } + } + } + } + } + }, + "/api/members/check-name": { + "get": { + "tags": ["회원 관리"], + "summary": "닉네임 중복 확인", + "description": "## 인증(JWT): **불필요**\n\n## 요청 파라미터\n- **`name`**: 확인할 닉네임 (Query Parameter, 필수, 2자 이상 50자 이하)\n\n## 반환값 (CheckNicknameResponse)\n- **`isAvailable`**: 사용 가능 여부 (true: 사용 가능, false: 이미 사용 중)\n- **`name`**: 확인한 닉네임\n\n## 특이사항\n- 회원가입 전에도 사용 가능한 API입니다.\n- 닉네임 길이는 2자 이상 50자 이하여야 합니다.\n- 탈퇴한 회원의 닉네임은 타임스탬프가 추가되어 중복 체크에서 제외됩니다.\n\n## 에러코드\n- **`INVALID_NAME_LENGTH`**: 닉네임은 2자 이상 50자 이하여야 합니다.\n- **`INVALID_INPUT_VALUE`**: 유효하지 않은 입력값입니다.\n\n\n**API 변경 이력:**\n\u003Ctable\u003E\u003Cthead\u003E\u003Ctr\u003E\u003Cth\u003E날짜\u003C/th\u003E\u003Cth\u003E작성자\u003C/th\u003E\u003Cth\u003E이슈번호\u003C/th\u003E\u003Cth\u003E이슈 제목\u003C/th\u003E\u003Cth\u003E변경 내용\u003C/th\u003E\u003C/tr\u003E\u003C/thead\u003E\u003Ctbody\u003E\u003Ctr\u003E\u003Ctd\u003E2025.11.23\u003C/td\u003E\u003Ctd\u003E서새찬\u003C/td\u003E\u003Ctd\u003E\u003Ca href=\"https://github.com/MapSee-Lab/MapSy-BE/issues/106\" target=\"_blank\"\u003E#106\u003C/a\u003E\u003C/td\u003E\u003Ctd\u003E로딩 실패\u003C/td\u003E\u003Ctd\u003E닉네임 중복 확인 API 추가\u003C/td\u003E\u003C/tr\u003E\u003C/tbody\u003E\u003C/table\u003E", + "operationId": "checkName", + "parameters": [ + { + "name": "name", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json;charset=UTF-8": { + "schema": { + "$ref": "#/components/schemas/CheckNameResponse" + } + } + } + } + } + } + }, + "/api/content/{contentId}": { + "get": { + "tags": ["content-controller"], + "summary": "단일 SNS 컨텐츠 정보 조회", + "description": "## 인증(JWT): **필요**\n\n## 요청 파라미터\n- **`contentId`**: 조회할 Content UUID (Path Variable)\n\n## 반환값\n- **`content`**: Content 상세 정보 (ContentDto)\n - `id`: 콘텐츠 ID\n - `platform`: 플랫폼 유형 (INSTAGRAM, YOUTUBE 등)\n - `status`: 처리 상태 (PENDING, COMPLETED, FAILED 등)\n - `platformUploader`: 업로더 이름\n - `caption`: 캡션\n - `thumbnailUrl`: 썸네일 URL\n - `originalUrl`: 원본 SNS URL\n - `title`: 제목\n - `summary`: 요약 설명\n - `lastCheckedAt`: 마지막 확인 시각\n- **`places`**: 연관된 Place 목록 (List\u003CPlaceDto\u003E, position 순서)\n - 각 Place 정보: `id`, `name`, `address`, `latitude`, `longitude`, `rating` 등\n\n## 동작 방식\n- Content ID로 콘텐츠 정보와 연관된 장소 목록을 조회합니다.\n- Place 목록은 position 순서대로 정렬되어 반환됩니다.\n- Content가 존재하지 않으면 404 에러를 반환합니다.\n- 연관된 Place가 없는 경우 빈 배열을 반환합니다.\n\n\n**API 변경 이력:**\n\u003Ctable\u003E\u003Cthead\u003E\u003Ctr\u003E\u003Cth\u003E날짜\u003C/th\u003E\u003Cth\u003E작성자\u003C/th\u003E\u003Cth\u003E이슈번호\u003C/th\u003E\u003Cth\u003E이슈 제목\u003C/th\u003E\u003Cth\u003E변경 내용\u003C/th\u003E\u003C/tr\u003E\u003C/thead\u003E\u003Ctbody\u003E\u003Ctr\u003E\u003Ctd\u003E2025.11.23\u003C/td\u003E\u003Ctd\u003E서새찬\u003C/td\u003E\u003Ctd\u003E\u003Ca href=\"https://github.com/MapSee-Lab/MapSy-BE/issues/111\" target=\"_blank\"\u003E#111\u003C/a\u003E\u003C/td\u003E\u003Ctd\u003E로딩 실패\u003C/td\u003E\u003Ctd\u003E단일 SNS 컨텐츠 조회 API 추가\u003C/td\u003E\u003C/tr\u003E\u003C/tbody\u003E\u003C/table\u003E", + "operationId": "getContentInfo", + "parameters": [ + { + "name": "contentId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json;charset=UTF-8": { + "schema": { + "$ref": "#/components/schemas/GetContentInfoResponse" + } + } + } + } + } + } + }, + "/api/content/recent": { + "get": { + "tags": ["content-controller"], + "summary": "최근 SNS 콘텐츠 목록 조회", + "description": "## 인증(JWT): **필요**\n\n## 요청 파라미터\n- JWT 인증만 필요, 별도 파라미터 없음\n\n## 반환값 (List\u003CRecentContentResponse\u003E)\n- **`contentId`**: 콘텐츠 UUID\n- **`platform`**: 콘텐츠 플랫폼 (INSTAGRAM, YOUTUBE, TIKTOK 등)\n- **`title`**: 콘텐츠 제목\n- **`thumbnailUrl`**: 썸네일 URL\n- **`originalUrl`**: 원본 URL\n- **`status`**: 콘텐츠 상태 (PENDING, COMPLETED, FAILED 등)\n- **`createdAt`**: 생성일시\n\n## 특이사항\n- 인증된 사용자의 최근 10개 SNS 콘텐츠 목록을 생성일시 내림차순으로 조회합니다.\n\n## 에러코드\n- **`MEMBER_NOT_FOUND`**: 해당 회원을 찾을 수 없습니다.\n\n\n**API 변경 이력:**\n\u003Ctable\u003E\u003Cthead\u003E\u003Ctr\u003E\u003Cth\u003E날짜\u003C/th\u003E\u003Cth\u003E작성자\u003C/th\u003E\u003Cth\u003E이슈번호\u003C/th\u003E\u003Cth\u003E이슈 제목\u003C/th\u003E\u003Cth\u003E변경 내용\u003C/th\u003E\u003C/tr\u003E\u003C/thead\u003E\u003Ctbody\u003E\u003Ctr\u003E\u003Ctd\u003E2025.11.16\u003C/td\u003E\u003Ctd\u003E강지윤\u003C/td\u003E\u003Ctd\u003E\u003Ca href=\"https://github.com/MapSee-Lab/MapSy-BE/issues/78\" target=\"_blank\"\u003E#78\u003C/a\u003E\u003C/td\u003E\u003Ctd\u003E로딩 실패\u003C/td\u003E\u003Ctd\u003E최근 콘텐츠 조회 Docs 추가 및 리팩토링\u003C/td\u003E\u003C/tr\u003E\u003C/tbody\u003E\u003C/table\u003E", + "operationId": "getRecentContents", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json;charset=UTF-8": { + "schema": { + "$ref": "#/components/schemas/GetRecentContentResponse" + } + } + } + } + } + } + }, + "/api/content/place/saved": { + "get": { + "tags": ["content-controller"], + "summary": "최근 장소 목록 조회", + "description": "## 인증(JWT): **필요**\n\n## 요청 파라미터\n- JWT 인증만 필요, 별도 파라미터 없음\n\n## 반환값 (List\u003C\u003E)\n- **`placeId`**: 장소 ID\n- **`name`**: 장소 이름\n- **`address`**: 장소 주소\n- **`rating`**: 장소 평점\n- **`photoUrls`**: 장소 사진 URL 목록\n- **`description`**: 장소 설명\n\n## 특이사항\n- 최신순으로 장소를 조회합니다.\n- 최대 10개의 장소를 반환합니다.\n- 장소의 사진 URL은 최대 10개까지 반환합니다.\n\n## 에러코드\n- **`MEMBER_NOT_FOUND`**: 회원을 찾을 수 없습니다.\n\n\n**API 변경 이력:**\n\u003Ctable\u003E\u003Cthead\u003E\u003Ctr\u003E\u003Cth\u003E날짜\u003C/th\u003E\u003Cth\u003E작성자\u003C/th\u003E\u003Cth\u003E이슈번호\u003C/th\u003E\u003Cth\u003E이슈 제목\u003C/th\u003E\u003Cth\u003E변경 내용\u003C/th\u003E\u003C/tr\u003E\u003C/thead\u003E\u003Ctbody\u003E\u003Ctr\u003E\u003Ctd\u003E2025.11.20\u003C/td\u003E\u003Ctd\u003E강지윤\u003C/td\u003E\u003Ctd\u003E\u003Ca href=\"https://github.com/MapSee-Lab/MapSy-BE/issues/80\" target=\"_blank\"\u003E#80\u003C/a\u003E\u003C/td\u003E\u003Ctd\u003E로딩 실패\u003C/td\u003E\u003Ctd\u003E최신순으로 장소 조회\u003C/td\u003E\u003C/tr\u003E\u003C/tbody\u003E\u003C/table\u003E", + "operationId": "getSavedPlaces_1", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json;charset=UTF-8": { + "schema": { + "$ref": "#/components/schemas/GetSavedPlacesResponse" + } + } + } + } + } + } + }, + "/api/content/member": { + "get": { + "tags": ["content-controller"], + "summary": "회원 콘텐츠 목록 조회", + "description": "## 인증(JWT): **필요**\n\n## 요청 파라미터\n- **`pageSize`**: 페이지 크기 (Query Parameter, 선택, 기본값 10)\n\n## 반환값\n- **`contentPage`**: Page\u003CContentDto\u003E\n - `content`: 콘텐츠 목록 (ContentDto 배열)\n - `id`: 콘텐츠 ID\n - `platform`: 플랫폼 유형 (INSTAGRAM, YOUTUBE 등)\n - `status`: 처리 상태 (PENDING, COMPLETED, FAILED 등)\n - `platformUploader`: 업로더 이름\n - `caption`: 캡션\n - `thumbnailUrl`: 썸네일 URL\n - `originalUrl`: 원본 SNS URL\n - `title`: 제목\n - `summary`: 요약 설명\n - `lastCheckedAt`: 마지막 확인 시각\n - `totalElements`: 전체 콘텐츠 개수\n - `totalPages`: 전체 페이지 수\n - `number`: 현재 페이지 번호 (0부터 시작)\n - `size`: 페이지 크기\n - `first`: 첫 페이지 여부\n - `last`: 마지막 페이지 여부\n\n## 동작 방식\n- 인증된 회원이 소유한 Content 목록을 최신순(createdAt DESC)으로 조회합니다.\n- Place 정보는 제외하고 Content 정보만 반환합니다.\n- 페이지 크기를 지정하지 않으면 기본 10개가 조회됩니다.\n- 첫 페이지(0번 페이지)만 조회됩니다.\n\n\n**API 변경 이력:**\n\u003Ctable\u003E\u003Cthead\u003E\u003Ctr\u003E\u003Cth\u003E날짜\u003C/th\u003E\u003Cth\u003E작성자\u003C/th\u003E\u003Cth\u003E이슈번호\u003C/th\u003E\u003Cth\u003E이슈 제목\u003C/th\u003E\u003Cth\u003E변경 내용\u003C/th\u003E\u003C/tr\u003E\u003C/thead\u003E\u003Ctbody\u003E\u003Ctr\u003E\u003Ctd\u003E2025.11.23\u003C/td\u003E\u003Ctd\u003E서새찬\u003C/td\u003E\u003Ctd\u003E\u003Ca href=\"https://github.com/MapSee-Lab/MapSy-BE/issues/112\" target=\"_blank\"\u003E#112\u003C/a\u003E\u003C/td\u003E\u003Ctd\u003E로딩 실패\u003C/td\u003E\u003Ctd\u003EMember가 소유한 Content 목록 조회 API 추가\u003C/td\u003E\u003C/tr\u003E\u003C/tbody\u003E\u003C/table\u003E", + "operationId": "getMemberContentPage", + "parameters": [ + { + "name": "pageSize", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 10 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json;charset=UTF-8": { + "schema": { + "$ref": "#/components/schemas/GetMemberContentPageResponse" + } + } + } + } + } + } + }, + "/api/place/{placeId}/temporary": { + "delete": { + "tags": ["place-controller"], + "summary": "임시 저장 장소 삭제", + "description": "## 인증(JWT): **필요**\n\n## 요청 파라미터\n- **`placeId`**: 삭제할 장소 ID (필수, Path Variable)\n\n## 반환값\n- **204 No Content**: 삭제 성공 (반환값 없음)\n\n## 특이사항\n- 임시 저장 상태(TEMPORARY)의 장소만 삭제 가능합니다.\n- 저장된 상태(SAVED)의 장소는 삭제할 수 없습니다.\n- Soft Delete 방식으로 데이터는 실제로 삭제되지 않습니다.\n\n## 에러코드\n- **`PLACE_NOT_FOUND`**: 장소를 찾을 수 없습니다.\n- **`MEMBER_PLACE_NOT_FOUND`**: 회원의 장소 정보를 찾을 수 없습니다.\n- **`CANNOT_DELETE_SAVED_PLACE`**: 임시 저장된 장소만 삭제할 수 있습니다.\n- **`MEMBER_NOT_FOUND`**: 회원을 찾을 수 없습니다.\n\n\n**API 변경 이력:**\n\u003Ctable\u003E\u003Cthead\u003E\u003Ctr\u003E\u003Cth\u003E날짜\u003C/th\u003E\u003Cth\u003E작성자\u003C/th\u003E\u003Cth\u003E이슈번호\u003C/th\u003E\u003Cth\u003E이슈 제목\u003C/th\u003E\u003Cth\u003E변경 내용\u003C/th\u003E\u003C/tr\u003E\u003C/thead\u003E\u003Ctbody\u003E\u003Ctr\u003E\u003Ctd\u003E2025.11.24\u003C/td\u003E\u003Ctd\u003E서새찬\u003C/td\u003E\u003Ctd\u003E\u003Ca href=\"https://github.com/MapSee-Lab/MapSy-BE/issues/103\" target=\"_blank\"\u003E#103\u003C/a\u003E\u003C/td\u003E\u003Ctd\u003E로딩 실패\u003C/td\u003E\u003Ctd\u003E임시 저장 장소 삭제 API 추가\u003C/td\u003E\u003C/tr\u003E\u003C/tbody\u003E\u003C/table\u003E", + "operationId": "deleteTemporaryPlace", + "parameters": [ + { + "name": "placeId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/auth/withdraw": { + "delete": { + "tags": ["인증 API"], + "summary": "회원 탈퇴", + "description": "## 인증(JWT): **필요**\n\n## 요청 파라미터\n- 없음 (JWT 토큰에서 회원 ID 추출)\n\n## 반환값\n- **`204 No Content`**: 탈퇴 성공\n\n## 특이사항\n- 현재 로그인한 회원을 탈퇴 처리합니다. (소프트삭제)\n- 탈퇴 시 이메일과 닉네임에 타임스탬프가 추가됩니다. (예: email_2025_01_19_143022)\n- 이를 통해 동일한 이메일/닉네임으로 재가입이 가능합니다.\n- 회원의 관심사도 함께 소프트삭제 됩니다.\n- **보안**: AccessToken은 블랙리스트에 등록되고, RefreshToken은 Redis에서 삭제됩니다.\n- 탈퇴 후에는 해당 토큰으로 API 접근이 불가능합니다.\n\n## 에러코드\n- **`MEMBER_NOT_FOUND`**: 회원을 찾을 수 없습니다.\n- **`MEMBER_ALREADY_WITHDRAWN`**: 이미 탈퇴한 회원입니다.\n- **`UNAUTHORIZED`**: 인증이 필요합니다.\n\n\n**API 변경 이력:**\n\u003Ctable\u003E\u003Cthead\u003E\u003Ctr\u003E\u003Cth\u003E날짜\u003C/th\u003E\u003Cth\u003E작성자\u003C/th\u003E\u003Cth\u003E이슈번호\u003C/th\u003E\u003Cth\u003E이슈 제목\u003C/th\u003E\u003Cth\u003E변경 내용\u003C/th\u003E\u003C/tr\u003E\u003C/thead\u003E\u003Ctbody\u003E\u003Ctr\u003E\u003Ctd\u003E2025.11.19\u003C/td\u003E\u003Ctd\u003E서새찬\u003C/td\u003E\u003Ctd\u003E\u003Ca href=\"https://github.com/MapSee-Lab/MapSy-BE/issues/91\" target=\"_blank\"\u003E#91\u003C/a\u003E\u003C/td\u003E\u003Ctd\u003E로딩 실패\u003C/td\u003E\u003Ctd\u003E회원 탈퇴 API 추가\u003C/td\u003E\u003C/tr\u003E\u003C/tbody\u003E\u003C/table\u003E", + "operationId": "withdrawMember", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + } + }, + "components": { + "schemas": { + "SavePlaceResponse": { + "type": "object", + "description": "장소 저장 응답", + "properties": { + "memberPlaceId": { + "type": "string", + "format": "uuid", + "description": "회원 장소 ID", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "placeId": { + "type": "string", + "format": "uuid", + "description": "장소 ID", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "savedStatus": { + "type": "string", + "description": "저장 상태", + "example": "SAVED" + }, + "savedAt": { + "type": "string", + "format": "date-time", + "description": "저장 일시", + "example": "2024-11-24T10:30:00" + } + } + }, + "MemberDto": { + "type": "object", + "description": "회원 정보 DTO", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "회원 ID", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "email": { + "type": "string", + "description": "이메일", + "example": "user@example.com" + }, + "name": { + "type": "string", + "description": "닉네임", + "example": "여행러버", + "maxLength": 50, + "minLength": 2 + }, + "onboardingStatus": { + "type": "string", + "description": "회원 상태", + "example": "NOT_STARTED" + }, + "isServiceTermsAndPrivacyAgreed": { + "type": "boolean", + "description": "서비스 이용약관 및 개인정보처리방침 동의 여부", + "example": true + }, + "isMarketingAgreed": { + "type": "boolean", + "description": "마케팅 수신 동의 여부(선택)", + "example": false + }, + "birthDate": { + "type": "string", + "format": "date", + "description": "생년월일", + "example": "1990-01-01" + }, + "gender": { + "type": "string", + "description": "성별", + "enum": ["MALE", "FEMALE", "NOT_SELECTED"], + "example": "MALE" + } + }, + "required": ["birthDate", "email", "name"] + }, + "ProfileUpdateRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "gender": { + "type": "string", + "enum": ["MALE", "FEMALE", "NOT_SELECTED"] + }, + "birthDate": { + "type": "string", + "format": "date" + } + } + }, + "UpdateServiceAgreementTermsRequest": { + "type": "object", + "properties": { + "isServiceTermsAndPrivacyAgreed": { + "type": "boolean", + "description": "서비스 이용약관 및 개인정보처리방침 동의 여부", + "example": true + }, + "isMarketingAgreed": { + "type": "boolean", + "description": "마케팅 수신 동의 여부(선택)", + "example": false + } + }, + "required": ["isServiceTermsAndPrivacyAgreed"] + }, + "UpdateServiceAgreementTermsResponse": { + "type": "object", + "properties": { + "currentStep": { + "type": "string", + "description": "현재 온보딩 단계", + "enum": ["TERMS", "BIRTH_DATE", "GENDER", "COMPLETED"], + "example": "NAME" + }, + "onboardingStatus": { + "type": "string", + "description": "온보딩 상태", + "example": "IN_PROGRESS" + }, + "member": { + "$ref": "#/components/schemas/MemberDto", + "description": "회원 정보 (디버깅용)" + } + } + }, + "UpdateGenderRequest": { + "type": "object", + "properties": { + "gender": { + "type": "string", + "description": "성별", + "enum": ["MALE", "FEMALE", "NOT_SELECTED"], + "example": "MALE" + } + }, + "required": ["gender"] + }, + "OnboardingResponse": { + "type": "object", + "properties": { + "currentStep": { + "type": "string", + "description": "현재 온보딩 단계", + "enum": ["TERMS", "BIRTH_DATE", "GENDER", "COMPLETED"], + "example": "BIRTH_DATE" + }, + "onboardingStatus": { + "type": "string", + "description": "온보딩 상태", + "example": "IN_PROGRESS" + }, + "member": { + "$ref": "#/components/schemas/MemberDto", + "description": "회원 정보 (디버깅용)" + } + } + }, + "UpdateBirthDateRequest": { + "type": "object", + "properties": { + "birthDate": { + "type": "string", + "format": "date", + "description": "생년월일", + "example": "1990-01-01" + } + }, + "required": ["birthDate"] + }, + "RequestPlaceExtractionRequest": { + "type": "object", + "properties": { + "snsUrl": { + "type": "string", + "description": "SNS URL", + "example": "https://www.instagram.com/p/ABC123/" + } + }, + "required": ["snsUrl"] + }, + "RequestPlaceExtractionResponse": { + "type": "object", + "properties": { + "contentId": { + "type": "string", + "format": "uuid" + }, + "memberId": { + "type": "string", + "format": "uuid" + }, + "status": { + "type": "string", + "enum": ["PENDING", "ANALYZING", "COMPLETED", "FAILED", "DELETED"] + } + } + }, + "SignInRequest": { + "type": "object", + "properties": { + "firebaseIdToken": { + "type": "string", + "description": "Firebase ID Token (클라이언트에서 Firebase 인증 후 전달)", + "example": "eyJhbGciOiJSUzI1NiIsImtpZCI6..." + }, + "fcmToken": { + "type": "string", + "description": "FCM 푸시 알림 토큰 (선택)", + "example": "dXQzM2k1N2RkZjM0OGE3YjczZGY5..." + }, + "deviceType": { + "type": "string", + "description": "디바이스 타입 (IOS, ANDROID)", + "enum": ["IOS", "ANDROID"], + "example": "IOS" + }, + "deviceId": { + "type": "string", + "description": "디바이스 고유 식별자 (UUID)", + "example": "550e8400-e29b-41d4-a716-446655440000" + } + }, + "required": ["firebaseIdToken"] + }, + "SignInResponse": { + "type": "object", + "properties": { + "accessToken": { + "type": "string", + "description": "액세스 토큰" + }, + "refreshToken": { + "type": "string", + "description": "리프레시 토큰" + }, + "isFirstLogin": { + "type": "boolean", + "description": "첫 로그인 여부" + }, + "requiresOnboarding": { + "type": "boolean", + "description": "약관/온보딩 완료 여부" + }, + "onboardingStep": { + "type": "string", + "description": "현재 온보딩 단계", + "example": "TERMS" + } + } + }, + "ReissueRequest": { + "type": "object", + "properties": { + "refreshToken": { + "type": "string", + "description": "리프레시 토큰", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } + }, + "required": ["refreshToken"] + }, + "ReissueResponse": { + "type": "object", + "properties": { + "accessToken": { + "type": "string", + "description": "액세스 토큰" + }, + "refreshToken": { + "type": "string", + "description": "리프레시 토큰" + }, + "isFirstLogin": { + "type": "boolean", + "description": "첫 로그인 여부" + } + } + }, + "AuthRequest": { + "type": "object", + "properties": { + "socialPlatform": { + "type": "string", + "description": "로그인 플랫폼 (KAKAO, GOOGLE 등)", + "enum": ["NORMAL", "KAKAO", "GOOGLE"], + "example": "KAKAO" + }, + "email": { + "type": "string", + "description": "소셜 로그인 후 반환된 이메일", + "example": "user@example.com" + }, + "name": { + "type": "string", + "description": "소셜 로그인 후 반환된 닉네임", + "example": "홍길동" + }, + "profileUrl": { + "type": "string", + "description": "소셜 로그인 후 반환된 프로필 URL", + "example": "https://example.com/profile.jpg" + }, + "fcmToken": { + "type": "string", + "description": "FCM 푸시 알림 토큰 (선택)", + "example": "dXQzM2k1N2RkZjM0OGE3YjczZGY5..." + }, + "deviceType": { + "type": "string", + "description": "디바이스 타입 (IOS, ANDROID)", + "enum": ["IOS", "ANDROID"], + "example": "IOS" + }, + "deviceId": { + "type": "string", + "description": "디바이스 고유 식별자 (UUID)", + "example": "550e8400-e29b-41d4-a716-446655440000" + } + } + }, + "AiCallbackRequest": { + "type": "object", + "properties": { + "contentId": { + "type": "string", + "format": "uuid", + "description": "Content UUID", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "resultStatus": { + "type": "string", + "description": "처리 결과 상태", + "enum": ["SUCCESS", "FAILED"], + "example": "SUCCESS" + }, + "snsInfo": { + "$ref": "#/components/schemas/SnsInfoCallback", + "description": "SNS 콘텐츠 정보 (SUCCESS 시 필수)" + }, + "placeDetails": { + "type": "array", + "description": "추출된 장소 상세 목록", + "items": { + "$ref": "#/components/schemas/PlaceDetailCallback" + } + }, + "statistics": { + "$ref": "#/components/schemas/ExtractionStatistics", + "description": "추출 처리 통계" + }, + "errorMessage": { + "type": "string", + "description": "실패 사유 (FAILED 시)" + } + }, + "required": ["contentId", "resultStatus"] + }, + "ExtractionStatistics": { + "type": "object", + "properties": { + "extractedPlaceNames": { + "type": "array", + "description": "LLM이 추출한 장소명 리스트", + "example": ["늘푸른목장", "강남역"], + "items": { + "type": "string" + } + }, + "totalExtracted": { + "type": "integer", + "format": "int32", + "description": "LLM이 추출한 장소 수", + "example": 2 + }, + "totalFound": { + "type": "integer", + "format": "int32", + "description": "네이버 지도에서 찾은 장소 수", + "example": 1 + }, + "failedSearches": { + "type": "array", + "description": "검색 실패한 장소명", + "example": ["강남역"], + "items": { + "type": "string" + } + } + } + }, + "PlaceDetailCallback": { + "type": "object", + "properties": { + "placeId": { + "type": "string", + "description": "네이버 Place ID", + "example": 11679241 + }, + "name": { + "type": "string", + "description": "장소명", + "example": "늘푸른목장 잠실본점" + }, + "category": { + "type": "string", + "description": "카테고리", + "example": "소고기구이" + }, + "description": { + "type": "string", + "description": "한줄 설명", + "example": "된장찌개와 냉면으로 완성하는 한상차림" + }, + "latitude": { + "type": "number", + "format": "double", + "description": "위도", + "example": 37.5112 + }, + "longitude": { + "type": "number", + "format": "double", + "description": "경도", + "example": 127.0867 + }, + "address": { + "type": "string", + "description": "지번 주소", + "example": "서울 송파구 백제고분로9길 34 1F" + }, + "roadAddress": { + "type": "string", + "description": "도로명 주소", + "example": "서울 송파구 백제고분로9길 34 1F" + }, + "subwayInfo": { + "type": "string", + "description": "지하철 정보", + "example": "잠실새내역 4번 출구에서 412m" + }, + "directionsText": { + "type": "string", + "description": "찾아가는 길", + "example": "잠실새내역 4번 출구에서 맥도널드 골목 끼고..." + }, + "rating": { + "type": "number", + "format": "double", + "description": "별점 (0.0~5.0)", + "example": 4.42 + }, + "visitorReviewCount": { + "type": "integer", + "format": "int32", + "description": "방문자 리뷰 수", + "example": 1510 + }, + "blogReviewCount": { + "type": "integer", + "format": "int32", + "description": "블로그 리뷰 수", + "example": 1173 + }, + "businessStatus": { + "type": "string", + "description": "영업 상태", + "example": "영업 중" + }, + "businessHours": { + "type": "string", + "description": "영업 시간 요약", + "example": "24:00에 영업 종료" + }, + "openHoursDetail": { + "type": "array", + "description": "요일별 상세 영업시간", + "example": ["월 11:30 - 24:00", "화 11:30 - 24:00"], + "items": { + "type": "string" + } + }, + "holidayInfo": { + "type": "string", + "description": "휴무일 정보", + "example": "연중무휴" + }, + "phoneNumber": { + "type": "string", + "description": "전화번호", + "example": "02-3431-4520" + }, + "homepageUrl": { + "type": "string", + "description": "홈페이지 URL", + "example": "http://example.com" + }, + "naverMapUrl": { + "type": "string", + "description": "네이버 지도 URL", + "example": "https://map.naver.com/p/search/늘푸른목장/place/11679241" + }, + "reservationAvailable": { + "type": "boolean", + "description": "예약 가능 여부", + "example": true + }, + "amenities": { + "type": "array", + "description": "편의시설 목록", + "example": ["단체 이용 가능", "주차", "발렛파킹"], + "items": { + "type": "string" + } + }, + "keywords": { + "type": "array", + "description": "키워드/태그", + "example": ["소고기", "한우", "회식"], + "items": { + "type": "string" + } + }, + "tvAppearances": { + "type": "array", + "description": "TV 방송 출연 정보", + "example": ["줄서는식당 14회 (24.05.13)"], + "items": { + "type": "string" + } + }, + "menuInfo": { + "type": "array", + "description": "대표 메뉴", + "example": ["경주갈비살", "한우된장밥"], + "items": { + "type": "string" + } + }, + "imageUrl": { + "type": "string", + "description": "대표 이미지 URL", + "example": "https://..." + }, + "imageUrls": { + "type": "array", + "description": "이미지 URL 목록", + "example": ["https://..."], + "items": { + "type": "string" + } + } + }, + "required": ["name", "placeId"] + }, + "SnsInfoCallback": { + "type": "object", + "properties": { + "platform": { + "type": "string", + "description": "SNS 플랫폼", + "enum": [ + "INSTAGRAM", + "YOUTUBE", + "YOUTUBE_SHORTS", + "TIKTOK", + "FACEBOOK", + "TWITTER" + ], + "example": "INSTAGRAM" + }, + "contentType": { + "type": "string", + "description": "콘텐츠 타입", + "example": "reel" + }, + "url": { + "type": "string", + "description": "원본 SNS URL", + "example": "https://www.instagram.com/reel/ABC123/" + }, + "author": { + "type": "string", + "description": "작성자 ID", + "example": "username" + }, + "caption": { + "type": "string", + "description": "게시물 본문", + "example": "여기 정말 맛있어! #맛집 #서울" + }, + "likesCount": { + "type": "integer", + "format": "int32", + "description": "좋아요 수", + "example": 1234 + }, + "commentsCount": { + "type": "integer", + "format": "int32", + "description": "댓글 수", + "example": 56 + }, + "postedAt": { + "type": "string", + "description": "게시 날짜 (ISO 8601)", + "example": "2024-01-15T10:30:00Z" + }, + "hashtags": { + "type": "array", + "description": "해시태그 리스트", + "example": ["맛집", "서울"], + "items": { + "type": "string" + } + }, + "thumbnailUrl": { + "type": "string", + "description": "대표 이미지/썸네일 URL", + "example": "https://..." + }, + "imageUrls": { + "type": "array", + "description": "이미지 URL 리스트", + "example": ["https://..."], + "items": { + "type": "string" + } + }, + "authorProfileImageUrl": { + "type": "string", + "description": "작성자 프로필 이미지 URL", + "example": "https://..." + } + }, + "required": ["contentType", "platform", "url"] + }, + "AiCallbackResponse": { + "type": "object", + "properties": { + "received": { + "type": "boolean", + "description": "수신 여부", + "example": true + }, + "contentId": { + "type": "string", + "format": "uuid", + "description": "Content UUID" + } + } + }, + "LocalTime": { + "type": "object", + "properties": { + "hour": { + "type": "integer", + "format": "int32" + }, + "minute": { + "type": "integer", + "format": "int32" + }, + "second": { + "type": "integer", + "format": "int32" + }, + "nano": { + "type": "integer", + "format": "int32" + } + } + }, + "PlaceBusinessHourDto": { + "type": "object", + "description": "영업시간 정보", + "properties": { + "weekday": { + "type": "string", + "description": "요일", + "enum": ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"], + "example": "MONDAY" + }, + "openTime": { + "$ref": "#/components/schemas/LocalTime", + "description": "오픈 시간", + "example": "09:00:00" + }, + "closeTime": { + "$ref": "#/components/schemas/LocalTime", + "description": "마감 시간", + "example": "22:00:00" + } + } + }, + "PlaceDetailDto": { + "type": "object", + "description": "장소 상세 정보", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "장소 ID", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "name": { + "type": "string", + "description": "장소명", + "example": "제주 카페 쿠모" + }, + "address": { + "type": "string", + "description": "주소", + "example": "제주특별자치도 제주시 애월읍" + }, + "country": { + "type": "string", + "description": "국가 코드 (ISO 3166-1 alpha-2)", + "example": "KR" + }, + "latitude": { + "type": "number", + "description": "위도", + "example": 33.4996213 + }, + "longitude": { + "type": "number", + "description": "경도", + "example": 126.5311884 + }, + "businessType": { + "type": "string", + "description": "업종", + "example": "카페" + }, + "phone": { + "type": "string", + "description": "전화번호", + "example": "010-1234-5678" + }, + "description": { + "type": "string", + "description": "장소 설명", + "example": "제주 바다를 바라보며 커피를 즐길 수 있는 카페" + }, + "types": { + "type": "array", + "description": "장소 유형 배열", + "example": ["cafe", "restaurant"], + "items": { + "type": "string" + } + }, + "businessStatus": { + "type": "string", + "description": "영업 상태", + "example": "OPERATIONAL" + }, + "iconUrl": { + "type": "string", + "description": "Google 아이콘 URL", + "example": "https://maps.gstatic.com/mapfiles/place_api/icons/cafe-71.png" + }, + "rating": { + "type": "number", + "description": "평점 (0.0 ~ 5.0)", + "example": 4.5 + }, + "userRatingsTotal": { + "type": "integer", + "format": "int32", + "description": "리뷰 수", + "example": 123 + }, + "photoUrls": { + "type": "array", + "description": "사진 URL 배열", + "example": [ + "https://example.com/photo1.jpg", + "https://example.com/photo2.jpg" + ], + "items": { + "type": "string" + } + }, + "platformReferences": { + "type": "array", + "description": "플랫폼별 참조 정보 (Google Place ID 등)", + "items": { + "$ref": "#/components/schemas/PlacePlatformReferenceDto" + } + }, + "businessHours": { + "type": "array", + "description": "영업시간 목록", + "items": { + "$ref": "#/components/schemas/PlaceBusinessHourDto" + } + }, + "medias": { + "type": "array", + "description": "추가 미디어 목록", + "items": { + "$ref": "#/components/schemas/PlaceMediaDto" + } + } + } + }, + "PlaceMediaDto": { + "type": "object", + "description": "장소 미디어 정보", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "미디어 ID", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "url": { + "type": "string", + "description": "미디어 URL", + "example": "https://example.com/media1.jpg" + }, + "mimeType": { + "type": "string", + "description": "MIME 타입", + "example": "image/jpeg" + }, + "position": { + "type": "integer", + "format": "int32", + "description": "정렬 순서", + "example": 0 + } + } + }, + "PlacePlatformReferenceDto": { + "type": "object", + "description": "플랫폼별 장소 참조 정보", + "properties": { + "placePlatform": { + "type": "string", + "description": "플랫폼 타입", + "enum": ["NAVER", "GOOGLE", "KAKAO"], + "example": "GOOGLE" + }, + "placePlatformId": { + "type": "string", + "description": "플랫폼 장소 ID", + "example": "ChIJN1t_tDeuEmsRUsoyG83frY4" + } + } + }, + "GetTemporaryPlacesResponse": { + "type": "object", + "description": "임시 저장 장소 목록 응답", + "properties": { + "places": { + "type": "array", + "description": "임시 저장 장소 목록", + "items": { + "$ref": "#/components/schemas/PlaceDto" + } + } + } + }, + "PlaceDto": { + "type": "object", + "description": "장소 DTO", + "properties": { + "placeId": { + "type": "string", + "format": "uuid", + "description": "장소 ID", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "name": { + "type": "string", + "description": "장소명", + "example": "스타벅스 서울역점" + }, + "address": { + "type": "string", + "description": "주소", + "example": "서울특별시 중구 명동길 29" + }, + "rating": { + "type": "number", + "description": "별점 (0.0 ~ 5.0)", + "example": 4.5 + }, + "userRatingsTotal": { + "type": "integer", + "format": "int32", + "description": "리뷰 수", + "example": 123 + }, + "photoUrls": { + "type": "array", + "description": "사진 URL 배열 (최대 10개)", + "items": { + "type": "string" + } + }, + "description": { + "type": "string", + "description": "장소 요약 설명", + "example": "서울역 인근, 공부하기 좋은 카페" + } + } + }, + "GetSavedPlacesResponse": { + "type": "object", + "description": "저장한 장소 목록 응답", + "properties": { + "places": { + "type": "array", + "description": "장소 목록", + "items": { + "$ref": "#/components/schemas/PlaceDto" + } + } + } + }, + "CheckNameResponse": { + "type": "object", + "description": "닉네임 중복 확인 응답", + "properties": { + "isAvailable": { + "type": "boolean", + "description": "사용 가능 여부 (true: 사용 가능, false: 중복)", + "example": true + }, + "name": { + "type": "string", + "description": "닉네임", + "example": "트립게더" + } + } + }, + "ContentDto": { + "type": "object", + "description": "콘텐츠 정보 DTO", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "콘텐츠 ID", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "platform": { + "type": "string", + "description": "플랫폼 유형", + "enum": [ + "INSTAGRAM", + "TIKTOK", + "YOUTUBE", + "YOUTUBE_SHORTS", + "FACEBOOK", + "TWITTER" + ], + "example": "INSTAGRAM" + }, + "status": { + "type": "string", + "description": "처리 상태", + "enum": ["PENDING", "ANALYZING", "COMPLETED", "FAILED", "DELETED"], + "example": "COMPLETED" + }, + "platformUploader": { + "type": "string", + "description": "업로더 이름", + "example": "travel_lover_123" + }, + "caption": { + "type": "string", + "description": "캡션", + "example": "제주도 여행 브이로그" + }, + "thumbnailUrl": { + "type": "string", + "description": "썸네일 URL", + "example": "https://example.com/thumbnail.jpg" + }, + "originalUrl": { + "type": "string", + "description": "원본 SNS URL", + "example": "https://www.instagram.com/p/ABC123/" + }, + "title": { + "type": "string", + "description": "제목", + "example": "제주도 힐링 여행" + }, + "summary": { + "type": "string", + "description": "요약 설명", + "example": "제주도의 아름다운 카페와 맛집을 소개합니다." + }, + "lastCheckedAt": { + "type": "string", + "format": "date-time", + "description": "마지막 확인 시각", + "example": "2025-11-23T10:30:00" + } + } + }, + "GetContentInfoResponse": { + "type": "object", + "description": "콘텐츠 정보 조회 응답", + "properties": { + "content": { + "$ref": "#/components/schemas/ContentDto", + "description": "콘텐츠 상세 정보" + }, + "places": { + "type": "array", + "description": "연관된 장소 목록 (position 순서)", + "items": { + "$ref": "#/components/schemas/PlaceDto" + } + } + } + }, + "GetRecentContentResponse": { + "type": "object", + "description": "최근 콘텐츠 목록 응답", + "properties": { + "contents": { + "type": "array", + "description": "콘텐츠 목록", + "items": { + "$ref": "#/components/schemas/ContentDto" + } + } + } + }, + "GetMemberContentPageResponse": { + "type": "object", + "description": "회원 콘텐츠 목록 조회 응답", + "properties": { + "contentPage": { + "$ref": "#/components/schemas/PageContentDto", + "description": "콘텐츠 페이지 정보 (최신순)" + } + } + }, + "PageContentDto": { + "type": "object", + "properties": { + "totalPages": { + "type": "integer", + "format": "int32" + }, + "totalElements": { + "type": "integer", + "format": "int64" + }, + "first": { + "type": "boolean" + }, + "last": { + "type": "boolean" + }, + "pageable": { + "$ref": "#/components/schemas/PageableObject" + }, + "numberOfElements": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContentDto" + } + }, + "number": { + "type": "integer", + "format": "int32" + }, + "sort": { + "$ref": "#/components/schemas/SortObject" + }, + "empty": { + "type": "boolean" + } + } + }, + "PageableObject": { + "type": "object", + "properties": { + "unpaged": { + "type": "boolean" + }, + "pageNumber": { + "type": "integer", + "format": "int32" + }, + "paged": { + "type": "boolean" + }, + "pageSize": { + "type": "integer", + "format": "int32" + }, + "offset": { + "type": "integer", + "format": "int64" + }, + "sort": { + "$ref": "#/components/schemas/SortObject" + } + } + }, + "SortObject": { + "type": "object", + "properties": { + "unsorted": { + "type": "boolean" + }, + "sorted": { + "type": "boolean" + }, + "empty": { + "type": "boolean" + } + } + } + }, + "securitySchemes": { + "Bearer Token": { + "type": "http", + "name": "Authorization", + "in": "header", + "scheme": "bearer", + "bearerFormat": "JWT" + } + } + } +} diff --git a/docs/plans/2026-02-10-fix-nickname-submit-button.md b/docs/plans/2026-02-10-fix-nickname-submit-button.md new file mode 100644 index 0000000..9b53b65 --- /dev/null +++ b/docs/plans/2026-02-10-fix-nickname-submit-button.md @@ -0,0 +1,74 @@ +# Fix Nickname Submit Button Not Enabling + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Fix the "완료" button being permanently disabled when user keeps the server-assigned random nickname without changes. + +**Architecture:** The bug is a missing `setState()` in `addPostFrameCallback`. On the first `build()`, `_initialNickname` is null, so `_isNicknameChanged` returns true, which requires `nicknameAvailable == true` for `canSubmit` - but it's null. After `addPostFrameCallback` sets `_initialNickname`, no rebuild occurs, so the button stays disabled. + +**Tech Stack:** Flutter, Riverpod + +--- + +### Task 1: Add `setState` in `addPostFrameCallback` + +**Files:** +- Modify: `lib/features/onboarding/presentation/pages/nickname_step_page.dart:28-38` + +**Step 1: Add setState after setting _initialNickname** + +Change lines 28-38 from: +```dart + @override + void initState() { + super.initState(); + // 서버에서 배정받은 임시 닉네임으로 초기화 + WidgetsBinding.instance.addPostFrameCallback((_) { + final state = ref.read(onboardingNotifierProvider); + if (state.nickname != null && state.nickname!.isNotEmpty) { + _controller.text = state.nickname!; + _initialNickname = state.nickname; + } + _focusNode.requestFocus(); + }); + } +``` + +To: +```dart + @override + void initState() { + super.initState(); + // 서버에서 배정받은 임시 닉네임으로 초기화 + WidgetsBinding.instance.addPostFrameCallback((_) { + final state = ref.read(onboardingNotifierProvider); + if (state.nickname != null && state.nickname!.isNotEmpty) { + _controller.text = state.nickname!; + _initialNickname = state.nickname; + } + _focusNode.requestFocus(); + setState(() {}); // canSubmit 재계산을 위한 rebuild 트리거 + }); + } +``` + +**Why this fixes it:** +- First build: `_initialNickname` = null → `_isNicknameChanged` = true → `canSubmit` = false +- addPostFrameCallback: `_initialNickname` = "랜덤닉네임" → `setState()` → rebuild +- Second build: `_isNicknameChanged` = false → `canSubmit` = `nickname.isNotEmpty && isFormatValid` = true → 완료 ENABLED + +--- + +### Task 2: Verify build + +Run: `flutter analyze` +Expected: No errors + +--- + +### Task 3: Commit + +```bash +git add lib/features/onboarding/presentation/pages/nickname_step_page.dart +git commit -m "온보딩 닉네임 완료 버튼 활성화 버그 수정 : fix : addPostFrameCallback 후 setState 누락으로 canSubmit 재계산 안되던 문제" +``` diff --git a/docs/plans/2026-02-10-gender-not-selected-ui.md b/docs/plans/2026-02-10-gender-not-selected-ui.md new file mode 100644 index 0000000..17e9671 --- /dev/null +++ b/docs/plans/2026-02-10-gender-not-selected-ui.md @@ -0,0 +1,73 @@ +# Gender "선택 안 함" UI Change + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Change "선택안함" from a full-width card button (same style as 남성/여성) to a subtle underlined text link, visually differentiating it as a secondary/skip action. + +**Architecture:** Replace the `_GenderButton` widget for NOT_SELECTED with a centered underlined text. When selected, the text color changes to primary to indicate active state. This is a single-file change. + +**Tech Stack:** Flutter, flutter_screenutil + +--- + +### Task 1: Replace "선택안함" card button with underlined text link + +**Files:** +- Modify: `lib/features/onboarding/presentation/pages/gender_step_page.dart:78-84` + +**Step 1: Replace the _GenderButton for 선택안함** + +Change lines 78-84 from: +```dart + SizedBox(height: 12.h), + _GenderButton( + label: '선택안함', + icon: Icons.person_outline, + isSelected: state.gender == Gender.notSelected, + onTap: () => notifier.setGender(Gender.notSelected), + ), +``` + +To: +```dart + SizedBox(height: 16.h), + Center( + child: GestureDetector( + onTap: () => notifier.setGender(Gender.notSelected), + child: Text( + '선택 안 함', + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w400, + color: state.gender == Gender.notSelected + ? AppColors.primary + : AppColors.gray500, + decoration: TextDecoration.underline, + decorationColor: state.gender == Gender.notSelected + ? AppColors.primary + : AppColors.gray500, + ), + ), + ), + ), +``` + +**Result:** "선택 안 함" appears as a small underlined text link centered below the gender cards. When selected, it turns primary blue. Visually distinct from the card-style 남성/여성 buttons. + +--- + +### Task 2: Verify build + +**Step 1: Run flutter analyze** + +Run: `cd /Users/luca/workspace/Flutter_Project/mapsy && flutter analyze` +Expected: No errors + +--- + +### Task 3: Commit + +```bash +git add lib/features/onboarding/presentation/pages/gender_step_page.dart +git commit -m "온보딩 성별 선택 UI 개선 : refactor : '선택 안 함' 옵션을 카드 버튼에서 언더라인 텍스트로 변경" +``` diff --git a/docs/plans/2026-02-10-remove-onboarding-back-navigation.md b/docs/plans/2026-02-10-remove-onboarding-back-navigation.md new file mode 100644 index 0000000..124047d --- /dev/null +++ b/docs/plans/2026-02-10-remove-onboarding-back-navigation.md @@ -0,0 +1,291 @@ +# Remove Onboarding Back Navigation & Animations + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Remove all backward navigation (back buttons) and slide animations from the onboarding flow. Onboarding is a forward-only flow controlled by `currentStep`. + +**Architecture:** Onboarding uses GoRouter with 4 step pages (Terms → BirthDate → Gender → Nickname). Currently each page (except Terms) has a back button and all pages use `_buildOnboardingPage()` for slide animations with `extra: 'back'`/`extra: 'forward'` direction control. We remove all back buttons, remove the custom animation helper, and switch routes from `pageBuilder` to `builder`. + +**Tech Stack:** Flutter, GoRouter, Riverpod + +--- + +### Task 1: Remove back buttons from BirthDateStepPage + +**Files:** +- Modify: `lib/features/onboarding/presentation/pages/birth_date_step_page.dart:38-45` + +**Step 1: Replace AppBar with back button → AppBar without back button** + +Change lines 38-45 from: +```dart + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: AppColors.gray700), + onPressed: () => context.go(RoutePaths.onboardingTerms, extra: 'back'), + ), + ), +``` + +To: +```dart + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + automaticallyImplyLeading: false, + ), +``` + +**Step 2: Remove unused import (if `AppColors` is only used by back button icon)** + +Check if `AppColors.gray700` is used elsewhere in the file. If so, keep the import. (It is used in other places in this file, so keep it.) + +--- + +### Task 2: Remove back button from GenderStepPage + +**Files:** +- Modify: `lib/features/onboarding/presentation/pages/gender_step_page.dart:24-31` + +**Step 1: Replace AppBar with back button → AppBar without back button** + +Change lines 24-31 from: +```dart + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: AppColors.gray700), + onPressed: () => context.go(RoutePaths.onboardingBirthDate, extra: 'back'), + ), + ), +``` + +To: +```dart + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + automaticallyImplyLeading: false, + ), +``` + +--- + +### Task 3: Remove back button from NicknameStepPage + +**Files:** +- Modify: `lib/features/onboarding/presentation/pages/nickname_step_page.dart:82-89` + +**Step 1: Replace AppBar with back button → AppBar without back button** + +Change lines 82-89 from: +```dart + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: AppColors.gray700), + onPressed: () => context.go(RoutePaths.onboardingGender, extra: 'back'), + ), + ), +``` + +To: +```dart + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + automaticallyImplyLeading: false, + ), +``` + +--- + +### Task 4: Remove `extra: 'forward'` from all forward navigation calls + +The `extra` parameter is only needed for the slide animation direction. Since we're removing animations, remove `extra` from all `context.go()` calls in onboarding pages. + +**Files:** +- Modify: `lib/features/onboarding/presentation/pages/terms_step_page.dart:108` +- Modify: `lib/features/onboarding/presentation/pages/birth_date_step_page.dart:117` +- Modify: `lib/features/onboarding/presentation/pages/gender_step_page.dart:108` + +**Step 1: terms_step_page.dart line 108** + +Change: +```dart +context.go(RoutePaths.onboardingBirthDate, extra: 'forward'); +``` +To: +```dart +context.go(RoutePaths.onboardingBirthDate); +``` + +**Step 2: birth_date_step_page.dart line 117** + +Change: +```dart +context.go(RoutePaths.onboardingGender, extra: 'forward'); +``` +To: +```dart +context.go(RoutePaths.onboardingGender); +``` + +**Step 3: gender_step_page.dart line 108** + +Change: +```dart +context.go(RoutePaths.onboardingNickname, extra: 'forward'); +``` +To: +```dart +context.go(RoutePaths.onboardingNickname); +``` + +(nickname_step_page.dart line 218 `context.go(RoutePaths.home)` already has no `extra` - no change needed.) + +--- + +### Task 5: Remove slide animation from router and switch to plain `builder` + +**Files:** +- Modify: `lib/router/app_router.dart:141-178` (route definitions) +- Delete: `lib/router/app_router.dart:222-253` (`_buildOnboardingPage` helper + comments) + +**Step 1: Replace `pageBuilder` with `builder` for all 4 onboarding routes** + +Change lines 141-178 from: +```dart + // ==================================================================== + // Onboarding Step Routes (슬라이드 전환 애니메이션 적용) + // ==================================================================== + GoRoute( + path: RoutePaths.onboardingTerms, + name: RoutePaths.onboardingTermsName, + pageBuilder: (context, state) => _buildOnboardingPage( + state: state, + child: const TermsStepPage(), + ), + ), + + GoRoute( + path: RoutePaths.onboardingBirthDate, + name: RoutePaths.onboardingBirthDateName, + pageBuilder: (context, state) => _buildOnboardingPage( + state: state, + child: const BirthDateStepPage(), + ), + ), + + GoRoute( + path: RoutePaths.onboardingGender, + name: RoutePaths.onboardingGenderName, + pageBuilder: (context, state) => _buildOnboardingPage( + state: state, + child: const GenderStepPage(), + ), + ), + + GoRoute( + path: RoutePaths.onboardingNickname, + name: RoutePaths.onboardingNicknameName, + pageBuilder: (context, state) => _buildOnboardingPage( + state: state, + child: const NicknameStepPage(), + ), + ), +``` + +To: +```dart + // ==================================================================== + // Onboarding Step Routes + // ==================================================================== + GoRoute( + path: RoutePaths.onboardingTerms, + name: RoutePaths.onboardingTermsName, + builder: (context, state) => const TermsStepPage(), + ), + + GoRoute( + path: RoutePaths.onboardingBirthDate, + name: RoutePaths.onboardingBirthDateName, + builder: (context, state) => const BirthDateStepPage(), + ), + + GoRoute( + path: RoutePaths.onboardingGender, + name: RoutePaths.onboardingGenderName, + builder: (context, state) => const GenderStepPage(), + ), + + GoRoute( + path: RoutePaths.onboardingNickname, + name: RoutePaths.onboardingNicknameName, + builder: (context, state) => const NicknameStepPage(), + ), +``` + +**Step 2: Delete the `_buildOnboardingPage` helper function** + +Delete lines 222-253 (the entire helper function and its surrounding comments): +```dart +// ============================================================================ +// 온보딩 슬라이드 전환 애니메이션 헬퍼 +// ============================================================================ + +/// 온보딩 페이지 슬라이드 전환을 생성하는 헬퍼 함수 +/// +/// [state.extra]가 'back'이면 좌→우 슬라이드, 그 외에는 우→좌 슬라이드 +CustomTransitionPage _buildOnboardingPage({ + required GoRouterState state, + required Widget child, +}) { + ... (entire function) +} +``` + +--- + +### Task 6: Verify build passes + +**Step 1: Run flutter analyze** + +Run: `cd /Users/luca/workspace/Flutter_Project/mapsy && flutter analyze` +Expected: No errors (warnings OK) + +**Step 2: Run build** + +Run: `cd /Users/luca/workspace/Flutter_Project/mapsy && flutter build apk --debug 2>&1 | tail -5` +Expected: BUILD SUCCESSFUL + +--- + +### Task 7: Commit + +```bash +git add lib/features/onboarding/presentation/pages/birth_date_step_page.dart \ + lib/features/onboarding/presentation/pages/gender_step_page.dart \ + lib/features/onboarding/presentation/pages/nickname_step_page.dart \ + lib/features/onboarding/presentation/pages/terms_step_page.dart \ + lib/router/app_router.dart +git commit -m "온보딩 뒤로가기 및 슬라이드 애니메이션 제거 : refactor : 온보딩 forward-only 흐름으로 변경" +``` + +--- + +## Summary of Changes + +| File | Change | +|------|--------| +| `birth_date_step_page.dart` | Remove back button (IconButton → automaticallyImplyLeading: false) | +| `gender_step_page.dart` | Remove back button | +| `nickname_step_page.dart` | Remove back button | +| `terms_step_page.dart` | Remove `extra: 'forward'` from navigation | +| `birth_date_step_page.dart` | Remove `extra: 'forward'` from navigation | +| `gender_step_page.dart` | Remove `extra: 'forward'` from navigation | +| `app_router.dart` | `pageBuilder` → `builder` for 4 routes, delete `_buildOnboardingPage()` | diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 82ab00d..0858cae 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -147,6 +147,9 @@ PODS: - nanopb/encode (3.30910.0) - package_info_plus (0.4.5): - Flutter + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS - PromisesObjC (2.4.0) - PromisesSwift (2.4.0): - PromisesObjC (= 2.4.0) @@ -168,6 +171,7 @@ DEPENDENCIES: - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - google_sign_in_ios (from `.symlinks/plugins/google_sign_in_ios/darwin`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/ios`) @@ -218,6 +222,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/google_sign_in_ios/darwin" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" sign_in_with_apple: @@ -226,12 +232,12 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: AppAuth: d4f13a8fe0baf391b2108511793e4b479691fb73 AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f - device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896 + device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d Firebase: 9a58fdbc9d8655ed7b79a19cf9690bb007d3d46d - firebase_auth: e9031a1dbe04a90d98e8d11ff2302352a1c6d9e8 - firebase_core: ee30637e6744af8e0c12a6a1e8a9718506ec2398 - firebase_crashlytics: 28b8f39df8104131376393e6af658b8b77dd120f - firebase_messaging: 343de01a8d3e18b60df0c6d37f7174c44ae38e02 + firebase_auth: e7aec07fcada64e296cf237a61df9660e52842c2 + firebase_core: 8d5e24676350f15dd111aa59a88a1ae26605f9ba + firebase_crashlytics: a14ae83fe2d4738b6b5a7bebdf9dad9ccc747e70 + firebase_messaging: 834cfc0887393d3108cdb19da8e57655c54fd0e4 FirebaseAppCheckInterop: ba3dc604a89815379e61ec2365101608d365cf7d FirebaseAuth: 4c289b1a43f5955283244a55cf6bd616de344be5 FirebaseAuthInterop: 95363fe96493cb4f106656666a0768b420cba090 @@ -244,21 +250,22 @@ SPEC CHECKSUMS: FirebaseRemoteConfigInterop: 869ddca16614f979e5c931ece11fbb0b8729ed41 FirebaseSessions: d614ca154c63dbbc6c10d6c38259c2162c4e7c9b Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb - flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 - google_sign_in_ios: b48bb9af78576358a168361173155596c845f0b9 + flutter_local_notifications: ff50f8405aaa0ccdc7dcfb9022ca192e8ad9688f + flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 + google_sign_in_ios: 7411fab6948df90490dc4620ecbcabdc3ca04017 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleSignIn: ce8c89bb9b37fb624b92e7514cc67335d1e277e4 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 - package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 + path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 RecaptchaInterop: 11e0b637842dfb48308d242afc3f448062325aba - shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb - sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418 + shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 + sign_in_with_apple: f3bf75217ea4c2c8b91823f225d70230119b8440 PODFILE CHECKSUM: 85d318c08613be190fccc1abd43524ac3b83a41b diff --git a/lib/core/constants/api_endpoints.dart b/lib/core/constants/api_endpoints.dart index 71a5f38..8dbed87 100644 --- a/lib/core/constants/api_endpoints.dart +++ b/lib/core/constants/api_endpoints.dart @@ -7,8 +7,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; /// Base URL configuration managed via environment variables (.env) /// /// **사용 가능한 환경 변수**: -/// - `API_BASE_URL`: 백엔드 API Base URL (기본값: https://api.mapsy.suhsaechan.kr) -/// - `WS_URL`: WebSocket 연결 URL +/// - `API_BASE_URL`: 백엔드 API Base URL (필수) /// - `USE_MOCK_API`: Mock API 사용 여부 (true/false) class ApiEndpoints { // Private 생성자 - 인스턴스화 방지 @@ -18,13 +17,12 @@ class ApiEndpoints { // Base URL (환경 변수에서 로드) // ============================================ - /// API Base URL (.env에서 로드, 기본값: 개발 서버) - static String get baseUrl => - dotenv.env['API_BASE_URL'] ?? 'https://api.mapsy.suhsaechan.kr'; - - /// WebSocket URL (.env에서 로드) - static String get wsUrl => - dotenv.env['WS_URL'] ?? 'wss://api.mapsy.suhsaechan.kr/ws'; + /// API Base URL (.env에서 로드) + static String get baseUrl { + final url = dotenv.env['API_BASE_URL']; + assert(url != null, 'API_BASE_URL이 .env 파일에 설정되지 않았습니다.'); + return url!; + } /// Mock API 사용 여부 (.env에서 로드) static bool get useMockApi => diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart index edcfc40..30f4837 100644 --- a/lib/core/network/api_client.dart +++ b/lib/core/network/api_client.dart @@ -1,4 +1,5 @@ import 'package:dio/dio.dart'; +import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -6,7 +7,6 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../constants/api_endpoints.dart'; import 'auth_interceptor.dart'; import 'error_interceptor.dart'; -import 'token_refresh_interceptor.dart'; part 'api_client.g.dart'; @@ -15,12 +15,14 @@ part 'api_client.g.dart'; /// 앱 전체에서 사용하는 HTTP 클라이언트입니다. /// 인증, 토큰 갱신, 에러 변환 인터셉터가 자동으로 적용됩니다. /// -/// **사용법**: -/// ```dart -/// final dio = ref.watch(dioProvider); -/// final response = await dio.get('/api/some-endpoint'); -/// ``` -@riverpod +/// **인터셉터 체인**: +/// 1. LogInterceptor (debug only) - 요청/응답 로깅 +/// 2. AuthInterceptor (통합) - 토큰 주입 + 401 갱신 + 강제 로그아웃 +/// 3. ErrorInterceptor - DioException → AppException 변환 +/// +/// keepAlive: 인터셉터 내부 상태(_isRefreshing, _pendingRequests)를 +/// 유지하기 위해 AutoDispose를 사용하지 않습니다. +@Riverpod(keepAlive: true) Dio dio(Ref ref) { final dio = Dio( BaseOptions( @@ -50,15 +52,27 @@ Dio dio(Ref ref) { ); } - // 2. 인증 (Access Token 주입) - dio.interceptors.add(AuthInterceptor(ref)); - - // 3. 토큰 갱신 (401 → Refresh → 재시도) - // refreshDio를 별도로 사용하여 인터셉터 순환 방지 + // 2. 통합 인증 인터셉터 (토큰 주입 + 401 갱신 + 강제 로그아웃) final refreshDio = ref.read(refreshDioProvider); - dio.interceptors.add(TokenRefreshInterceptor(ref, dio, refreshDio)); + dio.interceptors.add( + AuthInterceptor( + ref, + dio, + refreshDio, + onForceLogout: () async { + debugPrint('🚪 Force logout: Firebase signOut + GoRouter redirect'); + try { + await FirebaseAuth.instance.signOut(); + } catch (e) { + debugPrint('⚠️ Firebase signOut error during force logout: $e'); + } + // GoRouter의 refreshListenable이 authStateChanges를 구독하고 있으므로 + // Firebase signOut만으로 자동으로 로그인 페이지로 리다이렉트됩니다. + }, + ), + ); - // 4. 에러 변환 (DioException → AppException) + // 3. 에러 변환 (DioException → AppException) dio.interceptors.add(ErrorInterceptor()); return dio; @@ -66,9 +80,9 @@ Dio dio(Ref ref) { /// 토큰 갱신 전용 Dio 인스턴스 /// -/// TokenRefreshInterceptor에서 사용하는 별도의 Dio 인스턴스입니다. +/// AuthInterceptor에서 토큰 갱신 요청에 사용하는 별도의 Dio 인스턴스입니다. /// 인터셉터 순환을 방지하기 위해 최소한의 설정만 적용합니다. -@riverpod +@Riverpod(keepAlive: true) Dio refreshDio(Ref ref) { return Dio( BaseOptions( diff --git a/lib/core/network/api_client.g.dart b/lib/core/network/api_client.g.dart index 750d308..131162d 100644 --- a/lib/core/network/api_client.g.dart +++ b/lib/core/network/api_client.g.dart @@ -6,22 +6,24 @@ part of 'api_client.dart'; // RiverpodGenerator // ************************************************************************** -String _$dioHash() => r'59e14cd015af5ec5163321683e8232da6291ca1b'; +String _$dioHash() => r'54eb6570fb1e5e68ec85fd495e0c9a17fc4b26e8'; /// Dio 인스턴스 Provider /// /// 앱 전체에서 사용하는 HTTP 클라이언트입니다. /// 인증, 토큰 갱신, 에러 변환 인터셉터가 자동으로 적용됩니다. /// -/// **사용법**: -/// ```dart -/// final dio = ref.watch(dioProvider); -/// final response = await dio.get('/api/some-endpoint'); -/// ``` +/// **인터셉터 체인**: +/// 1. LogInterceptor (debug only) - 요청/응답 로깅 +/// 2. AuthInterceptor (통합) - 토큰 주입 + 401 갱신 + 강제 로그아웃 +/// 3. ErrorInterceptor - DioException → AppException 변환 +/// +/// keepAlive: 인터셉터 내부 상태(_isRefreshing, _pendingRequests)를 +/// 유지하기 위해 AutoDispose를 사용하지 않습니다. /// /// Copied from [dio]. @ProviderFor(dio) -final dioProvider = AutoDisposeProvider.internal( +final dioProvider = Provider.internal( dio, name: r'dioProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') @@ -33,17 +35,17 @@ final dioProvider = AutoDisposeProvider.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element -typedef DioRef = AutoDisposeProviderRef; -String _$refreshDioHash() => r'4e4a3a496c4b824d722ceb8ea9ed618351f0e92c'; +typedef DioRef = ProviderRef; +String _$refreshDioHash() => r'e30a74a4052304c887b5f57eae3142339ff5cf70'; /// 토큰 갱신 전용 Dio 인스턴스 /// -/// TokenRefreshInterceptor에서 사용하는 별도의 Dio 인스턴스입니다. +/// AuthInterceptor에서 토큰 갱신 요청에 사용하는 별도의 Dio 인스턴스입니다. /// 인터셉터 순환을 방지하기 위해 최소한의 설정만 적용합니다. /// /// Copied from [refreshDio]. @ProviderFor(refreshDio) -final refreshDioProvider = AutoDisposeProvider.internal( +final refreshDioProvider = Provider.internal( refreshDio, name: r'refreshDioProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') @@ -55,6 +57,6 @@ final refreshDioProvider = AutoDisposeProvider.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element -typedef RefreshDioRef = AutoDisposeProviderRef; +typedef RefreshDioRef = ProviderRef; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/core/network/auth_interceptor.dart b/lib/core/network/auth_interceptor.dart index 9d7bdcb..66fbc8f 100644 --- a/lib/core/network/auth_interceptor.dart +++ b/lib/core/network/auth_interceptor.dart @@ -2,22 +2,49 @@ import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../constants/api_endpoints.dart'; import 'token_storage.dart'; -/// 인증 인터셉터 +/// 통합 인증 인터셉터 /// -/// 모든 HTTP 요청에 Access Token을 자동으로 주입합니다. +/// 토큰 주입 + 401 갱신 + 강제 로그아웃을 하나의 인터셉터로 처리합니다. /// /// **동작**: -/// 1. 요청 전: Authorization 헤더에 Bearer Token 추가 -/// 2. 인증이 필요 없는 경로는 제외 (예: /auth/sign-in) +/// 1. onRequest: 인증이 필요한 요청에 Access Token 자동 주입 +/// 2. onError (401): Refresh Token으로 Access Token 갱신 후 원래 요청 재시도 +/// 3. 갱신 실패 시: 토큰 삭제 + onForceLogout 콜백 호출 (Firebase 로그아웃 + 로그인 화면 이동) +/// +/// **무한 루프 방지**: `extra['_isRetry']` 플래그로 재시도 요청의 401 재진입 차단 +/// **동시 요청 처리**: 갱신 중 들어오는 401 요청은 대기열에 추가 후 일괄 처리 class AuthInterceptor extends Interceptor { final Ref _ref; + final Dio _mainDio; + final Dio _refreshDio; + final Future Function()? onForceLogout; /// 인증이 필요 없는 경로 목록 - static const _publicPaths = ['/api/auth/sign-in', '/api/auth/reissue']; + static const List _publicPaths = [ + ApiEndpoints.signIn, + ApiEndpoints.reissue, + ApiEndpoints.checkName, + ]; + + /// 토큰 갱신 중 여부 (중복 갱신 방지) + bool _isRefreshing = false; + + /// 갱신 완료 대기 중인 요청들 + final List<_PendingRequest> _pendingRequests = []; + + AuthInterceptor( + this._ref, + this._mainDio, + this._refreshDio, { + this.onForceLogout, + }); - AuthInterceptor(this._ref); + // ============================================ + // onRequest: Access Token 자동 주입 + // ============================================ @override Future onRequest( @@ -47,8 +74,207 @@ class AuthInterceptor extends Interceptor { return handler.next(options); } + // ============================================ + // onError: 401 감지 → 토큰 갱신 → 재시도 + // ============================================ + + @override + Future onError( + DioException err, + ErrorInterceptorHandler handler, + ) async { + // 401이 아니면 그대로 전달 + if (err.response?.statusCode != 401) { + return handler.next(err); + } + + // 이미 재시도한 요청이 다시 401이면 → 강제 로그아웃 + if (err.requestOptions.extra['_isRetry'] == true) { + debugPrint('🔴 Retry request got 401 again, forcing logout...'); + await _handleForceLogout(); + return handler.next(err); + } + + // 토큰 갱신 요청 자체가 실패한 경우 → 강제 로그아웃 + if (err.requestOptions.path == ApiEndpoints.reissue) { + debugPrint('🔴 Refresh token expired, forcing logout...'); + await _handleForceLogout(); + return handler.next(err); + } + + debugPrint('🔄 401 detected, attempting token refresh...'); + + // 이미 갱신 중이면 대기열에 추가 + if (_isRefreshing) { + debugPrint('⏳ Token refresh in progress, queuing request...'); + _pendingRequests.add( + _PendingRequest(options: err.requestOptions, handler: handler), + ); + return; + } + + _isRefreshing = true; + + try { + final success = await _refreshToken(); + + if (!success) { + debugPrint('🔴 Token refresh failed, forcing logout...'); + await _handleForceLogout(); + handler.next(err); + _rejectPendingRequests(err); + return; + } + + debugPrint('✅ Token refreshed successfully'); + + // 원래 요청 재시도 (별도 try로 분리 → 재시도 실패와 갱신 실패 구분) + try { + final response = await _retryRequest(err.requestOptions); + handler.resolve(response); + _retryPendingRequests(); + } catch (retryError) { + debugPrint('❌ Retry request failed: $retryError'); + final retryException = + retryError is DioException + ? retryError + : DioException( + requestOptions: err.requestOptions, + error: retryError, + message: retryError.toString(), + ); + handler.next(retryException); + _rejectPendingRequests(retryException); + } + } catch (e) { + debugPrint('❌ Token refresh error: $e'); + await _handleForceLogout(); + handler.next(err); + _rejectPendingRequests(err); + } finally { + _isRefreshing = false; + } + } + + // ============================================ + // Private Methods + // ============================================ + /// 인증이 필요 없는 경로인지 확인 bool _isPublicPath(String path) { - return _publicPaths.any((publicPath) => path.contains(publicPath)); + return _publicPaths.any( + (publicPath) => path == publicPath || path.startsWith('$publicPath?'), + ); + } + + /// Refresh Token으로 새 Access Token 요청 + Future _refreshToken() async { + try { + final tokenStorage = _ref.read(tokenStorageProvider); + final refreshToken = await tokenStorage.getRefreshToken(); + + if (refreshToken == null || refreshToken.isEmpty) { + debugPrint('⚠️ No refresh token available'); + return false; + } + + // 별도 Dio 인스턴스로 갱신 요청 (인터셉터 순환 방지) + final response = await _refreshDio.post( + ApiEndpoints.reissue, + data: {'refreshToken': refreshToken}, + ); + + if (response.statusCode == 200 && response.data != null) { + final newAccessToken = response.data['accessToken'] as String?; + final newRefreshToken = response.data['refreshToken'] as String?; + + if (newAccessToken != null && newRefreshToken != null) { + await tokenStorage.saveTokens( + accessToken: newAccessToken, + refreshToken: newRefreshToken, + ); + return true; + } + } + + return false; + } catch (e) { + debugPrint('❌ Refresh token request failed: $e'); + return false; + } + } + + /// 원래 요청 재시도 (_isRetry 플래그 추가) + /// + /// mainDio를 사용하여 LogInterceptor, ErrorInterceptor를 거치도록 합니다. + /// _isRetry 플래그로 AuthInterceptor의 401 무한 루프를 방지합니다. + Future _retryRequest(RequestOptions requestOptions) async { + final tokenStorage = _ref.read(tokenStorageProvider); + final newAccessToken = await tokenStorage.getAccessToken(); + + // 새 토큰으로 헤더 업데이트 + requestOptions.headers['Authorization'] = 'Bearer $newAccessToken'; + // 재시도 플래그 설정 (무한 루프 방지) + requestOptions.extra['_isRetry'] = true; + + return await _mainDio.fetch(requestOptions); } + + /// 대기 중인 요청들 재시도 + void _retryPendingRequests() { + for (final pending in _pendingRequests) { + _retryRequest(pending.options).then( + (response) => pending.handler.resolve(response), + onError: (error) { + if (error is DioException) { + pending.handler.reject(error); + } else { + pending.handler.reject( + DioException( + requestOptions: pending.options, + error: error, + message: error.toString(), + ), + ); + } + }, + ); + } + _pendingRequests.clear(); + } + + /// 대기 중인 요청들 거부 + void _rejectPendingRequests(DioException err) { + for (final pending in _pendingRequests) { + pending.handler.next(err); + } + _pendingRequests.clear(); + } + + /// 강제 로그아웃 처리 + /// + /// 1. 로컬 토큰 삭제 + /// 2. onForceLogout 콜백 호출 (Firebase 로그아웃 + 로그인 화면 이동) + Future _handleForceLogout() async { + try { + final tokenStorage = _ref.read(tokenStorageProvider); + await tokenStorage.clearTokens(); + debugPrint('🚪 Tokens cleared'); + + if (onForceLogout != null) { + await onForceLogout!(); + debugPrint('🚪 Force logout callback executed'); + } + } catch (e) { + debugPrint('❌ Force logout error: $e'); + } + } +} + +/// 대기 중인 요청 정보 +class _PendingRequest { + final RequestOptions options; + final ErrorInterceptorHandler handler; + + _PendingRequest({required this.options, required this.handler}); } diff --git a/lib/core/network/error_interceptor.dart b/lib/core/network/error_interceptor.dart index 63eea66..03e399d 100644 --- a/lib/core/network/error_interceptor.dart +++ b/lib/core/network/error_interceptor.dart @@ -70,7 +70,9 @@ class ErrorInterceptor extends Interceptor { serverMessage = responseData['message'] as String? ?? responseData['error'] as String?; - serverCode = responseData['code'] as String?; + serverCode = + responseData['errorCode'] as String? ?? + responseData['code'] as String?; } switch (statusCode) { diff --git a/lib/core/network/token_refresh_interceptor.dart b/lib/core/network/token_refresh_interceptor.dart deleted file mode 100644 index 9956078..0000000 --- a/lib/core/network/token_refresh_interceptor.dart +++ /dev/null @@ -1,175 +0,0 @@ -import 'dart:async'; - -import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../constants/api_endpoints.dart'; -import 'token_storage.dart'; - -/// 토큰 갱신 인터셉터 -/// -/// 401 Unauthorized 응답 시 Refresh Token으로 Access Token을 갱신하고 -/// 원래 요청을 재시도합니다. -/// -/// **동작**: -/// 1. 401 에러 감지 -/// 2. Refresh Token으로 새 Access Token 요청 (refreshDio 사용) -/// 3. 새 토큰 저장 -/// 4. 원래 요청 재시도 -/// 5. Refresh Token도 만료된 경우 → 로그아웃 처리 -/// -/// **주의**: 토큰 갱신 요청 시 [_refreshDio]를 사용하여 -/// 인터셉터 순환 호출을 방지합니다. -class TokenRefreshInterceptor extends Interceptor { - final Ref _ref; - final Dio _mainDio; - final Dio _refreshDio; - - /// 토큰 갱신 중 여부 (중복 갱신 방지) - bool _isRefreshing = false; - - /// 갱신 완료 대기 중인 요청들 - final List<({RequestOptions options, ErrorInterceptorHandler handler})> - _pendingRequests = []; - - TokenRefreshInterceptor(this._ref, this._mainDio, this._refreshDio); - - @override - Future onError( - DioException err, - ErrorInterceptorHandler handler, - ) async { - // 401 에러가 아니면 그대로 전달 - if (err.response?.statusCode != 401) { - return handler.next(err); - } - - // 토큰 갱신 요청 자체가 실패한 경우 → 로그아웃 - if (err.requestOptions.path.contains(ApiEndpoints.reissue)) { - debugPrint('🔴 Refresh token expired, logging out...'); - await _handleLogout(); - return handler.next(err); - } - - debugPrint('🔄 401 detected, attempting token refresh...'); - - // 이미 갱신 중이면 대기열에 추가 - if (_isRefreshing) { - debugPrint('⏳ Token refresh in progress, queuing request...'); - _pendingRequests.add((options: err.requestOptions, handler: handler)); - return; - } - - _isRefreshing = true; - - try { - // 토큰 갱신 시도 - final success = await _refreshToken(); - - if (success) { - debugPrint('✅ Token refreshed successfully'); - - // 원래 요청 재시도 - final response = await _retryRequest(err.requestOptions); - handler.resolve(response); - - // 대기 중인 요청들도 재시도 - _retryPendingRequests(); - } else { - debugPrint('🔴 Token refresh failed, logging out...'); - await _handleLogout(); - handler.next(err); - _rejectPendingRequests(err); - } - } catch (e) { - debugPrint('❌ Token refresh error: $e'); - await _handleLogout(); - handler.next(err); - _rejectPendingRequests(err); - } finally { - _isRefreshing = false; - } - } - - /// Refresh Token으로 새 Access Token 요청 - Future _refreshToken() async { - try { - final tokenStorage = _ref.read(tokenStorageProvider); - final refreshToken = await tokenStorage.getRefreshToken(); - - if (refreshToken == null || refreshToken.isEmpty) { - debugPrint('⚠️ No refresh token available'); - return false; - } - - // 토큰 갱신 요청 (인터셉터 순환 방지를 위해 별도 Dio 인스턴스 사용) - final response = await _refreshDio.post( - ApiEndpoints.reissue, - data: {'refreshToken': refreshToken}, - ); - - if (response.statusCode == 200 && response.data != null) { - final newAccessToken = response.data['accessToken'] as String?; - final newRefreshToken = response.data['refreshToken'] as String?; - - if (newAccessToken != null && newRefreshToken != null) { - await tokenStorage.saveTokens( - accessToken: newAccessToken, - refreshToken: newRefreshToken, - ); - return true; - } - } - - return false; - } catch (e) { - debugPrint('❌ Refresh token request failed: $e'); - return false; - } - } - - /// 원래 요청 재시도 - Future _retryRequest(RequestOptions requestOptions) async { - final tokenStorage = _ref.read(tokenStorageProvider); - final newAccessToken = await tokenStorage.getAccessToken(); - - // 새 토큰으로 헤더 업데이트 - requestOptions.headers['Authorization'] = 'Bearer $newAccessToken'; - - return await _mainDio.fetch(requestOptions); - } - - /// 대기 중인 요청들 재시도 - void _retryPendingRequests() { - for (final pending in _pendingRequests) { - _retryRequest(pending.options).then( - (response) => pending.handler.resolve(response), - onError: (error) => pending.handler.reject(error as DioException), - ); - } - _pendingRequests.clear(); - } - - /// 대기 중인 요청들 거부 - void _rejectPendingRequests(DioException err) { - for (final pending in _pendingRequests) { - pending.handler.next(err); - } - _pendingRequests.clear(); - } - - /// 로그아웃 처리 - Future _handleLogout() async { - try { - final tokenStorage = _ref.read(tokenStorageProvider); - await tokenStorage.clearTokens(); - debugPrint('🚪 Tokens cleared, user logged out'); - - // TODO: 로그인 화면으로 리다이렉트 (GoRouter 사용) - // ref.read(routerProvider).go('/login'); - } catch (e) { - debugPrint('❌ Logout cleanup error: $e'); - } - } -} diff --git a/lib/features/auth/data/datasources/auth_remote_datasource.dart b/lib/features/auth/data/datasources/auth_remote_datasource.dart index c820206..4a5d661 100644 --- a/lib/features/auth/data/datasources/auth_remote_datasource.dart +++ b/lib/features/auth/data/datasources/auth_remote_datasource.dart @@ -5,6 +5,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../../../core/constants/api_endpoints.dart'; import '../../../../core/network/api_client.dart'; +import '../models/auth_request.dart'; import '../models/sign_in_request.dart'; import '../models/sign_in_response.dart'; import '../models/reissue_request.dart'; @@ -70,10 +71,14 @@ class AuthRemoteDataSource { /// 로그아웃 /// /// 서버에 로그아웃을 알리고 토큰을 무효화합니다. - Future logout() async { + /// [request]에 기기 정보와 사용자 정보를 담아 전송합니다. + Future logout(AuthRequest request) async { debugPrint('🚪 Calling logout API...'); - await _dio.post(ApiEndpoints.logout); + await _dio.post( + ApiEndpoints.logout, + data: request.toJson(), + ); debugPrint('✅ Logout API completed'); } diff --git a/lib/features/auth/data/models/auth_request.dart b/lib/features/auth/data/models/auth_request.dart new file mode 100644 index 0000000..cc4383a --- /dev/null +++ b/lib/features/auth/data/models/auth_request.dart @@ -0,0 +1,37 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'auth_request.freezed.dart'; +part 'auth_request.g.dart'; + +/// 인증 요청 DTO (로그아웃/회원탈퇴 등에서 사용) +/// +/// 백엔드 AuthRequest 스키마에 대응하는 모델입니다. +/// 모든 필드가 선택이지만, 서버에서 request body 자체는 필수입니다. +@freezed +class AuthRequest with _$AuthRequest { + const factory AuthRequest({ + /// 로그인 플랫폼 (GOOGLE, KAKAO, NORMAL) + String? socialPlatform, + + /// 소셜 로그인 이메일 + String? email, + + /// 소셜 로그인 닉네임 + String? name, + + /// 프로필 이미지 URL + String? profileUrl, + + /// FCM 푸시 알림 토큰 + String? fcmToken, + + /// 디바이스 타입 (IOS, ANDROID) + String? deviceType, + + /// 디바이스 고유 식별자 (UUID) + String? deviceId, + }) = _AuthRequest; + + factory AuthRequest.fromJson(Map json) => + _$AuthRequestFromJson(json); +} diff --git a/lib/features/auth/data/models/auth_request.freezed.dart b/lib/features/auth/data/models/auth_request.freezed.dart new file mode 100644 index 0000000..7cbcd01 --- /dev/null +++ b/lib/features/auth/data/models/auth_request.freezed.dart @@ -0,0 +1,352 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'auth_request.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +AuthRequest _$AuthRequestFromJson(Map json) { + return _AuthRequest.fromJson(json); +} + +/// @nodoc +mixin _$AuthRequest { + /// 로그인 플랫폼 (GOOGLE, KAKAO, NORMAL) + String? get socialPlatform => throw _privateConstructorUsedError; + + /// 소셜 로그인 이메일 + String? get email => throw _privateConstructorUsedError; + + /// 소셜 로그인 닉네임 + String? get name => throw _privateConstructorUsedError; + + /// 프로필 이미지 URL + String? get profileUrl => throw _privateConstructorUsedError; + + /// FCM 푸시 알림 토큰 + String? get fcmToken => throw _privateConstructorUsedError; + + /// 디바이스 타입 (IOS, ANDROID) + String? get deviceType => throw _privateConstructorUsedError; + + /// 디바이스 고유 식별자 (UUID) + String? get deviceId => throw _privateConstructorUsedError; + + /// Serializes this AuthRequest to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of AuthRequest + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $AuthRequestCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $AuthRequestCopyWith<$Res> { + factory $AuthRequestCopyWith( + AuthRequest value, + $Res Function(AuthRequest) then, + ) = _$AuthRequestCopyWithImpl<$Res, AuthRequest>; + @useResult + $Res call({ + String? socialPlatform, + String? email, + String? name, + String? profileUrl, + String? fcmToken, + String? deviceType, + String? deviceId, + }); +} + +/// @nodoc +class _$AuthRequestCopyWithImpl<$Res, $Val extends AuthRequest> + implements $AuthRequestCopyWith<$Res> { + _$AuthRequestCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of AuthRequest + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? socialPlatform = freezed, + Object? email = freezed, + Object? name = freezed, + Object? profileUrl = freezed, + Object? fcmToken = freezed, + Object? deviceType = freezed, + Object? deviceId = freezed, + }) { + return _then( + _value.copyWith( + socialPlatform: freezed == socialPlatform + ? _value.socialPlatform + : socialPlatform // ignore: cast_nullable_to_non_nullable + as String?, + email: freezed == email + ? _value.email + : email // ignore: cast_nullable_to_non_nullable + as String?, + name: freezed == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String?, + profileUrl: freezed == profileUrl + ? _value.profileUrl + : profileUrl // ignore: cast_nullable_to_non_nullable + as String?, + fcmToken: freezed == fcmToken + ? _value.fcmToken + : fcmToken // ignore: cast_nullable_to_non_nullable + as String?, + deviceType: freezed == deviceType + ? _value.deviceType + : deviceType // ignore: cast_nullable_to_non_nullable + as String?, + deviceId: freezed == deviceId + ? _value.deviceId + : deviceId // ignore: cast_nullable_to_non_nullable + as String?, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$AuthRequestImplCopyWith<$Res> + implements $AuthRequestCopyWith<$Res> { + factory _$$AuthRequestImplCopyWith( + _$AuthRequestImpl value, + $Res Function(_$AuthRequestImpl) then, + ) = __$$AuthRequestImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + String? socialPlatform, + String? email, + String? name, + String? profileUrl, + String? fcmToken, + String? deviceType, + String? deviceId, + }); +} + +/// @nodoc +class __$$AuthRequestImplCopyWithImpl<$Res> + extends _$AuthRequestCopyWithImpl<$Res, _$AuthRequestImpl> + implements _$$AuthRequestImplCopyWith<$Res> { + __$$AuthRequestImplCopyWithImpl( + _$AuthRequestImpl _value, + $Res Function(_$AuthRequestImpl) _then, + ) : super(_value, _then); + + /// Create a copy of AuthRequest + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? socialPlatform = freezed, + Object? email = freezed, + Object? name = freezed, + Object? profileUrl = freezed, + Object? fcmToken = freezed, + Object? deviceType = freezed, + Object? deviceId = freezed, + }) { + return _then( + _$AuthRequestImpl( + socialPlatform: freezed == socialPlatform + ? _value.socialPlatform + : socialPlatform // ignore: cast_nullable_to_non_nullable + as String?, + email: freezed == email + ? _value.email + : email // ignore: cast_nullable_to_non_nullable + as String?, + name: freezed == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String?, + profileUrl: freezed == profileUrl + ? _value.profileUrl + : profileUrl // ignore: cast_nullable_to_non_nullable + as String?, + fcmToken: freezed == fcmToken + ? _value.fcmToken + : fcmToken // ignore: cast_nullable_to_non_nullable + as String?, + deviceType: freezed == deviceType + ? _value.deviceType + : deviceType // ignore: cast_nullable_to_non_nullable + as String?, + deviceId: freezed == deviceId + ? _value.deviceId + : deviceId // ignore: cast_nullable_to_non_nullable + as String?, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$AuthRequestImpl implements _AuthRequest { + const _$AuthRequestImpl({ + this.socialPlatform, + this.email, + this.name, + this.profileUrl, + this.fcmToken, + this.deviceType, + this.deviceId, + }); + + factory _$AuthRequestImpl.fromJson(Map json) => + _$$AuthRequestImplFromJson(json); + + /// 로그인 플랫폼 (GOOGLE, KAKAO, NORMAL) + @override + final String? socialPlatform; + + /// 소셜 로그인 이메일 + @override + final String? email; + + /// 소셜 로그인 닉네임 + @override + final String? name; + + /// 프로필 이미지 URL + @override + final String? profileUrl; + + /// FCM 푸시 알림 토큰 + @override + final String? fcmToken; + + /// 디바이스 타입 (IOS, ANDROID) + @override + final String? deviceType; + + /// 디바이스 고유 식별자 (UUID) + @override + final String? deviceId; + + @override + String toString() { + return 'AuthRequest(socialPlatform: $socialPlatform, email: $email, name: $name, profileUrl: $profileUrl, fcmToken: $fcmToken, deviceType: $deviceType, deviceId: $deviceId)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$AuthRequestImpl && + (identical(other.socialPlatform, socialPlatform) || + other.socialPlatform == socialPlatform) && + (identical(other.email, email) || other.email == email) && + (identical(other.name, name) || other.name == name) && + (identical(other.profileUrl, profileUrl) || + other.profileUrl == profileUrl) && + (identical(other.fcmToken, fcmToken) || + other.fcmToken == fcmToken) && + (identical(other.deviceType, deviceType) || + other.deviceType == deviceType) && + (identical(other.deviceId, deviceId) || + other.deviceId == deviceId)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + socialPlatform, + email, + name, + profileUrl, + fcmToken, + deviceType, + deviceId, + ); + + /// Create a copy of AuthRequest + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$AuthRequestImplCopyWith<_$AuthRequestImpl> get copyWith => + __$$AuthRequestImplCopyWithImpl<_$AuthRequestImpl>(this, _$identity); + + @override + Map toJson() { + return _$$AuthRequestImplToJson(this); + } +} + +abstract class _AuthRequest implements AuthRequest { + const factory _AuthRequest({ + final String? socialPlatform, + final String? email, + final String? name, + final String? profileUrl, + final String? fcmToken, + final String? deviceType, + final String? deviceId, + }) = _$AuthRequestImpl; + + factory _AuthRequest.fromJson(Map json) = + _$AuthRequestImpl.fromJson; + + /// 로그인 플랫폼 (GOOGLE, KAKAO, NORMAL) + @override + String? get socialPlatform; + + /// 소셜 로그인 이메일 + @override + String? get email; + + /// 소셜 로그인 닉네임 + @override + String? get name; + + /// 프로필 이미지 URL + @override + String? get profileUrl; + + /// FCM 푸시 알림 토큰 + @override + String? get fcmToken; + + /// 디바이스 타입 (IOS, ANDROID) + @override + String? get deviceType; + + /// 디바이스 고유 식별자 (UUID) + @override + String? get deviceId; + + /// Create a copy of AuthRequest + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$AuthRequestImplCopyWith<_$AuthRequestImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/features/auth/data/models/auth_request.g.dart b/lib/features/auth/data/models/auth_request.g.dart new file mode 100644 index 0000000..e7ce9f5 --- /dev/null +++ b/lib/features/auth/data/models/auth_request.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'auth_request.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$AuthRequestImpl _$$AuthRequestImplFromJson(Map json) => + _$AuthRequestImpl( + socialPlatform: json['socialPlatform'] as String?, + email: json['email'] as String?, + name: json['name'] as String?, + profileUrl: json['profileUrl'] as String?, + fcmToken: json['fcmToken'] as String?, + deviceType: json['deviceType'] as String?, + deviceId: json['deviceId'] as String?, + ); + +Map _$$AuthRequestImplToJson(_$AuthRequestImpl instance) => + { + 'socialPlatform': instance.socialPlatform, + 'email': instance.email, + 'name': instance.name, + 'profileUrl': instance.profileUrl, + 'fcmToken': instance.fcmToken, + 'deviceType': instance.deviceType, + 'deviceId': instance.deviceId, + }; diff --git a/lib/features/auth/data/repositories/auth_repository_impl.dart b/lib/features/auth/data/repositories/auth_repository_impl.dart index 297dbbb..69ace15 100644 --- a/lib/features/auth/data/repositories/auth_repository_impl.dart +++ b/lib/features/auth/data/repositories/auth_repository_impl.dart @@ -5,6 +5,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../../../core/network/token_storage.dart'; import '../../domain/repositories/auth_repository.dart'; import '../datasources/auth_remote_datasource.dart'; +import '../models/auth_request.dart'; import '../models/sign_in_request.dart'; import '../models/sign_in_response.dart'; import '../models/reissue_request.dart'; @@ -88,12 +89,26 @@ class AuthRepositoryImpl implements AuthRepository { } @override - Future logout() async { + Future logout({ + String? socialPlatform, + String? email, + String? name, + String? fcmToken, + String? deviceType, + String? deviceId, + }) async { debugPrint('🚪 AuthRepository: Logging out...'); try { - // 서버에 로그아웃 알림 - await _remoteDataSource.logout(); + final request = AuthRequest( + socialPlatform: socialPlatform, + email: email, + name: name, + fcmToken: fcmToken, + deviceType: deviceType, + deviceId: deviceId, + ); + await _remoteDataSource.logout(request); } catch (e) { // 서버 로그아웃 실패해도 로컬 토큰은 삭제 debugPrint('⚠️ Server logout failed, clearing local tokens anyway: $e'); diff --git a/lib/features/auth/domain/repositories/auth_repository.dart b/lib/features/auth/domain/repositories/auth_repository.dart index a48a0f0..924eb9b 100644 --- a/lib/features/auth/domain/repositories/auth_repository.dart +++ b/lib/features/auth/domain/repositories/auth_repository.dart @@ -30,7 +30,21 @@ abstract class AuthRepository { /// 로그아웃 /// /// 서버에 로그아웃을 알리고 로컬 토큰을 삭제합니다. - Future logout(); + /// + /// [socialPlatform]: 로그인 플랫폼 (GOOGLE, KAKAO 등) + /// [email]: 사용자 이메일 + /// [name]: 사용자 닉네임 + /// [fcmToken]: FCM 토큰 (선택) + /// [deviceType]: 디바이스 타입 (선택) + /// [deviceId]: 디바이스 ID (선택) + Future logout({ + String? socialPlatform, + String? email, + String? name, + String? fcmToken, + String? deviceType, + String? deviceId, + }); /// 회원 탈퇴 /// diff --git a/lib/features/auth/presentation/providers/auth_provider.dart b/lib/features/auth/presentation/providers/auth_provider.dart index 3c9cdc2..9596c2e 100644 --- a/lib/features/auth/presentation/providers/auth_provider.dart +++ b/lib/features/auth/presentation/providers/auth_provider.dart @@ -1,3 +1,4 @@ +import 'package:dio/dio.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -77,9 +78,14 @@ class AuthNotifier extends _$AuthNotifier { ); return null; } catch (e, stack) { + debugPrint('❌ Google 로그인 에러: $e'); + debugPrint('❌ Stack trace: $stack'); await _cleanupSessionOnFailure('google'); state = AsyncValue.error( - AuthException(message: '알 수 없는 오류가 발생했습니다.', originalException: e), + AuthException( + message: _extractErrorMessage(e), + originalException: e, + ), stack, ); return null; @@ -114,9 +120,14 @@ class AuthNotifier extends _$AuthNotifier { ); return null; } catch (e, stack) { + debugPrint('❌ Apple 로그인 에러: $e'); + debugPrint('❌ Stack trace: $stack'); await _cleanupSessionOnFailure('apple'); state = AsyncValue.error( - AuthException(message: '알 수 없는 오류가 발생했습니다.', originalException: e), + AuthException( + message: _extractErrorMessage(e), + originalException: e, + ), stack, ); return null; @@ -167,29 +178,63 @@ class AuthNotifier extends _$AuthNotifier { } /// 로그아웃 + /// + /// 1. Firebase 유저 정보 + 기기 정보 수집 + /// 2. 백엔드 로그아웃 API 호출 (AuthRequest body 포함) + /// 3. Firebase 로그아웃 Future signOut() async { state = const AsyncValue.loading(); try { - // Firebase 로그아웃 + // 1. Firebase 로그아웃 전에 유저 정보 수집 final dataSource = ref.read(firebaseAuthDataSourceProvider); - await dataSource.signOut(); + final currentUser = dataSource.currentUser; - // 백엔드 로그아웃 + 토큰 삭제 + // 2. FCM 토큰 + 기기 정보 수집 + String? fcmToken; + String? deviceType; + String? deviceId; + try { + final fcmService = ref.read(firebaseMessagingServiceProvider); + fcmToken = await fcmService.getFcmToken(); + } catch (_) {} + try { + final deviceInfoService = ref.read(deviceInfoServiceProvider); + final deviceInfo = await deviceInfoService.getDeviceInfo(); + deviceType = deviceInfo.deviceType; + deviceId = deviceInfo.deviceId; + } catch (_) {} + + // 3. 백엔드 로그아웃 API 호출 (Firebase 로그아웃 전에 수행) final authRepository = ref.read(authRepositoryProvider); - await authRepository.logout(); + await authRepository.logout( + socialPlatform: 'GOOGLE', + email: currentUser?.email, + name: currentUser?.displayName, + fcmToken: fcmToken, + deviceType: deviceType, + deviceId: deviceId, + ); + + // 4. Firebase 로그아웃 + await dataSource.signOut(); state = const AsyncValue.data(null); debugPrint('✅ Sign out success'); } catch (e, stack) { - // 에러가 발생해도 로컬 토큰은 삭제 + // 에러가 발생해도 로컬 토큰은 삭제하고 Firebase 로그아웃 try { + final dataSource = ref.read(firebaseAuthDataSourceProvider); + await dataSource.signOut(); final tokenStorage = ref.read(tokenStorageProvider); await tokenStorage.clearTokens(); } catch (_) {} state = AsyncValue.error( - AuthException(message: '로그아웃에 실패했습니다.', originalException: e), + AuthException( + message: _extractErrorMessage(e), + originalException: e, + ), stack, ); } @@ -212,7 +257,10 @@ class AuthNotifier extends _$AuthNotifier { debugPrint('✅ Withdraw success'); } catch (e, stack) { state = AsyncValue.error( - AuthException(message: '회원 탈퇴에 실패했습니다.', originalException: e), + AuthException( + message: _extractErrorMessage(e), + originalException: e, + ), stack, ); } @@ -259,6 +307,28 @@ class AuthNotifier extends _$AuthNotifier { } } + /// 에러에서 백엔드 메시지를 추출 + /// + /// ErrorInterceptor가 변환한 AppException → DioException의 response에서 + /// 백엔드가 보낸 원본 메시지를 그대로 추출합니다. + String _extractErrorMessage(dynamic error) { + // ErrorInterceptor가 변환한 AppException + if (error is DioException && error.error is AppException) { + return (error.error as AppException).message; + } + // AppException 직접 전달된 경우 + if (error is AppException) { + return error.message; + } + // DioException response에서 직접 추출 + if (error is DioException && error.response?.data is Map) { + final data = error.response!.data as Map; + final message = data['message'] as String?; + if (message != null) return message; + } + return error.toString(); + } + /// 로그인 실패 시 세션 정리 Future _cleanupSessionOnFailure(String provider) async { try { diff --git a/lib/features/auth/presentation/providers/auth_provider.g.dart b/lib/features/auth/presentation/providers/auth_provider.g.dart index 056bfed..c0f1674 100644 --- a/lib/features/auth/presentation/providers/auth_provider.g.dart +++ b/lib/features/auth/presentation/providers/auth_provider.g.dart @@ -50,12 +50,12 @@ final authStateProvider = AutoDisposeStreamProvider.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef AuthStateRef = AutoDisposeStreamProviderRef; -String _$authNotifierHash() => r'a210c385ebe3d6b5df10f0b2e8bf2944c575a0fe'; +String _$authNotifierHash() => r'87bb81e1bbb34e5e9f9027d8a5f1924024f2f374'; /// 인증 상태를 관리하는 Notifier /// -/// Google 로그인, 로그아웃 등의 인증 작업을 수행하며 -/// 로딩/에러 상태를 관리합니다. +/// Google/Apple 로그인, 로그아웃 등의 인증 작업을 수행하며 +/// Firebase 인증 후 백엔드 로그인까지 완료합니다. /// /// Copied from [AuthNotifier]. @ProviderFor(AuthNotifier) diff --git a/lib/features/onboarding/data/datasources/onboarding_remote_datasource.dart b/lib/features/onboarding/data/datasources/onboarding_remote_datasource.dart index 50ae0be..d36c22e 100644 --- a/lib/features/onboarding/data/datasources/onboarding_remote_datasource.dart +++ b/lib/features/onboarding/data/datasources/onboarding_remote_datasource.dart @@ -52,21 +52,38 @@ class OnboardingRemoteDataSource { /// 성별 제출 /// /// POST /api/members/onboarding/gender - Future submitGender(GenderRequest request) async { + /// 응답의 member.name에서 백엔드 자동 생성 닉네임을 반환 + Future submitGender(GenderRequest request) async { debugPrint('📤 OnboardingRemoteDataSource: Submitting gender...'); - await _dio.post(ApiEndpoints.onboardingGender, data: request.toJson()); + final response = await _dio.post( + ApiEndpoints.onboardingGender, + data: request.toJson(), + ); + + // 응답에서 member.name (임시 닉네임) 추출 + String? tempNickname; + if (response.data is Map) { + final member = response.data['member']; + if (member is Map) { + tempNickname = member['name'] as String?; + debugPrint('📛 Temporary nickname from server: $tempNickname'); + } + } debugPrint('✅ Gender submitted successfully'); + return tempNickname; } /// 프로필(닉네임) 제출 /// /// POST /api/members/profile + /// null 필드는 JSON에서 제외 (백엔드가 기존 저장값을 유지하도록) Future submitProfile(ProfileRequest request) async { debugPrint('📤 OnboardingRemoteDataSource: Submitting profile...'); - await _dio.post(ApiEndpoints.memberProfile, data: request.toJson()); + final data = request.toJson()..removeWhere((_, v) => v == null); + await _dio.post(ApiEndpoints.memberProfile, data: data); debugPrint('✅ Profile submitted successfully'); } @@ -83,7 +100,7 @@ class OnboardingRemoteDataSource { ); final checkNameResponse = CheckNameResponse.fromJson(response.data); - debugPrint('✅ Name check result: ${checkNameResponse.available}'); + debugPrint('✅ Name check result: ${checkNameResponse.isAvailable}'); return checkNameResponse; } diff --git a/lib/features/onboarding/data/models/gender_request.dart b/lib/features/onboarding/data/models/gender_request.dart index 1223278..27ed60b 100644 --- a/lib/features/onboarding/data/models/gender_request.dart +++ b/lib/features/onboarding/data/models/gender_request.dart @@ -9,8 +9,8 @@ enum Gender { male, @JsonValue('FEMALE') female, - @JsonValue('OTHER') - other, + @JsonValue('NOT_SELECTED') + notSelected, } /// 성별 요청 모델 diff --git a/lib/features/onboarding/data/models/gender_request.g.dart b/lib/features/onboarding/data/models/gender_request.g.dart index 36caf86..5ce17bd 100644 --- a/lib/features/onboarding/data/models/gender_request.g.dart +++ b/lib/features/onboarding/data/models/gender_request.g.dart @@ -15,5 +15,5 @@ Map _$$GenderRequestImplToJson(_$GenderRequestImpl instance) => const _$GenderEnumMap = { Gender.male: 'MALE', Gender.female: 'FEMALE', - Gender.other: 'OTHER', + Gender.notSelected: 'NOT_SELECTED', }; diff --git a/lib/features/onboarding/data/models/profile_request.dart b/lib/features/onboarding/data/models/profile_request.dart index b3256bc..bd88e7f 100644 --- a/lib/features/onboarding/data/models/profile_request.dart +++ b/lib/features/onboarding/data/models/profile_request.dart @@ -1,16 +1,25 @@ import 'package:freezed_annotation/freezed_annotation.dart'; +import 'gender_request.dart'; + part 'profile_request.freezed.dart'; part 'profile_request.g.dart'; -/// 프로필(닉네임) 요청 모델 +/// 프로필 업데이트 요청 모델 /// /// POST /api/members/profile 요청 바디 +/// 백엔드 스키마: ProfileUpdateRequest @freezed class ProfileRequest with _$ProfileRequest { const factory ProfileRequest({ /// 닉네임 required String name, + + /// 성별 (MALE, FEMALE, NOT_SELECTED) + Gender? gender, + + /// 생년월일 (YYYY-MM-DD 형식) + String? birthDate, }) = _ProfileRequest; factory ProfileRequest.fromJson(Map json) => @@ -20,14 +29,15 @@ class ProfileRequest with _$ProfileRequest { /// 닉네임 중복 확인 응답 모델 /// /// GET /api/members/check-name?name=xxx +/// 백엔드 스키마: CheckNameResponse @freezed class CheckNameResponse with _$CheckNameResponse { const factory CheckNameResponse({ /// 사용 가능 여부 - required bool available, + required bool isAvailable, - /// 메시지 (사용 불가 시 이유) - String? message, + /// 확인한 닉네임 + String? name, }) = _CheckNameResponse; factory CheckNameResponse.fromJson(Map json) => diff --git a/lib/features/onboarding/data/models/profile_request.freezed.dart b/lib/features/onboarding/data/models/profile_request.freezed.dart index 948aced..bfec38b 100644 --- a/lib/features/onboarding/data/models/profile_request.freezed.dart +++ b/lib/features/onboarding/data/models/profile_request.freezed.dart @@ -24,6 +24,12 @@ mixin _$ProfileRequest { /// 닉네임 String get name => throw _privateConstructorUsedError; + /// 성별 (MALE, FEMALE, NOT_SELECTED) + Gender? get gender => throw _privateConstructorUsedError; + + /// 생년월일 (YYYY-MM-DD 형식) + String? get birthDate => throw _privateConstructorUsedError; + /// Serializes this ProfileRequest to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -41,7 +47,7 @@ abstract class $ProfileRequestCopyWith<$Res> { $Res Function(ProfileRequest) then, ) = _$ProfileRequestCopyWithImpl<$Res, ProfileRequest>; @useResult - $Res call({String name}); + $Res call({String name, Gender? gender, String? birthDate}); } /// @nodoc @@ -58,13 +64,25 @@ class _$ProfileRequestCopyWithImpl<$Res, $Val extends ProfileRequest> /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override - $Res call({Object? name = null}) { + $Res call({ + Object? name = null, + Object? gender = freezed, + Object? birthDate = freezed, + }) { return _then( _value.copyWith( name: null == name ? _value.name : name // ignore: cast_nullable_to_non_nullable as String, + gender: freezed == gender + ? _value.gender + : gender // ignore: cast_nullable_to_non_nullable + as Gender?, + birthDate: freezed == birthDate + ? _value.birthDate + : birthDate // ignore: cast_nullable_to_non_nullable + as String?, ) as $Val, ); @@ -80,7 +98,7 @@ abstract class _$$ProfileRequestImplCopyWith<$Res> ) = __$$ProfileRequestImplCopyWithImpl<$Res>; @override @useResult - $Res call({String name}); + $Res call({String name, Gender? gender, String? birthDate}); } /// @nodoc @@ -96,13 +114,25 @@ class __$$ProfileRequestImplCopyWithImpl<$Res> /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override - $Res call({Object? name = null}) { + $Res call({ + Object? name = null, + Object? gender = freezed, + Object? birthDate = freezed, + }) { return _then( _$ProfileRequestImpl( name: null == name ? _value.name : name // ignore: cast_nullable_to_non_nullable as String, + gender: freezed == gender + ? _value.gender + : gender // ignore: cast_nullable_to_non_nullable + as Gender?, + birthDate: freezed == birthDate + ? _value.birthDate + : birthDate // ignore: cast_nullable_to_non_nullable + as String?, ), ); } @@ -111,7 +141,7 @@ class __$$ProfileRequestImplCopyWithImpl<$Res> /// @nodoc @JsonSerializable() class _$ProfileRequestImpl implements _ProfileRequest { - const _$ProfileRequestImpl({required this.name}); + const _$ProfileRequestImpl({required this.name, this.gender, this.birthDate}); factory _$ProfileRequestImpl.fromJson(Map json) => _$$ProfileRequestImplFromJson(json); @@ -120,9 +150,17 @@ class _$ProfileRequestImpl implements _ProfileRequest { @override final String name; + /// 성별 (MALE, FEMALE, NOT_SELECTED) + @override + final Gender? gender; + + /// 생년월일 (YYYY-MM-DD 형식) + @override + final String? birthDate; + @override String toString() { - return 'ProfileRequest(name: $name)'; + return 'ProfileRequest(name: $name, gender: $gender, birthDate: $birthDate)'; } @override @@ -130,12 +168,15 @@ class _$ProfileRequestImpl implements _ProfileRequest { return identical(this, other) || (other.runtimeType == runtimeType && other is _$ProfileRequestImpl && - (identical(other.name, name) || other.name == name)); + (identical(other.name, name) || other.name == name) && + (identical(other.gender, gender) || other.gender == gender) && + (identical(other.birthDate, birthDate) || + other.birthDate == birthDate)); } @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => Object.hash(runtimeType, name); + int get hashCode => Object.hash(runtimeType, name, gender, birthDate); /// Create a copy of ProfileRequest /// with the given fields replaced by the non-null parameter values. @@ -155,8 +196,11 @@ class _$ProfileRequestImpl implements _ProfileRequest { } abstract class _ProfileRequest implements ProfileRequest { - const factory _ProfileRequest({required final String name}) = - _$ProfileRequestImpl; + const factory _ProfileRequest({ + required final String name, + final Gender? gender, + final String? birthDate, + }) = _$ProfileRequestImpl; factory _ProfileRequest.fromJson(Map json) = _$ProfileRequestImpl.fromJson; @@ -165,6 +209,14 @@ abstract class _ProfileRequest implements ProfileRequest { @override String get name; + /// 성별 (MALE, FEMALE, NOT_SELECTED) + @override + Gender? get gender; + + /// 생년월일 (YYYY-MM-DD 형식) + @override + String? get birthDate; + /// Create a copy of ProfileRequest /// with the given fields replaced by the non-null parameter values. @override @@ -180,10 +232,10 @@ CheckNameResponse _$CheckNameResponseFromJson(Map json) { /// @nodoc mixin _$CheckNameResponse { /// 사용 가능 여부 - bool get available => throw _privateConstructorUsedError; + bool get isAvailable => throw _privateConstructorUsedError; - /// 메시지 (사용 불가 시 이유) - String? get message => throw _privateConstructorUsedError; + /// 확인한 닉네임 + String? get name => throw _privateConstructorUsedError; /// Serializes this CheckNameResponse to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -202,7 +254,7 @@ abstract class $CheckNameResponseCopyWith<$Res> { $Res Function(CheckNameResponse) then, ) = _$CheckNameResponseCopyWithImpl<$Res, CheckNameResponse>; @useResult - $Res call({bool available, String? message}); + $Res call({bool isAvailable, String? name}); } /// @nodoc @@ -219,16 +271,16 @@ class _$CheckNameResponseCopyWithImpl<$Res, $Val extends CheckNameResponse> /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override - $Res call({Object? available = null, Object? message = freezed}) { + $Res call({Object? isAvailable = null, Object? name = freezed}) { return _then( _value.copyWith( - available: null == available - ? _value.available - : available // ignore: cast_nullable_to_non_nullable + isAvailable: null == isAvailable + ? _value.isAvailable + : isAvailable // ignore: cast_nullable_to_non_nullable as bool, - message: freezed == message - ? _value.message - : message // ignore: cast_nullable_to_non_nullable + name: freezed == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable as String?, ) as $Val, @@ -245,7 +297,7 @@ abstract class _$$CheckNameResponseImplCopyWith<$Res> ) = __$$CheckNameResponseImplCopyWithImpl<$Res>; @override @useResult - $Res call({bool available, String? message}); + $Res call({bool isAvailable, String? name}); } /// @nodoc @@ -261,16 +313,16 @@ class __$$CheckNameResponseImplCopyWithImpl<$Res> /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override - $Res call({Object? available = null, Object? message = freezed}) { + $Res call({Object? isAvailable = null, Object? name = freezed}) { return _then( _$CheckNameResponseImpl( - available: null == available - ? _value.available - : available // ignore: cast_nullable_to_non_nullable + isAvailable: null == isAvailable + ? _value.isAvailable + : isAvailable // ignore: cast_nullable_to_non_nullable as bool, - message: freezed == message - ? _value.message - : message // ignore: cast_nullable_to_non_nullable + name: freezed == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable as String?, ), ); @@ -280,22 +332,22 @@ class __$$CheckNameResponseImplCopyWithImpl<$Res> /// @nodoc @JsonSerializable() class _$CheckNameResponseImpl implements _CheckNameResponse { - const _$CheckNameResponseImpl({required this.available, this.message}); + const _$CheckNameResponseImpl({required this.isAvailable, this.name}); factory _$CheckNameResponseImpl.fromJson(Map json) => _$$CheckNameResponseImplFromJson(json); /// 사용 가능 여부 @override - final bool available; + final bool isAvailable; - /// 메시지 (사용 불가 시 이유) + /// 확인한 닉네임 @override - final String? message; + final String? name; @override String toString() { - return 'CheckNameResponse(available: $available, message: $message)'; + return 'CheckNameResponse(isAvailable: $isAvailable, name: $name)'; } @override @@ -303,14 +355,14 @@ class _$CheckNameResponseImpl implements _CheckNameResponse { return identical(this, other) || (other.runtimeType == runtimeType && other is _$CheckNameResponseImpl && - (identical(other.available, available) || - other.available == available) && - (identical(other.message, message) || other.message == message)); + (identical(other.isAvailable, isAvailable) || + other.isAvailable == isAvailable) && + (identical(other.name, name) || other.name == name)); } @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => Object.hash(runtimeType, available, message); + int get hashCode => Object.hash(runtimeType, isAvailable, name); /// Create a copy of CheckNameResponse /// with the given fields replaced by the non-null parameter values. @@ -331,8 +383,8 @@ class _$CheckNameResponseImpl implements _CheckNameResponse { abstract class _CheckNameResponse implements CheckNameResponse { const factory _CheckNameResponse({ - required final bool available, - final String? message, + required final bool isAvailable, + final String? name, }) = _$CheckNameResponseImpl; factory _CheckNameResponse.fromJson(Map json) = @@ -340,11 +392,11 @@ abstract class _CheckNameResponse implements CheckNameResponse { /// 사용 가능 여부 @override - bool get available; + bool get isAvailable; - /// 메시지 (사용 불가 시 이유) + /// 확인한 닉네임 @override - String? get message; + String? get name; /// Create a copy of CheckNameResponse /// with the given fields replaced by the non-null parameter values. diff --git a/lib/features/onboarding/data/models/profile_request.g.dart b/lib/features/onboarding/data/models/profile_request.g.dart index 81eb341..30d5e71 100644 --- a/lib/features/onboarding/data/models/profile_request.g.dart +++ b/lib/features/onboarding/data/models/profile_request.g.dart @@ -7,22 +7,36 @@ part of 'profile_request.dart'; // ************************************************************************** _$ProfileRequestImpl _$$ProfileRequestImplFromJson(Map json) => - _$ProfileRequestImpl(name: json['name'] as String); + _$ProfileRequestImpl( + name: json['name'] as String, + gender: $enumDecodeNullable(_$GenderEnumMap, json['gender']), + birthDate: json['birthDate'] as String?, + ); Map _$$ProfileRequestImplToJson( _$ProfileRequestImpl instance, -) => {'name': instance.name}; +) => { + 'name': instance.name, + 'gender': _$GenderEnumMap[instance.gender], + 'birthDate': instance.birthDate, +}; + +const _$GenderEnumMap = { + Gender.male: 'MALE', + Gender.female: 'FEMALE', + Gender.notSelected: 'NOT_SELECTED', +}; _$CheckNameResponseImpl _$$CheckNameResponseImplFromJson( Map json, ) => _$CheckNameResponseImpl( - available: json['available'] as bool, - message: json['message'] as String?, + isAvailable: json['isAvailable'] as bool, + name: json['name'] as String?, ); Map _$$CheckNameResponseImplToJson( _$CheckNameResponseImpl instance, ) => { - 'available': instance.available, - 'message': instance.message, + 'isAvailable': instance.isAvailable, + 'name': instance.name, }; diff --git a/lib/features/onboarding/data/models/terms_request.dart b/lib/features/onboarding/data/models/terms_request.dart index 71c7bec..186d222 100644 --- a/lib/features/onboarding/data/models/terms_request.dart +++ b/lib/features/onboarding/data/models/terms_request.dart @@ -6,17 +6,15 @@ part 'terms_request.g.dart'; /// 약관 동의 요청 모델 /// /// POST /api/members/onboarding/terms 요청 바디 +/// 백엔드 스키마: UpdateServiceAgreementTermsRequest @freezed class TermsRequest with _$TermsRequest { const factory TermsRequest({ - /// 서비스 이용약관 동의 여부 - required bool serviceAgreement, - - /// 개인정보 처리방침 동의 여부 - required bool privacyAgreement, + /// 서비스 이용약관 + 개인정보 처리방침 동의 여부 (통합) + required bool isServiceTermsAndPrivacyAgreed, /// 마케팅 정보 수신 동의 여부 (선택) - @Default(false) bool marketingAgreement, + @Default(false) bool isMarketingAgreed, }) = _TermsRequest; factory TermsRequest.fromJson(Map json) => diff --git a/lib/features/onboarding/data/models/terms_request.freezed.dart b/lib/features/onboarding/data/models/terms_request.freezed.dart index ff0afc6..51e2965 100644 --- a/lib/features/onboarding/data/models/terms_request.freezed.dart +++ b/lib/features/onboarding/data/models/terms_request.freezed.dart @@ -21,14 +21,11 @@ TermsRequest _$TermsRequestFromJson(Map json) { /// @nodoc mixin _$TermsRequest { - /// 서비스 이용약관 동의 여부 - bool get serviceAgreement => throw _privateConstructorUsedError; - - /// 개인정보 처리방침 동의 여부 - bool get privacyAgreement => throw _privateConstructorUsedError; + /// 서비스 이용약관 + 개인정보 처리방침 동의 여부 (통합) + bool get isServiceTermsAndPrivacyAgreed => throw _privateConstructorUsedError; /// 마케팅 정보 수신 동의 여부 (선택) - bool get marketingAgreement => throw _privateConstructorUsedError; + bool get isMarketingAgreed => throw _privateConstructorUsedError; /// Serializes this TermsRequest to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -47,11 +44,7 @@ abstract class $TermsRequestCopyWith<$Res> { $Res Function(TermsRequest) then, ) = _$TermsRequestCopyWithImpl<$Res, TermsRequest>; @useResult - $Res call({ - bool serviceAgreement, - bool privacyAgreement, - bool marketingAgreement, - }); + $Res call({bool isServiceTermsAndPrivacyAgreed, bool isMarketingAgreed}); } /// @nodoc @@ -69,23 +62,19 @@ class _$TermsRequestCopyWithImpl<$Res, $Val extends TermsRequest> @pragma('vm:prefer-inline') @override $Res call({ - Object? serviceAgreement = null, - Object? privacyAgreement = null, - Object? marketingAgreement = null, + Object? isServiceTermsAndPrivacyAgreed = null, + Object? isMarketingAgreed = null, }) { return _then( _value.copyWith( - serviceAgreement: null == serviceAgreement - ? _value.serviceAgreement - : serviceAgreement // ignore: cast_nullable_to_non_nullable + isServiceTermsAndPrivacyAgreed: + null == isServiceTermsAndPrivacyAgreed + ? _value.isServiceTermsAndPrivacyAgreed + : isServiceTermsAndPrivacyAgreed // ignore: cast_nullable_to_non_nullable as bool, - privacyAgreement: null == privacyAgreement - ? _value.privacyAgreement - : privacyAgreement // ignore: cast_nullable_to_non_nullable - as bool, - marketingAgreement: null == marketingAgreement - ? _value.marketingAgreement - : marketingAgreement // ignore: cast_nullable_to_non_nullable + isMarketingAgreed: null == isMarketingAgreed + ? _value.isMarketingAgreed + : isMarketingAgreed // ignore: cast_nullable_to_non_nullable as bool, ) as $Val, @@ -102,11 +91,7 @@ abstract class _$$TermsRequestImplCopyWith<$Res> ) = __$$TermsRequestImplCopyWithImpl<$Res>; @override @useResult - $Res call({ - bool serviceAgreement, - bool privacyAgreement, - bool marketingAgreement, - }); + $Res call({bool isServiceTermsAndPrivacyAgreed, bool isMarketingAgreed}); } /// @nodoc @@ -123,23 +108,18 @@ class __$$TermsRequestImplCopyWithImpl<$Res> @pragma('vm:prefer-inline') @override $Res call({ - Object? serviceAgreement = null, - Object? privacyAgreement = null, - Object? marketingAgreement = null, + Object? isServiceTermsAndPrivacyAgreed = null, + Object? isMarketingAgreed = null, }) { return _then( _$TermsRequestImpl( - serviceAgreement: null == serviceAgreement - ? _value.serviceAgreement - : serviceAgreement // ignore: cast_nullable_to_non_nullable + isServiceTermsAndPrivacyAgreed: null == isServiceTermsAndPrivacyAgreed + ? _value.isServiceTermsAndPrivacyAgreed + : isServiceTermsAndPrivacyAgreed // ignore: cast_nullable_to_non_nullable as bool, - privacyAgreement: null == privacyAgreement - ? _value.privacyAgreement - : privacyAgreement // ignore: cast_nullable_to_non_nullable - as bool, - marketingAgreement: null == marketingAgreement - ? _value.marketingAgreement - : marketingAgreement // ignore: cast_nullable_to_non_nullable + isMarketingAgreed: null == isMarketingAgreed + ? _value.isMarketingAgreed + : isMarketingAgreed // ignore: cast_nullable_to_non_nullable as bool, ), ); @@ -150,30 +130,25 @@ class __$$TermsRequestImplCopyWithImpl<$Res> @JsonSerializable() class _$TermsRequestImpl implements _TermsRequest { const _$TermsRequestImpl({ - required this.serviceAgreement, - required this.privacyAgreement, - this.marketingAgreement = false, + required this.isServiceTermsAndPrivacyAgreed, + this.isMarketingAgreed = false, }); factory _$TermsRequestImpl.fromJson(Map json) => _$$TermsRequestImplFromJson(json); - /// 서비스 이용약관 동의 여부 - @override - final bool serviceAgreement; - - /// 개인정보 처리방침 동의 여부 + /// 서비스 이용약관 + 개인정보 처리방침 동의 여부 (통합) @override - final bool privacyAgreement; + final bool isServiceTermsAndPrivacyAgreed; /// 마케팅 정보 수신 동의 여부 (선택) @override @JsonKey() - final bool marketingAgreement; + final bool isMarketingAgreed; @override String toString() { - return 'TermsRequest(serviceAgreement: $serviceAgreement, privacyAgreement: $privacyAgreement, marketingAgreement: $marketingAgreement)'; + return 'TermsRequest(isServiceTermsAndPrivacyAgreed: $isServiceTermsAndPrivacyAgreed, isMarketingAgreed: $isMarketingAgreed)'; } @override @@ -181,21 +156,22 @@ class _$TermsRequestImpl implements _TermsRequest { return identical(this, other) || (other.runtimeType == runtimeType && other is _$TermsRequestImpl && - (identical(other.serviceAgreement, serviceAgreement) || - other.serviceAgreement == serviceAgreement) && - (identical(other.privacyAgreement, privacyAgreement) || - other.privacyAgreement == privacyAgreement) && - (identical(other.marketingAgreement, marketingAgreement) || - other.marketingAgreement == marketingAgreement)); + (identical( + other.isServiceTermsAndPrivacyAgreed, + isServiceTermsAndPrivacyAgreed, + ) || + other.isServiceTermsAndPrivacyAgreed == + isServiceTermsAndPrivacyAgreed) && + (identical(other.isMarketingAgreed, isMarketingAgreed) || + other.isMarketingAgreed == isMarketingAgreed)); } @JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash( runtimeType, - serviceAgreement, - privacyAgreement, - marketingAgreement, + isServiceTermsAndPrivacyAgreed, + isMarketingAgreed, ); /// Create a copy of TermsRequest @@ -214,25 +190,20 @@ class _$TermsRequestImpl implements _TermsRequest { abstract class _TermsRequest implements TermsRequest { const factory _TermsRequest({ - required final bool serviceAgreement, - required final bool privacyAgreement, - final bool marketingAgreement, + required final bool isServiceTermsAndPrivacyAgreed, + final bool isMarketingAgreed, }) = _$TermsRequestImpl; factory _TermsRequest.fromJson(Map json) = _$TermsRequestImpl.fromJson; - /// 서비스 이용약관 동의 여부 - @override - bool get serviceAgreement; - - /// 개인정보 처리방침 동의 여부 + /// 서비스 이용약관 + 개인정보 처리방침 동의 여부 (통합) @override - bool get privacyAgreement; + bool get isServiceTermsAndPrivacyAgreed; /// 마케팅 정보 수신 동의 여부 (선택) @override - bool get marketingAgreement; + bool get isMarketingAgreed; /// Create a copy of TermsRequest /// with the given fields replaced by the non-null parameter values. diff --git a/lib/features/onboarding/data/models/terms_request.g.dart b/lib/features/onboarding/data/models/terms_request.g.dart index cd29b91..7732192 100644 --- a/lib/features/onboarding/data/models/terms_request.g.dart +++ b/lib/features/onboarding/data/models/terms_request.g.dart @@ -8,14 +8,13 @@ part of 'terms_request.dart'; _$TermsRequestImpl _$$TermsRequestImplFromJson(Map json) => _$TermsRequestImpl( - serviceAgreement: json['serviceAgreement'] as bool, - privacyAgreement: json['privacyAgreement'] as bool, - marketingAgreement: json['marketingAgreement'] as bool? ?? false, + isServiceTermsAndPrivacyAgreed: + json['isServiceTermsAndPrivacyAgreed'] as bool, + isMarketingAgreed: json['isMarketingAgreed'] as bool? ?? false, ); Map _$$TermsRequestImplToJson(_$TermsRequestImpl instance) => { - 'serviceAgreement': instance.serviceAgreement, - 'privacyAgreement': instance.privacyAgreement, - 'marketingAgreement': instance.marketingAgreement, + 'isServiceTermsAndPrivacyAgreed': instance.isServiceTermsAndPrivacyAgreed, + 'isMarketingAgreed': instance.isMarketingAgreed, }; diff --git a/lib/features/onboarding/data/repositories/onboarding_repository_impl.dart b/lib/features/onboarding/data/repositories/onboarding_repository_impl.dart index dbe8043..7a4d9e7 100644 --- a/lib/features/onboarding/data/repositories/onboarding_repository_impl.dart +++ b/lib/features/onboarding/data/repositories/onboarding_repository_impl.dart @@ -36,9 +36,8 @@ class OnboardingRepositoryImpl implements OnboardingRepository { debugPrint('📝 OnboardingRepository: Submitting terms...'); final request = TermsRequest( - serviceAgreement: serviceAgreement, - privacyAgreement: privacyAgreement, - marketingAgreement: marketingAgreement, + isServiceTermsAndPrivacyAgreed: serviceAgreement && privacyAgreement, + isMarketingAgreed: marketingAgreement, ); await _remoteDataSource.submitTerms(request); @@ -75,11 +74,11 @@ class OnboardingRepositoryImpl implements OnboardingRepository { } @override - Future submitGender(Gender gender) async { + Future submitGender(Gender gender) async { debugPrint('📝 OnboardingRepository: Submitting gender...'); final request = GenderRequest(gender: gender); - await _remoteDataSource.submitGender(request); + final tempNickname = await _remoteDataSource.submitGender(request); // 온보딩 단계 업데이트: GENDER → NICKNAME await _tokenStorage.saveOnboardingState( @@ -88,13 +87,31 @@ class OnboardingRepositoryImpl implements OnboardingRepository { ); debugPrint('✅ Gender submitted, next step: NICKNAME'); + return tempNickname; } @override - Future submitProfile(String name) async { + Future submitProfile( + String name, { + Gender? gender, + DateTime? birthDate, + }) async { debugPrint('📝 OnboardingRepository: Submitting profile...'); - final request = ProfileRequest(name: name); + // 생년월일을 YYYY-MM-DD 형식으로 변환 + String? formattedBirthDate; + if (birthDate != null) { + final year = birthDate.year.toString(); + final month = birthDate.month.toString().padLeft(2, '0'); + final day = birthDate.day.toString().padLeft(2, '0'); + formattedBirthDate = '$year-$month-$day'; + } + + final request = ProfileRequest( + name: name, + gender: gender, + birthDate: formattedBirthDate, + ); await _remoteDataSource.submitProfile(request); // 온보딩 완료 diff --git a/lib/features/onboarding/domain/repositories/onboarding_repository.dart b/lib/features/onboarding/domain/repositories/onboarding_repository.dart index 8d03845..6933091 100644 --- a/lib/features/onboarding/domain/repositories/onboarding_repository.dart +++ b/lib/features/onboarding/domain/repositories/onboarding_repository.dart @@ -22,12 +22,19 @@ abstract class OnboardingRepository { /// 성별 제출 /// /// [gender]: 성별 (Gender enum) - Future submitGender(Gender gender); + /// Returns: 백엔드 자동 생성 임시 닉네임 (없으면 null) + Future submitGender(Gender gender); /// 프로필(닉네임) 제출 /// /// [name]: 닉네임 - Future submitProfile(String name); + /// [gender]: 성별 (선택) + /// [birthDate]: 생년월일 (선택) + Future submitProfile( + String name, { + Gender? gender, + DateTime? birthDate, + }); /// 닉네임 중복 확인 /// diff --git a/lib/features/onboarding/presentation/pages/birth_date_step_page.dart b/lib/features/onboarding/presentation/pages/birth_date_step_page.dart index aade8b2..a5731af 100644 --- a/lib/features/onboarding/presentation/pages/birth_date_step_page.dart +++ b/lib/features/onboarding/presentation/pages/birth_date_step_page.dart @@ -38,10 +38,7 @@ class _BirthDateStepPageState extends ConsumerState { appBar: AppBar( backgroundColor: Colors.white, elevation: 0, - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios, color: AppColors.gray700), - onPressed: () => context.go(RoutePaths.onboardingTerms), - ), + automaticallyImplyLeading: false, ), body: SafeArea( child: Padding( diff --git a/lib/features/onboarding/presentation/pages/gender_step_page.dart b/lib/features/onboarding/presentation/pages/gender_step_page.dart index c8f4030..69275c6 100644 --- a/lib/features/onboarding/presentation/pages/gender_step_page.dart +++ b/lib/features/onboarding/presentation/pages/gender_step_page.dart @@ -24,10 +24,7 @@ class GenderStepPage extends ConsumerWidget { appBar: AppBar( backgroundColor: Colors.white, elevation: 0, - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios, color: AppColors.gray700), - onPressed: () => context.go(RoutePaths.onboardingBirthDate), - ), + automaticallyImplyLeading: false, ), body: SafeArea( child: Padding( @@ -78,12 +75,25 @@ class GenderStepPage extends ConsumerWidget { ), ], ), - SizedBox(height: 12.h), - _GenderButton( - label: '기타', - icon: Icons.person_outline, - isSelected: state.gender == Gender.other, - onTap: () => notifier.setGender(Gender.other), + SizedBox(height: 16.h), + Center( + child: GestureDetector( + onTap: () => notifier.setGender(Gender.notSelected), + child: Text( + '선택 안 함', + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w400, + color: state.gender == Gender.notSelected + ? AppColors.primary + : AppColors.gray500, + decoration: TextDecoration.underline, + decorationColor: state.gender == Gender.notSelected + ? AppColors.primary + : AppColors.gray500, + ), + ), + ), ), const Spacer(), diff --git a/lib/features/onboarding/presentation/pages/nickname_step_page.dart b/lib/features/onboarding/presentation/pages/nickname_step_page.dart index b09f129..ee10377 100644 --- a/lib/features/onboarding/presentation/pages/nickname_step_page.dart +++ b/lib/features/onboarding/presentation/pages/nickname_step_page.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; @@ -21,32 +20,46 @@ class NicknameStepPage extends ConsumerStatefulWidget { class _NicknameStepPageState extends ConsumerState { final _controller = TextEditingController(); final _focusNode = FocusNode(); - Timer? _debounce; + + /// 서버에서 배정받은 초기 닉네임 (변경 여부 비교용) + String? _initialNickname; @override void initState() { super.initState(); - _focusNode.requestFocus(); + // 서버에서 배정받은 임시 닉네임으로 초기화 + WidgetsBinding.instance.addPostFrameCallback((_) { + final state = ref.read(onboardingNotifierProvider); + if (state.nickname != null && state.nickname!.isNotEmpty) { + _controller.text = state.nickname!; + _initialNickname = state.nickname; + } + _focusNode.requestFocus(); + setState(() {}); // canSubmit 재계산을 위한 rebuild 트리거 + }); } @override void dispose() { _controller.dispose(); _focusNode.dispose(); - _debounce?.cancel(); super.dispose(); } + /// 닉네임이 서버 배정값에서 변경되었는지 여부 + bool get _isNicknameChanged { + if (_initialNickname == null) return true; + return _controller.text.trim() != _initialNickname; + } + void _onNicknameChanged(String value, OnboardingNotifier notifier) { notifier.setNickname(value); + setState(() {}); // 중복확인 버튼 상태 갱신 + } - // 디바운스: 입력 후 500ms 후에 중복 확인 - _debounce?.cancel(); - if (value.length >= 2) { - _debounce = Timer(const Duration(milliseconds: 500), () { - notifier.checkNickname(); - }); - } + Future _onCheckDuplicate(OnboardingNotifier notifier) async { + FocusScope.of(context).unfocus(); + await notifier.checkNickname(); } @override @@ -54,15 +67,23 @@ class _NicknameStepPageState extends ConsumerState { final state = ref.watch(onboardingNotifierProvider); final notifier = ref.read(onboardingNotifierProvider.notifier); + final nickname = state.nickname ?? ''; + final isFormatValid = notifier.isNicknameFormatValid(nickname); + final canCheck = + nickname.isNotEmpty && isFormatValid && !state.isLoading; + + // 서버 닉네임 미변경 시: 중복확인 없이 바로 완료 가능 + // 서버 닉네임 변경 시: 중복확인 통과 필요 + final canSubmit = !_isNicknameChanged + ? (nickname.isNotEmpty && isFormatValid) + : notifier.canSubmitNickname; + return Scaffold( backgroundColor: Colors.white, appBar: AppBar( backgroundColor: Colors.white, elevation: 0, - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios, color: AppColors.gray700), - onPressed: () => context.go(RoutePaths.onboardingGender), - ), + automaticallyImplyLeading: false, ), body: SafeArea( child: Padding( @@ -91,7 +112,7 @@ class _NicknameStepPageState extends ConsumerState { ), SizedBox(height: 48.h), - // 닉네임 입력 필드 + // 닉네임 입력 필드 + 중복 확인 버튼 TextField( controller: _controller, focusNode: _focusNode, @@ -121,9 +142,55 @@ class _NicknameStepPageState extends ConsumerState { width: 2, ), ), - contentPadding: EdgeInsets.all(16.w), + contentPadding: EdgeInsets.only( + left: 16.w, + top: 16.w, + bottom: 16.w, + right: 100.w, + ), counterText: '', - suffixIcon: _buildSuffixIcon(state), + suffixIcon: Padding( + padding: EdgeInsets.only(right: 8.w), + child: state.isLoading + ? SizedBox( + width: 24.w, + height: 24.h, + child: const CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.primary, + ), + ) + : SizedBox( + height: 36.h, + child: ElevatedButton( + onPressed: canCheck && _isNicknameChanged + ? () => _onCheckDuplicate(notifier) + : null, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + disabledBackgroundColor: AppColors.gray300, + foregroundColor: Colors.white, + disabledForegroundColor: AppColors.gray500, + elevation: 0, + padding: EdgeInsets.symmetric(horizontal: 12.w), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.r), + ), + ), + child: Text( + '중복 확인', + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + suffixIconConstraints: BoxConstraints( + minWidth: 90.w, + minHeight: 36.h, + ), ), ), SizedBox(height: 8.h), @@ -142,7 +209,7 @@ class _NicknameStepPageState extends ConsumerState { OnboardingButton( text: '완료', isLoading: state.isLoading, - onPressed: notifier.canSubmitNickname + onPressed: canSubmit ? () async { final success = await notifier.submitProfile(); if (success && context.mounted) { @@ -160,6 +227,10 @@ class _NicknameStepPageState extends ConsumerState { } Color _getBorderColor(OnboardingState state) { + // 서버 닉네임 미변경 시 성공 표시 + if (!_isNicknameChanged && _initialNickname != null) { + return AppColors.success; + } if (state.nicknameAvailable == true) { return AppColors.success; } else if (state.nicknameAvailable == false) { @@ -168,44 +239,50 @@ class _NicknameStepPageState extends ConsumerState { return AppColors.gray200; } - Widget? _buildSuffixIcon(OnboardingState state) { - if (state.isLoading) { - return Padding( - padding: EdgeInsets.all(12.w), - child: SizedBox( - width: 20.w, - height: 20.h, - child: const CircularProgressIndicator( - strokeWidth: 2, - color: AppColors.primary, + Widget _buildStatusMessage(OnboardingState state) { + // 서버 닉네임 미변경 시 + if (!_isNicknameChanged && _initialNickname != null) { + return Row( + children: [ + Icon(Icons.check_circle, color: AppColors.success, size: 16.w), + SizedBox(width: 4.w), + Expanded( + child: Text( + '서버에서 배정된 닉네임입니다. 그대로 사용하거나 변경할 수 있어요.', + style: TextStyle(fontSize: 12.sp, color: AppColors.success), + ), ), - ), + ], ); } if (state.nicknameAvailable == true) { - return Icon(Icons.check_circle, color: AppColors.success, size: 24.w); - } - - if (state.nicknameAvailable == false) { - return Icon(Icons.error, color: AppColors.error, size: 24.w); - } - - return null; - } - - Widget _buildStatusMessage(OnboardingState state) { - if (state.nicknameAvailable == true) { - return Text( - '사용 가능한 닉네임입니다.', - style: TextStyle(fontSize: 13.sp, color: AppColors.success), + return Row( + children: [ + Icon(Icons.check_circle, color: AppColors.success, size: 16.w), + SizedBox(width: 4.w), + Expanded( + child: Text( + '사용 가능한 닉네임입니다.', + style: TextStyle(fontSize: 13.sp, color: AppColors.success), + ), + ), + ], ); } if (state.errorMessage != null) { - return Text( - state.errorMessage!, - style: TextStyle(fontSize: 13.sp, color: AppColors.error), + return Row( + children: [ + Icon(Icons.error, color: AppColors.error, size: 16.w), + SizedBox(width: 4.w), + Expanded( + child: Text( + state.errorMessage!, + style: TextStyle(fontSize: 13.sp, color: AppColors.error), + ), + ), + ], ); } diff --git a/lib/features/onboarding/presentation/providers/onboarding_provider.dart b/lib/features/onboarding/presentation/providers/onboarding_provider.dart index 5a4421e..ea6204c 100644 --- a/lib/features/onboarding/presentation/providers/onboarding_provider.dart +++ b/lib/features/onboarding/presentation/providers/onboarding_provider.dart @@ -48,7 +48,10 @@ class OnboardingState with _$OnboardingState { } /// 온보딩 Notifier -@riverpod +/// +/// keepAlive: 온보딩 단계 간 페이지 전환 시 상태(birthDate, gender 등)를 +/// 유지하기 위해 AutoDispose를 사용하지 않습니다. +@Riverpod(keepAlive: true) class OnboardingNotifier extends _$OnboardingNotifier { @override OnboardingState build() { @@ -165,11 +168,12 @@ class OnboardingNotifier extends _$OnboardingNotifier { state = state.copyWith(isLoading: true, errorMessage: null); try { - await _repository.submitGender(state.gender!); + final tempNickname = await _repository.submitGender(state.gender!); state = state.copyWith( isLoading: false, currentStep: OnboardingStep.nickname, + nickname: tempNickname, ); return true; @@ -255,13 +259,13 @@ class OnboardingNotifier extends _$OnboardingNotifier { state = state.copyWith( isLoading: false, - nicknameAvailable: response.available, - errorMessage: response.available + nicknameAvailable: response.isAvailable, + errorMessage: response.isAvailable ? null - : response.message ?? '이미 사용 중인 닉네임입니다.', + : '이미 사용 중인 닉네임입니다.', ); - return response.available; + return response.isAvailable; } catch (e) { state = state.copyWith( isLoading: false, @@ -280,13 +284,25 @@ class OnboardingNotifier extends _$OnboardingNotifier { state.nicknameAvailable == true; /// 프로필(닉네임) 제출 + /// + /// 서버 배정 닉네임을 변경하지 않은 경우에도 제출 가능합니다. + /// (nicknameAvailable이 null이어도 format이 유효하면 통과) Future submitProfile() async { - if (!canSubmitNickname || state.nickname == null) return false; + final nickname = state.nickname; + if (nickname == null || nickname.isEmpty) return false; + if (!isNicknameFormatValid(nickname)) return false; + + // 닉네임을 변경한 경우에만 중복확인 필수 + if (state.nicknameAvailable == false) return false; state = state.copyWith(isLoading: true, errorMessage: null); try { - await _repository.submitProfile(state.nickname!); + await _repository.submitProfile( + state.nickname!, + gender: state.gender, + birthDate: state.birthDate, + ); state = state.copyWith( isLoading: false, diff --git a/lib/features/onboarding/presentation/providers/onboarding_provider.g.dart b/lib/features/onboarding/presentation/providers/onboarding_provider.g.dart index dad3598..333d696 100644 --- a/lib/features/onboarding/presentation/providers/onboarding_provider.g.dart +++ b/lib/features/onboarding/presentation/providers/onboarding_provider.g.dart @@ -7,14 +7,17 @@ part of 'onboarding_provider.dart'; // ************************************************************************** String _$onboardingNotifierHash() => - r'4782c882b4809345d6b62241c9809feccdc9beab'; + r'cdf299a59ec51c81095f59031e6513b2892128cb'; /// 온보딩 Notifier /// +/// keepAlive: 온보딩 단계 간 페이지 전환 시 상태(birthDate, gender 등)를 +/// 유지하기 위해 AutoDispose를 사용하지 않습니다. +/// /// Copied from [OnboardingNotifier]. @ProviderFor(OnboardingNotifier) final onboardingNotifierProvider = - AutoDisposeNotifierProvider.internal( + NotifierProvider.internal( OnboardingNotifier.new, name: r'onboardingNotifierProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') @@ -24,6 +27,6 @@ final onboardingNotifierProvider = allTransitiveDependencies: null, ); -typedef _$OnboardingNotifier = AutoDisposeNotifier; +typedef _$OnboardingNotifier = Notifier; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/main.dart b/lib/main.dart index 721b1eb..f0b167c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'firebase_options.dart'; @@ -19,12 +20,17 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); // ═══════════════════════════════════════════════════════════════════════ - // 2. Firebase 초기화 (플랫폼별 설정 자동 적용) + // 2. 환경 변수 로드 (.env 파일) + // ═══════════════════════════════════════════════════════════════════════ + await dotenv.load(fileName: '.env'); + + // ═══════════════════════════════════════════════════════════════════════ + // 3. Firebase 초기화 (플랫폼별 설정 자동 적용) // ═══════════════════════════════════════════════════════════════════════ await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); // ═══════════════════════════════════════════════════════════════════════ - // 3. Firebase Crashlytics 설정 + // 4. Firebase Crashlytics 설정 // ═══════════════════════════════════════════════════════════════════════ // Flutter 프레임워크 에러를 Crashlytics에 자동 전송 FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError; @@ -36,7 +42,7 @@ void main() async { }; // ═══════════════════════════════════════════════════════════════════════ - // 4. FCM 및 로컬 알림 서비스 초기화 + // 5. FCM 및 로컬 알림 서비스 초기화 // ═══════════════════════════════════════════════════════════════════════ final localNotificationsService = LocalNotificationsService.instance(); await localNotificationsService.init(); @@ -46,7 +52,7 @@ void main() async { ); // ═══════════════════════════════════════════════════════════════════════ - // 5. 앱 실행 + // 6. 앱 실행 // ═══════════════════════════════════════════════════════════════════════ runApp(const ProviderScope(child: MyApp())); } diff --git a/pubspec.lock b/pubspec.lock index 5f6657f..01a3f44 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -169,14 +169,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" - code_assets: - dependency: transitive - description: - name: code_assets - sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" - url: "https://pub.dev" - source: hosted - version: "1.0.0" code_builder: dependency: transitive description: @@ -640,14 +632,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" - hooks: - dependency: transitive - description: - name: hooks - sha256: "5d309c86e7ce34cd8e37aa71cb30cb652d3829b900ab145e4d9da564b31d59f7" - url: "https://pub.dev" - source: hosted - version: "1.0.0" http: dependency: transitive description: @@ -772,10 +756,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" mime: dependency: transitive description: @@ -784,22 +768,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" - native_toolchain_c: - dependency: transitive - description: - name: native_toolchain_c - sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" - url: "https://pub.dev" - source: hosted - version: "0.17.4" - objective_c: - dependency: transitive - description: - name: objective_c - sha256: "7fd0c4d8ac8980011753b9bdaed2bf15111365924cdeeeaeb596214ea2b03537" - url: "https://pub.dev" - source: hosted - version: "9.2.4" package_config: dependency: transitive description: @@ -860,10 +828,10 @@ packages: dependency: transitive description: name: path_provider_foundation - sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.5.1" path_provider_linux: dependency: transitive description: @@ -928,6 +896,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.3" + protobuf: + dependency: transitive + description: + name: protobuf + sha256: de9c9eb2c33f8e933a42932fe1dc504800ca45ebc3d673e6ed7f39754ee4053e + url: "https://pub.dev" + source: hosted + version: "4.2.0" pub_semver: dependency: transitive description: @@ -948,10 +924,18 @@ packages: dependency: "direct main" description: name: retrofit - sha256: "0f629ed26b2c48c66fe54bd548313c6fdf7955be18bff37e08a46dd3f97f8eaf" + sha256: "699cf44ec6c7fc7d248740932eca75d334e36bdafe0a8b3e9ff93100591c8a25" + url: "https://pub.dev" + source: hosted + version: "4.7.2" + retrofit_generator: + dependency: "direct dev" + description: + name: retrofit_generator + sha256: "9abcf21acb95bf7040546eafff87f60cf0aee20b05101d71f99876fc4df1f522" url: "https://pub.dev" source: hosted - version: "4.9.2" + version: "9.7.0" riverpod: dependency: transitive description: @@ -1185,10 +1169,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.6" timezone: dependency: transitive description: @@ -1342,5 +1326,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.3 <4.0.0" - flutter: ">=3.38.4" + dart: ">=3.9.2 <4.0.0" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index f747d5a..74ca091 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: mapsy description: "MapSy - Flutter Application" publish_to: "none" -version: 1.0.31+31 +version: 1.0.32+32 environment: sdk: ^3.9.2 dependencies: @@ -19,7 +19,7 @@ dependencies: shared_preferences: ^2.3.4 # 로컬 설정 저장 (테마, 온보딩 등) # 네트워크 & 인증 dio: ^5.9.0 # HTTP 클라이언트 - retrofit: ^4.7.2 # REST API 클라이언트 생성 + retrofit: 4.7.2 # REST API 클라이언트 생성 (retrofit_generator 호환 버전 고정) flutter_secure_storage: ^9.2.4 # 민감 데이터 안전 저장 flutter_dotenv: ^6.0.0 # 환경 변수 관리 (.env 파일) uuid: ^4.5.2 # 앱 내 고유 식별자 생성 @@ -55,7 +55,7 @@ dev_dependencies: riverpod_generator: ^2.6.2 # @riverpod 코드 생성 freezed: ^2.5.7 # freezed 코드 생성기 (불변 클래스) json_serializable: ^6.9.2 # JSON 직렬화 코드 생성기 - retrofit_generator: ^9.1.8 # REST API 클라이언트 코드 생성기 + retrofit_generator: ^9.1.10 # REST API 클라이언트 코드 생성기 # 앱 통합 관리 도구 flutter_launcher_icons: ^0.14.4 # 앱 런처 아이콘 자동 생성 change_app_package_name: ^1.5.0 # 앱 패키지명 변경 자동화 diff --git a/version.yml b/version.yml index f845267..56fb8ca 100644 --- a/version.yml +++ b/version.yml @@ -34,12 +34,12 @@ # - 버전은 항상 높은 버전으로 자동 동기화됩니다 # =================================================================== -version: "1.0.31" -version_code: 32 # app build number +version: "1.0.32" +version_code: 33 # app build number project_type: "flutter" # spring, flutter, react, react-native, react-native-expo, node, python, basic metadata: - last_updated: "2026-02-10 00:56:22" - last_updated_by: "Cassiiopeia" + last_updated: "2026-02-10 16:52:23" + last_updated_by: "EM-H20" default_branch: "main" integrated_from: "SUH-DEVOPS-TEMPLATE" integration_date: "2026-01-19"