# MolluLog API
> 학생 검색과 모집 학생 상태 읽기·쓰기를 위한 API예요.

Base URL: https://api.mollulog.net/v1
OpenAPI: https://api.mollulog.net/v1/openapi.json
사람용 문서: https://mollulog.net/developers/docs

## 인증과 키 취급
- 요청마다 `Authorization: Bearer <API 키>` 헤더를 보내세요.
- API 키는 `MOLLULOG_API_KEY` 환경 변수에 저장하고 코드, 커밋, 로그에 남기지 마세요.
- 연동에 필요한 권한만 부여하세요. 쓰기 권한을 고르면 읽기 권한도 함께 부여돼요.

## 핵심 실행 흐름
1. 학생 찾기로 UID를 확인한 뒤 모집 학생 조회를 요청하세요.
2. 조회 결과가 `student_not_recruited`이면 POST로 등록하세요.
3. POST가 `student_already_recruited`로 끝나면 PATCH로 필요한 필드만 보내세요.

## 엔드포인트

### GET /students?query=
권한: 학생 읽기
학생 찾기

파라미터:
- `query`: 학생 UID 또는 이름 검색어 (1–200자)
- `limit`: 기본 50 · 최대 100
- `cursor`: 이전 응답에서 받은 nextCursor

요청 예시:
```bash
curl --request GET \
  --url 'https://api.mollulog.net/v1/students?query=아루' \
  --header 'Authorization: Bearer <API_KEY>' \
  --header 'Accept: application/json'
```

응답 예시:
```json
{
  "students": [
    {
      "studentUid": "10000",
      "name": "아루"
    }
  ],
  "nextCursor": null
}
```

students(UID·이름)와 nextCursor

오류: `400 invalid_request` · `401 unauthorized` · `403 forbidden` · `429 rate_limited` · `500 internal_error` · `503 unavailable`

### GET /me/students
권한: 학생 읽기
모집 학생 목록

파라미터:
- `limit`: 기본 50 · 최대 100
- `cursor`: 이전 응답에서 받은 nextCursor

요청 예시:
```bash
curl --request GET \
  --url 'https://api.mollulog.net/v1/me/students?limit=50&cursor=<CURSOR>' \
  --header 'Authorization: Bearer <API_KEY>' \
  --header 'Accept: application/json'
```

응답 예시:
```json
{
  "students": [
    {
      "studentUid": "10000",
      "tier": 5,
      "weaponTier": 0,
      "level": 90,
      "skillEx": 5,
      "skillNormal": 10,
      "skillEnhanced": 10,
      "skillSub": 10,
      "equip1": 10,
      "equip2": 10,
      "equip3": 10,
      "equip1Level": 70,
      "equip2Level": 70,
      "equip3Level": 70,
      "equipSpecial": 2,
      "weaponLevel": 0,
      "abilityHp": 0,
      "abilityAtk": 0,
      "abilityHeal": 0,
      "bond": 20,
      "bondExp": 1845
    }
  ],
  "nextCursor": null
}
```

모집 학생의 현재 상태 목록

오류: `400 invalid_request` · `401 unauthorized` · `403 forbidden` · `429 rate_limited` · `500 internal_error` · `503 unavailable`

### GET /me/students/:studentUid
권한: 학생 읽기
모집 학생 조회

파라미터:
- `studentUid`: 학생 찾기로 얻은 UID

요청 예시:
```bash
curl --request GET \
  --url 'https://api.mollulog.net/v1/me/students/10000' \
  --header 'Authorization: Bearer <API_KEY>' \
  --header 'Accept: application/json'
```

응답 예시:
```json
{
  "studentUid": "10000",
  "tier": 5,
  "weaponTier": 0,
  "level": 90,
  "skillEx": 5,
  "skillNormal": 10,
  "skillEnhanced": 10,
  "skillSub": 10,
  "equip1": 10,
  "equip2": 10,
  "equip3": 10,
  "equip1Level": 70,
  "equip2Level": 70,
  "equip3Level": 70,
  "equipSpecial": 2,
  "weaponLevel": 0,
  "abilityHp": 0,
  "abilityAtk": 0,
  "abilityHeal": 0,
  "bond": 20,
  "bondExp": 1845
}
```

