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"