diff --git a/CLAUDE.md b/CLAUDE.md index 278e39ea..0e4864e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,8 +28,8 @@ pnpm 모노레포 — apps/web(React 19 + Vite) + apps/mobile(Expo, 대부분 **변경 이력 (최근 5행 이내 — 전체는 `docs/HARNESS_CHANGELOG.md`, 밀려난 행은 그리로 이관):** | 날짜 | 변경 내용 | 대상 | 사유 | |------|----------|------|------| +| 2026-08-28 | react-doctor `js-tosorted-immutable` 규칙 off 선언 + **규칙 설정이 v0.9.3 스캐너에 안 먹는다는 실측 기록** | doctor.config.json, CLAUDE.md | MSG-488 PR #104 리뷰 — **두 도구가 정반대를 지시하고 있었다**: react-doctor는 `[...arr].sort()`를 `toSorted()`로 바꾸라 하고, `.oxlintrc.json:84`는 `toSorted`를 error로 금지한다(Hermes 미구현 — MSG-427에서 지도 홈을 마운트 즉시 크래시시킨 그 API). oxlint 금지가 `apps/mobile`·`packages/ui-native` 한정이라 **웹 파일에서는 react-doctor만 말하고 게이트 6종이 아무것도 안 잡는다**. 비용은 실측된다: 웹 프로덕션 `toSorted` 6곳 중 모바일 이식본이 있는 **5곳 전부**가 손으로 `[...].sort()`로 되돌려져 Hermes 주석을 달고 있고(`hot-region-summary`·`course`·`use-multi-grid-videos-query`·`region-cluster-overlay`·`gallery-groups`), 뒤 둘은 MSG-427 사고로 지목된 바로 그 파일들이다(주석 2회로도 못 막혔던 자리). 지적 대상 `route-legs.ts:6`은 스스로 `(RN 재사용 대상)`이라 선언한다. 규칙의 근거도 사실과 다르다 — `toSorted()`도 새 배열을 할당하므로 "복사 없이 정렬"이 아니다. **단, 이 설정은 현재 무효다(실측)**: v0.9.3에서 `rules list`·`rules explain`은 `off`로 보고하고 공식 `react-doctor rules disable`이 쓴 것도 같은 내용인데, 스캐너는 규칙을 그대로 보고한다(레포 루트·`apps/web` cwd 양쪽, `ignore.tags`도 동일하게 무시). **기존 `js-combine-iterations: off`도 같은 이유로 무효였다.** 따라서 실효 방어선은 이 행의 기록이고, 설정은 도구가 고쳐지면 자동으로 듣도록 선언만 남긴다. **후속 후보**: oxlint 금지를 레포 전역으로 승격하면 이식 세금이 원천 제거되나 기존 웹 6곳 수정이 따르므로 별도 티켓 | | 2026-08-24 | claude-review 완주 검증 판정을 **PR 코멘트 마커 grep → 액션 execution output 3단 판정**(① 파일 존재 → ② 마지막 result `is_error=false && subtype=success` → ③ 게시 도구 호출 증거: `gh pr comment` Bash 또는 `update_claude_comment`)으로 교체, 마커는 warning 강등(프롬프트 지시 존치·1줄 보정), 실패 사유별 구분 `::error::`+실측값 출력 | .github/workflows/claude-review.yml | MSG-465 — PR #89 af67beb 오탐: 리뷰가 정상 완주했는데 봇이 진행 코멘트에 요약을 이어 붙이며 SHA 마커를 생략해 검증 스텝이 실패 표시. 판정이 봇의 그때그때 코멘트 작성 방식에 좌우되던 것을 액션 산출물(전사) 기준으로 분리 — MSG-403 원 사고(정상 종료·요약 미게시, `is_error=false`·`num_turns=17` 실측이라 result 판정만으론 통과)는 ③ 게시 증거가 계속 잡는다. 잔여 창(체크리스트만 갱신 후 침묵 종료)은 마커 warning으로 관찰, 재발 실측 시 후속 조임 | | 2026-08-21 | codex 리뷰 **실행 경로를 Bash companion 하나로 확정** + 시점을 커밋 직전 → **push 전**으로 이동 | skills/fillmap-page-dev | MSG-451 사고 — 종전 문구가 "`/codex:review` **또는** companion 서브커맨드"로 두 경로를 동등 나열해 세션마다 선택이 갈렸다. 8/17~19 세션은 companion으로 정상 실행했으나(워크트리 state 실측 15건), 이번 세션은 슬래시 커맨드를 시도했다가 `disable-model-invocation`에 막히자 **그대로 건너뛰어 커밋 4개가 리뷰 없이 진행**됐다. 선택지를 주면 막힌 쪽을 고른 세션이 스킵한다 — 명령줄을 스킬에 그대로 박았다. 시점은 커밋이 로컬이라 되돌리기 싸고 중간 커밋 리뷰가 곧 폐기될 코드를 반복해 보기 때문(MSG-451은 뒤 커밋이 앞 결정을 뒤집었다). **조사 과정 교훈**: 메인 레포 state만 보고 "8/13 이후 리뷰 0건"으로 단정했다가 워크트리별 state에서 15건을 발견해 정정 — 워크트리 병렬 작업에서는 저장소 단위 상태가 흩어진다 | | 2026-08-21 | 검증 스킬 실동작 경로를 **대상 앱**으로 분기 — 3-A(웹 브라우저) / 3-B(모바일 Android dev client) 신설, 모바일 확인불가 판정 기준·리포트 형식 명문화, 실기 런북(`docs/MOBILE_RUNBOOK.md`)과 원커맨드 스크립트(`pnpm --filter mobile android:dev`) 신설 | skills/page-verification, skills/fillmap-page-dev, docs/MOBILE_RUNBOOK.md, apps/mobile/scripts | MSG-449 — 절차 3이 `pnpm dev` + 브라우저로만 쓰여 있어 모바일 티켓에 대응 절차가 없었고, 웨이브 0~3(MSG-419~431)이 전부 정적 게이트(vitest·typecheck·lint)만으로 통과했다. 2026-08-20 실기 시도에서 함정 6가지(임베디드 번들 로더·스킴 충돌·8081 콜드 스타트 복귀·페어링 포트 만료·`--device`는 모델명·새 네이티브 의존성 prebuild)에 순차로 걸렸는데 전부 재발할 문제라 문서가 아니라 검증 경로 자체로 내린다. 후속 모바일 티켓이 이 절차로 검증한다 | | 2026-08-19 | 모바일 런타임(Hermes) 미구현 API 차단 규칙 신설 — `apps/mobile`·`packages/ui-native` 한정 `no-restricted-properties`(`toSorted`·`toReversed`·`toSpliced`·`Object.groupBy`·`Map.groupBy`·`Promise.withResolvers`) + `no-restricted-globals`(`structuredClone`) | .oxlintrc.json | MSG-427 실기 사고 — 웹에서 포팅한 `toSorted` 3곳이 지도 홈을 마운트 즉시 크래시시켰는데 **게이트 6종이 전부 통과**했다(vitest는 Node에서 돌아 메서드가 존재하고, typecheck는 `lib`에 ES2023이 있다). 같은 함정이 `gallery-groups.ts`·`region-cluster-overlay.ts`에 두 번 주석으로 문서화돼 있었는데도 재발해, 주석으로는 못 막는 것이 실증됐다. 웹→모바일 포팅이 계속되는 한(MSG-428 등) 재발 창이 열려 있어 기계 게이트로 내린다 (MSG-298·386 좌시프트 원칙) | -| 2026-08-19 | e2e apt 미러 고정(azure→archive) + Playwright 설치 스텝 timeout-minutes 8 | .github/workflows/ci.yml | PR #74 실측 — `azure.archive.ubuntu.com` 불통으로 apt가 저장소마다 재시도하다 21분·14분+ 멈춤(2회 재현). `playwright install --with-deps`로 바꾸라는 조언은 같은 apt 경로를 타므로 무효였고, 캐시 히트 경로에서 바이너리 재다운로드만 추가된다. 미러리스트를 정본으로 고정해 재시도 구간을 제거하고, 그래도 물리면 빨리 실패하도록 타임아웃을 건다 | diff --git a/apps/mobile/src/features/profile/model/profile-image.parity.test.ts b/apps/mobile/src/features/profile/model/profile-image.parity.test.ts index 245c6d37..c3833797 100644 --- a/apps/mobile/src/features/profile/model/profile-image.parity.test.ts +++ b/apps/mobile/src/features/profile/model/profile-image.parity.test.ts @@ -145,6 +145,7 @@ describe("mergeProfileImage — 확정 응답 병합 (포팅 parity)", () => { profileImageUrl: null, createdAt: "2026-01-12T00:00:00", locationConsent: true, + role: "USER" as const, }; const confirmed = { ...prev, diff --git a/apps/mobile/src/shared/api/reset-session-cache.test.ts b/apps/mobile/src/shared/api/reset-session-cache.test.ts index 29ede4f4..599349a4 100644 --- a/apps/mobile/src/shared/api/reset-session-cache.test.ts +++ b/apps/mobile/src/shared/api/reset-session-cache.test.ts @@ -129,6 +129,7 @@ describe("resetSessionCache — 로그인 직후 이전 세션 캐시 폐기 (AC profileImageUrl: null, createdAt: "2026-01-01T00:00:00Z", locationConsent: true, + role: "USER", }, }); // 비로그인 게이트는 enabled:false라 요청을 만들지 않는다 (AC 3) diff --git a/apps/web/openapi/api-docs.json b/apps/web/openapi/api-docs.json index c7d97afa..689360f8 100644 --- a/apps/web/openapi/api-docs.json +++ b/apps/web/openapi/api-docs.json @@ -1 +1 @@ -{"openapi":"3.1.0","info":{"title":"FillMap API","description":"FillMap API 문서","version":"v1"},"servers":[{"url":"https://api.fillmap.kr","description":"Generated server url"}],"security":[{"bearerAuth":[]}],"tags":[{"name":"행사 (Events)","description":"지도 홈 행사 칩·행사방 헤더·행사 위치 목록 조회 API."},{"name":"행사 (Events)","description":"지도에서 누른 격자를 행사 위치로 해석하는 역조회 API."},{"name":"미션 영상 (Mission Videos)","description":"미션 상세 하단 \"이 미션의 영상\" 목록 API — 그 미션의 대상 격자에서 미션 기간에 촬영된 공개 영상."},{"name":"영상 (Video)","description":"영상 업로드·교체·삭제 API. 업로드는 presigned URL 발급 → S3 직접 업로드 → 메타데이터 저장 순서다."},{"name":"인증 (Auth)","description":"회원가입·로그인·소셜 로그인·토큰 재발급 API. 이 그룹의 엔드포인트는 인증 없이 호출한다."},{"name":"알림 (Notification)","description":"FCM 푸시 토큰 등록/갱신·해제 API."},{"name":"장소 검색 (Search)","description":"장소명 자유 텍스트 검색 — 카카오 로컬 키워드 검색 실시간 프록시 + 격자 ID 합성."},{"name":"격자 (Grid)","description":"개인 도감 색칠 격자 조회 API — 로그인 사용자가 점령한 격자만 반환한다.\n\n격자는 EPSG:5179 미터 평면에서 100m 로 나눈 셀이다(2026-08-08 MSG-347 전까지는 위경도 등간격 근사였다). gridId 포맷 `\"{grid_y}_{grid_x}\"` 와 이 API 들의 요청·응답 구조는 그대로지만 **값은 전면 교체됐다** (같은 장소가 `41642_110458` 에서 `19422_9582` 로 바뀌었다). 예전 gridId 를 저장해 둔 클라이언트는 빈 결과를 받으므로 캐시를 비워야 한다.\n\n셀은 위경도 축과 평행하지 않다(자오선 수렴 최대 약 1.6도). 지도에 그릴 때 남서·북동 2점으로 만든 직사각형을 쓰면 어긋나므로 **꼭짓점 4점 폴리곤**으로 그린다. 화면에 보이는 격자 범위를 구할 때도 2점이 아니라 꼭짓점 4점의 min/max 를 써야 가장자리 셀이 빠지지 않는다.\n\n클라이언트가 같은 격자를 계산하려면 서버와 **글자 단위로 같은 proj4 정의**를 써야 한다: `+proj=tmerc +lat_0=38 +lon_0=127.5 +k=0.9996 +x_0=1000000 +y_0=2000000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs`. 대조용 전국 샘플 200건은 서버 레포 `src/test/resources/fixtures/grid-epsg5179-samples.json` 에 있다."},{"name":"알림 (Notification)","description":"받은 알림 목록 조회와 읽음 처리 API."},{"name":"인증-개발용 (Auth Dev)","description":"로컬/dev 전용 — 소셜 로그인을 실제 소셜 토큰 없이 백엔드에서 테스트. 운영(prod) 미노출."},{"name":"도감 (Collection)","description":"개인 도감 요약 조회 API — 로그인 사용자의 점령·영상·방문 행정동 집계."},{"name":"격자 상세 (Grid Videos)","description":"격자를 탭했을 때 그 격자의 영상 조회 API — 내 영상 리스트·전역 대표 영상·전역 인기 목록."},{"name":"AI 경로 추천 (Routes)","description":"자연어 한 문장과 뷰포트로 활성 미션·행사·장소 검색 실조회 후보에 방문 순서와 이유를 붙여 돌려준다."},{"name":"구역 (Zone)","description":"구역(\"서면\" 등)의 이름과 격자 사각형 범위. 검색바에서 구역으로 지도를 옮기거나 구역 범위를 오버레이로 그릴 때 쓴다 — 격자 표시명(\"서면 A-14\")은 서버가 계산해 격자 응답에 함께 싣는다."},{"name":"행사 (Events)","description":"행사 위치의 영상 업로드·피드·상세 API."},{"name":"미션 (Missions)","description":"지도 오버레이용 활성 미션 목록·내 진행도·미션 상세 조회 API."},{"name":"알림 (Notification)","description":"카테고리별 알림 수신 설정 조회/토글 API."},{"name":"행정동 (Region)","description":"좌표를 포함하는 행정동을 우리 region_code 체계로 판정하는 역지오코딩 API."},{"name":"인기 검색어 (Trending)","description":"사용자 검색어 일별 집계 기반 인기 검색어 순위 — 오늘+어제 합산 TOP 10."},{"name":"전역 탐색 (Region Explore)","description":"행정동 축으로 전역 공개 콘텐츠를 탐색하는 API — 지도 홈 패널·전체 보기 격자 썸네일 뷰·검색 무입력 전체 지역 리스트."},{"name":"관리자 신고 처리 (Admin Report)","description":"접수된 영상 신고의 열람·승인·기각과 블라인드 해제·단건 확인 API (MSG-195). ADMIN 권한 필수."},{"name":"사용자 (User)","description":"계정 관리 API. 인증 필수 — 본인 계정만 대상이다."},{"name":"행사방 (Event)","description":"행사방 열람 인원 heartbeat·조회 API."},{"name":"뱃지 (Badge)","description":"뱃지 API — 내 뱃지 목록 조회 · 대표 뱃지 집합 교체."},{"name":"친구 (Friend)","description":"고정 친구 코드 기반 친구 관계 API — 코드·요청·수락·거절·삭제 (MSG-185), 친구 목록·친구 프로필 조회 (MSG-186), 친구 도감 레이어(격자 뷰포트·격자 영상 목록, MSG-187, 축소 시야의 행정 단위 집계는 MSG-356). 인증 필수."},{"name":"신고 (Report)","description":"영상 신고 접수 API (MSG-192). 인증 필수."},{"name":"행사 (Events)","description":"행사 영상의 댓글·도움돼요 API."},{"name":"핫구역 (HotZone)","description":"최근 48시간 방문(업로드) 신호 상위 격자 조회 API — 개인화 없는 공용 목록."}],"paths":{"/api/videos/{videoId}":{"get":{"tags":["영상 (Video)"],"summary":"단건 영상 재생 조회","description":"영상 하나의 표시용 메타와 재생본 presigned GET URL을 발급한다. 소유자·타인 모두 조회할 수 있으나 삭제·블라인드(타인)는 404, 비공개(타인)·친구만 공개(비친구)는 403이다. READY가 아니면 playbackUrl은 null이다.","operationId":"getPlayback","parameters":[{"name":"videoId","in":"path","description":"재생할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoVideoPlaybackResponseDto"}}}}}},"put":{"tags":["영상 (Video)"],"summary":"영상 교체","description":"기존 영상을 새 파일로 교체한다. 좌표를 생략하면 격자를 유지하고 파일만 교체하며, 좌표를 보내면 기존과 같은 격자여야 한다(다르면 거부). 교체 직후 상태는 UPLOADED다.","operationId":"replace","parameters":[{"name":"videoId","in":"path","description":"교체할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1001}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VideoReplaceRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoVideoReplaceResponseDto"}}}}}},"delete":{"tags":["영상 (Video)"],"summary":"영상 삭제","description":"영상을 삭제한다. 해당 격자의 내 영상이 모두 사라지면 점령이 롤백(색칠 해제)된다.","operationId":"delete","parameters":[{"name":"videoId","in":"path","description":"삭제할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1001}],"responses":{"200":{"description":"OK"}}}},"/api/users/me/profile-image":{"put":{"tags":["사용자 (User)"],"summary":"프로필 이미지 변경 확정","description":"presign 으로 올린 pending 키를 확정해 프로필 이미지를 교체하고 갱신된 프로필을 반환한다. 내 pending 경로가 아니거나 확장자 없는 키는 1401, S3 에 실제로 없는 키는 1402, 실측 크기가 5MB 를 넘으면 1413. 교체된 이전 이미지는 응답 후 정리된다.","operationId":"updateProfileImage","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileImageUpdateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoUserProfileResponseDto"}}}}}},"delete":{"tags":["사용자 (User)"],"summary":"프로필 이미지 제거","description":"프로필 이미지를 기본 상태(null)로 되돌린다. 이미 기본 상태여도 성공한다(멱등). 응답은 변경 확정과 같은 프로필 형태다.","operationId":"removeProfileImage","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoUserProfileResponseDto"}}}}}}},"/api/users/me/nickname":{"put":{"tags":["사용자 (User)"],"summary":"닉네임 수정","description":"닉네임(2~20자)을 교체하고 변경 후 프로필을 반환한다. 중복 닉네임은 허용된다.","operationId":"updateNickname","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NicknameUpdateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoUserProfileResponseDto"}}}}}}},"/api/users/me/marketing-consent":{"put":{"tags":["사용자 (User)"],"summary":"마케팅 정보 수신 동의 변경","description":"가입 후 설정 화면에서 마케팅 수신 동의를 켜거나 끈다. 이미 저장된 값과 같은 값을 다시 보내도 성공하며, 이때 서버가 보관하는 마지막 변경 시각은 갱신되지 않는다(멱등). 응답은 변경 후 동의 상태다 — 위치정보 사용 동의 변경과 같은 구조다.","operationId":"updateMarketingConsent","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarketingConsentUpdateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoConsentStatusResponseDto"}}}}}}},"/api/users/me/location-consent":{"put":{"tags":["사용자 (User)"],"summary":"위치정보 사용 동의 켜기","description":"위치기반서비스 이용 동의를 켜고 변경 후 프로필을 반환한다. 첫 로그인 온보딩의 동의 제출과 프로필 화면이 이 엔드포인트 하나를 공용으로 쓴다.\n\n이 동의는 철회할 수 없다 — consented=false 요청은 1400 으로 거절된다. 되돌리려면 계정을 삭제해야 하며, 이는 다른 필수 약관 동의와 같은 규칙이다. 이미 켜진 상태에서 다시 켜는 요청은 성공하고, 이때 서버가 보관하는 동의 시각은 갱신되지 않는다(멱등).","operationId":"updateLocationConsent","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationConsentUpdateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoUserProfileResponseDto"}}}}}}},"/api/users/me/consents":{"get":{"tags":["사용자 (User)"],"summary":"가입 약관 동의 상태 조회","description":"로그인 직후 동의 게이트를 띄울지 판별하는 재료다. 항목별 동의 여부 5종과 필수 4항목 완료 여부(requiredCompleted)를 함께 반환한다 — 필수 항목 목록이 늘어도 클라이언트가 조립을 고치지 않도록 서버가 계산한다.\n\n위치기반서비스 항목(locationTerms)은 프로필 화면의 위치정보 사용 동의와 같은 한 값이다. 이 동의는 철회할 수 없으므로 한 번 true 가 되면 되돌아가지 않고, 필수 동의를 마친 사용자에게 게이트가 다시 뜨는 일도 없다. 동의 시각은 서버에만 보관하고 응답에 싣지 않는다.","operationId":"getConsentStatus","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoConsentStatusResponseDto"}}}}}},"put":{"tags":["사용자 (User)"],"summary":"가입 약관 동의 제출","description":"가입 게이트의 \"동의하고 시작하기\" 제출이다. 필수 4항목(만 14세 이상·서비스 이용약관·개인정보 수집·이용·위치기반서비스 이용약관)은 true 여야 하고 마케팅만 선택이다 — 하나라도 false 거나 누락이면 400 이며 이때 아무 항목도 저장되지 않는다.\n\n같은 내용을 다시 보내도 성공한다(멱등). 재제출이 필수 4항목의 최초 동의 시각을 덮지 않고, 마케팅만 값이 실제로 달라질 때 변경 시각이 갱신된다. 제출은 위치정보 사용 동의도 함께 켜므로 프로필 화면의 위치 동의와 값이 하나다. 응답은 제출 후 동의 상태다.","operationId":"submitConsents","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsentSubmitRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoConsentStatusResponseDto"}}}}}}},"/api/event-videos/{videoId}/helpful":{"put":{"tags":["행사 (Events)"],"summary":"행사 영상 도움돼요 추가","description":"이 영상에 도움돼요를 누른다. 사용자당 한 번이고 이미 누른 상태에서 다시 불러도 성공하며 수가 늘지 않는다 — 네트워크 재시도가 수를 흔들지 않도록 PUT 으로 둔 이유다.\n\n응답의 helpfulCount 는 처리 후 다시 센 값이라 그 사이 다른 사람이 누른 것도 반영된다.\n\n아카이브된 행사(종료 30일 후)에서는 409 + developCode 13422 다 — 유예 기간까지는 계속 누를 수 있다. 상세에 보이지 않는 영상은 404 + 13406 이다.","operationId":"addHelpful","parameters":[{"name":"videoId","in":"path","description":"영상 id","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventVideoHelpfulResponseDto"}}}}}},"delete":{"tags":["행사 (Events)"],"summary":"행사 영상 도움돼요 취소","description":"누른 도움돼요를 되돌린다. 누른 적이 없어도 실패하지 않는다(멱등).\n\n아카이브된 행사(종료 30일 후)에서는 409 + developCode 13422 다 — 유예 기간까지는 취소할 수 있다. 상세에 보이지 않는 영상은 404 + 13406 이다.","operationId":"removeHelpful","parameters":[{"name":"videoId","in":"path","description":"영상 id","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventVideoHelpfulResponseDto"}}}}}}},"/api/event-occurrences/{occurrenceId}/notification":{"put":{"tags":["행사 (Events)"],"summary":"행사 알림 구독 토글","description":"행사 회차 단위로 알림을 켜고 끈다. 행사방에는 참여 절차가 없어 구독이 사용자와 행사가 맺는 관계의 전부다. 같은 값을 반복 요청해도 같은 결과로 성공한다.\n\n응답의 enabled 는 저장된 구독 행의 존재가 아니라 **노출 상태**다 — 구독 행이 있으면서 회차가 예정이거나 진행 중일 때만 true 이고, 종료된 회차는 행이 남아 있어도 false 다(종료 시점부터 즉시 OFF, 정리 배치를 기다리지 않는다).\n\n종료된 행사(업로드 유예·아카이브)에 켜기를 요청하면 409 + developCode 13422 다 — 시작 알림이 이미 지나 받을 것이 없기 때문이다. 끄기는 상태와 무관하게 언제나 성공한다. 없는 회차이거나 아직 노출 기간 전인 예정 회차면 404 + developCode 13404 다.\n\n실제 발송은 이 구독 위에 알림 설정의 EVENT 카테고리 스위치가 겹쳐 결정된다 — 카테고리를 끈 사용자에게는 구독이 켜져 있어도 발송되지 않는다.","operationId":"updateSubscription","parameters":[{"name":"occurrenceId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventNotificationUpdateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventNotificationResponseDto"}}}}}}},"/api/badges/featured":{"put":{"tags":["뱃지 (Badge)"],"summary":"대표 뱃지 집합 교체","description":"획득한 뱃지 중 최대 2개를 대표로 교체 지정한다(멱등). 배열 순서 = 표시 순서(rank 1·2), 빈 배열은 전부 해제. 미획득·미존재 뱃지는 7403, 중복 id 는 7400, 3개 이상은 400 이다.","operationId":"replaceFeatured","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeaturedBadgeRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListFeaturedBadgeResponseDto"}}}}}}},"/api/videos":{"post":{"tags":["영상 (Video)"],"summary":"영상 메타데이터 저장 (업로드 확정)","description":"S3 업로드 완료 후 영상 메타데이터를 저장하고 좌표로 격자를 매핑한다. 해당 격자에 내 첫 영상이면 점령(occupied=true)된다.","operationId":"upload","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VideoUploadRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoVideoUploadResponseDto"}}}}}}},"/api/videos/{videoId}/reports":{"post":{"tags":["신고 (Report)"],"summary":"영상 신고 접수","description":"다른 사람의 영상을 사유 5종(INAPPROPRIATE, PRIVACY, SPAM, COPYRIGHT, OTHER) 중 하나와 함께 신고한다. 접수된 신고는 PENDING 으로 쌓여 관리자 처리의 입력이 되며, 접수 자체는 영상 상태를 바꾸지 않는다. 같은 영상 재신고는 409, 자기 영상 신고는 400, 없는 영상·삭제·블라인드 영상은 재생 조회와 같은 404 다.","operationId":"report","parameters":[{"name":"videoId","in":"path","description":"신고할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportCreateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoReportCreateResponseDto"}}}}}}},"/api/videos/presigned-url":{"post":{"tags":["영상 (Video)"],"summary":"업로드용 presigned URL 발급","description":"영상 파일을 S3에 직접 올릴 presigned URL을 발급한다. 이 URL로 PUT 업로드한 뒤 메타데이터 저장을 호출한다.","operationId":"issuePresignedUrl","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PresignedUrlRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoPresignedUrlResponseDto"}}}}}}},"/api/videos/highlight-preview":{"post":{"tags":["영상 (Video)"],"summary":"하이라이트 선분석","description":"업로드 확정 전 원본(presign purpose=HIGHLIGHT_PREVIEW 로 올린 pending 키)의 AI 하이라이트 구간을 동기로 계산해 돌려준다. 원본 길이에 따라 응답까지 수 초에서 수십 초 걸린다(30초 1080p 기준 5초 내외). highlights 가 빈 배열이면 추천 없음이니 FE 는 추천 단계를 스킵한다. 실패 시 FE 는 직접 구간 지정으로 폴백한다 — 3502(분석 서버 문제, 재시도 가능)·3426(원본 파일 불량, 재시도 무의미)·3425(3분 초과)·3413(400, 허용 크기 초과). 결과는 저장되지 않는 임시 값이며, 같은 키로 이후 업로드 확정(POST /api/videos)이 가능하다.","operationId":"highlightPreview","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HighlightPreviewRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoHighlightPreviewResponseDto"}}}}}}},"/api/users/me/profile-image/presigned-url":{"post":{"tags":["사용자 (User)"],"summary":"프로필 이미지 업로드용 presigned URL 발급","description":"프로필 이미지를 S3 에 직접 올릴 presigned URL 을 발급한다. 이 URL 로 PUT 업로드한 뒤 받은 s3Key 로 변경 확정(PUT /api/users/me/profile-image)을 호출한다. 허용 형식은 jpg·jpeg·png·webp 이고 크기 상한은 5MB 다 — 확장자와 Content-Type 이 어긋나거나 허용 밖이면 1415, 선언 크기가 상한을 넘으면 1413.\n\n아이폰 사진(heic·heif)은 받지 않는다 — 저장해도 대부분의 브라우저가 표시하지 못하기 때문이다. 파일 선택 accept 목록에서 heic 를 빼면 iOS 가 플랫폼 수준에서 JPEG 로 변환해 주므로 정상 경로에서는 거부가 나오지 않고, 그래도 새어 들어온 원본 heic 는 1415 응답을 안내 문구로 처리한다.","operationId":"issueProfileImagePresignedUrl","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileImagePresignRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoProfileImagePresignResponseDto"}}}}}}},"/api/routes/recommend":{"post":{"tags":["AI 경로 추천 (Routes)"],"summary":"AI 경로 추천","description":"자연어 한 문장과 지금 보는 지도 범위를 보내면 서버 보유 후보(활성 미션·행사·장소 검색)에서 골라 방문 순서를 붙인 지점 목록(최대 8개)을 돌려준다. 지점마다 추천 이유 한 줄이 실린다.\n\n후보가 0~2개면 실패가 아니라 찾은 만큼과 notice 안내가 함께 오는 성공이다.\n\nviewport 가 뒤집혔거나 넓이 0 이거나 범위 밖이면 400 + developCode 14400, 한 변이 0.5도를 넘으면 400 + 14401 이다. 같은 사용자의 직전 시도 후 10초 안 재요청은 429 + 14429. AI 해석 실패는 502 + 14502 이고, 기능이 꺼진 환경(route.ai.enabled=false)에서는 503 + 14503 이다.","operationId":"recommend","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RouteRecommendRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRouteRecommendResponseDto"}}}}}}},"/api/notifications/tokens":{"post":{"tags":["알림 (Notification)"],"summary":"FCM 토큰 등록/갱신","description":"디바이스의 FCM 토큰을 현재 계정으로 등록한다(UPSERT). 같은 토큰 재등록은 충돌 없이 user_id·platform·appVersion·last_used_at 이 갱신된다 — 재로그인·계정 전환 포함. platform 이 IOS/ANDROID/WEB(대소문자 무시) 외면 10400 이다.","operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PushTokenRequestDto"}}},"required":true},"responses":{"200":{"description":"OK"}}},"delete":{"tags":["알림 (Notification)"],"summary":"FCM 토큰 해제","description":"본인 소유(user_id 일치) 토큰 행을 삭제한다 — 멱등, 없는 토큰·소유 불일치 해제도 200. 로그아웃은 /api/auth/logout body 의 fcmToken 으로 한 번에 처리하고, 이 API 는 토큰 로테이션 등 로그아웃 외 정리 용도다.","operationId":"unregister","parameters":[{"name":"fcmToken","in":"query","description":"해제할 FCM 토큰","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/missions/{missionId}/videos":{"get":{"tags":["미션 영상 (Mission Videos)"],"summary":"미션 영상 목록 조회","description":"그 미션의 대상 격자에서 미션 기간에 촬영된 공개(PUBLIC)·READY 영상을 촬영 시각(recordedAt) 최신순으로 페이지 조회한다 — 촬영 시각이 같으면 videoId 내림차순으로 갈린다. 기간이 없는 미션(코스·지속형)은 기간 조건 없이 과거 영상까지 담고, 기간이 끝난 미션도 목록은 그대로 조회된다. 비공개·친구 공개·삭제·블라인드·인코딩 미완 영상은 본인 것이라도 제외되며, 응답은 누가 부르든 같다. 첫 요청은 cursor 없이 부르고, hasNext 가 true 면 응답의 nextCursor 를 다음 요청 cursor 로 넘기면 이어진다. 커서는 발급된 그 미션 전용이라 다른 미션 커서는 400(INVALID_CURSOR)이고, 형식이 깨진 커서도 같다. size 는 1~50 밖이면 클램프된다. 조건에 맞는 영상이 없거나 존재하지 않는 missionId 는 빈 페이지다. 썸네일은 presigned GET URL 로 내려준다.","operationId":"getMissionVideos","parameters":[{"name":"missionId","in":"path","description":"미션 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":12},{"name":"cursor","in":"query","description":"직전 응답의 nextCursor (opaque). 생략하면 첫 페이지","required":false,"schema":{"type":"string"}},{"name":"size","in":"query","description":"페이지 크기 (1~50, 기본 20)","required":false,"schema":{"type":"integer","format":"int32","default":20}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoGridVideoPageResponseDto"}}}}}},"post":{"tags":["미션 영상 (Mission Videos)"],"summary":"미션 경유 영상 업로드 확정","description":"축제·팝업 미션에 영상을 올린다. 파일은 기존 presigned 발급(POST /api/videos/presigned-url)으로 S3 에 먼저 올리고 이 API 가 확정한다.\n\n좌표도 격자도 받지 않는다. 저장 위치는 서버가 그 미션의 대표 격자로 정하므로 같은 미션의 영상이 지도에서 한 칸에 모인다. 공개 범위는 PUBLIC 으로 고정되고, 업로드는 일반 업로드와 똑같이 그 격자의 점령을 만들며 뱃지·스트릭·미션 스탬프도 그대로 반영된다.\n\n같은 s3Key 로 다시 보내면 영상이 하나 더 생기지 않고 저장된 행 기준의 성공이 돌아온다. 이때 occupied 는 false, newBadges 와 completedMissions 는 빈 배열이다(첫 응답 전용 필드).\n\n촬영 시각이 미래면 400 + developCode 3424, 키 형식이 아니거나 남의 pending 키면 400 + 3401 이다. 그 밖의 모든 실패는 409 + 12409 하나로 돌아온다 — 없는 미션, 코스처럼 대상이 아닌 유형, 기간 밖, 촬영 시각이 미션 기간 밖, 대표 격자가 없는 미션, 이미 다른 자리에 쓴 키, S3 에 없는 키가 전부 여기 해당하며 사유는 갈라 주지 않는다. 이 응답을 받으면 그대로 재시도하지 말고 미션 상세를 다시 불러 업로드 가능 여부를 확인하고, 미션이 여전히 열려 있으면 presigned URL 을 새로 발급받아 파일부터 다시 올린다.\n\n인코딩이 끝나기 전에는 목록에 잡히지 않는다 — 업로드 직후 화면에 카드를 보여주려면 이 응답으로 낙관적으로 그린다(기존 업로드와 같은 성질).","operationId":"uploadMissionVideo","parameters":[{"name":"missionId","in":"path","description":"미션 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":12}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MissionVideoUploadRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoMissionVideoUploadResponseDto"}}}}}}},"/api/friends/requests":{"post":{"tags":["친구 (Friend)"],"summary":"친구 요청","description":"상대의 친구 코드로 요청을 보낸다. 응답 status 가 PENDING 이면 상대 수락 대기, ACCEPTED 면 상대가 먼저 보낸 요청이 있어 즉시 친구 성립(자동 수락)이다.","operationId":"request","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FriendRequestCreateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoFriendRequestCreateResponseDto"}}}}}}},"/api/friends/requests/{requesterId}/reject":{"post":{"tags":["친구 (Friend)"],"summary":"친구 요청 거절","description":"받은 요청을 거절한다. 보낸 쪽에 통지는 없고, 상대는 다시 요청할 수 있다.","operationId":"reject","parameters":[{"name":"requesterId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"OK"}}}},"/api/friends/requests/{requesterId}/accept":{"post":{"tags":["친구 (Friend)"],"summary":"친구 요청 수락","description":"받은 요청을 수락해 친구 관계를 성립시킨다. 요청의 수신자 본인만 가능하다.","operationId":"accept","parameters":[{"name":"requesterId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"OK"}}}},"/api/event-videos/{videoId}/comments":{"get":{"tags":["행사 (Events)"],"summary":"행사 영상 댓글 목록 조회","description":"영상에 달린 댓글을 오래된 순으로 한 페이지 돌려준다 — 새 댓글이 아래에 쌓이는 배열이다.\n\n영상 상세가 첫 페이지(20건)를 이미 품고 있으므로 이 API 는 둘째 페이지부터를 위한 것이다. cursor 는 직전 응답의 nextCursor 를 그대로 넣는다(첫 페이지는 생략). 형식이 깨졌거나 다른 영상 목록에서 받은 커서면 400 + developCode 13402 다. size 는 1~50 범위 밖이면 잘라서 적용하고 생략하면 20 이다.\n\n아카이브된 행사에서도 조회할 수 있고 댓글이 없으면 실패가 아니라 빈 페이지다. 비로그인으로도 조회할 수 있다.","operationId":"getComments","parameters":[{"name":"videoId","in":"path","description":"영상 id","required":true,"schema":{"type":"integer","format":"int64"},"example":1042},{"name":"cursor","in":"query","description":"직전 응답의 nextCursor. 첫 페이지는 생략","required":false,"schema":{"type":"string"}},{"name":"size","in":"query","description":"페이지 크기 (1~50, 기본 20)","required":false,"schema":{"type":"integer","format":"int32","default":0},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventVideoCommentPageResponseDto"}}}}}},"post":{"tags":["행사 (Events)"],"summary":"행사 영상 댓글 작성","description":"행사 영상에 댓글을 단다. 내용은 1~500자다.\n\n행사방이 아카이브로 넘어가면 댓글을 더 달 수 없다 — 종료 30일 후부터 409 + developCode 13422 다(기존 댓글은 계속 보인다). 그 전까지는 예정·진행 중은 물론 유예 기간(종료 후 30일)에도 쓸 수 있고, 유예 기간에 새로 올라온 영상에도 댓글을 남길 수 있다.\n\n상세에 보이는 영상에만 쓸 수 있다 — 삭제·블라인드·비공개·처리 미완료 영상과 행사 영상이 아닌 영상 id 는 올린 본인에게도 404 + 13406 이다.","operationId":"createComment","parameters":[{"name":"videoId","in":"path","description":"영상 id","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventVideoCommentRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventVideoCommentResponseDto"}}}}}}},"/api/event-occurrences/{occurrenceId}/locations/{locationId}/videos":{"get":{"tags":["행사 (Events)"],"summary":"위치별 영상 피드 조회","description":"행사 위치에 올라온 영상을 최신 업로드순으로 한 페이지 돌려준다. 영역 안 어느 격자를 눌러 들어와도 같은 위치의 같은 피드다.\n\n여기 담기는 영상은 위치 목록의 영상 수와 정확히 같은 집합이다 — 삭제·비공개·처리 미완료 영상은 숫자에서도 목록에서도 함께 빠진다. 인코딩이 끝나기 전 영상은 아직 담기지 않는다.\n\ncursor 는 직전 응답의 nextCursor 를 그대로 넣는다(첫 페이지는 생략). 형식이 깨졌거나 다른 위치 피드에서 받은 커서면 400 + developCode 13402 다. size 는 1~50 범위 밖이면 잘라서 적용하고 생략하면 20 이다.\n\n아카이브된 행사에서도 조회할 수 있고 영상이 없으면 실패가 아니라 빈 페이지다. 존재하지 않거나 노출 기간 전인 회차는 404 + 13404, 위치가 없거나 그 회차의 위치가 아니면 404 + 13405 다. 비로그인으로도 조회할 수 있다.","operationId":"getLocationVideos","parameters":[{"name":"occurrenceId","in":"path","description":"행사 회차 id","required":true,"schema":{"type":"integer","format":"int64"},"example":12},{"name":"locationId","in":"path","description":"행사 위치 id","required":true,"schema":{"type":"integer","format":"int64"},"example":34},{"name":"cursor","in":"query","description":"직전 응답의 nextCursor. 첫 페이지는 생략","required":false,"schema":{"type":"string"}},{"name":"size","in":"query","description":"페이지 크기 (1~50, 기본 20)","required":false,"schema":{"type":"integer","format":"int32","default":0},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventLocationVideoPageResponseDto"}}}}}},"post":{"tags":["행사 (Events)"],"summary":"행사 영상 업로드 확정","description":"행사 위치에 영상을 올린다. 파일은 기존 presigned 발급(POST /api/videos/presigned-url)으로 S3 에 먼저 올리고 이 API 가 확정한다 — 촬영이든 갤러리 선택이든 서버 계약은 하나다.\n\n좌표를 받지 않는다. 격자는 서버가 그 위치의 대표 격자로 정하므로 현장에 없어도 올릴 수 있고, 공개 범위는 PUBLIC 으로 고정된다. 업로드는 일반 업로드와 똑같이 그 격자의 점령을 만들고 뱃지·스트릭도 그대로 반영된다(미션만 연계되지 않는다).\n\n같은 s3Key 로 다시 보내면 영상이 하나 더 생기지 않고 저장된 행 기준의 성공이 돌아온다. 이때 occupied 는 false, newBadges 는 빈 배열이다(첫 응답 전용 필드).\n\n올릴 수 있는 기간은 행사 시작부터 종료 30일 후 직전까지다. 시작 전이면 409 + developCode 13410, 마감 이후면 409 + 13409 다. 존재하지 않거나 아직 노출 기간 전인 회차는 404 + 13404, 위치가 없거나 그 회차의 위치가 아니면 404 + 13405 다.\n\n인코딩이 끝나기 전에는 피드에 잡히지 않는다 — 업로드 직후 화면에 카드를 보여주려면 이 응답으로 낙관적으로 그린다(기존 업로드와 같은 성질).","operationId":"upload_1","parameters":[{"name":"occurrenceId","in":"path","description":"행사 회차 id","required":true,"schema":{"type":"integer","format":"int64"},"example":12},{"name":"locationId","in":"path","description":"행사 위치 id","required":true,"schema":{"type":"integer","format":"int64"},"example":34}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventVideoUploadRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventVideoUploadResponseDto"}}}}}}},"/api/event-occurrences/{occurrenceId}/heartbeat":{"post":{"tags":["행사방 (Event)"],"summary":"열람 heartbeat","description":"행사방을 보는 동안 30초 주기로 보낸다. 마지막 신호가 90초 이내인 세션만 열람 인원에 센다. 비로그인은 X-Viewer-Session 헤더(공백 아님·최대 64자) 필수 — 없으면 400. 캐시 장애는 삼켜져 200 이다.","operationId":"heartbeat","parameters":[{"name":"occurrenceId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"X-Viewer-Session","in":"header","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/auth/signup":{"post":{"tags":["인증 (Auth)"],"summary":"이메일 회원가입","description":"이메일/비밀번호/닉네임으로 신규 회원을 생성한다.","operationId":"signup","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignupRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoSignupResponseDto"}}}}}}},"/api/auth/reissue":{"post":{"tags":["인증 (Auth)"],"summary":"토큰 재발급","description":"리프레시 토큰(웹=쿠키, 앱=body)으로 새 액세스 토큰과 회전된 새 리프레시 토큰을 발급받는다. 직전 리프레시 토큰은 즉시 무효화되며, 회전된 옛 토큰 재사용 시 세션 체인이 폐기된다. 쿠키로 리프레시를 보내는 웹은 CSRF 방어를 위해 X-Client-Type 헤더가 필수다(없으면 400). body 로 보내는 앱은 생략할 수 있다.","operationId":"reissue","parameters":[{"name":"refreshToken","in":"cookie","required":false,"schema":{"type":"string"}},{"name":"X-Client-Type","in":"header","description":"클라이언트 유형 (web|app). 리프레시를 쿠키로 보내면 필수, body 로 보내면 생략 가능(생략 시 web 취급).","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReissueRequestDto"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoReissueResponseDto"}}}}}}},"/api/auth/oauth/{provider}":{"post":{"tags":["인증 (Auth)"],"summary":"소셜 로그인 (OIDC)","description":"소셜 제공자의 ID Token으로 로그인/가입하고 JWT 액세스 토큰과 리프레시 토큰을 발급받는다. 웹(X-Client-Type: web, 기본)은 리프레시가 HttpOnly 쿠키(Set-Cookie)로 내려가 body 의 refreshToken 이 null 이고, 앱(app)은 body 로 내려간다.","operationId":"oauthLogin","parameters":[{"name":"provider","in":"path","description":"소셜 제공자","required":true,"schema":{"type":"string"},"example":"KAKAO"},{"name":"X-Client-Type","in":"header","description":"클라이언트 유형 (web|app, 기본 web)","required":false,"schema":{"type":"string","default":"web"}},{"name":"X-Device-Id","in":"header","description":"디바이스 식별자. 없으면 서버가 UUID 를 생성해 응답 헤더 X-Device-Id 로 반환한다.","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OidcLoginRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoLoginResponseDto"}}}}}}},"/api/auth/oauth/kakao/code":{"post":{"tags":["인증 (Auth)"],"summary":"소셜 로그인 (카카오 인가 코드)","description":"웹에서 카카오 콜백으로 받은 인가 코드로 로그인/가입한다. 서버가 REST API 키로 카카오 토큰 엔드포인트를 호출해 ID Token 을 받은 뒤, 소셜 로그인(OIDC)과 완전히 같은 검증·발급 경로를 태운다. 인가 진입점이 심은 OAUTH_NONCE 쿠키가 함께 와야 한다(없으면 401). 응답 형태는 기존 소셜 로그인과 동일하다 — 웹(X-Client-Type: web, 기본)은 리프레시가 HttpOnly 쿠키(Set-Cookie)로 내려가 body 의 refreshToken 이 null 이고, 앱(app)은 body 로 내려간다. 네이티브 SDK 가 교환까지 해주는 앱은 이 API 가 아니라 POST /api/auth/oauth/{provider} 를 쓴다.","operationId":"oauthCodeLogin","parameters":[{"name":"OAUTH_NONCE","in":"cookie","required":false,"schema":{"type":"string"}},{"name":"X-Client-Type","in":"header","description":"클라이언트 유형 (web|app, 기본 web)","required":false,"schema":{"type":"string","default":"web"}},{"name":"X-Device-Id","in":"header","description":"디바이스 식별자. 없으면 서버가 UUID 를 생성해 응답 헤더 X-Device-Id 로 반환한다.","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KakaoCodeLoginRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoLoginResponseDto"}}}}}}},"/api/auth/logout":{"post":{"tags":["인증 (Auth)"],"summary":"로그아웃","description":"Authorization 헤더의 액세스 토큰을 무효화하고 해당 디바이스(X-Device-Id)의 리프레시 세션을 삭제한다. X-Device-Id 가 없으면 해당 유저의 모든 디바이스 세션을 삭제한다. 선택 body 의 fcmToken 이 있으면 해당 FCM 푸시 토큰도 함께 정리된다 (MSG-178 logout 통합).","operationId":"logout","parameters":[{"name":"Authorization","in":"header","required":false,"schema":{"type":"string"}},{"name":"X-Device-Id","in":"header","description":"디바이스 식별자. 없으면 모든 디바이스 세션 삭제(로그아웃-올).","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LogoutRequestDto"}}}},"responses":{"200":{"description":"OK"}}}},"/api/auth/login":{"post":{"tags":["인증 (Auth)"],"summary":"이메일 로그인","description":"이메일/비밀번호로 로그인하고 JWT 액세스 토큰과 리프레시 토큰을 발급받는다. 웹(X-Client-Type: web, 기본)은 리프레시가 HttpOnly 쿠키로, 앱(app)은 body 로 내려간다.","operationId":"login","parameters":[{"name":"X-Client-Type","in":"header","description":"클라이언트 유형 (web|app, 기본 web)","required":false,"schema":{"type":"string","default":"web"}},{"name":"X-Device-Id","in":"header","description":"디바이스 식별자. 없으면 서버가 UUID 를 생성해 응답 헤더 X-Device-Id 로 반환한다.","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoLoginResponseDto"}}}}}}},"/api/auth/dev/social-login":{"post":{"tags":["인증-개발용 (Auth Dev)"],"summary":"[개발용] 소셜 로그인 모의","description":"실제 OIDC ID Token 검증 없이 (provider, oid)로 사용자를 find-or-create 하고 액세스+리프레시 토큰을 발급한다. 리프레시는 body 로 내려간다(앱 모드). 로컬/dev 프로파일에서만 노출.","operationId":"socialLogin","parameters":[{"name":"X-Device-Id","in":"header","description":"디바이스 식별자. 없으면 서버가 UUID 를 생성해 응답 헤더 X-Device-Id 로 반환.","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DevSocialLoginRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoLoginResponseDto"}}}}}}},"/api/admin/videos/{videoId}/unblind":{"post":{"tags":["관리자 신고 처리 (Admin Report)"],"summary":"블라인드 해제","description":"BLINDED 영상을 ACTIVE 로 복구한다. 오판 복구용이며 그 신고의 RESOLVED 는 되돌리지 않는다. 없는 영상과 삭제된 영상은 404(3404), 이미 ACTIVE 면 409(3409) 다.","operationId":"unblindVideo","parameters":[{"name":"videoId","in":"path","description":"해제할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminVideoUnblindResponseDto"}}}}}}},"/api/admin/reports/{reportId}/reject":{"post":{"tags":["관리자 신고 처리 (Admin Report)"],"summary":"신고 기각","description":"신고를 REJECTED 로 종결한다. 영상에는 아무 영향이 없고 응답의 videoStatus 는 현재 상태 그대로다. 없는 신고는 404(11404), 이미 처리된 신고는 409(11410) 다.","operationId":"reject_1","parameters":[{"name":"reportId","in":"path","description":"기각할 신고 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":7}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminReportProcessResponseDto"}}}}}}},"/api/admin/reports/{reportId}/approve":{"post":{"tags":["관리자 신고 처리 (Admin Report)"],"summary":"신고 승인","description":"신고를 RESOLVED 로 종결하고 대상 영상을 블라인드한다 — 한 트랜잭션이다. 영상이 이미 BLINDED 거나 DELETED 면 영상 전이 없이 신고만 종결하며, 응답의 videoStatus 로 구분할 수 있다. 없는 신고는 404(11404), 이미 처리된 신고와 동시 처리의 늦은 쪽은 409(11410) 다.","operationId":"approve","parameters":[{"name":"reportId","in":"path","description":"승인할 신고 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":7}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminReportProcessResponseDto"}}}}}}},"/api/videos/{videoId}/visibility":{"patch":{"tags":["영상 (Video)"],"summary":"영상 공개 범위 전환","description":"본인 영상의 공개 범위를 PUBLIC·PRIVATE·FRIENDS 간 전환한다. 전환된 상태를 반환하며, 같은 값 재전환은 멱등하게 성공한다.","operationId":"setVisibility","parameters":[{"name":"videoId","in":"path","description":"공개 범위를 전환할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VideoVisibilityRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoVideoVisibilityResponseDto"}}}}}}},"/api/notifications/{notificationId}/read":{"patch":{"tags":["알림 (Notification)"],"summary":"알림 하나 읽음 처리","description":"행을 탭했을 때 그 알림을 읽음으로 바꾼다 — 이미 읽은 알림을 다시 요청해도 성공이고 최초로 읽은 시각이 그대로 남는다. 없는 알림이나 남의 알림이면 10404 로, 둘을 구분하지 않는다.","operationId":"markRead","parameters":[{"name":"notificationId","in":"path","description":"읽음 처리할 알림 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":123}],"responses":{"200":{"description":"OK"}}}},"/api/notifications/read-all":{"patch":{"tags":["알림 (Notification)"],"summary":"알림 모두 읽음 처리","description":"안읽은 알림을 전부 읽음으로 바꾼다 — 안읽은 알림이 하나도 없어도 성공한다.","operationId":"markAllRead","responses":{"200":{"description":"OK"}}}},"/api/notifications/preferences/{category}":{"patch":{"tags":["알림 (Notification)"],"summary":"카테고리 수신 토글","description":"카테고리 하나의 수신 여부를 바꾸고 변경 후 전체 상태를 반환한다 — 같은 값 재전환은 멱등. category 가 8종(BADGE·HOTZONE·REMIND·VIDEO·WEEKLY·FRIEND·MISSION_NEARBY·EVENT, 대소문자 무시) 외면 10420 이다. off 는 발송만 막고 off 중 쌓인 알림이 on 복귀 후 재발송되는 일은 없다. MISSION_NEARBY 는 서버 발송이 없어 기기가 발화 전 이 설정을 조회해 로컬로 억제한다.","operationId":"update","parameters":[{"name":"category","in":"path","description":"알림 카테고리 — BADGE·HOTZONE·REMIND·VIDEO·WEEKLY·FRIEND·MISSION_NEARBY (대소문자 무시)","required":true,"schema":{"type":"string"},"example":"HOTZONE"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationPreferenceUpdateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoNotificationPreferenceResponseDto"}}}}}}},"/api/event-videos/{videoId}/comments/{commentId}":{"delete":{"tags":["행사 (Events)"],"summary":"행사 영상 댓글 삭제","description":"댓글을 실제로 지운다(복구 없음). 본인 댓글만 지울 수 있고 남의 댓글이면 403 + developCode 13403 이다.\n\n이미 지운 댓글을 다시 지우면 404 + 13407 이다 — 없는 댓글의 삭제를 성공으로 돌려주면 화면 상태 불일치가 감춰지기 때문이다(도움돼요 취소는 토글이라 멱등인 것과 다르다).\n\n아카이브된 행사(종료 30일 후)에서는 409 + 13422 다.","operationId":"deleteComment","parameters":[{"name":"videoId","in":"path","description":"영상 id","required":true,"schema":{"type":"integer","format":"int64"},"example":1042},{"name":"commentId","in":"path","description":"댓글 id","required":true,"schema":{"type":"integer","format":"int64"},"example":3021}],"responses":{"200":{"description":"OK"}}},"patch":{"tags":["행사 (Events)"],"summary":"행사 영상 댓글 수정","description":"댓글 내용을 통째로 바꾼다. 본인 댓글만 고칠 수 있고 남의 댓글이면 403 + developCode 13403, 없거나 다른 영상의 댓글이면 404 + 13407 이다.\n\n작성 시각은 그대로다(수정 이력을 남기지 않는다). 아카이브된 행사에서는 자기 댓글이든 남의 댓글이든 409 + 13422 로 같다 — 잠금이 권한 판정보다 앞이다.","operationId":"updateComment","parameters":[{"name":"videoId","in":"path","description":"영상 id","required":true,"schema":{"type":"integer","format":"int64"},"example":1042},{"name":"commentId","in":"path","description":"댓글 id","required":true,"schema":{"type":"integer","format":"int64"},"example":3021}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventVideoCommentRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventVideoCommentResponseDto"}}}}}}},"/api/zones":{"get":{"tags":["구역 (Zone)"],"summary":"구역 목록 조회","description":"전체 구역(zone) 목록을 반환한다. 검색바에서 구역을 골라 지도를 옮기거나 구역 범위를 오버레이로 그릴 때 쓴다 — 표시명은 격자 응답의 zoneName·zoneCell 을 그대로 조립하면 되므로 이 목록으로 이름을 계산할 필요가 없다. 시딩 전이면 빈 배열(전 시스템이 행정동 폴백으로 동작).","operationId":"getZones","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListZoneResponseDto"}}}}}}},"/api/users/me":{"get":{"tags":["사용자 (User)"],"summary":"내 프로필 조회","description":"소셜 로그인이 자동 저장한 이메일·닉네임을 반환한다. 항상 본인 계정만 — 경로에 대상 식별자가 없다.","operationId":"getMe","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoUserProfileResponseDto"}}}}}},"delete":{"tags":["사용자 (User)"],"summary":"계정 삭제","description":"내 계정을 즉시·비가역 삭제한다. 연쇄 개인 데이터·영상 S3 객체가 제거되고 전 디바이스 세션이 무효화된다. 같은 이메일·카카오 계정으로 다시 로그인하면 신규 가입이다.","operationId":"deleteMe","parameters":[{"name":"Authorization","in":"header","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/search/trending":{"get":{"tags":["인기 검색어 (Trending)"],"summary":"인기 검색어 TOP 10","description":"오늘+어제(KST) 검색어 집계를 합산해 상위 10개를 순위·검색어로 반환한다. 동률은 검색어 사전순. 검색 횟수와 장소 정보는 포함하지 않으며(클릭 후 장소 검색 API 재호출), 집계가 없으면 200 + 빈 배열이다.","operationId":"getTrendingKeywords","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListTrendingKeywordResponseDto"}}}}}}},"/api/search/places":{"get":{"tags":["장소 검색 (Search)"],"summary":"장소 검색 (장소명 → 좌표·격자)","description":"카카오 로컬 키워드 검색 결과(정확도순 ≤15건)에 각 좌표의 격자 ID 를 얹어 반환한다. 선택 즉시 lat/lng 지도 이동 + gridId 격자 하이라이트. q 누락 400 / trim 후 빈 q·무매치 200 [] / 카카오 장애·타임아웃 502(developCode 5502). 비로그인도 호출할 수 있고 결과는 로그인 때와 같다 — 비로그인 호출은 X-Viewer-Session 헤더(공백 아님·최대 64자·콜론 불가)를 실으면 인기 검색어 집계에 잡히고, 안 실어도 검색은 정상 200 이다.","operationId":"searchPlaces","parameters":[{"name":"q","in":"query","description":"검색어 (자유 텍스트 장소명)","required":true,"schema":{"type":"string"},"example":"부산대"},{"name":"X-Viewer-Session","in":"header","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListPlaceSearchResponseDto"}}}}}}},"/api/regions/{regionCode}/grids":{"get":{"tags":["전역 탐색 (Region Explore)"],"summary":"행정동 격자 카드 리스트 + 헤더 카운트 조회","description":"그 행정동 격자들 중 전역 공개 콘텐츠(공개·인코딩 완료·타인 영상 포함)가 있는 격자를 카드로 반환한다. 카운트(gridCount·videoCount)는 limit 무관 전체 기준이라 지도 홈 패널(sort=LATEST&limit=20, SRS FR-MAP-10)과 전체 보기(limit 생략)가 같은 값을 받지만, **전역 공개 콘텐츠를 센 값이라 패널 헤더(\"이 지역 격자 N개 · 영상 M개\")에 쓰면 안 된다** — 헤더는 내 도감 집계 응답의 currentRegion(중심 동 전체의 내 것, MSG-374)이 채운다. 카드 커버는 격자 대표(cover)와 같은 영상이고 썸네일은 presigned GET URL 이다. 미존재·무콘텐츠 regionCode 는 404 가 아니라 200 + 카운트 0·빈 배열이다.","operationId":"getRegionGrids","parameters":[{"name":"regionCode","in":"path","description":"행정동 코드 — reverse-geocode·전체 지역 리스트의 regionCode 를 그대로 전달","required":true,"schema":{"type":"string"},"example":2644056000},{"name":"sort","in":"query","description":"정렬 — POPULAR(조회수 합)·LATEST(최신 공개 영상). 대문자 전용이며 소문자 포함 무효 값은 400 이다. 지도 홈 패널은 LATEST (SRS FR-MAP-10, 생략 기본값은 POPULAR 유지)","required":false,"schema":{"type":"string","default":"POPULAR","enum":["POPULAR","LATEST"]},"example":"LATEST"},{"name":"limit","in":"query","description":"카드 수 상한 — 지도 홈 패널은 20 (SRS FR-MAP-10). 생략하면 전부, 1 미만은 1 로 보정한다","required":false,"schema":{"type":"integer","format":"int32"},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRegionExploreResponseDto"}}}}}}},"/api/regions/stats":{"get":{"tags":["행정동 (Region)"],"summary":"내 행정동별 수집률 조회","description":"로그인 사용자가 점령(수집)한 격자를 행정동별로 집계한 수집률 리스트를 반환한다. parentCode 로 시군구를 좁힐 수 있고(실존하지 않는 코드면 404/6404), collectedOnly=false 면 롤백으로 0이 된 행정동도 포함한다. 수집이 없으면 404 가 아니라 200 + 빈 배열.","operationId":"getStats","parameters":[{"name":"parentCode","in":"query","description":"상위 시군구 코드. 생략하면 전국. 실존하지 않으면 6404","required":false,"schema":{"type":"string"},"example":11680},{"name":"collectedOnly","in":"query","description":"true=수집한 행정동만, false=손댄 행정동 전부(롤백 0-row 포함)","required":false,"schema":{"type":"boolean","default":true},"example":true}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListRegionStatResponseDto"}}}}}}},"/api/regions/stats/national":{"get":{"tags":["행정동 (Region)"],"summary":"내 전국 탐험률 재료 (분자·분모)","description":"도감·프로필 헤더의 \"전체 지도 N% 탐험\" 재료. 내가 점령한 격자 수(전국 합)와 전국 격자 총수를 반올림 없는 원값 정수 2개로 반환한다. 비율·표시 자릿수·100 상한은 화면이 min(100, 분자/분모 × 100) 으로 계산한다. 수집이 없어도 오류가 아니라 분자 0. 분모가 0 이면 기준 데이터 미적재 상태라 화면은 비율을 그리지 않는다.","operationId":"getNationalStat","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRegionNationalStatResponseDto"}}}}}}},"/api/regions/stats/by-point":{"get":{"tags":["행정동 (Region)"],"summary":"현재 위치 행정동 탐험률 (좌표 → 수집률)","description":"도감 갤러리 진입 초기값. 현재 위치 좌표가 속한 행정동 1건의 내 수집률을 반환한다. 그 행정동에 수집이 없어도 0% 로 합성해 반환하고, 어떤 행정동에도 안 속하면(바다·국외) 404 가 아니라 200 + data null. 서비스 범위 밖 좌표는 400(6400).","operationId":"getStatByPoint","parameters":[{"name":"lat","in":"query","description":"위도","required":true,"schema":{"type":"number","format":"double"},"example":37.4979},{"name":"lng","in":"query","description":"경도","required":true,"schema":{"type":"number","format":"double"},"example":127.0276}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRegionStatResponseDto"}}}}}}},"/api/regions/stats/by-grid":{"get":{"tags":["행정동 (Region)"],"summary":"격자 중심 행정동 탐험률 (격자 클릭 → 수집률)","description":"클릭한 격자의 중심점이 속한 행정동 1건의 내 수집률을 반환한다. 귀속 축이 수집률 집계(MSG-155)와 같아 탐험률·라벨이 일치한다. 중심점이 어떤 행정동에도 안 속하거나 gridId 형식이 이상하면 200 + data null(별도 에러 코드 없음).","operationId":"getStatByGrid","parameters":[{"name":"gridId","in":"query","description":"격자 ID \"{grid_y}_{grid_x}\"","required":true,"schema":{"type":"string"},"example":"19422_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRegionStatResponseDto"}}}}}}},"/api/regions/reverse-geocode":{"get":{"tags":["행정동 (Region)"],"summary":"역지오코딩 (좌표 → 행정동)","description":"좌표를 포함하는 행정동 1건을 반환한다. 포함 행정동이 없으면(바다·국외) 404가 아니라 200 + data null. 서비스 좌표 범위(한국) 밖이면 400(6400).","operationId":"reverseGeocode","parameters":[{"name":"lat","in":"query","description":"위도","required":true,"schema":{"type":"number","format":"double"},"example":37.4979},{"name":"lng","in":"query","description":"경도","required":true,"schema":{"type":"number","format":"double"},"example":127.0276}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRegionResponseDto"}}}}}}},"/api/regions/explore":{"get":{"tags":["전역 탐색 (Region Explore)"],"summary":"전체 지역 리스트 조회","description":"전역 공개 콘텐츠가 있는 행정동을 20개씩 반환한다. 로그인 사용자가 직접 최근 업로드한 지역이 먼저 나오고 나머지는 격자 수 내림차순이다. hasNext가 true면 nextCursor를 다음 요청의 cursor에 그대로 전달한다.","operationId":"getExploreRegions","parameters":[{"name":"cursor","in":"query","description":"직전 응답의 nextCursor. 첫 페이지는 생략","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRegionExplorePageResponseDto"}}}}}}},"/api/regions/districts":{"get":{"tags":["행정동 (Region)"],"summary":"시군구 목록 (검색 지역 필터)","description":"검색 화면 \"전체 지역\" 목록용 시군구 전량. 이름·식별자와 그 구의 전체 격자 수를 준다. 격자 수는 사용자 무관 값이고 0 인 시군구는 빠진다. 정렬은 이름순, 같은 이름은 식별자순. 응답의 parentCode 는 /api/regions/stats 의 parentCode 로 그대로 이어 쓸 수 있다.","operationId":"getDistricts","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListRegionDistrictResponseDto"}}}}}}},"/api/notifications":{"get":{"tags":["알림 (Notification)"],"summary":"알림 목록 조회","description":"받은 알림을 최신순으로 한 페이지 반환한다. 최근 30일 이내 생성분만 보이고, 알림 설정을 꺼서 발송되지 않은 알림은 빠진다 — 전송률 상한이나 푸시 토큰 없음으로 발송되지 않은 알림은 보인다. 다음 페이지는 응답의 nextCursor 를 cursor 로 다시 넘긴다. 목록 조회는 읽음 상태를 바꾸지 않는다.","operationId":"getInbox","parameters":[{"name":"cursor","in":"query","description":"직전 응답의 nextCursor — 생략하면 첫 페이지","required":false,"schema":{"type":"integer","format":"int64"},"example":123},{"name":"size","in":"query","description":"페이지 크기 — 0 이하면 20, 50 초과면 50 으로 자른다","required":false,"schema":{"type":"integer","format":"int32","default":20},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoNotificationPageResponseDto"}}}}}}},"/api/notifications/unread-count":{"get":{"tags":["알림 (Notification)"],"summary":"안읽은 알림 개수 조회","description":"목록과 같은 노출 조건으로 안읽은 알림 수를 센다 — 없으면 0 이다.","operationId":"getUnreadCount","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoNotificationUnreadCountResponseDto"}}}}}}},"/api/notifications/preferences":{"get":{"tags":["알림 (Notification)"],"summary":"알림 설정 조회","description":"카테고리 8종(BADGE·HOTZONE·REMIND·VIDEO·WEEKLY·FRIEND·MISSION_NEARBY·EVENT) 전부의 수신 상태를 반환한다. 설정을 만진 적 없는 사용자는 전부 true 다 — opt-out 기본 전부 on. MODERATION 은 설정 대상이 아니라 목록에 없다 (수신 거부 불가).","operationId":"getPreferences","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoNotificationPreferenceResponseDto"}}}}}}},"/api/missions/{missionId}":{"get":{"tags":["미션 (Missions)"],"summary":"미션 상세 조회","description":"미션 ID 하나의 상세 — 미션 정보와 렌더 shape(목록과 같은 필드), 내 진행도와 스탬프 보유 여부, 이 미션에 올라온 전체 영상 개수, 코스라면 포토스팟별 방문 여부·영상 개수를 한 번에 반환한다. spotStats 는 shape.spots 와 같은 순서로 오고, 코스가 아니면 null 대신 빈 배열이다.\n\n기간 판정은 하지 않는다 — 기간이 끝난 미션도 행이 남아 있으면 조회되고, 영상 개수는 그 미션이 활성일 때 촬영된 것만 센다(미션 영상 목록 GET /api/missions/{missionId}/videos 의 실제 후보 수와 항상 같다). 존재하지 않는 미션 ID 는 404 + developCode 12404(MISSION_NOT_FOUND)다.\n\n비로그인으로도 조회된다(MSG-454). 이때 사용자별 값은 빠진다 — progress 는 키는 있고 값이 null 이며 spotStats[].visited 는 전부 false 다. 미션 정보·전체 영상 수·스팟별 영상 수는 로그인과 같다.","operationId":"getMissionDetail","parameters":[{"name":"missionId","in":"path","description":"미션 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":412}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoMissionDetailResponseDto"}}}}}}},"/api/missions/progress":{"get":{"tags":["미션 (Missions)"],"summary":"미션별 내 진행도 조회","description":"미션 id 여러 개의 내 진행도(채운 칸/목표 칸)와 스탬프 보유 여부를 한 번에 반환한다. 채운 칸은 스탬프 판정과 같은 술어로 센다 — 미션 기간 안에 촬영한 내 영상(삭제 제외)이 있는 격자 수다. 영상을 전부 지우면 진행도는 0으로 돌아가지만 스탬프는 비회수라 completed 는 남는다 — \"0/1 인데 완료\"가 정상 응답이다.\n\nmissionIds 가 없거나 비면 빈 배열이고(오류 아님), 존재하지 않는 id 는 응답에서 빠진다. 기간이 끝난 미션도 조회된다. 배열 순서는 missionId 오름차순으로 고정된다(요청 순서 미보존). 300개 초과는 400 + developCode 12403 으로 거절한다.","operationId":"getMyProgress","parameters":[{"name":"missionIds","in":"query","description":"미션 id 목록 — 콤마 구분 또는 반복 파라미터. 없거나 비면 빈 배열 응답, 300개 초과는 거절","required":false,"schema":{"type":"array","items":{"type":"integer","format":"int64"}},"example":"412,413"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListMissionProgressResponseDto"}}}}}}},"/api/missions/aggregation":{"get":{"tags":["미션 (Missions)"],"summary":"넓은 축척용 미션 행정 단위 집계 조회 (줌아웃)","description":"지도를 축소해 개별 핀을 그릴 수 없는 축척에서, bbox 안의 축제·팝업 미션을 행정 단위(동·구·시)로 묶어 지역 이름과 개수로 반환한다. 단위 전환 시점은 서버가 정하지 않으며 클라이언트가 화면 축척에 맞춰 unit 만 바꿔 부른다.\n\n항목마다 마커 식별 키(regionCode), 표시 이름, 대표 좌표, 미션 수, 그 묶음의 미션 id 목록이 온다. 대표 좌표는 묶음에 속한 미션 귀속점의 평균이라 마커가 실제 데이터 위에 선다. missionIds 는 묶음 마커를 눌러 줌인한 뒤 개별 조회(GET /api/missions/active) 결과와 교집합을 내 목록을 좁히는 재료다 — 카드 재료는 개별 조회 응답에 있다.\n\n미션이 속한 격자 사각형이 아니라 그 사각형 중앙의 귀속점이 bbox 안인지로 센다. 사각형이 화면에 걸쳤지만 중심이 밖인 미션은 빠지며, 이 때문에 개별 조회와 집계를 갈아타는 순간 마커 수가 미세하게 달라질 수 있다. 행정동이 판정되지 않은 미션은 제외가 아니라 regionCode·name 이 null 인 항목 하나로 묶여 마지막에 온다. 범위 안에 미션이 없으면 빈 배열이다.\n\nbbox span 상한은 단위별로 다르다(DONG 1도, SIGUNGU 4도, SIDO 10도 — 위도·경도 각 변에 따로 적용, 정확히 상한값은 허용). 초과 시 400 + developCode 12401, 좌표가 WGS84 범위를 벗어나거나 bbox 가 뒤집히면 12400, type 이 없거나 EVENT·POPUP 이 아니면 12402, unit 이 없거나 미지원 값이면 12405 다. 응답에 사용자별 값은 없다.","operationId":"getMissionAggregates","parameters":[{"name":"type","in":"query","description":"미션 종류 — EVENT(지역축제), POPUP(팝업스토어). 대소문자 무관","required":true,"schema":{"type":"string"},"example":"POPUP"},{"name":"unit","in":"query","description":"집계 단위 — DONG(동), SIGUNGU(시군구), SIDO(시도). 대소문자 무관","required":true,"schema":{"type":"string"},"example":"SIGUNGU"},{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.1},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":128.9},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.3},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":129.2}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListMissionRegionAggregateResponseDto"}}}}}}},"/api/missions/active":{"get":{"tags":["미션 (Missions)"],"summary":"뷰포트 내 활성 미션 목록 조회","description":"지도 화면 bbox(남서~북동 좌표) 안의, 고른 종류(type)의 활성 미션을 유형별 렌더 shape(코스=PATH·축제/팝업=BOX)로 반환한다. bbox span 상한은 0.5도로 위도·경도 각 변에 따로 적용된다(정확히 0.5도는 허용). 초과 시 잘라서 응답하지 않고 400 + developCode 12401(VIEWPORT_TOO_LARGE)로 거절한다. 클라이언트는 격자 개별 조회(GET /api/grids)를 멈추는 것과 같은 0.5도 지점에서 이 조회도 멈추고 확대 안내를 그린다.\n\n보이는 범위에 그 종류 미션이 없으면 실패가 아니라 빈 배열이다(뷰포트가 너무 넓은 12401 과 다른 상태). 한국 밖이지만 WGS84 정의역 안인 bbox 도 오류가 아니라 빈 배열이다. 응답에 사용자별 값은 없다 — 진행도는 GET /api/missions/progress 로 따로 받는다.","operationId":"getActiveMissionsInViewport","parameters":[{"name":"type","in":"query","description":"미션 종류 — EVENT(지역축제), POPUP(팝업스토어), COURSE(경로추천). 대소문자 무관","required":true,"schema":{"type":"string"},"example":"POPUP"},{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.5},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.0},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.55},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.05}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListMissionResponseDto"}}}}}}},"/api/hotzones":{"get":{"tags":["핫구역 (HotZone)"],"summary":"뷰포트 내 핫구역 조회","description":"지도 화면 bbox(남서~북동 좌표) 안의 핫구역을 핫스코어 내림차순으로 반환한다. 전국 상위 K(50)·최소 임계(3) 판정 후 뷰포트 필터 — 없으면 빈 목록이다.\n\n항목마다 표시 이름 재료가 함께 온다: zoneName이 null이면 regionName(행정동)이 표시 이름이다(폴백에는 칸 번호를 붙이지 않는다). 이름 때문에 마커마다 단건 조회를 돌릴 필요가 없다.","operationId":"getHotZones","parameters":[{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.5},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.0},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.55},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.05}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoHotZoneListResponseDto"}}}}}}},"/api/hotzones/aggregation":{"get":{"tags":["핫구역 (HotZone)"],"summary":"뷰포트 내 핫구역 행정 단위 집계 조회","description":"축소 화면용 — 뷰포트 안 핫구역을 행정 단위(동·구·시)로 묶어 지역 이름과 핫 격자 수로 반환한다. 묶음 대상은 개별 조회(GET /api/hotzones)와 완전히 같은 판정 집합이라 두 화면을 갈아타도 세는 대상이 달라지지 않는다.\n\n항목마다 gridIds 가 함께 온다 — 묶음 마커를 눌러 줌인한 뒤 개별 조회 결과와 교집합으로 목록을 좁히는 재료다. count 는 핫 격자 수이고 핫스코어 합산이 아니다. 행정동이 판정되지 않은 격자는 제외가 아니라 regionCode·name 이 null 인 항목 하나로 묶여 마지막에 온다. 범위 안에 핫 격자가 없으면 빈 배열이다.\n\nbbox span 상한은 단위별로 다르다(DONG 1도, SIGUNGU 4도, SIDO 10도 — 위도·경도 각 변에 따로 적용, 정확히 상한값은 허용). 초과 시 400 + developCode 8401, 좌표가 WGS84 범위를 벗어나거나 bbox 가 누락·뒤집히면 8400, unit 이 없거나 미지원 값이면 8405 다. 응답에 사용자별 값은 없다.","operationId":"getHotZoneAggregates","parameters":[{"name":"unit","in":"query","description":"집계 단위 — DONG(동), SIGUNGU(시군구), SIDO(시도). 대소문자 무관","required":true,"schema":{"type":"string"},"example":"SIGUNGU"},{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.1},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":128.9},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.3},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":129.2}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListHotZoneRegionAggregateResponseDto"}}}}}}},"/api/grids":{"get":{"tags":["격자 (Grid)"],"summary":"뷰포트 내 색칠 격자 조회 (커서 페이지네이션)","description":"지도 화면 bbox(남서~북동 좌표) 안에서 내가 점령한 격자를 (grid_y, grid_x) 오름차순으로 반환한다. 응답의 nextCursor를 다음 요청 cursor에 넣어 이어서 조회한다. bbox span 상한은 0.5도로 위도·경도 각 변에 따로 적용된다(정확히 0.5도는 허용). 초과 시 잘라서 응답하지 않고 400 + developCode 4402(VIEWPORT_TOO_LARGE)로 거절한다.\n\n항목마다 표시 이름 재료가 함께 온다: zoneName이 null이면 regionName(행정동)이 표시 이름이다(폴백에는 칸 번호를 붙이지 않는다). 이름 때문에 다른 API를 더 호출할 필요가 없다.","operationId":"getOccupiedInViewport","parameters":[{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.5},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.0},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.55},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.05},{"name":"cursor","in":"query","description":"다음 페이지 커서 (직전 응답의 nextCursor). 첫 페이지는 생략","required":false,"schema":{"type":"string"},"example":"MTk0MjJfOTU4Mg=="},{"name":"size","in":"query","description":"페이지 크기 (기본 1000, 최대 5000)","required":false,"schema":{"type":"integer","format":"int32","default":1000},"example":1000}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoOccupiedGridPageResponseDto"}}}}}}},"/api/grids/{gridId}":{"get":{"tags":["격자 (Grid)"],"summary":"단일 격자 색칠 상태 조회","description":"특정 격자를 내가 점령(색칠)했는지와 내 영상 수를 반환한다. 미점령 격자도 404가 아니라 occupied=false로 응답한다.\n\n표시 이름 재료가 함께 온다: zoneName이 null이면 regionName(행정동)이 표시 이름이다(폴백에는 칸 번호를 붙이지 않는다). regionName은 아직 아무도 영상을 올리지 않은 격자에도 실리고, 어느 행정동에도 속하지 않거나 서비스 범위(한국) 밖인 격자면 null이다(에러가 아니다).","operationId":"getCell","parameters":[{"name":"gridId","in":"path","description":"격자 ID (\"{grid_y}_{grid_x}\" 포맷)","required":true,"schema":{"type":"string"},"example":"19422_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoGridCellResponseDto"}}}}}}},"/api/grids/{gridId}/videos":{"get":{"tags":["격자 상세 (Grid Videos)"],"summary":"격자 전역 영상 목록 조회","description":"그 격자에 쌓인 공개(PUBLIC)·READY 영상을 전역(본인·타인 포함)에서 조회수(viewCount) → 최신(createdAt) 순으로 페이지 조회한다. 비공개·삭제·인코딩 미완 영상은 본인 것이라도 제외한다. 첫 요청은 cursor 없이 부르고, hasNext 가 true 면 응답의 nextCursor 를 다음 요청 cursor 로 넘기면 이어진다. 무효 커서는 400(INVALID_CURSOR)이고, size 는 1~50 밖이면 클램프된다. 후보가 없거나 존재하지 않는 gridId 는 빈 페이지다. 썸네일은 presigned GET URL 로 내려준다.","operationId":"getGridGlobalVideos","parameters":[{"name":"gridId","in":"path","description":"격자 ID","required":true,"schema":{"type":"string"},"example":"19422_9582"},{"name":"cursor","in":"query","description":"직전 응답의 nextCursor (opaque). 생략하면 첫 페이지","required":false,"schema":{"type":"string"}},{"name":"size","in":"query","description":"페이지 크기 (1~50, 기본 20)","required":false,"schema":{"type":"integer","format":"int32","default":20}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoGridVideoPageResponseDto"}}}}}}},"/api/grids/{gridId}/my-videos":{"get":{"tags":["격자 상세 (Grid Videos)"],"summary":"격자별 내 영상 리스트 조회","description":"로그인 사용자가 해당 격자에 올린 본인 영상을 최근 업로드 순(createdAt DESC)으로 반환한다. 미점령·타인만 점령한 격자·존재하지 않는 gridId 는 빈 배열이다. 썸네일은 presigned GET URL 로 내려주며 READY 이전이면 null 이다.","operationId":"getGridVideos","parameters":[{"name":"gridId","in":"path","description":"격자 ID","required":true,"schema":{"type":"string"},"example":"19422_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListGridVideoResponseDto"}}}}}}},"/api/grids/{gridId}/missions":{"get":{"tags":["미션 (Missions)"],"summary":"격자가 대표 격자인 미션 조회","description":"지도에서 누른 격자가 어느 축제·팝업 미션의 자리인지 되짚는다. 미션 경유로 올린 영상은 그 미션의 대표 격자 한 칸에만 저장되므로, 영상이 모인 칸을 눌러 무슨 미션이었는지 확인하는 경로다.\n\n기간 필터가 없다 — 끝난 축제도 담긴다. 진행 중인지 시작 전인지 끝났는지는 startAt·endAt 을 서버 시각과 견주어 화면이 판정한다. 배열 첫 항목이 화면 진입 기본값이 되도록 진행 중 → 시작 전(임박한 순) → 종료(최근 종료 순)로 정렬한다.\n\n판정 범위(축제 9×9)에만 걸친 격자는 나오지 않는다 — 나오는 것은 영상이 모인 자리로 지목된 미션뿐이다. 어떤 미션의 대표 격자도 아닌 격자와 격자 형식이 아닌 문자열은 오류가 아니라 빈 배열이다. videoCount 는 미션 상세의 videoCount 와 같은 술어라 두 화면의 숫자가 어긋나지 않는다. 비로그인으로도 조회할 수 있다.","operationId":"getMissionsByGrid","parameters":[{"name":"gridId","in":"path","description":"격자 id — \"{gridY}_{gridX}\" 포맷","required":true,"schema":{"type":"string"},"example":"19443_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListGridMissionResponseDto"}}}}}}},"/api/grids/{gridId}/hourly-uploads":{"get":{"tags":["격자 상세 (Grid Videos)"],"summary":"격자 전역 시간대 분포 조회","description":"그 격자의 공개(PUBLIC)·READY 영상이 업로드된 시간대 분포를 KST 0시부터 23시까지 24구간 개수로 반환한다. 세는 대상은 전역 영상 목록(/videos)과 같아 카드에 보이는 영상만 세어진다 — 비공개·삭제·인코딩 미완 영상은 본인 것이라도 빠진다. 집계 구간은 전체 누적이며, 응답의 hours 는 항상 24개·hour 오름차순이라 빈 시간대도 count 0 으로 실린다. 공개 영상이 없는 격자·존재하지 않는 gridId 도 전 구간 0 인 정상 응답이다.","operationId":"getGridHourlyUploads","parameters":[{"name":"gridId","in":"path","description":"격자 ID","required":true,"schema":{"type":"string"},"example":"19422_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoGridHourlyUploadResponseDto"}}}}}}},"/api/grids/{gridId}/event-locations":{"get":{"tags":["행사 (Events)"],"summary":"격자가 속한 행사 위치 조회","description":"지도에서 누른 격자가 어느 행사 위치에 속하는지 해석한다. 영상은 위치의 대표 격자 하나에만 저장되므로, 영역 안 아무 격자나 눌러도 같은 위치가 나오는 이 역조회가 위치별 영상 피드로 들어가는 유일한 경로다.\n\n같은 장소에서 행사가 여러 번 열렸으면 회차마다 한 항목씩 배열로 온다. 배열 첫 항목이 화면 진입 기본값이 되도록 진행 중 → 예정 → 업로드 유예 → 아카이브 순으로 정렬하며, 예정끼리는 임박한 순, 나머지는 최근 순이다. 아직 노출 기간 전인 예정 회차는 배열에 담기지 않는다.\n\n어떤 행사 위치에도 속하지 않는 격자와 격자 형식이 아닌 문자열은 오류가 아니라 빈 배열이다. 비로그인으로도 조회할 수 있다.","operationId":"getEventLocationsByGrid","parameters":[{"name":"gridId","in":"path","description":"격자 id — \"{gridY}_{gridX}\" 포맷","required":true,"schema":{"type":"string"},"example":"19443_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListGridEventLocationResponseDto"}}}}}}},"/api/grids/{gridId}/cover":{"get":{"tags":["격자 상세 (Grid Videos)"],"summary":"격자 전역 대표 영상 조회","description":"그 격자를 전역에서 대표하는 영상 1건을 반환한다. 공개(PUBLIC)·READY 영상 중 조회수(view_count) → 최신(createdAt) 순으로 뽑으며, 본인·타인 영상 모두 후보다. 비공개·삭제·인코딩 미완 영상은 제외한다. 후보가 없으면(미점령·비공개만·존재하지 않는 gridId) data 는 null 이다. 썸네일은 presigned GET URL 로 내려준다.","operationId":"getGridCover","parameters":[{"name":"gridId","in":"path","description":"격자 ID","required":true,"schema":{"type":"string"},"example":"19422_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoGridCoverVideoResponseDto"}}}}}}},"/api/grids/aggregation":{"get":{"tags":["격자 (Grid)"],"summary":"뷰포트 내 색칠 격자 행정 단위 집계 조회 (줌아웃)","description":"응답 data는 {currentRegion, items} 객체다. currentRegion은 뷰포트 중심이 속한 행정동의 이름과 그 동 전체에서 내가 점령한 격자 수·영상 수를 담는다. 화면 범위나 unit과 무관하며, 중심이 해상 또는 서비스 범위 밖일 때만 null이다.\n\nitems는 bbox 안에서 내가 점령한 격자를 행정 단위로 묶어 센 목록이다. 단위 전환 시점은 서버가 정하지 않으며 클라이언트가 화면 축척에 맞춰 unit만 바꿔 부른다. items가 비어 있어도 한국 내 중심점의 currentRegion은 이름과 0 집계를 독립적으로 담는다.\n\n항목마다 마커 식별 키(regionCode), 표시 이름, 대표 좌표, 격자 수가 온다. 대표 좌표는 그 묶음에 속한 점령 격자 중심의 평균이라 마커가 실제 데이터 위에 선다. 어느 단위로 묶어도, 항목을 더 묶어 합산해도 같은 bbox 개별 격자 조회의 총 개수와 일치한다.\n\n행정동이 판정되지 않은 격자(해상 등)는 제외가 아니라 regionCode·name 이 null 인 항목 하나로 묶여 온다. 점령 격자가 없으면 빈 배열이다.\n\nbbox span 상한은 단위별로 다르다(DONG 1도, SIGUNGU 4도, SIDO 10도 — 위도·경도 각 변에 따로 적용). 초과 시 400 + developCode 4402, 좌표가 WGS84 범위를 벗어나거나 bbox 가 뒤집히면 4401, unit 이 없거나 미지원 값이면 4405 다.","operationId":"getOccupiedAggregatesInViewport","parameters":[{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.1},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":128.9},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.3},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":129.2},{"name":"unit","in":"query","description":"집계 단위 — DONG(동), SIGUNGU(시군구), SIDO(시도). 대소문자 무관","required":true,"schema":{"type":"string"},"example":"DONG"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoGridAggregationResponseDto"}}}}}}},"/api/friends":{"get":{"tags":["친구 (Friend)"],"summary":"친구 목록 조회","description":"수락된 친구 전체를 반환한다 — 누가 먼저 요청했는지와 무관하다. 기본 정렬은 친구가 된 시각 내림차순이고 sort=nickname 이면 닉네임순이다. 친구가 없으면 빈 배열.","operationId":"getFriends","parameters":[{"name":"sort","in":"query","description":"정렬 기준 — recent(기본, 친구가 된 시각 내림차순) 또는 nickname","required":false,"schema":{"type":"string"},"example":"nickname"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListFriendListItemResponseDto"}}}}}}},"/api/friends/{userId}/profile":{"get":{"tags":["친구 (Friend)"],"summary":"친구 프로필·도감 요약 조회","description":"친구의 프로필(닉네임·프로필 이미지·도감 색상)과 도감 요약(수집 격자 수·영상 총합·방문 동 수), 최근 수집 격자 최대 30개를 한 번에 반환한다. 도감 요약 수치는 그 친구가 자기 도감에서 보는 값과 같다. 썸네일은 그 격자에 재생 가능한 공개 영상이 있을 때만 붙는다. 친구가 아닌 사용자·본인·존재하지 않는 사용자 조회는 모두 같은 404 다.","operationId":"getFriendProfile","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoFriendProfileResponseDto"}}}}}}},"/api/friends/{userId}/grids":{"get":{"tags":["친구 (Friend)"],"summary":"친구 격자 뷰포트 조회","description":"지도 화면 bbox(남서~북동 좌표) 안에서 그 친구가 점령한 격자를 (grid_y, grid_x) 오름차순으로 반환한다. 응답 형상·검증 규칙·에러는 내 격자 조회(GET /api/grids)와 같다 — 응답의 nextCursor 를 다음 요청 cursor 에 넣어 이어 조회하고, bbox span 상한 0.5도는 위도·경도 각 변에 따로 적용되며 초과 시 400 + 4402(VIEWPORT_TOO_LARGE)로 거절된다. 격자 색상은 내려주지 않는다(FE 단일색 렌더). 친구가 아닌 사용자·본인·존재하지 않는 사용자 조회는 모두 같은 404 다.","operationId":"getFriendGrids","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.5},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.0},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.55},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.05},{"name":"cursor","in":"query","description":"다음 페이지 커서 (직전 응답의 nextCursor). 첫 페이지는 생략","required":false,"schema":{"type":"string"},"example":"MTk0MjJfOTU4Mg=="},{"name":"size","in":"query","description":"페이지 크기 (기본 1000, 최대 5000)","required":false,"schema":{"type":"integer","format":"int32","default":1000},"example":1000}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoOccupiedGridPageResponseDto"}}}}}}},"/api/friends/{userId}/grids/{gridId}/videos":{"get":{"tags":["친구 (Friend)"],"summary":"친구 격자 영상 목록 조회","description":"그 친구가 해당 격자에 올린 영상을 최근 업로드 순으로 반환한다. 친구에게 공개된 영상(전체 공개·친구만 보기)만 담기고 비공개 영상은 포함되지 않으며, 삭제·인코딩 미완 영상도 제외된다 — 목록의 영상은 모두 재생 조회로 바로 진입할 수 있다. 친구가 점령하지 않은 격자·존재하지 않는 gridId 는 빈 배열이다. 친구가 아닌 사용자·본인·존재하지 않는 사용자 조회는 모두 같은 404 다.","operationId":"getFriendGridVideos","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"gridId","in":"path","description":"격자 ID","required":true,"schema":{"type":"string"},"example":"19422_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListFriendGridVideoResponseDto"}}}}}}},"/api/friends/{userId}/grids/aggregation":{"get":{"tags":["친구 (Friend)"],"summary":"친구 격자 행정 단위 집계 조회 (줌아웃)","description":"지도를 축소한 시야에서 그 친구가 점령한 격자를 행정 단위로 묶어 센 목록을 페이지 없이 한 번에 반환한다. 파라미터·에러는 내 집계 조회(GET /api/grids/aggregation)와 같고, 묶음 항목의 공통 필드는 내 집계 조회와 같다. 다만 친구 응답은 currentRegion/items 겉면 없이 기존 배열로 반환한다. 단위 전환 시점은 서버가 정하지 않고 클라이언트가 화면 축척에 맞춰 unit 만 바꿔 부른다.\n\n항목마다 마커 식별 키(regionCode), 표시 이름, 대표 좌표, 격자 수가 온다. 행정동이 판정되지 않은 격자(해상 등)는 제외가 아니라 regionCode·name 이 null 인 항목 하나로 묶여 오고, 그 친구가 점령한 격자가 없으면 빈 배열이다.\n\nbbox span 상한은 단위별로 다르다(DONG 1도, SIGUNGU 4도, SIDO 10도 — 위도·경도 각 변에 따로 적용). 초과 시 400 + developCode 4402, bbox 가 뒤집히거나 파라미터가 빠지면 4401, unit 이 없거나 미지원 값이면 4405 다. 친구가 아닌 사용자·본인·존재하지 않는 사용자 조회는 모두 같은 404 다.","operationId":"getFriendGridAggregates","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.1},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":128.9},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.3},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":129.2},{"name":"unit","in":"query","description":"집계 단위 — DONG(동), SIGUNGU(시군구), SIDO(시도). 대소문자 무관","required":true,"schema":{"type":"string"},"example":"DONG"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListRegionAggregateResponseDto"}}}}}}},"/api/friends/requests/received":{"get":{"tags":["친구 (Friend)"],"summary":"받은 친구 요청 목록","description":"내가 수신자인 대기 중 요청을 최신순으로 반환한다. 항목의 requesterId 를 수락/거절 경로 변수로 그대로 쓴다.","operationId":"getReceivedRequests","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListReceivedFriendRequestResponseDto"}}}}}}},"/api/friends/preview":{"get":{"tags":["친구 (Friend)"],"summary":"친구 코드 미리보기","description":"요청을 보내기 전 확인 화면용 — 코드 소유자의 닉네임만 반환한다. 요청 가능 여부 검증(자기 자신·중복 등)은 요청 API 가 수행한다.","operationId":"preview","parameters":[{"name":"code","in":"query","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoFriendPreviewResponseDto"}}}}}}},"/api/friends/code":{"get":{"tags":["친구 (Friend)"],"summary":"내 친구 코드 조회","description":"가입 시 자동 부여된 고정 8자 코드를 반환한다. 상대에게 임의 채널(카톡 등)로 공유하면 상대가 이 코드로 친구 요청을 보낼 수 있다. 재발급 없음.","operationId":"getMyFriendCode","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoFriendCodeResponseDto"}}}}}}},"/api/event-videos/{videoId}":{"get":{"tags":["행사 (Events)"],"summary":"행사 영상 상세 조회","description":"영상 하나의 재생본 presigned GET URL 과 표시 재료를 돌려준다. 소속 행사 회차·위치·대표 격자와 그 표시명 재료가 함께 담겨, 상세 화면이 추가 호출 없이 위치줄을 그린다.\n\n피드에 보이는 영상만 열린다 — 삭제·블라인드·비공개·처리 미완료 영상은 올린 본인에게도 404 + developCode 13406 이다(본인 영상 확인은 GET /api/videos/{videoId}). 행사 영상이 아닌 영상 id 도 같은 404 다.\n\ninteractionLocked 는 아카이브 전환(행사 종료 + 30일)부터 true 이며 댓글·도움돼요 입력 UI 를 비활성화하는 재료다(기존 수는 계속 표시. 유예 기간에는 반응을 계속 남길 수 있다). 재생 URL 을 발급받은 타인 조회는 조회수를 올린다 — 비로그인 조회도 포함이고 올린 본인은 제외다.","operationId":"getVideoDetail","parameters":[{"name":"videoId","in":"path","description":"영상 id","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventVideoDetailResponseDto"}}}}}}},"/api/event-occurrences":{"get":{"tags":["행사 (Events)"],"summary":"뷰포트 내 행사 회차 목록 조회","description":"지도 화면 bbox(남서~북동 좌표) 안에 노출 영역이 걸친 행사 회차를 반환한다. 담기는 것은 진행 중이거나, 시작 2주 전부터의 노출 기간에 든 예정 회차뿐이다 — 종료된 행사(업로드 유예·아카이브)는 칩에 담기지 않고 상세·격자 역조회로만 접근한다. 아직 노출 기간 전인 예정 회차는 존재 자체를 숨긴다.\n\n정렬은 시 이름 → 시작일 → 회차 id 오름차순이라, 시 칩 아래에 그 시의 행사 칩을 나열하는 화면이 매 요청 같은 순서를 받는다. 보이는 범위에 행사가 없으면 실패가 아니라 빈 배열이다.\n\nbbox span 상한은 0.5도로 위도·경도 각 변에 따로 적용된다(정확히 0.5도는 허용). 초과 시 400 + developCode 13401, 좌표가 WGS84 범위를 벗어나거나 bbox 가 뒤집히거나 파라미터가 빠지면 13400 이다. D-day 는 startsAt 을 KST 로 읽어 클라이언트가 계산한다.","operationId":"getOccurrencesInViewport","parameters":[{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.1},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":128.9},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.2},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":129.1}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListEventOccurrenceChipResponseDto"}}}}}}},"/api/event-occurrences/{occurrenceId}":{"get":{"tags":["행사 (Events)"],"summary":"행사 회차 상세 조회","description":"행사방 헤더 재료 — 행사명, 기간, 업로드 마감(종료 30일 후), 서버 시각 기준 상태, 알림 구독 여부, 같은 시리즈의 지난 회차 목록이다. 상태는 저장값이 아니라 요청 시점 계산이며 경계 정각은 다음 상태에 속한다(종료 정각부터 UPLOAD_GRACE).\n\n지난 회차는 최신순이고 예정 회차는 담기지 않는다. 그 회차의 위치·영상은 회차 id 로 위치 목록을 다시 부르면 되므로 회차 간 데이터가 섞이지 않는다. 알림 구독 여부는 구독을 켰으면서 회차가 예정이거나 진행 중일 때만 true 다 — 비로그인 열람과 종료된 회차는 false 다.\n\n존재하지 않는 회차와 아직 노출 기간 전인 예정 회차는 똑같이 404 + developCode 13404 다 — 노출 전 행사의 존재를 id 대입으로 알아낼 수 없다.","operationId":"getOccurrenceDetail","parameters":[{"name":"occurrenceId","in":"path","description":"행사 회차 id","required":true,"schema":{"type":"integer","format":"int64"},"example":12}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventOccurrenceDetailResponseDto"}}}}}}},"/api/event-occurrences/{occurrenceId}/viewer-count":{"get":{"tags":["행사방 (Event)"],"summary":"현재 열람 인원 조회","description":"viewerCount 0 은 아무도 없음(표시), null 은 캐시 장애(숨김)다. 응답이 사용자 무관이라 인증 없이 호출할 수 있다.","operationId":"getViewerCount","parameters":[{"name":"occurrenceId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventViewerCountResponseDto"}}}}}}},"/api/event-occurrences/{occurrenceId}/locations":{"get":{"tags":["행사 (Events)"],"summary":"행사 회차의 위치 목록 조회","description":"회차에 속한 행사 위치(팝업·체험존·퍼레이드 등)와 각 위치의 격자 영역, 대표 격자, 표시명 재료, 영상 수를 반환한다. 영상 수는 집계 테이블 없이 조회 시점에 세며, 위치별 영상 피드에 실제로 보이는 영상만 센다(삭제·비공개·처리 미완료 제외).\n\ngridIds 는 화면에서 영역을 채색하는 재료이고 영상은 그중 representativeGridId 하나에만 붙는다. 표시명은 대표 격자 기준으로 `zoneName + \" \" + zoneCell`, 구역 밖이면 regionName 을 쓴다. 정렬은 표시 순서 → 위치 id 오름차순이다. 위치가 없으면 빈 배열이고, 존재하지 않는 회차와 노출 기간 전인 예정 회차는 404 + developCode 13404 다.","operationId":"getLocations","parameters":[{"name":"occurrenceId","in":"path","description":"행사 회차 id","required":true,"schema":{"type":"integer","format":"int64"},"example":12}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListEventLocationResponseDto"}}}}}}},"/api/collections/videos":{"get":{"tags":["도감 (Collection)"],"summary":"동 단위 내 영상 조회","description":"행정동(regionCode) 격자들에 올린 로그인 사용자의 영상을 created_at 내림차순으로 반환한다(무커서). regionCode 는 by-grid 응답의 regionCode 를 그대로 넘긴다. 귀속은 격자 축이라 영상 좌표가 옆 동이어도 격자 소속 행정동 기준으로 포함된다. 내 도감이라 PRIVATE·인코딩 중 영상도 포함하며(status ACTIVE 만), 그 행정동에 내 영상이 없거나 미존재 regionCode 면 에러 없이 빈 배열을 받는다.","operationId":"getRegionVideos","parameters":[{"name":"regionCode","in":"query","description":"행정동 코드 — by-grid 응답의 regionCode 를 그대로 전달","required":true,"schema":{"type":"string"},"example":1168051500}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListRegionVideoResponseDto"}}}}}}},"/api/collections/upload-history":{"get":{"tags":["도감 (Collection)"],"summary":"날짜별 업로드 기록 조회","description":"로그인 사용자 본인의 업로드를 KST 날짜로 접어, 업로드가 있었던 날과 그날의 건수를 날짜 오름차순으로 반환한다(잔디 재료 — 빈 날은 항목 없음, 빈 칸 채우기는 FE 몫). 삭제·블라인드된 영상의 업로드도 센다. 업로드 0건 사용자는 에러 없이 빈 배열을 받는다.","operationId":"getUploadHistory","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListUploadHistoryResponseDto"}}}}}}},"/api/collections/summary":{"get":{"tags":["도감 (Collection)"],"summary":"개인 도감 요약 조회","description":"로그인 사용자의 점령한 격자 수·올린 영상 총합·방문한 행정동 수에 더해 현재 스트릭·최장 스트릭·획득 뱃지 수를 한 번에 반환한다. 현재 스트릭은 마지막 기록이 KST 그제 이전이면 0이다. 업로드 경험 0 사용자도 에러 없이 여섯 값이 모두 0으로 응답한다.","operationId":"getSummary","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoCollectionSummaryResponseDto"}}}}}}},"/api/collections/grids":{"get":{"tags":["도감 (Collection)"],"summary":"갤러리 격자 목록 조회","description":"로그인 사용자가 수집한 격자를 카드로 반환한다(무커서). 파라미터를 모두 생략하면 전국을 first_collected_at 내림차순 최대 30개로 준다(기존 계약). regionCode 를 주면 그 행정동에 속한 내 격자만 나가며, 귀속은 격자 축이라 영상 좌표가 옆 동이어도 격자 소속 행정동 기준으로 잡힌다. 각 항목은 gridId·gridY/gridX·수집/방문 시각·영상 수·cover 영상 ID·cover 썸네일 URL·cover 길이(초)를 담는다. 내 격자가 없거나 미존재 regionCode 면 에러 없이 빈 배열을 받는다.","operationId":"getCollectionGrids","parameters":[{"name":"regionCode","in":"query","description":"행정동 코드 — 생략하면 전국. by-grid 응답의 regionCode 를 그대로 전달","required":false,"schema":{"type":"string"},"example":1168051500},{"name":"sort","in":"query","description":"정렬 축 — COLLECTED(수집 시각순, 기본) 또는 UPLOADED(최신 업로드순)","required":false,"schema":{"type":"string","default":"COLLECTED","enum":["COLLECTED","UPLOADED"]}},{"name":"limit","in":"query","description":"카드 수 상한 — 지도 홈 패널은 20 (SRS FR-MAP-10). 생략하면 regionCode 없을 때 30, regionCode 있을 때 그 동네 전부. 1 미만은 1 로 보정한다","required":false,"schema":{"type":"integer","format":"int32"},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListCollectionGridResponseDto"}}}}}}},"/api/badges":{"get":{"tags":["뱃지 (Badge)"],"summary":"내 뱃지 전체 목록","description":"시딩된 뱃지를 내 획득 상태와 함께 시딩 순(badges.id 오름차순)으로 반환한다. 은퇴 뱃지(retired_at 있음)는 획득자에게만 보이고 미획득자 목록에서는 빠진다 — 그래서 사용자마다 행 수가 다를 수 있다. 미획득 행은 earned false·earnedAt null·isNew false·featuredRank null. 이번 응답에 노출된 미확인(새 뱃지) 행은 자동으로 확인 처리되어 다음 조회부터 isNew false 가 된다.","operationId":"findMyBadges","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListMyBadgeResponseDto"}}}}}}},"/api/auth/oauth/kakao/authorize":{"get":{"tags":["인증 (Auth)"],"summary":"카카오 로그인 시작 (인가 진입점)","description":"웹 로그인의 시작점이다. 클라이언트는 이 URL 로 이동하기만 하면 된다(location.href). 서버가 카카오 인가 URL(client_id·response_type=code·scope=openid·nonce 포함)을 조립해 302 로 보내면서 같은 응답에 OAUTH_NONCE 쿠키(HttpOnly, 10분)를 심는다. 그래서 scope=openid 누락이나 nonce 누락이 구조적으로 불가능하고, REST API 키가 클라이언트 코드로 나갈 일도 없다. 응답은 리다이렉트라 공통 응답 포맷을 쓰지 않는다.","operationId":"redirectToKakaoAuthorize","parameters":[{"name":"redirectUri","in":"query","description":"카카오 콜백 URI. 콘솔 등록값과 정확히 일치해야 한다(검증 주체는 카카오).","required":true,"schema":{"type":"string"},"example":"http://localhost:5173/oauth/kakao/callback"},{"name":"state","in":"query","description":"콜백 위조 검증용 난수. 서버는 손대지 않고 인가 URL 에 그대로 전달한다.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/admin/videos/{videoId}":{"get":{"tags":["관리자 신고 처리 (Admin Report)"],"summary":"관리자 단건 영상 확인","description":"신고 판단용으로 영상 하나를 확인한다 — 공개범위와 상태(BLINDED 포함)를 무시하고 요청 시점에 재생·썸네일 presigned URL 을 발급하며, 조회수를 올리지 않는다. 처리 상태가 READY 가 아니면 playbackUrl 과 expiresInSec 은 null 이다. 없는 영상과 삭제된 영상은 404(3404) 다.","operationId":"getVideoForReview","parameters":[{"name":"videoId","in":"path","description":"확인할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminVideoReviewResponseDto"}}}}}}},"/api/admin/reports":{"get":{"tags":["관리자 신고 처리 (Admin Report)"],"summary":"신고 목록 조회","description":"상태 필터 기준으로 신고를 접수 최신순 페이지 단위로 조회한다. 기본은 미처리(PENDING) 신고다. 항목에 신고자·영상 소유자 닉네임과 영상 현재 상태가 함께 담겨 목록만으로 판단할 수 있다. 지원하지 않는 status 는 400(11420), page 음수나 size 범위(1~100) 밖은 400(11421) 이다. REVIEWING 은 유효한 값이지만 만드는 경로가 없어 항상 빈 목록이다.","operationId":"getReports","parameters":[{"name":"status","in":"query","description":"신고 상태 필터 (PENDING, REVIEWING, RESOLVED, REJECTED — 대소문자 무관)","required":false,"schema":{"type":"string","default":"PENDING"},"example":"PENDING"},{"name":"page","in":"query","description":"페이지 번호 (0부터)","required":false,"schema":{"type":"integer","format":"int32","default":0},"example":0},{"name":"size","in":"query","description":"페이지 크기 (1~100)","required":false,"schema":{"type":"integer","format":"int32","default":20},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminReportListResponseDto"}}}}}}},"/api/friends/{userId}":{"delete":{"tags":["친구 (Friend)"],"summary":"친구 삭제","description":"친구 관계를 해소한다. 어느 쪽이든 삭제할 수 있고 즉시 양쪽 모두에서 사라진다. 대기 중 요청은 대상이 아니다.","operationId":"deleteFriend","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"OK"}}}}},"components":{"schemas":{"VideoReplaceRequestDto":{"type":"object","description":"영상 교체 요청. 파일만 바꾸려면 좌표를 생략한다. 좌표를 보내면 기존과 같은 격자여야 하며 다르면 GRID_MISMATCH로 거부된다.","properties":{"s3Key":{"type":"string","description":"새로 업로드한 영상의 S3 객체 키","example":"videos/2026/07/new-uuid.mp4","minLength":1},"lat":{"type":["number","null"],"format":"double","description":"위도 (선택). lng와 함께 보내거나 둘 다 생략","example":37.5665},"lng":{"type":["number","null"],"format":"double","description":"경도 (선택). lat과 함께 보내거나 둘 다 생략","example":126.978},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초). 1~30초","example":15,"maximum":30,"minimum":1},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각","example":"2026-07-17T14:30:00Z"}},"required":["durationSec","recordedAt","s3Key"]},"ApiResponseDtoVideoReplaceResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/VideoReplaceResponseDto"}},"required":["data","developCode","message"]},"VideoReplaceResponseDto":{"type":"object","description":"영상 교체 응답. 교체 직후는 항상 재인코딩 대기(UPLOADED) 상태다.","properties":{"videoId":{"type":"integer","format":"int64","description":"교체된 영상 ID","example":1001},"processingStatus":{"type":"string","description":"영상 처리 상태 (교체 직후 UPLOADED)","example":"UPLOADED"}},"required":["processingStatus","videoId"]},"ProfileImageUpdateRequestDto":{"type":"object","description":"프로필 이미지 변경 확정 요청 (MSG-373)","properties":{"s3Key":{"type":"string","description":"presign 발급으로 받은 pending 키. 그 URL 로 업로드를 마친 뒤 그대로 전달한다.","example":"profiles/pending/42/3f0c1f2e-....jpg","minLength":1}},"required":["s3Key"]},"ApiResponseDtoUserProfileResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/UserProfileResponseDto"}},"required":["data","developCode","message"]},"UserProfileResponseDto":{"type":"object","description":"내 프로필 응답. 조회·닉네임 수정·프로필 이미지 변경·위치정보 동의 변경이 같은 형태를 반환한다.","properties":{"email":{"type":["string","null"],"description":"가입 이메일 — 이메일 가입 시 저장된 값. 카카오 가입은 이메일을 수집하지 않아 null (MSG-310)","example":"user@fillmap.dev"},"nickname":{"type":"string","description":"닉네임 — 카카오 로그인 시 카카오 닉네임이 자동 저장되며, 이후 수정 가능","example":"채우미"},"profileImageUrl":{"type":["string","null"],"description":"프로필 이미지 공개 URL — 미설정이면 null 이고 기본 프로필 표시는 FE 몫이다 (MSG-373)","example":"https://fillmap-video-dev.s3.ap-northeast-2.amazonaws.com/profiles/original/42/uuid.jpg"},"createdAt":{"type":"string","format":"date-time","description":"가입 시각 — DB 저장값(UTC) 그대로다. \"2026.01.12\" 같은 표기는 FE 몫 (MSG-373)","example":"2026-01-12T03:24:11Z"},"locationConsent":{"type":"boolean","description":"위치기반서비스 이용 동의 여부 — 가입 직후는 false 다. 마지막 변경 시각은 서버에만 두고 응답에 싣지 않는다 (MSG-402 §D-6)","example":false}},"required":["createdAt","email","locationConsent","nickname","profileImageUrl"]},"NicknameUpdateRequestDto":{"type":"object","description":"닉네임 수정 요청","properties":{"nickname":{"type":"string","description":"새 닉네임 (2~20자)","example":"채우미","maxLength":20,"minLength":2}},"required":["nickname"]},"MarketingConsentUpdateRequestDto":{"type":"object","description":"마케팅 정보 수신 동의 변경 요청","properties":{"consented":{"type":"boolean","description":"true 면 동의, false 면 철회","example":true}},"required":["consented"]},"ApiResponseDtoConsentStatusResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/ConsentStatusResponseDto"}},"required":["data","developCode","message"]},"ConsentStatusResponseDto":{"type":"object","description":"가입 약관 동의 상태. 조회·제출·마케팅 변경이 같은 형태를 반환한다.","properties":{"ageOver14":{"type":"boolean","description":"만 14세 이상 확인 여부 (필수). 자기 확인 체크 사실만 저장하며 생년월일은 수집하지 않는다","example":true},"serviceTerms":{"type":"boolean","description":"서비스 이용약관 동의 여부 (필수)","example":true},"privacyPolicy":{"type":"boolean","description":"개인정보 수집·이용 동의 여부 (필수)","example":true},"locationTerms":{"type":"boolean","description":"위치기반서비스 이용약관 동의 여부 (필수). 프로필 화면의 위치정보 사용 동의와 같은 한 값이며 철회할 수 없다 — 한 번 true 가 되면 되돌아가지 않는다","example":true},"marketing":{"type":"boolean","description":"마케팅 정보 수신 동의 여부 (선택). 가입 후에도 전용 API 로 켜고 끌 수 있다","example":false},"requiredCompleted":{"type":"boolean","description":"필수 4항목을 전부 동의했으면 true. false 면 클라이언트가 동의 게이트를 띄운다","example":true}},"required":["ageOver14","locationTerms","marketing","privacyPolicy","requiredCompleted","serviceTerms"]},"LocationConsentUpdateRequestDto":{"type":"object","description":"위치정보 사용 동의 켜기 요청","properties":{"consented":{"type":"boolean","description":"true 면 동의. 이 동의는 철회할 수 없어 false 는 1400 으로 거절된다","example":true}},"required":["consented"]},"ConsentSubmitRequestDto":{"type":"object","description":"가입 약관 동의 제출 요청. 필수 4항목은 true 여야 하고 마케팅만 선택이다.","properties":{"ageOver14":{"type":"boolean","description":"만 14세 이상 확인 (필수, true 만 허용)","example":true},"serviceTerms":{"type":"boolean","description":"서비스 이용약관 동의 (필수, true 만 허용)","example":true},"privacyPolicy":{"type":"boolean","description":"개인정보 수집·이용 동의 (필수, true 만 허용)","example":true},"locationTerms":{"type":"boolean","description":"위치기반서비스 이용약관 동의 (필수, true 만 허용)","example":true},"marketing":{"type":"boolean","description":"마케팅 정보 수신 동의 (선택). true·false 모두 유효하되 누락은 400 이다","example":false}},"required":["ageOver14","locationTerms","marketing","privacyPolicy","serviceTerms"]},"ApiResponseDtoEventVideoHelpfulResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventVideoHelpfulResponseDto"}},"required":["data","developCode","message"]},"EventVideoHelpfulResponseDto":{"type":"object","description":"행사 영상 도움돼요 변경 결과","properties":{"helpfulCount":{"type":"integer","format":"int64","description":"처리 후 현재 도움돼요 수","example":12},"helpfulByMe":{"type":"boolean","description":"내가 누른 상태인지","example":true}},"required":["helpfulByMe","helpfulCount"]},"EventNotificationUpdateRequestDto":{"type":"object","description":"행사 알림 구독 토글","properties":{"enabled":{"type":"boolean","description":"구독 여부 — true 면 ON, false 면 OFF","example":true}},"required":["enabled"]},"ApiResponseDtoEventNotificationResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventNotificationResponseDto"}},"required":["data","developCode","message"]},"EventNotificationResponseDto":{"type":"object","description":"행사 알림 구독 상태","properties":{"enabled":{"type":"boolean","description":"구독 여부 — 구독 행 존재이면서 회차가 예정·진행 중일 때만 true","example":true}},"required":["enabled"]},"FeaturedBadgeRequestDto":{"type":"object","description":"대표 뱃지 집합 교체 요청 — 배열 순서가 표시 순서, 빈 배열은 전부 해제","properties":{"badgeIds":{"type":"array","description":"대표로 지정할 뱃지 id 목록 (최대 2개, 순서 = 표시 순서)","example":[3,7],"items":{"type":"integer","format":"int64"},"maxItems":2,"minItems":0}},"required":["badgeIds"]},"ApiResponseDtoListFeaturedBadgeResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/FeaturedBadgeResponseDto"}}},"required":["data","developCode","message"]},"FeaturedBadgeResponseDto":{"type":"object","description":"적용된 대표 뱃지","properties":{"badgeId":{"type":"integer","format":"int64","description":"뱃지 ID","example":3},"code":{"type":"string","description":"뱃지 code","example":"EXPLORER_50"},"name":{"type":"string","description":"표시명","example":"탐험가 II"},"iconUrl":{"type":["string","null"],"description":"아이콘 URL (에셋 확정 전 null)","example":null},"rank":{"type":"integer","format":"int32","description":"표시 순서 (1·2)","example":1}},"required":["badgeId","code","iconUrl","name","rank"]},"VideoUploadRequestDto":{"type":"object","description":"S3 업로드 완료 후 영상 메타데이터 저장 요청","properties":{"s3Key":{"type":"string","description":"presigned 발급 때 받은 S3 객체 키","example":"videos/2026/07/uuid.mp4","minLength":1},"lat":{"type":"number","format":"double","description":"촬영 위치 위도 (격자 매핑에 사용)","example":37.5665},"lng":{"type":"number","format":"double","description":"촬영 위치 경도 (격자 매핑에 사용)","example":126.978},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초). 1~30초","example":15,"maximum":30,"minimum":1},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각","example":"2026-07-17T14:30:00Z"},"visibility":{"type":"string","description":"공개범위. PUBLIC, PRIVATE, FRIENDS 중 하나. 생략 시 PUBLIC","example":"PUBLIC"}},"required":["durationSec","lat","lng","recordedAt","s3Key"]},"ApiResponseDtoVideoUploadResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/VideoUploadResponseDto"}},"required":["data","developCode","message"]},"CompletedMissionResponseDto":{"type":"object","description":"이번 업로드로 완료된 미션 스탬프","properties":{"missionId":{"type":"integer","format":"int64","description":"미션 ID","example":3},"title":{"type":"string","description":"미션 제목","example":"성수 골목 코스"},"type":{"type":"string","description":"미션 유형 (COURSE/AREA/EVENT/THEME/CONTINUOUS)","example":"COURSE"}},"required":["missionId","title","type"]},"EarnedBadgeResponseDto":{"type":"object","description":"이번 행동으로 새로 획득한 뱃지","properties":{"badgeId":{"type":"integer","format":"int64","description":"뱃지 ID","example":1},"code":{"type":"string","description":"뱃지 code","example":"EXPLORER_1"},"name":{"type":"string","description":"표시명","example":"첫 발자국"},"description":{"type":["string","null"],"description":"설명 — badges.description 은 NULL 허용 컬럼이다","example":"첫 격자를 수집했어요"},"iconUrl":{"type":["string","null"],"description":"아이콘 URL (에셋 확정 전 null)","example":null}},"required":["badgeId","code","description","iconUrl","name"]},"VideoUploadResponseDto":{"type":"object","description":"영상 메타데이터 저장 응답","properties":{"videoId":{"type":"integer","format":"int64","description":"생성된 영상 ID","example":1001},"gridId":{"type":"string","description":"매핑된 격자 ID","example":"19422_9582"},"processingStatus":{"type":"string","description":"영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED)","example":"UPLOADED"},"occupied":{"type":"boolean","description":"이 업로드로 격자를 처음 점령(첫 방문)했는지 여부","example":true},"newBadges":{"type":"array","description":"이 업로드로 새로 획득한 뱃지 목록 — 없으면 빈 배열","items":{"$ref":"#/components/schemas/EarnedBadgeResponseDto"}},"completedMissions":{"type":"array","description":"이 업로드로 완료된 미션 스탬프 목록 — 없으면 빈 배열","items":{"$ref":"#/components/schemas/CompletedMissionResponseDto"}},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름 (예 \"서면\"). 구역 밖 격자면 null — 이때 라벨은 regionName 이다","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A 는 구역 북단, 열 1 은 서단). zoneName 과 항상 쌍이라 구역 밖이면 함께 null","example":"I-6"},"regionName":{"type":["string","null"],"description":"격자 중심점 행정동 이름 — 구역 밖 격자의 폴백 라벨. 무귀속(해상 등)이거나 미판정이면 null","example":"서울특별시 강남구 역삼1동"}},"required":["completedMissions","gridId","newBadges","occupied","processingStatus","regionName","videoId","zoneCell","zoneName"]},"ReportCreateRequestDto":{"type":"object","description":"영상 신고 접수 요청. 사유 5종 중 하나와 선택적 상세 설명.","properties":{"reason":{"type":"string","description":"신고 사유. INAPPROPRIATE, PRIVACY, SPAM, COPYRIGHT, OTHER 중 하나 (대소문자 무관)","example":"INAPPROPRIATE","minLength":1},"detail":{"type":"string","description":"상세 설명. OTHER 사유는 필수, 나머지 사유는 선택. 최대 500자","example":"타인의 얼굴이 그대로 찍혀 있습니다","maxLength":500,"minLength":0}},"required":["reason"]},"ApiResponseDtoReportCreateResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/ReportCreateResponseDto"}},"required":["data","developCode","message"]},"ReportCreateResponseDto":{"type":"object","description":"영상 신고 접수 응답.","properties":{"reportId":{"type":"integer","format":"int64","description":"접수된 신고 ID","example":17},"status":{"type":"string","description":"신고 처리 상태. 접수 직후라 항상 PENDING","example":"PENDING"}},"required":["reportId","status"]},"PresignedUrlRequestDto":{"type":"object","description":"S3 업로드용 presigned URL 발급 요청","properties":{"extension":{"type":"string","description":"영상 파일 확장자 (점 없이)","example":"mp4","minLength":1},"contentType":{"type":"string","description":"영상 MIME 타입","example":"video/mp4","minLength":1},"contentLength":{"type":"integer","format":"int64","description":"업로드할 파일 크기(바이트). 서버 상한 초과 시 거부","example":10485760},"purpose":{"type":"string","description":"발급 용도. 미지정(null)은 UPLOAD 와 동일. 하이라이트 선분석 원본은 HIGHLIGHT_PREVIEW 로 발급받아 전용 크기 상한(기본 2GiB)을 적용받는다","example":"HIGHLIGHT_PREVIEW","pattern":"UPLOAD|HIGHLIGHT_PREVIEW"}},"required":["contentLength","contentType","extension"]},"ApiResponseDtoPresignedUrlResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/PresignedUrlResponseDto"}},"required":["data","developCode","message"]},"PresignedUrlResponseDto":{"type":"object","description":"presigned URL 발급 응답. uploadUrl로 S3에 직접 PUT 업로드 후, s3Key로 메타데이터 저장(POST /api/videos)을 호출한다.","properties":{"uploadUrl":{"type":"string","description":"S3에 직접 PUT 업로드할 presigned URL","example":"https://bucket.s3.amazonaws.com/videos/..."},"s3Key":{"type":"string","description":"업로드 대상 S3 객체 키. 이후 메타데이터 저장 요청에 그대로 전달한다.","example":"videos/2026/07/uuid.mp4"},"expiresInSec":{"type":"integer","format":"int64","description":"presigned URL 유효 시간(초)","example":300}},"required":["expiresInSec","s3Key","uploadUrl"]},"HighlightPreviewRequestDto":{"type":"object","description":"하이라이트 선분석 요청 (MSG-351). 원본은 presign(purpose=HIGHLIGHT_PREVIEW)으로 먼저 올린다.","properties":{"s3Key":{"type":"string","description":"presign 으로 올린 원본의 pending 키. videos/pending/{내 userId}/ prefix 여야 한다","example":"videos/pending/42/550e8400-e29b-41d4-a716-446655440000.mp4","minLength":1}},"required":["s3Key"]},"ApiResponseDtoHighlightPreviewResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/HighlightPreviewResponseDto"}},"required":["data","developCode","message"]},"HighlightPreviewResponseDto":{"type":"object","description":"하이라이트 선분석 응답 (MSG-351). 결과는 저장되지 않는 임시 값이다 — 확정본의 하이라이트는 업로드 확정 후 블러 파이프라인이 따로 계산한다.","properties":{"highlights":{"type":"array","description":"[[시작초, 끝초], ...] 최대 3구간, 초는 소수점 둘째 자리. 배열 순서가 추천 우선순위(첫 요소가 최우선)다. 각 구간은 5초 이상이고 시작점끼리 5초 이상 벌어진다. 5초 미만 원본이거나 조건을 채우는 구간이 없으면 빈 배열 [] — 추천 없음이니 FE 는 추천 단계를 스킵한다","example":[[0.0,5.12],[10.0,16.4]],"items":{"type":"array","items":{"type":"number","format":"double"}}}},"required":["highlights"]},"ProfileImagePresignRequestDto":{"type":"object","description":"프로필 이미지 업로드용 presigned URL 발급 요청 (MSG-373)","properties":{"extension":{"type":"string","description":"이미지 파일 확장자 (점 없이). jpg, jpeg, png, webp — heic·heif 는 받지 않는다","example":"jpg","minLength":1},"contentType":{"type":"string","description":"이미지 MIME 타입. 확장자와 쌍이 맞아야 한다","example":"image/jpeg","minLength":1},"contentLength":{"type":"integer","format":"int64","description":"업로드할 파일 크기(바이트). 5MB 초과 시 거부","example":1048576}},"required":["contentLength","contentType","extension"]},"ApiResponseDtoProfileImagePresignResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/ProfileImagePresignResponseDto"}},"required":["data","developCode","message"]},"ProfileImagePresignResponseDto":{"type":"object","description":"프로필 이미지 presigned URL 발급 응답. uploadUrl 로 S3 에 직접 PUT 업로드한 뒤 s3Key 로 변경 확정(PUT /api/users/me/profile-image)을 호출한다.","properties":{"uploadUrl":{"type":"string","description":"S3 에 직접 PUT 업로드할 presigned URL","example":"https://bucket.s3.amazonaws.com/profiles/..."},"s3Key":{"type":"string","description":"업로드 대상 S3 객체 키. 변경 확정 요청에 그대로 전달한다.","example":"profiles/pending/42/3f0c1f2e-....jpg"},"expiresInSec":{"type":"integer","format":"int64","description":"presigned URL 유효 시간(초)","example":600}},"required":["expiresInSec","s3Key","uploadUrl"]},"OriginDto":{"type":"object","description":"출발 지점 좌표","properties":{"lat":{"type":"number","format":"double","description":"위도","example":35.115,"maximum":90.0,"minimum":-90.0},"lng":{"type":"number","format":"double","description":"경도","example":129.042,"maximum":180.0,"minimum":-180.0}},"required":["lat","lng"]},"RouteRecommendRequestDto":{"type":"object","description":"AI 경로 추천 요청","properties":{"text":{"type":"string","description":"하고 싶은 일 자연어 한 문장 (trim 후 1~500자)","example":"부산역 내려서 해운대에서 밥 먹고 축제도 보고 싶어","maxLength":500,"minLength":0},"viewport":{"$ref":"#/components/schemas/ViewportDto","description":"지금 보고 있는 지도 범위 (WGS84 사각형)"},"origin":{"$ref":"#/components/schemas/OriginDto","description":"출발 지점 좌표 (선택). 있으면 동선이 여기서 시작한다"}},"required":["text","viewport"]},"ViewportDto":{"type":"object","description":"WGS84 뷰포트 사각형","properties":{"minLat":{"type":"number","format":"double","description":"남서 위도","example":35.05},"minLng":{"type":"number","format":"double","description":"남서 경도","example":128.95},"maxLat":{"type":"number","format":"double","description":"북동 위도","example":35.25},"maxLng":{"type":"number","format":"double","description":"북동 경도","example":129.2}},"required":["maxLat","maxLng","minLat","minLng"]},"ApiResponseDtoRouteRecommendResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/RouteRecommendResponseDto"}},"required":["data","developCode","message"]},"MentionedAreaDto":{"type":"object","description":"언급 지역 신호 — 지도 이동(MOVE)·축소(ZOOM_OUT) 제안의 이름·중심·범위 재료","properties":{"name":{"type":"string","description":"지역의 정식 표기 — 행정구역 매칭 단위 토큰 또는 구역 통칭(zones.name)","example":"부산광역시"},"centerLat":{"type":"number","format":"double","description":"지역 중심 위도 (WGS84) — 행정구역은 경계 무게중심, 구역은 외접 사각형 중점","example":35.1985},"centerLng":{"type":"number","format":"double","description":"지역 중심 경도 (WGS84)","example":129.0538},"minLat":{"type":"number","format":"double","description":"외접 사각형 남단 위도 (WGS84)","example":35.0512},"minLng":{"type":"number","format":"double","description":"외접 사각형 서단 경도 (WGS84)","example":128.7602},"maxLat":{"type":"number","format":"double","description":"외접 사각형 북단 위도 (WGS84)","example":35.3891},"maxLng":{"type":"number","format":"double","description":"외접 사각형 동단 경도 (WGS84)","example":129.2723},"kind":{"type":"string","description":"신호 종류 — MOVE(뷰포트와 안 겹침, 이동 제안)·ZOOM_OUT(겹치지만 뚜렷이 좁음, 축소 제안)","example":"MOVE"}},"required":["centerLat","centerLng","kind","maxLat","maxLng","minLat","minLng","name"]},"RoutePointDto":{"type":"object","description":"추천 지점","properties":{"order":{"type":"integer","format":"int32","description":"방문 순서 (1부터 연속)","example":1},"name":{"type":"string","description":"지점 이름 (원문 그대로 — AI 로 보낼 때만 100자 절단)","example":"해운대 빛축제"},"kind":{"type":"string","description":"지점 종류 — MISSION_FESTIVAL·MISSION_POPUP·MISSION_COURSE·EVENT·PLACE. FE 마커 분기용","example":"MISSION_FESTIVAL"},"lat":{"type":"number","format":"double","description":"대표 좌표 위도 (WGS84)","example":35.1587},"lng":{"type":"number","format":"double","description":"대표 좌표 경도 (WGS84)","example":129.1604},"gridId":{"type":"string","description":"격자 ID — 대표 좌표를 GridEncoder 로 즉석 계산","example":"16941_11439"},"zoneName":{"type":["string","null"],"description":"표시명 구역 이름 (MSG-341). 구역 밖이면 zoneCell 과 쌍으로 null"},"zoneCell":{"type":["string","null"],"description":"표시명 구역 셀","example":"B-3"},"regionName":{"type":["string","null"],"description":"행정동 폴백 재료 (MSG-349 정책 동일). 무귀속이면 null"},"reason":{"type":"string","description":"추천 이유 한 줄 — AI explain 응답의 reasons 항목 그대로 (FR-ROUTE-05)"},"missionId":{"type":["integer","null"],"format":"int64","description":"미션 후보면 미션 id — FE 가 미션 상세로 잇는 데 쓴다"},"occurrenceId":{"type":["integer","null"],"format":"int64","description":"행사 후보면 회차 id"}},"required":["gridId","kind","lat","lng","missionId","name","occurrenceId","order","reason","regionName","zoneCell","zoneName"]},"RouteRecommendResponseDto":{"type":"object","description":"AI 경로 추천 응답","properties":{"points":{"type":"array","description":"방문 순서대로 정렬된 지점 목록 (최대 8개)","items":{"$ref":"#/components/schemas/RoutePointDto"}},"notice":{"type":["string","null"],"description":"후보 부족 안내 — 지점 3개 이상이면 null, 0~2개면 안내 문구"},"mentionedArea":{"anyOf":[{"$ref":"#/components/schemas/MentionedAreaDto"},{"type":"null"}],"description":"언급 지역 신호 (MSG-468) — 문장이 화면 밖 지역을 말했으면 이동·축소 제안 재료가 실린다. 무신호(지역 무언급·동명 다수·대조 실패·충분히 담김)가 기본값"}},"required":["mentionedArea","notice","points"]},"PushTokenRequestDto":{"type":"object","description":"FCM 푸시 토큰 등록/갱신 요청 — 같은 토큰 재등록은 충돌 없이 현재 계정으로 갱신된다","properties":{"fcmToken":{"type":"string","description":"FCM 디바이스 토큰 (push_tokens PK, 최대 512자)","example":"fcm-token-abc123","maxLength":512,"minLength":0},"platform":{"type":"string","description":"플랫폼 — IOS·ANDROID·WEB (대소문자 무시)","example":"WEB","minLength":1},"appVersion":{"type":"string","description":"앱 버전 (선택, 최대 20자)","example":"1.0.0","maxLength":20,"minLength":0}},"required":["fcmToken","platform"]},"MissionVideoUploadRequestDto":{"type":"object","description":"미션 경유 영상 업로드 확정 요청","properties":{"s3Key":{"type":"string","description":"presigned 발급 때 받은 S3 객체 키. 같은 키로 다시 보내면 멱등하게 처리된다","example":"videos/pending/42/6f1c1f0e-1d2b-4a5a-9f0e-2b3c4d5e6f70.mp4","minLength":1},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초). 1~30초","example":15,"maximum":30,"minimum":1},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각. 미래 시각은 거부되고(단말 시계 오차 5분 허용), 미션 기간 밖도 거부된다","example":"2026-10-06T12:30:00Z"}},"required":["durationSec","recordedAt","s3Key"]},"ApiResponseDtoMissionVideoUploadResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/MissionVideoUploadResponseDto"}},"required":["data","developCode","message"]},"MissionVideoUploadResponseDto":{"type":"object","description":"미션 경유 영상 업로드 확정 응답","properties":{"videoId":{"type":"integer","format":"int64","description":"생성된 영상 ID","example":1001},"gridId":{"type":"string","description":"서버가 정한 그 미션의 대표 격자 ID","example":"19422_9582"},"processingStatus":{"type":"string","description":"영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED)","example":"UPLOADED"},"occupied":{"type":"boolean","description":"이 업로드로 대표 격자를 처음 점령했는지 여부. 재시도 응답은 항상 false","example":true},"newBadges":{"type":"array","description":"이 업로드로 새로 획득한 뱃지 목록 — 없거나 재시도 응답이면 빈 배열","items":{"$ref":"#/components/schemas/EarnedBadgeResponseDto"}},"completedMissions":{"type":"array","description":"이 업로드로 새로 발급된 스탬프 — 이미 받았거나 재시도 응답이면 빈 배열","items":{"$ref":"#/components/schemas/CompletedMissionResponseDto"}}},"required":["completedMissions","gridId","newBadges","occupied","processingStatus","videoId"]},"FriendRequestCreateRequestDto":{"type":"object","description":"친구 요청 생성 요청","properties":{"friendCode":{"type":"string","description":"상대의 고정 친구 코드","example":"AB3DE7GH","minLength":1}},"required":["friendCode"]},"ApiResponseDtoFriendRequestCreateResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/FriendRequestCreateResponseDto"}},"required":["data","developCode","message"]},"FriendRequestCreateResponseDto":{"type":"object","description":"친구 요청 생성 응답","properties":{"status":{"type":"string","description":"PENDING = 요청이 등록돼 상대 수락 대기, ACCEPTED = 상대가 먼저 보낸 요청이 있어 즉시 친구 성립(자동 수락 — FR-8). FE 는 이 값으로 \"요청 보냄\"과 \"친구가 됐어요\" 화면을 구분한다.","enum":["PENDING","ACCEPTED"]}},"required":["status"]},"EventVideoCommentRequestDto":{"type":"object","description":"행사 영상 댓글 작성·수정 요청","properties":{"content":{"type":"string","description":"댓글 본문 (1~500자)","example":"저도 어제 다녀왔어요","maxLength":500,"minLength":0}},"required":["content"]},"ApiResponseDtoEventVideoCommentResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventVideoCommentResponseDto"}},"required":["data","developCode","message"]},"EventVideoCommentResponseDto":{"type":"object","description":"행사 영상 댓글","properties":{"commentId":{"type":"integer","format":"int64","description":"댓글 ID","example":3021},"authorId":{"type":"integer","format":"int64","description":"작성자 사용자 ID","example":7007},"authorNickname":{"type":"string","description":"작성자 닉네임","example":"필맵러"},"content":{"type":"string","description":"댓글 본문","example":"저도 어제 다녀왔어요"},"createdAt":{"type":"string","format":"date-time","description":"작성 시각","example":"2026-10-06T12:30:00Z"}},"required":["authorId","authorNickname","commentId","content","createdAt"]},"EventVideoUploadRequestDto":{"type":"object","description":"행사 영상 업로드 확정 요청","properties":{"s3Key":{"type":"string","description":"presigned 발급 때 받은 S3 객체 키. 같은 키로 다시 보내면 멱등하게 처리된다","example":"videos/pending/42/6f1c1f0e-1d2b-4a5a-9f0e-2b3c4d5e6f70.mp4","minLength":1},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초). 1~30초","example":15,"maximum":30,"minimum":1},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각. 미래 시각은 거부된다(단말 시계 오차 5분 허용)","example":"2026-10-06T12:30:00Z"}},"required":["durationSec","recordedAt","s3Key"]},"ApiResponseDtoEventVideoUploadResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventVideoUploadResponseDto"}},"required":["data","developCode","message"]},"EventVideoUploadResponseDto":{"type":"object","description":"행사 영상 업로드 확정 응답","properties":{"videoId":{"type":"integer","format":"int64","description":"생성된 영상 ID","example":1001},"gridId":{"type":"string","description":"서버가 지정한 대표 격자 ID","example":"19422_9582"},"processingStatus":{"type":"string","description":"영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED)","example":"UPLOADED"},"occupied":{"type":"boolean","description":"이 업로드로 대표 격자를 처음 점령했는지 여부. 재시도 응답은 항상 false","example":true},"newBadges":{"type":"array","description":"이 업로드로 새로 획득한 뱃지 목록 — 없거나 재시도 응답이면 빈 배열","items":{"$ref":"#/components/schemas/EarnedBadgeResponseDto"}}},"required":["gridId","newBadges","occupied","processingStatus","videoId"]},"SignupRequestDto":{"type":"object","description":"이메일 회원가입 요청","properties":{"email":{"type":"string","format":"email","description":"이메일 (최대 255자, 중복 불가)","example":"user@fillmap.dev","maxLength":255,"minLength":0},"password":{"type":"string","description":"비밀번호. 영문과 숫자를 각각 하나 이상 포함한 8~64자","example":"Fillmap1234","maxLength":64,"minLength":8,"pattern":"^(?=.*[A-Za-z])(?=.*\\d).+$"},"nickname":{"type":"string","description":"닉네임 (2~20자)","example":"채우미","maxLength":20,"minLength":2}},"required":["email","nickname","password"]},"ApiResponseDtoSignupResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/SignupResponseDto"}},"required":["data","developCode","message"]},"SignupResponseDto":{"type":"object","description":"회원가입 성공 응답 — 생성된 사용자 정보","properties":{"id":{"type":"integer","format":"int64","description":"생성된 사용자 ID","example":1},"email":{"type":"string","description":"가입 이메일","example":"user@fillmap.dev"},"nickname":{"type":"string","description":"닉네임","example":"채우미"},"createdAt":{"type":"string","format":"date-time","description":"가입 시각","example":"2026-07-17T20:11:03Z"}},"required":["createdAt","email","id","nickname"]},"ReissueRequestDto":{"type":"object","description":"토큰 재발급 요청. 웹은 리프레시 토큰이 쿠키(refreshToken)로 전송되므로 body 를 생략할 수 있다.","properties":{"refreshToken":{"type":"string","description":"앱(X-Client-Type: app) 클라이언트의 리프레시 토큰. 웹은 쿠키를 사용하므로 생략.","example":"eyJhbGciOiJIUzI1NiJ9..."}}},"ApiResponseDtoReissueResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/ReissueResponseDto"}},"required":["data","developCode","message"]},"ReissueResponseDto":{"type":"object","description":"토큰 재발급 성공 응답","properties":{"accessToken":{"type":"string","description":"새로 발급된 JWT 액세스 토큰.","example":"eyJhbGciOiJIUzI1NiJ9..."},"refreshToken":{"type":["string","null"],"description":"회전된 새 리프레시 토큰. 앱(X-Client-Type: app)만 값이 채워지고, 웹은 HttpOnly 쿠키(Set-Cookie)로 재설정되므로 null 이다.","example":"eyJhbGciOiJIUzI1NiJ9..."}},"required":["accessToken","refreshToken"]},"OidcLoginRequestDto":{"type":"object","description":"소셜(OIDC) 로그인 요청","properties":{"idToken":{"type":"string","description":"소셜 제공자(카카오 등)에서 발급받은 OIDC ID Token","example":"eyJraWQiOiI...","minLength":1}},"required":["idToken"]},"ApiResponseDtoLoginResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/LoginResponseDto"}},"required":["data","developCode","message"]},"LoginResponseDto":{"type":"object","description":"로그인 성공 응답","properties":{"accessToken":{"type":"string","description":"발급된 JWT 액세스 토큰. 이후 요청 Authorization 헤더에 'Bearer {토큰}'으로 넣는다.","example":"eyJhbGciOiJIUzI1NiJ9..."},"refreshToken":{"type":["string","null"],"description":"발급된 리프레시 토큰. 앱(X-Client-Type: app)만 값이 채워지고, 웹은 HttpOnly 쿠키(Set-Cookie)로 내려가므로 null 이다.","example":"eyJhbGciOiJIUzI1NiJ9..."}},"required":["accessToken","refreshToken"]},"KakaoCodeLoginRequestDto":{"type":"object","description":"카카오 인가 코드 로그인 요청 (웹). 카카오 콜백으로 받은 코드를 서버가 ID Token 으로 교환한다.","properties":{"code":{"type":"string","description":"카카오 콜백 쿼리로 받은 1회용 인가 코드","example":"vBv8oXbeLnDF2mkw...","minLength":1},"redirectUri":{"type":"string","description":"인가 요청에 사용한 redirect URI 그대로. 카카오 콘솔 등록값과 정확히 일치해야 한다.","example":"http://localhost:5173/oauth/kakao/callback","minLength":1}},"required":["code","redirectUri"]},"LogoutRequestDto":{"type":"object","description":"로그아웃 요청 (선택 body) — fcmToken 이 있으면 세션 삭제와 함께 해당 FCM 푸시 토큰도 정리된다","properties":{"fcmToken":{"type":"string","description":"정리할 FCM 토큰 (선택)","example":"fcm-token-abc123"}}},"LoginRequestDto":{"type":"object","description":"이메일/비밀번호 로그인 요청","properties":{"email":{"type":"string","format":"email","description":"가입한 이메일","example":"user@fillmap.dev","minLength":1},"password":{"type":"string","description":"비밀번호 (영문+숫자 포함 8~64자)","example":"Fillmap1234","minLength":1}},"required":["email","password"]},"DevSocialLoginRequestDto":{"type":"object","description":"[로컬/dev 전용] 소셜 로그인 모의 요청 — 실제 소셜 ID Token 없이 (provider, oid)로 로그인/가입한다.","properties":{"provider":{"type":"string","description":"소셜 제공자 (기본 KAKAO)","example":"KAKAO"},"oid":{"type":"string","description":"소셜 고유 식별자(oid). 같은 값이면 같은 사용자로 재로그인된다.","example":"dev-kakao-1","minLength":1},"email":{"type":"string","description":"이메일 (선택). 없으면 {oid}@dev.local","example":"kakaouser@dev.local"},"nickname":{"type":"string","description":"닉네임 (선택). 없으면 dev-{oid}","example":"카카오테스터"}},"required":["oid"]},"AdminVideoUnblindResponseDto":{"type":"object","description":"블라인드 해제 결과 — 복구된 영상 상태.","properties":{"videoId":{"type":"integer","format":"int64","description":"해제된 영상 ID","example":1042},"status":{"type":"string","description":"해제 후 영상 상태 — 성공이면 항상 ACTIVE","enum":["ACTIVE","BLINDED","DELETED"],"example":"ACTIVE"}},"required":["status","videoId"]},"ApiResponseDtoAdminVideoUnblindResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminVideoUnblindResponseDto"}},"required":["data","developCode","message"]},"AdminReportProcessResponseDto":{"type":"object","description":"신고 승인·기각 처리 결과 — 종결된 신고 상태와 처리 후 영상 상태.","properties":{"reportId":{"type":"integer","format":"int64","description":"처리된 신고 ID","example":7},"status":{"type":"string","description":"처리 후 신고 상태 — 승인이면 RESOLVED, 기각이면 REJECTED","enum":["PENDING","REVIEWING","RESOLVED","REJECTED"],"example":"RESOLVED"},"videoId":{"type":"integer","format":"int64","description":"신고 대상 영상 ID","example":1042},"videoStatus":{"type":"string","description":"처리 후 영상 상태 — 승인의 전이 생략 케이스(FR-5)를 이 값으로 구분한다","enum":["ACTIVE","BLINDED","DELETED"],"example":"BLINDED"},"reviewedAt":{"type":"string","format":"date-time","description":"처리 시각","example":"2026-08-06T11:00:00Z"}},"required":["reportId","reviewedAt","status","videoId","videoStatus"]},"ApiResponseDtoAdminReportProcessResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminReportProcessResponseDto"}},"required":["data","developCode","message"]},"VideoVisibilityRequestDto":{"type":"object","description":"영상 공개 범위 전환 요청. PUBLIC · PRIVATE · FRIENDS.","properties":{"visibility":{"type":"string","description":"공개 범위. PUBLIC, PRIVATE, FRIENDS 중 하나 (대소문자 무관)","example":"PUBLIC","minLength":1}},"required":["visibility"]},"ApiResponseDtoVideoVisibilityResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/VideoVisibilityResponseDto"}},"required":["data","developCode","message"]},"VideoVisibilityResponseDto":{"type":"object","description":"영상 공개 범위 전환 응답. 전환 후 공개 범위를 담는다.","properties":{"videoId":{"type":"integer","format":"int64","description":"전환된 영상 ID","example":1042},"visibility":{"type":"string","description":"전환 후 공개 범위 (PUBLIC, PRIVATE, FRIENDS 중 하나)","example":"PUBLIC"}},"required":["videoId","visibility"]},"NotificationPreferenceUpdateRequestDto":{"type":"object","description":"카테고리 수신 토글 요청 — 같은 값 재전환은 멱등하게 성공한다","properties":{"enabled":{"type":"boolean","description":"수신 여부 — false 면 off(거부 행 저장), true 면 on(행 삭제)","example":false}},"required":["enabled"]},"ApiResponseDtoNotificationPreferenceResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/NotificationPreferenceResponseDto"}},"required":["data","developCode","message"]},"CategoryPreferenceDto":{"type":"object","description":"카테고리 하나의 수신 상태","properties":{"category":{"type":"string","description":"알림 카테고리","enum":["BADGE","HOTZONE","REMIND","VIDEO","WEEKLY","FRIEND","MISSION_NEARBY","EVENT"],"example":"HOTZONE"},"enabled":{"type":"boolean","description":"수신 여부 — off 행 부재면 true (opt-out 기본 전부 on)","example":true}},"required":["category","enabled"]},"NotificationPreferenceResponseDto":{"type":"object","description":"알림 설정 — 전 카테고리(8종)의 수신 상태 (저장 행 없는 카테고리는 true)","properties":{"preferences":{"type":"array","description":"카테고리별 수신 상태 (BADGE·HOTZONE·REMIND·VIDEO·WEEKLY·FRIEND·MISSION_NEARBY·EVENT 고정 8종 — MODERATION 은 설정 대상이 아니라 없다)","items":{"$ref":"#/components/schemas/CategoryPreferenceDto"}}},"required":["preferences"]},"ApiResponseDtoListZoneResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/ZoneResponseDto"}}},"required":["data","developCode","message"]},"ZoneResponseDto":{"type":"object","description":"구역(zone)의 이름과 격자 사각형 범위 — 검색바 구역 이동·범위 오버레이용. 격자 표시명은 서버가 계산해 격자 응답에 함께 싣는다.","properties":{"zoneKey":{"type":"string","description":"안정 식별자 slug (zones.zone_key) — 클라이언트 참조·타이브레이크 기준","example":"seomyeon"},"name":{"type":"string","description":"구역명 (zones.name)","example":"서면"},"regionCode":{"type":["string","null"],"description":"소속 행정동 코드 (zones.region_code, nullable)","example":"2623051000"},"minGridY":{"type":"integer","format":"int32","description":"사각형 남단 행 (zones.min_grid_y)","example":16850},"maxGridY":{"type":"integer","format":"int32","description":"사각형 북단 행 = A행 (zones.max_grid_y)","example":16866},"minGridX":{"type":"integer","format":"int32","description":"사각형 서단 열 = 1열 (zones.min_grid_x)","example":11414},"maxGridX":{"type":"integer","format":"int32","description":"사각형 동단 열 (zones.max_grid_x)","example":11424},"priority":{"type":"integer","format":"int32","description":"겹침 결정성 우선순위 (zones.priority)","example":0}},"required":["maxGridX","maxGridY","minGridX","minGridY","name","priority","regionCode","zoneKey"]},"ApiResponseDtoVideoPlaybackResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/VideoPlaybackResponseDto"}},"required":["data","developCode","message"]},"VideoPlaybackResponseDto":{"type":"object","description":"단건 영상 재생 조회 응답","properties":{"videoId":{"type":"integer","format":"int64","description":"영상(방문 이벤트) ID","example":1042},"playbackUrl":{"type":["string","null"],"description":"재생본 presigned GET URL. READY 아님·BLINDED(소유자)면 null"},"thumbnailUrl":{"type":["string","null"],"description":"썸네일 presigned GET URL. 썸네일 key 없음(READY 이전)이면 null"},"gridId":{"type":"string","description":"이 영상이 속한 격자 ID","example":"19422_9582"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"processingStatus":{"type":"string","description":"영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED)","example":"READY"},"visibility":{"type":"string","description":"공개 범위 (PUBLIC, PRIVATE, FRIENDS 중 하나)","example":"PUBLIC"},"status":{"type":"string","description":"영상 상태 (ACTIVE/BLINDED). 소유자가 블라인드 사유를 구분하는 축","example":"ACTIVE"},"viewCount":{"type":"integer","format":"int64","description":"조회수 (이번 조회 증가 전 스냅샷)","example":37},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각 (표시용)","example":"2026-07-20T18:03:11Z"},"expiresInSec":{"type":["integer","null"],"format":"int64","description":"playbackUrl presign TTL(초). playbackUrl=null 이면 null"},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름 (예 \"서면\"). 구역 밖 격자면 null — 이때 라벨은 regionName 이다","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A 는 구역 북단, 열 1 은 서단). zoneName 과 항상 쌍이라 구역 밖이면 함께 null","example":"I-6"},"regionName":{"type":["string","null"],"description":"격자 중심점 행정동 이름 — 구역 밖 격자의 폴백 라벨. 무귀속(해상 등)이거나 미판정이면 null","example":"서울특별시 강남구 역삼1동"},"highlights":{"type":["array","null"],"description":"AI 추천 하이라이트 구간 [[시작초, 끝초], ...]. 최대 3구간, 초는 소수점 둘째 자리. 배열 순서가 추천 우선순위(첫 요소가 최우선 추천). 없으면 null (READY 이전·FAILED·0구간 포함, 빈 배열은 내려가지 않는다) 예시: [[0.0, 4.25], [12.0, 18.5], [20.0, 27.5]]","items":{"type":"array","items":{"type":"number","format":"double"}}},"nickname":{"type":"string","description":"작성자 닉네임 원문. @ 등 화면 표기는 FE 가 붙인다","example":"busan.vlog"}},"required":["durationSec","expiresInSec","gridId","highlights","nickname","playbackUrl","processingStatus","recordedAt","regionName","status","thumbnailUrl","videoId","viewCount","visibility","zoneCell","zoneName"]},"ApiResponseDtoListTrendingKeywordResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/TrendingKeywordResponseDto"}}},"required":["data","developCode","message"]},"TrendingKeywordResponseDto":{"type":"object","description":"인기 검색어 1건. 클릭 시 keyword 로 기존 장소 검색 API 를 다시 호출한다.","properties":{"rank":{"type":"integer","format":"int32","description":"순위 (1부터)","example":1},"keyword":{"type":"string","description":"정규화된 검색어","example":"홍대 카페"}},"required":["keyword","rank"]},"ApiResponseDtoListPlaceSearchResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/PlaceSearchResponseDto"}}},"required":["data","developCode","message"]},"PlaceSearchResponseDto":{"type":"object","description":"장소 검색 결과 1건. 선택 시 lat/lng 로 지도 이동 + gridId 로 격자 하이라이트를 한 번에 처리한다.","properties":{"name":{"type":"string","description":"장소명 (카카오 place_name)","example":"부산대학교"},"address":{"type":"string","description":"표시용 주소 — 도로명 우선, 없으면 지번 (§D2)","example":"부산 금정구 부산대학로63번길 2"},"lat":{"type":"number","format":"double","description":"위도 (WGS84, 카카오 y 직결 — 변환 없음)","example":35.23272},"lng":{"type":"number","format":"double","description":"경도 (WGS84, 카카오 x)","example":129.08246},"gridId":{"type":"string","description":"그 좌표의 격자 ID — FE 격자 하이라이트 키 (즉석 계산, 저장 아님)","example":"16941_11439"},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름. 구역 밖이면 null — 표시 라벨은 address 가 맡으므로 행정동 폴백 재료를 싣지 않는다(§D2 유지).","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A는 구역 북단, 열 1은 서단). zoneName 과 항상 쌍이라 구역 밖이면 함께 null 이다.","example":"I-6"}},"required":["address","gridId","lat","lng","name","zoneCell","zoneName"]},"ApiResponseDtoRegionExploreResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/RegionExploreResponseDto"}},"required":["data","developCode","message"]},"ExploreGridResponseDto":{"type":"object","description":"전역 탐색 격자 카드","properties":{"gridId":{"type":"string","description":"격자 ID — 카드 탭 시 격자 전역 영상 목록(MSG-237) 진입 키","example":"16676_11596"},"gridY":{"type":"integer","format":"int64","description":"격자 세로 인덱스 (EPSG:5179 평면 y / 100 — 위도가 아니다). FE 지도 이동·라벨 조합","example":16676},"gridX":{"type":"integer","format":"int64","description":"격자 가로 인덱스 (EPSG:5179 평면 x / 100 — 경도가 아니다)","example":11596},"videoCount":{"type":"integer","format":"int32","description":"그 격자의 게이트 통과 영상 수 — \"N개 영상\"","example":138},"coverThumbnailUrl":{"type":["string","null"],"description":"커버 썸네일 presigned GET URL. READY 게이트라 non-null 기대(null 이면 null 통과)"},"coverDurationSec":{"type":"integer","format":"int32","description":"커버 영상 길이(초) — duration 뱃지","example":12},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름 (예 \"서면\"). 구역 밖 격자면 null — 이때 FE 는 래퍼의 regionName 을 라벨로 쓴다","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A 는 구역 북단, 열 1 은 서단). zoneName 과 항상 쌍이라 구역 밖이면 함께 null","example":"I-6"}},"required":["coverDurationSec","coverThumbnailUrl","gridId","gridX","gridY","videoCount","zoneCell","zoneName"]},"RegionExploreResponseDto":{"type":"object","description":"행정동 격자 카드 리스트 + 헤더 카운트","properties":{"regionCode":{"type":"string","description":"행정동 코드 (요청 에코)","example":"2644056000"},"regionName":{"type":["string","null"],"description":"행정동 이름 — 미존재 코드면 null","example":"부산광역시 부산진구 부전2동"},"gridCount":{"type":"integer","format":"int32","description":"게이트 통과 영상 ≥1 격자 수 — \"이 지역 격자 N개\"","example":5},"videoCount":{"type":"integer","format":"int64","description":"게이트 통과 영상 총수 — \"영상 M개\"","example":355},"grids":{"type":"array","description":"격자 카드 (정렬·limit 적용 후). 없으면 빈 배열","items":{"$ref":"#/components/schemas/ExploreGridResponseDto"}}},"required":["gridCount","grids","regionCode","regionName","videoCount"]},"ApiResponseDtoListRegionStatResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/RegionStatResponseDto"}}},"required":["data","developCode","message"]},"RegionStatResponseDto":{"type":"object","description":"한 행정동의 수집률. 사용자가 그 행정동에서 점령(수집)한 격자 수와 진행률.","properties":{"regionCode":{"type":"string","description":"행정동 코드 (region_stats.region_code)","example":"1168051500"},"regionName":{"type":"string","description":"행정동 이름 (regions.region_name)","example":"서울특별시 강남구 역삼1동"},"parentCode":{"type":["string","null"],"description":"상위 시군구 코드 (regions.parent_code) — NULL 허용 컬럼이라 최상위 행은 null","example":"11680"},"collectedCount":{"type":"integer","format":"int32","description":"점령(수집)한 격자 수","example":5},"totalCount":{"type":"integer","format":"int32","description":"그 행정동 전체 격자 수(분모)","example":20},"progressRate":{"type":"number","description":"수집률(%) — 100 상한 clamp","example":25.0},"updatedAt":{"type":"string","format":"date-time","description":"수집률 캐시 기준 시각","example":"2026-07-20T10:00:00Z"}},"required":["collectedCount","parentCode","progressRate","regionCode","regionName","totalCount","updatedAt"]},"ApiResponseDtoRegionNationalStatResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/RegionNationalStatResponseDto"}},"required":["data","developCode","message"]},"RegionNationalStatResponseDto":{"type":"object","description":"내 전국 탐험률 재료. 점령한 격자 수(분자)와 전국 격자 총수(분모)의 원값.","properties":{"collectedCount":{"type":"integer","format":"int64","description":"내가 점령(수집)한 격자 수의 전국 합. 수집이 없으면 0","example":1223},"totalCount":{"type":"integer","format":"int64","description":"전국 격자 총수(분모). 0 이면 기준 데이터 미적재 상태라 화면은 비율을 그리지 않는다","example":10193482}},"required":["collectedCount","totalCount"]},"ApiResponseDtoRegionStatResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"anyOf":[{"$ref":"#/components/schemas/RegionStatResponseDto"},{"type":"null"}]}},"required":["data","developCode","message"]},"ApiResponseDtoRegionResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"anyOf":[{"$ref":"#/components/schemas/RegionResponseDto"},{"type":"null"}]}},"required":["data","developCode","message"]},"RegionResponseDto":{"type":"object","description":"좌표를 포함하는 행정동. 포함 행정동이 없으면(바다·국외) data 가 null 이다.","properties":{"regionCode":{"type":"string","description":"행정동 코드 (regions.region_code = adm_cd2)","example":"1168051500"},"regionName":{"type":"string","description":"행정동 이름 (regions.region_name = adm_nm)","example":"서울특별시 강남구 역삼1동"},"parentCode":{"type":["string","null"],"description":"상위 시군구 코드 (regions.parent_code) — NULL 허용 컬럼이라 최상위 행은 null","example":"11680"}},"required":["parentCode","regionCode","regionName"]},"ApiResponseDtoRegionExplorePageResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/RegionExplorePageResponseDto"}},"required":["data","developCode","message"]},"RegionExplorePageResponseDto":{"type":"object","description":"전체 지역 개인화 커서 페이지","properties":{"items":{"type":"array","description":"현재 페이지 행정동 목록. 최대 20개","items":{"$ref":"#/components/schemas/RegionGridCountResponseDto"}},"hasNext":{"type":"boolean","description":"다음 페이지 존재 여부"},"nextCursor":{"type":["string","null"],"description":"다음 요청에 그대로 전달할 불투명 커서"}},"required":["hasNext","items","nextCursor"]},"RegionGridCountResponseDto":{"type":"object","description":"전체 지역 리스트 항목 (행정동별 격자 수)","properties":{"regionCode":{"type":"string","description":"행정동 코드 — 선택 시 격자 카드 조회에 전달","example":"2644056000"},"regionName":{"type":"string","description":"행정동 이름","example":"부산광역시 부산진구 부전2동"},"gridCount":{"type":"integer","format":"int32","description":"그 행정동의 게이트 통과 격자 수","example":5}},"required":["gridCount","regionCode","regionName"]},"ApiResponseDtoListRegionDistrictResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/RegionDistrictResponseDto"}}},"required":["data","developCode","message"]},"RegionDistrictResponseDto":{"type":"object","description":"시군구 한 건. 이름·식별자·전체 격자 수.","properties":{"parentCode":{"type":"string","description":"시군구 식별자(행정동 코드 앞 5자리). /api/regions/stats 의 parentCode 로 그대로 쓴다","example":"11680"},"name":{"type":"string","description":"시군구 이름","example":"강남구"},"gridCount":{"type":"integer","format":"int64","description":"그 시군구의 전체 격자 수(사용자 무관). 0 인 시군구는 목록에 없다","example":4102}},"required":["gridCount","name","parentCode"]},"ApiResponseDtoNotificationPageResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/NotificationPageResponseDto"}},"required":["data","developCode","message"]},"NotificationItemResponseDto":{"type":"object","description":"알림 한 건","properties":{"notificationId":{"type":"integer","format":"int64","description":"알림 ID — 읽음 처리와 커서에 쓴다","example":123},"category":{"type":"string","description":"알림 카테고리","enum":["BADGE","HOTZONE","REMIND","VIDEO","WEEKLY","FRIEND","MODERATION","EVENT"],"example":"BADGE"},"title":{"type":"string","description":"알림 제목","example":"새 뱃지 획득"},"body":{"type":"string","description":"알림 본문","example":"'첫 걸음' 뱃지를 획득했어요"},"createdAt":{"type":"string","format":"date-time","description":"생성 시각 (UTC)","example":"2026-08-19T02:11:00Z"},"read":{"type":"boolean","description":"읽음 여부","example":false}},"required":["body","category","createdAt","notificationId","read","title"]},"NotificationPageResponseDto":{"type":"object","description":"알림함 목록 한 페이지 — 최신순(id 내림차순)","properties":{"notifications":{"type":"array","description":"알림 항목 — 없으면 빈 배열","items":{"$ref":"#/components/schemas/NotificationItemResponseDto"}},"nextCursor":{"type":["integer","null"],"format":"int64","description":"다음 페이지 커서 — 다음 요청의 cursor 로 그대로 되돌려 준다. hasNext 가 false 면 null","example":123},"hasNext":{"type":"boolean","description":"다음 페이지 존재 여부","example":true}},"required":["hasNext","nextCursor","notifications"]},"ApiResponseDtoNotificationUnreadCountResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/NotificationUnreadCountResponseDto"}},"required":["data","developCode","message"]},"NotificationUnreadCountResponseDto":{"type":"object","description":"안읽은 알림 개수 — 목록과 같은 노출 조건(최근 30일·수신 거부 스킵 제외)","properties":{"count":{"type":"integer","format":"int64","description":"안읽은 알림 개수 — 없으면 0","example":3}},"required":["count"]},"ApiResponseDtoMissionDetailResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/MissionDetailResponseDto"}},"required":["data","developCode","message"]},"BoxShape":{"allOf":[{"$ref":"#/components/schemas/MissionShape"},{"type":"object","properties":{"polygon":{"type":"array","items":{"$ref":"#/components/schemas/LatLng"}}}}],"description":"축제·팝업(EVENT·POPUP) — 격자 집합을 감싸는 경계 사각형","required":["polygon"]},"Cell":{"type":"object","description":"격자 중심점","properties":{"gridId":{"type":"string"},"lat":{"type":"number","format":"double"},"lng":{"type":"number","format":"double"}},"required":["gridId","lat","lng"]},"CellsShape":{"allOf":[{"$ref":"#/components/schemas/MissionShape"},{"type":"object","properties":{"cells":{"type":"array","items":{"$ref":"#/components/schemas/Cell"}}}}],"description":"테마·지속(THEME·CONTINUOUS) — 각 격자 중심점","required":["cells"]},"LatLng":{"type":"object","description":"좌표 한 점","properties":{"lat":{"type":"number","format":"double"},"lng":{"type":"number","format":"double"}},"required":["lat","lng"]},"MissionDetailResponseDto":{"type":"object","description":"미션 상세 — 미션 정보 + 내 진행도 + 전체 영상 개수 + 코스 스팟별 통계","properties":{"mission":{"$ref":"#/components/schemas/MissionResponseDto","description":"미션 정보 — 목록(GET /api/missions/active)과 같은 필드·shape"},"progress":{"anyOf":[{"$ref":"#/components/schemas/MissionProgressResponseDto"},{"type":"null"}],"description":"내 진행도 — 목록 진행도(GET /api/missions/progress)와 같은 계산 (MSG-398 D8). 비로그인 조회면 키는 그대로 있고 값이 null 이다 (MSG-454)"},"videoCount":{"type":"integer","format":"int64","description":"미션 기간 안에 촬영된 전역 공개(ACTIVE·PUBLIC·READY) 영상 수 — 미션 영상 목록(MSG-390)의 실제 후보 수와 같다","example":19},"spotStats":{"type":"array","description":"코스 포토스팟별 방문 여부·영상 개수 — shape.spots 와 같은 순서(seq ASC NULLS LAST, gridId ASC). 코스가 아니면 빈 배열","items":{"$ref":"#/components/schemas/SpotStats"}}},"required":["mission","progress","spotStats","videoCount"]},"MissionProgressResponseDto":{"type":"object","description":"미션 하나에 대한 내 진행도","properties":{"missionId":{"type":"integer","format":"int64","description":"미션 id (missions.id)","example":412},"targetCount":{"type":"integer","format":"int32","description":"완료에 필요한 격자 수 (missions.target_count)","example":1},"filledCount":{"type":"integer","format":"int32","description":"그 미션 격자 중 기간 안에 촬영한 내 영상이 있는 칸 수. targetCount 를 넘지 않는다","example":1},"completed":{"type":"boolean","description":"내 스탬프 보유 여부 (user_missions)","example":true}},"required":["completed","filledCount","missionId","targetCount"]},"MissionResponseDto":{"type":"object","description":"미션 하나 — 공통 필드 + 유형별 렌더 shape","properties":{"missionId":{"type":"integer","format":"int64","description":"미션 id (missions.id)","example":12},"type":{"type":"string","description":"미션 유형 — FE 렌더러 판별자","enum":["COURSE","AREA","EVENT","THEME","CONTINUOUS","POPUP"],"example":"COURSE"},"title":{"type":"string","description":"미션 제목","example":"남파랑길 3코스"},"targetCount":{"type":"integer","format":"int32","description":"완료에 필요한 distinct 방문 격자 수(표시·판정 힌트, 판정은 MSG-223)","example":3},"startAt":{"type":["string","null"],"format":"date-time","description":"시작 시각. NULL = 무기간(상시)","example":"2026-11-01T00:00:00Z"},"endAt":{"type":["string","null"],"format":"date-time","description":"종료 시각. NULL = 무기간(상시)","example":"2026-11-01T23:59:59Z"},"shape":{"description":"유형별 렌더 shape 하나(type 에 대응하는 PATH/BOX/CELLS/REGION)","oneOf":[{"$ref":"#/components/schemas/BoxShape"},{"$ref":"#/components/schemas/CellsShape"},{"$ref":"#/components/schemas/PathShape"},{"$ref":"#/components/schemas/RegionShape"}]},"description":{"type":["string","null"],"description":"소개문 원문. 출처 표기 없이 그대로 노출한다","example":"부산 앞바다를 따라 걷는 해안 산책로"},"placeName":{"type":["string","null"],"description":"사람이 읽는 위치 한 줄 — 축제는 행사장, 팝업은 주소, 코스는 시군","example":"부산 영도구"},"sourceUrl":{"type":["string","null"],"description":"원문 링크 — 축제 홈페이지·팝업 상세 페이지. 코스는 없다","example":"https://festival.example.kr"},"operationTime":{"type":["string","null"],"description":"운영시간 안내 문구. 여러 줄이면 개행으로 이어 붙인다(팝업 전용)","example":"매일 11:00 ~ 20:00"},"imageUrl":{"type":["string","null"],"description":"대표 이미지 주소 — 우리 스토리지 URL 만 들어간다(MSG-383 §D7)","example":"https://cdn.fillmap.kr/mission/12.webp"},"distanceMeters":{"type":["integer","null"],"format":"int32","description":"코스 총 거리(미터). 코스가 아니면 없다","example":14000},"durationMinutes":{"type":["integer","null"],"format":"int32","description":"코스 소요시간(분). 코스가 아니면 없다","example":330},"difficulty":{"type":["integer","null"],"format":"int32","description":"코스 난이도 — 두루누비 등급 1(쉬움)·2(보통)·3(어려움). 코스가 아니면 없다","example":2}},"required":["description","difficulty","distanceMeters","durationMinutes","endAt","imageUrl","missionId","operationTime","placeName","shape","sourceUrl","startAt","targetCount","title","type"]},"MissionShape":{"description":"미션 유형별 렌더 shape (상위 type 으로 판별). PATH·BOX·CELLS·REGION 중 하나."},"PathShape":{"allOf":[{"$ref":"#/components/schemas/MissionShape"},{"type":"object","properties":{"line":{"type":["object","null"],"description":"코스 라인 GeoJSON LineString 원문 — missions.path 는 NULL 허용 컬럼이라 없을 수 있다"},"spots":{"type":"array","items":{"$ref":"#/components/schemas/Spot"}}}}],"description":"코스(COURSE) — GeoJSON LineString + seq순 포토스팟 마커","required":["line","spots"]},"RegionShape":{"allOf":[{"$ref":"#/components/schemas/MissionShape"},{"type":"object","properties":{"regionCode":{"type":["string","null"],"description":"행정동 코드 — missions.region_code 는 NULL 허용 컬럼이라 없을 수 있다"}}}],"description":"구역(AREA) — region_code 만(경계는 region API 로 별도 조회)","required":["regionCode"]},"Spot":{"type":"object","description":"코스 포토스팟 마커","properties":{"gridId":{"type":"string"},"lat":{"type":"number","format":"double"},"lng":{"type":"number","format":"double"},"seq":{"type":["integer","null"],"format":"int32","description":"코스 내 순번 — mission_grids.seq 는 NULL 허용 컬럼이라 없을 수 있다"}},"required":["gridId","lat","lng","seq"]},"SpotStats":{"type":"object","description":"코스 포토스팟 하나의 방문 여부·영상 개수","properties":{"gridId":{"type":"string","description":"포토스팟 격자 id — shape.spots 의 gridId 에 대응","example":"38677_114635"},"visited":{"type":"boolean","description":"미션 기간 안에 촬영한 내 영상이 있는지 — 진행도와 같은 술어. 비로그인 조회면 항상 false (MSG-454)","example":true},"videoCount":{"type":"integer","format":"int64","description":"이 스팟에 올라온 전역 공개 영상 수. 영상이 없으면 0","example":9}},"required":["gridId","videoCount","visited"]},"ApiResponseDtoGridVideoPageResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/GridVideoPageResponseDto"}},"required":["data","developCode","message"]},"GridGlobalVideoResponseDto":{"type":"object","description":"전역 공개 영상 목록 항목","properties":{"videoId":{"type":"integer","format":"int64","description":"영상 ID. 항목 탭 → 단건 재생(GET /api/videos/{videoId}) 진입 키","example":1042},"thumbnailUrl":{"type":"string","description":"썸네일 presigned GET URL. 목록은 READY 만 담겨 null 아님이 기대값이다"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"viewCount":{"type":"integer","format":"int64","description":"조회수","example":37},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각","example":"2026-07-20T18:03:11Z"},"nickname":{"type":"string","description":"작성자 닉네임 원문. @ 등 화면 표기는 FE 가 붙인다","example":"busan.vlog"}},"required":["durationSec","nickname","recordedAt","thumbnailUrl","videoId","viewCount"]},"GridVideoPageResponseDto":{"type":"object","description":"전역 공개 영상 목록 페이지 응답 (keyset 커서 페이지네이션)","properties":{"videos":{"type":"array","description":"이 페이지의 전역 공개·READY 영상. 없으면 빈 배열","items":{"$ref":"#/components/schemas/GridGlobalVideoResponseDto"}},"hasNext":{"type":"boolean","description":"다음 페이지 존재 여부 (lookahead 판정)"},"nextCursor":{"type":["string","null"],"description":"다음 페이지 조회용 opaque 커서. 다음 요청 cursor 파라미터에 넣는다. 마지막 페이지면 null."}},"required":["hasNext","nextCursor","videos"]},"ApiResponseDtoListMissionProgressResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/MissionProgressResponseDto"}}},"required":["data","developCode","message"]},"ApiResponseDtoListMissionRegionAggregateResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/MissionRegionAggregateResponseDto"}}},"required":["data","developCode","message"]},"MissionRegionAggregateResponseDto":{"type":"object","description":"행정 단위로 묶어 센 미션 집계 한 항목","properties":{"regionCode":{"type":["string","null"],"description":"묶음 키 — 행정동 코드(10자리)를 단위 길이로 자른 접두(동 10, 구 5, 시 2자리). 행정동이 판정되지 않은 묶음만 null","example":"26230"},"name":{"type":["string","null"],"description":"단위 표시 이름 (동 \"부전2동\", 구 \"부산진구\", 시 \"부산광역시\"). 무귀속만 null","example":"부산진구"},"lat":{"type":"number","format":"double","description":"마커 대표 좌표 위도 — 묶음에 속한 미션 귀속점의 평균이라 마커가 실제 데이터 위에 선다","example":35.1568},"lng":{"type":"number","format":"double","description":"마커 대표 좌표 경도","example":129.0592},"count":{"type":"integer","format":"int32","description":"그 단위 안의 미션 수","example":12},"missionIds":{"type":"array","description":"그 묶음에 속한 미션 id 오름차순 — 줌인 후 개별 조회 결과와 교집합으로 목록을 좁힌다(D5). 크기는 count 와 같다","items":{"type":"integer","format":"int64"}}},"required":["count","lat","lng","missionIds","name","regionCode"]},"ApiResponseDtoListMissionResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/MissionResponseDto"}}},"required":["data","developCode","message"]},"ApiResponseDtoHotZoneListResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/HotZoneListResponseDto"}},"required":["data","developCode","message"]},"HotZoneListResponseDto":{"type":"object","description":"뷰포트 내 핫구역 목록 응답 (핫스코어 내림차순)","properties":{"hotZones":{"type":"array","description":"핫구역 목록 — 핫스코어 내림차순. 없으면 빈 배열","items":{"$ref":"#/components/schemas/HotZoneResponseDto"}}},"required":["hotZones"]},"HotZoneResponseDto":{"type":"object","description":"핫구역 한 칸 — 최근 48시간 방문(업로드) 신호가 상위인 격자","properties":{"gridId":{"type":"string","description":"격자 ID (\"{grid_y}_{grid_x}\" 포맷)","example":"19422_9582"},"gridY":{"type":"integer","format":"int32","description":"격자 세로 인덱스 (EPSG:5179 평면 y / 100 — 위도가 아니다)","example":19422},"gridX":{"type":"integer","format":"int32","description":"격자 가로 인덱스 (EPSG:5179 평면 x / 100 — 경도가 아니다)","example":9582},"score":{"type":"integer","format":"int64","description":"핫스코어 — 최근 48시간(8버킷) 방문 신호 합산","example":12},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름. 구역 밖 격자면 null — 이때 마커 라벨은 같은 항목의 regionName(행정동)이다(추가 호출 없음).","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A는 구역 북단, 열 1은 서단) — 마커 배지용. zoneName 과 항상 쌍이라 구역 밖 격자면 함께 null 이다.","example":"I-6"},"regionName":{"type":["string","null"],"description":"격자 중심점이 속한 행정동 전체 이름. 어느 행정동에도 속하지 않으면(해상 등) null. zoneName 이 null 이면 이 값이 표시 이름 폴백이다(폴백에는 칸 번호를 붙이지 않는다).","example":"부산광역시 부산진구 부전1동"}},"required":["gridId","gridX","gridY","regionName","score","zoneCell","zoneName"]},"ApiResponseDtoListHotZoneRegionAggregateResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/HotZoneRegionAggregateResponseDto"}}},"required":["data","developCode","message"]},"HotZoneRegionAggregateResponseDto":{"type":"object","description":"행정 단위로 묶어 센 핫구역 집계 한 항목","properties":{"regionCode":{"type":["string","null"],"description":"묶음 키 — 행정동 코드(10자리)를 단위 길이로 자른 접두(동 10, 구 5, 시 2자리). 행정동이 판정되지 않은 묶음만 null","example":"26230"},"name":{"type":["string","null"],"description":"단위 표시 이름 (동 \"부전2동\", 구 \"부산진구\", 시 \"부산광역시\"). 무귀속만 null","example":"부산진구"},"lat":{"type":"number","format":"double","description":"마커 대표 좌표 위도 — 묶음에 속한 핫 격자 셀 중심의 평균이라 마커가 실제 데이터 위에 선다","example":35.1568},"lng":{"type":"number","format":"double","description":"마커 대표 좌표 경도","example":129.0592},"count":{"type":"integer","format":"int32","description":"그 단위 안의 핫 격자 수 — 핫스코어 합산이 아니다","example":12},"gridIds":{"type":"array","description":"그 묶음에 속한 핫 격자 id 오름차순 — 줌인 후 개별 조회 결과와 교집합으로 목록을 좁힌다(D4). 크기는 count 와 같다","items":{"type":"string"}}},"required":["count","gridIds","lat","lng","name","regionCode"]},"ApiResponseDtoOccupiedGridPageResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/OccupiedGridPageResponseDto"}},"required":["data","developCode","message"]},"OccupiedGridPageResponseDto":{"type":"object","description":"뷰포트 색칠 격자 페이지 응답 (커서 페이지네이션)","properties":{"grids":{"type":"array","description":"이 페이지의 색칠 격자 목록 ((grid_y, grid_x) 오름차순)","items":{"$ref":"#/components/schemas/OccupiedGridResponseDto"}},"nextCursor":{"type":["string","null"],"description":"다음 페이지 조회용 커서. 다음 요청 cursor 파라미터에 넣는다. 마지막 페이지면 null.","example":"MTk0MjJfOTU4Mg=="}},"required":["grids","nextCursor"]},"OccupiedGridResponseDto":{"type":"object","description":"뷰포트 색칠 격자 한 칸 — 지도 렌더링용 위치 정보","properties":{"gridId":{"type":"string","description":"격자 ID (\"{grid_y}_{grid_x}\" 포맷)","example":"19422_9582"},"gridY":{"type":"integer","format":"int32","description":"격자 세로 인덱스 (EPSG:5179 평면 y / 100 — 위도가 아니다)","example":19422},"gridX":{"type":"integer","format":"int32","description":"격자 가로 인덱스 (EPSG:5179 평면 x / 100 — 경도가 아니다)","example":9582},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름. 구역 밖 격자면 null — 이때 표시 이름은 같은 항목의 regionName(행정동)이다(추가 호출 없음).","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A는 구역 북단, 열 1은 서단) — 셀 배지용. zoneName 과 항상 쌍이라 구역 밖 격자면 함께 null 이다.","example":"I-6"},"regionName":{"type":["string","null"],"description":"격자 중심점이 속한 행정동 전체 이름. 어느 행정동에도 속하지 않으면(해상 등) null. zoneName 이 null 이면 이 값이 표시 이름 폴백이다(폴백에는 칸 번호를 붙이지 않는다).","example":"부산광역시 부산진구 부전1동"}},"required":["gridId","gridX","gridY","regionName","zoneCell","zoneName"]},"ApiResponseDtoGridCellResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/GridCellResponseDto"}},"required":["data","developCode","message"]},"GridCellResponseDto":{"type":"object","description":"단일 격자의 내 색칠(점령) 상태. 미점령이어도 404가 아니라 occupied=false로 응답한다.","properties":{"gridId":{"type":"string","description":"격자 ID (\"{grid_y}_{grid_x}\" 포맷)","example":"19422_9582"},"occupied":{"type":"boolean","description":"내가 이 격자를 점령(색칠)했는지 여부","example":true},"videoCount":{"type":"integer","format":"int32","description":"이 격자에 올린 내 영상 수 (미점령이면 0)","example":3},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름. 구역 밖 격자면 null — 이때 표시 이름은 같은 응답의 regionName(행정동)이다(추가 호출 없음).","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A는 구역 북단, 열 1은 서단). zoneName 과 항상 쌍이라 구역 밖 격자면 함께 null 이다.","example":"I-6"},"regionName":{"type":["string","null"],"description":"격자 중심점이 속한 행정동 전체 이름. 아직 아무도 영상을 올리지 않은 격자에도 실린다. 어느 행정동에도 속하지 않으면(해상 등) null. zoneName 이 null 이면 이 값이 표시 이름 폴백이다(폴백에는 칸 번호를 붙이지 않는다).","example":"부산광역시 영도구 영선1동"}},"required":["gridId","occupied","regionName","videoCount","zoneCell","zoneName"]},"ApiResponseDtoListGridVideoResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/GridVideoResponseDto"}}},"required":["data","developCode","message"]},"GridVideoResponseDto":{"type":"object","description":"격자별 내 영상 리스트 항목","properties":{"videoId":{"type":"integer","format":"int64","description":"영상(방문 이벤트) ID. 개별 재생·교체·삭제 진입 키","example":1042},"thumbnailUrl":{"type":["string","null"],"description":"썸네일 presigned GET URL. READY 아니면(썸네일 key 없음) null"},"processingStatus":{"type":"string","description":"영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED)","example":"READY"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"createdAt":{"type":"string","format":"date-time","description":"업로드(방문) 시각 — 정렬 키","example":"2026-07-20T18:03:11Z"}},"required":["createdAt","durationSec","processingStatus","thumbnailUrl","videoId"]},"ApiResponseDtoListGridMissionResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/GridMissionResponseDto"}}},"required":["data","developCode","message"]},"GridMissionResponseDto":{"type":"object","description":"격자가 대표 격자인 미션","properties":{"missionId":{"type":"integer","format":"int64","description":"미션 ID — 상세(GET /api/missions/{missionId})로 넘어가는 키","example":412},"type":{"type":"string","description":"미션 종류 — EVENT(지역축제) 또는 POPUP(팝업스토어)","example":"EVENT"},"title":{"type":"string","description":"미션 이름","example":"부산 불꽃축제"},"startAt":{"type":["string","null"],"format":"date-time","description":"시작 시각","example":"2026-10-01T00:00:00Z"},"endAt":{"type":["string","null"],"format":"date-time","description":"종료 시각","example":"2026-10-07T14:59:59Z"},"videoCount":{"type":"integer","format":"int64","description":"미션 기간 안에 촬영된 전역 공개 영상 수 — 미션 상세의 videoCount 와 같은 술어다","example":37}},"required":["endAt","missionId","startAt","title","type","videoCount"]},"ApiResponseDtoGridHourlyUploadResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/GridHourlyUploadResponseDto"}},"required":["data","developCode","message"]},"GridHourlyUploadResponseDto":{"type":"object","description":"격자 전역 시간대 분포 응답 (KST 24구간)","properties":{"gridId":{"type":"string","description":"격자 ID","example":"19422_9582"},"hours":{"type":"array","description":"KST 0시부터 23시까지 24개 구간. 업로드가 없는 구간은 count 0","items":{"$ref":"#/components/schemas/HourlyUploadCountResponseDto"}}},"required":["gridId","hours"]},"HourlyUploadCountResponseDto":{"type":"object","description":"시간대 구간 하나의 업로드 수","properties":{"hour":{"type":"integer","format":"int32","description":"KST 기준 시 (0~23)","example":18},"count":{"type":"integer","format":"int64","description":"그 시간대의 전역 공개 영상 수. 업로드가 없으면 0","example":3}},"required":["count","hour"]},"ApiResponseDtoListGridEventLocationResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/GridEventLocationResponseDto"}}},"required":["data","developCode","message"]},"GridEventLocationResponseDto":{"type":"object","description":"격자 역조회 결과 하나 — 회차와 해석된 행사 위치","properties":{"occurrenceId":{"type":"integer","format":"int64","description":"소속 행사 회차 id","example":12},"occurrenceTitle":{"type":"string","description":"행사명","example":"부산불꽃축제"},"occurrenceStatus":{"type":"string","description":"서버 시각 기준 파생 상태 — 상세와 같은 계산","enum":["UPCOMING","LIVE","UPLOAD_GRACE","ARCHIVED"],"example":"LIVE"},"locationId":{"type":"integer","format":"int64","description":"해석된 행사 위치 id — 피드 진입 키","example":31},"locationName":{"type":"string","description":"위치 이름","example":"부산역 팝업"},"representativeGridId":{"type":"string","description":"대표 격자 — 피드(MSG-440)가 영상을 붙일 격자","example":"19443_9582"},"zoneName":{"type":["string","null"],"description":"대표 격자가 속한 구역 이름. 구역 밖이면 null","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 안 위치 코드. 구역 밖이면 null","example":"A-14"},"regionName":{"type":["string","null"],"description":"대표 격자의 행정동 이름 — 구역 밖 표시명 폴백. 무귀속이면 null","example":"부전동"}},"required":["locationId","locationName","occurrenceId","occurrenceStatus","occurrenceTitle","regionName","representativeGridId","zoneCell","zoneName"]},"ApiResponseDtoGridCoverVideoResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"anyOf":[{"$ref":"#/components/schemas/GridCoverVideoResponseDto"},{"type":"null"}]}},"required":["data","developCode","message"]},"GridCoverVideoResponseDto":{"type":"object","description":"격자 전역 대표 영상","properties":{"videoId":{"type":"integer","format":"int64","description":"대표 영상 ID. 개별 재생 진입 키","example":1042},"thumbnailUrl":{"type":"string","description":"썸네일 presigned GET URL. 대표는 항상 READY 라 null 이 아니다"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"viewCount":{"type":"integer","format":"int64","description":"조회수 — 대표 선정 정렬 키","example":37},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각 (표시용). 정렬 tie-break 키는 createdAt 이다","example":"2026-07-20T18:03:11Z"},"nickname":{"type":"string","description":"작성자 닉네임 원문. @ 등 화면 표기는 FE 가 붙인다","example":"busan.vlog"}},"required":["durationSec","nickname","recordedAt","thumbnailUrl","videoId","viewCount"]},"ApiResponseDtoGridAggregationResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/GridAggregationResponseDto"}},"required":["data","developCode","message"]},"CurrentRegionResponseDto":{"type":"object","description":"뷰포트 중심이 속한 현재 행정동과 개인 점령 요약","properties":{"regionCode":{"type":"string","description":"행정동 코드 10자리","example":"2623058000"},"name":{"type":"string","description":"동 이름 한 토큰","example":"부전2동"},"gridCount":{"type":"integer","format":"int32","description":"이 행정동 전체에서 내가 점령한 격자 수(뷰포트 무관)","example":5},"videoCount":{"type":"integer","format":"int64","description":"이 행정동 전체에서 내 격자에 올린 영상 수(뷰포트 무관)","example":355}},"required":["gridCount","name","regionCode","videoCount"]},"GridAggregationResponseDto":{"type":"object","description":"뷰포트 점령 격자 묶음과 현재 동네 집계","properties":{"currentRegion":{"anyOf":[{"$ref":"#/components/schemas/CurrentRegionResponseDto"},{"type":"null"}],"description":"뷰포트 중심이 속한 행정동. 해상이나 서비스 범위 밖이면 null"},"items":{"type":"array","description":"뷰포트 안에서 행정 단위로 묶은 내 점령 격자 목록","items":{"$ref":"#/components/schemas/RegionAggregateResponseDto"}}},"required":["currentRegion","items"]},"RegionAggregateResponseDto":{"type":"object","description":"행정 단위로 묶어 센 점령 격자 집계 한 항목","properties":{"regionCode":{"type":["string","null"],"description":"묶음 키 — 행정동 코드를 단위 길이로 자른 접두(동 10자리, 구 5자리, 시 2자리). 행정동이 판정되지 않은 격자 묶음만 null 이다.","example":"2623058000"},"name":{"type":["string","null"],"description":"단위 표시 이름(동 \"부전2동\", 구 \"부산진구\", 시 \"부산광역시\"). \"부산광역시 214\" 를 \"부산 214\" 로 줄이는 표기 축약은 클라이언트 몫이다. 행정동이 판정되지 않은 격자 묶음만 null 이다.","example":"부전2동"},"lat":{"type":"number","format":"double","description":"마커 대표 좌표 위도 — 그 묶음에 속한 점령 격자 중심의 평균이다(행정 경계 무게중심이 아니다)","example":35.162},"lng":{"type":"number","format":"double","description":"마커 대표 좌표 경도","example":129.065},"count":{"type":"integer","format":"int32","description":"그 단위 안 점령 격자 수. 항목을 더 묶어 합산해도 같은 뷰포트 개별 조회 총수와 일치한다","example":31}},"required":["count","lat","lng","name","regionCode"]},"ApiResponseDtoListFriendListItemResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/FriendListItemResponseDto"}}},"required":["data","developCode","message"]},"FriendListItemResponseDto":{"type":"object","description":"친구 목록 항목 — 수락된 친구 한 명.","properties":{"userId":{"type":"integer","format":"int64","description":"친구의 사용자 id — 프로필 조회·친구 삭제 경로 변수로 그대로 쓴다","example":7},"nickname":{"type":"string","description":"친구의 닉네임","example":"채우미"},"profileImageUrl":{"type":["string","null"],"description":"친구의 프로필 이미지 URL — 미설정이면 null"},"gridColor":{"type":"string","description":"친구의 도감 색상 — 지도에서 친구가 수집한 격자를 칠하는 색","enum":["BLUE","GREEN","PURPLE","ORANGE","PINK","YELLOW","RED","TEAL"],"example":"PINK"}},"required":["gridColor","nickname","profileImageUrl","userId"]},"ApiResponseDtoFriendProfileResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/FriendProfileResponseDto"}},"required":["data","developCode","message"]},"CollectionSummaryResponseDto":{"type":"object","description":"개인 도감 요약 — 점령한 격자 수·올린 영상 총합·방문한 행정동 수·현재/최장 스트릭·획득 뱃지 수.","properties":{"totalGridCount":{"type":"integer","format":"int32","description":"내가 점령한 격자 수 (도감 크기)","example":15},"totalVideoCount":{"type":"integer","format":"int64","description":"내가 올린 영상 총합 (활성 영상만)","example":42},"visitedRegionCount":{"type":"integer","format":"int32","description":"내가 방문한 서로 다른 행정동 수","example":6},"currentStreak":{"type":"integer","format":"int32","description":"현재 스트릭 (연속 업로드 일수). 마지막 기록이 KST 그제 이전이면 끊긴 것으로 보고 0","example":12},"maxStreak":{"type":"integer","format":"int32","description":"최장 스트릭. 끊겨도 유지되는 역대 최고 기록","example":21},"badgeCount":{"type":"integer","format":"int32","description":"획득한 뱃지 수","example":7}},"required":["badgeCount","currentStreak","maxStreak","totalGridCount","totalVideoCount","visitedRegionCount"]},"FriendCollectionGridResponseDto":{"type":"object","description":"친구가 수집한 격자 하나 — 썸네일은 재생 허용 영상이 있을 때만 붙는다.","properties":{"gridId":{"type":"string","description":"격자 ID \"{grid_y}_{grid_x}\"","example":"19422_9582"},"gridY":{"type":"integer","format":"int32","description":"격자 Y 인덱스(지도 이동용, gridId 디코드값)","example":19422},"gridX":{"type":"integer","format":"int32","description":"격자 X 인덱스(지도 이동용, gridId 디코드값)","example":9582},"firstCollectedAt":{"type":"string","format":"date-time","description":"친구가 이 격자를 처음 수집한 시각 — 정렬 키","example":"2026-07-20T18:03:11Z"},"lastUploadedAt":{"type":"string","format":"date-time","description":"친구의 마지막 업로드 시각","example":"2026-07-21T09:12:00Z"},"videoCount":{"type":"integer","format":"int32","description":"그 격자에 친구가 올린 영상 수","example":3},"thumbnailUrl":{"type":["string","null"],"description":"썸네일 presigned GET URL — 재생 허용 영상이 없으면 null"},"regionName":{"type":["string","null"],"description":"격자 중심점 행정동 이름(무귀속/미판정이면 null)","example":"서울특별시 강남구 역삼1동"},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름 (예 \"서면\"). 구역 밖 격자면 null — 이때 라벨은 regionName 이다","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A 는 구역 북단, 열 1 은 서단). zoneName 과 항상 쌍이라 구역 밖이면 함께 null","example":"I-6"}},"required":["firstCollectedAt","gridId","gridX","gridY","lastUploadedAt","regionName","thumbnailUrl","videoCount","zoneCell","zoneName"]},"FriendProfileResponseDto":{"type":"object","description":"친구 프로필 — 프로필 정보와 도감 요약·최근 수집 격자.","properties":{"nickname":{"type":"string","description":"친구의 닉네임","example":"채우미"},"profileImageUrl":{"type":["string","null"],"description":"친구의 프로필 이미지 URL — 미설정이면 null"},"gridColor":{"type":"string","description":"친구의 도감 색상","enum":["BLUE","GREEN","PURPLE","ORANGE","PINK","YELLOW","RED","TEAL"],"example":"PINK"},"summary":{"$ref":"#/components/schemas/CollectionSummaryResponseDto","description":"친구의 도감 요약 — 본인이 보는 값과 동일하다"},"recentGrids":{"type":"array","description":"친구가 최근 수집한 격자 최대 30개 — 수집 시각 역순","items":{"$ref":"#/components/schemas/FriendCollectionGridResponseDto"}}},"required":["gridColor","nickname","profileImageUrl","recentGrids","summary"]},"ApiResponseDtoListFriendGridVideoResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/FriendGridVideoResponseDto"}}},"required":["data","developCode","message"]},"FriendGridVideoResponseDto":{"type":"object","description":"친구 격자 영상 리스트 항목","properties":{"videoId":{"type":"integer","format":"int64","description":"영상(방문 이벤트) ID. 재생 조회 진입 키","example":1042},"thumbnailUrl":{"type":["string","null"],"description":"썸네일 presigned GET URL. 썸네일 key 가 없으면 null"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"createdAt":{"type":"string","format":"date-time","description":"업로드(방문) 시각 — 정렬 키","example":"2026-07-20T18:03:11Z"}},"required":["createdAt","durationSec","thumbnailUrl","videoId"]},"ApiResponseDtoListRegionAggregateResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/RegionAggregateResponseDto"}}},"required":["data","developCode","message"]},"ApiResponseDtoListReceivedFriendRequestResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/ReceivedFriendRequestResponseDto"}}},"required":["data","developCode","message"]},"ReceivedFriendRequestResponseDto":{"type":"object","description":"받은 친구 요청 응답 — 최신 요청 우선 정렬.","properties":{"requesterId":{"type":"integer","format":"int64","description":"보낸 사용자 id — 수락/거절 호출의 경로 변수로 그대로 쓴다","example":3},"nickname":{"type":"string","description":"보낸 사용자의 닉네임","example":"채우미"},"profileImageUrl":{"type":["string","null"],"description":"보낸 사용자의 프로필 이미지 URL — 미설정이면 null"},"requestedAt":{"type":"string","format":"date-time","description":"요청 시각","example":"2026-08-03T12:00:00Z"}},"required":["nickname","profileImageUrl","requestedAt","requesterId"]},"ApiResponseDtoFriendPreviewResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/FriendPreviewResponseDto"}},"required":["data","developCode","message"]},"FriendPreviewResponseDto":{"type":"object","description":"친구 코드 미리보기 응답 — 요청 확정 전 확인 화면(\"OOO님에게 요청을 보낼까요?\")용. 조회 전용이며 요청 API 가 전 검증을 재수행한다.","properties":{"nickname":{"type":"string","description":"코드 소유자의 닉네임","example":"채우미"}},"required":["nickname"]},"ApiResponseDtoFriendCodeResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/FriendCodeResponseDto"}},"required":["data","developCode","message"]},"FriendCodeResponseDto":{"type":"object","description":"내 친구 코드 응답","properties":{"friendCode":{"type":"string","description":"고정 친구 코드 — 혼동 문자(I·O·0·1) 제외 32종 8자, 재발급 없음","example":"AB3DE7GH"}},"required":["friendCode"]},"ApiResponseDtoEventVideoDetailResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventVideoDetailResponseDto"}},"required":["data","developCode","message"]},"EventVideoCommentPageResponseDto":{"type":"object","description":"행사 영상 댓글 페이지 (keyset 커서 페이지네이션)","properties":{"comments":{"type":"array","description":"이 페이지의 댓글 (오래된 순). 댓글이 없으면 빈 배열","items":{"$ref":"#/components/schemas/EventVideoCommentResponseDto"}},"hasNext":{"type":"boolean","description":"다음 페이지 존재 여부 (lookahead 판정)"},"nextCursor":{"type":["string","null"],"description":"다음 페이지 조회용 opaque 커서. 다음 요청 cursor 파라미터에 그대로 넣는다. 마지막 페이지면 null"}},"required":["comments","hasNext","nextCursor"]},"EventVideoDetailResponseDto":{"type":"object","description":"행사 영상 상세","properties":{"videoId":{"type":"integer","format":"int64","description":"영상 ID","example":1042},"occurrenceId":{"type":"integer","format":"int64","description":"소속 행사 회차 ID","example":12},"occurrenceStatus":{"type":"string","description":"요청 시점 회차 상태 (UPCOMING/LIVE/UPLOAD_GRACE/ARCHIVED)","example":"LIVE"},"locationId":{"type":"integer","format":"int64","description":"소속 행사 위치 ID","example":34},"locationName":{"type":"string","description":"소속 행사 위치 이름","example":"영화의전당"},"representativeGridId":{"type":"string","description":"영상이 붙은 대표 격자 ID","example":"19422_9582"},"zoneName":{"type":["string","null"],"description":"대표 격자가 속한 구역 이름. 구역 밖이면 null — 이때 라벨은 regionName 이다","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\". zoneName 과 항상 쌍이라 구역 밖이면 함께 null","example":"A-14"},"regionName":{"type":["string","null"],"description":"대표 격자 중심점 행정동 이름 — 구역 밖 격자의 폴백 라벨. 무귀속이면 null","example":"부산광역시 부산진구 부전2동"},"playbackUrl":{"type":"string","description":"재생본 presigned GET URL"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초)","example":15},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각","example":"2026-10-06T12:00:00Z"},"createdAt":{"type":"string","format":"date-time","description":"업로드 시각","example":"2026-10-06T12:30:00Z"},"uploaderNickname":{"type":"string","description":"작성자 닉네임","example":"필맵러"},"interactionLocked":{"type":"boolean","description":"댓글·도움돼요 입력 UI 를 비활성화할지 여부 — 아카이브 전환(행사 종료 + 30일)부터 true","example":false},"helpfulCount":{"type":"integer","format":"int64","description":"도움돼요 수","example":12},"helpfulByMe":{"type":"boolean","description":"내가 도움돼요를 누른 상태인지. 비로그인 조회는 항상 false","example":false},"commentCount":{"type":"integer","format":"int64","description":"댓글 수","example":3},"comments":{"$ref":"#/components/schemas/EventVideoCommentPageResponseDto","description":"댓글 첫 페이지 (오래된 순 20건)"}},"required":["commentCount","comments","createdAt","durationSec","helpfulByMe","helpfulCount","interactionLocked","locationId","locationName","occurrenceId","occurrenceStatus","playbackUrl","recordedAt","regionName","representativeGridId","uploaderNickname","videoId","zoneCell","zoneName"]},"ApiResponseDtoEventVideoCommentPageResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventVideoCommentPageResponseDto"}},"required":["data","developCode","message"]},"ApiResponseDtoListEventOccurrenceChipResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/EventOccurrenceChipResponseDto"}}},"required":["data","developCode","message"]},"EventOccurrenceChipResponseDto":{"type":"object","description":"뷰포트에 걸친 행사 회차 하나 — 지도 홈 칩 재료","properties":{"occurrenceId":{"type":"integer","format":"int64","description":"행사 회차 id","example":12},"title":{"type":"string","description":"행사명 — 칩 라벨 재료","example":"부산불꽃축제"},"cityName":{"type":"string","description":"대상 지역 시 이름 — 시 칩 묶음 기준","example":"부산"},"startsAt":{"type":"string","format":"date-time","description":"행사 시작 시각","example":"2026-10-06T01:00:00Z"},"endsAt":{"type":"string","format":"date-time","description":"행사 종료 시각","example":"2026-10-15T13:00:00Z"},"status":{"type":"string","description":"서버 시각 기준 파생 상태 — 이 목록에는 두 값만 담긴다","enum":["UPCOMING","LIVE"],"example":"LIVE"}},"required":["cityName","endsAt","occurrenceId","startsAt","status","title"]},"ApiResponseDtoEventOccurrenceDetailResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventOccurrenceDetailResponseDto"}},"required":["data","developCode","message"]},"EventOccurrenceDetailResponseDto":{"type":"object","description":"행사 회차 상세 — 행사방 헤더","properties":{"occurrenceId":{"type":"integer","format":"int64","description":"행사 회차 id","example":12},"seriesId":{"type":"integer","format":"int64","description":"행사 시리즈 id — 이전 회차 묶음 기준","example":3},"title":{"type":"string","description":"행사명","example":"부산불꽃축제"},"startsAt":{"type":"string","format":"date-time","description":"행사 시작 시각","example":"2026-10-06T01:00:00Z"},"endsAt":{"type":"string","format":"date-time","description":"행사 종료 시각","example":"2026-10-15T13:00:00Z"},"uploadClosesAt":{"type":"string","format":"date-time","description":"영상 업로드 마감 — 종료 30일 후 파생값","example":"2026-11-14T13:00:00Z"},"status":{"type":"string","description":"서버 시각 기준 파생 상태","enum":["UPCOMING","LIVE","UPLOAD_GRACE","ARCHIVED"],"example":"LIVE"},"notificationOn":{"type":"boolean","description":"알림 구독 여부 — 구독 행 존재이면서 회차가 예정·진행 중일 때만 true. 비로그인은 항상 false 고, 종료된 회차는 구독 행이 남아 있어도 false 다","example":false},"previousOccurrences":{"type":"array","description":"같은 시리즈의 지난 회차 — 최신순. 없으면 빈 배열","items":{"$ref":"#/components/schemas/PreviousOccurrenceDto"}}},"required":["endsAt","notificationOn","occurrenceId","previousOccurrences","seriesId","startsAt","status","title","uploadClosesAt"]},"PreviousOccurrenceDto":{"type":"object","description":"같은 시리즈의 지난 회차 하나","properties":{"occurrenceId":{"type":"integer","format":"int64","description":"행사 회차 id","example":9},"title":{"type":"string","description":"행사명","example":"부산불꽃축제"},"startsAt":{"type":"string","format":"date-time","description":"행사 시작 시각","example":"2025-10-04T01:00:00Z"},"endsAt":{"type":"string","format":"date-time","description":"행사 종료 시각","example":"2025-10-13T13:00:00Z"}},"required":["endsAt","occurrenceId","startsAt","title"]},"ApiResponseDtoEventViewerCountResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventViewerCountResponseDto"}},"required":["data","developCode","message"]},"EventViewerCountResponseDto":{"type":"object","description":"행사방 현재 열람 인원 응답.","properties":{"viewerCount":{"type":["integer","null"],"format":"int32","description":"현재 열람 인원 — 마지막 heartbeat 가 90초 이내인 고유 세션 수. 0 은 아무도 없음(표시), null 은 캐시 장애(숨김)","example":120}},"required":["viewerCount"]},"ApiResponseDtoListEventLocationResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/EventLocationResponseDto"}}},"required":["data","developCode","message"]},"EventLocationResponseDto":{"type":"object","description":"행사 위치 하나 — 영역 격자·대표 격자·표시명 재료·영상 수","properties":{"locationId":{"type":"integer","format":"int64","description":"행사 위치 id — 위치별 영상 피드 진입 키","example":31},"name":{"type":"string","description":"위치 이름","example":"부산역 팝업"},"type":{"type":"string","description":"위치 유형 — 표시 라벨 변환은 FE 몫","enum":["POPUP","EXPERIENCE_ZONE","PARADE","PHOTO_ZONE","ETC"],"example":"POPUP"},"operatingHours":{"type":["string","null"],"description":"운영 시간 표시 문자열","example":"11:00 ~ 20:00"},"gridIds":{"type":"array","description":"영역을 구성하는 격자 전체 — FE 영역 채색 재료","example":["19443_9582"],"items":{"type":"string"}},"representativeGridId":{"type":"string","description":"대표 격자 — 이 위치의 영상이 붙는 단 하나의 격자","example":"19443_9582"},"zoneName":{"type":["string","null"],"description":"대표 격자가 속한 구역 이름. 구역 밖이면 null","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 안 위치 코드. 구역 밖이면 null","example":"A-14"},"regionName":{"type":["string","null"],"description":"대표 격자의 행정동 이름 — 구역 밖 표시명 폴백. 무귀속이면 null","example":"부전동"},"videoCount":{"type":"integer","format":"int64","description":"이 위치의 영상 수 — 조회 시점 실측(전역 노출 게이트 통과분)","example":7}},"required":["gridIds","locationId","name","operatingHours","regionName","representativeGridId","type","videoCount","zoneCell","zoneName"]},"ApiResponseDtoEventLocationVideoPageResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventLocationVideoPageResponseDto"}},"required":["data","developCode","message"]},"EventLocationVideoPageResponseDto":{"type":"object","description":"위치별 영상 피드 페이지 (keyset 커서 페이지네이션)","properties":{"videos":{"type":"array","description":"이 페이지의 영상. 조건에 맞는 영상이 없으면 빈 배열","items":{"$ref":"#/components/schemas/EventLocationVideoResponseDto"}},"hasNext":{"type":"boolean","description":"다음 페이지 존재 여부 (lookahead 판정)"},"nextCursor":{"type":["string","null"],"description":"다음 페이지 조회용 opaque 커서. 다음 요청 cursor 파라미터에 그대로 넣는다. 마지막 페이지면 null"}},"required":["hasNext","nextCursor","videos"]},"EventLocationVideoResponseDto":{"type":"object","description":"위치별 영상 피드 항목","properties":{"videoId":{"type":"integer","format":"int64","description":"영상 ID — 상세 진입 키","example":1042},"thumbnailUrl":{"type":"string","description":"썸네일 presigned GET URL"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초)","example":15},"createdAt":{"type":"string","format":"date-time","description":"업로드 시각","example":"2026-10-06T12:30:00Z"},"helpfulCount":{"type":"integer","format":"int64","description":"도움돼요 수","example":12},"commentCount":{"type":"integer","format":"int64","description":"댓글 수","example":3}},"required":["commentCount","createdAt","durationSec","helpfulCount","thumbnailUrl","videoId"]},"ApiResponseDtoListRegionVideoResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/RegionVideoResponseDto"}}},"required":["data","developCode","message"]},"RegionVideoResponseDto":{"type":"object","description":"동 단위 내 영상 리스트 항목 — 그 행정동 격자들에 올린 내 영상 하나.","properties":{"videoId":{"type":"integer","format":"int64","description":"영상(방문 이벤트) ID. 개별 재생·교체·삭제 진입 키","example":1042},"gridId":{"type":"string","description":"영상이 속한 격자 ID \"{grid_y}_{grid_x}\" — 항목별 격자 라벨·지도 이동용","example":"19422_9582"},"thumbnailUrl":{"type":["string","null"],"description":"썸네일 presigned GET URL. READY 아니면(썸네일 key 없음) null"},"processingStatus":{"type":"string","description":"영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED)","example":"READY"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"createdAt":{"type":"string","format":"date-time","description":"업로드(방문) 시각 — 정렬 키","example":"2026-07-20T18:03:11Z"},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름 (예 \"서면\"). 구역 밖 격자면 null — 이 화면은 행정동 헤더 아래 목록이라 폴백 이름을 문맥에서 알 수 있어 항목에 regionName 을 따로 담지 않는다","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A 는 구역 북단, 열 1 은 서단). zoneName 과 항상 쌍이라 구역 밖이면 함께 null","example":"I-6"}},"required":["createdAt","durationSec","gridId","processingStatus","thumbnailUrl","videoId","zoneCell","zoneName"]},"ApiResponseDtoListUploadHistoryResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/UploadHistoryResponseDto"}}},"required":["data","developCode","message"]},"UploadHistoryResponseDto":{"type":"object","description":"날짜별 업로드 기록 항목 — 업로드가 있었던 KST 날짜 하나와 그날의 건수.","properties":{"uploadDate":{"type":"string","format":"date","description":"업로드가 있었던 KST 날짜","example":"2026-08-11"},"uploadCount":{"type":"integer","format":"int32","description":"그날 업로드한 영상 수 (1 이상)","example":3}},"required":["uploadCount","uploadDate"]},"ApiResponseDtoCollectionSummaryResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/CollectionSummaryResponseDto"}},"required":["data","developCode","message"]},"ApiResponseDtoListCollectionGridResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/CollectionGridResponseDto"}}},"required":["data","developCode","message"]},"CollectionGridResponseDto":{"type":"object","description":"갤러리 격자 항목 — 내가 수집한 격자 하나와 cover 썸네일.","properties":{"gridId":{"type":"string","description":"격자 ID \"{grid_y}_{grid_x}\"","example":"19422_9582"},"gridY":{"type":"integer","format":"int32","description":"격자 Y 인덱스(지도 이동용, gridId 디코드값)","example":19422},"gridX":{"type":"integer","format":"int32","description":"격자 X 인덱스(지도 이동용, gridId 디코드값)","example":9582},"firstCollectedAt":{"type":"string","format":"date-time","description":"최초 수집(점령) 시각 — 정렬 키","example":"2026-07-20T18:03:11Z"},"lastUploadedAt":{"type":"string","format":"date-time","description":"마지막 방문(업로드) 시각","example":"2026-07-21T09:12:00Z"},"videoCount":{"type":"integer","format":"int32","description":"그 격자 내 내 영상 수","example":3},"coverVideoId":{"type":["integer","null"],"format":"int64","description":"cover 영상 ID(없으면 null)","example":1042},"coverThumbnailUrl":{"type":["string","null"],"description":"cover 썸네일 presigned GET URL(없거나 READY 이전이면 null)"},"coverDurationSec":{"type":["integer","null"],"format":"int32","description":"cover 영상 길이(초) — 카드 duration 뱃지 재료. READY 이전에도 실리고 cover 자체가 없을 때만 null","example":12},"regionName":{"type":["string","null"],"description":"격자 중심점 행정동 이름(무귀속/미판정이면 null)","example":"서울특별시 강남구 역삼1동"},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름 (예 \"서면\"). 구역 밖 격자면 null — 이때 라벨은 regionName 이다","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A 는 구역 북단, 열 1 은 서단). zoneName 과 항상 쌍이라 구역 밖이면 함께 null","example":"I-6"}},"required":["coverDurationSec","coverThumbnailUrl","coverVideoId","firstCollectedAt","gridId","gridX","gridY","lastUploadedAt","regionName","videoCount","zoneCell","zoneName"]},"ApiResponseDtoListMyBadgeResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/MyBadgeResponseDto"}}},"required":["data","developCode","message"]},"MyBadgeResponseDto":{"type":"object","description":"내 뱃지 목록 행 — 획득+미획득 (은퇴 뱃지는 획득자에게만)","properties":{"badgeId":{"type":"integer","format":"int64","description":"뱃지 ID","example":2},"code":{"type":"string","description":"뱃지 code","example":"EXPLORER_10"},"name":{"type":"string","description":"표시명","example":"탐험가 I"},"description":{"type":["string","null"],"description":"설명 — badges.description 은 NULL 허용 컬럼이다","example":"격자 10개를 수집했어요"},"iconUrl":{"type":["string","null"],"description":"아이콘 URL (에셋 확정 전 null)","example":null},"earned":{"type":"boolean","description":"획득 여부","example":true},"earnedAt":{"type":["string","null"],"format":"date-time","description":"획득 시각 — 미획득이면 null","example":"2026-07-29T11:02:31Z"},"isNew":{"type":"boolean","description":"미확인(새 뱃지) 여부 — 미획득이면 false","example":false},"featuredRank":{"type":["integer","null"],"format":"int32","description":"대표 뱃지 순서(1·2) — 대표 아니면 null","example":1}},"required":["badgeId","code","description","earned","earnedAt","featuredRank","iconUrl","isNew","name"]},"AdminVideoReviewResponseDto":{"type":"object","description":"관리자 단건 영상 확인 응답 — 영상 메타와 재생·썸네일 presigned GET URL.","properties":{"videoId":{"type":"integer","format":"int64","description":"영상 ID","example":1042},"status":{"type":"string","description":"영상 상태 — BLINDED 여도 발급된다 (DELETED 만 404)","enum":["ACTIVE","BLINDED","DELETED"],"example":"BLINDED"},"processingStatus":{"type":"string","description":"영상 처리 상태 — READY 일 때만 재생 URL 이 발급된다","enum":["UPLOADED","ENCODING","BLURRING","READY","FAILED"],"example":"READY"},"visibility":{"type":"string","description":"공개 범위 — PRIVATE 여도 발급된다 (관리자 확인은 은닉 없음)","enum":["PUBLIC","PRIVATE","FRIENDS"],"example":"PRIVATE"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각 (표시용)","example":"2026-07-20T18:03:11Z"},"playbackUrl":{"type":["string","null"],"description":"재생본 presigned GET URL — READY 가 아니면 null"},"thumbnailUrl":{"type":["string","null"],"description":"썸네일 presigned GET URL — 썸네일 key 없음(READY 이전)이면 null"},"expiresInSec":{"type":["integer","null"],"format":"int64","description":"playbackUrl presign TTL(초) — playbackUrl=null 이면 null","example":600}},"required":["durationSec","expiresInSec","playbackUrl","processingStatus","recordedAt","status","thumbnailUrl","videoId","visibility"]},"ApiResponseDtoAdminVideoReviewResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminVideoReviewResponseDto"}},"required":["data","developCode","message"]},"AdminReportItemResponseDto":{"type":"object","description":"관리자 신고 목록 항목 — 신고 한 건과 판단에 필요한 주변 정보.","properties":{"reportId":{"type":"integer","format":"int64","description":"신고 ID — 승인·기각 경로 변수로 그대로 쓴다","example":7},"status":{"type":"string","description":"신고 처리 상태","enum":["PENDING","REVIEWING","RESOLVED","REJECTED"],"example":"PENDING"},"reason":{"type":"string","description":"신고 사유","enum":["INAPPROPRIATE","PRIVACY","SPAM","COPYRIGHT","OTHER"],"example":"INAPPROPRIATE"},"detail":{"type":["string","null"],"description":"신고자가 적은 상세 설명 — OTHER 가 아닌 사유는 없을 수 있다"},"createdAt":{"type":"string","format":"date-time","description":"신고 접수 시각","example":"2026-08-06T10:15:00Z"},"reporterId":{"type":"integer","format":"int64","description":"신고자의 사용자 ID","example":3},"reporterNickname":{"type":"string","description":"신고자의 닉네임","example":"정민"},"videoId":{"type":"integer","format":"int64","description":"신고 대상 영상 ID — 단건 확인·블라인드 해제 경로 변수로 쓴다","example":1042},"videoStatus":{"type":"string","description":"신고 대상 영상의 현재 상태 (ACTIVE/BLINDED/DELETED)","enum":["ACTIVE","BLINDED","DELETED"],"example":"ACTIVE"},"videoOwnerNickname":{"type":"string","description":"영상 소유자의 닉네임","example":"성민"},"reviewedBy":{"type":["integer","null"],"format":"int64","description":"처리한 관리자의 사용자 ID — 미처리면 null","example":1},"reviewedAt":{"type":["string","null"],"format":"date-time","description":"처리 시각 — 미처리면 null","example":"2026-08-06T11:00:00Z"}},"required":["createdAt","detail","reason","reportId","reporterId","reporterNickname","reviewedAt","reviewedBy","status","videoId","videoOwnerNickname","videoStatus"]},"AdminReportListResponseDto":{"type":"object","description":"관리자 신고 목록 응답 — 상태 필터 기준 한 페이지.","properties":{"items":{"type":"array","description":"이 페이지의 신고 목록. 정렬은 접수 최신순 고정","items":{"$ref":"#/components/schemas/AdminReportItemResponseDto"}},"page":{"type":"integer","format":"int32","description":"현재 페이지 번호 (0부터)","example":0},"size":{"type":"integer","format":"int32","description":"페이지 크기","example":20},"totalElements":{"type":"integer","format":"int64","description":"필터에 해당하는 전체 신고 수","example":1},"totalPages":{"type":"integer","format":"int32","description":"전체 페이지 수","example":1}},"required":["items","page","size","totalElements","totalPages"]},"ApiResponseDtoAdminReportListResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminReportListResponseDto"}},"required":["data","developCode","message"]}},"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT"}}}} \ No newline at end of file +{"openapi":"3.1.0","info":{"title":"FillMap API","description":"FillMap API 문서","version":"v1"},"servers":[{"url":"https://api.fillmap.kr","description":"Generated server url"}],"security":[{"bearerAuth":[]}],"tags":[{"name":"행사 (Events)","description":"지도 홈 행사 칩·행사방 헤더·행사 위치 목록 조회 API."},{"name":"행사 (Events)","description":"지도에서 누른 격자를 행사 위치로 해석하는 역조회 API."},{"name":"미션 영상 (Mission Videos)","description":"미션 상세 하단 \"이 미션의 영상\" 목록 API — 그 미션의 대상 격자에서 미션 기간에 촬영된 공개 영상."},{"name":"영상 (Video)","description":"영상 업로드·교체·삭제 API. 업로드는 presigned URL 발급 → S3 직접 업로드 → 메타데이터 저장 순서다."},{"name":"인증 (Auth)","description":"회원가입·로그인·소셜 로그인·토큰 재발급 API. 이 그룹의 엔드포인트는 인증 없이 호출한다."},{"name":"알림 (Notification)","description":"FCM 푸시 토큰 등록/갱신·해제 API."},{"name":"장소 검색 (Search)","description":"장소명 자유 텍스트 검색 — 카카오 로컬 키워드 검색 실시간 프록시 + 격자 ID 합성."},{"name":"격자 (Grid)","description":"개인 도감 색칠 격자 조회 API — 로그인 사용자가 점령한 격자만 반환한다.\n\n격자는 EPSG:5179 미터 평면에서 100m 로 나눈 셀이다(2026-08-08 MSG-347 전까지는 위경도 등간격 근사였다). gridId 포맷 `\"{grid_y}_{grid_x}\"` 와 이 API 들의 요청·응답 구조는 그대로지만 **값은 전면 교체됐다** (같은 장소가 `41642_110458` 에서 `19422_9582` 로 바뀌었다). 예전 gridId 를 저장해 둔 클라이언트는 빈 결과를 받으므로 캐시를 비워야 한다.\n\n셀은 위경도 축과 평행하지 않다(자오선 수렴 최대 약 1.6도). 지도에 그릴 때 남서·북동 2점으로 만든 직사각형을 쓰면 어긋나므로 **꼭짓점 4점 폴리곤**으로 그린다. 화면에 보이는 격자 범위를 구할 때도 2점이 아니라 꼭짓점 4점의 min/max 를 써야 가장자리 셀이 빠지지 않는다.\n\n클라이언트가 같은 격자를 계산하려면 서버와 **글자 단위로 같은 proj4 정의**를 써야 한다: `+proj=tmerc +lat_0=38 +lon_0=127.5 +k=0.9996 +x_0=1000000 +y_0=2000000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs`. 대조용 전국 샘플 200건은 서버 레포 `src/test/resources/fixtures/grid-epsg5179-samples.json` 에 있다."},{"name":"알림 (Notification)","description":"받은 알림 목록 조회와 읽음 처리 API."},{"name":"인증-개발용 (Auth Dev)","description":"로컬/dev 전용 — 소셜 로그인을 실제 소셜 토큰 없이 백엔드에서 테스트. 운영(prod) 미노출."},{"name":"도감 (Collection)","description":"개인 도감 요약 조회 API — 로그인 사용자의 점령·영상·방문 행정동 집계."},{"name":"격자 상세 (Grid Videos)","description":"격자를 탭했을 때 그 격자의 영상 조회 API — 내 영상 리스트·전역 대표 영상·전역 인기 목록."},{"name":"AI 경로 추천 (Routes)","description":"자연어 한 문장과 뷰포트로 활성 미션·행사·장소 검색 실조회 후보에 방문 순서와 이유를 붙여 돌려준다."},{"name":"구역 (Zone)","description":"구역(\"서면\" 등)의 이름과 격자 사각형 범위. 검색바에서 구역으로 지도를 옮기거나 구역 범위를 오버레이로 그릴 때 쓴다 — 격자 표시명(\"서면 A-14\")은 서버가 계산해 격자 응답에 함께 싣는다."},{"name":"행사 (Events)","description":"행사 위치의 영상 업로드·피드·상세 API."},{"name":"미션 (Missions)","description":"지도 오버레이용 활성 미션 목록·내 진행도·미션 상세 조회 API."},{"name":"알림 (Notification)","description":"카테고리별 알림 수신 설정 조회/토글 API."},{"name":"행정동 (Region)","description":"좌표를 포함하는 행정동을 우리 region_code 체계로 판정하는 역지오코딩 API."},{"name":"인기 검색어 (Trending)","description":"사용자 검색어 일별 집계 기반 인기 검색어 순위 — 오늘+어제 합산 TOP 10."},{"name":"전역 탐색 (Region Explore)","description":"행정동 축으로 전역 공개 콘텐츠를 탐색하는 API — 지도 홈 패널·전체 보기 격자 썸네일 뷰·검색 무입력 전체 지역 리스트."},{"name":"관리자 신고 처리 (Admin Report)","description":"접수된 영상 신고의 열람·승인·기각과 블라인드 해제·단건 확인 API (MSG-195). ADMIN 권한 필수."},{"name":"사용자 (User)","description":"계정 관리 API. 인증 필수 — 본인 계정만 대상이다."},{"name":"행사방 (Event)","description":"행사방 열람 인원 heartbeat·조회 API."},{"name":"뱃지 (Badge)","description":"뱃지 API — 내 뱃지 목록 조회 · 대표 뱃지 집합 교체."},{"name":"친구 (Friend)","description":"고정 친구 코드 기반 친구 관계 API — 코드·요청·수락·거절·삭제 (MSG-185), 친구 목록·친구 프로필 조회 (MSG-186), 친구 도감 레이어(격자 뷰포트·격자 영상 목록, MSG-187, 축소 시야의 행정 단위 집계는 MSG-356). 인증 필수."},{"name":"신고 (Report)","description":"영상 신고 접수 API (MSG-192). 인증 필수."},{"name":"행사 (Events)","description":"행사 영상의 댓글·도움돼요 API."},{"name":"핫구역 (HotZone)","description":"최근 48시간 방문(업로드) 신호 상위 격자 조회 API — 개인화 없는 공용 목록."}],"paths":{"/api/videos/{videoId}":{"get":{"tags":["영상 (Video)"],"summary":"단건 영상 재생 조회","description":"영상 하나의 표시용 메타와 재생본 presigned GET URL을 발급한다. 소유자·타인 모두 조회할 수 있으나 삭제·블라인드(타인)는 404, 비공개(타인)·친구만 공개(비친구)는 403이다. READY가 아니면 playbackUrl은 null이다. 비로그인도 조회할 수 있으며 전체 공개 영상만 통과한다 — 나머지는 타인이 요청할 때와 같은 응답으로 거절된다.","operationId":"getPlayback","parameters":[{"name":"videoId","in":"path","description":"재생할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoVideoPlaybackResponseDto"}}}}}},"put":{"tags":["영상 (Video)"],"summary":"영상 교체","description":"기존 영상을 새 파일로 교체한다. 좌표를 생략하면 격자를 유지하고 파일만 교체하며, 좌표를 보내면 기존과 같은 격자여야 한다(다르면 거부). 교체 직후 상태는 UPLOADED다.","operationId":"replace","parameters":[{"name":"videoId","in":"path","description":"교체할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1001}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VideoReplaceRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoVideoReplaceResponseDto"}}}}}},"delete":{"tags":["영상 (Video)"],"summary":"영상 삭제","description":"영상을 삭제한다. 해당 격자의 내 영상이 모두 사라지면 점령이 롤백(색칠 해제)된다.","operationId":"delete","parameters":[{"name":"videoId","in":"path","description":"삭제할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1001}],"responses":{"200":{"description":"OK"}}}},"/api/users/me/profile-image":{"put":{"tags":["사용자 (User)"],"summary":"프로필 이미지 변경 확정","description":"presign 으로 올린 pending 키를 확정해 프로필 이미지를 교체하고 갱신된 프로필을 반환한다. 내 pending 경로가 아니거나 확장자 없는 키는 1401, S3 에 실제로 없는 키는 1402, 실측 크기가 5MB 를 넘으면 1413. 교체된 이전 이미지는 응답 후 정리된다.","operationId":"updateProfileImage","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileImageUpdateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoUserProfileResponseDto"}}}}}},"delete":{"tags":["사용자 (User)"],"summary":"프로필 이미지 제거","description":"프로필 이미지를 기본 상태(null)로 되돌린다. 이미 기본 상태여도 성공한다(멱등). 응답은 변경 확정과 같은 프로필 형태다.","operationId":"removeProfileImage","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoUserProfileResponseDto"}}}}}}},"/api/users/me/nickname":{"put":{"tags":["사용자 (User)"],"summary":"닉네임 수정","description":"닉네임(2~20자)을 교체하고 변경 후 프로필을 반환한다. 중복 닉네임은 허용된다.","operationId":"updateNickname","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NicknameUpdateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoUserProfileResponseDto"}}}}}}},"/api/users/me/marketing-consent":{"put":{"tags":["사용자 (User)"],"summary":"마케팅 정보 수신 동의 변경","description":"가입 후 설정 화면에서 마케팅 수신 동의를 켜거나 끈다. 이미 저장된 값과 같은 값을 다시 보내도 성공하며, 이때 서버가 보관하는 마지막 변경 시각은 갱신되지 않는다(멱등). 응답은 변경 후 동의 상태다 — 위치정보 사용 동의 변경과 같은 구조다.","operationId":"updateMarketingConsent","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarketingConsentUpdateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoConsentStatusResponseDto"}}}}}}},"/api/users/me/location-consent":{"put":{"tags":["사용자 (User)"],"summary":"위치정보 사용 동의 켜기","description":"위치기반서비스 이용 동의를 켜고 변경 후 프로필을 반환한다. 첫 로그인 온보딩의 동의 제출과 프로필 화면이 이 엔드포인트 하나를 공용으로 쓴다.\n\n이 동의는 철회할 수 없다 — consented=false 요청은 1400 으로 거절된다. 되돌리려면 계정을 삭제해야 하며, 이는 다른 필수 약관 동의와 같은 규칙이다. 이미 켜진 상태에서 다시 켜는 요청은 성공하고, 이때 서버가 보관하는 동의 시각은 갱신되지 않는다(멱등).","operationId":"updateLocationConsent","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationConsentUpdateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoUserProfileResponseDto"}}}}}}},"/api/users/me/consents":{"get":{"tags":["사용자 (User)"],"summary":"가입 약관 동의 상태 조회","description":"로그인 직후 동의 게이트를 띄울지 판별하는 재료다. 항목별 동의 여부 5종과 필수 4항목 완료 여부(requiredCompleted)를 함께 반환한다 — 필수 항목 목록이 늘어도 클라이언트가 조립을 고치지 않도록 서버가 계산한다.\n\n위치기반서비스 항목(locationTerms)은 프로필 화면의 위치정보 사용 동의와 같은 한 값이다. 이 동의는 철회할 수 없으므로 한 번 true 가 되면 되돌아가지 않고, 필수 동의를 마친 사용자에게 게이트가 다시 뜨는 일도 없다. 동의 시각은 서버에만 보관하고 응답에 싣지 않는다.","operationId":"getConsentStatus","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoConsentStatusResponseDto"}}}}}},"put":{"tags":["사용자 (User)"],"summary":"가입 약관 동의 제출","description":"가입 게이트의 \"동의하고 시작하기\" 제출이다. 필수 4항목(만 14세 이상·서비스 이용약관·개인정보 수집·이용·위치기반서비스 이용약관)은 true 여야 하고 마케팅만 선택이다 — 하나라도 false 거나 누락이면 400 이며 이때 아무 항목도 저장되지 않는다.\n\n같은 내용을 다시 보내도 성공한다(멱등). 재제출이 필수 4항목의 최초 동의 시각을 덮지 않고, 마케팅만 값이 실제로 달라질 때 변경 시각이 갱신된다. 제출은 위치정보 사용 동의도 함께 켜므로 프로필 화면의 위치 동의와 값이 하나다. 응답은 제출 후 동의 상태다.","operationId":"submitConsents","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsentSubmitRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoConsentStatusResponseDto"}}}}}}},"/api/event-videos/{videoId}/helpful":{"put":{"tags":["행사 (Events)"],"summary":"행사 영상 도움돼요 추가","description":"이 영상에 도움돼요를 누른다. 사용자당 한 번이고 이미 누른 상태에서 다시 불러도 성공하며 수가 늘지 않는다 — 네트워크 재시도가 수를 흔들지 않도록 PUT 으로 둔 이유다.\n\n응답의 helpfulCount 는 처리 후 다시 센 값이라 그 사이 다른 사람이 누른 것도 반영된다.\n\n아카이브된 행사(종료 30일 후)에서는 409 + developCode 13422 다 — 유예 기간까지는 계속 누를 수 있다. 상세에 보이지 않는 영상은 404 + 13406 이다.","operationId":"addHelpful","parameters":[{"name":"videoId","in":"path","description":"영상 id","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventVideoHelpfulResponseDto"}}}}}},"delete":{"tags":["행사 (Events)"],"summary":"행사 영상 도움돼요 취소","description":"누른 도움돼요를 되돌린다. 누른 적이 없어도 실패하지 않는다(멱등).\n\n아카이브된 행사(종료 30일 후)에서는 409 + developCode 13422 다 — 유예 기간까지는 취소할 수 있다. 상세에 보이지 않는 영상은 404 + 13406 이다.","operationId":"removeHelpful","parameters":[{"name":"videoId","in":"path","description":"영상 id","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventVideoHelpfulResponseDto"}}}}}}},"/api/event-occurrences/{occurrenceId}/notification":{"put":{"tags":["행사 (Events)"],"summary":"행사 알림 구독 토글","description":"행사 회차 단위로 알림을 켜고 끈다. 행사방에는 참여 절차가 없어 구독이 사용자와 행사가 맺는 관계의 전부다. 같은 값을 반복 요청해도 같은 결과로 성공한다.\n\n응답의 enabled 는 저장된 구독 행의 존재가 아니라 **노출 상태**다 — 구독 행이 있으면서 회차가 예정이거나 진행 중일 때만 true 이고, 종료된 회차는 행이 남아 있어도 false 다(종료 시점부터 즉시 OFF, 정리 배치를 기다리지 않는다).\n\n종료된 행사(업로드 유예·아카이브)에 켜기를 요청하면 409 + developCode 13422 다 — 시작 알림이 이미 지나 받을 것이 없기 때문이다. 끄기는 상태와 무관하게 언제나 성공한다. 없는 회차이거나 아직 노출 기간 전인 예정 회차면 404 + developCode 13404 다.\n\n실제 발송은 이 구독 위에 알림 설정의 EVENT 카테고리 스위치가 겹쳐 결정된다 — 카테고리를 끈 사용자에게는 구독이 켜져 있어도 발송되지 않는다.","operationId":"updateSubscription","parameters":[{"name":"occurrenceId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventNotificationUpdateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventNotificationResponseDto"}}}}}}},"/api/badges/featured":{"put":{"tags":["뱃지 (Badge)"],"summary":"대표 뱃지 집합 교체","description":"획득한 뱃지 중 최대 2개를 대표로 교체 지정한다(멱등). 배열 순서 = 표시 순서(rank 1·2), 빈 배열은 전부 해제. 미획득·미존재 뱃지는 7403, 중복 id 는 7400, 3개 이상은 400 이다.","operationId":"replaceFeatured","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeaturedBadgeRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListFeaturedBadgeResponseDto"}}}}}}},"/api/videos":{"post":{"tags":["영상 (Video)"],"summary":"영상 메타데이터 저장 (업로드 확정)","description":"S3 업로드 완료 후 영상 메타데이터를 저장하고 좌표로 격자를 매핑한다. 해당 격자에 내 첫 영상이면 점령(occupied=true)된다.","operationId":"upload","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VideoUploadRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoVideoUploadResponseDto"}}}}}}},"/api/videos/{videoId}/reports":{"post":{"tags":["신고 (Report)"],"summary":"영상 신고 접수","description":"다른 사람의 영상을 사유 5종(INAPPROPRIATE, PRIVACY, SPAM, COPYRIGHT, OTHER) 중 하나와 함께 신고한다. 접수된 신고는 PENDING 으로 쌓여 관리자 처리의 입력이 되며, 접수 자체는 영상 상태를 바꾸지 않는다. 같은 영상 재신고는 409, 자기 영상 신고는 400, 없는 영상·삭제·블라인드 영상은 재생 조회와 같은 404 다.","operationId":"report","parameters":[{"name":"videoId","in":"path","description":"신고할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportCreateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoReportCreateResponseDto"}}}}}}},"/api/videos/presigned-url":{"post":{"tags":["영상 (Video)"],"summary":"업로드용 presigned URL 발급","description":"영상 파일을 S3에 직접 올릴 presigned URL을 발급한다. 이 URL로 PUT 업로드한 뒤 메타데이터 저장을 호출한다.","operationId":"issuePresignedUrl","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PresignedUrlRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoPresignedUrlResponseDto"}}}}}}},"/api/videos/highlight-preview":{"post":{"tags":["영상 (Video)"],"summary":"하이라이트 선분석","description":"업로드 확정 전 원본(presign purpose=HIGHLIGHT_PREVIEW 로 올린 pending 키)의 AI 하이라이트 구간을 동기로 계산해 돌려준다. 원본 길이에 따라 응답까지 수 초에서 수십 초 걸린다(30초 1080p 기준 5초 내외). highlights 가 빈 배열이면 추천 없음이니 FE 는 추천 단계를 스킵한다. 실패 시 FE 는 직접 구간 지정으로 폴백한다 — 3502(분석 서버 문제, 재시도 가능)·3426(원본 파일 불량, 재시도 무의미)·3425(3분 초과)·3413(400, 허용 크기 초과). 결과는 저장되지 않는 임시 값이며, 같은 키로 이후 업로드 확정(POST /api/videos)이 가능하다.","operationId":"highlightPreview","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HighlightPreviewRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoHighlightPreviewResponseDto"}}}}}}},"/api/users/me/profile-image/presigned-url":{"post":{"tags":["사용자 (User)"],"summary":"프로필 이미지 업로드용 presigned URL 발급","description":"프로필 이미지를 S3 에 직접 올릴 presigned URL 을 발급한다. 이 URL 로 PUT 업로드한 뒤 받은 s3Key 로 변경 확정(PUT /api/users/me/profile-image)을 호출한다. 허용 형식은 jpg·jpeg·png·webp 이고 크기 상한은 5MB 다 — 확장자와 Content-Type 이 어긋나거나 허용 밖이면 1415, 선언 크기가 상한을 넘으면 1413.\n\n아이폰 사진(heic·heif)은 받지 않는다 — 저장해도 대부분의 브라우저가 표시하지 못하기 때문이다. 파일 선택 accept 목록에서 heic 를 빼면 iOS 가 플랫폼 수준에서 JPEG 로 변환해 주므로 정상 경로에서는 거부가 나오지 않고, 그래도 새어 들어온 원본 heic 는 1415 응답을 안내 문구로 처리한다.","operationId":"issueProfileImagePresignedUrl","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileImagePresignRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoProfileImagePresignResponseDto"}}}}}}},"/api/routes/walk-paths":{"post":{"tags":["AI 경로 추천 (Routes)"],"summary":"세그먼트 보행 경로 조회","description":"추천 응답의 이웃 좌표쌍(출발지 구간 포함 1~8개)을 보내면 서버가 TMap 보행자 경로안내를 대신 호출해 세그먼트별 보행 좌표열과 실거리(미터)를 요청과 같은 개수, 같은 순서로 돌려준다.\n\nTMap 호출 실패·형태 위반·일 한도 소진은 에러가 아니라 200 에 해당 세그먼트 resolved: false 다 — 그 세그먼트는 직선과 직선거리 안내를 유지하면 된다 (부분 실패 허용).\n\n목록이 없거나 비었거나 9개 이상, 원소가 null, 좌표가 한국 서비스 범위(위도 33~39·경도 124~132) 밖이면 400 + developCode 14402 이고, 기능이 꺼진 환경(route.walk.enabled=false)에서는 503 + 14504 다.","operationId":"walkPaths","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RouteWalkPathRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRouteWalkPathResponseDto"}}}}}}},"/api/routes/recommend":{"post":{"tags":["AI 경로 추천 (Routes)"],"summary":"AI 경로 추천","description":"자연어 한 문장과 지금 보는 지도 범위를 보내면 서버 보유 후보(활성 미션·행사·장소 검색)에서 골라 방문 순서를 붙인 지점 목록(최대 8개)을 돌려준다. 지점마다 추천 이유 한 줄이 실린다.\n\n후보가 0~2개면 실패가 아니라 찾은 만큼과 notice 안내가 함께 오는 성공이다.\n\nviewport 가 뒤집혔거나 넓이 0 이거나 범위 밖이면 400 + developCode 14400, 한 변이 0.5도를 넘으면 400 + 14401 이다. 같은 사용자의 직전 시도 후 10초 안 재요청은 429 + 14429. AI 해석 실패는 502 + 14502 이고, 기능이 꺼진 환경(route.ai.enabled=false)에서는 503 + 14503 이다.","operationId":"recommend","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RouteRecommendRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRouteRecommendResponseDto"}}}}}}},"/api/notifications/tokens":{"post":{"tags":["알림 (Notification)"],"summary":"FCM 토큰 등록/갱신","description":"디바이스의 FCM 토큰을 현재 계정으로 등록한다(UPSERT). 같은 토큰 재등록은 충돌 없이 user_id·platform·appVersion·last_used_at 이 갱신된다 — 재로그인·계정 전환 포함. platform 이 IOS/ANDROID/WEB(대소문자 무시) 외면 10400 이다.","operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PushTokenRequestDto"}}},"required":true},"responses":{"200":{"description":"OK"}}},"delete":{"tags":["알림 (Notification)"],"summary":"FCM 토큰 해제","description":"본인 소유(user_id 일치) 토큰 행을 삭제한다 — 멱등, 없는 토큰·소유 불일치 해제도 200. 로그아웃은 /api/auth/logout body 의 fcmToken 으로 한 번에 처리하고, 이 API 는 토큰 로테이션 등 로그아웃 외 정리 용도다.","operationId":"unregister","parameters":[{"name":"fcmToken","in":"query","description":"해제할 FCM 토큰","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/missions/{missionId}/videos":{"get":{"tags":["미션 영상 (Mission Videos)"],"summary":"미션 영상 목록 조회","description":"그 미션의 대상 격자에서 미션 기간에 촬영된 공개(PUBLIC)·READY 영상을 촬영 시각(recordedAt) 최신순으로 페이지 조회한다 — 촬영 시각이 같으면 videoId 내림차순으로 갈린다. 기간이 없는 미션(코스·지속형)은 기간 조건 없이 과거 영상까지 담고, 기간이 끝난 미션도 목록은 그대로 조회된다. 비공개·친구 공개·삭제·블라인드·인코딩 미완 영상은 본인 것이라도 제외되며, 응답은 누가 부르든 같다. 첫 요청은 cursor 없이 부르고, hasNext 가 true 면 응답의 nextCursor 를 다음 요청 cursor 로 넘기면 이어진다. 커서는 발급된 그 미션 전용이라 다른 미션 커서는 400(INVALID_CURSOR)이고, 형식이 깨진 커서도 같다. size 는 1~50 밖이면 클램프된다. 조건에 맞는 영상이 없거나 존재하지 않는 missionId 는 빈 페이지다. 썸네일은 presigned GET URL 로 내려준다.","operationId":"getMissionVideos","parameters":[{"name":"missionId","in":"path","description":"미션 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":12},{"name":"cursor","in":"query","description":"직전 응답의 nextCursor (opaque). 생략하면 첫 페이지","required":false,"schema":{"type":"string"}},{"name":"size","in":"query","description":"페이지 크기 (1~50, 기본 20)","required":false,"schema":{"type":"integer","format":"int32","default":20}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoGridVideoPageResponseDto"}}}}}},"post":{"tags":["미션 영상 (Mission Videos)"],"summary":"미션 경유 영상 업로드 확정","description":"축제·팝업 미션에 영상을 올린다. 파일은 기존 presigned 발급(POST /api/videos/presigned-url)으로 S3 에 먼저 올리고 이 API 가 확정한다.\n\n좌표도 격자도 받지 않는다. 저장 위치는 서버가 그 미션의 대표 격자로 정하므로 같은 미션의 영상이 지도에서 한 칸에 모인다. 공개 범위는 PUBLIC 으로 고정되고, 업로드는 일반 업로드와 똑같이 그 격자의 점령을 만들며 뱃지·스트릭·미션 스탬프도 그대로 반영된다.\n\n같은 s3Key 로 다시 보내면 영상이 하나 더 생기지 않고 저장된 행 기준의 성공이 돌아온다. 이때 occupied 는 false, newBadges 와 completedMissions 는 빈 배열이다(첫 응답 전용 필드).\n\n촬영 시각이 미래면 400 + developCode 3424, 키 형식이 아니거나 남의 pending 키면 400 + 3401 이다. 그 밖의 모든 실패는 409 + 12409 하나로 돌아온다 — 없는 미션, 코스처럼 대상이 아닌 유형, 기간 밖, 촬영 시각이 미션 기간 밖, 대표 격자가 없는 미션, 이미 다른 자리에 쓴 키, S3 에 없는 키가 전부 여기 해당하며 사유는 갈라 주지 않는다. 이 응답을 받으면 그대로 재시도하지 말고 미션 상세를 다시 불러 업로드 가능 여부를 확인하고, 미션이 여전히 열려 있으면 presigned URL 을 새로 발급받아 파일부터 다시 올린다.\n\n인코딩이 끝나기 전에는 목록에 잡히지 않는다 — 업로드 직후 화면에 카드를 보여주려면 이 응답으로 낙관적으로 그린다(기존 업로드와 같은 성질).","operationId":"uploadMissionVideo","parameters":[{"name":"missionId","in":"path","description":"미션 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":12}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MissionVideoUploadRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoMissionVideoUploadResponseDto"}}}}}}},"/api/friends/requests":{"post":{"tags":["친구 (Friend)"],"summary":"친구 요청","description":"상대의 친구 코드로 요청을 보낸다. 응답 status 가 PENDING 이면 상대 수락 대기, ACCEPTED 면 상대가 먼저 보낸 요청이 있어 즉시 친구 성립(자동 수락)이다.","operationId":"request","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FriendRequestCreateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoFriendRequestCreateResponseDto"}}}}}}},"/api/friends/requests/{requesterId}/reject":{"post":{"tags":["친구 (Friend)"],"summary":"친구 요청 거절","description":"받은 요청을 거절한다. 보낸 쪽에 통지는 없고, 상대는 다시 요청할 수 있다.","operationId":"reject","parameters":[{"name":"requesterId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"OK"}}}},"/api/friends/requests/{requesterId}/accept":{"post":{"tags":["친구 (Friend)"],"summary":"친구 요청 수락","description":"받은 요청을 수락해 친구 관계를 성립시킨다. 요청의 수신자 본인만 가능하다.","operationId":"accept","parameters":[{"name":"requesterId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"OK"}}}},"/api/event-videos/{videoId}/comments":{"get":{"tags":["행사 (Events)"],"summary":"행사 영상 댓글 목록 조회","description":"영상에 달린 댓글을 오래된 순으로 한 페이지 돌려준다 — 새 댓글이 아래에 쌓이는 배열이다.\n\n영상 상세가 첫 페이지(20건)를 이미 품고 있으므로 이 API 는 둘째 페이지부터를 위한 것이다. cursor 는 직전 응답의 nextCursor 를 그대로 넣는다(첫 페이지는 생략). 형식이 깨졌거나 다른 영상 목록에서 받은 커서면 400 + developCode 13402 다. size 는 1~50 범위 밖이면 잘라서 적용하고 생략하면 20 이다.\n\n아카이브된 행사에서도 조회할 수 있고 댓글이 없으면 실패가 아니라 빈 페이지다. 비로그인으로도 조회할 수 있다.","operationId":"getComments","parameters":[{"name":"videoId","in":"path","description":"영상 id","required":true,"schema":{"type":"integer","format":"int64"},"example":1042},{"name":"cursor","in":"query","description":"직전 응답의 nextCursor. 첫 페이지는 생략","required":false,"schema":{"type":"string"}},{"name":"size","in":"query","description":"페이지 크기 (1~50, 기본 20)","required":false,"schema":{"type":"integer","format":"int32","default":0},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventVideoCommentPageResponseDto"}}}}}},"post":{"tags":["행사 (Events)"],"summary":"행사 영상 댓글 작성","description":"행사 영상에 댓글을 단다. 내용은 1~500자다.\n\n행사방이 아카이브로 넘어가면 댓글을 더 달 수 없다 — 종료 30일 후부터 409 + developCode 13422 다(기존 댓글은 계속 보인다). 그 전까지는 예정·진행 중은 물론 유예 기간(종료 후 30일)에도 쓸 수 있고, 유예 기간에 새로 올라온 영상에도 댓글을 남길 수 있다.\n\n상세에 보이는 영상에만 쓸 수 있다 — 삭제·블라인드·비공개·처리 미완료 영상과 행사 영상이 아닌 영상 id 는 올린 본인에게도 404 + 13406 이다.","operationId":"createComment","parameters":[{"name":"videoId","in":"path","description":"영상 id","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventVideoCommentRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventVideoCommentResponseDto"}}}}}}},"/api/event-occurrences/{occurrenceId}/locations/{locationId}/videos":{"get":{"tags":["행사 (Events)"],"summary":"위치별 영상 피드 조회","description":"행사 위치에 올라온 영상을 최신 업로드순으로 한 페이지 돌려준다. 영역 안 어느 격자를 눌러 들어와도 같은 위치의 같은 피드다.\n\n여기 담기는 영상은 위치 목록의 영상 수와 정확히 같은 집합이다 — 삭제·비공개·처리 미완료 영상은 숫자에서도 목록에서도 함께 빠진다. 인코딩이 끝나기 전 영상은 아직 담기지 않는다.\n\ncursor 는 직전 응답의 nextCursor 를 그대로 넣는다(첫 페이지는 생략). 형식이 깨졌거나 다른 위치 피드에서 받은 커서면 400 + developCode 13402 다. size 는 1~50 범위 밖이면 잘라서 적용하고 생략하면 20 이다.\n\n아카이브된 행사에서도 조회할 수 있고 영상이 없으면 실패가 아니라 빈 페이지다. 존재하지 않거나 노출 기간 전인 회차는 404 + 13404, 위치가 없거나 그 회차의 위치가 아니면 404 + 13405 다. 비로그인으로도 조회할 수 있다.","operationId":"getLocationVideos","parameters":[{"name":"occurrenceId","in":"path","description":"행사 회차 id","required":true,"schema":{"type":"integer","format":"int64"},"example":12},{"name":"locationId","in":"path","description":"행사 위치 id","required":true,"schema":{"type":"integer","format":"int64"},"example":34},{"name":"cursor","in":"query","description":"직전 응답의 nextCursor. 첫 페이지는 생략","required":false,"schema":{"type":"string"}},{"name":"size","in":"query","description":"페이지 크기 (1~50, 기본 20)","required":false,"schema":{"type":"integer","format":"int32","default":0},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventLocationVideoPageResponseDto"}}}}}},"post":{"tags":["행사 (Events)"],"summary":"행사 영상 업로드 확정","description":"행사 위치에 영상을 올린다. 파일은 기존 presigned 발급(POST /api/videos/presigned-url)으로 S3 에 먼저 올리고 이 API 가 확정한다 — 촬영이든 갤러리 선택이든 서버 계약은 하나다.\n\n좌표를 받지 않는다. 격자는 서버가 그 위치의 대표 격자로 정하므로 현장에 없어도 올릴 수 있고, 공개 범위는 PUBLIC 으로 고정된다. 업로드는 일반 업로드와 똑같이 그 격자의 점령을 만들고 뱃지·스트릭도 그대로 반영된다(미션만 연계되지 않는다).\n\n같은 s3Key 로 다시 보내면 영상이 하나 더 생기지 않고 저장된 행 기준의 성공이 돌아온다. 이때 occupied 는 false, newBadges 는 빈 배열이다(첫 응답 전용 필드).\n\n올릴 수 있는 기간은 행사 시작부터 종료 30일 후 직전까지다. 시작 전이면 409 + developCode 13410, 마감 이후면 409 + 13409 다. 존재하지 않거나 아직 노출 기간 전인 회차는 404 + 13404, 위치가 없거나 그 회차의 위치가 아니면 404 + 13405 다.\n\n인코딩이 끝나기 전에는 피드에 잡히지 않는다 — 업로드 직후 화면에 카드를 보여주려면 이 응답으로 낙관적으로 그린다(기존 업로드와 같은 성질).","operationId":"upload_1","parameters":[{"name":"occurrenceId","in":"path","description":"행사 회차 id","required":true,"schema":{"type":"integer","format":"int64"},"example":12},{"name":"locationId","in":"path","description":"행사 위치 id","required":true,"schema":{"type":"integer","format":"int64"},"example":34}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventVideoUploadRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventVideoUploadResponseDto"}}}}}}},"/api/event-occurrences/{occurrenceId}/heartbeat":{"post":{"tags":["행사방 (Event)"],"summary":"열람 heartbeat","description":"행사방을 보는 동안 30초 주기로 보낸다. 마지막 신호가 90초 이내인 세션만 열람 인원에 센다. 비로그인은 X-Viewer-Session 헤더(공백 아님·최대 64자) 필수 — 없으면 400. 캐시 장애는 삼켜져 200 이다.","operationId":"heartbeat","parameters":[{"name":"occurrenceId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"X-Viewer-Session","in":"header","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/auth/signup":{"post":{"tags":["인증 (Auth)"],"summary":"이메일 회원가입","description":"이메일/비밀번호/닉네임으로 신규 회원을 생성한다.","operationId":"signup","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignupRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoSignupResponseDto"}}}}}}},"/api/auth/reissue":{"post":{"tags":["인증 (Auth)"],"summary":"토큰 재발급","description":"리프레시 토큰(웹=쿠키, 앱=body)으로 새 액세스 토큰과 회전된 새 리프레시 토큰을 발급받는다. 직전 리프레시 토큰은 즉시 무효화되며, 회전된 옛 토큰 재사용 시 세션 체인이 폐기된다. 쿠키로 리프레시를 보내는 웹은 CSRF 방어를 위해 X-Client-Type 헤더가 필수다(없으면 400). body 로 보내는 앱은 생략할 수 있다.","operationId":"reissue","parameters":[{"name":"refreshToken","in":"cookie","required":false,"schema":{"type":"string"}},{"name":"X-Client-Type","in":"header","description":"클라이언트 유형 (web|app). 리프레시를 쿠키로 보내면 필수, body 로 보내면 생략 가능(생략 시 web 취급).","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReissueRequestDto"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoReissueResponseDto"}}}}}}},"/api/auth/oauth/{provider}":{"post":{"tags":["인증 (Auth)"],"summary":"소셜 로그인 (OIDC)","description":"소셜 제공자의 ID Token으로 로그인/가입하고 JWT 액세스 토큰과 리프레시 토큰을 발급받는다. 웹(X-Client-Type: web, 기본)은 리프레시가 HttpOnly 쿠키(Set-Cookie)로 내려가 body 의 refreshToken 이 null 이고, 앱(app)은 body 로 내려간다.","operationId":"oauthLogin","parameters":[{"name":"provider","in":"path","description":"소셜 제공자","required":true,"schema":{"type":"string"},"example":"KAKAO"},{"name":"X-Client-Type","in":"header","description":"클라이언트 유형 (web|app, 기본 web)","required":false,"schema":{"type":"string","default":"web"}},{"name":"X-Device-Id","in":"header","description":"디바이스 식별자. 없으면 서버가 UUID 를 생성해 응답 헤더 X-Device-Id 로 반환한다.","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OidcLoginRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoLoginResponseDto"}}}}}}},"/api/auth/oauth/kakao/code":{"post":{"tags":["인증 (Auth)"],"summary":"소셜 로그인 (카카오 인가 코드)","description":"웹에서 카카오 콜백으로 받은 인가 코드로 로그인/가입한다. 서버가 REST API 키로 카카오 토큰 엔드포인트를 호출해 ID Token 을 받은 뒤, 소셜 로그인(OIDC)과 완전히 같은 검증·발급 경로를 태운다. 인가 진입점이 심은 OAUTH_NONCE 쿠키가 함께 와야 한다(없으면 401). 응답 형태는 기존 소셜 로그인과 동일하다 — 웹(X-Client-Type: web, 기본)은 리프레시가 HttpOnly 쿠키(Set-Cookie)로 내려가 body 의 refreshToken 이 null 이고, 앱(app)은 body 로 내려간다. 네이티브 SDK 가 교환까지 해주는 앱은 이 API 가 아니라 POST /api/auth/oauth/{provider} 를 쓴다.","operationId":"oauthCodeLogin","parameters":[{"name":"OAUTH_NONCE","in":"cookie","required":false,"schema":{"type":"string"}},{"name":"X-Client-Type","in":"header","description":"클라이언트 유형 (web|app, 기본 web)","required":false,"schema":{"type":"string","default":"web"}},{"name":"X-Device-Id","in":"header","description":"디바이스 식별자. 없으면 서버가 UUID 를 생성해 응답 헤더 X-Device-Id 로 반환한다.","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KakaoCodeLoginRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoLoginResponseDto"}}}}}}},"/api/auth/logout":{"post":{"tags":["인증 (Auth)"],"summary":"로그아웃","description":"Authorization 헤더의 액세스 토큰을 무효화하고 해당 디바이스(X-Device-Id)의 리프레시 세션을 삭제한다. X-Device-Id 가 없으면 해당 유저의 모든 디바이스 세션을 삭제한다. 선택 body 의 fcmToken 이 있으면 해당 FCM 푸시 토큰도 함께 정리된다 (MSG-178 logout 통합).","operationId":"logout","parameters":[{"name":"Authorization","in":"header","required":false,"schema":{"type":"string"}},{"name":"X-Device-Id","in":"header","description":"디바이스 식별자. 없으면 모든 디바이스 세션 삭제(로그아웃-올).","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LogoutRequestDto"}}}},"responses":{"200":{"description":"OK"}}}},"/api/auth/login":{"post":{"tags":["인증 (Auth)"],"summary":"이메일 로그인","description":"이메일/비밀번호로 로그인하고 JWT 액세스 토큰과 리프레시 토큰을 발급받는다. 웹(X-Client-Type: web, 기본)은 리프레시가 HttpOnly 쿠키로, 앱(app)은 body 로 내려간다.","operationId":"login","parameters":[{"name":"X-Client-Type","in":"header","description":"클라이언트 유형 (web|app, 기본 web)","required":false,"schema":{"type":"string","default":"web"}},{"name":"X-Device-Id","in":"header","description":"디바이스 식별자. 없으면 서버가 UUID 를 생성해 응답 헤더 X-Device-Id 로 반환한다.","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoLoginResponseDto"}}}}}}},"/api/auth/dev/social-login":{"post":{"tags":["인증-개발용 (Auth Dev)"],"summary":"[개발용] 소셜 로그인 모의","description":"실제 OIDC ID Token 검증 없이 (provider, oid)로 사용자를 find-or-create 하고 액세스+리프레시 토큰을 발급한다. 리프레시는 body 로 내려간다(앱 모드). 로컬/dev 프로파일에서만 노출.","operationId":"socialLogin","parameters":[{"name":"X-Device-Id","in":"header","description":"디바이스 식별자. 없으면 서버가 UUID 를 생성해 응답 헤더 X-Device-Id 로 반환.","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DevSocialLoginRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoLoginResponseDto"}}}}}}},"/api/admin/videos/{videoId}/unblind":{"post":{"tags":["관리자 신고 처리 (Admin Report)"],"summary":"블라인드 해제","description":"BLINDED 영상을 ACTIVE 로 복구한다. 오판 복구용이며 그 신고의 RESOLVED 는 되돌리지 않는다. 없는 영상과 삭제된 영상은 404(3404), 이미 ACTIVE 면 409(3409) 다.","operationId":"unblindVideo","parameters":[{"name":"videoId","in":"path","description":"해제할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminVideoUnblindResponseDto"}}}}}}},"/api/admin/reports/{reportId}/reject":{"post":{"tags":["관리자 신고 처리 (Admin Report)"],"summary":"신고 기각","description":"신고를 REJECTED 로 종결한다. 영상에는 아무 영향이 없고 응답의 videoStatus 는 현재 상태 그대로다. 없는 신고는 404(11404), 이미 처리된 신고는 409(11410) 다.","operationId":"reject_1","parameters":[{"name":"reportId","in":"path","description":"기각할 신고 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":7}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminReportProcessResponseDto"}}}}}}},"/api/admin/reports/{reportId}/approve":{"post":{"tags":["관리자 신고 처리 (Admin Report)"],"summary":"신고 승인","description":"신고를 RESOLVED 로 종결하고 대상 영상을 블라인드한다 — 한 트랜잭션이다. 영상이 이미 BLINDED 거나 DELETED 면 영상 전이 없이 신고만 종결하며, 응답의 videoStatus 로 구분할 수 있다. 없는 신고는 404(11404), 이미 처리된 신고와 동시 처리의 늦은 쪽은 409(11410) 다.","operationId":"approve","parameters":[{"name":"reportId","in":"path","description":"승인할 신고 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":7}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminReportProcessResponseDto"}}}}}}},"/api/videos/{videoId}/visibility":{"patch":{"tags":["영상 (Video)"],"summary":"영상 공개 범위 전환","description":"본인 영상의 공개 범위를 PUBLIC·PRIVATE·FRIENDS 간 전환한다. 전환된 상태를 반환하며, 같은 값 재전환은 멱등하게 성공한다.","operationId":"setVisibility","parameters":[{"name":"videoId","in":"path","description":"공개 범위를 전환할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VideoVisibilityRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoVideoVisibilityResponseDto"}}}}}}},"/api/notifications/{notificationId}/read":{"patch":{"tags":["알림 (Notification)"],"summary":"알림 하나 읽음 처리","description":"행을 탭했을 때 그 알림을 읽음으로 바꾼다 — 이미 읽은 알림을 다시 요청해도 성공이고 최초로 읽은 시각이 그대로 남는다. 없는 알림이나 남의 알림이면 10404 로, 둘을 구분하지 않는다.","operationId":"markRead","parameters":[{"name":"notificationId","in":"path","description":"읽음 처리할 알림 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":123}],"responses":{"200":{"description":"OK"}}}},"/api/notifications/read-all":{"patch":{"tags":["알림 (Notification)"],"summary":"알림 모두 읽음 처리","description":"안읽은 알림을 전부 읽음으로 바꾼다 — 안읽은 알림이 하나도 없어도 성공한다.","operationId":"markAllRead","responses":{"200":{"description":"OK"}}}},"/api/notifications/preferences/{category}":{"patch":{"tags":["알림 (Notification)"],"summary":"카테고리 수신 토글","description":"카테고리 하나의 수신 여부를 바꾸고 변경 후 전체 상태를 반환한다 — 같은 값 재전환은 멱등. category 가 8종(BADGE·HOTZONE·REMIND·VIDEO·WEEKLY·FRIEND·MISSION_NEARBY·EVENT, 대소문자 무시) 외면 10420 이다. off 는 발송만 막고 off 중 쌓인 알림이 on 복귀 후 재발송되는 일은 없다. MISSION_NEARBY 는 서버 발송이 없어 기기가 발화 전 이 설정을 조회해 로컬로 억제한다.","operationId":"update","parameters":[{"name":"category","in":"path","description":"알림 카테고리 — BADGE·HOTZONE·REMIND·VIDEO·WEEKLY·FRIEND·MISSION_NEARBY (대소문자 무시)","required":true,"schema":{"type":"string"},"example":"HOTZONE"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationPreferenceUpdateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoNotificationPreferenceResponseDto"}}}}}}},"/api/event-videos/{videoId}/comments/{commentId}":{"delete":{"tags":["행사 (Events)"],"summary":"행사 영상 댓글 삭제","description":"댓글을 실제로 지운다(복구 없음). 본인 댓글만 지울 수 있고 남의 댓글이면 403 + developCode 13403 이다.\n\n이미 지운 댓글을 다시 지우면 404 + 13407 이다 — 없는 댓글의 삭제를 성공으로 돌려주면 화면 상태 불일치가 감춰지기 때문이다(도움돼요 취소는 토글이라 멱등인 것과 다르다).\n\n아카이브된 행사(종료 30일 후)에서는 409 + 13422 다.","operationId":"deleteComment","parameters":[{"name":"videoId","in":"path","description":"영상 id","required":true,"schema":{"type":"integer","format":"int64"},"example":1042},{"name":"commentId","in":"path","description":"댓글 id","required":true,"schema":{"type":"integer","format":"int64"},"example":3021}],"responses":{"200":{"description":"OK"}}},"patch":{"tags":["행사 (Events)"],"summary":"행사 영상 댓글 수정","description":"댓글 내용을 통째로 바꾼다. 본인 댓글만 고칠 수 있고 남의 댓글이면 403 + developCode 13403, 없거나 다른 영상의 댓글이면 404 + 13407 이다.\n\n작성 시각은 그대로다(수정 이력을 남기지 않는다). 아카이브된 행사에서는 자기 댓글이든 남의 댓글이든 409 + 13422 로 같다 — 잠금이 권한 판정보다 앞이다.","operationId":"updateComment","parameters":[{"name":"videoId","in":"path","description":"영상 id","required":true,"schema":{"type":"integer","format":"int64"},"example":1042},{"name":"commentId","in":"path","description":"댓글 id","required":true,"schema":{"type":"integer","format":"int64"},"example":3021}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventVideoCommentRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventVideoCommentResponseDto"}}}}}}},"/api/zones":{"get":{"tags":["구역 (Zone)"],"summary":"구역 목록 조회","description":"전체 구역(zone) 목록을 반환한다. 검색바에서 구역을 골라 지도를 옮기거나 구역 범위를 오버레이로 그릴 때 쓴다 — 표시명은 격자 응답의 zoneName·zoneCell 을 그대로 조립하면 되므로 이 목록으로 이름을 계산할 필요가 없다. 시딩 전이면 빈 배열(전 시스템이 행정동 폴백으로 동작).","operationId":"getZones","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListZoneResponseDto"}}}}}}},"/api/users/me":{"get":{"tags":["사용자 (User)"],"summary":"내 프로필 조회","description":"소셜 로그인이 자동 저장한 이메일·닉네임을 반환한다. 항상 본인 계정만 — 경로에 대상 식별자가 없다.","operationId":"getMe","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoUserProfileResponseDto"}}}}}},"delete":{"tags":["사용자 (User)"],"summary":"계정 삭제","description":"내 계정을 즉시·비가역 삭제한다. 연쇄 개인 데이터·영상 S3 객체가 제거되고 전 디바이스 세션이 무효화된다. 같은 이메일·카카오 계정으로 다시 로그인하면 신규 가입이다.","operationId":"deleteMe","parameters":[{"name":"Authorization","in":"header","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/search/trending":{"get":{"tags":["인기 검색어 (Trending)"],"summary":"인기 검색어 TOP 10","description":"오늘+어제(KST) 검색어 집계를 합산해 상위 10개를 순위·검색어로 반환한다. 동률은 검색어 사전순. 검색 횟수와 장소 정보는 포함하지 않으며(클릭 후 장소 검색 API 재호출), 집계가 없으면 200 + 빈 배열이다.","operationId":"getTrendingKeywords","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListTrendingKeywordResponseDto"}}}}}}},"/api/search/places":{"get":{"tags":["장소 검색 (Search)"],"summary":"장소 검색 (장소명 → 좌표·격자)","description":"카카오 로컬 키워드 검색 결과(정확도순 ≤15건)에 각 좌표의 격자 ID 를 얹어 반환한다. 선택 즉시 lat/lng 지도 이동 + gridId 격자 하이라이트. q 누락 400 / trim 후 빈 q·무매치 200 [] / 카카오 장애·타임아웃 502(developCode 5502). 비로그인도 호출할 수 있고 결과는 로그인 때와 같다 — 비로그인 호출은 X-Viewer-Session 헤더(공백 아님·최대 64자·콜론 불가)를 실으면 인기 검색어 집계에 잡히고, 안 실어도 검색은 정상 200 이다.\n\nlat·lng 에 지금 보고 있는 지도의 중심 좌표를 실으면 그 중심 반경 20km 안의 장소를 먼저 찾는다. 근처에 결과가 하나도 없으면 위치 없이 다시 찾아 전국 결과를 주므로 좌표를 붙였다는 이유로 결과가 사라지지는 않는다. 두 값은 반드시 한 쌍으로 보내야 하고, 한쪽만 오거나 숫자가 아니거나 대한민국 범위(위도 33~39·경도 124~132) 밖이면 400 + developCode 5400 이다. 좌표를 아예 안 보내면 종전과 똑같이 동작한다.","operationId":"searchPlaces","parameters":[{"name":"q","in":"query","description":"검색어 (자유 텍스트 장소명)","required":true,"schema":{"type":"string"},"example":"부산대"},{"name":"lat","in":"query","description":"지도 중심 위도 (33.0~39.0). lng 과 한 쌍으로만 유효하다","required":false,"schema":{"type":"number","format":"double"},"example":35.1578},{"name":"lng","in":"query","description":"지도 중심 경도 (124.0~132.0). lat 과 한 쌍으로만 유효하다","required":false,"schema":{"type":"number","format":"double"},"example":129.0594},{"name":"X-Viewer-Session","in":"header","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListPlaceSearchResponseDto"}}}}}}},"/api/regions/{regionCode}/grids":{"get":{"tags":["전역 탐색 (Region Explore)"],"summary":"행정동 격자 카드 리스트 + 헤더 카운트 조회","description":"그 행정동 격자들 중 전역 공개 콘텐츠(공개·인코딩 완료·타인 영상 포함)가 있는 격자를 카드로 반환한다. 카운트(gridCount·videoCount)는 limit 무관 전체 기준이라 지도 홈 패널(sort=LATEST&limit=20, SRS FR-MAP-10)과 전체 보기(limit 생략)가 같은 값을 받지만, **전역 공개 콘텐츠를 센 값이라 패널 헤더(\"이 지역 격자 N개 · 영상 M개\")에 쓰면 안 된다** — 헤더는 내 도감 집계 응답의 currentRegion(중심 동 전체의 내 것, MSG-374)이 채운다. 카드 커버는 격자 대표(cover)와 같은 영상이고 썸네일은 presigned GET URL 이다. 미존재·무콘텐츠 regionCode 는 404 가 아니라 200 + 카운트 0·빈 배열이다.","operationId":"getRegionGrids","parameters":[{"name":"regionCode","in":"path","description":"행정동 코드 — reverse-geocode·전체 지역 리스트의 regionCode 를 그대로 전달","required":true,"schema":{"type":"string"},"example":2644056000},{"name":"sort","in":"query","description":"정렬 — POPULAR(조회수 합)·LATEST(최신 공개 영상). 대문자 전용이며 소문자 포함 무효 값은 400 이다. 지도 홈 패널은 LATEST (SRS FR-MAP-10, 생략 기본값은 POPULAR 유지)","required":false,"schema":{"type":"string","default":"POPULAR","enum":["POPULAR","LATEST"]},"example":"LATEST"},{"name":"limit","in":"query","description":"카드 수 상한 — 지도 홈 패널은 20 (SRS FR-MAP-10). 생략하면 전부, 1 미만은 1 로 보정한다","required":false,"schema":{"type":"integer","format":"int32"},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRegionExploreResponseDto"}}}}}}},"/api/regions/stats":{"get":{"tags":["행정동 (Region)"],"summary":"내 행정동별 수집률 조회","description":"로그인 사용자가 점령(수집)한 격자를 행정동별로 집계한 수집률 리스트를 반환한다. parentCode 로 시군구를 좁힐 수 있고(실존하지 않는 코드면 404/6404), collectedOnly=false 면 롤백으로 0이 된 행정동도 포함한다. 수집이 없으면 404 가 아니라 200 + 빈 배열.","operationId":"getStats","parameters":[{"name":"parentCode","in":"query","description":"상위 시군구 코드. 생략하면 전국. 실존하지 않으면 6404","required":false,"schema":{"type":"string"},"example":11680},{"name":"collectedOnly","in":"query","description":"true=수집한 행정동만, false=손댄 행정동 전부(롤백 0-row 포함)","required":false,"schema":{"type":"boolean","default":true},"example":true}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListRegionStatResponseDto"}}}}}}},"/api/regions/stats/national":{"get":{"tags":["행정동 (Region)"],"summary":"내 전국 탐험률 재료 (분자·분모)","description":"도감·프로필 헤더의 \"전체 지도 N% 탐험\" 재료. 내가 점령한 격자 수(전국 합)와 전국 격자 총수를 반올림 없는 원값 정수 2개로 반환한다. 비율·표시 자릿수·100 상한은 화면이 min(100, 분자/분모 × 100) 으로 계산한다. 수집이 없어도 오류가 아니라 분자 0. 분모가 0 이면 기준 데이터 미적재 상태라 화면은 비율을 그리지 않는다.","operationId":"getNationalStat","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRegionNationalStatResponseDto"}}}}}}},"/api/regions/stats/by-point":{"get":{"tags":["행정동 (Region)"],"summary":"현재 위치 행정동 탐험률 (좌표 → 수집률)","description":"도감 갤러리 진입 초기값. 현재 위치 좌표가 속한 행정동 1건의 내 수집률을 반환한다. 그 행정동에 수집이 없어도 0% 로 합성해 반환하고, 어떤 행정동에도 안 속하면(바다·국외) 404 가 아니라 200 + data null. 서비스 범위 밖 좌표는 400(6400).","operationId":"getStatByPoint","parameters":[{"name":"lat","in":"query","description":"위도","required":true,"schema":{"type":"number","format":"double"},"example":37.4979},{"name":"lng","in":"query","description":"경도","required":true,"schema":{"type":"number","format":"double"},"example":127.0276}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRegionStatResponseDto"}}}}}}},"/api/regions/stats/by-grid":{"get":{"tags":["행정동 (Region)"],"summary":"격자 중심 행정동 탐험률 (격자 클릭 → 수집률)","description":"클릭한 격자의 중심점이 속한 행정동 1건의 내 수집률을 반환한다. 귀속 축이 수집률 집계(MSG-155)와 같아 탐험률·라벨이 일치한다. 중심점이 어떤 행정동에도 안 속하거나 gridId 형식이 이상하면 200 + data null(별도 에러 코드 없음).","operationId":"getStatByGrid","parameters":[{"name":"gridId","in":"query","description":"격자 ID \"{grid_y}_{grid_x}\"","required":true,"schema":{"type":"string"},"example":"19422_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRegionStatResponseDto"}}}}}}},"/api/regions/reverse-geocode":{"get":{"tags":["행정동 (Region)"],"summary":"역지오코딩 (좌표 → 행정동)","description":"좌표를 포함하는 행정동 1건을 반환한다. 포함 행정동이 없으면(바다·국외) 404가 아니라 200 + data null. 서비스 좌표 범위(한국) 밖이면 400(6400).","operationId":"reverseGeocode","parameters":[{"name":"lat","in":"query","description":"위도","required":true,"schema":{"type":"number","format":"double"},"example":37.4979},{"name":"lng","in":"query","description":"경도","required":true,"schema":{"type":"number","format":"double"},"example":127.0276}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRegionResponseDto"}}}}}}},"/api/regions/explore":{"get":{"tags":["전역 탐색 (Region Explore)"],"summary":"전체 지역 리스트 조회","description":"전역 공개 콘텐츠가 있는 행정동을 20개씩 반환한다. 로그인 사용자가 직접 최근 업로드한 지역이 먼저 나오고 나머지는 격자 수 내림차순이다. hasNext가 true면 nextCursor를 다음 요청의 cursor에 그대로 전달한다.","operationId":"getExploreRegions","parameters":[{"name":"cursor","in":"query","description":"직전 응답의 nextCursor. 첫 페이지는 생략","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRegionExplorePageResponseDto"}}}}}}},"/api/regions/districts":{"get":{"tags":["행정동 (Region)"],"summary":"시군구 목록 (검색 지역 필터)","description":"검색 화면 \"전체 지역\" 목록용 시군구 전량. 이름·식별자와 그 구의 전체 격자 수를 준다. 격자 수는 사용자 무관 값이고 0 인 시군구는 빠진다. 정렬은 이름순, 같은 이름은 식별자순. 응답의 parentCode 는 /api/regions/stats 의 parentCode 로 그대로 이어 쓸 수 있다.","operationId":"getDistricts","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListRegionDistrictResponseDto"}}}}}}},"/api/notifications":{"get":{"tags":["알림 (Notification)"],"summary":"알림 목록 조회","description":"받은 알림을 최신순으로 한 페이지 반환한다. 최근 30일 이내 생성분만 보이고, 알림 설정을 꺼서 발송되지 않은 알림은 빠진다 — 전송률 상한이나 푸시 토큰 없음으로 발송되지 않은 알림은 보인다. 다음 페이지는 응답의 nextCursor 를 cursor 로 다시 넘긴다. 목록 조회는 읽음 상태를 바꾸지 않는다.","operationId":"getInbox","parameters":[{"name":"cursor","in":"query","description":"직전 응답의 nextCursor — 생략하면 첫 페이지","required":false,"schema":{"type":"integer","format":"int64"},"example":123},{"name":"size","in":"query","description":"페이지 크기 — 0 이하면 20, 50 초과면 50 으로 자른다","required":false,"schema":{"type":"integer","format":"int32","default":20},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoNotificationPageResponseDto"}}}}}}},"/api/notifications/unread-count":{"get":{"tags":["알림 (Notification)"],"summary":"안읽은 알림 개수 조회","description":"목록과 같은 노출 조건으로 안읽은 알림 수를 센다 — 없으면 0 이다.","operationId":"getUnreadCount","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoNotificationUnreadCountResponseDto"}}}}}}},"/api/notifications/preferences":{"get":{"tags":["알림 (Notification)"],"summary":"알림 설정 조회","description":"카테고리 8종(BADGE·HOTZONE·REMIND·VIDEO·WEEKLY·FRIEND·MISSION_NEARBY·EVENT) 전부의 수신 상태를 반환한다. 설정을 만진 적 없는 사용자는 전부 true 다 — opt-out 기본 전부 on. MODERATION 은 설정 대상이 아니라 목록에 없다 (수신 거부 불가).","operationId":"getPreferences","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoNotificationPreferenceResponseDto"}}}}}}},"/api/missions/{missionId}":{"get":{"tags":["미션 (Missions)"],"summary":"미션 상세 조회","description":"미션 ID 하나의 상세 — 미션 정보와 렌더 shape(목록과 같은 필드), 내 진행도와 스탬프 보유 여부, 이 미션에 올라온 전체 영상 개수, 코스라면 포토스팟별 방문 여부·영상 개수를 한 번에 반환한다. spotStats 는 shape.spots 와 같은 순서로 오고, 코스가 아니면 null 대신 빈 배열이다.\n\n기간 판정은 하지 않는다 — 기간이 끝난 미션도 행이 남아 있으면 조회되고, 영상 개수는 그 미션이 활성일 때 촬영된 것만 센다(미션 영상 목록 GET /api/missions/{missionId}/videos 의 실제 후보 수와 항상 같다). 존재하지 않는 미션 ID 는 404 + developCode 12404(MISSION_NOT_FOUND)다.\n\n비로그인으로도 조회된다(MSG-454). 이때 사용자별 값은 빠진다 — progress 는 키는 있고 값이 null 이며 spotStats[].visited 는 전부 false 다. 미션 정보·전체 영상 수·스팟별 영상 수는 로그인과 같다.","operationId":"getMissionDetail","parameters":[{"name":"missionId","in":"path","description":"미션 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":412}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoMissionDetailResponseDto"}}}}}}},"/api/missions/progress":{"get":{"tags":["미션 (Missions)"],"summary":"미션별 내 진행도 조회","description":"미션 id 여러 개의 내 진행도(채운 칸/목표 칸)와 스탬프 보유 여부를 한 번에 반환한다. 채운 칸은 스탬프 판정과 같은 술어로 센다 — 미션 기간 안에 촬영한 내 영상(삭제 제외)이 있는 격자 수다. 영상을 전부 지우면 진행도는 0으로 돌아가지만 스탬프는 비회수라 completed 는 남는다 — \"0/1 인데 완료\"가 정상 응답이다.\n\nmissionIds 가 없거나 비면 빈 배열이고(오류 아님), 존재하지 않는 id 는 응답에서 빠진다. 기간이 끝난 미션도 조회된다. 배열 순서는 missionId 오름차순으로 고정된다(요청 순서 미보존). 300개 초과는 400 + developCode 12403 으로 거절한다.","operationId":"getMyProgress","parameters":[{"name":"missionIds","in":"query","description":"미션 id 목록 — 콤마 구분 또는 반복 파라미터. 없거나 비면 빈 배열 응답, 300개 초과는 거절","required":false,"schema":{"type":"array","items":{"type":"integer","format":"int64"}},"example":"412,413"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListMissionProgressResponseDto"}}}}}}},"/api/missions/aggregation":{"get":{"tags":["미션 (Missions)"],"summary":"넓은 축척용 미션 행정 단위 집계 조회 (줌아웃)","description":"지도를 축소해 개별 핀을 그릴 수 없는 축척에서, bbox 안의 축제·팝업 미션을 행정 단위(동·구·시)로 묶어 지역 이름과 개수로 반환한다. 단위 전환 시점은 서버가 정하지 않으며 클라이언트가 화면 축척에 맞춰 unit 만 바꿔 부른다.\n\n항목마다 마커 식별 키(regionCode), 표시 이름, 대표 좌표, 미션 수, 그 묶음의 미션 id 목록이 온다. 대표 좌표는 묶음에 속한 미션 귀속점의 평균이라 마커가 실제 데이터 위에 선다. missionIds 는 묶음 마커를 눌러 줌인한 뒤 개별 조회(GET /api/missions/active) 결과와 교집합을 내 목록을 좁히는 재료다 — 카드 재료는 개별 조회 응답에 있다.\n\n미션이 속한 격자 사각형이 아니라 그 사각형 중앙의 귀속점이 bbox 안인지로 센다. 사각형이 화면에 걸쳤지만 중심이 밖인 미션은 빠지며, 이 때문에 개별 조회와 집계를 갈아타는 순간 마커 수가 미세하게 달라질 수 있다. 행정동이 판정되지 않은 미션은 제외가 아니라 regionCode·name 이 null 인 항목 하나로 묶여 마지막에 온다. 범위 안에 미션이 없으면 빈 배열이다.\n\nbbox span 상한은 단위별로 다르다(DONG 1도, SIGUNGU 4도, SIDO 10도 — 위도·경도 각 변에 따로 적용, 정확히 상한값은 허용). 초과 시 400 + developCode 12401, 좌표가 WGS84 범위를 벗어나거나 bbox 가 뒤집히면 12400, type 이 없거나 EVENT·POPUP 이 아니면 12402, unit 이 없거나 미지원 값이면 12405 다. 응답에 사용자별 값은 없다.","operationId":"getMissionAggregates","parameters":[{"name":"type","in":"query","description":"미션 종류 — EVENT(지역축제), POPUP(팝업스토어). 대소문자 무관","required":true,"schema":{"type":"string"},"example":"POPUP"},{"name":"unit","in":"query","description":"집계 단위 — DONG(동), SIGUNGU(시군구), SIDO(시도). 대소문자 무관","required":true,"schema":{"type":"string"},"example":"SIGUNGU"},{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.1},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":128.9},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.3},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":129.2}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListMissionRegionAggregateResponseDto"}}}}}}},"/api/missions/active":{"get":{"tags":["미션 (Missions)"],"summary":"뷰포트 내 활성 미션 목록 조회","description":"지도 화면 bbox(남서~북동 좌표) 안의, 고른 종류(type)의 활성 미션을 유형별 렌더 shape(코스=PATH·축제/팝업=BOX)로 반환한다. bbox span 상한은 0.5도로 위도·경도 각 변에 따로 적용된다(정확히 0.5도는 허용). 초과 시 잘라서 응답하지 않고 400 + developCode 12401(VIEWPORT_TOO_LARGE)로 거절한다. 클라이언트는 격자 개별 조회(GET /api/grids)를 멈추는 것과 같은 0.5도 지점에서 이 조회도 멈추고 확대 안내를 그린다.\n\n보이는 범위에 그 종류 미션이 없으면 실패가 아니라 빈 배열이다(뷰포트가 너무 넓은 12401 과 다른 상태). 한국 밖이지만 WGS84 정의역 안인 bbox 도 오류가 아니라 빈 배열이다. 응답에 사용자별 값은 없다 — 진행도는 GET /api/missions/progress 로 따로 받는다.","operationId":"getActiveMissionsInViewport","parameters":[{"name":"type","in":"query","description":"미션 종류 — EVENT(지역축제), POPUP(팝업스토어), COURSE(경로추천). 대소문자 무관","required":true,"schema":{"type":"string"},"example":"POPUP"},{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.5},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.0},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.55},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.05}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListMissionResponseDto"}}}}}}},"/api/hotzones":{"get":{"tags":["핫구역 (HotZone)"],"summary":"뷰포트 내 핫구역 조회","description":"지도 화면 bbox(남서~북동 좌표) 안의 핫구역을 핫스코어 내림차순으로 반환한다. 전국 상위 K(50)·최소 임계(3) 판정 후 뷰포트 필터 — 없으면 빈 목록이다.\n\n항목마다 표시 이름 재료가 함께 온다: zoneName이 null이면 regionName(행정동)이 표시 이름이다(폴백에는 칸 번호를 붙이지 않는다). 이름 때문에 마커마다 단건 조회를 돌릴 필요가 없다.","operationId":"getHotZones","parameters":[{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.5},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.0},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.55},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.05}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoHotZoneListResponseDto"}}}}}}},"/api/hotzones/aggregation":{"get":{"tags":["핫구역 (HotZone)"],"summary":"뷰포트 내 핫구역 행정 단위 집계 조회","description":"축소 화면용 — 뷰포트 안 핫구역을 행정 단위(동·구·시)로 묶어 지역 이름과 핫 격자 수로 반환한다. 묶음 대상은 개별 조회(GET /api/hotzones)와 완전히 같은 판정 집합이라 두 화면을 갈아타도 세는 대상이 달라지지 않는다.\n\n항목마다 gridIds 가 함께 온다 — 묶음 마커를 눌러 줌인한 뒤 개별 조회 결과와 교집합으로 목록을 좁히는 재료다. count 는 핫 격자 수이고 핫스코어 합산이 아니다. 행정동이 판정되지 않은 격자는 제외가 아니라 regionCode·name 이 null 인 항목 하나로 묶여 마지막에 온다. 범위 안에 핫 격자가 없으면 빈 배열이다.\n\nbbox span 상한은 단위별로 다르다(DONG 1도, SIGUNGU 4도, SIDO 10도 — 위도·경도 각 변에 따로 적용, 정확히 상한값은 허용). 초과 시 400 + developCode 8401, 좌표가 WGS84 범위를 벗어나거나 bbox 가 누락·뒤집히면 8400, unit 이 없거나 미지원 값이면 8405 다. 응답에 사용자별 값은 없다.","operationId":"getHotZoneAggregates","parameters":[{"name":"unit","in":"query","description":"집계 단위 — DONG(동), SIGUNGU(시군구), SIDO(시도). 대소문자 무관","required":true,"schema":{"type":"string"},"example":"SIGUNGU"},{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.1},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":128.9},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.3},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":129.2}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListHotZoneRegionAggregateResponseDto"}}}}}}},"/api/grids":{"get":{"tags":["격자 (Grid)"],"summary":"뷰포트 내 색칠 격자 조회 (커서 페이지네이션)","description":"지도 화면 bbox(남서~북동 좌표) 안에서 내가 점령한 격자를 (grid_y, grid_x) 오름차순으로 반환한다. 응답의 nextCursor를 다음 요청 cursor에 넣어 이어서 조회한다. bbox span 상한은 0.5도로 위도·경도 각 변에 따로 적용된다(정확히 0.5도는 허용). 초과 시 잘라서 응답하지 않고 400 + developCode 4402(VIEWPORT_TOO_LARGE)로 거절한다.\n\n항목마다 표시 이름 재료가 함께 온다: zoneName이 null이면 regionName(행정동)이 표시 이름이다(폴백에는 칸 번호를 붙이지 않는다). 이름 때문에 다른 API를 더 호출할 필요가 없다.","operationId":"getOccupiedInViewport","parameters":[{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.5},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.0},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.55},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.05},{"name":"cursor","in":"query","description":"다음 페이지 커서 (직전 응답의 nextCursor). 첫 페이지는 생략","required":false,"schema":{"type":"string"},"example":"MTk0MjJfOTU4Mg=="},{"name":"size","in":"query","description":"페이지 크기 (기본 1000, 최대 5000)","required":false,"schema":{"type":"integer","format":"int32","default":1000},"example":1000}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoOccupiedGridPageResponseDto"}}}}}}},"/api/grids/{gridId}":{"get":{"tags":["격자 (Grid)"],"summary":"단일 격자 색칠 상태 조회","description":"특정 격자를 내가 점령(색칠)했는지와 내 영상 수를 반환한다. 미점령 격자도 404가 아니라 occupied=false로 응답한다.\n\n표시 이름 재료가 함께 온다: zoneName이 null이면 regionName(행정동)이 표시 이름이다(폴백에는 칸 번호를 붙이지 않는다). regionName은 아직 아무도 영상을 올리지 않은 격자에도 실리고, 어느 행정동에도 속하지 않거나 서비스 범위(한국) 밖인 격자면 null이다(에러가 아니다).","operationId":"getCell","parameters":[{"name":"gridId","in":"path","description":"격자 ID (\"{grid_y}_{grid_x}\" 포맷)","required":true,"schema":{"type":"string"},"example":"19422_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoGridCellResponseDto"}}}}}}},"/api/grids/{gridId}/videos":{"get":{"tags":["격자 상세 (Grid Videos)"],"summary":"격자 전역 영상 목록 조회","description":"그 격자에 쌓인 공개(PUBLIC)·READY 영상을 전역(본인·타인 포함)에서 조회수(viewCount) → 최신(createdAt) 순으로 페이지 조회한다. 비공개·삭제·인코딩 미완 영상은 본인 것이라도 제외한다. 첫 요청은 cursor 없이 부르고, hasNext 가 true 면 응답의 nextCursor 를 다음 요청 cursor 로 넘기면 이어진다. 무효 커서는 400(INVALID_CURSOR)이고, size 는 1~50 밖이면 클램프된다. 후보가 없거나 존재하지 않는 gridId 는 빈 페이지다. 썸네일은 presigned GET URL 로 내려준다.","operationId":"getGridGlobalVideos","parameters":[{"name":"gridId","in":"path","description":"격자 ID","required":true,"schema":{"type":"string"},"example":"19422_9582"},{"name":"cursor","in":"query","description":"직전 응답의 nextCursor (opaque). 생략하면 첫 페이지","required":false,"schema":{"type":"string"}},{"name":"size","in":"query","description":"페이지 크기 (1~50, 기본 20)","required":false,"schema":{"type":"integer","format":"int32","default":20}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoGridVideoPageResponseDto"}}}}}}},"/api/grids/{gridId}/my-videos":{"get":{"tags":["격자 상세 (Grid Videos)"],"summary":"격자별 내 영상 리스트 조회","description":"로그인 사용자가 해당 격자에 올린 본인 영상을 최근 업로드 순(createdAt DESC)으로 반환한다. 미점령·타인만 점령한 격자·존재하지 않는 gridId 는 빈 배열이다. 썸네일은 presigned GET URL 로 내려주며 READY 이전이면 null 이다.","operationId":"getGridVideos","parameters":[{"name":"gridId","in":"path","description":"격자 ID","required":true,"schema":{"type":"string"},"example":"19422_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListGridVideoResponseDto"}}}}}}},"/api/grids/{gridId}/missions":{"get":{"tags":["미션 (Missions)"],"summary":"격자가 대표 격자인 미션 조회","description":"지도에서 누른 격자가 어느 축제·팝업 미션의 자리인지 되짚는다. 미션 경유로 올린 영상은 그 미션의 대표 격자 한 칸에만 저장되므로, 영상이 모인 칸을 눌러 무슨 미션이었는지 확인하는 경로다.\n\n기간 필터가 없다 — 끝난 축제도 담긴다. 진행 중인지 시작 전인지 끝났는지는 startAt·endAt 을 서버 시각과 견주어 화면이 판정한다. 배열 첫 항목이 화면 진입 기본값이 되도록 진행 중 → 시작 전(임박한 순) → 종료(최근 종료 순)로 정렬한다.\n\n판정 범위(축제 9×9)에만 걸친 격자는 나오지 않는다 — 나오는 것은 영상이 모인 자리로 지목된 미션뿐이다. 어떤 미션의 대표 격자도 아닌 격자와 격자 형식이 아닌 문자열은 오류가 아니라 빈 배열이다. videoCount 는 미션 상세의 videoCount 와 같은 술어라 두 화면의 숫자가 어긋나지 않는다. 비로그인으로도 조회할 수 있다.","operationId":"getMissionsByGrid","parameters":[{"name":"gridId","in":"path","description":"격자 id — \"{gridY}_{gridX}\" 포맷","required":true,"schema":{"type":"string"},"example":"19443_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListGridMissionResponseDto"}}}}}}},"/api/grids/{gridId}/hourly-uploads":{"get":{"tags":["격자 상세 (Grid Videos)"],"summary":"격자 전역 시간대 분포 조회","description":"그 격자의 공개(PUBLIC)·READY 영상이 업로드된 시간대 분포를 KST 0시부터 23시까지 24구간 개수로 반환한다. 세는 대상은 전역 영상 목록(/videos)과 같아 카드에 보이는 영상만 세어진다 — 비공개·삭제·인코딩 미완 영상은 본인 것이라도 빠진다. 집계 구간은 전체 누적이며, 응답의 hours 는 항상 24개·hour 오름차순이라 빈 시간대도 count 0 으로 실린다. 공개 영상이 없는 격자·존재하지 않는 gridId 도 전 구간 0 인 정상 응답이다.","operationId":"getGridHourlyUploads","parameters":[{"name":"gridId","in":"path","description":"격자 ID","required":true,"schema":{"type":"string"},"example":"19422_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoGridHourlyUploadResponseDto"}}}}}}},"/api/grids/{gridId}/event-locations":{"get":{"tags":["행사 (Events)"],"summary":"격자가 속한 행사 위치 조회","description":"지도에서 누른 격자가 어느 행사 위치에 속하는지 해석한다. 영상은 위치의 대표 격자 하나에만 저장되므로, 영역 안 아무 격자나 눌러도 같은 위치가 나오는 이 역조회가 위치별 영상 피드로 들어가는 유일한 경로다.\n\n같은 장소에서 행사가 여러 번 열렸으면 회차마다 한 항목씩 배열로 온다. 배열 첫 항목이 화면 진입 기본값이 되도록 진행 중 → 예정 → 업로드 유예 → 아카이브 순으로 정렬하며, 예정끼리는 임박한 순, 나머지는 최근 순이다. 아직 노출 기간 전인 예정 회차는 배열에 담기지 않는다.\n\n어떤 행사 위치에도 속하지 않는 격자와 격자 형식이 아닌 문자열은 오류가 아니라 빈 배열이다. 비로그인으로도 조회할 수 있다.","operationId":"getEventLocationsByGrid","parameters":[{"name":"gridId","in":"path","description":"격자 id — \"{gridY}_{gridX}\" 포맷","required":true,"schema":{"type":"string"},"example":"19443_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListGridEventLocationResponseDto"}}}}}}},"/api/grids/{gridId}/cover":{"get":{"tags":["격자 상세 (Grid Videos)"],"summary":"격자 전역 대표 영상 조회","description":"그 격자를 전역에서 대표하는 영상 1건을 반환한다. 공개(PUBLIC)·READY 영상 중 조회수(view_count) → 최신(createdAt) 순으로 뽑으며, 본인·타인 영상 모두 후보다. 비공개·삭제·인코딩 미완 영상은 제외한다. 후보가 없으면(미점령·비공개만·존재하지 않는 gridId) data 는 null 이다. 썸네일은 presigned GET URL 로 내려준다.","operationId":"getGridCover","parameters":[{"name":"gridId","in":"path","description":"격자 ID","required":true,"schema":{"type":"string"},"example":"19422_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoGridCoverVideoResponseDto"}}}}}}},"/api/grids/aggregation":{"get":{"tags":["격자 (Grid)"],"summary":"뷰포트 내 색칠 격자 행정 단위 집계 조회 (줌아웃)","description":"응답 data는 {currentRegion, items} 객체다. currentRegion은 뷰포트 중심이 속한 행정동의 이름과 그 동 전체에서 내가 점령한 격자 수·영상 수를 담는다. 화면 범위나 unit과 무관하며, 중심이 해상 또는 서비스 범위 밖일 때만 null이다.\n\nitems는 bbox 안에서 내가 점령한 격자를 행정 단위로 묶어 센 목록이다. 단위 전환 시점은 서버가 정하지 않으며 클라이언트가 화면 축척에 맞춰 unit만 바꿔 부른다. items가 비어 있어도 한국 내 중심점의 currentRegion은 이름과 0 집계를 독립적으로 담는다.\n\n항목마다 마커 식별 키(regionCode), 표시 이름, 대표 좌표, 격자 수가 온다. 대표 좌표는 그 묶음에 속한 점령 격자 중심의 평균이라 마커가 실제 데이터 위에 선다. 어느 단위로 묶어도, 항목을 더 묶어 합산해도 같은 bbox 개별 격자 조회의 총 개수와 일치한다.\n\n행정동이 판정되지 않은 격자(해상 등)는 제외가 아니라 regionCode·name 이 null 인 항목 하나로 묶여 온다. 점령 격자가 없으면 빈 배열이다.\n\nbbox span 상한은 단위별로 다르다(DONG 1도, SIGUNGU 4도, SIDO 10도 — 위도·경도 각 변에 따로 적용). 초과 시 400 + developCode 4402, 좌표가 WGS84 범위를 벗어나거나 bbox 가 뒤집히면 4401, unit 이 없거나 미지원 값이면 4405 다.","operationId":"getOccupiedAggregatesInViewport","parameters":[{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.1},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":128.9},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.3},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":129.2},{"name":"unit","in":"query","description":"집계 단위 — DONG(동), SIGUNGU(시군구), SIDO(시도). 대소문자 무관","required":true,"schema":{"type":"string"},"example":"DONG"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoGridAggregationResponseDto"}}}}}}},"/api/friends":{"get":{"tags":["친구 (Friend)"],"summary":"친구 목록 조회","description":"수락된 친구 전체를 반환한다 — 누가 먼저 요청했는지와 무관하다. 기본 정렬은 친구가 된 시각 내림차순이고 sort=nickname 이면 닉네임순이다. 친구가 없으면 빈 배열.","operationId":"getFriends","parameters":[{"name":"sort","in":"query","description":"정렬 기준 — recent(기본, 친구가 된 시각 내림차순) 또는 nickname","required":false,"schema":{"type":"string"},"example":"nickname"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListFriendListItemResponseDto"}}}}}}},"/api/friends/{userId}/profile":{"get":{"tags":["친구 (Friend)"],"summary":"친구 프로필·도감 요약 조회","description":"친구의 프로필(닉네임·프로필 이미지·도감 색상)과 도감 요약(수집 격자 수·영상 총합·방문 동 수), 최근 수집 격자 최대 30개를 한 번에 반환한다. 도감 요약 수치는 그 친구가 자기 도감에서 보는 값과 같다. 썸네일은 그 격자에 재생 가능한 공개 영상이 있을 때만 붙는다. 친구가 아닌 사용자·본인·존재하지 않는 사용자 조회는 모두 같은 404 다.","operationId":"getFriendProfile","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoFriendProfileResponseDto"}}}}}}},"/api/friends/{userId}/grids":{"get":{"tags":["친구 (Friend)"],"summary":"친구 격자 뷰포트 조회","description":"지도 화면 bbox(남서~북동 좌표) 안에서 그 친구가 점령한 격자를 (grid_y, grid_x) 오름차순으로 반환한다. 응답 형상·검증 규칙·에러는 내 격자 조회(GET /api/grids)와 같다 — 응답의 nextCursor 를 다음 요청 cursor 에 넣어 이어 조회하고, bbox span 상한 0.5도는 위도·경도 각 변에 따로 적용되며 초과 시 400 + 4402(VIEWPORT_TOO_LARGE)로 거절된다. 격자 색상은 내려주지 않는다(FE 단일색 렌더). 친구가 아닌 사용자·본인·존재하지 않는 사용자 조회는 모두 같은 404 다.","operationId":"getFriendGrids","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.5},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.0},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.55},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.05},{"name":"cursor","in":"query","description":"다음 페이지 커서 (직전 응답의 nextCursor). 첫 페이지는 생략","required":false,"schema":{"type":"string"},"example":"MTk0MjJfOTU4Mg=="},{"name":"size","in":"query","description":"페이지 크기 (기본 1000, 최대 5000)","required":false,"schema":{"type":"integer","format":"int32","default":1000},"example":1000}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoOccupiedGridPageResponseDto"}}}}}}},"/api/friends/{userId}/grids/{gridId}/videos":{"get":{"tags":["친구 (Friend)"],"summary":"친구 격자 영상 목록 조회","description":"그 친구가 해당 격자에 올린 영상을 최근 업로드 순으로 반환한다. 친구에게 공개된 영상(전체 공개·친구만 보기)만 담기고 비공개 영상은 포함되지 않으며, 삭제·인코딩 미완 영상도 제외된다 — 목록의 영상은 모두 재생 조회로 바로 진입할 수 있다. 친구가 점령하지 않은 격자·존재하지 않는 gridId 는 빈 배열이다. 친구가 아닌 사용자·본인·존재하지 않는 사용자 조회는 모두 같은 404 다.","operationId":"getFriendGridVideos","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"gridId","in":"path","description":"격자 ID","required":true,"schema":{"type":"string"},"example":"19422_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListFriendGridVideoResponseDto"}}}}}}},"/api/friends/{userId}/grids/aggregation":{"get":{"tags":["친구 (Friend)"],"summary":"친구 격자 행정 단위 집계 조회 (줌아웃)","description":"지도를 축소한 시야에서 그 친구가 점령한 격자를 행정 단위로 묶어 센 목록을 페이지 없이 한 번에 반환한다. 파라미터·에러는 내 집계 조회(GET /api/grids/aggregation)와 같고, 묶음 항목의 공통 필드는 내 집계 조회와 같다. 다만 친구 응답은 currentRegion/items 겉면 없이 기존 배열로 반환한다. 단위 전환 시점은 서버가 정하지 않고 클라이언트가 화면 축척에 맞춰 unit 만 바꿔 부른다.\n\n항목마다 마커 식별 키(regionCode), 표시 이름, 대표 좌표, 격자 수가 온다. 행정동이 판정되지 않은 격자(해상 등)는 제외가 아니라 regionCode·name 이 null 인 항목 하나로 묶여 오고, 그 친구가 점령한 격자가 없으면 빈 배열이다.\n\nbbox span 상한은 단위별로 다르다(DONG 1도, SIGUNGU 4도, SIDO 10도 — 위도·경도 각 변에 따로 적용). 초과 시 400 + developCode 4402, bbox 가 뒤집히거나 파라미터가 빠지면 4401, unit 이 없거나 미지원 값이면 4405 다. 친구가 아닌 사용자·본인·존재하지 않는 사용자 조회는 모두 같은 404 다.","operationId":"getFriendGridAggregates","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.1},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":128.9},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.3},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":129.2},{"name":"unit","in":"query","description":"집계 단위 — DONG(동), SIGUNGU(시군구), SIDO(시도). 대소문자 무관","required":true,"schema":{"type":"string"},"example":"DONG"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListRegionAggregateResponseDto"}}}}}}},"/api/friends/requests/received":{"get":{"tags":["친구 (Friend)"],"summary":"받은 친구 요청 목록","description":"내가 수신자인 대기 중 요청을 최신순으로 반환한다. 항목의 requesterId 를 수락/거절 경로 변수로 그대로 쓴다.","operationId":"getReceivedRequests","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListReceivedFriendRequestResponseDto"}}}}}}},"/api/friends/preview":{"get":{"tags":["친구 (Friend)"],"summary":"친구 코드 미리보기","description":"요청을 보내기 전 확인 화면용 — 코드 소유자의 닉네임과 나와의 관계 상태(relation)를 반환한다. relation 은 SELF(내 코드)·NONE(관계 없음)·OUTGOING_PENDING(내가 보낸 요청 대기)·INCOMING_PENDING(상대가 보낸 요청 대기)·FRIENDS(이미 친구) 다섯 값이고 조회 시점 실시간 판정이다. 미리보기는 힌트일 뿐이며 최종 검증(자기 자신·중복 등)은 요청 API 가 다시 수행한다.","operationId":"preview","parameters":[{"name":"code","in":"query","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoFriendPreviewResponseDto"}}}}}}},"/api/friends/code":{"get":{"tags":["친구 (Friend)"],"summary":"내 친구 코드 조회","description":"가입 시 자동 부여된 고정 8자 코드를 반환한다. 상대에게 임의 채널(카톡 등)로 공유하면 상대가 이 코드로 친구 요청을 보낼 수 있다. 재발급 없음.","operationId":"getMyFriendCode","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoFriendCodeResponseDto"}}}}}}},"/api/event-videos/{videoId}":{"get":{"tags":["행사 (Events)"],"summary":"행사 영상 상세 조회","description":"영상 하나의 재생본 presigned GET URL 과 표시 재료를 돌려준다. 소속 행사 회차·위치·대표 격자와 그 표시명 재료가 함께 담겨, 상세 화면이 추가 호출 없이 위치줄을 그린다.\n\n피드에 보이는 영상만 열린다 — 삭제·블라인드·비공개·처리 미완료 영상은 올린 본인에게도 404 + developCode 13406 이다(본인 영상 확인은 GET /api/videos/{videoId}). 행사 영상이 아닌 영상 id 도 같은 404 다.\n\ninteractionLocked 는 아카이브 전환(행사 종료 + 30일)부터 true 이며 댓글·도움돼요 입력 UI 를 비활성화하는 재료다(기존 수는 계속 표시. 유예 기간에는 반응을 계속 남길 수 있다). 재생 URL 을 발급받은 타인 조회는 조회수를 올린다 — 비로그인 조회도 포함이고 올린 본인은 제외다.","operationId":"getVideoDetail","parameters":[{"name":"videoId","in":"path","description":"영상 id","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventVideoDetailResponseDto"}}}}}}},"/api/event-occurrences":{"get":{"tags":["행사 (Events)"],"summary":"뷰포트 내 행사 회차 목록 조회","description":"지도 화면 bbox(남서~북동 좌표) 안에 노출 영역이 걸친 행사 회차를 반환한다. 담기는 것은 진행 중이거나, 시작 2주 전부터의 노출 기간에 든 예정 회차뿐이다 — 종료된 행사(업로드 유예·아카이브)는 칩에 담기지 않고 상세·격자 역조회로만 접근한다. 아직 노출 기간 전인 예정 회차는 존재 자체를 숨긴다.\n\n정렬은 시 이름 → 시작일 → 회차 id 오름차순이라, 시 칩 아래에 그 시의 행사 칩을 나열하는 화면이 매 요청 같은 순서를 받는다. 보이는 범위에 행사가 없으면 실패가 아니라 빈 배열이다.\n\nbbox span 상한은 0.5도로 위도·경도 각 변에 따로 적용된다(정확히 0.5도는 허용). 초과 시 400 + developCode 13401, 좌표가 WGS84 범위를 벗어나거나 bbox 가 뒤집히거나 파라미터가 빠지면 13400 이다. D-day 는 startsAt 을 KST 로 읽어 클라이언트가 계산한다.","operationId":"getOccurrencesInViewport","parameters":[{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.1},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":128.9},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.2},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":129.1}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListEventOccurrenceChipResponseDto"}}}}}}},"/api/event-occurrences/{occurrenceId}":{"get":{"tags":["행사 (Events)"],"summary":"행사 회차 상세 조회","description":"행사방 헤더 재료 — 행사명, 기간, 업로드 마감(종료 30일 후), 서버 시각 기준 상태, 알림 구독 여부, 같은 시리즈의 지난 회차 목록이다. 상태는 저장값이 아니라 요청 시점 계산이며 경계 정각은 다음 상태에 속한다(종료 정각부터 UPLOAD_GRACE).\n\n지난 회차는 최신순이고 예정 회차는 담기지 않는다. 그 회차의 위치·영상은 회차 id 로 위치 목록을 다시 부르면 되므로 회차 간 데이터가 섞이지 않는다. 알림 구독 여부는 구독을 켰으면서 회차가 예정이거나 진행 중일 때만 true 다 — 비로그인 열람과 종료된 회차는 false 다.\n\n존재하지 않는 회차와 아직 노출 기간 전인 예정 회차는 똑같이 404 + developCode 13404 다 — 노출 전 행사의 존재를 id 대입으로 알아낼 수 없다.","operationId":"getOccurrenceDetail","parameters":[{"name":"occurrenceId","in":"path","description":"행사 회차 id","required":true,"schema":{"type":"integer","format":"int64"},"example":12}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventOccurrenceDetailResponseDto"}}}}}}},"/api/event-occurrences/{occurrenceId}/viewer-count":{"get":{"tags":["행사방 (Event)"],"summary":"현재 열람 인원 조회","description":"viewerCount 0 은 아무도 없음(표시), null 은 캐시 장애(숨김)다. 응답이 사용자 무관이라 인증 없이 호출할 수 있다.","operationId":"getViewerCount","parameters":[{"name":"occurrenceId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventViewerCountResponseDto"}}}}}}},"/api/event-occurrences/{occurrenceId}/locations":{"get":{"tags":["행사 (Events)"],"summary":"행사 회차의 위치 목록 조회","description":"회차에 속한 행사 위치(팝업·체험존·퍼레이드 등)와 각 위치의 격자 영역, 대표 격자, 표시명 재료, 영상 수를 반환한다. 영상 수는 집계 테이블 없이 조회 시점에 세며, 위치별 영상 피드에 실제로 보이는 영상만 센다(삭제·비공개·처리 미완료 제외).\n\ngridIds 는 화면에서 영역을 채색하는 재료이고 영상은 그중 representativeGridId 하나에만 붙는다. 표시명은 대표 격자 기준으로 `zoneName + \" \" + zoneCell`, 구역 밖이면 regionName 을 쓴다. 정렬은 표시 순서 → 위치 id 오름차순이다. 위치가 없으면 빈 배열이고, 존재하지 않는 회차와 노출 기간 전인 예정 회차는 404 + developCode 13404 다.","operationId":"getLocations","parameters":[{"name":"occurrenceId","in":"path","description":"행사 회차 id","required":true,"schema":{"type":"integer","format":"int64"},"example":12}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListEventLocationResponseDto"}}}}}}},"/api/collections/videos":{"get":{"tags":["도감 (Collection)"],"summary":"동 단위 내 영상 조회","description":"행정동(regionCode) 격자들에 올린 로그인 사용자의 영상을 created_at 내림차순으로 반환한다(무커서). regionCode 는 by-grid 응답의 regionCode 를 그대로 넘긴다. 귀속은 격자 축이라 영상 좌표가 옆 동이어도 격자 소속 행정동 기준으로 포함된다. 내 도감이라 PRIVATE·인코딩 중 영상도 포함하며(status ACTIVE 만), 그 행정동에 내 영상이 없거나 미존재 regionCode 면 에러 없이 빈 배열을 받는다.","operationId":"getRegionVideos","parameters":[{"name":"regionCode","in":"query","description":"행정동 코드 — by-grid 응답의 regionCode 를 그대로 전달","required":true,"schema":{"type":"string"},"example":1168051500}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListRegionVideoResponseDto"}}}}}}},"/api/collections/upload-history":{"get":{"tags":["도감 (Collection)"],"summary":"날짜별 업로드 기록 조회","description":"로그인 사용자 본인의 업로드를 KST 날짜로 접어, 업로드가 있었던 날과 그날의 건수를 날짜 오름차순으로 반환한다(잔디 재료 — 빈 날은 항목 없음, 빈 칸 채우기는 FE 몫). 삭제·블라인드된 영상의 업로드도 센다. 업로드 0건 사용자는 에러 없이 빈 배열을 받는다.","operationId":"getUploadHistory","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListUploadHistoryResponseDto"}}}}}}},"/api/collections/summary":{"get":{"tags":["도감 (Collection)"],"summary":"개인 도감 요약 조회","description":"로그인 사용자의 점령한 격자 수·올린 영상 총합·방문한 행정동 수에 더해 현재 스트릭·최장 스트릭·획득 뱃지 수를 한 번에 반환한다. 현재 스트릭은 마지막 기록이 KST 그제 이전이면 0이다. 업로드 경험 0 사용자도 에러 없이 여섯 값이 모두 0으로 응답한다.","operationId":"getSummary","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoCollectionSummaryResponseDto"}}}}}}},"/api/collections/grids":{"get":{"tags":["도감 (Collection)"],"summary":"갤러리 격자 목록 조회","description":"로그인 사용자가 수집한 격자를 카드로 반환한다(무커서). 파라미터를 모두 생략하면 전국을 first_collected_at 내림차순 최대 30개로 준다(기존 계약). regionCode 를 주면 그 행정동에 속한 내 격자만 나가며, 귀속은 격자 축이라 영상 좌표가 옆 동이어도 격자 소속 행정동 기준으로 잡힌다. 각 항목은 gridId·gridY/gridX·수집/방문 시각·영상 수·cover 영상 ID·cover 썸네일 URL·cover 길이(초)를 담는다. 내 격자가 없거나 미존재 regionCode 면 에러 없이 빈 배열을 받는다.","operationId":"getCollectionGrids","parameters":[{"name":"regionCode","in":"query","description":"행정동 코드 — 생략하면 전국. by-grid 응답의 regionCode 를 그대로 전달","required":false,"schema":{"type":"string"},"example":1168051500},{"name":"sort","in":"query","description":"정렬 축 — COLLECTED(수집 시각순, 기본) 또는 UPLOADED(최신 업로드순)","required":false,"schema":{"type":"string","default":"COLLECTED","enum":["COLLECTED","UPLOADED"]}},{"name":"limit","in":"query","description":"카드 수 상한 — 지도 홈 패널은 20 (SRS FR-MAP-10). 생략하면 regionCode 없을 때 30, regionCode 있을 때 그 동네 전부. 1 미만은 1 로 보정한다","required":false,"schema":{"type":"integer","format":"int32"},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListCollectionGridResponseDto"}}}}}}},"/api/badges":{"get":{"tags":["뱃지 (Badge)"],"summary":"내 뱃지 전체 목록","description":"시딩된 뱃지를 내 획득 상태와 함께 시딩 순(badges.id 오름차순)으로 반환한다. 은퇴 뱃지(retired_at 있음)는 획득자에게만 보이고 미획득자 목록에서는 빠진다 — 그래서 사용자마다 행 수가 다를 수 있다. 미획득 행은 earned false·earnedAt null·isNew false·featuredRank null. 이번 응답에 노출된 미확인(새 뱃지) 행은 자동으로 확인 처리되어 다음 조회부터 isNew false 가 된다.","operationId":"findMyBadges","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListMyBadgeResponseDto"}}}}}}},"/api/auth/oauth/kakao/authorize":{"get":{"tags":["인증 (Auth)"],"summary":"카카오 로그인 시작 (인가 진입점)","description":"웹 로그인의 시작점이다. 클라이언트는 이 URL 로 이동하기만 하면 된다(location.href). 서버가 카카오 인가 URL(client_id·response_type=code·scope=openid·nonce 포함)을 조립해 302 로 보내면서 같은 응답에 OAUTH_NONCE 쿠키(HttpOnly, 10분)를 심는다. 그래서 scope=openid 누락이나 nonce 누락이 구조적으로 불가능하고, REST API 키가 클라이언트 코드로 나갈 일도 없다. 응답은 리다이렉트라 공통 응답 포맷을 쓰지 않는다.","operationId":"redirectToKakaoAuthorize","parameters":[{"name":"redirectUri","in":"query","description":"카카오 콜백 URI. 콘솔 등록값과 정확히 일치해야 한다(검증 주체는 카카오).","required":true,"schema":{"type":"string"},"example":"http://localhost:5173/oauth/kakao/callback"},{"name":"state","in":"query","description":"콜백 위조 검증용 난수. 서버는 손대지 않고 인가 URL 에 그대로 전달한다.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/admin/videos/{videoId}":{"get":{"tags":["관리자 신고 처리 (Admin Report)"],"summary":"관리자 단건 영상 확인","description":"신고 판단용으로 영상 하나를 확인한다 — 공개범위와 상태(BLINDED 포함)를 무시하고 요청 시점에 재생·썸네일 presigned URL 을 발급하며, 조회수를 올리지 않는다. 처리 상태가 READY 가 아니면 playbackUrl 과 expiresInSec 은 null 이다. 없는 영상과 삭제된 영상은 404(3404) 다.","operationId":"getVideoForReview","parameters":[{"name":"videoId","in":"path","description":"확인할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminVideoReviewResponseDto"}}}}}}},"/api/admin/reports":{"get":{"tags":["관리자 신고 처리 (Admin Report)"],"summary":"신고 목록 조회","description":"상태 필터 기준으로 신고를 접수 최신순 페이지 단위로 조회한다. 기본은 미처리(PENDING) 신고다. 항목에 신고자·영상 소유자 닉네임과 영상 현재 상태가 함께 담겨 목록만으로 판단할 수 있다. 지원하지 않는 status 는 400(11420), page 음수나 size 범위(1~100) 밖은 400(11421) 이다. REVIEWING 은 유효한 값이지만 만드는 경로가 없어 항상 빈 목록이다.","operationId":"getReports","parameters":[{"name":"status","in":"query","description":"신고 상태 필터 (PENDING, REVIEWING, RESOLVED, REJECTED — 대소문자 무관)","required":false,"schema":{"type":"string","default":"PENDING"},"example":"PENDING"},{"name":"page","in":"query","description":"페이지 번호 (0부터)","required":false,"schema":{"type":"integer","format":"int32","default":0},"example":0},{"name":"size","in":"query","description":"페이지 크기 (1~100)","required":false,"schema":{"type":"integer","format":"int32","default":20},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminReportListResponseDto"}}}}}}},"/api/friends/{userId}":{"delete":{"tags":["친구 (Friend)"],"summary":"친구 삭제","description":"친구 관계를 해소한다. 어느 쪽이든 삭제할 수 있고 즉시 양쪽 모두에서 사라진다. 대기 중 요청은 대상이 아니다.","operationId":"deleteFriend","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"OK"}}}}},"components":{"schemas":{"VideoReplaceRequestDto":{"type":"object","description":"영상 교체 요청. 파일만 바꾸려면 좌표를 생략한다. 좌표를 보내면 기존과 같은 격자여야 하며 다르면 GRID_MISMATCH로 거부된다.","properties":{"s3Key":{"type":"string","description":"새로 업로드한 영상의 S3 객체 키","example":"videos/2026/07/new-uuid.mp4","minLength":1},"lat":{"type":["number","null"],"format":"double","description":"위도 (선택). lng와 함께 보내거나 둘 다 생략","example":37.5665},"lng":{"type":["number","null"],"format":"double","description":"경도 (선택). lat과 함께 보내거나 둘 다 생략","example":126.978},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초). 1~30초","example":15,"maximum":30,"minimum":1},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각","example":"2026-07-17T14:30:00Z"}},"required":["durationSec","recordedAt","s3Key"]},"ApiResponseDtoVideoReplaceResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/VideoReplaceResponseDto"}},"required":["data","developCode","message"]},"VideoReplaceResponseDto":{"type":"object","description":"영상 교체 응답. 교체 직후는 항상 재인코딩 대기(UPLOADED) 상태다.","properties":{"videoId":{"type":"integer","format":"int64","description":"교체된 영상 ID","example":1001},"processingStatus":{"type":"string","description":"영상 처리 상태 (교체 직후 UPLOADED)","example":"UPLOADED"}},"required":["processingStatus","videoId"]},"ProfileImageUpdateRequestDto":{"type":"object","description":"프로필 이미지 변경 확정 요청 (MSG-373)","properties":{"s3Key":{"type":"string","description":"presign 발급으로 받은 pending 키. 그 URL 로 업로드를 마친 뒤 그대로 전달한다.","example":"profiles/pending/42/3f0c1f2e-....jpg","minLength":1}},"required":["s3Key"]},"ApiResponseDtoUserProfileResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/UserProfileResponseDto"}},"required":["data","developCode","message"]},"UserProfileResponseDto":{"type":"object","description":"내 프로필 응답. 조회·닉네임 수정·프로필 이미지 변경·위치정보 동의 변경이 같은 형태를 반환한다.","properties":{"email":{"type":["string","null"],"description":"가입 이메일 — 이메일 가입 시 저장된 값. 카카오 가입은 이메일을 수집하지 않아 null (MSG-310)","example":"user@fillmap.dev"},"nickname":{"type":"string","description":"닉네임 — 카카오 로그인 시 카카오 닉네임이 자동 저장되며, 이후 수정 가능","example":"채우미"},"profileImageUrl":{"type":["string","null"],"description":"프로필 이미지 공개 URL — 미설정이면 null 이고 기본 프로필 표시는 FE 몫이다 (MSG-373)","example":"https://fillmap-video-dev.s3.ap-northeast-2.amazonaws.com/profiles/original/42/uuid.jpg"},"createdAt":{"type":"string","format":"date-time","description":"가입 시각 — DB 저장값(UTC) 그대로다. \"2026.01.12\" 같은 표기는 FE 몫 (MSG-373)","example":"2026-01-12T03:24:11Z"},"locationConsent":{"type":"boolean","description":"위치기반서비스 이용 동의 여부 — 가입 직후는 false 다. 마지막 변경 시각은 서버에만 두고 응답에 싣지 않는다 (MSG-402 §D-6)","example":false},"role":{"type":"string","description":"사용자 역할 — 화면이 일반 사용자·행사 운영자·관리자 진입을 가르는 재료다 (MSG-496)","enum":["USER","ORG","ADMIN"],"example":"USER"}},"required":["createdAt","email","locationConsent","nickname","profileImageUrl","role"]},"NicknameUpdateRequestDto":{"type":"object","description":"닉네임 수정 요청","properties":{"nickname":{"type":"string","description":"새 닉네임 (2~20자)","example":"채우미","maxLength":20,"minLength":2}},"required":["nickname"]},"MarketingConsentUpdateRequestDto":{"type":"object","description":"마케팅 정보 수신 동의 변경 요청","properties":{"consented":{"type":"boolean","description":"true 면 동의, false 면 철회","example":true}},"required":["consented"]},"ApiResponseDtoConsentStatusResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/ConsentStatusResponseDto"}},"required":["data","developCode","message"]},"ConsentStatusResponseDto":{"type":"object","description":"가입 약관 동의 상태. 조회·제출·마케팅 변경이 같은 형태를 반환한다.","properties":{"ageOver14":{"type":"boolean","description":"만 14세 이상 확인 여부 (필수). 자기 확인 체크 사실만 저장하며 생년월일은 수집하지 않는다","example":true},"serviceTerms":{"type":"boolean","description":"서비스 이용약관 동의 여부 (필수)","example":true},"privacyPolicy":{"type":"boolean","description":"개인정보 수집·이용 동의 여부 (필수)","example":true},"locationTerms":{"type":"boolean","description":"위치기반서비스 이용약관 동의 여부 (필수). 프로필 화면의 위치정보 사용 동의와 같은 한 값이며 철회할 수 없다 — 한 번 true 가 되면 되돌아가지 않는다","example":true},"marketing":{"type":"boolean","description":"마케팅 정보 수신 동의 여부 (선택). 가입 후에도 전용 API 로 켜고 끌 수 있다","example":false},"requiredCompleted":{"type":"boolean","description":"필수 4항목을 전부 동의했으면 true. false 면 클라이언트가 동의 게이트를 띄운다","example":true}},"required":["ageOver14","locationTerms","marketing","privacyPolicy","requiredCompleted","serviceTerms"]},"LocationConsentUpdateRequestDto":{"type":"object","description":"위치정보 사용 동의 켜기 요청","properties":{"consented":{"type":"boolean","description":"true 면 동의. 이 동의는 철회할 수 없어 false 는 1400 으로 거절된다","example":true}},"required":["consented"]},"ConsentSubmitRequestDto":{"type":"object","description":"가입 약관 동의 제출 요청. 필수 4항목은 true 여야 하고 마케팅만 선택이다.","properties":{"ageOver14":{"type":"boolean","description":"만 14세 이상 확인 (필수, true 만 허용)","example":true},"serviceTerms":{"type":"boolean","description":"서비스 이용약관 동의 (필수, true 만 허용)","example":true},"privacyPolicy":{"type":"boolean","description":"개인정보 수집·이용 동의 (필수, true 만 허용)","example":true},"locationTerms":{"type":"boolean","description":"위치기반서비스 이용약관 동의 (필수, true 만 허용)","example":true},"marketing":{"type":"boolean","description":"마케팅 정보 수신 동의 (선택). true·false 모두 유효하되 누락은 400 이다","example":false}},"required":["ageOver14","locationTerms","marketing","privacyPolicy","serviceTerms"]},"ApiResponseDtoEventVideoHelpfulResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventVideoHelpfulResponseDto"}},"required":["data","developCode","message"]},"EventVideoHelpfulResponseDto":{"type":"object","description":"행사 영상 도움돼요 변경 결과","properties":{"helpfulCount":{"type":"integer","format":"int64","description":"처리 후 현재 도움돼요 수","example":12},"helpfulByMe":{"type":"boolean","description":"내가 누른 상태인지","example":true}},"required":["helpfulByMe","helpfulCount"]},"EventNotificationUpdateRequestDto":{"type":"object","description":"행사 알림 구독 토글","properties":{"enabled":{"type":"boolean","description":"구독 여부 — true 면 ON, false 면 OFF","example":true}},"required":["enabled"]},"ApiResponseDtoEventNotificationResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventNotificationResponseDto"}},"required":["data","developCode","message"]},"EventNotificationResponseDto":{"type":"object","description":"행사 알림 구독 상태","properties":{"enabled":{"type":"boolean","description":"구독 여부 — 구독 행 존재이면서 회차가 예정·진행 중일 때만 true","example":true}},"required":["enabled"]},"FeaturedBadgeRequestDto":{"type":"object","description":"대표 뱃지 집합 교체 요청 — 배열 순서가 표시 순서, 빈 배열은 전부 해제","properties":{"badgeIds":{"type":"array","description":"대표로 지정할 뱃지 id 목록 (최대 2개, 순서 = 표시 순서)","example":[3,7],"items":{"type":"integer","format":"int64"},"maxItems":2,"minItems":0}},"required":["badgeIds"]},"ApiResponseDtoListFeaturedBadgeResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/FeaturedBadgeResponseDto"}}},"required":["data","developCode","message"]},"FeaturedBadgeResponseDto":{"type":"object","description":"적용된 대표 뱃지","properties":{"badgeId":{"type":"integer","format":"int64","description":"뱃지 ID","example":3},"code":{"type":"string","description":"뱃지 code","example":"EXPLORER_50"},"name":{"type":"string","description":"표시명","example":"탐험가 II"},"iconUrl":{"type":["string","null"],"description":"아이콘 URL (에셋 확정 전 null)","example":null},"rank":{"type":"integer","format":"int32","description":"표시 순서 (1·2)","example":1}},"required":["badgeId","code","iconUrl","name","rank"]},"VideoUploadRequestDto":{"type":"object","description":"S3 업로드 완료 후 영상 메타데이터 저장 요청","properties":{"s3Key":{"type":"string","description":"presigned 발급 때 받은 S3 객체 키","example":"videos/2026/07/uuid.mp4","minLength":1},"lat":{"type":"number","format":"double","description":"촬영 위치 위도 (격자 매핑에 사용)","example":37.5665},"lng":{"type":"number","format":"double","description":"촬영 위치 경도 (격자 매핑에 사용)","example":126.978},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초). 1~30초","example":15,"maximum":30,"minimum":1},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각","example":"2026-07-17T14:30:00Z"},"visibility":{"type":"string","description":"공개범위. PUBLIC, PRIVATE, FRIENDS 중 하나. 생략 시 PUBLIC","example":"PUBLIC"}},"required":["durationSec","lat","lng","recordedAt","s3Key"]},"ApiResponseDtoVideoUploadResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/VideoUploadResponseDto"}},"required":["data","developCode","message"]},"CompletedMissionResponseDto":{"type":"object","description":"이번 업로드로 완료된 미션 스탬프","properties":{"missionId":{"type":"integer","format":"int64","description":"미션 ID","example":3},"title":{"type":"string","description":"미션 제목","example":"성수 골목 코스"},"type":{"type":"string","description":"미션 유형 (COURSE/AREA/EVENT/THEME/CONTINUOUS)","example":"COURSE"}},"required":["missionId","title","type"]},"EarnedBadgeResponseDto":{"type":"object","description":"이번 행동으로 새로 획득한 뱃지","properties":{"badgeId":{"type":"integer","format":"int64","description":"뱃지 ID","example":1},"code":{"type":"string","description":"뱃지 code","example":"EXPLORER_1"},"name":{"type":"string","description":"표시명","example":"첫 발자국"},"description":{"type":["string","null"],"description":"설명 — badges.description 은 NULL 허용 컬럼이다","example":"첫 격자를 수집했어요"},"iconUrl":{"type":["string","null"],"description":"아이콘 URL (에셋 확정 전 null)","example":null}},"required":["badgeId","code","description","iconUrl","name"]},"VideoUploadResponseDto":{"type":"object","description":"영상 메타데이터 저장 응답","properties":{"videoId":{"type":"integer","format":"int64","description":"생성된 영상 ID","example":1001},"gridId":{"type":"string","description":"매핑된 격자 ID","example":"19422_9582"},"processingStatus":{"type":"string","description":"영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED)","example":"UPLOADED"},"occupied":{"type":"boolean","description":"이 업로드로 격자를 처음 점령(첫 방문)했는지 여부","example":true},"newBadges":{"type":"array","description":"이 업로드로 새로 획득한 뱃지 목록 — 없으면 빈 배열","items":{"$ref":"#/components/schemas/EarnedBadgeResponseDto"}},"completedMissions":{"type":"array","description":"이 업로드로 완료된 미션 스탬프 목록 — 없으면 빈 배열","items":{"$ref":"#/components/schemas/CompletedMissionResponseDto"}},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름 (예 \"서면\"). 구역 밖 격자면 null — 이때 라벨은 regionName 이다","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A 는 구역 북단, 열 1 은 서단). zoneName 과 항상 쌍이라 구역 밖이면 함께 null","example":"I-6"},"regionName":{"type":["string","null"],"description":"격자 중심점 행정동 이름 — 구역 밖 격자의 폴백 라벨. 무귀속(해상 등)이거나 미판정이면 null","example":"서울특별시 강남구 역삼1동"}},"required":["completedMissions","gridId","newBadges","occupied","processingStatus","regionName","videoId","zoneCell","zoneName"]},"ReportCreateRequestDto":{"type":"object","description":"영상 신고 접수 요청. 사유 5종 중 하나와 선택적 상세 설명.","properties":{"reason":{"type":"string","description":"신고 사유. INAPPROPRIATE, PRIVACY, SPAM, COPYRIGHT, OTHER 중 하나 (대소문자 무관)","example":"INAPPROPRIATE","minLength":1},"detail":{"type":"string","description":"상세 설명. OTHER 사유는 필수, 나머지 사유는 선택. 최대 500자","example":"타인의 얼굴이 그대로 찍혀 있습니다","maxLength":500,"minLength":0}},"required":["reason"]},"ApiResponseDtoReportCreateResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/ReportCreateResponseDto"}},"required":["data","developCode","message"]},"ReportCreateResponseDto":{"type":"object","description":"영상 신고 접수 응답.","properties":{"reportId":{"type":"integer","format":"int64","description":"접수된 신고 ID","example":17},"status":{"type":"string","description":"신고 처리 상태. 접수 직후라 항상 PENDING","example":"PENDING"}},"required":["reportId","status"]},"PresignedUrlRequestDto":{"type":"object","description":"S3 업로드용 presigned URL 발급 요청","properties":{"extension":{"type":"string","description":"영상 파일 확장자 (점 없이)","example":"mp4","minLength":1},"contentType":{"type":"string","description":"영상 MIME 타입","example":"video/mp4","minLength":1},"contentLength":{"type":"integer","format":"int64","description":"업로드할 파일 크기(바이트). 서버 상한 초과 시 거부","example":10485760},"purpose":{"type":"string","description":"발급 용도. 미지정(null)은 UPLOAD 와 동일. 하이라이트 선분석 원본은 HIGHLIGHT_PREVIEW 로 발급받아 전용 크기 상한(기본 2GiB)을 적용받는다","example":"HIGHLIGHT_PREVIEW","pattern":"UPLOAD|HIGHLIGHT_PREVIEW"}},"required":["contentLength","contentType","extension"]},"ApiResponseDtoPresignedUrlResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/PresignedUrlResponseDto"}},"required":["data","developCode","message"]},"PresignedUrlResponseDto":{"type":"object","description":"presigned URL 발급 응답. uploadUrl로 S3에 직접 PUT 업로드 후, s3Key로 메타데이터 저장(POST /api/videos)을 호출한다.","properties":{"uploadUrl":{"type":"string","description":"S3에 직접 PUT 업로드할 presigned URL","example":"https://bucket.s3.amazonaws.com/videos/..."},"s3Key":{"type":"string","description":"업로드 대상 S3 객체 키. 이후 메타데이터 저장 요청에 그대로 전달한다.","example":"videos/2026/07/uuid.mp4"},"expiresInSec":{"type":"integer","format":"int64","description":"presigned URL 유효 시간(초)","example":300}},"required":["expiresInSec","s3Key","uploadUrl"]},"HighlightPreviewRequestDto":{"type":"object","description":"하이라이트 선분석 요청 (MSG-351). 원본은 presign(purpose=HIGHLIGHT_PREVIEW)으로 먼저 올린다.","properties":{"s3Key":{"type":"string","description":"presign 으로 올린 원본의 pending 키. videos/pending/{내 userId}/ prefix 여야 한다","example":"videos/pending/42/550e8400-e29b-41d4-a716-446655440000.mp4","minLength":1}},"required":["s3Key"]},"ApiResponseDtoHighlightPreviewResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/HighlightPreviewResponseDto"}},"required":["data","developCode","message"]},"HighlightPreviewResponseDto":{"type":"object","description":"하이라이트 선분석 응답 (MSG-351). 결과는 저장되지 않는 임시 값이다 — 확정본의 하이라이트는 업로드 확정 후 블러 파이프라인이 따로 계산한다.","properties":{"highlights":{"type":"array","description":"[[시작초, 끝초], ...] 최대 3구간, 초는 소수점 둘째 자리. 배열 순서가 추천 우선순위(첫 요소가 최우선)다. 각 구간은 5초 이상이고 시작점끼리 5초 이상 벌어진다. 5초 미만 원본이거나 조건을 채우는 구간이 없으면 빈 배열 [] — 추천 없음이니 FE 는 추천 단계를 스킵한다","example":[[0.0,5.12],[10.0,16.4]],"items":{"type":"array","items":{"type":"number","format":"double"}}}},"required":["highlights"]},"ProfileImagePresignRequestDto":{"type":"object","description":"프로필 이미지 업로드용 presigned URL 발급 요청 (MSG-373)","properties":{"extension":{"type":"string","description":"이미지 파일 확장자 (점 없이). jpg, jpeg, png, webp — heic·heif 는 받지 않는다","example":"jpg","minLength":1},"contentType":{"type":"string","description":"이미지 MIME 타입. 확장자와 쌍이 맞아야 한다","example":"image/jpeg","minLength":1},"contentLength":{"type":"integer","format":"int64","description":"업로드할 파일 크기(바이트). 5MB 초과 시 거부","example":1048576}},"required":["contentLength","contentType","extension"]},"ApiResponseDtoProfileImagePresignResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/ProfileImagePresignResponseDto"}},"required":["data","developCode","message"]},"ProfileImagePresignResponseDto":{"type":"object","description":"프로필 이미지 presigned URL 발급 응답. uploadUrl 로 S3 에 직접 PUT 업로드한 뒤 s3Key 로 변경 확정(PUT /api/users/me/profile-image)을 호출한다.","properties":{"uploadUrl":{"type":"string","description":"S3 에 직접 PUT 업로드할 presigned URL","example":"https://bucket.s3.amazonaws.com/profiles/..."},"s3Key":{"type":"string","description":"업로드 대상 S3 객체 키. 변경 확정 요청에 그대로 전달한다.","example":"profiles/pending/42/3f0c1f2e-....jpg"},"expiresInSec":{"type":"integer","format":"int64","description":"presigned URL 유효 시간(초)","example":600}},"required":["expiresInSec","s3Key","uploadUrl"]},"RouteWalkPathRequestDto":{"type":"object","description":"보행 경로 조회 요청","properties":{"segments":{"type":"array","description":"추천 응답의 이웃 좌표쌍 목록 (1~8개 — 지점 상한 8이라 세그먼트 최대 7개에 출발지 구간 1개)","items":{"$ref":"#/components/schemas/SegmentDto"}}}},"SegmentDto":{"type":"object","description":"이웃 두 지점 사이 구간 (WGS84)","properties":{"startLat":{"type":"number","format":"double","description":"출발 위도","example":35.1587},"startLng":{"type":"number","format":"double","description":"출발 경도","example":129.1604},"endLat":{"type":"number","format":"double","description":"도착 위도","example":35.1631},"endLng":{"type":"number","format":"double","description":"도착 경도","example":129.1635}}},"ApiResponseDtoRouteWalkPathResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/RouteWalkPathResponseDto"}},"required":["data","developCode","message"]},"PathPointDto":{"type":"object","description":"보행로 좌표 (WGS84)","properties":{"lat":{"type":"number","format":"double","description":"위도","example":35.1587},"lng":{"type":"number","format":"double","description":"경도","example":129.1604}},"required":["lat","lng"]},"RouteWalkPathResponseDto":{"type":"object","description":"보행 경로 조회 응답 — 요청과 같은 개수, 같은 순서","properties":{"segments":{"type":"array","description":"세그먼트별 보행 경로 결과","items":{"$ref":"#/components/schemas/WalkSegmentDto"}}},"required":["segments"]},"WalkSegmentDto":{"type":"object","description":"세그먼트 보행 경로","properties":{"resolved":{"type":"boolean","description":"보행 경로 확보 여부 — false 면 직선 폴백"},"path":{"type":["array","null"],"description":"보행로를 따르는 좌표열 (위도-경도 순). 실패 시 null","items":{"$ref":"#/components/schemas/PathPointDto"}},"distanceMeters":{"type":["integer","null"],"format":"int32","description":"실제 걷는 거리 (TMap totalDistance, 미터). 실패 시 null"}},"required":["distanceMeters","path","resolved"]},"OriginDto":{"type":"object","description":"출발 지점 좌표","properties":{"lat":{"type":"number","format":"double","description":"위도","example":35.115,"maximum":90.0,"minimum":-90.0},"lng":{"type":"number","format":"double","description":"경도","example":129.042,"maximum":180.0,"minimum":-180.0}},"required":["lat","lng"]},"RouteRecommendRequestDto":{"type":"object","description":"AI 경로 추천 요청","properties":{"text":{"type":"string","description":"하고 싶은 일 자연어 한 문장 (trim 후 1~500자)","example":"부산역 내려서 해운대에서 밥 먹고 축제도 보고 싶어","maxLength":500,"minLength":0},"viewport":{"$ref":"#/components/schemas/ViewportDto","description":"지금 보고 있는 지도 범위 (WGS84 사각형)"},"origin":{"$ref":"#/components/schemas/OriginDto","description":"출발 지점 좌표 (선택). 있으면 동선이 여기서 시작한다"}},"required":["text","viewport"]},"ViewportDto":{"type":"object","description":"WGS84 뷰포트 사각형","properties":{"minLat":{"type":"number","format":"double","description":"남서 위도","example":35.05},"minLng":{"type":"number","format":"double","description":"남서 경도","example":128.95},"maxLat":{"type":"number","format":"double","description":"북동 위도","example":35.25},"maxLng":{"type":"number","format":"double","description":"북동 경도","example":129.2}},"required":["maxLat","maxLng","minLat","minLng"]},"ApiResponseDtoRouteRecommendResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/RouteRecommendResponseDto"}},"required":["data","developCode","message"]},"MentionedAreaDto":{"type":"object","description":"언급 지역 신호 — 지도 이동(MOVE)·축소(ZOOM_OUT) 제안의 이름·중심·범위 재료","properties":{"name":{"type":"string","description":"지역의 정식 표기 — 행정구역 매칭 단위 토큰 또는 구역 통칭(zones.name)","example":"부산광역시"},"centerLat":{"type":"number","format":"double","description":"지역 중심 위도 (WGS84) — 행정구역은 경계 무게중심, 구역은 외접 사각형 중점","example":35.1985},"centerLng":{"type":"number","format":"double","description":"지역 중심 경도 (WGS84)","example":129.0538},"minLat":{"type":"number","format":"double","description":"외접 사각형 남단 위도 (WGS84)","example":35.0512},"minLng":{"type":"number","format":"double","description":"외접 사각형 서단 경도 (WGS84)","example":128.7602},"maxLat":{"type":"number","format":"double","description":"외접 사각형 북단 위도 (WGS84)","example":35.3891},"maxLng":{"type":"number","format":"double","description":"외접 사각형 동단 경도 (WGS84)","example":129.2723},"kind":{"type":"string","description":"신호 종류 — MOVE(뷰포트와 안 겹침, 이동 제안)·ZOOM_OUT(겹치지만 뚜렷이 좁음, 축소 제안)","example":"MOVE"}},"required":["centerLat","centerLng","kind","maxLat","maxLng","minLat","minLng","name"]},"RoutePointDto":{"type":"object","description":"추천 지점","properties":{"order":{"type":"integer","format":"int32","description":"방문 순서 (1부터 연속)","example":1},"name":{"type":"string","description":"지점 이름 (원문 그대로 — AI 로 보낼 때만 100자 절단)","example":"해운대 빛축제"},"kind":{"type":"string","description":"지점 종류 — MISSION_FESTIVAL·MISSION_POPUP·MISSION_COURSE·EVENT·PLACE. FE 마커 분기용","example":"MISSION_FESTIVAL"},"lat":{"type":"number","format":"double","description":"대표 좌표 위도 (WGS84)","example":35.1587},"lng":{"type":"number","format":"double","description":"대표 좌표 경도 (WGS84)","example":129.1604},"gridId":{"type":"string","description":"격자 ID — 대표 좌표를 GridEncoder 로 즉석 계산","example":"16941_11439"},"zoneName":{"type":["string","null"],"description":"표시명 구역 이름 (MSG-341). 구역 밖이면 zoneCell 과 쌍으로 null"},"zoneCell":{"type":["string","null"],"description":"표시명 구역 셀","example":"B-3"},"regionName":{"type":["string","null"],"description":"행정동 폴백 재료 (MSG-349 정책 동일). 무귀속이면 null"},"reason":{"type":"string","description":"추천 이유 한 줄 — AI explain 응답의 reasons 항목 그대로 (FR-ROUTE-05)"},"missionId":{"type":["integer","null"],"format":"int64","description":"미션 후보면 미션 id — FE 가 미션 상세로 잇는 데 쓴다"},"occurrenceId":{"type":["integer","null"],"format":"int64","description":"행사 후보면 회차 id"}},"required":["gridId","kind","lat","lng","missionId","name","occurrenceId","order","reason","regionName","zoneCell","zoneName"]},"RouteRecommendResponseDto":{"type":"object","description":"AI 경로 추천 응답","properties":{"points":{"type":"array","description":"방문 순서대로 정렬된 지점 목록 (최대 8개)","items":{"$ref":"#/components/schemas/RoutePointDto"}},"notice":{"type":["string","null"],"description":"후보 부족 안내 — 지점 3개 이상이면 null, 0~2개면 안내 문구"},"mentionedArea":{"anyOf":[{"$ref":"#/components/schemas/MentionedAreaDto"},{"type":"null"}],"description":"언급 지역 신호 (MSG-468) — 문장이 화면 밖 지역을 말했으면 이동·축소 제안 재료가 실린다. 무신호(지역 무언급·동명 다수·대조 실패·충분히 담김)가 기본값"}},"required":["mentionedArea","notice","points"]},"PushTokenRequestDto":{"type":"object","description":"FCM 푸시 토큰 등록/갱신 요청 — 같은 토큰 재등록은 충돌 없이 현재 계정으로 갱신된다","properties":{"fcmToken":{"type":"string","description":"FCM 디바이스 토큰 (push_tokens PK, 최대 512자)","example":"fcm-token-abc123","maxLength":512,"minLength":0},"platform":{"type":"string","description":"플랫폼 — IOS·ANDROID·WEB (대소문자 무시)","example":"WEB","minLength":1},"appVersion":{"type":"string","description":"앱 버전 (선택, 최대 20자)","example":"1.0.0","maxLength":20,"minLength":0}},"required":["fcmToken","platform"]},"MissionVideoUploadRequestDto":{"type":"object","description":"미션 경유 영상 업로드 확정 요청","properties":{"s3Key":{"type":"string","description":"presigned 발급 때 받은 S3 객체 키. 같은 키로 다시 보내면 멱등하게 처리된다","example":"videos/pending/42/6f1c1f0e-1d2b-4a5a-9f0e-2b3c4d5e6f70.mp4","minLength":1},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초). 1~30초","example":15,"maximum":30,"minimum":1},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각. 미래 시각은 거부되고(단말 시계 오차 5분 허용), 미션 기간 밖도 거부된다","example":"2026-10-06T12:30:00Z"}},"required":["durationSec","recordedAt","s3Key"]},"ApiResponseDtoMissionVideoUploadResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/MissionVideoUploadResponseDto"}},"required":["data","developCode","message"]},"MissionVideoUploadResponseDto":{"type":"object","description":"미션 경유 영상 업로드 확정 응답","properties":{"videoId":{"type":"integer","format":"int64","description":"생성된 영상 ID","example":1001},"gridId":{"type":"string","description":"서버가 정한 그 미션의 대표 격자 ID","example":"19422_9582"},"processingStatus":{"type":"string","description":"영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED)","example":"UPLOADED"},"occupied":{"type":"boolean","description":"이 업로드로 대표 격자를 처음 점령했는지 여부. 재시도 응답은 항상 false","example":true},"newBadges":{"type":"array","description":"이 업로드로 새로 획득한 뱃지 목록 — 없거나 재시도 응답이면 빈 배열","items":{"$ref":"#/components/schemas/EarnedBadgeResponseDto"}},"completedMissions":{"type":"array","description":"이 업로드로 새로 발급된 스탬프 — 이미 받았거나 재시도 응답이면 빈 배열","items":{"$ref":"#/components/schemas/CompletedMissionResponseDto"}}},"required":["completedMissions","gridId","newBadges","occupied","processingStatus","videoId"]},"FriendRequestCreateRequestDto":{"type":"object","description":"친구 요청 생성 요청","properties":{"friendCode":{"type":"string","description":"상대의 고정 친구 코드","example":"AB3DE7GH","minLength":1}},"required":["friendCode"]},"ApiResponseDtoFriendRequestCreateResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/FriendRequestCreateResponseDto"}},"required":["data","developCode","message"]},"FriendRequestCreateResponseDto":{"type":"object","description":"친구 요청 생성 응답","properties":{"status":{"type":"string","description":"PENDING = 요청이 등록돼 상대 수락 대기, ACCEPTED = 상대가 먼저 보낸 요청이 있어 즉시 친구 성립(자동 수락 — FR-8). FE 는 이 값으로 \"요청 보냄\"과 \"친구가 됐어요\" 화면을 구분한다.","enum":["PENDING","ACCEPTED"]}},"required":["status"]},"EventVideoCommentRequestDto":{"type":"object","description":"행사 영상 댓글 작성·수정 요청","properties":{"content":{"type":"string","description":"댓글 본문 (1~500자)","example":"저도 어제 다녀왔어요","maxLength":500,"minLength":0}},"required":["content"]},"ApiResponseDtoEventVideoCommentResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventVideoCommentResponseDto"}},"required":["data","developCode","message"]},"EventVideoCommentResponseDto":{"type":"object","description":"행사 영상 댓글","properties":{"commentId":{"type":"integer","format":"int64","description":"댓글 ID","example":3021},"authorId":{"type":"integer","format":"int64","description":"작성자 사용자 ID","example":7007},"authorNickname":{"type":"string","description":"작성자 닉네임","example":"필맵러"},"content":{"type":"string","description":"댓글 본문","example":"저도 어제 다녀왔어요"},"createdAt":{"type":"string","format":"date-time","description":"작성 시각","example":"2026-10-06T12:30:00Z"}},"required":["authorId","authorNickname","commentId","content","createdAt"]},"EventVideoUploadRequestDto":{"type":"object","description":"행사 영상 업로드 확정 요청","properties":{"s3Key":{"type":"string","description":"presigned 발급 때 받은 S3 객체 키. 같은 키로 다시 보내면 멱등하게 처리된다","example":"videos/pending/42/6f1c1f0e-1d2b-4a5a-9f0e-2b3c4d5e6f70.mp4","minLength":1},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초). 1~30초","example":15,"maximum":30,"minimum":1},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각. 미래 시각은 거부된다(단말 시계 오차 5분 허용)","example":"2026-10-06T12:30:00Z"}},"required":["durationSec","recordedAt","s3Key"]},"ApiResponseDtoEventVideoUploadResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventVideoUploadResponseDto"}},"required":["data","developCode","message"]},"EventVideoUploadResponseDto":{"type":"object","description":"행사 영상 업로드 확정 응답","properties":{"videoId":{"type":"integer","format":"int64","description":"생성된 영상 ID","example":1001},"gridId":{"type":"string","description":"서버가 지정한 대표 격자 ID","example":"19422_9582"},"processingStatus":{"type":"string","description":"영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED)","example":"UPLOADED"},"occupied":{"type":"boolean","description":"이 업로드로 대표 격자를 처음 점령했는지 여부. 재시도 응답은 항상 false","example":true},"newBadges":{"type":"array","description":"이 업로드로 새로 획득한 뱃지 목록 — 없거나 재시도 응답이면 빈 배열","items":{"$ref":"#/components/schemas/EarnedBadgeResponseDto"}}},"required":["gridId","newBadges","occupied","processingStatus","videoId"]},"SignupRequestDto":{"type":"object","description":"이메일 회원가입 요청","properties":{"email":{"type":"string","format":"email","description":"이메일 (최대 255자, 중복 불가)","example":"user@fillmap.dev","maxLength":255,"minLength":0},"password":{"type":"string","description":"비밀번호. 영문과 숫자를 각각 하나 이상 포함한 8~64자","example":"Fillmap1234","maxLength":64,"minLength":8,"pattern":"^(?=.*[A-Za-z])(?=.*\\d).+$"},"nickname":{"type":"string","description":"닉네임 (2~20자)","example":"채우미","maxLength":20,"minLength":2}},"required":["email","nickname","password"]},"ApiResponseDtoSignupResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/SignupResponseDto"}},"required":["data","developCode","message"]},"SignupResponseDto":{"type":"object","description":"회원가입 성공 응답 — 생성된 사용자 정보","properties":{"id":{"type":"integer","format":"int64","description":"생성된 사용자 ID","example":1},"email":{"type":"string","description":"가입 이메일","example":"user@fillmap.dev"},"nickname":{"type":"string","description":"닉네임","example":"채우미"},"createdAt":{"type":"string","format":"date-time","description":"가입 시각","example":"2026-07-17T20:11:03Z"}},"required":["createdAt","email","id","nickname"]},"ReissueRequestDto":{"type":"object","description":"토큰 재발급 요청. 웹은 리프레시 토큰이 쿠키(refreshToken)로 전송되므로 body 를 생략할 수 있다.","properties":{"refreshToken":{"type":"string","description":"앱(X-Client-Type: app) 클라이언트의 리프레시 토큰. 웹은 쿠키를 사용하므로 생략.","example":"eyJhbGciOiJIUzI1NiJ9..."}}},"ApiResponseDtoReissueResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/ReissueResponseDto"}},"required":["data","developCode","message"]},"ReissueResponseDto":{"type":"object","description":"토큰 재발급 성공 응답","properties":{"accessToken":{"type":"string","description":"새로 발급된 JWT 액세스 토큰.","example":"eyJhbGciOiJIUzI1NiJ9..."},"refreshToken":{"type":["string","null"],"description":"회전된 새 리프레시 토큰. 앱(X-Client-Type: app)만 값이 채워지고, 웹은 HttpOnly 쿠키(Set-Cookie)로 재설정되므로 null 이다.","example":"eyJhbGciOiJIUzI1NiJ9..."}},"required":["accessToken","refreshToken"]},"OidcLoginRequestDto":{"type":"object","description":"소셜(OIDC) 로그인 요청","properties":{"idToken":{"type":"string","description":"소셜 제공자(카카오 등)에서 발급받은 OIDC ID Token","example":"eyJraWQiOiI...","minLength":1}},"required":["idToken"]},"ApiResponseDtoLoginResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/LoginResponseDto"}},"required":["data","developCode","message"]},"LoginResponseDto":{"type":"object","description":"로그인 성공 응답","properties":{"accessToken":{"type":"string","description":"발급된 JWT 액세스 토큰. 이후 요청 Authorization 헤더에 'Bearer {토큰}'으로 넣는다.","example":"eyJhbGciOiJIUzI1NiJ9..."},"refreshToken":{"type":["string","null"],"description":"발급된 리프레시 토큰. 앱(X-Client-Type: app)만 값이 채워지고, 웹은 HttpOnly 쿠키(Set-Cookie)로 내려가므로 null 이다.","example":"eyJhbGciOiJIUzI1NiJ9..."},"role":{"type":"string","description":"로그인한 사용자의 역할. 화면이 일반 사용자·행사 운영자·관리자 진입을 가르는 재료다 (MSG-496).","enum":["USER","ORG","ADMIN"],"example":"USER"}},"required":["accessToken","refreshToken","role"]},"KakaoCodeLoginRequestDto":{"type":"object","description":"카카오 인가 코드 로그인 요청 (웹). 카카오 콜백으로 받은 코드를 서버가 ID Token 으로 교환한다.","properties":{"code":{"type":"string","description":"카카오 콜백 쿼리로 받은 1회용 인가 코드","example":"vBv8oXbeLnDF2mkw...","minLength":1},"redirectUri":{"type":"string","description":"인가 요청에 사용한 redirect URI 그대로. 카카오 콘솔 등록값과 정확히 일치해야 한다.","example":"http://localhost:5173/oauth/kakao/callback","minLength":1}},"required":["code","redirectUri"]},"LogoutRequestDto":{"type":"object","description":"로그아웃 요청 (선택 body) — fcmToken 이 있으면 세션 삭제와 함께 해당 FCM 푸시 토큰도 정리된다","properties":{"fcmToken":{"type":"string","description":"정리할 FCM 토큰 (선택)","example":"fcm-token-abc123"}}},"LoginRequestDto":{"type":"object","description":"이메일/비밀번호 로그인 요청","properties":{"email":{"type":"string","format":"email","description":"가입한 이메일","example":"user@fillmap.dev","minLength":1},"password":{"type":"string","description":"비밀번호 (영문+숫자 포함 8~64자)","example":"Fillmap1234","minLength":1}},"required":["email","password"]},"DevSocialLoginRequestDto":{"type":"object","description":"[로컬/dev 전용] 소셜 로그인 모의 요청 — 실제 소셜 ID Token 없이 (provider, oid)로 로그인/가입한다.","properties":{"provider":{"type":"string","description":"소셜 제공자 (기본 KAKAO)","example":"KAKAO"},"oid":{"type":"string","description":"소셜 고유 식별자(oid). 같은 값이면 같은 사용자로 재로그인된다.","example":"dev-kakao-1","minLength":1},"email":{"type":"string","description":"이메일 (선택). 없으면 {oid}@dev.local","example":"kakaouser@dev.local"},"nickname":{"type":"string","description":"닉네임 (선택). 없으면 dev-{oid}","example":"카카오테스터"}},"required":["oid"]},"AdminVideoUnblindResponseDto":{"type":"object","description":"블라인드 해제 결과 — 복구된 영상 상태.","properties":{"videoId":{"type":"integer","format":"int64","description":"해제된 영상 ID","example":1042},"status":{"type":"string","description":"해제 후 영상 상태 — 성공이면 항상 ACTIVE","enum":["ACTIVE","BLINDED","DELETED"],"example":"ACTIVE"}},"required":["status","videoId"]},"ApiResponseDtoAdminVideoUnblindResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminVideoUnblindResponseDto"}},"required":["data","developCode","message"]},"AdminReportProcessResponseDto":{"type":"object","description":"신고 승인·기각 처리 결과 — 종결된 신고 상태와 처리 후 영상 상태.","properties":{"reportId":{"type":"integer","format":"int64","description":"처리된 신고 ID","example":7},"status":{"type":"string","description":"처리 후 신고 상태 — 승인이면 RESOLVED, 기각이면 REJECTED","enum":["PENDING","REVIEWING","RESOLVED","REJECTED"],"example":"RESOLVED"},"videoId":{"type":"integer","format":"int64","description":"신고 대상 영상 ID","example":1042},"videoStatus":{"type":"string","description":"처리 후 영상 상태 — 승인의 전이 생략 케이스(FR-5)를 이 값으로 구분한다","enum":["ACTIVE","BLINDED","DELETED"],"example":"BLINDED"},"reviewedAt":{"type":"string","format":"date-time","description":"처리 시각","example":"2026-08-06T11:00:00Z"}},"required":["reportId","reviewedAt","status","videoId","videoStatus"]},"ApiResponseDtoAdminReportProcessResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminReportProcessResponseDto"}},"required":["data","developCode","message"]},"VideoVisibilityRequestDto":{"type":"object","description":"영상 공개 범위 전환 요청. PUBLIC · PRIVATE · FRIENDS.","properties":{"visibility":{"type":"string","description":"공개 범위. PUBLIC, PRIVATE, FRIENDS 중 하나 (대소문자 무관)","example":"PUBLIC","minLength":1}},"required":["visibility"]},"ApiResponseDtoVideoVisibilityResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/VideoVisibilityResponseDto"}},"required":["data","developCode","message"]},"VideoVisibilityResponseDto":{"type":"object","description":"영상 공개 범위 전환 응답. 전환 후 공개 범위를 담는다.","properties":{"videoId":{"type":"integer","format":"int64","description":"전환된 영상 ID","example":1042},"visibility":{"type":"string","description":"전환 후 공개 범위 (PUBLIC, PRIVATE, FRIENDS 중 하나)","example":"PUBLIC"}},"required":["videoId","visibility"]},"NotificationPreferenceUpdateRequestDto":{"type":"object","description":"카테고리 수신 토글 요청 — 같은 값 재전환은 멱등하게 성공한다","properties":{"enabled":{"type":"boolean","description":"수신 여부 — false 면 off(거부 행 저장), true 면 on(행 삭제)","example":false}},"required":["enabled"]},"ApiResponseDtoNotificationPreferenceResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/NotificationPreferenceResponseDto"}},"required":["data","developCode","message"]},"CategoryPreferenceDto":{"type":"object","description":"카테고리 하나의 수신 상태","properties":{"category":{"type":"string","description":"알림 카테고리","enum":["BADGE","HOTZONE","REMIND","VIDEO","WEEKLY","FRIEND","MISSION_NEARBY","EVENT"],"example":"HOTZONE"},"enabled":{"type":"boolean","description":"수신 여부 — off 행 부재면 true (opt-out 기본 전부 on)","example":true}},"required":["category","enabled"]},"NotificationPreferenceResponseDto":{"type":"object","description":"알림 설정 — 전 카테고리(8종)의 수신 상태 (저장 행 없는 카테고리는 true)","properties":{"preferences":{"type":"array","description":"카테고리별 수신 상태 (BADGE·HOTZONE·REMIND·VIDEO·WEEKLY·FRIEND·MISSION_NEARBY·EVENT 고정 8종 — MODERATION 은 설정 대상이 아니라 없다)","items":{"$ref":"#/components/schemas/CategoryPreferenceDto"}}},"required":["preferences"]},"ApiResponseDtoListZoneResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/ZoneResponseDto"}}},"required":["data","developCode","message"]},"ZoneResponseDto":{"type":"object","description":"구역(zone)의 이름과 격자 사각형 범위 — 검색바 구역 이동·범위 오버레이용. 격자 표시명은 서버가 계산해 격자 응답에 함께 싣는다.","properties":{"zoneKey":{"type":"string","description":"안정 식별자 slug (zones.zone_key) — 클라이언트 참조·타이브레이크 기준","example":"seomyeon"},"name":{"type":"string","description":"구역명 (zones.name)","example":"서면"},"regionCode":{"type":["string","null"],"description":"소속 행정동 코드 (zones.region_code, nullable)","example":"2623051000"},"minGridY":{"type":"integer","format":"int32","description":"사각형 남단 행 (zones.min_grid_y)","example":16850},"maxGridY":{"type":"integer","format":"int32","description":"사각형 북단 행 = A행 (zones.max_grid_y)","example":16866},"minGridX":{"type":"integer","format":"int32","description":"사각형 서단 열 = 1열 (zones.min_grid_x)","example":11414},"maxGridX":{"type":"integer","format":"int32","description":"사각형 동단 열 (zones.max_grid_x)","example":11424},"priority":{"type":"integer","format":"int32","description":"겹침 결정성 우선순위 (zones.priority)","example":0}},"required":["maxGridX","maxGridY","minGridX","minGridY","name","priority","regionCode","zoneKey"]},"ApiResponseDtoVideoPlaybackResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/VideoPlaybackResponseDto"}},"required":["data","developCode","message"]},"VideoPlaybackResponseDto":{"type":"object","description":"단건 영상 재생 조회 응답","properties":{"videoId":{"type":"integer","format":"int64","description":"영상(방문 이벤트) ID","example":1042},"playbackUrl":{"type":["string","null"],"description":"재생본 presigned GET URL. READY 아님·BLINDED(소유자)면 null"},"thumbnailUrl":{"type":["string","null"],"description":"썸네일 presigned GET URL. 썸네일 key 없음(READY 이전)이면 null"},"gridId":{"type":"string","description":"이 영상이 속한 격자 ID","example":"19422_9582"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"processingStatus":{"type":"string","description":"영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED)","example":"READY"},"visibility":{"type":"string","description":"공개 범위 (PUBLIC, PRIVATE, FRIENDS 중 하나)","example":"PUBLIC"},"status":{"type":"string","description":"영상 상태 (ACTIVE/BLINDED). 소유자가 블라인드 사유를 구분하는 축","example":"ACTIVE"},"viewCount":{"type":"integer","format":"int64","description":"조회수 (이번 조회 증가 전 스냅샷)","example":37},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각 (표시용)","example":"2026-07-20T18:03:11Z"},"expiresInSec":{"type":["integer","null"],"format":"int64","description":"playbackUrl presign TTL(초). playbackUrl=null 이면 null"},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름 (예 \"서면\"). 구역 밖 격자면 null — 이때 라벨은 regionName 이다","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A 는 구역 북단, 열 1 은 서단). zoneName 과 항상 쌍이라 구역 밖이면 함께 null","example":"I-6"},"regionName":{"type":["string","null"],"description":"격자 중심점 행정동 이름 — 구역 밖 격자의 폴백 라벨. 무귀속(해상 등)이거나 미판정이면 null","example":"서울특별시 강남구 역삼1동"},"highlights":{"type":["array","null"],"description":"AI 추천 하이라이트 구간 [[시작초, 끝초], ...]. 최대 3구간, 초는 소수점 둘째 자리. 배열 순서가 추천 우선순위(첫 요소가 최우선 추천). 없으면 null (READY 이전·FAILED·0구간 포함, 빈 배열은 내려가지 않는다) 예시: [[0.0, 4.25], [12.0, 18.5], [20.0, 27.5]]","items":{"type":"array","items":{"type":"number","format":"double"}}},"nickname":{"type":"string","description":"작성자 닉네임 원문. @ 등 화면 표기는 FE 가 붙인다","example":"busan.vlog"}},"required":["durationSec","expiresInSec","gridId","highlights","nickname","playbackUrl","processingStatus","recordedAt","regionName","status","thumbnailUrl","videoId","viewCount","visibility","zoneCell","zoneName"]},"ApiResponseDtoListTrendingKeywordResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/TrendingKeywordResponseDto"}}},"required":["data","developCode","message"]},"TrendingKeywordResponseDto":{"type":"object","description":"인기 검색어 1건. 클릭 시 keyword 로 기존 장소 검색 API 를 다시 호출한다.","properties":{"rank":{"type":"integer","format":"int32","description":"순위 (1부터)","example":1},"keyword":{"type":"string","description":"정규화된 검색어","example":"홍대 카페"}},"required":["keyword","rank"]},"ApiResponseDtoListPlaceSearchResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/PlaceSearchResponseDto"}}},"required":["data","developCode","message"]},"PlaceSearchResponseDto":{"type":"object","description":"장소 검색 결과 1건. 선택 시 lat/lng 로 지도 이동 + gridId 로 격자 하이라이트를 한 번에 처리한다.","properties":{"name":{"type":"string","description":"장소명 (카카오 place_name)","example":"부산대학교"},"address":{"type":"string","description":"표시용 주소 — 도로명 우선, 없으면 지번 (§D2)","example":"부산 금정구 부산대학로63번길 2"},"lat":{"type":"number","format":"double","description":"위도 (WGS84, 카카오 y 직결 — 변환 없음)","example":35.23272},"lng":{"type":"number","format":"double","description":"경도 (WGS84, 카카오 x)","example":129.08246},"gridId":{"type":"string","description":"그 좌표의 격자 ID — FE 격자 하이라이트 키 (즉석 계산, 저장 아님)","example":"16941_11439"},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름. 구역 밖이면 null — 표시 라벨은 address 가 맡으므로 행정동 폴백 재료를 싣지 않는다(§D2 유지).","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A는 구역 북단, 열 1은 서단). zoneName 과 항상 쌍이라 구역 밖이면 함께 null 이다.","example":"I-6"}},"required":["address","gridId","lat","lng","name","zoneCell","zoneName"]},"ApiResponseDtoRegionExploreResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/RegionExploreResponseDto"}},"required":["data","developCode","message"]},"ExploreGridResponseDto":{"type":"object","description":"전역 탐색 격자 카드","properties":{"gridId":{"type":"string","description":"격자 ID — 카드 탭 시 격자 전역 영상 목록(MSG-237) 진입 키","example":"16676_11596"},"gridY":{"type":"integer","format":"int64","description":"격자 세로 인덱스 (EPSG:5179 평면 y / 100 — 위도가 아니다). FE 지도 이동·라벨 조합","example":16676},"gridX":{"type":"integer","format":"int64","description":"격자 가로 인덱스 (EPSG:5179 평면 x / 100 — 경도가 아니다)","example":11596},"videoCount":{"type":"integer","format":"int32","description":"그 격자의 게이트 통과 영상 수 — \"N개 영상\"","example":138},"coverThumbnailUrl":{"type":["string","null"],"description":"커버 썸네일 presigned GET URL. READY 게이트라 non-null 기대(null 이면 null 통과)"},"coverDurationSec":{"type":"integer","format":"int32","description":"커버 영상 길이(초) — duration 뱃지","example":12},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름 (예 \"서면\"). 구역 밖 격자면 null — 이때 FE 는 래퍼의 regionName 을 라벨로 쓴다","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A 는 구역 북단, 열 1 은 서단). zoneName 과 항상 쌍이라 구역 밖이면 함께 null","example":"I-6"}},"required":["coverDurationSec","coverThumbnailUrl","gridId","gridX","gridY","videoCount","zoneCell","zoneName"]},"RegionExploreResponseDto":{"type":"object","description":"행정동 격자 카드 리스트 + 헤더 카운트","properties":{"regionCode":{"type":"string","description":"행정동 코드 (요청 에코)","example":"2644056000"},"regionName":{"type":["string","null"],"description":"행정동 이름 — 미존재 코드면 null","example":"부산광역시 부산진구 부전2동"},"gridCount":{"type":"integer","format":"int32","description":"게이트 통과 영상 ≥1 격자 수 — \"이 지역 격자 N개\"","example":5},"videoCount":{"type":"integer","format":"int64","description":"게이트 통과 영상 총수 — \"영상 M개\"","example":355},"grids":{"type":"array","description":"격자 카드 (정렬·limit 적용 후). 없으면 빈 배열","items":{"$ref":"#/components/schemas/ExploreGridResponseDto"}}},"required":["gridCount","grids","regionCode","regionName","videoCount"]},"ApiResponseDtoListRegionStatResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/RegionStatResponseDto"}}},"required":["data","developCode","message"]},"RegionStatResponseDto":{"type":"object","description":"한 행정동의 수집률. 사용자가 그 행정동에서 점령(수집)한 격자 수와 진행률.","properties":{"regionCode":{"type":"string","description":"행정동 코드 (region_stats.region_code)","example":"1168051500"},"regionName":{"type":"string","description":"행정동 이름 (regions.region_name)","example":"서울특별시 강남구 역삼1동"},"parentCode":{"type":["string","null"],"description":"상위 시군구 코드 (regions.parent_code) — NULL 허용 컬럼이라 최상위 행은 null","example":"11680"},"collectedCount":{"type":"integer","format":"int32","description":"점령(수집)한 격자 수","example":5},"totalCount":{"type":"integer","format":"int32","description":"그 행정동 전체 격자 수(분모)","example":20},"progressRate":{"type":"number","description":"수집률(%) — 100 상한 clamp","example":25.0},"updatedAt":{"type":"string","format":"date-time","description":"수집률 캐시 기준 시각","example":"2026-07-20T10:00:00Z"}},"required":["collectedCount","parentCode","progressRate","regionCode","regionName","totalCount","updatedAt"]},"ApiResponseDtoRegionNationalStatResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/RegionNationalStatResponseDto"}},"required":["data","developCode","message"]},"RegionNationalStatResponseDto":{"type":"object","description":"내 전국 탐험률 재료. 점령한 격자 수(분자)와 전국 격자 총수(분모)의 원값.","properties":{"collectedCount":{"type":"integer","format":"int64","description":"내가 점령(수집)한 격자 수의 전국 합. 수집이 없으면 0","example":1223},"totalCount":{"type":"integer","format":"int64","description":"전국 격자 총수(분모). 0 이면 기준 데이터 미적재 상태라 화면은 비율을 그리지 않는다","example":10193482}},"required":["collectedCount","totalCount"]},"ApiResponseDtoRegionStatResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"anyOf":[{"$ref":"#/components/schemas/RegionStatResponseDto"},{"type":"null"}]}},"required":["data","developCode","message"]},"ApiResponseDtoRegionResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"anyOf":[{"$ref":"#/components/schemas/RegionResponseDto"},{"type":"null"}]}},"required":["data","developCode","message"]},"RegionResponseDto":{"type":"object","description":"좌표를 포함하는 행정동. 포함 행정동이 없으면(바다·국외) data 가 null 이다.","properties":{"regionCode":{"type":"string","description":"행정동 코드 (regions.region_code = adm_cd2)","example":"1168051500"},"regionName":{"type":"string","description":"행정동 이름 (regions.region_name = adm_nm)","example":"서울특별시 강남구 역삼1동"},"parentCode":{"type":["string","null"],"description":"상위 시군구 코드 (regions.parent_code) — NULL 허용 컬럼이라 최상위 행은 null","example":"11680"}},"required":["parentCode","regionCode","regionName"]},"ApiResponseDtoRegionExplorePageResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/RegionExplorePageResponseDto"}},"required":["data","developCode","message"]},"RegionExplorePageResponseDto":{"type":"object","description":"전체 지역 개인화 커서 페이지","properties":{"items":{"type":"array","description":"현재 페이지 행정동 목록. 최대 20개","items":{"$ref":"#/components/schemas/RegionGridCountResponseDto"}},"hasNext":{"type":"boolean","description":"다음 페이지 존재 여부"},"nextCursor":{"type":["string","null"],"description":"다음 요청에 그대로 전달할 불투명 커서"}},"required":["hasNext","items","nextCursor"]},"RegionGridCountResponseDto":{"type":"object","description":"전체 지역 리스트 항목 (행정동별 격자 수)","properties":{"regionCode":{"type":"string","description":"행정동 코드 — 선택 시 격자 카드 조회에 전달","example":"2644056000"},"regionName":{"type":"string","description":"행정동 이름","example":"부산광역시 부산진구 부전2동"},"gridCount":{"type":"integer","format":"int32","description":"그 행정동의 게이트 통과 격자 수","example":5}},"required":["gridCount","regionCode","regionName"]},"ApiResponseDtoListRegionDistrictResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/RegionDistrictResponseDto"}}},"required":["data","developCode","message"]},"RegionDistrictResponseDto":{"type":"object","description":"시군구 한 건. 이름·식별자·전체 격자 수.","properties":{"parentCode":{"type":"string","description":"시군구 식별자(행정동 코드 앞 5자리). /api/regions/stats 의 parentCode 로 그대로 쓴다","example":"11680"},"name":{"type":"string","description":"시군구 이름","example":"강남구"},"gridCount":{"type":"integer","format":"int64","description":"그 시군구의 전체 격자 수(사용자 무관). 0 인 시군구는 목록에 없다","example":4102}},"required":["gridCount","name","parentCode"]},"ApiResponseDtoNotificationPageResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/NotificationPageResponseDto"}},"required":["data","developCode","message"]},"NotificationItemResponseDto":{"type":"object","description":"알림 한 건","properties":{"notificationId":{"type":"integer","format":"int64","description":"알림 ID — 읽음 처리와 커서에 쓴다","example":123},"category":{"type":"string","description":"알림 카테고리","enum":["BADGE","HOTZONE","REMIND","VIDEO","WEEKLY","FRIEND","MODERATION","EVENT"],"example":"BADGE"},"title":{"type":"string","description":"알림 제목","example":"새 뱃지 획득"},"body":{"type":"string","description":"알림 본문","example":"'첫 걸음' 뱃지를 획득했어요"},"createdAt":{"type":"string","format":"date-time","description":"생성 시각 (UTC)","example":"2026-08-19T02:11:00Z"},"read":{"type":"boolean","description":"읽음 여부","example":false}},"required":["body","category","createdAt","notificationId","read","title"]},"NotificationPageResponseDto":{"type":"object","description":"알림함 목록 한 페이지 — 최신순(id 내림차순)","properties":{"notifications":{"type":"array","description":"알림 항목 — 없으면 빈 배열","items":{"$ref":"#/components/schemas/NotificationItemResponseDto"}},"nextCursor":{"type":["integer","null"],"format":"int64","description":"다음 페이지 커서 — 다음 요청의 cursor 로 그대로 되돌려 준다. hasNext 가 false 면 null","example":123},"hasNext":{"type":"boolean","description":"다음 페이지 존재 여부","example":true}},"required":["hasNext","nextCursor","notifications"]},"ApiResponseDtoNotificationUnreadCountResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/NotificationUnreadCountResponseDto"}},"required":["data","developCode","message"]},"NotificationUnreadCountResponseDto":{"type":"object","description":"안읽은 알림 개수 — 목록과 같은 노출 조건(최근 30일·수신 거부 스킵 제외)","properties":{"count":{"type":"integer","format":"int64","description":"안읽은 알림 개수 — 없으면 0","example":3}},"required":["count"]},"ApiResponseDtoMissionDetailResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/MissionDetailResponseDto"}},"required":["data","developCode","message"]},"BoxShape":{"allOf":[{"$ref":"#/components/schemas/MissionShape"},{"type":"object","properties":{"polygon":{"type":"array","items":{"$ref":"#/components/schemas/LatLng"}}}}],"description":"축제·팝업(EVENT·POPUP) — 격자 집합을 감싸는 경계 사각형","required":["polygon"]},"Cell":{"type":"object","description":"격자 중심점","properties":{"gridId":{"type":"string"},"lat":{"type":"number","format":"double"},"lng":{"type":"number","format":"double"}},"required":["gridId","lat","lng"]},"CellsShape":{"allOf":[{"$ref":"#/components/schemas/MissionShape"},{"type":"object","properties":{"cells":{"type":"array","items":{"$ref":"#/components/schemas/Cell"}}}}],"description":"테마·지속(THEME·CONTINUOUS) — 각 격자 중심점","required":["cells"]},"LatLng":{"type":"object","description":"좌표 한 점","properties":{"lat":{"type":"number","format":"double"},"lng":{"type":"number","format":"double"}},"required":["lat","lng"]},"MissionDetailResponseDto":{"type":"object","description":"미션 상세 — 미션 정보 + 내 진행도 + 전체 영상 개수 + 코스 스팟별 통계","properties":{"mission":{"$ref":"#/components/schemas/MissionResponseDto","description":"미션 정보 — 목록(GET /api/missions/active)과 같은 필드·shape"},"progress":{"anyOf":[{"$ref":"#/components/schemas/MissionProgressResponseDto"},{"type":"null"}],"description":"내 진행도 — 목록 진행도(GET /api/missions/progress)와 같은 계산 (MSG-398 D8). 비로그인 조회면 키는 그대로 있고 값이 null 이다 (MSG-454)"},"videoCount":{"type":"integer","format":"int64","description":"미션 기간 안에 촬영된 전역 공개(ACTIVE·PUBLIC·READY) 영상 수 — 미션 영상 목록(MSG-390)의 실제 후보 수와 같다","example":19},"spotStats":{"type":"array","description":"코스 포토스팟별 방문 여부·영상 개수 — shape.spots 와 같은 순서(seq ASC NULLS LAST, gridId ASC). 코스가 아니면 빈 배열","items":{"$ref":"#/components/schemas/SpotStats"}}},"required":["mission","progress","spotStats","videoCount"]},"MissionProgressResponseDto":{"type":"object","description":"미션 하나에 대한 내 진행도","properties":{"missionId":{"type":"integer","format":"int64","description":"미션 id (missions.id)","example":412},"targetCount":{"type":"integer","format":"int32","description":"완료에 필요한 격자 수 (missions.target_count)","example":1},"filledCount":{"type":"integer","format":"int32","description":"그 미션 격자 중 기간 안에 촬영한 내 영상이 있는 칸 수. targetCount 를 넘지 않는다","example":1},"completed":{"type":"boolean","description":"내 스탬프 보유 여부 (user_missions)","example":true}},"required":["completed","filledCount","missionId","targetCount"]},"MissionResponseDto":{"type":"object","description":"미션 하나 — 공통 필드 + 유형별 렌더 shape","properties":{"missionId":{"type":"integer","format":"int64","description":"미션 id (missions.id)","example":12},"type":{"type":"string","description":"미션 유형 — FE 렌더러 판별자","enum":["COURSE","AREA","EVENT","THEME","CONTINUOUS","POPUP"],"example":"COURSE"},"title":{"type":"string","description":"미션 제목","example":"남파랑길 3코스"},"targetCount":{"type":"integer","format":"int32","description":"완료에 필요한 distinct 방문 격자 수(표시·판정 힌트, 판정은 MSG-223)","example":3},"startAt":{"type":["string","null"],"format":"date-time","description":"시작 시각. NULL = 무기간(상시)","example":"2026-11-01T00:00:00Z"},"endAt":{"type":["string","null"],"format":"date-time","description":"종료 시각. NULL = 무기간(상시)","example":"2026-11-01T23:59:59Z"},"shape":{"description":"유형별 렌더 shape 하나(type 에 대응하는 PATH/BOX/CELLS/REGION)","oneOf":[{"$ref":"#/components/schemas/BoxShape"},{"$ref":"#/components/schemas/CellsShape"},{"$ref":"#/components/schemas/PathShape"},{"$ref":"#/components/schemas/RegionShape"}]},"description":{"type":["string","null"],"description":"소개문 원문. 출처 표기 없이 그대로 노출한다","example":"부산 앞바다를 따라 걷는 해안 산책로"},"placeName":{"type":["string","null"],"description":"사람이 읽는 위치 한 줄 — 축제는 행사장, 팝업은 주소, 코스는 시군","example":"부산 영도구"},"sourceUrl":{"type":["string","null"],"description":"원문 링크 — 축제 홈페이지·팝업 상세 페이지. 코스는 없다","example":"https://festival.example.kr"},"operationTime":{"type":["string","null"],"description":"운영시간 안내 문구. 여러 줄이면 개행으로 이어 붙인다(팝업 전용)","example":"매일 11:00 ~ 20:00"},"imageUrl":{"type":["string","null"],"description":"대표 이미지 주소 — 우리 스토리지 URL 만 들어간다(MSG-383 §D7)","example":"https://cdn.fillmap.kr/mission/12.webp"},"distanceMeters":{"type":["integer","null"],"format":"int32","description":"코스 총 거리(미터). 코스가 아니면 없다","example":14000},"durationMinutes":{"type":["integer","null"],"format":"int32","description":"코스 소요시간(분). 코스가 아니면 없다","example":330},"difficulty":{"type":["integer","null"],"format":"int32","description":"코스 난이도 — 두루누비 등급 1(쉬움)·2(보통)·3(어려움). 코스가 아니면 없다","example":2}},"required":["description","difficulty","distanceMeters","durationMinutes","endAt","imageUrl","missionId","operationTime","placeName","shape","sourceUrl","startAt","targetCount","title","type"]},"MissionShape":{"description":"미션 유형별 렌더 shape (상위 type 으로 판별). PATH·BOX·CELLS·REGION 중 하나."},"PathShape":{"allOf":[{"$ref":"#/components/schemas/MissionShape"},{"type":"object","properties":{"line":{"type":["object","null"],"description":"코스 라인 GeoJSON LineString 원문 — missions.path 는 NULL 허용 컬럼이라 없을 수 있다"},"spots":{"type":"array","items":{"$ref":"#/components/schemas/Spot"}}}}],"description":"코스(COURSE) — GeoJSON LineString + seq순 포토스팟 마커","required":["line","spots"]},"RegionShape":{"allOf":[{"$ref":"#/components/schemas/MissionShape"},{"type":"object","properties":{"regionCode":{"type":["string","null"],"description":"행정동 코드 — missions.region_code 는 NULL 허용 컬럼이라 없을 수 있다"}}}],"description":"구역(AREA) — region_code 만(경계는 region API 로 별도 조회)","required":["regionCode"]},"Spot":{"type":"object","description":"코스 포토스팟 마커","properties":{"gridId":{"type":"string"},"lat":{"type":"number","format":"double"},"lng":{"type":"number","format":"double"},"seq":{"type":["integer","null"],"format":"int32","description":"코스 내 순번 — mission_grids.seq 는 NULL 허용 컬럼이라 없을 수 있다"},"name":{"type":["string","null"],"description":"표시 이름 (MSG-492) — 명소 이름·구역 표시명(\"서면 A-14\")·행정동 이름 중 하나로 이미 조립된 문자열이다. 시더가 적재 시점에 정해 저장한 값을 그대로 통과시킨다. 코스가 아닌 유형의 스팟과 시더 갱신 전 스팟만 null — 화면은 기존 안내 문구를 폴백으로 남긴다"}},"required":["gridId","lat","lng","name","seq"]},"SpotStats":{"type":"object","description":"코스 포토스팟 하나의 방문 여부·영상 개수","properties":{"gridId":{"type":"string","description":"포토스팟 격자 id — shape.spots 의 gridId 에 대응","example":"38677_114635"},"visited":{"type":"boolean","description":"미션 기간 안에 촬영한 내 영상이 있는지 — 진행도와 같은 술어. 비로그인 조회면 항상 false (MSG-454)","example":true},"videoCount":{"type":"integer","format":"int64","description":"이 스팟에 올라온 전역 공개 영상 수. 영상이 없으면 0","example":9}},"required":["gridId","videoCount","visited"]},"ApiResponseDtoGridVideoPageResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/GridVideoPageResponseDto"}},"required":["data","developCode","message"]},"GridGlobalVideoResponseDto":{"type":"object","description":"전역 공개 영상 목록 항목","properties":{"videoId":{"type":"integer","format":"int64","description":"영상 ID. 항목 탭 → 단건 재생(GET /api/videos/{videoId}) 진입 키","example":1042},"thumbnailUrl":{"type":"string","description":"썸네일 presigned GET URL. 목록은 READY 만 담겨 null 아님이 기대값이다"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"viewCount":{"type":"integer","format":"int64","description":"조회수","example":37},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각","example":"2026-07-20T18:03:11Z"},"nickname":{"type":"string","description":"작성자 닉네임 원문. @ 등 화면 표기는 FE 가 붙인다","example":"busan.vlog"}},"required":["durationSec","nickname","recordedAt","thumbnailUrl","videoId","viewCount"]},"GridVideoPageResponseDto":{"type":"object","description":"전역 공개 영상 목록 페이지 응답 (keyset 커서 페이지네이션)","properties":{"videos":{"type":"array","description":"이 페이지의 전역 공개·READY 영상. 없으면 빈 배열","items":{"$ref":"#/components/schemas/GridGlobalVideoResponseDto"}},"hasNext":{"type":"boolean","description":"다음 페이지 존재 여부 (lookahead 판정)"},"nextCursor":{"type":["string","null"],"description":"다음 페이지 조회용 opaque 커서. 다음 요청 cursor 파라미터에 넣는다. 마지막 페이지면 null."}},"required":["hasNext","nextCursor","videos"]},"ApiResponseDtoListMissionProgressResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/MissionProgressResponseDto"}}},"required":["data","developCode","message"]},"ApiResponseDtoListMissionRegionAggregateResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/MissionRegionAggregateResponseDto"}}},"required":["data","developCode","message"]},"MissionRegionAggregateResponseDto":{"type":"object","description":"행정 단위로 묶어 센 미션 집계 한 항목","properties":{"regionCode":{"type":["string","null"],"description":"묶음 키 — 행정동 코드(10자리)를 단위 길이로 자른 접두(동 10, 구 5, 시 2자리). 행정동이 판정되지 않은 묶음만 null","example":"26230"},"name":{"type":["string","null"],"description":"단위 표시 이름 (동 \"부전2동\", 구 \"부산진구\", 시 \"부산광역시\"). 무귀속만 null","example":"부산진구"},"lat":{"type":"number","format":"double","description":"마커 대표 좌표 위도 — 묶음에 속한 미션 귀속점의 평균이라 마커가 실제 데이터 위에 선다","example":35.1568},"lng":{"type":"number","format":"double","description":"마커 대표 좌표 경도","example":129.0592},"count":{"type":"integer","format":"int32","description":"그 단위 안의 미션 수","example":12},"missionIds":{"type":"array","description":"그 묶음에 속한 미션 id 오름차순 — 줌인 후 개별 조회 결과와 교집합으로 목록을 좁힌다(D5). 크기는 count 와 같다","items":{"type":"integer","format":"int64"}}},"required":["count","lat","lng","missionIds","name","regionCode"]},"ApiResponseDtoListMissionResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/MissionResponseDto"}}},"required":["data","developCode","message"]},"ApiResponseDtoHotZoneListResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/HotZoneListResponseDto"}},"required":["data","developCode","message"]},"HotZoneListResponseDto":{"type":"object","description":"뷰포트 내 핫구역 목록 응답 (핫스코어 내림차순)","properties":{"hotZones":{"type":"array","description":"핫구역 목록 — 핫스코어 내림차순. 없으면 빈 배열","items":{"$ref":"#/components/schemas/HotZoneResponseDto"}}},"required":["hotZones"]},"HotZoneResponseDto":{"type":"object","description":"핫구역 한 칸 — 최근 48시간 방문(업로드) 신호가 상위인 격자","properties":{"gridId":{"type":"string","description":"격자 ID (\"{grid_y}_{grid_x}\" 포맷)","example":"19422_9582"},"gridY":{"type":"integer","format":"int32","description":"격자 세로 인덱스 (EPSG:5179 평면 y / 100 — 위도가 아니다)","example":19422},"gridX":{"type":"integer","format":"int32","description":"격자 가로 인덱스 (EPSG:5179 평면 x / 100 — 경도가 아니다)","example":9582},"score":{"type":"integer","format":"int64","description":"핫스코어 — 최근 48시간(8버킷) 방문 신호 합산","example":12},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름. 구역 밖 격자면 null — 이때 마커 라벨은 같은 항목의 regionName(행정동)이다(추가 호출 없음).","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A는 구역 북단, 열 1은 서단) — 마커 배지용. zoneName 과 항상 쌍이라 구역 밖 격자면 함께 null 이다.","example":"I-6"},"regionName":{"type":["string","null"],"description":"격자 중심점이 속한 행정동 전체 이름. 어느 행정동에도 속하지 않으면(해상 등) null. zoneName 이 null 이면 이 값이 표시 이름 폴백이다(폴백에는 칸 번호를 붙이지 않는다).","example":"부산광역시 부산진구 부전1동"}},"required":["gridId","gridX","gridY","regionName","score","zoneCell","zoneName"]},"ApiResponseDtoListHotZoneRegionAggregateResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/HotZoneRegionAggregateResponseDto"}}},"required":["data","developCode","message"]},"HotZoneRegionAggregateResponseDto":{"type":"object","description":"행정 단위로 묶어 센 핫구역 집계 한 항목","properties":{"regionCode":{"type":["string","null"],"description":"묶음 키 — 행정동 코드(10자리)를 단위 길이로 자른 접두(동 10, 구 5, 시 2자리). 행정동이 판정되지 않은 묶음만 null","example":"26230"},"name":{"type":["string","null"],"description":"단위 표시 이름 (동 \"부전2동\", 구 \"부산진구\", 시 \"부산광역시\"). 무귀속만 null","example":"부산진구"},"lat":{"type":"number","format":"double","description":"마커 대표 좌표 위도 — 묶음에 속한 핫 격자 셀 중심의 평균이라 마커가 실제 데이터 위에 선다","example":35.1568},"lng":{"type":"number","format":"double","description":"마커 대표 좌표 경도","example":129.0592},"count":{"type":"integer","format":"int32","description":"그 단위 안의 핫 격자 수 — 핫스코어 합산이 아니다","example":12},"gridIds":{"type":"array","description":"그 묶음에 속한 핫 격자 id 오름차순 — 줌인 후 개별 조회 결과와 교집합으로 목록을 좁힌다(D4). 크기는 count 와 같다","items":{"type":"string"}}},"required":["count","gridIds","lat","lng","name","regionCode"]},"ApiResponseDtoOccupiedGridPageResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/OccupiedGridPageResponseDto"}},"required":["data","developCode","message"]},"OccupiedGridPageResponseDto":{"type":"object","description":"뷰포트 색칠 격자 페이지 응답 (커서 페이지네이션)","properties":{"grids":{"type":"array","description":"이 페이지의 색칠 격자 목록 ((grid_y, grid_x) 오름차순)","items":{"$ref":"#/components/schemas/OccupiedGridResponseDto"}},"nextCursor":{"type":["string","null"],"description":"다음 페이지 조회용 커서. 다음 요청 cursor 파라미터에 넣는다. 마지막 페이지면 null.","example":"MTk0MjJfOTU4Mg=="}},"required":["grids","nextCursor"]},"OccupiedGridResponseDto":{"type":"object","description":"뷰포트 색칠 격자 한 칸 — 지도 렌더링용 위치 정보","properties":{"gridId":{"type":"string","description":"격자 ID (\"{grid_y}_{grid_x}\" 포맷)","example":"19422_9582"},"gridY":{"type":"integer","format":"int32","description":"격자 세로 인덱스 (EPSG:5179 평면 y / 100 — 위도가 아니다)","example":19422},"gridX":{"type":"integer","format":"int32","description":"격자 가로 인덱스 (EPSG:5179 평면 x / 100 — 경도가 아니다)","example":9582},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름. 구역 밖 격자면 null — 이때 표시 이름은 같은 항목의 regionName(행정동)이다(추가 호출 없음).","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A는 구역 북단, 열 1은 서단) — 셀 배지용. zoneName 과 항상 쌍이라 구역 밖 격자면 함께 null 이다.","example":"I-6"},"regionName":{"type":["string","null"],"description":"격자 중심점이 속한 행정동 전체 이름. 어느 행정동에도 속하지 않으면(해상 등) null. zoneName 이 null 이면 이 값이 표시 이름 폴백이다(폴백에는 칸 번호를 붙이지 않는다).","example":"부산광역시 부산진구 부전1동"}},"required":["gridId","gridX","gridY","regionName","zoneCell","zoneName"]},"ApiResponseDtoGridCellResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/GridCellResponseDto"}},"required":["data","developCode","message"]},"GridCellResponseDto":{"type":"object","description":"단일 격자의 내 색칠(점령) 상태. 미점령이어도 404가 아니라 occupied=false로 응답한다.","properties":{"gridId":{"type":"string","description":"격자 ID (\"{grid_y}_{grid_x}\" 포맷)","example":"19422_9582"},"occupied":{"type":"boolean","description":"내가 이 격자를 점령(색칠)했는지 여부","example":true},"videoCount":{"type":"integer","format":"int32","description":"이 격자에 올린 내 영상 수 (미점령이면 0)","example":3},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름. 구역 밖 격자면 null — 이때 표시 이름은 같은 응답의 regionName(행정동)이다(추가 호출 없음).","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A는 구역 북단, 열 1은 서단). zoneName 과 항상 쌍이라 구역 밖 격자면 함께 null 이다.","example":"I-6"},"regionName":{"type":["string","null"],"description":"격자 중심점이 속한 행정동 전체 이름. 아직 아무도 영상을 올리지 않은 격자에도 실린다. 어느 행정동에도 속하지 않으면(해상 등) null. zoneName 이 null 이면 이 값이 표시 이름 폴백이다(폴백에는 칸 번호를 붙이지 않는다).","example":"부산광역시 영도구 영선1동"}},"required":["gridId","occupied","regionName","videoCount","zoneCell","zoneName"]},"ApiResponseDtoListGridVideoResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/GridVideoResponseDto"}}},"required":["data","developCode","message"]},"GridVideoResponseDto":{"type":"object","description":"격자별 내 영상 리스트 항목","properties":{"videoId":{"type":"integer","format":"int64","description":"영상(방문 이벤트) ID. 개별 재생·교체·삭제 진입 키","example":1042},"thumbnailUrl":{"type":["string","null"],"description":"썸네일 presigned GET URL. READY 아니면(썸네일 key 없음) null"},"processingStatus":{"type":"string","description":"영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED)","example":"READY"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"createdAt":{"type":"string","format":"date-time","description":"업로드(방문) 시각 — 정렬 키","example":"2026-07-20T18:03:11Z"}},"required":["createdAt","durationSec","processingStatus","thumbnailUrl","videoId"]},"ApiResponseDtoListGridMissionResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/GridMissionResponseDto"}}},"required":["data","developCode","message"]},"GridMissionResponseDto":{"type":"object","description":"격자가 대표 격자인 미션","properties":{"missionId":{"type":"integer","format":"int64","description":"미션 ID — 상세(GET /api/missions/{missionId})로 넘어가는 키","example":412},"type":{"type":"string","description":"미션 종류 — EVENT(지역축제) 또는 POPUP(팝업스토어)","example":"EVENT"},"title":{"type":"string","description":"미션 이름","example":"부산 불꽃축제"},"startAt":{"type":["string","null"],"format":"date-time","description":"시작 시각","example":"2026-10-01T00:00:00Z"},"endAt":{"type":["string","null"],"format":"date-time","description":"종료 시각","example":"2026-10-07T14:59:59Z"},"videoCount":{"type":"integer","format":"int64","description":"미션 기간 안에 촬영된 전역 공개 영상 수 — 미션 상세의 videoCount 와 같은 술어다","example":37}},"required":["endAt","missionId","startAt","title","type","videoCount"]},"ApiResponseDtoGridHourlyUploadResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/GridHourlyUploadResponseDto"}},"required":["data","developCode","message"]},"GridHourlyUploadResponseDto":{"type":"object","description":"격자 전역 시간대 분포 응답 (KST 24구간)","properties":{"gridId":{"type":"string","description":"격자 ID","example":"19422_9582"},"hours":{"type":"array","description":"KST 0시부터 23시까지 24개 구간. 업로드가 없는 구간은 count 0","items":{"$ref":"#/components/schemas/HourlyUploadCountResponseDto"}}},"required":["gridId","hours"]},"HourlyUploadCountResponseDto":{"type":"object","description":"시간대 구간 하나의 업로드 수","properties":{"hour":{"type":"integer","format":"int32","description":"KST 기준 시 (0~23)","example":18},"count":{"type":"integer","format":"int64","description":"그 시간대의 전역 공개 영상 수. 업로드가 없으면 0","example":3}},"required":["count","hour"]},"ApiResponseDtoListGridEventLocationResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/GridEventLocationResponseDto"}}},"required":["data","developCode","message"]},"GridEventLocationResponseDto":{"type":"object","description":"격자 역조회 결과 하나 — 회차와 해석된 행사 위치","properties":{"occurrenceId":{"type":"integer","format":"int64","description":"소속 행사 회차 id","example":12},"occurrenceTitle":{"type":"string","description":"행사명","example":"부산불꽃축제"},"occurrenceStatus":{"type":"string","description":"서버 시각 기준 파생 상태 — 상세와 같은 계산","enum":["UPCOMING","LIVE","UPLOAD_GRACE","ARCHIVED"],"example":"LIVE"},"locationId":{"type":"integer","format":"int64","description":"해석된 행사 위치 id — 피드 진입 키","example":31},"locationName":{"type":"string","description":"위치 이름","example":"부산역 팝업"},"representativeGridId":{"type":"string","description":"대표 격자 — 피드(MSG-440)가 영상을 붙일 격자","example":"19443_9582"},"zoneName":{"type":["string","null"],"description":"대표 격자가 속한 구역 이름. 구역 밖이면 null","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 안 위치 코드. 구역 밖이면 null","example":"A-14"},"regionName":{"type":["string","null"],"description":"대표 격자의 행정동 이름 — 구역 밖 표시명 폴백. 무귀속이면 null","example":"부전동"}},"required":["locationId","locationName","occurrenceId","occurrenceStatus","occurrenceTitle","regionName","representativeGridId","zoneCell","zoneName"]},"ApiResponseDtoGridCoverVideoResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"anyOf":[{"$ref":"#/components/schemas/GridCoverVideoResponseDto"},{"type":"null"}]}},"required":["data","developCode","message"]},"GridCoverVideoResponseDto":{"type":"object","description":"격자 전역 대표 영상","properties":{"videoId":{"type":"integer","format":"int64","description":"대표 영상 ID. 개별 재생 진입 키","example":1042},"thumbnailUrl":{"type":"string","description":"썸네일 presigned GET URL. 대표는 항상 READY 라 null 이 아니다"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"viewCount":{"type":"integer","format":"int64","description":"조회수 — 대표 선정 정렬 키","example":37},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각 (표시용). 정렬 tie-break 키는 createdAt 이다","example":"2026-07-20T18:03:11Z"},"nickname":{"type":"string","description":"작성자 닉네임 원문. @ 등 화면 표기는 FE 가 붙인다","example":"busan.vlog"}},"required":["durationSec","nickname","recordedAt","thumbnailUrl","videoId","viewCount"]},"ApiResponseDtoGridAggregationResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/GridAggregationResponseDto"}},"required":["data","developCode","message"]},"CurrentRegionResponseDto":{"type":"object","description":"뷰포트 중심이 속한 현재 행정동과 개인 점령 요약","properties":{"regionCode":{"type":"string","description":"행정동 코드 10자리","example":"2623058000"},"name":{"type":"string","description":"동 이름 한 토큰","example":"부전2동"},"gridCount":{"type":"integer","format":"int32","description":"이 행정동 전체에서 내가 점령한 격자 수(뷰포트 무관)","example":5},"videoCount":{"type":"integer","format":"int64","description":"이 행정동 전체에서 내 격자에 올린 영상 수(뷰포트 무관)","example":355}},"required":["gridCount","name","regionCode","videoCount"]},"GridAggregationResponseDto":{"type":"object","description":"뷰포트 점령 격자 묶음과 현재 동네 집계","properties":{"currentRegion":{"anyOf":[{"$ref":"#/components/schemas/CurrentRegionResponseDto"},{"type":"null"}],"description":"뷰포트 중심이 속한 행정동. 해상이나 서비스 범위 밖이면 null"},"items":{"type":"array","description":"뷰포트 안에서 행정 단위로 묶은 내 점령 격자 목록","items":{"$ref":"#/components/schemas/RegionAggregateResponseDto"}}},"required":["currentRegion","items"]},"RegionAggregateResponseDto":{"type":"object","description":"행정 단위로 묶어 센 점령 격자 집계 한 항목","properties":{"regionCode":{"type":["string","null"],"description":"묶음 키 — 행정동 코드를 단위 길이로 자른 접두(동 10자리, 구 5자리, 시 2자리). 행정동이 판정되지 않은 격자 묶음만 null 이다.","example":"2623058000"},"name":{"type":["string","null"],"description":"단위 표시 이름(동 \"부전2동\", 구 \"부산진구\", 시 \"부산광역시\"). \"부산광역시 214\" 를 \"부산 214\" 로 줄이는 표기 축약은 클라이언트 몫이다. 행정동이 판정되지 않은 격자 묶음만 null 이다.","example":"부전2동"},"lat":{"type":"number","format":"double","description":"마커 대표 좌표 위도 — 그 묶음에 속한 점령 격자 중심의 평균이다(행정 경계 무게중심이 아니다)","example":35.162},"lng":{"type":"number","format":"double","description":"마커 대표 좌표 경도","example":129.065},"count":{"type":"integer","format":"int32","description":"그 단위 안 점령 격자 수. 항목을 더 묶어 합산해도 같은 뷰포트 개별 조회 총수와 일치한다","example":31}},"required":["count","lat","lng","name","regionCode"]},"ApiResponseDtoListFriendListItemResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/FriendListItemResponseDto"}}},"required":["data","developCode","message"]},"FriendListItemResponseDto":{"type":"object","description":"친구 목록 항목 — 수락된 친구 한 명.","properties":{"userId":{"type":"integer","format":"int64","description":"친구의 사용자 id — 프로필 조회·친구 삭제 경로 변수로 그대로 쓴다","example":7},"nickname":{"type":"string","description":"친구의 닉네임","example":"채우미"},"profileImageUrl":{"type":["string","null"],"description":"친구의 프로필 이미지 URL — 미설정이면 null"},"gridColor":{"type":"string","description":"친구의 도감 색상 — 지도에서 친구가 수집한 격자를 칠하는 색","enum":["BLUE","GREEN","PURPLE","ORANGE","PINK","YELLOW","RED","TEAL"],"example":"PINK"}},"required":["gridColor","nickname","profileImageUrl","userId"]},"ApiResponseDtoFriendProfileResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/FriendProfileResponseDto"}},"required":["data","developCode","message"]},"CollectionSummaryResponseDto":{"type":"object","description":"개인 도감 요약 — 점령한 격자 수·올린 영상 총합·방문한 행정동 수·현재/최장 스트릭·획득 뱃지 수.","properties":{"totalGridCount":{"type":"integer","format":"int32","description":"내가 점령한 격자 수 (도감 크기)","example":15},"totalVideoCount":{"type":"integer","format":"int64","description":"내가 올린 영상 총합 (활성 영상만)","example":42},"visitedRegionCount":{"type":"integer","format":"int32","description":"내가 방문한 서로 다른 행정동 수","example":6},"currentStreak":{"type":"integer","format":"int32","description":"현재 스트릭 (연속 업로드 일수). 마지막 기록이 KST 그제 이전이면 끊긴 것으로 보고 0","example":12},"maxStreak":{"type":"integer","format":"int32","description":"최장 스트릭. 끊겨도 유지되는 역대 최고 기록","example":21},"badgeCount":{"type":"integer","format":"int32","description":"획득한 뱃지 수","example":7}},"required":["badgeCount","currentStreak","maxStreak","totalGridCount","totalVideoCount","visitedRegionCount"]},"FriendCollectionGridResponseDto":{"type":"object","description":"친구가 수집한 격자 하나 — 썸네일은 재생 허용 영상이 있을 때만 붙는다.","properties":{"gridId":{"type":"string","description":"격자 ID \"{grid_y}_{grid_x}\"","example":"19422_9582"},"gridY":{"type":"integer","format":"int32","description":"격자 Y 인덱스(지도 이동용, gridId 디코드값)","example":19422},"gridX":{"type":"integer","format":"int32","description":"격자 X 인덱스(지도 이동용, gridId 디코드값)","example":9582},"firstCollectedAt":{"type":"string","format":"date-time","description":"친구가 이 격자를 처음 수집한 시각 — 정렬 키","example":"2026-07-20T18:03:11Z"},"lastUploadedAt":{"type":"string","format":"date-time","description":"친구의 마지막 업로드 시각","example":"2026-07-21T09:12:00Z"},"videoCount":{"type":"integer","format":"int32","description":"그 격자에 친구가 올린 영상 수","example":3},"thumbnailUrl":{"type":["string","null"],"description":"썸네일 presigned GET URL — 재생 허용 영상이 없으면 null"},"regionName":{"type":["string","null"],"description":"격자 중심점 행정동 이름(무귀속/미판정이면 null)","example":"서울특별시 강남구 역삼1동"},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름 (예 \"서면\"). 구역 밖 격자면 null — 이때 라벨은 regionName 이다","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A 는 구역 북단, 열 1 은 서단). zoneName 과 항상 쌍이라 구역 밖이면 함께 null","example":"I-6"}},"required":["firstCollectedAt","gridId","gridX","gridY","lastUploadedAt","regionName","thumbnailUrl","videoCount","zoneCell","zoneName"]},"FriendProfileResponseDto":{"type":"object","description":"친구 프로필 — 프로필 정보와 도감 요약·최근 수집 격자.","properties":{"nickname":{"type":"string","description":"친구의 닉네임","example":"채우미"},"profileImageUrl":{"type":["string","null"],"description":"친구의 프로필 이미지 URL — 미설정이면 null"},"gridColor":{"type":"string","description":"친구의 도감 색상","enum":["BLUE","GREEN","PURPLE","ORANGE","PINK","YELLOW","RED","TEAL"],"example":"PINK"},"summary":{"$ref":"#/components/schemas/CollectionSummaryResponseDto","description":"친구의 도감 요약 — 본인이 보는 값과 동일하다"},"recentGrids":{"type":"array","description":"친구가 최근 수집한 격자 최대 30개 — 수집 시각 역순","items":{"$ref":"#/components/schemas/FriendCollectionGridResponseDto"}}},"required":["gridColor","nickname","profileImageUrl","recentGrids","summary"]},"ApiResponseDtoListFriendGridVideoResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/FriendGridVideoResponseDto"}}},"required":["data","developCode","message"]},"FriendGridVideoResponseDto":{"type":"object","description":"친구 격자 영상 리스트 항목","properties":{"videoId":{"type":"integer","format":"int64","description":"영상(방문 이벤트) ID. 재생 조회 진입 키","example":1042},"thumbnailUrl":{"type":["string","null"],"description":"썸네일 presigned GET URL. 썸네일 key 가 없으면 null"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"createdAt":{"type":"string","format":"date-time","description":"업로드(방문) 시각 — 정렬 키","example":"2026-07-20T18:03:11Z"}},"required":["createdAt","durationSec","thumbnailUrl","videoId"]},"ApiResponseDtoListRegionAggregateResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/RegionAggregateResponseDto"}}},"required":["data","developCode","message"]},"ApiResponseDtoListReceivedFriendRequestResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/ReceivedFriendRequestResponseDto"}}},"required":["data","developCode","message"]},"ReceivedFriendRequestResponseDto":{"type":"object","description":"받은 친구 요청 응답 — 최신 요청 우선 정렬.","properties":{"requesterId":{"type":"integer","format":"int64","description":"보낸 사용자 id — 수락/거절 호출의 경로 변수로 그대로 쓴다","example":3},"nickname":{"type":"string","description":"보낸 사용자의 닉네임","example":"채우미"},"profileImageUrl":{"type":["string","null"],"description":"보낸 사용자의 프로필 이미지 URL — 미설정이면 null"},"requestedAt":{"type":"string","format":"date-time","description":"요청 시각","example":"2026-08-03T12:00:00Z"}},"required":["nickname","profileImageUrl","requestedAt","requesterId"]},"ApiResponseDtoFriendPreviewResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/FriendPreviewResponseDto"}},"required":["data","developCode","message"]},"FriendPreviewResponseDto":{"type":"object","description":"친구 코드 미리보기 응답 — 요청 확정 전 확인 화면(\"OOO님에게 요청을 보낼까요?\")용. 관계 상태(relation)를 함께 담아 화면이 요청 버튼의 활성 여부·문구를 미리 정할 수 있다 (MSG-391). 조회 전용이며 요청 API 가 전 검증을 재수행한다.","properties":{"nickname":{"type":"string","description":"코드 소유자의 닉네임 — SELF 면 내 닉네임","example":"채우미"},"relation":{"type":"string","description":"조회자와 코드 소유자의 관계 상태 — 조회 시점 실시간 판정 (MSG-391)","enum":["SELF","NONE","OUTGOING_PENDING","INCOMING_PENDING","FRIENDS"],"example":"NONE"}},"required":["nickname","relation"]},"ApiResponseDtoFriendCodeResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/FriendCodeResponseDto"}},"required":["data","developCode","message"]},"FriendCodeResponseDto":{"type":"object","description":"내 친구 코드 응답","properties":{"friendCode":{"type":"string","description":"고정 친구 코드 — 혼동 문자(I·O·0·1) 제외 32종 8자, 재발급 없음","example":"AB3DE7GH"}},"required":["friendCode"]},"ApiResponseDtoEventVideoDetailResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventVideoDetailResponseDto"}},"required":["data","developCode","message"]},"EventVideoCommentPageResponseDto":{"type":"object","description":"행사 영상 댓글 페이지 (keyset 커서 페이지네이션)","properties":{"comments":{"type":"array","description":"이 페이지의 댓글 (오래된 순). 댓글이 없으면 빈 배열","items":{"$ref":"#/components/schemas/EventVideoCommentResponseDto"}},"hasNext":{"type":"boolean","description":"다음 페이지 존재 여부 (lookahead 판정)"},"nextCursor":{"type":["string","null"],"description":"다음 페이지 조회용 opaque 커서. 다음 요청 cursor 파라미터에 그대로 넣는다. 마지막 페이지면 null"}},"required":["comments","hasNext","nextCursor"]},"EventVideoDetailResponseDto":{"type":"object","description":"행사 영상 상세","properties":{"videoId":{"type":"integer","format":"int64","description":"영상 ID","example":1042},"occurrenceId":{"type":"integer","format":"int64","description":"소속 행사 회차 ID","example":12},"occurrenceStatus":{"type":"string","description":"요청 시점 회차 상태 (UPCOMING/LIVE/UPLOAD_GRACE/ARCHIVED)","example":"LIVE"},"locationId":{"type":"integer","format":"int64","description":"소속 행사 위치 ID","example":34},"locationName":{"type":"string","description":"소속 행사 위치 이름","example":"영화의전당"},"representativeGridId":{"type":"string","description":"영상이 붙은 대표 격자 ID","example":"19422_9582"},"zoneName":{"type":["string","null"],"description":"대표 격자가 속한 구역 이름. 구역 밖이면 null — 이때 라벨은 regionName 이다","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\". zoneName 과 항상 쌍이라 구역 밖이면 함께 null","example":"A-14"},"regionName":{"type":["string","null"],"description":"대표 격자 중심점 행정동 이름 — 구역 밖 격자의 폴백 라벨. 무귀속이면 null","example":"부산광역시 부산진구 부전2동"},"playbackUrl":{"type":"string","description":"재생본 presigned GET URL"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초)","example":15},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각","example":"2026-10-06T12:00:00Z"},"createdAt":{"type":"string","format":"date-time","description":"업로드 시각","example":"2026-10-06T12:30:00Z"},"uploaderNickname":{"type":"string","description":"작성자 닉네임","example":"필맵러"},"interactionLocked":{"type":"boolean","description":"댓글·도움돼요 입력 UI 를 비활성화할지 여부 — 아카이브 전환(행사 종료 + 30일)부터 true","example":false},"helpfulCount":{"type":"integer","format":"int64","description":"도움돼요 수","example":12},"helpfulByMe":{"type":"boolean","description":"내가 도움돼요를 누른 상태인지. 비로그인 조회는 항상 false","example":false},"commentCount":{"type":"integer","format":"int64","description":"댓글 수","example":3},"comments":{"$ref":"#/components/schemas/EventVideoCommentPageResponseDto","description":"댓글 첫 페이지 (오래된 순 20건)"}},"required":["commentCount","comments","createdAt","durationSec","helpfulByMe","helpfulCount","interactionLocked","locationId","locationName","occurrenceId","occurrenceStatus","playbackUrl","recordedAt","regionName","representativeGridId","uploaderNickname","videoId","zoneCell","zoneName"]},"ApiResponseDtoEventVideoCommentPageResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventVideoCommentPageResponseDto"}},"required":["data","developCode","message"]},"ApiResponseDtoListEventOccurrenceChipResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/EventOccurrenceChipResponseDto"}}},"required":["data","developCode","message"]},"EventOccurrenceChipResponseDto":{"type":"object","description":"뷰포트에 걸친 행사 회차 하나 — 지도 홈 칩 재료","properties":{"occurrenceId":{"type":"integer","format":"int64","description":"행사 회차 id","example":12},"title":{"type":"string","description":"행사명 — 칩 라벨 재료","example":"부산불꽃축제"},"cityName":{"type":"string","description":"대상 지역 시 이름 — 시 칩 묶음 기준","example":"부산"},"startsAt":{"type":"string","format":"date-time","description":"행사 시작 시각","example":"2026-10-06T01:00:00Z"},"endsAt":{"type":"string","format":"date-time","description":"행사 종료 시각","example":"2026-10-15T13:00:00Z"},"status":{"type":"string","description":"서버 시각 기준 파생 상태 — 이 목록에는 두 값만 담긴다","enum":["UPCOMING","LIVE"],"example":"LIVE"}},"required":["cityName","endsAt","occurrenceId","startsAt","status","title"]},"ApiResponseDtoEventOccurrenceDetailResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventOccurrenceDetailResponseDto"}},"required":["data","developCode","message"]},"EventOccurrenceDetailResponseDto":{"type":"object","description":"행사 회차 상세 — 행사방 헤더","properties":{"occurrenceId":{"type":"integer","format":"int64","description":"행사 회차 id","example":12},"seriesId":{"type":"integer","format":"int64","description":"행사 시리즈 id — 이전 회차 묶음 기준","example":3},"title":{"type":"string","description":"행사명","example":"부산불꽃축제"},"startsAt":{"type":"string","format":"date-time","description":"행사 시작 시각","example":"2026-10-06T01:00:00Z"},"endsAt":{"type":"string","format":"date-time","description":"행사 종료 시각","example":"2026-10-15T13:00:00Z"},"uploadClosesAt":{"type":"string","format":"date-time","description":"영상 업로드 마감 — 종료 30일 후 파생값","example":"2026-11-14T13:00:00Z"},"status":{"type":"string","description":"서버 시각 기준 파생 상태","enum":["UPCOMING","LIVE","UPLOAD_GRACE","ARCHIVED"],"example":"LIVE"},"notificationOn":{"type":"boolean","description":"알림 구독 여부 — 구독 행 존재이면서 회차가 예정·진행 중일 때만 true. 비로그인은 항상 false 고, 종료된 회차는 구독 행이 남아 있어도 false 다","example":false},"previousOccurrences":{"type":"array","description":"같은 시리즈의 지난 회차 — 최신순. 없으면 빈 배열","items":{"$ref":"#/components/schemas/PreviousOccurrenceDto"}}},"required":["endsAt","notificationOn","occurrenceId","previousOccurrences","seriesId","startsAt","status","title","uploadClosesAt"]},"PreviousOccurrenceDto":{"type":"object","description":"같은 시리즈의 지난 회차 하나","properties":{"occurrenceId":{"type":"integer","format":"int64","description":"행사 회차 id","example":9},"title":{"type":"string","description":"행사명","example":"부산불꽃축제"},"startsAt":{"type":"string","format":"date-time","description":"행사 시작 시각","example":"2025-10-04T01:00:00Z"},"endsAt":{"type":"string","format":"date-time","description":"행사 종료 시각","example":"2025-10-13T13:00:00Z"}},"required":["endsAt","occurrenceId","startsAt","title"]},"ApiResponseDtoEventViewerCountResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventViewerCountResponseDto"}},"required":["data","developCode","message"]},"EventViewerCountResponseDto":{"type":"object","description":"행사방 현재 열람 인원 응답.","properties":{"viewerCount":{"type":["integer","null"],"format":"int32","description":"현재 열람 인원 — 마지막 heartbeat 가 90초 이내인 고유 세션 수. 0 은 아무도 없음(표시), null 은 캐시 장애(숨김)","example":120}},"required":["viewerCount"]},"ApiResponseDtoListEventLocationResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/EventLocationResponseDto"}}},"required":["data","developCode","message"]},"EventLocationResponseDto":{"type":"object","description":"행사 위치 하나 — 영역 격자·대표 격자·표시명 재료·영상 수","properties":{"locationId":{"type":"integer","format":"int64","description":"행사 위치 id — 위치별 영상 피드 진입 키","example":31},"name":{"type":"string","description":"위치 이름","example":"부산역 팝업"},"type":{"type":"string","description":"위치 유형 — 표시 라벨 변환은 FE 몫","enum":["POPUP","EXPERIENCE_ZONE","PARADE","PHOTO_ZONE","ETC"],"example":"POPUP"},"operatingHours":{"type":["string","null"],"description":"운영 시간 표시 문자열","example":"11:00 ~ 20:00"},"gridIds":{"type":"array","description":"영역을 구성하는 격자 전체 — FE 영역 채색 재료","example":["19443_9582"],"items":{"type":"string"}},"representativeGridId":{"type":"string","description":"대표 격자 — 이 위치의 영상이 붙는 단 하나의 격자","example":"19443_9582"},"zoneName":{"type":["string","null"],"description":"대표 격자가 속한 구역 이름. 구역 밖이면 null","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 안 위치 코드. 구역 밖이면 null","example":"A-14"},"regionName":{"type":["string","null"],"description":"대표 격자의 행정동 이름 — 구역 밖 표시명 폴백. 무귀속이면 null","example":"부전동"},"videoCount":{"type":"integer","format":"int64","description":"이 위치의 영상 수 — 조회 시점 실측(전역 노출 게이트 통과분)","example":7}},"required":["gridIds","locationId","name","operatingHours","regionName","representativeGridId","type","videoCount","zoneCell","zoneName"]},"ApiResponseDtoEventLocationVideoPageResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventLocationVideoPageResponseDto"}},"required":["data","developCode","message"]},"EventLocationVideoPageResponseDto":{"type":"object","description":"위치별 영상 피드 페이지 (keyset 커서 페이지네이션)","properties":{"videos":{"type":"array","description":"이 페이지의 영상. 조건에 맞는 영상이 없으면 빈 배열","items":{"$ref":"#/components/schemas/EventLocationVideoResponseDto"}},"hasNext":{"type":"boolean","description":"다음 페이지 존재 여부 (lookahead 판정)"},"nextCursor":{"type":["string","null"],"description":"다음 페이지 조회용 opaque 커서. 다음 요청 cursor 파라미터에 그대로 넣는다. 마지막 페이지면 null"}},"required":["hasNext","nextCursor","videos"]},"EventLocationVideoResponseDto":{"type":"object","description":"위치별 영상 피드 항목","properties":{"videoId":{"type":"integer","format":"int64","description":"영상 ID — 상세 진입 키","example":1042},"thumbnailUrl":{"type":"string","description":"썸네일 presigned GET URL"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초)","example":15},"createdAt":{"type":"string","format":"date-time","description":"업로드 시각","example":"2026-10-06T12:30:00Z"},"helpfulCount":{"type":"integer","format":"int64","description":"도움돼요 수","example":12},"commentCount":{"type":"integer","format":"int64","description":"댓글 수","example":3}},"required":["commentCount","createdAt","durationSec","helpfulCount","thumbnailUrl","videoId"]},"ApiResponseDtoListRegionVideoResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/RegionVideoResponseDto"}}},"required":["data","developCode","message"]},"RegionVideoResponseDto":{"type":"object","description":"동 단위 내 영상 리스트 항목 — 그 행정동 격자들에 올린 내 영상 하나.","properties":{"videoId":{"type":"integer","format":"int64","description":"영상(방문 이벤트) ID. 개별 재생·교체·삭제 진입 키","example":1042},"gridId":{"type":"string","description":"영상이 속한 격자 ID \"{grid_y}_{grid_x}\" — 항목별 격자 라벨·지도 이동용","example":"19422_9582"},"thumbnailUrl":{"type":["string","null"],"description":"썸네일 presigned GET URL. READY 아니면(썸네일 key 없음) null"},"processingStatus":{"type":"string","description":"영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED)","example":"READY"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"createdAt":{"type":"string","format":"date-time","description":"업로드(방문) 시각 — 정렬 키","example":"2026-07-20T18:03:11Z"},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름 (예 \"서면\"). 구역 밖 격자면 null — 이 화면은 행정동 헤더 아래 목록이라 폴백 이름을 문맥에서 알 수 있어 항목에 regionName 을 따로 담지 않는다","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A 는 구역 북단, 열 1 은 서단). zoneName 과 항상 쌍이라 구역 밖이면 함께 null","example":"I-6"}},"required":["createdAt","durationSec","gridId","processingStatus","thumbnailUrl","videoId","zoneCell","zoneName"]},"ApiResponseDtoListUploadHistoryResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/UploadHistoryResponseDto"}}},"required":["data","developCode","message"]},"UploadHistoryResponseDto":{"type":"object","description":"날짜별 업로드 기록 항목 — 업로드가 있었던 KST 날짜 하나와 그날의 건수.","properties":{"uploadDate":{"type":"string","format":"date","description":"업로드가 있었던 KST 날짜","example":"2026-08-11"},"uploadCount":{"type":"integer","format":"int32","description":"그날 업로드한 영상 수 (1 이상)","example":3}},"required":["uploadCount","uploadDate"]},"ApiResponseDtoCollectionSummaryResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/CollectionSummaryResponseDto"}},"required":["data","developCode","message"]},"ApiResponseDtoListCollectionGridResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/CollectionGridResponseDto"}}},"required":["data","developCode","message"]},"CollectionGridResponseDto":{"type":"object","description":"갤러리 격자 항목 — 내가 수집한 격자 하나와 cover 썸네일.","properties":{"gridId":{"type":"string","description":"격자 ID \"{grid_y}_{grid_x}\"","example":"19422_9582"},"gridY":{"type":"integer","format":"int32","description":"격자 Y 인덱스(지도 이동용, gridId 디코드값)","example":19422},"gridX":{"type":"integer","format":"int32","description":"격자 X 인덱스(지도 이동용, gridId 디코드값)","example":9582},"firstCollectedAt":{"type":"string","format":"date-time","description":"최초 수집(점령) 시각 — 정렬 키","example":"2026-07-20T18:03:11Z"},"lastUploadedAt":{"type":"string","format":"date-time","description":"마지막 방문(업로드) 시각","example":"2026-07-21T09:12:00Z"},"videoCount":{"type":"integer","format":"int32","description":"그 격자 내 내 영상 수","example":3},"coverVideoId":{"type":["integer","null"],"format":"int64","description":"cover 영상 ID(없으면 null)","example":1042},"coverThumbnailUrl":{"type":["string","null"],"description":"cover 썸네일 presigned GET URL(없거나 READY 이전이면 null)"},"coverDurationSec":{"type":["integer","null"],"format":"int32","description":"cover 영상 길이(초) — 카드 duration 뱃지 재료. READY 이전에도 실리고 cover 자체가 없을 때만 null","example":12},"regionName":{"type":["string","null"],"description":"격자 중심점 행정동 이름(무귀속/미판정이면 null)","example":"서울특별시 강남구 역삼1동"},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름 (예 \"서면\"). 구역 밖 격자면 null — 이때 라벨은 regionName 이다","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A 는 구역 북단, 열 1 은 서단). zoneName 과 항상 쌍이라 구역 밖이면 함께 null","example":"I-6"}},"required":["coverDurationSec","coverThumbnailUrl","coverVideoId","firstCollectedAt","gridId","gridX","gridY","lastUploadedAt","regionName","videoCount","zoneCell","zoneName"]},"ApiResponseDtoListMyBadgeResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/MyBadgeResponseDto"}}},"required":["data","developCode","message"]},"MyBadgeResponseDto":{"type":"object","description":"내 뱃지 목록 행 — 획득+미획득 (은퇴 뱃지는 획득자에게만)","properties":{"badgeId":{"type":"integer","format":"int64","description":"뱃지 ID","example":2},"code":{"type":"string","description":"뱃지 code","example":"EXPLORER_10"},"name":{"type":"string","description":"표시명","example":"탐험가 I"},"description":{"type":["string","null"],"description":"설명 — badges.description 은 NULL 허용 컬럼이다","example":"격자 10개를 수집했어요"},"iconUrl":{"type":["string","null"],"description":"아이콘 URL (에셋 확정 전 null)","example":null},"earned":{"type":"boolean","description":"획득 여부","example":true},"earnedAt":{"type":["string","null"],"format":"date-time","description":"획득 시각 — 미획득이면 null","example":"2026-07-29T11:02:31Z"},"isNew":{"type":"boolean","description":"미확인(새 뱃지) 여부 — 미획득이면 false","example":false},"featuredRank":{"type":["integer","null"],"format":"int32","description":"대표 뱃지 순서(1·2) — 대표 아니면 null","example":1}},"required":["badgeId","code","description","earned","earnedAt","featuredRank","iconUrl","isNew","name"]},"AdminVideoReviewResponseDto":{"type":"object","description":"관리자 단건 영상 확인 응답 — 영상 메타와 재생·썸네일 presigned GET URL.","properties":{"videoId":{"type":"integer","format":"int64","description":"영상 ID","example":1042},"status":{"type":"string","description":"영상 상태 — BLINDED 여도 발급된다 (DELETED 만 404)","enum":["ACTIVE","BLINDED","DELETED"],"example":"BLINDED"},"processingStatus":{"type":"string","description":"영상 처리 상태 — READY 일 때만 재생 URL 이 발급된다","enum":["UPLOADED","ENCODING","BLURRING","READY","FAILED"],"example":"READY"},"visibility":{"type":"string","description":"공개 범위 — PRIVATE 여도 발급된다 (관리자 확인은 은닉 없음)","enum":["PUBLIC","PRIVATE","FRIENDS"],"example":"PRIVATE"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각 (표시용)","example":"2026-07-20T18:03:11Z"},"playbackUrl":{"type":["string","null"],"description":"재생본 presigned GET URL — READY 가 아니면 null"},"thumbnailUrl":{"type":["string","null"],"description":"썸네일 presigned GET URL — 썸네일 key 없음(READY 이전)이면 null"},"expiresInSec":{"type":["integer","null"],"format":"int64","description":"playbackUrl presign TTL(초) — playbackUrl=null 이면 null","example":600}},"required":["durationSec","expiresInSec","playbackUrl","processingStatus","recordedAt","status","thumbnailUrl","videoId","visibility"]},"ApiResponseDtoAdminVideoReviewResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminVideoReviewResponseDto"}},"required":["data","developCode","message"]},"AdminReportItemResponseDto":{"type":"object","description":"관리자 신고 목록 항목 — 신고 한 건과 판단에 필요한 주변 정보.","properties":{"reportId":{"type":"integer","format":"int64","description":"신고 ID — 승인·기각 경로 변수로 그대로 쓴다","example":7},"status":{"type":"string","description":"신고 처리 상태","enum":["PENDING","REVIEWING","RESOLVED","REJECTED"],"example":"PENDING"},"reason":{"type":"string","description":"신고 사유","enum":["INAPPROPRIATE","PRIVACY","SPAM","COPYRIGHT","OTHER"],"example":"INAPPROPRIATE"},"detail":{"type":["string","null"],"description":"신고자가 적은 상세 설명 — OTHER 가 아닌 사유는 없을 수 있다"},"createdAt":{"type":"string","format":"date-time","description":"신고 접수 시각","example":"2026-08-06T10:15:00Z"},"reporterId":{"type":"integer","format":"int64","description":"신고자의 사용자 ID","example":3},"reporterNickname":{"type":"string","description":"신고자의 닉네임","example":"정민"},"videoId":{"type":"integer","format":"int64","description":"신고 대상 영상 ID — 단건 확인·블라인드 해제 경로 변수로 쓴다","example":1042},"videoStatus":{"type":"string","description":"신고 대상 영상의 현재 상태 (ACTIVE/BLINDED/DELETED)","enum":["ACTIVE","BLINDED","DELETED"],"example":"ACTIVE"},"videoOwnerNickname":{"type":"string","description":"영상 소유자의 닉네임","example":"성민"},"reviewedBy":{"type":["integer","null"],"format":"int64","description":"처리한 관리자의 사용자 ID — 미처리면 null","example":1},"reviewedAt":{"type":["string","null"],"format":"date-time","description":"처리 시각 — 미처리면 null","example":"2026-08-06T11:00:00Z"}},"required":["createdAt","detail","reason","reportId","reporterId","reporterNickname","reviewedAt","reviewedBy","status","videoId","videoOwnerNickname","videoStatus"]},"AdminReportListResponseDto":{"type":"object","description":"관리자 신고 목록 응답 — 상태 필터 기준 한 페이지.","properties":{"items":{"type":"array","description":"이 페이지의 신고 목록. 정렬은 접수 최신순 고정","items":{"$ref":"#/components/schemas/AdminReportItemResponseDto"}},"page":{"type":"integer","format":"int32","description":"현재 페이지 번호 (0부터)","example":0},"size":{"type":"integer","format":"int32","description":"페이지 크기","example":20},"totalElements":{"type":"integer","format":"int64","description":"필터에 해당하는 전체 신고 수","example":1},"totalPages":{"type":"integer","format":"int32","description":"전체 페이지 수","example":1}},"required":["items","page","size","totalElements","totalPages"]},"ApiResponseDtoAdminReportListResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminReportListResponseDto"}},"required":["data","developCode","message"]}},"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT"}}}} \ No newline at end of file diff --git a/apps/web/public/robots.txt b/apps/web/public/robots.txt index 11fa5e3c..da04461d 100644 --- a/apps/web/public/robots.txt +++ b/apps/web/public/robots.txt @@ -1,10 +1,11 @@ # FillMap 웹 (MSG-478) — 색인 대상은 비로그인으로 열리는 홈뿐이다. -# /dex·/profile은 로그인 뒤 화면, /oauth/는 카카오 콜백, /upload는 스텁(실제 업로드는 모달). +# /dex·/profile·/ai-route는 로그인 뒤 화면, /oauth/는 카카오 콜백, /upload는 스텁(실제 업로드는 모달). User-agent: * Allow: / Disallow: /dex Disallow: /profile Disallow: /oauth/ Disallow: /upload +Disallow: /ai-route Sitemap: https://fillmap.kr/sitemap.xml diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx index 137d7685..eca764b4 100644 --- a/apps/web/src/app/router.tsx +++ b/apps/web/src/app/router.tsx @@ -3,6 +3,7 @@ import { AppLayout } from "@/app/layouts/AppLayout"; import { RequireAuth } from "@/app/RequireAuth"; import { RouteErrorBoundary } from "@/app/RouteErrorBoundary"; import { KAKAO_CALLBACK_PATH, ROUTES } from "@/app/routes"; +import { AiRoutePage } from "@/pages/ai-route/AiRoutePage"; import { DexPanel } from "@/pages/dex/DexPanel"; import { MapHomePage } from "@/pages/map-home/MapHomePage"; import { KakaoCallbackPage } from "@/pages/oauth-callback/KakaoCallbackPage"; @@ -25,6 +26,16 @@ export const router = createBrowserRouter([ children: [ { path: ROUTES.home, element: }, // /explore는 MSG-328에서 제거 — 직접 진입은 무매칭 404로 errorElement에 수렴한다 (AC 2) + // AI 경로추천(MSG-488) — 서버가 익명 POST를 401(2403)로 막아 로그인 전용이다. + // 레일 클릭은 SideRailNav가 모달로 막고, 이 래핑은 직접 URL 진입 방어다 + { + path: ROUTES.aiRoute, + element: ( + + + + ), + }, { path: ROUTES.upload, element: }, // 도감(MSG-121·122) — 탭은 URL 정본(/dex·/dex/badges), 무효 탭("gallery" 포함)은 지도 폴백(AC 2·21). // 비로그인 진입은 프로필과 동일하게 홈+로그인 모달 (MSG-328 사용자 피드백) diff --git a/apps/web/src/app/routes.test.ts b/apps/web/src/app/routes.test.ts index f0653956..722afcb6 100644 --- a/apps/web/src/app/routes.test.ts +++ b/apps/web/src/app/routes.test.ts @@ -30,6 +30,7 @@ describe("getActiveNavKey", () => { }); it("각 섹션 경로는 해당 네비 키를 반환한다", () => { + expect(getActiveNavKey(ROUTES.aiRoute)).toBe("aiRoute"); expect(getActiveNavKey(ROUTES.upload)).toBe("upload"); expect(getActiveNavKey(ROUTES.dex)).toBe("dex"); expect(getActiveNavKey(ROUTES.profile)).toBe("profile"); @@ -49,3 +50,29 @@ describe("getActiveNavKey", () => { expect(getActiveNavKey("/explore")).toBeUndefined(); }); }); + +/** + * AI 경로추천 라우트 신설 (MSG-488 L11, 승인 Q1) — 티켓 가칭 `/route`가 아니라 `/ai-route`다. + * 기존 `route` 3중 동음이의(ROUTES · ThemeId "route" 코스 칩 · map-overlay-store.routes)를 피한다. + */ +describe("AI 경로추천 라우트 (MSG-488 L11)", () => { + it("네비 섹션은 홈·AI 경로추천·업로드·도감·프로필 5개다 (L11)", () => { + expect(Object.keys(ROUTES)).toEqual([ + "home", + "aiRoute", + "upload", + "dex", + "profile", + ]); + }); + + it('경로는 "/ai-route"이고 getActiveNavKey가 aiRoute를 반환한다 (L11)', () => { + expect(ROUTES.aiRoute).toBe("/ai-route"); + expect(getActiveNavKey("/ai-route")).toBe("aiRoute"); + }); + + it('티켓 가칭 "/route"는 미등록 경로다 — 라우터 404 폴백을 따른다 (L11, Q1)', () => { + expect("route" in ROUTES).toBe(false); + expect(getActiveNavKey("/route")).toBeUndefined(); + }); +}); diff --git a/apps/web/src/app/routes.ts b/apps/web/src/app/routes.ts index d1555218..14e89340 100644 --- a/apps/web/src/app/routes.ts +++ b/apps/web/src/app/routes.ts @@ -4,6 +4,10 @@ */ export const ROUTES = { home: "/", + // AI 경로추천 (MSG-488) — 티켓 가칭 `/route`가 아니라 `/ai-route`다(승인 Q1): + // `route`는 코드베이스에서 이미 3중 의미(ROUTES · ThemeId "route" 코스 칩 · + // map-overlay-store.routes)라 새 섹션까지 그 낱말을 쓰면 계속 섞인다 + aiRoute: "/ai-route", // explore는 MSG-328에서 제거 — 지역 탐색·검색이 홈 좌측 패널로 통합됐다. 재추가 금지. upload: "/upload", dex: "/dex", diff --git a/apps/web/src/app/seo-static.test.ts b/apps/web/src/app/seo-static.test.ts index 5f9bfbab..60402870 100644 --- a/apps/web/src/app/seo-static.test.ts +++ b/apps/web/src/app/seo-static.test.ts @@ -112,7 +112,14 @@ describe("public/robots.txt · sitemap.xml (B1·B2)", () => { expect(text).toMatch(/^User-agent: \*$/m); expect(text).toMatch(/^Allow: \/$/m); - expect(disallows()).toEqual(["/dex", "/profile", "/oauth/", "/upload"]); + // MSG-488: /ai-route는 로그인 전용 화면이라 색인 대상이 아니다 (L13) + expect(disallows()).toEqual([ + "/dex", + "/profile", + "/oauth/", + "/upload", + "/ai-route", + ]); expect(text).toMatch(/^Sitemap: https:\/\/fillmap\.kr\/sitemap\.xml$/m); }); diff --git a/apps/web/src/features/ai-route/api/use-route-recommend.test.tsx b/apps/web/src/features/ai-route/api/use-route-recommend.test.tsx new file mode 100644 index 00000000..43ea6c29 --- /dev/null +++ b/apps/web/src/features/ai-route/api/use-route-recommend.test.tsx @@ -0,0 +1,99 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { envelopeResponse, errorEnvelope } from "@/test/envelope-response"; +import { ROUTE_POINTS } from "@/test/route-points"; +import { stubFetch } from "@/test/stub-fetch"; +import { useAiRouteStore } from "../model/ai-route-store"; +import { useRouteRecommend } from "./use-route-recommend"; + +const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +const BODY = { + text: "서면에서 밥 먹고 저녁 경기까지 동선 짜 줘", + viewport: { + minLat: 35.1521, + minLng: 129.0537, + maxLat: 35.1662, + maxLng: 129.0712, + }, +}; + +/** 훅 마운트 → 1회 요청 — 세 시나리오가 공유하는 진입 동작 */ +const mutateRecommend = ( + callbacks?: Parameters[0], +) => { + const { result } = renderHook(() => useRouteRecommend(callbacks), { + wrapper, + }); + result.current.mutate(BODY); +}; + +describe("useRouteRecommend — 추천 1회 요청 배선 (L5·L7 배선)", () => { + beforeEach(() => { + useAiRouteStore.setState(useAiRouteStore.getInitialState(), true); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("응답 봉투를 벗겨 지점과 부족 안내를 스토어에 싣는다 (§4-3)", async () => { + const received = stubFetch(() => + envelopeResponse({ + points: ROUTE_POINTS, + notice: null, + mentionedArea: null, + }), + ); + mutateRecommend(); + + await waitFor(() => + expect(useAiRouteStore.getState().status).toBe("result"), + ); + expect(useAiRouteStore.getState().points.map((p) => p.order)).toEqual([ + 1, 2, 3, + ]); + expect(received[0].body).toEqual(BODY); + }); + + it("실패는 developCode 매핑 안내로 스토어에 실린다 — 입력 문장은 유지된다 (§1-4)", async () => { + stubFetch(() => errorEnvelope(14400, "뷰포트가 너무 넓습니다", 400)); + useAiRouteStore.getState().setText(BODY.text); + const { result } = renderHook(() => useRouteRecommend(), { wrapper }); + + result.current.mutate(BODY); + + await waitFor(() => + expect(useAiRouteStore.getState().status).toBe("error"), + ); + expect(useAiRouteStore.getState().errorNotice?.message).toBe( + "지도를 조금 더 확대하거나 다른 곳으로 옮긴 뒤 다시 시도해 주세요", + ); + expect(useAiRouteStore.getState().text).toBe(BODY.text); + }); + + it("401(2403)이면 입력 대기로 되돌아가고 로그인 콜백이 호출된다 (§1-3)", async () => { + stubFetch(() => errorEnvelope(2403, "인증이 필요합니다", 401)); + const onLoginRequired = vi.fn(); + const { result } = renderHook( + () => useRouteRecommend({ onLoginRequired }), + { + wrapper, + }, + ); + + result.current.mutate(BODY); + + await waitFor(() => expect(onLoginRequired).toHaveBeenCalledTimes(1)); + expect(useAiRouteStore.getState().status).toBe("idle"); + expect(useAiRouteStore.getState().errorNotice).toBeNull(); + }); +}); diff --git a/apps/web/src/features/ai-route/api/use-route-recommend.ts b/apps/web/src/features/ai-route/api/use-route-recommend.ts new file mode 100644 index 00000000..86c7a4e7 --- /dev/null +++ b/apps/web/src/features/ai-route/api/use-route-recommend.ts @@ -0,0 +1,45 @@ +import { useMutation } from "@tanstack/react-query"; +// 생성 mutation 옵션은 barrel 미재수출 — 직접 경로 import (MSG-323 관례) +import { recommendMutation } from "@/shared/api/generated/@tanstack/react-query.gen"; +import type { RouteRecommendRequestDto } from "@/shared/api/generated"; +import { unwrapEnvelope } from "@/shared/api/envelope"; +import { useAiRouteStore } from "../model/ai-route-store"; +import { routeErrorNotice } from "../model/route-error"; + +/** + * AI 경로 추천 요청 (MSG-488 §4-3) — `POST /api/routes/recommend`를 1회 쏜다. + * 생성 SDK mutation 옵션 기반(직접 fetch·URL 하드코딩 없음, use-video-mutations 패턴 미러). + * 봉투 언랩 → 스토어 게시까지가 이 훅의 계약이고, 화면 분기는 스토어를 구독한다. + * + * mutation은 TanStack 기본 `retry: 0`이라 14429(10초 제한)가 자동 재시도로 악화되지 않는다. + * 콜백은 훅 레벨 옵션으로 받는다 — mutate per-call 콜백은 관찰자 언마운트 시 유실된다(MSG-325 선례). + * + * [MSG-489 확장점] 2차 자동 재요청 트리거가 이 훅에 얹힌다. + */ +// 생성 팩토리는 mutationFn을 항상 채운다 — UseMutationOptions 타입만 optional이라 !로 좁힌다 +const recommendFn = recommendMutation().mutationFn!; + +export const useRouteRecommend = (callbacks?: { + /** 401(2403) — 패널은 입력을 유지한 채 입력 대기로 돌아가고 로그인 모달만 연다 (§1-3) */ + onLoginRequired?: () => void; +}) => { + const startRequest = useAiRouteStore((s) => s.startRequest); + const succeed = useAiRouteStore((s) => s.succeed); + const fail = useAiRouteStore((s) => s.fail); + + return useMutation({ + mutationFn: (body: RouteRecommendRequestDto, context) => + recommendFn({ body }, context), + // 이전 결과·선택은 요청 시작 시점에 비운다 — 로딩 화면에 잔상이 남지 않는다 (L7) + onMutate: () => startRequest(), + onSuccess: (response) => { + const data = unwrapEnvelope(response); + succeed(data.points, data.notice); + }, + onError: (error) => { + const notice = routeErrorNotice(error); + fail(notice); + if (notice.requiresLogin) callbacks?.onLoginRequired?.(); + }, + }); +}; diff --git a/apps/web/src/features/ai-route/model/ai-route-store.test.ts b/apps/web/src/features/ai-route/model/ai-route-store.test.ts new file mode 100644 index 00000000..011860ad --- /dev/null +++ b/apps/web/src/features/ai-route/model/ai-route-store.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { ROUTE_POINTS } from "@/test/route-points"; +import { useAiRouteStore } from "./ai-route-store"; + +const store = () => useAiRouteStore.getState(); + +const LOGIN_REQUIRED = { + message: null, + retryable: false, + disablesFeature: false, + requiresLogin: true, +} as const; + +const FEATURE_OFF = { + message: "지금은 경로 추천을 쓸 수 없어요", + retryable: false, + disablesFeature: true, + requiresLogin: false, +} as const; + +describe("useAiRouteStore — 요청·결과 상태 전이 (L7)", () => { + beforeEach(() => { + useAiRouteStore.setState(useAiRouteStore.getInitialState(), true); + }); + + it("초기 상태는 입력 대기(idle)이고 입력·결과가 비어 있다 (L7)", () => { + expect(store().status).toBe("idle"); + expect(store().text).toBe(""); + expect(store().points).toEqual([]); + }); + + it("요청을 시작하면 loading이 되고, 결과가 도착하면 result가 된다 (L7)", () => { + store().setText("서면에서 밥 먹고 저녁 경기까지"); + + store().startRequest(); + expect(store().status).toBe("loading"); + + store().succeed(ROUTE_POINTS, null); + expect(store().status).toBe("result"); + expect(store().points.map((p) => p.order)).toEqual([1, 2, 3]); + expect(store().text).toBe("서면에서 밥 먹고 저녁 경기까지"); + }); + + it("새 요청을 시작하면 이전 결과·안내·선택이 먼저 비워진다 — 잔상이 남지 않는다 (L7)", () => { + store().succeed(ROUTE_POINTS, "부족 안내"); + store().selectOrder(2); + + store().startRequest(); + + expect(store().points).toEqual([]); + expect(store().notice).toBeNull(); + expect(store().selectedOrder).toBeNull(); + }); + + it("요청이 실패하면 error가 되고 안내가 실리며 입력 문장은 유지된다 (L7)", () => { + store().setText("서면 동선"); + store().startRequest(); + + store().fail({ + message: "잠시 후 다시 시도해 주세요", + retryable: true, + disablesFeature: false, + requiresLogin: false, + }); + + expect(store().status).toBe("error"); + expect(store().errorNotice?.message).toBe("잠시 후 다시 시도해 주세요"); + expect(store().text).toBe("서면 동선"); + }); + + it("로그인이 필요한 실패는 에러 문구 없이 입력 대기로 되돌아간다 (L7, §1-4)", () => { + store().setText("서면 동선"); + store().startRequest(); + + store().fail(LOGIN_REQUIRED); + + expect(store().status).toBe("idle"); + expect(store().errorNotice).toBeNull(); + expect(store().text).toBe("서면 동선"); + }); + + it("기능 꺼짐(14503) 실패는 세션 동안 featureDisabled를 켠 채 유지된다 (L7, §1-4)", () => { + store().startRequest(); + store().fail(FEATURE_OFF); + + expect(store().featureDisabled).toBe(true); + + store().reset(); + expect(store().featureDisabled).toBe(true); + }); + + it("selectOrder가 선택 지점을 바꾸고 null로 해제한다 (L7)", () => { + store().succeed(ROUTE_POINTS, null); + + store().selectOrder(2); + expect(store().selectedOrder).toBe(2); + + store().selectOrder(null); + expect(store().selectedOrder).toBeNull(); + }); + + it("reset은 입력 문장까지 비워 입력 대기로 되돌린다 (L7, 레일 재클릭 2단)", () => { + store().setText("서면 동선"); + store().succeed(ROUTE_POINTS, "부족 안내"); + store().selectOrder(1); + + store().reset(); + + expect(store().status).toBe("idle"); + expect(store().text).toBe(""); + expect(store().points).toEqual([]); + expect(store().notice).toBeNull(); + expect(store().selectedOrder).toBeNull(); + }); +}); diff --git a/apps/web/src/features/ai-route/model/ai-route-store.ts b/apps/web/src/features/ai-route/model/ai-route-store.ts new file mode 100644 index 00000000..90a02e00 --- /dev/null +++ b/apps/web/src/features/ai-route/model/ai-route-store.ts @@ -0,0 +1,65 @@ +import { create } from "zustand"; +import type { RoutePointDto } from "@/shared/api/generated"; +import type { RouteErrorNotice } from "./route-error"; + +/** + * AI 경로추천 세션 상태 (MSG-488 L7). + * 플랫폼 중립 — 지도 SDK·라우터·웹 API를 import하지 않는다(RN 경계). + * 스토어가 정본이라 다른 섹션에 갔다 돌아와도 입력·결과·지도 표시가 복원된다 (S11). + * + * [MSG-489 확장점] 출발지(origin)·mentionedArea·2차 자동 재요청 플래그가 이 스토어에 얹힌다. + */ +export type AiRouteStatus = "idle" | "loading" | "result" | "error"; + +interface AiRouteState { + /** 입력 문장 — 요청 실패·성공과 무관하게 유지된다 (§1-4 전 행 공통) */ + text: string; + status: AiRouteStatus; + points: RoutePointDto[]; + /** 서버 부족 신호 — 문구는 쓰지 않고 null 여부만 읽는다 (L4) */ + notice: string | null; + /** 선택된 지점 order — 카드↔마커 양방향 강조 (S8) */ + selectedOrder: number | null; + errorNotice: RouteErrorNotice | null; + /** 14503(기능 꺼짐) — 세션 동안 제출을 막는다 (§1-4) */ + featureDisabled: boolean; + setText: (text: string) => void; + startRequest: () => void; + succeed: (points: RoutePointDto[], notice: string | null) => void; + fail: (notice: RouteErrorNotice) => void; + selectOrder: (order: number | null) => void; + reset: () => void; +} + +/** 결과·선택·에러가 비워진 상태 — 새 요청 시작과 reset이 공유한다 (매번 새 배열) */ +const cleared = (): Pick< + AiRouteState, + "status" | "points" | "notice" | "selectedOrder" | "errorNotice" +> => ({ + status: "idle", + points: [], + notice: null, + selectedOrder: null, + errorNotice: null, +}); + +export const useAiRouteStore = create((set) => ({ + text: "", + featureDisabled: false, + ...cleared(), + setText: (text) => set({ text }), + // 새 요청은 이전 결과를 **먼저** 비운다 — 잔상(이전 카드·지도 표시)이 로딩 중에 남지 않는다 + startRequest: () => set({ ...cleared(), status: "loading" }), + succeed: (points, notice) => + set({ status: "result", points, notice, errorNotice: null }), + fail: (notice) => + set((state) => ({ + // 로그인 필요는 에러 화면 없이 입력 대기로 되돌린다 — 모달 열기는 호출부 몫 (§1-3) + status: notice.requiresLogin ? "idle" : "error", + errorNotice: notice.requiresLogin ? null : notice, + featureDisabled: state.featureDisabled || notice.disablesFeature, + })), + selectOrder: (order) => set({ selectedOrder: order }), + // 레일 재클릭 2단의 초기화 — 입력까지 비운다. featureDisabled는 세션 플래그라 남긴다 + reset: () => set({ ...cleared(), text: "" }), +})); diff --git a/apps/web/src/features/ai-route/model/route-error.test.ts b/apps/web/src/features/ai-route/model/route-error.test.ts new file mode 100644 index 00000000..8205ec40 --- /dev/null +++ b/apps/web/src/features/ai-route/model/route-error.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { ApiError } from "@/shared/api/api-error"; +import { routeErrorNotice } from "./route-error"; + +const apiError = (options: { status?: number; developCode?: number }) => + new ApiError("서버 원문", options); + +describe("routeErrorNotice — developCode → UI 반응 매핑 (L5, §1-4)", () => { + it("14400·14401(뷰포트)은 지도 조정 안내 + 재시도 행이다 (L5)", () => { + for (const developCode of [14400, 14401]) { + expect(routeErrorNotice(apiError({ status: 400, developCode }))).toEqual({ + message: + "지도를 조금 더 확대하거나 다른 곳으로 옮긴 뒤 다시 시도해 주세요", + retryable: true, + disablesFeature: false, + requiresLogin: false, + }); + } + }); + + it("14429(요청 과다)는 잠시 후 재시도 안내다 — 클라이언트 쿨다운은 걸지 않는다 (L5, Q7)", () => { + expect( + routeErrorNotice(apiError({ status: 429, developCode: 14429 })), + ).toEqual({ + message: "잠시 후 다시 시도해 주세요", + retryable: true, + disablesFeature: false, + requiresLogin: false, + }); + }); + + it("14502(AI 실패)는 문장 이해 실패 안내 + 재시도 행이다 (L5)", () => { + expect( + routeErrorNotice(apiError({ status: 502, developCode: 14502 })), + ).toEqual({ + message: "AI가 문장을 이해하지 못했어요. 다시 시도해 주세요", + retryable: true, + disablesFeature: false, + requiresLogin: false, + }); + }); + + it("14503(기능 꺼짐)은 재시도 행이 없고 기능을 세션 동안 비활성화한다 (L5)", () => { + expect( + routeErrorNotice(apiError({ status: 503, developCode: 14503 })), + ).toEqual({ + message: "지금은 경로 추천을 쓸 수 없어요", + retryable: false, + disablesFeature: true, + requiresLogin: false, + }); + }); + + it("401 또는 developCode 2403은 패널 문구 없이 로그인 모달을 요구한다 (L5)", () => { + const expected = { + message: null, + retryable: false, + disablesFeature: false, + requiresLogin: true, + }; + + expect( + routeErrorNotice(apiError({ status: 401, developCode: 2403 })), + ).toEqual(expected); + expect(routeErrorNotice(apiError({ status: 401 }))).toEqual(expected); + }); + + it("status가 없으면(네트워크 실패) 네트워크 안내다 (L5)", () => { + expect(routeErrorNotice(apiError({}))).toEqual({ + message: "네트워크 상태를 확인하고 다시 시도해 주세요", + retryable: true, + disablesFeature: false, + requiresLogin: false, + }); + }); + + it("미분류 5xx와 ApiError가 아닌 오류는 공통 실패 안내다 (L5)", () => { + const expected = { + message: "동선을 짜지 못했어요. 잠시 후 다시 시도해 주세요", + retryable: true, + disablesFeature: false, + requiresLogin: false, + }; + + expect(routeErrorNotice(apiError({ status: 500 }))).toEqual(expected); + expect(routeErrorNotice(new Error("알 수 없음"))).toEqual(expected); + }); +}); diff --git a/apps/web/src/features/ai-route/model/route-error.ts b/apps/web/src/features/ai-route/model/route-error.ts new file mode 100644 index 00000000..2e9eb0ed --- /dev/null +++ b/apps/web/src/features/ai-route/model/route-error.ts @@ -0,0 +1,66 @@ +import { ApiError } from "@/shared/api/api-error"; + +/** + * 추천 요청 실패 → UI 반응 매핑 (MSG-488 L5, 스펙 §1-4). + * 순수 함수 — 정규화는 `shared/api/api-error`의 `ApiError`(status·developCode)를 그대로 쓴다 + * (video-actions `reportFailureNotice` 선례). 로그인 모달·스토어 전이는 호출부가 한다. + * + * mutation은 TanStack 기본 `retry: 0`이라 14429(10초 제한)가 자동 재시도로 악화되지 않는다 — + * 전역 `shouldRetryQuery`는 query 전용이다. + */ +export interface RouteErrorNotice { + /** 패널에 띄울 문구 — 로그인 필요(401/2403)일 때만 null(에러 표시 없이 모달만) */ + message: string | null; + /** 재시도 행("다시 시도") 표시 여부 */ + retryable: boolean; + /** 세션 동안 기능을 끈다 (14503) — 제출 버튼이 계속 비활성 */ + disablesFeature: boolean; + /** 로그인 모달을 열고 입력 대기로 되돌린다 */ + requiresLogin: boolean; +} + +const notice = ( + message: string | null, + overrides: Partial> = {}, +): RouteErrorNotice => ({ + message, + retryable: true, + disablesFeature: false, + requiresLogin: false, + ...overrides, +}); + +const GENERIC = notice("동선을 짜지 못했어요. 잠시 후 다시 시도해 주세요"); + +/** developCode별 문구 — 표에 없는 코드는 공통 실패 안내로 떨어진다 */ +const BY_DEVELOP_CODE: Record = { + 14400: notice( + "지도를 조금 더 확대하거나 다른 곳으로 옮긴 뒤 다시 시도해 주세요", + ), + 14401: notice( + "지도를 조금 더 확대하거나 다른 곳으로 옮긴 뒤 다시 시도해 주세요", + ), + 14429: notice("잠시 후 다시 시도해 주세요"), + 14502: notice("AI가 문장을 이해하지 못했어요. 다시 시도해 주세요"), + 14503: notice("지금은 경로 추천을 쓸 수 없어요", { + retryable: false, + disablesFeature: true, + }), + 2403: notice(null, { retryable: false, requiresLogin: true }), +}; + +export const routeErrorNotice = (error: unknown): RouteErrorNotice => { + if (!(error instanceof ApiError)) return GENERIC; + + const mapped = + error.developCode === undefined + ? undefined + : BY_DEVELOP_CODE[error.developCode]; + if (mapped) return mapped; + + if (error.status === 401) return BY_DEVELOP_CODE[2403]; + if (error.status === undefined) { + return notice("네트워크 상태를 확인하고 다시 시도해 주세요"); + } + return GENERIC; +}; diff --git a/apps/web/src/features/ai-route/model/route-legs.test.ts b/apps/web/src/features/ai-route/model/route-legs.test.ts new file mode 100644 index 00000000..7f96dcec --- /dev/null +++ b/apps/web/src/features/ai-route/model/route-legs.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { buildRouteLegs, formatWalkDistance } from "./route-legs"; + +/** 서면 일대 실좌표 근사 — 이웃 간 수백 m 간격 (MVP 지역 부산 서면) */ +const STOPS = [ + { order: 1, lat: 35.1568, lng: 129.0594 }, + { order: 2, lat: 35.1601, lng: 129.0621 }, + { order: 3, lat: 35.1633, lng: 129.0668 }, +]; + +describe("formatWalkDistance — 도보 거리 표기 (L3, Q6)", () => { + it("1000m 미만은 10m 단위로 반올림해 m로 적는다 (L3)", () => { + expect(formatWalkDistance(604)).toBe("도보 약 600m"); + expect(formatWalkDistance(447)).toBe("도보 약 450m"); + }); + + it("1000m 이상은 소수 1자리 km로 적는다 (L3)", () => { + expect(formatWalkDistance(1234)).toBe("도보 약 1.2km"); + }); + + it("반올림으로 1000m에 닿으면 km 표기로 넘어간다 — '1000m'는 나오지 않는다 (경계)", () => { + expect(formatWalkDistance(999.6)).toBe("도보 약 1.0km"); + }); +}); + +describe("buildRouteLegs — 이웃 지점 직선 거리 구간 (L3)", () => { + it("지점이 N개면 이웃 쌍마다 N-1개 구간을 만든다 (L3)", () => { + const legs = buildRouteLegs(STOPS); + + expect(legs).toHaveLength(2); + expect(legs.map((leg) => [leg.fromOrder, leg.toOrder])).toEqual([ + [1, 2], + [2, 3], + ]); + }); + + it("구간 거리는 이웃 좌표의 직선(하버사인) 거리이고 문구가 함께 붙는다 (L3)", () => { + const [first] = buildRouteLegs(STOPS); + + // 서면 좌표 두 점의 하버사인 실측 ≈ 441m — 10m 반올림으로 440m + expect(Math.round(first.meters)).toBe(441); + expect(first.label).toBe("도보 약 440m"); + }); + + it("지점이 1개 이하면 구간이 없다 (L3, 경계)", () => { + expect(buildRouteLegs([STOPS[0]])).toEqual([]); + expect(buildRouteLegs([])).toEqual([]); + }); +}); diff --git a/apps/web/src/features/ai-route/model/route-legs.ts b/apps/web/src/features/ai-route/model/route-legs.ts new file mode 100644 index 00000000..9124f20c --- /dev/null +++ b/apps/web/src/features/ai-route/model/route-legs.ts @@ -0,0 +1,51 @@ +import { distanceMeters } from "@/entities/cell"; +import type { RoutePointDto } from "@/shared/api/generated"; + +/** + * 구간(이웃 지점 쌍) 거리 파생 (MSG-488 L3). + * 순수 함수 — 지도 SDK·플랫폼에 의존하지 않는다(RN 재사용 대상). + * + * 이 티켓의 거리는 **이웃 좌표 직선(하버사인)** 근사다. 실보행 경로·거리는 MSG-490이 + * `POST /api/routes/walk-paths`로 교체하며, 직선은 그때 폴백으로 남는다. + */ + +/** 구간 거리 계산 입력 — 방문 순서와 좌표만 쓴다 */ +type RouteStopGeo = Pick; + +export interface RouteLeg { + fromOrder: number; + toOrder: number; + /** 직선 거리(m) — 표기 반올림 전 원값 */ + meters: number; + /** 커넥터 행 문구 */ + label: string; +} + +/** + * 도보 거리 표기 (승인 Q6) — 1000m 미만은 10m 반올림 m, 1000m 이상은 소수 1자리 km. + * 반올림을 먼저 하므로 999.6m는 "1000m"가 아니라 "1.0km"로 넘어간다. + */ +export const formatWalkDistance = (meters: number): string => { + const rounded = Math.round(meters / 10) * 10; + return rounded < 1000 + ? `도보 약 ${rounded}m` + : `도보 약 ${(rounded / 1000).toFixed(1)}km`; +}; + +/** 방문 순서대로 이웃 쌍마다 구간 하나 — 지점이 1개 이하면 구간이 없다 (L3) */ +export const buildRouteLegs = (points: RouteStopGeo[]): RouteLeg[] => { + const ordered = [...points].sort((a, b) => a.order - b.order); + return ordered.slice(1).map((to, index) => { + const from = ordered[index]; + const meters = distanceMeters( + { lat: from.lat, lng: from.lng }, + { lat: to.lat, lng: to.lng }, + ); + return { + fromOrder: from.order, + toOrder: to.order, + meters, + label: formatWalkDistance(meters), + }; + }); +}; diff --git a/apps/web/src/features/ai-route/model/route-overlay.test.ts b/apps/web/src/features/ai-route/model/route-overlay.test.ts new file mode 100644 index 00000000..d9da358b --- /dev/null +++ b/apps/web/src/features/ai-route/model/route-overlay.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { palette } from "@fillmap/design-tokens"; +import { ROUTE_POINTS, routePointOf } from "@/test/route-points"; +import { AI_ROUTE_OVERLAY_ID, buildAiRouteOverlay } from "./route-overlay"; + +describe("buildAiRouteOverlay — 지도 게시 오버레이 파생 (L6)", () => { + it("경로 오버레이 1개에 order 순 직선 path와 번호 경유지를 싣는다 (L6)", () => { + const { routes } = buildAiRouteOverlay(ROUTE_POINTS, []); + + expect(routes).toHaveLength(1); + expect(routes[0].id).toBe(AI_ROUTE_OVERLAY_ID); + expect(routes[0].color).toBe(palette["theme-route"]); + expect(routes[0].waypoints.map((w) => w.seq)).toEqual([1, 2, 3]); + expect(routes[0].path).toEqual( + ROUTE_POINTS.map(({ lat, lng }) => ({ lat, lng })), + ); + }); + + it("응답 순서가 뒤섞여도 order 순으로 잇는다 (L6)", () => { + const shuffled = [ROUTE_POINTS[2], ROUTE_POINTS[0], ROUTE_POINTS[1]]; + + const { routes } = buildAiRouteOverlay(shuffled, []); + + expect(routes[0].waypoints.map((w) => w.seq)).toEqual([1, 2, 3]); + }); + + it("선택된 지점의 경유지 마커만 강조 표시가 켜진다 (L6, S8)", () => { + const { routes } = buildAiRouteOverlay(ROUTE_POINTS, [], 2); + + expect(routes[0].waypoints.map((w) => w.active === true)).toEqual([ + false, + true, + false, + ]); + }); + + it("지점 격자를 중복 없이 경로 색 셀로 만든다 (L6)", () => { + const sameGrid = [ + ROUTE_POINTS[0], + routePointOf(2, { gridId: ROUTE_POINTS[0].gridId }), + ]; + + const { cells } = buildAiRouteOverlay(sameGrid, []); + + expect(cells).toHaveLength(1); + expect(cells[0].id).toBe(ROUTE_POINTS[0].gridId); + expect(cells[0].color).toBe(palette["theme-route"]); + expect(cells[0].corners).toHaveLength(4); + }); + + it("내 점령 격자와 겹치는 셀만 빗금이 켜진다 (L6)", () => { + const { cells } = buildAiRouteOverlay(ROUTE_POINTS, [ + ROUTE_POINTS[1].gridId, + ]); + + expect(cells.map((cell) => cell.hatched === true)).toEqual([ + false, + true, + false, + ]); + }); + + it("지점이 없으면 경로도 셀도 게시하지 않는다 (L6, Q10)", () => { + expect(buildAiRouteOverlay([], ["39064_112221"])).toEqual({ + routes: [], + cells: [], + }); + }); +}); diff --git a/apps/web/src/features/ai-route/model/route-overlay.ts b/apps/web/src/features/ai-route/model/route-overlay.ts new file mode 100644 index 00000000..8b2f7e68 --- /dev/null +++ b/apps/web/src/features/ai-route/model/route-overlay.ts @@ -0,0 +1,75 @@ +import { palette } from "@fillmap/design-tokens"; +import { decodeGridCorners } from "@/entities/cell"; +import type { + RouteOverlay, + StyledCellOverlay, +} from "@/features/map-home/model/theme-overlay"; +import type { RoutePointDto } from "@/shared/api/generated"; + +/** + * 추천 지점 → 지도 게시 오버레이 파생 (MSG-488 L6). + * 순수 함수 — 렌더(naver Polyline·Marker·Polygon)는 MapCanvas 경계 안에서만 한다(RN 경계, R7). + * 오버레이 타입 3종은 `map-overlay-store`와 같은 계약(theme-overlay)을 type-only로 쓴다(Q4). + * + * [MSG-490 확장점] `path`가 walk-paths 실보행 폴리라인으로 교체된다 — 직선은 폴백으로 남는다. + */ + +/** 게시 경로 오버레이 id — AI 추천은 항상 한 줄이라 고정 id다 (코스 목록과 달리 다중 아님) */ +export const AI_ROUTE_OVERLAY_ID = "ai-route"; + +/** 오버레이 파생 입력 — 순서·좌표·격자만 쓴다 */ +type RouteStopGeometry = Pick< + RoutePointDto, + "order" | "lat" | "lng" | "gridId" +>; + +export interface AiRouteOverlay { + routes: RouteOverlay[]; + cells: StyledCellOverlay[]; +} + +/** + * 경로선 + 번호 경유지 + 지점 격자 초록 틴트 (L6). + * - 지점이 없으면 둘 다 빈 배열이다 — 이전 표시가 걷힌다 (Q10) + * - 격자는 gridId 중복을 제거한다 (한 격자에 지점 둘이면 셀 하나) + * - 내 점령 격자와 겹치면 빗금 (홈 테마 셀 규칙과 동일) + */ +export const buildAiRouteOverlay = ( + points: RouteStopGeometry[], + occupiedGridIds: string[], + selectedOrder: number | null = null, +): AiRouteOverlay => { + if (points.length === 0) return { routes: [], cells: [] }; + + const ordered = [...points].sort((a, b) => a.order - b.order); + const occupied = new Set(occupiedGridIds); + + const seen = new Set(); + const cells: StyledCellOverlay[] = []; + for (const point of ordered) { + if (seen.has(point.gridId)) continue; + seen.add(point.gridId); + cells.push({ + id: point.gridId, + corners: decodeGridCorners(point.gridId), + color: palette["theme-route"], + hatched: occupied.has(point.gridId), + }); + } + + return { + routes: [ + { + id: AI_ROUTE_OVERLAY_ID, + path: ordered.map(({ lat, lng }) => ({ lat, lng })), + waypoints: ordered.map(({ order, lat, lng }) => ({ + seq: order, + position: { lat, lng }, + active: order === selectedOrder, + })), + color: palette["theme-route"], + }, + ], + cells, + }; +}; diff --git a/apps/web/src/features/ai-route/model/route-point-view.test.ts b/apps/web/src/features/ai-route/model/route-point-view.test.ts new file mode 100644 index 00000000..1fd5c9b5 --- /dev/null +++ b/apps/web/src/features/ai-route/model/route-point-view.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { kindTag, partialBannerText, stopMetaLine } from "./route-point-view"; + +const META = { + zoneName: "서면", + zoneCell: "A-14", + regionName: "부산 부산진구", +}; + +describe("stopMetaLine — 표시명 줄 조립 (L1)", () => { + it("zoneName이 있으면 '{zoneName} {zoneCell}'과 regionName을 ' · '로 이어 붙인다 (L1)", () => { + expect(stopMetaLine(META)).toBe("서면 A-14 · 부산 부산진구"); + }); + + it("zoneName이 없으면 regionName만 남는다 (L1)", () => { + expect( + stopMetaLine({ + zoneName: null, + zoneCell: null, + regionName: "부산 부산진구", + }), + ).toBe("부산 부산진구"); + }); + + it("regionName이 없으면 구역 조각만 남는다 (L1)", () => { + expect(stopMetaLine({ ...META, regionName: null })).toBe("서면 A-14"); + }); + + it("둘 다 null이면 null이다 — 줄 자체를 생략한다 (L1)", () => { + expect( + stopMetaLine({ zoneName: null, zoneCell: null, regionName: null }), + ).toBeNull(); + }); +}); + +describe("kindTag — 지점 종류 태그 매핑 (L2)", () => { + it("서버 kind 5종을 사용자 언어 라벨로 바꾼다 (L2)", () => { + expect(kindTag("PLACE")?.label).toBe("장소"); + expect(kindTag("EVENT")?.label).toBe("행사"); + expect(kindTag("MISSION_FESTIVAL")?.label).toBe("축제"); + expect(kindTag("MISSION_POPUP")?.label).toBe("팝업"); + expect(kindTag("MISSION_COURSE")?.label).toBe("코스"); + }); + + it("행사와 축제는 같은 festival 톤을 쓴다 — 글자로만 구분한다 (승인 Q3)", () => { + expect(kindTag("EVENT")?.tone).toBe("festival"); + expect(kindTag("MISSION_FESTIVAL")?.tone).toBe("festival"); + }); + + it("서버가 준 미지 문자열이면 null이다 — 태그를 표시하지 않는다 (L2, R4)", () => { + expect(kindTag("SOMETHING_NEW")).toBeNull(); + expect(kindTag("")).toBeNull(); + }); +}); + +describe("partialBannerText — 결과 부족 배너 문구 (L4)", () => { + it("notice가 null이면 배너 문구가 없다 (L4)", () => { + expect(partialBannerText(null, 3)).toBeNull(); + }); + + it("notice가 있으면 실제 개수를 넣은 FE 고정 문구를 만든다 — 서버 notice 문자열은 쓰지 않는다 (L4)", () => { + const text = partialBannerText("서버가 준 안내 문구", 2); + + expect(text).toBe( + "조건에 맞는 곳을 2곳만 찾았어요. 문장을 바꾸거나 다른 지역에서 다시 짜 보세요", + ); + expect(text).not.toContain("서버가 준 안내 문구"); + }); + + it("0곳이어도 같은 문구를 개수만 바꿔 만든다 (L4)", () => { + expect(partialBannerText("부족", 0)).toContain("0곳만 찾았어요"); + }); +}); diff --git a/apps/web/src/features/ai-route/model/route-point-view.ts b/apps/web/src/features/ai-route/model/route-point-view.ts new file mode 100644 index 00000000..fa02aa70 --- /dev/null +++ b/apps/web/src/features/ai-route/model/route-point-view.ts @@ -0,0 +1,59 @@ +import type { RoutePointDto } from "@/shared/api/generated"; + +/** + * 추천 지점 표시 텍스트 파생 (MSG-488 L1·L2·L4). + * 순수 함수 — 지도 SDK·플랫폼·라우터에 의존하지 않는다(RN 재사용 대상). + * 색 클래스는 여기서 만들지 않는다 — tone(의미)만 내보내고 tailwind 매핑은 뷰가 소유한다. + */ + +/** kind 태그 색 계열 — 뷰가 theme-* 토큰 클래스로 옮긴다 (§6) */ +export type RouteKindTone = "place" | "festival" | "popup" | "route"; + +export interface RouteKindTag { + label: string; + tone: RouteKindTone; +} + +/** + * 서버 kind → 사용자 언어 태그 (L2). + * `EVENT`와 `MISSION_FESTIVAL`은 같은 festival 톤을 쓴다 — Figma가 "행사"만 보라로 그렸고 + * 두 종류는 글자로 구분한다(승인 Q3). + * 서버 타입이 `string`(enum 아님)이라 미지 문자열이 올 수 있다 — 그때는 태그를 표시하지 + * 않는다(null). 카드 자체는 정상 렌더된다 (R4). + */ +const KIND_TAGS: Record = { + PLACE: { label: "장소", tone: "place" }, + EVENT: { label: "행사", tone: "festival" }, + MISSION_FESTIVAL: { label: "축제", tone: "festival" }, + MISSION_POPUP: { label: "팝업", tone: "popup" }, + MISSION_COURSE: { label: "코스", tone: "route" }, +}; + +export const kindTag = (kind: string): RouteKindTag | null => + KIND_TAGS[kind] ?? null; + +/** + * 표시명 줄 조립 (L1, 승인 Q2) — `[zoneName zoneCell] · [regionName]`을 non-null만 남겨 잇는다. + * Figma 정본이 "서면 A-14 · 부산 부산진구" 두 조각을 함께 보여준다. 둘 다 없으면 null(줄 생략). + */ +export const stopMetaLine = ( + point: Pick, +): string | null => { + const zone = [point.zoneName, point.zoneCell].filter(Boolean).join(" "); + const parts = [zone, point.regionName].filter( + (part): part is string => typeof part === "string" && part.length > 0, + ); + return parts.length > 0 ? parts.join(" · ") : null; +}; + +/** + * 결과 부족 배너 문구 (L4) — 서버 `notice`는 **null 여부 신호로만** 읽고 문구는 FE 고정이다. + * 개수는 실제 렌더된 카드 수(points.length)를 그대로 넣는다. + */ +export const partialBannerText = ( + notice: string | null, + count: number, +): string | null => + notice === null + ? null + : `조건에 맞는 곳을 ${count}곳만 찾았어요. 문장을 바꾸거나 다른 지역에서 다시 짜 보세요`; diff --git a/apps/web/src/features/ai-route/model/route-request.test.ts b/apps/web/src/features/ai-route/model/route-request.test.ts new file mode 100644 index 00000000..b35f0587 --- /dev/null +++ b/apps/web/src/features/ai-route/model/route-request.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import type { Bounds } from "@/entities/cell"; +import { + MAX_ROUTE_TEXT_LENGTH, + buildRecommendBody, + canSubmit, + toViewportDto, +} from "./route-request"; + +const BOUNDS: Bounds = { + sw: { lat: 35.1521, lng: 129.0537 }, + ne: { lat: 35.1662, lng: 129.0712 }, +}; + +const SUBMITTABLE = { + text: "서면에서 밥 먹고 저녁 경기까지 동선 짜 줘", + status: "idle", + featureDisabled: false, + mapReady: true, +} as const; + +describe("toViewportDto — 뷰포트 변환 (L9)", () => { + it("Bounds{sw,ne}를 서버 뷰포트 사각형으로 바꾼다 (L9)", () => { + expect(toViewportDto(BOUNDS)).toEqual({ + minLat: 35.1521, + minLng: 129.0537, + maxLat: 35.1662, + maxLng: 129.0712, + }); + }); +}); + +describe("buildRecommendBody — 요청 본문 조립 (L9)", () => { + it("문장을 trim해 뷰포트와 함께 싣는다 (L9)", () => { + expect( + buildRecommendBody({ text: " 서면 동선 ", bounds: BOUNDS }), + ).toEqual({ text: "서면 동선", viewport: toViewportDto(BOUNDS) }); + }); + + it("bounds가 null이면 요청을 만들지 않는다 — 지도 준비 전 제출은 무시된다 (L9)", () => { + expect(buildRecommendBody({ text: "서면 동선", bounds: null })).toBeNull(); + }); + + it("trim 후 빈 문장이면 요청을 만들지 않는다 (L9)", () => { + expect(buildRecommendBody({ text: " ", bounds: BOUNDS })).toBeNull(); + }); +}); + +describe("canSubmit — 제출 가능 판정 (L8)", () => { + it("trim 후 1~500자면 제출할 수 있다 (L8)", () => { + expect(canSubmit(SUBMITTABLE)).toBe(true); + expect(canSubmit({ ...SUBMITTABLE, text: "가" })).toBe(true); + expect( + canSubmit({ ...SUBMITTABLE, text: "가".repeat(MAX_ROUTE_TEXT_LENGTH) }), + ).toBe(true); + }); + + it("공백뿐이거나 500자를 넘으면 제출할 수 없다 (L8, 경계)", () => { + expect(canSubmit({ ...SUBMITTABLE, text: " " })).toBe(false); + expect( + canSubmit({ + ...SUBMITTABLE, + text: "가".repeat(MAX_ROUTE_TEXT_LENGTH + 1), + }), + ).toBe(false); + }); + + it("요청 중(loading)에는 제출할 수 없다 (L8)", () => { + expect(canSubmit({ ...SUBMITTABLE, status: "loading" })).toBe(false); + }); + + it("기능이 꺼진 상태(14503)에서는 제출할 수 없다 (L8)", () => { + expect(canSubmit({ ...SUBMITTABLE, featureDisabled: true })).toBe(false); + }); + + // codex 리뷰 P2 — 버튼이 활성인데 submit이 조용히 early-return하면 "눌러도 안 되는 버튼"이 된다. + // canSubmit의 판정 집합을 buildRecommendBody의 성립 조건과 일치시킨다. + it("지도 준비 전(bounds null)에는 제출할 수 없다 (L8, codex P2)", () => { + expect(canSubmit({ ...SUBMITTABLE, mapReady: false })).toBe(false); + }); + + it("canSubmit이 true면 buildRecommendBody가 반드시 요청을 만든다 (L8↔L9 정합)", () => { + const submittable = canSubmit({ ...SUBMITTABLE, mapReady: true }); + expect(submittable).toBe(true); + expect( + buildRecommendBody({ text: SUBMITTABLE.text, bounds: BOUNDS }), + ).not.toBeNull(); + // 역도 성립 — 지도가 준비되지 않으면 양쪽 다 막힌다 + expect(canSubmit({ ...SUBMITTABLE, mapReady: false })).toBe(false); + expect( + buildRecommendBody({ text: SUBMITTABLE.text, bounds: null }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/features/ai-route/model/route-request.ts b/apps/web/src/features/ai-route/model/route-request.ts new file mode 100644 index 00000000..d5db21e1 --- /dev/null +++ b/apps/web/src/features/ai-route/model/route-request.ts @@ -0,0 +1,70 @@ +import type { Bounds } from "@/entities/cell"; +import type { + RouteRecommendRequestDto, + ViewportDto, +} from "@/shared/api/generated"; +import type { AiRouteStatus } from "./ai-route-store"; + +/** + * 추천 요청 조립·제출 판정 (MSG-488 L8·L9). + * 순수 함수 — 지도 SDK를 모르고 뷰포트를 플랫폼 중립 `Bounds`로 받는다(RN 경계). + * + * [MSG-489 확장점] `origin` 병합과 2차 재요청 뷰포트 규칙이 여기에 얹힌다. + */ + +/** 서버 계약 상한 (RouteRecommendRequestDto.text: trim 후 1~500자) */ +export const MAX_ROUTE_TEXT_LENGTH = 500; + +/** 지도 뷰포트 → 서버 사각형 (L9) */ +export const toViewportDto = ({ sw, ne }: Bounds): ViewportDto => ({ + minLat: sw.lat, + minLng: sw.lng, + maxLat: ne.lat, + maxLng: ne.lng, +}); + +/** + * 요청 본문 조립 (L9) — 지도가 준비되기 전(bounds null)이거나 빈 문장이면 null이다. + * null이면 호출부가 요청을 보내지 않는다(제출 무시). + */ +export const buildRecommendBody = ({ + text, + bounds, +}: { + text: string; + bounds: Bounds | null; +}): RouteRecommendRequestDto | null => { + const trimmed = text.trim(); + if (bounds === null || trimmed.length === 0) return null; + return { text: trimmed, viewport: toViewportDto(bounds) }; +}; + +/** + * 제출 가능 판정 (L8) — trim 후 1~500자 && 요청 중 아님 && 기능 켜짐 && 지도 준비됨. + * + * `mapReady`(= 뷰포트 bounds 확보)를 포함하는 이유: 이 판정이 참인데 + * `buildRecommendBody`가 null을 내면 버튼은 활성인 채 클릭이 아무 일도 하지 않는 + * "눌러도 안 되는 버튼"이 된다(지도 초기화가 느리거나 실패한 경우 — codex 리뷰 P2). + * 두 함수의 성립 조건을 같게 유지한다. + */ +export const canSubmit = ({ + text, + status, + featureDisabled, + mapReady, +}: { + text: string; + status: AiRouteStatus; + featureDisabled: boolean; + /** 요청 뷰포트를 만들 수 있는가 — `viewport-store.bounds !== null` */ + mapReady: boolean; +}): boolean => { + const length = text.trim().length; + return ( + length > 0 && + length <= MAX_ROUTE_TEXT_LENGTH && + status !== "loading" && + !featureDisabled && + mapReady + ); +}; diff --git a/apps/web/src/features/map-home/model/theme-overlay.ts b/apps/web/src/features/map-home/model/theme-overlay.ts index 4472f056..72ecb7a2 100644 --- a/apps/web/src/features/map-home/model/theme-overlay.ts +++ b/apps/web/src/features/map-home/model/theme-overlay.ts @@ -112,6 +112,8 @@ export const emphasizeCell = ( export interface RouteWaypoint { seq: number; position: LatLng; + /** 선택 강조 (MSG-488 S8) — 미지정이면 기존 코스 마커 렌더 그대로(가산 확장) */ + active?: boolean; } /** diff --git a/apps/web/src/features/map-home/model/use-occupied-grids-query.render.test.tsx b/apps/web/src/features/map-home/model/use-occupied-grids-query.render.test.tsx index 65dce5e2..7ad0ec8a 100644 --- a/apps/web/src/features/map-home/model/use-occupied-grids-query.render.test.tsx +++ b/apps/web/src/features/map-home/model/use-occupied-grids-query.render.test.tsx @@ -29,6 +29,31 @@ afterEach(() => { signOutForTest(); }); +/** + * 참조 안정성 (MSG-488 PR #104 리뷰) — `grids`는 `flatMap`이라 호출마다 새 배열이다. + * 그대로 반환하면 소비자의 `useMemo([grids])`가 매 렌더 재계산되고, AI 경로추천처럼 + * 그 파생을 **전역 오버레이 스토어에 effect로 게시**하는 화면에서는 무관한 리렌더 + * (예: textarea 타이핑)마다 clear → 재게시가 돌아 naver Marker가 재생성된다. + */ +describe("useOccupiedGridsQuery — 참조 안정성", () => { + it("데이터가 그대로면 grids 참조가 재렌더 사이에 유지된다", async () => { + stubGrids(); + + const { result, rerender } = renderHook( + () => useOccupiedGridsQuery(SEOMYEON_VIEWPORT), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + const first = result.current.grids; + + rerender(); + rerender(); + + expect(result.current.grids).toBe(first); + }); +}); + describe("useOccupiedGridsQuery — 실요청 형태", () => { it("첫 페이지 요청에 cursor 파라미터를 싣지 않는다 — 빈 cursor는 서버가 400으로 거부한다", async () => { const received = stubGrids(); diff --git a/apps/web/src/features/map-home/model/use-occupied-grids-query.ts b/apps/web/src/features/map-home/model/use-occupied-grids-query.ts index 60f4dc42..11db15bf 100644 --- a/apps/web/src/features/map-home/model/use-occupied-grids-query.ts +++ b/apps/web/src/features/map-home/model/use-occupied-grids-query.ts @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { useInfiniteQuery } from "@tanstack/react-query"; import type { Bounds } from "@/entities/cell"; import { useAuthStore } from "@/features/auth/model/auth-store"; @@ -58,5 +59,15 @@ export const useOccupiedGridsQuery = (bounds: Bounds | null) => { ...mapQueryPolicy, }); - return { ...query, grids: flattenGridPages(query.data?.pages) }; + // `flatMap`은 호출마다 새 배열이라 그대로 반환하면 소비자의 `useMemo([grids])`가 + // 매 렌더 재계산된다. AI 경로추천은 그 파생을 **전역 오버레이 스토어에 effect로 게시**해 + // 무관한 리렌더(타이핑 등)마다 clear → 재게시가 돌고 naver Marker가 재생성됐다 + // (MSG-488 PR #104 리뷰). react-query는 데이터 불변 시 `data`를 같은 참조로 주므로 + // 여기서 한 번 memo하면 소비자 전원이 안정된 참조를 받는다 (MapShell:149도 함께 해소). + const grids = useMemo( + () => flattenGridPages(query.data?.pages), + [query.data?.pages], + ); + + return { ...query, grids }; }; diff --git a/apps/web/src/features/profile/model/profile-image.test.ts b/apps/web/src/features/profile/model/profile-image.test.ts index 92373de3..80a6af77 100644 --- a/apps/web/src/features/profile/model/profile-image.test.ts +++ b/apps/web/src/features/profile/model/profile-image.test.ts @@ -125,6 +125,7 @@ describe("mergeProfileImage — 캐시 병합 (기준 7)", () => { profileImageUrl: null, createdAt: "2026-01-12T09:00:00", locationConsent: false, + role: "USER", }; const confirmed: UserProfileResponseDto = { email: "server-account@kakao.com", @@ -132,6 +133,7 @@ describe("mergeProfileImage — 캐시 병합 (기준 7)", () => { profileImageUrl: "https://cdn.fillmap.kr/profile/42.png", createdAt: "2026-08-11T09:00:00", locationConsent: false, + role: "USER", }; it("profileImageUrl만 확정 응답 값으로 갱신된 새 객체를 만든다", () => { diff --git a/apps/web/src/features/profile/model/upload-profile-image.test.ts b/apps/web/src/features/profile/model/upload-profile-image.test.ts index ffc887d4..3b869820 100644 --- a/apps/web/src/features/profile/model/upload-profile-image.test.ts +++ b/apps/web/src/features/profile/model/upload-profile-image.test.ts @@ -20,6 +20,7 @@ const CONFIRMED: UserProfileResponseDto = { profileImageUrl: "https://cdn.fillmap.kr/profile/1.png", createdAt: "2026-08-11T09:00:00", locationConsent: false, + role: "USER", }; const makePorts = ( diff --git a/apps/web/src/features/profile/model/use-profile-query.test.ts b/apps/web/src/features/profile/model/use-profile-query.test.ts index b55cc19e..171df777 100644 --- a/apps/web/src/features/profile/model/use-profile-query.test.ts +++ b/apps/web/src/features/profile/model/use-profile-query.test.ts @@ -22,6 +22,7 @@ const SERVER_ME = { profileImageUrl: "https://cdn.fillmap.test/profile/me.png", createdAt: "2026-05-02T09:00:00", locationConsent: false, + role: "USER" as const, }; const createHarness = () => { diff --git a/apps/web/src/features/profile/ui/profile-edit-modal.smoke.test.tsx b/apps/web/src/features/profile/ui/profile-edit-modal.smoke.test.tsx index 45633ed8..a04722eb 100644 --- a/apps/web/src/features/profile/ui/profile-edit-modal.smoke.test.tsx +++ b/apps/web/src/features/profile/ui/profile-edit-modal.smoke.test.tsx @@ -107,6 +107,7 @@ const renderModal = (profile: ProfileData = PROFILE) => { profileImageUrl: profile.profileImageUrl, createdAt: profile.joinedAt, locationConsent: false, + role: "USER", }, }); render(); diff --git a/apps/web/src/pages/ai-route/AiRoutePage.tsx b/apps/web/src/pages/ai-route/AiRoutePage.tsx new file mode 100644 index 00000000..03fe4a09 --- /dev/null +++ b/apps/web/src/pages/ai-route/AiRoutePage.tsx @@ -0,0 +1,152 @@ +import { useCallback, useMemo } from "react"; +import { useRouteRecommend } from "@/features/ai-route/api/use-route-recommend"; +import { useAiRouteStore } from "@/features/ai-route/model/ai-route-store"; +import { partialBannerText } from "@/features/ai-route/model/route-point-view"; +import { + buildRecommendBody, + canSubmit, +} from "@/features/ai-route/model/route-request"; +import { useLoginModalStore } from "@/features/auth/model/login-modal-store"; +import { useOccupiedGridsQuery } from "@/features/map-home/model/use-occupied-grids-query"; +import { useViewportStore } from "@/features/map-home/model/viewport-store"; +import { useRegionPanelStore } from "@/features/region/model/region-panel-store"; +import { formatDocumentTitle } from "@/shared/document-title"; +import { useDocumentTitle } from "@/shared/use-document-title"; +import { useMapShell } from "@/widgets/map-shell/use-map-shell"; +import { RouteEmptyState } from "./ui/RouteEmptyState"; +import { RouteErrorNotice } from "./ui/RouteErrorNotice"; +import { RouteInputCard } from "./ui/RouteInputCard"; +import { RouteLoadingList } from "./ui/RouteLoadingList"; +import { RoutePartialBanner } from "./ui/RoutePartialBanner"; +import { RouteResultHeader } from "./ui/RouteResultHeader"; +import { RouteResultList } from "./ui/RouteResultList"; +import { RouteSuggestionChips } from "./ui/RouteSuggestionChips"; +import { useAiRouteOverlayPublish } from "./ui/use-ai-route-overlay-publish"; + +/** + * AI 경로추천 패널 (MSG-488) — 문장 한 줄 + 현재 뷰포트로 추천을 1회 요청하고, + * 네 상태(입력 대기·로딩·결과·결과 부족)와 지도 표시(번호 마커·격자 틴트·직선)를 그린다. + * + * 셸은 도감·프로필과 같은 388px aside 관례다 — `SectionPanel`은 제목 헤더를 강제하는데 + * Figma 패널에는 제목 바가 없어 쓰지 않는다(§3-1). + * 상태 정본은 `useAiRouteStore`라 섹션을 떠났다 돌아와도 입력·결과·지도 표시가 복원된다 (S11). + */ +/** 추천 근거 각주 (Figma 15666:12621·12855) — 로딩·결과에 공통으로 붙는다 */ +const RESULT_FOOTNOTE = + "축제, 팝업, 코스, 행사 정보와 장소 검색 결과로만 추천해요"; + +export const AiRoutePage = () => { + useDocumentTitle(formatDocumentTitle("AI 경로추천")); + + const { moveTo } = useMapShell(); + const openLoginModal = useLoginModalStore((s) => s.openModal); + + const text = useAiRouteStore((s) => s.text); + const status = useAiRouteStore((s) => s.status); + const points = useAiRouteStore((s) => s.points); + const notice = useAiRouteStore((s) => s.notice); + const selectedOrder = useAiRouteStore((s) => s.selectedOrder); + const errorNotice = useAiRouteStore((s) => s.errorNotice); + const featureDisabled = useAiRouteStore((s) => s.featureDisabled); + const setText = useAiRouteStore((s) => s.setText); + const selectOrder = useAiRouteStore((s) => s.selectOrder); + + // 요청 뷰포트는 "지금 보이는 지도 범위"(viewport-store), 점령 격자는 확정 영역 기준 + // 기존 쿼리를 그대로 재사용한다 — 같은 키라 캐시 히트로 추가 요청이 없다 (§3-1) + const bounds = useViewportStore((s) => s.bounds); + const committedBounds = useRegionPanelStore((s) => s.committedBounds); + const { grids } = useOccupiedGridsQuery(committedBounds); + const occupiedGridIds = useMemo( + () => grids.map((grid) => grid.gridId), + [grids], + ); + + const { mutate } = useRouteRecommend({ onLoginRequired: openLoginModal }); + const submit = useCallback(() => { + // 지도 준비 전(bounds null)이거나 빈 문장이면 요청을 만들지 않는다 (L9) + const body = buildRecommendBody({ text, bounds }); + if (body === null) return; + mutate(body); + }, [text, bounds, mutate]); + + // 카드 클릭 — 선택 강조 + 그 지점으로 지도 이동(줌은 그대로). fitBounds·zoomTo는 489 몫 + const selectFromCard = useCallback( + (order: number) => { + selectOrder(order); + const point = points.find((item) => item.order === order); + if (point) moveTo({ lat: point.lat, lng: point.lng }); + }, + [points, selectOrder, moveTo], + ); + // 마커 클릭 — 선택만 바꾼다(카드 스크롤은 RouteResultList가 반응). 지도는 이미 그 자리다 + const selectFromMarker = useCallback( + (order: number) => selectOrder(order), + [selectOrder], + ); + + useAiRouteOverlayPublish({ + points, + occupiedGridIds, + selectedOrder, + onWaypointSelect: selectFromMarker, + }); + + // [MSG-489 확장점] mentionedArea 자동 이동 훅을 여기서 마운트한다. + + const loading = status === "loading"; + const bannerText = partialBannerText(notice, points.length); + + return ( + + ); +}; diff --git a/apps/web/src/pages/ai-route/ai-route-panel.smoke.test.tsx b/apps/web/src/pages/ai-route/ai-route-panel.smoke.test.tsx new file mode 100644 index 00000000..6e421135 --- /dev/null +++ b/apps/web/src/pages/ai-route/ai-route-panel.smoke.test.tsx @@ -0,0 +1,269 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter, Outlet, Route, Routes } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { RequireAuth } from "@/app/RequireAuth"; +import { useAiRouteStore } from "@/features/ai-route/model/ai-route-store"; +import { useLoginModalStore } from "@/features/auth/model/login-modal-store"; +import { useViewportStore } from "@/features/map-home/model/viewport-store"; +import { useMapOverlayStore } from "@/widgets/map-shell/map-overlay-store"; +import type { MapShellContext } from "@/widgets/map-shell/use-map-shell"; +import { envelopeResponse, errorEnvelope } from "@/test/envelope-response"; +import { signInForTest, signOutForTest } from "@/test/auth-session"; +import { ROUTE_POINTS } from "@/test/route-points"; +import { stubFetch } from "@/test/stub-fetch"; +import { AiRoutePage } from "./AiRoutePage"; + +/** + * AI 경로추천 패널 스모크 (MSG-488) — 로그인 게이트(L12)와 4상태·카드↔마커 배선(S2~S8)의 + * 사용자 관찰 가능한 계약만 고정한다. 문구·값 분기는 모델 유닛(route-*)이 덮는다. + */ +const moveTo = vi.fn(); + +/** MapShell이 Outlet context로 주는 지도 명령 API — 패널은 moveTo만 쓴다 */ +const mapShellContext = { + moveTo, + zoomIn: vi.fn(), + zoomOut: vi.fn(), + zoomTo: vi.fn(), + fitBounds: vi.fn(), + locate: vi.fn(), +} satisfies MapShellContext; + +const renderPanel = () => + render( + + + + }> + + + + } + /> + + 지도 홈

} /> +
+
+
, + ); + +const recommendResponse = (notice: string | null = null) => + envelopeResponse({ points: ROUTE_POINTS, notice, mentionedArea: null }); + +const submitButton = () => + screen.getByRole("button", { name: /동선 짜기|다시 짜기|짜는 중/ }); + +const textarea = () => + screen.getByLabelText("하고 싶은 일 한 문장") as HTMLTextAreaElement; + +/** 문장 입력 → 제출 — 결과·실패 케이스가 공유하는 진입 동작 */ +const submitText = (value: string) => { + fireEvent.change(textarea(), { target: { value } }); + fireEvent.click(submitButton()); +}; + +beforeEach(() => { + signInForTest(); + useLoginModalStore.setState({ open: false }); + useAiRouteStore.setState(useAiRouteStore.getInitialState(), true); + useMapOverlayStore.setState(useMapOverlayStore.getInitialState(), true); + // 지도 준비 완료 — 요청 뷰포트의 근원 (부산 서면) + useViewportStore.setState({ + bounds: { + sw: { lat: 35.1521, lng: 129.0537 }, + ne: { lat: 35.1662, lng: 129.0712 }, + }, + }); + moveTo.mockClear(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("로그인 게이트 (L12)", () => { + it("비로그인으로 직접 진입하면 패널 콘텐츠가 렌더되지 않고 로그인 모달이 열린다 (L12)", () => { + signOutForTest(); + + renderPanel(); + + expect(screen.queryByRole("button", { name: "동선 짜기" })).toBeNull(); + expect(screen.getByText("지도 홈")).toBeTruthy(); + expect(useLoginModalStore.getState().open).toBe(true); + }); +}); + +describe("입력 대기 (S2·S3)", () => { + it("안내 문구와 예시 칩 2개가 뜨고 빈 입력이라 제출 버튼이 비활성이다 (S2)", () => { + renderPanel(); + + expect( + screen.getByText("지금 보이는 지도 범위에서 동선을 짜 드려요"), + ).toBeTruthy(); + expect(screen.getByText("이렇게 물어보세요")).toBeTruthy(); + expect( + screen.getByRole("button", { + name: "서면에서 밥 먹고 저녁 경기까지 동선 짜 줘", + }), + ).toBeTruthy(); + expect( + screen.getByRole("button", { name: "지금 하는 축제 위주로 반나절 코스" }), + ).toBeTruthy(); + expect( + screen + .getByRole("button", { name: "동선 짜기" }) + .hasAttribute("disabled"), + ).toBe(true); + }); + + it("예시 칩을 누르면 그 문장이 입력창에 채워지고 제출 버튼이 활성화된다 (S3)", () => { + renderPanel(); + + fireEvent.click( + screen.getByRole("button", { name: "지금 하는 축제 위주로 반나절 코스" }), + ); + + expect(textarea().value).toBe("지금 하는 축제 위주로 반나절 코스"); + expect( + screen + .getByRole("button", { name: "동선 짜기" }) + .hasAttribute("disabled"), + ).toBe(false); + }); +}); + +describe("요청 → 결과 (S4·S5·S6·S9)", () => { + it("제출하면 상태줄이 '동선 찾는 중'이 되고 버튼이 '짜는 중…'으로 잠긴다 (S4)", async () => { + stubFetch( + () => new Promise(() => undefined) as unknown as Response, + ); + renderPanel(); + + submitText("서면에서 밥 먹고 저녁 경기까지"); + + await waitFor(() => + expect(screen.getByText("· 동선 찾는 중")).toBeTruthy(), + ); + expect( + screen.getByRole("button", { name: "짜는 중…" }).hasAttribute("disabled"), + ).toBe(true); + }); + + it("결과가 오면 지점 카드와 개수 상태줄이 뜨고 지도에 경로·격자가 게시된다 (S5·S6)", async () => { + stubFetch(() => recommendResponse()); + renderPanel(); + + submitText("서면 동선"); + + await waitFor(() => expect(screen.getByText("· 3곳")).toBeTruthy()); + expect(screen.getByRole("button", { name: /서면 지점 1/ })).toBeTruthy(); + expect(useMapOverlayStore.getState().routes).toHaveLength(1); + expect(useMapOverlayStore.getState().cells).toHaveLength(3); + expect(useMapOverlayStore.getState().onRouteWaypointClick).toBeTruthy(); + }); + + it("notice가 있으면 실제 카드 수를 담은 부족 배너가 리스트 위에 뜬다 (S9)", async () => { + stubFetch(() => recommendResponse("후보가 부족합니다")); + renderPanel(); + + submitText("서면 동선"); + + await waitFor(() => + expect(screen.getByText(/조건에 맞는 곳을 3곳만 찾았어요/)).toBeTruthy(), + ); + expect(screen.queryByText("후보가 부족합니다")).toBeNull(); + }); +}); + +describe("카드↔마커 연동 (S8)", () => { + it("카드를 누르면 그 지점으로 지도가 이동하고 해당 마커가 강조된다 (S8)", async () => { + stubFetch(() => recommendResponse()); + renderPanel(); + + submitText("서면 동선"); + await waitFor(() => expect(screen.getByText("· 3곳")).toBeTruthy()); + + fireEvent.click(screen.getByRole("button", { name: /서면 지점 2/ })); + + expect(moveTo).toHaveBeenCalledWith({ + lat: ROUTE_POINTS[1].lat, + lng: ROUTE_POINTS[1].lng, + }); + await waitFor(() => + expect( + useMapOverlayStore + .getState() + .routes[0].waypoints.map((waypoint) => waypoint.active === true), + ).toEqual([false, true, false]), + ); + }); + + it("마커를 누르면 지도 이동 없이 그 카드가 선택 표시된다 (S8)", async () => { + stubFetch(() => recommendResponse()); + renderPanel(); + + submitText("서면 동선"); + await waitFor(() => expect(screen.getByText("· 3곳")).toBeTruthy()); + + useMapOverlayStore.getState().onRouteWaypointClick?.("ai-route", 3); + + await waitFor(() => + expect( + screen + .getByRole("button", { name: /서면 지점 3/ }) + .getAttribute("aria-pressed"), + ).toBe("true"), + ); + expect(moveTo).not.toHaveBeenCalled(); + }); +}); + +describe("실패 경로 (S10)", () => { + it("14503(기능 꺼짐)은 재시도 행 없이 문구만 남기고 제출 버튼이 계속 비활성이다 (S10)", async () => { + stubFetch(() => errorEnvelope(14503, "기능이 꺼져 있습니다", 503)); + renderPanel(); + + submitText("서면 동선"); + + await waitFor(() => + expect(screen.getByText("지금은 경로 추천을 쓸 수 없어요")).toBeTruthy(), + ); + expect(screen.queryByRole("button", { name: "다시 시도" })).toBeNull(); + expect( + screen + .getByRole("button", { name: "다시 짜기" }) + .hasAttribute("disabled"), + ).toBe(true); + // 입력 문장은 어떤 실패에서도 지워지지 않는다 (§1-4) + expect(textarea().value).toBe("서면 동선"); + }); + + it("14400(뷰포트)은 재시도 행이 뜨고 제출 버튼이 살아 있다 (S10)", async () => { + stubFetch(() => errorEnvelope(14400, "뷰포트가 너무 넓습니다", 400)); + renderPanel(); + + submitText("서면 동선"); + + await waitFor(() => + expect( + screen.getByText( + "지도를 조금 더 확대하거나 다른 곳으로 옮긴 뒤 다시 시도해 주세요", + ), + ).toBeTruthy(), + ); + expect(screen.getByRole("button", { name: "다시 시도" })).toBeTruthy(); + expect( + screen + .getByRole("button", { name: "다시 짜기" }) + .hasAttribute("disabled"), + ).toBe(false); + }); +}); diff --git a/apps/web/src/pages/ai-route/ui/RouteEmptyState.tsx b/apps/web/src/pages/ai-route/ui/RouteEmptyState.tsx new file mode 100644 index 00000000..d84aae29 --- /dev/null +++ b/apps/web/src/pages/ai-route/ui/RouteEmptyState.tsx @@ -0,0 +1,19 @@ +import { Sparkles } from "lucide-react"; + +/** + * 입력 대기 빈 상태 (Figma 15666:12402) — theme-route 10% 원 안 sparkles + 2줄 안내. + * 패널 세로 중앙 정렬은 부모(AiRoutePage 본문)가 flex-1로 맡는다. + */ +export const RouteEmptyState = () => ( +
+ + + +

+ 지금 보이는 지도 범위에서 동선을 짜 드려요 +

+

+ 적은 문장과 지도 범위만 사용해요 +

+
+); diff --git a/apps/web/src/pages/ai-route/ui/RouteErrorNotice.tsx b/apps/web/src/pages/ai-route/ui/RouteErrorNotice.tsx new file mode 100644 index 00000000..c273e8c0 --- /dev/null +++ b/apps/web/src/pages/ai-route/ui/RouteErrorNotice.tsx @@ -0,0 +1,23 @@ +import { RetryNotice } from "@fillmap/ui-web"; +import type { RouteErrorNotice as RouteErrorNoticeData } from "@/features/ai-route/model/route-error"; + +/** + * 실패 안내 (§1-4) — 재시도 가능한 실패는 공용 `RetryNotice`(ui-web) 그대로, + * 14503(기능 꺼짐)은 재시도 행 없이 문구만 남긴다(제출 버튼도 세션 동안 비활성). + */ +interface RouteErrorNoticeProps { + notice: RouteErrorNoticeData; + onRetry: () => void; +} + +export const RouteErrorNotice = ({ + notice, + onRetry, +}: RouteErrorNoticeProps) => { + if (notice.message === null) return null; + return notice.retryable ? ( + + ) : ( +

{notice.message}

+ ); +}; diff --git a/apps/web/src/pages/ai-route/ui/RouteInputCard.tsx b/apps/web/src/pages/ai-route/ui/RouteInputCard.tsx new file mode 100644 index 00000000..57ddde4e --- /dev/null +++ b/apps/web/src/pages/ai-route/ui/RouteInputCard.tsx @@ -0,0 +1,62 @@ +import { Button } from "@fillmap/ui-web"; +import { MAX_ROUTE_TEXT_LENGTH } from "@/features/ai-route/model/route-request"; + +/** + * 입력 카드 (Figma 15666:12402·12855) — 테두리 없는 멀티라인 textarea + 하단 액션 행. + * ui-web `Input`은 자체 h-12·보더·단일행이라 카드 안에 넣으면 이중 테두리가 된다(§3-3). + * 500자는 서버 계약 상한이라 하드 컷으로 막는다 — 카운터는 두지 않는다(Q11). + */ +interface RouteInputCardProps { + text: string; + onChange: (text: string) => void; + onSubmit: () => void; + /** 제출 가능 여부 — 판정은 route-request.canSubmit(순수) 소유 */ + canSubmit: boolean; + /** 버튼 문구 — 입력 대기 "동선 짜기" / 결과·실패 "다시 짜기" (로딩은 아래에서 덮는다) */ + submitLabel: string; + loading: boolean; +} + +export const RouteInputCard = ({ + text, + onChange, + onSubmit, + canSubmit, + submitLabel, + loading, +}: RouteInputCardProps) => ( +
+ +