등록하지 않은 학생이면 `404 student_not_recruited`

오류: `400 invalid_request` · `401 unauthorized` · `403 forbidden` · `404 not_found` · `404 student_not_recruited` · `429 rate_limited` · `500 internal_error` · `503 unavailable`

### POST /me/students
권한: 학생 쓰기
모집 학생 등록

요청 예시:
```bash
curl --request POST \
  --url 'https://api.mollulog.net/v1/me/students' \
  --header 'Authorization: Bearer <API_KEY>' \
  --header 'Content-Type: application/json' \
  --data '{"studentUid":"10000","tier":5,"weaponTier":0,"level":90}'
```

응답 예시:
```json
{
  "studentUid": "10000"
}
```

studentUid, tier, weaponTier는 꼭 보내야 해요.
응답에 현재 상태 필드가 함께 와요
이미 등록한 학생이면 `409 student_already_recruited`

오류: `400 invalid_request` · `401 unauthorized` · `403 forbidden` · `404 not_found` · `409 student_already_recruited` · `429 rate_limited` · `500 internal_error` · `503 unavailable`

### PATCH /me/students/:studentUid
권한: 학생 쓰기
모집 학생 수정

파라미터:
- `studentUid`: 학생 찾기로 얻은 UID

요청 예시:
```bash
curl --request PATCH \
  --url 'https://api.mollulog.net/v1/me/students/10000' \
  --header 'Authorization: Bearer <API_KEY>' \
  --header 'Content-Type: application/json' \
  --data '{"level":90,"bond":20}'
```

응답 예시:
```json
{
  "studentUid": "10000"
}
```

보낸 필드만 바뀌어요 · 등록하지 않은 학생이면 `404 student_not_recruited`

오류: `400 invalid_request` · `401 unauthorized` · `403 forbidden` · `404 not_found` · `404 student_not_recruited` · `429 rate_limited` · `500 internal_error` · `503 unavailable`

### DELETE /me/students/:studentUid
권한: 학생 쓰기
모집 학생 삭제

파라미터:
- `studentUid`: 학생 찾기로 얻은 UID

요청 예시:
```bash
curl --request DELETE \
  --url 'https://api.mollulog.net/v1/me/students/10000' \
  --header 'Authorization: Bearer <API_KEY>'
```

응답 예시:
```json
{
  "studentUid": "10000",
  "deleted": true
}
```

모집 등록, 현재 성장도, 인연·경험치를 지워요 · 육성 목표, 선물 계획, 플래너는 남아요

오류: `400 invalid_request` · `401 unauthorized` · `403 forbidden` · `404 not_found` · `404 student_not_recruited` · `429 rate_limited` · `500 internal_error` · `503 unavailable`

## 학생 상태 필드

| 필드 | 의미 | 범위 | null로 비우기 |
| --- | --- | --- | --- |
| `tier` | 학생 성급<br>등록할 때 필요 | 1–5 | 불가 |
| `weaponTier` | 고유무기 성급<br>0은 고유무기 없음 · 1 이상은 학생 성급 5에서만 · 등록할 때 필요 | 0–4 | 불가 |
| `level` | 학생 Lv | 1–90 | 가능 |
| `skillEx` | EX 스킬 | 1–5 | 가능 |
| `skillNormal`<br>`skillEnhanced`<br>`skillSub` | 기본 · 강화 · 서브 스킬 | 1–10 | 가능 |
| `equip1`<br>`equip2`<br>`equip3` | 장비 1 · 2 · 3 티어 | 1–10 | 가능 |
| `equip1Level`<br>`equip2Level`<br>`equip3Level` | 장비 1 · 2 · 3 레벨 | 1–70 | 가능 |
| `equipSpecial` | 애용품 | 1–2 | 가능 |
| `weaponLevel` | 고유무기 Lv<br>고유무기 성급에 따라 최대값이 달라요 | 0–60 | 가능 |
| `abilityHp`<br>`abilityAtk`<br>`abilityHeal` | 능력 해방 (HP · 공격력 · 치유력)<br>1 이상은 고유무기가 있을 때만 | 0–25 | 가능 |
| `bond` | 인연 랭크 | 1–100 | 가능 |
| `bondExp` | 인연 누적 경험치<br>인연 랭크와 맞지 않으면 비워져요 | 0–240,225 | 가능 |

