docs: Sync documentation from private repository
This commit is contained in:
parent
09db74ff4c
commit
a3f6ecb5c4
1364
API_SPEC.md
Normal file
1364
API_SPEC.md
Normal file
File diff suppressed because it is too large
Load Diff
772
DATA_MODELS.md
Normal file
772
DATA_MODELS.md
Normal file
@ -0,0 +1,772 @@
|
||||
# 라온누리 - 데이터 모델 및 스키마
|
||||
|
||||
> 최종 업데이트: 2025-11-07
|
||||
|
||||
이 문서는 Firestore 데이터베이스 구조와 TypeScript 타입 정의를 설명합니다.
|
||||
|
||||
**참고**: API 타입 정의는 [API_SPEC.md](./API_SPEC.md)를 참조하세요.
|
||||
|
||||
---
|
||||
|
||||
## Firestore 컬렉션 구조
|
||||
|
||||
### 컬렉션 개요
|
||||
|
||||
```
|
||||
firestore
|
||||
├── teams/ # ✅ 팀 (팀 코드 시스템)
|
||||
├── students/ # ✅ 학생 계정 (Anonymous Auth)
|
||||
├── users/ # 🔜 사용자 프로필 및 진행 상황
|
||||
├── writings/ # ✅ 작성한 글
|
||||
├── topics/ # ✅ 글쓰기 주제
|
||||
├── lessons/ # 🔜 학습 레슨
|
||||
├── stickers/ # 🔜 스티커 마스터 데이터
|
||||
└── userStickers/ # 🔜 사용자별 스티커 획득 기록
|
||||
```
|
||||
|
||||
**범례**:
|
||||
- ✅ 구현 완료
|
||||
- 🔜 구현 예정
|
||||
|
||||
---
|
||||
|
||||
## 1. Team (팀) ✅
|
||||
|
||||
**컬렉션**: `teams/{teamId}`
|
||||
|
||||
### 스키마
|
||||
|
||||
```typescript
|
||||
interface Team {
|
||||
id: string; // 문서 ID
|
||||
code: string; // 팀 코드 (예: "춤추는파란사자")
|
||||
name: string; // 팀 이름 (예: "2학년 1반")
|
||||
ownerId: string; // 팀 소유자 UID (정식 계정)
|
||||
|
||||
// 보안 설정
|
||||
securityMode: 'simple' | 'normal' | 'open';
|
||||
requirePin: boolean; // PIN 입력 필요 여부
|
||||
allowAnonymousJoin: boolean; // 명단 없는 학생 자동 가입 허용
|
||||
|
||||
// 멤버
|
||||
studentIds: string[]; // students 컬렉션 참조
|
||||
|
||||
// 타임스탬프
|
||||
createdAt: Timestamp;
|
||||
updatedAt: Timestamp;
|
||||
isActive: boolean; // 활성 상태
|
||||
}
|
||||
```
|
||||
|
||||
### 보안 모드
|
||||
|
||||
| 모드 | requirePin | allowAnonymousJoin | 설명 |
|
||||
|------|-----------|-------------------|------|
|
||||
| `simple` | false | true | 팀 코드 + 이름 (초등 저학년) |
|
||||
| `normal` | true | false | 팀 코드 + 이름 + PIN (보안 강화) |
|
||||
| `open` | false | true | 누구나 자유롭게 참여 |
|
||||
|
||||
### 예시 데이터
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "team_abc123",
|
||||
"code": "춤추는파란사자",
|
||||
"name": "2학년 1반",
|
||||
"ownerId": "teacher_xyz",
|
||||
|
||||
"securityMode": "simple",
|
||||
"requirePin": false,
|
||||
"allowAnonymousJoin": true,
|
||||
|
||||
"studentIds": ["student_001", "student_002", "student_003"],
|
||||
|
||||
"createdAt": "2024-11-06T00:00:00Z",
|
||||
"updatedAt": "2024-11-07T10:00:00Z",
|
||||
"isActive": true
|
||||
}
|
||||
```
|
||||
|
||||
### 인덱스
|
||||
|
||||
- `code` (단일 필드, 고유)
|
||||
- `ownerId` + `isActive` (복합 인덱스)
|
||||
|
||||
**TypeScript**: `src/types/team.ts`
|
||||
|
||||
---
|
||||
|
||||
## 2. Student (학생 계정) ✅
|
||||
|
||||
**컬렉션**: `students/{studentId}`
|
||||
|
||||
### 스키마
|
||||
|
||||
```typescript
|
||||
interface Student {
|
||||
id: string; // 문서 ID
|
||||
firebaseUid: string; // Firebase Anonymous Auth UID
|
||||
linkedUserId?: string; // 연결된 정식 계정 UID (1:1, 선택적)
|
||||
|
||||
// 기본 정보
|
||||
name: string; // 학생 이름
|
||||
|
||||
// 보안
|
||||
pinHash?: string; // SHA-256 해시된 PIN
|
||||
|
||||
// 팀 정보
|
||||
teamIds: string[]; // 속한 팀 ID 배열 (다중 팀 지원)
|
||||
|
||||
// 메타데이터
|
||||
isAnonymous: boolean; // true (Anonymous Auth)
|
||||
createdAt: Timestamp;
|
||||
lastLoginAt: Timestamp;
|
||||
}
|
||||
```
|
||||
|
||||
### 예시 데이터
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "student_001",
|
||||
"firebaseUid": "anon_xyz123",
|
||||
"linkedUserId": "user_parent_abc",
|
||||
|
||||
"name": "김민지",
|
||||
|
||||
"pinHash": "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3",
|
||||
|
||||
"teamIds": ["team_abc123", "team_def456"],
|
||||
|
||||
"isAnonymous": true,
|
||||
"createdAt": "2024-11-06T09:00:00Z",
|
||||
"lastLoginAt": "2024-11-07T14:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 인덱스
|
||||
|
||||
- `firebaseUid` (단일 필드, 고유)
|
||||
- `linkedUserId` (단일 필드)
|
||||
- `teamIds` (array-contains)
|
||||
|
||||
**TypeScript**: `src/types/student.ts`
|
||||
|
||||
---
|
||||
|
||||
## 3. User (사용자)
|
||||
|
||||
**컬렉션**: `users/{userId}` (🔜 구현 예정)
|
||||
|
||||
### 스키마
|
||||
|
||||
```typescript
|
||||
interface User {
|
||||
// Firebase Auth 정보
|
||||
uid: string; // Firebase UID (문서 ID와 동일)
|
||||
email: string; // 이메일
|
||||
displayName: string; // 표시 이름
|
||||
photoURL?: string; // 프로필 사진 URL (선택)
|
||||
createdAt: Timestamp; // 가입일
|
||||
updatedAt: Timestamp; // 최종 수정일
|
||||
|
||||
// 게임화 데이터
|
||||
level: number; // 현재 레벨 (1부터 시작)
|
||||
experience: number; // 현재 경험치 (XP)
|
||||
totalStickers: number; // 획득한 스티커 총 개수
|
||||
|
||||
// 학습 통계
|
||||
writingCount: number; // 작성한 글 수
|
||||
completedLessons: string[]; // 완료한 레슨 ID 배열
|
||||
currentStreak: number; // 연속 출석일
|
||||
longestStreak: number; // 최장 연속 출석일
|
||||
|
||||
// 활동 기록
|
||||
lastLoginAt: Timestamp; // 마지막 로그인 시간
|
||||
lastWritingAt?: Timestamp; // 마지막 글 작성 시간
|
||||
}
|
||||
```
|
||||
|
||||
### 예시 데이터
|
||||
|
||||
```json
|
||||
{
|
||||
"uid": "abc123xyz",
|
||||
"email": "student@example.com",
|
||||
"displayName": "김학생",
|
||||
"photoURL": "https://example.com/photo.jpg",
|
||||
"createdAt": "2024-10-28T00:00:00Z",
|
||||
"updatedAt": "2024-10-28T10:30:00Z",
|
||||
|
||||
"level": 5,
|
||||
"experience": 450,
|
||||
"totalStickers": 12,
|
||||
|
||||
"writingCount": 8,
|
||||
"completedLessons": ["lesson_001", "lesson_002", "lesson_003"],
|
||||
"currentStreak": 3,
|
||||
"longestStreak": 7,
|
||||
|
||||
"lastLoginAt": "2024-10-28T10:00:00Z",
|
||||
"lastWritingAt": "2024-10-27T15:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 인덱스
|
||||
|
||||
- `email` (단일 필드)
|
||||
- `level` (단일 필드, 내림차순)
|
||||
- `writingCount` (단일 필드, 내림차순)
|
||||
|
||||
---
|
||||
|
||||
## 4. Writing (작성글) ✅
|
||||
|
||||
**컬렉션**: `writings/{writingId}`
|
||||
|
||||
### 스키마
|
||||
|
||||
```typescript
|
||||
interface Writing {
|
||||
id: string; // 문서 ID
|
||||
userId: string; // 작성자 UID (TODO: studentId로 변경 예정)
|
||||
topicId?: string | null; // 주제 ID (null은 자유 주제)
|
||||
|
||||
// 글 내용
|
||||
title: string; // 제목
|
||||
content: string; // 본문 내용 (HTML)
|
||||
wordCount: number; // 단어 수
|
||||
charCount: number; // 글자 수
|
||||
|
||||
// 상태
|
||||
status: 'draft' | 'published'; // 임시저장/발행
|
||||
|
||||
// 타임스탬프
|
||||
createdAt: Timestamp; // 최초 작성일
|
||||
updatedAt: Timestamp; // 최종 수정일
|
||||
}
|
||||
```
|
||||
|
||||
**현재 구현**:
|
||||
- ✅ CRUD 기능 완료 (WritingManager)
|
||||
- 🔜 피드백 시스템 (향후 추가)
|
||||
- 🔜 userId → studentId 마이그레이션 예정
|
||||
|
||||
### 예시 데이터
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "writing_001",
|
||||
"userId": "abc123xyz",
|
||||
"topicId": "topic_daily_001",
|
||||
|
||||
"title": "내가 좋아하는 계절",
|
||||
"content": "나는 가을을 제일 좋아해요. 왜냐하면...",
|
||||
"wordCount": 120,
|
||||
|
||||
"status": "submitted",
|
||||
|
||||
"createdAt": "2024-10-27T14:00:00Z",
|
||||
"updatedAt": "2024-10-27T15:00:00Z",
|
||||
"submittedAt": "2024-10-27T15:00:00Z",
|
||||
|
||||
"feedback": {
|
||||
"authorId": "parent_abc",
|
||||
"authorName": "김엄마",
|
||||
"content": "계절의 특징을 잘 표현했어요!",
|
||||
"rating": 5,
|
||||
"createdAt": "2024-10-27T20:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 인덱스
|
||||
|
||||
- `userId` + `createdAt` (복합 인덱스, 내림차순)
|
||||
- `topicId` + `createdAt` (복합 인덱스, 내림차순)
|
||||
- `status` + `createdAt` (복합 인덱스)
|
||||
|
||||
---
|
||||
|
||||
## 5. Topic (글쓰기 주제) ✅
|
||||
|
||||
**컬렉션**: `topics/{topicId}`
|
||||
|
||||
### 스키마
|
||||
|
||||
```typescript
|
||||
interface Topic {
|
||||
id: string; // 문서 ID
|
||||
title: string; // 주제 제목
|
||||
description: string; // 주제 설명
|
||||
|
||||
// 분류
|
||||
category: 'daily' | 'imagination' | 'emotion' | 'experience';
|
||||
difficulty: 'easy' | 'medium' | 'hard';
|
||||
|
||||
// 소유자 정보
|
||||
ownerType: 'system' | 'team' | 'personal';
|
||||
ownerId: string; // 팀 주제: teamId, 개인 주제: userId
|
||||
|
||||
// 메타데이터
|
||||
keywords: string[]; // 관련 키워드
|
||||
examplePrompts: string[]; // 예시 질문/프롬프트
|
||||
|
||||
// 템플릿 (선택적)
|
||||
titleTemplate?: string; // 제목 템플릿 (예: "{date} 일기")
|
||||
contentTemplate?: string; // 내용 템플릿
|
||||
|
||||
// 통계
|
||||
usageCount: number; // 사용된 횟수
|
||||
|
||||
// 타임스탬프
|
||||
createdAt: Timestamp; // 생성일
|
||||
updatedAt: Timestamp; // 수정일
|
||||
|
||||
// 관리자 정보
|
||||
createdBy: string; // 작성자 UID
|
||||
isActive: boolean; // 활성화 여부
|
||||
}
|
||||
```
|
||||
|
||||
**주제 소유 유형**:
|
||||
- `system`: 시스템 기본 주제 (모든 사용자) - 미래 확장용
|
||||
- `team`: 팀 주제 (팀 멤버만, `ownerId = teamId`)
|
||||
- `personal`: 개인 주제 (작성자만)
|
||||
|
||||
**현재 구현**:
|
||||
- ✅ CRUD 기능 완료 (TopicManager)
|
||||
- ✅ 개인 주제 생성/수정/삭제
|
||||
- ✅ 팀 주제 생성/수정/삭제
|
||||
- ✅ 템플릿 처리 (클라이언트)
|
||||
- ✅ TopicSelector에 팀/개인 배지 표시
|
||||
|
||||
### 카테고리 설명
|
||||
|
||||
| 카테고리 | 설명 | 예시 |
|
||||
|---------|------|------|
|
||||
| `daily` | 일상 경험 | "오늘 있었던 일", "가족과 함께한 시간" |
|
||||
| `imagination` | 상상과 창작 | "만약 내가 슈퍼히어로라면", "꿈속에서의 모험" |
|
||||
| `emotion` | 감정 표현 | "가장 행복했던 순간", "속상했던 경험" |
|
||||
| `experience` | 특별한 경험 | "처음 해본 것", "여행에서의 추억" |
|
||||
|
||||
### 예시 데이터
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "topic_daily_001",
|
||||
"title": "내가 좋아하는 계절",
|
||||
"description": "사계절 중 가장 좋아하는 계절과 그 이유를 써보아요.",
|
||||
|
||||
"category": "daily",
|
||||
"difficulty": "easy",
|
||||
|
||||
"keywords": ["계절", "봄", "여름", "가을", "겨울", "날씨"],
|
||||
"examplePrompts": [
|
||||
"어떤 계절을 가장 좋아하나요?",
|
||||
"그 계절에는 무엇을 하나요?",
|
||||
"왜 그 계절이 좋은가요?"
|
||||
],
|
||||
|
||||
"usageCount": 145,
|
||||
|
||||
"createdAt": "2024-10-01T00:00:00Z",
|
||||
"updatedAt": "2024-10-01T00:00:00Z",
|
||||
|
||||
"createdBy": "admin_001",
|
||||
"isActive": true
|
||||
}
|
||||
```
|
||||
|
||||
### 인덱스
|
||||
|
||||
- `category` + `difficulty` (복합 인덱스)
|
||||
- `isActive` + `usageCount` (복합 인덱스, 내림차순)
|
||||
|
||||
---
|
||||
|
||||
## 6. Lesson (학습 레슨) 🔜
|
||||
|
||||
**컬렉션**: `lessons/{lessonId}` (구현 예정)
|
||||
|
||||
### 스키마
|
||||
|
||||
```typescript
|
||||
interface Lesson {
|
||||
id: string; // 문서 ID
|
||||
title: string; // 레슨 제목
|
||||
description: string; // 레슨 설명
|
||||
|
||||
// 분류
|
||||
level: number; // 추천 레벨 (1-100)
|
||||
category: string; // 카테고리 (예: "문단 쓰기", "비유 표현")
|
||||
order: number; // 순서 (같은 카테고리 내)
|
||||
|
||||
// 콘텐츠
|
||||
content: {
|
||||
theory: string; // 이론 설명 (마크다운)
|
||||
examples: string[]; // 예시 문장들
|
||||
exercises: Exercise[]; // 연습 문제
|
||||
};
|
||||
|
||||
// 선행 조건
|
||||
requiredLessons?: string[]; // 선행 레슨 ID 배열
|
||||
|
||||
// 보상
|
||||
reward: {
|
||||
experience: number; // 획득 경험치
|
||||
stickers?: string[]; // 획득 가능한 스티커 ID
|
||||
};
|
||||
|
||||
// 통계
|
||||
completionCount: number; // 완료한 학생 수
|
||||
averageScore?: number; // 평균 점수 (0-100)
|
||||
|
||||
// 타임스탬프
|
||||
createdAt: Timestamp;
|
||||
updatedAt: Timestamp;
|
||||
|
||||
// 관리자
|
||||
createdBy: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface Exercise {
|
||||
id: string;
|
||||
type: 'multiple_choice' | 'short_answer' | 'writing';
|
||||
question: string;
|
||||
options?: string[]; // 객관식인 경우
|
||||
correctAnswer?: string | number;
|
||||
explanation?: string; // 해설
|
||||
}
|
||||
```
|
||||
|
||||
### 예시 데이터
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "lesson_001",
|
||||
"title": "문장 부호 사용하기",
|
||||
"description": "마침표, 물음표, 느낌표를 올바르게 사용하는 방법을 배워요.",
|
||||
|
||||
"level": 1,
|
||||
"category": "기초 문법",
|
||||
"order": 1,
|
||||
|
||||
"content": {
|
||||
"theory": "# 문장 부호란?\n문장의 끝에는 마침표(.), 물음표(?), 느낌표(!)를 사용해요.",
|
||||
"examples": [
|
||||
"오늘은 날씨가 좋아요.",
|
||||
"너는 어디에 가니?",
|
||||
"와, 정말 멋지다!"
|
||||
],
|
||||
"exercises": [
|
||||
{
|
||||
"id": "ex_001",
|
||||
"type": "multiple_choice",
|
||||
"question": "다음 중 올바른 문장은?",
|
||||
"options": [
|
||||
"오늘은 날씨가 좋아요",
|
||||
"오늘은 날씨가 좋아요.",
|
||||
"오늘은 날씨가 좋아요?"
|
||||
],
|
||||
"correctAnswer": 1,
|
||||
"explanation": "평서문은 마침표(.)로 끝나요."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"reward": {
|
||||
"experience": 50,
|
||||
"stickers": ["sticker_beginner_001"]
|
||||
},
|
||||
|
||||
"completionCount": 234,
|
||||
"averageScore": 87.5,
|
||||
|
||||
"createdAt": "2024-10-01T00:00:00Z",
|
||||
"updatedAt": "2024-10-01T00:00:00Z",
|
||||
|
||||
"createdBy": "admin_001",
|
||||
"isActive": true
|
||||
}
|
||||
```
|
||||
|
||||
### 인덱스
|
||||
|
||||
- `category` + `order` (복합 인덱스, 오름차순)
|
||||
- `level` + `order` (복합 인덱스, 오름차순)
|
||||
|
||||
---
|
||||
|
||||
## 7. Sticker (스티커) 🔜
|
||||
|
||||
**컬렉션**: `stickers/{stickerId}` (구현 예정)
|
||||
|
||||
### 스키마
|
||||
|
||||
```typescript
|
||||
interface Sticker {
|
||||
id: string; // 문서 ID
|
||||
name: string; // 스티커 이름
|
||||
imageUrl: string; // 이미지 URL
|
||||
description: string; // 설명
|
||||
|
||||
// 분류
|
||||
category: 'learning' | 'challenge' | 'special'; // 카테고리
|
||||
rarity: 'common' | 'rare' | 'epic' | 'legendary'; // 희귀도
|
||||
|
||||
// 획득 조건
|
||||
unlockCondition: {
|
||||
type: 'writing_count' | 'lesson_completion' | 'level' | 'streak' | 'manual';
|
||||
value?: number; // 조건 값 (type에 따라 다름)
|
||||
lessonId?: string; // lesson_completion인 경우
|
||||
};
|
||||
|
||||
// 통계
|
||||
unlockCount: number; // 획득한 사용자 수
|
||||
|
||||
// 타임스탬프
|
||||
createdAt: Timestamp;
|
||||
createdBy: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### 희귀도 및 카테고리
|
||||
|
||||
| 희귀도 | 설명 | 예시 |
|
||||
|-------|------|------|
|
||||
| `common` | 일반 | 기본 달성 스티커 |
|
||||
| `rare` | 레어 | 10개 글 작성 |
|
||||
| `epic` | 에픽 | 50개 글 작성, 레벨 20 달성 |
|
||||
| `legendary` | 전설 | 100개 글 작성, 모든 레슨 완료 |
|
||||
|
||||
| 카테고리 | 설명 |
|
||||
|---------|------|
|
||||
| `learning` | 학습 관련 (레슨 완료) |
|
||||
| `challenge` | 도전 과제 (글 수, 연속 출석) |
|
||||
| `special` | 특별 이벤트 |
|
||||
|
||||
### 예시 데이터
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "sticker_beginner_001",
|
||||
"name": "첫 글쓰기",
|
||||
"imageUrl": "https://example.com/stickers/first_writing.png",
|
||||
"description": "첫 번째 글을 완성했어요!",
|
||||
|
||||
"category": "challenge",
|
||||
"rarity": "common",
|
||||
|
||||
"unlockCondition": {
|
||||
"type": "writing_count",
|
||||
"value": 1
|
||||
},
|
||||
|
||||
"unlockCount": 856,
|
||||
|
||||
"createdAt": "2024-10-01T00:00:00Z",
|
||||
"createdBy": "admin_001",
|
||||
"isActive": true
|
||||
}
|
||||
```
|
||||
|
||||
### 인덱스
|
||||
|
||||
- `category` + `rarity` (복합 인덱스)
|
||||
- `unlockCount` (단일 필드, 내림차순)
|
||||
|
||||
---
|
||||
|
||||
## 8. UserSticker (사용자 스티커) 🔜
|
||||
|
||||
**컬렉션**: `userStickers/{userStickerId}` (구현 예정)
|
||||
|
||||
### 스키마
|
||||
|
||||
```typescript
|
||||
interface UserSticker {
|
||||
id: string; // 문서 ID
|
||||
userId: string; // 사용자 UID
|
||||
stickerId: string; // 스티커 ID
|
||||
unlockedAt: Timestamp; // 획득 시간
|
||||
}
|
||||
```
|
||||
|
||||
### 예시 데이터
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "us_abc123_sticker001",
|
||||
"userId": "abc123xyz",
|
||||
"stickerId": "sticker_beginner_001",
|
||||
"unlockedAt": "2024-10-27T15:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 인덱스
|
||||
|
||||
- `userId` + `unlockedAt` (복합 인덱스, 내림차순)
|
||||
- `userId` + `stickerId` (복합 인덱스, 중복 방지용)
|
||||
|
||||
---
|
||||
|
||||
## TypeScript 타입 정의 파일
|
||||
|
||||
### 데이터 모델 타입
|
||||
|
||||
모든 데이터 모델 타입은 `src/types/` 디렉토리에 정의됩니다.
|
||||
|
||||
```
|
||||
src/types/
|
||||
├── team.ts # ✅ Team 데이터 모델
|
||||
├── student.ts # ✅ Student 데이터 모델
|
||||
├── writing.ts # ✅ Writing 데이터 모델
|
||||
├── topic.ts # ✅ Topic 데이터 모델
|
||||
├── user.ts # 🔜 User 관련 타입 (예정)
|
||||
├── lesson.ts # 🔜 Lesson 관련 타입 (예정)
|
||||
├── sticker.ts # 🔜 Sticker 관련 타입 (예정)
|
||||
└── api/ # ✅ API Request/Response 타입
|
||||
├── team.ts # Team API 타입 (10개 엔드포인트)
|
||||
├── student.ts # Student API 타입 (13개 엔드포인트)
|
||||
├── writing.ts # Writing API 타입 (6개 엔드포인트)
|
||||
└── topic.ts # Topic API 타입 (6개 엔드포인트)
|
||||
```
|
||||
|
||||
### 사용 예시
|
||||
|
||||
```typescript
|
||||
// 데이터 모델 import
|
||||
import type { Team } from "@/types/team";
|
||||
import type { Student } from "@/types/student";
|
||||
import type { Writing } from "@/types/writing";
|
||||
|
||||
// API 타입 import
|
||||
import type { CreateTeamRequest, CreateTeamResponse } from "@/types/api/team";
|
||||
import type { GetStudentsByTeamResponse } from "@/types/api/student";
|
||||
|
||||
// Manager에서 사용
|
||||
import { teamManager, studentManager } from "@/managers";
|
||||
const teams = await teamManager.getMyTeams(); // Team[] 반환
|
||||
```
|
||||
|
||||
### API 공통 타입
|
||||
|
||||
`src/types/api.ts`에 정의된 공통 타입:
|
||||
|
||||
```typescript
|
||||
// 표준 응답 형식
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: ApiError;
|
||||
}
|
||||
|
||||
// 에러 형식
|
||||
interface ApiError {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: any;
|
||||
}
|
||||
|
||||
// HTTP Method Enum
|
||||
enum HttpMethod {
|
||||
GET = "GET",
|
||||
POST = "POST",
|
||||
PUT = "PUT",
|
||||
DELETE = "DELETE",
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Firestore 보안 규칙
|
||||
|
||||
### 파일 위치
|
||||
|
||||
- `firestore.rules` (프로젝트 루트)
|
||||
|
||||
### 기본 규칙 예시
|
||||
|
||||
**참고**: 실제 구현 시 Next.js API Routes/Server Actions에서 권한 체크 수행
|
||||
|
||||
```javascript
|
||||
rules_version = '2';
|
||||
service cloud.firestore {
|
||||
match /databases/{database}/documents {
|
||||
|
||||
// 팀: 읽기는 모두 가능, 쓰기는 서버에서만
|
||||
match /teams/{teamId} {
|
||||
allow read: if request.auth != null;
|
||||
allow write: if false; // API에서만 쓰기 (권한 체크)
|
||||
}
|
||||
|
||||
// 학생: 본인 또는 연결된 계정만 읽기, 쓰기는 서버에서만
|
||||
match /students/{studentId} {
|
||||
allow read: if request.auth != null && (
|
||||
request.auth.uid == resource.data.firebaseUid ||
|
||||
request.auth.uid == resource.data.linkedUserId
|
||||
);
|
||||
allow write: if false; // API에서만 쓰기
|
||||
}
|
||||
|
||||
// 사용자는 자신의 문서만 읽고 쓸 수 있음
|
||||
match /users/{userId} {
|
||||
allow read, write: if request.auth != null && request.auth.uid == userId;
|
||||
}
|
||||
|
||||
// 작성글: 작성자만 읽고 쓸 수 있음
|
||||
match /writings/{writingId} {
|
||||
allow read: if request.auth != null &&
|
||||
resource.data.userId == request.auth.uid;
|
||||
allow write: if false; // API에서만 쓰기
|
||||
}
|
||||
|
||||
// 주제: 모든 인증된 사용자가 읽을 수 있음
|
||||
match /topics/{topicId} {
|
||||
allow read: if request.auth != null;
|
||||
allow write: if false; // API에서만 쓰기
|
||||
}
|
||||
|
||||
// 레슨: 모든 인증된 사용자가 읽을 수 있음
|
||||
match /lessons/{lessonId} {
|
||||
allow read: if request.auth != null;
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
// 스티커: 모든 인증된 사용자가 읽을 수 있음
|
||||
match /stickers/{stickerId} {
|
||||
allow read: if request.auth != null;
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
// 사용자 스티커: 자신의 것만 읽을 수 있음
|
||||
match /userStickers/{userStickerId} {
|
||||
allow read: if request.auth != null &&
|
||||
resource.data.userId == request.auth.uid;
|
||||
allow write: if false; // 서버에서만 부여
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**보안 참고사항**:
|
||||
- 모든 쓰기 작업은 Next.js API Routes에서 수행
|
||||
- API에서 Firebase Admin SDK 사용하여 Firestore 접근
|
||||
- 클라이언트 매니저는 API 호출만 수행 (Firestore 직접 접근 안 함)
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [API_SPEC.md](./API_SPEC.md) - API 명세서 (35개 엔드포인트)
|
||||
- [PROJECT_STRUCTURE.md](./PROJECT_STRUCTURE.md) - 프로젝트 구조
|
||||
- [TECH_STACK.md](./TECH_STACK.md) - 기술 스택
|
||||
- [ROADMAP.md](./ROADMAP.md) - 개발 로드맵
|
||||
- [CLAUDE.md](./CLAUDE.md) - Claude Code 개발 가이드
|
||||
|
||||
---
|
||||
|
||||
© 2024 BlueNovaLab. All rights reserved.
|
||||
@ -1,9 +1,15 @@
|
||||
# 라온누리 - 프로젝트 구조
|
||||
|
||||
> 최종 업데이트: 2025-11-07 (아키텍처 단순화 - Users 컬렉션 전환)
|
||||
> 최종 업데이트: 2025-11-10 (5단계 보안 레벨 시스템, User 타입 최소화)
|
||||
|
||||
초등학생을 위한 창작 글쓰기 교육 플랫폼
|
||||
|
||||
**주요 업데이트** (2025-11-10):
|
||||
- 🔐 5단계 보안 레벨 시스템 (팀별 보안 정책 선택)
|
||||
- 📦 User 타입 분리 (FirestoreUser / User)
|
||||
- 🏷️ 닉네임 저장 위치 변경 (team.members[uid].nickname)
|
||||
- 🗑️ memberUids optional (Object.keys(members) 사용)
|
||||
|
||||
---
|
||||
|
||||
## 페이지 구조
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# 라온누리 - 개발 로드맵
|
||||
|
||||
> 최종 업데이트: 2025-11-07 (그룹 기능 제거, 팀 시스템 개선)
|
||||
> 최종 업데이트: 2025-11-10 (5단계 보안 레벨 시스템, User 타입 최소화)
|
||||
|
||||
초등학생을 위한 창작 글쓰기 교육 플랫폼 개발 계획
|
||||
|
||||
@ -53,6 +53,10 @@
|
||||
| **UserManager 개선** | **getUser() 404 시 null 반환, 팀 코드 로그인 시 신규 사용자 생성 플로우 개선** | **2025-11-07** |
|
||||
| **TopicSelector UI 개선** | **드롭다운 메뉴에 팀/개인 주제 배지 표시, 선택 전에도 주제 타입 확인 가능** | **2025-11-07** |
|
||||
| **T_ prefix 제거** | **팀 주제 ownerId에서 T_ prefix 제거, ownerId = teamId 직접 사용** | **2025-11-07** |
|
||||
| **5단계 보안 레벨 시스템** | **TeamSecurityLevel enum (1-5), 팀별 보안 정책 선택, 명단 관리 API** | **2025-11-10** |
|
||||
| **User 타입 최소화** | **FirestoreUser/User 분리, Firebase Auth를 Single Source of Truth로, 데이터 중복 제거** | **2025-11-10** |
|
||||
| **닉네임 저장 위치 변경** | **users.nicknames → team.members[uid].nickname 이동, TeamMember 타입 확장** | **2025-11-10** |
|
||||
| **authStore DB 연동** | **combineUserData로 Firebase Auth + Firestore 자동 결합, userManager.getUser() 활용** | **2025-11-10** |
|
||||
|
||||
### 🚧 진행 중
|
||||
|
||||
|
||||
@ -262,13 +262,13 @@ Manager Layer (비즈니스 로직 + 클라이언트 캐싱)
|
||||
│ ├─> deleteTeam() → DELETE /team/:id
|
||||
│ └─> generateUniqueTeamCode() → POST /team/generate-code
|
||||
│
|
||||
├─> StudentManager (싱글톤)
|
||||
│ ├─> createStudent() → POST /student
|
||||
│ ├─> getStudent() → GET /student/:id (5분 캐싱)
|
||||
│ ├─> getStudentsByTeam() → POST /student/by-team (30초 캐싱)
|
||||
│ ├─> kickStudentFromTeam() → POST /student/kick
|
||||
│ ├─> validateStudentPin() → POST /student/validate-pin
|
||||
│ └─> linkStudentToUser() → POST /student/link
|
||||
├─> UserManager (싱글톤)
|
||||
│ ├─> createUser() → POST /user
|
||||
│ ├─> getUser() → GET /user/:id (Firebase Auth + Firestore 자동 결합, 5분 캐싱)
|
||||
│ ├─> getUsersByTeam() → GET /user/by-team/:teamId (30초 캐싱)
|
||||
│ ├─> updateLastLogin() → POST /user/:uid/update-last-login
|
||||
│ ├─> findUserByNickname() → POST /user/find-by-nickname (Level 1용)
|
||||
│ └─> setUserNickname() → POST /user/:uid/nickname (DEPRECATED - 팀에서 관리)
|
||||
│
|
||||
├─> WritingManager (싱글톤)
|
||||
│ ├─> createWriting()
|
||||
@ -335,14 +335,19 @@ import { teamManager } from "@/managers";
|
||||
// 팀 목록 조회 - 소유한 팀 + 참여한 팀 (1분간 캐싱됨)
|
||||
const teams = await teamManager.getMyTeams();
|
||||
|
||||
// 팀 생성 (캐시 자동 무효화)
|
||||
// 🆕 팀 생성 (5단계 보안 레벨)
|
||||
const teamId = await teamManager.createTeam({
|
||||
name: "우리반",
|
||||
code: "춤추는파란사자",
|
||||
securityMode: "simple",
|
||||
requirePin: false,
|
||||
allowAnonymousJoin: true
|
||||
securityLevel: 2, // 1-5 (명단 기반)
|
||||
allowedNames: ["홍길동", "김철수"]
|
||||
});
|
||||
|
||||
// 🆕 보안 레벨 변경
|
||||
await teamManager.updateSecurityLevel(teamId, 4, true); // Level 4, 자동 명단 생성
|
||||
|
||||
// 🆕 닉네임 조회
|
||||
const nickname = teamManager.getMemberNickname(team, uid, user?.name);
|
||||
```
|
||||
|
||||
**참고 문서**:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user