2388 lines
62 KiB
Markdown
2388 lines
62 KiB
Markdown
# 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분)
|