### 필드 간 제약
- 고유무기 성급은 0–4이고, 1 이상이면 학생 성급은 5여야 해요. 등록할 때 학생 성급과 고유무기 성급을 모두 보내세요.
- 고유무기 Lv 범위는 0–60이며 고유무기 성급에 따라 상한이 달라요. 고유무기 성급 0이면 `weaponLevel`은 0 또는 null이어야 해요. 고유무기 성급 1부터 4까지 각각 30·40·50·60까지 입력할 수 있어요.
- 능력 해방 필드는 각각 0–25이고, 1 이상은 고유무기가 있을 때만 입력할 수 있어요.
- 인연 경험치는 현재 인연 랭크의 구간에 맞아야 해요. 인연 랭크와 맞지 않으면 요청 성공 후 경험치가 null로 비워져요.
- 등록할 때 보내지 않은 상태 필드는 null로 저장돼요. 다만 이미 기록된 인연 랭크와 누적 경험치가 있으면 그대로 유지돼요. PATCH에서 보내지 않은 필드는 그대로 유지돼요.
- 모든 변경을 합친 결과가 규칙에 맞지 않으면 요청 전체를 거부해요. 다른 필드를 대신 수정하지 않아요.
- 쓰기 본문은 학생 한 명의 객체여야 해요. 배열은 보낼 수 없어요.

학생 찾기와 학생 상태 요청에서 폼 체인지 학생의 다른 폼 UID를 보내도 대표 UID로 처리돼요. 응답의 studentUid를 다음 요청에 사용하세요. 수영복처럼 따로 모집하는 버전은 서로 다른 학생으로 처리돼요.
모집 학생 조회, POST, PATCH 응답에는 대표 studentUid와 현재 상태 필드가 포함돼요.
DELETE는 모집 등록, 현재 성장도, 인연·경험치를 지워요. 육성 목표, 선물 계획, 플래너는 남아요.

## 오류 code별 대응

| HTTP | code | 의미와 대응 |
| --- | --- | --- |
| 400 | `invalid_request` | 잘못된 요청 · 요청 내용을 확인해주세요 · 문제가 된 필드는 fields에 담겨요 |
| 401 | `unauthorized` | 인증 실패 · API 키가 없거나, 올바르지 않거나, 만료·삭제됐어요 |
| 403 | `forbidden` | 권한 없음 · 키에 필요한 권한이 없거나 계정이 활성 상태가 아니에요 |
| 404 | `not_found` | 찾을 수 없음 · API 경로나 학생 UID를 확인해주세요 |
| 404 | `student_not_recruited` | 등록하지 않은 학생 · 먼저 POST로 등록해주세요 |
| 409 | `student_already_recruited` | 이미 등록한 학생 · 값을 바꾸려면 PATCH로 보내주세요 |
| 429 | `rate_limited` | 요청 한도 초과 · Retry-After만큼 기다린 뒤 다시 보내주세요 |
| 500 | `internal_error` | 서버 오류 · 요청이 처리됐는지 알 수 없어요 · GET으로 확인한 뒤 필요하면 다시 보내주세요 |
| 503 | `unavailable` | 잠시 사용할 수 없음 · 요청을 처리하지 않았어요 · 잠시 후 다시 보내주세요 |

- `code`는 오류 종류를 나타내는 안정된 식별자예요.
- `message`는 해당 요청에서 확인할 내용을 한국어로 설명해요.
- `fields`는 입력 검증 오류가 난 경우에만 포함되며 문제 필드와 이유를 담아요.
- `docs`는 같은 오류 code의 설명으로 이동하는 URL이에요.

