docs: Sync documentation from private repository
This commit is contained in:
parent
2248306446
commit
fcb0ad4ab0
297
API_SPEC.md
297
API_SPEC.md
@ -2,146 +2,199 @@
|
||||
|
||||
라온누리 서버 API 명세서
|
||||
|
||||
## ⚠️ 최신 변경사항 (2025-12-02)
|
||||
---
|
||||
|
||||
### 📰 피드 시스템 API
|
||||
- **GET /api/feed/daily-prompt**: 오늘의 글감 조회
|
||||
- 매일 새로운 주제 제안 (AI 생성)
|
||||
- 캐싱: 2분
|
||||
- 응답: `{ prompt: string, category: string, keywords: string[] }`
|
||||
- **GET /api/feed/inspirations**: 추천 영감 목록
|
||||
- Vertex AI 생성 영감 (제목, 내용, 이미지)
|
||||
- Unsplash API 이미지 (작가 크레딧 포함)
|
||||
- 캐싱: 2분
|
||||
- 응답: `Inspiration[]` (id, title, content, imageUrl, unsplashCredit)
|
||||
- **GET /api/feed/weekly-stats**: 주간 통계 조회
|
||||
- 주간 목표 및 진행률
|
||||
- 권한: 로그인 필요
|
||||
- 응답: `{ weeklyGoal: number, writingsThisWeek: number, ... }`
|
||||
- **GET /api/feed/team-activity**: 팀 활동 조회
|
||||
- 최근 팀 글 활동 요약
|
||||
- 권한: 로그인 필요
|
||||
- 응답: `TeamActivity[]`
|
||||
- **POST /api/user/[uid]/weekly-goal**: 주간 목표 설정
|
||||
- 권한: 본인만 수정 가능
|
||||
- 요청: `{ weeklyGoal: number }`
|
||||
- 응답: `{ success: true }`
|
||||
## 🔧 API 개발 필수 가이드
|
||||
|
||||
### 🎨 AI 영감 생성 (Cloud Functions)
|
||||
- **generateDailyInspirations** (Scheduled): 매일 새벽 자동 생성
|
||||
- Vertex AI (Gemini 2.5 Flash)
|
||||
- Unsplash API 이미지 검색 및 다운로드
|
||||
- Firebase Storage 저장
|
||||
- Firestore `inspirations` 컬렉션 저장
|
||||
- 크레딧 정보 저장 (작가 이름, 프로필 링크)
|
||||
- **generateInspirationsManual** (HTTP): 관리자 수동 생성
|
||||
- POST 요청으로 즉시 생성
|
||||
- 기존 영감 덮어쓰기 옵션
|
||||
- Rate Limit: 1초 간격
|
||||
### RESTful API 설계 원칙
|
||||
|
||||
### 📦 FeedManager
|
||||
- **메서드**: `getDailyPrompt()`, `getInspirations()`, `getWeeklyStats()`, `getTeamActivity()`
|
||||
- **캐싱**: 2분 TTL (BaseManager 캐싱 활용)
|
||||
- **타입 시스템**: `Inspiration`, `DailyPrompt`, `WeeklyStats`, `TeamActivity`
|
||||
**HTTP Method로 동작 구분** (경로로 구분하지 않음):
|
||||
|
||||
## ⚠️ 변경사항 (2025-11-27)
|
||||
```typescript
|
||||
// ✅ 올바른 방식 (RESTful)
|
||||
POST /api/team/:id/allowed-names // 이름 추가
|
||||
DELETE /api/team/:id/allowed-names // 이름 제거
|
||||
|
||||
### 🖼️ 팀 커버 이미지 API
|
||||
- **POST /api/team/[teamId]/cover-image**: 팀 커버 이미지 업로드
|
||||
- FormData로 이미지 파일 전송 (JPEG/PNG/WebP/GIF, 최대 5MB)
|
||||
- Firebase Storage 업로드 (`teams/{teamId}/cover-{timestamp}.{ext}`)
|
||||
- 기존 이미지 자동 삭제
|
||||
- 파일 공개 설정 (makePublic)
|
||||
- 팀 문서 coverImage 필드 업데이트
|
||||
- 권한: 팀 소유자만
|
||||
- **DELETE /api/team/[teamId]/cover-image**: 팀 커버 이미지 삭제
|
||||
- Storage에서 파일 삭제
|
||||
- 팀 문서 coverImage 필드 제거
|
||||
- 권한: 팀 소유자만
|
||||
- **TeamManager 메서드**: `uploadCoverImage()`, `deleteCoverImage()`
|
||||
- **컴포넌트**: TeamCoverImageUploader (드래그앤드롭, 미리보기, AspectRatio 16:9)
|
||||
POST /api/team/:id/allowed-emails // 이메일 추가
|
||||
DELETE /api/team/:id/allowed-emails // 이메일 제거
|
||||
|
||||
## ⚠️ 변경사항 (2025-11-26)
|
||||
// ❌ 잘못된 방식 (경로로 구분)
|
||||
POST /api/team/:id/allowed-names/add // ❌
|
||||
POST /api/team/:id/allowed-names/remove // ❌
|
||||
```
|
||||
|
||||
### 🔗 익명 계정 연결 기능
|
||||
- **POST /api/auth/merge-account**: 익명 계정 데이터를 정식 계정으로 마이그레이션
|
||||
- Firestore 데이터 이전 (writings, topics, comments, userReactions, teams)
|
||||
- Realtime DB 데이터 이전 (drafts, monitoring, previewRequests)
|
||||
- 원자성 보장 (Firestore Batch, Realtime DB Transaction)
|
||||
- 병합 완료 후 통계 반환
|
||||
- **서비스 레이어**: `src/services/firebaseAuth.ts` (mergeAndLoginWithEmail, mergeAndLoginWithGoogle)
|
||||
- **상태 관리**: `src/store/authStore.ts` (mergeWithEmail, mergeWithGoogle 액션)
|
||||
- **UI 통합**: LoginForm/SignupForm mode prop, LoginDialog link 모드
|
||||
**API Route 파일 구조**:
|
||||
```typescript
|
||||
// src/app/api/team/[teamId]/allowed-names/route.ts
|
||||
|
||||
## ⚠️ 변경사항 (2025-11-12)
|
||||
export async function POST(request: NextRequest, context: RouteContext) {
|
||||
// 추가 로직
|
||||
}
|
||||
|
||||
### ✅ Writing API 구현 완료
|
||||
- **POST /api/writing**: 글 생성 (서버에서 wordCount/charCount 자동 계산)
|
||||
- **GET /api/writing/[id]**: 글 조회 (작성자만 접근)
|
||||
- **PUT /api/writing/[id]**: 글 수정 (작성자만 접근)
|
||||
- **DELETE /api/writing/[id]**: 글 삭제 (작성자만 접근)
|
||||
- **POST /api/writing/user**: 사용자 글 목록
|
||||
- **POST /api/writing/recent**: 최근 글 (limit 파라미터)
|
||||
- **서버 레이어**: `src/lib/server/writing.ts` (Firestore CRUD)
|
||||
export async function DELETE(request: NextRequest, context: RouteContext) {
|
||||
// 삭제 로직
|
||||
}
|
||||
```
|
||||
|
||||
### ⚡ Content Hash 기반 3단계 스마트 캐싱
|
||||
- **POST /api/analyze-pattern**: contentHash 파라미터 추가
|
||||
- **L1 캐시**: localStorage (영구, LRU 10개) ~1ms
|
||||
- **L2 캐시**: Firestore `patternAnalyses` 컬렉션 (영구) ~100ms
|
||||
- **L3 캐시**: Server in-memory (5분, 50개) ~50ms
|
||||
- **해시 생성**: `id:updatedAt` 조합 (SHA-256)
|
||||
- **자동 변경 감지**: 글 추가/수정 → 해시 변경 → 재분석
|
||||
- **AI 비용 절감**: 동일 글 세트는 전체 사용자 기준 1회만 분석
|
||||
- **서버 레이어**: `src/lib/server/patternAnalysis.ts` (Firestore CRUD)
|
||||
|
||||
## ⚠️ 변경사항 (2025-11-11)
|
||||
|
||||
### 🤖 실시간 피드백 시스템
|
||||
- **POST /api/analyze-text**: Vertex AI 기반 텍스트 분석 API
|
||||
- Delta 전송 지원 (previousText 파라미터)
|
||||
- 서버 캐싱 (In-Memory LRU, TTL 1분)
|
||||
- Multi-region failover (3개 region)
|
||||
- 점수, 찾은 단어, 수정 제안 반환
|
||||
|
||||
### 🌏 Multi-Region Failover
|
||||
- Vertex AI 3개 region 자동 전환
|
||||
- Rate Limit 대응 (RPM 15 → 45)
|
||||
- Region health tracking
|
||||
- Exponential backoff
|
||||
**HTTP Method 사용 가이드**:
|
||||
- **GET**: 조회 (캐싱 가능, 멱등성)
|
||||
- **POST**: 생성, 추가, 복잡한 조회
|
||||
- **PUT**: 전체 수정 (멱등성)
|
||||
- **PATCH**: 부분 수정
|
||||
- **DELETE**: 삭제 (멱등성)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 2025-11-10 변경사항
|
||||
### Firebase Admin SDK 사용 규칙
|
||||
|
||||
### 🔐 5단계 보안 레벨 시스템
|
||||
팀 생성 시 5가지 보안 레벨 선택 가능:
|
||||
- **Level 1 (OPEN)**: 완전 개방, 닉네임 공유 로그인
|
||||
- **Level 2 (NAME_LIST)**: 명단 기반, allowedNames 체크
|
||||
- **Level 3 (AUTH_REQUIRED)**: 로그인 필수, 정식 계정 누구나
|
||||
- **Level 4 (EMAIL_LIST)**: 이메일 화이트리스트, allowedEmails 체크
|
||||
- **Level 5 (CLOSED)**: 닫힌 팀, 신규 가입 불가
|
||||
**❌ 절대 사용 금지**: `getFirestore()` 직접 호출
|
||||
**✅ 필수 사용**: `adminFbClient` 싱글톤 인스턴스
|
||||
|
||||
### 📦 User 타입 분리
|
||||
- **FirestoreUser**: DB 저장용 (최소 데이터만)
|
||||
- **User**: UI 사용용 (Firebase Auth + Firestore 결합)
|
||||
- 이름, 이메일, 사진 등은 Firebase Auth가 Single Source of Truth
|
||||
```typescript
|
||||
// ❌ 잘못된 방식 - 초기화 문제 및 인증 오류 발생 가능
|
||||
import {getFirestore} from "firebase-admin/firestore";
|
||||
const db = getFirestore();
|
||||
const doc = await db.collection('users').doc(uid).get();
|
||||
|
||||
### 🏷️ 닉네임 저장 위치 변경
|
||||
- ❌ 기존: `users.nicknames[teamId]`
|
||||
- ✅ 신규: `team.members[uid].nickname`
|
||||
|
||||
### 🎯 RESTful API 설계 규칙
|
||||
**HTTP Method로 동작 구분** (경로가 아님):
|
||||
// ✅ 올바른 방식 - 싱글톤 인스턴스 사용
|
||||
import {adminFbClient} from "@/lib/firebase-admin";
|
||||
const doc = await adminFbClient.collection('users').doc(uid).get();
|
||||
```
|
||||
✅ POST /api/resource → 생성/추가
|
||||
✅ DELETE /api/resource → 삭제/제거
|
||||
✅ PUT /api/resource → 전체 수정
|
||||
✅ GET /api/resource → 조회
|
||||
|
||||
❌ POST /api/resource/add → 사용 금지
|
||||
❌ POST /api/resource/remove → 사용 금지
|
||||
**이유**:
|
||||
- `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` 체크 코드는 타입 에러 발생
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
@ -368,9 +421,7 @@ const result = await analyzeText("오늘 날씨가 좋다.");
|
||||
- 권한: 팀 소유자만
|
||||
- 팀 외 글(자유 주제, 다른 팀)은 제외
|
||||
|
||||
**🆕 Content Hash 기반 3단계 캐싱** (2025-11-12):
|
||||
|
||||
**캐싱 전략**:
|
||||
**캐싱 전략** (Content Hash 기반 3단계):
|
||||
- **L1 (Client)**: localStorage에 contentHash를 키로 저장 (영구, LRU 10개) ~1ms
|
||||
- **L2 (Firestore)**: `patternAnalyses/{contentHash}` 컬렉션에 저장 (영구) ~100ms
|
||||
- **L3 (Server)**: In-memory Map에 저장 (5분 TTL, 최대 50개) ~50ms
|
||||
|
||||
589
CHANGELOG.md
Normal file
589
CHANGELOG.md
Normal file
@ -0,0 +1,589 @@
|
||||
# Changelog
|
||||
|
||||
라온누리 프로젝트의 모든 주요 변경사항을 기록합니다.
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 계획 중
|
||||
- 서버 사이드 Redis 캐싱
|
||||
- Rate Limiting 구현
|
||||
|
||||
---
|
||||
|
||||
## [2025-12-02] - 피드 시스템 및 영감 생성
|
||||
|
||||
### Added
|
||||
- **피드 시스템 API**: 4개 엔드포인트 추가
|
||||
- GET /api/feed/daily-prompt - 오늘의 글감 조회
|
||||
- GET /api/feed/inspirations - 추천 영감 목록
|
||||
- GET /api/feed/weekly-stats - 주간 통계
|
||||
- GET /api/feed/team-activity - 팀 활동
|
||||
- POST /api/user/[uid]/weekly-goal - 주간 목표 설정
|
||||
- **FeedManager**: 피드 데이터 관리 (2분 캐싱)
|
||||
- **피드 컴포넌트**: TodayTopicCard, WeeklyGoalCard, RecentActivityCard, InspirationCard
|
||||
- **GlassCard 컴포넌트**: 반투명 배경 + 블러 효과 통일
|
||||
- **AI 영감 자동 생성**: Cloud Function `generateDailyInspirations` (매일 새벽)
|
||||
- Vertex AI (Gemini 2.5 Flash)
|
||||
- Unsplash API 이미지 검색/다운로드
|
||||
- Firebase Storage 저장
|
||||
- 크레딧 정보 저장
|
||||
- **AI 영감 수동 생성**: HTTP Function `generateInspirationsManual`
|
||||
- **Vertex AI 라이브러리 변경**: @google-cloud/vertexai → @google/genai v1.29.0
|
||||
- **Unsplash API 키**: Secret Manager로 관리
|
||||
- **Container 레이아웃 확장**: maxW 1200px → 1400px
|
||||
- **Tlab신영복체 폰트**: 글쓰기 페이지 제목에 적용
|
||||
- **ModeSelectionCard UI 개선**: 이미지 드롭 쉐도우, 전환 효과
|
||||
|
||||
### Changed
|
||||
- Vertex AI 클라이언트: 싱글톤 패턴으로 인스턴스 재사용
|
||||
- Vertex AI 모델 업그레이드: gemini-1.5-flash → gemini-2.5-flash
|
||||
- InspirationCard: Unsplash 크레딧 표시 제거 (저장은 유지)
|
||||
|
||||
### Fixed
|
||||
- Unsplash Rate Limit 방지: API 호출 간격 1초 추가
|
||||
- 영감 재생성 시 기존 데이터 덮어쓰기 옵션
|
||||
|
||||
---
|
||||
|
||||
## [2025-12-01] - 글쓰기 모드 선택
|
||||
|
||||
### Added
|
||||
- **글쓰기 모드 선택 화면**: 글부터 쓰기 / 그림부터 올리기
|
||||
- **URL 파라미터**: ?mode=wrt|img로 모드 관리
|
||||
- **ImageDropzone 컴포넌트**: 드래그앤드롭 이미지 업로드, 미리보기
|
||||
- **ImageFirstLayout**: 이미지+에디터 2컬럼 레이아웃
|
||||
- **pendingImageStore**: IndexedDB 기반 이미지 임시 저장 (새로고침 유지)
|
||||
- **캐릭터 이미지**: startWriting.png, uploadImage.png
|
||||
|
||||
### Changed
|
||||
- 모드 선택 카드 디자인: 그라데이션 배경, 상단 장식 바, 제목 배지
|
||||
- 모드 선택 카드 이미지: 크기 증가 180px → 240px
|
||||
- 호버 애니메이션 개선
|
||||
|
||||
### Translations
|
||||
- modeSelection namespace 추가 (ko/en/ja)
|
||||
|
||||
---
|
||||
|
||||
## [2025-11-28] - 이미지 업로드 플로우 개편
|
||||
|
||||
### Added
|
||||
- **/imageUpload 페이지**: AI 생성/직접 업로드 선택
|
||||
- **WritingManager.uploadUserImage()**: 클라이언트 사이드 이미지 리사이즈
|
||||
- Canvas API 사용 (1920x1080 최대, 85% 품질)
|
||||
- Firebase Storage 업로드
|
||||
- **WritingManager.analyzeWritingBackground()**: fire-and-forget 패턴
|
||||
- **가격정책 페이지** (/pricing)
|
||||
- 4개 플랜: Free, Classroom, Academy, School
|
||||
- 월간/연간 결제 토글 (20% 할인)
|
||||
- FAQ 섹션
|
||||
- **댓글 시스템**
|
||||
- Comment 데이터 모델 (계층 구조, 반응형)
|
||||
- CommentList/CommentItem/CommentInput 컴포넌트
|
||||
- API Routes 구현 (GET/POST/PUT/DELETE)
|
||||
- 낙관적 업데이트
|
||||
|
||||
### Changed
|
||||
- Write 페이지 저장 플로우: 저장 → 백그라운드 분석 → /imageUpload 리다이렉트
|
||||
- Interaction 페이지: 이미지 없으면 /imageUpload로 자동 이동
|
||||
- authStore.isLoading 초기값: false → true (리다이렉트 방지)
|
||||
|
||||
### Removed
|
||||
- GenerateImageDialog 제거 (플로우 변경)
|
||||
|
||||
### Translations
|
||||
- imageUpload namespace 추가 (ko/en/ja, 15개 키)
|
||||
- pricing namespace 추가 (ko/en/ja, 40개 키)
|
||||
|
||||
---
|
||||
|
||||
## [2025-11-27] - 팀 공개 설정 및 커버 이미지
|
||||
|
||||
### Added
|
||||
- **팀 공개 설정 시스템**
|
||||
- Team 타입 확장: isPublic, allowPublicWritings, description
|
||||
- 팀 관리 페이지: 공개 설정 UI
|
||||
- 공개 팀 조회 API (커서 기반 페이지네이션)
|
||||
- 공개 글 조회 API (팀별)
|
||||
- **팀 커버 이미지 시스템**
|
||||
- Team.coverImage 필드
|
||||
- POST/DELETE /api/team/[teamId]/cover-image
|
||||
- TeamCoverImageUploader 컴포넌트 (드래그앤드롭, 16:9)
|
||||
- Firebase Storage 업로드 (5MB 제한)
|
||||
- TeamCard 이미지 표시
|
||||
- **공개 팀 목록 페이지** (/team/all)
|
||||
- TeamCard 컴포넌트 (글래스모피즘)
|
||||
- 무한 스크롤 페이지네이션
|
||||
- **채점 시스템 전면 개편**
|
||||
- 0~1 품질 기반 점수 (5단계)
|
||||
- 계층적 설정 (기본→팀→주제)
|
||||
- TeamScoringSettings/TeamRubricSettings 컴포넌트
|
||||
|
||||
### Changed
|
||||
- 팀 상세 페이지: 3가지 뷰 모드 (멤버/공개/접근 불가)
|
||||
- 팀원 아바타 표시: photoURL 지원, 정식/익명 아이콘 구분
|
||||
|
||||
### Translations
|
||||
- teams namespace 추가 (공개 팀 목록)
|
||||
- team.manage.publicSettings namespace
|
||||
- team.coverImage namespace (16개 키)
|
||||
|
||||
---
|
||||
|
||||
## [2025-11-26] - 익명 계정 연결 기능
|
||||
|
||||
### Added
|
||||
- **POST /api/auth/merge-account**: 익명 계정 데이터 병합 API
|
||||
- Firestore 데이터 마이그레이션 (writings, topics, comments 등)
|
||||
- Realtime DB 데이터 마이그레이션 (drafts, monitoring)
|
||||
- 원자성 보장 (Batch, Transaction)
|
||||
- **서비스 레이어**: mergeAndLoginWithEmail, mergeAndLoginWithGoogle
|
||||
- **authStore**: mergeWithEmail, mergeWithGoogle 액션
|
||||
- **LoginForm/SignupForm**: mode prop ('auth'|'link')
|
||||
- **LoginDialog**: link 모드 지원
|
||||
|
||||
### Changed
|
||||
- 용어 변경: "병합" → "연결", "익명" → "임시"
|
||||
|
||||
### Removed
|
||||
- LinkAccountFlow 컴포넌트 (기존 폼 재사용)
|
||||
|
||||
### Translations
|
||||
- linkAccountDescription, mergeButton, linkButton 등 15개 키 (ko/en/ja)
|
||||
|
||||
---
|
||||
|
||||
## [2025-11-25] - 글 분석 및 이미지 왜곡 영역
|
||||
|
||||
### Added
|
||||
- **POST /api/writing/[id]/analyze**: 서버 데이터 기반 글 분석
|
||||
- **상호작용 컴포넌트**: AnalysisNeededBanner, AreaUnlockBadge, ImprovementHint, ScoreBadge
|
||||
- **점수 기반 영역 해금**: areaLimit.ts 로직
|
||||
- **DistortionAreaData**: 이미지 왜곡 영역 데이터 모델
|
||||
- **InteractiveImageViewer**: 왜곡 효과 + 애니메이션
|
||||
- **팀 멤버 닉네임**: POST /api/team/add-member에 nickname 파라미터
|
||||
|
||||
### Changed
|
||||
- WritingAnalysis 타입 확장
|
||||
- 글 수정 시 왜곡 영역 저장/불러오기
|
||||
|
||||
---
|
||||
|
||||
## [2025-11-23] - 이미지 비율 유지 및 CORS
|
||||
|
||||
### Added
|
||||
- **Firebase Storage CORS 설정**
|
||||
- firebase-storage-cors.json 생성
|
||||
- gsutil cors set 명령어로 적용
|
||||
- Canvas 이미지 조작 허용
|
||||
|
||||
### Changed
|
||||
- **InteractiveImage 비율 유지**
|
||||
- useEffect로 이미지 원본 크기 측정
|
||||
- Box에 aspectRatio 속성 적용
|
||||
- Canvas 찌그러짐 방지
|
||||
|
||||
### Fixed
|
||||
- 캐시 무효화: cacheBustedSrc (?t=Date.now())
|
||||
- CDN 캐시 문제 해결 (쿼리 파라미터)
|
||||
|
||||
---
|
||||
|
||||
## [2025-11-22] - Server Component 전환 및 BackButton
|
||||
|
||||
### Added
|
||||
- **BackButton 공통 컴포넌트**
|
||||
- router.back() 기본 동작
|
||||
- href prop으로 특정 경로 이동
|
||||
- label prop으로 텍스트 커스터마이징
|
||||
|
||||
### Changed
|
||||
- **글 상세보기 Server Component 전환**
|
||||
- /writing/[writingId]/page.tsx
|
||||
- Firebase Admin SDK 사용
|
||||
- SEO 최적화 (서버 HTML 생성)
|
||||
- SNS 공유 미리보기 지원
|
||||
- 초기 로딩 성능 개선
|
||||
|
||||
---
|
||||
|
||||
## [2025-11-21] - 주제 생성 Dialog 통합 및 UI 개선
|
||||
|
||||
### Added
|
||||
- **수정 모드 UX 대폭 개선**
|
||||
- Sticky 헤더바 (오렌지 그라데이션, z-index:100)
|
||||
- 강조된 테두리 (2px solid 오렌지)
|
||||
- 섀도우 효과 (글로우)
|
||||
- 색상 시스템 정립
|
||||
|
||||
### Changed
|
||||
- **주제 생성 Dialog 통합**
|
||||
- CreateTeamTopicDialog 삭제 (674줄)
|
||||
- CreateTopicDialog로 통합 (개인/팀 공용)
|
||||
- onSubmit 콜백 패턴
|
||||
- **홈 페이지 모듈화**
|
||||
- 6개 컴포넌트 분리 (src/components/home/)
|
||||
- 580줄 → 223줄 (62% 감소)
|
||||
- **수정 모드 주제 변경 차단**
|
||||
- TopicSelector readonly prop
|
||||
- 데이터 무결성 보장
|
||||
|
||||
### Removed
|
||||
- "새 글쓰기" 버튼 (write 페이지)
|
||||
- 미사용 i18n 키 (newWriting, discardConfirm)
|
||||
|
||||
---
|
||||
|
||||
## [2025-11-20] - AI 이미지 생성 스타일 개선
|
||||
|
||||
### Changed
|
||||
- **Imagen 4.0 Fast 업그레이드**
|
||||
- **일관된 스타일 가이드**: 애니메이션/만화 스타일
|
||||
- **한국 문화권 고려**: "Korean elementary student" 명시
|
||||
- **Negative Prompt 강화**: 과도한 사실주의, 어두운 분위기 차단
|
||||
- **키워드 개수 증가**: 6-12개
|
||||
|
||||
---
|
||||
|
||||
## [2025-11-19] - Draft 클라우드 동기화 및 팀 코드 개선
|
||||
|
||||
### Added
|
||||
- **Draft 클라우드 동기화**
|
||||
- localStorage + Realtime DB 하이브리드
|
||||
- syncStatus 필드 ('local'|'synced'|'syncing')
|
||||
- DraftManager: syncToCloud, loadDraftsFromCloud, mergeDrafts
|
||||
- SavedDraftsDialog 배지 표시
|
||||
- **팀 코드 예약 반환 로직**
|
||||
- generate-code API: previousCode 파라미터
|
||||
- 새 코드 받기 시 이전 예약 해제
|
||||
- 중복 호출 방지 (isGeneratingCode)
|
||||
|
||||
### Changed
|
||||
- **AI Delta 전송 로직 개선**
|
||||
- diff-match-patch 도입
|
||||
- 정확한 diff 계산
|
||||
- 5자 미만 변경 누적
|
||||
- 완화된 기준 (80%, 200자)
|
||||
|
||||
### Security
|
||||
- **XSS 방지**: HTML Sanitization 구현 (sanitize-html)
|
||||
- 백엔드 자동 세탁 (src/lib/server/writing.ts)
|
||||
- 26개 유닛 테스트
|
||||
|
||||
---
|
||||
|
||||
## [2025-11-18] - 다국어 및 팀 코드 개선
|
||||
|
||||
### Added
|
||||
- **팀 코드 다국어 생성**
|
||||
- 언어별 단어 목록 (한국어/영어/일본어)
|
||||
- generateTeamCode(locale)
|
||||
- **Realtime DB 팀 코드 예약 시스템**
|
||||
- teamCodeReservation.ts 서버 레이어
|
||||
- Transaction 기반 atomic 예약
|
||||
- 5분 TTL, onDisconnect 자동 정리
|
||||
- Race condition 완전 방지
|
||||
- **팀 나가기 기능**
|
||||
- 팀 상세 페이지 "팀 나가기" 버튼
|
||||
- POST /api/team/remove-member 권한 체크
|
||||
- **Level 1 중복 체크 로직 (UID 기반)**
|
||||
- Custom Token API (익명 계정만 발급)
|
||||
- 정식 계정 탈취 방지
|
||||
- **내가 쓴 글 목록 + 글 수정 기능**
|
||||
- WritingCard 컴포넌트
|
||||
- /writings 전체 글 목록 페이지
|
||||
- /write 페이지 수정 모드 (?id=xxx)
|
||||
- **주제별 학생 분석 API**
|
||||
- GET /api/topic/[topicId]/writers
|
||||
- TopicMemberAnalysisSection UI
|
||||
|
||||
### Changed
|
||||
- **팀 관리 컴포넌트 다국어 완성**
|
||||
- TeamTopicManager, LiveWritingMonitor
|
||||
- StudentLoginFlow 일본어 번역
|
||||
|
||||
### Translations
|
||||
- team.create, team.detail, team.manage 일본어 (126개 키)
|
||||
- writings 섹션 (ko/en/ja, 22개 키)
|
||||
- errors.team namespace (6개 키)
|
||||
|
||||
---
|
||||
|
||||
## [2025-11-17] - AI 이미지 생성 및 AI 설정 UI
|
||||
|
||||
### Added
|
||||
- **AI 이미지 생성 시스템**
|
||||
- Vertex AI Imagen 3.0 통합
|
||||
- AI 장면 분리 기능 (Gemini Flash)
|
||||
- AI 프롬프트 최적화 (키워드 추출)
|
||||
- 4단계 플로우 (장면 추출 → 선택 → 최적화 → 생성)
|
||||
- sceneExtractionService.ts, imagenService.ts, imageStorage.ts
|
||||
- SceneSelector 컴포넌트
|
||||
- GenerateImageDialog 개선
|
||||
- **AI 설정 고급 UI**
|
||||
- AIConfigDialog 컴포넌트
|
||||
- Slider 3개 (멈춤 감지/최대 힌트/쿨다운)
|
||||
- 커스텀 힌트 레벨 카드 (우상단 삼각형 인디케이터)
|
||||
|
||||
### Translations
|
||||
- write.generateImage, write.extractScenes namespace (22개 키)
|
||||
- team.manage.aiConfig namespace (28개 키, ko/en/ja)
|
||||
|
||||
---
|
||||
|
||||
## [2025-11-14] - 개별 글 분석 및 AI 도우미
|
||||
|
||||
### Added
|
||||
- **개별 글 분석 결과 저장**
|
||||
- Writing.analysis 필드
|
||||
- SpellingError 타입
|
||||
- generateWritingContentHash 유틸
|
||||
- contentHash 기반 재분석 방지 (90% 비용 절감)
|
||||
- **AI 글쓰기 도우미 시스템**
|
||||
- 4단계 점진적 힌트 (질문 → 방향 → 선택지 → 예시)
|
||||
- useWritingInactivityDetection 훅 (5분 멈춤 감지)
|
||||
- InactivityPrompt, HintDisplay 컴포넌트
|
||||
- writingAssistanceService.ts
|
||||
- POST /api/writing-assistance
|
||||
- GET/PUT /api/team/[teamId]/ai-config
|
||||
- Team.aiAssistanceConfig 필드
|
||||
|
||||
### Changed
|
||||
- 글쓰기 페이지: 저장 시 AI 분석 병렬 수행
|
||||
- 실시간 분석 제거 (저장 시에만)
|
||||
|
||||
### Translations
|
||||
- aiAssist namespace (19개 키, ko/en/ja)
|
||||
|
||||
---
|
||||
|
||||
## [2025-11-13] - 다국어 지원 시스템 (i18n)
|
||||
|
||||
### Added
|
||||
- **next-intl 라이브러리** 설치 및 설정
|
||||
- **[locale] 라우팅 구조**: /ko/*, /en/*, /ja/*
|
||||
- **middleware.ts**: 브라우저 언어 자동 감지
|
||||
- **3개 언어 번역 파일**: messages/ko.json, en.json, ja.json (각 407줄, 220+ 키)
|
||||
- **LocaleSwitcher 드롭다운**: 국기 이모지, 체크 표시
|
||||
|
||||
### Changed
|
||||
- **전체 페이지 번역**
|
||||
- Navbar (4개 링크)
|
||||
- Landing 페이지 (20+ 항목)
|
||||
- Home, Write, Team 페이지
|
||||
- 인증 컴포넌트 (70+ 항목)
|
||||
- site.ts 텍스트 → 번역 파일로 이동
|
||||
|
||||
### Config
|
||||
- next.config.ts: next-intl 플러그인
|
||||
- i18n/routing.ts, i18n/request.ts 설정
|
||||
- NEXT_LOCALE 쿠키 저장
|
||||
|
||||
---
|
||||
|
||||
## [2025-11-12] - 패턴 분석 캐싱 및 실시간 모니터링
|
||||
|
||||
### Added
|
||||
- **Content Hash 기반 3단계 캐싱**
|
||||
- L1: localStorage (영구, LRU 10개) ~1ms
|
||||
- L2: Firestore patternAnalyses (영구) ~100ms
|
||||
- L3: Server in-memory (5분, 50개) ~50ms
|
||||
- AI 비용 절감 (전체 사용자 기준 1회 분석)
|
||||
- **실시간 글쓰기 모니터링**
|
||||
- Firebase Realtime Database 기반
|
||||
- WritingSessionManager (5초 주기)
|
||||
- LiveWritingMonitor 컴포넌트
|
||||
- 3가지 상태 관리 (작성 중/나감/대기)
|
||||
- 작성 속도 계산 (글자/분)
|
||||
- Sparkline 그래프 (Area Chart)
|
||||
- 30초 타임아웃 체크
|
||||
- **Writing API 구현 완료**
|
||||
- POST/GET/PUT/DELETE /api/writing
|
||||
- src/lib/server/writing.ts (Firestore CRUD)
|
||||
- **패턴 분석 - 팀 소유자 기능**
|
||||
- 3가지 분석 타입 (self/by-team/by-topic)
|
||||
- TopicMemberAnalysisSection UI
|
||||
|
||||
### Performance
|
||||
- 캐시 히트: ~1ms (localStorage)
|
||||
- 완전 무료 (Realtime DB 100명)
|
||||
|
||||
---
|
||||
|
||||
## [2025-11-11] - 실시간 피드백 시스템
|
||||
|
||||
### Added
|
||||
- **Vertex AI 기반 텍스트 분석**
|
||||
- POST /api/analyze-text
|
||||
- Delta 전송 지원 (40% 비용 절감)
|
||||
- Multi-region failover (3개 region)
|
||||
- 서버 캐싱 (LRU, 1분)
|
||||
- **Google AI SDK 마이그레이션**: @google-cloud/vertexai → @google/genai v1.29.0
|
||||
- **텍스트 분석 평가 기준 개편**: 오감(4점) + 감정(2점) + 대화(2점) + 의성어(2점)
|
||||
- **분석 히스토리 시스템**: Draft.analysisHistory (최대 5개)
|
||||
- **맞춤법 검사 서비스**: spellingService.ts (독립 debounce 5초)
|
||||
- **글 작성 패턴 분석**: WritingPatternDialog/Display
|
||||
- **실시간 하이라이트**
|
||||
- SpellingHighlight/SensoryWordHighlight Extensions
|
||||
- EditorTooltip (Portal + ESC/외부 클릭)
|
||||
- **Toast 알림**: 분석 시작/완료/실패
|
||||
|
||||
### Changed
|
||||
- `descriptive` → `emotion`
|
||||
- 프롬프트 최적화 (칭찬 강화, 제안 0~1개)
|
||||
|
||||
---
|
||||
|
||||
## [2025-11-10] - 5단계 보안 레벨 시스템
|
||||
|
||||
### Added
|
||||
- **TeamSecurityLevel enum** (1-5)
|
||||
- OPEN, NAME_LIST, AUTH_REQUIRED, EMAIL_LIST, CLOSED
|
||||
- **명단 관리 API**
|
||||
- POST/DELETE /api/team/:teamId/allowed-names
|
||||
- POST/DELETE /api/team/:teamId/allowed-emails
|
||||
- POST /api/team/:teamId/security-level
|
||||
- **SecurityLevelSelector**: RadioCard 기반 UI
|
||||
- **다중 글조각 관리**: DraftManager (최대 10개)
|
||||
- **SavedDraftsDialog**: syncStatus 배지
|
||||
|
||||
### Changed
|
||||
- **User 타입 최소화**
|
||||
- FirestoreUser/User 분리
|
||||
- Firebase Auth = Single Source of Truth
|
||||
- **닉네임 저장 위치**: users.nicknames → team.members[uid].nickname
|
||||
- **memberUids 제거**: Object.keys(members) 사용
|
||||
|
||||
### Removed
|
||||
- useColorModeValue() 전체 제거 (Semantic token 사용)
|
||||
|
||||
### Translations
|
||||
- securitySelector namespace (13개 키, ko/en/ja)
|
||||
|
||||
---
|
||||
|
||||
## [2025-11-07] - 매니저 패턴 API 전환
|
||||
|
||||
### Added
|
||||
- **API 타입 시스템**: ApiResponse, HttpMethod Enum
|
||||
- **클라이언트 캐싱**: BaseManager (TTL, 자동 무효화)
|
||||
- **팀 주제 시스템**: 팀 소유자가 팀 주제 생성
|
||||
- **API 명세서**: API_SPEC.md (23개 엔드포인트)
|
||||
|
||||
### Changed
|
||||
- TeamManager, StudentManager → API 호출 방식
|
||||
- Firestore 직접 호출 제거
|
||||
- **아키텍처 단순화**: students → users 컬렉션, PIN 제거
|
||||
- TopicSelector: 팀/개인 배지 표시
|
||||
|
||||
### Removed
|
||||
- 그룹(Group) 기능 완전 제거
|
||||
|
||||
---
|
||||
|
||||
## [2025-11-06] - 팀 코드 시스템
|
||||
|
||||
### Added
|
||||
- **한글 팀 코드 생성**: "춤추는 파란 사자"
|
||||
- **Anonymous Auth**: Firebase 익명 로그인
|
||||
- **teamService, studentService**: 백엔드 로직
|
||||
- **PIN SHA-256**: 암호화 저장
|
||||
- **정식 계정 연결**: linkWithCredential
|
||||
- **팀 관리 UI**: 팀 목록, 생성, 멤버, 관리 페이지
|
||||
- **학생 관리 기능**: 이름 수정, 강퇴 (Menu + Dialog)
|
||||
|
||||
### Changed
|
||||
- 용어 변경: "클래스" → "팀", "교사" → "소유자"
|
||||
- currentStudent 중심 authStore 재설계
|
||||
- Semantic token 적극 활용
|
||||
|
||||
---
|
||||
|
||||
## [2025-10-31] - 개인 주제 생성 UI
|
||||
|
||||
### Added
|
||||
- **CreateTopicDialog**: 태그 입력 필드
|
||||
- 키보드 네비게이션
|
||||
- 방향키 선택
|
||||
- Backspace/Delete 삭제
|
||||
|
||||
---
|
||||
|
||||
## [2025-10-30] - 글쓰기 페이지 및 주제 선택
|
||||
|
||||
### Added
|
||||
- **글쓰기 페이지**: Tiptap 순수 텍스트 에디터
|
||||
- Editable 제목
|
||||
- LocalStorage 자동 저장
|
||||
- 하단 고정 버튼
|
||||
- **Firestore 연동**: 글 저장/불러오기/삭제
|
||||
- **Manager 패턴 도입**: WritingManager (싱글톤)
|
||||
- **주제 선택 기능**
|
||||
- TopicManager, TopicSelector
|
||||
- 개인 주제 시스템
|
||||
- 템플릿 미리채우기
|
||||
|
||||
---
|
||||
|
||||
## [2025-10-29] - 인증 기반 라우팅
|
||||
|
||||
### Added
|
||||
- **인증 기반 라우팅**: `/`와 `/home` 분리
|
||||
- 자동 리다이렉트 구현
|
||||
|
||||
---
|
||||
|
||||
## [2025-10-28] - 회원가입 및 테마
|
||||
|
||||
### Added
|
||||
- **회원가입 기능**
|
||||
- LoginDialog 페이드 전환
|
||||
- 비밀번호 강도 게이지
|
||||
- HIBP API 연동
|
||||
- **커스텀 테마**: Chakra UI v3 테마 시스템
|
||||
- **SEO 최적화**: 메타데이터, OpenGraph, StructuredData
|
||||
- **랜딩 페이지**: Hero, Features, How It Works, CTA
|
||||
- **네비게이션**: Navbar, 다크모드 지원
|
||||
|
||||
---
|
||||
|
||||
## [2025-10-27] - 인증 시스템
|
||||
|
||||
### Added
|
||||
- **로그인 기능**: 이메일/비밀번호
|
||||
- **Google OAuth**: 소셜 로그인
|
||||
- **상태 관리**: Zustand 인증 스토어
|
||||
|
||||
---
|
||||
|
||||
## [2025-10-26] - Firebase 연동
|
||||
|
||||
### Added
|
||||
- **Firebase Auth**: 설정 및 초기화
|
||||
|
||||
---
|
||||
|
||||
## [2025-10-25] - 프로젝트 초기화
|
||||
|
||||
### Added
|
||||
- **프로젝트 설정**: Next.js 16, React 19, TypeScript
|
||||
|
||||
---
|
||||
|
||||
## 범례
|
||||
|
||||
- **Added**: 새로운 기능
|
||||
- **Changed**: 기존 기능 변경
|
||||
- **Deprecated**: 곧 제거될 기능
|
||||
- **Removed**: 제거된 기능
|
||||
- **Fixed**: 버그 수정
|
||||
- **Security**: 보안 관련 변경
|
||||
- **Performance**: 성능 개선
|
||||
- **Translations**: 다국어 번역 추가/수정
|
||||
- **Config**: 설정 파일 변경
|
||||
|
||||
---
|
||||
|
||||
© 2024 BlueNovaLab. All rights reserved.
|
||||
@ -1,7 +1,5 @@
|
||||
# 라온누리 - 데이터 모델 및 스키마
|
||||
|
||||
> 최종 업데이트: 2025-11-28 (댓글 시스템 추가)
|
||||
|
||||
이 문서는 Firestore 데이터베이스 및 Firebase Realtime Database 구조와 TypeScript 타입 정의를 설명합니다.
|
||||
|
||||
**참고**: API 타입 정의는 [API_SPEC.md](./API_SPEC.md)를 참조하세요.
|
||||
|
||||
517
DEVELOPMENT_GUIDE.md
Normal file
517
DEVELOPMENT_GUIDE.md
Normal file
@ -0,0 +1,517 @@
|
||||
# Development Guide
|
||||
|
||||
라온누리 프로젝트의 구현 세부사항 가이드입니다.
|
||||
|
||||
---
|
||||
|
||||
## AI Delta 전송
|
||||
|
||||
**diff-match-patch** 사용 (`src/app/api/analyze-text/route.ts`)
|
||||
|
||||
### 동작 원리
|
||||
|
||||
- 5자 미만 변경은 누적 (previousText 유지)
|
||||
- Delta 전송 기준: 5자 이상, 80% 미만, 200자 미만
|
||||
- 정확한 diff 계산 (앞/중간/뒤 수정 모두 감지)
|
||||
|
||||
### 구현 예시
|
||||
|
||||
```typescript
|
||||
// src/app/api/analyze-text/route.ts
|
||||
import * as dmp from "diff-match-patch";
|
||||
|
||||
const differ = new dmp.diff_match_patch();
|
||||
const diffs = differ.diff_main(previousText, currentText);
|
||||
differ.diff_cleanupSemantic(diffs);
|
||||
|
||||
// 변경 비율 계산
|
||||
const changeRatio = calculateChangeRatio(diffs, previousText.length);
|
||||
|
||||
// Delta 전송 판단
|
||||
if (changeSize >= 5 && changeRatio < 0.8 && changeSize < 200) {
|
||||
// Delta 전송
|
||||
return analyzeText(currentText, previousText);
|
||||
} else {
|
||||
// 전체 전송 또는 스킵
|
||||
}
|
||||
```
|
||||
|
||||
### 테스트 케이스
|
||||
|
||||
- ✅ 뒷부분 추가
|
||||
- ✅ 중간 수정
|
||||
- ✅ 앞부분 추가
|
||||
- ✅ 삭제
|
||||
- ✅ 대량 변경
|
||||
- ✅ 누적 변경
|
||||
|
||||
**참조**: `src/app/api/analyze-text/route.ts:46-120`
|
||||
|
||||
---
|
||||
|
||||
## Draft 저장
|
||||
|
||||
**localStorage + Realtime DB 하이브리드**
|
||||
|
||||
### 구조
|
||||
|
||||
- **DraftManager**: `syncToCloud()`, `mergeDrafts()` 사용
|
||||
- **Draft.syncStatus**: 'local' | 'synced' | 'syncing'
|
||||
- **페이지 로드 시**: `mergeDrafts()` 호출 (로그인 시)
|
||||
|
||||
### 동작 플로우
|
||||
|
||||
```typescript
|
||||
// 1. 로컬 저장 (즉시)
|
||||
draftManager.saveDraft(draft); // localStorage
|
||||
|
||||
// 2. 클라우드 동기화 (2초 debounce)
|
||||
draftManager.syncToCloud(draftId); // Realtime DB
|
||||
|
||||
// 3. 기기 간 동기화 (페이지 로드 시)
|
||||
const mergedDrafts = await draftManager.mergeDrafts();
|
||||
// updatedAt 비교로 최신 버전 선택
|
||||
```
|
||||
|
||||
### Realtime DB 구조
|
||||
|
||||
```
|
||||
drafts/
|
||||
{userId}/
|
||||
{draftId}/
|
||||
title: string
|
||||
content: string
|
||||
topicId?: string
|
||||
updatedAt: number
|
||||
syncStatus: 'synced'
|
||||
```
|
||||
|
||||
### Security Rules
|
||||
|
||||
```json
|
||||
{
|
||||
"rules": {
|
||||
"drafts": {
|
||||
"$userId": {
|
||||
".read": "auth != null && auth.uid == $userId",
|
||||
".write": "auth != null && auth.uid == $userId"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**참조**: `src/managers/DraftManager.ts`
|
||||
|
||||
---
|
||||
|
||||
## Firebase Cloud Functions
|
||||
|
||||
### 파일 분리 구조
|
||||
|
||||
- **index.ts**: export만
|
||||
- **로직**: 별도 파일
|
||||
|
||||
```typescript
|
||||
// functions/src/index.ts
|
||||
import { cleanupExpiredReservations } from "./scheduledFunctions/cleanupExpiredReservations";
|
||||
import { cleanupExpiredDrafts } from "./scheduledFunctions/cleanupExpiredDrafts";
|
||||
|
||||
export { cleanupExpiredReservations, cleanupExpiredDrafts };
|
||||
```
|
||||
|
||||
### 배포된 Functions
|
||||
|
||||
| 함수 | 타입 | 실행 주기 | 설명 |
|
||||
|------|------|----------|------|
|
||||
| `cleanupExpiredReservations` | Scheduled | 매 시간 | 팀 코드 예약 정리 |
|
||||
| `cleanupExpiredDrafts` | Scheduled | 매일 새벽 3시 | 180일+ drafts 정리 |
|
||||
| `generateDailyInspirations` | Scheduled | 매일 새벽 | AI 영감 자동 생성 |
|
||||
| `generateInspirationsManual` | HTTP | 수동 호출 | 관리자 수동 영감 생성 |
|
||||
| `onTeamDeleted` | Firestore Trigger | 팀 삭제 시 | Cascade 삭제 (writings 제외) |
|
||||
| `onWritingCreated` | Firestore Trigger | 글 생성 시 | 추후 자동 분석 |
|
||||
|
||||
### 공통 규칙
|
||||
|
||||
- **한글 로그**: 모든 logger 메시지 한글 작성
|
||||
- **리전**: asia-northeast1 (도쿄)
|
||||
- **배포**: `cd functions && npm run build && firebase deploy --only functions`
|
||||
|
||||
**참조**: `functions/src/`
|
||||
|
||||
---
|
||||
|
||||
## AI 영감 생성 시스템
|
||||
|
||||
### 기술 스택
|
||||
|
||||
- **Vertex AI**: Gemini 2.5 Flash 모델 (`@google/genai`)
|
||||
- **Unsplash API**: 이미지 검색 및 다운로드
|
||||
- **싱글톤 패턴**: `vertexAI.ts`에서 클라이언트 재사용
|
||||
|
||||
### 구현
|
||||
|
||||
```typescript
|
||||
// functions/src/services/vertexAI.ts
|
||||
import { GoogleGenerativeAI } from "@google/genai";
|
||||
|
||||
let vertexAI: GoogleGenerativeAI | null = null;
|
||||
|
||||
export function getVertexAI(): GoogleGenerativeAI {
|
||||
if (!vertexAI) {
|
||||
vertexAI = new GoogleGenerativeAI({
|
||||
apiKey: process.env.GOOGLE_API_KEY,
|
||||
});
|
||||
}
|
||||
return vertexAI;
|
||||
}
|
||||
```
|
||||
|
||||
### 데이터 저장
|
||||
|
||||
```typescript
|
||||
// Inspiration 타입
|
||||
interface Inspiration {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
imageUrl: string;
|
||||
unsplashCredit?: {
|
||||
name: string;
|
||||
profileUrl: string;
|
||||
};
|
||||
createdAt: Timestamp;
|
||||
}
|
||||
```
|
||||
|
||||
### 자동 생성 플로우
|
||||
|
||||
1. **Vertex AI**: 3개 영감 생성 (제목 + 내용)
|
||||
2. **Unsplash API**: 키워드로 이미지 검색
|
||||
3. **Firebase Storage**: 이미지 다운로드 및 저장
|
||||
4. **Firestore**: `inspirations` 컬렉션에 저장
|
||||
5. **Rate Limit**: API 호출 간격 1초
|
||||
|
||||
**참조**: `functions/src/scheduledFunctions/generateDailyInspirations.ts`
|
||||
|
||||
---
|
||||
|
||||
## 팀 코드 예약 시스템
|
||||
|
||||
### Race Condition 방지
|
||||
|
||||
**Realtime DB Transaction** 사용:
|
||||
|
||||
```typescript
|
||||
// src/lib/server/teamCodeReservation.ts
|
||||
export async function generateAndReserveTeamCode(
|
||||
userId: string,
|
||||
locale: string = "ko"
|
||||
): Promise<string> {
|
||||
const maxAttempts = 10;
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const code = generateTeamCode(locale);
|
||||
const codeRef = realtimeDb.ref(`teamCodeReservations/${code}`);
|
||||
|
||||
// Atomic 예약
|
||||
const result = await codeRef.transaction((current) => {
|
||||
if (current !== null) return; // 이미 예약됨
|
||||
|
||||
return {
|
||||
userId,
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + 5 * 60 * 1000, // 5분 TTL
|
||||
locale,
|
||||
};
|
||||
});
|
||||
|
||||
if (result.committed) {
|
||||
return code; // 성공
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("팀 코드 생성 실패");
|
||||
}
|
||||
```
|
||||
|
||||
### 예약 해제
|
||||
|
||||
```typescript
|
||||
// 새 코드 받기 시 이전 예약 해제
|
||||
export async function POST(request: NextRequest) {
|
||||
const { previousCode } = await request.json();
|
||||
|
||||
if (previousCode) {
|
||||
await releaseTeamCodeReservation(previousCode);
|
||||
}
|
||||
|
||||
const newCode = await generateAndReserveTeamCode(userId);
|
||||
return successResponse({ code: newCode });
|
||||
}
|
||||
```
|
||||
|
||||
### 실패 시 반환
|
||||
|
||||
```typescript
|
||||
// 팀 생성 실패 시에도 예약 해제
|
||||
try {
|
||||
const team = await createTeam({ code, ... });
|
||||
await releaseTeamCodeReservation(code);
|
||||
return successResponse({ team });
|
||||
} catch (error) {
|
||||
await releaseTeamCodeReservation(code); // catch 블록에서도 해제
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
**참조**: `src/lib/server/teamCodeReservation.ts`
|
||||
|
||||
---
|
||||
|
||||
## XSS 보안
|
||||
|
||||
### 백엔드 자동 세탁
|
||||
|
||||
**`src/lib/server/writing.ts`**에서 모든 HTML 자동 sanitize
|
||||
|
||||
```typescript
|
||||
import { sanitizeHtml } from "@/utils/sanitizeHtml";
|
||||
|
||||
// 글 생성 시
|
||||
const sanitizedTitle = sanitizeHtml(data.title);
|
||||
const sanitizedContent = sanitizeHtml(data.content);
|
||||
|
||||
// 글 수정 시
|
||||
if (data.title) {
|
||||
updateData.title = sanitizeHtml(data.title);
|
||||
}
|
||||
if (data.content) {
|
||||
updateData.content = sanitizeHtml(data.content);
|
||||
}
|
||||
```
|
||||
|
||||
### sanitizeHtml 함수
|
||||
|
||||
**라이브러리**: `sanitize-html`
|
||||
|
||||
```typescript
|
||||
// src/utils/sanitizeHtml.ts
|
||||
import sanitizeHtmlLib from "sanitize-html";
|
||||
|
||||
export function sanitizeHtml(dirty: string): string {
|
||||
return sanitizeHtmlLib(dirty, {
|
||||
allowedTags: [
|
||||
"p", "strong", "em", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"ul", "ol", "li", "a", "img", "blockquote", "code", "pre",
|
||||
"br", "hr", "span", "div"
|
||||
],
|
||||
allowedAttributes: {
|
||||
a: ["href", "target", "rel"],
|
||||
img: ["src", "alt", "width", "height"],
|
||||
},
|
||||
allowedSchemes: ["http", "https", "mailto"],
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**특징**:
|
||||
- ✅ 안전한 HTML 보존
|
||||
- ❌ `<script>`, `<iframe>`, `onclick` 등 차단
|
||||
- ❌ `javascript:`, 위험한 `data:` URI 차단
|
||||
- 프론트엔드: 별도 처리 불필요 (서버에서 자동)
|
||||
|
||||
**참조**: `SECURITY.md`, `src/utils/sanitizeHtml.ts`
|
||||
|
||||
---
|
||||
|
||||
## 페이지 인증 규칙
|
||||
|
||||
### 1. 완전 인증 필수 페이지 (Protected Pages)
|
||||
|
||||
**적용 페이지**: `/home`, `/team`, `/team/create`, `/team/[teamId]/*`
|
||||
|
||||
```typescript
|
||||
import { useRequireAuth } from "@/hooks/useRequireAuth";
|
||||
|
||||
export default function ProtectedPage() {
|
||||
// 인증 필수 (비인증 시 자동 리다이렉트)
|
||||
const isLoadingAuth = useRequireAuth();
|
||||
|
||||
if (isLoadingAuth) {
|
||||
return null; // 또는 <LoadingSkeleton />
|
||||
}
|
||||
|
||||
// 이후 코드는 user가 보장됨
|
||||
return <Box>...</Box>;
|
||||
}
|
||||
```
|
||||
|
||||
**데이터 로딩이 있는 경우** (리다이렉트 방지):
|
||||
```typescript
|
||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||
|
||||
// additionalLoading: 데이터 로딩 중에는 리다이렉트하지 않음
|
||||
const isLoadingAuth = useRequireAuth(isLoadingData);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData().finally(() => setIsLoadingData(false));
|
||||
}, []);
|
||||
```
|
||||
|
||||
### 2. 부분 인증 필요 페이지 (Partially Protected)
|
||||
|
||||
**적용 페이지**: `/write` (접근 자유, 저장 시 인증)
|
||||
|
||||
```typescript
|
||||
const { isAuthenticated, openLoginDialog } = useAuthStore();
|
||||
|
||||
function handleProtectedAction() {
|
||||
if (!isAuthenticated) {
|
||||
openLoginDialog();
|
||||
return;
|
||||
}
|
||||
// 인증 필요 로직
|
||||
}
|
||||
```
|
||||
|
||||
**참조**: `src/hooks/useRequireAuth.ts`
|
||||
|
||||
---
|
||||
|
||||
## Server Component vs Client Component
|
||||
|
||||
### 언제 Server Component를 사용해야 하는가?
|
||||
|
||||
**✅ Server Component 사용이 필수인 경우:**
|
||||
1. **SEO가 중요한 페이지** - 검색엔진 크롤링 필요
|
||||
2. **SNS 공유 미리보기** - 카카오톡/페이스북 링크 미리보기 지원
|
||||
3. **공개 콘텐츠** - 비로그인 사용자도 볼 수 있는 콘텐츠
|
||||
4. **초기 로딩 성능** - 데이터가 포함된 HTML을 서버에서 생성
|
||||
|
||||
**예시: 글 상세보기 페이지** (`/writing/[writingId]`)
|
||||
- 학생들이 카카오톡으로 글 공유 시 미리보기 필요
|
||||
- 검색엔진에서 찾을 수 있어야 함
|
||||
- 서버에서 권한 체크 (클라이언트 깜빡임 없음)
|
||||
|
||||
### Server Component 구현 패턴
|
||||
|
||||
```typescript
|
||||
// ✅ Server Component (SEO/SNS 공유 최적화)
|
||||
import {getWriting} from "@/lib/server/writing";
|
||||
import {getTranslations} from "next-intl/server";
|
||||
import {BackButton} from "@/components/layout/BackButton";
|
||||
|
||||
export default async function WritingDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{locale: string; writingId: string}>;
|
||||
}) {
|
||||
const {writingId} = await params;
|
||||
const t = await getTranslations('interaction');
|
||||
|
||||
// 서버에서 데이터 fetch (Firebase Admin SDK)
|
||||
const writing = await getWriting(writingId);
|
||||
|
||||
if (!writing) {
|
||||
return <ErrorPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<BackButton /> {/* Client Component */}
|
||||
<Heading>{writing.title}</Heading>
|
||||
<div dangerouslySetInnerHTML={{__html: writing.content}} />
|
||||
<CommentList writingId={writingId} /> {/* Client Component */}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 공통 컴포넌트 패턴
|
||||
|
||||
#### BackButton (공통 뒤로가기 버튼)
|
||||
```typescript
|
||||
// src/components/layout/BackButton.tsx
|
||||
"use client";
|
||||
|
||||
import {Button, ButtonProps} from "@chakra-ui/react";
|
||||
import {LuArrowLeft} from "react-icons/lu";
|
||||
import {useRouter} from "@/i18n/routing";
|
||||
import {useTranslations} from "next-intl";
|
||||
|
||||
export function BackButton({href, label, ...props}: BackButtonProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations('interaction');
|
||||
|
||||
return (
|
||||
<Button variant="ghost" onClick={() => href ? router.push(href) : router.back()}>
|
||||
<LuArrowLeft />
|
||||
{label || t('back')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**사용 예시:**
|
||||
```typescript
|
||||
// 기본 사용 (router.back())
|
||||
<BackButton />
|
||||
|
||||
// 특정 경로로 이동
|
||||
<BackButton href="/home" />
|
||||
|
||||
// 커스텀 라벨
|
||||
<BackButton label="목록으로" />
|
||||
```
|
||||
|
||||
**참조**: `src/components/layout/BackButton.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Server vs Client 레이어 분리
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Server Components │
|
||||
│ - Direct DB access (Firebase Admin) │
|
||||
│ - No client state/hooks │
|
||||
│ - async/await functions │
|
||||
│ - SEO-friendly │
|
||||
└─────────────────────────────────────────┘
|
||||
↓ uses
|
||||
┌─────────────────────────────────────────┐
|
||||
│ src/lib/server/* (Server Functions) │
|
||||
│ - getWriting(id) │
|
||||
│ - getTopic(id) │
|
||||
│ - getTeam(id) │
|
||||
│ - Firebase Admin SDK │
|
||||
└─────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Client Components ("use client") │
|
||||
│ - Interactive UI (buttons, forms) │
|
||||
│ - React hooks (useState, useEffect) │
|
||||
│ - Client-side routing │
|
||||
└─────────────────────────────────────────┘
|
||||
↓ uses
|
||||
┌─────────────────────────────────────────┐
|
||||
│ src/managers/* (Client Managers) │
|
||||
│ - writingManager.getWriting() │
|
||||
│ - API Routes 호출 (fetch) │
|
||||
│ - Firebase Client SDK │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [CLAUDE.md](./CLAUDE.md) - 개발 가이드
|
||||
- [API_SPEC.md](./API_SPEC.md) - API 명세서
|
||||
- [SECURITY.md](./SECURITY.md) - 보안 정책
|
||||
- [TECH_STACK.md](./TECH_STACK.md) - 기술 스택
|
||||
|
||||
---
|
||||
|
||||
© 2024 BlueNovaLab. All rights reserved.
|
||||
236
I18N_GUIDE.md
Normal file
236
I18N_GUIDE.md
Normal file
@ -0,0 +1,236 @@
|
||||
# 다국어 지원 (i18n) 가이드
|
||||
|
||||
라온누리 프로젝트의 다국어 지원 시스템 가이드입니다.
|
||||
|
||||
---
|
||||
|
||||
## 필수 적용 규칙
|
||||
|
||||
**모든 새로운 UI 및 페이지는 다국어 지원이 필수입니다.**
|
||||
|
||||
---
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
1. **하드코딩 텍스트 금지**: 모든 사용자 대면 텍스트는 번역 파일(`messages/ko.json`, `messages/en.json`, `messages/ja.json`)에 저장
|
||||
2. **번역 키 사용**: `useTranslations('namespace')` 훅으로 번역 텍스트 사용
|
||||
3. **타입 안전 Link**: `import {Link} from '@/i18n/routing'` 사용 (next/link 대신)
|
||||
4. **타입 안전 Router**: `import {useRouter} from '@/i18n/routing'` 사용 (next/navigation 대신)
|
||||
|
||||
---
|
||||
|
||||
## 페이지 생성 시
|
||||
|
||||
```typescript
|
||||
// ❌ 잘못된 방식
|
||||
"use client";
|
||||
import {useRouter} from "next/navigation";
|
||||
|
||||
export default function NewPage() {
|
||||
return <h1>새 페이지</h1>; // 하드코딩 금지!
|
||||
}
|
||||
|
||||
// ✅ 올바른 방식
|
||||
"use client";
|
||||
import {useRouter} from "@/i18n/routing"; // next-intl router
|
||||
import {useTranslations} from "next-intl";
|
||||
|
||||
export default function NewPage() {
|
||||
const t = useTranslations('newPage');
|
||||
|
||||
return <h1>{t('title')}</h1>;
|
||||
}
|
||||
|
||||
// messages/ko.json에 추가:
|
||||
// "newPage": { "title": "새 페이지" }
|
||||
|
||||
// messages/en.json에 추가:
|
||||
// "newPage": { "title": "New Page" }
|
||||
|
||||
// messages/ja.json에 추가:
|
||||
// "newPage": { "title": "新しいページ" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 컴포넌트 생성 시
|
||||
|
||||
```typescript
|
||||
// 공통 컴포넌트는 namespace를 props로 받거나 고정
|
||||
"use client";
|
||||
import {useTranslations} from "next-intl";
|
||||
|
||||
export function MyComponent() {
|
||||
const t = useTranslations('components.myComponent');
|
||||
|
||||
return (
|
||||
<Button>{t('submit')}</Button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Server Component에서 번역 사용
|
||||
|
||||
Server Component에서는 `getTranslations()` 함수를 사용합니다:
|
||||
|
||||
```typescript
|
||||
// ✅ Server Component
|
||||
import {getTranslations} from "next-intl/server";
|
||||
|
||||
export default async function WritingDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{locale: string; writingId: string}>;
|
||||
}) {
|
||||
const {writingId} = await params;
|
||||
const t = await getTranslations('interaction');
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<BackButton label={t('back')} />
|
||||
<Heading>{writing.title}</Heading>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 서비스 레이어에서 번역 사용
|
||||
|
||||
React 훅을 사용할 수 없는 곳에서는 `src/utils/i18n.ts`의 `t()` 함수를 사용합니다:
|
||||
|
||||
```typescript
|
||||
// src/services/firebaseAuth.ts
|
||||
import { t } from "@/utils/i18n";
|
||||
|
||||
export function getErrorMessage(code: string): string {
|
||||
switch (code) {
|
||||
case "auth/invalid-email":
|
||||
return t("errors.auth.invalidEmail");
|
||||
case "auth/weak-password":
|
||||
return t("errors.auth.weakPassword", { min: 8 });
|
||||
default:
|
||||
return t("errors.auth.unknown");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**파일 위치**: `src/utils/i18n.ts`
|
||||
- `detectLocale()`: URL path 우선 → navigator.language fallback
|
||||
- `t()`: nested key 지원, 파라미터 치환
|
||||
|
||||
---
|
||||
|
||||
## 체크리스트 (페이지/컴포넌트 생성 시)
|
||||
|
||||
- [ ] 모든 사용자 대면 텍스트를 번역 키로 추출
|
||||
- [ ] `messages/ko.json`, `messages/en.json`, `messages/ja.json`에 번역 추가
|
||||
- [ ] `useTranslations` 훅 사용 (Client Component)
|
||||
- [ ] `getTranslations` 함수 사용 (Server Component)
|
||||
- [ ] next-intl의 Link/Router 사용
|
||||
- [ ] 파라미터가 있는 경우 `{name}` 플레이스홀더 사용
|
||||
- [ ] 일본어는 어린이 친화적 표현 (한자 최소화, ひらがな 우선)
|
||||
|
||||
---
|
||||
|
||||
## 일본어 번역 주의사항
|
||||
|
||||
- **한자 최소화**: 초등학생 대상이므로 가능한 한 히라가나 사용
|
||||
- **정중한 표현**: です/ます 체 사용
|
||||
- **예시**:
|
||||
```json
|
||||
// ❌ Bad
|
||||
"login": "ログイン"
|
||||
|
||||
// ✅ Good
|
||||
"login": "ログインする"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 언어별 파일
|
||||
|
||||
| 언어 | 파일 | 라인 수 | 키 개수 |
|
||||
|------|------|--------|--------|
|
||||
| 한국어 | `messages/ko.json` | 407줄 | 220+ 키 |
|
||||
| 영어 | `messages/en.json` | 407줄 | 220+ 키 |
|
||||
| 일본어 | `messages/ja.json` | 407줄 | 220+ 키 |
|
||||
|
||||
---
|
||||
|
||||
## 언어 전환 UI
|
||||
|
||||
LocaleSwitcher 컴포넌트 (`src/components/layout/LocaleSwitcher.tsx`):
|
||||
- 국기 이모지 (🇰🇷 🇺🇸 🇯🇵)
|
||||
- 현재 언어 체크 표시
|
||||
- Portal 사용 (z-index 이슈 방지)
|
||||
|
||||
---
|
||||
|
||||
## 설정 파일
|
||||
|
||||
### middleware.ts
|
||||
|
||||
```typescript
|
||||
import createMiddleware from "next-intl/middleware";
|
||||
import { routing } from "./i18n/routing";
|
||||
|
||||
export default createMiddleware(routing);
|
||||
|
||||
export const config = {
|
||||
matcher: ["/", "/(ko|en|ja)/:path*"],
|
||||
};
|
||||
```
|
||||
|
||||
### i18n/routing.ts
|
||||
|
||||
```typescript
|
||||
import { defineRouting } from "next-intl/routing";
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: ["ko", "en", "ja"],
|
||||
defaultLocale: "ko",
|
||||
localePrefix: "always",
|
||||
});
|
||||
```
|
||||
|
||||
### next.config.ts
|
||||
|
||||
```typescript
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
|
||||
export default withNextIntl({
|
||||
// ... other config
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 라우팅 구조
|
||||
|
||||
```
|
||||
/ko/* - 한국어 페이지
|
||||
/en/* - 영어 페이지
|
||||
/ja/* - 일본어 페이지
|
||||
```
|
||||
|
||||
- 브라우저 언어 자동 감지 (Accept-Language 헤더)
|
||||
- NEXT_LOCALE 쿠키 저장
|
||||
- localeDetection: true 설정
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [next-intl Documentation](https://next-intl-docs.vercel.app/)
|
||||
- [CLAUDE.md](./CLAUDE.md) - 개발 가이드
|
||||
- [TECH_STACK.md](./TECH_STACK.md) - 기술 스택
|
||||
|
||||
---
|
||||
|
||||
© 2024 BlueNovaLab. All rights reserved.
|
||||
18
README.md
18
README.md
@ -2,8 +2,6 @@
|
||||
|
||||
초등학생을 위한 창의적 글쓰기 교육 플랫폼
|
||||
|
||||
> 최종 업데이트: 2025-11-07
|
||||
|
||||
## 프로젝트 소개
|
||||
|
||||
라온누리는 초등학생들이 재미있게 글쓰기를 배울 수 있는 한국어 교육 플랫폼입니다.
|
||||
@ -147,11 +145,21 @@ src/
|
||||
## 개발자 가이드
|
||||
|
||||
상세한 개발 가이드는 다음 문서를 참고하세요:
|
||||
|
||||
### 핵심 가이드
|
||||
- [CLAUDE.md](./CLAUDE.md) - Claude Code 개발 가이드
|
||||
- [API_SPEC.md](./API_SPEC.md) - API 명세서
|
||||
- [TECH_STACK.md](./TECH_STACK.md) - 기술 스택 상세
|
||||
- [PROJECT_STRUCTURE.md](./PROJECT_STRUCTURE.md) - 프로젝트 구조 상세
|
||||
- [API_SPEC.md](./API_SPEC.md) - API 명세서 (RESTful 원칙, Firebase Admin SDK)
|
||||
- [STYLE_GUIDE.md](./STYLE_GUIDE.md) - 스타일 가이드 (Color, Icon 규칙)
|
||||
- [I18N_GUIDE.md](./I18N_GUIDE.md) - 다국어 지원 가이드 (필수)
|
||||
- [DEVELOPMENT_GUIDE.md](./DEVELOPMENT_GUIDE.md) - 구현 세부사항
|
||||
|
||||
### 프로젝트 문서
|
||||
- [PROJECT_STRUCTURE.md](./PROJECT_STRUCTURE.md) - 프로젝트 구조
|
||||
- [TECH_STACK.md](./TECH_STACK.md) - 기술 스택
|
||||
- [DATA_MODELS.md](./DATA_MODELS.md) - 데이터 모델
|
||||
- [SECURITY.md](./SECURITY.md) - 보안 정책
|
||||
- [ROADMAP.md](./ROADMAP.md) - 개발 로드맵
|
||||
- [CHANGELOG.md](./CHANGELOG.md) - 변경 내역
|
||||
|
||||
### Manager 패턴 사용
|
||||
|
||||
|
||||
103
SECURITY.md
103
SECURITY.md
@ -256,15 +256,102 @@ npm update
|
||||
|
||||
---
|
||||
|
||||
## 12. 보안 업데이트 이력
|
||||
## 13. 5단계 보안 레벨 시스템
|
||||
|
||||
| 날짜 | 조치 | 설명 |
|
||||
|------|------|------|
|
||||
| 2025-11-19 | XSS 방지 | HTML Sanitization 구현 (DOMPurify) |
|
||||
| 2025-10-xx | 인증 시스템 | Firebase Auth 적용 |
|
||||
| 2025-10-xx | API 인증 | ID Token 검증 적용 |
|
||||
라온누리는 팀별로 선택 가능한 5가지 보안 레벨을 제공합니다.
|
||||
|
||||
### 보안 레벨 개요
|
||||
|
||||
| Level | Enum | 이름 | 익명 허용 | 가입 제한 | 주요 사용처 |
|
||||
|-------|------|------|-----------|-----------|------------|
|
||||
| **1** | `OPEN` | 완전 개방 | ✅ | 닉네임 공유 로그인 | 공개 워크샵, 체험 수업 |
|
||||
| **2** | `NAME_LIST` | 명단 기반 | ✅ | `allowedNames` 체크 | 저학년 반 (익명이지만 통제) |
|
||||
| **3** | `AUTH_REQUIRED` | 로그인 필수 | ❌ | 정식 계정 누구나 | 고학년 반 (구글 계정) ⭐ 추천 |
|
||||
| **4** | `EMAIL_LIST` | 이메일 제한 | ❌ | `allowedEmails` 체크 | 특정 학생만 (전학생 차단) |
|
||||
| **5** | `CLOSED` | 닫힌 팀 | ❌ | 신규 가입 차단 | 졸업반, 종료된 프로젝트 |
|
||||
|
||||
### 핵심 동작
|
||||
|
||||
**Level 1 (OPEN)**:
|
||||
- 닉네임 중복 시 Custom Token으로 재로그인 (익명 계정만)
|
||||
- 정식 계정 탈취 방지 (Custom Token API에서 익명 체크)
|
||||
- **보안 조치**: Firebase Admin SDK로 `providerData` 검증
|
||||
- ✅ 익명 계정 (`providerData.length === 0`): Custom Token 발급
|
||||
- ❌ 정식 계정 (Google/Email): 403 에러 반환
|
||||
|
||||
```typescript
|
||||
// POST /api/team/get-custom-token
|
||||
const userRecord = await adminAuth.getUser(uid);
|
||||
if (userRecord.providerData.length > 0) {
|
||||
// 정식 계정 → 거부
|
||||
return forbiddenResponse("해당 이름은 다른 사용자가 사용 중입니다.");
|
||||
}
|
||||
// 익명 계정 → Custom Token 발급
|
||||
const customToken = await adminAuth.createCustomToken(uid);
|
||||
return successResponse({ customToken });
|
||||
```
|
||||
|
||||
**Level 2 (NAME_LIST)**:
|
||||
- `team.allowedNames` 배열 체크
|
||||
- 익명 로그인 허용하되, 등록된 이름만 입장 가능
|
||||
- 저학년 반에 적합 (익명이지만 통제)
|
||||
|
||||
**Level 3 (AUTH_REQUIRED)**:
|
||||
- `user.isAnonymous === false` 체크
|
||||
- 정식 계정(구글/이메일) 필수
|
||||
- 고학년 반 권장
|
||||
|
||||
**Level 4 (EMAIL_LIST)**:
|
||||
- `team.allowedEmails` 배열 체크
|
||||
- 특정 학생만 접근 가능 (이메일 화이트리스트)
|
||||
|
||||
**Level 5 (CLOSED)**:
|
||||
- `uid in team.members` 체크만
|
||||
- 신규 가입 차단, 기존 멤버만 접근
|
||||
|
||||
### 레벨 변경 규칙
|
||||
|
||||
```typescript
|
||||
await teamManager.updateSecurityLevel(teamId, 4, true);
|
||||
// autoPopulateList: true → 기존 멤버 이메일/이름 자동 등록
|
||||
// Level 3→4: 기존 멤버 이메일 자동 등록
|
||||
// Level 2→4: 기존 익명 멤버는 유지, 신규는 정식 계정만
|
||||
```
|
||||
|
||||
**API 엔드포인트**:
|
||||
- `POST /api/team/:teamId/security-level` - 보안 레벨 변경
|
||||
- `POST /api/team/:teamId/allowed-names` - 이름 추가 (Level 2)
|
||||
- `DELETE /api/team/:teamId/allowed-names` - 이름 제거 (Level 2)
|
||||
- `POST /api/team/:teamId/allowed-emails` - 이메일 추가 (Level 4)
|
||||
- `DELETE /api/team/:teamId/allowed-emails` - 이메일 제거 (Level 4)
|
||||
- `POST /api/team/get-custom-token` - Custom Token 생성 (Level 1)
|
||||
|
||||
**참조**: `DATA_MODELS.md`, `API_SPEC.md`
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-11-19
|
||||
**Security Contact**: [프로젝트 이슈 트래커](https://github.com/[your-repo]/issues)
|
||||
## 14. 데이터 모델 보안
|
||||
|
||||
### User 타입 최소화
|
||||
|
||||
- **FirestoreUser** (DB): uid, createdAt, lastLoginAt, settings만 저장
|
||||
- **User** (UI): Firebase Auth + Firestore 자동 결합
|
||||
- 이름, 이메일, 사진은 Firebase Auth가 Single Source of Truth
|
||||
- **보안 이점**: 민감 정보 중복 저장 방지, 데이터 일관성 보장
|
||||
|
||||
### 닉네임 저장 위치
|
||||
|
||||
- ❌ `users.nicknames[teamId]` (기존) - 중복 저장
|
||||
- ✅ `team.members[uid].nickname` (신규) - 단일 진실 공급원
|
||||
- **보안 이점**: 팀 탈퇴 시 자동 삭제, 권한 관리 용이
|
||||
|
||||
### memberUids 제거
|
||||
|
||||
- ❌ `team.memberUids` 배열 (중복, 동기화 이슈)
|
||||
- ✅ `Object.keys(team.members)` 사용
|
||||
- 멤버 확인: `uid in team.members`
|
||||
- **보안 이점**: 데이터 불일치 방지, atomic 업데이트
|
||||
|
||||
---
|
||||
|
||||
© 2024 BlueNovaLab. All rights reserved.
|
||||
|
||||
195
STYLE_GUIDE.md
Normal file
195
STYLE_GUIDE.md
Normal file
@ -0,0 +1,195 @@
|
||||
# Style Guide
|
||||
|
||||
라온누리 프로젝트의 코딩 스타일 가이드입니다.
|
||||
|
||||
---
|
||||
|
||||
## Color Usage Guidelines
|
||||
|
||||
### 핵심 원칙
|
||||
|
||||
**항상 브랜드 컬러를 우선 사용하세요:**
|
||||
|
||||
1. **버튼에는 항상 `colorPalette="brand"`** 사용:
|
||||
```tsx
|
||||
// ✅ Good
|
||||
<Button colorPalette="brand">클릭</Button>
|
||||
|
||||
// ❌ Bad
|
||||
<Button>클릭</Button> // 기본 gray 사용 금지
|
||||
```
|
||||
|
||||
2. **Semantic 토큰 우선 사용** (하드코딩된 숫자 금지):
|
||||
```tsx
|
||||
// ✅ Good
|
||||
color="fg" // 기본 텍스트
|
||||
color="fg.muted" // 보조 텍스트
|
||||
color="brand.fg" // 브랜드 텍스트
|
||||
bg="bg" // 기본 배경
|
||||
bg="brand.subtle" // 브랜드 배경
|
||||
borderColor="border.muted"
|
||||
|
||||
// ❌ Bad
|
||||
color="gray.600" // 숫자 사용 금지
|
||||
bg="#FF6B9D" // 하드코딩 금지
|
||||
```
|
||||
|
||||
3. **브랜드 컬러 활용**:
|
||||
- Primary actions: `colorPalette="brand"` (핑크)
|
||||
- Secondary: `colorPalette="teal"`, `colorPalette="blue"`
|
||||
- Destructive: `colorPalette="red"`
|
||||
- Success: `colorPalette="green"`
|
||||
|
||||
4. **다크 모드 대응**:
|
||||
- Semantic 토큰 사용 시 자동으로 light/dark 변환
|
||||
- ❌ **`useColorModeValue()` 사용 금지** - 전체 프로젝트에서 제거됨 (2025-11-10)
|
||||
- ✅ **Semantic token 또는 conditional style 객체 사용**:
|
||||
```tsx
|
||||
// ✅ Good - Semantic token (단색)
|
||||
<Box bg="bg" color="fg" borderColor="border.muted">
|
||||
|
||||
// ✅ Good - Semantic token (그라데이션은 var() 필수)
|
||||
<Box bgGradient="var(--chakra-colors-landing-hero-bg)">
|
||||
|
||||
// ✅ Good - Conditional style 객체 (특수한 경우에만, 기본적으로는 Semantic token 우선)
|
||||
<Box _hover={{bg: {_light: "gray.50", _dark: "gray.700"}}}>
|
||||
|
||||
// ✅ Good - Conditional style 객체 (그라데이션)
|
||||
<Box bgGradient={{
|
||||
_light: "linear-gradient(to bottom right, #FFE5E5, #FFF5E5, #E5F5FF)",
|
||||
_dark: "linear-gradient(to bottom right, gray.900, gray.800, blue.900)"
|
||||
}}>
|
||||
|
||||
// ❌ Bad - useColorModeValue 사용 금지
|
||||
const bg = useColorModeValue("white", "gray.800");
|
||||
```
|
||||
|
||||
### Landing 페이지 전용 토큰
|
||||
|
||||
```tsx
|
||||
color="landing.text" // 메인 텍스트 (#2C3E50 / gray.100)
|
||||
color="landing.subtext" // 보조 텍스트 (#5A6C7D / gray.300)
|
||||
bg="landing.section.bg" // 섹션 배경 (#F8F9FA / gray.800)
|
||||
bg="landing.footer.bg" // Footer 배경
|
||||
|
||||
// ⚠️ 그라데이션은 var() 사용 필수
|
||||
bgGradient="var(--chakra-colors-landing-hero-bg)"
|
||||
|
||||
// Feature Card 색상 (11가지)
|
||||
bg="landing.feature.pink.bg" // 1. Pink (#FFE5EE / #FF6B9D)
|
||||
bg="landing.feature.teal.bg" // 2. Teal (#E5F9F7 / #4ECDC4)
|
||||
bg="landing.feature.yellow.bg" // 3. Yellow (#FFF9E5 / #FFD93D)
|
||||
bg="landing.feature.mint.bg" // 4. Mint (#E5F9F5 / #95E1D3)
|
||||
bg="landing.feature.purple.bg" // 5. Purple (#F3E5F5 / #9C27B0)
|
||||
bg="landing.feature.blue.bg" // 6. Blue (#E3F2FD / #2196F3)
|
||||
bg="landing.feature.orange.bg" // 7. Orange (#FFF3E0 / #FF9800)
|
||||
bg="landing.feature.green.bg" // 8. Green (#E8F5E9 / #4CAF50)
|
||||
bg="landing.feature.red.bg" // 9. Red (#FFEBEE / #F44336)
|
||||
bg="landing.feature.indigo.bg" // 10. Indigo (#E8EAF6 / #3F51B5)
|
||||
bg="landing.feature.lime.bg" // 11. Lime (#F9FBE7 / #CDDC39)
|
||||
|
||||
color="landing.feature.{color}.icon" // 아이콘 색상 (500 shade)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Icon 사용 규칙
|
||||
|
||||
**react-icons 필수** (이모티콘 사용 금지):
|
||||
|
||||
- ✅ `react-icons/lu` (Lucide) - 메인 아이콘 세트
|
||||
- ✅ `react-icons/fa` (Font Awesome) - 보조 아이콘
|
||||
- ❌ 이모티콘 (🌍📝🔐) - 사용 금지 (브라우저마다 다르게 표시)
|
||||
|
||||
### 사용 예시
|
||||
|
||||
```tsx
|
||||
import { LuGlobe, LuClipboardList, LuLock } from "react-icons/lu";
|
||||
import { FaUserGraduate } from "react-icons/fa";
|
||||
|
||||
// ✅ Good
|
||||
<Icon as={LuGlobe} />
|
||||
|
||||
// ❌ Bad
|
||||
<Text>🌍</Text> // 이모티콘 사용 금지
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chakra UI 테마 확장
|
||||
|
||||
### 위치
|
||||
- **파일**: `src/theme/system.ts`
|
||||
- Semantic tokens, recipes 패턴 따르기
|
||||
- **Overlay UI (Dialog, Select, Menu)**: Portal 사용, bg 자동 적용
|
||||
|
||||
### 테마 슬롯 레시피
|
||||
|
||||
Dialog, Select 등 Overlay 컴포넌트는 slot recipe 적용:
|
||||
|
||||
```typescript
|
||||
// src/theme/system.ts
|
||||
export default createSystem(defaultConfig, {
|
||||
theme: {
|
||||
recipes: {
|
||||
dialog: defineRecipe({
|
||||
base: {
|
||||
backdrop: {
|
||||
backdropFilter: "blur(4px)",
|
||||
},
|
||||
content: {
|
||||
bg: "bg",
|
||||
borderColor: "border.muted",
|
||||
shadow: "lg",
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 컴포넌트 스타일 가이드
|
||||
|
||||
### GlassCard 컴포넌트
|
||||
|
||||
반투명 배경 + 블러 효과 통일:
|
||||
|
||||
```tsx
|
||||
<GlassCard>
|
||||
<CardHeader>제목</CardHeader>
|
||||
<CardBody>내용</CardBody>
|
||||
</GlassCard>
|
||||
```
|
||||
|
||||
**특징**:
|
||||
- 자동 배경 블러 (`backdrop-filter: blur(8px)`)
|
||||
- 다크 모드 자동 대응
|
||||
- 일관된 시각적 통일성
|
||||
|
||||
### 버튼 스타일
|
||||
|
||||
```tsx
|
||||
// Primary action
|
||||
<Button colorPalette="brand">저장</Button>
|
||||
|
||||
// Secondary action
|
||||
<Button variant="outline" colorPalette="teal">취소</Button>
|
||||
|
||||
// Destructive action
|
||||
<Button colorPalette="red">삭제</Button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- chakra mcp
|
||||
- [TECH_STACK.md](./TECH_STACK.md) - 기술 스택
|
||||
- [CLAUDE.md](./CLAUDE.md) - 개발 가이드
|
||||
|
||||
---
|
||||
|
||||
© 2024 BlueNovaLab. All rights reserved.
|
||||
Loading…
x
Reference in New Issue
Block a user