1365 lines
24 KiB
Markdown
1365 lines
24 KiB
Markdown
# API Specification
|
|
|
|
라온누리 서버 API 명세서
|
|
|
|
## ⚠️ 최신 변경사항 (2025-11-10)
|
|
|
|
### 🔐 5단계 보안 레벨 시스템
|
|
팀 생성 시 5가지 보안 레벨 선택 가능:
|
|
- **Level 1 (OPEN)**: 완전 개방, 닉네임 공유 로그인
|
|
- **Level 2 (NAME_LIST)**: 명단 기반, allowedNames 체크
|
|
- **Level 3 (AUTH_REQUIRED)**: 로그인 필수, 정식 계정 누구나
|
|
- **Level 4 (EMAIL_LIST)**: 이메일 화이트리스트, allowedEmails 체크
|
|
- **Level 5 (CLOSED)**: 닫힌 팀, 신규 가입 불가
|
|
|
|
### 📦 User 타입 분리
|
|
- **FirestoreUser**: DB 저장용 (최소 데이터만)
|
|
- **User**: UI 사용용 (Firebase Auth + Firestore 결합)
|
|
- 이름, 이메일, 사진 등은 Firebase Auth가 Single Source of Truth
|
|
|
|
### 🏷️ 닉네임 저장 위치 변경
|
|
- ❌ 기존: `users.nicknames[teamId]`
|
|
- ✅ 신규: `team.members[uid].nickname`
|
|
|
|
---
|
|
|
|
## 개요
|
|
|
|
- **Base URL**: `/api` (환경변수 `NEXT_PUBLIC_API_URL`로 설정 가능, 기본값 `/api`)
|
|
- **엔드포인트**: `/team`, `/user` 등 (Base URL에 `/api` 포함됨)
|
|
- **실제 호출 URL**: `{BASE_URL}{endpoint}` → `/api/team`, `/api/user`
|
|
- **인증**: Firebase ID Token을 `Authorization: Bearer {token}` 헤더로 전달
|
|
- **응답 형식**: 모든 API는 `ApiResponse<T>` 형식 반환
|
|
|
|
```typescript
|
|
interface ApiResponse<T> {
|
|
success: boolean;
|
|
data?: T;
|
|
error?: ApiError;
|
|
}
|
|
|
|
interface ApiError {
|
|
code: string;
|
|
message: string;
|
|
details?: any;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Team API
|
|
|
|
### 1. POST `/team` - 팀 생성
|
|
실제 URL: `POST /api/team`
|
|
|
|
**인증**: 필수 (정식 계정)
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
name: string; // 팀 이름
|
|
code: string; // 팀 코드 (고유)
|
|
securityLevel: 1 | 2 | 3 | 4 | 5; // 🆕 5단계 보안 레벨
|
|
allowedNames?: string[]; // 🆕 Level 2용 (명단 기반)
|
|
allowedEmails?: string[]; // 🆕 Level 4용 (이메일 화이트리스트)
|
|
}
|
|
|
|
// 보안 레벨:
|
|
// 1: OPEN (완전 개방, 닉네임 공유 로그인)
|
|
// 2: NAME_LIST (명단 기반, 등록된 이름만)
|
|
// 3: AUTH_REQUIRED (로그인 필수, 정식 계정 누구나)
|
|
// 4: EMAIL_LIST (이메일 화이트리스트)
|
|
// 5: CLOSED (닫힌 팀, 신규 가입 불가)
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
teamId: string;
|
|
team: Team; // 생성된 팀 객체
|
|
}
|
|
}
|
|
```
|
|
|
|
**권한**: 현재 로그인한 사용자가 자동으로 팀 소유자가 됨
|
|
|
|
---
|
|
|
|
### 2. GET `/team/:id` - 팀 조회
|
|
실제 URL: `GET /api/team/:id`
|
|
|
|
**인증**: 선택적 (공개 팀은 인증 없이 조회 가능)
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
team: Team;
|
|
}
|
|
}
|
|
```
|
|
|
|
**캐싱**: 클라이언트에서 5분간 캐싱
|
|
|
|
---
|
|
|
|
### 3. POST `/team/search` - 팀 코드로 조회
|
|
실제 URL: `POST /api/team/search`
|
|
|
|
**인증**: 선택적
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
code: string; // 팀 코드 (정규화됨)
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
team: Team | null; // 팀이 없으면 null
|
|
}
|
|
}
|
|
```
|
|
|
|
**캐싱**: 클라이언트에서 1분간 캐싱
|
|
|
|
---
|
|
|
|
### 4. GET `/team/list` - 내 팀 목록
|
|
실제 URL: `GET /api/team/list`
|
|
|
|
**인증**: 필수 (정식 계정)
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
teams: Team[];
|
|
}
|
|
}
|
|
```
|
|
|
|
**권한**: 로그인한 사용자가 소유한 팀 + 참여한 팀 모두 조회 (중복 제거)
|
|
|
|
**백엔드 로직**:
|
|
1. `getTeamsByOwner(uid)` - 소유한 팀 조회
|
|
2. `getTeamsByMember(uid)` - 참여한 팀 조회 (`memberUids` array-contains)
|
|
3. 두 결과를 병합하고 중복 제거하여 반환
|
|
|
|
**캐싱**: 클라이언트에서 1분간 캐싱
|
|
|
|
---
|
|
|
|
### 5. PUT `/team/:id` - 팀 정보 수정
|
|
실제 URL: `PUT /api/team/:id`
|
|
|
|
**인증**: 필수
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
teamId: string;
|
|
data: {
|
|
name?: string;
|
|
securityMode?: "simple" | "normal" | "open";
|
|
requirePin?: boolean;
|
|
allowAnonymousJoin?: boolean;
|
|
isActive?: boolean;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
team: Team;
|
|
}
|
|
}
|
|
```
|
|
|
|
**권한**: 팀 소유자만 수정 가능
|
|
|
|
**캐시 무효화**: 해당 팀, 팀 목록
|
|
|
|
---
|
|
|
|
### 6. DELETE `/team/:id` - 팀 삭제 (Soft Delete)
|
|
실제 URL: `DELETE /api/team/:id`
|
|
|
|
**인증**: 필수
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
teamId: string;
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
success: true;
|
|
}
|
|
}
|
|
```
|
|
|
|
**권한**: 팀 소유자만 삭제 가능
|
|
|
|
**캐시 무효화**: 해당 팀, 팀 목록
|
|
|
|
---
|
|
|
|
### 7. POST `/team/add-student` - 팀에 학생 추가
|
|
실제 URL: `POST /api/team/add-student`
|
|
|
|
**인증**: 필수 (내부 사용 - StudentManager에서 호출)
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
teamId: string;
|
|
studentId: string;
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
success: true;
|
|
}
|
|
}
|
|
```
|
|
|
|
**캐시 무효화**: 해당 팀
|
|
|
|
---
|
|
|
|
### 8. POST `/team/remove-student` - 팀에서 학생 제거
|
|
실제 URL: `POST /api/team/remove-student`
|
|
|
|
**인증**: 필수
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
teamId: string;
|
|
studentId: string;
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
success: true;
|
|
}
|
|
}
|
|
```
|
|
|
|
**권한**: 팀 소유자만 제거 가능 (서버에서 검증)
|
|
|
|
**캐시 무효화**: 해당 팀
|
|
|
|
---
|
|
|
|
### 9. POST `/team/generate-code` - 고유 팀 코드 생성
|
|
실제 URL: `POST /api/team/generate-code`
|
|
|
|
**인증**: 선택적
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
maxAttempts?: number; // 기본값: 10
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
code: string; // 생성된 고유 팀 코드
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 10. POST `/team/check-code` - 팀 코드 존재 여부
|
|
실제 URL: `POST /api/team/check-code`
|
|
|
|
**인증**: 선택적
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
code: string;
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
exists: boolean;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## User API
|
|
|
|
**중요**: User vs FirestoreUser 구분
|
|
- **FirestoreUser**: DB 저장용 (uid, createdAt, lastLoginAt, settings만)
|
|
- **User**: API 응답/UI용 (Firebase Auth + FirestoreUser 결합)
|
|
|
|
### 1. POST `/user` - 사용자 생성
|
|
실제 URL: `POST /api/user`
|
|
|
|
**인증**: 필수
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
uid: string; // Firebase Auth UID
|
|
displayName: string; // Firebase Auth displayName 설정용
|
|
teamId: string; // 최초 가입 팀
|
|
isAnonymous: boolean; // 익명 여부
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
user: User; // Firebase Auth + Firestore 결합된 완전한 User
|
|
}
|
|
}
|
|
```
|
|
|
|
**부수 효과**:
|
|
- Firestore에 FirestoreUser 생성 (최소 데이터)
|
|
- 팀에 멤버 추가 (`team.members[uid]`)
|
|
- Firebase Auth displayName 설정
|
|
|
|
**캐시 무효화**: 팀별 사용자 목록
|
|
|
|
---
|
|
|
|
### 2. GET `/student/:id` - 학생 조회
|
|
실제 URL: `GET /api/student/:id`
|
|
|
|
**인증**: 선택적
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
student: Student;
|
|
}
|
|
}
|
|
```
|
|
|
|
**캐싱**: 클라이언트에서 5분간 캐싱
|
|
|
|
---
|
|
|
|
### 3. POST `/student/by-uid` - Firebase UID로 학생 조회
|
|
실제 URL: `POST /api/student/by-uid`
|
|
|
|
**인증**: 필수 (Anonymous Auth)
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
firebaseUid: string;
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
student: Student | null;
|
|
}
|
|
}
|
|
```
|
|
|
|
**용도**: 로그인 시 기존 학생 확인
|
|
|
|
**캐싱**: 클라이언트에서 5분간 캐싱
|
|
|
|
---
|
|
|
|
### 4. POST `/student/by-team` - 팀별 학생 목록
|
|
실제 URL: `POST /api/student/by-team`
|
|
|
|
**인증**: 선택적
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
teamId: string;
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
students: Student[];
|
|
}
|
|
}
|
|
```
|
|
|
|
**캐싱**: 클라이언트에서 30초간 캐싱 (자주 변경)
|
|
|
|
---
|
|
|
|
### 5. POST `/student/find-by-name` - 이름으로 학생 찾기
|
|
실제 URL: `POST /api/student/find-by-name`
|
|
|
|
**인증**: 선택적
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
teamId: string;
|
|
name: string;
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
student: Student | null;
|
|
}
|
|
}
|
|
```
|
|
|
|
**용도**: 학생 로그인 시 기존 학생 확인
|
|
|
|
---
|
|
|
|
### 6. PUT `/student/:id` - 학생 정보 수정
|
|
실제 URL: `PUT /api/student/:id`
|
|
|
|
**인증**: 필수
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
studentId: string;
|
|
data: {
|
|
name?: string;
|
|
pinHash?: string;
|
|
linkedUserId?: string;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
student: Student;
|
|
}
|
|
}
|
|
```
|
|
|
|
**캐시 무효화**: 해당 학생, 팀별 학생 목록
|
|
|
|
---
|
|
|
|
### 7. POST `/student/kick` - 팀에서 학생 강퇴
|
|
실제 URL: `POST /api/student/kick`
|
|
|
|
**인증**: 필수
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
studentId: string;
|
|
teamId: string;
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
success: true;
|
|
}
|
|
}
|
|
```
|
|
|
|
**권한**: 팀 소유자만 강퇴 가능
|
|
|
|
**부수 효과**:
|
|
- `student.teamIds`에서 팀 제거
|
|
- `team.studentIds`에서 학생 제거
|
|
|
|
**캐시 무효화**: 해당 학생, 팀, 팀별 학생 목록
|
|
|
|
---
|
|
|
|
### 8. POST `/student/update-pin` - PIN 변경
|
|
실제 URL: `POST /api/student/update-pin`
|
|
|
|
**인증**: 필수
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
studentId: string;
|
|
newPin: string; // 평문 (4자리 숫자)
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
success: true;
|
|
}
|
|
}
|
|
```
|
|
|
|
**검증**: 서버에서 PIN 형식 검증 (4자리 숫자)
|
|
|
|
**보안**: PIN은 SHA-256 해시로 저장
|
|
|
|
**캐시 무효화**: 해당 학생
|
|
|
|
---
|
|
|
|
### 9. POST `/student/validate-pin` - PIN 검증
|
|
실제 URL: `POST /api/student/validate-pin`
|
|
|
|
**인증**: 선택적
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
studentId: string;
|
|
pin: string;
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
valid: boolean;
|
|
}
|
|
}
|
|
```
|
|
|
|
**용도**: 학생 로그인 시 PIN 확인
|
|
|
|
---
|
|
|
|
### 10. POST `/student/link` - 정식 계정 연결
|
|
실제 URL: `POST /api/student/link`
|
|
|
|
**인증**: 필수 (정식 계정)
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
studentId: string;
|
|
// userId는 Authorization 헤더에서 추출
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
success: true;
|
|
}
|
|
}
|
|
```
|
|
|
|
**권한**: 현재 로그인한 사용자와 연결
|
|
|
|
**검증**:
|
|
- 학생이 이미 다른 계정과 연결되어 있으면 에러
|
|
|
|
**캐시 무효화**: 해당 학생, 내 학생 목록
|
|
|
|
---
|
|
|
|
### 11. POST `/student/unlink` - 정식 계정 연결 해제
|
|
실제 URL: `POST /api/student/unlink`
|
|
|
|
**인증**: 필수
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
studentId: string;
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
success: true;
|
|
}
|
|
}
|
|
```
|
|
|
|
**캐시 무효화**: 해당 학생, 내 학생 목록
|
|
|
|
---
|
|
|
|
### 12. GET `/student/my-students` - 내 학생 목록
|
|
실제 URL: `GET /api/student/my-students`
|
|
|
|
**인증**: 필수 (정식 계정)
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
students: Student[];
|
|
}
|
|
}
|
|
```
|
|
|
|
**권한**: 로그인한 사용자가 소유한 학생만 조회 (`linkedUserId` 기준)
|
|
|
|
**캐싱**: 클라이언트에서 1분간 캐싱
|
|
|
|
---
|
|
|
|
### 13. POST `/student/update-last-login` - 마지막 로그인 시간 업데이트
|
|
실제 URL: `POST /api/student/update-last-login`
|
|
|
|
**인증**: 선택적
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
studentId: string;
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
success: true;
|
|
}
|
|
}
|
|
```
|
|
|
|
**참고**: 실패해도 클라이언트는 에러를 무시 (크리티컬하지 않음)
|
|
|
|
---
|
|
|
|
## Writing API
|
|
|
|
### 1. POST `/writing` - 글 생성
|
|
실제 URL: `POST /api/writing`
|
|
|
|
**인증**: 필수
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
title: string;
|
|
content: string;
|
|
status?: "draft" | "published";
|
|
topicId?: string | null;
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
writingId: string;
|
|
writing: Writing;
|
|
}
|
|
}
|
|
```
|
|
|
|
**부수 효과**: 서버에서 wordCount, charCount 자동 계산
|
|
|
|
**캐시 무효화**: 사용자 글 목록, 최근 글
|
|
|
|
---
|
|
|
|
### 2. GET `/writing/:id` - 글 조회
|
|
실제 URL: `GET /api/writing/:id`
|
|
|
|
**인증**: 선택적
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
writing: Writing | null;
|
|
}
|
|
}
|
|
```
|
|
|
|
**캐싱**: 클라이언트에서 5분간 캐싱
|
|
|
|
---
|
|
|
|
### 3. POST `/writing/user` - 사용자의 글 목록
|
|
실제 URL: `POST /api/writing/user`
|
|
|
|
**인증**: 필수
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
userId?: string; // 없으면 현재 사용자
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
writings: Writing[];
|
|
}
|
|
}
|
|
```
|
|
|
|
**캐싱**: 클라이언트에서 1분간 캐싱
|
|
|
|
---
|
|
|
|
### 4. POST `/writing/recent` - 최근 글
|
|
실제 URL: `POST /api/writing/recent`
|
|
|
|
**인증**: 필수
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
limit?: number; // 기본값: 5
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
writings: Writing[];
|
|
}
|
|
}
|
|
```
|
|
|
|
**캐싱**: 클라이언트에서 30초간 캐싱
|
|
|
|
---
|
|
|
|
### 5. PUT `/writing/:id` - 글 수정
|
|
실제 URL: `PUT /api/writing/:id`
|
|
|
|
**인증**: 필수
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
writingId: string;
|
|
data: {
|
|
title?: string;
|
|
content?: string;
|
|
status?: "draft" | "published";
|
|
topicId?: string | null;
|
|
wordCount?: number;
|
|
charCount?: number;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
writing: Writing;
|
|
}
|
|
}
|
|
```
|
|
|
|
**권한**: 작성자만 수정 가능
|
|
|
|
**캐시 무효화**: 해당 글, 사용자 글 목록, 최근 글
|
|
|
|
---
|
|
|
|
### 6. DELETE `/writing/:id` - 글 삭제
|
|
실제 URL: `DELETE /api/writing/:id`
|
|
|
|
**인증**: 필수
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
writingId: string;
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
success: true;
|
|
}
|
|
}
|
|
```
|
|
|
|
**권한**: 작성자만 삭제 가능
|
|
|
|
**캐시 무효화**: 해당 글, 사용자 글 목록, 최근 글
|
|
|
|
---
|
|
|
|
## Topic API
|
|
|
|
### 1. POST `/topic/available` - 사용 가능한 주제 목록
|
|
실제 URL: `POST /api/topic/available`
|
|
|
|
**인증**: 필수
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
teamIds?: string[]; // 팀 주제를 가져올 팀 ID 목록
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
topics: Topic[];
|
|
}
|
|
}
|
|
```
|
|
|
|
**백엔드 로직**:
|
|
1. 개인 주제: `ownerType === PERSONAL && ownerId === currentUserId`
|
|
2. 팀 주제: `ownerType === TEAM && ownerId in [teamId1, teamId2, ...]`
|
|
- 클라이언트에서 `teamIds: ["team1", "team2"]` 전달
|
|
- 서버에서 필터링
|
|
3. 모든 주제를 병합하여 반환
|
|
|
|
**캐싱**: 클라이언트에서 5분간 캐싱
|
|
|
|
**참고**: 그룹(Group) 기능은 제거되었습니다. 팀(Team) 기능만 사용합니다.
|
|
|
|
---
|
|
|
|
### 2. GET `/topic/:id` - 주제 조회
|
|
실제 URL: `GET /api/topic/:id`
|
|
|
|
**인증**: 선택적
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
topic: Topic | null;
|
|
}
|
|
}
|
|
```
|
|
|
|
**캐싱**: 클라이언트에서 5분간 캐싱
|
|
|
|
---
|
|
|
|
### 3. POST `/topic` - 개인 주제 생성
|
|
실제 URL: `POST /api/topic`
|
|
|
|
**인증**: 필수
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
title: string;
|
|
description: string;
|
|
category: "daily" | "imagination" | "emotion" | "experience";
|
|
difficulty: "easy" | "medium" | "hard";
|
|
keywords?: string[];
|
|
examplePrompts?: string[];
|
|
titleTemplate?: string;
|
|
contentTemplate?: string;
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
topicId: string;
|
|
topic: Topic;
|
|
}
|
|
}
|
|
```
|
|
|
|
**권한**: 로그인한 사용자가 자동으로 소유자가 됨
|
|
|
|
**캐시 무효화**: 주제 목록
|
|
|
|
---
|
|
|
|
### 4. PUT `/topic/:id` - 개인 주제 수정
|
|
실제 URL: `PUT /api/topic/:id`
|
|
|
|
**인증**: 필수
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
topicId: string;
|
|
data: {
|
|
title?: string;
|
|
description?: string;
|
|
category?: "daily" | "imagination" | "emotion" | "experience";
|
|
difficulty?: "easy" | "medium" | "hard";
|
|
keywords?: string[];
|
|
examplePrompts?: string[];
|
|
titleTemplate?: string;
|
|
contentTemplate?: string;
|
|
isActive?: boolean;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
topic: Topic;
|
|
}
|
|
}
|
|
```
|
|
|
|
**권한**: 주제 소유자만 수정 가능
|
|
|
|
**캐시 무효화**: 해당 주제, 주제 목록
|
|
|
|
---
|
|
|
|
### 5. DELETE `/topic/:id` - 개인 주제 삭제
|
|
실제 URL: `DELETE /api/topic/:id`
|
|
|
|
**인증**: 필수
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
topicId: string;
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
success: true;
|
|
}
|
|
}
|
|
```
|
|
|
|
**권한**: 주제 소유자만 삭제 가능
|
|
|
|
**캐시 무효화**: 해당 주제, 주제 목록
|
|
|
|
---
|
|
|
|
### 6. POST `/topic/increment-usage` - 주제 사용 횟수 증가
|
|
실제 URL: `POST /api/topic/increment-usage`
|
|
|
|
**인증**: 선택적
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
topicId: string;
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
success: true;
|
|
}
|
|
}
|
|
```
|
|
|
|
**참고**: 실패해도 클라이언트는 에러를 무시 (크리티컬하지 않음)
|
|
|
|
---
|
|
|
|
### 7. POST `/topic/team` - 팀 주제 생성
|
|
실제 URL: `POST /api/topic/team`
|
|
|
|
**인증**: 필수 (팀 소유자만)
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
teamId: string; // 팀 ID (ownerId = teamId)
|
|
title: string;
|
|
description: string;
|
|
category: "daily" | "imagination" | "emotion" | "experience";
|
|
difficulty: "easy" | "medium" | "hard";
|
|
keywords?: string[];
|
|
examplePrompts?: string[];
|
|
titleTemplate?: string;
|
|
contentTemplate?: string;
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
topicId: string;
|
|
topic: Topic; // ownerType: TEAM, ownerId: teamId
|
|
}
|
|
}
|
|
```
|
|
|
|
**권한**:
|
|
- 로그인한 사용자가 팀 소유자인지 확인 (`team.ownerId === currentUserId`)
|
|
- 팀이 활성화 상태인지 확인 (`team.isActive === true`)
|
|
|
|
**백엔드 처리**:
|
|
```typescript
|
|
|
|
const topic = {
|
|
...requestData,
|
|
ownerType: TopicOwnerType.TEAM,
|
|
ownerId: teamId,
|
|
createdBy: currentUserId,
|
|
usageCount: 0,
|
|
isActive: true,
|
|
createdAt: serverTimestamp(),
|
|
updatedAt: serverTimestamp(),
|
|
};
|
|
```
|
|
|
|
**캐시 무효화**: 주제 목록, 팀 주제 목록
|
|
|
|
---
|
|
|
|
### 8. GET `/topic/team/:teamId` - 팀 주제 목록 조회
|
|
실제 URL: `GET /api/topic/team/:teamId`
|
|
|
|
**인증**: 선택적 (공개 조회 가능)
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
topics: Topic[]; // ownerType === TEAM && ownerId === teamId
|
|
}
|
|
}
|
|
```
|
|
|
|
**백엔드 로직**:
|
|
```typescript
|
|
|
|
const topics = await db.collection('topics')
|
|
.where('ownerType', '==', TopicOwnerType.TEAM)
|
|
.where('ownerId', '==', teamId)
|
|
.where('isActive', '==', true)
|
|
.orderBy('createdAt', 'desc')
|
|
.get();
|
|
```
|
|
|
|
**캐싱**: 클라이언트에서 5분간 캐싱
|
|
|
|
---
|
|
|
|
### 9. PUT `/topic/team/:id` - 팀 주제 수정
|
|
실제 URL: `PUT /api/topic/team/:id`
|
|
|
|
**인증**: 필수 (팀 소유자만)
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
topicId: string;
|
|
teamId: string; // 소유권 검증용
|
|
data: {
|
|
title?: string;
|
|
description?: string;
|
|
category?: "daily" | "imagination" | "emotion" | "experience";
|
|
difficulty?: "easy" | "medium" | "hard";
|
|
keywords?: string[];
|
|
examplePrompts?: string[];
|
|
titleTemplate?: string;
|
|
contentTemplate?: string;
|
|
isActive?: boolean;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
topic: Topic;
|
|
}
|
|
}
|
|
```
|
|
|
|
**권한 검증**:
|
|
1. 주제가 팀 주제인지 확인: `topic.ownerType === TEAM && isTeamOwnerId(topic.ownerId)`
|
|
2. 요청한 teamId와 주제의 teamId 일치 확인: `extractTeamId(topic.ownerId) === teamId`
|
|
3. 현재 사용자가 팀 소유자인지 확인: `team.ownerId === currentUserId`
|
|
|
|
**캐시 무효화**: 해당 주제, 주제 목록, 팀 주제 목록
|
|
|
|
---
|
|
|
|
### 10. DELETE `/topic/team/:id` - 팀 주제 삭제
|
|
실제 URL: `DELETE /api/topic/team/:id`
|
|
|
|
**인증**: 필수 (팀 소유자만)
|
|
|
|
**Request**:
|
|
```typescript
|
|
{
|
|
topicId: string;
|
|
teamId: string; // 소유권 검증용
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
success: true;
|
|
}
|
|
}
|
|
```
|
|
|
|
**권한 검증**: PUT과 동일
|
|
|
|
**캐시 무효화**: 해당 주제, 주제 목록, 팀 주제 목록
|
|
|
|
**참고**: Soft delete 방식 (`isActive: false`로 설정)
|
|
|
|
---
|
|
|
|
## 에러 코드
|
|
|
|
| 코드 | 설명 |
|
|
|------|------|
|
|
| `UNAUTHORIZED` | 인증 필요 |
|
|
| `FORBIDDEN` | 권한 없음 |
|
|
| `NOT_FOUND` | 리소스 없음 |
|
|
| `VALIDATION_ERROR` | 유효성 검사 실패 |
|
|
| `TEAM_CODE_INVALID` | 팀 코드 형식 오류 |
|
|
| `TEAM_INACTIVE` | 비활성화된 팀 |
|
|
| `PIN_REQUIRED` | PIN 입력 필요 |
|
|
| `PIN_INVALID` | PIN 불일치 |
|
|
| `STUDENT_NAME_DUPLICATE` | 팀 내 이름 중복 |
|
|
| `ALREADY_EXISTS` | 리소스 중복 |
|
|
| `INTERNAL_ERROR` | 서버 오류 |
|
|
|
|
---
|
|
|
|
## 구현 노트
|
|
|
|
### Next.js API Routes 구현 예시
|
|
|
|
```typescript
|
|
// app/api/team/route.ts
|
|
import { NextRequest, NextResponse } from 'next/server';
|
|
import { verifyIdToken } from '@/lib/auth';
|
|
import { createTeam } from '@/services/teamService';
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
// 1. 인증 확인
|
|
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
|
|
const decoded = await verifyIdToken(token);
|
|
|
|
if (!decoded) {
|
|
return NextResponse.json({
|
|
success: false,
|
|
error: { code: 'UNAUTHORIZED', message: '로그인이 필요합니다.' }
|
|
}, { status: 401 });
|
|
}
|
|
|
|
// 2. Request 파싱
|
|
const body = await request.json();
|
|
|
|
// 3. 유효성 검사
|
|
if (!body.name || !body.code) {
|
|
return NextResponse.json({
|
|
success: false,
|
|
error: { code: 'VALIDATION_ERROR', message: '필수 필드가 누락되었습니다.' }
|
|
}, { status: 400 });
|
|
}
|
|
|
|
// 4. 비즈니스 로직 실행
|
|
const teamId = await createTeam({
|
|
...body,
|
|
ownerId: decoded.uid
|
|
});
|
|
|
|
// 5. 응답
|
|
return NextResponse.json({
|
|
success: true,
|
|
data: { teamId, team: {...} }
|
|
});
|
|
|
|
} catch (error: any) {
|
|
return NextResponse.json({
|
|
success: false,
|
|
error: { code: 'INTERNAL_ERROR', message: error.message }
|
|
}, { status: 500 });
|
|
}
|
|
}
|
|
```
|
|
|
|
### Server Actions 구현 예시
|
|
|
|
```typescript
|
|
// app/actions/team.ts
|
|
'use server'
|
|
|
|
import { auth } from '@/lib/auth';
|
|
import { createTeam as createTeamFirestore } from '@/services/teamService';
|
|
import type { ApiResponse } from '@/types/api';
|
|
import type { CreateTeamRequest, CreateTeamResponse } from '@/types/api/team';
|
|
|
|
export async function createTeam(data: CreateTeamRequest): Promise<ApiResponse<CreateTeamResponse>> {
|
|
try {
|
|
const session = await auth();
|
|
|
|
if (!session) {
|
|
return {
|
|
success: false,
|
|
error: { code: 'UNAUTHORIZED', message: '로그인이 필요합니다.' }
|
|
};
|
|
}
|
|
|
|
const teamId = await createTeamFirestore({
|
|
...data,
|
|
ownerId: session.uid
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
data: { teamId, team: {...} }
|
|
};
|
|
} catch (error: any) {
|
|
return {
|
|
success: false,
|
|
error: { code: 'INTERNAL_ERROR', message: error.message }
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 보안 고려사항
|
|
|
|
1. **인증 토큰 검증**: 모든 쓰기 작업은 Firebase ID Token 검증 필수
|
|
2. **권한 체크**: 팀 소유자 확인 (ownerId === decoded.uid)
|
|
3. **입력 검증**: 모든 입력값 sanitization 및 validation
|
|
4. **Rate Limiting**: Redis로 API 호출 횟수 제한 (선택적)
|
|
5. **PIN 보안**: PIN은 평문으로 받아 서버에서 SHA-256 해시로 저장
|
|
|
|
---
|
|
|
|
## Redis 캐싱 전략 (서버 사이드)
|
|
|
|
### 캐싱 대상
|
|
- 팀 정보: `redis:team:{teamId}` - TTL 5분
|
|
- 팀 코드 조회: `redis:team:code:{code}` - TTL 1분
|
|
- 학생 정보: `redis:student:{studentId}` - TTL 5분
|
|
- 팀별 학생 목록: `redis:students:team:{teamId}` - TTL 30초
|
|
|
|
### 캐시 무효화
|
|
- 팀 생성/수정/삭제 시: 해당 팀 + 팀 목록
|
|
- 학생 생성/수정 시: 해당 학생 + 팀별 학생 목록
|
|
- 강퇴 시: 학생 + 팀 + 팀별 학생 목록
|
|
|
|
---
|
|
|
|
## 클라이언트 캐싱 전략
|
|
|
|
매니저 레벨에서 in-memory 캐싱 (SingletonManager):
|
|
- **조회 작업**: 캐싱 활성화 (GET 요청)
|
|
- **변경 작업**: 캐싱 안 함 (POST/PUT/DELETE)
|
|
- **캐시 무효화**: 변경 작업 시 관련 캐시 자동 무효화
|
|
|
|
---
|
|
|
|
## 개발 순서
|
|
|
|
1. ✅ API 타입 정의 (`src/types/api.ts`, `src/types/api/team.ts`, `src/types/api/student.ts`)
|
|
2. ✅ BaseManager에 `authenticatedFetch`, `callApi` 구현
|
|
3. ✅ BaseManager에 클라이언트 캐싱 메서드 구현
|
|
4. ✅ TeamManager, StudentManager를 API 호출 방식으로 전환
|
|
5. ⏳ Next.js API Routes 또는 Server Actions 구현
|
|
6. ⏳ Redis 캐싱 구현 (선택적)
|
|
7. ⏳ Rate Limiting 구현 (선택적)
|