# 주선AI API

주변 모임 매칭 앱 **주선AI**(`com.croninc.meetup`)의 백엔드 API 문서입니다.

- **Base URL** : `https://api.meetup.croninc.com`
- **버전** : `2.0.0` (경로 접두사 `/api/v1`)
- **형식** : 요청·응답 모두 `application/json; charset=utf-8`
- **시간** : 모든 시각은 ISO 8601 UTC 문자열 (`2026-08-25T10:30:00+00:00`)
- **대화형 문서** : [`/swagger`](https://api.meetup.croninc.com/swagger) · [`/redoc`](https://api.meetup.croninc.com/redoc)

---

## 1. 인증

### 토큰 방식

로그인하면 **액세스 토큰**(7일)과 **리프레시 토큰**(60일)을 함께 받습니다.
인증이 필요한 API 는 헤더에 액세스 토큰을 담아 보냅니다.

```
Authorization: Bearer <access_token>
```

- 액세스 토큰이 만료되면 `POST /api/v1/auth/refresh` 로 새 토큰 쌍을 받습니다.
  이때 기존 토큰 쌍은 폐기됩니다(리프레시 토큰 재사용 방지).
- 로그아웃·회원탈퇴 시 서버에 저장된 세션이 삭제되어, 남아 있는 토큰도 즉시 무효가 됩니다.

### 세 가지 로그인 방법

| 방법 | 엔드포인트 | 설명 |
|---|---|---|
| 게스트 로그인 | `POST /api/v1/auth/guest` | 가입 없이 즉시 이용. `device_id` 를 보내면 같은 기기에서 같은 계정을 재사용 |
| 휴대폰 인증 | `POST /api/v1/auth/phone/firebase` | Firebase 휴대폰 인증(문자)으로 받은 ID 토큰을 검증. 처음 인증한 번호면 자동 가입 |
| 이메일 로그인 | `POST /api/v1/auth/email/signup` · `/login` | 이메일 + 비밀번호(8자 이상) |

게스트로 쓰다가 정식 계정으로 바꾸려면 `POST /api/v1/auth/email/link` 를 씁니다.
기존 활동 기록(참여한 모임, 관심 목록)이 그대로 유지됩니다.

---

## 2. 공통 규약

### 오류 형식

```json
{ "detail": "이메일 또는 비밀번호가 올바르지 않습니다." }
```

| 상태 코드 | 의미 |
|---|---|
| `400` | 잘못된 요청 파라미터 |
| `401` | 토큰 없음·만료·폐기, 로그인 실패 |
| `403` | 권한 없음 (예: 남의 모임 수정) |
| `404` | 리소스를 찾을 수 없음 |
| `409` | 상태 충돌 (중복 가입, 정원 초과, 중복 참여) |
| `422` | 요청 본문 검증 실패 |
| `500` | 서버 내부 오류 |

### 목록 응답

목록은 `{ "count": n, "items": [...] }` 형태로 통일되어 있습니다.

---

## 3. 시스템

### GET /api/v1/health

```bash
curl https://api.meetup.croninc.com/api/v1/health
```

```json
{
  "status": "ok",
  "service": "meetup",
  "version": "2.0.0",
  "sms_provider": "solapi",
  "mail_provider": "aws_ses",
  "time": "2026-08-24T14:42:00+00:00"
}
```

---

## 4. 인증 API

### POST /api/v1/auth/guest

게스트 로그인. **인증 불필요.**

| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| `device_id` | string | N | 기기 식별자. 같은 값이면 같은 게스트 계정 재사용 |
| `nickname` | string | N | 지정하지 않으면 `지금게스트1628` 처럼 자동 생성 |

```bash
curl -X POST https://api.meetup.croninc.com/api/v1/auth/guest \
  -H 'Content-Type: application/json' \
  -d '{"device_id": "ios-6C1A2F80"}'
```

**응답 `200 OK`**

```json
{
  "access_token": "eyJhbGciOiJIUzI1NiIs...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 604800,
  "user": {
    "user_id": "u_e37ef3331c50474c",
    "nickname": "지금게스트1628",
    "email": null,
    "is_guest": true,
    "gender": "unknown",
    "interests": [],
    "created_at": "2026-08-24T14:40:12+00:00"
  }
}
```

### POST /api/v1/auth/email/signup

이메일 회원가입. **인증 불필요.** 성공 시 `201 Created`.

| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| `email` | string | Y | 이메일 형식 |
| `password` | string | Y | 8~72자 |
| `nickname` | string | N | 생략 시 이메일 아이디 부분 사용 |

```bash
curl -X POST https://api.meetup.croninc.com/api/v1/auth/email/signup \
  -H 'Content-Type: application/json' \
  -d '{"email": "hong@example.com", "password": "meetup1234", "nickname": "홍길동"}'
```

이미 가입된 이메일이면 `409 Conflict`.

### POST /api/v1/auth/email/login

이메일 로그인. **인증 불필요.** 응답은 게스트 로그인과 같은 토큰 묶음입니다.

**처음 보는 이메일이면 그 자리에서 계정을 만들어 로그인시킵니다**(자동 가입).
닉네임은 이메일 아이디 부분으로 자동 지정되며, 이때 비밀번호가 8자 미만이면 `400` 입니다.
이미 있는 이메일인데 비밀번호가 틀리면 `401` 입니다.

```bash
curl -X POST https://api.meetup.croninc.com/api/v1/auth/email/login \
  -H 'Content-Type: application/json' \
  -d '{"email": "hong@example.com", "password": "meetup1234"}'
```

### POST /api/v1/auth/email/link

**인증 필요.** 게스트 계정에 이메일·비밀번호를 붙여 정식 계정으로 승격합니다.
이미 이메일 계정이면 `400`, 이메일이 중복이면 `409`.

### POST /api/v1/auth/phone/firebase

**휴대폰 인증 로그인(권장 경로).** 앱이 Firebase 휴대폰 인증으로 문자 확인을 마치고 받은
ID 토큰을 보내면, 서버가 구글 공개키로 서명을 검증하고 `phone_number` 클레임으로
계정을 찾거나 새로 만듭니다. **인증 불필요.**

```json
{ "id_token": "eyJhbGciOiJSUzI1NiIs...", "nickname": "홍길동" }
```

응답은 다른 로그인과 같은 토큰 묶음입니다. 처음 인증한 번호면 계정이 생성되고
닉네임은 `모임5678`처럼 번호 뒷자리로 자동 지정됩니다(요청에 `nickname` 을 주면 그 값 사용).

| 오류 | 의미 |
|---|---|
| `401` | ID 토큰이 유효하지 않거나 만료됨 |
| `400` | 토큰에 휴대폰 번호가 없음(문자 인증이 아닌 로그인) |

> 문자 발송·재발송·차단은 Firebase 가 처리하므로 서버에는 별도 SMS 설정이 필요 없습니다.
> 서버 환경변수 `MEETUP_FIREBASE_PROJECT_ID` 가 앱의 Firebase 프로젝트와 같아야 합니다.

### POST /api/v1/auth/phone/request-code · /verify · /link

**SMS 공급자를 직접 붙일 때 쓰는 대체 경로**입니다. Firebase 를 쓰는 지금은 앱에서 사용하지 않지만,
자체 발송으로 바꾸고 싶을 때를 대비해 남겨둔 구현입니다.

| 메서드 | 경로 | 설명 |
|---|---|---|
| `POST` | `/api/v1/auth/phone/request-code` | 인증번호 발송 (`{"phone": "01012345678"}`). 응답 `resend_after` 가 재요청 대기시간(초)이며 앱은 이 값으로 카운트다운합니다 — 보통 60, **심사·테스트 번호는 5**(문자가 안 나가므로, 2026-09-17). 대기 중 다시 부르면 `429` |
| `POST` | `/api/v1/auth/phone/verify` | 인증번호 확인 후 로그인/가입 (`{"phone", "code", "nickname?"}`) |
| `POST` | `/api/v1/auth/phone/link` | 로그인한 계정에 번호 연결 (**인증 필요**) |

- 인증번호는 6자리, **3분** 유효. 서버에는 HMAC-SHA256 해시로만 저장됩니다.
- 재발송은 **60초** 대기, 10분 동안 최대 **5회**. 입력 시도는 **5회** 초과 시 잠깁니다(`429`).
- 발송 공급자는 환경변수 `MEETUP_SMS_PROVIDER` 로 고릅니다: `solapi` · `aligo` · `ncp_sens` · `aws_sns` · `console`(기본, 실제 발송 없음).
  `console` 상태에서 실번호로 요청하면 `503` 을 돌려줍니다.
- `MEETUP_SMS_TEST_NUMBERS="01000000000:123456"` 로 심사용 고정번호를 지정할 수 있습니다(문자를 보내지 않고 그 코드로 통과).

### POST /api/v1/auth/refresh

**인증 불필요** (본문의 리프레시 토큰으로 검증).

```json
{ "refresh_token": "eyJhbGciOiJIUzI1NiIs..." }
```

### POST /api/v1/auth/logout

**인증 필요.** 현재 세션의 액세스·리프레시 토큰을 서버에서 삭제합니다.

```json
{ "ok": true, "message": "로그아웃되었습니다." }
```

---

## 5. 사용자 API

모두 **인증 필요**.

### GET /api/v1/users/me

내 정보를 반환합니다.

응답의 **`profile_completed`** 는 생년월일·성별·닉네임이 모두 채워졌는지를 나타냅니다. 단, **탈퇴 후 재가입한 계정은 서버가 생년월일·성별을 미리 채워도 온보딩(프로필 저장)을 마치기 전까지 `false`** 입니다(2026-09-17) — 그래서 앱은 처음 가입처럼 프로필 입력부터 보여 주고, `PATCH /api/v1/users/me` 로 한 번 저장하면 `true` 가 됩니다.
앱은 로그인 직후 이 값이 `false` 인 정식 계정을 **프로필 입력 화면**으로 보냅니다
(게스트는 대상이 아닙니다).

```json
{
  "user_id": "u_9f2c41ab77de",
  "nickname": "수아",
  "email": "sua@example.com",
  "phone": null,
  "is_guest": false,
  "age": 28,
  "birth_date": "1998-04-21",
  "gender": "female",
  "profile_completed": true
}
```

### PATCH /api/v1/users/me

프로필 수정. 보낸 필드만 바뀝니다.

| 필드 | 타입 | 설명 |
|---|---|---|
| `nickname` | string | 1~20자. **한 달(30일)에 한 번만** 바꿀 수 있어 그 안에 다른 값이면 `400`("…○년 ○월 ○일부터 다시 바꿀 수 있어요"). 같은 닉네임을 다시 보내는 것과 프로필을 처음 채우는 온보딩(`profile_completed=false`)은 세지 않음. 응답 `nickname_changeable_at`(ISO, 지금 가능하면 `null`)으로 잠김 여부를 알 수 있음 (2026-09-15) |
| `mbti` | string | MBTI 16유형(`ENFP` 등). 빈 문자열이면 선택 안 함 |
| `height` | integer | 키(cm) 100~250 |
| `drinking` | string | `none`(안 함) / `sometimes`(가끔) / `often`(자주) |
| `smoking` | string | `none`(비흡연) / `sometimes`(가끔) / `often`(흡연) |
| `job` | string | 직업, 30자 이내 |
| `school` | string | 졸업학교, 40자 이내 |
| `residence_region` | string | 거주지역 (지역 검색 API 로 고른 값) |
| `activity_region` | string | 활동지역 |
| `birth_date` | string | 생년월일 `YYYY-MM-DD`. 보내면 `age` 를 서버가 계산해 함께 갱신한다. 만 14세 미만이면 `400`. **이미 생년월일이 정해진 계정은 바꿀 수 없어** 다른 값이면 `400` |
| `birth_time` | string | 태어난 시간 `HH:MM` (선택, 2026-09-20). 가입 직후 프로필 입력에서 생년월일 다음에 받으며, 모르면 비워 둔다. 빈 문자열을 보내면 지움. 응답 `birth_time` 으로 돌려준다 |
| `age` | integer | 14~100 (보통은 `birth_date` 로 대신한다). 생년월일이 정해진 계정은 바꿀 수 없어 다른 값이면 `400` |
| `gender` | string | `male` / `female` / `other` / `unknown`. **이미 `male`·`female` 로 정해진 계정은 바꿀 수 없어** 다른 값이면 `400` |
| `bio` | string | 자기소개, **1500자 이내** |
| `status_message` | string | 80자 이내. 주변찾기 목록에 노출 |
| `interests` | string[] | 최대 20개 |
| `region` | string | 활동 지역 |

### PUT /api/v1/users/me/location

현재 위치 저장. **주변찾기에 노출되려면 반드시 먼저 호출해야 합니다.**

```json
{ "lat": 37.4979, "lng": 127.0276, "region": "서울시 강남구 역삼동" }
```

### GET · PUT /api/v1/users/me/bank-account

**내 계좌정보** (더보기 > 내 계좌정보, 2026-09-16). 소개사례금을 받을 계좌를 저장해 두고 채팅방에서 카드로 보냅니다. **본인만 조회·수정**할 수 있고, 카드로 직접 보내기 전에는 누구에게도 보이지 않습니다.

| 필드 | 타입 | 설명 |
|---|---|---|
| `bank` | string | 은행명 (1~20자) |
| `number` | string | 계좌번호 (4~30자, 숫자와 `-` 만) |
| `holder` | string | 예금주 (1~20자) |

응답은 위 세 항목에 `configured`(저장한 적 있는지)와 `updated_at` 을 더해 돌려줍니다.

`POST /api/v1/chat/rooms/{room_id}/messages` 의 `profile_card` 에 `{ "kind": "bank" }` 를 보내면 계좌 카드가 전송됩니다(계좌를 저장하지 않았으면 `422`). 미리보기 문구에는 은행명과 예금주만 들어가고 **계좌번호는 카드 안에만** 담깁니다.

### PUT /api/v1/users/me/settings

알림 설정 저장. 값은 계정에 저장되어 다른 기기에서도 같게 적용됩니다.

```json
{ "push_chat": true, "push_meetup": true, "push_request": true, "push_marketing": false }
```

응답은 갱신된 사용자 객체이며, `settings` 필드로 현재 값을 확인할 수 있습니다.

### GET /api/v1/users/me/meetups

내 모임 목록. `role=joined`(기본) 또는 `role=hosted`.

### GET /api/v1/users/{user_id}

다른 사용자 공개 프로필. 이메일은 반환하지 않습니다.

### DELETE /api/v1/users/me

**회원탈퇴.** 다음이 한 번에 처리됩니다.

1. 내가 연 모집 중 모임을 모두 `cancelled` 로 변경
2. 모임 참여 기록 삭제
3. 이메일·비밀번호·기기 ID·위치·자기소개 등 개인정보 삭제 후 `status=deleted` 로 표시
4. 모든 기기의 토큰 폐기
5. 생년월일·성별은 휴대폰 번호·이메일의 HMAC 을 키로 `meetup_identity_locks` 에 **1년간** 남김(번호·이메일 원문은 저장하지 않음)

**게스트 계정 자동 삭제 (2026-09-20).** 게스트로 로그인한 계정은 만든 지 **30일**이 지나면 위 회원탈퇴와 같은 절차로 지워집니다. 만료된 게스트가 API 를 부르면 그 자리에서 삭제하고 `401 게스트 계정은 30일이 지나 삭제되었어요…` 를 돌려주며(토큰 갱신도 실패), 앱은 토큰을 비우고 인트로로 돌아갑니다. 다시 들어오지 않는 게스트는 서버가 하루 한 번 정리합니다(`app/guest_expiry.py`).

같은 이메일로 다시 가입할 수 있습니다. 다만 **탈퇴 후 1년 안에 같은 휴대폰 번호·이메일로 재가입하거나 게스트
계정에 연결하면 탈퇴 전 생년월일(나이)과 성별이 미리 들어가고 바꿀 수 없습니다.** 여러 번
탈퇴해도 처음 남긴 값이 유지되고, 보관 기간은 마지막 탈퇴부터 다시 셉니다. 1년이 지난 기록은 쓰지 않으며
DynamoDB TTL(`expires_at`, epoch 초)로 지워집니다(2026-09-15).

성별(`male`·`female`)과 생년월일은 한 번 정하면 `PATCH /api/v1/users/me` 로 바꿀 수 없습니다(다른 값이면 `400`).

---

## 6. 모임 API

### GET /api/v1/meetups

모집 중인 모임 목록. **인증 선택** (토큰을 주면 `joined` 값이 채워집니다).

| 파라미터 | 타입 | 기본값 | 설명 |
|---|---|---|---|
| `category` | string | – | `술모임` `운동` `취미` `문화` `맛집` `스터디` `기타` |
| `lat`, `lng` | float | – | 주면 반경 필터 + 거리순 정렬 |
| `radius_m` | integer | `5000` | 100 ~ 500000 |
| `include_past` | boolean | `true` | 기본은 시간으로 거르지 않습니다. `false` 로 주면 시작 후 18시간이 지난 모임을 제외합니다 |
| `limit` | integer | `30` | 1 ~ 100 |
| `sort` | string | `distance` | `distance` · `recent`. `recent` 면 최근 올라온 순 (앱 메인 모임 탭) |

모집 중(`open`)인 모임은 **시간이 지나도 목록에서 사라지지 않습니다.** 개설자가 취소하기
전까지 계속 남습니다.

좌표를 주지 않으면 **아직 시작하지 않은 모임을 먼저**(임박한 순), 그다음 이미 시작한
모임을(최근에 있었던 순) 보여줍니다. 부스트한 모임은 각 묶음 안에서 맨 위에 옵니다.

`sort=recent` 면 좌표가 있어도 반경으로 거르기만 하고, **최근에 만들어진 모임부터**
(`created_at` 역순) 보여줍니다. 부스트한 모임은 이때도 맨 위에 옵니다.

```json
{
  "count": 1,
  "items": [
    {
      "meetup_id": "mt_81f02a0584dd47",
      "host_id": "u_38eb8669c8cf4fe4",
      "host_nickname": "테스터",
      "title": "퇴근하고 맥주 한잔 🍺",
      "category": "술모임",
      "place": "강남역 11번 출구",
      "meet_at": "2026-08-24T17:42:11+00:00",
      "capacity": 4,
      "joined_count": 2,
      "status": "open",
      "service_note": "1인당 치킨+맥주 제공",
      "service_amount": 3,
      "lat": 37.4985,
      "lng": 127.0278,
      "distance_m": 69,
      "joined": true,
      "created_at": "2026-08-24T14:42:11+00:00"
    }
  ]
}
```

### POST /api/v1/meetups

모임 만들기. **인증 필요.** `201 Created`. 개설자는 자동으로 참여 처리됩니다.

| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| `title` | string | Y | 2~60자 |
| `category` | string | N | 기본 `기타` |
| `place` | string | Y | 만나는 장소 |
| `meet_at` | string | Y | ISO 8601 |
| `capacity` | integer | N | 2~50, 기본 4 |
| `description` | string | N | 500자 이내 |
| `service_note` | string | N | 1인당 제공하는 서비스 설명, 100자 이내 |
| `service_amount` | integer | N | 1인당 제공 혜택 금액, **만원 단위** 0~1000 (기본 0) |
| `lat`, `lng` | float | N | 주면 거리 기반 목록에 노출 |
| `image_url` | string | N | 대표 이미지 주소. 기본 샘플이나 직접 올린 이미지(아래 모임 이미지 API)만 가능, 아니면 `400` |

`service_amount` 는 만원 단위 정수입니다. `3` 이면 1인당 3만원 상당을 제공한다는 뜻이고,
주변찾기 지도에서는 마커 위에 `3만원` 으로 표시됩니다.

### 모임 이미지 — /api/v1/meetup-images

모임 대표 이미지. **인증 필요.** 모임 목록·상세 응답의 `image_url` 로 내려가며, 없으면
앱이 종류별 아이콘으로 채웁니다. `PATCH /api/v1/meetups/{meetup_id}` 로도 같은 제한으로
바꿀 수 있습니다.

| 메서드 | 경로 | 설명 |
|---|---|---|
| `GET` | `/api/v1/meetup-images/samples` | 기본 샘플 이미지 `{count, items: [{url, theme}]}`. 파일은 서버 `media/meetup_samples/` (Unsplash License 사진, 테마는 같은 폴더 `manifest.json`) |
| `POST` | `/api/v1/meetup-images` | multipart `file` 로 직접 올리기 (12MB 이하, 긴 변 1080px JPEG 로 저장) → `201 {"url": "..."}`. 파일은 `media/meetups/{user_id}/`, 회원탈퇴하면 삭제 |

### GET /api/v1/meetups/{meetup_id}

모임 상세. **인증 선택.**

### PATCH /api/v1/meetups/{meetup_id}

**개설자만.** 이미 참여한 인원보다 작게 `capacity` 를 줄이면 `400`.
`service_note`, `service_amount` 도 같은 방식으로 수정할 수 있습니다.

### DELETE /api/v1/meetups/{meetup_id}

**개설자만.** 삭제가 아니라 `status=cancelled` 로 바뀝니다.

### POST /api/v1/meetups/{meetup_id}/join

참여. 정원이 차면 `409`, 이미 참여했으면 `409`.
정원 초과는 DynamoDB 조건부 갱신으로 막기 때문에 동시에 신청해도 초과되지 않습니다.

### DELETE /api/v1/meetups/{meetup_id}/join

참여·신청 취소. 개설자는 취소할 수 없고 모임 자체를 취소해야 합니다(`400`).
참여가 확정된 상태였다면 단톡방에서도 함께 빠집니다.

### GET /api/v1/meetups/{meetup_id}/participants

참여자 목록. **인증 필요.**

---

### 참여 신청과 수락

모임 참여는 **개설자의 수락**이 있어야 확정됩니다.

| 메서드 | 경로 | 설명 |
|---|---|---|
| `POST` | `/api/v1/meetups/{id}/join` | 참여 신청. 개설자에게 알림이 갑니다. **게스트는 `403` `{ "detail": …, "code": "guest_signup_required" }`** — 앱은 휴대폰 정식 가입(`POST /api/v1/auth/phone/link`)을 안내 (2026-09-14) |
| `DELETE` | `/api/v1/meetups/{id}/join` | 참여 또는 신청 취소 |
| `GET` | `/api/v1/meetups/{id}/join-requests` | 대기 중인 신청 목록 (**개설자만**). 각 항목에 `photo_url` 포함 |
| `POST` | `/api/v1/meetups/{id}/join-requests/{user_id}/accept` | 수락 (**개설자만**) |
| `POST` | `/api/v1/meetups/{id}/join-requests/{user_id}/reject` | 거절 (**개설자만**) |

- 모임 응답의 `join_status` 로 내 상태를 알 수 있습니다: `none` · `pending` · `joined`
- **정원은 수락하는 순간에 확인합니다.** 신청만으로는 인원이 늘지 않습니다
- `GET .../participants` 는 확정된 참여자만 돌려줍니다
- 알림: 신청 → `meetup_join_requested`(개설자), 수락 → `meetup_join_accepted`,
  거절 → `meetup_join_rejected`(둘 다 신청자)

### POST /api/v1/meetups/{id}/chat

모임 **단톡방**을 열고 들어갑니다. 모임마다 방은 하나이며, 여러 번 불러도 같은 방이 나옵니다.
**참여가 확정된 사람만** 들어갈 수 있습니다(아니면 `403`).

```json
{
  "room_id": "rmg_mt_ab12cd34ef5678",
  "kind": "group",
  "title": "퇴근하고 맥주 한잔 🍺",
  "meetup_id": "mt_ab12cd34ef5678",
  "member_count": 3,
  "peer_id": null,
  "peer_nickname": "퇴근하고 맥주 한잔 🍺"
}
```

이후 대화는 1:1 방과 같은 `/api/v1/chat/rooms/{room_id}/messages` 를 씁니다.
단톡방에 메시지를 보내면 **나를 뺀 참여자 전원**에게 알림이 갑니다.
새로 수락된 참여자는 방이 이미 열려 있으면 자동으로 들어갑니다.

---

## 7. 주변찾기 API

### GET /api/v1/nearby/users

**인증 필요.** 내 위치 기준으로 가까운 사용자를 거리순으로 반환합니다.

| 파라미터 | 타입 | 기본값 | 설명 |
|---|---|---|---|
| `lat`, `lng` | float | 내 저장 위치 | 생략하면 마지막으로 저장한 위치 사용 |
| `radius_m` | integer | `1000` | 100 ~ 500000 (앱: 10km / 20km / 50km / 100km / 500km, 기본 10km) |
| `limit` | integer | `50` | 1 ~ 100 |

```json
{
  "count": 1,
  "radius_m": 3000,
  "items": [
    {
      "user_id": "u_38eb8669c8cf4fe4",
      "nickname": "테스터",
      "age": 28,
      "gender": "unknown",
      "status_message": "커피 한잔 하실 분",
      "distance_m": 127,
      "online": true,
      "last_active_at": "2026-08-24T14:42:10+00:00"
    }
  ]
}
```

- 응답의 `approx_lat`·`approx_lng` 는 **지도 표시용 근사 좌표**입니다. 실제 위치에서 사용자마다
  고정된 방향·거리(60~150m)만큼 비틀어 두어, 정확한 위치가 노출되지 않으면서도 새로고침할 때
  마커가 튀지 않습니다.
- 위치를 한 번도 올리지 않은 사용자는 목록에 나오지 않습니다.
- `online` 은 최근 **15분** 안에 활동한 경우 `true`.
- 내부적으로 0.1도 지오 격자로 후보를 좁힌 뒤 하버사인 거리로 다시 거릅니다.

---

## 8. 모임 신청 API

주변찾기에서 만난 상대에게 1:1 로 보내는 신청입니다. 모두 **인증 필요**.

| 메서드 | 경로 | 설명 |
|---|---|---|
| `POST` | `/api/v1/requests` | 신청 보내기 (`to_user_id`, `message`). **게스트는 `403` `code: guest_signup_required`** (휴대폰 정식 가입 안내, 2026-09-14) |
| `GET` | `/api/v1/requests?box=received` | 받은 신청 (`box=sent` 면 보낸 신청) |
| `POST` | `/api/v1/requests/{request_id}/accept` | 수락 |
| `POST` | `/api/v1/requests/{request_id}/reject` | 거절 |

상태값은 `pending` → `accepted` / `rejected` 이며, 받은 사람만 응답할 수 있습니다(`403`).
이미 처리한 신청에 다시 응답하면 `409`.

---

## 9. 채팅 API

대화방은 두 종류입니다. `kind` 가 `direct` 면 1:1, `group` 이면 모임 단톡방이고
단톡방은 `title`(모임 제목)·`meetup_id`·`member_count` 가 채워지고 `peer_id` 는 비어 있습니다.

1:1 방은 **매칭된 사이에서만** 열립니다. 여기서 매칭이란 *모임 신청이 수락된 관계*를 뜻하며,
신청이 수락되는 순간 두 사람의 대화방이 자동으로 만들어집니다. 모두 **인증 필요**.

### GET /api/v1/chat/rooms

내 대화방 목록. 최근 메시지가 있는 방이 위로 옵니다.

```json
{
  "count": 1,
  "items": [
    {
      "room_id": "rm_f268e4fa13fff4e1",
      "peer_id": "u_demo3f1c2a",
      "peer_nickname": "지우",
      "last_message": "안녕하세요! 모임 신청 반가워요 :)",
      "last_message_at": "2026-08-25T00:31:02+00:00",
      "unread_count": 1
    }
  ]
}
```

### POST /api/v1/chat/rooms

매칭된 상대와의 대화방을 열거나 이미 있으면 그대로 가져옵니다. 방 ID 는 두 사람 조합마다 하나로 고정됩니다.

```json
{ "peer_user_id": "u_demo3f1c2a" }
```

아직 매칭되지 않았다면 `403`:

```json
{ "detail": "아직 매칭되지 않았어요. 모임 신청이 수락되면 대화할 수 있습니다." }
```

### GET /api/v1/chat/rooms/{room_id}/messages

대화 내용을 시간순(오래된 것 → 최신)으로 반환합니다. `limit` 기본 100, 최대 200.
각 메시지의 `mine` 은 내가 보낸 메시지인지 여부입니다. 참여자가 아니면 `403`.

```json
{
  "count": 2,
  "peer_id": "u_demo3f1c2a",
  "items": [
    {
      "message_id": "1756081862123_9f2c41ab",
      "room_id": "rm_f268e4fa13fff4e1",
      "sender_id": "u_demo3f1c2a",
      "sender_nickname": "지우",
      "text": "안녕하세요!",
      "created_at": "2026-08-25T00:31:02+00:00",
      "mine": false
    }
  ]
}
```

### POST /api/v1/chat/rooms/{room_id}/messages

메시지 전송(1~1000자). `201` 로 생성된 메시지를 그대로 돌려줍니다.

```json
{ "text": "네 좋아요! 7시에 봬요" }
```

**프로필 카드** (채팅방 입력창 + 버튼): `text` 대신 `profile_card` 를 보냅니다.

지인 프로필 카드는 **받는 사람이 이미 아는 사람이면 보내지지 않습니다**(2026-09-17). 지인 프로필에 적어 둔 연락처가 대화 상대의 휴대폰 연락처에 있으면 `409 이미 지인입니다.` 로 막고, 대화방에는 카드도 문구도 남지 않습니다(앱은 이 메시지를 팝업으로 보여 줍니다).

```json
{ "profile_card": { "kind": "user" } }
{ "profile_card": { "kind": "friend", "friend_profile_id": "fp_8243…" } }
```

- `user` 는 내 프로필, `friend` 는 내 지인 프로필(남의 것이면 `404`).
- 서버가 보낸 시점의 프로필 사본을 메시지의 `card` 에 담습니다(조회 응답에도 포함, 일반 메시지는 `null`).
  - `user`: `kind, user_id, nickname, photo_url, age, gender, job, region` → 앱은 `user_id` 로 프로필 상세를 엽니다.
  - `friend`: `kind` + 지인 프로필 필드(`profile_id, nickname, job, school, birth_year, height, region, salary, assets`) → 지인 프로필은 만든 사람만 조회할 수 있어 받는 사람은 이 사본으로 상세를 봅니다.
- `text` 는 비워도 되며, 옛 앱·대화 목록·알림용 문구(`👤 OO님의 프로필` / `💌 지인 프로필 · 요약`)가 채워집니다.

**연락처 차단** (2026-09-14): 전화번호로 보이는 메시지는 저장·전달하지 않고 `422` 로 거절합니다.

```json
{ "detail": "전화번호 등 연락처는 채팅으로 보낼 수 없어요. …", "code": "contact_info_blocked" }
```

- 숫자·한글 숫자(공일공, 하나둘셋)·전각/동그라미·한자 숫자·o/l 을 숫자로 읽고, 구분자(공백 - . , / 등)만 끼인 숫자가 **8자리 이상**이면 막습니다. 날짜(`2026.09.14`)·천 단위 금액(`10,000,000`)은 예외.
- 같은 사람이 3분 안에 보낸 직전 메시지(최대 3개)와 이어 붙여서도 봅니다(번호 나눠 보내기).
- 막힌 시도는 `meetup_user_reports` 에 남아 관리자 **유저리포트**(admin.croninc.com/aiparty/report)에서 봅니다. 앱은 `code` 를 보고 경고 팝업을 띄웁니다.

### POST /api/v1/chat/rooms/{room_id}/read

현재 시각까지 읽음 처리. 이후 `unread_count` 가 0 이 됩니다.

### 약속 (대화방)

| 메서드 | 경로 | 설명 |
|---|---|---|
| `POST` | `/api/v1/chat/rooms/{room_id}/appointment` | 약속 제안 `{ at, place?, address?, with_user_id? }`. 제안한 사람은 바로 확인한 것으로 침 |
| `GET` | `/api/v1/chat/rooms/{room_id}/appointment` | 약속 조회 |
| `GET` | `/api/v1/chat/rooms/{room_id}/appointments` | **약속 목록**(2026-09-17). 지금 약속(`current: true`)이 맨 위, 그 아래 지난 약속이 최신순. 지난 약속은 `status: replaced`(새 약속으로 바뀜) 또는 `cancelled`(취소) 이며 그때 모습 그대로 저장됨(지인 이름 포함, 최대 20건) |
| `POST` | `/api/v1/chat/rooms/{room_id}/appointment/confirm` | 내가 확인. 방 전원이 확인하면 `status: confirmed` |
| `DELETE` | `/api/v1/chat/rooms/{room_id}/appointment` | 약속 취소 |

| 필드 | 타입 | 설명 |
|---|---|---|
| `at` | string | ISO8601 약속 시각 |
| `place` | string | 장소 (80자, 선택) |
| `address` | string | 장소 주소 (120자, 선택) (2026-09-17) |
| `with_user_id` | string | **누구와의 약속인지**(방 참여자 ID, 선택). 방에 없는 사람이면 `422`. 비우면 방 전체 (2026-09-17) |
| `with_friend_profile_id` | string | **내 지인 프로필과의 약속**일 때(선택, 2026-09-17). 내 지인이 아니면 `404`. 지인을 보내면 `with_user_id` 는 무시되고, 응답 `with_nickname` 에는 제안 당시 지인 닉네임이 담김(지인 프로필은 만든 사람만 조회할 수 있어 이름을 저장해 둠) |

응답 `appointment` 에는 위 항목과 함께 `with_nickname`, `proposed_by`, `proposed_by_nickname`, `status`, `confirmed_count`, `member_count`, `my_confirmed`, `pending_nicknames` 가 들어갑니다. 제안하면 대화방에 시스템 메시지가 남고(주소·상대 포함) 다른 참여자에게 알림이 갑니다.

### GET /api/v1/chat/unread

모든 대화방을 합친 안 읽은 메시지 수. 하단 탭 배지에 씁니다. 차단한 단톡방은 빼고 셉니다.

### POST /api/v1/chat/rooms/{room_id}/leave

대화방 나가기. **인증 필요.** 1:1·단톡방 모두 가능하며, 참여하지 않은 방이면 `403`.

- 내 대화 목록과 안 읽은 수에서 빠지고, 이후 새 메시지 알림도 오지 않습니다.
- 단톡방이면 방 참여자(`member_ids`)에서도 빠집니다.
- 남은 사람에게는 `OO님이 대화방을 나갔어요.` 안내 메시지가 남습니다.
- 1:1 대화는 다시 채팅하기(`POST /api/v1/chat/rooms`)·소개팅 해주기로 열면, 단톡방은 모임 상세에서 다시 열면 돌아옵니다.

```json
{ "ok": true, "message": "대화방을 나갔어요." }
```

### POST /api/v1/chat/rooms/{room_id}/block

단톡방을 차단합니다. **인증 필요.** 본문은 선택(`reason`, 최대 200자).

- 대화 목록에서 사라지고, 새 메시지 알림과 안 읽은 수에서 빠집니다.
- 다른 참여자에게는 나간 것처럼 보입니다(참여자 목록·인원수에서 제외).
- 방을 나가는 것과 달리 참여 기록은 남습니다. **모임 상세에서 단톡방을 다시 열면**
  자동으로 풀립니다(`DELETE /api/v1/chat/rooms/{room_id}/block` 으로도 풀 수 있습니다).
- 차단한 상태로 메시지·참여자 조회, 메시지 보내기를 부르면 `403`.
- 사용자 차단과 마찬가지로 **고객센터에 접수**됩니다(단톡방·연결된 모임 정보 포함).
- 1:1 대화방에는 쓸 수 없습니다(`400`). 상대를 `POST /api/v1/blocks` 로 차단하세요.

```json
{ "ok": true, "message": "단톡방을 차단했어요. 고객센터에도 접수했어요." }
```

```json
{ "unread_count": 3 }
```

> **실시간성** — 현재는 폴링 방식입니다. 앱은 대화방을 열고 있는 동안 4초마다
> 메시지를 다시 불러옵니다. WebSocket 은 도입하지 않았습니다.

> **데모 계정** — `demo` 표시가 붙은 계정(지우·현우·서연·도윤·하늘)은 사람이 응답해 줄 수 없으므로
> 모임 신청을 받으면 자동으로 수락하고 인사 메시지를 보냅니다. 테스터가 혼자서도 채팅을 확인할 수 있게 하기
> 위한 장치이며, 데모 계정을 지우면(`scripts/seed_demo.py purge`) 이 동작도 사라집니다.

---

## 10. 프로필 사진 API

사진은 **AI 검수를 통과해야만 저장**됩니다. 서버가 Gemini(`gemini-3.1-flash-lite`)로
사진을 분석해 *실제 사람의 얼굴이 또렷하게 보이는 안전한 사진*인지 확인하고,
아니면 저장하지 않고 이유를 돌려줍니다. 모두 **인증 필요**.

| 메서드 | 경로 | 설명 |
|---|---|---|
| `GET` | `/api/v1/users/me/photos` | 내 사진 목록 (`count`, `max`, `items`) |
| `POST` | `/api/v1/users/me/photos` | 사진 업로드 (multipart, 필드명 `file`) |
| `PUT` | `/api/v1/users/me/photos/{photo_id}/main` | 대표 사진 지정 |
| `PUT` | `/api/v1/users/me/photos/order` | 사진 순서 바꾸기 `{ "photo_ids": [...] }` — 내 사진 ID 전부를 새 순서로(빠지거나 겹치면 `400`). **첫 사진이 대표**가 되고 응답은 새 목록(각 항목 `sort_order`). 대표 지정도 그 사진을 맨 앞으로 옮기는 것과 같음 (2026-09-15) |
| `DELETE` | `/api/v1/users/me/photos/{photo_id}` | 사진 삭제 |
| `GET` | `/api/v1/users/{user_id}/photos` | 상대 사진 목록 |

**업로드 규칙**

- 최대 **6장**(대표 1 + 서브 5), 파일당 **12MB** 이하
- **형식 판정은 확장자·Content-Type 이 아니라 실제 이미지 해독으로** 합니다.
  아이폰처럼 `application/octet-stream` 으로 올라와도 정상 처리되며, HEIC 도 지원합니다.
- 서버가 긴 변 1080px JPEG 로 다시 인코딩해 저장합니다(앱도 올리기 전에 1080px JPG 로 줄입니다)
- 첫 사진은 자동으로 대표가 되고, 대표를 지우면 남은 사진 중 가장 오래된 것이 대표가 됩니다
- 대표 사진 URL 은 사용자 응답의 `photo_url`, 주변찾기 목록에도 함께 내려갑니다

```bash
curl -X POST https://api.meetup.croninc.com/api/v1/users/me/photos \
  -H "Authorization: Bearer $TOKEN" \
  -F "file=@profile.jpg"
```

**응답 `201 Created`**

```json
{
  "photo_id": "ph_398751578ebe42",
  "url": "https://api.meetup.croninc.com/media/profiles/u_.../ph_398751578ebe42.jpg",
  "is_main": true,
  "created_at": "2026-08-25T00:12:44+00:00",
  "review_reason": "한 사람의 얼굴이 정면에서 또렷하게 잘 보이며, 부적절한 요소가 없는 안전한 사진입니다."
}
```

**검수 실패 `422`** — `detail` 문구를 그대로 사용자에게 보여주면 됩니다.

| 상황 | 문구 |
|---|---|
| 사람 사진이 아님(그림·사물·풍경 등) | 실제 사람을 찍은 사진만 올릴 수 있어요. |
| 얼굴이 없음 | 얼굴이 보이는 사진을 올려주세요. |
| 얼굴이 가려짐 | 얼굴이 가려지지 않은 사진을 올려주세요. |
| 부적절한 사진 | 프로필에 쓸 수 없는 사진이에요. |

---

## 11. 캐시캐치 API

카메라 화면에서 공을 던져 캐시를 모으는 미니게임입니다. **판정은 전부 서버가** 합니다
(클라이언트가 캐시를 임의로 늘릴 수 없습니다). 모두 **인증 필요**.

**규칙** — 3번 던질 때마다 1번 잡히고, 잡으면 **1캐시**가 적립됩니다.

| 메서드 | 경로 | 설명 |
|---|---|---|
| `GET` | `/api/v1/cash` | 캐시 현황 |
| `POST` | `/api/v1/cash/throw` | 공 던지기(판정 포함) |
| `GET` | `/api/v1/cash/history` | 적립 내역 |

```json
// POST /api/v1/cash/throw
{
  "caught": true,
  "cash_earned": 1,
  "balance": 3,
  "total_earned": 3,
  "throws": 9,
  "catches": 3,
  "throws_to_next_catch": 3,
  "throws_per_catch": 3,
  "daily_throws": 9,
  "daily_limit": 300
}
```

- 던지기 간격이 **0.4초** 미만이거나 하루 **300회**를 넘으면 `429` 입니다.
- 앱에서는 **더보기 → 실험실**(허용된 계정만) 안에 캐시캐치와 캐시 현황이 함께 있습니다.

---

## 12. 캐시 상점 API

모은 캐시를 쓰는 곳입니다. 차감은 조건부 갱신으로 처리해 **잔액보다 많이 쓸 수 없습니다**.
모두 **인증 필요**.

| 메서드 | 경로 | 설명 |
|---|---|---|
| `GET` | `/api/v1/store/items` | 상품 목록 + 내 잔액 |
| `POST` | `/api/v1/store/purchase` | 구매 (`item_id`, 모임 부스트는 `meetup_id`) |
| `GET` | `/api/v1/store/purchases` | 구매 내역 |

**상품**

| item_id | 이름 | 가격 | 효과 |
|---|---|---|---|
| `profile_boost` | 프로필 부스트 | 2캐시 | **1시간** 동안 주변찾기 목록 맨 위 노출 (`boosted: true`) |
| `meetup_boost` | 모임 부스트 | 3캐시 | 내가 연 모임이 **2시간** 동안 모임 목록 맨 위 노출 |
| `super_request` | 슈퍼 모임 신청권 | 1캐시 | 모임 신청을 강조해서 1회 보낼 수 있음 |

- 부스트는 사용자/모임 레코드의 `boost_until` 로 관리되며, 목록 정렬에서 유효한 부스트가 먼저 옵니다.
- 슈퍼 신청권은 `super_tickets` 로 쌓이고, `POST /api/v1/requests` 에 `"use_super": true` 를 주면
  1장 차감되며 신청이 `is_super: true` 로 표시됩니다. 티켓이 없으면 `409`.
- 캐시가 모자라면 `409`, 남의 모임을 부스트하려 하면 `403`, 없는 모임이면 `404` 입니다.

```json
// POST /api/v1/store/purchase  {"item_id": "profile_boost"}
{
  "item_id": "profile_boost",
  "item_name": "프로필 부스트",
  "price": 2,
  "balance": 1,
  "boost_until": "2026-08-25T01:24:13+00:00",
  "message": "프로필 부스트을(를) 적용했어요!"
}
```

---

## 13. 지역 검색 API

거주지역·활동지역을 고를 때 쓰는 행정구역 검색입니다. **인증 불필요.**

```bash
curl -G https://api.meetup.croninc.com/api/v1/regions --data-urlencode "q=강남"
# {"count":1,"items":["서울특별시 강남구"]}
```

- 시·도 17개와 시·군·구 약 230개를 서버에 내장하고 있어 외부 주소 API 키가 필요 없습니다.
- 검색어의 공백은 무시하며, 접두 일치를 먼저 보여줍니다. `q` 를 비우면 시·도 목록부터 나옵니다.

---

## 14. 장소 검색 API

모임 만들기에서 "만나는 장소"를 검색할 때 씁니다. **인증 필요.**

```bash
curl -G https://api.meetup.croninc.com/api/v1/places/search \
  -H "Authorization: Bearer $TOKEN" --data-urlencode "q=강남역"
```

```json
{
  "count": 2,
  "items": [
    {
      "name": "강남역",
      "address": "서울특별시 강남구 역삼1동 강남대로",
      "lat": 37.49426,
      "lng": 127.02963,
      "source": "osm"
    }
  ]
}
```

- 두 글자 이상부터 검색되며, 같은 검색어는 5분간 캐시합니다.
- 공급자 순서: **네이버 지오코딩(NCP)** → 실패하면 **OpenStreetMap Nominatim**.
  현재 발급된 네이버 키는 모바일 지도 SDK 전용이라 지오코딩 API 구독이 없어 자동으로 OSM 으로 넘어갑니다.
  NCP 콘솔에서 Geocoding 을 구독하면 코드 변경 없이 네이버 결과가 우선 사용됩니다.

---

## 15. 알림 API

모임 신청·수락, 새 메시지, 모임 참여, 프로필 평가가 생기면 알림이 쌓입니다. 모두 **인증 필요**.

| 메서드 | 경로 | 설명 |
|---|---|---|
| `GET` | `/api/v1/notifications` | 알림 목록(최신순, `unread_count` 포함) |
| `GET` | `/api/v1/notifications/unread` | 안 읽은 알림 수(아이콘 배지용) |
| `POST` | `/api/v1/notifications/{id}/read` | 한 건 읽음 |
| `POST` | `/api/v1/notifications/read-all` | 모두 읽음 |

**알림 종류(`type`)와 함께 오는 `data`**

| type | 언제 | data |
|---|---|---|
| `request_received` | 모임 신청을 받았을 때 | `request_id`, `from_user_id` |
| `request_accepted` / `request_rejected` | 내 신청이 처리됐을 때 | `request_id`, `user_id` |
| `chat_message` | 새 메시지가 왔을 때 | `room_id`, `peer_id` |
| `meetup_joined` | 내 모임에 누가 참여했을 때 | `meetup_id`, `user_id` |
| `rating_received` | 프로필 평가를 처음 받았을 때 | `score` |

알림 저장이 실패해도 원래 동작(신청·메시지 전송 등)은 그대로 성공합니다.

### 푸시 알림

알림이 쌓일 때 **FCM 푸시**도 함께 발송됩니다(서버가 Firebase 서비스 계정으로 직접 전송).

| 메서드 | 경로 | 설명 |
|---|---|---|
| `POST` | `/api/v1/push/token` | 이 기기의 FCM 토큰 등록 (`{"token", "platform"}`) |
| `DELETE` | `/api/v1/push/token` | 토큰 해제(로그아웃·탈퇴 시) |

- 푸시 본문에는 알림과 같은 `title`·`body` 가 들어가고, `data.type` 으로 종류를 구분합니다.
- 더 이상 유효하지 않은 토큰(`UNREGISTERED`)은 발송 중 자동으로 정리됩니다.
- 푸시 설정이 없거나 발송이 실패해도 알림 저장과 본래 동작에는 영향이 없습니다.

---

## 16. 뱃지 인증 API

프로필 신뢰도를 보여주는 뱃지입니다. 모두 **인증 필요**.

| 메서드 | 경로 | 설명 |
|---|---|---|
| `GET` | `/api/v1/badges` | 내 뱃지 현황(카테고리·인정 서류·인증 여부) |
| `POST` | `/api/v1/badges/{badge_id}/verify` | 서류 사진(multipart `file`)으로 인증 |
| `GET` | `/api/v1/badges/users/{user_id}` | 상대가 인증한 뱃지 목록 |

**자동 뱃지** — 조건을 만족하면 즉시 인증됩니다.

| badge_id | 이름 | 조건 |
|---|---|---|
| `phone` | 본인인증 | 휴대폰 인증 완료 |
| `photo` | 사진 인증 | AI 검수를 통과한 프로필 사진 등록 |
| `profile` | 프로필 완성 | 닉네임·생년월일·성별 입력 |

**서류 뱃지** — 촬영한 서류를 Gemini 가 심사합니다.

| badge_id | 이름 | 인정 서류 / 기준 |
|---|---|---|
| `job` | 직장인 인증 | 사원증·명함·재직증명서 |
| `professional` | 전문직 인증 | 의사·변호사·회계사 등 자격증 |
| `business_owner` | 사업가 인증 | 사업자등록증·법인등기부등본 |
| `income_70m` | 고소득 인증 | 원천징수영수증 등 · 연소득 7,000만원 이상 |
| `income_100m` | 억대연봉 인증 | 원천징수영수증 등 · 연소득 1억원 이상 |
| `asset_700m` | 고액자산 인증 | 잔액증명서·등기부등본 등 · 7억원 이상 |
| `asset_2b` | 초고액자산 인증 | 잔액증명서·등기부등본 등 · 20억원 이상 |
| `car_70m` | 고급차량 인증 | 자동차등록증 · 시세 7,000만원 이상 |
| `car_100m` | 억대차량 인증 | 자동차등록증 · 시세 1억원 이상 |
| `student` | 학생 인증 | 학생증·재학증명서 |
| `elite_school` | 명문대 인증 | 서울대·연세대·고려대·KAIST·아이비리그 |

> **서류 이미지는 비공개로 보관합니다(2026-09-13~).** 인증 요청마다 결과와 함께 `meetup_verifications` 에 기록되고, 이미지는 공개 `/media` 밖(`private_media/verifications/`)에 저장돼 관리자 화면(admin.croninc.com/aiparty/verifications)에서만 볼 수 있습니다. 프로필 사진 업로드도 AI 통과·실패와 관계없이 같은 방식으로 기록됩니다. 탈퇴 시 함께 삭제됩니다.
> 앱에서는 **카메라 촬영만** 허용하며 앨범 선택은 제공하지 않습니다.

심사에 걸리면 `422` 와 함께 사유가 내려갑니다(예: "인정 서류가 확인되지 않아요…").
자동 뱃지에 서류를 올리면 `400`, 없는 뱃지는 `404` 입니다.

---

## 17. 프로필 평점 API

사용자가 **평점 인증을 요청**하면 다른 사용자들이 1~5점을 줄 수 있습니다. 모두 **인증 필요**.

| 메서드 | 경로 | 설명 |
|---|---|---|
| `GET` | `/api/v1/ratings/me` | 내 평점(평균·인원·요청 여부) |
| `POST` | `/api/v1/ratings/request` | 평점 인증요청(평가 목록에 노출) |
| `DELETE` | `/api/v1/ratings/request` | 요청 취소 |
| `GET` | `/api/v1/ratings/candidates` | 평가할 사람 목록(프로필 상세·사진 포함) |
| `POST` | `/api/v1/ratings/{user_id}` | 별점 주기 (`{"score": 1~5}`) |

- `candidates` 응답에는 평가 화면에 필요한 정보가 함께 옵니다:
  `photos`(대표 사진이 맨 앞), `age`, `height`, `job`, `school`, `residence_region`,
  `activity_region`, `mbti`, `bio`, `average`, `rating_count`, `my_score`.
- 한 사람당 한 번만 반영되며, 다시 주면 **점수만 갱신**되고 평가 인원수는 늘지 않습니다.
- 요청하지 않은 사용자를 평가하면 `409`, 자기 자신은 `400`, 1~5 범위를 벗어나면 `422`.
- 평균·인원은 사용자 응답(`rating_average`, `rating_count`)에도 포함됩니다.

---

## 18. 관심 목록 API

| 메서드 | 경로 | 설명 |
|---|---|---|
| `GET` | `/api/v1/favorites` | 관심 목록 조회 |
| `PUT` | `/api/v1/favorites/{user_id}` | 관심 등록 |
| `DELETE` | `/api/v1/favorites/{user_id}` | 관심 해제 |

---

## 18-1. 소개팅 API

소개팅 요청을 올리고, 올라온 요청을 보고 "소개팅 해주기"로 요청자와 대화를 시작합니다.
모두 **인증 필요.** 차단 관계인 사람의 요청은 서로 보이지 않습니다.

요청한 사람은 드러나지 않습니다. 닉네임·프로필 사진·사용자 ID 는 내려주지 않고, 요청자
성별 `gender`(`male`·`female`, 모르면 `null`)와 성별에 맞는 영문 이름 별명 `alias`
(예: `제시카 심슨`, `톰 하디`)만 보여줍니다. 별명은 요청할 때 정해져 바뀌지 않습니다.
"소개팅 해주기"로 대화방을 열면 그때부터는 일반 대화방처럼 닉네임이 보입니다.

### POST /api/v1/blind-dates

소개팅 요청하기. `201 Created`, 응답은 목록의 한 항목과 같은 모양입니다.

| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| `birth_year_from` | integer | O | 원하는 이성 나이대 시작 출생연도 (예: `1990`) |
| `birth_year_to` | integer | O | 끝 출생연도 (예: `1995`). 시작보다 늦어야 하고 올해 기준 만 19세가 되는 해까지만 가능, 아니면 `400` |
| `height_from`, `height_to` | integer | – | 선호하는 키 범위(cm, 120~220). 둘 다 주거나 둘 다 비워야 하고(비우면 상관없음), 앞이 뒤보다 크면 `400`. 앱은 140~200cm 를 5cm 단위로 고름 |
| `religion` | string | – | 원하는 종교 `none`(무교) · `christian`(기독교) · `catholic`(천주교) · `buddhist`(불교) · `other`(기타). 비우면 상관없음 |
| `smoking` | string | – | 원하는 흡연 여부 `none`(비흡연) · `sometimes`(가끔) · `often`(흡연). 비우면 상관없음 |
| `drinking` | string | – | 원하는 음주 여부 `none`(안 함) · `sometimes`(가끔) · `often`(자주). 비우면 상관없음 |
| `region` | string | O | 지역 (최대 40자) |
| `jobs` | string[] | – | 선호 직업군 (최대 20개, 중복은 한 번만) |
| `note` | string | – | 특이사항 (최대 500자) |
| `fee` | integer | – | 소개사례금, **만원 단위** 0~100000 (기본 0) |
| `marriage_reward` | integer | – | 성혼사례금, **만원 단위** 0~1000000 (선택). `fee` 가 0 보다 클 때만 저장. 목록·상세·요청하기·수정 응답에 모두 `marriage_reward` 로 나옴(적지 않았으면 `null`). 앱은 **0보다 클 때만** 목록·상세에 표시 (2026-09-14, 목록 노출 2026-09-16) |
| `friend_profile_id` | string | – | 누구의 소개팅인지. 비우면 내 프로필, **내** 지인 프로필 ID 면 그 지인을 위한 요청(남의 것·없는 ID 면 `404`). 지인 ID·태어난 해만 저장하고 지인의 이름·사진은 누구에게도 내려주지 않음. 응답의 `for_friend` 로 지인 요청인지 알 수 있고, `friend_profile_id` 는 작성자 본인에게만 나옴 (2026-09-14) |
| `alias` | string | – | 목록에 보일 익명 이름 (최대 20자). 앱은 내 성별에 맞는 영문 이름+성을 뽑아 보냄. 비우면 서버가 요청자 성별에 맞게 뽑음 |
| `group_size` | integer | – | 만남 형태. `1` 이면 1:1 소개팅, `2`~`5` 면 2:2~5:5 미팅 (기본 1). 목록·상세 응답에도 내려가며 예전 요청은 `1` |

```json
{
  "request_id": "bd_1a2b3c4d5e6f70",
  "alias": "제시카 심슨",
  "gender": "female",
  "group_size": 1,
  "birth_year_from": 1990,
  "birth_year_to": 1995,
  "height_from": 160,
  "height_to": 170,
  "religion": null,
  "smoking": "none",
  "drinking": "sometimes",
  "region": "서울 강남구",
  "jobs": ["전문직", "공무원"],
  "note": "비흡연자였으면 좋겠어요",
  "fee": 30,
  "status": "open",
  "created_at": "2026-09-13T12:00:00+00:00",
  "mine": true
}
```

| 메서드 | 경로 | 설명 |
|---|---|---|
| `GET` | `/api/v1/blind-dates?limit=50` | 올라온 요청 목록 (**최신순**, `limit` 1~100). `mine` 은 내가 올린 요청인지. 항목마다 `requester` = `{ age_group, badge_count, is_friend, job, region }` (**소개할 사람**의 나이대·인증 뱃지 수·직업·사는 곳, 지인 요청이면 지인 기준이고 `badge_count: null`) (2026-09-14, 직업·사는 곳 2026-09-16)  `ideal=true` 면 내 이상형 설정에 맞는 요청만(거른 뒤 `limit` 개) (2026-09-15). 지인 요청의 `gender` 는 지인 성별(없으면 `null`). 목록·상세·"소개팅 해주기"는 **직접 차단한 사람(양쪽)만** 가리며, 연락처 기반 지인 매칭 차단(`kind=contact`)은 소개팅에 적용하지 않습니다 (2026-09-16) | **내 휴대폰 연락처에 있는 번호의 요청은 목록에서 빠집니다**(2026-09-17): 요청을 올릴 때 소개 대상의 번호(지인 요청이면 지인 프로필의 연락처, 본인 요청이면 가입 번호)를 요청에 함께 저장해 두고, 보는 사람의 연락처와 해시로만 맞춰 봅니다. 반대로 **나를 연락처에 올린 사람(지인 매칭 차단으로 내 번호를 올린 사람)의 번호가 대상인 요청도 내게는 안 보입니다** — 보는 사람의 번호 해시로 `hash-index` 를 한 번 조회해 그 사람들을 찾습니다. 번호는 어떤 응답에도 담기지 않고, 내가 올린 요청은 언제나 보입니다. |
| `PUT` | `/api/v1/blind-dates/{request_id}` | 내 요청 수정. 본문은 요청하기와 같고 **모든 항목을 새 값으로** 바꿈(빼거나 비운 키·종교·흡연·음주·메모·성혼사례금은 지움, 별명을 비우면 쓰던 별명 유지). 요청 ID·작성 시각·성별은 그대로. 남의 요청이면 `403`, 내려갔으면 `404` (2026-09-14) |
| `DELETE` | `/api/v1/blind-dates/{request_id}` | 내 요청 내리기 (남의 요청이면 `403`) |
| `POST` | `/api/v1/blind-dates/{request_id}/chat` | **소개팅 해주기**: 요청자와 1:1 대화방을 열고 `ChatRoomResponse` 를 돌려줌. 일반 대화방과 달리 모임 신청 수락(매칭) 없이 열림. 내 요청이면 `400`, 내려갔거나 차단 관계면 `404` |

**소개팅 해주기 본문(선택)** — 누구를 소개할지 고릅니다. 본문이 없거나 아무도 고르지 않으면 대화방만 엽니다(선택 없이 대화 요청).

| 필드 | 타입 | 설명 |
|---|---|---|
| `introduce_self` | boolean | 본인을 소개 (기본 `false`) |
| `friend_profile_ids` | string[] | 소개할 **내** 지인 프로필 ID 여러 개 (남의 것·없는 ID면 `404`) |

하나라도 고르면 대화방에 주선자가 보낸 메시지로 소개 내용이 남고 요청자에게 알림이 갑니다. 예:

```
💌 소개팅 해드리고 싶어요! 이런 분을 소개할게요.

1. 본인 (지민)
2. 지인 · 다정한 고래 · 1995년생 · 간호사 · 165cm · 서울특별시 강남구 · 연봉 5,000만원
```

회원탈퇴하면 올려 둔 요청은 내려갑니다.

## 18-3. 앱 버전 API

더보기 > 앱 버전 화면이 쓰는 최신 버전 안내입니다. **인증 없음.** (2026-09-15)

### GET /api/v1/app/version

```json
{
  "latest_version": "26.09.14",
  "latest_build": 0,
  "update_url": "https://apps.apple.com/kr/app/id6804692294",
  "release_notes": ["…"],
  "released_at": "2026-09-15"
}
```

- 값은 서버 `app_version.json` 에서 읽습니다. `source` 가 `appstore` 면 Apple 공개 조회 API(`itunes.apple.com/lookup?id=6804692294&country=kr`)로 App Store 에 올라간 버전·업데이트 내용을 가져오고(10분 캐시), 실패하면 파일 값을 줍니다.
- 앱은 `latest_build` 가 0 보다 크면 설치된 빌드 번호와, 0 이면 버전 이름(`YY.MM.DD`)과 비교해 더 새로우면 "최신버전 업데이트" 버튼을 켜고 `update_url` 을 엽니다.

## 18-4. 이상형 설정 API

더보기 > 이상형 설정. **인증 필요.** 소개팅 목록에서 "내 이상형만" 볼 때 씁니다. (2026-09-15)

| 메서드 | 경로 | 설명 |
|---|---|---|
| `GET` | `/api/v1/users/me/ideal-type` | 내 이상형 설정. 없으면 모든 항목이 `null`/빈 목록이고 `configured: false` |
| `PUT` | `/api/v1/users/me/ideal-type` | 저장(모든 항목을 새 값으로, 비우면 상관없음). 모두 비워도 저장되며 "이성이면 누구나"로 `configured: true` |

| 필드 | 타입 | 설명 |
|---|---|---|
| `gender` | string | **응답 전용.** 원하는 성별은 고르지 않고 항상 내 성별의 이성(`male` ↔ `female`). 내 성별이 없으면 `null`. 요청에 보내도 무시 (2026-09-15) |
| `birth_year_from`, `birth_year_to` | integer | 태어난 해 범위(둘 다 주거나 둘 다 비움, 앞이 작아야 함 — 아니면 `422`) |
| `height_from`, `height_to` | integer | 키 범위(cm, 규칙 같음) |
| `regions` | string[] | 원하는 지역 최대 3곳(앞부분 일치: `서울특별시` 는 `서울특별시 강남구` 와 맞음) |
| `smoking`, `drinking` | string | `none` · `sometimes` · `often` |
| `salary_min` | integer | 원하는 최소 연봉(**만원**, 0~1,000,000). 대상에게 연봉 정보가 없으면 통과. 회원 프로필에는 연봉 항목이 없어 지인 요청에만 걸림 (2026-09-17) |
| `assets_min` | integer | 원하는 최소 자산(**만원**, 0~100,000,000). 규칙 같음 (2026-09-17) |
| `jobs` | string[] | 원하는 직업군 최대 10개(소개 대상의 **직업 문구**와 견줌: 고른 값이 직업에 들어 있으면 통과). 비우면 상관없음 (2026-09-17) |
| `notify` | boolean | 조건에 맞는 소개팅이 올라오면 **푸시 알림**을 받을지. 앱의 알림 탭 > + 버튼(이상형 알림)에서 켠다 (2026-09-17) |

비교 규칙: 소개팅 요청의 **소개 대상**(내 프로필 요청이면 요청한 회원 프로필, 지인 요청이면 지인 프로필)으로 비교합니다. 성별은 반드시 일치, 나머지는 대상에게 정보가 없으면 통과합니다. 대상 정보는 응답에 포함되지 않습니다(익명 유지). 종교는 회원 프로필에 없어 비교하지 않습니다.

**이상형 알림 (2026-09-17).** `notify: true` 로 저장해 두면, 새 소개팅 요청이 올라올 때(`POST /api/v1/blind-dates`) 서버가 조건에 맞는 회원에게 알림을 남기고 푸시를 보냅니다(`type: blind_date_match`, `data.request_id`). 비교 기준은 목록의 "내 이상형" 과 같고, 본인 요청과 서로 가려진 사이(차단)는 보내지 않습니다. 알림 문구에는 소개 대상의 **나이대·지역만** 들어가고 닉네임·사진은 넣지 않습니다. 알림 발송이 실패해도 요청 등록은 그대로 끝납니다.

## 18-2. 지인 프로필 API

소개해 주고 싶은 지인의 프로필을 내가 만들어 두는 기능입니다. **인증 필요.** 만든 사람만 볼 수
있고 다른 회원에게는 공개되지 않습니다. 한 사람당 50개까지 만들 수 있습니다.

### POST /api/v1/friend-profiles

지인 프로필 새로 만들기. `201 Created`, 응답은 목록의 한 항목과 같은 모양입니다.

| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| `phone` | string | – | 연락처 (최대 20자, 숫자와 `-` 만, 선택). **만든 사람만 봅니다** — 목록·상세 응답에만 담기고 소개 요약·채팅 프로필 카드에는 넣지 않습니다. 앱은 내 연락처에서 골라 넣습니다. 수정 때 비우면 지움 (2026-09-17) |
| `real_name` | string | – | 성함 (최대 20자, 선택). **만든 사람만 봅니다** — 목록·상세 응답에만 담기고, 소개 요약과 채팅 프로필 카드에는 넣지 않습니다. 수정 때 비우거나 `null` 이면 지움 (2026-09-16) |
| `nickname` | string | – | 닉네임 (최대 20자). 비우면 서버가 랜덤 닉네임(예: "다정한 고래")을 정함 |
| `job` | string | O | 직업 (최대 30자) |
| `school` | string | – | 졸업학교 (최대 40자, 선택). 수정 때 비우거나 `null` 이면 지움 (2026-09-14) |
| `gender` | string | – | 성별 `male` · `female` (선택). 수정 때 비우거나 `null` 이면 지움 (2026-09-15) |
| `appearance` | string | – | 외모 (선택, 앱은 사진이 없을 때만 보냄). `male`: `잘생김`·`훈훈`·`보통` / `female`: `매우예쁨`·`예쁨`·`훈훈`·`보통`. 성별에 맞지 않거나 성별 없이 보내면 `422` (2026-09-15) |
| `birth_year` | integer | O | 태어난 해. 올해 기준 19세~80세(예: 2026년이면 1946~2007), 벗어나면 `422` |
| `height` | integer | O | 키(cm) 140~210 |
| `region` | string | O | 사는 곳 (최대 40자, 지역 검색으로 고른 값) |
| `note` | string | – | 특이사항 (최대 300자, 선택). 수정 때 비우면 지움 (2026-09-16) |
| `family` | string | – | 가족정보 (최대 300자, 선택). 수정 때 비우면 지움 (2026-09-16) |
| `parents_care` | string | – | 부모님노후 (최대 300자, 선택). 수정 때 비우면 지움 (2026-09-16) |
| `salary` | integer | – | 연봉, **만원 단위** (선택) |
| `assets` | integer | – | 자산, **만원 단위** (선택) |
| `photo_urls` | string[] | – | 지인 사진 주소 최대 6장(첫 장이 대표). `POST /api/v1/friend-profiles/photos` 로 올린 **내** 사진 주소만 가능(아니면 `422`). 수정 때 빠진 사진은 파일도 지움 (2026-09-14) |

```json
{
  "profile_id": "fp_8210693612345_a1b2c3",
  "nickname": "다정한 고래",
  "job": "회사원",
  "school": "한국대학교",
  "gender": "female",
  "appearance": "예쁨",
  "birth_year": 1995,
  "height": 172,
  "region": "서울특별시 강남구",
  "salary": 5000,
  "assets": null,
  "photo_urls": ["https://api.meetup.croninc.com/media/friend_profiles/u_…/fph_…jpg"],
  "created_at": "2026-09-14T06:30:00+00:00",
  "updated_at": null
}
```

| 메서드 | 경로 | 설명 |
|---|---|---|
| `POST` | `/api/v1/friend-profiles/photos` | 지인 사진 한 장 올리기(multipart `file`, 12MB 이하, 긴 변 1080px JPEG 로 저장). `{ "url": … }` 을 돌려주며 만들기·수정의 `photo_urls` 에 넣음 |
| `GET` | `/api/v1/friend-profiles` | 내가 만든 지인 프로필 목록 (**최신순**) |
| `GET` | `/api/v1/friend-profiles/{profile_id}` | 지인 프로필 상세 (내 것이 아니면 `404`) |
| `PUT` | `/api/v1/friend-profiles/{profile_id}` | 지인 프로필 수정. 본문은 만들기와 같고 **모든 항목을 새 값으로** 바꿈(성함·연봉·자산을 빼거나 `null` 이면 지움, 닉네임을 비우면 쓰던 닉네임 유지). 내 것이 아니면 `404` |
| `PUT` | `/api/v1/friend-profiles/{profile_id}/photos` | **사진만 저장** `{ "photo_urls": [...] }` (2026-09-17). 다른 항목은 그대로 두고 사진 목록만 바꿈(첫 장이 대표, 빠진 사진은 파일도 삭제). 앱은 수정 화면에서 사진을 올리거나 지울 때 바로 부름. 내 것이 아니면 `404`, 내가 올린 사진이 아니면 `422` |
| `PUT` | `/api/v1/friend-profiles/{profile_id}/ideal-type` | **지인의 이상형 설정** (2026-09-17). 본문은 `PUT /api/v1/users/me/ideal-type` 과 같음. `notify: true` 면 이 지인의 조건에 맞는 소개팅이 올라올 때 **지인을 만든 회원에게** 알림·푸시가 감(`type: blind_date_match`, `data.friend_profile_id` 포함). 성별은 지인의 이성 기준. 저장한 값은 지인 프로필 응답의 `ideal_type` 으로 내려오며, 지인 프로필 수정(전체 덮어쓰기)에도 지워지지 않음. 내 것이 아니면 `404` |
| `DELETE` | `/api/v1/friend-profiles/{profile_id}` | 지인 프로필 삭제 (내 것이 아니면 `404`) |

나이(`age`)로 저장했던 예전 프로필은 만든 해 기준으로 `birth_year` 를 셈해 돌려주고, 닉네임이 없으면 프로필 ID 로 늘 같은 닉네임을 정해 돌려줍니다.

지인 프로필을 새로 만들 때 앱은 **개인정보 수집 동의**(`GET /api/v1/legal/friend_privacy` 안내)를 받아야 등록 버튼을 누를 수 있습니다.

회원탈퇴하면 만든 지인 프로필과 올린 지인 사진도 모두 지워집니다.


## 19. 신고·차단 API

### POST /api/v1/blocks

사용자를 차단합니다. **인증 필요.** `201 Created`.

| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| `user_id` | string | O | 차단할 사용자 ID |
| `context_type` | string | – | 어느 화면에서 차단했는지. `meetup` · `user` · `chat_room` |
| `context_id` | string | – | 그 화면의 대상 ID (모임 ID·사용자 ID·대화방 ID) |
| `reason` | string | – | 차단 사유 (최대 200자) |

차단하면 **양쪽 모두** 서로가 보이지 않게 됩니다. 차단당한 쪽에도 상대가 사라지므로
차단했다는 사실이 드러나지 않습니다.

- 주변찾기·관심 목록·평점 평가 대상에서 상대가 빠집니다.
- 상대 프로필과 프로필 사진은 없는 사용자처럼 `404` 로 답합니다.
- 상대가 연 모임은 목록·상세·참여 신청에서 없는 모임처럼 `404` 로 답하고, 내가 참여한
  모임 목록에서도 빠집니다.
- 모임 참여자·참여 신청 목록, 단톡방 참여자 목록에서 상대 줄이 빠지고, 단톡방에서
  상대가 쓴 메시지도 보이지 않습니다.
- 1:1 대화방은 목록에서 빠지고 메시지를 보낼 수 없으며, 1:1 모임 신청도 주고받을 수 없습니다.

차단하면 **고객센터에도 자동으로 접수**됩니다. 차단된 사용자·차단한 사용자 정보와,
`context_type`/`context_id` 가 있으면 그 모임·대화방·프로필 내용을 서버가 채워 신고와
같은 주소로 메일을 보냅니다. 메일이 나가지 않아도 차단은 그대로 적용되며(응답 `message`
로 구분), 같은 상대를 60초 안에 다시 차단하면 메일은 다시 보내지 않습니다.
접수 사실은 서버 로그(`meetup.blocks`)에도 남습니다.

```json
{ "ok": true, "message": "차단했어요. 서로 보이지 않게 되고, 고객센터에도 접수했어요." }
```

| 메서드 | 경로 | 설명 |
|---|---|---|
| `GET` | `/api/v1/blocks` | 내가 차단한 사용자 목록 (`user_id`·`nickname`·`photo_url`) |
| `DELETE` | `/api/v1/blocks/{user_id}` | 차단 해제 |

### PUT /api/v1/contacts

지인 매칭 차단. 휴대폰 연락처의 번호를 올리면 **그 번호로 가입한 사람과 양쪽 모두 서로
보이지 않습니다.** **인증 필요.** 보낸 목록으로 통째로 바꾸며, 목록에서 빠진 번호의 지인
차단은 풀립니다.

| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| `phones` | string[] | O | 휴대폰 번호 (최대 10,000개). `010-1234-5678`·`+82 10-…` 표기도 받고, 휴대폰 번호가 아닌 값·내 번호는 버립니다 |

- 사용자 차단과 같은 곳(주변찾기·프로필·모임·대화·모임 신청)에서 걸러집니다. 차단 테이블에
  `kind: "contact"` 행으로 남기므로 차단 목록(`GET /api/v1/blocks`)에는 나오지 않습니다.
- 번호는 저장하지 않고 서버 비밀값으로 만든 HMAC 해시만 `meetup_contacts` 에 남깁니다.
- 아직 가입하지 않은 번호도 남겨 두었다가, 그 번호로 가입하거나 번호를 연결하면 자동으로
  차단됩니다.
- 이미 직접 차단한 사용자는 그대로 두고, 연락처를 지워도 직접 차단은 풀리지 않습니다.
- 운영·테스트 계정(`01012345678`·`01036504100`·`01024698758`)끼리는 서로의 연락처에 있어도 지인 차단을 걸지 않습니다. 한 사람이 여러 번호로 쓰는 계정이라, 막으면 서로의 소개팅 요청이 안 보여 같은 계정처럼 보이기 때문입니다. 운영 계정 연락처에 있는 일반 지인은 그대로 차단됩니다.
  직접 차단을 해제해도 연락처에 있는 지인이면 계속 서로 보이지 않습니다.
- 회원탈퇴하면 올린 연락처와 지인 연결이 모두 지워집니다.

```json
{ "contact_count": 312, "blocked_count": 4, "synced_at": "2026-09-13T10:20:00+00:00" }
```

| 메서드 | 경로 | 설명 |
|---|---|---|
| `GET` | `/api/v1/contacts` | 현황 (위와 같은 응답, 올린 적 없으면 `0`·`0`·`null`) |
| `DELETE` | `/api/v1/contacts` | 올린 연락처 모두 삭제 (지인 차단이 풀림) |

### POST /api/v1/reports

모임·사용자·대화방을 신고합니다. **인증 필요.** `201 Created`.

| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| `target_type` | string | O | `meetup` · `user` · `chat_room` |
| `target_id` | string | O | 대상 ID (최대 64자) |
| `reason` | string | O | 신고 사유 (최대 40자) |
| `detail` | string | – | 추가 설명 (최대 500자) |

신고 대상의 정보(모임 제목·개설자, 사용자 닉네임 등)와 신고한 사람은 **서버가 직접 채워**
고객센터 주소로 메일을 보냅니다. 접수 사실은 서버 로그(`meetup.reports`)에도 남습니다.

`target_type` 이 `meetup` 이면 **신고한 사람에게 그 모임이 더 이상 보이지 않습니다**
(목록에서 빠지고 상세·참여 신청은 `404`). 차단 테이블에 `kind: "meetup"` 행으로 남기므로
별도 테이블이 없고, 차단 목록(`GET /api/v1/blocks`)에는 나타나지 않습니다.

| 코드 | 뜻 |
|---|---|
| `429` | 같은 대상을 60초 안에 다시 신고했을 때 |
| `503` | 메일 발송 설정이 없거나 발송에 실패했을 때 |

---

## 20. 고객센터 API

### POST /api/v1/support/inquiry

앱의 이메일 문의를 고객센터 주소로 보낸다. **인증 필요.**

| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| `subject` | string | O | 제목. 2~80자 |
| `body` | string | O | 내용. 5~2000자 |
| `app_version` | string | – | 앱 버전(최대 20자) |

닉네임·계정(전화번호 또는 이메일)·사용자 ID 는 **서버가 토큰에서 직접 채워** 본문 끝에 붙입니다.
클라이언트가 보낸 값을 쓰지 않으므로 위조할 수 없습니다.
이메일 계정이면 `Reply-To` 에 그 주소가 들어가 답장이 문의자에게 바로 갑니다.

```json
{ "ok": true, "message": "문의를 보냈어요. 영업일 기준 1~2일 안에 답변드립니다." }
```

| 코드 | 뜻 |
|---|---|
| `429` | 같은 사람이 30초 안에 다시 보냈을 때 |
| `503` | 메일 발송 설정이 없거나 발송에 실패했을 때 |

발송 설정은 환경변수로 정합니다.

| 변수 | 기본값 | 설명 |
|---|---|---|
| `MEETUP_MAIL_PROVIDER` | `aws_ses` | `aws_ses` · `smtp` · `console`(로그만) |
| `MEETUP_MAIL_SENDER` | `support@croninc.com` | 보내는 주소 |
| `MEETUP_SUPPORT_EMAIL` | `support@croninc.com` | 받는 주소 |
| `MEETUP_SMTP_HOST` / `_PORT` / `_USER` / `_PASSWORD` | – | `smtp` 일 때 |

기본값이 `aws_ses` 라서 **환경변수 없이 배포해도 SES 로 발송**합니다. 대신 AWS 쪽에
아래가 갖춰져 있어야 합니다.

- 발신 주소(또는 `croninc.com` 도메인)가 SES 에서 **검증**되어 있을 것
- SES 계정이 **샌드박스** 상태면 받는 주소도 검증되어 있어야 합니다
  (보내는 주소와 받는 주소가 같으므로 한 번만 검증하면 됩니다)
- EC2 인스턴스 IAM 역할에 **`ses:SendRawEmail`** 권한이 있을 것

현재 설정은 `GET /api/v1/health` 의 `mail_provider` 로 확인할 수 있습니다.

---

## 21. 데이터 저장소

DynamoDB (리전 `ap-northeast-2`, 전부 온디맨드 과금). 테이블 이름은 모두 `meetup_` 으로 시작합니다.

| 테이블 | 키 | 인덱스 | 용도 |
|---|---|---|---|
| `meetup_users` | `user_id` | `email-index`, `phone-index`, `device-index`, `geo-index`, `rating-index` | 회원·게스트 계정, 위치 |
| `meetup_sessions` | `jti` | `user-index` | 발급된 토큰(TTL 자동 만료), 로그아웃 시 삭제 |
| `meetup_meetups` | `meetup_id` | `open-index`, `host-index`, `geo-index` | 모임 |
| `meetup_participants` | `meetup_id` + `user_id` | `user-index` | 모임 참여자 |
| `meetup_requests` | `request_id` | `to-index`, `from-index` | 1:1 모임 신청 |
| `meetup_favorites` | `user_id` + `target_id` | – | 관심 목록 |
| `meetup_blocks` | `user_id` + `target_id` | `target-index` | 사용자 차단, 신고한 모임(`kind: meetup`), 지인(`kind: contact`) |
| `meetup_contacts` | `user_id` + `phone_hash` | `hash-index` | 지인 매칭 차단용으로 올린 번호(HMAC 해시만) |
| `meetup_blind_dates` | `request_id` | `status-index`, `user-index` | 소개팅 요청 |
| `meetup_identity_locks` | `identity` | – | 탈퇴 후 1년간 재가입해도 바꿀 수 없는 생년월일·성별(키는 `phone:`/`email:` + HMAC, TTL `expires_at`) |
| `meetup_friend_profiles` | `user_id` + `profile_id` | – | 내가 만든 지인 프로필(만든 사람만 봄, 최신순 정렬 ID) |
| `meetup_chat_rooms` | `room_id` | – | 1:1 대화방(참여자, 마지막 메시지) |
| `meetup_chat_members` | `user_id` + `room_id` | `room-index` | 내 대화방 목록, 읽음 시각 |
| `meetup_chat_messages` | `room_id` + `message_id` | – | 메시지(ID가 시간순으로 정렬됨) |
| `meetup_phone_codes` | `phone` | – | 자체 SMS 인증번호(TTL 3분, 해시 저장) |
| `meetup_photos` | `user_id` + `photo_id` | – | 프로필 사진(파일은 서버 `media/profiles/`) |
| `meetup_cash` | `user_id` | – | 캐시 잔액·던지기/잡기 누적 |
| `meetup_cash_events` | `user_id` + `event_id` | – | 캐시 적립·사용 내역 |
| `meetup_badges` | `user_id` + `badge_id` | – | 뱃지 심사 결과(최신 1건) |
| `meetup_verifications` | `verification_id` | – | AI 인증 요청 기록(프로필 사진·서류, 통과·실패 모두, 관리자 확인 여부). 이미지는 `private_media/verifications/` |
| `meetup_ratings` | `target_id` + `rater_id` | `rater-index` | 프로필 평점 |
| `meetup_notifications` | `user_id` + `notification_id` | – | 알림(최신순 정렬 ID) |
| `meetup_push_tokens` | `user_id` + `token` | – | 기기별 FCM 토큰 |

`meetup_sessions` 는 `expires_at` 속성으로 TTL 이 걸려 있어 만료된 토큰 기록이 자동 삭제됩니다.

---

## 22. 앱 화면 ↔ API 매핑

| 앱 화면 | 사용하는 API |
|---|---|
| 인트로 | – |
| 로그인 – 게스트로 로그인 | `POST /api/v1/auth/guest` |
| 로그인 – 첫 가입 직후 프로필 입력 | `PATCH /api/v1/users/me` (생년월일·성별·닉네임) |
| 로그인 – 휴대폰 번호로 로그인 | Firebase 문자 인증 → `POST /api/v1/auth/phone/firebase` |
| 로그인 – 이메일로 로그인/회원가입 | `POST /api/v1/auth/email/login`, `POST /api/v1/auth/email/signup` |
| 메인(모임) 탭 | `GET /api/v1/meetups` |
| 모임 만들기 시트 | `POST /api/v1/meetups`, `GET /api/v1/places/search`, `GET /api/v1/meetup-images/samples`, `POST /api/v1/meetup-images` |
| 모임 상세 | `GET /api/v1/meetups/{id}`, `GET .../participants`, `POST .../join`, `DELETE .../join`, `DELETE /api/v1/meetups/{id}` |
| 주변찾기 탭 | `PUT /api/v1/users/me/location`, `GET /api/v1/nearby/users`, `POST /api/v1/requests` |
| 상대 프로필 | `GET /api/v1/users/{id}`, `PUT`/`DELETE /api/v1/favorites/{id}`, `POST /api/v1/requests` |
| 더보기 – 프로필 카드(정식 계정) | `GET /api/v1/users/me`, `PATCH /api/v1/users/me` |
| 더보기 – 프로필 카드(게스트) | `POST /api/v1/auth/email/link` |
| 더보기 – 참여한 모임 | `GET /api/v1/users/me/meetups?role=joined\|hosted` |
| 더보기 – 관심 목록 | `GET /api/v1/favorites`, `DELETE /api/v1/favorites/{id}` |
| 채팅 탭 | `GET /api/v1/chat/rooms`, `GET /api/v1/chat/unread` |
| 대화방 | `GET`/`POST /api/v1/chat/rooms/{id}/messages`, `POST /api/v1/chat/rooms/{id}/read` |
| 상대 프로필 – 채팅하기 | `POST /api/v1/chat/rooms` |
| 더보기 – 모임 신청함 | `GET /api/v1/requests?box=received\|sent`, `POST /api/v1/requests/{id}/accept\|reject` |
| 더보기 – 프로필 사진 | `GET`/`POST /api/v1/users/me/photos`, `PUT .../main`, `DELETE .../{id}` |
| 더보기 – 실험실 – 캐시 현황 | `GET /api/v1/cash`, `GET /api/v1/cash/history` |
| 캐시 상점 | `GET /api/v1/store/items`, `POST /api/v1/store/purchase` |
| 상대 프로필 – 슈퍼 모임 신청 | `POST /api/v1/requests` (`use_super: true`) |
| 더보기 – 실험실 – 캐시캐치 | `POST /api/v1/cash/throw` |
| 더보기 – 프로필 수정 | `PATCH /api/v1/users/me`, `GET /api/v1/regions` |
| 각 탭 상단 알림 아이콘 | `GET /api/v1/notifications/unread` |
| 알림 페이지 | `GET /api/v1/notifications`, `POST .../{id}/read`, `POST .../read-all` |
| 더보기 – 뱃지 인증 | `GET /api/v1/badges`, `POST /api/v1/badges/{id}/verify` |
| 더보기 – 프로필 평점 | `GET`/`POST`/`DELETE /api/v1/ratings/request`, `GET /api/v1/ratings/me` |
| 더보기 – 유저평가하기 | `GET /api/v1/ratings/candidates`, `POST /api/v1/ratings/{user_id}` |
| 더보기 – 알림 설정 | `PUT /api/v1/users/me/settings` |
| 더보기 – 지인 매칭 차단 | `GET`/`PUT`/`DELETE /api/v1/contacts` |
| 소개팅 탭 | `GET /api/v1/blind-dates` |
| 더보기 – 지인 프로필 | `GET /api/v1/friend-profiles`, `DELETE /api/v1/friend-profiles/{id}` |
| 지인 프로필 새로만들기 | `POST /api/v1/friend-profiles`, `GET /api/v1/regions` |
| 지인 프로필 상세·수정 | `PUT /api/v1/friend-profiles/{id}`, `DELETE /api/v1/friend-profiles/{id}` |
| 소개팅 요청하기 | `POST /api/v1/blind-dates`, `GET /api/v1/regions` |
| 소개팅 상세 | `GET /api/v1/blind-dates/{id}` (성혼사례금·요청자 요약), `PUT /api/v1/blind-dates/{id}` (내 요청 수정), `POST /api/v1/blind-dates/{id}/chat` (소개팅 해주기: 본인·지인 프로필 여러 명 선택 또는 선택 없이 대화 요청), `GET /api/v1/friend-profiles` (소개할 지인 고르기), `DELETE /api/v1/blind-dates/{id}` (내 요청) |
| 더보기 – 고객센터 / 자주 묻는 질문 | 정적 화면 (서버 호출 없음) |
| 고객센터 – 이메일 문의 | `POST /api/v1/support/inquiry` |
| 더보기 – 앱 버전 | 정적 화면 (서버 호출 없음) |
| 더보기 – 로그아웃 | `POST /api/v1/auth/logout` |
| 더보기 – 회원탈퇴 | `DELETE /api/v1/users/me` |

---

## 23. 레거시 API

`meetup.croninc.com` 랜딩 페이지가 쓰는 데모 엔드포인트입니다. **앱에서는 쓰지 않습니다.**

`/api/health` · `/api/stats` · `/api/profiles` · `/api/profiles/{id}` · `/api/signup` · `/api/signup/{id}` · `/api/matches`

---

## 24. 정책

- 비밀번호는 PBKDF2-HMAC-SHA256(210,000회)로 해시해 저장하며 평문은 보관하지 않습니다.
- 위치 정보는 주변찾기 노출 목적으로만 쓰이며, 회원탈퇴 시 즉시 삭제됩니다.
- 주변찾기 응답에는 상대의 정확한 좌표 대신 **거리(m)** 와 60~150m 비틀어 둔 근사 좌표만 내려갑니다.
- 채팅은 매칭된 상대 사이에서만 열리며, 대화방 참여자가 아니면 메시지를 읽거나 보낼 수 없습니다.
- 회원탈퇴 시 내 대화방 목록은 삭제됩니다(상대방 화면에 남은 지난 메시지까지 지우지는 않습니다).
- 뱃지 인증 서류 사진은 심사 결과와 함께 비공개로 보관하며 운영자만 열람합니다(다른 회원에게 공개되지 않음, 탈퇴 시 삭제).
- 프로필 평점은 평점 인증을 요청한 사용자에 한해 수집되며, 요청을 취소하면 평가 대상에서 제외됩니다.
- 프로필 사진은 등록 시 AI 검수를 거치며, 검수를 통과하지 못한 사진은 프로필에 등록되지 않습니다(검수 요청 사진은 운영자 확인용으로 비공개 보관).
- 캐시 적립은 서버에서만 계산되며, 회원탈퇴 시 캐시와 내역도 함께 삭제됩니다.
- 자동화된 대량 수집(스크래핑)은 금지되며, 위반 시 차단될 수 있습니다.

---

문의: [support@croninc.com](mailto:support@croninc.com) · © 2026 CronInc
