2026-08-11 05:50:34 +00:00

2388 lines
62 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# API Specification
라온누리 서버 API 명세서
---
## 🔧 API 개발 필수 가이드
### RESTful API 설계 원칙
**HTTP Method로 동작 구분** (경로로 구분하지 않음):
```typescript
// ✅ 올바른 방식 (RESTful)
POST /api/team/:id/members // 멤버 추가
DELETE /api/team/:id/members // 멤버 제거
// ❌ 잘못된 방식 (경로로 구분)
POST /api/team/:id/members/add // ❌
POST /api/team/:id/members/remove // ❌
```
**API Route 파일 구조**:
```typescript
// src/app/api/team/[teamId]/members/route.ts
export async function POST(request: NextRequest, context: RouteContext) {
// 추가 로직
}
export async function DELETE(request: NextRequest, context: RouteContext) {
// 삭제 로직
}
```
**HTTP Method 사용 가이드**:
- **GET**: 조회 (캐싱 가능, 멱등성)
- **POST**: 생성, 추가, 복잡한 조회
- **PUT**: 전체 수정 (멱등성)
- **PATCH**: 부분 수정
- **DELETE**: 삭제 (멱등성)
---
### Firebase Admin SDK 사용 규칙
**❌ 절대 사용 금지**: `getFirestore()` 직접 호출
**✅ 필수 사용**: `adminFbClient` 싱글톤 인스턴스
```typescript
// ❌ 잘못된 방식 - 초기화 문제 및 인증 오류 발생 가능
import {getFirestore} from "firebase-admin/firestore";
const db = getFirestore();
const doc = await db.collection('users').doc(uid).get();
// ✅ 올바른 방식 - 싱글톤 인스턴스 사용
import {adminFbClient} from "@/lib/firebase-admin";
const doc = await adminFbClient.collection('users').doc(uid).get();
```
**이유**:
- `getFirestore()`는 매번 호출 시 초기화 상태 불확실
- `adminFbClient``src/lib/firebase-admin.ts`에서 한 번만 초기화
- 환경변수 `FIREBASE_SERVICE_ACCOUNT_KEY`를 통해 명시적 인증 보장
---
### API Route 응답 헬퍼 함수 (필수)
**모든 API Route는 `src/lib/api-response.ts`의 헬퍼 함수를 사용해야 합니다.**
```typescript
import {
successResponse,
errorResponse,
unauthorizedResponse,
forbiddenResponse,
notFoundResponse,
validationErrorResponse,
internalErrorResponse
} from "@/lib/api-response";
import {UnwrapApiResponse} from "@/types/api";
// ✅ 성공 응답 패턴
export async function GET(request: NextRequest) {
const data = await fetchData();
const response: UnwrapApiResponse<MyResponse> = {
items: data,
totalCount: data.length,
};
return successResponse(response);
}
// ✅ 에러 응답 패턴
export async function POST(request: NextRequest) {
const authHeader = request.headers.get("authorization");
if (!authHeader?.startsWith("Bearer ")) {
return unauthorizedResponse(); // 401
}
const body = await request.json();
if (!body.title?.trim()) {
return validationErrorResponse("제목이 비어있습니다"); // 400
}
const user = await getUser(body.userId);
if (!user) {
return notFoundResponse("사용자를 찾을 수 없습니다"); // 404
}
if (user.id !== currentUserId) {
return forbiddenResponse(); // 403
}
try {
// ... 로직
} catch (error) {
return internalErrorResponse(); // 500
}
}
```
**❌ 절대 사용 금지**: 수동 응답 객체 생성
```typescript
// ❌ Bad - 직접 NextResponse.json 사용 금지
return NextResponse.json(
{success: false, error: "에러 메시지", code: "ERROR_CODE"},
{status: 400}
);
// ✅ Good - 헬퍼 함수 사용
return validationErrorResponse("에러 메시지");
```
---
### Manager ApiCall 패턴 (클라이언트)
**Manager에서 `ApiCall` 사용 시 주의사항**:
`ApiCall`은 성공 시 **언래핑된 데이터만** 반환하고, 실패 시 **자동으로 에러를 throw**합니다.
```typescript
import {SingletonManager} from "./ManagerBase";
import {HttpMethod, UnwrapApiResponse} from "@/types/api";
import type {CreateItemRequest, CreateItemResponse} from "@/types/api/item";
class ItemManager extends SingletonManager {
// ✅ 올바른 방식 - ApiCall은 자동으로 언래핑
async createItem(data: CreateItemRequest): Promise<Item> {
const response = await this.ApiCall<CreateItemRequest, CreateItemResponse>(
HttpMethod.POST,
'/item',
data
);
// response는 이미 {item: Item} 형태 (언래핑됨)
return response.item;
}
// ❌ 잘못된 방식 - success 체크 불필요
async createItemWrong(data: CreateItemRequest): Promise<Item> {
const response = await this.ApiCall<CreateItemRequest, CreateItemResponse>(
HttpMethod.POST,
'/item',
data
);
// ❌ response.success는 존재하지 않음 (타입 에러)
if (!response.success || !response.item) {
throw new Error("생성 실패");
}
return response.item;
}
// ✅ 반환값이 없는 경우 (DELETE 등)
async deleteItem(id: string): Promise<void> {
await this.ApiCall<null, DeleteItemResponse>(
HttpMethod.DELETE,
`/item/${id}`,
null
);
// 성공하면 그대로 종료, 실패하면 자동으로 에러 throw
}
}
```
**핵심 원칙**:
- `ApiCall` 반환값 = `UnwrapApiResponse<T>` (success, error 필드 없음)
- 에러 처리는 `try-catch`로 (자동 throw)
- `response.success` 체크 코드는 타입 에러 발생
---
## 개요
- **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;
}
```
---
## Image Generation Check API
### POST /api/check-image-generation
**설명**: 이미지 생성 가능 여부 확인 (팀/개인 글쓰기에 따라 다른 검증)
**인증**: 필수
**Request Body**:
```typescript
{
writingId: string; // 글 ID
}
```
**Response**:
```typescript
{
success: true,
data: {
allowed: boolean; // 이미지 생성 가능 여부
reason?: ImageGenerationDisableReason; // 비활성화 사유
remaining?: number; // 남은 횟수
limit?: number; // 전체 한도 (-1은 무제한)
isTeamWriting: boolean; // 팀 글쓰기 여부
}
}
```
**ImageGenerationDisableReason**:
```typescript
type ImageGenerationDisableReason =
| "PLAN_NOT_SUPPORTED" // Pro 플랜 이상 필요
| "LIMIT_EXCEEDED" // 개인 월간 한도 초과
| "TEAM_AI_DISABLED" // 팀 AI 비활성화
| "TEAM_LIMIT_EXCEEDED" // 팀 월간 한도 초과
| "DAILY_LIMIT_EXCEEDED"; // 팀원 일일 한도 초과
```
**검증 로직**:
1. **팀 글쓰기** (`writing.teamId` 존재):
- `canTeamUseImageGeneration(teamId, userId)` 호출
- 팀 AI 설정 + 월간/일일 제한 확인
2. **개인 글쓰기** (`writing.teamId` 없음):
- `canUseAIFeature(userId, AIFeatureType.IMAGE_GENERATION)` 호출
- 개인 플랜 + 월간 제한 확인
**에러**:
- `400`: writingId 누락
- `401`: 인증 필요
- `404`: 글을 찾을 수 없음 또는 본인 글이 아님
**Manager 사용법**:
```typescript
import { writingManager } from "@/managers";
const result = await writingManager.checkImageGenerationAvailability(writingId);
// → { allowed: true, remaining: 5, limit: 10, isTeamWriting: true }
// → { allowed: false, reason: "TEAM_LIMIT_EXCEEDED", isTeamWriting: true }
```
**캐싱**: 없음 (실시간 확인 필요)
---
## Text Analysis API
### POST /api/analyze-text
**설명**: Vertex AI 기반 텍스트 분석 (초등학생 글쓰기 평가)
**인증**: 선택 (비로그인도 사용 가능)
**Request Body**:
```typescript
{
text: string; // 분석할 텍스트 (최소 30자)
previousText?: string; // 이전 텍스트 (Delta 전송용, 선택)
}
```
**Response**:
```typescript
{
success: true,
data: {
score: number; // 0~10 점수
breakdown: {
sensory: number; // 감각 동사 점수 (0~4)
descriptive: number; // 감각 형용사 점수 (0~3)
dialogue: number; // 대화 점수 (0~2)
onomatopoeia: number; // 의성어/의태어 점수 (0~1)
};
foundWords: {
sensory: string[]; // 찾은 감각 동사 목록
descriptive: string[]; // 찾은 형용사 목록
onomatopoeia: string[]; // 찾은 의성어 목록
};
suggestions: string[]; // AI 수정 제안 목록
}
}
```
**Error Response** (429 Rate Limit):
```typescript
{
success: false,
error: {
code: "RATE_LIMIT",
message: "Vertex AI 요청 실패 (모든 region 시도 완료)"
}
}
```
**특징**:
-**Delta 전송**: previousText 제공 시 변경분만 분석 (토큰 40% 절감)
-**서버 캐싱**: 동일 텍스트 1분간 캐싱 (In-Memory LRU)
-**Multi-Region**: 3개 region 자동 전환 (도쿄/싱가포르/미국)
-**Retry**: Exponential backoff (최대 3회)
-**Region Health**: 과부하 region 1분간 제외
**Manager 사용법**:
```typescript
// 직접 API 호출 (서비스 레이어)
import { analyzeText } from "@/services/textAnalysisService";
const result = await analyzeText("오늘 날씨가 좋다.");
// → { score: 2.5, foundWords: {...}, suggestions: [...] }
```
**캐싱 전략**:
- 마지막 100자로 해시 생성
- TTL: 60초
- 최대 50개 캐시
**비용**:
- Gemini 2.5 Flash: $0.075/1M 입력 토큰
- 평균 500자 분석: ~$0.0003/회
- Delta 사용 시: ~$0.00018/회 (40% 절감)
---
## Pattern Analysis API
### POST /api/analyze-pattern
**설명**: 글 작성 패턴 분석 (사용자의 여러 글을 종합 분석)
**인증**: 필수
**Request Body**:
```typescript
{
analysisType?: "self" | "by-topic" | "by-team"; // 분석 타입 (기본: self)
targetUserId?: string; // 분석 대상 유저 (팀원 분석 시)
topicId?: string; // 주제 ID (by-topic 시 필수)
teamId?: string; // 팀 ID (by-team 시 필수)
limit?: number; // 분석할 글의 최대 개수 (기본: 10개)
contentHash?: string; // 🆕 클라이언트가 계산한 해시 (캐시 조회용, 선택)
}
```
**Response**:
```typescript
{
success: true,
pattern: {
userId: string;
analyzedAt: Date;
totalWritingsAnalyzed: number;
// 분석 컨텍스트
analysisType?: "self" | "by-topic" | "by-team";
targetUserName?: string;
topicId?: string;
topicName?: string;
teamId?: string;
teamName?: string;
// 작성 스타일
writingStyle: {
averageWordCount: number;
averageCharCount: number;
preferredLength: "짧음" | "보통" | "긴편";
sentenceStructure: "단문 위주" | "복문 위주" | "혼합형";
};
// 표현력 분석
expressionAnalysis: {
averageScore: number; // 0~10
strongPoints: string[];
weakPoints: string[];
breakdown: {
sensory: number; // 오감 표현 (0~4)
emotion: number; // 감정 표현 (0~2)
dialogue: number; // 대화 표현 (0~2)
onomatopoeia: number; // 의성어/의태어 (0~2)
};
frequentExpressions: {
sensory: string[];
emotion: string[];
onomatopoeia: string[];
};
};
// 맞춤법 경향
spellingTendency: {
commonErrors: Array<{
error: string;
correction: string;
frequency: number;
}>;
improvementRate: number; // 0~100
};
// 발전 추이
progressTrend: {
isImproving: boolean;
scoreChange: number;
improvementAreas: string[];
needsAttention: string[];
};
// AI 종합 평가
summary: {
overallAssessment: string;
encouragement: string;
recommendations: string[];
};
},
contentHash: string; // 🆕 서버가 계산한 해시 (클라이언트 캐싱용)
}
```
**에러**:
```typescript
// 404 - 분석할 글 없음
{
success: false,
error: "분석할 글이 없습니다. 글을 작성한 후 다시 시도해주세요."
}
// 403 - 권한 없음 (by-topic, by-team)
{
success: false,
error: "팀 소유자만 팀원의 글을 분석할 수 있습니다"
}
```
**분석 타입**:
1. **self (본인 분석)**:
- 모든 published 글 분석
- 권한: 본인만
2. **by-topic (주제별 분석)**:
- 특정 팀 주제로 작성된 글만 분석
- 권한: 팀 소유자만
- 주제가 팀 주제(`ownerType="team"`)여야 함
3. **by-team (팀 전체 분석)**:
- 팀의 모든 주제로 작성된 글 분석
- 권한: 팀 소유자만
- 팀 외 글(자유 주제, 다른 팀)은 제외
**캐싱 전략** (Content Hash 기반 3단계):
- **L1 (Client)**: localStorage에 contentHash를 키로 저장 (영구, LRU 10개) ~1ms
- **L2 (Firestore)**: `patternAnalyses/{contentHash}` 컬렉션에 저장 (영구) ~100ms
- **L3 (Server)**: In-memory Map에 저장 (5분 TTL, 최대 50개) ~50ms
- **변경 감지**: 글 추가/수정 시 `updatedAt` 변경 → 해시 변경 → 자동 재분석
- **AI 비용 절감**: 동일 글 세트는 전체 사용자 기준 1회만 분석 (Firestore 공유)
**Hash 생성 규칙**:
```typescript
// 입력: analysisType | limit | topicId | teamId | id:updatedAt,id:updatedAt,...
// 예시: "self|10|||abc:2024-01-01T00:00:00.000Z,def:2024-01-02T00:00:00.000Z"
// SHA-256 → "abc123def456..."
```
**성능**:
- 캐시 히트 (동일 글 세트): ~1ms (localStorage)
- 캐시 히트 (서버): ~50ms (in-memory)
- 캐시 미스: 5~10초 (AI 분석)
**Firestore 저장 구조**:
```typescript
patternAnalyses/{contentHash}
- contentHash: string
- pattern: WritingPatternAnalysis
- createdAt: Timestamp
```
**사용 흐름**:
```typescript
// 클라이언트 (self 분석)
1. 목록 조회
2. contentHash 계산
3. L1 확인 (localStorage) 히트 즉시 반환 (~1ms)
4. 서버 요청 (contentHash 포함)
5. 서버 응답 L1에 저장
// 서버
1. 클라이언트 contentHash로 L3 확인 (in-memory) 히트 즉시 반환 (~50ms)
2. 권한 체크 조회
3. 서버 contentHash 계산
4. L3 재확인 (in-memory) 히트 즉시 반환
5. L2 확인 (Firestore) 히트 반환 + L3 저장 (~100ms)
6. 캐시 미스: AI 분석 수행 (5~10)
7. L2 (Firestore) + L3 (in-memory) 저장
8. contentHash 포함하여 응답
```
**AI 비용 절감**:
- 동일한 글 세트는 **전체 사용자 기준 1회만 분석** (Firestore 공유)
- 예: 학생 A가 글 10개 작성 → 학생 B도 같은 글 10개 작성 → B는 AI 분석 없이 Firestore에서 조회
---
## Team API
### 1. POST `/team` - 팀 생성
실제 URL: `POST /api/team`
**인증**: 필수 (정식 계정)
**Request**:
```typescript
{
name: string; // 팀 이름
grade?: number; // 학년 (1~6) — D17: 팀 생성 시 학년 고정
curriculumId?: string; // 커리큘럼 ID (예: "g3-c1") — D16: 팀 생성 시 8주 코스 고정
}
```
**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. 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;
grade?: number; // 학년 (1~6) — D17
curriculumId?: string; // 커리큘럼 ID (예: "g3-c1") — D16; grade와 일치해야 함
securityMode?: "simple" | "normal" | "open";
requirePin?: boolean;
allowAnonymousJoin?: boolean;
isActive?: boolean;
// 수업 일정은 여기 없다 — 날짜는 회차 문서가 든다 (「TeamSession API (회차)」 참조)
}
}
```
**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/add-member` - 공개 팀 self-join
실제 URL: `POST /api/team/add-member`
**인증**: 필수 (본인 참여 전용 — body의 `uid`는 인증된 사용자와 일치해야 하며, 생략 가능)
**제약**:
- 공개 팀(`isPublic: true`)만 참여 가능 — 비공개 팀은 초대 링크로만
- 비활성 팀(`isActive: false`) 참여 불가
- 이미 멤버/소유자면 멱등 성공
**Request**:
```typescript
{
teamId: string;
uid?: string; // 선택 — 전달 시 본인 uid여야 함 (타인 추가 불가)
nickname?: string; // 닉네임 (선택적)
}
```
**Response**:
```typescript
{
success: true,
data: {
success: true;
}
}
```
**캐시 무효화**: 해당 팀
---
### 9. POST `/team/remove-student` - 팀에서 학생 제거
실제 URL: `POST /api/team/remove-student`
**인증**: 필수
**Request**:
```typescript
{
teamId: string;
studentId: string;
}
```
**Response**:
```typescript
{
success: true,
data: {
success: true;
}
}
```
**권한**: 팀 소유자만 제거 가능 (서버에서 검증)
**캐시 무효화**: 해당 팀
---
### 🆕 10. POST `/team/remove-member` - 팀 멤버 제거/나가기
실제 URL: `POST /api/team/remove-member`
**설명**: 팀에서 멤버를 제거합니다. 소유자는 다른 멤버를 강퇴할 수 있고, 일반 멤버는 본인을 제거(팀 나가기)할 수 있습니다.
**인증**: 필수
**Request**:
```typescript
{
teamId: string;
uid: string; // 제거할 멤버의 UID
}
```
**Response**:
```typescript
{
success: true
}
```
**권한 체크**:
-**팀 소유자**: 다른 멤버 강퇴 가능 (자신은 불가)
-**일반 멤버**: 본인만 제거 가능 (팀 나가기)
- ❌ 소유자가 본인을 제거: "팀 소유자는 팀을 나갈 수 없습니다. 팀을 삭제하거나 소유권을 이전해주세요."
- ❌ 일반 멤버가 타인을 제거: "팀을 관리할 권한이 없습니다."
**에러**:
- 404: 팀을 찾을 수 없음
- 403: 권한 없음 (위 권한 체크 참조)
**사용 예시**:
```typescript
// 팀 나가기
await teamManager.removeMember(teamId, currentUser.uid);
```
---
---
### 🆕 17. POST `/team/:teamId/cover-image` - 팀 커버 이미지 업로드
실제 URL: `POST /api/team/:teamId/cover-image`
**설명**: 팀 커버 이미지를 Firebase Storage에 업로드하고 팀 문서를 업데이트합니다.
**인증**: 필수 (팀 소유자만)
**Request**:
```typescript
// FormData 형식
{
file: File; // 이미지 파일 (JPEG/PNG/WebP/GIF, 최대 5MB)
}
```
**Response**:
```typescript
{
success: true,
data: {
coverImageUrl: string; // 업로드된 이미지의 공개 URL
}
}
```
**처리 로직**:
1. 파일 타입 검증 (JPEG/PNG/WebP/GIF만 허용)
2. 파일 크기 검증 (5MB 이하)
3. Firebase Storage 업로드 (`teams/{teamId}/cover-{timestamp}.{ext}`)
4. 파일 공개 설정 (`makePublic()`)
5. 기존 이미지가 있으면 Storage에서 삭제
6. 팀 문서 `coverImage` 필드 업데이트
**캐시 무효화**: 해당 팀, 공개 팀 목록
---
### 🆕 18. DELETE `/team/:teamId/cover-image` - 팀 커버 이미지 삭제
실제 URL: `DELETE /api/team/:teamId/cover-image`
**설명**: 팀 커버 이미지를 Storage에서 삭제하고 팀 문서를 업데이트합니다.
**인증**: 필수 (팀 소유자만)
**Response**:
```typescript
{
success: true
}
```
**처리 로직**:
1. 팀 문서에서 `coverImage` URL 조회
2. Storage에서 파일 삭제
3. 팀 문서 `coverImage` 필드 제거
**캐시 무효화**: 해당 팀, 공개 팀 목록
---
### 🆕 19. GET `/team/:teamId/attendance` - 팀 출결관리 탭 데이터 조회
실제 URL: `GET /api/team/:teamId/attendance`
**인증**: 필수 (팀 소유자만)
**설명**: 출석의 유일한 진실은 `teamSessions/{sessionId}/checkins` 서브컬렉션이다 (`src/lib/server/teamSession.ts`). 이 라우트는 회차+체크인 데이터를 응답 DTO 형태로 집계만 한다 (`src/lib/server/attendance.ts`).
**"최근 수업"은 관측된 회차만이다.** 개설 시점에 8주치 회차를 미리 만들어 두므로 그냥 date DESC로 뽑으면 목록 앞쪽이 통째로 아직 하지 않은 미래 회차다. 그래서 `getObservedSessions``in_progress`·`done`만 본다. 관측된 회차가 하나도 없으면 다음 예정 회차로 "언제 첫 수업인가"에 답하고, 그것마저 없을 때만 `hasSchedule: false`다.
**Response**:
```typescript
{
success: true,
data: {
hasSchedule: boolean;
classTime?: string; // "HH:mm"
membersTotal: number; // 팀 소유자 제외 인원수
date?: string; // "YYYY-MM-DD" (오늘 또는 가장 최근 수업일, KST)
present: number;
late: number;
rows: Array<{ uid: string; name: string; time: string; isLate: boolean }>; // 출석(지각 포함)한 팀원만
history: Array<{ date: string; membersTotal: number; present: number; late: number }>; // 최근 수업일 최대 5개 (최신순)
}
}
```
**백엔드 로직**:
1. `getTeam(teamId)` 조회 후 소유자 권한 체크
2. `getObservedSessions(teamId, 5)``in_progress`·`done` 회차를 date 내림차순 최대 5개
3. 하나도 없으면 `getNextScheduledSession(teamId)`로 다음 예정 회차의 시각만 실어 보낸다
4. 가장 최근 회차가 오늘의 출결, 전체 목록이 이력 — `buildAttendanceSummary`로 회차+체크인을 응답 DTO로 집계
**캐싱**: 클라이언트에서 1분간 캐싱 (`teamManager.getTeamAttendance(teamId)`)
**Firestore 인덱스**: `teamSessions(teamId, status, date DESC)` 복합 인덱스 필요 (`firestore.indexes.json` 참조)
---
### 🆕 20. POST `/team/:teamId/session/checkin` - 학생 체크인
실제 URL: `POST /api/team/:teamId/session/checkin`
**인증**: 필수 (팀 멤버만, `uid in team.members`)
**Request**:
```typescript
{
sessionId: string; // GET /session/current 이 돌려준 회차 ID
}
```
**설명**: 대기실 도착 시 호출하는 체크인. 멱등 — 재호출·URL 직접 접근 시에도 최초 체크인 레코드를 그대로 반환한다.
**이 라우트에는 오프너가 없다.** 회차를 여는 것은 `GET /session/current`이고, 클라이언트는 거기서 받은 ID로 여기 온다. 여기서 한 번 더 열면 같은 전이 규칙이 두 경로에 복제되고, 복제된 규칙이 어긋나는 것이 이 모델을 다시 만든 원래 병이다. 그래서 이 라우트는 **검증만** 한다.
**서버는 클라이언트가 보낸 `sessionId`를 믿지 않는다.** 그 ID는 쉽게 낡는다 — 교사가 휴강으로 날짜를 옮기면 학생이 들고 있던 ID는 어제 회차를 가리킨 채 **여전히 유효한 문서**다. 그대로 두면 출석이 조용히 다른 회차에 기록된다. 그래서 세 가지를 다시 확인하고, 하나라도 어긋나면 **409**다:
| 검증 | 어긋났을 때 |
|---|---|
| 회차의 `teamId`가 경로의 팀과 같은가 | 409 "이 반의 회차가 아닙니다." — 경로 권한만 보고 통과시키면 남의 반 출석부에 이름이 남는다 |
| `status === "in_progress"`인가 | 409 "아직 시작하지 않았거나 이미 끝난 수업입니다." — 문서 존재는 열림을 뜻하지 않는다 |
| 회차 날짜가 오늘(KST)인가 (`assertSessionIsToday`) | 409 "오늘의 수업이 아닙니다. 화면을 새로고침해 주세요." |
셋 다 `console.warn`으로 흔적을 남긴다 — 조용한 오기록보다 시끄러운 거절이 낫다.
**Response**:
```typescript
{
success: true,
data: {
session: TeamSession;
myCheckIn: SessionCheckIn; // { uid, arrivedAt, isLate }
}
}
```
**에러**: 400(`sessionId` 누락), 403(팀 멤버 아님·비활성 팀), 404(팀/회차 없음), 409(위 3종)
**Manager**: `teamSessionManager.checkIn(teamId, sessionId)` — 성공 시 입장 카드 캐시를 즉시 무효화
---
### 🆕 21. GET `/team/:teamId/session/current` - 수업 대기실 데이터 조회
실제 URL: `GET /api/team/:teamId/session/current`
**인증**: 필수 (팀 멤버만)
**설명**: 대기실 화면용 데이터. **의도적 GET write-through**`getInProgressSession`이 전이를 수행하므로 이 조회 자체가 오프너다. 크론이 없어 수업을 실제로 여는 것은 이런 요청 경로뿐이고, 학생 체크인은 여기서 받은 ID로 이뤄지므로 이 라우트가 대기실의 오프너 단일 지점이다.
⚠️ `session: null`은 "오늘 회차 문서가 없다"가 **아니라** "지금 진행 중인 회차가 없다"다. 개설 시점에 8주치 회차를 만들어 두므로 문서 존재는 아무것도 말해 주지 않는다.
**Response**:
```typescript
{
success: true,
data: {
session: TeamSession | null;
checkIns: Array<SessionCheckIn & { name: string }>; // 팀 닉네임 포함
myCheckIn: SessionCheckIn | null;
}
}
```
**에러**: 403(팀 멤버 아님), 404(팀 없음), 409(진행 중 회차가 둘 이상 — 하나를 조용히 고르면 출석·글이 틀린 회차에 영구 기록되므로 고르지 않고 거부한다)
**캐싱**: 없음 (근실시간성 필요)
---
## TeamSession API (회차)
수업 1회가 문서 1건이다(`teamSessions/{randomId}`). 그 문서가 날짜·시각·주차·글감·글작성 단계·진행 상태·출석을 전부 들기 때문에 "오늘 무슨 수업인가"는 계산이 아니라 조회 한 줄이고, 교사 표와 학생 알림장이 같은 행을 읽는다. 데이터 모델은 `DATA_MODELS.md`의 「TeamSession」 절을 참조.
구 배정 API(`/team/:teamId/assignment` 4종)와 구 세션 API(`POST /team/:teamId/session`)는 이 API로 대체되어 **제거되었다**. `assignments`·`classSessions` 컬렉션도 함께 삭제됐다.
**세 가지를 먼저 알아야 한다:**
1. **전이에 크론이 없다.** 회차를 실제로 여닫는 것은 조회 요청뿐이다 — `GET /team/:teamId/sessions`, `GET /team/:teamId/session/current`, `GET /student/sessions`, `GET /teacher/attendance`가 응답 직전에 전이를 수행한다(의도적 GET write-through). 부수 효과가 지저분해 보인다고 떼면 수업이 영영 열리지 않는다. 반대로 `GET /teacher/sessions`**일부러 전이를 하지 않는다** — 교사가 팀 목록을 여는 것만으로 전 팀의 수업이 열리면 안 되기 때문이다.
2. **회차 개수는 팀 개설 시점에만 정해진다.** 주 N회는 사후에 바꿀 수 없고, 어떤 요청 타입에도 `weeklyCount` 필드가 없다. 늘리거나 줄이는 것은 단건 추가·삭제 경로의 일이다.
3. **에러 코드는 공통이다.** 서버 레이어의 `TeamSessionError`를 라우트가 그대로 매핑한다 — `NOT_FOUND`→404, `VALIDATION_ERROR`→400, `CONFLICT`→409.
**Firestore 인덱스**: `teamSessions(teamId, date)`, `(teamId, status)`, `(teamId, status, date ASC)`, `(teamId, status, date DESC)`
---
### 1. GET `/team/:teamId/sessions` - 팀 회차 전체 조회
실제 URL: `GET /api/team/:teamId/sessions`
**인증**: 필수 (팀 멤버 — 소유자 또는 `uid in team.members`)
**설명**: 날짜 오름차순 전체 목록. **이 GET은 쓰기를 한다** — 응답 전에 `transitionTeamSessions`를 부르므로 담겨 오는 `status`는 "지금" 기준으로 갱신된 값이다. 전이가 실패해도 조회는 성공한다(저장된 `status`로 그릴 수 있는 화면은 전부 그려져야 한다). 다만 실패는 반드시 `console.error`로 남는다 — 크론이 없어 이 로그가 유일한 관측 수단이다.
**Response**:
```typescript
{
success: true,
data: { sessions: TeamSession[] }
}
```
**에러**: 403(팀 멤버 아님), 404(팀 없음)
**Manager**: `teamSessionManager.getTeamSessions(teamId)` (캐싱 30초, 조작 후 즉시 무효화)
---
### 2. POST `/team/:teamId/sessions` - 회차 생성 (배치·단건 겸용)
실제 URL: `POST /api/team/:teamId/sessions`
**인증**: 필수 (**팀 소유자 = 교사만**)
**Request**:
```typescript
{
sessions: Array<{
date: string; // "YYYY-MM-DD"
time: string; // "HH:mm"
weekId: string;
topicId: string; // 필수 — 빈 문자열 불가
writingStages?: WritingStage[]; // 생략 시 서버 기본값
}>;
}
```
**배열 하나가 배치 생성과 단건 추가를 겸한다.** 팀 개설은 `주 N회 × 8주`개를 한 번에 보내고, 회차 끼워넣기는 길이 1인 배열을 보낸다. 쓰기가 **트랜잭션 1회**라 부분 성공이 없다 — 24건을 24요청으로 쪼개면 중간 실패 시 절반만 있는 팀이 남는다.
입력 검증은 라우트와 서버 레이어 양쪽에 있다. 라우트가 한 번 더 보는 이유는 **24건 중 몇 번째가 잘못됐는지는 라우트만 알려 줄 수 있기** 때문이다(`sessions[7].topicId가 필요합니다.`).
**Response** (201):
```typescript
{
success: true,
data: { sessions: TeamSession[] }
}
```
**에러**: 400(형식·글감 누락), 403(소유자 아님·비활성 팀), 404(팀 없음), 409(같은 날짜 회차가 이미 있음 — 하루 1회차)
**Manager**: `teamSessionManager.createSessions(teamId, inputs)` / `createSession(teamId, input)`
---
### 3. PUT `/team/:teamId/sessions/:sessionId` - 회차 수정
실제 URL: `PUT /api/team/:teamId/sessions/:sessionId`
**인증**: 필수 (**팀 소유자 = 교사만**)
**Request** (넘긴 필드만 바뀐다. 전부 생략하면 400):
```typescript
{
date?: string;
time?: string;
topicId?: string; // 비울 수 없다
writingStages?: WritingStage[]; // 1개 이상
}
```
회차가 독립 문서라 **인접 회차를 건드리지 않고** 한 건만 고칠 수 있다. 구 모델은 일정이 팀 문서 안의 배열이라 3회차 날짜를 옮기려면 배열 전체를 다시 썼다. 회차 ID가 불변이므로 날짜를 옮겨도 그 회차를 가리키던 글·출석은 끊기지 않는다.
**Response**: `{ success: true, data: { session: TeamSession } }`
**에러**: 400(형식·수정 항목 없음), 403(소유자 아님·비활성 팀), 404(팀/회차 없음 — 다른 팀의 회차 ID도 404), 409(옮긴 날짜에 이미 회차가 있음)
**Manager**: `teamSessionManager.updateSession(teamId, sessionId, patch)`
---
### 4. PATCH `/team/:teamId/sessions/:sessionId` - 수동 열기 / 종료
실제 URL: `PATCH /api/team/:teamId/sessions/:sessionId`
**인증**: 필수 (**팀 소유자 = 교사만**)
**Request**:
```typescript
{ action: "open" | "close" }
```
**PUT과 나눈 이유**: 열기는 `order` 부여 + 같은 팀의 다른 진행 회차 종료를 한 트랜잭션에서 하는 **상태 전이**이지 필드 수정이 아니다. 한 라우트에 섞으면 "날짜만 고치려던 요청이 수업을 여는" 사고가 가능해진다.
- `open`: 확정 회차 번호(`order`)를 부여하고, 같은 팀에 진행 중인 회차가 있으면 함께 닫는다(`closedBy: "auto:open"`). 팀당 `in_progress`는 항상 최대 1개다.
- `close`: `closedAt`·`closedBy`를 남기고 `done`으로 내린다.
**Response**: `{ success: true, data: { session: TeamSession } }`
**에러**: 400(action 누락/오타), 403(소유자 아님·비활성 팀), 404(팀/회차 없음), 409(전이 제약 위반)
**Manager**: `teamSessionManager.openSession(teamId, sessionId)` / `closeSession(teamId, sessionId)`
---
### 5. DELETE `/team/:teamId/sessions/:sessionId` - 회차 삭제
실제 URL: `DELETE /api/team/:teamId/sessions/:sessionId`
**인증**: 필수 (**팀 소유자 = 교사만**)
🔒 **`scheduled` 회차만 지울 수 있다.** 진행·완료 회차를 지우면 `checkins` 서브컬렉션이 부모 없는 고아로 남아 어떤 조회에도 걸리지 않은 채 살아 있다 — 그건 삭제가 아니라 조용한 기록 파기다. 없는 회차는 멱등 성공이다.
**Response**: `{ success: true, data: { deleted: true } }`
**에러**: 403(소유자 아님·비활성 팀), 404(팀 없음 · 다른 팀의 회차 ID), 409(`scheduled`가 아님)
**Manager**: `teamSessionManager.deleteSession(teamId, sessionId)`
---
### 6. POST `/team/:teamId/sessions/regenerate` - 일정 규칙 일괄 재생성
실제 URL: `POST /api/team/:teamId/sessions/regenerate`
**인증**: 필수 (**팀 소유자 = 교사만**)
**Request**:
```typescript
{ dates: Array<{ date: string; time: string }> } // 날짜 오름차순
```
**삭제-재생성이 아니라 UPDATE다.** 문서 ID가 유지되어야 그 회차를 가리키는 출석·글이 끊기지 않는다. 그래서 **개수는 바꿀 수 없고**, `dates.length`가 예정 회차 수와 다르면 400이다.
`scheduled` 회차의 날짜만 갈아끼운다 — 진행·완료 회차는 날짜도 문서 ID도 바뀌지 않는다. 중복 검사는 **진행·완료 회차의 날짜를 포함한 전체 집합**에 건다. 예정분만 보고 검사하면 새 날짜가 이미 끝난 회차의 날짜와 겹쳐 하루 2회차가 생긴다.
**Response**: `{ success: true, data: { sessions: TeamSession[] } }`
**에러**: 400(형식·같은 날짜 중복·개수 불일치), 403(소유자 아님·비활성 팀), 404(팀 없음), 409(전체 집합 기준 날짜 충돌)
**Manager**: `teamSessionManager.regenerateSessions(teamId, dates)`
---
## Teacher API
### 🆕 1. GET `/teacher/stats` - 선생님 홈 통계 (미확인/미평가 과제 수)
실제 URL: `GET /api/teacher/stats`
**인증**: 필수 (교사 역할, `withTeacherAuth`)
**Response**:
```typescript
{
success: true,
data: {
unevaluatedCount: number; // 미평가 과제 수
}
}
```
**미평가 정의**: 내 팀(소유한 팀 + 참여한 팀)에 속한 `status === "published"` 글 중, 내(선생님)가 작성한 댓글이 하나도 없는 글 (`comments` 컬렉션에서 `userId == 나`, `isDeleted == false` 기준으로 판단. 학생 댓글은 평가로 카운트하지 않음)
**백엔드 로직**:
1. `getAllUserTeams(uid)` - 내 팀 목록 조회
2. `comments` 컬렉션에서 내가 작성한 댓글의 `writingId` 집합 조회
3. `writings` 컬렉션에서 내 팀들의 `published` 글 조회 (`teamId in [...]`, 10개 청크 분할)
4. 댓글 집합에 없는 글 개수 = `unevaluatedCount`
**캐싱**: 클라이언트에서 2분간 캐싱
**Firestore 인덱스**: `comments(userId, isDeleted)`, `writings(teamId, status)` 복합 인덱스 필요 (`firestore.indexes.json` 참조)
---
### 🆕 2. GET `/teacher/attendance` - 선생님 홈 출석현황 패널
실제 URL: `GET /api/teacher/attendance`
**인증**: 필수 (교사 역할, `withTeacherAuth`)
**설명**: 출석의 유일한 진실은 `teamSessions/{sessionId}/checkins` 서브컬렉션이다. 대상 팀은 **오늘 날짜의 회차 문서가 있는 팀**이다.
🔴 **여기서 불변식이 반전됐다.** 구 모델에서 "오늘 날짜의 세션 문서가 있다"는 곧 "수업이 열렸다"였다 — 문서는 수업이 열릴 때 비로소 만들어졌기 때문이다. 신 모델은 개설 시점에 8주치 회차를 미리 만들므로 문서 존재는 아무것도 말해 주지 않고, 열림 여부는 `status`로만 판정한다. 시간표 역산(`isScheduledOn`/`timeForDate`)도 사라졌다 — 회차 문서가 날짜·시각을 직접 든다.
**의도적 GET write-through**: `getTodaySessionsForTeams`가 시각이 도래한 회차의 전이를 함께 수행하므로, 아무도 접속하지 않은 수업도 교사가 홈을 열어보는 시점에 정확히 열린다.
**Response**:
```typescript
{
success: true,
data: {
teams: Array<{
teamId: string;
name: string;
classTime: string; // "HH:mm"
membersTotal: number; // 팀 소유자 제외 인원수
attendance: { present: number; late: number } | null; // null = 현재(KST) < classTime, 수업 전 대기
rows: Array<{ uid: string; name: string; time: string; isLate: boolean }>; // 상위 5명
}>;
}
}
```
**백엔드 로직**:
1. `getAllUserTeams(uid)` - 내 팀 목록 조회
2. `getTodaySessionsForTeams`로 오늘 날짜의 회차를 청크 단위 단일 쿼리로 조회 — 여기 걸린 팀만 대상. **오늘 날짜 토큰은 이 함수 안에만 있다**(라우트에 날짜 비교를 두지 않는다)
3. 회차가 `scheduled`면 아직 시각 전이므로 `attendance: null`("수업 전 대기")
4. 아니면 `buildAttendanceSummary`로 체크인 데이터를 응답 DTO로 집계 (팀원의 최초 도착 시각 = 출석 시각, `baselineAt + graceMinutes` 초과 시 지각)
**캐싱**: 클라이언트에서 1분간 캐싱 (`teamManager.getTodayAttendance()`)
**Firestore 인덱스**: `teamSessions(teamId, date)` 복합 인덱스 필요 (`firestore.indexes.json` 참조)
---
### 🆕 3. GET `/teacher/sessions` - 내 팀 전체의 회차 조회 (교사 목록 화면)
실제 URL: `GET /api/teacher/sessions`
**인증**: 필수 (교사 역할, `withTeacherAuth`)
**설명**: 팀 카드의 요일 칩, 팀 리포트 표의 수업일정·시간·진행 회차, 학습 스케줄 캘린더 — 셋 다 **여러 팀의 일정을 한 화면에** 그린다. 팀마다 `/team/:id/sessions`를 부르면 그대로 N+1이라 조회를 여기 하나로 모았다.
🔴 **이 GET은 전이를 하지 않는다 — 오프너가 아니다.** 여기까지 전이를 걸면 교사가 팀 목록을 여는 것만으로 전 팀의 수업이 열리고, 그 비용은 팀 수만큼의 트랜잭션이 된다. 이 응답은 **그리기 위한 스냅샷**이다.
**Response**:
```typescript
{
success: true,
data: { sessions: TeamSession[] } // 내 모든 팀, 날짜 오름차순
}
```
**Manager**: `teamSessionManager.getMyTeamSessions()` (캐싱 30초)
---
## Student API
### 🆕 1. GET `/student/sessions` - 학생 홈 입장 카드용 회차 알림 조회
실제 URL: `GET /api/student/sessions`
**인증**: 필수 (`withAuth`)
**설명**: 내 모든 팀에 대해 `getTeamSessionNotificationsForUser(uid)`(알림 파생의 단일 지점)를 호출한다. 각 팀은 회차 전이를 거쳐 lazy 판정(오픈 포함)되므로, 크론이 없는 지금 이 조회가 그 팀의 유일한 관찰자일 수 있다(의도적 GET write-through).
**Response**:
```typescript
{
success: true,
data: {
notifications: Array<{
sessionId: string;
teamId: string;
targetUid: string;
deepLink: string; // getClassSessionHref(teamId) — 단일 지점에서 생성
channel: "inapp"; // 현재는 인앱 카드만 소비, 추후 채널 추가 시 이음새
}>;
}
}
```
**캐싱**: 클라이언트에서 30초 캐싱 (`teamSessionManager.getEnterableSessions()`, 체크인 후 즉시 무효화). ⚠️ 이 매니저에서 TTL은 성능 손잡이가 아니라 **전환 지연 손잡이**다 — 크론이 없어 조회가 곧 전이이므로, TTL을 늘리면 "수업이 늦게 열린다"가 같이 늘어난다.
---
## 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; // 최초 가입 팀
}
```
**Response**:
```typescript
{
success: true,
data: {
user: User; // Firebase Auth + Firestore 결합된 완전한 User
}
}
```
**부수 효과**:
- Firestore에 FirestoreUser 생성 (최소 데이터)
- 팀에 멤버 추가 (`team.members[uid]`)
- Firebase Auth displayName 설정
**캐시 무효화**: 팀별 사용자 목록
---
### 2. POST `/user/purchase` - 플랜 구매/변경
실제 URL: `POST /api/user/purchase`
**인증**: 필수
**Request**:
```typescript
{
planType: PlanType; // FREE, PRO, CLASSROOM, ACADEMY, SCHOOL
billingCycle: BillingCycle; // MONTHLY, YEARLY
amount: number; // 결제 금액 (원)
downgradeMode?: "immediate" | "scheduled"; // 다운그레이드 시 적용 시점
}
```
**Response**:
```typescript
{
success: true,
data: {
creditsAdded: number; // 환불로 지급된 크레딧 (업그레이드/즉시 다운그레이드)
isScheduled: boolean; // true면 다음 결제일에 적용
isBillingCycleChange: boolean; // true면 결제 주기만 변경
}
}
```
**동작**:
1. **업그레이드**: 즉시 적용, 남은 기간 비례 환불 → 크레딧 지급
2. **다운그레이드 (immediate)**: 즉시 적용, 남은 기간 비례 환불 → 크레딧 지급
3. **다운그레이드 (scheduled)**: `scheduledPlan`에 저장, 만료 시 자동 적용
4. **결제 주기 변경**: `scheduledPlan`에 저장, 만료 시 새 주기로 적용
**환불 계산**:
```typescript
const refundKRW = calculateProratedRefund(currentPlan, PLAN_MONTHLY_PRICES);
const credits = convertKRWToCredits(refundKRW); // 100원 = 10 크레딧
```
**캐시 무효화**: 사용자 정보
---
### 3. GET `/user/plan/estimate-refund` - 예상 환불 크레딧 조회
실제 URL: `GET /api/user/plan/estimate-refund`
**인증**: 필수
**설명**: 다운그레이드 전 예상 환불 크레딧을 조회합니다. UI에서 "즉시 다운그레이드" 옵션에 표시됩니다.
**Response**:
```typescript
{
success: true,
data: {
estimatedCredits: number; // 예상 환불 크레딧
estimatedKRW: number; // 예상 환불 금액 (원)
validUntil: string; // 현재 플랜 만료일 (ISO 8601)
currentPlanType: PlanType; // 현재 플랜 타입
}
}
```
**에러**:
- `400`: 활성 플랜 없음
- `401`: 인증 필요
**캐싱**: 없음 (실시간 계산)
---
## 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`
**인증**: 필수
**권한**: 작성자만 조회 가능 (`writing.userId === currentUserId`)
**Response**:
```typescript
{
success: true,
data: {
writing: Writing | null;
}
}
```
**에러**:
- `404 Not Found`: 글이 존재하지 않음
- `403 Forbidden`: 작성자가 아님
**캐싱**: 클라이언트에서 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;
distortionAreas?: DistortionAreaData[]; // 🆕 왜곡 영역 설정
analysis?: WritingAnalysis; // 🆕 AI 분석 결과 (영역 제한용)
}
}
```
**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;
}
}
```
**권한**: 작성자만 삭제 가능
**캐시 무효화**: 해당 글, 사용자 글 목록, 최근 글
---
### 7. POST `/writing/:id/analyze` - 글 분석 실행
실제 URL: `POST /api/writing/:id/analyze`
**인증**: 필수 (작성자 본인만 가능)
**설명**: 저장된 글을 서버에서 불러와 AI 분석을 실행하고 결과를 저장합니다.
**Request**:
```typescript
{
locale?: "ko" | "en" | "ja"; // 기본값: "ko"
}
```
**Response**:
```typescript
{
success: true,
data: {
analysis: WritingAnalysis;
cached: boolean; // true면 기존 분석 결과 반환 (재사용)
}
}
```
**특징**:
- **Content Hash 기반 재사용**: 글 내용이 변경되지 않았으면 기존 분석 결과 반환 (비용 절감)
- **최소 길이**: 30자 이상이어야 분석 가능
- **저장**: 분석 결과는 자동으로 `writings/{id}.analysis`에 저장됨
---
## Comment API
### 1. GET `/comment/writing/:writingId` - 댓글 목록 조회
실제 URL: `GET /api/comment/writing/:writingId`
**인증**: 선택적 (현재 사용자 반응 확인용)
**Response**:
```typescript
{
success: true,
data: {
comments: CommentWithReplies[];
totalCount: number;
}
}
```
**특징**:
- 계층 구조 (댓글 + 답글) 반환
- 작성자 정보 (displayName, photoURL) 포함
- 현재 사용자의 반응 포함 (로그인 시)
### 2. POST `/comment/writing/:writingId` - 댓글 작성
실제 URL: `POST /api/comment/writing/:writingId`
**인증**: 필수
**Request**:
```typescript
{
content: string;
parentId?: string; // 답글인 경우 부모 댓글 ID
}
```
**Response**:
```typescript
{
success: true,
data: {
comment: Comment;
}
}
```
### 3. PUT `/comment/:id` - 댓글 수정
실제 URL: `PUT /api/comment/:id`
**인증**: 필수 (작성자 본인만)
**Request**:
```typescript
{
content: string;
}
```
**Response**:
```typescript
{
success: true,
data: {
comment: Comment;
}
}
```
### 4. DELETE `/comment/:id` - 댓글 삭제
실제 URL: `DELETE /api/comment/:id`
**인증**: 필수
**권한**:
- 작성자 본인
- 글 작성자 (관리 차원)
- 팀 소유자 (팀 주제인 경우)
**Response**:
```typescript
{
success: true
}
```
---
## Notice API
### 1. GET `/notice` - 공지사항 목록 조회
실제 URL: `GET /api/notice`
**인증**: 필수 (일반 사용자)
**Response**:
```typescript
{
success: true,
data: {
notices: Notice[]; // 고정 공지 우선, 이후 최신순
}
}
```
### 2. POST `/notice` - 공지사항 생성
실제 URL: `POST /api/notice`
**인증**: 필수 (관리자 전용, Custom Claim `isAdmin === true`)
**Request**:
```typescript
{
title: string;
body: string;
pinned?: boolean;
}
```
**Response**:
```typescript
{
success: true,
data: {
notice: Notice;
}
}
```
### 3. PUT `/notice/:noticeId` - 공지사항 수정
실제 URL: `PUT /api/notice/:noticeId`
**인증**: 필수 (관리자 전용)
**Request**:
```typescript
{
title?: string;
body?: string;
pinned?: boolean;
}
```
**Response**:
```typescript
{
success: true,
data: {
notice: Notice;
}
}
```
### 4. DELETE `/notice/:noticeId` - 공지사항 삭제
실제 URL: `DELETE /api/notice/:noticeId`
**인증**: 필수 (관리자 전용)
**Response**:
```typescript
{
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) 기능만 사용합니다.
**참고**: 이 엔드포인트는 PERSONAL + TEAM 글감만 반환합니다. 시스템 글감(SYSTEM)은 포함되지 않습니다.
---
### 1-B. GET `/topic?weekId=` - 주차별 시스템 글감 목록
실제 URL: `GET /api/topic?weekId={weekId}`
**인증**: 불필요 (시스템 콘텐츠)
**Query Parameters**:
- `weekId`: string (필수 — 예: "g3-c1-w01")
**Response**:
```typescript
{
success: true,
data: {
topics: Topic[]; // ownerType === SYSTEM, topic.weekId === weekId
}
}
```
**Manager 메서드**:
- `topicManager.getByWeek(weekId)`
**캐싱**: 클라이언트에서 5분간 캐싱
**참고**: 시스템 글감(128개/학년)은 이 엔드포인트를 통해서만 접근 가능합니다. `weekId` 없이 호출하면 400 에러를 반환합니다.
---
### 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_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: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 구현 (선택적)
---
## Curriculum & Week API
데이터 계층: **Curriculum(4개/학년) → CurriculumWeek(8개/커리큘럼, 32개/학년) → Topic(4개/주차, 128개/학년)**
- `Curriculum` 컬렉션: `id`(예: "g3-c1"), `grade`, `track`("C1"~"C4"), `name`, `description`, `totalWeeks`, `isActive`
- `CurriculumWeek` 컬렉션: `id`(예: "g3-c1-w01"), `curriculumId`, `grade`, `week`(1~8), `name`, `weekGoal`, `competency`, `learningGoal`, `writingActivity`, `keyExpressions`, `output`, `teacherPoint`, `topicCriteria`, `drawingDirection`, `isActive`
- 시스템 데이터는 읽기 전용(인증 불필요). 관리자 쓰기 엔드포인트는 미구현.
---
### GET /api/curriculum
커리큘럼 목록 조회 (학년별 필터 선택적)
**Query Parameters**:
- `grade`: number (선택 — 학년 필터)
**Response**:
```typescript
{
success: true,
data: { curricula: Curriculum[] }
}
```
**Manager 메서드**:
- `curriculumManager.getByGrade(grade?)` — grade 없으면 전체 반환
**인증**: 불필요 (시스템 콘텐츠)
**캐싱**: 클라이언트 캐싱 (TTL 5분)
---
### GET /api/curriculum/[curriculumId]
커리큘럼 단건 조회
**Response**:
```typescript
{
success: true,
data: { curriculum: Curriculum }
}
```
**Manager 메서드**:
- `curriculumManager.getCurriculum(curriculumId)`
**인증**: 불필요 (시스템 콘텐츠)
**캐싱**: 클라이언트 캐싱 (TTL 5분)
---
### GET /api/curriculum/[curriculumId]/week
커리큘럼의 주차 목록 조회 (8개)
**Response**:
```typescript
{
success: true,
data: { weeks: CurriculumWeek[] }
}
```
**서버 로직**:
- 커리큘럼이 존재하지 않으면 404
- `curriculumId`로 필터링, `week` 오름차순 정렬
**Manager 메서드**:
- `curriculumManager.getWeeks(curriculumId)`
**인증**: 불필요 (시스템 콘텐츠)
**캐싱**: 클라이언트 캐싱 (TTL 5분)
---
### GET /api/curriculum/[curriculumId]/week/[weekId]
주차 단건 조회
**Response**:
```typescript
{
success: true,
data: { week: CurriculumWeek }
}
```
**서버 로직**:
- `week.curriculumId !== curriculumId`면 404 (크로스 커리큘럼 가드)
**Manager 메서드**:
- `curriculumManager.getWeek(curriculumId, weekId)`
**인증**: 불필요 (시스템 콘텐츠)
**캐싱**: 클라이언트 캐싱 (TTL 5분)