959 lines
25 KiB
Markdown
959 lines
25 KiB
Markdown
# 라온누리 - 데이터 모델 및 스키마
|
|
|
|
> 최종 업데이트: 2025-11-12 (실시간 글쓰기 모니터링)
|
|
|
|
이 문서는 Firestore 데이터베이스 및 Firebase Realtime Database 구조와 TypeScript 타입 정의를 설명합니다.
|
|
|
|
**참고**: API 타입 정의는 [API_SPEC.md](./API_SPEC.md)를 참조하세요.
|
|
|
|
---
|
|
|
|
## 데이터베이스 구조
|
|
|
|
### Firestore (영구 데이터)
|
|
|
|
```
|
|
firestore
|
|
├── teams/ # ✅ 팀 (팀 코드 시스템)
|
|
├── users/ # ✅ 사용자 프로필 및 메타데이터
|
|
├── writings/ # ✅ 작성한 글
|
|
├── topics/ # ✅ 글쓰기 주제
|
|
├── patternAnalyses/ # ✅ 패턴 분석 결과 (contentHash 캐싱)
|
|
├── lessons/ # 🔜 학습 레슨
|
|
├── stickers/ # 🔜 스티커 마스터 데이터
|
|
└── userStickers/ # 🔜 사용자별 스티커 획득 기록
|
|
```
|
|
|
|
### Firebase Realtime Database (실시간 데이터)
|
|
|
|
```
|
|
realtime-db
|
|
├── monitoring/ # 🆕 실시간 글쓰기 모니터링
|
|
│ └── {teamId}/
|
|
│ └── {topicId}/
|
|
│ └── {userId}/
|
|
├── previewRequests/ # 🆕 미리보기 요청
|
|
│ └── {userId}/
|
|
└── previewResponses/ # 🆕 미리보기 응답
|
|
└── {requestId}/
|
|
```
|
|
|
|
**범례**:
|
|
- ✅ 구현 완료
|
|
- 🔜 구현 예정
|
|
|
|
---
|
|
|
|
## 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` (복합 인덱스, 중복 방지용)
|
|
|
|
---
|
|
|
|
## 9. WritingSession (실시간 글쓰기 모니터링) 🆕
|
|
|
|
**데이터베이스**: Firebase Realtime Database (휘발성 데이터)
|
|
|
|
### Realtime DB 구조
|
|
|
|
#### 9.1. monitoring (글쓰기 통계)
|
|
|
|
**경로**: `monitoring/{teamId}/{topicId}/{userId}`
|
|
|
|
```typescript
|
|
interface WritingStats {
|
|
userId: string; // 작성자 UID
|
|
contentLength: number; // 현재 글자 수 (공백 포함)
|
|
wordCount: number; // 현재 단어 수
|
|
topicId: string; // 현재 작성 중인 주제 ID
|
|
lastUpdated: number; // 마지막 업데이트 시간 (timestamp) - 항상 변경됨
|
|
}
|
|
|
|
interface SpeedDataPoint {
|
|
speed: number; // 작성 속도 (글자/분)
|
|
timestamp: number; // 기록 시간
|
|
}
|
|
|
|
interface MonitoringData extends WritingStats {
|
|
displayName: string; // 표시 이름
|
|
speedHistory: SpeedDataPoint[]; // 속도 히스토리 (최근 10개)
|
|
currentSpeed: number; // 현재 속도 (글자/분)
|
|
isActive: boolean; // 활성 상태 (false면 나간 상태)
|
|
preview?: string; // 미리보기 (선택적)
|
|
}
|
|
```
|
|
|
|
**예시 데이터**:
|
|
```json
|
|
{
|
|
"monitoring": {
|
|
"team_abc123": {
|
|
"topic_xyz": {
|
|
"user_001": {
|
|
"userId": "user_001",
|
|
"contentLength": 1500,
|
|
"wordCount": 300,
|
|
"topicId": "topic_xyz",
|
|
"lastUpdated": 1731398400000
|
|
},
|
|
"user_002": {
|
|
"userId": "user_002",
|
|
"contentLength": 800,
|
|
"wordCount": 160,
|
|
"topicId": "topic_xyz",
|
|
"lastUpdated": 1731398395000
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**클라이언트 측 계산** (선생님 화면):
|
|
```typescript
|
|
// MonitoringData (UI용)
|
|
{
|
|
userId: "user_001",
|
|
contentLength: 1500,
|
|
wordCount: 300,
|
|
topicId: "topic_xyz",
|
|
lastUpdated: 1731398400000,
|
|
displayName: "철수",
|
|
currentSpeed: 420, // 글자/분 (클라이언트 계산)
|
|
isActive: true, // 활성 상태 (30초 타임아웃 체크)
|
|
speedHistory: [ // 최근 10개 (클라이언트 계산)
|
|
{ speed: 480, timestamp: 1731398370000 },
|
|
{ speed: 420, timestamp: 1731398375000 },
|
|
{ speed: 360, timestamp: 1731398380000 },
|
|
{ speed: 0, timestamp: 1731398385000 }, // 멈춤!
|
|
{ speed: 0, timestamp: 1731398390000 },
|
|
{ speed: 420, timestamp: 1731398395000 }, // 재시작
|
|
{ speed: 480, timestamp: 1731398400000 }
|
|
]
|
|
}
|
|
```
|
|
|
|
**활성 상태 판단 로직**:
|
|
```typescript
|
|
// 1. Firebase에서 데이터 수신 → isActive: true
|
|
// 2. 30초 타임아웃 체크
|
|
const timeSinceUpdate = Date.now() - lastUpdated;
|
|
if (timeSinceUpdate > 30000) {
|
|
isActive = false; // 30초 이상 업데이트 없음 → 나감
|
|
}
|
|
|
|
// 3. Firebase에서 삭제되어도 마지막 통계 유지
|
|
if (!newData[userId] && prevData[userId]) {
|
|
keep prevData[userId] with isActive: false;
|
|
}
|
|
```
|
|
|
|
#### 9.2. previewRequests (미리보기 요청)
|
|
|
|
**경로**: `previewRequests/{userId}/{requestId}`
|
|
|
|
```typescript
|
|
interface PreviewRequest {
|
|
requestedBy: string; // 요청한 관리자 UID
|
|
timestamp: number; // 요청 시간
|
|
requestId: string; // 고유 요청 ID
|
|
}
|
|
```
|
|
|
|
#### 9.3. previewResponses (미리보기 응답)
|
|
|
|
**경로**: `previewResponses/{requestId}`
|
|
|
|
```typescript
|
|
interface PreviewResponse {
|
|
content: string; // 현재 작성 중인 글 내용
|
|
timestamp: number; // 응답 시간
|
|
requestId: string; // 요청 ID (매칭용)
|
|
}
|
|
```
|
|
|
|
### 업데이트 주기
|
|
|
|
- **통계 전송**: 5초마다 (학생이 팀 주제로 작성 중일 때만)
|
|
- **자동 정리**: `onDisconnect().remove()`로 페이지 이탈 시 자동 삭제
|
|
- **미리보기**: 요청 시에만 (10초 타임아웃)
|
|
|
|
### Security Rules
|
|
|
|
**파일**: `database.rules.json`
|
|
|
|
```json
|
|
{
|
|
"rules": {
|
|
"monitoring": {
|
|
"$teamId": {
|
|
"$topicId": {
|
|
"$userId": {
|
|
".read": "auth != null && (auth.uid == $userId || root.child('teamOwners').child($teamId).val() == auth.uid)",
|
|
".write": "auth != null && auth.uid == $userId"
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"previewRequests": {
|
|
"$userId": {
|
|
".read": "auth != null && auth.uid == $userId",
|
|
".write": "auth != null"
|
|
}
|
|
},
|
|
"previewResponses": {
|
|
"$requestId": {
|
|
".read": "auth != null",
|
|
".write": "auth != null"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**권한**:
|
|
- 통계 쓰기: 본인만
|
|
- 통계 읽기: 본인 + 팀 소유자
|
|
- 미리보기 요청: 누구나 쓰기, 대상자만 읽기
|
|
- 미리보기 응답: 누구나 읽기/쓰기 (requestId로 필터링)
|
|
|
|
**TypeScript**: `src/types/writingSession.ts`
|
|
|
|
---
|
|
|
|
## TypeScript 타입 정의 파일
|
|
|
|
### 데이터 모델 타입
|
|
|
|
모든 데이터 모델 타입은 `src/types/` 디렉토리에 정의됩니다.
|
|
|
|
```
|
|
src/types/
|
|
├── team.ts # ✅ Team 데이터 모델
|
|
├── firestoreUser.ts # ✅ User 데이터 모델 (FirestoreUser, User 분리)
|
|
├── writing.ts # ✅ Writing 데이터 모델
|
|
├── topic.ts # ✅ Topic 데이터 모델
|
|
├── draft.ts # ✅ Draft 데이터 모델 (글조각)
|
|
├── writingPattern.ts # ✅ WritingPattern 분석 데이터 모델
|
|
├── writingSession.ts # 🆕 WritingSession 실시간 모니터링 타입
|
|
├── lesson.ts # 🔜 Lesson 관련 타입 (예정)
|
|
├── sticker.ts # 🔜 Sticker 관련 타입 (예정)
|
|
└── api/ # ✅ API Request/Response 타입
|
|
├── team.ts # Team API 타입
|
|
├── user.ts # User API 타입
|
|
├── writing.ts # Writing API 타입
|
|
└── topic.ts # Topic API 타입
|
|
```
|
|
|
|
### 사용 예시
|
|
|
|
```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. |