오류 응답 예시:
```json
{
  "error": {
    "code": "invalid_request",
    "message": "요청 내용을 확인해주세요.",
    "fields": [{ "field": "level", "message": "1부터 90 사이여야 해요." }],
    "docs": "https://mollulog.net/developers/docs#error-invalid_request"
  }
}
```

## 한도와 재시도
- 목록의 limit은 기본 50명이고, 한 번에 최대 100명이에요.
- cursor는 이전 응답의 nextCursor를 그대로 보내는 불투명한 페이지 토큰이에요.
- 학생 찾기와 모집 학생 목록 응답의 nextCursor가 null이면 다음 페이지가 없어요.
- 읽기는 계정과 Cloudflare 지역별로 분당 60회까지 허용돼요.
- 쓰기는 모든 API 키를 합산해 계정당 10분 창에 500회까지 허용돼요. 첫 쓰기 요청이 창을 시작해요.
- 쓰기 권한으로 인증된 요청은 처리 결과와 관계없이 한 번으로 세요. 인증·권한 실패와 이미 한도를 넘은 429 요청은 세지 않아요.
- 요청 본문은 1MiB까지 보낼 수 있어요.
- API 키는 발급 후 1년 동안 유효하며 만료되거나 삭제된 키는 인증에 사용할 수 없어요.
- 쓰기 요청은 멱등하지 않아요. 같은 요청을 다시 보내면 다시 처리돼요.
- 결과를 알 수 없으면 먼저 GET으로 현재 상태를 확인하세요. 원하는 상태가 아니면 필요한 쓰기를 다시 보내세요.
- 429 응답의 Retry-After 초만큼 기다린 뒤 다시 보내세요.
- 500 응답은 처리 여부를 알 수 없으므로 GET으로 확인한 뒤 필요하면 다시 보내세요. 503은 요청을 처리하지 않았으므로 잠시 후 다시 보내세요.

## 동기화 예제
```ts
const baseUrl = "https://api.mollulog.net/v1";
const apiKey = process.env.MOLLULOG_API_KEY;
if (!apiKey) throw new Error("MOLLULOG_API_KEY 환경 변수를 설정해주세요.");

const headers = {
  Authorization: `Bearer ${apiKey}`,
  "Content-Type": "application/json",
};
const studentQuery = await fetch(`${baseUrl}/students?query=10000`, { headers });
if (!studentQuery.ok) throw new Error("학생 찾기 요청을 확인해주세요.");
const found = (await studentQuery.json()) as { students: Array<{ studentUid: string }> };
const studentUid = found.students[0]?.studentUid;
if (!studentUid) throw new Error("학생 UID를 찾지 못했어요.");

const stateUrl = `${baseUrl}/me/students/${encodeURIComponent(studentUid)}`;
const current = await fetch(stateUrl, { headers });
const body = { studentUid, tier: 5, weaponTier: 0, level: 90 };

if (current.ok) {
  const updated = await fetch(stateUrl, { method: "PATCH", headers, body: JSON.stringify({ level: 90 }) });
  if (!updated.ok) throw new Error("학생 상태 수정을 확인해주세요.");
} else {
  const error = (await current.json()) as { error?: { code?: string } };
  if (current.status !== 404 || error.error?.code !== "student_not_recruited") {
    throw new Error("학생 상태 조회 결과를 확인해주세요.");
  }
  const registered = await fetch(`${baseUrl}/me/students`, { method: "POST", headers, body: JSON.stringify(body) });
  if (registered.status === 409) {
    const updated = await fetch(stateUrl, { method: "PATCH", headers, body: JSON.stringify({ level: 90 }) });
    if (!updated.ok) throw new Error("학생 상태 수정을 확인해주세요.");
  } else if (!registered.ok) {
    throw new Error("학생 등록 결과를 확인해주세요.");
  }
}
```
