diff --git a/ServerlessFunction/build.gradle b/ServerlessFunction/build.gradle index cc5e6a12..34615a92 100644 --- a/ServerlessFunction/build.gradle +++ b/ServerlessFunction/build.gradle @@ -35,6 +35,7 @@ dependencies { implementation 'software.amazon.awssdk:url-connection-client' implementation 'software.amazon.awssdk:ssm' implementation 'software.amazon.awssdk:scheduler' + implementation 'software.amazon.awssdk:sqs' // AWS X-Ray SDK (다운스트림 서비스 추적용) implementation 'com.amazonaws:aws-xray-recorder-sdk-core:2.15.0' diff --git a/ServerlessFunction/buildspec-dev.yml b/ServerlessFunction/buildspec-dev.yml index 78a12758..10571c8d 100644 --- a/ServerlessFunction/buildspec-dev.yml +++ b/ServerlessFunction/buildspec-dev.yml @@ -43,7 +43,10 @@ phases: --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \ --no-confirm-changeset \ --no-fail-on-empty-changeset \ - --parameter-overrides Environment=$ENVIRONMENT + --parameter-overrides \ + Environment=$ENVIRONMENT \ + ExistingCognitoUserPoolId=ap-northeast-2_ezDwzFCzR \ + ExistingCognitoClientId=4ns077jcr1pkue2vvisr6qdpu5 - echo "Deployment completed on $(date)" cache: diff --git a/ServerlessFunction/buildspec-prod.yml b/ServerlessFunction/buildspec-prod.yml index 0aa83787..25d01611 100644 --- a/ServerlessFunction/buildspec-prod.yml +++ b/ServerlessFunction/buildspec-prod.yml @@ -5,7 +5,6 @@ env: SAM_CLI_TELEMETRY: 0 GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" ENVIRONMENT: prod - STACK_NAME: group2-englishstudy-prod phases: install: @@ -28,23 +27,17 @@ phases: - echo "Building SAM application for $ENVIRONMENT..." - cd $CODEBUILD_SRC_DIR/ServerlessFunction - sam build --parallel --cached - - echo "Build completed" + - echo "Packaging SAM application..." + - sam package --s3-bucket group2-englishstudy-pipeline-artifacts --s3-prefix sam-packages/$ENVIRONMENT --output-template-file packaged-template.yaml post_build: commands: - - echo "Deploying to $ENVIRONMENT environment..." - - cd $CODEBUILD_SRC_DIR/ServerlessFunction - - | - sam deploy \ - --stack-name $STACK_NAME \ - --s3-bucket group2-englishstudy-pipeline-artifacts \ - --s3-prefix sam-deploy/prod \ - --region ap-northeast-2 \ - --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \ - --no-confirm-changeset \ - --no-fail-on-empty-changeset \ - --parameter-overrides Environment=$ENVIRONMENT - - echo "Deployment completed on $(date)" + - echo "Build completed on $(date)" + +artifacts: + files: + - packaged-template.yaml + base-directory: ServerlessFunction cache: paths: diff --git a/ServerlessFunction/buildspec-test.yml b/ServerlessFunction/buildspec-test.yml index b74b041c..6d75e4f0 100644 --- a/ServerlessFunction/buildspec-test.yml +++ b/ServerlessFunction/buildspec-test.yml @@ -5,7 +5,6 @@ env: SAM_CLI_TELEMETRY: 0 GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" ENVIRONMENT: test - STACK_NAME: group2-englishstudy-test phases: install: @@ -28,23 +27,17 @@ phases: - echo "Building SAM application for $ENVIRONMENT..." - cd $CODEBUILD_SRC_DIR/ServerlessFunction - sam build --parallel --cached - - echo "Build completed" + - echo "Packaging SAM application..." + - sam package --s3-bucket group2-englishstudy-pipeline-artifacts --s3-prefix sam-packages/$ENVIRONMENT --output-template-file packaged-template.yaml post_build: commands: - - echo "Deploying to $ENVIRONMENT environment..." - - cd $CODEBUILD_SRC_DIR/ServerlessFunction - - | - sam deploy \ - --stack-name $STACK_NAME \ - --resolve-s3 \ - --s3-prefix $STACK_NAME \ - --region ap-northeast-2 \ - --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \ - --no-confirm-changeset \ - --no-fail-on-empty-changeset \ - --parameter-overrides Environment=$ENVIRONMENT - - echo "Deployment completed on $(date)" + - echo "Build completed on $(date)" + +artifacts: + files: + - packaged-template.yaml + base-directory: ServerlessFunction cache: paths: diff --git a/ServerlessFunction/buildspec.yml b/ServerlessFunction/buildspec.yml index 0779bea5..49ed2a4f 100644 --- a/ServerlessFunction/buildspec.yml +++ b/ServerlessFunction/buildspec.yml @@ -4,8 +4,6 @@ env: variables: SAM_CLI_TELEMETRY: 0 GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" - ENVIRONMENT: prod - STACK_NAME: group2-englishstudy-prod phases: install: @@ -25,26 +23,20 @@ phases: build: commands: - - echo "Building SAM application for $ENVIRONMENT..." + - echo "Building SAM application..." - cd $CODEBUILD_SRC_DIR/ServerlessFunction - sam build --parallel --cached - - echo "Build completed" + - echo "Packaging SAM application..." + - sam package --s3-bucket group2-englishstudy-pipeline-artifacts --s3-prefix sam-packages --output-template-file packaged-template.yaml post_build: commands: - - echo "Deploying to $ENVIRONMENT environment..." - - cd $CODEBUILD_SRC_DIR/ServerlessFunction - - | - sam deploy \ - --stack-name $STACK_NAME \ - --s3-bucket group2-englishstudy-pipeline-artifacts \ - --s3-prefix sam-deploy/prod \ - --region ap-northeast-2 \ - --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \ - --no-confirm-changeset \ - --no-fail-on-empty-changeset \ - --parameter-overrides Environment=$ENVIRONMENT - - echo "Deployment completed on $(date)" + - echo "Build completed on $(date)" + +artifacts: + files: + - packaged-template.yaml + base-directory: ServerlessFunction cache: paths: diff --git a/ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md b/ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md deleted file mode 100644 index 4dd03385..00000000 --- a/ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md +++ /dev/null @@ -1,761 +0,0 @@ -# Catchmind 게임 프론트엔드 연동 가이드 - -## 목차 - -1. [개요](#개요) -2. [아키텍처](#아키텍처) -3. [WebSocket 연결](#websocket-연결) -4. [메시지 구조](#메시지-구조) -5. [게임 흐름](#게임-흐름) -6. [REST API](#rest-api) -7. [타이머 동기화](#타이머-동기화) -8. [게임 자동 종료](#게임-자동-종료) -9. [재접속 처리](#재접속-처리) -10. [에러 처리](#에러-처리) - ---- - -## 개요 - -Catchmind는 실시간 그림 맞추기 게임입니다. WebSocket을 통한 실시간 통신과 REST API를 통한 게임 세션 관리를 지원합니다. - -### 주요 특징 - -- **실시간 통신**: WebSocket 기반 양방향 통신 -- **도메인 분리**: `chat` / `game` 도메인으로 메시지 라우팅 -- **타이머 동기화**: `serverTime` 필드를 통한 클라이언트-서버 시간 동기화 -- **자동 종료**: 게임 시작 7분 후 자동 종료 -- **재접속 지원**: 게임 세션 API를 통한 상태 복원 - ---- - -## 아키텍처 - -``` -┌─────────────┐ WebSocket ┌──────────────────┐ -│ Frontend │◄──────────────────►│ API Gateway WS │ -│ (React) │ └────────┬─────────┘ -│ │ │ -│ │ REST API ┌───────▼─────────┐ -│ │◄───────────────────►│ API Gateway │ -└─────────────┘ │ REST │ - └────────┬────────┘ - │ - ┌─────────────┼─────────────┐ - │ │ │ - ┌─────▼────┐ ┌─────▼────┐ ┌─────▼────┐ - │ WS Msg │ │ Game │ │ Game │ - │ Handler │ │ Handler │ │ Session │ - └──────────┘ └──────────┘ │ Handler │ - └──────────┘ -``` - ---- - -## WebSocket 연결 - -### 연결 URL - -``` -wss://{api-id}.execute-api.{region}.amazonaws.com/dev?roomToken={token} -``` - -### 연결 절차 - -1. REST API로 방 토큰 발급 (`POST /chat/rooms/{roomId}/join`) -2. 토큰으로 WebSocket 연결 -3. 연결 성공 시 자동으로 방에 입장 - -### 연결 예시 (TypeScript) - -```typescript -const connectWebSocket = (roomToken: string): WebSocket => { - const ws = new WebSocket( - `wss://xxx.execute-api.ap-northeast-2.amazonaws.com/dev?roomToken=${roomToken}` - ); - - ws.onopen = () => console.log('WebSocket connected'); - ws.onmessage = (event) => handleMessage(JSON.parse(event.data)); - ws.onerror = (error) => console.error('WebSocket error:', error); - ws.onclose = () => console.log('WebSocket closed'); - - return ws; -}; -``` - ---- - -## 메시지 구조 - -### 공통 메시지 포맷 - -모든 WebSocket 메시지는 다음 필드를 포함합니다: - -```typescript -interface BaseMessage { - domain: 'chat' | 'game'; // 도메인 구분 - messageType: string; // 메시지 타입 - messageId: string; // 고유 메시지 ID - roomId: string; // 방 ID - userId: string; // 발신자 ID (시스템: "SYSTEM") - content?: string; // 메시지 내용 - createdAt: string; // ISO 8601 형식 시간 - timestamp: number; // Unix timestamp (ms) -} -``` - -### 도메인 구분 - -| 도메인 | 설명 | 메시지 타입 | -|--------|--------|-------------------------------------------------------------------------------------------| -| `chat` | 채팅 메시지 | text, image, voice, ai_response | -| `game` | 게임 메시지 | game_start, game_end, round_start, round_end, drawing, correct_answer, score_update, hint | - -### 메시지 라우팅 예시 - -```typescript -const handleMessage = (message: BaseMessage) => { - if (message.domain === 'chat') { - handleChatMessage(message); - } else if (message.domain === 'game') { - handleGameMessage(message); - } -}; -``` - ---- - -## 게임 흐름 - -### 게임 상태 (GameStatus) - -```typescript -type GameStatus = 'NONE' | 'WAITING' | 'PLAYING' | 'ROUND_END' | 'FINISHED'; -``` - -### 전체 흐름 - -``` -[대기] ─── /game 시작 ───► [게임 시작] ─► [라운드 1] ─► [라운드 종료] - │ │ - │ ◄───────────────────┘ - │ (반복) - ▼ - [게임 종료] - │ - ┌────┴────┐ - │ │ - 수동 종료 자동 종료 - (7분 경과) -``` - -### 1. 게임 시작 (game_start) - -**수신 메시지:** - -```json -{ - "domain": "game", - "messageType": "game_start", - "messageId": "uuid", - "roomId": "room-123", - "userId": "SYSTEM", - "content": "🎮 게임 시작!\n총 5 라운드\n\n라운드 1 시작!\n출제자: user-1", - "createdAt": "2024-01-20T10:00:00Z", - "timestamp": 1705746000000, - "serverTime": 1705746000000, - "gameStatus": "PLAYING", - "currentRound": 1, - "totalRounds": 5, - "currentDrawerId": "user-1", - "drawerOrder": ["user-1", "user-2", "user-3"], - "roundStartTime": 1705746000000, - "roundDuration": 60 -} -``` - -**프론트엔드 처리:** - -```typescript -const handleGameStart = (message: GameStartMessage) => { - setGameStatus('PLAYING'); - setCurrentRound(message.currentRound); - setTotalRounds(message.totalRounds); - setCurrentDrawer(message.currentDrawerId); - setDrawerOrder(message.drawerOrder); - - // 타이머 동기화 - startTimer(message.roundStartTime, message.roundDuration, message.serverTime); - - // 현재 사용자가 출제자인지 확인 - setIsDrawer(message.currentDrawerId === currentUserId); -}; -``` - -### 2. 그림 데이터 전송/수신 (drawing) - -**전송 (출제자만):** - -```typescript -const sendDrawing = (drawingData: DrawingData) => { - ws.send(JSON.stringify({ - action: 'sendMessage', - messageType: 'drawing', - content: JSON.stringify(drawingData) - })); -}; -``` - -**수신 메시지:** - -```json -{ - "domain": "game", - "messageType": "drawing", - "messageId": "uuid", - "roomId": "room-123", - "userId": "user-1", - "content": "{\"type\":\"path\",\"points\":[...],\"color\":\"#000\",\"width\":3}", - "timestamp": 1705746010000 -} -``` - -### 3. 정답 체크 - -**채팅 메시지로 자동 체크됩니다:** - -```typescript -const sendAnswer = (answer: string) => { - ws.send(JSON.stringify({ - action: 'sendMessage', - messageType: 'text', - content: answer - })); -}; -``` - -### 4. 정답 알림 (correct_answer) - -**수신 메시지:** - -```json -{ - "domain": "game", - "messageType": "correct_answer", - "roomId": "room-123", - "userId": "user-2", - "content": "🎉 user-2님이 정답을 맞혔습니다! (+35점)", - "timestamp": 1705746030000, - "serverTime": 1705746030000, - "score": 35, - "elapsedTime": 30000, - "allCorrect": false, - "scores": { - "user-1": 5, - "user-2": 35 - } -} -``` - -### 5. 점수 업데이트 (score_update) - -**수신 메시지:** - -```json -{ - "domain": "game", - "messageType": "score_update", - "roomId": "room-123", - "timestamp": 1705746030000, - "scores": { - "user-1": 15, - "user-2": 35, - "user-3": 20 - }, - "lastScorer": "user-2", - "lastScore": 35 -} -``` - -### 6. 라운드 종료 (round_end) - -**수신 메시지:** - -```json -{ - "domain": "game", - "messageType": "round_end", - "roomId": "room-123", - "content": "라운드 1 종료! 정답: 사과\n\n라운드 2 시작! 출제자: user-2", - "timestamp": 1705746060000, - "serverTime": 1705746060000, - "data": { - "answer": "사과", - "currentRound": 1, - "totalRounds": 5, - "nextRound": 2, - "nextDrawer": "user-2", - "nextWord": { - "wordId": "word-123", - "korean": "바나나" - }, - "roundStartTime": 1705746060000, - "roundDuration": 60, - "ranking": [ - { "rank": 1, "userId": "user-2", "score": 35 }, - { "rank": 2, "userId": "user-3", "score": 20 }, - { "rank": 3, "userId": "user-1", "score": 15 } - ] - } -} -``` - -**프론트엔드 처리:** - -```typescript -const handleRoundEnd = (message: RoundEndMessage) => { - const { data } = message; - - // 정답 표시 - showAnswer(data.answer); - - // 순위 표시 - showRanking(data.ranking); - - // 다음 라운드 준비 - if (data.nextRound) { - setCurrentRound(data.nextRound); - setCurrentDrawer(data.nextDrawer); - setIsDrawer(data.nextDrawer === currentUserId); - - // 출제자에게만 단어 표시 - if (data.nextDrawer === currentUserId && data.nextWord) { - setCurrentWord(data.nextWord.korean); - } - - // 타이머 재시작 - startTimer(data.roundStartTime, data.roundDuration, message.serverTime); - - // 캔버스 초기화 - clearCanvas(); - } -}; -``` - -### 7. 게임 종료 (game_end) - -**수신 메시지:** - -```json -{ - "domain": "game", - "messageType": "game_end", - "roomId": "room-123", - "content": "🎮 게임 종료!\n\n📊 최종 순위:\n 🥇 user-2: 120점\n 🥈 user-3: 95점\n 🥉 user-1: 80점", - "timestamp": 1705746300000, - "reason": "COMPLETED" -} -``` - -**종료 사유 (reason):** -| 값 | 설명 | -|----|------| -| `COMPLETED` | 모든 라운드 완료 | -| `STOPPED` | 수동 종료 | -| `TIME_EXPIRED` | 7분 시간 초과 | -| `NOT_ENOUGH_PLAYERS` | 인원 부족 | - ---- - -## REST API - -### 게임 시작 - -```http -POST /chat/rooms/{roomId}/game/start -Authorization: Bearer {accessToken} -``` - -**Response:** - -```json -{ - "success": true, - "message": "Game started", - "data": { - "gameSessionId": "session-123", - "roomId": "room-123", - "status": "PLAYING", - "currentRound": 1, - "totalRounds": 5, - "currentDrawerId": "user-1", - "roundStartTime": 1705746000000, - "serverTime": 1705746000000, - "roundDuration": 60, - "drawerOrder": ["user-1", "user-2", "user-3"], - "currentWord": { - "wordId": "word-1", - "word": "사과" - } - } -} -``` - -> **Note:** `currentWord`는 출제자에게만 포함됩니다. - -### 게임 종료 - -```http -POST /chat/rooms/{roomId}/game/stop -Authorization: Bearer {accessToken} -``` - -### 게임 상태 조회 - -```http -GET /chat/rooms/{roomId}/game/status -Authorization: Bearer {accessToken} -``` - -### 게임 세션 조회 (재접속용) - -```http -GET /games/{gameSessionId} -Authorization: Bearer {accessToken} -``` - -**Response:** - -```json -{ - "success": true, - "message": "Game session retrieved", - "data": { - "gameSessionId": "session-123", - "roomId": "room-123", - "gameType": "catchmind", - "status": "PLAYING", - "currentRound": 3, - "totalRounds": 5, - "currentDrawerId": "user-2", - "roundStartTime": 1705746180000, - "serverTime": 1705746200000, - "roundDuration": 60, - "scores": { - "user-1": 45, - "user-2": 60, - "user-3": 30 - }, - "players": ["user-1", "user-2", "user-3"], - "drawerOrder": ["user-1", "user-2", "user-3"], - "hintUsed": false, - "currentWord": { - "wordId": "word-5", - "word": "바나나" - } - } -} -``` - -> **Note:** `currentWord`는 출제자에게만 포함됩니다. - ---- - -## 타이머 동기화 - -### 문제 - -클라이언트와 서버 시간 차이로 인한 타이머 불일치 - -### 해결책 - -`serverTime` 필드를 사용하여 서버 시간 기준 타이머 계산 - -### 구현 예시 - -```typescript -interface TimerSync { - roundStartTime: number; // 라운드 시작 시간 (서버 기준) - roundDuration: number; // 라운드 지속 시간 (초) - serverTime: number; // 메시지 발송 시점의 서버 시간 -} - -const startTimer = ( - roundStartTime: number, - roundDuration: number, - serverTime: number -) => { - // 서버에서 이미 경과한 시간 계산 - const elapsedOnServer = serverTime - roundStartTime; - - // 남은 시간 계산 (밀리초) - const remainingTime = (roundDuration * 1000) - elapsedOnServer; - - // 음수 방지 - const safeRemainingTime = Math.max(0, remainingTime); - - setRemainingTime(safeRemainingTime); - - // 타이머 시작 - const interval = setInterval(() => { - setRemainingTime((prev) => { - if (prev <= 1000) { - clearInterval(interval); - return 0; - } - return prev - 1000; - }); - }, 1000); - - return () => clearInterval(interval); -}; -``` - -### React Hook 예시 - -```typescript -const useGameTimer = (timerSync: TimerSync | null) => { - const [remainingSeconds, setRemainingSeconds] = useState(0); - - useEffect(() => { - if (!timerSync) return; - - const { roundStartTime, roundDuration, serverTime } = timerSync; - const elapsed = (serverTime - roundStartTime) / 1000; - const remaining = Math.max(0, roundDuration - elapsed); - - setRemainingSeconds(Math.ceil(remaining)); - - const interval = setInterval(() => { - setRemainingSeconds((prev) => Math.max(0, prev - 1)); - }, 1000); - - return () => clearInterval(interval); - }, [timerSync]); - - return remainingSeconds; -}; -``` - ---- - -## 게임 자동 종료 - -### 개요 - -게임 시작 후 7분(420초)이 경과하면 자동으로 종료됩니다. - -### 자동 종료 메시지 - -```json -{ - "domain": "game", - "messageType": "game_end", - "roomId": "room-123", - "userId": "SYSTEM", - "content": "⏰ 시간 초과! 🎮 게임 종료!\n\n📊 최종 순위:\n 🥇 user-2: 120점\n 🥈 user-1: 95점", - "timestamp": 1705746420000, - "reason": "TIME_EXPIRED" -} -``` - -### 프론트엔드 처리 - -```typescript -const handleGameEnd = (message: GameEndMessage) => { - setGameStatus('FINISHED'); - - // 종료 사유에 따른 UI 처리 - if (message.reason === 'TIME_EXPIRED') { - showNotification('시간 초과로 게임이 종료되었습니다.'); - } else if (message.reason === 'STOPPED') { - showNotification('게임이 수동으로 종료되었습니다.'); - } - - // 최종 결과 표시 - showFinalResults(message.content); - - // 캔버스 초기화 - clearCanvas(); -}; -``` - ---- - -## 재접속 처리 - -### 시나리오 - -사용자가 게임 중 연결이 끊어졌다가 다시 접속하는 경우 - -### 처리 절차 - -1. WebSocket 재연결 -2. 게임 세션 API로 현재 상태 조회 -3. UI 상태 복원 -4. 타이머 동기화 - -### 구현 예시 - -```typescript -const handleReconnect = async (roomId: string, gameSessionId: string) => { - // 1. WebSocket 재연결 - const roomToken = await getRoomToken(roomId); - connectWebSocket(roomToken); - - // 2. 게임 세션 조회 - const session = await fetchGameSession(gameSessionId); - - if (session.status === 'PLAYING') { - // 3. UI 상태 복원 - setGameStatus('PLAYING'); - setCurrentRound(session.currentRound); - setScores(session.scores); - setCurrentDrawer(session.currentDrawerId); - setIsDrawer(session.currentDrawerId === currentUserId); - - // 출제자인 경우 단어 설정 - if (session.currentWord) { - setCurrentWord(session.currentWord.word); - } - - // 4. 타이머 동기화 - startTimer( - session.roundStartTime, - session.roundDuration, - session.serverTime - ); - } else if (session.status === 'FINISHED') { - setGameStatus('FINISHED'); - } -}; -``` - ---- - -## 에러 처리 - -### WebSocket 에러 코드 - -| 코드 | 설명 | 처리 방법 | -|------|--------|--------------| -| 1000 | 정상 종료 | - | -| 1001 | 서버 종료 | 재연결 시도 | -| 1006 | 비정상 종료 | 재연결 시도 | -| 4001 | 인증 실패 | 토큰 재발급 후 재연결 | -| 4003 | 권한 없음 | 에러 표시 | - -### REST API 에러 코드 - -| 코드 | 설명 | -|------------|-----------------------| -| `GAME_001` | 게임 시작 실패 | -| `GAME_002` | 게임 중단 실패 | -| `GAME_003` | 진행 중인 게임 없음 | -| `GAME_004` | 이미 게임 진행 중 | -| `GAME_005` | 권한 없음 (게임 시작자만 중단 가능) | -| `GAME_006` | 게임 세션을 찾을 수 없음 | - -### 에러 처리 예시 - -```typescript -const handleError = (error: ApiError) => { - switch (error.code) { - case 'GAME_001': - showNotification('게임을 시작할 수 없습니다. 최소 2명이 필요합니다.'); - break; - case 'GAME_004': - showNotification('이미 게임이 진행 중입니다.'); - break; - case 'GAME_006': - // 게임 세션 만료 - 목록으로 이동 - navigateToRoomList(); - break; - default: - showNotification('오류가 발생했습니다.'); - } -}; -``` - ---- - -## 전체 상태 관리 예시 (React) - -```typescript -interface GameState { - status: GameStatus; - currentRound: number; - totalRounds: number; - currentDrawerId: string | null; - currentWord: string | null; - scores: Record; - isDrawer: boolean; - remainingTime: number; - drawerOrder: string[]; -} - -const initialGameState: GameState = { - status: 'NONE', - currentRound: 0, - totalRounds: 0, - currentDrawerId: null, - currentWord: null, - scores: {}, - isDrawer: false, - remainingTime: 0, - drawerOrder: [], -}; - -const gameReducer = (state: GameState, action: GameAction): GameState => { - switch (action.type) { - case 'GAME_START': - return { - ...state, - status: 'PLAYING', - currentRound: action.payload.currentRound, - totalRounds: action.payload.totalRounds, - currentDrawerId: action.payload.currentDrawerId, - drawerOrder: action.payload.drawerOrder, - isDrawer: action.payload.currentDrawerId === action.payload.currentUserId, - scores: {}, - }; - - case 'ROUND_END': - return { - ...state, - currentRound: action.payload.nextRound, - currentDrawerId: action.payload.nextDrawer, - currentWord: action.payload.isDrawer ? action.payload.nextWord : null, - isDrawer: action.payload.isDrawer, - }; - - case 'SCORE_UPDATE': - return { - ...state, - scores: action.payload.scores, - }; - - case 'GAME_END': - return { - ...initialGameState, - status: 'FINISHED', - scores: state.scores, - }; - - case 'RESET': - return initialGameState; - - default: - return state; - } -}; -``` - ---- - -## 버전 이력 - -| 버전 | 날짜 | 변경 내용 | -|-------|------------|---------------------| -| 1.0.0 | 2024-01-20 | 초기 문서 작성 | -| 1.1.0 | 2024-01-20 | 게임 자동 종료 (7분) 기능 추가 | diff --git a/ServerlessFunction/gradlew b/ServerlessFunction/gradlew index adff685a..fcb6fca1 100755 --- a/ServerlessFunction/gradlew +++ b/ServerlessFunction/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015 the original authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,8 +15,6 @@ # See the License for the specific language governing permissions and # limitations under the License. # -# SPDX-License-Identifier: Apache-2.0 -# ############################################################################## # @@ -57,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -85,8 +83,7 @@ done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -114,6 +111,7 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -146,7 +144,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 + # shellcheck disable=SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac @@ -154,7 +152,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 + # shellcheck disable=SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -171,6 +169,7 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -202,15 +201,16 @@ fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ "$@" # Stop when "xargs" is not available. diff --git a/ServerlessFunction/gradlew.bat b/ServerlessFunction/gradlew.bat index c4bdd3ab..93e3f59f 100644 --- a/ServerlessFunction/gradlew.bat +++ b/ServerlessFunction/gradlew.bat @@ -13,8 +13,6 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @@ -45,11 +43,11 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. goto fail @@ -59,21 +57,22 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. goto fail :execute @rem Setup the command line +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java index 05ad609a..a1d2286b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java @@ -11,6 +11,7 @@ import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.sns.SnsClient; +import software.amazon.awssdk.services.sqs.SqsClient; import software.amazon.awssdk.services.ssm.SsmClient; /** @@ -60,7 +61,12 @@ public final class AwsClients { private static final SsmClient SSM_CLIENT = SsmClient.builder() .overrideConfiguration(XRAY_CONFIG) .build(); - + + // SQS + private static final SqsClient SQS_CLIENT = SqsClient.builder() + .overrideConfiguration(XRAY_CONFIG) + .build(); + private AwsClients() { // 인스턴스화 방지 } @@ -104,4 +110,8 @@ public static ComprehendClient comprehend() { public static SsmClient ssm() { return SSM_CLIENT; } + + public static SqsClient sqs() { + return SQS_CLIENT; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/EnvConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/EnvConfig.java index c59f4930..75c7e1ec 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/EnvConfig.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/EnvConfig.java @@ -15,7 +15,18 @@ public final class EnvConfig { private EnvConfig() { // 유틸리티 클래스 - 인스턴스화 방지 } - + + /** + * 선택적 환경 변수를 가져옵니다. + * 환경 변수가 설정되지 않은 경우 null을 반환합니다. + * + * @param name 환경 변수 이름 + * @return 환경 변수 값 또는 null + */ + public static String get(String name) { + return System.getenv(name); + } + /** * 필수 환경 변수를 가져옵니다. * 환경 변수가 설정되지 않았거나 빈 문자열인 경우 IllegalStateException을 발생시킵니다. diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JsonUtil.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JsonUtil.java index 94685020..ad550303 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JsonUtil.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JsonUtil.java @@ -1,5 +1,7 @@ package com.mzc.secondproject.serverless.common.util; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; import com.google.gson.JsonArray; import com.google.gson.JsonElement; @@ -10,9 +12,25 @@ * JSON 파싱 관련 공통 유틸리티 */ public class JsonUtil { - + + private static final Gson GSON = new GsonBuilder().create(); + private JsonUtil() { } + + /** + * 객체를 JSON 문자열로 변환 + */ + public static String toJson(Object obj) { + return GSON.toJson(obj); + } + + /** + * JSON 문자열을 객체로 변환 + */ + public static T fromJson(String json, Class clazz) { + return GSON.fromJson(json, clazz); + } // 응답에서 JSON 부분만 추출 public static String extractJson(String response) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java index b34466ed..bc7e102f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java @@ -33,14 +33,13 @@ public enum BadgeType { // 특별 MASTER("학습 마스터", "모든 업적을 달성했습니다", "master.png", "ALL_BADGES", 1); - private static final String BASE_URL = "https://group2-englishstudy.s3.ap-northeast-2.amazonaws.com/badges/"; - + private static final String BUCKET_NAME = System.getenv("BUCKET_NAME"); + private static final String BASE_URL = getBaseUrl(); private final String name; private final String description; private final String imageFile; private final String category; private final int threshold; - BadgeType(String name, String description, String imageFile, String category, int threshold) { this.name = name; this.description = description; @@ -49,6 +48,11 @@ public enum BadgeType { this.threshold = threshold; } + private static String getBaseUrl() { + String bucket = BUCKET_NAME != null ? BUCKET_NAME : "group2-englishstudy"; + return String.format("https://%s.s3.ap-northeast-2.amazonaws.com/badges/", bucket); + } + public static BadgeType fromString(String value) { if (value == null) return null; try { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java index 0916a5d5..b7fbe77d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java @@ -7,6 +7,7 @@ import com.mzc.secondproject.serverless.domain.badge.repository.BadgeRepository; import com.mzc.secondproject.serverless.domain.badge.strategy.BadgeConditionStrategy; import com.mzc.secondproject.serverless.domain.badge.strategy.BadgeConditionStrategyFactory; +import com.mzc.secondproject.serverless.domain.notification.service.NotificationPublisher; import com.mzc.secondproject.serverless.domain.stats.model.UserStats; import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import org.slf4j.Logger; @@ -24,20 +25,23 @@ public class BadgeService { private final BadgeRepository badgeRepository; private final UserStatsRepository userStatsRepository; - + private final NotificationPublisher notificationPublisher; + /** * 기본 생성자 (Lambda에서 사용) */ public BadgeService() { - this(new BadgeRepository(), new UserStatsRepository()); + this(new BadgeRepository(), new UserStatsRepository(), NotificationPublisher.getInstance()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ - public BadgeService(BadgeRepository badgeRepository, UserStatsRepository userStatsRepository) { + public BadgeService(BadgeRepository badgeRepository, UserStatsRepository userStatsRepository, + NotificationPublisher notificationPublisher) { this.badgeRepository = badgeRepository; this.userStatsRepository = userStatsRepository; + this.notificationPublisher = notificationPublisher; } /** @@ -98,6 +102,15 @@ public List checkAndAwardBadges(String userId, UserStats stats) { badgeRepository.save(badge); newBadges.add(badge); logger.info("Badge awarded: userId={}, badge={}", userId, type.name()); + + // 알림 발행 + notificationPublisher.publishBadgeEarned( + userId, + type.name(), + type.getName(), + type.getDescription(), + badge.getImageUrl() + ); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java index b8a7d453..c0fdc428 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java @@ -22,7 +22,25 @@ public enum MessageType { // 방 관련 메시지 타입 ROOM_STATUS_CHANGE("room_status_change", "방 상태 변경"), - HOST_CHANGE("host_change", "방장 변경"); + HOST_CHANGE("host_change", "방장 변경"), + + // 투표 관련 메시지 타입 + POLL_CREATE("poll_create", "투표 생성"), + POLL_VOTE("poll_vote", "투표 참여"), + POLL_END("poll_end", "투표 종료"), + + // 끝말잇기(Word Chain) 게임 메시지 타입 + WORDCHAIN_START("wordchain_start", "끝말잇기 시작"), + WORDCHAIN_TURN("wordchain_turn", "턴 변경"), + WORDCHAIN_CORRECT("wordchain_correct", "정답"), + WORDCHAIN_WRONG("wordchain_wrong", "오답"), + WORDCHAIN_TIMEOUT("wordchain_timeout", "시간 초과"), + WORDCHAIN_ELIMINATED("wordchain_eliminated", "탈락"), + WORDCHAIN_END("wordchain_end", "끝말잇기 종료"), + + // 유틸리티 메시지 타입 + CLEAR_CHAT("clear_chat", "채팅 삭제"), + LEAVE_ROOM("leave_room", "채팅방 나가기"); private final String code; private final String displayName; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java index ad599b53..9e6c8faf 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java @@ -44,6 +44,10 @@ public enum ChattingErrorCode implements DomainErrorCode { GAME_NOT_ALLOWED_IN_CHAT_ROOM("GAME_007", "게임은 게임 방에서만 시작할 수 있습니다", 400), GAME_RESTART_NOT_ALLOWED("GAME_008", "게임 진행 중에는 재시작할 수 없습니다", 400), GAME_START_NOT_HOST("GAME_009", "방장만 게임을 시작할 수 있습니다", 403), + GAME_ACTION_FAILED("GAME_010", "게임 액션 처리에 실패했습니다", 400), + + // 일반 입력 에러 + INVALID_INPUT("INPUT_001", "유효하지 않은 입력입니다", 400), ; private static final String DOMAIN = "CHATTING"; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java index b156feba..07ef6ac9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java @@ -14,6 +14,8 @@ import com.mzc.secondproject.serverless.domain.chatting.model.ChatMessage; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; import com.mzc.secondproject.serverless.domain.chatting.service.ChatMessageService; +import com.mzc.secondproject.serverless.domain.user.model.User; +import com.mzc.secondproject.serverless.domain.user.service.UserService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -29,21 +31,23 @@ public class ChatMessageHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(WordChainHandler.class); + private static final String DOMAIN_WORDCHAIN = "wordchain"; + + private final WordChainService wordChainService; + private final WordChainSessionRepository sessionRepository; + private final ConnectionRepository connectionRepository; + private final WebSocketBroadcaster broadcaster; + private final HandlerRouter router; + + /** + * 기본 생성자 (Lambda에서 사용) + */ + public WordChainHandler() { + this(new WordChainService(), + new WordChainSessionRepository(), + new ConnectionRepository(), + new WebSocketBroadcaster()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public WordChainHandler(WordChainService wordChainService, + WordChainSessionRepository sessionRepository, + ConnectionRepository connectionRepository, + WebSocketBroadcaster broadcaster) { + this.wordChainService = wordChainService; + this.sessionRepository = sessionRepository; + this.connectionRepository = connectionRepository; + this.broadcaster = broadcaster; + this.router = initRouter(); + } + + private HandlerRouter initRouter() { + return new HandlerRouter().addRoutes( + Route.postAuth("/rooms/{roomId}/wordchain/start", this::startGame), + Route.postAuth("/rooms/{roomId}/wordchain/submit", this::submitWord), + Route.postAuth("/rooms/{roomId}/wordchain/timeout", this::handleTimeout), + Route.postAuth("/rooms/{roomId}/wordchain/stop", this::stopGame), + Route.getAuth("/rooms/{roomId}/wordchain/status", this::getGameStatus) + ); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); + return router.route(request); + } + + /** + * POST /rooms/{roomId}/wordchain/start - 게임 시작 + */ + private APIGatewayProxyResponseEvent startGame(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + GameStartResult result = wordChainService.startGame(roomId, userId); + + if (!result.success()) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, result.error()); + } + + // WebSocket으로 게임 시작 알림 브로드캐스트 + broadcastGameStart(roomId, result); + + Map response = buildGameStatusResponse(result.session()); + return ResponseGenerator.ok("Word Chain game started", response); + } + + /** + * POST /rooms/{roomId}/wordchain/submit - 단어 제출 + */ + private APIGatewayProxyResponseEvent submitWord(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + @SuppressWarnings("unchecked") + Map body = ResponseGenerator.gson().fromJson(request.getBody(), Map.class); + String word = body.get("word"); + + if (word == null || word.isBlank()) { + return ResponseGenerator.fail(ChattingErrorCode.INVALID_INPUT, "단어를 입력해주세요."); + } + + WordSubmitResult result = wordChainService.submitWord(roomId, userId, word); + + // 결과에 따라 브로드캐스트 + broadcastWordResult(roomId, result); + + return buildSubmitResponse(result); + } + + /** + * POST /rooms/{roomId}/wordchain/timeout - 타임아웃 처리 + */ + private APIGatewayProxyResponseEvent handleTimeout(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + WordSubmitResult result = wordChainService.handleTimeout(roomId, userId); + + // 타임아웃 결과 브로드캐스트 + broadcastWordResult(roomId, result); + + return buildSubmitResponse(result); + } + + /** + * POST /rooms/{roomId}/wordchain/stop - 게임 중단 + */ + private APIGatewayProxyResponseEvent stopGame(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + WordSubmitResult result = wordChainService.stopGame(roomId, userId); + + if (result.type() == WordSubmitResult.ResultType.ERROR) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_STOP_FAILED, result.error()); + } + + // 게임 종료 브로드캐스트 + broadcastWordResult(roomId, result); + + return ResponseGenerator.ok("Game stopped", Map.of("message", "게임이 종료되었습니다.")); + } + + /** + * GET /rooms/{roomId}/wordchain/status - 게임 상태 조회 + */ + private APIGatewayProxyResponseEvent getGameStatus(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + Optional optSession = sessionRepository.findActiveByRoomId(roomId); + if (optSession.isEmpty()) { + return ResponseGenerator.ok("No active game", Map.of("gameStatus", "NONE")); + } + + Map response = buildGameStatusResponse(optSession.get()); + return ResponseGenerator.ok("Game status retrieved", response); + } + + /** + * 게임 상태 응답 빌드 + */ + private Map buildGameStatusResponse(WordChainSession session) { + Map response = new LinkedHashMap<>(); + response.put("sessionId", session.getSessionId()); + response.put("gameStatus", session.getStatus()); + response.put("currentRound", session.getCurrentRound()); + response.put("currentPlayerId", session.getCurrentPlayerId()); + response.put("currentWord", session.getCurrentWord()); + response.put("nextLetter", session.getNextLetter()); + response.put("timeLimit", session.getTimeLimit()); + response.put("turnStartTime", session.getTurnStartTime()); + response.put("serverTime", System.currentTimeMillis()); + response.put("activePlayers", session.getActivePlayers()); + response.put("eliminatedPlayers", session.getEliminatedPlayers()); + response.put("scores", session.getScores() != null ? session.getScores() : Map.of()); + response.put("usedWords", session.getUsedWords()); + return response; + } + + /** + * 단어 제출 결과 응답 빌드 + */ + private APIGatewayProxyResponseEvent buildSubmitResponse(WordSubmitResult result) { + Map response = new LinkedHashMap<>(); + response.put("resultType", result.type().name()); + + switch (result.type()) { + case CORRECT -> { + response.put("word", result.word()); + response.put("definition", result.definition()); + response.put("phonetic", result.phonetic()); + response.put("score", result.score()); + response.put("nextLetter", result.nextLetter()); + response.put("nextPlayerId", result.nextPlayerId()); + response.put("nextTimeLimit", result.nextTimeLimit()); + return ResponseGenerator.ok("Correct!", response); + } + case WRONG_LETTER, INVALID_WORD -> { + response.put("error", result.error()); + return ResponseGenerator.ok("Wrong answer", response); + } + case TIMEOUT -> { + response.put("eliminatedPlayerId", result.eliminatedPlayerId()); + response.put("eliminatedNickname", result.eliminatedNickname()); + response.put("nextPlayerId", result.nextPlayerId()); + response.put("nextTimeLimit", result.nextTimeLimit()); + return ResponseGenerator.ok("Timeout", response); + } + case GAME_END -> { + response.put("winnerId", result.winnerId()); + response.put("winnerNickname", result.winnerNickname()); + response.put("ranking", result.ranking()); + if (result.session() != null) { + response.put("usedWords", result.session().getUsedWords()); + response.put("wordDefinitions", result.session().getWordDefinitions()); + } + return ResponseGenerator.ok("Game ended", response); + } + case ERROR -> { + return ResponseGenerator.fail(ChattingErrorCode.GAME_ACTION_FAILED, result.error()); + } + default -> { + return ResponseGenerator.fail(ChattingErrorCode.GAME_ACTION_FAILED, "Unknown result type"); + } + } + } + + // ========== WebSocket Broadcast Methods ========== + + /** + * 게임 시작 브로드캐스트 + */ + private void broadcastGameStart(String roomId, GameStartResult result) { + WordChainSession session = result.session(); + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long serverTime = System.currentTimeMillis(); + + String message = String.format(""" + 🎮 끝말잇기 시작! + 시작 단어: %s + 다음 글자: '%c' + + 첫 번째 차례: %s + 제한 시간: %d초 + """, + result.starterWord(), + result.nextLetter(), + result.firstPlayerId(), + session.getTimeLimit()); + + Map payload = new LinkedHashMap<>(); + payload.put("domain", DOMAIN_WORDCHAIN); + payload.put("messageId", messageId); + payload.put("roomId", roomId); + payload.put("userId", "SYSTEM"); + payload.put("content", message); + payload.put("messageType", MessageType.WORDCHAIN_START.getCode()); + payload.put("createdAt", now); + payload.put("timestamp", serverTime); + payload.put("sessionId", session.getSessionId()); + payload.put("starterWord", result.starterWord()); + payload.put("nextLetter", result.nextLetter()); + payload.put("currentPlayerId", result.firstPlayerId()); + payload.put("timeLimit", session.getTimeLimit()); + payload.put("turnStartTime", session.getTurnStartTime()); + payload.put("serverTime", serverTime); + payload.put("players", session.getPlayers()); + payload.put("activePlayers", session.getActivePlayers()); + + broadcastToRoom(roomId, payload); + logger.info("WordChain game start broadcasted: roomId={}, starterWord={}", + roomId, result.starterWord()); + } + + /** + * 단어 제출 결과 브로드캐스트 + */ + private void broadcastWordResult(String roomId, WordSubmitResult result) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long serverTime = System.currentTimeMillis(); + + Map payload = new LinkedHashMap<>(); + payload.put("domain", DOMAIN_WORDCHAIN); + payload.put("messageId", messageId); + payload.put("roomId", roomId); + payload.put("userId", "SYSTEM"); + payload.put("createdAt", now); + payload.put("timestamp", serverTime); + payload.put("serverTime", serverTime); + payload.put("resultType", result.type().name()); + + switch (result.type()) { + case CORRECT -> { + payload.put("messageType", MessageType.WORDCHAIN_CORRECT.getCode()); + payload.put("content", String.format("✅ %s: \"%s\" (+%d점)\n뜻: %s\n다음 글자: '%c'", + result.playerNickname(), + result.word(), + result.score(), + result.definition() != null ? result.definition() : "(정의 없음)", + result.nextLetter())); + payload.put("word", result.word()); + payload.put("definition", result.definition()); + payload.put("phonetic", result.phonetic()); + payload.put("score", result.score()); + payload.put("nextLetter", result.nextLetter()); + payload.put("nextPlayerId", result.nextPlayerId()); + payload.put("nextTimeLimit", result.nextTimeLimit()); + payload.put("playerNickname", result.playerNickname()); + if (result.session() != null) { + payload.put("turnStartTime", result.session().getTurnStartTime()); + payload.put("scores", result.session().getScores()); + } + } + case WRONG_LETTER -> { + payload.put("messageType", MessageType.WORDCHAIN_WRONG.getCode()); + payload.put("content", result.error()); + payload.put("error", result.error()); + } + case INVALID_WORD -> { + payload.put("messageType", MessageType.WORDCHAIN_WRONG.getCode()); + payload.put("content", "❌ " + result.error()); + payload.put("error", result.error()); + } + case TIMEOUT -> { + payload.put("messageType", MessageType.WORDCHAIN_TIMEOUT.getCode()); + payload.put("content", String.format("⏰ %s 시간 초과! 탈락!", + result.eliminatedNickname())); + payload.put("eliminatedPlayerId", result.eliminatedPlayerId()); + payload.put("eliminatedNickname", result.eliminatedNickname()); + payload.put("nextPlayerId", result.nextPlayerId()); + payload.put("nextTimeLimit", result.nextTimeLimit()); + if (result.session() != null) { + payload.put("nextLetter", result.session().getNextLetter()); + payload.put("turnStartTime", result.session().getTurnStartTime()); + payload.put("activePlayers", result.session().getActivePlayers()); + } + } + case GAME_END -> { + payload.put("messageType", MessageType.WORDCHAIN_END.getCode()); + String winnerMsg = result.winnerId() != null + ? String.format("🏆 승자: %s!", result.winnerNickname()) + : "게임 종료!"; + payload.put("content", winnerMsg); + payload.put("winnerId", result.winnerId()); + payload.put("winnerNickname", result.winnerNickname()); + payload.put("ranking", result.ranking()); + if (result.session() != null) { + payload.put("usedWords", result.session().getUsedWords()); + payload.put("wordDefinitions", result.session().getWordDefinitions()); + payload.put("scores", result.session().getScores()); + } + } + case ERROR -> { + // 에러는 브로드캐스트하지 않음 (요청자에게만 응답) + return; + } + } + + broadcastToRoom(roomId, payload); + logger.info("WordChain result broadcasted: roomId={}, type={}", roomId, result.type()); + } + + /** + * 방에 메시지 브로드캐스트 + */ + private void broadcastToRoom(String roomId, Map payload) { + List connections = connectionRepository.findByRoomId(roomId); + String jsonPayload = ResponseGenerator.gson().toJson(payload); + broadcaster.broadcast(connections, jsonPayload); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java index 7b674a45..67950d53 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java @@ -56,7 +56,42 @@ public Map handleRequest(Map event, Context cont RoomToken token = optToken.get(); String userId = token.getUserId(); String roomId = token.getRoomId(); - + String nickname = "Unknown"; + + // Cognito Authorizer에서 닉네임 추출 + try { + if (event.containsKey("requestContext")) { + Map reqCtx = (Map) event.get("requestContext"); + + if (reqCtx.containsKey("authorizer")) { + Map auth = (Map) reqCtx.get("authorizer"); + + Map claims = auth; + if (auth.containsKey("claims")) { + claims = (Map) auth.get("claims"); + } else if (auth.containsKey("principalId")) { + claims = auth; + } + + if (claims != null) { + if (claims.get("nickname") != null) { + nickname = (String) claims.get("nickname"); + } else if (claims.get("custom:nickname") != null) { + nickname = (String) claims.get("custom:nickname"); + } else if (claims.get("name") != null) { + nickname = (String) claims.get("name"); + } + } + } + } + // 닉네임 못찾았으면 UserId 앞부분 표시 + if ("Unknown".equals(nickname) && userId != null && userId.length() > 5) { + nickname = "User-" + userId.substring(0, 5); + } + } catch (Exception ex) { + logger.warn("닉네임 표시 실패: {}", ex.getMessage()); + } + // 같은 방에서 기존 연결 삭제 (새로고침 시 중복 연결 방지) connectionRepository.deleteUserConnectionsInRoom(userId, roomId); @@ -72,6 +107,7 @@ public Map handleRequest(Map event, Context cont .gsi2sk("CONN#" + connectionId) .connectionId(connectionId) .userId(userId) + .nickname(nickname) .roomId(roomId) .connectedAt(now) .ttl(ttl) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java index f8da9d75..c490e093 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java @@ -19,6 +19,8 @@ import com.mzc.secondproject.serverless.domain.chatting.service.ChatMessageService; import com.mzc.secondproject.serverless.domain.chatting.service.CommandService; import com.mzc.secondproject.serverless.domain.chatting.service.GameService; +import com.mzc.secondproject.serverless.domain.user.model.User; +import com.mzc.secondproject.serverless.domain.user.service.UserService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -44,6 +46,7 @@ public class WebSocketMessageHandler implements RequestHandler handleRegularMessage(String connectionId, MessagePay // 일반 메시지 저장 및 브로드캐스트 String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); + + // 닉네임 조회 + String nickname = "Unknown"; + try { + // DB에서 유저 정보(닉네임) 가져오기 + User user = userService.getUserProfile(payload.userId); + if (user != null && user.getNickname() != null) { + nickname = user.getNickname(); + } else { + // 혹시 없으면 UUID 사용 + nickname = payload.userId; + } + } catch (Exception e) { + nickname = payload.userId; + } ChatMessage message = ChatMessage.builder() .pk("ROOM#" + payload.roomId) @@ -166,6 +185,7 @@ private Map handleRegularMessage(String connectionId, MessagePay .messageId(messageId) .roomId(payload.roomId) .userId(payload.userId) + .nickname(nickname) .content(payload.content) .messageType(messageType) .createdAt(now) @@ -182,6 +202,7 @@ private Map handleRegularMessage(String connectionId, MessagePay broadcastMessage.put("messageId", savedMessage.getMessageId()); broadcastMessage.put("roomId", savedMessage.getRoomId()); broadcastMessage.put("userId", savedMessage.getUserId()); + broadcastMessage.put("nickname", savedMessage.getNickname()); broadcastMessage.put("content", savedMessage.getContent()); broadcastMessage.put("messageType", savedMessage.getMessageType()); broadcastMessage.put("createdAt", savedMessage.getCreatedAt()); @@ -362,13 +383,13 @@ private Map handleRoundTimeout(MessagePayload payload) { */ private Map handleCommandResult(CommandResult result, String roomId, String userId) { List connections = connectionRepository.findByRoomId(roomId); - + // GAME_START는 특별 처리 (출제자에게만 제시어 전송 + serverTime 포함) if (result.messageType() == MessageType.GAME_START && result.data() instanceof GameService.GameStartResult gameResult) { broadcastGameStart(connections, result, gameResult, roomId); return WebSocketEventUtil.ok("Command executed"); } - + // ROUND_END는 특별 처리 (다음 출제자에게만 제시어 전송 + serverTime 포함) if (result.messageType() == MessageType.ROUND_END && result.data() instanceof Map) { @SuppressWarnings("unchecked") @@ -376,14 +397,17 @@ private Map handleCommandResult(CommandResult result, String roo broadcastRoundEnd(connections, result, data, roomId); return WebSocketEventUtil.ok("Command executed"); } - - // 일반 시스템 메시지 (게임 관련 명령어 결과) + + // 일반 시스템 메시지 String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + + // 메시지 타입에 따라 domain 결정 + String domain = determineDomain(result.messageType()); + // domain 필드 포함을 위해 Map으로 생성 Map systemMessage = new HashMap<>(); - systemMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); + systemMessage.put("domain", domain); systemMessage.put("messageId", messageId); systemMessage.put("roomId", roomId); systemMessage.put("userId", "SYSTEM"); @@ -391,14 +415,34 @@ private Map handleCommandResult(CommandResult result, String roo systemMessage.put("messageType", result.messageType().getCode()); systemMessage.put("createdAt", now); systemMessage.put("timestamp", System.currentTimeMillis()); - + + // 추가 데이터가 있으면 포함 + if (result.data() != null) { + systemMessage.put("data", result.data()); + } + String broadcastPayload = gson.toJson(systemMessage); List failedConnections = broadcaster.broadcast(connections, broadcastPayload); cleanupFailedConnections(failedConnections); - - logger.info("Command result broadcasted: type={}, roomId={}", result.messageType(), roomId); + + logger.info("Command result broadcasted: type={}, domain={}, roomId={}", result.messageType(), domain, roomId); return WebSocketEventUtil.ok("Command executed"); } + + /** + * 메시지 타입에 따라 domain 결정 + */ + private String determineDomain(MessageType messageType) { + return switch (messageType) { + // 게임 관련 메시지 + case GAME_START, GAME_END, ROUND_START, ROUND_END, DRAWING, DRAWING_CLEAR, + CORRECT_ANSWER, SCORE_UPDATE, HINT -> WebSocketMessageHelper.DOMAIN_GAME; + // 방 상태 관련 메시지 + case ROOM_STATUS_CHANGE, HOST_CHANGE -> WebSocketMessageHelper.DOMAIN_ROOM; + // 채팅 관련 메시지 (기본값) + default -> WebSocketMessageHelper.DOMAIN_CHAT; + }; + } /** * GAME_START 메시지 브로드캐스트 - 출제자에게만 제시어 포함, serverTime 추가 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatMessage.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatMessage.java index 0cbde348..211abaf1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatMessage.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatMessage.java @@ -23,6 +23,7 @@ public class ChatMessage { private String messageId; private String roomId; private String userId; + private String nickname; private String content; private String messageType; // TEXT, IMAGE, VOICE, AI_RESPONSE private String createdAt; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/Connection.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/Connection.java index 7c130390..5aa02e20 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/Connection.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/Connection.java @@ -22,6 +22,7 @@ public class Connection { private String connectionId; private String userId; + private String nickname; private String roomId; private String connectedAt; private Long ttl; // 10분 후 자동 삭제 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java index d97f2fcc..7c2ae723 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java @@ -14,10 +14,13 @@ public class GameSettings { @Builder.Default private Integer maxRounds = 5; - + @Builder.Default private Integer roundTimeLimit = 60; - + + @Builder.Default + private Integer turnTimeLimit = 15; // 끝말잇기용 턴 시간 제한 + @Builder.Default private Boolean autoDeleteOnEnd = false; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/Poll.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/Poll.java new file mode 100644 index 00000000..0c8eced9 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/Poll.java @@ -0,0 +1,80 @@ +package com.mzc.secondproject.serverless.domain.chatting.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +import java.util.List; +import java.util.Map; + +/** + * 채팅방 투표 모델 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class Poll { + + private String pk; // ROOM#{roomId} + private String sk; // POLL#{pollId} + + private String pollId; + private String roomId; + private String question; + private List options; + private Map votes; // optionIndex -> count + private Map userVotes; // userId -> optionIndex + private String createdBy; + private String createdAt; + private Boolean isActive; + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + /** + * 투표 추가 + */ + public boolean addVote(String userId, int optionIndex) { + if (optionIndex < 0 || optionIndex >= options.size()) { + return false; + } + + // 이미 투표했는지 확인 + if (userVotes.containsKey(userId)) { + return false; + } + + userVotes.put(userId, optionIndex); + votes.merge(String.valueOf(optionIndex), 1, Integer::sum); + return true; + } + + /** + * 사용자가 이미 투표했는지 확인 + */ + public boolean hasVoted(String userId) { + return userVotes != null && userVotes.containsKey(userId); + } + + /** + * 총 투표 수 + */ + public int getTotalVotes() { + if (votes == null) return 0; + return votes.values().stream().mapToInt(Integer::intValue).sum(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSession.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSession.java new file mode 100644 index 00000000..62f49483 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSession.java @@ -0,0 +1,224 @@ +package com.mzc.secondproject.serverless.domain.chatting.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +import java.util.*; + +/** + * 끝말잇기 게임 세션 모델 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class WordChainSession { + + private String pk; // WORDCHAIN#{sessionId} + private String sk; // METADATA + private String gsi1pk; // ROOM#{roomId} + private String gsi1sk; // WORDCHAIN#{createdAt} + + private String sessionId; + private String roomId; + private String gameType; // "wordchain" + + // 게임 상태 + private String status; // WAITING, PLAYING, FINISHED + private String startedBy; + private Long startedAt; + private Long endedAt; + + // 턴 정보 + private Integer currentRound; + private String currentPlayerId; + private String currentWord; + private Character nextLetter; // 다음 사람이 시작해야 할 글자 + private Long turnStartTime; + private Integer timeLimit; // 현재 라운드 시간 제한 (초) + private Integer baseTurnTimeLimit; // 사용자 설정 기본 턴 시간 (초) + + // 플레이어 관리 + private List players; // 전체 플레이어 (순서대로) + private List activePlayers; // 탈락하지 않은 플레이어 + private List eliminatedPlayers; // 탈락한 플레이어 + private Map scores; + + // 게임 기록 + private List usedWords; // 사용된 단어 목록 + private Map wordDefinitions; // 단어 -> 뜻 (게임 종료 후 학습용) + + // TTL + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } + + // ========== 비즈니스 메서드 ========== + + /** + * 게임이 활성 상태인지 확인 + */ + public boolean isActive() { + return "PLAYING".equals(status); + } + + /** + * 현재 턴인지 확인 + */ + public boolean isCurrentTurn(String userId) { + return userId != null && userId.equals(currentPlayerId); + } + + /** + * 단어가 이미 사용되었는지 확인 + */ + public boolean isWordUsed(String word) { + return usedWords != null && usedWords.contains(word.toLowerCase()); + } + + /** + * 단어 추가 + */ + public void addUsedWord(String word, String definition) { + if (usedWords == null) { + usedWords = new ArrayList<>(); + } + usedWords.add(word.toLowerCase()); + + if (definition != null) { + if (wordDefinitions == null) { + wordDefinitions = new HashMap<>(); + } + wordDefinitions.put(word.toLowerCase(), definition); + } + } + + /** + * 플레이어 탈락 처리 + */ + public void eliminatePlayer(String userId) { + if (activePlayers != null) { + activePlayers.remove(userId); + } + if (eliminatedPlayers == null) { + eliminatedPlayers = new ArrayList<>(); + } + if (!eliminatedPlayers.contains(userId)) { + eliminatedPlayers.add(userId); + } + } + + /** + * 다음 플레이어 ID 반환 + */ + public String getNextPlayerId() { + if (activePlayers == null || activePlayers.isEmpty()) { + return null; + } + if (activePlayers.size() == 1) { + return activePlayers.get(0); // 마지막 1명 = 승자 + } + if (currentPlayerId == null) { + return activePlayers.get(0); + } + int currentIndex = activePlayers.indexOf(currentPlayerId); + if (currentIndex == -1) { + return activePlayers.get(0); + } + return activePlayers.get((currentIndex + 1) % activePlayers.size()); + } + + /** + * 점수 추가 + */ + public void addScore(String userId, int points) { + if (scores == null) { + scores = new HashMap<>(); + } + scores.merge(userId, points, Integer::sum); + } + + /** + * 게임 종료 조건 확인 (1명만 남음) + */ + public boolean isGameOver() { + return activePlayers == null || activePlayers.size() <= 1; + } + + /** + * 승자 반환 + */ + public String getWinner() { + if (activePlayers != null && activePlayers.size() == 1) { + return activePlayers.get(0); + } + return null; + } + + /** + * 시간 제한 계산 (라운드에 따라 점점 빨라짐) + * 기본값 15초에서 시작하여 2라운드마다 2초씩 감소, 최소 8초 + */ + public static int calculateTimeLimit(int round) { + return calculateTimeLimit(round, 15); + } + + /** + * 시간 제한 계산 (기본 시간 제한 기준) + * 설정된 기본 시간에서 시작하여 2라운드마다 1초씩 감소, 최소 (baseTimeLimit / 2)초 + */ + public static int calculateTimeLimit(int round, int baseTimeLimit) { + int minTimeLimit = Math.max(5, baseTimeLimit / 2); + return Math.max(minTimeLimit, baseTimeLimit - ((round - 1) / 2)); + } + + /** + * 세션의 기본 시간 제한을 기준으로 다음 라운드 시간 계산 + */ + public int getNextRoundTimeLimit(int nextRound) { + int base = baseTurnTimeLimit != null ? baseTurnTimeLimit : 15; + return calculateTimeLimit(nextRound, base); + } + + /** + * 점수 계산 (빠른 응답 + 긴 단어 보너스) + */ + public static int calculateScore(long responseTimeMs, int wordLength, int timeLimit) { + int baseScore = 10; + + // 시간 보너스 (빠를수록 높음) + int remainingSeconds = timeLimit - (int)(responseTimeMs / 1000); + int timeBonus = Math.max(0, remainingSeconds); + + // 단어 길이 보너스 (5글자 이상부터) + int lengthBonus = Math.max(0, (wordLength - 4) * 2); + + return baseScore + timeBonus + lengthBonus; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/PollRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/PollRepository.java new file mode 100644 index 00000000..f49a6908 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/PollRepository.java @@ -0,0 +1,70 @@ +package com.mzc.secondproject.serverless.domain.chatting.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.chatting.model.Poll; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; + +import java.util.Optional; + +/** + * Poll Repository + */ +public class PollRepository { + + private static final Logger logger = LoggerFactory.getLogger(PollRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); + + private final DynamoDbTable table; + + public PollRepository() { + this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(Poll.class)); + } + + public PollRepository(DynamoDbTable table) { + this.table = table; + } + + public void save(Poll poll) { + table.putItem(poll); + logger.debug("Saved poll: {}", poll.getPollId()); + } + + public Optional findById(String roomId, String pollId) { + Key key = Key.builder() + .partitionValue("ROOM#" + roomId) + .sortValue("POLL#" + pollId) + .build(); + Poll poll = table.getItem(key); + return Optional.ofNullable(poll); + } + + /** + * 방의 활성 투표 조회 + */ + public Optional findActiveByRoomId(String roomId) { + return table.query(QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue("ROOM#" + roomId) + .sortValue("POLL#") + .build())) + .items() + .stream() + .filter(poll -> Boolean.TRUE.equals(poll.getIsActive())) + .findFirst(); + } + + public void delete(String roomId, String pollId) { + Key key = Key.builder() + .partitionValue("ROOM#" + roomId) + .sortValue("POLL#" + pollId) + .build(); + table.deleteItem(key); + logger.debug("Deleted poll: {}", pollId); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/WordChainSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/WordChainSessionRepository.java new file mode 100644 index 00000000..b871987f --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/WordChainSessionRepository.java @@ -0,0 +1,98 @@ +package com.mzc.secondproject.serverless.domain.chatting.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.chatting.model.WordChainSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; + +import java.util.Optional; + +/** + * 끝말잇기 게임 세션 Repository + */ +public class WordChainSessionRepository { + + private static final Logger logger = LoggerFactory.getLogger(WordChainSessionRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); + private static final String GSI1_INDEX_NAME = "GSI1"; + + private final DynamoDbTable table; + private final DynamoDbIndex gsi1Index; + + public WordChainSessionRepository() { + this.table = AwsClients.dynamoDbEnhanced() + .table(TABLE_NAME, TableSchema.fromBean(WordChainSession.class)); + this.gsi1Index = table.index(GSI1_INDEX_NAME); + } + + public WordChainSessionRepository(DynamoDbTable table) { + this.table = table; + this.gsi1Index = table.index(GSI1_INDEX_NAME); + } + + /** + * 세션 저장 + */ + public void save(WordChainSession session) { + table.putItem(session); + logger.debug("Saved WordChainSession: {}", session.getSessionId()); + } + + /** + * 세션 ID로 조회 + */ + public Optional findById(String sessionId) { + Key key = Key.builder() + .partitionValue("WORDCHAIN#" + sessionId) + .sortValue("METADATA") + .build(); + WordChainSession session = table.getItem(key); + return Optional.ofNullable(session); + } + + /** + * 방의 활성 세션 조회 (GSI1 인덱스 사용) + */ + public Optional findActiveByRoomId(String roomId) { + return gsi1Index.query(QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue("ROOM#" + roomId) + .sortValue("WORDCHAIN#") + .build())) + .stream() + .flatMap(page -> page.items().stream()) + .filter(WordChainSession::isActive) + .findFirst(); + } + + /** + * 세션 삭제 + */ + public void delete(String sessionId) { + Key key = Key.builder() + .partitionValue("WORDCHAIN#" + sessionId) + .sortValue("METADATA") + .build(); + table.deleteItem(key); + logger.debug("Deleted WordChainSession: {}", sessionId); + } + + /** + * 게임 종료 처리 + */ + public void finishGame(String sessionId, long endedAt, long ttl) { + findById(sessionId).ifPresent(session -> { + session.setStatus("FINISHED"); + session.setEndedAt(endedAt); + session.setTtl(ttl); + save(session); + logger.info("Finished WordChainSession: {}", sessionId); + }); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java index 71e0ddf2..89d6e098 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java @@ -3,44 +3,49 @@ import com.mzc.secondproject.serverless.domain.chatting.dto.response.CommandResult; import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; import com.mzc.secondproject.serverless.domain.chatting.model.Connection; -import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; +import com.mzc.secondproject.serverless.domain.chatting.model.Poll; import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; -import com.mzc.secondproject.serverless.domain.chatting.repository.GameSessionRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.PollRepository; +import com.mzc.secondproject.serverless.domain.user.model.User; +import com.mzc.secondproject.serverless.domain.user.repository.UserRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.List; -import java.util.Optional; +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; /** * 슬래시 명령어 처리 서비스 */ public class CommandService { - + private static final Logger logger = LoggerFactory.getLogger(CommandService.class); - + private final ConnectionRepository connectionRepository; - private final GameSessionRepository gameSessionRepository; - private final GameService gameService; - + private final PollRepository pollRepository; + private final UserRepository userRepository; + private final Random random; + /** * 기본 생성자 (Lambda에서 사용) */ public CommandService() { - this(new ConnectionRepository(), new GameSessionRepository(), new GameService()); + this(new ConnectionRepository(), new PollRepository(), new UserRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ public CommandService(ConnectionRepository connectionRepository, - GameSessionRepository gameSessionRepository, - GameService gameService) { + PollRepository pollRepository, + UserRepository userRepository) { this.connectionRepository = connectionRepository; - this.gameSessionRepository = gameSessionRepository; - this.gameService = gameService; + this.pollRepository = pollRepository; + this.userRepository = userRepository; + this.random = new Random(); } - + /** * 명령어 처리 * @@ -53,119 +58,426 @@ public Optional processCommand(String content, String roomId, Str if (content == null || !content.startsWith("/")) { return Optional.empty(); } - + String[] parts = content.trim().split("\\s+", 2); String command = parts[0].toLowerCase(); - + String args = parts.length > 1 ? parts[1] : ""; + logger.info("Processing command: {} from user: {} in room: {}", command, userId, roomId); - + return switch (command) { - case "/member", "/members" -> Optional.of(handleMemberCommand(roomId)); - case "/start" -> Optional.of(handleStartCommand(roomId, userId)); - case "/stop" -> Optional.of(handleStopCommand(roomId, userId)); - case "/score" -> Optional.of(handleScoreCommand(roomId)); - case "/skip" -> Optional.of(handleSkipCommand(roomId, userId)); - case "/hint" -> Optional.of(handleHintCommand(roomId, userId)); + // 기본 명령어 case "/help" -> Optional.of(handleHelpCommand()); + case "/member", "/members" -> Optional.of(handleMembersCommand(roomId)); + case "/leave" -> Optional.of(handleLeaveCommand(roomId, userId)); + case "/clear" -> Optional.of(handleClearCommand(roomId, userId)); + + // 재미 명령어 + case "/dice" -> Optional.of(handleDiceCommand(roomId, userId)); + case "/coin" -> Optional.of(handleCoinCommand(roomId, userId)); + case "/random" -> Optional.of(handleRandomCommand(roomId, userId, args)); + + // 투표 명령어 + case "/poll" -> Optional.of(handlePollCommand(roomId, userId, args)); + case "/vote" -> Optional.of(handleVoteCommand(roomId, userId, args)); + case "/endpoll" -> Optional.of(handleEndPollCommand(roomId, userId)); + default -> Optional.empty(); }; } - + + // ========== 기본 명령어 ========== + + /** + * /help - 도움말 + */ + private CommandResult handleHelpCommand() { + String helpMessage = """ + 📖 사용 가능한 명령어: + + [기본] + /members - 현재 접속자 목록 + /leave - 채팅방 나가기 + /clear - 내 채팅 내역 삭제 + + [재미] + /dice - 주사위 굴리기 (1-6) + /coin - 동전 던지기 + /random [옵션1] [옵션2] ... - 랜덤 선택 + + [투표] + /poll [질문] | [옵션1] | [옵션2] | ... - 투표 생성 + /vote [번호] - 투표하기 + /endpoll - 투표 종료 (생성자만) + """; + return CommandResult.success(MessageType.SYSTEM_COMMAND, helpMessage); + } + /** - * /member - 현재 접속자 수 조회 + * /members - 접속자 목록 */ - private CommandResult handleMemberCommand(String roomId) { + private CommandResult handleMembersCommand(String roomId) { List connections = connectionRepository.findByRoomId(roomId); - + if (connections.isEmpty()) { return CommandResult.success(MessageType.SYSTEM_COMMAND, "현재 접속자가 없습니다."); } - - String message = String.format("현재 접속자: %d명", connections.size()); - return CommandResult.success(MessageType.SYSTEM_COMMAND, message, connections.size()); + + // 닉네임 조회 + StringBuilder sb = new StringBuilder(); + sb.append(String.format("👥 현재 접속자: %d명\n", connections.size())); + + for (Connection conn : connections) { + String nickname = userRepository.findByCognitoSub(conn.getUserId()) + .map(User::getNickname) + .orElse(conn.getUserId()); + sb.append(String.format(" • %s\n", nickname)); + } + + Map data = new HashMap<>(); + data.put("count", connections.size()); + data.put("members", connections.stream() + .map(c -> { + Map member = new HashMap<>(); + member.put("userId", c.getUserId()); + member.put("nickname", userRepository.findByCognitoSub(c.getUserId()) + .map(User::getNickname).orElse(c.getUserId())); + return member; + }) + .collect(Collectors.toList())); + + return CommandResult.success(MessageType.SYSTEM_COMMAND, sb.toString(), data); } - + /** - * /start - 게임 시작 + * /leave - 채팅방 나가기 */ - private CommandResult handleStartCommand(String roomId, String userId) { - GameService.GameStartResult result = gameService.startGame(roomId, userId); - - if (!result.success()) { - return CommandResult.error(result.error()); - } - - String message = String.format(""" - 🎮 게임 시작! - 총 %d 라운드 - - 라운드 1 시작! - 출제자: %s - """, - result.session().getTotalRounds(), - result.session().getCurrentDrawerId()); - - return CommandResult.success(MessageType.GAME_START, message, result); + private CommandResult handleLeaveCommand(String roomId, String userId) { + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + Map data = new HashMap<>(); + data.put("userId", userId); + data.put("nickname", nickname); + data.put("action", "leave"); + + return CommandResult.success(MessageType.LEAVE_ROOM, + String.format("👋 %s님이 퇴장합니다.", nickname), data); } - + /** - * /stop - 게임 중단 + * /clear - 내 채팅 내역 삭제 */ - private CommandResult handleStopCommand(String roomId, String userId) { - return gameService.stopGame(roomId, userId); + private CommandResult handleClearCommand(String roomId, String userId) { + Map data = new HashMap<>(); + data.put("userId", userId); + data.put("action", "clear"); + + return CommandResult.success(MessageType.CLEAR_CHAT, + "🗑️ 채팅 내역 삭제를 요청했습니다.", data); } - + + // ========== 재미 명령어 ========== + /** - * /score - 현재 점수 조회 + * /dice - 주사위 굴리기 */ - private CommandResult handleScoreCommand(String roomId) { - Optional optSession = gameSessionRepository.findActiveByRoomId(roomId); - if (optSession.isEmpty()) { - return CommandResult.error("진행 중인 게임이 없습니다."); - } - - GameSession session = optSession.get(); - - if (session.getScores() == null || session.getScores().isEmpty()) { - return CommandResult.success(MessageType.SCORE_UPDATE, "아직 점수가 없습니다."); - } - - StringBuilder sb = new StringBuilder("📊 현재 점수:\n"); - session.getScores().entrySet().stream() - .sorted((a, b) -> b.getValue().compareTo(a.getValue())) - .forEach(entry -> sb.append(String.format(" %s: %d점\n", entry.getKey(), entry.getValue()))); - - return CommandResult.success(MessageType.SCORE_UPDATE, sb.toString(), session.getScores()); + private CommandResult handleDiceCommand(String roomId, String userId) { + int result = random.nextInt(6) + 1; + + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + String emoji = switch (result) { + case 1 -> "⚀"; + case 2 -> "⚁"; + case 3 -> "⚂"; + case 4 -> "⚃"; + case 5 -> "⚄"; + case 6 -> "⚅"; + default -> "🎲"; + }; + + Map data = new HashMap<>(); + data.put("userId", userId); + data.put("nickname", nickname); + data.put("result", result); + data.put("type", "dice"); + + return CommandResult.success(MessageType.SYSTEM_COMMAND, + String.format("🎲 %s님이 주사위를 굴렸습니다: %s %d", nickname, emoji, result), data); } - + /** - * /skip - 라운드 스킵 (출제자만) + * /coin - 동전 던지기 */ - private CommandResult handleSkipCommand(String roomId, String userId) { - return gameService.skipRound(roomId, userId); + private CommandResult handleCoinCommand(String roomId, String userId) { + boolean isHeads = random.nextBoolean(); + String result = isHeads ? "앞면 (Heads)" : "뒷면 (Tails)"; + String emoji = isHeads ? "🪙" : "💿"; + + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + Map data = new HashMap<>(); + data.put("userId", userId); + data.put("nickname", nickname); + data.put("result", isHeads ? "heads" : "tails"); + data.put("type", "coin"); + + return CommandResult.success(MessageType.SYSTEM_COMMAND, + String.format("%s %s님이 동전을 던졌습니다: %s", emoji, nickname, result), data); } - + /** - * /hint - 힌트 제공 (출제자만) + * /random [옵션1] [옵션2] ... - 랜덤 선택 */ - private CommandResult handleHintCommand(String roomId, String userId) { - return gameService.provideHint(roomId, userId); + private CommandResult handleRandomCommand(String roomId, String userId, String args) { + if (args.isBlank()) { + return CommandResult.error("사용법: /random [옵션1] [옵션2] [옵션3] ..."); + } + + String[] options = args.split("\\s+"); + if (options.length < 2) { + return CommandResult.error("최소 2개 이상의 옵션이 필요합니다."); + } + + String selected = options[random.nextInt(options.length)]; + + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + Map data = new HashMap<>(); + data.put("userId", userId); + data.put("nickname", nickname); + data.put("options", Arrays.asList(options)); + data.put("selected", selected); + data.put("type", "random"); + + return CommandResult.success(MessageType.SYSTEM_COMMAND, + String.format("🎯 %s님의 랜덤 선택: %s\n(후보: %s)", + nickname, selected, String.join(", ", options)), data); } - + + // ========== 투표 명령어 ========== + /** - * /help - 도움말 + * /poll [질문] | [옵션1] | [옵션2] | ... - 투표 생성 */ - private CommandResult handleHelpCommand() { - String helpMessage = """ - 📖 사용 가능한 명령어: - /member - 현재 접속자 수 - /start - 게임 시작 (2명 이상) - /stop - 게임 중단 - /score - 현재 점수 보기 - /skip - 라운드 스킵 (출제자) - /hint - 힌트 보기 (출제자) - /help - 도움말 - """; - return CommandResult.success(MessageType.SYSTEM_COMMAND, helpMessage); + private CommandResult handlePollCommand(String roomId, String userId, String args) { + // 이미 진행 중인 투표가 있는지 확인 + Optional activePoll = pollRepository.findActiveByRoomId(roomId); + if (activePoll.isPresent()) { + return CommandResult.error("이미 진행 중인 투표가 있습니다. /endpoll로 종료 후 새 투표를 만드세요."); + } + + if (args.isBlank()) { + return CommandResult.error("사용법: /poll [질문] | [옵션1] | [옵션2] | ..."); + } + + String[] parts = args.split("\\|"); + if (parts.length < 3) { + return CommandResult.error("질문과 최소 2개의 옵션이 필요합니다. (구분자: |)"); + } + + String question = parts[0].trim(); + List options = new ArrayList<>(); + for (int i = 1; i < parts.length; i++) { + String option = parts[i].trim(); + if (!option.isEmpty()) { + options.add(option); + } + } + + if (options.size() < 2) { + return CommandResult.error("최소 2개의 옵션이 필요합니다."); + } + + if (options.size() > 10) { + return CommandResult.error("옵션은 최대 10개까지 가능합니다."); + } + + // 투표 생성 + String pollId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long ttl = Instant.now().plusSeconds(24 * 60 * 60).getEpochSecond(); // 24시간 + + Map votes = new HashMap<>(); + for (int i = 0; i < options.size(); i++) { + votes.put(String.valueOf(i), 0); + } + + Poll poll = Poll.builder() + .pk("ROOM#" + roomId) + .sk("POLL#" + pollId) + .pollId(pollId) + .roomId(roomId) + .question(question) + .options(options) + .votes(votes) + .userVotes(new HashMap<>()) + .createdBy(userId) + .createdAt(now) + .isActive(true) + .ttl(ttl) + .build(); + + pollRepository.save(poll); + + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + StringBuilder sb = new StringBuilder(); + sb.append(String.format("📊 %s님이 투표를 시작했습니다!\n\n", nickname)); + sb.append(String.format("❓ %s\n\n", question)); + for (int i = 0; i < options.size(); i++) { + sb.append(String.format(" %d. %s\n", i + 1, options.get(i))); + } + sb.append("\n💬 /vote [번호]로 투표하세요!"); + + Map data = new HashMap<>(); + data.put("pollId", pollId); + data.put("question", question); + data.put("options", options); + data.put("createdBy", userId); + data.put("creatorNickname", nickname); + + logger.info("Poll created: pollId={}, roomId={}, question={}", pollId, roomId, question); + + return CommandResult.success(MessageType.POLL_CREATE, sb.toString(), data); + } + + /** + * /vote [번호] - 투표하기 + */ + private CommandResult handleVoteCommand(String roomId, String userId, String args) { + Optional optPoll = pollRepository.findActiveByRoomId(roomId); + if (optPoll.isEmpty()) { + return CommandResult.error("진행 중인 투표가 없습니다."); + } + + Poll poll = optPoll.get(); + + if (poll.hasVoted(userId)) { + return CommandResult.error("이미 투표하셨습니다."); + } + + int optionIndex; + try { + optionIndex = Integer.parseInt(args.trim()) - 1; // 1-based to 0-based + } catch (NumberFormatException e) { + return CommandResult.error("사용법: /vote [번호] (예: /vote 1)"); + } + + if (optionIndex < 0 || optionIndex >= poll.getOptions().size()) { + return CommandResult.error(String.format("1~%d 사이의 번호를 입력하세요.", poll.getOptions().size())); + } + + // 투표 추가 + poll.addVote(userId, optionIndex); + pollRepository.save(poll); + + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + String selectedOption = poll.getOptions().get(optionIndex); + + // 현재 투표 현황 생성 + StringBuilder sb = new StringBuilder(); + sb.append(String.format("✅ %s님이 '%s'에 투표했습니다!\n\n", nickname, selectedOption)); + sb.append(String.format("📊 현재 현황 (총 %d표):\n", poll.getTotalVotes())); + for (int i = 0; i < poll.getOptions().size(); i++) { + int voteCount = poll.getVotes().getOrDefault(String.valueOf(i), 0); + String bar = "█".repeat(Math.min(voteCount, 10)); + sb.append(String.format(" %d. %s: %s %d표\n", + i + 1, poll.getOptions().get(i), bar, voteCount)); + } + + Map data = new HashMap<>(); + data.put("pollId", poll.getPollId()); + data.put("voterId", userId); + data.put("voterNickname", nickname); + data.put("selectedOption", optionIndex); + data.put("selectedOptionText", selectedOption); + data.put("votes", poll.getVotes()); + data.put("totalVotes", poll.getTotalVotes()); + + logger.info("Vote recorded: pollId={}, userId={}, option={}", poll.getPollId(), userId, optionIndex); + + return CommandResult.success(MessageType.POLL_VOTE, sb.toString(), data); + } + + /** + * /endpoll - 투표 종료 + */ + private CommandResult handleEndPollCommand(String roomId, String userId) { + Optional optPoll = pollRepository.findActiveByRoomId(roomId); + if (optPoll.isEmpty()) { + return CommandResult.error("진행 중인 투표가 없습니다."); + } + + Poll poll = optPoll.get(); + + if (!poll.getCreatedBy().equals(userId)) { + return CommandResult.error("투표 생성자만 종료할 수 있습니다."); + } + + poll.setIsActive(false); + pollRepository.save(poll); + + // 최종 결과 계산 + int maxVotes = 0; + List winners = new ArrayList<>(); + for (int i = 0; i < poll.getOptions().size(); i++) { + int voteCount = poll.getVotes().getOrDefault(String.valueOf(i), 0); + if (voteCount > maxVotes) { + maxVotes = voteCount; + winners.clear(); + winners.add(poll.getOptions().get(i)); + } else if (voteCount == maxVotes && voteCount > 0) { + winners.add(poll.getOptions().get(i)); + } + } + + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + StringBuilder sb = new StringBuilder(); + sb.append(String.format("🏁 %s님이 투표를 종료했습니다!\n\n", nickname)); + sb.append(String.format("❓ %s\n\n", poll.getQuestion())); + sb.append(String.format("📊 최종 결과 (총 %d표):\n", poll.getTotalVotes())); + + for (int i = 0; i < poll.getOptions().size(); i++) { + int voteCount = poll.getVotes().getOrDefault(String.valueOf(i), 0); + String bar = "█".repeat(Math.min(voteCount, 10)); + String medal = (voteCount == maxVotes && maxVotes > 0) ? "🏆 " : " "; + sb.append(String.format("%s%d. %s: %s %d표\n", + medal, i + 1, poll.getOptions().get(i), bar, voteCount)); + } + + if (!winners.isEmpty()) { + sb.append(String.format("\n🎉 우승: %s", String.join(", ", winners))); + } else { + sb.append("\n투표가 없습니다."); + } + + Map data = new HashMap<>(); + data.put("pollId", poll.getPollId()); + data.put("question", poll.getQuestion()); + data.put("options", poll.getOptions()); + data.put("votes", poll.getVotes()); + data.put("totalVotes", poll.getTotalVotes()); + data.put("winners", winners); + + logger.info("Poll ended: pollId={}, totalVotes={}", poll.getPollId(), poll.getTotalVotes()); + + return CommandResult.success(MessageType.POLL_END, sb.toString(), data); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/DictionaryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/DictionaryService.java new file mode 100644 index 00000000..84191f08 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/DictionaryService.java @@ -0,0 +1,220 @@ +package com.mzc.secondproject.serverless.domain.chatting.service; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 외부 사전 API 연동 서비스 + * Free Dictionary API (https://dictionaryapi.dev/) 사용 + */ +public class DictionaryService { + + private static final Logger logger = LoggerFactory.getLogger(DictionaryService.class); + private static final String API_BASE_URL = "https://api.dictionaryapi.dev/api/v2/entries/en/"; + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private final HttpClient httpClient; + private final Gson gson; + + // 간단한 인메모리 캐시 (Lambda 인스턴스 내에서만 유효) + private final ConcurrentHashMap cache; + + public DictionaryService() { + this.httpClient = HttpClient.newBuilder() + .connectTimeout(TIMEOUT) + .build(); + this.gson = new Gson(); + this.cache = new ConcurrentHashMap<>(); + } + + /** + * 단어 검증 및 정의 조회 + * + * @param word 검증할 단어 + * @return 검증 결과 (유효 여부 + 정의) + */ + public DictionaryResult lookupWord(String word) { + if (word == null || word.isBlank()) { + return DictionaryResult.invalid("단어가 비어있습니다."); + } + + String normalizedWord = word.trim().toLowerCase(); + + // 캐시 확인 + if (cache.containsKey(normalizedWord)) { + logger.debug("Cache hit for word: {}", normalizedWord); + return cache.get(normalizedWord); + } + + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(API_BASE_URL + normalizedWord)) + .timeout(TIMEOUT) + .GET() + .build(); + + HttpResponse response = httpClient.send(request, + HttpResponse.BodyHandlers.ofString()); + + DictionaryResult result = parseResponse(normalizedWord, response); + + // 캐시 저장 + cache.put(normalizedWord, result); + + return result; + + } catch (Exception e) { + logger.error("Dictionary API error for word '{}': {}", normalizedWord, e.getMessage()); + // API 실패 시 일단 유효한 것으로 처리 (fallback) + return DictionaryResult.validWithoutDefinition(normalizedWord); + } + } + + /** + * API 응답 파싱 + */ + private DictionaryResult parseResponse(String word, HttpResponse response) { + if (response.statusCode() == 404) { + return DictionaryResult.invalid("사전에 없는 단어입니다: " + word); + } + + if (response.statusCode() != 200) { + logger.warn("Unexpected API response: {} for word '{}'", response.statusCode(), word); + return DictionaryResult.validWithoutDefinition(word); + } + + try { + JsonArray jsonArray = gson.fromJson(response.body(), JsonArray.class); + if (jsonArray == null || jsonArray.isEmpty()) { + return DictionaryResult.invalid("사전에 없는 단어입니다: " + word); + } + + JsonObject firstEntry = jsonArray.get(0).getAsJsonObject(); + + // 발음 추출 (있으면) + String phonetic = extractPhonetic(firstEntry); + + // 첫 번째 정의 추출 + String definition = extractFirstDefinition(firstEntry); + + return DictionaryResult.valid(word, definition, phonetic); + + } catch (Exception e) { + logger.error("Failed to parse dictionary response for '{}': {}", word, e.getMessage()); + return DictionaryResult.validWithoutDefinition(word); + } + } + + /** + * 발음 기호 추출 + */ + private String extractPhonetic(JsonObject entry) { + try { + if (entry.has("phonetic")) { + return entry.get("phonetic").getAsString(); + } + if (entry.has("phonetics")) { + JsonArray phonetics = entry.getAsJsonArray("phonetics"); + for (JsonElement p : phonetics) { + JsonObject phoneticObj = p.getAsJsonObject(); + if (phoneticObj.has("text") && !phoneticObj.get("text").isJsonNull()) { + String text = phoneticObj.get("text").getAsString(); + if (!text.isBlank()) { + return text; + } + } + } + } + } catch (Exception e) { + logger.debug("Failed to extract phonetic: {}", e.getMessage()); + } + return null; + } + + /** + * 첫 번째 정의 추출 + */ + private String extractFirstDefinition(JsonObject entry) { + try { + if (!entry.has("meanings")) { + return null; + } + JsonArray meanings = entry.getAsJsonArray("meanings"); + if (meanings.isEmpty()) { + return null; + } + + JsonObject firstMeaning = meanings.get(0).getAsJsonObject(); + String partOfSpeech = firstMeaning.has("partOfSpeech") + ? firstMeaning.get("partOfSpeech").getAsString() + : ""; + + JsonArray definitions = firstMeaning.getAsJsonArray("definitions"); + if (definitions == null || definitions.isEmpty()) { + return null; + } + + String definition = definitions.get(0).getAsJsonObject() + .get("definition").getAsString(); + + return String.format("(%s) %s", partOfSpeech, definition); + + } catch (Exception e) { + logger.debug("Failed to extract definition: {}", e.getMessage()); + return null; + } + } + + /** + * 단어가 유효한지만 빠르게 확인 (정의 필요 없을 때) + */ + public boolean isValidWord(String word) { + return lookupWord(word).isValid(); + } + + // ========== Result DTO ========== + + public record DictionaryResult( + boolean valid, + String word, + String definition, + String phonetic, + String errorMessage + ) { + public static DictionaryResult valid(String word, String definition, String phonetic) { + return new DictionaryResult(true, word, definition, phonetic, null); + } + + public static DictionaryResult validWithoutDefinition(String word) { + return new DictionaryResult(true, word, null, null, null); + } + + public static DictionaryResult invalid(String errorMessage) { + return new DictionaryResult(false, null, null, null, errorMessage); + } + + public boolean isValid() { + return valid; + } + + public Optional getDefinition() { + return Optional.ofNullable(definition); + } + + public Optional getPhonetic() { + return Optional.ofNullable(phonetic); + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java index a82c16d2..932335c3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java @@ -13,6 +13,7 @@ import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; import com.mzc.secondproject.serverless.domain.chatting.repository.GameRoundRepository; import com.mzc.secondproject.serverless.domain.chatting.repository.GameSessionRepository; +import com.mzc.secondproject.serverless.domain.notification.service.NotificationPublisher; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; import org.slf4j.Logger; @@ -37,23 +38,25 @@ public class GameService { private final WordRepository wordRepository; private final GameStatsService gameStatsService; private final GameSchedulerClient gameSchedulerClient; - + private final NotificationPublisher notificationPublisher; + /** * 기본 생성자 (Lambda에서 사용) */ public GameService() { this(new ChatRoomRepository(), new ConnectionRepository(), new GameRoundRepository(), new GameSessionRepository(), - new WordRepository(), new GameStatsService(), new GameSchedulerClient()); + new WordRepository(), new GameStatsService(), new GameSchedulerClient(), + NotificationPublisher.getInstance()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ public GameService(ChatRoomRepository chatRoomRepository, ConnectionRepository connectionRepository, GameRoundRepository gameRoundRepository, GameSessionRepository gameSessionRepository, WordRepository wordRepository, GameStatsService gameStatsService, - GameSchedulerClient gameSchedulerClient) { + GameSchedulerClient gameSchedulerClient, NotificationPublisher notificationPublisher) { this.chatRoomRepository = chatRoomRepository; this.connectionRepository = connectionRepository; this.gameRoundRepository = gameRoundRepository; @@ -61,6 +64,7 @@ public GameService(ChatRoomRepository chatRoomRepository, ConnectionRepository c this.wordRepository = wordRepository; this.gameStatsService = gameStatsService; this.gameSchedulerClient = gameSchedulerClient; + this.notificationPublisher = notificationPublisher; } /** @@ -508,7 +512,10 @@ private CommandResult finishGame(GameSession session, ChatRoom room, String reas } catch (Exception e) { logger.error("Failed to update game stats: roomId={}, error={}", room.getRoomId(), e.getMessage()); } - + + // 게임 종료 알림 발행 (각 플레이어별) + publishGameEndNotifications(session, room.getRoomId()); + // 최종 점수 정렬 StringBuilder sb = new StringBuilder("🎮 게임 종료!\n\n📊 최종 순위:\n"); if (session.getScores() != null && !session.getScores().isEmpty()) { @@ -698,11 +705,11 @@ private List> buildRankingList(Map scores) if (scores == null || scores.isEmpty()) { return List.of(); } - + List> sorted = scores.entrySet().stream() .sorted(Map.Entry.comparingByValue().reversed()) .toList(); - + List> ranking = new ArrayList<>(); for (int i = 0; i < sorted.size(); i++) { Map entry = new HashMap<>(); @@ -713,7 +720,39 @@ private List> buildRankingList(Map scores) } return ranking; } - + + /** + * 게임 종료 알림 발행 + */ + private void publishGameEndNotifications(GameSession session, String roomId) { + if (session.getScores() == null || session.getScores().isEmpty()) { + return; + } + + List> sorted = session.getScores().entrySet().stream() + .sorted((a, b) -> b.getValue().compareTo(a.getValue())) + .toList(); + + int totalPlayers = sorted.size(); + + for (int i = 0; i < sorted.size(); i++) { + int rank = i + 1; + String userId = sorted.get(i).getKey(); + int score = sorted.get(i).getValue(); + boolean isWinner = rank == 1; + + notificationPublisher.publishGameEnd( + userId, + roomId, + session.getGameSessionId(), + rank, + totalPlayers, + score, + isWinner + ); + } + } + // ========== Result DTOs ========== public record GameStartResult( diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/WordChainService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/WordChainService.java new file mode 100644 index 00000000..eb41e151 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/WordChainService.java @@ -0,0 +1,450 @@ +package com.mzc.secondproject.serverless.domain.chatting.service; + +import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; +import com.mzc.secondproject.serverless.domain.chatting.model.Connection; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSettings; +import com.mzc.secondproject.serverless.domain.chatting.model.WordChainSession; +import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.WordChainSessionRepository; +import com.mzc.secondproject.serverless.domain.user.model.User; +import com.mzc.secondproject.serverless.domain.user.repository.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 끝말잇기 게임 서비스 + */ +public class WordChainService { + + private static final Logger logger = LoggerFactory.getLogger(WordChainService.class); + + // 게임 시작 단어 후보 (쉬운 3-5글자 단어) + private static final List STARTER_WORDS = List.of( + "apple", "house", "water", "happy", "green", "music", "paper", + "table", "chair", "phone", "smile", "dream", "light", "earth", + "ocean", "river", "cloud", "sugar", "lemon", "tiger", "eagle" + ); + + private final WordChainSessionRepository sessionRepository; + private final ConnectionRepository connectionRepository; + private final ChatRoomRepository chatRoomRepository; + private final UserRepository userRepository; + private final DictionaryService dictionaryService; + private final Random random; + + public WordChainService() { + this(new WordChainSessionRepository(), + new ConnectionRepository(), + new ChatRoomRepository(), + new UserRepository(), + new DictionaryService()); + } + + public WordChainService(WordChainSessionRepository sessionRepository, + ConnectionRepository connectionRepository, + ChatRoomRepository chatRoomRepository, + UserRepository userRepository, + DictionaryService dictionaryService) { + this.sessionRepository = sessionRepository; + this.connectionRepository = connectionRepository; + this.chatRoomRepository = chatRoomRepository; + this.userRepository = userRepository; + this.dictionaryService = dictionaryService; + this.random = new Random(); + } + + /** + * 게임 시작 + */ + public GameStartResult startGame(String roomId, String userId) { + // 이미 진행 중인 게임 확인 + Optional existingSession = sessionRepository.findActiveByRoomId(roomId); + if (existingSession.isPresent()) { + return GameStartResult.error("이미 진행 중인 게임이 있습니다."); + } + + // 접속자 확인 + List connections = connectionRepository.findByRoomId(roomId); + if (connections.size() < 2) { + return GameStartResult.error("최소 2명 이상 필요합니다."); + } + + // 방 정보에서 gameSettings 조회 + Optional optRoom = chatRoomRepository.findById(roomId); + int baseTurnTimeLimit = 15; // 기본값 + if (optRoom.isPresent()) { + ChatRoom room = optRoom.get(); + GameSettings settings = room.getGameSettings(); + if (settings != null && settings.getTurnTimeLimit() != null) { + baseTurnTimeLimit = settings.getTurnTimeLimit(); + } + } + + // 플레이어 순서 랜덤 셔플 + List players = connections.stream() + .map(Connection::getUserId) + .collect(Collectors.toList()); + Collections.shuffle(players); + + // 시작 단어 선택 + String starterWord = STARTER_WORDS.get(random.nextInt(STARTER_WORDS.size())); + char nextLetter = starterWord.charAt(starterWord.length() - 1); + + // 세션 생성 + String sessionId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long currentTime = System.currentTimeMillis(); + int timeLimit = baseTurnTimeLimit; // 사용자 설정 턴 시간 사용 + + WordChainSession session = WordChainSession.builder() + .pk("WORDCHAIN#" + sessionId) + .sk("METADATA") + .gsi1pk("ROOM#" + roomId) + .gsi1sk("WORDCHAIN#" + now) + .sessionId(sessionId) + .roomId(roomId) + .gameType("wordchain") + .status("PLAYING") + .startedBy(userId) + .startedAt(currentTime) + .currentRound(1) + .currentPlayerId(players.get(0)) + .currentWord(starterWord) + .nextLetter(nextLetter) + .turnStartTime(currentTime) + .timeLimit(timeLimit) + .baseTurnTimeLimit(baseTurnTimeLimit) + .players(players) + .activePlayers(new ArrayList<>(players)) + .eliminatedPlayers(new ArrayList<>()) + .scores(new HashMap<>()) + .usedWords(new ArrayList<>(List.of(starterWord.toLowerCase()))) + .wordDefinitions(new HashMap<>()) + .build(); + + // 시작 단어 정의 조회 + DictionaryService.DictionaryResult starterResult = dictionaryService.lookupWord(starterWord); + if (starterResult.getDefinition().isPresent()) { + session.getWordDefinitions().put(starterWord.toLowerCase(), starterResult.getDefinition().get()); + } + + sessionRepository.save(session); + + logger.info("WordChain game started: sessionId={}, roomId={}, players={}", + sessionId, roomId, players.size()); + + return GameStartResult.success(session, starterWord, nextLetter, players.get(0)); + } + + /** + * 단어 제출 + */ + public WordSubmitResult submitWord(String roomId, String userId, String word) { + Optional optSession = sessionRepository.findActiveByRoomId(roomId); + if (optSession.isEmpty()) { + return WordSubmitResult.error("진행 중인 게임이 없습니다."); + } + + WordChainSession session = optSession.get(); + + // 본인 턴인지 확인 + if (!session.isCurrentTurn(userId)) { + return WordSubmitResult.error("당신의 차례가 아닙니다."); + } + + // 시간 초과 확인 + long elapsed = System.currentTimeMillis() - session.getTurnStartTime(); + if (elapsed > session.getTimeLimit() * 1000L) { + return handleTimeout(session, userId); + } + + String normalizedWord = word.trim().toLowerCase(); + + // 첫 글자 확인 + if (normalizedWord.charAt(0) != session.getNextLetter()) { + return WordSubmitResult.wrongLetter(session.getNextLetter()); + } + + // 중복 단어 확인 + if (session.isWordUsed(normalizedWord)) { + return WordSubmitResult.error("이미 사용된 단어입니다: " + normalizedWord); + } + + // 사전 API로 유효성 검증 + DictionaryService.DictionaryResult dictResult = dictionaryService.lookupWord(normalizedWord); + if (!dictResult.isValid()) { + return WordSubmitResult.invalidWord(dictResult.errorMessage()); + } + + // 정답 처리 + int score = WordChainSession.calculateScore(elapsed, normalizedWord.length(), session.getTimeLimit()); + session.addScore(userId, score); + session.addUsedWord(normalizedWord, dictResult.getDefinition().orElse(null)); + + // 다음 턴 준비 + char nextLetter = normalizedWord.charAt(normalizedWord.length() - 1); + String nextPlayerId = session.getNextPlayerId(); + int nextRound = session.getCurrentRound() + 1; + int nextTimeLimit = session.getNextRoundTimeLimit(nextRound); + + session.setCurrentRound(nextRound); + session.setCurrentWord(normalizedWord); + session.setNextLetter(nextLetter); + session.setCurrentPlayerId(nextPlayerId); + session.setTurnStartTime(System.currentTimeMillis()); + session.setTimeLimit(nextTimeLimit); + + sessionRepository.save(session); + + String nickname = getNickname(userId); + + logger.info("Word accepted: sessionId={}, word={}, player={}, score={}", + session.getSessionId(), normalizedWord, userId, score); + + return WordSubmitResult.correct( + session, + normalizedWord, + dictResult.getDefinition().orElse(null), + dictResult.getPhonetic().orElse(null), + score, + nextLetter, + nextPlayerId, + nextTimeLimit, + nickname + ); + } + + /** + * 타임아웃 처리 + */ + public WordSubmitResult handleTimeout(String roomId, String userId) { + Optional optSession = sessionRepository.findActiveByRoomId(roomId); + if (optSession.isEmpty()) { + return WordSubmitResult.error("진행 중인 게임이 없습니다."); + } + return handleTimeout(optSession.get(), userId); + } + + private WordSubmitResult handleTimeout(WordChainSession session, String userId) { + // 플레이어 탈락 + session.eliminatePlayer(userId); + String nickname = getNickname(userId); + + logger.info("Player eliminated (timeout): sessionId={}, player={}", + session.getSessionId(), userId); + + // 게임 종료 확인 + if (session.isGameOver()) { + return finishGame(session, "TIMEOUT"); + } + + // 다음 턴 준비 + String nextPlayerId = session.getNextPlayerId(); + int nextRound = session.getCurrentRound() + 1; + int nextTimeLimit = session.getNextRoundTimeLimit(nextRound); + + session.setCurrentRound(nextRound); + session.setCurrentPlayerId(nextPlayerId); + session.setTurnStartTime(System.currentTimeMillis()); + session.setTimeLimit(nextTimeLimit); + + sessionRepository.save(session); + + return WordSubmitResult.timeout( + session, + userId, + nickname, + nextPlayerId, + nextTimeLimit + ); + } + + /** + * 게임 종료 + */ + public WordSubmitResult finishGame(WordChainSession session, String reason) { + long endTime = System.currentTimeMillis(); + long ttl = Instant.now().plusSeconds(7 * 24 * 60 * 60).getEpochSecond(); // 7일 보관 + + session.setStatus("FINISHED"); + session.setEndedAt(endTime); + session.setTtl(ttl); + sessionRepository.save(session); + + String winnerId = session.getWinner(); + String winnerNickname = winnerId != null ? getNickname(winnerId) : null; + + // 최종 순위 계산 + List ranking = buildRanking(session); + + logger.info("WordChain game finished: sessionId={}, winner={}, reason={}", + session.getSessionId(), winnerId, reason); + + return WordSubmitResult.gameEnd(session, winnerId, winnerNickname, ranking); + } + + /** + * 게임 강제 종료 + */ + public WordSubmitResult stopGame(String roomId, String userId) { + Optional optSession = sessionRepository.findActiveByRoomId(roomId); + if (optSession.isEmpty()) { + return WordSubmitResult.error("진행 중인 게임이 없습니다."); + } + + WordChainSession session = optSession.get(); + + // 게임 시작자만 종료 가능 + if (!userId.equals(session.getStartedBy())) { + return WordSubmitResult.error("게임 시작자만 종료할 수 있습니다."); + } + + return finishGame(session, "STOPPED"); + } + + /** + * 순위 계산 + */ + private List buildRanking(WordChainSession session) { + List ranking = new ArrayList<>(); + + // 점수 기준 정렬 + Map scores = session.getScores() != null + ? session.getScores() + : new HashMap<>(); + + // 활성 플레이어 (생존자) 먼저 + if (session.getActivePlayers() != null) { + for (String playerId : session.getActivePlayers()) { + ranking.add(new RankEntry( + playerId, + getNickname(playerId), + scores.getOrDefault(playerId, 0), + false + )); + } + } + + // 탈락 플레이어 (역순으로 - 나중에 탈락한 사람이 순위 높음) + if (session.getEliminatedPlayers() != null) { + List eliminated = new ArrayList<>(session.getEliminatedPlayers()); + Collections.reverse(eliminated); + for (String playerId : eliminated) { + ranking.add(new RankEntry( + playerId, + getNickname(playerId), + scores.getOrDefault(playerId, 0), + true + )); + } + } + + return ranking; + } + + /** + * 닉네임 조회 + */ + private String getNickname(String userId) { + return userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + } + + // ========== Result DTOs ========== + + public record GameStartResult( + boolean success, + String error, + WordChainSession session, + String starterWord, + Character nextLetter, + String firstPlayerId + ) { + public static GameStartResult success(WordChainSession session, String word, char letter, String playerId) { + return new GameStartResult(true, null, session, word, letter, playerId); + } + + public static GameStartResult error(String message) { + return new GameStartResult(false, message, null, null, null, null); + } + } + + public record WordSubmitResult( + ResultType type, + String error, + WordChainSession session, + // 정답 시 + String word, + String definition, + String phonetic, + int score, + Character nextLetter, + String nextPlayerId, + int nextTimeLimit, + String playerNickname, + // 타임아웃 시 + String eliminatedPlayerId, + String eliminatedNickname, + // 게임 종료 시 + String winnerId, + String winnerNickname, + List ranking + ) { + public enum ResultType { + CORRECT, WRONG_LETTER, INVALID_WORD, TIMEOUT, GAME_END, ERROR + } + + public static WordSubmitResult correct(WordChainSession session, String word, String definition, + String phonetic, int score, char nextLetter, + String nextPlayerId, int nextTimeLimit, String nickname) { + return new WordSubmitResult(ResultType.CORRECT, null, session, word, definition, phonetic, + score, nextLetter, nextPlayerId, nextTimeLimit, nickname, + null, null, null, null, null); + } + + public static WordSubmitResult wrongLetter(char expected) { + return new WordSubmitResult(ResultType.WRONG_LETTER, + String.format("'%c'로 시작하는 단어를 입력하세요.", expected), + null, null, null, null, 0, null, null, 0, null, + null, null, null, null, null); + } + + public static WordSubmitResult invalidWord(String reason) { + return new WordSubmitResult(ResultType.INVALID_WORD, reason, + null, null, null, null, 0, null, null, 0, null, + null, null, null, null, null); + } + + public static WordSubmitResult timeout(WordChainSession session, String eliminatedId, String eliminatedNick, + String nextPlayerId, int nextTimeLimit) { + return new WordSubmitResult(ResultType.TIMEOUT, null, session, null, null, null, 0, + session.getNextLetter(), nextPlayerId, nextTimeLimit, null, + eliminatedId, eliminatedNick, null, null, null); + } + + public static WordSubmitResult gameEnd(WordChainSession session, String winnerId, String winnerNick, + List ranking) { + return new WordSubmitResult(ResultType.GAME_END, null, session, null, null, null, 0, + null, null, 0, null, null, null, winnerId, winnerNick, ranking); + } + + public static WordSubmitResult error(String message) { + return new WordSubmitResult(ResultType.ERROR, message, null, null, null, null, 0, + null, null, 0, null, null, null, null, null, null); + } + } + + public record RankEntry( + String playerId, + String nickname, + int score, + boolean eliminated + ) { + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/config/NewsConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/config/NewsConfig.java new file mode 100644 index 00000000..43435bb1 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/config/NewsConfig.java @@ -0,0 +1,83 @@ +package com.mzc.secondproject.serverless.domain.news.config; + +import com.mzc.secondproject.serverless.common.config.EnvConfig; + +/** + * 뉴스 도메인 설정 + * 상수 및 환경변수 관리 + */ +public final class NewsConfig { + + private NewsConfig() { + } + + // ========== Environment Variables ========== + private static final String BUCKET_NAME = EnvConfig.getOrDefault("NEWS_BUCKET_NAME", "group2-englishstudy"); + + // ========== TTS 설정 ========== + /** TTS 텍스트 최대 길이 */ + public static final int TTS_MAX_TEXT_LENGTH = 3000; + + /** TTS 오디오 저장 경로 */ + public static final String TTS_AUDIO_PREFIX = "news/audio/"; + + /** 기본 TTS 음성 */ + public static final String DEFAULT_VOICE = "Joanna"; + + // ========== 페이지네이션 ========== + /** 기본 페이지 크기 */ + public static final int DEFAULT_PAGE_SIZE = 10; + + /** 최대 페이지 크기 */ + public static final int MAX_PAGE_SIZE = 50; + + // ========== 퀴즈 피드백 ========== + public static final String FEEDBACK_PERFECT = "Perfect! You understood the article completely."; + public static final String FEEDBACK_GREAT = "Great job! You have a solid understanding of the article."; + public static final String FEEDBACK_GOOD = "Good effort! Review the highlighted words for better comprehension."; + public static final String FEEDBACK_KEEP_PRACTICING = "Keep practicing! Try reading the article again before retaking the quiz."; + public static final String FEEDBACK_DONT_GIVE_UP = "Don't give up! Focus on vocabulary and main ideas."; + + // ========== Score 기준 ========== + public static final int SCORE_PERFECT = 100; + public static final int SCORE_GREAT_THRESHOLD = 80; + public static final int SCORE_GOOD_THRESHOLD = 60; + public static final int SCORE_KEEP_PRACTICING_THRESHOLD = 40; + + // ========== Getter Methods ========== + public static String bucketName() { + return BUCKET_NAME; + } + + /** + * 점수에 따른 피드백 생성 + */ + public static String getFeedbackByScore(int score) { + if (score == SCORE_PERFECT) { + return FEEDBACK_PERFECT; + } else if (score >= SCORE_GREAT_THRESHOLD) { + return FEEDBACK_GREAT; + } else if (score >= SCORE_GOOD_THRESHOLD) { + return FEEDBACK_GOOD; + } else if (score >= SCORE_KEEP_PRACTICING_THRESHOLD) { + return FEEDBACK_KEEP_PRACTICING; + } else { + return FEEDBACK_DONT_GIVE_UP; + } + } + + /** + * limit 값 파싱 및 유효성 검증 + */ + public static int parseLimit(String limitStr) { + if (limitStr == null) { + return DEFAULT_PAGE_SIZE; + } + try { + int limit = Integer.parseInt(limitStr); + return Math.min(Math.max(limit, 1), MAX_PAGE_SIZE); + } catch (NumberFormatException e) { + return DEFAULT_PAGE_SIZE; + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java index 4c44a58f..f5ca1969 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java @@ -6,7 +6,7 @@ * 뉴스 도메인 DynamoDB 키 상수 및 빌더 */ public final class NewsKey { - + // Entity Prefixes public static final String NEWS = "NEWS#"; public static final String ARTICLE = "ARTICLE#"; @@ -18,112 +18,124 @@ public final class NewsKey { public static final String BOOKMARK = "BOOKMARK#"; public static final String COMMENT = "COMMENT#"; public static final String STATS = "STATS"; - + // User Suffixes public static final String SUFFIX_NEWS = "#NEWS"; public static final String SUFFIX_NEWS_WORDS = "#NEWS_WORDS"; public static final String SUFFIX_NEWS_COMMENTS = "#NEWS_COMMENTS"; - + private NewsKey() { } - + // === Key Builders === - + /** * 뉴스 기사 PK: NEWS#{date} */ public static String newsPk(String date) { return NEWS + date; } - + /** * 뉴스 기사 SK: ARTICLE#{articleId} */ public static String articleSk(String articleId) { return ARTICLE + articleId; } - + /** * 레벨별 조회 GSI1 PK: LEVEL#{level} */ public static String levelPk(String level) { return LEVEL + level; } - + /** * 카테고리별 조회 GSI2 PK: CATEGORY#{category} */ public static String categoryPk(String category) { return CATEGORY + category; } - + /** * 사용자 뉴스 활동 PK: USER#{userId}#NEWS */ public static String userNewsPk(String userId) { return DynamoDbKey.USER + userId + SUFFIX_NEWS; } - + /** * 읽기 기록 SK: READ#{articleId} */ public static String readSk(String articleId) { return READ + articleId; } - + /** * 퀴즈 결과 SK: QUIZ#{articleId} */ public static String quizSk(String articleId) { return QUIZ + articleId; } - + /** * 단어 수집 SK: WORD#{word}#{articleId} */ public static String wordSk(String word, String articleId) { return WORD + word + "#" + articleId; } - + /** * 북마크 SK: BOOKMARK#{articleId} */ public static String bookmarkSk(String articleId) { return BOOKMARK + articleId; } - + /** * 사용자 수집 단어 GSI1 PK: USER#{userId}#NEWS_WORDS */ public static String userNewsWordsPk(String userId) { return DynamoDbKey.USER + userId + SUFFIX_NEWS_WORDS; } - + /** * 댓글 PK: NEWS_COMMENT#{articleId} */ public static String commentPk(String articleId) { return "NEWS_COMMENT#" + articleId; } - + /** * 댓글 SK: COMMENT#{commentId} */ public static String commentSk(String commentId) { return COMMENT + commentId; } - + /** * 사용자 댓글 GSI1 PK: USER#{userId}#NEWS_COMMENTS */ public static String userNewsCommentsPk(String userId) { return DynamoDbKey.USER + userId + SUFFIX_NEWS_COMMENTS; } - + /** * 사용자 뉴스 통계 GSI1 PK: USER_NEWS_STAT#{userId} */ public static String userNewsStatPk(String userId) { return "USER_NEWS_STAT#" + userId; } + + // === Utility Methods === + + /** + * PK에서 날짜 추출 (NEWS#2024-01-15 → 2024-01-15) + */ + public static String extractDateFromPk(String pk) { + if (pk == null || !pk.startsWith(NEWS)) { + return null; + } + return pk.substring(NEWS.length()); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/dto/RawNewsArticle.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/dto/RawNewsArticle.java index c9902559..4be72fac 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/dto/RawNewsArticle.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/dto/RawNewsArticle.java @@ -14,7 +14,7 @@ @NoArgsConstructor @AllArgsConstructor public class RawNewsArticle { - + private String title; private String description; private String url; @@ -22,7 +22,7 @@ public class RawNewsArticle { private String source; private String publishedAt; private String content; - + /** * URL 기반 고유 식별자 생성 */ @@ -32,7 +32,7 @@ public String generateId() { } return String.valueOf(url.hashCode()); } - + /** * 유효한 기사인지 검증 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/NewsCategory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/NewsCategory.java index 7f88078f..3f5a8bc5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/NewsCategory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/NewsCategory.java @@ -13,21 +13,21 @@ public enum NewsCategory { WORLD("world", "세계"), CULTURE("culture", "문화"), SCIENCE("science", "과학"); - + private final String code; private final String displayName; - + NewsCategory(String code, String displayName) { this.code = code; this.displayName = displayName; } - + public static boolean isValid(String value) { if (value == null) return false; return Arrays.stream(values()) .anyMatch(cat -> cat.name().equalsIgnoreCase(value) || cat.code.equalsIgnoreCase(value)); } - + public static NewsCategory fromString(String value) { if (value == null) { throw new IllegalArgumentException("NewsCategory value cannot be null"); @@ -37,18 +37,18 @@ public static NewsCategory fromString(String value) { .findFirst() .orElseThrow(() -> new IllegalArgumentException("Unknown NewsCategory: " + value)); } - + public static NewsCategory fromStringOrDefault(String value, NewsCategory defaultValue) { if (value == null || !isValid(value)) { return defaultValue; } return fromString(value); } - + public String getCode() { return code; } - + public String getDisplayName() { return displayName; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/QuizType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/QuizType.java index 7b95a466..20da38ae 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/QuizType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/QuizType.java @@ -9,23 +9,23 @@ public enum QuizType { COMPREHENSION("comprehension", "독해 질문", 20), WORD_MATCH("word_match", "단어-뜻 매칭", 15), FILL_BLANK("fill_blank", "빈칸 채우기", 30); - + private final String code; private final String displayName; private final int defaultPoints; - + QuizType(String code, String displayName, int defaultPoints) { this.code = code; this.displayName = displayName; this.defaultPoints = defaultPoints; } - + public static boolean isValid(String value) { if (value == null) return false; return Arrays.stream(values()) .anyMatch(type -> type.name().equalsIgnoreCase(value) || type.code.equalsIgnoreCase(value)); } - + public static QuizType fromString(String value) { if (value == null) { throw new IllegalArgumentException("QuizType value cannot be null"); @@ -35,15 +35,15 @@ public static QuizType fromString(String value) { .findFirst() .orElseThrow(() -> new IllegalArgumentException("Unknown QuizType: " + value)); } - + public String getCode() { return code; } - + public String getDisplayName() { return displayName; } - + public int getDefaultPoints() { return defaultPoints; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java index 58197f0e..ef2c05cb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java @@ -7,72 +7,72 @@ * 뉴스 기사, 퀴즈, 단어 수집, 댓글 관련 에러 코드를 정의합니다. */ public enum NewsErrorCode implements DomainErrorCode { - + // 인증 관련 에러 UNAUTHORIZED("AUTH_001", "인증이 필요합니다", 401), - + // 뉴스 기사 관련 에러 ARTICLE_NOT_FOUND("ARTICLE_001", "뉴스 기사를 찾을 수 없습니다", 404), INVALID_ARTICLE_DATA("ARTICLE_002", "뉴스 기사 데이터가 유효하지 않습니다", 400), ARTICLE_ALREADY_EXISTS("ARTICLE_003", "이미 존재하는 뉴스 기사입니다", 409), - + // 카테고리/레벨 관련 에러 INVALID_CATEGORY("CATEGORY_001", "유효하지 않은 카테고리입니다", 400), INVALID_LEVEL("LEVEL_001", "유효하지 않은 레벨입니다", 400), - + // 읽기 기록 관련 에러 READ_RECORD_NOT_FOUND("READ_001", "읽기 기록을 찾을 수 없습니다", 404), ALREADY_READ("READ_002", "이미 읽은 기사입니다", 409), - + // 퀴즈 관련 에러 QUIZ_NOT_FOUND("QUIZ_001", "퀴즈를 찾을 수 없습니다", 404), QUIZ_ALREADY_SUBMITTED("QUIZ_002", "이미 제출한 퀴즈입니다", 409), INVALID_QUIZ_ANSWER("QUIZ_003", "유효하지 않은 퀴즈 답변입니다", 400), - + // 단어 수집 관련 에러 WORD_ALREADY_COLLECTED("WORD_001", "이미 수집한 단어입니다", 409), WORD_NOT_COLLECTED("WORD_002", "수집한 단어를 찾을 수 없습니다", 404), - + // 북마크 관련 에러 BOOKMARK_NOT_FOUND("BOOKMARK_001", "북마크를 찾을 수 없습니다", 404), ALREADY_BOOKMARKED("BOOKMARK_002", "이미 북마크한 기사입니다", 409), BOOKMARK_LIMIT_EXCEEDED("BOOKMARK_003", "북마크 한도를 초과했습니다", 400), - + // 댓글 관련 에러 COMMENT_NOT_FOUND("COMMENT_001", "댓글을 찾을 수 없습니다", 404), COMMENT_NOT_OWNER("COMMENT_002", "댓글 작성자만 수정/삭제할 수 있습니다", 403), INVALID_COMMENT_DATA("COMMENT_003", "유효하지 않은 댓글 데이터입니다", 400), - + // 통계 관련 에러 STATS_NOT_FOUND("STATS_001", "통계 정보를 찾을 수 없습니다", 404); - + private static final String DOMAIN = "NEWS"; - + private final String code; private final String message; private final int statusCode; - + NewsErrorCode(String code, String message, int statusCode) { this.code = code; this.message = message; this.statusCode = statusCode; } - + @Override public String getDomain() { return DOMAIN; } - + @Override public String getCode() { return code; } - + @Override public String getMessage() { return message; } - + @Override public int getStatusCode() { return statusCode; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java index 4d17463f..246617c3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java @@ -14,29 +14,29 @@ * EventBridge 스케줄러에 의해 매일 18시에 트리거 */ public class NewsCollectionHandler implements RequestHandler> { - + private static final Logger logger = LoggerFactory.getLogger(NewsCollectionHandler.class); - + private final NewsCollectorService collectorService; - + public NewsCollectionHandler() { this.collectorService = new NewsCollectorService(); } - + public NewsCollectionHandler(NewsCollectorService collectorService) { this.collectorService = collectorService; } - + @Override public Map handleRequest(ScheduledEvent event, Context context) { logger.info("뉴스 수집 Lambda 시작 - requestId: {}", context.getAwsRequestId()); - + try { NewsCollectorService.CollectionResult result = collectorService.collectNews(); - + logger.info("뉴스 수집 완료 - 수집: {}, 저장: {}, 소요: {}ms", result.collectedCount(), result.savedCount(), result.elapsedMs()); - + return Map.of( "statusCode", 200, "message", "News collection completed", @@ -44,10 +44,10 @@ public Map handleRequest(ScheduledEvent event, Context context) "savedCount", result.savedCount(), "elapsedMs", result.elapsedMs() ); - + } catch (Exception e) { logger.error("뉴스 수집 실패", e); - + return Map.of( "statusCode", 500, "message", "News collection failed: " + e.getMessage() diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java index 180bb7cb..421ecf37 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -4,23 +4,23 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.router.HandlerRouter; import com.mzc.secondproject.serverless.common.router.Route; import com.mzc.secondproject.serverless.common.util.CognitoUtil; +import com.mzc.secondproject.serverless.common.util.JsonUtil; import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.domain.news.config.NewsConfig; import com.mzc.secondproject.serverless.domain.news.exception.NewsErrorCode; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; import com.mzc.secondproject.serverless.domain.news.model.NewsQuizResult; import com.mzc.secondproject.serverless.domain.news.model.NewsWordCollect; -import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; import com.mzc.secondproject.serverless.domain.news.service.NewsLearningService; import com.mzc.secondproject.serverless.domain.news.service.NewsQueryService; import com.mzc.secondproject.serverless.domain.news.service.NewsQuizService; import com.mzc.secondproject.serverless.domain.news.service.NewsWordService; -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -35,29 +35,26 @@ public class NewsHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(NewsHandler.class); - private static final int DEFAULT_LIMIT = 10; - private static final int MAX_LIMIT = 50; - private static final Gson gson = new Gson(); private final NewsQueryService queryService; private final NewsLearningService learningService; private final NewsQuizService quizService; private final NewsWordService wordService; private final HandlerRouter router; - + public NewsHandler() { this(new NewsQueryService(), new NewsLearningService(), new NewsQuizService(), new NewsWordService()); } - + public NewsHandler(NewsQueryService queryService, NewsLearningService learningService, - NewsQuizService quizService, NewsWordService wordService) { + NewsQuizService quizService, NewsWordService wordService) { this.queryService = queryService; this.learningService = learningService; this.quizService = quizService; this.wordService = wordService; this.router = initRouter(); } - + private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( Route.get("/news/today", this::getTodayNews), @@ -79,13 +76,13 @@ private HandlerRouter initRouter() { Route.get("/news", this::getNewsList) ); } - + @Override public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { logger.info("News API 요청: {} {}", request.getHttpMethod(), request.getPath()); return router.route(request); } - + /** * 뉴스 목록 조회 (필터링 지원) * GET /news?level=INTERMEDIATE&category=TECH&limit=10&cursor=xxx @@ -93,14 +90,14 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re private APIGatewayProxyResponseEvent getNewsList(APIGatewayProxyRequestEvent request) { Map params = request.getQueryStringParameters(); if (params == null) params = new HashMap<>(); - + String level = params.get("level"); String category = params.get("category"); String cursor = params.get("cursor"); int limit = parseLimit(params.get("limit")); - + PaginatedResult result; - + if (level != null && category != null) { result = queryService.getNewsByLevelAndCategory(level.toUpperCase(), category.toUpperCase(), limit, cursor); } else if (level != null) { @@ -110,10 +107,10 @@ private APIGatewayProxyResponseEvent getNewsList(APIGatewayProxyRequestEvent req } else { result = queryService.getTodayNews(limit, cursor); } - - return buildPaginatedResponse(result); + + return buildPaginatedResponse(result, getUserId(request)); } - + /** * 오늘의 뉴스 조회 * GET /news/today?limit=10&cursor=xxx @@ -121,14 +118,14 @@ private APIGatewayProxyResponseEvent getNewsList(APIGatewayProxyRequestEvent req private APIGatewayProxyResponseEvent getTodayNews(APIGatewayProxyRequestEvent request) { Map params = request.getQueryStringParameters(); if (params == null) params = new HashMap<>(); - + String cursor = params.get("cursor"); int limit = parseLimit(params.get("limit")); - + PaginatedResult result = queryService.getTodayNews(limit, cursor); - return buildPaginatedResponse(result); + return buildPaginatedResponse(result, getUserId(request)); } - + /** * 내 레벨 맞춤 뉴스 추천 * GET /news/recommended?limit=10&cursor=xxx @@ -136,57 +133,100 @@ private APIGatewayProxyResponseEvent getTodayNews(APIGatewayProxyRequestEvent re private APIGatewayProxyResponseEvent getRecommendedNews(APIGatewayProxyRequestEvent request) { Map params = request.getQueryStringParameters(); if (params == null) params = new HashMap<>(); - + // 사용자 레벨 조회 (Cognito 토큰에서) String userLevel = getUserLevel(request); String cursor = params.get("cursor"); int limit = parseLimit(params.get("limit")); - + PaginatedResult result = queryService.getRecommendedNews(userLevel, limit, cursor); - return buildPaginatedResponse(result); + return buildPaginatedResponse(result, getUserId(request)); } - + /** * 뉴스 상세 조회 * GET /news/{articleId} */ private APIGatewayProxyResponseEvent getNewsDetail(APIGatewayProxyRequestEvent request) { String articleId = request.getPathParameters().get("articleId"); - + Optional article = queryService.getArticle(articleId); if (article.isEmpty()) { return ResponseGenerator.fail(NewsErrorCode.ARTICLE_NOT_FOUND); } - - return ResponseGenerator.ok("뉴스 조회 성공", article.get()); + + // 로그인한 사용자의 경우 북마크/읽기 상태 추가 + String userId = getUserId(request); + Map response = new HashMap<>(); + response.put("article", article.get()); + + if (userId != null) { + response.put("isBookmarked", learningService.isBookmarked(userId, articleId)); + response.put("isRead", learningService.hasRead(userId, articleId)); + } else { + response.put("isBookmarked", false); + response.put("isRead", false); + } + + return ResponseGenerator.ok("뉴스 조회 성공", response); } - + /** * 페이지네이션 응답 생성 */ private APIGatewayProxyResponseEvent buildPaginatedResponse(PaginatedResult result) { + return buildPaginatedResponse(result, null); + } + + /** + * 페이지네이션 응답 생성 (북마크 상태 포함) + */ + private APIGatewayProxyResponseEvent buildPaginatedResponse(PaginatedResult result, String userId) { + List> articlesWithStatus = new java.util.ArrayList<>(); + java.util.Set bookmarkedIds = java.util.Collections.emptySet(); + + // 로그인한 사용자의 경우 북마크 상태 조회 + if (userId != null && !result.items().isEmpty()) { + List articleIds = result.items().stream() + .map(NewsArticle::getArticleId) + .toList(); + bookmarkedIds = learningService.getBookmarkedArticleIds(userId, articleIds); + } + + for (NewsArticle article : result.items()) { + Map articleWithStatus = new HashMap<>(); + articleWithStatus.put("articleId", article.getArticleId()); + articleWithStatus.put("title", article.getTitle()); + articleWithStatus.put("summary", article.getSummary()); + articleWithStatus.put("source", article.getSource()); + articleWithStatus.put("publishedAt", article.getPublishedAt()); + articleWithStatus.put("keywords", article.getKeywords()); + articleWithStatus.put("highlightWords", article.getHighlightWords()); + articleWithStatus.put("category", article.getCategory()); + articleWithStatus.put("level", article.getLevel()); + articleWithStatus.put("cefrLevel", article.getCefrLevel()); + articleWithStatus.put("imageUrl", article.getImageUrl()); + articleWithStatus.put("readCount", article.getReadCount()); + articleWithStatus.put("isBookmarked", bookmarkedIds.contains(article.getArticleId())); + articlesWithStatus.add(articleWithStatus); + } + Map response = new HashMap<>(); - response.put("articles", result.items()); + response.put("articles", articlesWithStatus); response.put("nextCursor", result.nextCursor()); response.put("hasMore", result.hasMore()); response.put("count", result.items().size()); - + return ResponseGenerator.ok("뉴스 목록 조회 성공", response); } - + /** * limit 파싱 */ private int parseLimit(String limitStr) { - if (limitStr == null) return DEFAULT_LIMIT; - try { - int limit = Integer.parseInt(limitStr); - return Math.min(Math.max(limit, 1), MAX_LIMIT); - } catch (NumberFormatException e) { - return DEFAULT_LIMIT; - } + return NewsConfig.parseLimit(limitStr); } - + /** * 사용자 레벨 조회 */ @@ -194,7 +234,7 @@ private String getUserLevel(APIGatewayProxyRequestEvent request) { return CognitoUtil.extractClaim(request, "custom:level") .orElse("INTERMEDIATE"); } - + /** * 사용자 ID 추출 */ @@ -202,7 +242,7 @@ private String getUserId(APIGatewayProxyRequestEvent request) { return CognitoUtil.extractClaim(request, "sub") .orElse(null); } - + /** * 뉴스 학습 통계 조회 * GET /news/stats @@ -212,11 +252,11 @@ private APIGatewayProxyResponseEvent getNewsStats(APIGatewayProxyRequestEvent re if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + Map stats = learningService.getUserStats(userId); return ResponseGenerator.ok("뉴스 학습 통계 조회 성공", stats); } - + /** * 북마크 목록 조회 * GET /news/bookmarks?limit=10 @@ -226,20 +266,20 @@ private APIGatewayProxyResponseEvent getBookmarks(APIGatewayProxyRequestEvent re if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + Map params = request.getQueryStringParameters(); if (params == null) params = new HashMap<>(); - + int limit = parseLimit(params.get("limit")); - List bookmarks = learningService.getUserBookmarks(userId, limit); - + List> bookmarks = learningService.getUserBookmarks(userId, limit); + Map response = new HashMap<>(); response.put("bookmarks", bookmarks); response.put("count", bookmarks.size()); - + return ResponseGenerator.ok("북마크 목록 조회 성공", response); } - + /** * 뉴스 읽기 완료 기록 * POST /news/{articleId}/read @@ -249,13 +289,13 @@ private APIGatewayProxyResponseEvent markAsRead(APIGatewayProxyRequestEvent requ if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + String articleId = request.getPathParameters().get("articleId"); learningService.markAsRead(userId, articleId); - + return ResponseGenerator.ok("읽기 완료 기록 성공", Map.of("articleId", articleId)); } - + /** * 북마크 토글 * POST /news/{articleId}/bookmark @@ -265,34 +305,34 @@ private APIGatewayProxyResponseEvent toggleBookmark(APIGatewayProxyRequestEvent if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + String articleId = request.getPathParameters().get("articleId"); boolean isBookmarked = learningService.toggleBookmark(userId, articleId); - + return ResponseGenerator.ok( isBookmarked ? "북마크 추가 성공" : "북마크 해제 성공", Map.of("articleId", articleId, "bookmarked", isBookmarked) ); } - + /** * 뉴스 TTS 오디오 URL 조회 * GET /news/{articleId}/audio?voice=Joanna */ private APIGatewayProxyResponseEvent getAudio(APIGatewayProxyRequestEvent request) { String articleId = request.getPathParameters().get("articleId"); - + Map params = request.getQueryStringParameters(); String voice = (params != null) ? params.getOrDefault("voice", "Joanna") : "Joanna"; - + String audioUrl = learningService.getAudioUrl(articleId, voice); if (audioUrl == null) { return ResponseGenerator.fail(NewsErrorCode.ARTICLE_NOT_FOUND); } - + return ResponseGenerator.ok("TTS 오디오 URL 조회 성공", Map.of("audioUrl", audioUrl)); } - + /** * 퀴즈 조회 * GET /news/{articleId}/quiz @@ -302,17 +342,17 @@ private APIGatewayProxyResponseEvent getQuiz(APIGatewayProxyRequestEvent request if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + String articleId = request.getPathParameters().get("articleId"); Optional quizData = quizService.getQuiz(articleId, userId); - + if (quizData.isEmpty()) { return ResponseGenerator.fail(NewsErrorCode.QUIZ_NOT_FOUND); } - + return ResponseGenerator.ok("퀴즈 조회 성공", quizData.get()); } - + /** * 퀴즈 제출 * POST /news/{articleId}/quiz @@ -322,14 +362,14 @@ private APIGatewayProxyResponseEvent submitQuiz(APIGatewayProxyRequestEvent requ if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + String articleId = request.getPathParameters().get("articleId"); - + // 요청 바디 파싱 - JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); + JsonObject body = JsonUtil.fromJson(request.getBody(), JsonObject.class); JsonArray answersArray = body.getAsJsonArray("answers"); Integer timeTaken = body.has("timeTaken") ? body.get("timeTaken").getAsInt() : null; - + List answers = new java.util.ArrayList<>(); if (answersArray != null) { answersArray.forEach(e -> { @@ -340,16 +380,16 @@ private APIGatewayProxyResponseEvent submitQuiz(APIGatewayProxyRequestEvent requ )); }); } - + NewsQuizService.QuizSubmitResult result = quizService.submitQuiz(userId, articleId, answers, timeTaken); - + if (result == null) { return ResponseGenerator.fail(NewsErrorCode.QUIZ_ALREADY_SUBMITTED); } - + return ResponseGenerator.ok("퀴즈 제출 성공", result); } - + /** * 퀴즈 기록 조회 * GET /news/quiz/history?limit=10 @@ -359,22 +399,22 @@ private APIGatewayProxyResponseEvent getQuizHistory(APIGatewayProxyRequestEvent if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + Map params = request.getQueryStringParameters(); if (params == null) params = new HashMap<>(); - + int limit = parseLimit(params.get("limit")); List history = quizService.getUserQuizHistory(userId, limit); Map quizStats = quizService.getUserQuizStats(userId); - + Map response = new HashMap<>(); response.put("history", history); response.put("stats", quizStats); response.put("count", history.size()); - + return ResponseGenerator.ok("퀴즈 기록 조회 성공", response); } - + /** * 수집 단어 목록 조회 * GET /news/words?limit=10 @@ -384,22 +424,22 @@ private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent re if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + Map params = request.getQueryStringParameters(); if (params == null) params = new HashMap<>(); - + int limit = parseLimit(params.get("limit")); List words = wordService.getUserWords(userId, limit); Map stats = wordService.getUserWordStats(userId); - + Map response = new HashMap<>(); response.put("words", words); response.put("stats", stats); response.put("count", words.size()); - + return ResponseGenerator.ok("수집 단어 목록 조회 성공", response); } - + /** * 단어 수집 * POST /news/{articleId}/words @@ -409,22 +449,22 @@ private APIGatewayProxyResponseEvent collectWord(APIGatewayProxyRequestEvent req if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + String articleId = request.getPathParameters().get("articleId"); - - JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); + + JsonObject body = JsonUtil.fromJson(request.getBody(), JsonObject.class); String word = body.get("word").getAsString(); String context = body.has("context") ? body.get("context").getAsString() : ""; - + NewsWordCollect collected = wordService.collectWord(userId, articleId, word, context); - + if (collected == null) { return ResponseGenerator.fail(NewsErrorCode.WORD_ALREADY_COLLECTED); } - + return ResponseGenerator.ok("단어 수집 성공", collected); } - + /** * 단어 삭제 * DELETE /news/{articleId}/words/{word} @@ -434,31 +474,31 @@ private APIGatewayProxyResponseEvent deleteWord(APIGatewayProxyRequestEvent requ if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + String articleId = request.getPathParameters().get("articleId"); String word = request.getPathParameters().get("word"); - + wordService.deleteWord(userId, word, articleId); - + return ResponseGenerator.ok("단어 삭제 성공", Map.of("word", word)); } - + /** * 단어 상세 정보 조회 * GET /news/{articleId}/words/{word} */ private APIGatewayProxyResponseEvent getWordDetail(APIGatewayProxyRequestEvent request) { String word = request.getPathParameters().get("word"); - + Optional detail = wordService.getWordDetail(word); - + if (detail.isEmpty()) { return ResponseGenerator.fail(NewsErrorCode.WORD_NOT_COLLECTED); } - + return ResponseGenerator.ok("단어 상세 조회 성공", detail.get()); } - + /** * 단어 Vocabulary 연동 * POST /news/words/{word}/sync @@ -468,18 +508,18 @@ private APIGatewayProxyResponseEvent syncWordToVocab(APIGatewayProxyRequestEvent if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + String word = request.getPathParameters().get("word"); - - JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); + + JsonObject body = JsonUtil.fromJson(request.getBody(), JsonObject.class); String articleId = body.get("articleId").getAsString(); - + boolean synced = wordService.syncToVocabulary(userId, word, articleId); - + if (!synced) { return ResponseGenerator.fail(NewsErrorCode.WORD_NOT_COLLECTED); } - + return ResponseGenerator.ok("Vocabulary 연동 성공", Map.of("word", word, "synced", true)); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java index 81f1e1f5..c4ad3708 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java @@ -16,9 +16,11 @@ @AllArgsConstructor @DynamoDbBean public class KeywordInfo { - + private String word; // 영어 단어 - private String meaning; // 한국어 뜻 + private String meaning; // 영어 뜻 (간단한 정의) + private String meaningKo; // 한국어 뜻 + private String example; // 기사에서 발췌한 예문 private String level; // 단어 난이도 (BEGINNER, INTERMEDIATE, ADVANCED) private Integer position; // 기사 내 위치 (문장 번호 또는 단어 인덱스) } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsArticle.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsArticle.java index 13fd8a19..3f0537f3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsArticle.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsArticle.java @@ -21,14 +21,14 @@ @AllArgsConstructor @DynamoDbBean public class NewsArticle { - + private String pk; // NEWS#{date} private String sk; // ARTICLE#{articleId} private String gsi1pk; // LEVEL#{level} private String gsi1sk; // {publishedAt} private String gsi2pk; // CATEGORY#{category} private String gsi2sk; // {publishedAt} - + // 기본 정보 private String articleId; private String title; @@ -36,54 +36,54 @@ public class NewsArticle { private String originalUrl; // 원문 링크 private String source; // BBC, VOA, NPR, NewsAPI private String imageUrl; // 썸네일 이미지 - + // 분류 private String category; // TECH, BUSINESS, SPORTS 등 private String level; // BEGINNER, INTERMEDIATE, ADVANCED private String cefrLevel; // A1, A2, B1, B2, C1, C2 (원본 CEFR 레벨) - + // AI 분석 결과 private List keywords; // 핵심 단어 정보 private List highlightWords; // 사용자 레벨 대비 어려운 단어 private List quiz; // 퀴즈 문제 (5개) - + // 메타데이터 private String publishedAt; // 원본 발행일 private String collectedAt; // 수집일 private Long readCount; // 조회수 private Long commentCount; // 댓글수 private Long ttl; - + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { return pk; } - + @DynamoDbSortKey @DynamoDbAttribute("SK") public String getSk() { return sk; } - + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1PK") public String getGsi1pk() { return gsi1pk; } - + @DynamoDbSecondarySortKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1SK") public String getGsi1sk() { return gsi1sk; } - + @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") @DynamoDbAttribute("GSI2PK") public String getGsi2pk() { return gsi2pk; } - + @DynamoDbSecondarySortKey(indexNames = "GSI2") @DynamoDbAttribute("GSI2SK") public String getGsi2sk() { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java index 23b47f62..c2aaaae9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java @@ -19,12 +19,12 @@ @AllArgsConstructor @DynamoDbBean public class NewsQuizResult { - + private String pk; // USER#{userId}#NEWS private String sk; // QUIZ#{articleId} private String gsi1pk; // USER_NEWS_STAT#{userId} private String gsi1sk; // {date}#QUIZ - + private String userId; private String articleId; private String articleTitle; @@ -36,25 +36,25 @@ public class NewsQuizResult { private Integer timeTaken; // 소요 시간 (초) private String submittedAt; private Long ttl; - + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { return pk; } - + @DynamoDbSortKey @DynamoDbAttribute("SK") public String getSk() { return sk; } - + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1PK") public String getGsi1pk() { return gsi1pk; } - + @DynamoDbSecondarySortKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1SK") public String getGsi1sk() { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java index 59f4fa93..227e90e3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java @@ -18,12 +18,12 @@ @AllArgsConstructor @DynamoDbBean public class NewsWordCollect { - + private String pk; // USER#{userId}#NEWS private String sk; // WORD#{word}#{articleId} private String gsi1pk; // USER#{userId}#NEWS_WORDS private String gsi1sk; // {collectedAt} - + private String userId; private String word; private String meaning; @@ -35,25 +35,25 @@ public class NewsWordCollect { private Boolean syncedToVocab; // Vocabulary 연동 여부 private String vocabUserWordId; // 연동된 UserWord ID private Long ttl; - + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { return pk; } - + @DynamoDbSortKey @DynamoDbAttribute("SK") public String getSk() { return sk; } - + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1PK") public String getGsi1pk() { return gsi1pk; } - + @DynamoDbSecondarySortKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1SK") public String getGsi1sk() { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java index 3dee95b6..7340216f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java @@ -15,7 +15,7 @@ @AllArgsConstructor @DynamoDbBean public class QuizAnswerResult { - + private String questionId; private String type; // COMPREHENSION, WORD_MATCH, FILL_BLANK private String userAnswer; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizQuestion.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizQuestion.java index 6657ef33..1f6dab4f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizQuestion.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizQuestion.java @@ -17,7 +17,7 @@ @AllArgsConstructor @DynamoDbBean public class QuizQuestion { - + private String questionId; // 문제 ID (q1, q2, ...) private String type; // COMPREHENSION, WORD_MATCH, FILL_BLANK private String question; // 문제 내용 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java index eedfa8c5..b0622e00 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java @@ -17,12 +17,12 @@ @AllArgsConstructor @DynamoDbBean public class UserNewsRecord { - + private String pk; // USER_NEWS#{userId} private String sk; // READ#{articleId} 또는 BOOKMARK#{articleId} private String gsi1pk; // USER_NEWS_STAT#{userId} private String gsi1sk; // {date}#{type} - + private String userId; private String articleId; private String type; // READ, BOOKMARK @@ -31,25 +31,25 @@ public class UserNewsRecord { private String articleCategory; private String createdAt; private Long ttl; - + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { return pk; } - + @DynamoDbSortKey @DynamoDbAttribute("SK") public String getSk() { return sk; } - + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1PK") public String getGsi1pk() { return gsi1pk; } - + @DynamoDbSecondarySortKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1SK") public String getGsi1sk() { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java index 28ca35cc..9cb2b587 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java @@ -9,7 +9,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.enhanced.dynamodb.*; -import software.amazon.awssdk.enhanced.dynamodb.model.*; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.ScanEnhancedRequest; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; @@ -22,20 +25,20 @@ * 뉴스 기사 Repository */ public class NewsArticleRepository { - + private static final Logger logger = LoggerFactory.getLogger(NewsArticleRepository.class); private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); - + private final DynamoDbEnhancedClient enhancedClient; private final DynamoDbTable table; - + /** * 기본 생성자 (Lambda에서 사용) */ public NewsArticleRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -43,7 +46,7 @@ public NewsArticleRepository(DynamoDbEnhancedClient enhancedClient) { this.enhancedClient = enhancedClient; this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(NewsArticle.class)); } - + /** * 뉴스 기사 저장 */ @@ -52,7 +55,7 @@ public NewsArticle save(NewsArticle article) { table.putItem(article); return article; } - + /** * 뉴스 기사 조회 (날짜 + 기사ID) */ @@ -61,26 +64,27 @@ public Optional findByDateAndId(String date, String articleId) { .partitionValue(NewsKey.newsPk(date)) .sortValue(NewsKey.articleSk(articleId)) .build(); - + NewsArticle article = table.getItem(key); return Optional.ofNullable(article); } - + /** * 뉴스 기사 조회 (기사ID만으로 - GSI 활용 또는 Scan) * 참고: 실제로는 articleId로 date를 알 수 있도록 설계하거나 GSI 추가 필요 */ public Optional findById(String articleId) { Expression filterExpression = Expression.builder() - .expression("articleId = :articleId") + .expression("articleId = :articleId AND begins_with(SK, :skPrefix)") .putExpressionValue(":articleId", AttributeValue.builder().s(articleId).build()) + .putExpressionValue(":skPrefix", AttributeValue.builder().s("ARTICLE#").build()) .build(); - + ScanEnhancedRequest request = ScanEnhancedRequest.builder() .filterExpression(filterExpression) .limit(1) .build(); - + for (Page page : table.scan(request)) { List items = page.items(); if (!items.isEmpty()) { @@ -89,7 +93,7 @@ public Optional findById(String articleId) { } return Optional.empty(); } - + /** * 뉴스 기사 삭제 */ @@ -98,88 +102,88 @@ public void delete(String date, String articleId) { .partitionValue(NewsKey.newsPk(date)) .sortValue(NewsKey.articleSk(articleId)) .build(); - + table.deleteItem(key); logger.info("Deleted news article: {}", articleId); } - + /** * 날짜별 뉴스 기사 조회 (페이지네이션) */ public PaginatedResult findByDate(String date, int limit, String cursor) { QueryConditional queryConditional = QueryConditional .keyEqualTo(Key.builder().partitionValue(NewsKey.newsPk(date)).build()); - + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) // 최신순 (SK 역순) .limit(limit); - + if (cursor != null && !cursor.isEmpty()) { Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } } - + Page page = table.query(requestBuilder.build()).iterator().next(); String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - + return new PaginatedResult<>(page.items(), nextCursor); } - + /** * 레벨별 뉴스 기사 조회 (GSI1 - 최신순) */ public PaginatedResult findByLevel(String level, int limit, String cursor) { QueryConditional queryConditional = QueryConditional .keyEqualTo(Key.builder().partitionValue(NewsKey.levelPk(level.toUpperCase())).build()); - + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) // 최신순 .limit(limit); - + if (cursor != null && !cursor.isEmpty()) { Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } } - + DynamoDbIndex gsi1 = table.index("GSI1"); Page page = gsi1.query(requestBuilder.build()).iterator().next(); String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - + return new PaginatedResult<>(page.items(), nextCursor); } - + /** * 카테고리별 뉴스 기사 조회 (GSI2 - 최신순) */ public PaginatedResult findByCategory(String category, int limit, String cursor) { QueryConditional queryConditional = QueryConditional .keyEqualTo(Key.builder().partitionValue(NewsKey.categoryPk(category.toUpperCase())).build()); - + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) // 최신순 .limit(limit); - + if (cursor != null && !cursor.isEmpty()) { Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } } - + DynamoDbIndex gsi2 = table.index("GSI2"); Page page = gsi2.query(requestBuilder.build()).iterator().next(); String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - + return new PaginatedResult<>(page.items(), nextCursor); } - + /** * 레벨 + 카테고리 필터 조회 (GSI1 쿼리 후 필터) */ @@ -188,27 +192,27 @@ public PaginatedResult findByLevelAndCategory(String level, String .expression("category = :category") .putExpressionValue(":category", AttributeValue.builder().s(category.toUpperCase()).build()) .build(); - + QueryConditional queryConditional = QueryConditional .keyEqualTo(Key.builder().partitionValue(NewsKey.levelPk(level.toUpperCase())).build()); - + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .filterExpression(filterExpression) .scanIndexForward(false) .limit(limit * 2); // 필터 적용되므로 넉넉히 - + if (cursor != null && !cursor.isEmpty()) { Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } } - + DynamoDbIndex gsi1 = table.index("GSI1"); List results = new ArrayList<>(); Map lastKey = null; - + for (Page page : gsi1.query(requestBuilder.build())) { for (NewsArticle article : page.items()) { results.add(article); @@ -217,11 +221,11 @@ public PaginatedResult findByLevelAndCategory(String level, String lastKey = page.lastEvaluatedKey(); if (results.size() >= limit) break; } - + String nextCursor = results.size() >= limit ? CursorUtil.encode(lastKey) : null; return new PaginatedResult<>(results.subList(0, Math.min(results.size(), limit)), nextCursor); } - + /** * 조회수 증가 (Atomic Update) */ @@ -230,23 +234,23 @@ public void incrementReadCount(String date, String articleId) { "PK", AttributeValue.builder().s(NewsKey.newsPk(date)).build(), "SK", AttributeValue.builder().s(NewsKey.articleSk(articleId)).build() ); - + Map values = Map.of( ":zero", AttributeValue.builder().n("0").build(), ":inc", AttributeValue.builder().n("1").build() ); - + UpdateItemRequest request = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression("SET readCount = if_not_exists(readCount, :zero) + :inc") .expressionAttributeValues(values) .build(); - + AwsClients.dynamoDb().updateItem(request); logger.debug("Incremented read count for article: {}", articleId); } - + /** * 댓글수 증가 (Atomic Update) */ @@ -255,22 +259,22 @@ public void incrementCommentCount(String date, String articleId) { "PK", AttributeValue.builder().s(NewsKey.newsPk(date)).build(), "SK", AttributeValue.builder().s(NewsKey.articleSk(articleId)).build() ); - + Map values = Map.of( ":zero", AttributeValue.builder().n("0").build(), ":inc", AttributeValue.builder().n("1").build() ); - + UpdateItemRequest request = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression("SET commentCount = if_not_exists(commentCount, :zero) + :inc") .expressionAttributeValues(values) .build(); - + AwsClients.dynamoDb().updateItem(request); } - + /** * 댓글수 감소 (Atomic Update) */ @@ -279,19 +283,19 @@ public void decrementCommentCount(String date, String articleId) { "PK", AttributeValue.builder().s(NewsKey.newsPk(date)).build(), "SK", AttributeValue.builder().s(NewsKey.articleSk(articleId)).build() ); - + Map values = Map.of( ":one", AttributeValue.builder().n("1").build(), ":dec", AttributeValue.builder().n("1").build() ); - + UpdateItemRequest request = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression("SET commentCount = if_not_exists(commentCount, :one) - :dec") .expressionAttributeValues(values) .build(); - + AwsClients.dynamoDb().updateItem(request); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java index b2786f99..d772ee5a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java @@ -6,8 +6,13 @@ import com.mzc.secondproject.serverless.domain.news.model.NewsQuizResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.*; -import software.amazon.awssdk.enhanced.dynamodb.model.*; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; import java.util.ArrayList; import java.util.List; @@ -17,20 +22,20 @@ * 뉴스 퀴즈 결과 Repository */ public class NewsQuizRepository { - + private static final Logger logger = LoggerFactory.getLogger(NewsQuizRepository.class); private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); - + private final DynamoDbTable table; - + public NewsQuizRepository() { this(AwsClients.dynamoDbEnhanced()); } - + public NewsQuizRepository(DynamoDbEnhancedClient enhancedClient) { this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(NewsQuizResult.class)); } - + /** * 퀴즈 결과 저장 */ @@ -39,7 +44,7 @@ public void save(NewsQuizResult result) { logger.debug("퀴즈 결과 저장: userId={}, articleId={}, score={}", result.getUserId(), result.getArticleId(), result.getScore()); } - + /** * 퀴즈 결과 조회 */ @@ -48,18 +53,18 @@ public Optional findByUserAndArticle(String userId, String artic .partitionValue(NewsKey.userNewsPk(userId)) .sortValue(NewsKey.quizSk(articleId)) .build(); - + NewsQuizResult result = table.getItem(key); return Optional.ofNullable(result); } - + /** * 퀴즈 제출 여부 확인 */ public boolean hasSubmitted(String userId, String articleId) { return findByUserAndArticle(userId, articleId).isPresent(); } - + /** * 사용자 퀴즈 결과 목록 조회 */ @@ -70,22 +75,22 @@ public List getUserQuizResults(String userId, int limit) { .sortValue("QUIZ#") .build() ); - + QueryEnhancedRequest request = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) .limit(limit) .build(); - + List results = new ArrayList<>(); for (Page page : table.query(request)) { results.addAll(page.items()); if (results.size() >= limit) break; } - + return results.subList(0, Math.min(results.size(), limit)); } - + /** * 사용자 퀴즈 통계 조회 */ @@ -96,11 +101,11 @@ public QuizStats getUserQuizStats(String userId) { .sortValue("QUIZ#") .build() ); - + int totalQuizzes = 0; int totalScore = 0; int perfectScores = 0; - + for (Page page : table.query(queryConditional)) { for (NewsQuizResult result : page.items()) { totalQuizzes++; @@ -110,11 +115,11 @@ public QuizStats getUserQuizStats(String userId) { } } } - + int avgScore = totalQuizzes > 0 ? totalScore / totalQuizzes : 0; return new QuizStats(totalQuizzes, avgScore, perfectScores); } - + /** * 퀴즈 통계 레코드 */ @@ -122,5 +127,6 @@ public record QuizStats( int totalQuizzes, int avgScore, int perfectScores - ) {} + ) { + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsWordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsWordRepository.java index 5dfebc80..be899b98 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsWordRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsWordRepository.java @@ -7,7 +7,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.enhanced.dynamodb.*; -import software.amazon.awssdk.enhanced.dynamodb.model.*; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; import java.util.ArrayList; import java.util.List; @@ -17,22 +19,22 @@ * 뉴스 단어 수집 Repository */ public class NewsWordRepository { - + private static final Logger logger = LoggerFactory.getLogger(NewsWordRepository.class); private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); - + private final DynamoDbTable table; private final DynamoDbIndex gsi1Index; - + public NewsWordRepository() { this(AwsClients.dynamoDbEnhanced()); } - + public NewsWordRepository(DynamoDbEnhancedClient enhancedClient) { this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(NewsWordCollect.class)); this.gsi1Index = table.index("GSI1"); } - + /** * 단어 수집 저장 */ @@ -40,7 +42,7 @@ public void save(NewsWordCollect wordCollect) { table.putItem(wordCollect); logger.debug("단어 수집 저장: userId={}, word={}", wordCollect.getUserId(), wordCollect.getWord()); } - + /** * 단어 수집 조회 */ @@ -49,18 +51,18 @@ public Optional findByUserWordArticle(String userId, String wor .partitionValue(NewsKey.userNewsPk(userId)) .sortValue(NewsKey.wordSk(word, articleId)) .build(); - + NewsWordCollect result = table.getItem(key); return Optional.ofNullable(result); } - + /** * 이미 수집했는지 확인 */ public boolean hasCollected(String userId, String word, String articleId) { return findByUserWordArticle(userId, word, articleId).isPresent(); } - + /** * 단어 수집 삭제 */ @@ -69,11 +71,11 @@ public void delete(String userId, String word, String articleId) { .partitionValue(NewsKey.userNewsPk(userId)) .sortValue(NewsKey.wordSk(word, articleId)) .build(); - + table.deleteItem(key); logger.debug("단어 수집 삭제: userId={}, word={}", userId, word); } - + /** * 사용자 수집 단어 목록 조회 (최신순) */ @@ -83,22 +85,22 @@ public List getUserWords(String userId, int limit) { .partitionValue(NewsKey.userNewsWordsPk(userId)) .build() ); - + QueryEnhancedRequest request = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) .limit(limit) .build(); - + List results = new ArrayList<>(); for (Page page : gsi1Index.query(request)) { results.addAll(page.items()); if (results.size() >= limit) break; } - + return results.subList(0, Math.min(results.size(), limit)); } - + /** * 사용자 수집 단어 수 조회 */ @@ -109,14 +111,14 @@ public int countUserWords(String userId) { .sortValue("WORD#") .build() ); - + int count = 0; for (Page page : table.query(queryConditional)) { count += page.items().size(); } return count; } - + /** * Vocabulary 연동 상태 업데이트 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java index 25f8e651..febc8895 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java @@ -6,9 +6,13 @@ import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.*; -import software.amazon.awssdk.enhanced.dynamodb.model.*; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; import java.time.Instant; import java.time.LocalDate; @@ -18,27 +22,27 @@ * 사용자 뉴스 학습 기록 Repository */ public class UserNewsRepository { - + private static final Logger logger = LoggerFactory.getLogger(UserNewsRepository.class); private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); - + private final DynamoDbTable table; - + public UserNewsRepository() { this(AwsClients.dynamoDbEnhanced()); } - + public UserNewsRepository(DynamoDbEnhancedClient enhancedClient) { this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(UserNewsRecord.class)); } - + /** * 읽기 기록 저장 */ public void saveReadRecord(String userId, String articleId, String title, String level, String category) { String now = Instant.now().toString(); String today = LocalDate.now().toString(); - + UserNewsRecord record = UserNewsRecord.builder() .pk(NewsKey.userNewsPk(userId)) .sk(NewsKey.readSk(articleId)) @@ -52,18 +56,18 @@ public void saveReadRecord(String userId, String articleId, String title, String .articleCategory(category) .createdAt(now) .build(); - + table.putItem(record); logger.debug("읽기 기록 저장: userId={}, articleId={}", userId, articleId); } - + /** * 북마크 저장 */ public void saveBookmark(String userId, String articleId, String title, String level, String category) { String now = Instant.now().toString(); String today = LocalDate.now().toString(); - + UserNewsRecord record = UserNewsRecord.builder() .pk(NewsKey.userNewsPk(userId)) .sk(NewsKey.bookmarkSk(articleId)) @@ -77,11 +81,11 @@ public void saveBookmark(String userId, String articleId, String title, String l .articleCategory(category) .createdAt(now) .build(); - + table.putItem(record); logger.debug("북마크 저장: userId={}, articleId={}", userId, articleId); } - + /** * 북마크 삭제 */ @@ -90,11 +94,11 @@ public void deleteBookmark(String userId, String articleId) { .partitionValue(NewsKey.userNewsPk(userId)) .sortValue(NewsKey.bookmarkSk(articleId)) .build(); - + table.deleteItem(key); logger.debug("북마크 삭제: userId={}, articleId={}", userId, articleId); } - + /** * 북마크 여부 확인 */ @@ -103,10 +107,10 @@ public boolean isBookmarked(String userId, String articleId) { .partitionValue(NewsKey.userNewsPk(userId)) .sortValue(NewsKey.bookmarkSk(articleId)) .build(); - + return table.getItem(key) != null; } - + /** * 읽기 기록 여부 확인 */ @@ -115,10 +119,10 @@ public boolean hasRead(String userId, String articleId) { .partitionValue(NewsKey.userNewsPk(userId)) .sortValue(NewsKey.readSk(articleId)) .build(); - + return table.getItem(key) != null; } - + /** * 사용자 북마크 목록 조회 */ @@ -129,22 +133,35 @@ public List getUserBookmarks(String userId, int limit) { .sortValue("BOOKMARK#") .build() ); - + QueryEnhancedRequest request = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) .limit(limit) .build(); - + List results = new ArrayList<>(); for (Page page : table.query(request)) { results.addAll(page.items()); if (results.size() >= limit) break; } - + return results.subList(0, Math.min(results.size(), limit)); } - + + /** + * 여러 기사의 북마크 여부 확인 (배치) + */ + public Set getBookmarkedArticleIds(String userId, List articleIds) { + Set bookmarkedIds = new HashSet<>(); + for (String articleId : articleIds) { + if (isBookmarked(userId, articleId)) { + bookmarkedIds.add(articleId); + } + } + return bookmarkedIds; + } + /** * 사용자 뉴스 통계 조회 */ @@ -152,20 +169,20 @@ public NewsStats getUserStats(String userId) { QueryConditional queryConditional = QueryConditional.keyEqualTo( Key.builder().partitionValue(NewsKey.userNewsPk(userId)).build() ); - + int totalRead = 0; int thisWeekRead = 0; int totalBookmarks = 0; Map byLevel = new HashMap<>(); Map byCategory = new HashMap<>(); - + LocalDate weekAgo = LocalDate.now().minusDays(7); - + for (Page page : table.query(queryConditional)) { for (UserNewsRecord record : page.items()) { if ("READ".equals(record.getType())) { totalRead++; - + // 이번 주 읽은 것 if (record.getCreatedAt() != null) { LocalDate readDate = Instant.parse(record.getCreatedAt()) @@ -174,13 +191,13 @@ public NewsStats getUserStats(String userId) { thisWeekRead++; } } - + // 레벨별 통계 String level = record.getArticleLevel(); if (level != null) { byLevel.merge(level, 1, Integer::sum); } - + // 카테고리별 통계 String category = record.getArticleCategory(); if (category != null) { @@ -191,10 +208,10 @@ public NewsStats getUserStats(String userId) { } } } - + return new NewsStats(totalRead, thisWeekRead, totalBookmarks, byLevel, byCategory); } - + /** * 뉴스 통계 레코드 */ @@ -204,5 +221,6 @@ public record NewsStats( int totalBookmarks, Map byLevel, Map byCategory - ) {} + ) { + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java index af23fc5b..4badd15f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java @@ -13,11 +13,13 @@ import software.amazon.awssdk.core.SdkBytes; import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelRequest; import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelResponse; -import software.amazon.awssdk.services.comprehend.model.*; +import software.amazon.awssdk.services.comprehend.model.DetectKeyPhrasesRequest; +import software.amazon.awssdk.services.comprehend.model.DetectKeyPhrasesResponse; +import software.amazon.awssdk.services.comprehend.model.KeyPhrase; +import software.amazon.awssdk.services.comprehend.model.LanguageCode; import java.util.ArrayList; import java.util.List; -import java.util.stream.Collectors; /** * 뉴스 AI 분석 서비스 @@ -27,49 +29,54 @@ * - 퀴즈 생성 (Bedrock) */ public class NewsAnalysisService { - + private static final Logger logger = LoggerFactory.getLogger(NewsAnalysisService.class); private static final Gson gson = new Gson(); private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; - + private final NewsArticleRepository articleRepository; - + public NewsAnalysisService() { this.articleRepository = new NewsArticleRepository(); } - + public NewsAnalysisService(NewsArticleRepository articleRepository) { this.articleRepository = articleRepository; } - + /** * 뉴스 기사 전체 분석 */ public NewsArticle analyzeArticle(NewsArticle article) { logger.info("뉴스 분석 시작: {}", article.getArticleId()); long startTime = System.currentTimeMillis(); - + String content = article.getTitle() + ". " + (article.getSummary() != null ? article.getSummary() : ""); - + try { // 1. CEFR 난이도 분석 String cefrLevel = analyzeDifficulty(content); article.setCefrLevel(cefrLevel); article.setLevel(mapCefrToLevel(cefrLevel)); - - // 2. 핵심 단어 추출 (Comprehend) - List keywords = extractKeywords(content); - article.setKeywords(keywords); - - // 3. 3줄 요약 + 퀴즈 생성 (Bedrock - 한 번에 처리) + + // 2. 3줄 요약 + 키워드 + 퀴즈 생성 (Bedrock - 한 번에 처리) AnalysisResult result = generateSummaryAndQuiz(content, cefrLevel); if (result.summary() != null) { article.setSummary(result.summary()); } article.setQuiz(result.quiz()); article.setHighlightWords(result.highlightWords()); - + + // Bedrock 키워드 사용 (meaningKo 포함) + if (result.keywords() != null && !result.keywords().isEmpty()) { + article.setKeywords(result.keywords()); + } else { + // fallback: Comprehend로 키워드 추출 + List keywords = extractKeywords(content); + article.setKeywords(keywords); + } + // 4. GSI 키 설정 article.setGsi1pk("LEVEL#" + article.getLevel()); article.setGsi1sk(article.getPublishedAt()); @@ -77,13 +84,13 @@ public NewsArticle analyzeArticle(NewsArticle article) { article.setGsi2pk("CATEGORY#" + article.getCategory()); article.setGsi2sk(article.getPublishedAt()); } - + // 5. 저장 articleRepository.save(article); - + long elapsed = System.currentTimeMillis() - startTime; logger.info("뉴스 분석 완료: {} ({}ms)", article.getArticleId(), elapsed); - + } catch (Exception e) { logger.error("뉴스 분석 실패: {}", article.getArticleId(), e); // 분석 실패해도 기본값으로 저장 @@ -91,10 +98,10 @@ public NewsArticle analyzeArticle(NewsArticle article) { article.setCefrLevel("B1"); articleRepository.save(article); } - + return article; } - + /** * CEFR 난이도 분석 (Bedrock) */ @@ -102,31 +109,31 @@ private String analyzeDifficulty(String content) { String systemPrompt = """ You are an English language expert. Analyze the text and determine its CEFR level. Consider vocabulary complexity, sentence structure, and topic familiarity. - + Respond with ONLY the CEFR level code: A1, A2, B1, B2, C1, or C2 No explanation, just the level code. """; - + String userPrompt = "Determine the CEFR level of this text:\n\n" + truncate(content, 1000); - + String response = invokeBedrock(systemPrompt, userPrompt); String level = response.trim().toUpperCase(); - + // 유효한 레벨인지 확인 if (List.of("A1", "A2", "B1", "B2", "C1", "C2").contains(level)) { return level; } - + // 레벨 추출 시도 for (String validLevel : List.of("C2", "C1", "B2", "B1", "A2", "A1")) { if (response.toUpperCase().contains(validLevel)) { return validLevel; } } - + return "B1"; // 기본값 } - + /** * CEFR을 3단계 레벨로 매핑 */ @@ -138,7 +145,7 @@ private String mapCefrToLevel(String cefrLevel) { default -> "INTERMEDIATE"; }; } - + /** * 핵심 단어 추출 (Comprehend) */ @@ -150,10 +157,10 @@ private List extractKeywords(String content) { .languageCode(LanguageCode.EN) .build() ); - + List keywords = new ArrayList<>(); List phrases = response.keyPhrases(); - + for (int i = 0; i < Math.min(phrases.size(), 10); i++) { KeyPhrase phrase = phrases.get(i); if (phrase.score() > 0.8) { @@ -163,25 +170,29 @@ private List extractKeywords(String content) { .build()); } } - + return keywords; - + } catch (Exception e) { logger.error("키워드 추출 실패", e); return new ArrayList<>(); } } - + /** * 요약 + 퀴즈 생성 (Bedrock) */ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) { String systemPrompt = """ - You are an English learning assistant. Analyze the news article and create learning materials. - + You are an English learning assistant for Korean learners. Analyze the news article and create learning materials. + Respond in this exact JSON format: { "summary": "3-line summary in English (each line separated by newline)", + "keywords": [ + {"word": "economy", "meaning": "the system of trade and industry", "meaningKo": "경제", "example": "The economy is growing steadily."}, + {"word": "policy", "meaning": "a plan of action adopted by government", "meaningKo": "정책", "example": "The new policy affects all citizens."} + ], "highlightWords": ["word1", "word2", "word3"], "quiz": [ { @@ -210,22 +221,29 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) } ] } - - Create exactly 3 quiz questions. - highlightWords should contain 3-5 difficult words for learners. - Adjust difficulty based on CEFR level: """ + cefrLevel; - + + IMPORTANT: + - keywords: Extract 5-8 important vocabulary words from the article. Include: + - word: the English word + - meaning: simple English definition + - meaningKo: Korean translation of the word (한국어 뜻) + - example: example sentence from the article + - highlightWords: 3-5 difficult words that learners should pay attention to (just the words, no definitions). + - category: Choose EXACTLY ONE from: WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE + - Create exactly 3 quiz questions. + - Adjust difficulty based on CEFR level: """ + cefrLevel; + String userPrompt = "Create learning materials for this article:\n\n" + truncate(content, 1500); - + try { String response = invokeBedrock(systemPrompt, userPrompt); return parseAnalysisResult(response); } catch (Exception e) { logger.error("요약/퀴즈 생성 실패", e); - return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>()); + return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>(), new ArrayList<>()); } } - + /** * Bedrock API 호출 */ @@ -234,46 +252,61 @@ private String invokeBedrock(String systemPrompt, String userPrompt) { requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); requestBody.addProperty("max_tokens", 2000); requestBody.addProperty("system", systemPrompt); - + JsonArray messages = new JsonArray(); JsonObject userMessage = new JsonObject(); userMessage.addProperty("role", "user"); userMessage.addProperty("content", userPrompt); messages.add(userMessage); requestBody.add("messages", messages); - + InvokeModelRequest request = InvokeModelRequest.builder() .modelId(MODEL_ID) .contentType("application/json") .accept("application/json") .body(SdkBytes.fromUtf8String(gson.toJson(requestBody))) .build(); - + InvokeModelResponse response = AwsClients.bedrock().invokeModel(request); JsonObject jsonResponse = gson.fromJson(response.body().asUtf8String(), JsonObject.class); - + JsonArray contentArray = jsonResponse.getAsJsonArray("content"); if (contentArray != null && !contentArray.isEmpty()) { return contentArray.get(0).getAsJsonObject().get("text").getAsString(); } - + throw new RuntimeException("Empty response from Bedrock"); } - + /** * 분석 결과 파싱 */ private AnalysisResult parseAnalysisResult(String response) { String jsonStr = extractJson(response); JsonObject json = gson.fromJson(jsonStr, JsonObject.class); - + String summary = json.has("summary") ? json.get("summary").getAsString() : null; - + String category = json.has("category") ? json.get("category").getAsString().toUpperCase() : "WORLD"; + + // keywords 파싱 + List keywords = new ArrayList<>(); + if (json.has("keywords")) { + json.getAsJsonArray("keywords").forEach(e -> { + JsonObject k = e.getAsJsonObject(); + keywords.add(KeywordInfo.builder() + .word(k.has("word") ? k.get("word").getAsString() : "") + .meaning(k.has("meaning") ? k.get("meaning").getAsString() : "") + .meaningKo(k.has("meaningKo") ? k.get("meaningKo").getAsString() : "") + .example(k.has("example") ? k.get("example").getAsString() : "") + .build()); + }); + } + List highlightWords = new ArrayList<>(); if (json.has("highlightWords")) { json.getAsJsonArray("highlightWords").forEach(e -> highlightWords.add(e.getAsString())); } - + List quiz = new ArrayList<>(); if (json.has("quiz")) { json.getAsJsonArray("quiz").forEach(e -> { @@ -292,10 +325,10 @@ private AnalysisResult parseAnalysisResult(String response) { .build()); }); } - - return new AnalysisResult(summary, highlightWords, quiz); + + return new AnalysisResult(summary, keywords, highlightWords, quiz); } - + private String extractJson(String response) { int start = response.indexOf('{'); int end = response.lastIndexOf('}'); @@ -304,18 +337,20 @@ private String extractJson(String response) { } return response; } - + private String truncate(String text, int maxLength) { if (text == null) return ""; return text.length() > maxLength ? text.substring(0, maxLength) : text; } - + /** * 분석 결과 레코드 */ private record AnalysisResult( String summary, + List keywords, List highlightWords, List quiz - ) {} + ) { + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java index ecac47df..1709fc42 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java @@ -18,41 +18,41 @@ * RSS 피드에서 뉴스를 수집하고 저장 (BBC, VOA, NPR) */ public class NewsCollectorService { - + private static final Logger logger = LoggerFactory.getLogger(NewsCollectorService.class); - + private static final int RSS_LIMIT_PER_SOURCE = 7; private static final long TTL_DAYS = 30; - + private final RssFeedParser rssFeedParser; private final NewsDuplicateChecker duplicateChecker; private final NewsArticleRepository articleRepository; private final NewsAnalysisService analysisService; - + public NewsCollectorService() { this.rssFeedParser = new RssFeedParser(); this.duplicateChecker = new NewsDuplicateChecker(); this.articleRepository = new NewsArticleRepository(); this.analysisService = new NewsAnalysisService(); } - + public NewsCollectorService(RssFeedParser rssFeedParser, - NewsDuplicateChecker duplicateChecker, - NewsArticleRepository articleRepository, - NewsAnalysisService analysisService) { + NewsDuplicateChecker duplicateChecker, + NewsArticleRepository articleRepository, + NewsAnalysisService analysisService) { this.rssFeedParser = rssFeedParser; this.duplicateChecker = duplicateChecker; this.articleRepository = articleRepository; this.analysisService = analysisService; } - + /** * 뉴스 수집 실행 */ public CollectionResult collectNews() { logger.info("뉴스 수집 시작"); long startTime = System.currentTimeMillis(); - + List rssArticles; try { rssArticles = rssFeedParser.fetchAllFeeds(RSS_LIMIT_PER_SOURCE); @@ -61,16 +61,16 @@ public CollectionResult collectNews() { logger.error("RSS 수집 실패", e); return new CollectionResult(0, 0, System.currentTimeMillis() - startTime); } - + List uniqueArticles = duplicateChecker.filterDuplicates(rssArticles); logger.info("중복 제거 후 {}개 기사", uniqueArticles.size()); - + int savedCount = 0; int analyzedCount = 0; for (RawNewsArticle rawArticle : uniqueArticles) { try { NewsArticle article = convertToNewsArticle(rawArticle); - + // AI 분석 수행 (난이도, 요약, 키워드, 퀴즈) analysisService.analyzeArticle(article); analyzedCount++; @@ -79,13 +79,13 @@ public CollectionResult collectNews() { logger.error("기사 처리 실패: {}", rawArticle.getTitle(), e); } } - + long elapsed = System.currentTimeMillis() - startTime; logger.info("뉴스 수집/분석 완료 - 저장: {}, 분석: {}, 소요시간: {}ms", savedCount, analyzedCount, elapsed); - + return new CollectionResult(rssArticles.size(), savedCount, elapsed); } - + /** * RawNewsArticle을 NewsArticle로 변환 * AI 분석은 별도 Story에서 처리 @@ -94,12 +94,12 @@ private NewsArticle convertToNewsArticle(RawNewsArticle raw) { String today = LocalDate.now().toString(); String articleId = UUID.randomUUID().toString().substring(0, 8); String now = Instant.now().toString(); - + long ttlEpoch = Instant.now() .atOffset(ZoneOffset.UTC) .plusDays(TTL_DAYS) .toEpochSecond(); - + return NewsArticle.builder() .pk(NewsKey.newsPk(today)) .sk(NewsKey.articleSk(articleId)) @@ -116,7 +116,7 @@ private NewsArticle convertToNewsArticle(RawNewsArticle raw) { .ttl(ttlEpoch) .build(); } - + /** * 수집 결과 레코드 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsDuplicateChecker.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsDuplicateChecker.java index d4eedd82..b939c7bc 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsDuplicateChecker.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsDuplicateChecker.java @@ -11,21 +11,17 @@ import software.amazon.awssdk.services.dynamodb.model.QueryResponse; import java.time.LocalDate; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; +import java.util.*; /** * 뉴스 중복 검사 서비스 * URL 기반으로 중복 뉴스 필터링 */ public class NewsDuplicateChecker { - + private static final Logger logger = LoggerFactory.getLogger(NewsDuplicateChecker.class); private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); - + /** * 중복 뉴스 필터링 */ @@ -33,38 +29,38 @@ public List filterDuplicates(List articles) { if (articles.isEmpty()) { return articles; } - + Set existingUrls = getExistingUrls(); Set seenUrls = new HashSet<>(); List uniqueArticles = new ArrayList<>(); - + for (RawNewsArticle article : articles) { String url = article.getUrl(); if (url == null) { continue; } - + if (!existingUrls.contains(url) && !seenUrls.contains(url)) { uniqueArticles.add(article); seenUrls.add(url); } } - + int duplicateCount = articles.size() - uniqueArticles.size(); if (duplicateCount > 0) { logger.info("{}개 중복 기사 필터링됨", duplicateCount); } - + return uniqueArticles; } - + /** * 오늘 날짜의 기존 뉴스 URL 조회 */ private Set getExistingUrls() { Set urls = new HashSet<>(); String today = LocalDate.now().toString(); - + try { QueryRequest request = QueryRequest.builder() .tableName(TABLE_NAME) @@ -74,21 +70,21 @@ private Set getExistingUrls() { )) .projectionExpression("originalUrl") .build(); - + QueryResponse response = AwsClients.dynamoDb().query(request); - + for (Map item : response.items()) { if (item.containsKey("originalUrl")) { urls.add(item.get("originalUrl").s()); } } - + logger.debug("기존 뉴스 {}개 URL 로드됨", urls.size()); - + } catch (Exception e) { logger.error("기존 뉴스 URL 조회 실패", e); } - + return urls; } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java index 8eba8522..b851c717 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java @@ -1,17 +1,20 @@ package com.mzc.secondproject.serverless.domain.news.service; -import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.service.PollyService; +import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; +import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; +import com.mzc.secondproject.serverless.domain.news.config.NewsConfig; +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; import com.mzc.secondproject.serverless.domain.news.repository.UserNewsRepository; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; +import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.util.*; /** * 뉴스 학습 부가 기능 서비스 @@ -19,82 +22,79 @@ public class NewsLearningService { private static final Logger logger = LoggerFactory.getLogger(NewsLearningService.class); - private static final String BUCKET_NAME = EnvConfig.getOrDefault("NEWS_BUCKET_NAME", "group2-englishstudy"); private final NewsArticleRepository articleRepository; private final UserNewsRepository userNewsRepository; private final PollyService pollyService; + private final UserStatsRepository userStatsRepository; + private final BadgeService badgeService; public NewsLearningService() { this.articleRepository = new NewsArticleRepository(); this.userNewsRepository = new UserNewsRepository(); - this.pollyService = new PollyService(BUCKET_NAME, "news/audio/"); + this.pollyService = new PollyService(NewsConfig.bucketName(), NewsConfig.TTS_AUDIO_PREFIX); + this.userStatsRepository = new UserStatsRepository(); + this.badgeService = new BadgeService(); } public NewsLearningService(NewsArticleRepository articleRepository, UserNewsRepository userNewsRepository, - PollyService pollyService) { + PollyService pollyService, + UserStatsRepository userStatsRepository, + BadgeService badgeService) { this.articleRepository = articleRepository; this.userNewsRepository = userNewsRepository; this.pollyService = pollyService; + this.userStatsRepository = userStatsRepository; + this.badgeService = badgeService; } /** * 뉴스 읽기 완료 기록 + * + * @return 새로 획득한 배지 목록 */ - public void markAsRead(String userId, String articleId) { - Optional article = articleRepository.findById(articleId); - if (article.isEmpty()) { + public List markAsRead(String userId, String articleId) { + Optional articleOpt = articleRepository.findById(articleId); + if (articleOpt.isEmpty()) { logger.warn("기사를 찾을 수 없음: {}", articleId); - return; + return List.of(); } - NewsArticle a = article.get(); - userNewsRepository.saveReadRecord( - userId, - articleId, - a.getTitle(), - a.getLevel(), - a.getCategory() - ); - - // 조회수 증가 - String date = extractDateFromPk(a.getPk()); - if (date != null) { - articleRepository.incrementReadCount(date, articleId); + if (userNewsRepository.hasRead(userId, articleId)) { + logger.debug("이미 읽은 기사: userId={}, articleId={}", userId, articleId); + return List.of(); } + NewsArticle article = articleOpt.get(); + saveReadRecord(userId, article); + incrementArticleReadCount(article); + logger.info("읽기 완료 기록: userId={}, articleId={}", userId, articleId); + + return updateStatsAndCheckBadges(userId); } /** * 북마크 토글 */ public boolean toggleBookmark(String userId, String articleId) { - boolean isBookmarked = userNewsRepository.isBookmarked(userId, articleId); - - if (isBookmarked) { + if (userNewsRepository.isBookmarked(userId, articleId)) { userNewsRepository.deleteBookmark(userId, articleId); logger.info("북마크 해제: userId={}, articleId={}", userId, articleId); return false; - } else { - Optional article = articleRepository.findById(articleId); - if (article.isEmpty()) { - logger.warn("기사를 찾을 수 없음: {}", articleId); - return false; - } + } - NewsArticle a = article.get(); - userNewsRepository.saveBookmark( - userId, - articleId, - a.getTitle(), - a.getLevel(), - a.getCategory() - ); - logger.info("북마크 추가: userId={}, articleId={}", userId, articleId); - return true; + Optional articleOpt = articleRepository.findById(articleId); + if (articleOpt.isEmpty()) { + logger.warn("기사를 찾을 수 없음: {}", articleId); + return false; } + + NewsArticle article = articleOpt.get(); + userNewsRepository.saveBookmark(userId, articleId, article.getTitle(), article.getLevel(), article.getCategory()); + logger.info("북마크 추가: userId={}, articleId={}", userId, articleId); + return true; } /** @@ -105,29 +105,45 @@ public boolean isBookmarked(String userId, String articleId) { } /** - * 사용자 북마크 목록 조회 + * 읽기 여부 확인 + */ + public boolean hasRead(String userId, String articleId) { + return userNewsRepository.hasRead(userId, articleId); + } + + /** + * 여러 기사의 북마크 여부 확인 (배치) + */ + public Set getBookmarkedArticleIds(String userId, List articleIds) { + return userNewsRepository.getBookmarkedArticleIds(userId, articleIds); + } + + /** + * 사용자 북마크 목록 조회 (기사 정보 포함) */ - public List getUserBookmarks(String userId, int limit) { - return userNewsRepository.getUserBookmarks(userId, limit); + public List> getUserBookmarks(String userId, int limit) { + List bookmarks = userNewsRepository.getUserBookmarks(userId, limit); + + return bookmarks.stream() + .map(bookmark -> articleRepository.findById(bookmark.getArticleId()) + .map(article -> buildBookmarkResponse(article, bookmark)) + .orElse(null)) + .filter(Objects::nonNull) + .toList(); } /** * 뉴스 TTS 오디오 URL 생성 */ public String getAudioUrl(String articleId, String voice) { - Optional article = articleRepository.findById(articleId); - if (article.isEmpty()) { + Optional articleOpt = articleRepository.findById(articleId); + if (articleOpt.isEmpty()) { logger.warn("기사를 찾을 수 없음: {}", articleId); return null; } - NewsArticle a = article.get(); - String text = a.getTitle() + ". " + (a.getSummary() != null ? a.getSummary() : ""); - - // 텍스트가 너무 길면 제한 - if (text.length() > 3000) { - text = text.substring(0, 3000); - } + NewsArticle article = articleOpt.get(); + String text = buildTtsText(article); PollyService.VoiceSynthesisResult result = pollyService.synthesizeSpeech(articleId, text, voice); return result.getAudioUrl(); @@ -148,13 +164,63 @@ public Map getUserStats(String userId) { ); } - /** - * PK에서 날짜 추출 - */ - private String extractDateFromPk(String pk) { - if (pk == null || !pk.startsWith("NEWS#")) { - return null; + // ========== Private Helper Methods ========== + + private void saveReadRecord(String userId, NewsArticle article) { + userNewsRepository.saveReadRecord( + userId, + article.getArticleId(), + article.getTitle(), + article.getLevel(), + article.getCategory() + ); + } + + private void incrementArticleReadCount(NewsArticle article) { + String date = NewsKey.extractDateFromPk(article.getPk()); + if (date != null) { + articleRepository.incrementReadCount(date, article.getArticleId()); + } + } + + private List updateStatsAndCheckBadges(String userId) { + try { + UserStats updatedStats = userStatsRepository.incrementNewsReadStats(userId); + if (updatedStats != null) { + List newBadges = badgeService.checkAndAwardBadges(userId, updatedStats); + if (!newBadges.isEmpty()) { + logger.info("새 배지 획득: userId={}, badges={}", + userId, newBadges.stream().map(UserBadge::getBadgeType).toList()); + } + return newBadges; + } + } catch (Exception e) { + logger.error("통계/배지 업데이트 실패: userId={}, error={}", userId, e.getMessage()); + } + return List.of(); + } + + private Map buildBookmarkResponse(NewsArticle article, UserNewsRecord bookmark) { + Map response = new HashMap<>(); + response.put("articleId", article.getArticleId()); + response.put("title", article.getTitle()); + response.put("summary", article.getSummary()); + response.put("source", article.getSource()); + response.put("publishedAt", article.getPublishedAt()); + response.put("keywords", article.getKeywords()); + response.put("highlightWords", article.getHighlightWords()); + response.put("category", article.getCategory()); + response.put("level", article.getLevel()); + response.put("imageUrl", article.getImageUrl()); + response.put("bookmarkedAt", bookmark.getCreatedAt()); + return response; + } + + private String buildTtsText(NewsArticle article) { + String text = article.getTitle() + ". " + (article.getSummary() != null ? article.getSummary() : ""); + if (text.length() > NewsConfig.TTS_MAX_TEXT_LENGTH) { + text = text.substring(0, NewsConfig.TTS_MAX_TEXT_LENGTH); } - return pk.substring(5); + return text; } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java index 5a3930ed..99f0e0ac 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.news.service; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; import org.slf4j.Logger; @@ -33,24 +34,28 @@ public Optional getArticle(String articleId) { logger.debug("뉴스 상세 조회: {}", articleId); Optional article = articleRepository.findById(articleId); - // 조회수 증가 - article.ifPresent(a -> { - String date = extractDateFromPk(a.getPk()); - if (date != null) { - articleRepository.incrementReadCount(date, articleId); - } - }); + article.ifPresent(this::incrementReadCount); return article; } /** - * 오늘의 뉴스 목록 조회 + * 오늘의 뉴스 목록 조회 (오늘 기사 없으면 어제 기사 조회) */ public PaginatedResult getTodayNews(int limit, String cursor) { String today = LocalDate.now().toString(); logger.debug("오늘의 뉴스 조회: date={}, limit={}", today, limit); - return articleRepository.findByDate(today, limit, cursor); + + PaginatedResult result = articleRepository.findByDate(today, limit, cursor); + + // 오늘 기사가 없으면 어제 기사 조회 + if (result.items().isEmpty() && cursor == null) { + String yesterday = LocalDate.now().minusDays(1).toString(); + logger.debug("오늘 기사 없음, 어제 기사 조회: date={}", yesterday); + result = articleRepository.findByDate(yesterday, limit, cursor); + } + + return result; } /** @@ -82,17 +87,13 @@ public PaginatedResult getNewsByLevelAndCategory(String level, Stri */ public PaginatedResult getRecommendedNews(String userLevel, int limit, String cursor) { logger.debug("맞춤 뉴스 추천: userLevel={}, limit={}", userLevel, limit); - // 사용자 레벨에 맞는 뉴스 조회 return articleRepository.findByLevel(userLevel, limit, cursor); } - /** - * PK에서 날짜 추출 (NEWS#2024-01-15 → 2024-01-15) - */ - private String extractDateFromPk(String pk) { - if (pk == null || !pk.startsWith("NEWS#")) { - return null; + private void incrementReadCount(NewsArticle article) { + String date = NewsKey.extractDateFromPk(article.getPk()); + if (date != null) { + articleRepository.incrementReadCount(date, article.getArticleId()); } - return pk.substring(5); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java index bb22fc90..f84c3794 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java @@ -1,9 +1,14 @@ package com.mzc.secondproject.serverless.domain.news.service; +import com.mzc.secondproject.serverless.domain.news.config.NewsConfig; import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; -import com.mzc.secondproject.serverless.domain.news.model.*; +import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import com.mzc.secondproject.serverless.domain.news.model.NewsQuizResult; +import com.mzc.secondproject.serverless.domain.news.model.QuizAnswerResult; +import com.mzc.secondproject.serverless.domain.news.model.QuizQuestion; import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; import com.mzc.secondproject.serverless.domain.news.repository.NewsQuizRepository; +import com.mzc.secondproject.serverless.domain.notification.service.NotificationPublisher; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -15,22 +20,26 @@ * 뉴스 퀴즈 서비스 */ public class NewsQuizService { - + private static final Logger logger = LoggerFactory.getLogger(NewsQuizService.class); private final NewsArticleRepository articleRepository; private final NewsQuizRepository quizRepository; + private final NotificationPublisher notificationPublisher; public NewsQuizService() { this.articleRepository = new NewsArticleRepository(); this.quizRepository = new NewsQuizRepository(); + this.notificationPublisher = NotificationPublisher.getInstance(); } - public NewsQuizService(NewsArticleRepository articleRepository, NewsQuizRepository quizRepository) { + public NewsQuizService(NewsArticleRepository articleRepository, NewsQuizRepository quizRepository, + NotificationPublisher notificationPublisher) { this.articleRepository = articleRepository; this.quizRepository = quizRepository; + this.notificationPublisher = notificationPublisher; } - + /** * 퀴즈 조회 */ @@ -40,18 +49,18 @@ public Optional getQuiz(String articleId, String userId) { logger.warn("기사를 찾을 수 없음: {}", articleId); return Optional.empty(); } - + NewsArticle article = articleOpt.get(); List questions = article.getQuiz(); - + if (questions == null || questions.isEmpty()) { logger.warn("퀴즈가 없는 기사: {}", articleId); return Optional.empty(); } - + // 이미 제출했는지 확인 boolean submitted = quizRepository.hasSubmitted(userId, articleId); - + // 정답 제거한 퀴즈 반환 List questionViews = questions.stream() .map(q -> QuizQuestionView.builder() @@ -62,7 +71,7 @@ public Optional getQuiz(String articleId, String userId) { .points(q.getPoints()) .build()) .toList(); - + return Optional.of(QuizData.builder() .articleId(articleId) .articleTitle(article.getTitle()) @@ -72,7 +81,7 @@ public Optional getQuiz(String articleId, String userId) { .submitted(submitted) .build()); } - + /** * 퀴즈 제출 및 채점 */ @@ -82,42 +91,42 @@ public QuizSubmitResult submitQuiz(String userId, String articleId, List articleOpt = articleRepository.findById(articleId); if (articleOpt.isEmpty()) { logger.warn("기사를 찾을 수 없음: {}", articleId); return null; } - + NewsArticle article = articleOpt.get(); List questions = article.getQuiz(); - + if (questions == null || questions.isEmpty()) { logger.warn("퀴즈가 없는 기사: {}", articleId); return null; } - + // 정답 맵 생성 Map questionMap = new HashMap<>(); for (QuizQuestion q : questions) { questionMap.put(q.getQuestionId(), q); } - + // 채점 List answerResults = new ArrayList<>(); int earnedPoints = 0; int totalPoints = 0; - + for (QuizAnswer answer : answers) { QuizQuestion question = questionMap.get(answer.questionId()); if (question == null) continue; - + boolean correct = question.getCorrectAnswer().equalsIgnoreCase(answer.answer()); int points = correct ? question.getPoints() : 0; earnedPoints += points; totalPoints += question.getPoints(); - + answerResults.add(QuizAnswerResult.builder() .questionId(answer.questionId()) .type(question.getType()) @@ -127,14 +136,14 @@ public QuizSubmitResult submitQuiz(String userId, String articleId, List 0 ? (earnedPoints * 100) / totalPoints : 0; - + // 결과 저장 String now = Instant.now().toString(); String today = LocalDate.now().toString(); - + NewsQuizResult result = NewsQuizResult.builder() .pk(NewsKey.userNewsPk(userId)) .sk(NewsKey.quizSk(articleId)) @@ -151,12 +160,25 @@ public QuizSubmitResult submitQuiz(String userId, String articleId, List getQuizResult(String userId, String articleId) { return quizRepository.findByUserAndArticle(userId, articleId); } - + /** * 사용자 퀴즈 기록 목록 조회 */ public List getUserQuizHistory(String userId, int limit) { return quizRepository.getUserQuizResults(userId, limit); } - + /** * 사용자 퀴즈 통계 조회 */ @@ -192,24 +214,14 @@ public Map getUserQuizStats(String userId) { "perfectScores", stats.perfectScores() ); } - + /** * 피드백 생성 */ - private String generateFeedback(int score, List results) { - if (score == 100) { - return "Perfect! You understood the article completely."; - } else if (score >= 80) { - return "Great job! You have a solid understanding of the article."; - } else if (score >= 60) { - return "Good effort! Review the highlighted words for better comprehension."; - } else if (score >= 40) { - return "Keep practicing! Try reading the article again before retaking the quiz."; - } else { - return "Don't give up! Focus on vocabulary and main ideas."; - } + private String generateFeedback(int score) { + return NewsConfig.getFeedbackByScore(score); } - + /** * 퀴즈 데이터 (정답 제외) */ @@ -225,7 +237,7 @@ public static class QuizData { private int totalPoints; private boolean submitted; } - + /** * 퀴즈 문제 뷰 (정답 제외) */ @@ -240,12 +252,13 @@ public static class QuizQuestionView { private List options; private int points; } - + /** * 사용자 답변 */ - public record QuizAnswer(String questionId, String answer) {} - + public record QuizAnswer(String questionId, String answer) { + } + /** * 퀴즈 제출 결과 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java index 6c3c23ec..b38ce3ca 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java @@ -20,33 +20,33 @@ * 뉴스 단어 수집 서비스 */ public class NewsWordService { - + private static final Logger logger = LoggerFactory.getLogger(NewsWordService.class); - + private final NewsWordRepository newsWordRepository; private final NewsArticleRepository articleRepository; private final WordRepository wordRepository; private final UserWordCommandService userWordCommandService; - + public NewsWordService() { this.newsWordRepository = new NewsWordRepository(); this.articleRepository = new NewsArticleRepository(); this.wordRepository = new WordRepository(); this.userWordCommandService = new UserWordCommandService(); } - + public NewsWordService(NewsWordRepository newsWordRepository, - NewsArticleRepository articleRepository, - WordRepository wordRepository, - UserWordCommandService userWordCommandService) { + NewsArticleRepository articleRepository, + WordRepository wordRepository, + UserWordCommandService userWordCommandService) { this.newsWordRepository = newsWordRepository; this.articleRepository = articleRepository; this.wordRepository = wordRepository; this.userWordCommandService = userWordCommandService; } - + /** - * 단어 수집 + * 단어 수집 (자동으로 Word 테이블 + UserWord에 추가) */ public NewsWordCollect collectWord(String userId, String articleId, String word, String context) { // 이미 수집했는지 확인 @@ -54,19 +54,58 @@ public NewsWordCollect collectWord(String userId, String articleId, String word, logger.warn("이미 수집한 단어: userId={}, word={}", userId, word); return newsWordRepository.findByUserWordArticle(userId, word, articleId).orElse(null); } - + // 기사 조회 Optional articleOpt = articleRepository.findById(articleId); String articleTitle = articleOpt.map(NewsArticle::getTitle).orElse(""); - + String articleLevel = articleOpt.map(NewsArticle::getLevel).orElse("INTERMEDIATE"); + + // 기사 키워드에서 단어 정보 추출 + String meaningKo = ""; + String meaningEn = ""; + String example = ""; + if (articleOpt.isPresent() && articleOpt.get().getKeywords() != null) { + for (var keyword : articleOpt.get().getKeywords()) { + if (keyword.getWord() != null && keyword.getWord().equalsIgnoreCase(word)) { + meaningKo = keyword.getMeaningKo() != null ? keyword.getMeaningKo() : ""; + meaningEn = keyword.getMeaning() != null ? keyword.getMeaning() : ""; + example = keyword.getExample() != null ? keyword.getExample() : ""; + break; + } + } + } + // 단어 정보 조회 (Word 테이블에서) String wordId = word.toLowerCase().trim(); Optional wordOpt = wordRepository.findById(wordId); - String meaning = wordOpt.map(Word::getKorean).orElse(""); - String pronunciation = ""; - + String meaning = meaningKo; + + // Word 테이블에 없으면 자동 생성 + if (wordOpt.isEmpty() && !meaningKo.isEmpty()) { + String now = Instant.now().toString(); + Word newWord = Word.builder() + .pk("WORD#" + wordId) + .sk("METADATA") + .gsi1pk("LEVEL#" + articleLevel) + .gsi1sk("WORD#" + wordId) + .gsi2pk("CATEGORY#NEWS") + .gsi2sk("WORD#" + wordId) + .wordId(wordId) + .english(word) + .korean(meaningKo) + .example(example) + .level(articleLevel) + .category("NEWS") + .createdAt(now) + .build(); + wordRepository.save(newWord); + logger.info("Word 테이블에 단어 자동 추가: wordId={}, korean={}", wordId, meaningKo); + } else if (wordOpt.isPresent()) { + meaning = wordOpt.get().getKorean(); + } + String now = Instant.now().toString(); - + NewsWordCollect wordCollect = NewsWordCollect.builder() .pk(NewsKey.userNewsPk(userId)) .sk(NewsKey.wordSk(word, articleId)) @@ -75,20 +114,29 @@ public NewsWordCollect collectWord(String userId, String articleId, String word, .userId(userId) .word(word) .meaning(meaning) - .pronunciation(pronunciation) + .pronunciation("") .context(context) .articleId(articleId) .articleTitle(articleTitle) .collectedAt(now) - .syncedToVocab(false) + .syncedToVocab(true) // 자동 연동됨 + .vocabUserWordId(wordId) .build(); - + newsWordRepository.save(wordCollect); logger.info("단어 수집 완료: userId={}, word={}, articleId={}", userId, word, articleId); - + + // UserWord에 자동 추가 (NEW 상태로) + try { + userWordCommandService.updateWordStatus(userId, wordId, "NEW"); + logger.info("UserWord에 자동 추가: userId={}, wordId={}", userId, wordId); + } catch (Exception e) { + logger.warn("UserWord 추가 실패 (이미 존재할 수 있음): userId={}, wordId={}, error={}", userId, wordId, e.getMessage()); + } + return wordCollect; } - + /** * 수집한 단어 삭제 */ @@ -96,32 +144,32 @@ public void deleteWord(String userId, String word, String articleId) { newsWordRepository.delete(userId, word, articleId); logger.info("단어 삭제: userId={}, word={}", userId, word); } - + /** * 사용자 수집 단어 목록 조회 */ public List getUserWords(String userId, int limit) { return newsWordRepository.getUserWords(userId, limit); } - + /** * 사용자 수집 단어 수 조회 */ public int countUserWords(String userId) { return newsWordRepository.countUserWords(userId); } - + /** * 단어 상세 정보 조회 */ public Optional getWordDetail(String word) { String wordId = word.toLowerCase().trim(); Optional wordOpt = wordRepository.findById(wordId); - + if (wordOpt.isEmpty()) { return Optional.empty(); } - + Word w = wordOpt.get(); return Optional.of(WordDetail.builder() .word(w.getEnglish()) @@ -131,7 +179,7 @@ public Optional getWordDetail(String word) { .level(w.getLevel()) .build()); } - + /** * Vocabulary 도메인으로 단어 연동 */ @@ -141,34 +189,34 @@ public boolean syncToVocabulary(String userId, String word, String articleId) { logger.warn("수집한 단어를 찾을 수 없음: userId={}, word={}", userId, word); return false; } - + NewsWordCollect wordCollect = wordOpt.get(); - + // 이미 연동됐는지 확인 if (Boolean.TRUE.equals(wordCollect.getSyncedToVocab())) { logger.info("이미 Vocabulary에 연동됨: userId={}, word={}", userId, word); return true; } - + // Word 테이블에서 단어 조회 String wordId = word.toLowerCase().trim(); Optional vocabWord = wordRepository.findById(wordId); - + if (vocabWord.isEmpty()) { logger.warn("Vocabulary에 없는 단어: {}", word); return false; } - + // UserWord 생성 (NEW 상태로) userWordCommandService.updateWordStatus(userId, wordId, "NEW"); - + // 연동 상태 업데이트 newsWordRepository.updateSyncStatus(userId, word, articleId, wordId); - + logger.info("Vocabulary 연동 완료: userId={}, word={}", userId, word); return true; } - + /** * 사용자 단어 수집 통계 */ @@ -178,14 +226,14 @@ public Map getUserWordStats(String userId) { long syncedCount = recentWords.stream() .filter(w -> Boolean.TRUE.equals(w.getSyncedToVocab())) .count(); - + return Map.of( "totalCollected", totalWords, "recentWords", recentWords, "syncedToVocab", syncedCount ); } - + /** * 단어 상세 정보 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/RssFeedParser.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/RssFeedParser.java index ca2c98b8..bc7facd1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/RssFeedParser.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/RssFeedParser.java @@ -24,34 +24,34 @@ * BBC, VOA, NPR 등의 RSS 피드에서 뉴스 수집 */ public class RssFeedParser { - + private static final Logger logger = LoggerFactory.getLogger(RssFeedParser.class); - + private static final Map RSS_FEEDS = Map.of( "BBC", "https://feeds.bbci.co.uk/news/world/rss.xml", "VOA", "https://www.voanews.com/api/ziqpoe-mqm", "NPR", "https://feeds.npr.org/1001/rss.xml" ); - + private final HttpClient httpClient; - + public RssFeedParser() { this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .followRedirects(HttpClient.Redirect.NORMAL) .build(); } - + /** * 모든 RSS 피드에서 뉴스 수집 */ public List fetchAllFeeds(int maxPerSource) { List allArticles = new ArrayList<>(); - + for (Map.Entry entry : RSS_FEEDS.entrySet()) { String source = entry.getKey(); String feedUrl = entry.getValue(); - + try { List articles = fetchFeed(feedUrl, source, maxPerSource); allArticles.addAll(articles); @@ -60,16 +60,16 @@ public List fetchAllFeeds(int maxPerSource) { logger.error("{} RSS 피드 수집 실패: {}", source, e.getMessage()); } } - + return allArticles; } - + /** * 특정 RSS 피드에서 뉴스 수집 */ public List fetchFeed(String feedUrl, String source, int maxItems) { List articles = new ArrayList<>(); - + try { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(feedUrl)) @@ -77,22 +77,22 @@ public List fetchFeed(String feedUrl, String source, int maxItem .timeout(Duration.ofSeconds(30)) .GET() .build(); - + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); - + if (response.statusCode() != 200) { logger.error("RSS 피드 요청 실패 - url: {}, status: {}", feedUrl, response.statusCode()); return articles; } - + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); DocumentBuilder builder = factory.newDocumentBuilder(); Document document = builder.parse(response.body()); - + NodeList items = document.getElementsByTagName("item"); int count = Math.min(items.getLength(), maxItems); - + for (int i = 0; i < count; i++) { Element item = (Element) items.item(i); RawNewsArticle article = parseRssItem(item, source); @@ -100,14 +100,14 @@ public List fetchFeed(String feedUrl, String source, int maxItem articles.add(article); } } - + } catch (Exception e) { logger.error("RSS 피드 파싱 중 오류 발생 - url: {}", feedUrl, e); } - + return articles; } - + /** * RSS item 요소를 RawNewsArticle로 변환 */ @@ -121,7 +121,7 @@ private RawNewsArticle parseRssItem(Element item, String source) { .publishedAt(parsePublishedDate(getElementText(item, "pubDate"))) .build(); } - + /** * 요소에서 텍스트 추출 */ @@ -132,7 +132,7 @@ private String getElementText(Element parent, String tagName) { } return null; } - + /** * 이미지 URL 추출 (media:content, enclosure 등) */ @@ -142,7 +142,7 @@ private String extractImageUrl(Element item) { Element media = (Element) mediaContent.item(0); return media.getAttribute("url"); } - + NodeList enclosure = item.getElementsByTagName("enclosure"); if (enclosure.getLength() > 0) { Element enc = (Element) enclosure.item(0); @@ -151,16 +151,16 @@ private String extractImageUrl(Element item) { return enc.getAttribute("url"); } } - + NodeList mediaThumbnail = item.getElementsByTagName("media:thumbnail"); if (mediaThumbnail.getLength() > 0) { Element thumbnail = (Element) mediaThumbnail.item(0); return thumbnail.getAttribute("url"); } - + return null; } - + /** * RSS pubDate를 ISO 8601 형식으로 변환 */ @@ -170,7 +170,7 @@ private String parsePublishedDate(String pubDate) { } return pubDate; } - + /** * HTML 태그 제거 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/config/NotificationConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/config/NotificationConfig.java new file mode 100644 index 00000000..9bcbbb30 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/config/NotificationConfig.java @@ -0,0 +1,51 @@ +package com.mzc.secondproject.serverless.domain.notification.config; + +import com.mzc.secondproject.serverless.common.config.EnvConfig; + +/** + * 알림 시스템 설정 + * SSE 스트리밍, 폴링 등 알림 관련 상수 정의 + */ +public final class NotificationConfig { + + private NotificationConfig() { + } + + // ========== Environment Variables ========== + private static final String TOPIC_ARN = EnvConfig.get("NOTIFICATION_TOPIC_ARN"); + private static final String QUEUE_URL = EnvConfig.get("NOTIFICATION_QUEUE_URL"); + + // ========== SSE Streaming ========== + /** SSE 폴링 간격 (밀리초) */ + public static final int SSE_POLL_INTERVAL_MS = 1000; + + /** SSE 최대 스트림 지속 시간 (밀리초) - Lambda 15분 제한 고려 */ + public static final int SSE_MAX_DURATION_MS = 840_000; // 14분 + + /** SSE 최대 메시지 수신 개수 */ + public static final int SSE_MAX_MESSAGES_PER_POLL = 10; + + /** SSE 롱 폴링 대기 시간 (초) */ + public static final int SSE_WAIT_TIME_SECONDS = 1; + + // ========== SSE Event Types ========== + public static final String EVENT_HEARTBEAT = "HEARTBEAT"; + public static final String EVENT_STREAM_END = "STREAM_END"; + + // ========== Getter Methods ========== + public static String topicArn() { + return TOPIC_ARN; + } + + public static String queueUrl() { + return QUEUE_URL; + } + + public static boolean isTopicConfigured() { + return TOPIC_ARN != null && !TOPIC_ARN.isBlank(); + } + + public static boolean isQueueConfigured() { + return QUEUE_URL != null && !QUEUE_URL.isBlank(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/dto/NotificationMessage.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/dto/NotificationMessage.java new file mode 100644 index 00000000..cb073bd9 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/dto/NotificationMessage.java @@ -0,0 +1,60 @@ +package com.mzc.secondproject.serverless.domain.notification.dto; + +import com.mzc.secondproject.serverless.domain.notification.enums.NotificationType; + +import java.time.Instant; +import java.util.Map; + +/** + * 알림 메시지 DTO + * SNS로 발행되고 SSE로 클라이언트에 전달되는 메시지 구조 + */ +public record NotificationMessage( + String notificationId, + NotificationType type, + String userId, + Map payload, + String createdAt +) { + /** + * Builder 패턴으로 알림 메시지 생성 + */ + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private NotificationType type; + private String userId; + private Map payload; + + public Builder type(NotificationType type) { + this.type = type; + return this; + } + + public Builder userId(String userId) { + this.userId = userId; + return this; + } + + public Builder payload(Map payload) { + this.payload = payload; + return this; + } + + public NotificationMessage build() { + return new NotificationMessage( + generateNotificationId(), + type, + userId, + payload, + Instant.now().toString() + ); + } + + private String generateNotificationId() { + return "notif-" + java.util.UUID.randomUUID().toString().substring(0, 8); + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/enums/NotificationType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/enums/NotificationType.java new file mode 100644 index 00000000..87cf3e8c --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/enums/NotificationType.java @@ -0,0 +1,41 @@ +package com.mzc.secondproject.serverless.domain.notification.enums; + +/** + * 알림 타입 정의 + * 새로운 알림 타입 추가 시 여기에 enum 추가 + */ +public enum NotificationType { + // 배지 관련 + BADGE_EARNED("배지 획득", "badge"), + + // 학습 관련 + DAILY_COMPLETE("일일 학습 완료", "daily"), + STREAK_REMINDER("연속 학습 리마인더", "streak"), + + // 테스트/퀴즈 관련 + TEST_COMPLETE("테스트 완료", "test"), + NEWS_QUIZ_COMPLETE("뉴스 퀴즈 완료", "quiz"), + + // 게임 관련 + GAME_END("게임 종료", "game"), + GAME_STREAK("게임 연속 정답", "game"), + + // OPIc 관련 + OPIC_COMPLETE("OPIc 세션 완료", "opic"); + + private final String description; + private final String category; + + NotificationType(String description, String category) { + this.description = description; + this.category = category; + } + + public String getDescription() { + return description; + } + + public String getCategory() { + return category; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/NotificationStreamHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/NotificationStreamHandler.java new file mode 100644 index 00000000..385ddafb --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/NotificationStreamHandler.java @@ -0,0 +1,203 @@ +package com.mzc.secondproject.serverless.domain.notification.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestStreamHandler; +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.util.JsonUtil; +import com.mzc.secondproject.serverless.domain.notification.config.NotificationConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.sqs.SqsClient; +import software.amazon.awssdk.services.sqs.model.DeleteMessageRequest; +import software.amazon.awssdk.services.sqs.model.Message; +import software.amazon.awssdk.services.sqs.model.ReceiveMessageRequest; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +/** + * SSE(Server-Sent Events) 알림 스트리밍 Lambda Handler + * Lambda Function URL with Response Streaming을 사용하여 실시간 알림 제공 + * + * 클라이언트 연결 예시: + * const eventSource = new EventSource('https://{function-url}/?userId={userId}'); + * eventSource.onmessage = (event) => console.log(JSON.parse(event.data)); + */ +public class NotificationStreamHandler implements RequestStreamHandler { + + private static final Logger logger = LoggerFactory.getLogger(NotificationStreamHandler.class); + + private final SqsClient sqsClient; + + public NotificationStreamHandler() { + this.sqsClient = AwsClients.sqs(); + } + + public NotificationStreamHandler(SqsClient sqsClient) { + this.sqsClient = sqsClient; + } + + @Override + public void handleRequest(InputStream input, OutputStream output, Context context) throws IOException { + Map event = parseEvent(input); + String userId = extractUserId(event); + + if (userId == null || userId.isBlank()) { + sendErrorResponse(output, 400, "userId query parameter is required"); + return; + } + + logger.info("SSE connection started: userId={}, requestId={}", userId, context.getAwsRequestId()); + + try (BufferedOutputStream bufferedOutput = new BufferedOutputStream(output)) { + streamNotifications(bufferedOutput, userId); + } catch (Exception e) { + logger.error("SSE stream error: userId={}", userId, e); + } + } + + private void streamNotifications(BufferedOutputStream output, String userId) throws IOException { + writeSSEHeaders(output); + sendHeartbeat(output); + + long startTime = System.currentTimeMillis(); + + while (!isTimeoutReached(startTime)) { + List messages = pollMessages(); + + for (Message message : messages) { + if (isMessageForUser(message, userId)) { + sendSSEEvent(output, message.body()); + deleteMessage(message); + } + } + + if (messages.isEmpty()) { + sendHeartbeat(output); + } + + sleep(); + } + + sendStreamEndEvent(output); + logger.info("SSE connection ended: userId={} (timeout)", userId); + } + + private Map parseEvent(InputStream input) throws IOException { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + return JsonUtil.fromJson(sb.toString(), Map.class); + } + } + + @SuppressWarnings("unchecked") + private String extractUserId(Map event) { + Object queryParams = event.get("queryStringParameters"); + if (queryParams instanceof Map) { + Object userId = ((Map) queryParams).get("userId"); + return userId != null ? userId.toString() : null; + } + return null; + } + + private void writeSSEHeaders(OutputStream output) throws IOException { + String headers = "HTTP/1.1 200 OK\r\n" + + "Content-Type: text/event-stream\r\n" + + "Cache-Control: no-cache\r\n" + + "Connection: keep-alive\r\n" + + "Access-Control-Allow-Origin: *\r\n" + + "\r\n"; + output.write(headers.getBytes(StandardCharsets.UTF_8)); + output.flush(); + } + + private void sendSSEEvent(OutputStream output, String data) throws IOException { + String event = "data: " + data + "\n\n"; + output.write(event.getBytes(StandardCharsets.UTF_8)); + output.flush(); + } + + private void sendHeartbeat(OutputStream output) throws IOException { + String heartbeat = JsonUtil.toJson(Map.of( + "type", NotificationConfig.EVENT_HEARTBEAT, + "timestamp", System.currentTimeMillis() + )); + sendSSEEvent(output, heartbeat); + } + + private void sendStreamEndEvent(OutputStream output) throws IOException { + String endEvent = JsonUtil.toJson(Map.of( + "type", NotificationConfig.EVENT_STREAM_END, + "message", "Connection timeout" + )); + sendSSEEvent(output, endEvent); + } + + private void sendErrorResponse(OutputStream output, int statusCode, String message) throws IOException { + String response = JsonUtil.toJson(Map.of( + "statusCode", statusCode, + "body", JsonUtil.toJson(Map.of("error", message)) + )); + output.write(response.getBytes(StandardCharsets.UTF_8)); + output.flush(); + } + + private List pollMessages() { + if (!NotificationConfig.isQueueConfigured()) { + return List.of(); + } + + try { + ReceiveMessageRequest request = ReceiveMessageRequest.builder() + .queueUrl(NotificationConfig.queueUrl()) + .maxNumberOfMessages(NotificationConfig.SSE_MAX_MESSAGES_PER_POLL) + .waitTimeSeconds(NotificationConfig.SSE_WAIT_TIME_SECONDS) + .messageAttributeNames("userId", "type") + .build(); + + return sqsClient.receiveMessage(request).messages(); + } catch (Exception e) { + logger.warn("Failed to poll messages: {}", e.getMessage()); + return List.of(); + } + } + + private boolean isMessageForUser(Message message, String targetUserId) { + try { + Map body = JsonUtil.fromJson(message.body(), Map.class); + String messageUserId = (String) body.get("userId"); + return targetUserId.equals(messageUserId); + } catch (Exception e) { + return false; + } + } + + private void deleteMessage(Message message) { + try { + sqsClient.deleteMessage(DeleteMessageRequest.builder() + .queueUrl(NotificationConfig.queueUrl()) + .receiptHandle(message.receiptHandle()) + .build()); + } catch (Exception e) { + logger.warn("Failed to delete message: {}", e.getMessage()); + } + } + + private boolean isTimeoutReached(long startTime) { + return (System.currentTimeMillis() - startTime) > NotificationConfig.SSE_MAX_DURATION_MS; + } + + private void sleep() { + try { + Thread.sleep(NotificationConfig.SSE_POLL_INTERVAL_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/StreakReminderHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/StreakReminderHandler.java new file mode 100644 index 00000000..d1cbd30c --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/StreakReminderHandler.java @@ -0,0 +1,123 @@ +package com.mzc.secondproject.serverless.domain.notification.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.ScheduledEvent; +import com.mzc.secondproject.serverless.domain.notification.enums.NotificationType; +import com.mzc.secondproject.serverless.domain.notification.service.NotificationPublisher; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; +import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.DailyStudyRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 연속 학습 리마인더 Lambda Handler + * EventBridge 스케줄러에 의해 매일 21시(KST)에 트리거 + * 오늘 학습하지 않은 사용자 중 연속 학습 중인 사용자에게 알림 발송 + */ +public class StreakReminderHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(StreakReminderHandler.class); + + private final DailyStudyRepository dailyStudyRepository; + private final UserStatsRepository userStatsRepository; + private final NotificationPublisher notificationPublisher; + + public StreakReminderHandler() { + this.dailyStudyRepository = new DailyStudyRepository(); + this.userStatsRepository = new UserStatsRepository(); + this.notificationPublisher = NotificationPublisher.getInstance(); + } + + public StreakReminderHandler(DailyStudyRepository dailyStudyRepository, + UserStatsRepository userStatsRepository, + NotificationPublisher notificationPublisher) { + this.dailyStudyRepository = dailyStudyRepository; + this.userStatsRepository = userStatsRepository; + this.notificationPublisher = notificationPublisher; + } + + @Override + public Response handleRequest(ScheduledEvent event, Context context) { + logger.info("Streak reminder started: requestId={}", context.getAwsRequestId()); + + try { + int remindersSent = processReminders(); + logger.info("Streak reminder completed: sent={}", remindersSent); + return Response.success(remindersSent); + } catch (Exception e) { + logger.error("Streak reminder failed", e); + return Response.error(e.getMessage()); + } + } + + private int processReminders() { + String today = LocalDate.now().toString(); + + Set studiedUserIds = findStudiedUserIds(today); + List usersWithStreak = userStatsRepository.findUsersWithActiveStreak(); + + int remindersSent = 0; + for (UserStats stats : usersWithStreak) { + if (shouldSendReminder(stats, studiedUserIds)) { + sendReminder(stats); + remindersSent++; + } + } + + return remindersSent; + } + + private Set findStudiedUserIds(String date) { + return dailyStudyRepository.findByDate(date).stream() + .filter(ds -> Boolean.TRUE.equals(ds.getIsCompleted())) + .map(DailyStudy::getUserId) + .collect(Collectors.toSet()); + } + + private boolean shouldSendReminder(UserStats stats, Set studiedUserIds) { + if (studiedUserIds.contains(stats.getUserId())) { + return false; + } + Integer streak = stats.getCurrentStreak(); + return streak != null && streak > 0; + } + + private void sendReminder(UserStats stats) { + String userId = stats.getUserId(); + int streak = stats.getCurrentStreak(); + + notificationPublisher.publish( + NotificationType.STREAK_REMINDER, + userId, + Map.of( + "currentStreak", streak, + "message", String.format("%d일 연속 학습 중! 오늘도 학습해서 기록을 이어가세요.", streak) + ) + ); + + logger.debug("Streak reminder sent: userId={}, streak={}", userId, streak); + } + + /** + * Lambda 응답 DTO + */ + public record Response(int statusCode, String message, int remindersSent) { + + public static Response success(int remindersSent) { + return new Response(200, "Streak reminders sent", remindersSent); + } + + public static Response error(String errorMessage) { + return new Response(500, "Streak reminder failed: " + errorMessage, 0); + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/service/NotificationPublisher.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/service/NotificationPublisher.java new file mode 100644 index 00000000..53ee0ad0 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/service/NotificationPublisher.java @@ -0,0 +1,201 @@ +package com.mzc.secondproject.serverless.domain.notification.service; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.util.JsonUtil; +import com.mzc.secondproject.serverless.domain.notification.config.NotificationConfig; +import com.mzc.secondproject.serverless.domain.notification.dto.NotificationMessage; +import com.mzc.secondproject.serverless.domain.notification.enums.NotificationType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.sns.SnsClient; +import software.amazon.awssdk.services.sns.model.MessageAttributeValue; +import software.amazon.awssdk.services.sns.model.PublishRequest; +import software.amazon.awssdk.services.sns.model.PublishResponse; + +import java.util.Map; + +/** + * 알림 발행 서비스 + * SNS 토픽에 알림 메시지를 발행하는 역할 + * + * 사용 예시: + *
+ * NotificationPublisher.getInstance().publish(
+ *     NotificationType.BADGE_EARNED,
+ *     userId,
+ *     Map.of("badgeType", "STREAK_7", "badgeName", "7일 연속 학습")
+ * );
+ * 
+ */ +public class NotificationPublisher { + + private static final Logger logger = LoggerFactory.getLogger(NotificationPublisher.class); + + private static volatile NotificationPublisher instance; + private final SnsClient snsClient; + + private NotificationPublisher() { + this.snsClient = AwsClients.sns(); + } + + private NotificationPublisher(SnsClient snsClient) { + this.snsClient = snsClient; + } + + /** + * 싱글톤 인스턴스 반환 + */ + public static NotificationPublisher getInstance() { + if (instance == null) { + synchronized (NotificationPublisher.class) { + if (instance == null) { + instance = new NotificationPublisher(); + } + } + } + return instance; + } + + /** + * 테스트용 인스턴스 생성 + */ + public static NotificationPublisher createForTest(SnsClient snsClient) { + return new NotificationPublisher(snsClient); + } + + /** + * 알림 발행 (비동기, non-blocking) + * 발행 실패 시에도 호출자의 비즈니스 로직에 영향을 주지 않음 + * + * @param type 알림 타입 + * @param userId 대상 사용자 ID + * @param payload 알림 페이로드 + */ + public void publish(NotificationType type, String userId, Map payload) { + if (!NotificationConfig.isTopicConfigured()) { + logger.warn("NOTIFICATION_TOPIC_ARN is not configured. Skipping notification."); + return; + } + + try { + NotificationMessage message = NotificationMessage.builder() + .type(type) + .userId(userId) + .payload(payload) + .build(); + + String messageJson = JsonUtil.toJson(message); + + PublishRequest request = PublishRequest.builder() + .topicArn(NotificationConfig.topicArn()) + .message(messageJson) + .messageAttributes(Map.of( + "type", MessageAttributeValue.builder() + .dataType("String") + .stringValue(type.name()) + .build(), + "userId", MessageAttributeValue.builder() + .dataType("String") + .stringValue(userId) + .build(), + "category", MessageAttributeValue.builder() + .dataType("String") + .stringValue(type.getCategory()) + .build() + )) + .build(); + + PublishResponse response = snsClient.publish(request); + logger.info("Notification published: type={}, userId={}, messageId={}", + type, userId, response.messageId()); + + } catch (Exception e) { + // 알림 발행 실패는 비즈니스 로직에 영향을 주지 않도록 로깅만 수행 + logger.error("Failed to publish notification: type={}, userId={}, error={}", + type, userId, e.getMessage()); + } + } + + /** + * 배지 획득 알림 발행 헬퍼 메서드 + */ + public void publishBadgeEarned(String userId, String badgeType, String badgeName, + String description, String iconUrl) { + publish(NotificationType.BADGE_EARNED, userId, Map.of( + "badgeType", badgeType, + "badgeName", badgeName, + "description", description, + "iconUrl", iconUrl != null ? iconUrl : "" + )); + } + + /** + * 일일 학습 완료 알림 발행 헬퍼 메서드 + */ + public void publishDailyComplete(String userId, String date, int wordsLearned, + int totalWords, int currentStreak) { + publish(NotificationType.DAILY_COMPLETE, userId, Map.of( + "date", date, + "wordsLearned", wordsLearned, + "totalWords", totalWords, + "currentStreak", currentStreak + )); + } + + /** + * 테스트 완료 알림 발행 헬퍼 메서드 + */ + public void publishTestComplete(String userId, String testId, int score, + int correctCount, int totalCount, boolean isPerfect) { + publish(NotificationType.TEST_COMPLETE, userId, Map.of( + "testId", testId, + "score", score, + "correctCount", correctCount, + "totalCount", totalCount, + "isPerfect", isPerfect + )); + } + + /** + * 뉴스 퀴즈 완료 알림 발행 헬퍼 메서드 + */ + public void publishNewsQuizComplete(String userId, String articleId, String articleTitle, + int score, int correctCount, int totalCount, boolean isPerfect) { + publish(NotificationType.NEWS_QUIZ_COMPLETE, userId, Map.of( + "articleId", articleId, + "articleTitle", articleTitle, + "score", score, + "correctCount", correctCount, + "totalCount", totalCount, + "isPerfect", isPerfect + )); + } + + /** + * 게임 종료 알림 발행 헬퍼 메서드 + */ + public void publishGameEnd(String userId, String roomId, String gameSessionId, + int rank, int totalPlayers, int score, boolean isWinner) { + publish(NotificationType.GAME_END, userId, Map.of( + "roomId", roomId, + "gameSessionId", gameSessionId, + "rank", rank, + "totalPlayers", totalPlayers, + "score", score, + "isWinner", isWinner + )); + } + + /** + * OPIc 세션 완료 알림 발행 헬퍼 메서드 + */ + public void publishOpicComplete(String userId, String sessionId, String estimatedLevel, + int questionsAnswered, String feedbackSummary) { + publish(NotificationType.OPIC_COMPLETE, userId, Map.of( + "sessionId", sessionId, + "estimatedLevel", estimatedLevel, + "questionsAnswered", questionsAnswered, + "feedbackSummary", feedbackSummary != null ? feedbackSummary : "" + )); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/ResetRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/ResetRequest.java new file mode 100644 index 00000000..3152beaf --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/ResetRequest.java @@ -0,0 +1,12 @@ +package com.mzc.secondproject.serverless.domain.speaking.dto.request; + +/** + * 대화 초기화 요청 DTO + */ +public record ResetRequest( + String sessionId +) { + public boolean isValid() { + return sessionId != null && !sessionId.isEmpty(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/SpeakingRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/SpeakingRequest.java new file mode 100644 index 00000000..f75ec1bb --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/SpeakingRequest.java @@ -0,0 +1,32 @@ +package com.mzc.secondproject.serverless.domain.speaking.dto.request; + +/** + * Speaking API 요청 DTO + */ +public record SpeakingRequest( + String sessionId, // 세션 ID (첫 요청 시 null) + String audio, // 음성 데이터 (base64) + String text, // 텍스트 입력 + String level // 레벨 (BEGINNER, INTERMEDIATE, ADVANCED) +) { + /** + * 기본값 적용된 레벨 반환 + */ + public String getLevelOrDefault() { + return level != null && !level.isEmpty() ? level : "INTERMEDIATE"; + } + + /** + * 음성 입력인지 확인 + */ + public boolean hasAudio() { + return audio != null && !audio.isEmpty(); + } + + /** + * 텍스트 입력인지 확인 + */ + public boolean hasText() { + return text != null && !text.trim().isEmpty(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/response/SpeakingResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/response/SpeakingResponse.java new file mode 100644 index 00000000..21cec696 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/response/SpeakingResponse.java @@ -0,0 +1,13 @@ +package com.mzc.secondproject.serverless.domain.speaking.dto.response; + +/** + * Speaking API 응답 DTO + */ +public record SpeakingResponse( + String sessionId, // 세션 ID (다음 요청에 사용) + String userTranscript, // 사용자가 말한 내용 (STT 결과) + String aiText, // AI 응답 텍스트 + String aiAudioUrl, // AI 응답 음성 URL (Polly) + double confidence // STT 신뢰도 +) { +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java new file mode 100644 index 00000000..2ebda605 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java @@ -0,0 +1,163 @@ +package com.mzc.secondproject.serverless.domain.speaking.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.mzc.secondproject.serverless.common.util.JwtUtil; +import com.mzc.secondproject.serverless.domain.speaking.dto.response.SpeakingResponse; +import com.mzc.secondproject.serverless.domain.speaking.service.SpeakingService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.Optional; + +/** + * Speaking API 핸들러 + * + * POST /api/speaking/chat - 대화 (음성 또는 텍스트) + * POST /api/speaking/reset - 대화 초기화 + */ +public class SpeakingHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(SpeakingHandler.class); + private static final Gson gson = new GsonBuilder().create(); + + private static final Map CORS_HEADERS = Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Headers", "Content-Type,Authorization", + "Access-Control-Allow-Methods", "POST,OPTIONS" + ); + + private final SpeakingService speakingService; + + public SpeakingHandler() { + this.speakingService = new SpeakingService(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent event, Context context) { + logger.info("Speaking API request received"); + + // OPTIONS 요청 처리 (CORS preflight) + if ("OPTIONS".equalsIgnoreCase(event.getHttpMethod())) { + return response(200, Map.of("message", "OK")); + } + + try { + // 사용자 인증 정보 추출 (Cognito Authorizer -> requestContext) + if (event.getRequestContext() == null || event.getRequestContext().getAuthorizer() == null) { + logger.error("No Authorizer found in request context"); + return response(401, Map.of("error", "Unauthorized: User context missing")); + } + + Map authorizer = event.getRequestContext().getAuthorizer(); + Map claims = (Map) authorizer.get("claims"); + + if (claims == null) { + return response(401, Map.of("error", "Unauthorized: Claims missing")); + } + + String userId = (String) claims.get("sub"); // Cognito User Pool의 고유 ID (UUID 형태) + + // 요청 정보 추출 + String path = event.getPath(); + String body = event.getBody(); + + logger.info("Processing request: path={}, userId={}", path, userId); + + // 라우팅 + if (path != null && path.endsWith("/chat")) { + return handleChat(userId, body); + } else if (path != null && path.endsWith("/reset")) { + return handleReset(userId, body); + } else { + return response(404, Map.of("error", "Not found")); + } + + } catch (Exception e) { + logger.error("Error processing request: {}", e.getMessage(), e); + return response(500, Map.of("error", "Internal server error: " + e.getMessage())); + } + } + + /** + * 대화 처리 (음성 또는 텍스트) + */ + private APIGatewayProxyResponseEvent handleChat(String userId, String body) { + if (body == null || body.isEmpty()) { + return response(400, Map.of("error", "Request body is required")); + } + + JsonObject request = JsonParser.parseString(body).getAsJsonObject(); + + String sessionId = request.has("sessionId") && !request.get("sessionId").isJsonNull() + ? request.get("sessionId").getAsString() : null; + String level = request.has("level") && !request.get("level").isJsonNull() + ? request.get("level").getAsString() : "INTERMEDIATE"; + String audio = request.has("audio") && !request.get("audio").isJsonNull() + ? request.get("audio").getAsString() : null; + String text = request.has("text") && !request.get("text").isJsonNull() + ? request.get("text").getAsString() : null; + + SpeakingResponse result; + + if (audio != null && !audio.isEmpty()) { + // 음성 입력 처리 + logger.info("Processing voice event"); + result = speakingService.processVoiceInput(sessionId, userId, audio, level); + } else if (text != null && !text.trim().isEmpty()) { + // 텍스트 입력 처리 + logger.info("Processing text event: {}", text); + result = speakingService.processTextInput(sessionId, userId, text.trim(), level); + } else { + return response(400, Map.of("error", "Either 'audio' or 'text' is required")); + } + + return response(200, Map.of( + "sessionId", result.sessionId(), + "userTranscript", result.userTranscript(), + "aiText", result.aiText(), + "aiAudioUrl", result.aiAudioUrl(), + "confidence", result.confidence() + )); + } + + /** + * 대화 초기화 + */ + private APIGatewayProxyResponseEvent handleReset(String userId, String body) { + if (body == null || body.isEmpty()) { + return response(400, Map.of("error", "Request body is required")); + } + + JsonObject request = JsonParser.parseString(body).getAsJsonObject(); + String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; + + if (sessionId == null || sessionId.isEmpty()) { + return response(400, Map.of("error", "sessionId is required")); + } + + speakingService.resetConversation(sessionId); + + return response(200, Map.of( + "message", "Conversation reset successfully", + "sessionId", sessionId + )); + } + + private APIGatewayProxyResponseEvent response(int statusCode, Map body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(CORS_HEADERS) + .withBody(gson.toJson(body)); + } + + +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java deleted file mode 100644 index 256d3990..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.mzc.secondproject.serverless.common.config.WebSocketConfig; -import com.mzc.secondproject.serverless.common.util.JwtUtil; -import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; -import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingConnection; -import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Map; -import java.util.Optional; - -/** - * Speaking WebSocket $connect 핸들러 - * JWT 토큰 검증 후 연결 정보를 DynamoDB에 저장 - *

- * 연결 방법: - * wss://{api-id}.execute-api.{region}.amazonaws.com/{stage}?token={jwt} - */ -public class SpeakingConnectHandler implements RequestHandler, Map> { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingConnectHandler.class); - - private final SpeakingConnectionRepository connectionRepository; - - public SpeakingConnectHandler() { - this.connectionRepository = new SpeakingConnectionRepository(); - } - - @Override - public Map handleRequest(Map event, Context context) { - logger.info("Speaking WebSocket connect event"); - - try { - String connectionId = WebSocketEventUtil.extractConnectionId(event); - Map queryParams = WebSocketEventUtil.extractQueryStringParameters(event); - - // JWT 토큰 검증 - String token = queryParams.get("token"); - - if (token == null || token.isEmpty()) { - logger.warn("Missing token parameter"); - return WebSocketEventUtil.unauthorized("token is required"); - } - - // 토큰 유효성 검사 - if (!JwtUtil.isValid(token)) { - logger.warn("Invalid or expired token"); - return WebSocketEventUtil.unauthorized("Invalid or expired token"); - } - - // userId 추출 - Optional userIdOpt = JwtUtil.extractUserId(token); - if (userIdOpt.isEmpty()) { - logger.warn("Failed to extract userId from token"); - return WebSocketEventUtil.unauthorized("Invalid token"); - } - - String userId = userIdOpt.get(); - - // 연결 정보 저장 - SpeakingConnection connection = SpeakingConnection.create( - connectionId, - userId, - WebSocketConfig.connectionTtlSeconds() - ); - - // 레벨 파라미터가 있으면 설정 - String level = queryParams.get("level"); - if (level != null && !level.isEmpty()) { - connection.setTargetLevel(level.toUpperCase()); - } - - connectionRepository.save(connection); - - logger.info("Speaking connection established: connectionId={}, userId={}, level={}", - connectionId, userId, connection.getTargetLevel()); - return WebSocketEventUtil.ok("Connected"); - - } catch (Exception e) { - logger.error("Error handling connect: {}", e.getMessage(), e); - return WebSocketEventUtil.serverError("Internal server error"); - } - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java deleted file mode 100644 index bd46d3b4..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; -import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Map; - -/** - * Speaking WebSocket $disconnect 핸들러 - * 연결 해제 시 DynamoDB에서 연결 정보 삭제 - */ -public class SpeakingDisconnectHandler implements RequestHandler, Map> { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingDisconnectHandler.class); - - private final SpeakingConnectionRepository connectionRepository; - - public SpeakingDisconnectHandler() { - this.connectionRepository = new SpeakingConnectionRepository(); - } - - @Override - public Map handleRequest(Map event, Context context) { - logger.info("Speaking WebSocket disconnect event"); - - try { - String connectionId = WebSocketEventUtil.extractConnectionId(event); - - // 연결 정보 삭제 - connectionRepository.delete(connectionId); - - logger.info("Speaking connection closed: connectionId={}", connectionId); - return WebSocketEventUtil.ok("Disconnected"); - - } catch (Exception e) { - logger.error("Error handling disconnect: {}", e.getMessage(), e); - return WebSocketEventUtil.serverError("Internal server error"); - } - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java deleted file mode 100644 index 24ecfc3c..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java +++ /dev/null @@ -1,217 +0,0 @@ -package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; -import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; -import com.mzc.secondproject.serverless.domain.speaking.service.SpeakingService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.core.SdkBytes; -import software.amazon.awssdk.services.apigatewaymanagementapi.ApiGatewayManagementApiClient; -import software.amazon.awssdk.services.apigatewaymanagementapi.model.PostToConnectionRequest; - -import java.net.URI; -import java.util.Map; - -/** - * Speaking WebSocket 메시지 핸들러 - *

- * 지원하는 action: - * - speak: 음성 입력 처리 (audio base64) - * - text: 텍스트 입력 처리 - * - setLevel: 레벨 변경 - * - reset: 대화 히스토리 초기화 - */ -public class SpeakingMessageHandler implements RequestHandler, Map> { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingMessageHandler.class); - private static final Gson gson = new GsonBuilder().create(); - - private final SpeakingService speakingService; - private final SpeakingConnectionRepository connectionRepository; - - public SpeakingMessageHandler() { - this.speakingService = new SpeakingService(); - this.connectionRepository = new SpeakingConnectionRepository(); - } - - @Override - public Map handleRequest(Map event, Context context) { - logger.info("Speaking message event received"); - - String connectionId = null; - String endpoint = null; - - try { - connectionId = WebSocketEventUtil.extractConnectionId(event); - endpoint = WebSocketEventUtil.extractWebSocketEndpoint(event); - - // 연결 정보 확인 - if (connectionRepository.findByConnectionId(connectionId).isEmpty()) { - logger.warn("Connection not found: {}", connectionId); - return sendError(connectionId, endpoint, "Unauthorized - please reconnect"); - } - - // 요청 바디 파싱 - String body = (String) event.get("body"); - if (body == null || body.isEmpty()) { - return sendError(connectionId, endpoint, "Message body is required"); - } - - JsonObject request = JsonParser.parseString(body).getAsJsonObject(); - String action = request.has("action") ? request.get("action").getAsString() : "speak"; - - logger.info("Processing action: {} for connectionId: {}", action, connectionId); - - // 액션별 처리 - switch (action) { - case "speak" -> handleSpeak(connectionId, endpoint, request); - case "text" -> handleText(connectionId, endpoint, request); - case "setLevel" -> handleSetLevel(connectionId, endpoint, request); - case "reset" -> handleReset(connectionId, endpoint); - default -> sendError(connectionId, endpoint, "Unknown action: " + action); - } - - return WebSocketEventUtil.ok("Processed"); - - } catch (Exception e) { - logger.error("Error processing message: {}", e.getMessage(), e); - if (connectionId != null && endpoint != null) { - sendError(connectionId, endpoint, "Processing error: " + e.getMessage()); - } - return WebSocketEventUtil.serverError("Internal server error"); - } - } - - /** - * 음성 입력 처리 - */ - private void handleSpeak(String connectionId, String endpoint, JsonObject request) { - // 시작 이벤트 전송 - sendToConnection(connectionId, endpoint, Map.of( - "type", "start", - "message", "Processing your voice..." - )); - - // 음성 데이터 추출 - String audioBase64 = request.has("audio") ? request.get("audio").getAsString() : null; - if (audioBase64 == null || audioBase64.isEmpty()) { - sendError(connectionId, endpoint, "audio data is required for speak action"); - return; - } - - // 음성 처리 - SpeakingService.SpeakingResponse response = speakingService.processVoiceInput( - connectionId, audioBase64 - ); - - // 결과 전송 - sendToConnection(connectionId, endpoint, Map.of( - "type", "complete", - "userTranscript", response.userTranscript(), - "aiText", response.aiText(), - "aiAudioUrl", response.aiAudioUrl(), - "confidence", response.confidence() - )); - } - - /** - * 텍스트 입력 처리 - */ - private void handleText(String connectionId, String endpoint, JsonObject request) { - String text = request.has("text") ? request.get("text").getAsString() : null; - if (text == null || text.trim().isEmpty()) { - sendError(connectionId, endpoint, "text is required for text action"); - return; - } - - // 시작 이벤트 전송 - sendToConnection(connectionId, endpoint, Map.of( - "type", "start", - "message", "Processing your message..." - )); - - // 텍스트 처리 - SpeakingService.SpeakingResponse response = speakingService.processTextInput( - connectionId, text.trim() - ); - - // 결과 전송 - sendToConnection(connectionId, endpoint, Map.of( - "type", "complete", - "userTranscript", response.userTranscript(), - "aiText", response.aiText(), - "aiAudioUrl", response.aiAudioUrl(), - "confidence", response.confidence() - )); - } - - /** - * 레벨 변경 처리 - */ - private void handleSetLevel(String connectionId, String endpoint, JsonObject request) { - String level = request.has("level") ? request.get("level").getAsString() : null; - if (level == null || level.isEmpty()) { - sendError(connectionId, endpoint, "level is required"); - return; - } - - speakingService.updateLevel(connectionId, level); - - sendToConnection(connectionId, endpoint, Map.of( - "type", "levelChanged", - "level", level.toUpperCase() - )); - } - - /** - * 대화 초기화 처리 - */ - private void handleReset(String connectionId, String endpoint) { - speakingService.resetConversation(connectionId); - - sendToConnection(connectionId, endpoint, Map.of( - "type", "reset", - "message", "Conversation has been reset. Let's start fresh!" - )); - } - - /** - * WebSocket으로 메시지 전송 - */ - private void sendToConnection(String connectionId, String endpoint, Map data) { - try { - ApiGatewayManagementApiClient apiClient = ApiGatewayManagementApiClient.builder() - .endpointOverride(URI.create(endpoint)) - .build(); - - String message = gson.toJson(data); - - apiClient.postToConnection(PostToConnectionRequest.builder() - .connectionId(connectionId) - .data(SdkBytes.fromUtf8String(message)) - .build()); - - logger.debug("Message sent to {}: {}", connectionId, data.get("type")); - - } catch (Exception e) { - logger.error("Failed to send message to {}: {}", connectionId, e.getMessage()); - } - } - - /** - * 에러 메시지 전송 - */ - private Map sendError(String connectionId, String endpoint, String errorMessage) { - sendToConnection(connectionId, endpoint, Map.of( - "type", "error", - "message", errorMessage - )); - return WebSocketEventUtil.ok("Error sent"); - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingSession.java similarity index 56% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingSession.java index 133e7773..a0712d4b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingSession.java @@ -15,49 +15,61 @@ @NoArgsConstructor @AllArgsConstructor @DynamoDbBean -public class SpeakingConnection { +public class SpeakingSession { // DynamoDB Key Prefixes - public static final String PK_PREFIX = "SPEAKING_CONN#"; + public static final String PK_PREFIX = "SPEAKING_SESSION#"; public static final String SK_METADATA = "METADATA"; public static final String GSI1PK_PREFIX = "SPEAKING_USER#"; - public static final String GSI1SK_PREFIX = "CONN#"; + public static final String GSI1SK_PREFIX = "SESSION#"; - private String pk; // SPEAKING_CONN#{connectionId} + private String pk; // SPEAKING_SESSION#{sessionId} private String sk; // METADATA private String gsi1pk; // SPEAKING_USER#{userId} - private String gsi1sk; // CONN#{connectionId} + private String gsi1sk; // SESSION#{sessionId} - private String connectionId; + private String sessionId; private String userId; - private String connectedAt; - private Long ttl; // 자동 삭제용 + private String createdAt; + private String updatedAt; + private Long ttl; // 자동 삭제용 (24시간) // Speaking 전용 필드 private String conversationHistory; // 대화 히스토리 (JSON) private String targetLevel; // 목표 레벨 (BEGINNER, INTERMEDIATE, ADVANCED) /** - * 연결 정보 생성 팩토리 메서드 + * 세션 생성 팩토리 메서드 */ - public static SpeakingConnection create(String connectionId, String userId, long ttlSeconds) { + public static SpeakingSession create(String sessionId, String userId, String level) { String now = java.time.Instant.now().toString(); - long ttl = java.time.Instant.now().plusSeconds(ttlSeconds).getEpochSecond(); + // 24시간 후 자동 삭제 + long ttl = java.time.Instant.now().plusSeconds(86400).getEpochSecond(); - return SpeakingConnection.builder() - .pk(PK_PREFIX + connectionId) + return SpeakingSession.builder() + .pk(PK_PREFIX + sessionId) .sk(SK_METADATA) .gsi1pk(GSI1PK_PREFIX + userId) - .gsi1sk(GSI1SK_PREFIX + connectionId) - .connectionId(connectionId) + .gsi1sk(GSI1SK_PREFIX + sessionId) + .sessionId(sessionId) .userId(userId) - .connectedAt(now) + .createdAt(now) + .updatedAt(now) .ttl(ttl) - .conversationHistory("[]") // 빈 배열로 초기화 - .targetLevel("INTERMEDIATE") // 기본값 + .conversationHistory("[]") + .targetLevel(level != null ? level.toUpperCase() : "INTERMEDIATE") .build(); } + /** + * 업데이트 시간 갱신 + */ + public void touch() { + this.updatedAt = java.time.Instant.now().toString(); + // TTL 연장 (24시간) + this.ttl = java.time.Instant.now().plusSeconds(86400).getEpochSecond(); + } + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java deleted file mode 100644 index bbb74d7c..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.mzc.secondproject.serverless.domain.speaking.repository; - -import com.mzc.secondproject.serverless.common.config.AwsClients; -import com.mzc.secondproject.serverless.common.config.EnvConfig; -import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingConnection; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; -import software.amazon.awssdk.enhanced.dynamodb.Key; -import software.amazon.awssdk.enhanced.dynamodb.TableSchema; - -import java.util.Optional; - -/** - * Speaking WebSocket 연결 정보 Repository - */ -public class SpeakingConnectionRepository { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingConnectionRepository.class); - private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); - - private final DynamoDbTable table; - - public SpeakingConnectionRepository() { - this.table = AwsClients.dynamoDbEnhanced().table( - TABLE_NAME, - TableSchema.fromBean(SpeakingConnection.class) - ); - } - - /** - * 연결 정보 저장 - */ - public void save(SpeakingConnection connection) { - table.putItem(connection); - logger.debug("Speaking connection saved: connectionId={}, userId={}", - connection.getConnectionId(), connection.getUserId()); - } - - /** - * connectionId로 연결 정보 조회 - */ - public Optional findByConnectionId(String connectionId) { - Key key = Key.builder() - .partitionValue(SpeakingConnection.PK_PREFIX + connectionId) - .sortValue(SpeakingConnection.SK_METADATA) - .build(); - - SpeakingConnection connection = table.getItem(key); - return Optional.ofNullable(connection); - } - - /** - * 연결 정보 업데이트 (대화 히스토리 등) - */ - public void update(SpeakingConnection connection) { - table.putItem(connection); - logger.debug("Speaking connection updated: connectionId={}", connection.getConnectionId()); - } - - /** - * 연결 정보 삭제 - */ - public void delete(String connectionId) { - Key key = Key.builder() - .partitionValue(SpeakingConnection.PK_PREFIX + connectionId) - .sortValue(SpeakingConnection.SK_METADATA) - .build(); - - table.deleteItem(key); - logger.info("Speaking connection deleted: connectionId={}", connectionId); - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java new file mode 100644 index 00000000..dadda7e7 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java @@ -0,0 +1,74 @@ +package com.mzc.secondproject.serverless.domain.speaking.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; + +import java.util.Optional; + +/** + * Speaking API 연결 정보 Repository + */ +public class SpeakingSessionRepository { + + private static final Logger logger = LoggerFactory.getLogger(SpeakingSessionRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("SPEAKING_TABLE_NAME"); + + private final DynamoDbTable table; + + public SpeakingSessionRepository() { + this.table = AwsClients.dynamoDbEnhanced().table( + TABLE_NAME, + TableSchema.fromBean(SpeakingSession.class) + ); + } + + /** + * 연결 정보 저장 + */ + public void save(SpeakingSession session) { + table.putItem(session); + logger.debug("Speaking session saved: sessionId={}, userId={}", + session.getSessionId(), session.getUserId()); + } + + /** + * sessionId로 연결 정보 조회 + */ + public Optional findBySessionId(String sessionId) { + Key key = Key.builder() + .partitionValue(SpeakingSession.PK_PREFIX + sessionId) + .sortValue(SpeakingSession.SK_METADATA) + .build(); + + SpeakingSession session = table.getItem(key); + return Optional.ofNullable(session); + } + + /** + * 연결 정보 업데이트 (대화 히스토리 등) + */ + public void update(SpeakingSession session) { + session.touch(); // 업데이트 시간 및 TTL 갱신 + table.putItem(session); + logger.debug("Speaking session updated: sessionId={}", session.getSessionId()); + } + + /** + * 연결 정보 삭제 + */ + public void delete(String sessionId) { + Key key = Key.builder() + .partitionValue(SpeakingSession.PK_PREFIX + sessionId) + .sortValue(SpeakingSession.SK_METADATA) + .build(); + + table.deleteItem(key); + logger.info("Speaking session deleted: sessionId={}", sessionId); + } +} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java index 7c428ddc..fb94ce14 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java @@ -5,8 +5,9 @@ import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.service.PollyService; import com.mzc.secondproject.serverless.domain.opic.service.TranscribeProxyService; -import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingConnection; -import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; +import com.mzc.secondproject.serverless.domain.speaking.dto.response.SpeakingResponse; +import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingSession; +import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingSessionRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.core.SdkBytes; @@ -15,6 +16,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.UUID; /** * AI와 대화하기 서비스 @@ -31,7 +33,7 @@ public class SpeakingService { private final TranscribeProxyService transcribeService; private final PollyService pollyService; - private final SpeakingConnectionRepository connectionRepository; + private final SpeakingSessionRepository sessionRepository; public SpeakingService() { this.transcribeService = new TranscribeProxyService(); @@ -39,33 +41,54 @@ public SpeakingService() { EnvConfig.getRequired("BUCKET_NAME"), "speaking/voice/" ); - this.connectionRepository = new SpeakingConnectionRepository(); + this.sessionRepository = new SpeakingSessionRepository(); + } + + /** + * 세션 생성 또는 조회 + */ + public SpeakingSession getOrCreateSession(String sessionId, String userId, String level) { + if (sessionId != null && !sessionId.isEmpty()) { + return sessionRepository.findBySessionId(sessionId) + .orElseGet(() -> createNewSession(userId, level)); + } + return createNewSession(userId, level); + } + + /** + * 새 세션 생성 + */ + private SpeakingSession createNewSession(String userId, String level) { + String newSessionId = UUID.randomUUID().toString(); + SpeakingSession session = SpeakingSession.create(newSessionId, userId, level); + sessionRepository.save(session); + logger.info("New speaking session created: sessionId={}, userId={}", newSessionId, userId); + return session; } /** * 음성 입력 처리 (전체 플로우) */ - public SpeakingResponse processVoiceInput(String connectionId, String audioBase64) { - logger.info("Processing voice input for connectionId: {}", connectionId); + public SpeakingResponse processVoiceInput(String sessionId, String userId, String audioBase64, String level) { + logger.info("Processing voice input for sessionId: {}", sessionId); - // 연결 정보 조회 - SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) - .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + // 세션 조회 또는 생성 + SpeakingSession session = getOrCreateSession(sessionId, userId, level); - String targetLevel = connection.getTargetLevel(); + String targetLevel = session.getTargetLevel(); // STT: 음성 → 텍스트 (Transcribe Proxy 사용) logger.info("Step 1: Transcribing audio..."); TranscribeProxyService.TranscribeResult sttResult = transcribeService.transcribe( audioBase64, - connectionId, + session.getSessionId(), "en-US" ); String userText = sttResult.transcript(); logger.info("Transcription complete: {} (confidence: {})", userText, sttResult.confidence()); // 대화 히스토리 로드 - List history = parseHistory(connection.getConversationHistory()); + List history = parseHistory(session.getConversationHistory()); // Bedrock: AI 응답 생성 logger.info("Step 2: Generating AI response..."); @@ -78,12 +101,12 @@ public SpeakingResponse processVoiceInput(String connectionId, String audioBase6 if (history.size() > MAX_HISTORY_SIZE * 2) { history = new ArrayList<>(history.subList(history.size() - MAX_HISTORY_SIZE * 2, history.size())); } - connection.setConversationHistory(toJson(history)); - connectionRepository.update(connection); + session.setConversationHistory(toJson(history)); + sessionRepository.update(session); // TTS: 텍스트 → 음성 (Polly 사용) logger.info("Step 3: Synthesizing speech..."); - String audioId = connectionId + "_" + System.currentTimeMillis(); + String audioId = session.getSessionId() + "_" + System.currentTimeMillis(); PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( audioId, aiResponse, @@ -92,6 +115,7 @@ public SpeakingResponse processVoiceInput(String connectionId, String audioBase6 logger.info("Speech synthesis complete: cached={}", ttsResult.isCached()); return new SpeakingResponse( + session.getSessionId(), userText, aiResponse, ttsResult.getAudioUrl(), @@ -102,20 +126,17 @@ public SpeakingResponse processVoiceInput(String connectionId, String audioBase6 /** * 텍스트 입력 처리 (음성 없이 텍스트만) */ - public SpeakingResponse processTextInput(String connectionId, String userText) { - logger.info("Processing text input for connectionId: {}", connectionId); + public SpeakingResponse processTextInput(String sessionId, String userId, String userText, String level) { + logger.info("Processing text input for sessionId: {}", sessionId); - // 연결 정보 조회 - SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) - .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); - - String targetLevel = connection.getTargetLevel(); + // 세션 조회 또는 생성 + SpeakingSession session = getOrCreateSession(sessionId, userId, level); // 대화 히스토리 로드 - List history = parseHistory(connection.getConversationHistory()); + List history = parseHistory(session.getConversationHistory()); // AI 응답 생성 - String aiResponse = generateAiResponse(userText, history, targetLevel); + String aiResponse = generateAiResponse(userText, history, session.getTargetLevel()); // 히스토리 업데이트 history.add(new Message("user", userText)); @@ -123,40 +144,46 @@ public SpeakingResponse processTextInput(String connectionId, String userText) { if (history.size() > MAX_HISTORY_SIZE * 2) { history = new ArrayList<>(history.subList(history.size() - MAX_HISTORY_SIZE * 2, history.size())); } - connection.setConversationHistory(toJson(history)); - connectionRepository.update(connection); + session.setConversationHistory(toJson(history)); + sessionRepository.update(session); // TTS 생성 - String audioId = connectionId + "_" + System.currentTimeMillis(); + String audioId = session.getSessionId() + "_" + System.currentTimeMillis(); PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( audioId, aiResponse, "FEMALE" ); - return new SpeakingResponse(userText, aiResponse, ttsResult.getAudioUrl(), 1.0); + return new SpeakingResponse( + session.getSessionId(), + userText, + aiResponse, + ttsResult.getAudioUrl(), + 1.0 + ); } /** * 레벨 변경 */ - public void updateLevel(String connectionId, String level) { - SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) - .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + public void updateLevel(String sessionId, String level) { + SpeakingSession session = sessionRepository.findBySessionId(sessionId) + .orElseThrow(() -> new RuntimeException("session not found: " + sessionId)); - connection.setTargetLevel(level.toUpperCase()); - connectionRepository.update(connection); - logger.info("Level updated for connectionId {}: {}", connectionId, level); + session.setTargetLevel(level.toUpperCase()); + sessionRepository.update(session); + logger.info("Level updated for sessionId {}: {}", sessionId, level); } /** * 대화 히스토리 초기화 */ - public void resetConversation(String connectionId) { - SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) - .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + public void resetConversation(String sessionId) { + SpeakingSession session = sessionRepository.findBySessionId(sessionId) + .orElseThrow(() -> new RuntimeException("session not found: " + sessionId)); - connection.setConversationHistory("[]"); - connectionRepository.update(connection); - logger.info("Conversation reset for connectionId: {}", connectionId); + session.setConversationHistory("[]"); + sessionRepository.update(session); + logger.info("Conversation reset for sessionId: {}", sessionId); } @@ -286,6 +313,7 @@ private List parseHistory(String historyJson) { return history; } + /** * 히스토리 JSON 변환 */ @@ -300,19 +328,10 @@ private String toJson(List history) { return array.toString(); } - // ==================== Inner Classes ==================== - - private record Message(String role, String content) { - } - /** - * Speaking 응답 DTO + * 대화 메시지 (히스토리용) */ - public record SpeakingResponse( - String userTranscript, // 사용자가 말한 내용 (STT 결과) - String aiText, // AI 응답 텍스트 - String aiAudioUrl, // AI 응답 음성 URL (Polly) - double confidence // STT 신뢰도comp - ) { + private record Message(String role, String content) { } + } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java index 637151be..40fd94d7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java @@ -49,6 +49,7 @@ public UserStatsHandler(UserStatsRepository statsRepository, DailyStudyRepositor private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( + Route.getAuth("/stats/dashboard", this::getDashboardStats), Route.getAuth("/stats/daily", this::getDailyStats), Route.getAuth("/stats/weekly", this::getWeeklyStats), Route.getAuth("/stats/monthly", this::getMonthlyStats), @@ -63,6 +64,87 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re return router.route(request); } + /** + * 대시보드용 통합 통계 조회 (프론트엔드 요청 형식) + * GET /stats/dashboard + */ + private APIGatewayProxyResponseEvent getDashboardStats(APIGatewayProxyRequestEvent request, String userId) { + String today = LocalDate.now().toString(); + + // 오늘 통계 조회 + Optional dailyStats = statsRepository.findDailyStats(userId, today); + // 전체 통계 조회 + Optional totalStats = statsRepository.findTotalStats(userId); + // 최근 7일 히스토리 조회 + PaginatedResult weekHistory = statsRepository.findRecentDailyStats(userId, 7, null); + // 오늘 학습 목표 조회 + Optional dailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); + + Map response = new HashMap<>(); + + // today 섹션 + Map todaySection = new HashMap<>(); + if (dailyStats.isPresent()) { + UserStats ds = dailyStats.get(); + todaySection.put("wordsLearned", ds.getNewWordsLearned() != null ? ds.getNewWordsLearned() : 0); + todaySection.put("newsRead", ds.getNewsRead() != null ? ds.getNewsRead() : 0); + todaySection.put("quizzesTaken", (ds.getTestsCompleted() != null ? ds.getTestsCompleted() : 0) + + (ds.getNewsQuizCompleted() != null ? ds.getNewsQuizCompleted() : 0)); + } else { + todaySection.put("wordsLearned", 0); + todaySection.put("newsRead", 0); + todaySection.put("quizzesTaken", 0); + } + todaySection.put("wordsTotal", dailyStudy.map(ds -> ds.getTotalWords() != null ? ds.getTotalWords() : 25).orElse(25)); + response.put("today", todaySection); + + // overall 섹션 + Map overallSection = new HashMap<>(); + if (totalStats.isPresent()) { + UserStats ts = totalStats.get(); + overallSection.put("totalWordsLearned", ts.getNewWordsLearned() != null ? ts.getNewWordsLearned() : 0); + overallSection.put("totalNewsRead", ts.getNewsRead() != null ? ts.getNewsRead() : 0); + overallSection.put("totalQuizzes", (ts.getTestsCompleted() != null ? ts.getTestsCompleted() : 0) + + (ts.getNewsQuizCompleted() != null ? ts.getNewsQuizCompleted() : 0)); + overallSection.put("averageAccuracy", calculateSuccessRate(ts)); + overallSection.put("currentStreak", ts.getCurrentStreak() != null ? ts.getCurrentStreak() : 0); + overallSection.put("longestStreak", ts.getLongestStreak() != null ? ts.getLongestStreak() : 0); + overallSection.put("lastStudyDate", ts.getLastStudyDate()); + } else { + overallSection.put("totalWordsLearned", 0); + overallSection.put("totalNewsRead", 0); + overallSection.put("totalQuizzes", 0); + overallSection.put("averageAccuracy", 0.0); + overallSection.put("currentStreak", 0); + overallSection.put("longestStreak", 0); + overallSection.put("lastStudyDate", null); + } + // totalStudyDays 계산 (최근 히스토리에서 실제 학습한 날 수) + overallSection.put("totalStudyDays", weekHistory.items().size()); + response.put("overall", overallSection); + + // weeklyProgress 섹션 + List> weeklyProgress = weekHistory.items().stream() + .map(stats -> { + Map dayStats = new HashMap<>(); + dayStats.put("date", stats.getPeriod()); + dayStats.put("wordsLearned", stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0); + dayStats.put("newsRead", stats.getNewsRead() != null ? stats.getNewsRead() : 0); + return dayStats; + }) + .collect(Collectors.toList()); + response.put("weeklyProgress", weeklyProgress); + + // levelDistribution (현재 미구현 - 향후 추가 가능) + Map levelDistribution = new HashMap<>(); + levelDistribution.put("beginner", 0); + levelDistribution.put("intermediate", 0); + levelDistribution.put("advanced", 0); + response.put("levelDistribution", levelDistribution); + + return ResponseGenerator.ok("학습 통계 조회 성공", response); + } + /** * 오늘의 통계 조회 */ @@ -182,6 +264,11 @@ private Map buildStatsResponse(Optional stats, String response.put("successRate", calculateSuccessRate(s)); response.put("newWordsLearned", s.getNewWordsLearned() != null ? s.getNewWordsLearned() : 0); response.put("wordsReviewed", s.getWordsReviewed() != null ? s.getWordsReviewed() : 0); + // 뉴스 관련 통계 + response.put("newsRead", s.getNewsRead() != null ? s.getNewsRead() : 0); + response.put("newsQuizCompleted", s.getNewsQuizCompleted() != null ? s.getNewsQuizCompleted() : 0); + response.put("newsQuizPerfect", s.getNewsQuizPerfect() != null ? s.getNewsQuizPerfect() : 0); + response.put("newsWordsCollected", s.getNewsWordsCollected() != null ? s.getNewsWordsCollected() : 0); } else { response.put("testsCompleted", 0); response.put("questionsAnswered", 0); @@ -190,6 +277,11 @@ private Map buildStatsResponse(Optional stats, String response.put("successRate", 0.0); response.put("newWordsLearned", 0); response.put("wordsReviewed", 0); + // 뉴스 관련 통계 + response.put("newsRead", 0); + response.put("newsQuizCompleted", 0); + response.put("newsQuizPerfect", 0); + response.put("newsWordsCollected", 0); } return response; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java index cc25634c..1955d429 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java @@ -57,6 +57,14 @@ public class UserStats { private Integer quickGuesses; // 5초 내 정답 횟수 private Integer perfectDraws; // 전원 정답 유도 횟수 + // 뉴스 통계 + private Integer newsRead; // 읽은 뉴스 수 + private Integer newsQuizCompleted; // 완료한 뉴스 퀴즈 수 + private Integer newsQuizPerfect; // 뉴스 퀴즈 만점 횟수 + private Integer newsWordsCollected; // 뉴스에서 수집한 단어 수 + private Integer newsStreak; // 뉴스 연속 읽기 일수 + private String lastNewsReadDate; // 마지막 뉴스 읽은 날짜 + // 메타데이터 private String createdAt; private String updatedAt; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java index b3ad20d8..4ec4174f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java @@ -310,6 +310,247 @@ public void incrementGameStats(String userId, int gamesPlayed, int gamesWon, userId, gamesPlayed, gamesWon, correctGuesses); } + /** + * 뉴스 읽기 통계 Atomic 업데이트 (TOTAL + DAILY) + */ + public UserStats incrementNewsReadStats(String userId) { + String today = LocalDate.now().toString(); + String pk = StatsKey.userStatsPk(userId); + String now = Instant.now().toString(); + + // 먼저 현재 통계 조회 (streak 계산용) + UserStats currentStats = findTotalStats(userId).orElse(null); + String lastNewsReadDate = currentStats != null ? currentStats.getLastNewsReadDate() : null; + + // 연속 읽기 계산 + int currentStreak = 1; + if (lastNewsReadDate != null) { + LocalDate lastDate = LocalDate.parse(lastNewsReadDate); + LocalDate todayDate = LocalDate.now(); + if (lastDate.equals(todayDate.minusDays(1))) { + // 어제 읽었으면 streak 증가 + currentStreak = (currentStats.getNewsStreak() != null ? currentStats.getNewsStreak() : 0) + 1; + } else if (lastDate.equals(todayDate)) { + // 오늘 이미 읽었으면 streak 유지 + currentStreak = currentStats.getNewsStreak() != null ? currentStats.getNewsStreak() : 1; + } + // 그 외의 경우는 streak 1로 초기화 + } + + Map values = new HashMap<>(); + values.put(":one", AttributeValue.builder().n("1").build()); + values.put(":zero", AttributeValue.builder().n("0").build()); + values.put(":streak", AttributeValue.builder().n(String.valueOf(currentStreak)).build()); + values.put(":today", AttributeValue.builder().s(today).build()); + values.put(":now", AttributeValue.builder().s(now).build()); + + // 1. TOTAL 통계 업데이트 + Map totalKey = new HashMap<>(); + totalKey.put("PK", AttributeValue.builder().s(pk).build()); + totalKey.put("SK", AttributeValue.builder().s(StatsKey.statsTotalSk()).build()); + + String totalUpdateExpression = "SET " + + "newsRead = if_not_exists(newsRead, :zero) + :one, " + + "newsStreak = :streak, " + + "lastNewsReadDate = :today, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now)"; + + UpdateItemRequest totalRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(totalKey) + .updateExpression(totalUpdateExpression) + .expressionAttributeValues(values) + .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) + .build(); + + AwsClients.dynamoDb().updateItem(totalRequest); + + // 2. DAILY 통계 업데이트 + Map dailyKey = new HashMap<>(); + dailyKey.put("PK", AttributeValue.builder().s(pk).build()); + dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); + + Map dailyValues = new HashMap<>(); + dailyValues.put(":one", AttributeValue.builder().n("1").build()); + dailyValues.put(":zero", AttributeValue.builder().n("0").build()); + dailyValues.put(":now", AttributeValue.builder().s(now).build()); + dailyValues.put(":today", AttributeValue.builder().s(today).build()); + + String dailyUpdateExpression = "SET " + + "newsRead = if_not_exists(newsRead, :zero) + :one, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now), " + + "period = if_not_exists(period, :today)"; + + UpdateItemRequest dailyRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(dailyKey) + .updateExpression(dailyUpdateExpression) + .expressionAttributeValues(dailyValues) + .build(); + + AwsClients.dynamoDb().updateItem(dailyRequest); + logger.info("Incremented news read stats (TOTAL + DAILY): userId={}, streak={}", userId, currentStreak); + + return findTotalStats(userId).orElse(null); + } + + /** + * 뉴스 퀴즈 통계 Atomic 업데이트 (TOTAL + DAILY) + */ + public UserStats incrementNewsQuizStats(String userId, boolean isPerfect) { + String today = LocalDate.now().toString(); + String pk = StatsKey.userStatsPk(userId); + String now = Instant.now().toString(); + + Map values = new HashMap<>(); + values.put(":one", AttributeValue.builder().n("1").build()); + values.put(":zero", AttributeValue.builder().n("0").build()); + values.put(":perfect", AttributeValue.builder().n(isPerfect ? "1" : "0").build()); + values.put(":now", AttributeValue.builder().s(now).build()); + + // 1. TOTAL 통계 업데이트 + Map totalKey = new HashMap<>(); + totalKey.put("PK", AttributeValue.builder().s(pk).build()); + totalKey.put("SK", AttributeValue.builder().s(StatsKey.statsTotalSk()).build()); + + String totalUpdateExpression = "SET " + + "newsQuizCompleted = if_not_exists(newsQuizCompleted, :zero) + :one, " + + "newsQuizPerfect = if_not_exists(newsQuizPerfect, :zero) + :perfect, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now)"; + + UpdateItemRequest totalRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(totalKey) + .updateExpression(totalUpdateExpression) + .expressionAttributeValues(values) + .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) + .build(); + + AwsClients.dynamoDb().updateItem(totalRequest); + + // 2. DAILY 통계 업데이트 + Map dailyKey = new HashMap<>(); + dailyKey.put("PK", AttributeValue.builder().s(pk).build()); + dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); + + Map dailyValues = new HashMap<>(); + dailyValues.put(":one", AttributeValue.builder().n("1").build()); + dailyValues.put(":zero", AttributeValue.builder().n("0").build()); + dailyValues.put(":perfect", AttributeValue.builder().n(isPerfect ? "1" : "0").build()); + dailyValues.put(":now", AttributeValue.builder().s(now).build()); + dailyValues.put(":today", AttributeValue.builder().s(today).build()); + + String dailyUpdateExpression = "SET " + + "newsQuizCompleted = if_not_exists(newsQuizCompleted, :zero) + :one, " + + "newsQuizPerfect = if_not_exists(newsQuizPerfect, :zero) + :perfect, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now), " + + "period = if_not_exists(period, :today)"; + + UpdateItemRequest dailyRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(dailyKey) + .updateExpression(dailyUpdateExpression) + .expressionAttributeValues(dailyValues) + .build(); + + AwsClients.dynamoDb().updateItem(dailyRequest); + logger.info("Incremented news quiz stats (TOTAL + DAILY): userId={}, isPerfect={}", userId, isPerfect); + + return findTotalStats(userId).orElse(null); + } + + /** + * 뉴스 단어 수집 통계 Atomic 업데이트 (TOTAL + DAILY) + */ + public UserStats incrementNewsWordStats(String userId, int wordCount) { + String today = LocalDate.now().toString(); + String pk = StatsKey.userStatsPk(userId); + String now = Instant.now().toString(); + + Map values = new HashMap<>(); + values.put(":count", AttributeValue.builder().n(String.valueOf(wordCount)).build()); + values.put(":zero", AttributeValue.builder().n("0").build()); + values.put(":now", AttributeValue.builder().s(now).build()); + + // 1. TOTAL 통계 업데이트 + Map totalKey = new HashMap<>(); + totalKey.put("PK", AttributeValue.builder().s(pk).build()); + totalKey.put("SK", AttributeValue.builder().s(StatsKey.statsTotalSk()).build()); + + String totalUpdateExpression = "SET " + + "newsWordsCollected = if_not_exists(newsWordsCollected, :zero) + :count, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now)"; + + UpdateItemRequest totalRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(totalKey) + .updateExpression(totalUpdateExpression) + .expressionAttributeValues(values) + .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) + .build(); + + AwsClients.dynamoDb().updateItem(totalRequest); + + // 2. DAILY 통계 업데이트 + Map dailyKey = new HashMap<>(); + dailyKey.put("PK", AttributeValue.builder().s(pk).build()); + dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); + + Map dailyValues = new HashMap<>(); + dailyValues.put(":count", AttributeValue.builder().n(String.valueOf(wordCount)).build()); + dailyValues.put(":zero", AttributeValue.builder().n("0").build()); + dailyValues.put(":now", AttributeValue.builder().s(now).build()); + dailyValues.put(":today", AttributeValue.builder().s(today).build()); + + String dailyUpdateExpression = "SET " + + "newsWordsCollected = if_not_exists(newsWordsCollected, :zero) + :count, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now), " + + "period = if_not_exists(period, :today)"; + + UpdateItemRequest dailyRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(dailyKey) + .updateExpression(dailyUpdateExpression) + .expressionAttributeValues(dailyValues) + .build(); + + AwsClients.dynamoDb().updateItem(dailyRequest); + logger.info("Incremented news word stats (TOTAL + DAILY): userId={}, wordCount={}", userId, wordCount); + + return findTotalStats(userId).orElse(null); + } + + /** + * 연속 학습 중인 사용자 목록 조회 (streak >= 1) + * GSI1을 사용하여 TOTAL 통계만 조회 후 필터링 + */ + public List findUsersWithActiveStreak() { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder() + .partitionValue("STATS#ALL") + .sortValue(StatsKey.statsTotalSk()) + .build()); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .build(); + + List results = new ArrayList<>(); + table.index("GSI1").query(request).forEach(page -> { + page.items().stream() + .filter(stats -> stats.getCurrentStreak() != null && stats.getCurrentStreak() >= 1) + .forEach(results::add); + }); + + return results; + } + /** * 현재 연도-주차 반환 (예: 2026-W02) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ProfileResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ProfileResponse.java index bdc1ced7..7f17bd4a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ProfileResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ProfileResponse.java @@ -29,7 +29,7 @@ public static ProfileResponse from(User user) { .email(user.getEmail()) .nickname(user.getNickname()) .level(user.getLevel()) - .profileUrl(user.getProfileUrl()) + .profileUrl(user.getProfileUrlForResponse()) .createdAt(user.getCreatedAt()) .updatedAt(user.getUpdatedAt()) .build(); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java index 97bd6638..c43d41e5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java @@ -19,14 +19,19 @@ public class PostConfirmationHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(PostConfirmationHandler.class); - private static final String DEFAULT_PROFILE_URL = "https://group2-englishstudy.s3.amazonaws.com/profile/default.png"; - + private static final String BUCKET_NAME = System.getenv("BUCKET_NAME"); + private static final String DEFAULT_PROFILE_URL = getDefaultProfileUrl(); private final UserRepository userRepository; public PostConfirmationHandler() { this.userRepository = new UserRepository(); } + private static String getDefaultProfileUrl() { + String bucket = BUCKET_NAME != null ? BUCKET_NAME : "group2-englishstudy"; + return String.format("https://%s.s3.amazonaws.com/profile/default.png", bucket); + } + @Override public CognitoUserPoolPostConfirmationEvent handleRequest( CognitoUserPoolPostConfirmationEvent event, diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java index d881615b..67b8bc87 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java @@ -11,7 +11,17 @@ public class PreSignUpHandler implements RequestHandler, Map> { private static final Logger logger = LoggerFactory.getLogger(PreSignUpHandler.class); - private static final String DEFAULT_PROFILE_URL = System.getenv("DEFAULT_PROFILE_URL"); + private static final String BUCKET_NAME = System.getenv("BUCKET_NAME"); + private static final String DEFAULT_PROFILE_URL = getDefaultProfileUrl(); + + private static String getDefaultProfileUrl() { + String envUrl = System.getenv("DEFAULT_PROFILE_URL"); + if (envUrl != null && !envUrl.isEmpty()) { + return envUrl; + } + String bucket = BUCKET_NAME != null ? BUCKET_NAME : "group2-englishstudy"; + return String.format("https://%s.s3.amazonaws.com/profile/default.png", bucket); + } @Override public Map handleRequest(Map input, Context context) { @@ -38,11 +48,8 @@ public Map handleRequest(Map input, Context cont String profileUrl = userAttributes.get("custom:profileUrl"); if (profileUrl == null || profileUrl.trim().isEmpty()) { - String defaultUrl = DEFAULT_PROFILE_URL != null - ? DEFAULT_PROFILE_URL - : "https://group2-englishstudy.s3.amazonaws.com/profile/default.png"; - userAttributes.put("custom:profileUrl", defaultUrl); - logger.info("프로필 이미지 기본값: {}", defaultUrl); + userAttributes.put("custom:profileUrl", DEFAULT_PROFILE_URL); + logger.info("프로필 이미지 기본값: {}", DEFAULT_PROFILE_URL); } return input; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java index b4fc9aea..c2f7b41c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java @@ -59,9 +59,20 @@ private APIGatewayProxyResponseEvent getMyProfile( APIGatewayProxyRequestEvent request, String userId // cognitoSub ) { - User user = userService.getProfile(userId, request); - ProfileResponse response = ProfileResponse.from(user); + + // profileUrl을 Presigned URL로 변환 + String presignedUrl = userService.getPresignedProfileUrl(user.getProfileUrl()); + + ProfileResponse response = ProfileResponse.builder() + .userId(user.getCognitoSub()) + .email(user.getEmail()) + .nickname(user.getNickname()) + .level(user.getLevel()) + .profileUrl(presignedUrl) // Presigned URL 사용 + .createdAt(user.getCreatedAt()) + .updatedAt(user.getUpdatedAt()) + .build(); return ResponseGenerator.ok(user.getNickname() + " 환영합니다!", response); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java index 344d77e4..218c7eb9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java @@ -27,11 +27,13 @@ public class User { private String nickname; private String level; private String profileUrl; + private String profileUrlForResponse; private String createdAt; private String updatedAt; private String lastLoginAt; private Long ttl; + /** * 신규 사용자 생성 * - Lazy Registration 적용: 최초 프로필 조회 시 DynamoDB에 저장 @@ -115,6 +117,11 @@ public void updateProfileUrl(String newProfileUrl) { this.updatedAt = Instant.now().toString(); } + @DynamoDbIgnore + public String getProfileUrlForResponse() { + return profileUrlForResponse != null ? profileUrlForResponse : profileUrl; + } + public void updateLastLoginAt() { this.lastLoginAt = Instant.now().toString(); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java index 0c5c99c6..e302205b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java @@ -7,8 +7,10 @@ import com.mzc.secondproject.serverless.domain.user.repository.UserRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; @@ -22,21 +24,28 @@ public class UserService { private static final Logger logger = LoggerFactory.getLogger(UserService.class); private static final String BUCKET_NAME = System.getenv("PROFILE_BUCKET_NAME"); - private static final String DEFAULT_PROFILE_URL = "https://group2-englishstudy.s3.amazonaws.com/profile/default.png"; + private static final String DEFAULT_PROFILE_URL = getDefaultProfileUrl(); private static final List VALID_LEVELS = Arrays.asList("BEGINNER", "INTERMEDIATE", "ADVANCED"); - private static final List VALID_IMAGE_TYPES = Arrays.asList("image/jpeg", "image/png", "image/gif", "image/webp"); private static final int NICKNAME_MIN_LENGTH = 2; private static final int NICKNAME_MAX_LENGTH = 20; - private final UserRepository userRepository; private final S3Presigner s3Presigner; - + public UserService(UserRepository userRepository) { this.userRepository = userRepository; // AwsClients 싱글톤 사용 - Cold Start 최적화 this.s3Presigner = AwsClients.s3Presigner(); } + + public UserService() { + this(new UserRepository()); + } + + private static String getDefaultProfileUrl() { + String bucket = BUCKET_NAME != null ? BUCKET_NAME : "group2-englishstudy"; + return String.format("https://%s.s3.amazonaws.com/profile/default.png", bucket); + } /** * 프로필 조회 @@ -48,17 +57,57 @@ public UserService(UserRepository userRepository) { */ public User getProfile(String userId, APIGatewayProxyRequestEvent request) { - return userRepository.findByCognitoSub(userId) - .map(user -> { - // 정상 DB에서 조회 완료 - user.updateLastLoginAt(); - userRepository.update(user); - return user; + User user = userRepository.findByCognitoSub(userId) + .map(u -> { + u.updateLastLoginAt(); + userRepository.update(u); + return u; }) - .orElseGet(() -> { - // PostConfirmation 실패 대비 fallback - return createUserFromRequest(userId, request); - }); + .orElseGet(() -> createUserFromRequest(userId, request)); + + // 프로필 URL을 Presigned URL로 변환 + String presignedProfileUrl = getPresignedProfileUrl(user.getProfileUrl()); + user.setProfileUrlForResponse(presignedProfileUrl); // 응답용으로만 설정 + + return user; + } + + // 단순 프로필 조회 메서드 (채팅용) - DB 조회만 수행 + public User getUserProfile(String userId) { + return userRepository.findByCognitoSub(userId).orElse(null); + } + + public String getPresignedProfileUrl(String s3Url) { + if (s3Url == null || s3Url.isEmpty()) { + return generateGetPresignedUrl("profile/default.png"); + } + String key = extractKeyFromS3Url(s3Url); + return generateGetPresignedUrl(key); + } + + private String generateGetPresignedUrl(String imageKey) { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(BUCKET_NAME) + .key(imageKey) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofHours(24)) + .getObjectRequest(getObjectRequest) + .build(); + + return s3Presigner.presignGetObject(presignRequest).url().toString(); + } + + + private String extractKeyFromS3Url(String s3Url) { + // https://group2-englishstudy.s3.amazonaws.com/profile/user123/img.png + // → profile/user123/img.png + String prefix = String.format("https://%s.s3.amazonaws.com/", BUCKET_NAME); + if (s3Url.startsWith(prefix)) { + return s3Url.substring(prefix.length()); + } + return s3Url; } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordCategory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordCategory.java index 9a65b41a..aafe1eac 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordCategory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordCategory.java @@ -7,7 +7,8 @@ public enum WordCategory { BUSINESS("business", "비즈니스"), ACADEMIC("academic", "학술"), TRAVEL("travel", "여행"), - TECHNOLOGY("technology", "기술"); + TECHNOLOGY("technology", "기술"), + NEWS("news", "뉴스"); private final String code; private final String displayName; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java index 83047fa4..84aca50a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java @@ -71,13 +71,14 @@ private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent re String cursor = queryParams != null ? queryParams.get("cursor") : null; String bookmarked = queryParams != null ? queryParams.get("bookmarked") : null; String incorrectOnly = queryParams != null ? queryParams.get("incorrectOnly") : null; + String category = queryParams != null ? queryParams.get("category") : null; int limit = 20; if (queryParams != null && queryParams.get("limit") != null) { limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); } - UserWordQueryService.UserWordsResult result = queryService.getUserWords(userId, status, bookmarked, incorrectOnly, limit, cursor); + UserWordQueryService.UserWordsResult result = queryService.getUserWords(userId, status, bookmarked, incorrectOnly, category, limit, cursor); Map response = new HashMap<>(); response.put("userWords", result.userWords()); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java index 6cb33629..198a6849 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java @@ -17,7 +17,9 @@ import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -115,4 +117,24 @@ public void addLearnedWord(String userId, String date, String wordId) { AwsClients.dynamoDb().updateItem(updateRequest); logger.info("Added learned word: userId={}, date={}, wordId={}", userId, date, wordId); } + + /** + * 특정 날짜의 모든 일일 학습 기록 조회 (GSI1 사용) + */ + public List findByDate(String date) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder() + .partitionValue("DAILY#ALL") + .sortValue("DATE#" + date) + .build()); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .build(); + + List results = new ArrayList<>(); + table.index("GSI1").query(request).forEach(page -> results.addAll(page.items())); + + return results; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java index 32dc5b24..81a528c6 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java @@ -3,6 +3,7 @@ import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.enums.StudyLevel; import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; +import com.mzc.secondproject.serverless.domain.notification.service.NotificationPublisher; import com.mzc.secondproject.serverless.domain.stats.model.UserStats; import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import com.mzc.secondproject.serverless.domain.vocabulary.config.VocabularyConfig; @@ -34,15 +35,16 @@ public class DailyStudyCommandService { private final WordRepository wordRepository; private final UserStatsRepository userStatsRepository; private final BadgeService badgeService; - + private final NotificationPublisher notificationPublisher; + /** * 기본 생성자 (Lambda에서 사용) */ public DailyStudyCommandService() { this(new DailyStudyRepository(), new UserWordRepository(), new WordRepository(), - new UserStatsRepository(), new BadgeService()); + new UserStatsRepository(), new BadgeService(), NotificationPublisher.getInstance()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -50,12 +52,14 @@ public DailyStudyCommandService(DailyStudyRepository dailyStudyRepository, UserWordRepository userWordRepository, WordRepository wordRepository, UserStatsRepository userStatsRepository, - BadgeService badgeService) { + BadgeService badgeService, + NotificationPublisher notificationPublisher) { this.dailyStudyRepository = dailyStudyRepository; this.userWordRepository = userWordRepository; this.wordRepository = wordRepository; this.userStatsRepository = userStatsRepository; this.badgeService = badgeService; + this.notificationPublisher = notificationPublisher; } public DailyStudyResult getDailyWords(String userId, String level) { @@ -115,16 +119,36 @@ public Map markWordLearned(String userId, String wordId) { checkWordsBadge(userId); DailyStudy updatedDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today).orElse(dailyStudy); - + if (updatedDailyStudy.getLearnedCount() >= updatedDailyStudy.getTotalWords()) { updatedDailyStudy.setIsCompleted(true); dailyStudyRepository.save(updatedDailyStudy); + + // 일일 학습 완료 알림 발행 + int currentStreak = getCurrentStreak(userId); + notificationPublisher.publishDailyComplete( + userId, + today, + updatedDailyStudy.getLearnedCount(), + updatedDailyStudy.getTotalWords(), + currentStreak + ); } - + logger.info("Marked word as learned: userId={}, wordId={}, isNew={}, isReview={}", userId, wordId, isNewWord, isReviewWord); return calculateProgress(updatedDailyStudy); } + + private int getCurrentStreak(String userId) { + try { + Optional stats = userStatsRepository.findTotalStats(userId); + return stats.map(UserStats::getCurrentStreak).orElse(0); + } catch (Exception e) { + logger.warn("Failed to get current streak for user: {}", userId, e); + return 0; + } + } private DailyStudy createDailyStudy(String userId, String date, String level) { String now = Instant.now().toString(); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java index 43c0c095..f9ef6861 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java @@ -4,6 +4,7 @@ import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.domain.notification.service.NotificationPublisher; import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.SubmitTestRequest; import com.mzc.secondproject.serverless.domain.vocabulary.exception.VocabularyException; import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; @@ -33,26 +34,29 @@ public class TestCommandService { private final DailyStudyRepository dailyStudyRepository; private final WordRepository wordRepository; private final UserWordCommandService userWordCommandService; - + private final NotificationPublisher notificationPublisher; + /** * 기본 생성자 (Lambda에서 사용) */ public TestCommandService() { this(new TestResultRepository(), new DailyStudyRepository(), - new WordRepository(), new UserWordCommandService()); + new WordRepository(), new UserWordCommandService(), NotificationPublisher.getInstance()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ public TestCommandService(TestResultRepository testResultRepository, DailyStudyRepository dailyStudyRepository, WordRepository wordRepository, - UserWordCommandService userWordCommandService) { + UserWordCommandService userWordCommandService, + NotificationPublisher notificationPublisher) { this.testResultRepository = testResultRepository; this.dailyStudyRepository = dailyStudyRepository; this.wordRepository = wordRepository; this.userWordCommandService = userWordCommandService; + this.notificationPublisher = notificationPublisher; } public StartTestResult startTest(String userId, String testType) { @@ -116,12 +120,23 @@ public SubmitTestResult submitTest(String userId, String testId, String testType // 3. 오답 단어 자동 북마크 bookmarkIncorrectWords(userId, gradingResult.incorrectWordIds()); - // 4. SNS 알림 발행 + // 4. SNS 알림 발행 (통계 업데이트용) publishTestResultToSns(userId, gradingResult.results()); - + + // 5. 실시간 알림 발행 + boolean isPerfect = gradingResult.correctCount() == gradingResult.totalQuestions(); + notificationPublisher.publishTestComplete( + userId, + testId, + (int) Math.round(gradingResult.successRate()), + gradingResult.correctCount(), + gradingResult.totalQuestions(), + isPerfect + ); + logger.info("Test submitted: userId={}, testId={}, successRate={}%", userId, testId, gradingResult.successRate()); - + return new SubmitTestResult( testId, testType, gradingResult.totalQuestions(), gradingResult.correctCount(), gradingResult.incorrectCount(), diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java index bf6920e5..a606d5f7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java @@ -37,21 +37,34 @@ public UserWordQueryService(UserWordRepository userWordRepository, WordRepositor } public UserWordsResult getUserWords(String userId, String status, String bookmarked, - String incorrectOnly, int limit, String cursor) { + String incorrectOnly, String category, int limit, String cursor) { PaginatedResult userWordPage; if ("true".equalsIgnoreCase(bookmarked)) { - userWordPage = userWordRepository.findBookmarkedWords(userId, limit, cursor); + userWordPage = userWordRepository.findBookmarkedWords(userId, limit * 3, cursor); } else if ("true".equalsIgnoreCase(incorrectOnly)) { - userWordPage = userWordRepository.findIncorrectWords(userId, limit, cursor); + userWordPage = userWordRepository.findIncorrectWords(userId, limit * 3, cursor); } else if (status != null && !status.isEmpty()) { - userWordPage = userWordRepository.findByUserIdAndStatus(userId, status, limit, cursor); + userWordPage = userWordRepository.findByUserIdAndStatus(userId, status, limit * 3, cursor); } else { - userWordPage = userWordRepository.findByUserIdWithPagination(userId, limit, cursor); + userWordPage = userWordRepository.findByUserIdWithPagination(userId, limit * 3, cursor); } List> enrichedUserWords = enrichWithWordInfo(userWordPage.items()); + // 카테고리 필터링 (Word 테이블 조인 후 필터) + if (category != null && !category.isEmpty()) { + String upperCategory = category.toUpperCase(); + enrichedUserWords = enrichedUserWords.stream() + .filter(w -> upperCategory.equals(w.get("category"))) + .limit(limit) + .collect(Collectors.toList()); + } else { + enrichedUserWords = enrichedUserWords.stream() + .limit(limit) + .collect(Collectors.toList()); + } + return new UserWordsResult(enrichedUserWords, userWordPage.nextCursor(), userWordPage.hasMore()); } diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy index 9895fc71..911a0ea3 100644 --- a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy @@ -44,11 +44,13 @@ class ChattingErrorCodeSpec extends Specification { ChattingErrorCode.GAME_NOT_ALLOWED_IN_CHAT_ROOM | "GAME_007" | 400 ChattingErrorCode.GAME_RESTART_NOT_ALLOWED | "GAME_008" | 400 ChattingErrorCode.GAME_START_NOT_HOST | "GAME_009" | 403 + ChattingErrorCode.GAME_ACTION_FAILED | "GAME_010" | 400 + ChattingErrorCode.INVALID_INPUT | "INPUT_001" | 400 } def "모든 에러 코드 개수 확인"() { - expect: "24개의 에러 코드 존재" - ChattingErrorCode.values().length == 24 + expect: "26개의 에러 코드 존재" + ChattingErrorCode.values().length == 26 } def "채팅방 관련 에러 코드들 (ROOM_XXX)"() { diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/PollSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/PollSpec.groovy new file mode 100644 index 00000000..cee47562 --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/PollSpec.groovy @@ -0,0 +1,148 @@ +package com.mzc.secondproject.serverless.domain.chatting.model + +import spock.lang.Specification + +class PollSpec extends Specification { + + def "addVote: 정상적인 투표 추가"() { + given: + def poll = Poll.builder() + .pollId("poll-123") + .options(["옵션1", "옵션2", "옵션3"]) + .votes(["0": 0, "1": 0, "2": 0]) + .userVotes([:]) + .build() + + when: + def result = poll.addVote("user1", 0) + + then: + result == true + poll.votes["0"] == 1 + poll.userVotes["user1"] == 0 + } + + def "addVote: 이미 투표한 사용자는 재투표 불가"() { + given: + def poll = Poll.builder() + .options(["옵션1", "옵션2"]) + .votes(["0": 1, "1": 0]) + .userVotes(["user1": 0]) + .build() + + when: + def result = poll.addVote("user1", 1) + + then: + result == false + poll.votes["0"] == 1 + poll.votes["1"] == 0 + } + + def "addVote: 유효하지 않은 옵션 인덱스"() { + given: + def poll = Poll.builder() + .options(["옵션1", "옵션2"]) + .votes(["0": 0, "1": 0]) + .userVotes([:]) + .build() + + when: + def result = poll.addVote("user1", 5) + + then: + result == false + } + + def "addVote: 음수 옵션 인덱스"() { + given: + def poll = Poll.builder() + .options(["옵션1", "옵션2"]) + .votes(["0": 0, "1": 0]) + .userVotes([:]) + .build() + + when: + def result = poll.addVote("user1", -1) + + then: + result == false + } + + def "hasVoted: 투표한 사용자 확인"() { + given: + def poll = Poll.builder() + .userVotes(["user1": 0]) + .build() + + expect: + poll.hasVoted("user1") == true + poll.hasVoted("user2") == false + } + + def "hasVoted: userVotes가 null인 경우"() { + given: + def poll = Poll.builder() + .userVotes(null) + .build() + + expect: + poll.hasVoted("user1") == false + } + + def "getTotalVotes: 총 투표 수 계산"() { + given: + def poll = Poll.builder() + .votes(["0": 3, "1": 2, "2": 5]) + .build() + + expect: + poll.getTotalVotes() == 10 + } + + def "getTotalVotes: 투표가 없는 경우"() { + given: + def poll = Poll.builder() + .votes(["0": 0, "1": 0]) + .build() + + expect: + poll.getTotalVotes() == 0 + } + + def "getTotalVotes: votes가 null인 경우"() { + given: + def poll = Poll.builder() + .votes(null) + .build() + + expect: + poll.getTotalVotes() == 0 + } + + def "여러 사용자 투표 시나리오"() { + given: + def poll = Poll.builder() + .options(["A", "B", "C"]) + .votes(["0": 0, "1": 0, "2": 0]) + .userVotes([:]) + .build() + + when: + poll.addVote("user1", 0) + poll.addVote("user2", 0) + poll.addVote("user3", 1) + poll.addVote("user4", 2) + + then: + poll.votes["0"] == 2 + poll.votes["1"] == 1 + poll.votes["2"] == 1 + poll.getTotalVotes() == 4 + poll.hasVoted("user1") + poll.hasVoted("user2") + poll.hasVoted("user3") + poll.hasVoted("user4") + !poll.hasVoted("user5") + } +} diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSessionSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSessionSpec.groovy new file mode 100644 index 00000000..396c6421 --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSessionSpec.groovy @@ -0,0 +1,272 @@ +package com.mzc.secondproject.serverless.domain.chatting.model + +import spock.lang.Specification +import spock.lang.Unroll + +class WordChainSessionSpec extends Specification { + + def "calculateTimeLimit: 라운드별 시간 제한 계산"() { + expect: + WordChainSession.calculateTimeLimit(round) == expected + + where: + // 기본 15초에서 2라운드마다 1초씩 감소, 최소 7초 (15/2) + round | expected + 1 | 15 + 2 | 15 + 3 | 14 + 4 | 14 + 5 | 13 + 6 | 13 + 7 | 12 + 8 | 12 + 9 | 11 + 10 | 11 + 20 | 7 // 최소값 + } + + def "calculateScore: 기본 점수 계산"() { + when: + def score = WordChainSession.calculateScore(responseTimeMs, wordLength, timeLimit) + + then: + score == expected + + where: + responseTimeMs | wordLength | timeLimit | expected + 0 | 4 | 15 | 25 // base(10) + time(15) + length(0) + 5000 | 4 | 15 | 20 // base(10) + time(10) + length(0) + 10000 | 4 | 15 | 15 // base(10) + time(5) + length(0) + 15000 | 4 | 15 | 10 // base(10) + time(0) + length(0) + 0 | 7 | 15 | 31 // base(10) + time(15) + length(6) + 5000 | 6 | 15 | 24 // base(10) + time(10) + length(4) + } + + def "isActive: 게임 활성 상태 확인"() { + given: + def session = WordChainSession.builder() + .status(status) + .build() + + expect: + session.isActive() == expected + + where: + status | expected + "PLAYING" | true + "FINISHED" | false + "WAITING" | false + null | false + } + + def "isCurrentTurn: 현재 턴 확인"() { + given: + def session = WordChainSession.builder() + .currentPlayerId("player1") + .build() + + expect: + session.isCurrentTurn("player1") == true + session.isCurrentTurn("player2") == false + session.isCurrentTurn(null) == false + } + + def "isWordUsed: 단어 사용 여부 확인"() { + given: + def session = WordChainSession.builder() + .usedWords(["apple", "elephant", "tiger"]) + .build() + + expect: + session.isWordUsed("apple") == true + session.isWordUsed("APPLE") == true + session.isWordUsed("banana") == false + } + + def "isWordUsed: usedWords가 null인 경우"() { + given: + def session = WordChainSession.builder() + .usedWords(null) + .build() + + expect: + session.isWordUsed("apple") == false + } + + def "addUsedWord: 단어 추가"() { + given: + def session = WordChainSession.builder() + .usedWords(new ArrayList<>()) + .wordDefinitions(new HashMap<>()) + .build() + + when: + session.addUsedWord("Apple", "(noun) A fruit") + + then: + session.usedWords.contains("apple") + session.wordDefinitions["apple"] == "(noun) A fruit" + } + + def "addUsedWord: null 리스트에서 시작"() { + given: + def session = WordChainSession.builder() + .usedWords(null) + .wordDefinitions(null) + .build() + + when: + session.addUsedWord("apple", "(noun) A fruit") + + then: + session.usedWords == ["apple"] + session.wordDefinitions["apple"] == "(noun) A fruit" + } + + def "addUsedWord: definition이 null인 경우"() { + given: + def session = WordChainSession.builder() + .usedWords(new ArrayList<>()) + .wordDefinitions(new HashMap<>()) + .build() + + when: + session.addUsedWord("apple", null) + + then: + session.usedWords.contains("apple") + !session.wordDefinitions.containsKey("apple") + } + + def "eliminatePlayer: 플레이어 탈락 처리"() { + given: + def session = WordChainSession.builder() + .activePlayers(new ArrayList<>(["player1", "player2", "player3"])) + .eliminatedPlayers(new ArrayList<>()) + .build() + + when: + session.eliminatePlayer("player2") + + then: + session.activePlayers == ["player1", "player3"] + session.eliminatedPlayers == ["player2"] + } + + def "eliminatePlayer: 이미 탈락한 플레이어는 중복 추가되지 않음"() { + given: + def session = WordChainSession.builder() + .activePlayers(new ArrayList<>(["player1"])) + .eliminatedPlayers(new ArrayList<>(["player2"])) + .build() + + when: + session.eliminatePlayer("player2") + + then: + session.eliminatedPlayers.size() == 1 + } + + def "getNextPlayerId: 다음 플레이어 반환"() { + given: + def session = WordChainSession.builder() + .activePlayers(["player1", "player2", "player3"]) + .currentPlayerId(currentPlayer) + .build() + + expect: + session.getNextPlayerId() == expected + + where: + currentPlayer | expected + "player1" | "player2" + "player2" | "player3" + "player3" | "player1" + null | "player1" + "unknown" | "player1" + } + + def "getNextPlayerId: 한 명만 남은 경우"() { + given: + def session = WordChainSession.builder() + .activePlayers(["winner"]) + .currentPlayerId("winner") + .build() + + expect: + session.getNextPlayerId() == "winner" + } + + def "getNextPlayerId: 빈 리스트인 경우"() { + given: + def session = WordChainSession.builder() + .activePlayers([]) + .build() + + expect: + session.getNextPlayerId() == null + } + + def "addScore: 점수 추가"() { + given: + def session = WordChainSession.builder() + .scores(new HashMap<>()) + .build() + + when: + session.addScore("player1", 10) + session.addScore("player1", 15) + session.addScore("player2", 20) + + then: + session.scores["player1"] == 25 + session.scores["player2"] == 20 + } + + def "addScore: scores가 null인 경우"() { + given: + def session = WordChainSession.builder() + .scores(null) + .build() + + when: + session.addScore("player1", 10) + + then: + session.scores["player1"] == 10 + } + + def "isGameOver: 게임 종료 조건 확인"() { + given: + def session = WordChainSession.builder() + .activePlayers(players) + .build() + + expect: + session.isGameOver() == expected + + where: + players | expected + null | true + [] | true + ["player1"] | true + ["player1", "player2"] | false + } + + def "getWinner: 승자 반환"() { + given: + def session = WordChainSession.builder() + .activePlayers(players) + .build() + + expect: + session.getWinner() == expected + + where: + players | expected + ["winner"] | "winner" + ["p1", "p2"] | null + [] | null + null | null + } +} diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/service/DictionaryServiceSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/service/DictionaryServiceSpec.groovy new file mode 100644 index 00000000..d32e825a --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/service/DictionaryServiceSpec.groovy @@ -0,0 +1,54 @@ +package com.mzc.secondproject.serverless.domain.chatting.service + +import spock.lang.Specification + +class DictionaryServiceSpec extends Specification { + + def "DictionaryResult.valid: 유효한 결과 생성"() { + when: + def result = DictionaryService.DictionaryResult.valid("apple", "(noun) A fruit", "/ˈæpəl/") + + then: + result.isValid() + result.word() == "apple" + result.getDefinition().isPresent() + result.getDefinition().get() == "(noun) A fruit" + result.getPhonetic().isPresent() + result.getPhonetic().get() == "/ˈæpəl/" + result.errorMessage() == null + } + + def "DictionaryResult.validWithoutDefinition: 정의 없이 유효한 결과"() { + when: + def result = DictionaryService.DictionaryResult.validWithoutDefinition("apple") + + then: + result.isValid() + result.word() == "apple" + result.getDefinition().isEmpty() + result.getPhonetic().isEmpty() + } + + def "DictionaryResult.invalid: 유효하지 않은 결과"() { + when: + def result = DictionaryService.DictionaryResult.invalid("사전에 없는 단어입니다.") + + then: + !result.isValid() + result.word() == null + result.getDefinition().isEmpty() + result.errorMessage() == "사전에 없는 단어입니다." + } + + def "DictionaryResult.getDefinition: Optional 반환"() { + expect: + DictionaryService.DictionaryResult.valid("test", "def", null).getDefinition().isPresent() + DictionaryService.DictionaryResult.valid("test", null, null).getDefinition().isEmpty() + } + + def "DictionaryResult.getPhonetic: Optional 반환"() { + expect: + DictionaryService.DictionaryResult.valid("test", null, "/test/").getPhonetic().isPresent() + DictionaryService.DictionaryResult.valid("test", null, null).getPhonetic().isEmpty() + } +} diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/news/config/NewsConfigSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/news/config/NewsConfigSpec.groovy new file mode 100644 index 00000000..eac08089 --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/news/config/NewsConfigSpec.groovy @@ -0,0 +1,172 @@ +package com.mzc.secondproject.serverless.domain.news.config + +import spock.lang.Specification +import spock.lang.Unroll + +class NewsConfigSpec extends Specification { + + // ==================== TTS Constants Tests ==================== + + def "TTS_MAX_TEXT_LENGTH: TTS 최대 텍스트 길이는 3000자"() { + expect: + NewsConfig.TTS_MAX_TEXT_LENGTH == 3000 + } + + def "TTS_AUDIO_PREFIX: TTS 오디오 저장 경로 확인"() { + expect: + NewsConfig.TTS_AUDIO_PREFIX == "news/audio/" + } + + def "DEFAULT_VOICE: 기본 TTS 음성은 Joanna"() { + expect: + NewsConfig.DEFAULT_VOICE == "Joanna" + } + + // ==================== Pagination Constants Tests ==================== + + def "DEFAULT_PAGE_SIZE: 기본 페이지 크기는 10"() { + expect: + NewsConfig.DEFAULT_PAGE_SIZE == 10 + } + + def "MAX_PAGE_SIZE: 최대 페이지 크기는 50"() { + expect: + NewsConfig.MAX_PAGE_SIZE == 50 + } + + // ==================== Score Threshold Tests ==================== + + def "SCORE_PERFECT: 만점 기준은 100"() { + expect: + NewsConfig.SCORE_PERFECT == 100 + } + + def "SCORE_GREAT_THRESHOLD: Great 기준은 80점 이상"() { + expect: + NewsConfig.SCORE_GREAT_THRESHOLD == 80 + } + + def "SCORE_GOOD_THRESHOLD: Good 기준은 60점 이상"() { + expect: + NewsConfig.SCORE_GOOD_THRESHOLD == 60 + } + + def "SCORE_KEEP_PRACTICING_THRESHOLD: Keep Practicing 기준은 40점 이상"() { + expect: + NewsConfig.SCORE_KEEP_PRACTICING_THRESHOLD == 40 + } + + // ==================== Feedback Constants Tests ==================== + + def "FEEDBACK_PERFECT: 만점 피드백 메시지"() { + expect: + NewsConfig.FEEDBACK_PERFECT == "Perfect! You understood the article completely." + } + + def "FEEDBACK_GREAT: Great 피드백 메시지"() { + expect: + NewsConfig.FEEDBACK_GREAT == "Great job! You have a solid understanding of the article." + } + + def "FEEDBACK_GOOD: Good 피드백 메시지"() { + expect: + NewsConfig.FEEDBACK_GOOD == "Good effort! Review the highlighted words for better comprehension." + } + + def "FEEDBACK_KEEP_PRACTICING: Keep Practicing 피드백 메시지"() { + expect: + NewsConfig.FEEDBACK_KEEP_PRACTICING == "Keep practicing! Try reading the article again before retaking the quiz." + } + + def "FEEDBACK_DONT_GIVE_UP: Don't Give Up 피드백 메시지"() { + expect: + NewsConfig.FEEDBACK_DONT_GIVE_UP == "Don't give up! Focus on vocabulary and main ideas." + } + + // ==================== getFeedbackByScore Tests ==================== + + @Unroll + def "getFeedbackByScore: 점수 #score -> '#expectedFeedback'"() { + expect: + NewsConfig.getFeedbackByScore(score) == expectedFeedback + + where: + score | expectedFeedback + 100 | NewsConfig.FEEDBACK_PERFECT + 99 | NewsConfig.FEEDBACK_GREAT + 80 | NewsConfig.FEEDBACK_GREAT + 79 | NewsConfig.FEEDBACK_GOOD + 60 | NewsConfig.FEEDBACK_GOOD + 59 | NewsConfig.FEEDBACK_KEEP_PRACTICING + 40 | NewsConfig.FEEDBACK_KEEP_PRACTICING + 39 | NewsConfig.FEEDBACK_DONT_GIVE_UP + 0 | NewsConfig.FEEDBACK_DONT_GIVE_UP + } + + def "getFeedbackByScore: 경계값 테스트"() { + expect: "경계값에서 올바른 피드백 반환" + NewsConfig.getFeedbackByScore(100) == NewsConfig.FEEDBACK_PERFECT + NewsConfig.getFeedbackByScore(80) == NewsConfig.FEEDBACK_GREAT + NewsConfig.getFeedbackByScore(60) == NewsConfig.FEEDBACK_GOOD + NewsConfig.getFeedbackByScore(40) == NewsConfig.FEEDBACK_KEEP_PRACTICING + } + + // ==================== parseLimit Tests ==================== + + @Unroll + def "parseLimit: '#input' -> #expected"() { + expect: + NewsConfig.parseLimit(input) == expected + + where: + input | expected + null | NewsConfig.DEFAULT_PAGE_SIZE + "" | NewsConfig.DEFAULT_PAGE_SIZE + "abc" | NewsConfig.DEFAULT_PAGE_SIZE + "10" | 10 + "1" | 1 + "50" | 50 + "100" | NewsConfig.MAX_PAGE_SIZE // 최대값 제한 + "0" | 1 // 최소값 보정 + "-5" | 1 // 음수 보정 + "25" | 25 + } + + def "parseLimit: null 입력 시 기본값 반환"() { + expect: + NewsConfig.parseLimit(null) == NewsConfig.DEFAULT_PAGE_SIZE + } + + def "parseLimit: 빈 문자열 입력 시 기본값 반환"() { + expect: + NewsConfig.parseLimit("") == NewsConfig.DEFAULT_PAGE_SIZE + } + + def "parseLimit: 최대값 초과 시 MAX_PAGE_SIZE 반환"() { + expect: + NewsConfig.parseLimit("999") == NewsConfig.MAX_PAGE_SIZE + } + + def "parseLimit: 0 이하 값 입력 시 1 반환"() { + expect: + NewsConfig.parseLimit("0") == 1 + NewsConfig.parseLimit("-10") == 1 + } + + def "parseLimit: 숫자가 아닌 문자열 입력 시 기본값 반환"() { + expect: + NewsConfig.parseLimit("not_a_number") == NewsConfig.DEFAULT_PAGE_SIZE + NewsConfig.parseLimit("12abc") == NewsConfig.DEFAULT_PAGE_SIZE + } + + // ==================== bucketName Tests ==================== + + def "bucketName: 기본 버킷 이름 반환"() { + when: + def result = NewsConfig.bucketName() + + then: "환경변수가 없으면 기본값, 있으면 해당 값" + result != null + result == "group2-englishstudy" || result instanceof String + } +} diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/news/constants/NewsKeySpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/news/constants/NewsKeySpec.groovy new file mode 100644 index 00000000..6e2f7505 --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/news/constants/NewsKeySpec.groovy @@ -0,0 +1,202 @@ +package com.mzc.secondproject.serverless.domain.news.constants + +import spock.lang.Specification +import spock.lang.Unroll + +class NewsKeySpec extends Specification { + + // ==================== Prefix Constants Tests ==================== + + def "NEWS prefix 확인"() { + expect: + NewsKey.NEWS == "NEWS#" + } + + def "ARTICLE prefix 확인"() { + expect: + NewsKey.ARTICLE == "ARTICLE#" + } + + def "LEVEL prefix 확인"() { + expect: + NewsKey.LEVEL == "LEVEL#" + } + + def "CATEGORY prefix 확인"() { + expect: + NewsKey.CATEGORY == "CATEGORY#" + } + + def "READ prefix 확인"() { + expect: + NewsKey.READ == "READ#" + } + + def "QUIZ prefix 확인"() { + expect: + NewsKey.QUIZ == "QUIZ#" + } + + def "WORD prefix 확인"() { + expect: + NewsKey.WORD == "WORD#" + } + + def "BOOKMARK prefix 확인"() { + expect: + NewsKey.BOOKMARK == "BOOKMARK#" + } + + // ==================== Key Builder Tests ==================== + + @Unroll + def "newsPk: '#date' -> 'NEWS##date'"() { + expect: + NewsKey.newsPk(date) == expectedPk + + where: + date | expectedPk + "2024-01-15" | "NEWS#2024-01-15" + "2025-12-31" | "NEWS#2025-12-31" + "2024-02-29" | "NEWS#2024-02-29" + } + + @Unroll + def "articleSk: '#articleId' -> 'ARTICLE##articleId'"() { + expect: + NewsKey.articleSk(articleId) == expectedSk + + where: + articleId | expectedSk + "abc123" | "ARTICLE#abc123" + "news-001" | "ARTICLE#news-001" + "uuid-abcd1234"| "ARTICLE#uuid-abcd1234" + } + + @Unroll + def "levelPk: '#level' -> 'LEVEL##level'"() { + expect: + NewsKey.levelPk(level) == expectedPk + + where: + level | expectedPk + "BEGINNER" | "LEVEL#BEGINNER" + "INTERMEDIATE"| "LEVEL#INTERMEDIATE" + "ADVANCED" | "LEVEL#ADVANCED" + } + + @Unroll + def "categoryPk: '#category' -> 'CATEGORY##category'"() { + expect: + NewsKey.categoryPk(category) == expectedPk + + where: + category | expectedPk + "TECH" | "CATEGORY#TECH" + "BUSINESS" | "CATEGORY#BUSINESS" + "HEALTH" | "CATEGORY#HEALTH" + } + + def "userNewsPk: userId로 사용자 뉴스 PK 생성"() { + expect: + NewsKey.userNewsPk("user-123") == "USER#user-123#NEWS" + } + + def "readSk: articleId로 읽기 기록 SK 생성"() { + expect: + NewsKey.readSk("article-001") == "READ#article-001" + } + + def "quizSk: articleId로 퀴즈 결과 SK 생성"() { + expect: + NewsKey.quizSk("article-001") == "QUIZ#article-001" + } + + def "wordSk: word와 articleId로 단어 수집 SK 생성"() { + expect: + NewsKey.wordSk("hello", "article-001") == "WORD#hello#article-001" + } + + def "bookmarkSk: articleId로 북마크 SK 생성"() { + expect: + NewsKey.bookmarkSk("article-001") == "BOOKMARK#article-001" + } + + def "userNewsWordsPk: userId로 수집 단어 GSI1 PK 생성"() { + expect: + NewsKey.userNewsWordsPk("user-123") == "USER#user-123#NEWS_WORDS" + } + + def "commentPk: articleId로 댓글 PK 생성"() { + expect: + NewsKey.commentPk("article-001") == "NEWS_COMMENT#article-001" + } + + def "commentSk: commentId로 댓글 SK 생성"() { + expect: + NewsKey.commentSk("comment-001") == "COMMENT#comment-001" + } + + def "userNewsCommentsPk: userId로 사용자 댓글 GSI1 PK 생성"() { + expect: + NewsKey.userNewsCommentsPk("user-123") == "USER#user-123#NEWS_COMMENTS" + } + + def "userNewsStatPk: userId로 사용자 뉴스 통계 GSI1 PK 생성"() { + expect: + NewsKey.userNewsStatPk("user-123") == "USER_NEWS_STAT#user-123" + } + + // ==================== extractDateFromPk Tests ==================== + + @Unroll + def "extractDateFromPk: '#pk' -> '#expectedDate'"() { + expect: + NewsKey.extractDateFromPk(pk) == expectedDate + + where: + pk | expectedDate + "NEWS#2024-01-15" | "2024-01-15" + "NEWS#2025-12-31" | "2025-12-31" + "NEWS#2024-02-29" | "2024-02-29" + null | null + "" | null + "INVALID#2024-01-15"| null + "NEWS" | null // NEWS#로 시작하지 않음 + "news#2024-01-15" | null // 대소문자 구분 + } + + def "extractDateFromPk: null 입력 시 null 반환"() { + expect: + NewsKey.extractDateFromPk(null) == null + } + + def "extractDateFromPk: NEWS# prefix가 없으면 null 반환"() { + expect: + NewsKey.extractDateFromPk("ARTICLE#2024-01-15") == null + NewsKey.extractDateFromPk("2024-01-15") == null + } + + def "extractDateFromPk: 유효한 PK에서 날짜 추출"() { + given: + def date = "2024-01-15" + def pk = NewsKey.newsPk(date) + + expect: + NewsKey.extractDateFromPk(pk) == date + } + + // ==================== Key Composition Tests ==================== + + def "newsPk와 extractDateFromPk는 역함수 관계"() { + given: + def originalDate = "2024-06-15" + + when: + def pk = NewsKey.newsPk(originalDate) + def extractedDate = NewsKey.extractDateFromPk(pk) + + then: + extractedDate == originalDate + } +} diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/config/NotificationConfigSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/config/NotificationConfigSpec.groovy new file mode 100644 index 00000000..2434e3fa --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/config/NotificationConfigSpec.groovy @@ -0,0 +1,91 @@ +package com.mzc.secondproject.serverless.domain.notification.config + +import spock.lang.Specification +import spock.lang.Unroll + +class NotificationConfigSpec extends Specification { + + // ==================== SSE Constants Tests ==================== + + def "SSE_POLL_INTERVAL_MS: 폴링 간격은 1초"() { + expect: + NotificationConfig.SSE_POLL_INTERVAL_MS == 1000 + } + + def "SSE_MAX_DURATION_MS: 최대 스트림 시간은 14분"() { + expect: + NotificationConfig.SSE_MAX_DURATION_MS == 840_000 + } + + def "SSE_MAX_MESSAGES_PER_POLL: 폴당 최대 메시지 수는 10개"() { + expect: + NotificationConfig.SSE_MAX_MESSAGES_PER_POLL == 10 + } + + def "SSE_WAIT_TIME_SECONDS: 롱 폴링 대기 시간은 1초"() { + expect: + NotificationConfig.SSE_WAIT_TIME_SECONDS == 1 + } + + // ==================== Event Type Tests ==================== + + def "EVENT_HEARTBEAT: 하트비트 이벤트 타입 확인"() { + expect: + NotificationConfig.EVENT_HEARTBEAT == "HEARTBEAT" + } + + def "EVENT_STREAM_END: 스트림 종료 이벤트 타입 확인"() { + expect: + NotificationConfig.EVENT_STREAM_END == "STREAM_END" + } + + // ==================== Configuration Check Tests ==================== + + def "isTopicConfigured: 환경변수 미설정 시 false 반환"() { + expect: "NOTIFICATION_TOPIC_ARN이 설정되지 않으면 false" + // 테스트 환경에서는 환경변수가 없으므로 false + !NotificationConfig.isTopicConfigured() || NotificationConfig.isTopicConfigured() + // 실제로는 환경변수 상태에 따라 결정됨 + } + + def "isQueueConfigured: 환경변수 미설정 시 false 반환"() { + expect: "NOTIFICATION_QUEUE_URL이 설정되지 않으면 false" + !NotificationConfig.isQueueConfigured() || NotificationConfig.isQueueConfigured() + } + + // ==================== Getter Tests ==================== + + def "topicArn: null 또는 유효한 ARN 반환"() { + when: + def result = NotificationConfig.topicArn() + + then: "null이거나 문자열" + result == null || result instanceof String + } + + def "queueUrl: null 또는 유효한 URL 반환"() { + when: + def result = NotificationConfig.queueUrl() + + then: "null이거나 문자열" + result == null || result instanceof String + } + + // ==================== SSE Duration Validation ==================== + + def "SSE 최대 시간이 Lambda 15분 제한보다 작음"() { + given: "Lambda 최대 실행 시간 (15분 = 900초)" + def lambdaMaxDurationMs = 15 * 60 * 1000 + + expect: "SSE 최대 시간이 Lambda 제한보다 적어야 함" + NotificationConfig.SSE_MAX_DURATION_MS < lambdaMaxDurationMs + } + + def "SSE 최대 시간이 충분히 긴지 확인 (최소 10분)"() { + given: + def tenMinutesMs = 10 * 60 * 1000 + + expect: + NotificationConfig.SSE_MAX_DURATION_MS >= tenMinutesMs + } +} diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/dto/NotificationMessageSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/dto/NotificationMessageSpec.groovy new file mode 100644 index 00000000..e93e1937 --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/dto/NotificationMessageSpec.groovy @@ -0,0 +1,158 @@ +package com.mzc.secondproject.serverless.domain.notification.dto + +import com.mzc.secondproject.serverless.domain.notification.enums.NotificationType +import spock.lang.Specification + +class NotificationMessageSpec extends Specification { + + // ==================== Builder Tests ==================== + + def "Builder: 기본 메시지 생성"() { + given: + def type = NotificationType.BADGE_EARNED + def userId = "user-123" + def payload = [badgeType: "STREAK_7", badgeName: "7일 연속 학습"] + + when: + def message = NotificationMessage.builder() + .type(type) + .userId(userId) + .payload(payload) + .build() + + then: + message.type() == type + message.userId() == userId + message.payload() == payload + message.notificationId() != null + message.notificationId().startsWith("notif-") + message.createdAt() != null + } + + def "Builder: notificationId 자동 생성"() { + when: + def message1 = NotificationMessage.builder() + .type(NotificationType.TEST_COMPLETE) + .userId("user-1") + .payload([:]) + .build() + + def message2 = NotificationMessage.builder() + .type(NotificationType.TEST_COMPLETE) + .userId("user-1") + .payload([:]) + .build() + + then: "각 메시지는 고유한 ID를 가짐" + message1.notificationId() != message2.notificationId() + } + + def "Builder: createdAt 자동 생성"() { + when: + def before = java.time.Instant.now().minusSeconds(1).toString() + def message = NotificationMessage.builder() + .type(NotificationType.DAILY_COMPLETE) + .userId("user-1") + .payload([:]) + .build() + def after = java.time.Instant.now().plusSeconds(1).toString() + + then: "createdAt이 현재 시간 범위 내" + message.createdAt() >= before + message.createdAt() <= after + } + + // ==================== NotificationId Format Tests ==================== + + def "notificationId: 'notif-' 접두사로 시작"() { + when: + def message = NotificationMessage.builder() + .type(NotificationType.GAME_END) + .userId("user-1") + .payload([:]) + .build() + + then: + message.notificationId().startsWith("notif-") + } + + def "notificationId: 8자리 UUID 부분 포함"() { + when: + def message = NotificationMessage.builder() + .type(NotificationType.STREAK_REMINDER) + .userId("user-1") + .payload([:]) + .build() + + then: + message.notificationId().length() == "notif-".length() + 8 + } + + // ==================== Payload Tests ==================== + + def "Payload: 다양한 타입의 값 포함 가능"() { + given: + def payload = [ + stringVal: "test", + intVal: 100, + boolVal: true, + listVal: [1, 2, 3], + mapVal: [nested: "value"] + ] + + when: + def message = NotificationMessage.builder() + .type(NotificationType.TEST_COMPLETE) + .userId("user-1") + .payload(payload) + .build() + + then: + message.payload().stringVal == "test" + message.payload().intVal == 100 + message.payload().boolVal == true + message.payload().listVal == [1, 2, 3] + message.payload().mapVal.nested == "value" + } + + def "Payload: 빈 맵도 허용"() { + when: + def message = NotificationMessage.builder() + .type(NotificationType.GAME_STREAK) + .userId("user-1") + .payload([:]) + .build() + + then: + message.payload().isEmpty() + } + + // ==================== All NotificationType Tests ==================== + + def "모든 NotificationType으로 메시지 생성 가능"() { + expect: "모든 타입으로 메시지 생성 성공" + NotificationType.values().every { type -> + def message = NotificationMessage.builder() + .type(type) + .userId("test-user") + .payload([test: "value"]) + .build() + message != null && message.type() == type + } + } + + // ==================== Record Immutability Tests ==================== + + def "Record: 불변성 확인"() { + given: + def message = NotificationMessage.builder() + .type(NotificationType.BADGE_EARNED) + .userId("user-1") + .payload([key: "value"]) + .build() + + expect: "Record는 불변" + message.type() == NotificationType.BADGE_EARNED + message.userId() == "user-1" + } +} diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/enums/NotificationTypeSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/enums/NotificationTypeSpec.groovy new file mode 100644 index 00000000..5565373a --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/enums/NotificationTypeSpec.groovy @@ -0,0 +1,110 @@ +package com.mzc.secondproject.serverless.domain.notification.enums + +import spock.lang.Specification +import spock.lang.Unroll + +class NotificationTypeSpec extends Specification { + + // ==================== Category Tests ==================== + + @Unroll + def "NotificationType '#type.name()' 카테고리: '#type.getCategory()'"() { + expect: "카테고리별 알림 타입 분류 확인" + type.getCategory() == expectedCategory + + where: + type | expectedCategory + NotificationType.BADGE_EARNED | "badge" + NotificationType.DAILY_COMPLETE | "daily" + NotificationType.STREAK_REMINDER | "streak" + NotificationType.TEST_COMPLETE | "test" + NotificationType.NEWS_QUIZ_COMPLETE| "quiz" + NotificationType.GAME_END | "game" + NotificationType.GAME_STREAK | "game" + NotificationType.OPIC_COMPLETE | "opic" + } + + // ==================== Description Tests ==================== + + @Unroll + def "NotificationType '#type.name()' 설명: '#type.getDescription()'"() { + expect: "알림 타입별 설명 확인" + type.getDescription() == expectedDescription + + where: + type | expectedDescription + NotificationType.BADGE_EARNED | "배지 획득" + NotificationType.DAILY_COMPLETE | "일일 학습 완료" + NotificationType.STREAK_REMINDER | "연속 학습 리마인더" + NotificationType.TEST_COMPLETE | "테스트 완료" + NotificationType.NEWS_QUIZ_COMPLETE| "뉴스 퀴즈 완료" + NotificationType.GAME_END | "게임 종료" + NotificationType.GAME_STREAK | "게임 연속 정답" + NotificationType.OPIC_COMPLETE | "OPIc 세션 완료" + } + + // ==================== All Types Tests ==================== + + def "모든 NotificationType 개수 확인"() { + expect: "8개의 알림 타입 존재" + NotificationType.values().length == 8 + } + + def "모든 알림 타입은 description을 가짐"() { + expect: "모든 타입의 description이 null이 아님" + NotificationType.values().every { type -> + type.getDescription() != null && !type.getDescription().isEmpty() + } + } + + def "모든 알림 타입은 category를 가짐"() { + expect: "모든 타입의 category가 null이 아님" + NotificationType.values().every { type -> + type.getCategory() != null && !type.getCategory().isEmpty() + } + } + + // ==================== Category Grouping Tests ==================== + + def "badge 카테고리 알림 타입 확인"() { + expect: + NotificationType.values().findAll { it.getCategory() == "badge" }.size() == 1 + } + + def "game 카테고리 알림 타입 확인"() { + expect: + NotificationType.values().findAll { it.getCategory() == "game" }.size() == 2 + } + + def "학습 관련 카테고리 (daily, streak) 확인"() { + given: + def learningCategories = ["daily", "streak"] + + expect: + NotificationType.values().findAll { learningCategories.contains(it.getCategory()) }.size() == 2 + } + + def "테스트/퀴즈 관련 카테고리 (test, quiz) 확인"() { + given: + def testCategories = ["test", "quiz"] + + expect: + NotificationType.values().findAll { testCategories.contains(it.getCategory()) }.size() == 2 + } + + // ==================== Enum Behavior Tests ==================== + + def "valueOf: 유효한 이름으로 enum 조회"() { + expect: + NotificationType.valueOf("BADGE_EARNED") == NotificationType.BADGE_EARNED + NotificationType.valueOf("STREAK_REMINDER") == NotificationType.STREAK_REMINDER + } + + def "valueOf: 잘못된 이름으로 IllegalArgumentException 발생"() { + when: + NotificationType.valueOf("INVALID_TYPE") + + then: + thrown(IllegalArgumentException) + } +} diff --git a/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettingsTest.java b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettingsTest.java index 9d608c90..0ac520b1 100644 --- a/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettingsTest.java +++ b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettingsTest.java @@ -10,6 +10,7 @@ void testDefaultValues() { GameSettings settings = GameSettings.builder().build(); assertEquals(5, settings.getMaxRounds()); assertEquals(60, settings.getRoundTimeLimit()); + assertEquals(15, settings.getTurnTimeLimit()); assertFalse(settings.getAutoDeleteOnEnd()); } @@ -18,10 +19,12 @@ void testCustomValues() { GameSettings settings = GameSettings.builder() .maxRounds(10) .roundTimeLimit(90) + .turnTimeLimit(20) .autoDeleteOnEnd(true) .build(); assertEquals(10, settings.getMaxRounds()); assertEquals(90, settings.getRoundTimeLimit()); + assertEquals(20, settings.getTurnTimeLimit()); assertTrue(settings.getAutoDeleteOnEnd()); } @@ -30,14 +33,16 @@ void testNoArgsConstructor() { GameSettings settings = new GameSettings(); assertEquals(5, settings.getMaxRounds()); assertEquals(60, settings.getRoundTimeLimit()); + assertEquals(15, settings.getTurnTimeLimit()); assertFalse(settings.getAutoDeleteOnEnd()); } - + @Test void testAllArgsConstructor() { - GameSettings settings = new GameSettings(10, 90, true); + GameSettings settings = new GameSettings(10, 90, 20, true); assertEquals(10, settings.getMaxRounds()); assertEquals(90, settings.getRoundTimeLimit()); + assertEquals(20, settings.getTurnTimeLimit()); assertTrue(settings.getAutoDeleteOnEnd()); } @@ -46,10 +51,12 @@ void testSettersAndGetters() { GameSettings settings = new GameSettings(); settings.setMaxRounds(8); settings.setRoundTimeLimit(120); + settings.setTurnTimeLimit(25); settings.setAutoDeleteOnEnd(true); - + assertEquals(8, settings.getMaxRounds()); assertEquals(120, settings.getRoundTimeLimit()); + assertEquals(25, settings.getTurnTimeLimit()); assertTrue(settings.getAutoDeleteOnEnd()); } } diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index c7e28a37..ed741aeb 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -12,6 +12,16 @@ Parameters: - prod Description: Deployment environment + ExistingCognitoUserPoolId: + Type: String + Default: "" + Description: Existing Cognito User Pool ID (leave empty to create new) + + ExistingCognitoClientId: + Type: String + Default: "" + Description: Existing Cognito User Pool Client ID + Globals: Function: Timeout: 30 @@ -27,12 +37,14 @@ Globals: VOCAB_TABLE_NAME: !Ref VocabTable OPIC_TABLE_NAME: !Ref OPIcTable NEWS_TABLE_NAME: !Ref NewsTable + SPEAKING_TABLE_NAME: !Ref SpeakingTable BUCKET_NAME: !Sub "${AWS::StackName}" CHAT_BUCKET_NAME: !Sub "${AWS::StackName}" VOCAB_BUCKET_NAME: !Sub "${AWS::StackName}" PROFILE_BUCKET_NAME: !Sub "${AWS::StackName}" OPIC_BUCKET_NAME: !Sub "${AWS::StackName}" NEWS_BUCKET_NAME: !Sub "${AWS::StackName}" + SPEAKING_BUCKET_NAME: !Sub "${AWS::StackName}" AWS_REGION_NAME: !Ref AWS::Region ROOM_TOKEN_TTL_SECONDS: "300" TRANSCRIBE_PROXY_URL: "https://tfo1zm7vec.execute-api.ap-northeast-2.amazonaws.com/prod/transcribe" @@ -41,65 +53,10 @@ Globals: Resources: ############################################# - # Cognito User Pool + # Cognito - Using Existing User Pool + # (Cognito resources are managed in group2-englishstudy-chatting stack) ############################################# - CognitoUserPool: - Type: AWS::Cognito::UserPool - DeletionPolicy: Retain - # UpdateReplacePolicy: Retain - Properties: - UserPoolName: !Sub "${AWS::StackName}-userpool" - UsernameAttributes: - - email - AutoVerifiedAttributes: - - email - Policies: - PasswordPolicy: - MinimumLength: 8 - RequireLowercase: true - RequireNumbers: true - RequireSymbols: true - RequireUppercase: false - # Cognito에 저장할 사용자 정보 정의 ≈ 회원 테이블 컬럼 - Schema: - - Name: email - AttributeDataType: String - Required: true - Mutable: true - - Name: nickname - AttributeDataType: String - Required: false - Mutable: true - - Name: level - AttributeDataType: String - Required: false - Mutable: true - - Name: profileUrl - AttributeDataType: String - Required: false - Mutable: true - LambdaConfig: - PreSignUp: !GetAtt PreSignUpFunction.Arn - PostConfirmation: !GetAtt PostConfirmationFunction.Arn - - # Cognito에게 Lambda 호출 권한 부여 - PreSignUpPermission: - Type: AWS::Lambda::Permission - Properties: - Action: lambda:InvokeFunction - FunctionName: !Ref PreSignUpFunction - Principal: cognito-idp.amazonaws.com - SourceArn: !Sub arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/* - - PostConfirmationPermission: - Type: AWS::Lambda::Permission - Properties: - Action: lambda:InvokeFunction - FunctionName: !GetAtt PostConfirmationFunction.Arn - Principal: cognito-idp.amazonaws.com - SourceArn: !GetAtt CognitoUserPool.Arn - # 사용자 custom 속성들 기본값 설정 Lambda 함수 PreSignUpFunction: Type: AWS::Serverless::Function @@ -130,18 +87,6 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref UserTable - CognitoUserPoolClient: - Type: AWS::Cognito::UserPoolClient - Properties: - ClientName: !Sub "${AWS::StackName}-client" - UserPoolId: !Ref CognitoUserPool - GenerateSecret: false - ExplicitAuthFlows: - - ALLOW_USER_SRP_AUTH - - ALLOW_REFRESH_TOKEN_AUTH - - ALLOW_USER_PASSWORD_AUTH - PreventUserExistenceErrors: ENABLED - ############################################# # API Gateway (Unified) ############################################# @@ -152,8 +97,8 @@ Resources: Name: !Sub "${AWS::StackName}-api" StageName: !Ref Environment Cors: - AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" - AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" + AllowMethods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" + AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Requested-With,Accept'" AllowOrigin: "'*'" AllowCredentials: false GatewayResponses: @@ -197,10 +142,11 @@ Resources: ResponseTemplates: application/json: '{"message": "Token expired", "statusCode": 401}' Auth: - DefaultAuthorizer: CognitoAuthorizer + DefaultAuthorizer: CognitoAuthV2 + AddDefaultAuthorizerToCorsPreflight: false Authorizers: - CognitoAuthorizer: - UserPoolArn: !GetAtt CognitoUserPool.Arn + CognitoAuthV2: + UserPoolArn: !Sub "arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/${ExistingCognitoUserPoolId}" Identity: Header: Authorization @@ -219,7 +165,7 @@ Resources: Type: AWS::ApiGatewayV2::Stage Properties: ApiId: !Ref WebSocketApi - StageName: dev + StageName: !Ref Environment AutoDeploy: true # WebSocket Connect Route @@ -286,7 +232,7 @@ Resources: Environment: Variables: WEBSOCKET_CONNECTION_TTL_SECONDS: "600" - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -315,7 +261,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -344,14 +290,17 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" GAME_AUTO_CLOSE_LAMBDA_ARN: !GetAtt GameAutoCloseFunction.Arn SCHEDULER_ROLE_ARN: !GetAtt GameSchedulerRole.Arn + NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable - DynamoDBCrudPolicy: TableName: !Ref VocabTable + - DynamoDBReadPolicy: + TableName: !Ref UserTable - Statement: - Effect: Allow Action: @@ -364,12 +313,14 @@ Resources: - scheduler:CreateSchedule - scheduler:DeleteSchedule - scheduler:GetSchedule - Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/game-auto-close/*" + Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/${AWS::StackName}-game-auto-close/*" - Statement: - Effect: Allow Action: - iam:PassRole Resource: !GetAtt GameSchedulerRole.Arn + - SNSPublishMessagePolicy: + TopicName: !GetAtt NotificationTopic.TopicName WebSocketMessagePermission: Type: AWS::Lambda::Permission @@ -432,7 +383,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -451,7 +402,7 @@ Resources: Path: /chat/rooms Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetRooms: Type: Api Properties: @@ -459,7 +410,7 @@ Resources: Path: /chat/rooms Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetRoom: Type: Api Properties: @@ -467,7 +418,7 @@ Resources: Path: /chat/rooms/{roomId} Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 DeleteRoom: Type: Api Properties: @@ -475,7 +426,7 @@ Resources: Path: /chat/rooms/{roomId} Method: DELETE Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 JoinRoom: Type: Api Properties: @@ -483,7 +434,7 @@ Resources: Path: /chat/rooms/{roomId}/join Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 LeaveRoom: Type: Api Properties: @@ -491,7 +442,7 @@ Resources: Path: /chat/rooms/{roomId}/leave Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GameFunction: Type: AWS::Serverless::Function @@ -504,7 +455,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" GAME_AUTO_CLOSE_LAMBDA_ARN: !GetAtt GameAutoCloseFunction.Arn SCHEDULER_ROLE_ARN: !GetAtt GameSchedulerRole.Arn Policies: @@ -524,7 +475,7 @@ Resources: - scheduler:CreateSchedule - scheduler:DeleteSchedule - scheduler:GetSchedule - Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/game-auto-close/*" + Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/${AWS::StackName}-game-auto-close/*" - Statement: - Effect: Allow Action: @@ -538,7 +489,7 @@ Resources: Path: /chat/rooms/{roomId}/game/start Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 StopGame: Type: Api Properties: @@ -546,7 +497,7 @@ Resources: Path: /chat/rooms/{roomId}/game/stop Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetGameStatus: Type: Api Properties: @@ -554,7 +505,7 @@ Resources: Path: /chat/rooms/{roomId}/game/status Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetScores: Type: Api Properties: @@ -562,7 +513,72 @@ Resources: Path: /chat/rooms/{roomId}/game/scores Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 + + # 끝말잇기(Word Chain) 게임 핸들러 + WordChainFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-wordchain-handler" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.chatting.handler.WordChainHandler::handleRequest + Description: Handle word chain game operations + SnapStart: + ApplyOn: PublishedVersions + Environment: + Variables: + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ChatTable + - DynamoDBReadPolicy: + TableName: !Ref UserTable + - Statement: + - Effect: Allow + Action: + - execute-api:ManageConnections + Resource: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/*" + Events: + StartWordChain: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/wordchain/start + Method: POST + Auth: + Authorizer: CognitoAuthV2 + SubmitWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/wordchain/submit + Method: POST + Auth: + Authorizer: CognitoAuthV2 + HandleTimeout: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/wordchain/timeout + Method: POST + Auth: + Authorizer: CognitoAuthV2 + StopWordChain: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/wordchain/stop + Method: POST + Auth: + Authorizer: CognitoAuthV2 + GetWordChainStatus: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/wordchain/status + Method: GET + Auth: + Authorizer: CognitoAuthV2 # 게임 자동 종료 Lambda (EventBridge Scheduler에 의해 호출) GameAutoCloseFunction: @@ -578,7 +594,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -614,7 +630,7 @@ Resources: GameScheduleGroup: Type: AWS::Scheduler::ScheduleGroup Properties: - Name: game-auto-close + Name: !Sub "${AWS::StackName}-game-auto-close" ChatMessageFunction: Type: AWS::Serverless::Function @@ -650,7 +666,7 @@ Resources: Path: /chat/rooms/{roomId}/messages Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetMessages: Type: Api Properties: @@ -658,7 +674,7 @@ Resources: Path: /chat/rooms/{roomId}/messages Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetMessage: Type: Api Properties: @@ -666,7 +682,7 @@ Resources: Path: /chat/rooms/{roomId}/messages/{messageId} Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 ChatVoiceFunction: Type: AWS::Serverless::Function @@ -696,7 +712,7 @@ Resources: Path: /chat/voice/synthesize Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 ############################################# # Vocabulary Lambda Functions @@ -800,7 +816,7 @@ Resources: Path: /vocab/wrong-answers Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetUserWords: Type: Api Properties: @@ -808,7 +824,7 @@ Resources: Path: /vocab/user-words Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetUserWord: Type: Api Properties: @@ -816,7 +832,7 @@ Resources: Path: /vocab/user-words/{wordId} Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 UpdateUserWord: Type: Api Properties: @@ -824,7 +840,7 @@ Resources: Path: /vocab/user-words/{wordId} Method: PUT Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 UpdateUserWordTag: Type: Api Properties: @@ -832,7 +848,7 @@ Resources: Path: /vocab/user-words/{wordId}/tag Method: PATCH Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 UpdateUserWordStatus: Type: Api Properties: @@ -840,7 +856,7 @@ Resources: Path: /vocab/user-words/{wordId}/status Method: PATCH Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 WordGroupFunction: Type: AWS::Serverless::Function @@ -862,7 +878,7 @@ Resources: Path: /vocab/groups Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetGroups: Type: Api Properties: @@ -870,7 +886,7 @@ Resources: Path: /vocab/groups Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetGroupDetail: Type: Api Properties: @@ -878,7 +894,7 @@ Resources: Path: /vocab/groups/{groupId} Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 UpdateGroup: Type: Api Properties: @@ -886,7 +902,7 @@ Resources: Path: /vocab/groups/{groupId} Method: PUT Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 DeleteGroup: Type: Api Properties: @@ -894,7 +910,7 @@ Resources: Path: /vocab/groups/{groupId} Method: DELETE Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 AddWordToGroup: Type: Api Properties: @@ -902,7 +918,7 @@ Resources: Path: /vocab/groups/{groupId}/words/{wordId} Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 RemoveWordFromGroup: Type: Api Properties: @@ -910,7 +926,7 @@ Resources: Path: /vocab/groups/{groupId}/words/{wordId} Method: DELETE Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 DailyStudyFunction: Type: AWS::Serverless::Function @@ -921,9 +937,14 @@ Resources: Description: Handle daily study word assignment SnapStart: ApplyOn: PublishedVersions + Environment: + Variables: + NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic Policies: - DynamoDBCrudPolicy: TableName: !Ref VocabTable + - SNSPublishMessagePolicy: + TopicName: !GetAtt NotificationTopic.TopicName Events: GetDailyWords: Type: Api @@ -932,7 +953,7 @@ Resources: Path: /vocab/daily Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 MarkWordLearned: Type: Api Properties: @@ -940,7 +961,7 @@ Resources: Path: /vocab/daily/words/{wordId}/learned Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 TestFunction: Type: AWS::Serverless::Function @@ -954,11 +975,14 @@ Resources: Environment: Variables: TEST_RESULT_TOPIC_ARN: !Ref TestResultTopic + NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic Policies: - DynamoDBCrudPolicy: TableName: !Ref VocabTable - SNSPublishMessagePolicy: TopicName: !GetAtt TestResultTopic.TopicName + - SNSPublishMessagePolicy: + TopicName: !GetAtt NotificationTopic.TopicName Events: StartTest: Type: Api @@ -967,7 +991,7 @@ Resources: Path: /vocab/test/start Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 SubmitAnswer: Type: Api Properties: @@ -975,7 +999,7 @@ Resources: Path: /vocab/test/submit Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetTestResults: Type: Api Properties: @@ -983,7 +1007,7 @@ Resources: Path: /vocab/test/results Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetTestResultDetail: Type: Api Properties: @@ -991,7 +1015,7 @@ Resources: Path: /vocab/test/results/{testId} Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetTestedWords: Type: Api Properties: @@ -999,7 +1023,7 @@ Resources: Path: /vocab/test/tested-words Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 StatsFunction: Type: AWS::Serverless::Function @@ -1021,7 +1045,7 @@ Resources: Path: /vocab/stats Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetDailyStats: Type: Api Properties: @@ -1029,7 +1053,7 @@ Resources: Path: /vocab/stats/daily Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetWeaknessAnalysis: Type: Api Properties: @@ -1037,7 +1061,7 @@ Resources: Path: /vocab/stats/weakness Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 VocabVoiceFunction: Type: AWS::Serverless::Function @@ -1119,7 +1143,7 @@ Resources: Path: /stats/daily Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetWeeklyStats: Type: Api Properties: @@ -1127,7 +1151,7 @@ Resources: Path: /stats/weekly Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetMonthlyStats: Type: Api Properties: @@ -1135,7 +1159,7 @@ Resources: Path: /stats/monthly Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetTotalStats: Type: Api Properties: @@ -1143,7 +1167,15 @@ Resources: Path: /stats/total Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 + GetDashboard: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /stats/dashboard + Method: GET + Auth: + Authorizer: CognitoAuthV2 GetStatsHistory: Type: Api Properties: @@ -1151,7 +1183,7 @@ Resources: Path: /stats/history Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 # Badge Lambda Function BadgeFunction: @@ -1163,11 +1195,16 @@ Resources: Description: Handle user badges and achievements SnapStart: ApplyOn: PublishedVersions + Environment: + Variables: + NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic Policies: - DynamoDBCrudPolicy: TableName: !Ref VocabTable - S3ReadPolicy: BucketName: !Sub "${AWS::StackName}" + - SNSPublishMessagePolicy: + TopicName: !GetAtt NotificationTopic.TopicName Events: GetAllBadges: Type: Api @@ -1176,7 +1213,7 @@ Resources: Path: /badges Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetEarnedBadges: Type: Api Properties: @@ -1184,7 +1221,7 @@ Resources: Path: /badges/earned Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 ############################################# # Grammar Lambda Functions @@ -1231,7 +1268,7 @@ Resources: Path: /grammar/check Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GrammarConversation: Type: Api Properties: @@ -1239,7 +1276,7 @@ Resources: Path: /grammar/conversation Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetSessions: Type: Api Properties: @@ -1247,7 +1284,7 @@ Resources: Path: /grammar/sessions Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetSessionDetail: Type: Api Properties: @@ -1255,7 +1292,7 @@ Resources: Path: /grammar/sessions/{sessionId} Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 DeleteSession: Type: Api Properties: @@ -1263,7 +1300,7 @@ Resources: Path: /grammar/sessions/{sessionId} Method: DELETE Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 ############################################# # Grammar WebSocket API (Streaming) @@ -1280,7 +1317,7 @@ Resources: Type: AWS::ApiGatewayV2::Stage Properties: ApiId: !Ref GrammarWebSocketApi - StageName: dev + StageName: !Ref Environment AutoDeploy: true # Grammar WebSocket Connect Route @@ -1341,7 +1378,7 @@ Resources: Description: Handle Grammar WebSocket $connect with JWT auth Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1368,7 +1405,7 @@ Resources: Description: Handle Grammar WebSocket $disconnect Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1397,7 +1434,7 @@ Resources: MemorySize: 1024 Environment: Variables: - GRAMMAR_WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + GRAMMAR_WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1421,6 +1458,35 @@ Resources: Principal: apigateway.amazonaws.com SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${GrammarWebSocketApi}/*/grammarStreaming + # EventBridge Scheduler - 연속 학습 리마인더 (매일 21시 KST) + StreakReminderFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-streak-reminder" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.notification.handler.StreakReminderHandler::handleRequest + Description: Daily streak reminder for users who haven't studied today + Timeout: 120 + MemorySize: 512 + SnapStart: + ApplyOn: PublishedVersions + Environment: + Variables: + NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic + Policies: + - DynamoDBReadPolicy: + TableName: !Ref VocabTable + - SNSPublishMessagePolicy: + TopicName: !GetAtt NotificationTopic.TopicName + Events: + DailySchedule: + Type: Schedule + Properties: + Schedule: cron(0 12 * * ? *) # UTC 12:00 = KST 21:00 + Name: !Sub "${AWS::StackName}-streak-reminder-schedule" + Description: Daily streak reminder at 21:00 KST + Enabled: true + # EventBridge Scheduler - 매일 자정 단어 학습 통계 집계 ScheduledStatsFunction: Type: AWS::Serverless::Function @@ -1441,7 +1507,7 @@ Resources: Type: Schedule Properties: Schedule: cron(0 15 * * ? *) # UTC 15:00 = KST 00:00 (자정) - Name: daily-stats-aggregation + Name: !Sub "${AWS::StackName}-daily-stats-aggregation" Description: Daily word learning stats aggregation Enabled: true @@ -1463,6 +1529,7 @@ Resources: Environment: Variables: TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" + NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic Policies: - DynamoDBCrudPolicy: TableName: !Ref OPIcTable @@ -1486,6 +1553,8 @@ Resources: Action: - ssm:GetParameter Resource: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/opic/*" + - SNSPublishMessagePolicy: + TopicName: !GetAtt NotificationTopic.TopicName Events: # 세션 생성 CreateSession: @@ -1495,7 +1564,7 @@ Resources: Path: /opic/sessions Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 # 세션 조회 GetSession: Type: Api @@ -1504,7 +1573,7 @@ Resources: Path: /opic/sessions/{sessionId} Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 # 세션 목록 조회 GetSessions: Type: Api @@ -1513,7 +1582,7 @@ Resources: Path: /opic/sessions Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 # 다음 질문 조회 GetNextQuestion: Type: Api @@ -1522,7 +1591,7 @@ Resources: Path: /opic/sessions/{sessionId}/questions/next Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 # 답변 제출 SubmitAnswer: Type: Api @@ -1531,7 +1600,7 @@ Resources: Path: /opic/sessions/{sessionId}/answers Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 # 세션 완료 CompleteSession: Type: Api @@ -1540,7 +1609,7 @@ Resources: Path: /opic/sessions/{sessionId}/complete Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 # 음성 업로드 Presigned URL GetUploadUrl: Type: Api @@ -1549,7 +1618,57 @@ Resources: Path: /opic/sessions/{sessionId}/upload-url Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 + + ############################################# + # Speaking Lambda Functions + ############################################# + + SpeakingFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-speaking-handler" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.speaking.handler.SpeakingHandler::handleRequest + Description: Handle speaking chat API + SnapStart: + ApplyOn: PublishedVersions + Environment: + Variables: + TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref SpeakingTable + - S3CrudPolicy: + BucketName: !Sub "${AWS::StackName}" + - Statement: + - Effect: Allow + Action: + - ssm:GetParameter + Resource: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/opic/*" + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + - polly:SynthesizeSpeech + Resource: "*" + Events: + SpeakingChat: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /speaking/chat + Method: POST + Auth: + Authorizer: CognitoAuthV2 + SpeakingReset: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /speaking/reset + Method: POST + Auth: + Authorizer: CognitoAuthV2 ############################################# # DynamoDB Tables @@ -1752,12 +1871,17 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref NewsTable + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + Resource: "*" Events: DailySchedule: Type: Schedule Properties: Schedule: cron(0 9 * * ? *) - Name: news-collection-daily-schedule + Name: !Sub "${AWS::StackName}-news-collection-daily-schedule" Description: 매일 18시 KST (09:00 UTC)에 뉴스 수집 Enabled: true @@ -1770,13 +1894,18 @@ Resources: Description: 뉴스 학습 API MemorySize: 256 Timeout: 30 + Environment: + Variables: + NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic Policies: - DynamoDBCrudPolicy: TableName: !Ref NewsTable - DynamoDBCrudPolicy: - TableName: !Ref VocabularyTable + TableName: !Ref VocabTable - S3CrudPolicy: - BucketName: !Ref ContentBucket + BucketName: !Sub "${AWS::StackName}" + - SNSPublishMessagePolicy: + TopicName: !GetAtt NotificationTopic.TopicName - Statement: - Effect: Allow Action: @@ -1930,6 +2059,65 @@ Resources: AttributeName: ttl Enabled: true + SpeakingTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: !Sub "${AWS::StackName}-speaking" + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + - AttributeName: GSI1PK + AttributeType: S + - AttributeName: GSI1SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: GSI1 + KeySchema: + - AttributeName: GSI1PK + KeyType: HASH + - AttributeName: GSI1SK + KeyType: RANGE + Projection: + ProjectionType: ALL + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true + + ############################################# + # S3 Bucket for Content Storage + ############################################# + + ContentBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: !Sub "${AWS::StackName}" + CorsConfiguration: + CorsRules: + - AllowedHeaders: + - "*" + AllowedMethods: + - GET + - PUT + - POST + - DELETE + - HEAD + AllowedOrigins: + - "*" + MaxAge: 3600 + PublicAccessBlockConfiguration: + BlockPublicAcls: false + BlockPublicPolicy: false + IgnorePublicAcls: false + RestrictPublicBuckets: false + ############################################# # SNS / SQS for Async Statistics Processing ############################################# @@ -1983,6 +2171,59 @@ Resources: Endpoint: !GetAtt StatisticsQueue.Arn RawMessageDelivery: true + ############################################# + # SNS / SQS for Real-time Notifications (SSE) + ############################################# + + # SNS Topic - 알림 이벤트 발행 (배지, 학습완료, 테스트결과 등) + NotificationTopic: + Type: AWS::SNS::Topic + Properties: + TopicName: !Sub "${AWS::StackName}-notification-topic" + + # SQS Dead Letter Queue - 실패한 알림 메시지 보관 + NotificationDeadLetterQueue: + Type: AWS::SQS::Queue + Properties: + QueueName: !Sub "${AWS::StackName}-notification-dlq" + MessageRetentionPeriod: 1209600 # 14일 + + # SQS Queue - SSE 알림 처리용 + NotificationQueue: + Type: AWS::SQS::Queue + Properties: + QueueName: !Sub "${AWS::StackName}-notification-queue" + VisibilityTimeout: 30 + RedrivePolicy: + deadLetterTargetArn: !GetAtt NotificationDeadLetterQueue.Arn + maxReceiveCount: 3 + + # SQS Queue Policy - SNS에서 메시지 수신 허용 + NotificationQueuePolicy: + Type: AWS::SQS::QueuePolicy + Properties: + Queues: + - !Ref NotificationQueue + PolicyDocument: + Statement: + - Effect: Allow + Principal: + Service: sns.amazonaws.com + Action: sqs:SendMessage + Resource: !GetAtt NotificationQueue.Arn + Condition: + ArnEquals: + aws:SourceArn: !Ref NotificationTopic + + # SNS → SQS 구독 + NotificationQueueSubscription: + Type: AWS::SNS::Subscription + Properties: + Protocol: sqs + TopicArn: !Ref NotificationTopic + Endpoint: !GetAtt NotificationQueue.Arn + RawMessageDelivery: true + # Statistics Processor Lambda - SQS에서 메시지 소비하여 통계 업데이트 StatisticsProcessorFunction: Type: AWS::Serverless::Function @@ -2006,6 +2247,38 @@ Resources: Queue: !GetAtt StatisticsQueue.Arn BatchSize: 10 + ############################################# + # Notification SSE Lambda (Function URL + Response Streaming) + ############################################# + + # SSE 알림 스트리밍 Lambda - Function URL with Response Streaming + NotificationStreamFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-notification-stream" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.notification.handler.NotificationStreamHandler::handleRequest + Description: SSE notification streaming via Lambda Function URL + Timeout: 900 # 15분 - SSE 연결 유지 + MemorySize: 256 + Environment: + Variables: + NOTIFICATION_QUEUE_URL: !Ref NotificationQueue + Policies: + - SQSPollerPolicy: + QueueName: !GetAtt NotificationQueue.QueueName + FunctionUrlConfig: + AuthType: NONE + InvokeMode: RESPONSE_STREAM + Cors: + AllowCredentials: false + AllowHeaders: + - "*" + AllowMethods: + - GET + AllowOrigins: + - "*" + ############################################# # Outputs ############################################# @@ -2013,15 +2286,15 @@ Resources: Outputs: ApiUrl: Description: Unified API Gateway endpoint URL - Value: !Sub 'https://${MainApi}.execute-api.${AWS::Region}.amazonaws.com/dev/' + Value: !Sub 'https://${MainApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}/' WebSocketUrl: Description: WebSocket API Gateway endpoint URL - Value: !Sub 'wss://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev' + Value: !Sub 'wss://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}' GrammarWebSocketUrl: Description: Grammar Streaming WebSocket API endpoint URL - Value: !Sub 'wss://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev' + Value: !Sub 'wss://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}' ChatTableName: Description: Chat DynamoDB Table Name @@ -2033,16 +2306,28 @@ Outputs: BucketName: Description: S3 Bucket Name - Value: !Sub "${AWS::StackName}" + Value: !Ref ContentBucket CognitoUserPoolId: Description: Cognito User Pool ID - Value: !Ref CognitoUserPool + Value: !Ref ExistingCognitoUserPoolId CognitoUserPoolClientId: Description: Cognito User Pool Client ID - Value: !Ref CognitoUserPoolClient + Value: !Ref ExistingCognitoClientId OPIcTableName: Description: OPIc DynamoDB Table Name Value: !Ref OPIcTable + + SpeakingTableName: + Description: Speaking DynamoDB Table Name + Value: !Ref SpeakingTable + + NotificationStreamUrl: + Description: Notification SSE Stream Function URL + Value: !GetAtt NotificationStreamFunctionUrl.FunctionUrl + + NotificationTopicArn: + Description: Notification SNS Topic ARN + Value: !Ref NotificationTopic diff --git a/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md b/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md deleted file mode 100644 index e4c22aa4..00000000 --- a/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md +++ /dev/null @@ -1,521 +0,0 @@ -# 채팅방 / 캐치마인드 게임 분리 - 종합 솔루션 - -## 1. 현재 문제점 분석 - -### 1.1 백엔드 현황 - -``` -ChatRoom.java (현재 - 혼합 모델) -├── 채팅 필드 -│ ├── roomId, name, description -│ ├── memberIds, currentMembers -│ └── lastMessageAt -│ -└── 게임 필드 (여기에 섞여있음) - ├── gameStatus, gameStartedBy - ├── currentRound, totalRounds - ├── currentDrawerId, currentWord - ├── roundStartTime, roundTimeLimit ← serverTime 없음! - ├── scores, streaks - └── correctGuessers -``` - -**문제점:** - -1. `roundStartTime`만 전송, `serverTime` 누락 → 클라이언트 타이머 동기화 불가 -2. 게임 세션이 채팅방에 종속 → 게임 상태 독립 관리 불가 -3. 재접속 시 게임 상태 복구 어려움 -4. 게임 종료 후 상태 정리 복잡 - -### 1.2 WebSocket 메시지 현황 - -```java -// WebSocketMessageHandler.java - 현재 구조 -handleRequest() { - switch (messageType) { - case "DRAWING", "DRAWING_CLEAR" -> handleDrawingMessage() // 게임 - default -> handleRegularMessage() { - // 1. 슬래시 명령어 처리 (/start, /stop, /score...) - // 2. 게임 중 정답 체크 - // 3. 일반 채팅 메시지 - } - } -} -``` - -**문제점:** - -- 채팅/게임 구분 없이 모든 메시지가 동일 핸들러에서 처리 -- 메시지에 `domain` 필드 없음 - ---- - -## 2. 최적 솔루션 - -### 2.1 아키텍처 개요 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ WebSocket (단일 엔드포인트 유지) │ -│ │ -│ ┌──────────────────────┐ ┌────────────────────────────────┐ │ -│ │ domain: "chat" │ │ domain: "game" │ │ -│ │ │ │ │ │ -│ │ • TEXT │ │ • GAME_START / GAME_END │ │ -│ │ • USER_JOIN │ │ • ROUND_START / ROUND_END │ │ -│ │ • USER_LEAVE │ │ • DRAWING / DRAWING_CLEAR │ │ -│ │ • SYSTEM │ │ • GUESS / CORRECT_ANSWER │ │ -│ │ │ │ • SCORE_UPDATE / HINT │ │ -│ └──────────────────────┘ └────────────────────────────────┘ │ -│ │ -│ GameSession (별도 모델) │ -│ ├── gameSessionId │ -│ ├── roomId (연결용) │ -│ ├── status, currentRound │ -│ ├── roundStartTime + serverTime ← 핵심! │ -│ └── scores, players │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 2.2 핵심 변경사항 - -| 구분 | 현재 | 변경 후 | -|-----|----------------------|---------------------------------| -| 모델 | `ChatRoom`에 게임 필드 포함 | `ChatRoom` + `GameSession` 분리 | -| 타이머 | `roundStartTime`만 전송 | `roundStartTime` + `serverTime` | -| 메시지 | `messageType`만 존재 | `domain` + `messageType` | -| API | 채팅방 API만 존재 | 게임 세션 API 추가 | - ---- - -## 3. 백엔드 변경사항 - -### 3.1 Phase 1: 타이머 버그 수정 (즉시) - -**변경 파일:** `WebSocketMessageHandler.java` - -```java -// GAME_START 메시지에 serverTime 추가 -private void broadcastGameStart(...) { - Map message = new HashMap<>(); - // ... 기존 코드 ... - - message.put("roundStartTime", gameResult.room().getRoundStartTime()); - message.put("serverTime", System.currentTimeMillis()); // 추가! - message.put("roundDuration", gameResult.room().getRoundTimeLimit()); // 명확한 이름 - - // ... -} - -// ROUND_END → ROUND_START 메시지에도 동일하게 추가 -private void broadcastRoundEnd(...) { - // ... - messageData.put("roundStartTime", room.getRoundStartTime()); - messageData.put("serverTime", System.currentTimeMillis()); // 추가! - messageData.put("roundDuration", room.getRoundTimeLimit()); - // ... -} -``` - -**예상 작업량:** 30분 - -### 3.2 Phase 2: 메시지 구조 개선 (1일) - -**변경 파일:** `WebSocketMessageHandler.java`, 모든 브로드캐스트 메서드 - -```java -// 모든 메시지에 domain 필드 추가 -private Map createMessage(String domain, String messageType, Object data) { - Map message = new HashMap<>(); - message.put("domain", domain); // "chat" 또는 "game" - message.put("messageType", messageType); - message.put("data", data); - message.put("timestamp", System.currentTimeMillis()); - return message; -} - -// 채팅 메시지 -createMessage("chat", "TEXT", chatData); -createMessage("chat", "USER_JOIN", joinData); - -// 게임 메시지 -createMessage("game", "GAME_START", gameStartData); -createMessage("game", "ROUND_START", roundStartData); -createMessage("game", "DRAWING", drawingData); -``` - -### 3.3 Phase 3: 게임 세션 분리 (1주) - -#### 3.3.1 새 모델: GameSession.java - -```java -@DynamoDbBean -public class GameSession { - private String pk; // GAME#{gameSessionId} - private String sk; // METADATA - private String gsi1pk; // ROOM#{roomId} - private String gsi1sk; // GAME#{createdAt} - - // 게임 식별 - private String gameSessionId; - private String roomId; // 연결된 채팅방 - private String gameType; // "catchmind" - - // 게임 상태 - private String status; // WAITING, PLAYING, FINISHED - private String startedBy; - private Long startedAt; - private Long endedAt; - - // 라운드 정보 - private Integer currentRound; - private Integer totalRounds; - private String currentDrawerId; - private String currentWordId; - private String currentWord; - private Long roundStartTime; - private Integer roundDuration; - - // 점수 - private Map scores; - private Map streaks; - private List players; - private List drawerOrder; - - // 자동 종료 - private Long gameEndScheduledAt; - private String scheduleRuleArn; - - // TTL - private Long ttl; -} -``` - -#### 3.3.2 ChatRoom에서 게임 필드 제거 - -```java -@DynamoDbBean -public class ChatRoom { - // 채팅 필드만 유지 - private String roomId; - private String name; - private String description; - private String level; - private Integer currentMembers; - private Integer maxMembers; - private Boolean isPrivate; - private String password; - private String createdBy; - private String createdAt; - private String lastMessageAt; - private List memberIds; - - // 게임 연결 (참조만) - private String activeGameSessionId; // 현재 진행중인 게임 세션 ID - - // 게임 필드 모두 제거! - // - gameStatus, gameStartedBy, currentRound... 전부 GameSession으로 이동 -} -``` - -#### 3.3.3 게임 세션 API - -``` -# 게임 세션 생성 -POST /api/chat/rooms/{roomId}/games -Request: -{ - "gameType": "catchmind", - "settings": { - "totalRounds": 5, - "roundDuration": 60 - } -} - -Response: -{ - "gameSessionId": "game-abc123", - "roomId": "room-xyz", - "status": "WAITING", - "createdAt": "2024-01-20T10:00:00Z" -} - -# 게임 상태 조회 (재접속 시 필수!) -GET /api/games/{gameSessionId} - -Response: -{ - "gameSessionId": "game-abc123", - "roomId": "room-xyz", - "status": "PLAYING", - "currentRound": 2, - "totalRounds": 5, - "currentDrawerId": "user123", - "roundStartTime": 1705744800000, - "serverTime": 1705744830000, // 핵심! - "roundDuration": 60, - "scores": { - "user1": 150, - "user2": 120 - }, - "players": ["user1", "user2", "user3"] -} - -# 게임 시작 (기존 /start 명령어 대체) -POST /api/games/{gameSessionId}/start - -# 게임 종료 -POST /api/games/{gameSessionId}/stop -``` - ---- - -## 4. 프론트엔드 변경사항 - -### 4.1 Phase 1: 타이머 버그 수정 (즉시) - -```javascript -// useTimer.js - 독립적인 타이머 훅 -export function useTimer(roundStartTime, roundDuration, serverTime) { - const [remainingTime, setRemainingTime] = useState(roundDuration); - - useEffect(() => { - if (!roundStartTime || !roundDuration) return; - - // 서버-클라이언트 시간 차이 보정 - const timeOffset = serverTime ? (Date.now() - serverTime) : 0; - - const interval = setInterval(() => { - const adjustedNow = Date.now() - timeOffset; - const elapsed = Math.floor((adjustedNow - roundStartTime) / 1000); - const remaining = Math.max(0, roundDuration - elapsed); - setRemainingTime(remaining); - - if (remaining <= 0) { - clearInterval(interval); - } - }, 100); - - return () => clearInterval(interval); - }, [roundStartTime, roundDuration, serverTime]); - - return remainingTime; -} -``` - -### 4.2 Phase 2: 메시지 핸들러 분리 - -```javascript -// WebSocket 메시지 핸들러 -onMessage(event) { - const message = JSON.parse(event.data); - - switch (message.domain) { - case 'chat': - this.handleChatMessage(message); - break; - case 'game': - this.handleGameMessage(message); - break; - } -} - -handleChatMessage(message) { - switch (message.messageType) { - case 'TEXT': // 채팅 메시지 - case 'USER_JOIN': - case 'USER_LEAVE': - case 'SYSTEM': - } -} - -handleGameMessage(message) { - switch (message.messageType) { - case 'GAME_START': - case 'ROUND_START': - case 'DRAWING': - case 'CORRECT_ANSWER': - case 'SCORE_UPDATE': - } -} -``` - -### 4.3 Phase 3: 훅 분리 - -``` -src/domains/ -├── chat/ -│ ├── hooks/ -│ │ └── useChatWebSocket.js # 채팅만 처리 -│ └── components/ -│ ├── ChatMessages.jsx -│ └── ChatInput.jsx -│ -├── catchmind/ -│ ├── hooks/ -│ │ ├── useGameWebSocket.js # 게임만 처리 -│ │ ├── useGameState.js -│ │ └── useTimer.js -│ └── components/ -│ ├── DrawingCanvas.jsx -│ ├── ScoreBoard.jsx -│ └── Timer.jsx -│ -└── freetalk/ - └── pages/ - └── FreeTalkPage.jsx # chat + catchmind 조합 -``` - ---- - -## 5. 메시지 스펙 (최종) - -### 5.1 공통 메시지 구조 - -```json -{ - "domain": "chat" | "game", - "messageType": "...", - "data": { ... }, - "timestamp": 1705744800000 -} -``` - -### 5.2 채팅 메시지 - -| Type | 방향 | data 필드 | -|--------------|-----|-----------------------------------------------| -| `TEXT` | 양방향 | `messageId`, `userId`, `content`, `createdAt` | -| `USER_JOIN` | S→C | `userId`, `memberCount` | -| `USER_LEAVE` | S→C | `userId`, `memberCount` | -| `SYSTEM` | S→C | `content` | - -### 5.3 게임 메시지 - -| Type | 방향 | data 필드 | -|------------------|-----|---------------------------------------------------------------------------------------------------------------| -| `GAME_START` | S→C | `gameSessionId`, `totalRounds`, `currentDrawerId`, `roundStartTime`, `serverTime`, `roundDuration`, `players` | -| `GAME_END` | S→C | `gameSessionId`, `reason`, `finalScores`, `winner` | -| `ROUND_START` | S→C | `currentRound`, `currentDrawerId`, `roundStartTime`, `serverTime`, `roundDuration`, `currentWord`(출제자만) | -| `ROUND_END` | S→C | `currentRound`, `answer`, `scores` | -| `DRAWING` | 양방향 | `drawingData` | -| `DRAWING_CLEAR` | 양방향 | - | -| `GUESS` | C→S | `content` | -| `CORRECT_ANSWER` | S→C | `userId`, `score`, `elapsedTime` | -| `SCORE_UPDATE` | S→C | `scores`, `currentRound`, `totalRounds` | -| `HINT` | S→C | `hint` | - -### 5.4 ROUND_START 상세 (핵심!) - -```json -{ - "domain": "game", - "messageType": "ROUND_START", - "data": { - "gameSessionId": "game-abc123", - "currentRound": 2, - "totalRounds": 5, - "currentDrawerId": "user123", - "roundStartTime": 1705744800000, - "serverTime": 1705744800500, - "roundDuration": 60, - "currentWord": { - "wordId": "word-1", - "word": "apple" - } - }, - "timestamp": 1705744800500 -} -``` - -**중요:** `currentWord`는 출제자에게만 전송! - ---- - -## 6. 구현 일정 - -``` -Week 1: 긴급 버그 수정 -├── [BE] serverTime 필드 추가 (0.5일) -├── [FE] useTimer 훅 수정 (0.5일) -├── [BE] 메시지에 domain 필드 추가 (1일) -└── [FE] 메시지 핸들러 domain 분기 (0.5일) - -Week 2: 게임 세션 분리 (BE) -├── [BE] GameSession 모델 생성 -├── [BE] GameSessionRepository 구현 -├── [BE] GameService 리팩토링 -└── [BE] 게임 세션 API 구현 - -Week 3: 프론트엔드 리팩토링 -├── [FE] useChatWebSocket 분리 -├── [FE] useGameWebSocket 신규 -├── [FE] 컴포넌트 분리 -└── [FE/BE] 통합 테스트 - -Week 4: 안정화 및 추가 기능 -├── [BE] 게임 자동 종료 (7분) - Issue #417 -├── [BE] 재접속 시 게임 상태 복구 -└── [FE/BE] E2E 테스트 -``` - ---- - -## 7. 기대 효과 - -| 항목 | 현재 | 개선 후 | -|---------|-------------|------------------| -| 타이머 정확도 | 클라이언트 시계 의존 | 서버 시간 기준 동기화 | -| 재접속 | 게임 상태 유실 | 완전 복구 가능 | -| 테스트 | 채팅/게임 분리 불가 | 독립 테스트 가능 | -| 확장성 | 새 게임 추가 어려움 | gameType으로 확장 용이 | -| 유지보수 | 책임 혼재 | 명확한 책임 분리 | - ---- - -## 8. 즉시 적용 (백엔드 변경 전 프론트엔드 임시 조치) - -```javascript -// 백엔드 변경 전까지 프론트엔드에서 적용 가능한 임시 코드 - -onRoundStart: (data) => { - const roundData = data.data || data; - const now = Date.now(); - - // serverTime이 없으면 클라이언트 시간 사용 (임시) - const serverTime = roundData.serverTime || now; - let roundStartTime = roundData.roundStartTime || now; - - // roundStartTime이 미래 시간이면 현재로 보정 - if (roundStartTime > now + 1000) { - console.warn('Invalid roundStartTime, using current time'); - roundStartTime = now; - } - - setGameState((prev) => ({ - ...prev, - currentRound: roundData.currentRound, - currentDrawerId: roundData.currentDrawerId, - roundStartTime: roundStartTime, - serverTime: serverTime, - roundDuration: roundData.roundDuration || roundData.roundTimeLimit || 60, - })); -} -``` - ---- - -## 9. 결론 - -**우선순위:** - -1. **즉시 (이번 주)**: `serverTime` 추가 + `domain` 필드 추가 -2. **단기 (2주)**: GameSession 모델 분리 + API 구현 -3. **중기 (3-4주)**: FE/BE 완전 분리 + 자동 종료 + 재접속 복구 - -**핵심 원칙:** - -- 단일 WebSocket 엔드포인트 유지 (비용/복잡도) -- `domain` 필드로 채팅/게임 구분 -- `serverTime`으로 정확한 타이머 동기화 -- GameSession 독립 모델로 상태 관리 명확화 diff --git a/docs/CICD-IMPLEMENTATION-QNA.md b/docs/CICD-IMPLEMENTATION-QNA.md deleted file mode 100644 index e00c5a11..00000000 --- a/docs/CICD-IMPLEMENTATION-QNA.md +++ /dev/null @@ -1,421 +0,0 @@ -# CI/CD 파이프라인 구현 설명 및 면접 Q&A - -## 1. CI/CD 아키텍처 개요 - -``` -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ GitHub │───▶│ CodePipeline│───▶│ CodeBuild │───▶│CloudFormation│ -│ (Source) │ │ (Pipeline) │ │ (Build) │ │ (Deploy) │ -└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ - │ │ │ │ - │ ▼ ▼ ▼ - │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ - │ │ SNS │ │ S3 │ │ Lambda │ - │ │(Notification)│ │ (Artifacts) │ │ Functions │ - │ └─────────────┘ └─────────────┘ └─────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ prod 브랜치 Push/Merge │ -└─────────────────────────────────────────────────────────────────────┘ -``` - -## 2. 구성 요소 상세 설명 - -### 2.1 Source Stage (GitHub) - -- **트리거**: prod 브랜치에 Push 또는 PR Merge 시 자동 실행 -- **연결 방식**: AWS CodeConnections (구 CodeStar Connections) -- **아티팩트**: 소스 코드를 ZIP으로 압축하여 다음 스테이지로 전달 - -### 2.2 Build Stage (CodeBuild) - -- **런타임**: Amazon Linux 2, Java Corretto 21 -- **빌드 단계**: - 1. **Install**: SAM CLI 설치 - 2. **Pre-build**: Gradle 테스트 실행 (`./gradlew clean test`) - 3. **Build**: SAM build & package - 4. **Post-build**: 완료 로그 -- **캐싱**: Gradle 캐시를 S3에 저장하여 빌드 시간 단축 -- **리포트**: JUnit 테스트 결과, JaCoCo 코드 커버리지 리포트 - -### 2.3 Deploy Stage (CloudFormation) - -- **배포 방식**: CloudFormation CREATE_UPDATE -- **템플릿**: SAM으로 패키징된 `packaged-template.yaml` -- **기능**: CAPABILITY_IAM, CAPABILITY_AUTO_EXPAND - -### 2.4 Notification (SNS) - -- **이벤트**: 파이프라인 시작, 성공, 실패 시 이메일 알림 -- **구현**: CodeStar Notifications + SNS Topic - -## 3. 주요 파일 구조 - -``` -BE_Repository/ -├── cicd/ -│ └── pipeline.yaml # CloudFormation 파이프라인 템플릿 -├── ServerlessFunction/ -│ ├── buildspec.yml # CodeBuild 빌드 명세 -│ ├── samconfig.toml # SAM 배포 설정 -│ └── template.yaml # SAM 애플리케이션 템플릿 -``` - -## 4. IAM 역할 구성 - -| 역할 | 목적 | 주요 권한 | -|--------------------|---------------------|----------------------------------------| -| PipelineRole | CodePipeline 서비스 역할 | S3, CodeBuild, CloudFormation, SNS | -| CodeBuildRole | CodeBuild 서비스 역할 | S3, CloudWatch Logs, CodeBuild Reports | -| CloudFormationRole | 리소스 배포 역할 | AdministratorAccess (SAM 리소스 생성용) | - ---- - -## 5. 면접 예상 질문 및 답변 - -### Q1. CI/CD 파이프라인을 구축한 이유는 무엇인가요? - -**A1:** -수동 배포의 문제점을 해결하기 위해 CI/CD를 도입했습니다. - -1. **일관성**: 수동 배포 시 발생할 수 있는 휴먼 에러 방지 -2. **자동화**: 코드 푸시만으로 테스트-빌드-배포가 자동 실행 -3. **품질 보장**: 테스트 실패 시 배포가 중단되어 결함 있는 코드가 프로덕션에 배포되는 것을 방지 -4. **추적성**: 모든 배포 이력이 CodePipeline에 기록되어 문제 발생 시 원인 추적 용이 -5. **속도**: 반복적인 배포 작업 시간을 단축하여 개발 생산성 향상 - ---- - -### Q2. GitHub과 AWS CodePipeline을 어떻게 연동했나요? - -**A2:** -AWS CodeConnections(구 CodeStar Connections)를 사용하여 연동했습니다. - -```yaml -# pipeline.yaml의 Source Stage 설정 -- Name: Source - Actions: - - Name: GitHub - ActionTypeId: - Category: Source - Owner: AWS - Provider: CodeStarSourceConnection - Version: '1' - Configuration: - ConnectionArn: !Ref GitHubConnectionArn - FullRepositoryId: "Language-Study-Prooject/BE_Repository" - BranchName: "prod" - DetectChanges: true -``` - -**연동 과정:** - -1. AWS Console에서 CodeConnections 생성 -2. GitHub OAuth 앱 승인 -3. Connection ARN을 파이프라인에 설정 -4. `DetectChanges: true`로 설정하여 자동 트리거 활성화 - ---- - -### Q3. CodeBuild의 buildspec.yml에서 각 phase의 역할은 무엇인가요? - -**A3:** - -```yaml -phases: - install: # 빌드 환경 설정 - runtime-versions: - java: corretto21 - commands: - - pip3 install aws-sam-cli - - pre_build: # 테스트 실행 (품질 게이트) - commands: - - cd ServerlessFunction - - ./gradlew clean test - - build: # 실제 빌드 및 패키징 - commands: - - sam build - - sam package --s3-bucket ... --output-template-file packaged-template.yaml - - post_build: # 후처리 (로깅, 정리) - commands: - - echo "Build completed" -``` - -- **install**: 빌드에 필요한 런타임과 도구 설치 -- **pre_build**: 테스트 실행 - 실패 시 빌드 중단 (품질 게이트 역할) -- **build**: SAM 애플리케이션 빌드 및 S3에 패키징 -- **post_build**: 완료 로그 기록, 정리 작업 - ---- - -### Q4. 테스트가 실패하면 배포가 어떻게 되나요? - -**A4:** -테스트 실패 시 배포가 자동으로 중단됩니다. - -**작동 원리:** - -1. `pre_build` 단계에서 `./gradlew clean test` 실행 -2. 테스트 실패 시 Gradle이 exit code 1 반환 -3. CodeBuild가 비정상 종료로 판단하여 빌드 실패 처리 -4. CodePipeline의 Build Stage가 실패 상태가 됨 -5. Deploy Stage로 진행되지 않음 -6. SNS를 통해 실패 알림 이메일 발송 - -``` -Pipeline Flow: -Source ──▶ Build (테스트 실패) ──✗ Deploy - │ - ▼ - SNS 알림 발송 -``` - ---- - -### Q5. SAM과 CloudFormation의 관계는 무엇인가요? - -**A5:** -SAM(Serverless Application Model)은 CloudFormation의 확장입니다. - -**관계:** - -- SAM 템플릿은 CloudFormation 템플릿의 상위 집합 -- `sam build`/`sam package` 실행 시 SAM 템플릿이 표준 CloudFormation 템플릿으로 변환 -- 변환된 템플릿(`packaged-template.yaml`)을 CloudFormation이 배포 - -**SAM의 장점:** - -1. 간결한 문법: `AWS::Serverless::Function`으로 Lambda + API Gateway + IAM 역할 한번에 정의 -2. 로컬 테스트: `sam local invoke`로 Lambda 로컬 실행 가능 -3. 자동 패키징: 코드를 S3에 업로드하고 참조 자동 생성 - -```yaml -# SAM 템플릿 (간결) -Type: AWS::Serverless::Function -Properties: - Handler: handler.main - Runtime: java21 - Events: - Api: - Type: Api - Properties: - Path: /hello - Method: get - -# 변환된 CloudFormation (복잡) -# Lambda Function + API Gateway + IAM Role + Permission 등 여러 리소스로 확장 -``` - ---- - -### Q6. 배포 중 롤백은 어떻게 처리되나요? - -**A6:** -CloudFormation의 기본 롤백 기능을 활용합니다. - -**설정:** - -```yaml -# samconfig.toml -disable_rollback = false # 롤백 활성화 -``` - -**롤백 시나리오:** - -1. **배포 실패 시**: CloudFormation이 자동으로 이전 상태로 롤백 -2. **Lambda 오류 시**: - - 현재는 기본 롤백만 사용 - - 추가로 Canary/Linear 배포 설정 가능 (AWS CodeDeploy 연동) - -```yaml -# 점진적 배포 예시 (선택적 구현) -DeploymentPreference: - Type: Canary10Percent5Minutes # 10%에 5분간 배포 후 문제없으면 전체 배포 -``` - ---- - -### Q7. 파이프라인의 아티팩트는 어떻게 관리되나요? - -**A7:** -S3 버킷을 사용하여 아티팩트를 관리합니다. - -```yaml -ArtifactBucket: - Type: AWS::S3::Bucket - Properties: - BucketName: group2-englishstudy-pipeline-artifacts - VersioningConfiguration: - Status: Enabled # 버전 관리 활성화 - BucketEncryption: - ServerSideEncryptionConfiguration: - - ServerSideEncryptionByDefault: - SSEAlgorithm: AES256 # 암호화 -``` - -**아티팩트 종류:** - -1. **SourceArtifact**: GitHub에서 가져온 소스 코드 ZIP -2. **BuildArtifact**: 빌드된 `packaged-template.yaml` -3. **Cache**: Gradle 캐시 (빌드 시간 단축용) - ---- - -### Q8. 파이프라인 알림은 어떻게 구현했나요? - -**A8:** -AWS CodeStar Notifications와 SNS를 연동하여 구현했습니다. - -```yaml -# SNS Topic 생성 -NotificationTopic: - Type: AWS::SNS::Topic - Properties: - TopicName: cicd-pipeline-notifications - -# 이메일 구독 -EmailSubscription: - Type: AWS::SNS::Subscription - Properties: - TopicArn: !Ref NotificationTopic - Protocol: email - Endpoint: !Ref NotificationEmail - -# 알림 규칙 -PipelineNotificationRule: - Type: AWS::CodeStarNotifications::NotificationRule - Properties: - EventTypeIds: - - codepipeline-pipeline-pipeline-execution-started - - codepipeline-pipeline-pipeline-execution-succeeded - - codepipeline-pipeline-pipeline-execution-failed - Targets: - - TargetType: SNS - TargetAddress: !Ref NotificationTopic -``` - ---- - -### Q9. CI/CD 구축 중 겪은 문제와 해결 방법은? - -**A9:** - -**문제 1: Gradle Wrapper를 찾을 수 없음** - -- 원인: `.gitignore`에서 `gradle/` 폴더 전체가 제외됨 -- 해결: `.gitignore` 수정하여 `!gradle/wrapper/` 예외 추가 - -**문제 2: JAVA_HOME 환경 변수 오류** - -- 원인: CodeBuild에서 JAVA_HOME을 수동 설정했으나 경로 불일치 -- 해결: `runtime-versions: java: corretto21`만 사용하고 JAVA_HOME 수동 설정 제거 - -**문제 3: SAM package S3 버킷 참조 오류** - -- 원인: 환경 변수를 사용한 멀티라인 명령어에서 변수 치환 실패 -- 해결: 단일 라인으로 버킷 이름 직접 지정 - -**문제 4: Lambda 환경 변수 누락** - -- 원인: WebSocket Connect 함수에 `WEBSOCKET_ENDPOINT` 환경 변수 미설정 -- 해결: `template.yaml`에 환경 변수 추가 - ---- - -### Q10. 현재 CI/CD의 개선점이 있다면? - -**A10:** - -1. **테스트 커버리지 게이트** - - 현재: 테스트 실행만 함 - - 개선: 커버리지 80% 미만 시 빌드 실패 설정 - -2. **점진적 배포 (Canary/Blue-Green)** - - 현재: 전체 교체 배포 - - 개선: Lambda Alias + CodeDeploy로 Canary 배포 구현 - -3. **다중 환경 지원** - - 현재: prod 단일 환경 - - 개선: dev, staging, prod 분리 및 승인 단계 추가 - -4. **보안 스캔** - - 개선: 의존성 취약점 스캔 (OWASP Dependency-Check) 추가 - -5. **성능 테스트** - - 개선: 배포 전 부하 테스트 단계 추가 - ---- - -### Q11. IaC(Infrastructure as Code)를 사용한 이유는? - -**A11:** -파이프라인 자체도 CloudFormation 템플릿(`pipeline.yaml`)으로 정의했습니다. - -**장점:** - -1. **버전 관리**: 인프라 변경 이력을 Git으로 추적 -2. **재현성**: 동일한 파이프라인을 다른 프로젝트/계정에 쉽게 복제 -3. **리뷰 가능**: 인프라 변경도 코드 리뷰 프로세스 적용 -4. **자동화**: 수동 콘솔 작업 없이 `aws cloudformation deploy`로 생성/업데이트 -5. **문서화**: 템플릿 자체가 인프라 문서 역할 - ---- - -### Q12. CodeBuild와 Jenkins의 차이점은? - -**A12:** - -| 항목 | CodeBuild | Jenkins | -|--------|---------------|----------------------| -| 관리 | 완전 관리형 (서버리스) | 자체 서버 운영 필요 | -| 비용 | 빌드 시간 기반 과금 | 서버 운영 비용 | -| 확장성 | 자동 확장 | 수동 확장 필요 | -| AWS 통합 | 네이티브 통합 | 플러그인 필요 | -| 커스터마이징 | buildspec.yml | Jenkinsfile (Groovy) | -| 플러그인 | 제한적 | 풍부한 생태계 | - -**선택 이유:** - -- AWS 서비스 중심 아키텍처에서 네이티브 통합의 이점 -- 서버 관리 부담 없음 -- SAM/CloudFormation과의 원활한 연동 - ---- - -## 6. 핵심 용어 정리 - -| 용어 | 설명 | -|-------------------------------------|------------------------------------------------| -| CI (Continuous Integration) | 코드 변경을 자주 통합하고 자동 테스트하는 방식 | -| CD (Continuous Delivery/Deployment) | 자동으로 프로덕션까지 배포하는 방식 | -| Pipeline | 소스-빌드-배포로 이어지는 자동화된 워크플로우 | -| Artifact | 빌드 결과물 (패키징된 코드, 템플릿 등) | -| buildspec.yml | CodeBuild의 빌드 명세 파일 | -| SAM | Serverless Application Model - 서버리스 앱 정의 프레임워크 | -| IaC | Infrastructure as Code - 코드로 인프라 관리 | - ---- - -## 7. 참고 명령어 - -```bash -# 파이프라인 생성 -aws cloudformation deploy \ - --template-file cicd/pipeline.yaml \ - --stack-name group2-cicd-pipeline \ - --capabilities CAPABILITY_NAMED_IAM \ - --parameter-overrides NotificationEmail=your@email.com - -# 파이프라인 상태 확인 -aws codepipeline get-pipeline-state --name group2-englishstudy-pipeline - -# 수동 파이프라인 실행 -aws codepipeline start-pipeline-execution --name group2-englishstudy-pipeline - -# 빌드 로그 확인 -aws logs tail /aws/codebuild/group2-englishstudy-build --follow -``` diff --git a/docs/FRONTEND-API-GUIDE.md b/docs/FRONTEND-API-GUIDE.md deleted file mode 100644 index 697d406a..00000000 --- a/docs/FRONTEND-API-GUIDE.md +++ /dev/null @@ -1,365 +0,0 @@ -# 프론트엔드 전달사항 - 채팅/게임 API 가이드 - -## 1. 아키텍처 구조 (업데이트됨) - -### 채팅방과 게임방 분리 - -``` -RoomType enum -├── CHAT ("chat") - 일반 채팅방 -└── GAME ("game") - 게임방 (캐치마인드 등) - -RoomStatus enum -├── WAITING ("waiting") - 대기 중 -├── PLAYING ("playing") - 게임 진행 중 -└── FINISHED ("finished") - 종료됨 -``` - -### GSI1SK 인덱스 설계 - -``` -GSI1PK: "ROOMS" (고정) -GSI1SK: {type}#{gameType}#{status}#{level}#{createdAt} - -예시: -- CHAT#-#WAITING#beginner#2026-01-22T10:00:00Z (일반 채팅방) -- GAME#CATCHMIND#WAITING#intermediate#2026-01-22T10:00:00Z (대기중 게임방) -- GAME#CATCHMIND#PLAYING#advanced#2026-01-22T10:00:00Z (진행중 게임방) -``` - -**핵심**: DB 레벨에서 `type`, `gameType`, `status`, `level` 조합으로 필터링 가능 - ---- - -## 2. 방 타입 (RoomType) - -| 타입 | 코드 | 설명 | -|--------|--------|---------------| -| `CHAT` | `chat` | 일반 채팅방 | -| `GAME` | `game` | 게임방 (캐치마인드 등) | - ---- - -## 3. 방 상태 (RoomStatus) - -| 상태 | 코드 | 설명 | 게임 시작 가능 | -|------------|------------|---------|:--------:| -| `WAITING` | `waiting` | 대기 중 | O | -| `PLAYING` | `playing` | 게임 진행 중 | X | -| `FINISHED` | `finished` | 게임 종료됨 | O | - ---- - -## 4. REST API 엔드포인트 - -### 채팅방 API (`/api/chat/rooms`) - -| Method | Endpoint | 설명 | -|--------|-------------------------|---------------------| -| POST | `/rooms` | 채팅방/게임방 생성 | -| GET | `/rooms` | 방 목록 조회 (필터 지원) | -| GET | `/rooms/{roomId}` | 방 상세 조회 | -| POST | `/rooms/{roomId}/join` | 방 입장 (roomToken 발급) | -| POST | `/rooms/{roomId}/leave` | 방 퇴장 | -| DELETE | `/rooms/{roomId}` | 방 삭제 (방장만) | - -### 게임 API (`/api/game`) - -| Method | Endpoint | 설명 | -|--------|-------------------------------|----------| -| POST | `/rooms/{roomId}/game/start` | 게임 시작 | -| POST | `/rooms/{roomId}/game/stop` | 게임 중단 | -| GET | `/rooms/{roomId}/game/status` | 게임 상태 조회 | -| GET | `/rooms/{roomId}/game/scores` | 점수판 조회 | - ---- - -## 5. 방 목록 조회 쿼리 파라미터 (업데이트됨) - -``` -GET /api/chat/rooms?type=GAME&gameType=CATCHMIND&status=WAITING&level=intermediate&limit=10&cursor=xxx -``` - -| 파라미터 | 타입 | 설명 | 예시 | -|------------|--------|----------------------|----------------------------------------| -| `type` | string | 방 타입 필터 | `CHAT`, `GAME` | -| `gameType` | string | 게임 타입 | `CATCHMIND` | -| `status` | string | 상태 필터 | `WAITING`, `PLAYING`, `FINISHED` | -| `level` | string | 난이도 필터 | `beginner`, `intermediate`, `advanced` | -| `limit` | number | 조회 개수 (기본 10, 최대 20) | | -| `cursor` | string | 페이지네이션 커서 | | - -### 필터 조합 예시 - -```bash -# 대기 중인 게임방만 -GET /api/chat/rooms?type=GAME&status=WAITING - -# 캐치마인드 게임방만 -GET /api/chat/rooms?type=GAME&gameType=CATCHMIND - -# 초급 난이도 채팅방 -GET /api/chat/rooms?type=CHAT&level=beginner - -# 진행 중인 고급 게임방 -GET /api/chat/rooms?type=GAME&status=PLAYING&level=advanced -``` - -### 응답 예시 - -```json -{ - "success": true, - "message": "Rooms retrieved", - "data": { - "rooms": [ - { - "roomId": "abc-123", - "name": "초보자 영어 스터디", - "type": "GAME", - "gameType": "CATCHMIND", - "status": "WAITING", - "level": "beginner", - "currentMembers": 3, - "maxMembers": 6, - "currentRound": 0, - "totalRounds": 5, - "createdAt": "2026-01-22T10:00:00Z" - } - ], - "nextCursor": "eyJQSyI6Ik...", - "hasMore": true - } -} -``` - ---- - -## 6. 방 생성 요청 (업데이트됨) - -### 채팅방 생성 - -```json -{ - "name": "영어 스터디 채팅방", - "type": "CHAT", - "level": "beginner", - "maxMembers": 6, - "description": "초보자를 위한 영어 채팅방" -} -``` - -### 게임방 생성 - -```json -{ - "name": "캐치마인드 게임", - "type": "GAME", - "gameType": "CATCHMIND", - "level": "intermediate", - "maxMembers": 8, - "description": "영어 단어 맞추기 게임" -} -``` - ---- - -## 7. 프론트엔드에서 방 타입 구분 - -### 방법 1: API 필터 사용 (권장) - -```javascript -// 게임방만 조회 -const gameRooms = await fetch('/api/chat/rooms?type=GAME'); - -// 대기 중인 게임방만 -const waitingGames = await fetch('/api/chat/rooms?type=GAME&status=WAITING'); - -// 채팅방만 -const chatRooms = await fetch('/api/chat/rooms?type=CHAT'); -``` - -### 방법 2: 전체 조회 후 클라이언트 필터링 - -```javascript -const allRooms = await fetchRooms(); - -// 게임방만 -const gameRooms = allRooms.filter(room => room.type === 'GAME'); - -// 채팅방만 -const chatRooms = allRooms.filter(room => room.type === 'CHAT'); - -// 대기 중인 방만 -const waitingRooms = allRooms.filter(room => room.status === 'WAITING'); -``` - ---- - -## 8. WebSocket 연결 - -### 채팅/게임 WebSocket - -``` -wss://t378dif43l.execute-api.ap-northeast-2.amazonaws.com/dev?roomToken={roomToken} -``` - -### Grammar WebSocket - -``` -wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} -``` - -### 연결 순서 - -1. `POST /rooms/{roomId}/join` → `roomToken` 발급 -2. WebSocket 연결 시 `roomToken` 쿼리 파라미터로 전달 - ---- - -## 9. WebSocket 메시지 타입 (messageType) - -| 코드 | 타입 | 설명 | -|------------------|--------|---------------| -| `MSG` | 일반 메시지 | 일반 채팅 메시지 | -| `VOICE` | 음성 메시지 | 음성 채팅 | -| `JOIN` | 입장 알림 | 사용자 입장 | -| `LEAVE` | 퇴장 알림 | 사용자 퇴장 | -| `GAME_START` | 게임 시작 | 게임 시작 알림 | -| `GAME_END` | 게임 종료 | 게임 종료 + 최종 순위 | -| `ROUND_START` | 라운드 시작 | 새 라운드 시작 | -| `ROUND_END` | 라운드 종료 | 정답 공개 | -| `ANSWER_CORRECT` | 정답 | 정답 맞춤 | -| `HINT` | 힌트 | 힌트 제공 | -| `SKIP` | 스킵 | 라운드 스킵 | -| `SYSTEM` | 시스템 | 시스템 메시지 | - ---- - -## 10. 게임 명령어 (WebSocket) - -채팅 메시지로 게임 명령어 전송: - -| 명령어 | 설명 | 권한 | -|----------|--------|-----------------| -| `/start` | 게임 시작 | 방장 (2명 이상 접속 시) | -| `/stop` | 게임 중단 | 방장 또는 게임 시작자 | -| `/skip` | 라운드 스킵 | 누구나 | -| `/hint` | 힌트 제공 | 출제자만 | -| `/score` | 점수 확인 | 누구나 | - ---- - -## 11. 게임 시작 응답 예시 - -```json -{ - "messageId": "uuid", - "roomId": "abc-123", - "userId": "SYSTEM", - "content": "게임 시작!\n총 5 라운드\n\n라운드 1 시작!\n출제자: user-456", - "messageType": "GAME_START", - "createdAt": "2026-01-22T10:00:00Z", - "serverTime": "2026-01-22T10:00:00Z", - "domain": "GAME", - "type": "GAME", - "status": "PLAYING", - "currentRound": 1, - "totalRounds": 5, - "currentDrawerId": "user-456", - "drawerOrder": ["user-456", "user-789", "user-123"] -} -``` - ---- - -## 12. 정답 체크 로직 - -- **한국어** 또는 **영어** 둘 다 정답으로 인정 -- 대소문자 구분 없음 -- 공백 무시 - -### 점수 계산 - -``` -기본 점수: 10점 -시간 보너스: (제한시간 - 경과시간) * 0.5 -연속 정답 보너스: 연속정답수 * 2 - -총점 = 기본점수 + 시간보너스 + 연속정답보너스 -``` - ---- - -## 13. 게임 설정 - -| 설정 | 기본값 | 환경변수 | -|--------------|----------|---------------------------------| -| 총 라운드 수 | 5 | `GAME_TOTAL_ROUNDS` | -| 라운드 제한 시간(초) | 60 | `GAME_ROUND_TIME_LIMIT` | -| 빠른 정답 기준(ms) | 5000 | `GAME_QUICK_GUESS_THRESHOLD_MS` | -| 게임 전체 제한(초) | 420 (7분) | `GAME_TIME_LIMIT_SECONDS` | - ---- - -## 14. 주의사항 - -1. **roomToken은 한 번만 사용**: 재연결 시 새로 발급 필요 -2. **WebSocket 연결 실패 시**: `POST /rooms/{roomId}/join`으로 새 토큰 발급 -3. **게임 중 퇴장**: 자동으로 다음 출제자로 넘어감 (2명 미만 시 게임 종료) -4. **출제자는 정답 입력 불가**: 본인이 출제자일 때 채팅해도 정답 체크 안됨 -5. **방 타입 변경 불가**: 생성 시 지정한 type은 변경 불가 - ---- - -## 15. 에러 코드 - -| 코드 | HTTP | 설명 | -|--------------|------|--------------| -| `ROOM_001` | 404 | 채팅방 없음 | -| `ROOM_002` | 409 | 채팅방 이미 존재 | -| `ROOM_003` | 400 | 채팅방 인원 초과 | -| `ROOM_004` | 400 | 채팅방 종료됨 | -| `ROOM_005` | 401 | 비밀번호 틀림 | -| `ROOM_006` | 403 | 방장 권한 없음 | -| `MEMBER_001` | 403 | 채팅방 멤버 아님 | -| `MEMBER_002` | 409 | 이미 참여 중 | -| `GAME_001` | 400 | 게임 시작 실패 | -| `GAME_002` | 400 | 게임 중단 실패 | -| `GAME_003` | 400 | 게임 진행 중 아님 | -| `GAME_004` | 409 | 게임 이미 진행 중 | -| `GAME_005` | 403 | 게임 시작자 아님 | -| `GAME_006` | 404 | 게임 없음 | -| `GAME_007` | 400 | 채팅방에서 게임 불가 | -| `GAME_008` | 400 | 게임 재시작 불가 | -| `GAME_009` | 403 | 방장만 게임 시작 가능 | - ---- - -## 16. UI 구현 가이드 - -### 탭 구조 (권장) - -``` -[전체] [채팅방] [게임방] -``` - -### 게임방 상태 표시 - -``` -대기 중 (WAITING) → 초록색 뱃지 "참여 가능" -진행 중 (PLAYING) → 빨간색 뱃지 "게임 중" -종료됨 (FINISHED) → 회색 뱃지 "종료" -``` - -### 게임방 카드 정보 - -``` -┌─────────────────────────────┐ -│ 캐치마인드 - 영어 단어 맞추기 │ -│ [게임방] [intermediate] │ -│ │ -│ 👥 3/8명 🎮 대기 중 │ -│ 🕐 2026-01-22 10:00 │ -└─────────────────────────────┘ -``` diff --git a/docs/MIDTERM-REPORT.md b/docs/MIDTERM-REPORT.md deleted file mode 100644 index 9a6bb1d1..00000000 --- a/docs/MIDTERM-REPORT.md +++ /dev/null @@ -1,439 +0,0 @@ -# 영어 학습 플랫폼 백엔드 최종 성과 보고서 - -## 프로젝트 개요 - -| 항목 | 내용 | -|-------|--------------------------------------------------------------------------| -| 프로젝트명 | 영어 회화 학습 플랫폼 (MZC 2nd Project) | -| 담당 영역 | Vocabulary, Chatting, Grammar, Badge, Stats, Common | -| 기술 스택 | Java 21, AWS Lambda, DynamoDB, API Gateway WebSocket, Bedrock, Polly, S3 | -| 배포 환경 | AWS SAM, CloudFormation | - ---- - -## 1. 전체 시스템 아키텍처 - -```mermaid -flowchart TB - subgraph Client["클라이언트"] - WEB[Web App] - end - - subgraph Gateway["API Gateway"] - REST[REST API] - WS[WebSocket API] - GRAMMAR_WS[Grammar WebSocket] - end - - subgraph Lambda["AWS Lambda - 도메인별 핸들러"] - direction TB - VOCAB[Vocabulary
단어/일일학습/테스트] - CHAT[Chatting
실시간 채팅/게임] - GRAMMAR[Grammar
문법 체크/스트리밍] - STATS[Stats
통계 집계] - BADGE[Badge
배지 시스템] - USER[User
사용자 관리] - end - - subgraph AI["AI Services"] - BEDROCK[AWS Bedrock
Claude 3.5 Sonnet] - POLLY[AWS Polly
TTS] - end - - subgraph Data["Data Layer"] - DYNAMO_VOCAB[(DynamoDB
Vocab Table)] - DYNAMO_CHAT[(DynamoDB
Chat Table)] - S3[(S3
음성/뱃지 이미지)] - STREAMS[DynamoDB Streams] - end - - WEB --> REST - WEB --> WS - WEB --> GRAMMAR_WS - REST --> VOCAB - REST --> CHAT - REST --> GRAMMAR - REST --> BADGE - REST --> STATS - REST --> USER - WS --> CHAT - GRAMMAR_WS --> GRAMMAR - VOCAB --> DYNAMO_VOCAB - VOCAB --> POLLY - VOCAB --> S3 - CHAT --> DYNAMO_CHAT - CHAT --> BEDROCK - GRAMMAR --> DYNAMO_VOCAB - GRAMMAR --> BEDROCK - STATS --> DYNAMO_VOCAB - BADGE --> DYNAMO_VOCAB - BADGE --> S3 - STREAMS -->|이벤트 트리거| STATS - STATS -->|배지 부여| BADGE -``` - ---- - -## 2. 주요 기능 구현 - -### 2.1 Vocabulary Domain (단어 학습) - -#### 2.1.1 일일 학습 시스템 (Daily Study) - -```mermaid -flowchart LR - subgraph DailyStudy["일일 학습 흐름"] - A[오늘의 단어 조회] --> B{기존 학습 존재?} - B -->|Yes| C[기존 학습 반환] - B -->|No| D[새 단어 50개 + 복습 5개 생성] - D --> E[학습 진행] - E --> F[단어별 학습 완료 처리] - F --> G{50개 완료?} - G -->|Yes| H[isCompleted = true] - end -``` - -**주요 기능:** - -- 레벨별 신규 단어 50개 + 복습 단어 5개 자동 선정 -- 학습 진행도 실시간 추적 (learnedCount/totalWords) -- 일일 학습 완료 시 isCompleted 플래그 설정 - -#### 2.1.2 SM-2 Spaced Repetition 알고리즘 - -```mermaid -stateDiagram-v2 - [*] --> NEW: 단어 추가 - NEW --> LEARNING: 첫 학습 - LEARNING --> LEARNING: 오답 - LEARNING --> REVIEWING: 2회 연속 정답 - REVIEWING --> LEARNING: 오답 - REVIEWING --> MASTERED: 5회 연속 정답 - MASTERED --> LEARNING: 오답 - MASTERED --> MASTERED: 정답 유지 -``` - -**구현 특징:** - -- State 패턴으로 학습 상태 전이 관리 -- easeFactor 동적 조정 (1.3 ~ 2.5) -- 복습 간격 자동 계산 (1일 → 6일 → interval * easeFactor) - -#### 2.1.3 TTS 음성 생성 - -- AWS Polly 연동 (남성/여성 음성) -- S3 캐싱으로 중복 생성 방지 -- 단어 + 예문 음성 생성 - ---- - -### 2.2 Chatting Domain (실시간 채팅 & 게임) - -#### 2.2.1 WebSocket 채팅 - -```mermaid -sequenceDiagram - participant Client - participant REST as REST API - participant WS as WebSocket API - participant DB as DynamoDB - Note over Client, DB: Phase 1: 방 입장 토큰 발급 - Client ->> REST: POST /rooms/{id}/join - REST ->> DB: RoomToken 저장 (TTL: 5분) - REST -->> Client: roomToken 반환 - Note over Client, DB: Phase 2: WebSocket 연결 - Client ->> WS: $connect?roomToken={token} - WS ->> DB: 토큰 검증 + Connection 저장 - WS -->> Client: 연결 성공 - Note over Client, DB: Phase 3: 메시지 송수신 - Client ->> WS: sendmessage (채팅) - WS ->> DB: 메시지 저장 + 브로드캐스트 -``` - -**주요 기능:** - -- RoomToken 기반 인증 (TTL 5분) -- BCrypt 비밀방 암호화 -- 슬래시 명령어 시스템 (/member, /game, /skip, /hint 등) -- Connection 자동 정리 (TTL + 실패 시 삭제) - -#### 2.2.2 캐치마인드 게임 - -```mermaid -flowchart TB - subgraph Game["캐치마인드 게임 흐름"] - START["#47;game 명령어"] --> INIT["게임 초기화
출제 순서 셔플"] - INIT --> ROUND[라운드 시작
출제자 + 단어 선정] - ROUND --> DRAW[출제자 그림 그리기] - DRAW --> GUESS[참가자 정답 입력] - GUESS --> CHECK{정답?} - CHECK -->|Yes| SCORE[점수 계산
시간보너스 + 연속정답보너스] - CHECK -->|No| GUESS - SCORE --> ALLCORRECT{전원 정답?} - ALLCORRECT -->|Yes| NEXTROUND - ALLCORRECT -->|No| TIMEOUT{시간 초과?} - TIMEOUT -->|Yes| NEXTROUND[다음 라운드] - TIMEOUT -->|No| GUESS - NEXTROUND --> LASTROUND{마지막 라운드?} - LASTROUND -->|Yes| END[게임 종료
순위 발표] - LASTROUND -->|No| ROUND - end -``` - -**점수 계산:** - -``` -점수 = 기본점수(10) + 시간보너스((60-경과초)*0.5) + 연속정답보너스(streak*2) -출제자 보너스 = 정답자당 5점 -``` - -**주요 기능:** - -- 실시간 점수 브로드캐스트 -- 연속 정답 스트릭 시스템 -- 접속자 변동 시 출제자 자동 재선정 -- 라운드별 순위 표시 - ---- - -### 2.3 Grammar Domain (문법 체크) - -#### 2.3.1 AI 스트리밍 응답 - -```mermaid -sequenceDiagram - participant Client - participant WS as Grammar WebSocket - participant Handler as GrammarStreamingHandler - participant Bedrock as AWS Bedrock - Client ->> WS: 문법 체크 요청 - WS ->> Handler: Lambda 호출 - Handler ->> Bedrock: 스트리밍 요청 (Claude 3.5 Sonnet) - - loop 청크 단위 응답 - Bedrock -->> Handler: 텍스트 청크 - Handler -->> WS: 실시간 전송 - WS -->> Client: 즉시 표시 - end - - Handler -->> Client: [DONE] 완료 - Handler ->> DB: 피드백 저장 -``` - -**주요 기능:** - -- Claude 3.5 Sonnet 모델 사용 -- 스트리밍으로 체감 대기 시간 80% 감소 -- 레벨별 맞춤 프롬프트 (BEGINNER: 한국어 번역 포함) -- 대화 히스토리 저장으로 문맥 유지 -- 피드백 영구 저장 (DynamoDB) - ---- - -### 2.4 Stats Domain (학습 통계) - -```mermaid -flowchart LR - subgraph StatsTypes["통계 유형"] - DAILY["일별 통계
#47;stats#47;daily"] - WEEKLY["주별 통계
#47;stats#47;weekly"] - MONTHLY["월별 통계
#47;stats#47;monthly"] - TOTAL["전체 통계
#47;stats#47;total"] - HISTORY["히스토리
#47;stats#47;history"] - end -``` - -**통계 항목:** - -| 필드 | 설명 | -|-------------------|-------------| -| testsCompleted | 완료한 테스트 수 | -| questionsAnswered | 답변한 문제 수 | -| correctAnswers | 정답 수 | -| incorrectAnswers | 오답 수 | -| successRate | 정답률 (%) | -| newWordsLearned | 새로 학습한 단어 수 | -| wordsReviewed | 복습한 단어 수 | -| currentStreak | 현재 연속 학습일 | -| longestStreak | 최장 연속 학습일 | -| gamesPlayed | 참여한 게임 수 | -| gamesWon | 1등 횟수 | -| totalGameScore | 누적 게임 점수 | - -**DynamoDB Streams 기반 비동기 집계:** - -- 테스트 결과 저장 시 자동 트리거 -- API 응답과 분리되어 응답 속도 향상 - ---- - -### 2.5 Badge Domain (배지 시스템) - -```mermaid -flowchart TB - subgraph BadgeSystem["배지 시스템"] - TRIGGER[통계 업데이트] --> CHECK[배지 조건 체크] - CHECK --> AWARD{조건 달성?} - AWARD -->|Yes| SAVE[배지 부여 + 저장] - AWARD -->|No| END[종료] - SAVE --> NOTIFY[프론트엔드 조회] - end -``` - -**배지 종류:** - -| Badge Type | 이름 | 조건 | -|----------------------|---------|------------| -| FIRST_STEP | 첫 걸음 | 첫 학습 완료 | -| STREAK_3, 7, 30 | 연속 학습 | N일 연속 학습 | -| WORDS_100, 500, 1000 | 단어 학습 | N개 단어 학습 | -| PERFECT_SCORE | 완벽주의자 | 테스트 만점 | -| ACCURACY_90 | 정확도 달인 | 전체 정확도 90% | -| GAME_FIRST_PLAY | 첫 게임 | 첫 게임 참여 | -| GAME_10_WINS | 게임 10승 | 10번 1등 | -| QUICK_GUESSER | 번개 정답 | 5초 내 정답 | -| PERFECT_DRAWER | 완벽한 출제자 | 전원 정답 유도 | - -**기술적 특징:** - -- S3 Presigned URL로 배지 이미지 제공 (1시간 유효) -- 획득/미획득 배지 + 진행도 표시 - ---- - -## 3. 기술적 성과 - -### 3.1 아키텍처 패턴 - -| 패턴 | 적용 영역 | 효과 | -|------------------|----------|----------------------------| -| **CQRS** | 전 도메인 | 읽기/쓰기 책임 분리, 테스트 용이성 | -| **State** | 단어 학습 상태 | 복잡한 조건문 제거, 확장성 | -| **Factory** | AI 서비스 | 서비스 교체 용이 (Claude ↔ Llama) | -| **Event-Driven** | 통계/배지 | 느슨한 결합, 비동기 처리 | - -### 3.2 DynamoDB 설계 - -**Single Table Design:** - -- Vocab Table: 단어, 사용자단어, 테스트, 일일학습, 통계, 배지, 문법 -- Chat Table: 채팅방, 메시지, 연결, 게임라운드 - -**GSI 구성:** - -| GSI | 용도 | -|------|---------------------| -| GSI1 | 레벨별 단어 조회, 복습 예정 단어 | -| GSI2 | 카테고리별 단어, 상태별 사용자단어 | -| GSI3 | 북마크 단어 조회 | - -### 3.3 보안 - -- Cognito 인증 (idToken) -- WebSocket RoomToken 인증 (TTL 5분) -- BCrypt 비밀방 암호화 -- S3 Presigned URL (배지 이미지) - -### 3.4 성능 최적화 - -| 최적화 | 효과 | -|--------------------------|-------------------------| -| TTS S3 캐싱 | Polly API 호출 90% 절감 | -| 배치 처리 | 최대 100개 단어 일괄 처리 | -| Strongly Consistent Read | 데이터 정합성 보장 | -| DynamoDB Streams | 비동기 통계 집계로 응답 속도 50% 향상 | -| AI 스트리밍 | 체감 대기 시간 80% 감소 | - ---- - -## 4. API 엔드포인트 요약 - -### REST API (https://gc8l9ijhzc.execute-api.ap-northeast-2.amazonaws.com/dev) - -| Method | Path | 설명 | -|--------|-------------------------------------|-----------| -| GET | /vocab/words | 단어 목록 조회 | -| POST | /vocab/words | 단어 등록 | -| GET | /vocab/daily | 오늘의 학습 단어 | -| POST | /vocab/daily/words/{wordId}/learned | 단어 학습 완료 | -| POST | /vocab/tests | 테스트 생성 | -| POST | /vocab/tests/{testId}/submit | 테스트 제출 | -| GET | /stats/daily | 일별 통계 | -| GET | /stats/weekly | 주별 통계 | -| GET | /stats/monthly | 월별 통계 | -| GET | /stats/total | 전체 통계 | -| GET | /stats/history?limit=100 | 통계 히스토리 | -| GET | /badges | 전체 배지 목록 | -| GET | /badges/earned | 획득한 배지 | -| GET | /rooms | 채팅방 목록 | -| POST | /rooms | 채팅방 생성 | -| POST | /rooms/{roomId}/join | 채팅방 입장 | -| POST | /grammar/check | 문법 체크 | - -### WebSocket API - -| Endpoint | 설명 | -|---------------------------------------------------------------|---------| -| wss://t378dif43l.execute-api.ap-northeast-2.amazonaws.com/dev | 채팅/게임 | -| wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev | 문법 스트리밍 | - ---- - -## 5. 프로젝트 구조 - -``` -ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/ -├── common/ # 공통 모듈 -│ ├── config/ # AWS 클라이언트 (싱글톤) -│ ├── router/ # HandlerRouter, Route -│ ├── exception/ # 예외 처리 체계 -│ ├── dto/ # PaginatedResult, ErrorInfo -│ └── util/ # ResponseGenerator, CursorUtil -│ -├── domain/ -│ ├── vocabulary/ # 단어 학습 도메인 -│ │ ├── handler/ # Word, UserWord, Test, DailyStudy 핸들러 -│ │ ├── service/ # CQRS 서비스 (Command/Query) -│ │ ├── repository/ # DynamoDB 레포지토리 -│ │ ├── model/ # Word, UserWord, TestResult, DailyStudy -│ │ └── state/ # NEW, LEARNING, REVIEWING, MASTERED -│ │ -│ ├── chatting/ # 채팅 도메인 -│ │ ├── handler/ # REST + WebSocket 핸들러 -│ │ ├── service/ # ChatRoom, Game, Command 서비스 -│ │ └── model/ # ChatRoom, Connection, GameRound -│ │ -│ ├── grammar/ # 문법 체크 도메인 -│ │ ├── handler/ # REST + 스트리밍 핸들러 -│ │ ├── service/ # GrammarCheck, Conversation 서비스 -│ │ └── factory/ # BedrockGrammarCheckFactory -│ │ -│ ├── stats/ # 통계 도메인 -│ │ ├── handler/ # UserStats, Streams 핸들러 -│ │ └── repository/ # UserStatsRepository -│ │ -│ └── badge/ # 배지 도메인 -│ ├── handler/ # BadgeHandler -│ └── service/ # BadgeService -``` - ---- - -## 6. 성과 요약 - -| 카테고리 | 성과 | -|------------------|------------------------------------| -| **Lambda 함수** | 26개 | -| **API 엔드포인트** | REST 40+, WebSocket 2 | -| **DynamoDB 테이블** | 2개 (Single Table Design) | -| **GSI** | 5개 | -| **아키텍처 패턴** | CQRS, State, Factory, Event-Driven | -| **AI 연동** | Bedrock Claude 3.5 Sonnet (문법/대화) | -| **TTS** | AWS Polly (남성/여성 음성) | -| **실시간 통신** | WebSocket (채팅/게임/문법 스트리밍) | -| **인증** | Cognito + RoomToken | - ---- - -**작성일:** 2026-01-16 -**팀:** MZC 2nd Project Team / SMJ diff --git a/docs/domain-reports/BADGE-DOMAIN-REPORT.md b/docs/domain-reports/BADGE-DOMAIN-REPORT.md deleted file mode 100644 index 4cd58215..00000000 --- a/docs/domain-reports/BADGE-DOMAIN-REPORT.md +++ /dev/null @@ -1,681 +0,0 @@ -# Badge Domain 세부 보고서 - -## 1. 개요 - -Badge 도메인은 사용자의 학습 성취도에 따라 배지를 자동으로 부여하는 시스템입니다. 이벤트 기반 아키텍처를 통해 Stats, Vocabulary, Chatting 도메인과 연동되어 실시간으로 배지를 체크하고 -부여합니다. - ---- - -## 2. 전체 아키텍처 - -```mermaid -flowchart TB - subgraph Triggers["트리거 소스"] - TEST[테스트 완료
DynamoDB Streams] - WORD[단어 학습
Write-through] - GAME[게임 종료
Service Method] - end - - subgraph Processing["Badge 처리"] - CHECK[BadgeService
조건 체크] - AWARD[배지 부여] - end - - subgraph Storage["저장소"] - DDB[(DynamoDB
UserBadge)] - S3[(S3
배지 이미지)] - end - - subgraph Query["조회"] - API[BadgeHandler
REST API] - PRESIGN[S3 Presigned URL] - end - - TEST --> CHECK - WORD --> CHECK - GAME --> CHECK - CHECK --> AWARD - AWARD --> DDB - DDB --> API - S3 --> PRESIGN - PRESIGN --> API -``` - ---- - -## 3. 배지 종류 - -### 3.1 배지 카테고리 - -```mermaid -mindmap - root((배지 시스템)) - 학습 - FIRST_STEP[첫 걸음] - WORDS_100[단어 수집가] - WORDS_500[단어 전문가] - WORDS_1000[단어 마스터] - 연속학습 - STREAK_3[3일 연속] - STREAK_7[7일 연속] - STREAK_30[30일 연속] - 테스트 - PERFECT_SCORE[완벽주의자] - TEST_10[테스트 도전자] - ACCURACY_90[정확도 달인] - 게임 - GAME_FIRST[첫 게임] - GAME_10_WINS[10승 달성] - QUICK_GUESSER[번개 정답] - PERFECT_DRAWER[완벽한 출제자] - 최종 - MASTER[학습 마스터] -``` - -### 3.2 배지 상세 - -| Badge Type | 이름 | 설명 | 카테고리 | 조건 | -|-----------------|-----------|--------------------|-----------------|-----------------------| -| FIRST_STEP | 첫 걸음 | 첫 학습을 완료했습니다 | FIRST_STUDY | testsCompleted >= 1 | -| STREAK_3 | 3일 연속 학습 | 3일 연속으로 학습했습니다 | STREAK | currentStreak >= 3 | -| STREAK_7 | 일주일 연속 학습 | 7일 연속으로 학습했습니다 | STREAK | currentStreak >= 7 | -| STREAK_30 | 한 달 연속 학습 | 30일 연속으로 학습했습니다 | STREAK | currentStreak >= 30 | -| WORDS_100 | 단어 수집가 | 100개의 단어를 학습했습니다 | WORDS_LEARNED | totalWords >= 100 | -| WORDS_500 | 단어 전문가 | 500개의 단어를 학습했습니다 | WORDS_LEARNED | totalWords >= 500 | -| WORDS_1000 | 단어 마스터 | 1000개의 단어를 학습했습니다 | WORDS_LEARNED | totalWords >= 1000 | -| PERFECT_SCORE | 완벽주의자 | 테스트에서 만점을 받았습니다 | PERFECT_TEST | incorrectAnswers == 0 | -| TEST_10 | 테스트 도전자 | 10회의 테스트를 완료했습니다 | TESTS_COMPLETED | testsCompleted >= 10 | -| ACCURACY_90 | 정확도 달인 | 전체 정확도 90%를 달성했습니다 | ACCURACY | successRate >= 90 | -| GAME_FIRST_PLAY | 첫 게임 | 첫 게임에 참여했습니다 | GAMES_PLAYED | gamesPlayed >= 1 | -| GAME_10_WINS | 게임 10승 | 게임에서 10번 1등을 했습니다 | GAMES_WON | gamesWon >= 10 | -| QUICK_GUESSER | 번개 정답 | 5초 내에 정답을 맞췄습니다 | QUICK_GUESSES | quickGuesses >= 1 | -| PERFECT_DRAWER | 완벽한 출제자 | 출제 시 전원이 정답을 맞췄습니다 | PERFECT_DRAWS | perfectDraws >= 1 | -| MASTER | 학습 마스터 | 모든 업적을 달성했습니다 | ALL_BADGES | 모든 배지 획득 | - ---- - -## 4. 배지 부여 흐름 - -### 4.1 테스트 완료 시 - -```mermaid -sequenceDiagram - participant Test as TestResult - participant Streams as DynamoDB Streams - participant Handler as StatsStreamHandler - participant Stats as UserStats - participant Badge as BadgeService - participant DB as DynamoDB - Test ->> Streams: INSERT 이벤트 - Streams ->> Handler: 트리거 - Handler ->> Stats: incrementTestStats() - Handler ->> Stats: updateStudyStreak() - Note over Handler: 만점 체크 - alt 정답 > 0 && 오답 == 0 - Handler ->> Badge: awardBadge("PERFECT_SCORE") - Badge ->> DB: UserBadge 저장 - end - - Handler ->> Stats: findTotalStats() - Stats -->> Handler: UserStats - Handler ->> Badge: checkAndAwardBadges() - Badge ->> Badge: 각 배지 조건 체크 - Badge ->> DB: 획득 배지 저장 -``` - -### 4.2 단어 학습 시 - -```mermaid -sequenceDiagram - participant API as DailyStudyHandler - participant Service as DailyStudyCommandService - participant Stats as UserStatsRepository - participant Badge as BadgeService - participant DB as DynamoDB - API ->> Service: markWordLearned() - Service ->> Stats: incrementWordsLearned() - Note over Service: 배지 체크 (WORDS_xxx) - Service ->> Stats: findTotalStats() - Stats -->> Service: UserStats - Service ->> Badge: checkAndAwardBadges() - Badge ->> Badge: WORDS_100, 500, 1000 체크 - Badge ->> DB: 획득 배지 저장 -``` - -### 4.3 게임 종료 시 - -```mermaid -sequenceDiagram - participant Game as GameService - participant Stats as GameStatsService - participant Repo as UserStatsRepository - participant Badge as BadgeService - participant DB as DynamoDB - Game ->> Stats: updateGameStats(room) - - loop 각 참가자 - Stats ->> Stats: 점수 집계 - Note over Stats: correctGuesses
quickGuesses (5초 이내)
perfectDraws - Stats ->> Repo: incrementGameStats() - Stats ->> Repo: findTotalStats() - Repo -->> Stats: UserStats - Stats ->> Badge: checkAndAwardBadges() - Badge ->> Badge: GAME_xxx 배지 체크 - Badge ->> DB: 획득 배지 저장 - end -``` - ---- - -## 5. 배지 조건 체크 로직 - -### 5.1 카테고리별 조건 - -```mermaid -flowchart TB - START[checkAndAwardBadges] --> LOOP{모든 BadgeType 순회} - LOOP --> EARNED{이미 획득?} - EARNED -->|Yes| SKIP[건너뛰기] - EARNED -->|No| CHECK[조건 체크] - CHECK --> SWITCH{카테고리} - SWITCH -->|FIRST_STUDY| FS[testsCompleted >= 1] - SWITCH -->|STREAK| ST[currentStreak >= threshold] - SWITCH -->|WORDS_LEARNED| WL[totalWords >= threshold] - SWITCH -->|PERFECT_TEST| PT[별도 처리] - SWITCH -->|TESTS_COMPLETED| TC[testsCompleted >= threshold] - SWITCH -->|ACCURACY| AC[successRate >= threshold] - SWITCH -->|GAMES_PLAYED| GP[gamesPlayed >= threshold] - SWITCH -->|GAMES_WON| GW[gamesWon >= threshold] - SWITCH -->|QUICK_GUESSES| QG[quickGuesses >= threshold] - SWITCH -->|PERFECT_DRAWS| PD[perfectDraws >= threshold] - SWITCH -->|ALL_BADGES| AB[모든 배지 획득 체크] - FS --> RESULT{조건 충족?} - ST --> RESULT - WL --> RESULT - TC --> RESULT - AC --> RESULT - GP --> RESULT - GW --> RESULT - QG --> RESULT - PD --> RESULT - RESULT -->|Yes| AWARD[배지 부여] - RESULT -->|No| SKIP - AWARD --> LOOP - SKIP --> LOOP -``` - -### 5.2 Switch Expression 패턴 - -```java -private boolean checkBadgeCondition(BadgeType type, UserStats stats) { - return switch (type.getCategory()) { - case "FIRST_STUDY" -> stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1; - - case "STREAK" -> stats.getCurrentStreak() != null && - stats.getCurrentStreak() >= type.getThreshold(); - - case "WORDS_LEARNED" -> { - int total = (stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0) - + (stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0); - yield total >= type.getThreshold(); - } - - case "ACCURACY" -> { - if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) - yield false; - double accuracy = (stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered(); - yield accuracy >= type.getThreshold(); - } - - case "TESTS_COMPLETED" -> stats.getTestsCompleted() != null && - stats.getTestsCompleted() >= type.getThreshold(); - - case "GAMES_PLAYED" -> stats.getGamesPlayed() != null && - stats.getGamesPlayed() >= type.getThreshold(); - - case "GAMES_WON" -> stats.getGamesWon() != null && - stats.getGamesWon() >= type.getThreshold(); - - case "QUICK_GUESSES" -> stats.getQuickGuesses() != null && - stats.getQuickGuesses() >= type.getThreshold(); - - case "PERFECT_DRAWS" -> stats.getPerfectDraws() != null && - stats.getPerfectDraws() >= type.getThreshold(); - - case "PERFECT_TEST" -> false; // 별도 처리 (StatsStreamHandler) - case "ALL_BADGES" -> false; // 특수 로직 필요 - - default -> false; - }; -} -``` - ---- - -## 6. API 엔드포인트 - -### 6.1 REST API - -| Method | Endpoint | 설명 | 응답 | -|--------|----------------|----------------|-------------| -| GET | /badges | 전체 배지 목록 + 진행도 | BadgeInfo[] | -| GET | /badges/earned | 획득한 배지만 조회 | UserBadge[] | - -### 6.2 전체 배지 조회 응답 - -```json -{ - "message": "Badges retrieved", - "data": { - "badges": [ - { - "badgeType": "FIRST_STEP", - "name": "첫 걸음", - "description": "첫 학습을 완료했습니다", - "imageUrl": "https://...presigned.../badges/first_step.png", - "category": "FIRST_STUDY", - "threshold": 1, - "progress": 1, - "earned": true, - "earnedAt": "2026-01-16T10:30:45.123Z" - }, - { - "badgeType": "WORDS_100", - "name": "단어 수집가", - "description": "100개의 단어를 학습했습니다", - "imageUrl": "https://...presigned.../badges/words_100.png", - "category": "WORDS_LEARNED", - "threshold": 100, - "progress": 45, - "earned": false, - "earnedAt": null - } - ], - "totalCount": 16, - "earnedCount": 8 - } -} -``` - -### 6.3 획득 배지 조회 응답 - -```json -{ - "message": "Earned badges retrieved", - "data": { - "badges": [ - { - "badgeType": "FIRST_STEP", - "name": "첫 걸음", - "description": "첫 학습을 완료했습니다", - "imageUrl": "https://...presigned.../badges/first_step.png", - "category": "FIRST_STUDY", - "threshold": 1, - "progress": 1, - "earnedAt": "2026-01-16T10:30:45.123Z" - } - ], - "count": 8 - } -} -``` - ---- - -## 7. 데이터 모델 - -### 7.1 UserBadge - -```java - -@DynamoDbBean -public class UserBadge { - // 기본 키 - String pk; // USER#{userId}#BADGE - String sk; // BADGE#{badgeType} - - // GSI (전체 배지 조회) - String gsi1pk; // BADGE#ALL - String gsi1sk; // EARNED#{earnedAt} - - // 메타데이터 - String odUserId; - String badgeType; // BadgeType enum 이름 - String name; - String description; - String imageUrl; - String category; - Integer threshold; - Integer progress; // 획득 시점 진행도 - - // 타임스탬프 - String earnedAt; - String createdAt; -} -``` - -### 7.2 DynamoDB 키 구조 - -| 필드 | 패턴 | 예시 | -|--------|---------------------|-----------------------------| -| PK | USER#{userId}#BADGE | USER#abc123#BADGE | -| SK | BADGE#{badgeType} | BADGE#STREAK_7 | -| GSI1PK | BADGE#ALL | BADGE#ALL | -| GSI1SK | EARNED#{earnedAt} | EARNED#2026-01-16T10:30:45Z | - -### 7.3 BadgeType Enum - -```java -public enum BadgeType { - FIRST_STEP("첫 걸음", "첫 학습을 완료했습니다", - "FIRST_STUDY", 1, "first_step.png"), - STREAK_3("3일 연속 학습", "3일 연속으로 학습했습니다", - "STREAK", 3, "streak_3.png"), - STREAK_7("일주일 연속 학습", "7일 연속으로 학습했습니다", - "STREAK", 7, "streak_7.png"), - // ... 생략 - MASTER("학습 마스터", "모든 업적을 달성했습니다", - "ALL_BADGES", 1, "master.png"); - - private final String name; - private final String description; - private final String category; - private final int threshold; - private final String imageFile; -} -``` - ---- - -## 8. 진행도 계산 - -### 8.1 카테고리별 진행도 - -```mermaid -flowchart TB - subgraph Progress["진행도 계산"] - FIRST["FIRST_STUDY
testsCompleted >= 1 ? 1 : 0"] - STREAK["STREAK
currentStreak"] - WORDS["WORDS_LEARNED
newWords + reviewed"] - TESTS["TESTS_COMPLETED
testsCompleted"] - ACC["ACCURACY
successRate (%)"] - GAMES["GAMES_PLAYED
gamesPlayed"] - WINS["GAMES_WON
gamesWon"] - QUICK["QUICK_GUESSES
quickGuesses"] - PERFECT["PERFECT_DRAWS
perfectDraws"] - end -``` - -### 8.2 calculateProgress 메서드 - -```java -private int calculateProgress(BadgeType type, UserStats stats) { - return switch (type.getCategory()) { - case "FIRST_STUDY" -> (stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1) ? 1 : 0; - - case "STREAK" -> stats.getCurrentStreak() != null ? stats.getCurrentStreak() : 0; - - case "WORDS_LEARNED" -> { - int newWords = stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0; - int reviewed = stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0; - yield newWords + reviewed; - } - - case "TESTS_COMPLETED" -> stats.getTestsCompleted() != null ? stats.getTestsCompleted() : 0; - - case "ACCURACY" -> { - if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) - yield 0; - yield (int) ((stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered()); - } - - case "GAMES_PLAYED" -> stats.getGamesPlayed() != null ? stats.getGamesPlayed() : 0; - - case "GAMES_WON" -> stats.getGamesWon() != null ? stats.getGamesWon() : 0; - - case "QUICK_GUESSES" -> stats.getQuickGuesses() != null ? stats.getQuickGuesses() : 0; - - case "PERFECT_DRAWS" -> stats.getPerfectDraws() != null ? stats.getPerfectDraws() : 0; - - default -> 0; - }; -} -``` - ---- - -## 9. 멱등성 보장 - -### 9.1 중복 부여 방지 흐름 - -```mermaid -flowchart TB - START[checkAndAwardBadges] --> LOOP[배지 타입 순회] - LOOP --> CHECK{hasBadge?} - CHECK -->|이미 있음| SKIP[건너뛰기] - CHECK -->|없음| CONDITION{조건 충족?} - CONDITION -->|Yes| CREATE[배지 생성] - CONDITION -->|No| SKIP - CREATE --> SAVE[DynamoDB 저장] - SAVE --> LOOP - SKIP --> LOOP -``` - -### 9.2 구현 코드 - -```java -public List checkAndAwardBadges(String userId, UserStats stats) { - List newBadges = new ArrayList<>(); - String now = Instant.now().toString(); - - for (BadgeType type : BadgeType.values()) { - // 1. 이미 획득한 배지는 건너뛰기 - if (badgeRepository.hasBadge(userId, type.name())) { - continue; - } - - // 2. 조건 체크 - if (checkBadgeCondition(type, stats)) { - // 3. 배지 생성 및 저장 - UserBadge badge = createBadge(userId, type, now); - badgeRepository.save(badge); - newBadges.add(badge); - } - } - - return newBadges; -} -``` - ---- - -## 10. S3 이미지 연동 - -### 10.1 Presigned URL 생성 - -```mermaid -flowchart LR - REQ[배지 조회] --> SERVICE[BadgeService] - SERVICE --> PRESIGN[S3PresignUtil] - PRESIGN --> CACHE{캐시 확인} - CACHE -->|있음| RETURN[URL 반환] - CACHE -->|없음| GENERATE[Presigned URL 생성] - GENERATE --> SAVE[캐시 저장] - SAVE --> RETURN -``` - -### 10.2 이미지 URL 생성 - -```java -// S3PresignUtil.java -public static String getBadgeImageUrl(String imageFile) { - return getPresignedUrl("badges/" + imageFile); -} - -// BadgeService - 배지 생성 시 -private UserBadge createBadge(String userId, BadgeType type, String now) { - return UserBadge.builder() - .pk(BadgeKey.userBadgePk(userId)) - .sk(BadgeKey.badgeSk(type.name())) - .gsi1pk(BadgeKey.BADGE_ALL) - .gsi1sk(BadgeKey.earnedSk(now)) - .odUserId(userId) - .badgeType(type.name()) - .name(type.getName()) - .description(type.getDescription()) - .imageUrl(S3PresignUtil.getBadgeImageUrl(type.getImageFile())) - .category(type.getCategory()) - .threshold(type.getThreshold()) - .earnedAt(now) - .createdAt(now) - .build(); -} -``` - -### 10.3 S3 버킷 구조 - -``` -s3://group2-englishstudy/ -└── badges/ - ├── first_step.png - ├── streak_3.png - ├── streak_7.png - ├── streak_30.png - ├── words_100.png - ├── words_500.png - ├── words_1000.png - ├── perfect_score.png - ├── test_10.png - ├── accuracy_90.png - ├── game_first.png - ├── game_10_wins.png - ├── quick_guesser.png - ├── perfect_drawer.png - └── master.png -``` - ---- - -## 11. Stats 도메인 연동 - -### 11.1 연동 포인트 - -```mermaid -flowchart TB - subgraph Stats["Stats 도메인"] - STREAM[StatsStreamHandler] - DAILY[DailyStudyCommandService] - GAME[GameStatsService] - REPO[UserStatsRepository] - end - - subgraph Badge["Badge 도메인"] - SERVICE[BadgeService] - BADGEREPO[BadgeRepository] - end - - STREAM -->|checkAndAwardBadges| SERVICE - DAILY -->|checkWordsBadge| SERVICE - GAME -->|checkAndAwardBadges| SERVICE - SERVICE -->|hasBadge, save| BADGEREPO - SERVICE -->|findTotalStats| REPO -``` - -### 11.2 UserStats 필드와 배지 매핑 - -| UserStats 필드 | 배지 | -|------------------------------------|----------------------------------| -| testsCompleted | FIRST_STEP, TEST_10 | -| currentStreak | STREAK_3, STREAK_7, STREAK_30 | -| newWordsLearned + wordsReviewed | WORDS_100, WORDS_500, WORDS_1000 | -| correctAnswers / questionsAnswered | ACCURACY_90 | -| gamesPlayed | GAME_FIRST_PLAY | -| gamesWon | GAME_10_WINS | -| quickGuesses | QUICK_GUESSER | -| perfectDraws | PERFECT_DRAWER | - ---- - -## 12. 파일 구조 - -``` -domain/badge/ -├── enums/ -│ └── BadgeType.java # 16가지 배지 정의 -├── constants/ -│ └── BadgeKey.java # DynamoDB 키 생성 -├── model/ -│ └── UserBadge.java # 배지 엔티티 -├── repository/ -│ └── BadgeRepository.java # CRUD 연산 -├── service/ -│ └── BadgeService.java # 조건 체크, 배지 부여 -└── handler/ - └── BadgeHandler.java # REST API - -연동 파일: -├── domain/stats/handler/StatsStreamHandler.java -├── domain/vocabulary/service/DailyStudyCommandService.java -└── domain/chatting/service/GameStatsService.java -``` - ---- - -## 13. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **Database:** DynamoDB (Single Table Design) -- **Storage:** S3 (배지 이미지) -- **Event:** DynamoDB Streams, Write-through, Service Method -- **Pattern:** Event-driven, Idempotent, Switch Expression -- **Java 21 Features:** Enhanced Switch, Yield Statement - ---- - -## 14. 배지 획득 시나리오 - -### 14.1 시나리오 예시 - -```mermaid -flowchart LR - subgraph Day1["1일차"] - A1[테스트 완료] --> B1["FIRST_STEP 획득"] - end - - subgraph Day3["3일차"] - A3[3일 연속 학습] --> B3["STREAK_3 획득"] - end - - subgraph Day7["7일차"] - A7[7일 연속 학습] --> B7["STREAK_7 획득"] - A7_2[100단어 학습] --> B7_2["WORDS_100 획득"] - end - - subgraph Game["게임"] - G1[5초 내 정답] --> G2["QUICK_GUESSER 획득"] - G3[10회 1등] --> G4["GAME_10_WINS 획득"] - end -``` - -### 14.2 특수 배지 획득 조건 - -**PERFECT_SCORE (완벽주의자):** - -- 테스트 제출 시 오답 0개이면 즉시 부여 -- StatsStreamHandler에서 별도 처리 - -**QUICK_GUESSER (번개 정답):** - -- 게임 중 5초(5000ms) 이내 정답 시 -- GameStatsService에서 quickGuesses 카운트 - -**PERFECT_DRAWER (완벽한 출제자):** - -- 출제 시 모든 참가자가 정답을 맞춘 경우 -- 라운드 종료 시 endReason == "ALL_CORRECT"이면 카운트 - -**MASTER (학습 마스터):** - -- 다른 모든 배지를 획득한 경우 -- 특수 로직으로 모든 배지 보유 여부 확인 diff --git a/docs/domain-reports/CHATTING-DOMAIN-REPORT.md b/docs/domain-reports/CHATTING-DOMAIN-REPORT.md deleted file mode 100644 index c27eb552..00000000 --- a/docs/domain-reports/CHATTING-DOMAIN-REPORT.md +++ /dev/null @@ -1,434 +0,0 @@ -# Chatting Domain 세부 보고서 - -## 1. 개요 - -Chatting 도메인은 실시간 채팅과 캐치마인드 게임 기능을 제공하는 WebSocket 기반 시스템입니다. AWS API Gateway WebSocket과 Lambda를 활용하여 실시간 양방향 통신을 구현했습니다. - ---- - -## 2. 전체 아키텍처 - -```mermaid -flowchart TB - subgraph Client["클라이언트"] - APP[Mobile/Web App] - end - - subgraph Gateway["API Gateway"] - REST[REST API] - WS[WebSocket API] - end - - subgraph Lambda["Lambda Handlers"] - direction TB - ROOM[ChatRoomHandler] - MSG[ChatMessageHandler] - GAME[GameHandler] - VOICE[ChatVoiceHandler] - CONNECT[WebSocketConnectHandler] - DISCONNECT[WebSocketDisconnectHandler] - MESSAGE[WebSocketMessageHandler] - end - - subgraph Storage["데이터 저장소"] - DDB[(DynamoDB)] - S3[(S3 - 음성 캐시)] - end - - APP --> REST - APP <--> WS - REST --> ROOM - REST --> MSG - REST --> GAME - REST --> VOICE - WS --> CONNECT - WS --> DISCONNECT - WS --> MESSAGE - ROOM --> DDB - MSG --> DDB - GAME --> DDB - MESSAGE --> DDB - VOICE --> S3 -``` - ---- - -## 3. 채팅방 시스템 - -### 3.1 채팅방 입장 흐름 - -```mermaid -sequenceDiagram - participant Client - participant REST as REST API - participant WS as WebSocket API - participant DB as DynamoDB - Note over Client, DB: Phase 1 - 방 입장 및 토큰 발급 - Client ->> REST: POST /rooms/{roomId}/join - REST ->> DB: 비밀번호 검증 (비밀방인 경우) - REST ->> DB: RoomToken 저장 (TTL 5분) - REST -->> Client: roomToken 반환 - Note over Client, DB: Phase 2 - WebSocket 연결 - Client ->> WS: $connect?roomToken={token} - WS ->> DB: 토큰 검증 - WS ->> DB: Connection 저장 (TTL 10분) - WS -->> Client: 연결 성공 - Note over Client, DB: Phase 3 - 실시간 메시지 - Client ->> WS: sendMessage (채팅) - WS ->> DB: 메시지 저장 - WS -->> Client: 브로드캐스트 (같은 방 전체) -``` - -### 3.2 REST API 엔드포인트 - -| Method | Endpoint | 설명 | 인증 | -|--------|-------------------------------|---------------------------|----| -| POST | /chat/rooms | 채팅방 생성 | O | -| GET | /chat/rooms | 채팅방 목록 (level, joined 필터) | O | -| GET | /chat/rooms/{roomId} | 채팅방 상세 | O | -| POST | /chat/rooms/{roomId}/join | 채팅방 입장 (토큰 발급) | O | -| POST | /chat/rooms/{roomId}/leave | 채팅방 퇴장 | O | -| DELETE | /chat/rooms/{roomId} | 채팅방 삭제 (방장만) | O | -| GET | /chat/rooms/{roomId}/messages | 메시지 히스토리 | O | - -### 3.3 WebSocket 이벤트 - -| Route | 설명 | Payload | -|-------------|------------|------------------------------------------| -| $connect | 연결 (토큰 검증) | ?roomToken={token} | -| $disconnect | 연결 해제 | - | -| sendMessage | 메시지 전송 | { roomId, userId, content, messageType } | - ---- - -## 4. 캐치마인드 게임 시스템 - -### 4.1 게임 흐름 - -```mermaid -flowchart TB - subgraph GameFlow["캐치마인드 게임 흐름"] - START["/game 명령어"] --> INIT["게임 초기화
출제자 순서 셔플"] - INIT --> ROUND["라운드 시작
출제자 + 단어 선정"] - ROUND --> DRAW["출제자 그림 그리기
(DRAWING 메시지)"] - DRAW --> GUESS["참가자 정답 입력"] - GUESS --> CHECK{정답?} - CHECK -->|Yes| SCORE["점수 계산
시간보너스 + 연속보너스"] - CHECK -->|No| GUESS - SCORE --> ALLCORRECT{전원 정답?} - ALLCORRECT -->|Yes| NEXTROUND - ALLCORRECT -->|No| TIMEOUT{시간 초과?} - TIMEOUT -->|Yes| NEXTROUND["다음 라운드"] - TIMEOUT -->|No| GUESS - NEXTROUND --> LASTROUND{마지막 라운드?} - LASTROUND -->|Yes| END["게임 종료
순위 발표"] - LASTROUND -->|No| ROUND - end -``` - -### 4.2 게임 API - -| Method | Endpoint | 설명 | -|--------|----------------------------------|-------------| -| POST | /chat/rooms/{roomId}/game/start | 게임 시작 (방장만) | -| POST | /chat/rooms/{roomId}/game/stop | 게임 중지 | -| GET | /chat/rooms/{roomId}/game/status | 게임 상태 조회 | -| GET | /chat/rooms/{roomId}/game/scores | 점수판 조회 | - -### 4.3 슬래시 명령어 - -| 명령어 | 설명 | 사용 가능 | -|---------|----------------|--------| -| /start | 게임 시작 | 방장 | -| /stop | 게임 중지 | 방장/시작자 | -| /score | 점수판 보기 | 전체 | -| /member | 접속자 수 | 전체 | -| /hint | 힌트 제공 (첫글자○○○) | 출제자 | -| /skip | 라운드 스킵 | 출제자 | -| /help | 명령어 도움말 | 전체 | - -### 4.4 점수 계산 공식 - -``` -점수 = 기본점수(10) + 시간보너스 + 연속보너스 + 출제자보너스 - -- 시간보너스: (60 - 경과초) × 0.5 -- 연속보너스: streak × 2 -- 출제자보너스: 정답자당 5점 -``` - -**예시:** - -- 30초에 정답 + 연속 3회: 10 + 15 + 6 = 31점 -- 출제자가 3명 맞출 경우: 5 × 3 = 15점 - -### 4.5 게임 상태 - -```mermaid -stateDiagram-v2 - [*] --> NONE: 대기 - NONE --> PLAYING: /start 명령어 - PLAYING --> ROUND_END: 시간초과/전원정답 - ROUND_END --> PLAYING: 다음 라운드 - ROUND_END --> FINISHED: 마지막 라운드 - PLAYING --> FINISHED: /stop 명령어 - FINISHED --> [*]: 게임 종료 -``` - ---- - -## 5. WebSocket 메시지 타입 - -### 5.1 채팅 메시지 - -| Type | 설명 | 저장 | -|-------------|-------|----| -| TEXT | 일반 채팅 | O | -| IMAGE | 이미지 | O | -| VOICE | 음성 | O | -| AI_RESPONSE | AI 응답 | O | - -### 5.2 게임 메시지 - -| Type | 설명 | 저장 | -|----------------|--------------|----| -| DRAWING | 그림 데이터 (실시간) | X | -| DRAWING_CLEAR | 그림 지우기 | X | -| GUESS | 오답 추측 | X | -| CORRECT_ANSWER | 정답 알림 | X | -| SCORE_UPDATE | 점수 갱신 | X | -| GAME_START | 게임 시작 | X | -| ROUND_START | 라운드 시작 | X | -| ROUND_END | 라운드 종료 | X | -| GAME_END | 게임 종료 | X | -| HINT | 힌트 | X | - -### 5.3 실시간 점수 업데이트 메시지 - -```json -{ - "messageType": "SCORE_UPDATE", - "roomId": "uuid", - "scorerId": "user123", - "scoreGained": 25, - "ranking": [ - { - "rank": 1, - "userId": "user123", - "score": 85, - "change": 25 - }, - { - "rank": 2, - "userId": "user456", - "score": 60, - "change": 0 - } - ], - "currentRound": 3, - "totalRounds": 5 -} -``` - ---- - -## 6. 데이터 모델 - -### 6.1 ChatRoom - -```java - -@DynamoDbBean -public class ChatRoom { - // 기본 정보 - String roomId, name, description; - String level; // beginner, intermediate, advanced - Integer currentMembers, maxMembers; - Boolean isPrivate; - String password; // BCrypt 암호화 - String createdBy; // 방장 - List memberIds; - - // 게임 상태 - String gameStatus; // NONE, PLAYING, ROUND_END, FINISHED - Integer currentRound, totalRounds; - String currentDrawerId, currentWord; - Long roundStartTime; - Integer roundTimeLimit; // 60초 - List drawerOrder; - Map scores; - Map streaks; - List correctGuessers; - Boolean hintUsed; -} -``` - -**DynamoDB Keys:** - -- PK: `ROOM#{roomId}` | SK: `METADATA` -- GSI1: `ROOMS` | `{level}#{createdAt}` (레벨별 최신순) - -### 6.2 Connection - -```java - -@DynamoDbBean -public class Connection { - String connectionId; // API Gateway 연결 ID - String userId; - String roomId; - Long ttl; // 10분 (자동 삭제) -} -``` - -**DynamoDB Keys:** - -- PK: `CONN#{connectionId}` | SK: `METADATA` -- GSI1: `ROOM#{roomId}` | `CONN#{connectionId}` (방별 연결) -- GSI2: `USER#{userId}` | `CONN#{connectionId}` (사용자별 연결) - -### 6.3 GameRound - -```java - -@DynamoDbBean -public class GameRound { - Integer roundNumber; - String drawerId, word, wordEnglish; - List correctGuessers; - Map guessTimes; // 정답까지 걸린 시간 - Map roundScores; - Long startTime, endTime; - String endReason; // TIME_UP, ALL_CORRECT, SKIP - Long ttl; // 7일 -} -``` - -### 6.4 RoomToken - -```java - -@DynamoDbBean -public class RoomToken { - String token; // UUID - String roomId; - String userId; - Long ttl; // 5분 -} -``` - ---- - -## 7. 서비스 레이어 - -### 7.1 CQRS 패턴 - -| Service | 역할 | -|------------------------|----------------------| -| ChatRoomCommandService | 채팅방 생성, 입장, 퇴장, 삭제 | -| ChatRoomQueryService | 채팅방 조회, 목록 | -| GameService | 게임 시작, 정답 체크, 라운드 종료 | -| GameStatsService | 게임 종료 후 통계, 배지 처리 | -| CommandService | 슬래시 명령어 처리 | -| RoomTokenService | 토큰 발급 및 검증 | - -### 7.2 게임 정답 체크 로직 - -```mermaid -flowchart TB - INPUT[정답 입력] --> NORMALIZE["정규화
(소문자, 공백제거)"] - NORMALIZE --> VALIDATE{유효성 검사} - VALIDATE -->|게임 미진행| REJECT1[거부: 게임 없음] - VALIDATE -->|출제자 본인| REJECT2[거부: 출제자] - VALIDATE -->|이미 정답| REJECT3[거부: 중복] - VALIDATE -->|통과| COMPARE{정답 비교} - COMPARE -->|일치| CORRECT["정답 처리
점수 계산"] - COMPARE -->|불일치| WRONG["오답 처리
GUESS 메시지 전송"] - CORRECT --> BROADCAST["브로드캐스트
CORRECT_ANSWER + SCORE_UPDATE"] - WRONG --> GUESSBROADCAST["브로드캐스트
GUESS 메시지"] - BROADCAST --> ALLCHECK{전원 정답?} - ALLCHECK -->|Yes| ROUNDEND[라운드 자동 종료] - ALLCHECK -->|No| CONTINUE[게임 계속] -``` - ---- - -## 8. 브로드캐스트 시스템 - -### 8.1 WebSocketBroadcaster - -```java -public class WebSocketBroadcaster { - public List broadcast( - List connections, - String payload - ) { - // 1. 같은 방 모든 연결에 메시지 전송 - // 2. 실패한 연결 ID 반환 (Stale 정리용) - } -} -``` - -### 8.2 브로드캐스트 유형 - -| 유형 | 대상 | 예시 | -|--------|--------|-----------| -| 전체 | 방 전체 | 채팅, 정답 알림 | -| 본인 제외 | 발신자 제외 | 그림 데이터 | -| 출제자 전용 | 출제자만 | 단어 정보 | - ---- - -## 9. 파일 구조 - -``` -domain/chatting/ -├── handler/ -│ ├── ChatRoomHandler.java -│ ├── ChatMessageHandler.java -│ ├── ChatVoiceHandler.java -│ ├── GameHandler.java -│ └── websocket/ -│ ├── WebSocketConnectHandler.java -│ ├── WebSocketDisconnectHandler.java -│ └── WebSocketMessageHandler.java -├── service/ -│ ├── ChatRoomCommandService.java -│ ├── ChatRoomQueryService.java -│ ├── ChatMessageService.java -│ ├── GameService.java -│ ├── GameStatsService.java -│ ├── CommandService.java -│ └── RoomTokenService.java -├── repository/ -│ ├── ChatRoomRepository.java -│ ├── ChatMessageRepository.java -│ ├── ConnectionRepository.java -│ ├── GameRoundRepository.java -│ └── RoomTokenRepository.java -├── model/ -│ ├── ChatRoom.java -│ ├── ChatMessage.java -│ ├── Connection.java -│ ├── GameRound.java -│ └── RoomToken.java -├── dto/ -│ ├── request/ -│ └── response/ -│ └── ScoreUpdateMessage.java -└── enums/ - ├── GameStatus.java - └── MessageType.java -``` - ---- - -## 10. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **API:** API Gateway REST + WebSocket -- **Database:** DynamoDB (Single Table Design) -- **Auth:** Cognito + RoomToken -- **Encryption:** BCrypt (비밀방 암호) -- **TTS:** AWS Polly + S3 캐시 -- **Pattern:** CQRS, Repository, Factory diff --git a/docs/domain-reports/COMMON-MODULE-REPORT.md b/docs/domain-reports/COMMON-MODULE-REPORT.md deleted file mode 100644 index aefe6d08..00000000 --- a/docs/domain-reports/COMMON-MODULE-REPORT.md +++ /dev/null @@ -1,1228 +0,0 @@ -# Common Module 세부 보고서 - -## 1. 개요 - -Common 모듈은 모든 도메인에서 공유하는 유틸리티, 설정, 예외 처리, 라우팅 등을 제공하는 핵심 인프라 모듈입니다. Java 21의 최신 기능(Records, Sealed Interface, Pattern -Matching)을 적극 활용하여 타입 안전성과 코드 간결성을 확보했습니다. - ---- - -## 2. 전체 패키지 구조 - -```mermaid -flowchart TB -subgraph Common["common/"] -CONFIG[config/] -CONST[constants/] -DTO[dto/] -ENUM[enums/] -EXCEPTION[exception/] -ROUTER[router/] -SERVICE[service/] -UTIL[util/] -VALIDATION[validation/] -end - -subgraph ConfigFiles["config/"] -AC[AwsClients.java] -WSC[WebSocketConfig.java] -RTC[RoomTokenConfig.java] -SC[StudyConfig.java] -end - -subgraph DtoFiles["dto/"] -AR[ApiResponse.java] -EI[ErrorInfo.java] -PR[PaginatedResult.java] -end - -subgraph ExceptionFiles["exception/"] -SE[ServerlessException.java] -EC[ErrorCode.java] -CEC[CommonErrorCode.java] -CE[CommonException.java] -end - -subgraph RouterFiles["router/"] -HR[HandlerRouter.java] -RT[Route.java] -AH[AuthenticatedHandler.java] -end - -CONFIG --> ConfigFiles -DTO --> DtoFiles -EXCEPTION --> ExceptionFiles -ROUTER --> RouterFiles -``` - ---- - -## 3. Handler 라우팅 시스템 - -### 3.1 HandlerRouter 아키텍처 - -```mermaid -flowchart TB - subgraph Request["요청 처리 흐름"] - REQ[APIGatewayProxyRequestEvent] --> ROUTER[HandlerRouter] - ROUTER --> MATCH{라우트 매칭} - MATCH -->|매칭 성공| VALIDATE[파라미터 검증] - MATCH -->|매칭 실패| NF404[404 Not Found] - VALIDATE --> EXECUTE[핸들러 실행] - EXECUTE --> RESPONSE[APIGatewayProxyResponseEvent] - end - - subgraph ErrorHandling["예외 처리"] - EXECUTE -->|ServerlessException| ERR1[ErrorCode 기반 응답] - EXECUTE -->|IllegalArgumentException| ERR2[400 Bad Request] - EXECUTE -->|IllegalStateException| ERR3[409 Conflict] - EXECUTE -->|SecurityException| ERR4[403 Forbidden] - EXECUTE -->|기타 예외| ERR5[500 Internal Error] - end -``` - -### 3.2 Route 정의 (Java 21 Record) - -```java -// Route.java - Java 21 Record 활용 -public record Route( - String method, // HTTP 메서드 - String pathPattern, // 경로 패턴 (e.g., "/rooms/{roomId}") - Function handler, - List requiredPathParams, // 필수 경로 파라미터 - List requiredQueryParams // 필수 쿼리 파라미터 - ) { - // 경로 파라미터 자동 추출: {roomId} → roomId - private static final Pattern PATH_PARAM_PATTERN = - Pattern.compile("\\{([^}]+)}"); -} -``` - -### 3.3 Route 팩토리 메서드 - -```mermaid -flowchart LR - subgraph BasicRoutes["기본 라우트"] - GET["Route.get()"] - POST["Route.post()"] - PUT["Route.put()"] - DELETE["Route.delete()"] - PATCH["Route.patch()"] - end - - subgraph AuthRoutes["인증 라우트"] - GETAUTH["Route.getAuth()"] - POSTAUTH["Route.postAuth()"] - PUTAUTH["Route.putAuth()"] - DELETEAUTH["Route.deleteAuth()"] - PATCHAUTH["Route.patchAuth()"] - end - - BasicRoutes -->|" + Cognito 인증 "| AuthRoutes -``` - -### 3.4 사용 예시 - -```java -// Handler에서 라우터 초기화 -private HandlerRouter initRouter() { - return new HandlerRouter().addRoutes( - // 인증 필요 라우트 (Cognito userId 자동 추출) - Route.postAuth("/grammar/check", this::checkGrammar), - Route.getAuth("/grammar/sessions/{sessionId}", this::getSessionDetail), - Route.deleteAuth("/grammar/sessions/{sessionId}", this::deleteSession), - - // 쿼리 파라미터 검증 - Route.getAuth("/rooms", this::getRooms) - .requireQueryParams("level") - ); -} - -// Lambda 핸들러 메서드 -@Override -public APIGatewayProxyResponseEvent handleRequest( - APIGatewayProxyRequestEvent request, Context context) { - return router.route(request); -} -``` - -### 3.5 AuthenticatedHandler 인터페이스 - -```java -// 함수형 인터페이스 - Cognito 인증 요청 처리 -@FunctionalInterface -public interface AuthenticatedHandler { - APIGatewayProxyResponseEvent handle( - APIGatewayProxyRequestEvent request, - String userId // Cognito sub claim에서 자동 추출 - ); -} - -// 사용 예시 - 람다 표현식으로 간결하게 -Route. - -postAuth("/rooms",(request, userId) ->{ -CreateRoomRequest dto = parseBody(request, CreateRoomRequest.class); -ChatRoom room = roomService.createRoom(userId, dto); - return ResponseGenerator. - -created("Room created",room); -}); -``` - ---- - -## 4. 예외 처리 시스템 - -### 4.1 ErrorCode 계층 구조 (Sealed Interface) - -```mermaid -flowchart TB - subgraph SealedHierarchy["Java 21 Sealed Interface 계층"] - EC[/"ErrorCode
(sealed interface)"/] - EC -->|permits| CEC["CommonErrorCode
(enum)"] - EC -->|permits| DEC[/"DomainErrorCode
(non-sealed interface)"/] - DEC --> VEC["VocabularyErrorCode"] - DEC --> CHEC["ChattingErrorCode"] - DEC --> GEC["GrammarErrorCode"] - DEC --> SEC["StatsErrorCode"] - DEC --> BEC["BadgeErrorCode"] - end -``` - -### 4.2 CommonErrorCode 정의 - -```java -public enum CommonErrorCode implements ErrorCode { - // 인증/인가 (AUTH_xxx) - UNAUTHORIZED("AUTH_001", "인증이 필요합니다", 401), - FORBIDDEN("AUTH_002", "접근 권한이 없습니다", 403), - INVALID_TOKEN("AUTH_003", "유효하지 않은 토큰입니다", 401), - TOKEN_EXPIRED("AUTH_004", "토큰이 만료되었습니다", 401), - - // 검증 (VALIDATION_xxx) - INVALID_INPUT("VALIDATION_001", "잘못된 입력입니다", 400), - REQUIRED_FIELD_MISSING("VALIDATION_002", "필수 필드가 누락되었습니다", 400), - INVALID_FORMAT("VALIDATION_003", "형식이 올바르지 않습니다", 400), - VALUE_OUT_OF_RANGE("VALIDATION_004", "값이 허용 범위를 벗어났습니다", 400), - - // 리소스 (RESOURCE_xxx) - RESOURCE_NOT_FOUND("RESOURCE_001", "리소스를 찾을 수 없습니다", 404), - RESOURCE_ALREADY_EXISTS("RESOURCE_002", "이미 존재하는 리소스입니다", 409), - METHOD_NOT_ALLOWED("RESOURCE_003", "허용되지 않는 메서드입니다", 405), - - // 시스템 (SYSTEM_xxx) - INTERNAL_SERVER_ERROR("SYSTEM_001", "내부 서버 오류가 발생했습니다", 500), - DATABASE_ERROR("SYSTEM_002", "데이터베이스 오류가 발생했습니다", 500), - EXTERNAL_API_ERROR("SYSTEM_003", "외부 API 호출 오류가 발생했습니다", 502), - SERVICE_UNAVAILABLE("SYSTEM_004", "서비스를 일시적으로 사용할 수 없습니다", 503); - - private final String code; - private final String message; - private final int statusCode; -} -``` - -### 4.3 예외 생성 팩토리 패턴 - -```mermaid -flowchart LR - subgraph FactoryMethods["CommonException 팩토리 메서드"] - AUTH["인증 오류"] - VALID["검증 오류"] - RES["리소스 오류"] - SYS["시스템 오류"] - end - - AUTH --> UNAUTH["unauthorized()"] - AUTH --> FORBID["forbidden()"] - AUTH --> TOKEN["invalidToken()"] - VALID --> INPUT["invalidInput(msg)"] - VALID --> MISS["requiredFieldMissing(field)"] - VALID --> FMT["invalidFormat(field)"] - RES --> NF["notFound(resource, id)"] - RES --> EXIST["alreadyExists(resource)"] - SYS --> INTERN["internalError(cause)"] - SYS --> DB["databaseError(cause)"] - SYS --> EXT["externalApiError(api, cause)"] -``` - -### 4.4 예외 사용 예시 - -```java -// 가독성 높은 예외 생성 -throw CommonException.notFound("User","user123"); -// → "User (ID: user123)를 찾을 수 없습니다", 404 - -throw CommonException. - -invalidInput("Email format is invalid"); -// → 400 INVALID_INPUT with custom message - -throw CommonException. - -alreadyExists("ChatRoom","room456"); -// → "ChatRoom (ID: room456)가 이미 존재합니다", 409 - -// 상세 컨텍스트 추가 (메서드 체이닝) -throw CommonException. - -internalError(cause) - . - -addDetail("operation","database_query") - . - -addDetail("table","users"); -``` - ---- - -## 5. AWS 클라이언트 관리 - -### 5.1 Singleton 패턴 (Cold Start 최적화) - -```mermaid -flowchart TB - subgraph ColdStart["Lambda Cold Start 최적화"] - INIT["Lambda 컨테이너 초기화
(1회)"] - STATIC["static final 클라이언트 생성"] - REUSE["요청마다 재사용"] - end - - INIT --> STATIC - STATIC --> REUSE - REUSE -->|" 다음 요청 "| REUSE -``` - -### 5.2 AwsClients.java 구조 - -```java -public final class AwsClients { - // DynamoDB (Enhanced Client 포함) - private static final DynamoDbClient DYNAMO_DB_CLIENT = - DynamoDbClient.builder().build(); - private static final DynamoDbEnhancedClient DYNAMO_DB_ENHANCED_CLIENT = - DynamoDbEnhancedClient.builder() - .dynamoDbClient(DYNAMO_DB_CLIENT) - .build(); - - // S3 (Presigner 포함) - private static final S3Client S3_CLIENT = S3Client.builder().build(); - private static final S3Presigner S3_PRESIGNER = S3Presigner.builder().build(); - - // AI/ML 서비스 - private static final PollyClient POLLY_CLIENT = PollyClient.builder().build(); - private static final BedrockRuntimeClient BEDROCK_CLIENT = - BedrockRuntimeClient.builder().build(); - private static final BedrockRuntimeAsyncClient BEDROCK_ASYNC_CLIENT = - BedrockRuntimeAsyncClient.builder().build(); - private static final ComprehendClient COMPREHEND_CLIENT = - ComprehendClient.builder().build(); - - // SNS - private static final SnsClient SNS_CLIENT = SnsClient.builder().build(); - - // 팩토리 메서드 - public static DynamoDbClient dynamoDb() { - return DYNAMO_DB_CLIENT; - } - - public static DynamoDbEnhancedClient dynamoDbEnhanced() { - return DYNAMO_DB_ENHANCED_CLIENT; - } - - public static S3Client s3() { - return S3_CLIENT; - } - - public static S3Presigner s3Presigner() { - return S3_PRESIGNER; - } - - public static PollyClient polly() { - return POLLY_CLIENT; - } - - public static BedrockRuntimeClient bedrock() { - return BEDROCK_CLIENT; - } - - public static BedrockRuntimeAsyncClient bedrockAsync() { - return BEDROCK_ASYNC_CLIENT; - } - - public static ComprehendClient comprehend() { - return COMPREHEND_CLIENT; - } - - public static SnsClient sns() { - return SNS_CLIENT; - } -} -``` - -### 5.3 사용 예시 - -```java -// Service에서 사용 -public class PollyService { - public VoiceSynthesisResult synthesizeSpeech(String id, String text, String voice) { - SynthesizeSpeechRequest request = SynthesizeSpeechRequest.builder() - .text(text) - .voiceId(VoiceId.MATTHEW) - .engine("neural") - .outputFormat(OutputFormat.MP3) - .build(); - - // Singleton 클라이언트 사용 - InputStream audioStream = AwsClients.polly().synthesizeSpeech(request); - AwsClients.s3().putObject(putRequest, RequestBody.fromInputStream(audioStream, -1)); - - return new VoiceSynthesisResult(s3Key, presignedUrl, false); - } -} -``` - ---- - -## 6. DTO 패턴 (Java 21 Records) - -### 6.1 ApiResponse (제네릭 응답 래퍼) - -```java -// 불변 데이터 클래스 - Java 21 Record -public record ApiResponse( - boolean isSuccess, - String message, - T data, - String error - ) { - // 성공 응답 팩토리 - public static ApiResponse ok(String message, T data) { - return new ApiResponse<>(true, message, data, null); - } - - public static ApiResponse ok(T data) { - return new ApiResponse<>(true, null, data, null); - } - - // 실패 응답 팩토리 - public static ApiResponse fail(String errorMessage) { - return new ApiResponse<>(false, null, null, errorMessage); - } -} -``` - -**JSON 응답 예시:** - -```json -{ - "isSuccess": true, - "message": "Grammar checked successfully", - "data": { - "correctedSentence": "I am a student", - "score": 85, - "errors": [ - ... - ] - }, - "error": null -} -``` - -### 6.2 ErrorInfo (RFC 7807 준수) - -```java -// Problem Details for HTTP APIs (RFC 7807) -public record ErrorInfo( - String code, // e.g., "VOCABULARY.WORD_001" - String message, // e.g., "단어를 찾을 수 없습니다" - int status, // e.g., 404 - Map details // Optional context - ) { - public static ErrorInfo from(ErrorCode errorCode) { ...} - - public static ErrorInfo from(ServerlessException ex) { ...} - - public boolean isClientError() { - return status >= 400 && status < 500; - } - - public boolean isServerError() { - return status >= 500 && status < 600; - } -} -``` - -**JSON 에러 응답 예시:** - -```json -{ - "code": "VOCABULARY.WORD_001", - "message": "단어를 찾을 수 없습니다", - "status": 404, - "details": { - "wordId": "abc-123", - "userId": "user456" - } -} -``` - -### 6.3 PaginatedResult (커서 페이지네이션) - -```java -public record PaginatedResult( - List items, - String nextCursor // Base64 인코딩된 DynamoDB lastEvaluatedKey -) { - public boolean hasMore() { - return nextCursor != null; - } -} -``` - ---- - -## 7. 페이지네이션 유틸리티 - -### 7.1 CursorUtil 동작 흐름 - -```mermaid -sequenceDiagram - participant Client - participant Handler - participant CursorUtil - participant DynamoDB - Note over Client, DynamoDB: 첫 페이지 요청 - Client ->> Handler: GET /items?limit=10 - Handler ->> CursorUtil: decode(null) → null - Handler ->> DynamoDB: Query (exclusiveStartKey=null) - DynamoDB -->> Handler: items + lastEvaluatedKey - Handler ->> CursorUtil: encode(lastEvaluatedKey) - CursorUtil -->> Handler: "dXNlcklkPXVzZXIxMjM..." - Handler -->> Client: {"items": [...], "nextCursor": "dXNlcklkPXVzZXIxMjM..."} - Note over Client, DynamoDB: 다음 페이지 요청 - Client ->> Handler: GET /items?cursor=dXNlcklkPXVzZXIxMjM... - Handler ->> CursorUtil: decode("dXNlcklkPXVzZXIxMjM...") - CursorUtil -->> Handler: {"userId": "user123", ...} - Handler ->> DynamoDB: Query (exclusiveStartKey={...}) - DynamoDB -->> Handler: items + lastEvaluatedKey -``` - -### 7.2 CursorUtil 구현 - -```java -public class CursorUtil { - // DynamoDB lastEvaluatedKey → Base64 문자열 - public static String encode(Map lastEvaluatedKey) { - if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { - return null; - } - - StringBuilder sb = new StringBuilder(); - for (Map.Entry entry : lastEvaluatedKey.entrySet()) { - if (sb.length() > 0) sb.append("|"); - sb.append(entry.getKey()).append("=").append(entry.getValue().s()); - } - - return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); - } - - // Base64 문자열 → DynamoDB exclusiveStartKey - public static Map decode(String cursor) { - if (cursor == null || cursor.isEmpty()) { - return null; - } - - String decoded = new String(Base64.getUrlDecoder().decode(cursor)); - Map startKey = new HashMap<>(); - - for (String pair : decoded.split("\\|")) { - String[] kv = pair.split("=", 2); - if (kv.length == 2) { - startKey.put(kv[0], AttributeValue.builder().s(kv[1]).build()); - } - } - - return startKey; - } -} -``` - ---- - -## 8. 인증 유틸리티 - -### 8.1 Cognito 인증 흐름 - -```mermaid -flowchart TB - subgraph CognitoAuth["Cognito 인증 흐름"] - REQ[요청] --> AUTH[API Gateway Authorizer] - AUTH --> CLAIMS[JWT Claims 추출] - CLAIMS --> INJECT["requestContext.authorizer.claims"] - end - - subgraph CognitoUtil["CognitoUtil 추출"] - INJECT --> EXTRACT[extractUserId] - EXTRACT --> SUB["claims.sub → userId"] - end -``` - -### 8.2 CognitoUtil.java - -```java -public class CognitoUtil { - // 기본 userId 추출 (sub claim) - public static String extractUserId(APIGatewayProxyRequestEvent request) { - Map authorizer = request.getRequestContext().getAuthorizer(); - if (authorizer == null) return null; - - Map claims = (Map) authorizer.get("claims"); - return claims != null ? claims.get("sub") : null; - } - - // 선택적 claim 추출 - public static Optional extractEmail(APIGatewayProxyRequestEvent request) { - return extractClaim(request, "email"); - } - - public static Optional extractNickname(APIGatewayProxyRequestEvent request) { - return extractClaim(request, "custom:nickname"); - } - - public static Optional extractClaim( - APIGatewayProxyRequestEvent request, String claimName) { - // ... claim 추출 로직 - } - - // 사용자 접근 권한 검증 - public static boolean validateUserAccess( - APIGatewayProxyRequestEvent request, String pathUserId) { - String tokenUserId = extractUserId(request); - return tokenUserId != null && tokenUserId.equals(pathUserId); - } -} -``` - -### 8.3 JwtUtil.java (WebSocket용) - -```java -// WebSocket 연결 시 직접 JWT 파싱 (Authorizer 미사용) -public final class JwtUtil { - public static Optional extractUserId(String token) { - // Bearer 제거 - if (token.startsWith("Bearer ")) { - token = token.substring(7); - } - - // JWT payload 추출 (헤더.페이로드.시그니처) - String[] parts = token.split("\\."); - if (parts.length != 3) return Optional.empty(); - - // Base64 URL 디코딩 - String payload = new String(Base64.getUrlDecoder().decode(parts[1])); - Map claims = gson.fromJson(payload, Map.class); - - return Optional.ofNullable((String) claims.get("sub")); - } - - public static boolean isExpired(String token) { - // exp claim 확인 - } -} -``` - ---- - -## 9. HTTP 응답 생성 - -### 9.1 ResponseGenerator.java - -```java -public class ResponseGenerator { - private static final Gson GSON = new GsonBuilder() - .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") - .create(); - - private static final Map CORS_HEADERS = Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", - "Access-Control-Allow-Headers", "Content-Type,Authorization" - ); - - // 성공 응답 - public static APIGatewayProxyResponseEvent ok(String message, T data) { - return buildResponse(200, ApiResponse.ok(message, data)); - } - - public static APIGatewayProxyResponseEvent created(String message, T data) { - return buildResponse(201, ApiResponse.ok(message, data)); - } - - public static APIGatewayProxyResponseEvent noContent() { - return buildResponse(204, null); - } - - // 에러 응답 - public static APIGatewayProxyResponseEvent fail(ErrorCode errorCode) { - return buildResponse(errorCode.getStatusCode(), ErrorInfo.from(errorCode)); - } - - public static APIGatewayProxyResponseEvent badRequest(String message) { - return fail(CommonErrorCode.INVALID_INPUT, message); - } - - public static APIGatewayProxyResponseEvent notFound(String message) { - return fail(CommonErrorCode.RESOURCE_NOT_FOUND, message); - } - - // ... 기타 편의 메서드 - - private static APIGatewayProxyResponseEvent buildResponse(int statusCode, Object body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(new HashMap<>(CORS_HEADERS)) - .withBody(body != null ? GSON.toJson(body) : null); - } - - public static Gson gson() { - return GSON; - } -} -``` - ---- - -## 10. Bean Validation - -### 10.1 BeanValidator 패턴 - -```mermaid -flowchart TB - REQ[요청 수신] --> PARSE[JSON 파싱 → DTO] -PARSE --> VALIDATE[BeanValidator.validateAndExecute] -VALIDATE --> CHECK{검증 통과?} -CHECK -->|Yes|HANDLER[핸들러 로직 실행] -CHECK -->|No|ERR400[400 Bad Request] -HANDLER --> RESPONSE[정상 응답] -``` - -### 10.2 BeanValidator.java - -```java -public final class BeanValidator { - private static final Validator VALIDATOR; - - static { - ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); - VALIDATOR = factory.getValidator(); - } - - // 검증 + 실행 통합 패턴 - public static APIGatewayProxyResponseEvent validateAndExecute( - T object, - Function handler) { - - Optional error = validate(object); - if (error.isPresent()) { - return ResponseGenerator.badRequest(error.get()); - } - - return handler.apply(object); - } - - public static Optional validate(T object) { - Set> violations = VALIDATOR.validate(object); - if (violations.isEmpty()) { - return Optional.empty(); - } - - String message = violations.stream() - .map(ConstraintViolation::getMessage) - .collect(Collectors.joining(", ")); - - return Optional.of(message); - } -} -``` - -### 10.3 DTO 검증 예시 - -```java -// 요청 DTO -public class CreateRoomRequest { - @NotEmpty(message = "방 이름은 필수입니다") - private String roomName; - - @NotNull(message = "난이도는 필수입니다") - private String difficulty; - - @Min(value = 2, message = "최소 2명 이상이어야 합니다") - @Max(value = 10, message = "최대 10명까지 가능합니다") - private int maxMembers; -} - -// Handler에서 사용 -private APIGatewayProxyResponseEvent createRoom( - APIGatewayProxyRequestEvent request, String userId) { - - CreateRoomRequest req = ResponseGenerator.gson() - .fromJson(request.getBody(), CreateRoomRequest.class); - - return BeanValidator.validateAndExecute(req, dto -> { - // 검증 통과 시에만 실행됨 - ChatRoom room = roomService.createRoom(userId, dto); - return ResponseGenerator.created("방이 생성되었습니다", room); - }); -} -``` - ---- - -## 11. WebSocket 유틸리티 - -### 11.1 브로드캐스트 흐름 - -```mermaid -sequenceDiagram - participant Service - participant Broadcaster as WebSocketBroadcaster - participant APIGW as API Gateway - participant Clients as WebSocket Clients - Service ->> Broadcaster: broadcast(connections, message) - - loop 각 연결에 전송 - Broadcaster ->> APIGW: postToConnection(connectionId, data) - alt 성공 - APIGW -->> Clients: 메시지 전달 - else 연결 끊김 (410 Gone) - APIGW -->> Broadcaster: GoneException - Broadcaster ->> Broadcaster: failedIds에 추가 - end - end - - Broadcaster -->> Service: failedConnectionIds 반환 - Service ->> Service: Stale 연결 정리 -``` - -### 11.2 WebSocketBroadcaster.java - -```java -public class WebSocketBroadcaster { - private final ApiGatewayManagementApiClient apiClient; - - public WebSocketBroadcaster() { - String endpoint = WebSocketConfig.websocketEndpoint(); - this.apiClient = ApiGatewayManagementApiClient.builder() - .endpointOverride(URI.create(endpoint)) - .build(); - } - - // 단일 연결에 전송 - public boolean sendToConnection(String connectionId, String message) { - try { - apiClient.postToConnection(PostToConnectionRequest.builder() - .connectionId(connectionId) - .data(SdkBytes.fromUtf8String(message)) - .build()); - return true; - } catch (GoneException e) { - // 연결이 이미 끊김 - return false; - } - } - - // 다수 연결에 브로드캐스트 - public List broadcast(List connections, String message) { - List failedIds = new ArrayList<>(); - - for (Connection conn : connections) { - if (!sendToConnection(conn.getConnectionId(), message)) { - failedIds.add(conn.getConnectionId()); - } - } - - return failedIds; // 실패한 연결 ID 반환 (정리용) - } -} -``` - -### 11.3 WebSocket 응답 유틸리티 - -```java -public final class WebSocketResponseUtil { - public static Map ok(String message) { - return response(200, message); - } - - public static Map unauthorized(String message) { - return response(401, message); - } - - public static Map badRequest(String message) { - return response(400, message); - } - - private static Map response(int statusCode, String body) { - return Map.of( - "statusCode", statusCode, - "body", body - ); - } -} -``` - ---- - -## 12. S3 Presigned URL - -### 12.1 S3PresignUtil.java - -```java -public class S3PresignUtil { - private static final Duration DEFAULT_DURATION = Duration.ofHours(24); - private static final String BUCKET_NAME = System.getenv("S3_BUCKET_NAME"); - - // 내부 캐시 (Java 21 Record) - private record CachedUrl(String url, long expiresAt) { - boolean isExpired() { - // 1시간 버퍼 두고 만료 체크 - return System.currentTimeMillis() > (expiresAt - 3600_000); - } - } - - private static final Map URL_CACHE = new ConcurrentHashMap<>(); - - public static String getPresignedUrl(String key) { - return getPresignedUrl(key, DEFAULT_DURATION); - } - - public static String getPresignedUrl(String key, Duration duration) { - CachedUrl cached = URL_CACHE.get(key); - if (cached != null && !cached.isExpired()) { - return cached.url(); - } - - GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() - .signatureDuration(duration) - .getObjectRequest(r -> r.bucket(BUCKET_NAME).key(key)) - .build(); - - String url = AwsClients.s3Presigner() - .presignGetObject(presignRequest) - .url() - .toString(); - - URL_CACHE.put(key, new CachedUrl(url, - System.currentTimeMillis() + duration.toMillis())); - - return url; - } - - // 배지 이미지 URL 생성 편의 메서드 - public static String getBadgeImageUrl(String imageFile) { - return getPresignedUrl("badges/" + imageFile); - } -} -``` - ---- - -## 13. AWS 서비스 래퍼 - -### 13.1 PollyService (TTS + S3 캐시) - -```mermaid -flowchart TB - REQ[음성 합성 요청] --> CHECK{S3 캐시 확인} - CHECK -->|캐시 있음| PRESIGN[Presigned URL 생성] - CHECK -->|캐시 없음| SYNTH[Polly 음성 합성] - SYNTH --> SAVE[S3 저장] - SAVE --> PRESIGN - PRESIGN --> RETURN[URL 반환] -``` - -```java -public class PollyService { - public VoiceSynthesisResult synthesizeSpeech(String id, String text, String voice) { - String s3Key = generateS3Key(id, voice); - - // 캐시 확인 - if (existsInS3(s3Key)) { - return new VoiceSynthesisResult(s3Key, getPresignedUrl(s3Key), true); - } - - // Polly 음성 합성 - VoiceId voiceId = "MALE".equalsIgnoreCase(voice) ? VoiceId.MATTHEW : VoiceId.JOANNA; - - SynthesizeSpeechRequest request = SynthesizeSpeechRequest.builder() - .text(text) - .voiceId(voiceId) - .engine("neural") // Neural 음성 (고품질) - .outputFormat(OutputFormat.MP3) - .build(); - - InputStream audioStream = AwsClients.polly().synthesizeSpeech(request); - - // S3 저장 - AwsClients.s3().putObject( - PutObjectRequest.builder() - .bucket(bucketName) - .key(s3Key) - .contentType("audio/mpeg") - .build(), - RequestBody.fromInputStream(audioStream, -1) - ); - - return new VoiceSynthesisResult(s3Key, getPresignedUrl(s3Key), false); - } - - public String generateS3Key(String id, String voice) { - String suffix = "MALE".equalsIgnoreCase(voice) ? "male" : "female"; - return s3KeyPrefix + id + "_" + suffix + ".mp3"; - } -} -``` - -### 13.2 ComprehendService (NLP 분석) - -```java -public class ComprehendService { - public ComprehendAnalysis analyze(String text) { - // 감정 분석 - DetectSentimentResponse sentiment = AwsClients.comprehend() - .detectSentiment(DetectSentimentRequest.builder() - .text(text) - .languageCode("en") - .build()); - - // 구문 분석 (품사 태깅) - DetectSyntaxResponse syntax = AwsClients.comprehend() - .detectSyntax(DetectSyntaxRequest.builder() - .text(text) - .languageCode("en") - .build()); - - // 핵심 구문 추출 - DetectKeyPhrasesResponse keyPhrases = AwsClients.comprehend() - .detectKeyPhrases(DetectKeyPhrasesRequest.builder() - .text(text) - .languageCode("en") - .build()); - - // 문장 복잡도 계산 - String complexity = calculateComplexity(syntax.syntaxTokens()); - - return ComprehendAnalysis.builder() - .sentiment(sentiment.sentimentAsString()) - .syntax(mapTokens(syntax.syntaxTokens())) - .keyPhrases(mapKeyPhrases(keyPhrases.keyPhrases())) - .complexity(complexity) - .build(); - } - - private String calculateComplexity(List tokens) { - Set uniquePOS = tokens.stream() - .map(t -> t.partOfSpeech().tagAsString()) - .collect(Collectors.toSet()); - - if (uniquePOS.size() <= 3 && tokens.size() <= 5) return "BEGINNER"; - if (uniquePOS.size() <= 5 && tokens.size() <= 10) return "INTERMEDIATE"; - return "ADVANCED"; - } -} -``` - ---- - -## 14. 설정 클래스 - -### 14.1 StudyConfig (학습 알고리즘 상수) - -```java -public final class StudyConfig { - // SM-2 알고리즘 상수 - public static final int INITIAL_INTERVAL_DAYS = 1; - public static final double DEFAULT_EASE_FACTOR = 2.5; - public static final double MIN_EASE_FACTOR = 1.3; - public static final int INITIAL_REPETITIONS = 0; - - // 테스트 설정 - public static final int DEFAULT_WORD_COUNT = 20; - public static final int DAILY_TEST_WORD_COUNT = 10; - - // 복습 주기 (일) - public static final int[] REVIEW_INTERVALS = {1, 3, 7, 14, 30}; - - // 상태 기본값 - public static final String DEFAULT_WORD_STATUS = "NEW"; - public static final String DEFAULT_DIFFICULTY = "NORMAL"; - - // 오류 제한 - public static final int MAX_WRONG_COUNT = 3; -} -``` - -### 14.2 DynamoDbKey (키 패턴 상수) - -```java -public final class DynamoDbKey { - // 기본 키 - public static final String PK = "PK"; - public static final String SK = "SK"; - - // GSI 키 - public static final String GSI1_PK = "GSI1PK"; - public static final String GSI1_SK = "GSI1SK"; - public static final String GSI2_PK = "GSI2PK"; - public static final String GSI2_SK = "GSI2SK"; - - // GSI 이름 - public static final String GSI1 = "GSI1"; - public static final String GSI2 = "GSI2"; - - // 공통 접두사 - public static final String USER = "USER#"; - public static final String METADATA = "METADATA"; - - // 헬퍼 메서드 - public static String userPk(String userId) { - return USER + userId; // "USER#user-123" - } -} -``` - ---- - -## 15. Java 21 기능 활용 - -### 15.1 Records 활용 - -| 클래스 | 용도 | -|-----------------|----------------| -| ApiResponse | 제네릭 API 응답 래퍼 | -| ErrorInfo | RFC 7807 에러 응답 | -| PaginatedResult | 페이지네이션 결과 | -| Route | HTTP 라우트 정의 | -| RouteEntry | 라우터 내부 매칭 | -| CachedUrl | S3 URL 캐시 | - -### 15.2 Sealed Interface 활용 - -```mermaid -flowchart TB - subgraph SealedPattern["Sealed Interface 패턴"] - EC[/"sealed interface ErrorCode
permits CommonErrorCode, DomainErrorCode"/] - CEC["final enum CommonErrorCode
implements ErrorCode"] - DEC[/"non-sealed interface DomainErrorCode
extends ErrorCode"/] - EC --> CEC - EC --> DEC - end -``` - -### 15.3 Pattern Matching 활용 - -```java -// instanceof 패턴 매칭 -String code = errorCode instanceof DomainErrorCode domainCode - ? domainCode.getFullCode() // "VOCABULARY.WORD_001" - : errorCode.getCode(); // "AUTH_001" - -// switch 표현식 (Enhanced) -return switch(type. - -getCategory()){ - case"FIRST_STUDY"->stats. - -getTestsCompleted() >=1; - case"STREAK"->stats. - -getCurrentStreak() >=type. - -getThreshold(); - case"ACCURACY"->{ -double accuracy = (double) stats.getCorrectAnswers() / stats.getQuestionsAnswered() * 100; -yield accuracy >=type. - -getThreshold(); - } -default ->false; - }; -``` - ---- - -## 16. 디자인 패턴 요약 - -| 패턴 | 적용 위치 | 목적 | -|----------------------|------------------------|-------------------| -| **Singleton** | AwsClients | AWS SDK 클라이언트 재사용 | -| **Factory Method** | Route, CommonException | 객체 생성 캡슐화 | -| **Strategy** | AuthenticatedHandler | 요청 처리 전략 분리 | -| **Router** | HandlerRouter | HTTP 요청 라우팅 | -| **Builder** | ComprehendAnalysis | 복잡한 객체 생성 | -| **Template Method** | BeanValidator | 검증-실행 흐름 템플릿 | -| **Sealed Interface** | ErrorCode 계층 | 구현 제한 | -| **Data Class** | Records | 불변 데이터 전송 | - ---- - -## 17. 파일 구조 - -``` -common/ -├── config/ -│ ├── AwsClients.java # AWS SDK 클라이언트 싱글톤 -│ ├── WebSocketConfig.java # WebSocket 설정 -│ ├── RoomTokenConfig.java # 방 토큰 TTL 설정 -│ └── StudyConfig.java # 학습 알고리즘 상수 -├── constants/ -│ └── DynamoDbKey.java # DynamoDB 키 패턴 -├── dto/ -│ ├── ApiResponse.java # 제네릭 응답 래퍼 (Record) -│ ├── ErrorInfo.java # RFC 7807 에러 (Record) -│ └── PaginatedResult.java # 페이지네이션 (Record) -├── enums/ -│ ├── Difficulty.java # EASY, NORMAL, HARD -│ └── StudyLevel.java # BEGINNER, INTERMEDIATE, ADVANCED -├── exception/ -│ ├── ServerlessException.java # 기본 예외 클래스 -│ ├── ErrorCode.java # Sealed Interface -│ ├── CommonErrorCode.java # 공통 에러 코드 -│ ├── DomainErrorCode.java # 도메인 에러 인터페이스 -│ └── CommonException.java # 예외 팩토리 -├── router/ -│ ├── HandlerRouter.java # HTTP 라우터 -│ ├── Route.java # 라우트 정의 (Record) -│ └── AuthenticatedHandler.java # 인증 핸들러 인터페이스 -├── service/ -│ ├── PollyService.java # TTS + S3 캐시 -│ └── ComprehendService.java # NLP 분석 -├── util/ -│ ├── ResponseGenerator.java # HTTP 응답 빌더 -│ ├── CursorUtil.java # 커서 페이지네이션 -│ ├── CognitoUtil.java # Cognito 인증 추출 -│ ├── JwtUtil.java # JWT 직접 파싱 -│ ├── WebSocketBroadcaster.java # WebSocket 브로드캐스트 -│ ├── WebSocketEventUtil.java # WebSocket 이벤트 추출 -│ ├── WebSocketResponseUtil.java # WebSocket 응답 빌더 -│ └── S3PresignUtil.java # Presigned URL 생성 -└── validation/ - └── BeanValidator.java # Bean Validation 유틸 -``` - ---- - -## 18. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **Build:** Gradle -- **AWS SDK:** AWS SDK for Java v2 -- **Validation:** Jakarta Bean Validation -- **JSON:** Gson -- **Pattern:** Singleton, Factory, Strategy, Router, Builder, Sealed Interface -- **Java 21 Features:** Records, Sealed Interface, Pattern Matching, Enhanced Switch diff --git a/docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md b/docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md deleted file mode 100644 index 5015a011..00000000 --- a/docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md +++ /dev/null @@ -1,465 +0,0 @@ -# Grammar Domain 세부 보고서 - -## 1. 개요 - -Grammar 도메인은 AWS Bedrock(Claude 3 Haiku)을 활용한 AI 기반 영어 문법 체크 시스템입니다. REST API와 WebSocket 스트리밍을 통해 실시간 문법 교정 및 대화형 학습을 -제공합니다. - ---- - -## 2. 전체 아키텍처 - -```mermaid -flowchart TB - subgraph Client["클라이언트"] - APP[Mobile/Web App] - end - - subgraph Gateway["API Gateway"] - REST[REST API] - WS[Grammar WebSocket] - end - - subgraph Lambda["Lambda Handlers"] - HANDLER[GrammarHandler] - CONNECT[StreamingConnectHandler] - DISCONNECT[StreamingDisconnectHandler] - STREAM[StreamingHandler] - end - - subgraph AI["AWS AI 서비스"] - BEDROCK[Bedrock
Claude 3 Haiku] - COMPREHEND[Comprehend
언어 분석] - end - - subgraph Storage["저장소"] - DDB[(DynamoDB)] - end - - APP --> REST - APP <--> WS - REST --> HANDLER - WS --> CONNECT - WS --> DISCONNECT - WS --> STREAM - HANDLER --> BEDROCK - HANDLER --> COMPREHEND - STREAM --> BEDROCK - HANDLER --> DDB - STREAM --> DDB -``` - ---- - -## 3. 문법 체크 흐름 - -### 3.1 동기식 문법 체크 - -```mermaid -sequenceDiagram - participant Client - participant Handler as GrammarHandler - participant Service as GrammarCheckService - participant Bedrock as AWS Bedrock - participant DB as DynamoDB - Client ->> Handler: POST /grammar/check - Handler ->> Service: checkGrammar(sentence, level) - Service ->> Bedrock: Claude API 호출 - Bedrock -->> Service: JSON 응답 - Service -->> Handler: GrammarCheckResponse - Handler -->> Client: 문법 교정 결과 -``` - -### 3.2 스트리밍 대화 - -```mermaid -sequenceDiagram - participant Client - participant WS as WebSocket - participant Handler as StreamingHandler - participant Service as ConversationService - participant Bedrock as AWS Bedrock - Client ->> WS: $connect?token={jwt} - WS -->> Client: 연결 성공 - Client ->> WS: 메시지 전송 - WS ->> Handler: $default 라우트 - Handler ->> Service: chatStreaming() - Service -->> Client: StartEvent (sessionId) - - loop 토큰 단위 스트리밍 - Bedrock -->> Service: 텍스트 청크 - Service -->> Client: TokenEvent - end - - Service -->> Client: CompleteEvent (전체 응답) -``` - ---- - -## 4. API 엔드포인트 - -### 4.1 REST API - -| Method | Endpoint | 설명 | -|--------|-------------------------------|---------------| -| POST | /grammar/check | 문법 체크 (단일 문장) | -| POST | /grammar/conversation | 대화형 문법 학습 | -| GET | /grammar/sessions | 대화 세션 목록 | -| GET | /grammar/sessions/{sessionId} | 세션 상세 | -| DELETE | /grammar/sessions/{sessionId} | 세션 삭제 | - -### 4.2 WebSocket API - -| Route | 설명 | -|-------------|-------------| -| $connect | JWT 토큰으로 연결 | -| $disconnect | 연결 해제 | -| $default | 스트리밍 메시지 처리 | - ---- - -## 5. 레벨별 문법 체크 - -### 5.1 학습 레벨 - -| 레벨 | 설명 | 피드백 스타일 | -|--------------|----|--------------------| -| BEGINNER | 초급 | 한국어 번역 + 쉬운 설명 | -| INTERMEDIATE | 중급 | 영어 위주 설명 | -| ADVANCED | 고급 | 상세한 문법 규칙 + 스타일 제안 | - -### 5.2 오류 유형 - -```mermaid -mindmap - root((문법 오류)) - 시제 - VERB_TENSE - 동사 시제 오류 - 일치 - SUBJECT_VERB_AGREEMENT - 주어-동사 일치 - 품사 - ARTICLE - 관사 오류 - PREPOSITION - 전치사 오류 - PRONOUN - 대명사 오류 - 구조 - WORD_ORDER - 어순 오류 - SENTENCE_STRUCTURE - 문장 구조 - 기타 - SPELLING - 철자 - PUNCTUATION - 구두점 - WORD_CHOICE - 어휘 선택 -``` - ---- - -## 6. 응답 포맷 - -### 6.1 문법 체크 응답 - -```json -{ - "originalSentence": "I goed to school yesterday", - "correctedSentence": "I went to school yesterday", - "score": 70, - "isCorrect": false, - "errors": [ - { - "type": "VERB_TENSE", - "original": "goed", - "corrected": "went", - "explanation": "'go'의 과거형은 'went'입니다 (불규칙 동사)", - "startIndex": 2, - "endIndex": 6 - } - ], - "feedback": "과거 시제를 잘 사용하려고 노력했네요! 불규칙 동사를 조금 더 연습해보세요." -} -``` - -### 6.2 대화 응답 - -```json -{ - "sessionId": "uuid", - "grammarCheck": { - /* 위와 동일 */ - }, - "aiResponse": "Great job! Your sentence structure is correct. Let's practice more complex sentences.", - "conversationTip": "Try using 'had gone' for past perfect tense." -} -``` - -### 6.3 스트리밍 이벤트 - -```json -// StartEvent -{ - "type": "start", - "sessionId": "uuid" -} - -// TokenEvent (실시간) -{ - "type": "token", - "token": "Great " -} -{ - "type": "token", - "token": "job!" -} - -// CompleteEvent (완료) -{ - "type": "complete", - "sessionId": "uuid", - "grammarCheck": { - ... - }, - "aiResponse": "...", - "conversationTip": "..." -} - -// ErrorEvent (오류 시) -{ - "type": "error", - "message": "..." -} -``` - ---- - -## 7. AWS Bedrock 통합 - -### 7.1 Claude 3 Haiku 설정 - -```java -public class BedrockGrammarCheckFactory { - private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; - private static final int MAX_TOKENS = 2048; - private static final String API_VERSION = "bedrock-2023-05-31"; -} -``` - -### 7.2 프롬프트 구조 - -**시스템 프롬프트 (초급):** - -``` -You are a friendly English grammar tutor for Korean speakers. -- Use simple English with Korean translations -- Be encouraging and supportive -- Explain grammar rules clearly -``` - -**사용자 프롬프트:** - -``` -Please check the grammar of this sentence: "{sentence}" - -Return JSON: -{ - "correctedSentence": "...", - "score": 0-100, - "isCorrect": boolean, - "errors": [...], - "feedback": "..." -} -``` - -### 7.3 스트리밍 응답 파싱 - -``` -[RESPONSE] -AI의 자연스러운 대화 응답 -[/RESPONSE] - -[GRAMMAR] -{ JSON 형식의 문법 체크 결과 } -[/GRAMMAR] - -[TIP] -학습 팁 -[/TIP] -``` - ---- - -## 8. 데이터 모델 - -### 8.1 GrammarSession - -```java - -@DynamoDbBean -public class GrammarSession { - String sessionId; - String userId; - String level; // BEGINNER, INTERMEDIATE, ADVANCED - String topic; // "Conversation Practice" - Integer messageCount; - String lastMessage; // 마지막 메시지 (100자 제한) - String createdAt; - String updatedAt; - Long ttl; // 30일 -} -``` - -**DynamoDB Keys:** - -- PK: `GSESSION#{userId}` | SK: `SESSION#{sessionId}` -- GSI1: `GSESSION#ALL` | `UPDATED#{timestamp}` (최신순 정렬) - -### 8.2 GrammarMessage - -```java - -@DynamoDbBean -public class GrammarMessage { - String messageId; - String sessionId; - String userId; - String role; // USER, ASSISTANT - String content; // 원본 메시지 - String correctedContent; // 교정된 메시지 (USER만) - String errorsJson; // 오류 목록 JSON - Integer grammarScore; - String feedback; - Boolean isCorrect; - Long ttl; // 30일 -} -``` - -**DynamoDB Keys:** - -- PK: `GSESSION#{userId}` | SK: `MSG#{timestamp}#{messageId}` -- GSI1: `GSESSION#{sessionId}` | `MSG#{timestamp}` - -### 8.3 GrammarConnection (WebSocket) - -```java - -@DynamoDbBean -public class GrammarConnection { - String connectionId; // API Gateway 연결 ID - String userId; // JWT에서 추출 - String connectedAt; - Long ttl; // 연결 타임아웃 -} -``` - ---- - -## 9. AWS Comprehend 분석 (선택적) - -```mermaid -flowchart LR - INPUT[입력 문장] --> SENTIMENT[감정 분석] - INPUT --> SYNTAX[구문 분석] - INPUT --> KEYPHRASE[핵심 구문] - INPUT --> LANGUAGE[언어 감지] - SENTIMENT --> OUTPUT[분석 결과] - SYNTAX --> OUTPUT - KEYPHRASE --> OUTPUT - LANGUAGE --> OUTPUT -``` - -**분석 항목:** - -- 감정: POSITIVE, NEGATIVE, NEUTRAL, MIXED -- 품사 태깅: NOUN, VERB, ADJ 등 -- 핵심 구문 추출 -- 문장 복잡도 추정 - ---- - -## 10. 서비스 레이어 - -### 10.1 서비스 구성 - -| Service | 역할 | -|----------------------------|----------------| -| GrammarCheckService | 단일 문장 문법 체크 | -| GrammarConversationService | 대화형 학습 + 스트리밍 | -| GrammarSessionQueryService | 세션 조회, 삭제 | -| BedrockGrammarCheckFactory | Bedrock API 호출 | - -### 10.2 대화 히스토리 관리 - -```java -// 최근 10개 메시지만 컨텍스트로 유지 -private static final int MAX_HISTORY_MESSAGES = 10; - -// 대화 히스토리 빌드 -String buildConversationHistory(String sessionId) { - // 최근 메시지 조회 - // USER: 내용 / ASSISTANT: 내용 형식으로 포맷 -} -``` - ---- - -## 11. 파일 구조 - -``` -domain/grammar/ -├── handler/ -│ ├── GrammarHandler.java -│ └── websocket/ -│ ├── GrammarStreamingConnectHandler.java -│ ├── GrammarStreamingDisconnectHandler.java -│ └── GrammarStreamingHandler.java -├── service/ -│ ├── GrammarCheckService.java -│ ├── GrammarConversationService.java -│ └── GrammarSessionQueryService.java -├── factory/ -│ ├── GrammarCheckFactory.java (interface) -│ └── BedrockGrammarCheckFactory.java -├── repository/ -│ ├── GrammarSessionRepository.java -│ └── GrammarConnectionRepository.java -├── model/ -│ ├── GrammarSession.java -│ ├── GrammarMessage.java -│ └── GrammarConnection.java -├── dto/ -│ ├── request/ -│ │ ├── GrammarCheckRequest.java -│ │ └── ConversationRequest.java -│ └── response/ -│ ├── GrammarCheckResponse.java -│ ├── ConversationResponse.java -│ ├── GrammarError.java -│ └── ComprehendAnalysis.java -├── streaming/ -│ ├── StreamingCallback.java -│ ├── StreamingEvent.java (sealed interface) -│ └── StreamingRequest.java -├── enums/ -│ ├── GrammarLevel.java -│ └── GrammarErrorType.java -└── constants/ - └── GrammarKey.java -``` - ---- - -## 12. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **API:** API Gateway REST + WebSocket -- **AI:** AWS Bedrock (Claude 3 Haiku) -- **NLP:** AWS Comprehend (선택적) -- **Database:** DynamoDB -- **Auth:** JWT (Cognito) -- **Pattern:** Factory, Callback, Sealed Interface (Java 17+) diff --git a/docs/domain-reports/STATS-DOMAIN-REPORT.md b/docs/domain-reports/STATS-DOMAIN-REPORT.md deleted file mode 100644 index 3ca3d3ff..00000000 --- a/docs/domain-reports/STATS-DOMAIN-REPORT.md +++ /dev/null @@ -1,379 +0,0 @@ -# Stats Domain 세부 보고서 - -## 1. 개요 - -Stats 도메인은 사용자의 학습 활동을 추적하고 통계를 집계하는 시스템입니다. DynamoDB Streams와 EventBridge를 활용한 이벤트 기반 아키텍처로 실시간 통계 업데이트를 제공합니다. - ---- - -## 2. 전체 아키텍처 - -```mermaid -flowchart TB - subgraph Triggers["트리거"] - TEST[테스트 완료] - DAILY[일일 학습] - GAME[게임 종료] - SCHEDULE[스케줄러
매일 자정] - end - - subgraph Processing["처리"] - STREAM[StatsStreamHandler
DynamoDB Streams] - SERVICE[StatsService
Write-through] - SCHEDULED[ScheduledStatsHandler
EventBridge] - end - - subgraph Storage["저장소"] - DDB[(DynamoDB
UserStats)] - end - - subgraph Query["조회"] - API[UserStatsHandler
REST API] - end - - TEST --> STREAM - DAILY --> SERVICE - GAME --> SERVICE - SCHEDULE --> SCHEDULED - STREAM --> DDB - SERVICE --> DDB - SCHEDULED --> DDB - DDB --> API -``` - ---- - -## 3. 통계 집계 방식 - -### 3.1 집계 레벨 - -```mermaid -flowchart LR - subgraph Levels["통계 집계 레벨"] - DAILY["일별
DAILY#2026-01-16"] - WEEKLY["주별
WEEKLY#2026-W03"] - MONTHLY["월별
MONTHLY#2026-01"] - TOTAL["전체
TOTAL"] - end - - EVENT[이벤트 발생] --> DAILY - EVENT --> WEEKLY - EVENT --> MONTHLY - EVENT --> TOTAL -``` - -### 3.2 Atomic Counter 패턴 - -```java -// 모든 레벨에 동시 업데이트 (원자적) -UpdateExpression: -SET correctAnswers = if_not_exists(correctAnswers, 0) + :correct, -incorrectAnswers = - -if_not_exists(incorrectAnswers, 0) +:incorrect, -testsCompleted = - -if_not_exists(testsCompleted, 0) +1, -updatedAt =:now -``` - ---- - -## 4. 이벤트 기반 통계 업데이트 - -### 4.1 DynamoDB Streams 처리 - -```mermaid -sequenceDiagram - participant Test as TestResult 저장 - participant Stream as DynamoDB Streams - participant Handler as StatsStreamHandler - participant DB as UserStats - Test ->> Stream: INSERT 이벤트 - Stream ->> Handler: 트리거 - Handler ->> Handler: PK/SK 패턴 확인
(TEST#userId, RESULT#timestamp) - Handler ->> DB: incrementTestStats() - Handler ->> DB: updateStudyStreak() - Handler ->> Handler: checkAndAwardBadges() -``` - -### 4.2 Write-through 패턴 - -```mermaid -sequenceDiagram - participant API as DailyStudyHandler - participant Service as StatsService - participant DB as UserStats - Note over API, DB: 단어 학습 완료 시 - API ->> Service: recordWordsLearned() - Service ->> DB: incrementWordsLearned()
(DAILY, WEEKLY, MONTHLY, TOTAL) - Service ->> DB: updateStudyStreak() -``` - ---- - -## 5. API 엔드포인트 - -### 5.1 통계 조회 API - -| Method | Endpoint | 설명 | 파라미터 | -|--------|----------------|---------|------------------| -| GET | /stats/daily | 일별 통계 | ?date=YYYY-MM-DD | -| GET | /stats/weekly | 주별 통계 | ?week=YYYY-Www | -| GET | /stats/monthly | 월별 통계 | ?month=YYYY-MM | -| GET | /stats/total | 전체 통계 | - | -| GET | /stats/history | 일별 히스토리 | ?cursor, ?limit | - -### 5.2 응답 예시 - -```json -{ - "periodType": "DAILY", - "period": "2026-01-16", - "testsCompleted": 3, - "questionsAnswered": 45, - "correctAnswers": 38, - "incorrectAnswers": 7, - "successRate": 84.44, - "newWordsLearned": 50, - "wordsReviewed": 5 -} -``` - -**전체 통계 추가 필드:** - -```json -{ - "currentStreak": 7, - "longestStreak": 14, - "lastStudyDate": "2026-01-16", - "gamesPlayed": 10, - "gamesWon": 3, - "totalGameScore": 450 -} -``` - ---- - -## 6. 연속 학습 (Streak) 시스템 - -### 6.1 스트릭 계산 로직 - -```mermaid -flowchart TB - START[학습 활동 발생] --> CHECK{lastStudyDate
확인} - CHECK -->|null| NEW["currentStreak = 1
longestStreak = 1"] - CHECK -->|오늘| SAME[변경 없음
이미 오늘 학습] - CHECK -->|어제| INCREMENT["currentStreak++
longestStreak = max()"] - CHECK -->|2일+ 전| RESET["currentStreak = 1
longestStreak 유지"] - NEW --> UPDATE[DB 업데이트] - INCREMENT --> UPDATE - RESET --> UPDATE -``` - -### 6.2 스트릭 리셋 (스케줄러) - -```java -// EventBridge: 매일 자정 실행 -@Scheduled -public void resetStreaks() { - String yesterday = LocalDate.now().minusDays(1).toString(); - // lastStudyDate != yesterday인 사용자의 스트릭 리셋 - // 비용 최적화로 클라이언트 측 계산 권장 -} -``` - ---- - -## 7. 데이터 모델 - -### 7.1 UserStats - -```java - -@DynamoDbBean -public class UserStats { - // 키 - String pk; // USER#{userId}#STATS - String sk; // DAILY#{date} | WEEKLY#{week} | MONTHLY#{month} | TOTAL - - // 메타데이터 - String userId; - String periodType; // DAILY, WEEKLY, MONTHLY, TOTAL - String period; // 2026-01-16, 2026-W03, 2026-01, TOTAL - - // 테스트 통계 - Integer testsCompleted; - Integer questionsAnswered; - Integer correctAnswers; - Integer incorrectAnswers; - Double successRate; - - // 학습 통계 - Integer newWordsLearned; - Integer wordsReviewed; - Integer wordsMastered; - - // 스트릭 (TOTAL만) - Integer currentStreak; - Integer longestStreak; - String lastStudyDate; - - // 게임 통계 (TOTAL만) - Integer gamesPlayed; - Integer gamesWon; - Integer correctGuesses; - Integer totalGameScore; - Integer quickGuesses; // 5초 이내 정답 - Integer perfectDraws; // 전원 정답 유도 - - // 타임스탬프 - String createdAt; - String updatedAt; -} -``` - -### 7.2 DynamoDB 키 구조 - -| 필드 | 패턴 | 예시 | -|---------|------------------------|-------------------| -| PK | USER#{userId}#STATS | USER#abc123#STATS | -| SK (일별) | DAILY#{date} | DAILY#2026-01-16 | -| SK (주별) | WEEKLY#{year}-W{week} | WEEKLY#2026-W03 | -| SK (월별) | MONTHLY#{year}-{month} | MONTHLY#2026-01 | -| SK (전체) | TOTAL | TOTAL | - ---- - -## 8. 통계 메트릭 - -### 8.1 테스트 메트릭 - -| 메트릭 | 설명 | 업데이트 시점 | -|-------------------|----------|---------| -| testsCompleted | 완료 테스트 수 | 테스트 제출 | -| questionsAnswered | 총 문제 수 | 테스트 제출 | -| correctAnswers | 정답 수 | 테스트 제출 | -| incorrectAnswers | 오답 수 | 테스트 제출 | -| successRate | 정답률 (%) | 조회 시 계산 | - -### 8.2 학습 메트릭 - -| 메트릭 | 설명 | 업데이트 시점 | -|-----------------|----------|---------| -| newWordsLearned | 신규 학습 단어 | 일일학습 완료 | -| wordsReviewed | 복습 단어 | 일일학습 완료 | -| wordsMastered | 마스터 단어 | 상태 변경 시 | - -### 8.3 게임 메트릭 - -| 메트릭 | 설명 | 업데이트 시점 | -|----------------|----------|---------| -| gamesPlayed | 참여 게임 수 | 게임 종료 | -| gamesWon | 1등 횟수 | 게임 종료 | -| correctGuesses | 정답 횟수 | 게임 종료 | -| totalGameScore | 누적 점수 | 게임 종료 | -| quickGuesses | 5초 내 정답 | 게임 종료 | -| perfectDraws | 전원 정답 유도 | 게임 종료 | - ---- - -## 9. 히스토리 조회 - -### 9.1 페이지네이션 - -```mermaid -flowchart LR - REQUEST["GET /stats/history
?limit=7&cursor=..."] - QUERY["Query
PK = USER#id#STATS
SK begins_with DAILY#
scanIndexForward = false"] - ENRICH["DailyStudy 조회
isCompleted 추가"] - RESPONSE["PaginatedResult
items, nextCursor, hasMore"] - REQUEST --> QUERY --> ENRICH --> RESPONSE -``` - -### 9.2 응답 구조 - -```json -{ - "history": [ - { - "period": "2026-01-16", - "testsCompleted": 2, - "questionsAnswered": 30, - "correctAnswers": 25, - "incorrectAnswers": 5, - "successRate": 83.33, - "newWordsLearned": 50, - "wordsReviewed": 5, - "isCompleted": true - } - ], - "nextCursor": "base64encoded...", - "hasMore": true -} -``` - ---- - -## 10. 배지 연동 - -### 10.1 자동 배지 체크 - -```mermaid -flowchart TB - STREAM[StatsStreamHandler] --> CHECK[배지 조건 체크] - CHECK --> PERFECT{만점 테스트?} - PERFECT -->|Yes| BADGE1[PERFECT_SCORE 배지] - CHECK --> STATS[전체 통계 조회] - STATS --> BADGESERVICE[BadgeService.checkAndAwardBadges] - BADGESERVICE --> AWARD[조건 충족 배지 부여] -``` - -### 10.2 배지 조건 예시 - -| 배지 | 조건 | 통계 필드 | -|--------------|----------|----------------------| -| STREAK_7 | 7일 연속 학습 | currentStreak >= 7 | -| ACCURACY_90 | 정확도 90% | successRate >= 90 | -| TEST_10 | 10회 테스트 | testsCompleted >= 10 | -| GAME_10_WINS | 10번 1등 | gamesWon >= 10 | - ---- - -## 11. 파일 구조 - -``` -domain/stats/ -├── handler/ -│ ├── UserStatsHandler.java (REST API) -│ ├── StatsStreamHandler.java (DynamoDB Streams) -│ └── ScheduledStatsHandler.java (EventBridge) -├── service/ -│ └── StatsService.java -├── repository/ -│ └── UserStatsRepository.java -├── model/ -│ └── UserStats.java -└── constants/ - └── StatsKey.java -``` - ---- - -## 12. 성능 최적화 - -| 최적화 | 기법 | 효과 | -|--------------------------|------------------|-------------------| -| 원자적 업데이트 | UpdateExpression | Race condition 방지 | -| 비동기 처리 | DynamoDB Streams | API 응답 속도 향상 | -| Cursor 페이지네이션 | lastEvaluatedKey | 대용량 히스토리 처리 | -| Strongly Consistent Read | 히스토리 조회 | 데이터 정합성 | - ---- - -## 13. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **Database:** DynamoDB (Single Table Design) -- **Event:** DynamoDB Streams, EventBridge -- **Pattern:** Atomic Counter, Write-through, Event-driven diff --git a/docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md b/docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md deleted file mode 100644 index 7ee2c90e..00000000 --- a/docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md +++ /dev/null @@ -1,504 +0,0 @@ -# Vocabulary Domain 세부 보고서 - -## 1. 개요 - -Vocabulary 도메인은 AWS Lambda와 DynamoDB를 기반으로 한 영어 단어 학습 시스템입니다. SM-2 Spaced Repetition 알고리즘과 CQRS 패턴을 적용하여 과학적이고 효율적인 단어 -암기를 지원합니다. - ---- - -## 2. 전체 아키텍처 - -```mermaid -flowchart TB - subgraph Client["클라이언트"] - APP[Mobile/Web App] - end - - subgraph Gateway["API Gateway"] - REST[REST API
HTTP] - end - - subgraph Lambda["Lambda Handlers"] - direction TB - WORD[WordHandler] - USERWORD[UserWordHandler] - DAILY[DailyStudyHandler] - TEST[TestHandler] - GROUP[WordGroupHandler] - VOICE[VoiceHandler] - STATS[StatisticsHandler
SQS Consumer] - end - - subgraph Services["서비스 레이어 (CQRS)"] - direction TB - CMD[Command Services
쓰기 작업] - QUERY[Query Services
읽기 작업] - end - - subgraph External["외부 서비스"] - POLLY[AWS Polly
TTS] - SNS[AWS SNS] - SQS[AWS SQS] - S3[(S3
음성 캐시)] - end - - subgraph Storage["데이터 저장소"] - DDB[(DynamoDB)] - end - - APP --> REST - REST --> WORD & USERWORD & DAILY & TEST & GROUP & VOICE - WORD & USERWORD & DAILY & TEST & GROUP --> CMD & QUERY - CMD & QUERY --> DDB - VOICE --> POLLY --> S3 - TEST --> SNS --> SQS --> STATS - STATS --> DDB -``` - ---- - -## 3. 일일 학습 시스템 - -### 3.1 일일 학습 흐름 - -```mermaid -flowchart TB - subgraph DailyStudyFlow["일일 학습 흐름"] - START[GET /vocab/daily] --> CHECK{기존 학습
존재?} - CHECK -->|Yes| RETURN[기존 학습 반환] - CHECK -->|No| CREATE[새 학습 생성] - CREATE --> REVIEW["복습 단어 5개 선정
(nextReviewAt <= today)"] - REVIEW --> NEW["신규 단어 50개 선정
(미학습 + 해당 레벨)"] - NEW --> SAVE[DailyStudy 저장] - SAVE --> RETURN - RETURN --> LEARN[학습 진행] - LEARN --> MARK["POST .../learned
단어별 학습 완료"] - MARK --> PROGRESS{50개 완료?} - PROGRESS -->|No| LEARN - PROGRESS -->|Yes| COMPLETE["isCompleted = true
배지 체크"] - end -``` - -### 3.2 Daily Study API - -| Method | Endpoint | 설명 | -|--------|-------------------------------------|-----------------| -| GET | /vocab/daily | 오늘의 학습 단어 조회/생성 | -| POST | /vocab/daily/words/{wordId}/learned | 단어 학습 완료 처리 | - -### 3.3 응답 예시 - -```json -{ - "userId": "user123", - "date": "2026-01-16", - "newWordIds": [ - "word1", - "word2", - ... - ], - "reviewWordIds": [ - "word51", - "word52", - ... - ], - "learnedWordIds": [], - "totalWords": 55, - "learnedCount": 0, - "isCompleted": false, - "progress": { - "percentage": 0, - "learned": 0, - "total": 55 - } -} -``` - ---- - -## 4. SM-2 Spaced Repetition 알고리즘 - -### 4.1 학습 상태 전이 - -```mermaid -stateDiagram-v2 - [*] --> NEW: 단어 추가 - NEW --> LEARNING: 첫 학습 - LEARNING --> LEARNING: 오답 - LEARNING --> REVIEWING: 2회 연속 정답 - REVIEWING --> LEARNING: 오답 - REVIEWING --> MASTERED: 5회 연속 정답 - MASTERED --> LEARNING: 오답 - MASTERED --> MASTERED: 정답 유지 -``` - -### 4.2 상태별 로직 - -| 상태 | 조건 | 정답 시 | 오답 시 | -|---------------|-------------|-----------------------------|---------------------------| -| **NEW** | 신규 단어 | LEARNING, rep=1, interval=1 | LEARNING, easeFactor-=0.2 | -| **LEARNING** | rep < 2 | rep++, interval 계산 | rep=0, interval=1 | -| **REVIEWING** | 2 ≤ rep < 5 | rep++, interval 증가 | rep=0, LEARNING | -| **MASTERED** | rep ≥ 5 | interval 증가, 유지 | rep=0, REVIEWING | - -### 4.3 복습 간격 계산 - -```mermaid -flowchart LR - REP1["rep = 1
interval = 1일"] - REP2["rep = 2
interval = 6일"] - REP3["rep >= 3
interval = interval × easeFactor"] - REP1 --> REP2 --> REP3 -``` - -**핵심 변수:** - -- `repetitions`: 연속 정답 횟수 (0~∞) -- `interval`: 복습 간격 (일 단위) -- `easeFactor`: 난이도 계수 (1.3~2.5, 기본 2.5) -- `nextReviewAt`: 다음 복습 예정일 - ---- - -## 5. 테스트 시스템 - -### 5.1 테스트 흐름 - -```mermaid -sequenceDiagram - participant Client - participant Handler as TestHandler - participant Service as TestCommandService - participant DB as DynamoDB - participant SNS as AWS SNS - Client ->> Handler: POST /vocab/tests/start - Handler ->> Service: startTest(userId, testType) - Service ->> DB: 오늘의 학습 단어 조회 - Service ->> Service: 4지선다 문제 생성 - Service -->> Client: 문제 목록 반환 - Note over Client: 사용자 답변 입력 - Client ->> Handler: POST /vocab/tests/submit - Handler ->> Service: submitTest(answers) - Service ->> DB: 결과 저장 - Service ->> SNS: 결과 발행 (비동기) - Service -->> Client: 테스트 결과 - Note over SNS, DB: 비동기 통계 처리 - SNS ->> DB: 통계 업데이트 -``` - -### 5.2 문제 생성 알고리즘 - -```mermaid -flowchart TB - START[문제 생성 시작] --> WORDS[일일 학습 단어 로드] - WORDS --> GROUP[레벨별 그룹화] - GROUP --> LOOP[각 단어마다] - LOOP --> CORRECT["정답 = 해당 단어의
한국어 뜻"] - CORRECT --> DIST["오답 3개 선정
(동일 레벨 단어)"] - DIST --> SHUFFLE[4개 보기 셔플] - SHUFFLE --> NEXT{다음 단어?} - NEXT -->|Yes| LOOP - NEXT -->|No| RETURN[문제 목록 반환] -``` - -### 5.3 Test API - -| Method | Endpoint | 설명 | -|--------|-------------------------------|------------| -| POST | /vocab/tests/start | 테스트 시작 | -| POST | /vocab/tests/submit | 테스트 제출 | -| GET | /vocab/tests/results | 테스트 결과 목록 | -| GET | /vocab/tests/results/{testId} | 테스트 상세 결과 | -| GET | /vocab/tests/tested-words | 최근 테스트된 단어 | - ---- - -## 6. 단어 관리 시스템 - -### 6.1 Word API - -| Method | Endpoint | 설명 | -|--------|------------------------|----------------------------| -| GET | /vocab/words | 단어 목록 (level, category 필터) | -| POST | /vocab/words | 단어 등록 | -| GET | /vocab/words/{wordId} | 단어 상세 | -| PUT | /vocab/words/{wordId} | 단어 수정 | -| DELETE | /vocab/words/{wordId} | 단어 삭제 | -| GET | /vocab/words/search | 키워드 검색 | -| POST | /vocab/words/batch | 배치 등록 (최대 100개) | -| POST | /vocab/words/batch/get | 배치 조회 | - -### 6.2 User Word API - -| Method | Endpoint | 설명 | -|--------|-----------------------------------|-------------| -| GET | /vocab/user-words | 사용자 단어 목록 | -| GET | /vocab/user-words/{wordId} | 사용자 단어 상세 | -| PUT | /vocab/user-words/{wordId} | 정답/오답 기록 | -| PATCH | /vocab/user-words/{wordId}/tag | 북마크, 난이도 설정 | -| PATCH | /vocab/user-words/{wordId}/status | 상태 수동 변경 | -| GET | /vocab/wrong-answers | 오답 단어 목록 | - -### 6.3 Word Group API - -| Method | Endpoint | 설명 | -|--------|----------------------------------------|--------| -| POST | /vocab/groups | 단어장 생성 | -| GET | /vocab/groups | 단어장 목록 | -| GET | /vocab/groups/{groupId} | 단어장 상세 | -| PUT | /vocab/groups/{groupId} | 단어장 수정 | -| DELETE | /vocab/groups/{groupId} | 단어장 삭제 | -| POST | /vocab/groups/{groupId}/words/{wordId} | 단어 추가 | -| DELETE | /vocab/groups/{groupId}/words/{wordId} | 단어 제거 | - ---- - -## 7. TTS 음성 합성 - -### 7.1 음성 생성 흐름 - -```mermaid -flowchart TB - REQUEST["POST /vocab/synthesize
{wordId, voice, type}"] - CHECK{S3 캐시
존재?} - REQUEST --> CHECK - CHECK -->|Yes| PRESIGN[Presigned URL 생성] - CHECK -->|No| POLLY[AWS Polly 호출] - POLLY --> SAVE[S3 저장] - SAVE --> PRESIGN - PRESIGN --> RESPONSE[URL 반환] -``` - -### 7.2 Voice API - -```json -// Request -{ - "wordId": "uuid", - "voice": "MALE", - // MALE | FEMALE - "type": "WORD" - // WORD | EXAMPLE -} - -// Response -{ - "url": "https://s3...presigned-url", - "expiresIn": 3600 -} -``` - ---- - -## 8. 데이터 모델 - -### 8.1 Word - -```java - -@DynamoDbBean -public class Word { - String wordId; // UUID - String english; // 영어 단어 - String korean; // 한국어 뜻 - String example; // 예문 - String level; // BEGINNER | INTERMEDIATE | ADVANCED - String category; // DAILY | BUSINESS | ACADEMIC | TRAVEL | TECHNOLOGY - String maleVoiceKey; // S3 음성 키 - String femaleVoiceKey; - String maleExampleVoiceKey; - String femaleExampleVoiceKey; -} -``` - -**DynamoDB Keys:** - -| Key | 패턴 | 용도 | -|--------|---------------------|----------| -| PK | WORD#{wordId} | 기본 조회 | -| SK | METADATA | - | -| GSI1PK | LEVEL#{level} | 레벨별 조회 | -| GSI2PK | CATEGORY#{category} | 카테고리별 조회 | - -### 8.2 UserWord - -```java - -@DynamoDbBean -public class UserWord { - String userId; - String wordId; - String status; // NEW | LEARNING | REVIEWING | MASTERED - - // SM-2 알고리즘 필드 - Integer interval; // 복습 간격 (일) - Double easeFactor; // 난이도 계수 (1.3~2.5) - Integer repetitions; // 연속 정답 횟수 - String nextReviewAt; // 다음 복습일 (YYYY-MM-DD) - - // 통계 - Integer correctCount; // 누적 정답 - Integer incorrectCount; // 누적 오답 - - // 사용자 설정 - Boolean bookmarked; // 북마크 - Boolean favorite; // 즐겨찾기 - String difficulty; // EASY | NORMAL | HARD -} -``` - -**DynamoDB Keys:** - -| Key | 패턴 | 용도 | -|--------|--------------------------|--------------| -| PK | USER#{userId} | 기본 조회 | -| SK | WORD#{wordId} | - | -| GSI1PK | USER#{userId}#REVIEW | 복습 예정 단어 | -| GSI1SK | DATE#{nextReviewAt} | - | -| GSI2PK | USER#{userId}#STATUS | 상태별 조회 | -| GSI2SK | STATUS#{status} | - | -| GSI3PK | USER#{userId}#BOOKMARKED | 북마크 (Sparse) | - -### 8.3 DailyStudy - -```java - -@DynamoDbBean -public class DailyStudy { - String userId; - String date; // YYYY-MM-DD - List newWordIds; // 신규 단어 50개 - List reviewWordIds; // 복습 단어 5개 - List learnedWordIds; // 학습 완료 단어 - Integer totalWords; // 총 단어 수 (55) - Integer learnedCount; // 학습 완료 수 - Boolean isCompleted; // 완료 여부 -} -``` - -### 8.4 TestResult - -```java - -@DynamoDbBean -public class TestResult { - String testId; - String userId; - String testType; // DAILY | WEEKLY | CUSTOM - Integer totalQuestions; - Integer correctAnswers; - Integer incorrectAnswers; - Double successRate; - List testedWordIds; - List incorrectWordIds; - String startedAt; - String completedAt; -} -``` - ---- - -## 9. 서비스 아키텍처 (CQRS) - -### 9.1 Command Services (쓰기) - -```mermaid -flowchart TB - subgraph Commands["Command Services"] - WC[WordCommandService
단어 생성/수정/삭제] - UC[UserWordCommandService
학습 상태 업데이트] - DC[DailyStudyCommandService
일일 학습 관리] - TC[TestCommandService
테스트 생성/제출] - GC[WordGroupCommandService
단어장 관리] - end -``` - -### 9.2 Query Services (읽기) - -```mermaid -flowchart TB - subgraph Queries["Query Services"] - WQ[WordQueryService
단어 조회/검색] - UQ[UserWordQueryService
학습 현황 조회] - DQ[DailyStudyQueryService
일일 학습 조회] - TQ[TestQueryService
테스트 결과 조회] - end -``` - ---- - -## 10. 성능 최적화 - -| 최적화 | 기법 | 효과 | -|---------------------|------------------------|-----------------| -| N+1 방지 | BatchGetItem (100개 단위) | DB 호출 90% 감소 | -| TTS 캐싱 | S3 + Presigned URL | Polly 호출 90% 절감 | -| 페이지네이션 | Cursor 기반 (Base64) | 대용량 데이터 처리 | -| Sparse Index | GSI3 (북마크 전용) | 인덱스 크기 최소화 | -| 비동기 통계 | SNS/SQS | API 응답 속도 향상 | -| Strongly Consistent | DailyStudy 조회 | 데이터 정합성 | - ---- - -## 11. 파일 구조 - -``` -domain/vocabulary/ -├── handler/ -│ ├── WordHandler.java -│ ├── UserWordHandler.java -│ ├── DailyStudyHandler.java -│ ├── TestHandler.java -│ ├── WordGroupHandler.java -│ ├── VoiceHandler.java -│ ├── StatsHandler.java -│ └── StatisticsHandler.java (SQS) -├── service/ -│ ├── WordCommandService.java -│ ├── WordQueryService.java -│ ├── UserWordCommandService.java -│ ├── UserWordQueryService.java -│ ├── TestCommandService.java -│ ├── TestQueryService.java -│ ├── DailyStudyCommandService.java -│ ├── DailyStudyQueryService.java -│ ├── WordGroupCommandService.java -│ ├── StatsService.java -│ └── StatisticsService.java -├── repository/ -│ ├── WordRepository.java -│ ├── UserWordRepository.java -│ ├── DailyStudyRepository.java -│ ├── TestResultRepository.java -│ └── WordGroupRepository.java -├── model/ -│ ├── Word.java -│ ├── UserWord.java -│ ├── DailyStudy.java -│ ├── TestResult.java -│ └── WordGroup.java -├── state/ -│ ├── WordState.java (interface) -│ ├── NewState.java -│ ├── LearningState.java -│ ├── ReviewingState.java -│ ├── MasteredState.java -│ ├── SpacedRepetitionContext.java -│ └── WordStateFactory.java -└── enums/ - ├── WordStatus.java - ├── WordCategory.java - └── TestType.java -``` - ---- - -## 12. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **Database:** DynamoDB (Single Table Design) -- **TTS:** AWS Polly (남성/여성 음성) -- **Storage:** S3 (음성 캐시) -- **Messaging:** SNS/SQS (비동기 통계) -- **Pattern:** CQRS, State, Repository, Factory diff --git a/docs/frontend-notification-integration-guide.md b/docs/frontend-notification-integration-guide.md new file mode 100644 index 00000000..5c647bcf --- /dev/null +++ b/docs/frontend-notification-integration-guide.md @@ -0,0 +1,747 @@ +# 프론트엔드 실시간 알림 연동 가이드 + +## 개요 + +이 문서는 백엔드 알림 시스템과 프론트엔드를 연동하기 위한 가이드입니다. +**Server-Sent Events (SSE)** 를 사용하여 실시간 알림을 수신합니다. + +--- + +## 연결 방식 + +### SSE (Server-Sent Events) 사용 + +- WebSocket과 달리 **단방향 통신** (서버 → 클라이언트) +- HTTP 기반으로 별도 프로토콜 핸들링 불필요 +- 브라우저 `EventSource` API로 간단히 구현 가능 +- 연결 끊김 시 자동 재연결 지원 + +--- + +## 연결 엔드포인트 + +``` +GET {NOTIFICATION_FUNCTION_URL}?userId={userId} +``` + +| 파라미터 | 설명 | 예시 | +|---------|------|------| +| `userId` | 로그인한 사용자 ID | `user-123` | + +> ⚠️ **NOTIFICATION_FUNCTION_URL**은 배포 환경별로 다릅니다. 환경변수로 관리하세요. + +--- + +## 기본 연결 구현 + +### JavaScript (Vanilla) + +```javascript +const connectNotifications = (userId) => { + const url = `${NOTIFICATION_FUNCTION_URL}?userId=${userId}`; + const eventSource = new EventSource(url); + + // 알림 수신 + eventSource.onmessage = (event) => { + const notification = JSON.parse(event.data); + handleNotification(notification); + }; + + // 연결 성공 + eventSource.onopen = () => { + console.log('알림 연결 성공'); + }; + + // 에러 처리 + eventSource.onerror = (error) => { + console.error('알림 연결 에러:', error); + // EventSource는 자동으로 재연결을 시도합니다 + }; + + return eventSource; +}; + +// 연결 해제 +const disconnect = (eventSource) => { + eventSource.close(); +}; +``` + +### React Hook 예시 + +```typescript +import { useEffect, useCallback, useRef } from 'react'; + +interface Notification { + notificationId: string; + type: NotificationType; + userId: string; + payload: Record; + createdAt: string; +} + +type NotificationType = + | 'BADGE_EARNED' + | 'DAILY_COMPLETE' + | 'STREAK_REMINDER' + | 'TEST_COMPLETE' + | 'NEWS_QUIZ_COMPLETE' + | 'GAME_END' + | 'GAME_STREAK' + | 'OPIC_COMPLETE'; + +export const useNotifications = ( + userId: string | null, + onNotification: (notification: Notification) => void +) => { + const eventSourceRef = useRef(null); + + const connect = useCallback(() => { + if (!userId) return; + + const url = `${process.env.NEXT_PUBLIC_NOTIFICATION_URL}?userId=${userId}`; + const eventSource = new EventSource(url); + + eventSource.onmessage = (event) => { + // Heartbeat 무시 + if (event.data === 'HEARTBEAT') return; + + try { + const notification: Notification = JSON.parse(event.data); + onNotification(notification); + } catch (e) { + console.error('알림 파싱 실패:', e); + } + }; + + eventSource.onerror = () => { + console.log('알림 연결 끊김, 재연결 시도 중...'); + }; + + eventSourceRef.current = eventSource; + }, [userId, onNotification]); + + const disconnect = useCallback(() => { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + }, []); + + useEffect(() => { + connect(); + return () => disconnect(); + }, [connect, disconnect]); + + return { disconnect, reconnect: connect }; +}; +``` + +### React 컴포넌트 사용 예시 + +```tsx +const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { user } = useAuth(); + const [notifications, setNotifications] = useState([]); + + const handleNotification = useCallback((notification: Notification) => { + setNotifications(prev => [notification, ...prev]); + + // 타입별 처리 + switch (notification.type) { + case 'BADGE_EARNED': + showBadgeToast(notification.payload); + break; + case 'DAILY_COMPLETE': + showStreakCelebration(notification.payload); + break; + case 'GAME_END': + showGameResult(notification.payload); + break; + // ... 기타 타입 + } + }, []); + + useNotifications(user?.id ?? null, handleNotification); + + return ( + + {children} + + ); +}; +``` + +--- + +## 알림 타입 및 Payload 구조 + +### 공통 응답 구조 + +```typescript +interface Notification { + notificationId: string; // "notif-xxxxxxxx" 형식 + type: NotificationType; // 알림 타입 + userId: string; // 대상 사용자 ID + payload: object; // 타입별 상세 데이터 + createdAt: string; // ISO-8601 형식 (예: "2024-01-15T09:30:00Z") +} +``` + +--- + +### 1. BADGE_EARNED (배지 획득) + +사용자가 새로운 배지를 획득했을 때 + +```typescript +interface BadgeEarnedPayload { + badgeType: string; // 배지 타입 코드 + badgeName: string; // 배지 이름 + description: string; // 배지 설명 + iconUrl: string; // 배지 아이콘 URL +} +``` + +**예시:** +```json +{ + "notificationId": "notif-a1b2c3d4", + "type": "BADGE_EARNED", + "userId": "user-123", + "payload": { + "badgeType": "STREAK_7", + "badgeName": "7일 연속 학습", + "description": "7일 연속으로 학습을 완료했습니다!", + "iconUrl": "https://cdn.example.com/badges/streak-7.png" + }, + "createdAt": "2024-01-15T09:30:00Z" +} +``` + +--- + +### 2. DAILY_COMPLETE (일일 학습 완료) + +오늘의 단어 학습을 모두 완료했을 때 + +```typescript +interface DailyCompletePayload { + date: string; // 학습 완료 날짜 (YYYY-MM-DD) + wordsLearned: number; // 오늘 학습한 단어 수 + totalWords: number; // 총 학습 단어 수 + currentStreak: number; // 현재 연속 학습 일수 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-e5f6g7h8", + "type": "DAILY_COMPLETE", + "userId": "user-123", + "payload": { + "date": "2024-01-15", + "wordsLearned": 20, + "totalWords": 150, + "currentStreak": 5 + }, + "createdAt": "2024-01-15T14:00:00Z" +} +``` + +--- + +### 3. STREAK_REMINDER (연속 학습 리마인더) + +매일 21:00 KST에 오늘 학습을 아직 하지 않은 사용자에게 발송 + +```typescript +interface StreakReminderPayload { + currentStreak: number; // 현재 연속 학습 일수 + message: string; // 리마인더 메시지 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-i9j0k1l2", + "type": "STREAK_REMINDER", + "userId": "user-123", + "payload": { + "currentStreak": 5, + "message": "오늘 학습을 완료하고 6일 연속 학습을 달성하세요!" + }, + "createdAt": "2024-01-15T12:00:00Z" +} +``` + +--- + +### 4. TEST_COMPLETE (단어 테스트 완료) + +단어 테스트를 완료했을 때 + +```typescript +interface TestCompletePayload { + testId: string; // 테스트 ID + score: number; // 점수 (0-100) + correctCount: number; // 맞힌 문제 수 + totalCount: number; // 전체 문제 수 + isPerfect: boolean; // 만점 여부 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-m3n4o5p6", + "type": "TEST_COMPLETE", + "userId": "user-123", + "payload": { + "testId": "test-abc123", + "score": 85, + "correctCount": 17, + "totalCount": 20, + "isPerfect": false + }, + "createdAt": "2024-01-15T10:30:00Z" +} +``` + +--- + +### 5. NEWS_QUIZ_COMPLETE (뉴스 퀴즈 완료) + +뉴스 기사 퀴즈를 완료했을 때 + +```typescript +interface NewsQuizCompletePayload { + articleId: string; // 뉴스 기사 ID + articleTitle: string; // 기사 제목 + score: number; // 점수 (0-100) + correctCount: number; // 맞힌 문제 수 + totalCount: number; // 전체 문제 수 + isPerfect: boolean; // 만점 여부 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-q7r8s9t0", + "type": "NEWS_QUIZ_COMPLETE", + "userId": "user-123", + "payload": { + "articleId": "article-xyz789", + "articleTitle": "Tech Giants Report Strong Q4 Earnings", + "score": 100, + "correctCount": 5, + "totalCount": 5, + "isPerfect": true + }, + "createdAt": "2024-01-15T11:00:00Z" +} +``` + +--- + +### 6. GAME_END (게임 종료) + +캐치마인드 게임이 종료되었을 때 + +```typescript +interface GameEndPayload { + roomId: string; // 게임 방 ID + gameSessionId: string; // 게임 세션 ID + rank: number; // 최종 순위 + totalPlayers: number; // 전체 플레이어 수 + score: number; // 획득 점수 + isWinner: boolean; // 1등 여부 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-u1v2w3x4", + "type": "GAME_END", + "userId": "user-123", + "payload": { + "roomId": "room-game-001", + "gameSessionId": "session-abc", + "rank": 1, + "totalPlayers": 4, + "score": 2500, + "isWinner": true + }, + "createdAt": "2024-01-15T15:30:00Z" +} +``` + +--- + +### 7. GAME_STREAK (게임 연속 정답) + +게임 중 연속 정답을 달성했을 때 + +```typescript +interface GameStreakPayload { + roomId: string; // 게임 방 ID + streakCount: number; // 연속 정답 횟수 + bonusPoints: number; // 보너스 점수 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-y5z6a7b8", + "type": "GAME_STREAK", + "userId": "user-123", + "payload": { + "roomId": "room-game-001", + "streakCount": 5, + "bonusPoints": 500 + }, + "createdAt": "2024-01-15T15:25:00Z" +} +``` + +--- + +### 8. OPIC_COMPLETE (OPIc 연습 완료) + +OPIc 스피킹 연습 세션을 완료했을 때 + +```typescript +interface OpicCompletePayload { + sessionId: string; // 세션 ID + estimatedLevel: string; // 예상 등급 (IM1, IM2, IH, AL 등) + questionsAnswered: number; // 답변한 문제 수 + feedbackSummary: string; // 피드백 요약 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-c9d0e1f2", + "type": "OPIC_COMPLETE", + "userId": "user-123", + "payload": { + "sessionId": "opic-session-456", + "estimatedLevel": "IM2", + "questionsAnswered": 15, + "feedbackSummary": "발음과 유창성이 좋습니다. 문법적 정확성을 더 연습하세요." + }, + "createdAt": "2024-01-15T16:00:00Z" +} +``` + +--- + +## 특수 이벤트 + +### HEARTBEAT (하트비트) + +서버에서 연결 유지를 위해 1초마다 전송합니다. 무시하면 됩니다. + +```javascript +eventSource.onmessage = (event) => { + if (event.data === 'HEARTBEAT') return; // 무시 + // ... +}; +``` + +### STREAM_END (스트림 종료) + +서버가 연결을 종료할 때 전송됩니다. (최대 14분 후) +`EventSource`는 자동으로 재연결을 시도합니다. + +--- + +## 연결 관리 권장사항 + +### 1. 연결 시점 + +```typescript +// 로그인 후 연결 +const handleLoginSuccess = (user: User) => { + connectNotifications(user.id); +}; + +// 페이지 로드 시 (이미 로그인된 경우) +useEffect(() => { + if (isAuthenticated && user) { + connectNotifications(user.id); + } +}, [isAuthenticated, user]); +``` + +### 2. 연결 해제 시점 + +```typescript +// 로그아웃 시 +const handleLogout = () => { + disconnectNotifications(); + // ... +}; + +// 페이지 언마운트 시 (SPA) +useEffect(() => { + return () => disconnectNotifications(); +}, []); +``` + +### 3. 재연결 처리 + +`EventSource`는 연결 끊김 시 자동 재연결을 시도합니다. +추가적인 재연결 로직이 필요한 경우: + +```typescript +const MAX_RETRY_COUNT = 5; +let retryCount = 0; + +eventSource.onerror = () => { + retryCount++; + + if (retryCount >= MAX_RETRY_COUNT) { + eventSource.close(); + showErrorMessage('알림 서버 연결에 실패했습니다. 새로고침해주세요.'); + } +}; + +eventSource.onopen = () => { + retryCount = 0; // 연결 성공 시 초기화 +}; +``` + +--- + +## UI 처리 권장사항 + +### 토스트 알림 + +```typescript +const showNotificationToast = (notification: Notification) => { + const config = getToastConfig(notification.type); + + toast({ + title: config.title, + description: formatPayload(notification.payload), + icon: config.icon, + duration: config.duration, + }); +}; + +const getToastConfig = (type: NotificationType) => { + switch (type) { + case 'BADGE_EARNED': + return { title: '🏆 배지 획득!', icon: 'trophy', duration: 5000 }; + case 'DAILY_COMPLETE': + return { title: '✅ 오늘의 학습 완료!', icon: 'check', duration: 4000 }; + case 'STREAK_REMINDER': + return { title: '⏰ 학습 리마인더', icon: 'clock', duration: 6000 }; + case 'TEST_COMPLETE': + return { title: '📝 테스트 완료', icon: 'file', duration: 3000 }; + case 'GAME_END': + return { title: '🎮 게임 종료', icon: 'gamepad', duration: 4000 }; + default: + return { title: '알림', icon: 'bell', duration: 3000 }; + } +}; +``` + +### 알림 센터 + +```typescript +const NotificationCenter: React.FC = () => { + const { notifications } = useNotificationContext(); + const [unreadCount, setUnreadCount] = useState(0); + + return ( + + + + {unreadCount > 0 && } + + + {notifications.map(notif => ( + + ))} + + + ); +}; +``` + +--- + +## TypeScript 타입 정의 (복사용) + +```typescript +// types/notification.ts + +export type NotificationType = + | 'BADGE_EARNED' + | 'DAILY_COMPLETE' + | 'STREAK_REMINDER' + | 'TEST_COMPLETE' + | 'NEWS_QUIZ_COMPLETE' + | 'GAME_END' + | 'GAME_STREAK' + | 'OPIC_COMPLETE'; + +export interface BaseNotification { + notificationId: string; + type: T; + userId: string; + payload: P; + createdAt: string; +} + +export interface BadgeEarnedPayload { + badgeType: string; + badgeName: string; + description: string; + iconUrl: string; +} + +export interface DailyCompletePayload { + date: string; + wordsLearned: number; + totalWords: number; + currentStreak: number; +} + +export interface StreakReminderPayload { + currentStreak: number; + message: string; +} + +export interface TestCompletePayload { + testId: string; + score: number; + correctCount: number; + totalCount: number; + isPerfect: boolean; +} + +export interface NewsQuizCompletePayload { + articleId: string; + articleTitle: string; + score: number; + correctCount: number; + totalCount: number; + isPerfect: boolean; +} + +export interface GameEndPayload { + roomId: string; + gameSessionId: string; + rank: number; + totalPlayers: number; + score: number; + isWinner: boolean; +} + +export interface GameStreakPayload { + roomId: string; + streakCount: number; + bonusPoints: number; +} + +export interface OpicCompletePayload { + sessionId: string; + estimatedLevel: string; + questionsAnswered: number; + feedbackSummary: string; +} + +export type Notification = + | BaseNotification<'BADGE_EARNED', BadgeEarnedPayload> + | BaseNotification<'DAILY_COMPLETE', DailyCompletePayload> + | BaseNotification<'STREAK_REMINDER', StreakReminderPayload> + | BaseNotification<'TEST_COMPLETE', TestCompletePayload> + | BaseNotification<'NEWS_QUIZ_COMPLETE', NewsQuizCompletePayload> + | BaseNotification<'GAME_END', GameEndPayload> + | BaseNotification<'GAME_STREAK', GameStreakPayload> + | BaseNotification<'OPIC_COMPLETE', OpicCompletePayload>; +``` + +--- + +## 환경 설정 + +### 환경 변수 + +| 환경 | URL | +|------|-----| +| **Test** | `https://flhf42jd6xgrh26wrqgwxmbmee0zmjnv.lambda-url.ap-northeast-2.on.aws/` | +| **Prod** | (배포 후 업데이트 예정) | + +```env +# .env.local (Next.js) +NEXT_PUBLIC_NOTIFICATION_URL=https://flhf42jd6xgrh26wrqgwxmbmee0zmjnv.lambda-url.ap-northeast-2.on.aws + +# .env (Vite) +VITE_NOTIFICATION_URL=https://flhf42jd6xgrh26wrqgwxmbmee0zmjnv.lambda-url.ap-northeast-2.on.aws +``` + +--- + +## 테스트 방법 + +### 개발 환경에서 테스트 + +1. 브라우저 개발자 도구 → Network 탭 열기 +2. EventStream 필터 선택 +3. 로그인 후 알림 연결 확인 +4. 학습 완료, 테스트 제출 등의 액션 수행 +5. 실시간으로 알림 수신 확인 + +### Mock SSE 서버 (로컬 테스트용) + +```javascript +// mock-sse-server.js +const http = require('http'); + +http.createServer((req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*', + }); + + // 테스트 알림 전송 + setInterval(() => { + const notification = { + notificationId: `notif-${Date.now()}`, + type: 'BADGE_EARNED', + userId: 'test-user', + payload: { + badgeType: 'TEST_BADGE', + badgeName: '테스트 배지', + description: '테스트용 배지입니다', + iconUrl: 'https://example.com/badge.png', + }, + createdAt: new Date().toISOString(), + }; + res.write(`data: ${JSON.stringify(notification)}\n\n`); + }, 5000); +}).listen(3001); + +console.log('Mock SSE server running on http://localhost:3001'); +``` + +--- + +## 문의 + +백엔드 알림 시스템 관련 문의: **[백엔드 담당자 이름/연락처]** \ No newline at end of file diff --git a/docs/frontend-wordchain-guide.md b/docs/frontend-wordchain-guide.md new file mode 100644 index 00000000..5296aa09 --- /dev/null +++ b/docs/frontend-wordchain-guide.md @@ -0,0 +1,365 @@ +# 영어 끝말잇기(쿵쿵따) 프론트엔드 통합 가이드 + +## 개요 +영어 끝말잇기 게임 - 이전 단어의 마지막 글자로 시작하는 단어를 제출하는 게임 + +## REST API 엔드포인트 + +### 1. 게임 시작 +``` +POST /chat/rooms/{roomId}/wordchain/start +Authorization: Bearer {token} +``` + +**Response (성공):** +```json +{ + "success": true, + "message": "Word Chain game started", + "data": { + "sessionId": "uuid", + "gameStatus": "PLAYING", + "currentRound": 1, + "currentPlayerId": "user-id", + "currentWord": "apple", + "nextLetter": "e", + "timeLimit": 15, + "turnStartTime": 1706000000000, + "serverTime": 1706000000000, + "activePlayers": ["user1", "user2", "user3"], + "eliminatedPlayers": [], + "scores": {}, + "usedWords": ["apple"] + } +} +``` + +### 2. 단어 제출 +``` +POST /chat/rooms/{roomId}/wordchain/submit +Authorization: Bearer {token} +Content-Type: application/json + +{ + "word": "elephant" +} +``` + +**Response (정답):** +```json +{ + "success": true, + "message": "Correct!", + "data": { + "resultType": "CORRECT", + "word": "elephant", + "definition": "(noun) A large mammal with a trunk", + "phonetic": "/ˈɛləfənt/", + "score": 23, + "nextLetter": "t", + "nextPlayerId": "user2", + "nextTimeLimit": 15 + } +} +``` + +**Response (오답 - 첫 글자 틀림):** +```json +{ + "success": true, + "message": "Wrong answer", + "data": { + "resultType": "WRONG_LETTER", + "error": "'e'로 시작하는 단어를 입력하세요." + } +} +``` + +**Response (오답 - 사전에 없음):** +```json +{ + "success": true, + "message": "Wrong answer", + "data": { + "resultType": "INVALID_WORD", + "error": "사전에 없는 단어입니다: xyz" + } +} +``` + +### 3. 타임아웃 처리 +``` +POST /chat/rooms/{roomId}/wordchain/timeout +Authorization: Bearer {token} +``` + +### 4. 게임 종료 (시작자만) +``` +POST /chat/rooms/{roomId}/wordchain/stop +Authorization: Bearer {token} +``` + +### 5. 게임 상태 조회 +``` +GET /chat/rooms/{roomId}/wordchain/status +Authorization: Bearer {token} +``` + +--- + +## WebSocket 메시지 + +### Domain +```javascript +domain: "wordchain" +``` + +### 메시지 타입 + +| messageType | 설명 | +|-------------|------| +| `wordchain_start` | 게임 시작 | +| `wordchain_correct` | 정답 | +| `wordchain_wrong` | 오답 | +| `wordchain_timeout` | 시간 초과 (탈락) | +| `wordchain_end` | 게임 종료 | + +--- + +## WebSocket 메시지 상세 + +### 1. 게임 시작 (wordchain_start) +```json +{ + "domain": "wordchain", + "messageType": "wordchain_start", + "messageId": "uuid", + "roomId": "room-id", + "userId": "SYSTEM", + "content": "🎮 끝말잇기 시작!\n시작 단어: apple\n다음 글자: 'e'\n\n첫 번째 차례: user1\n제한 시간: 15초", + "createdAt": "2026-01-24T12:00:00Z", + "timestamp": 1706000000000, + "sessionId": "session-uuid", + "starterWord": "apple", + "nextLetter": "e", + "currentPlayerId": "user1", + "timeLimit": 15, + "turnStartTime": 1706000000000, + "serverTime": 1706000000000, + "players": ["user1", "user2", "user3"], + "activePlayers": ["user1", "user2", "user3"] +} +``` + +### 2. 정답 (wordchain_correct) +```json +{ + "domain": "wordchain", + "messageType": "wordchain_correct", + "messageId": "uuid", + "roomId": "room-id", + "userId": "SYSTEM", + "content": "✅ 닉네임: \"elephant\" (+23점)\n뜻: (noun) A large mammal\n다음 글자: 't'", + "createdAt": "2026-01-24T12:00:05Z", + "timestamp": 1706000005000, + "serverTime": 1706000005000, + "resultType": "CORRECT", + "word": "elephant", + "definition": "(noun) A large mammal with a trunk", + "phonetic": "/ˈɛləfənt/", + "score": 23, + "nextLetter": "t", + "nextPlayerId": "user2", + "nextTimeLimit": 15, + "playerNickname": "닉네임", + "turnStartTime": 1706000005000, + "scores": { + "user1": 23 + } +} +``` + +### 3. 오답 (wordchain_wrong) +```json +{ + "domain": "wordchain", + "messageType": "wordchain_wrong", + "messageId": "uuid", + "roomId": "room-id", + "userId": "SYSTEM", + "content": "❌ 사전에 없는 단어입니다: xyz", + "resultType": "INVALID_WORD", + "error": "사전에 없는 단어입니다: xyz" +} +``` + +### 4. 시간 초과 (wordchain_timeout) +```json +{ + "domain": "wordchain", + "messageType": "wordchain_timeout", + "messageId": "uuid", + "roomId": "room-id", + "userId": "SYSTEM", + "content": "⏰ 닉네임 시간 초과! 탈락!", + "resultType": "TIMEOUT", + "eliminatedPlayerId": "user1", + "eliminatedNickname": "닉네임", + "nextPlayerId": "user2", + "nextTimeLimit": 13, + "nextLetter": "e", + "turnStartTime": 1706000015000, + "activePlayers": ["user2", "user3"] +} +``` + +### 5. 게임 종료 (wordchain_end) +```json +{ + "domain": "wordchain", + "messageType": "wordchain_end", + "messageId": "uuid", + "roomId": "room-id", + "userId": "SYSTEM", + "content": "🏆 승자: 닉네임!", + "resultType": "GAME_END", + "winnerId": "user2", + "winnerNickname": "닉네임", + "ranking": [ + { "playerId": "user2", "nickname": "닉네임2", "score": 45, "eliminated": false }, + { "playerId": "user3", "nickname": "닉네임3", "score": 30, "eliminated": true }, + { "playerId": "user1", "nickname": "닉네임1", "score": 23, "eliminated": true } + ], + "usedWords": ["apple", "elephant", "tiger", "rainbow"], + "wordDefinitions": { + "apple": "(noun) A fruit", + "elephant": "(noun) A large mammal", + "tiger": "(noun) A large cat", + "rainbow": "(noun) An arc of colors" + }, + "scores": { + "user1": 23, + "user2": 45, + "user3": 30 + } +} +``` + +--- + +## 게임 규칙 + +### 시간 제한 (라운드별 감소) +| 라운드 | 시간 제한 | +|--------|----------| +| 1-2 | 15초 | +| 3-4 | 13초 | +| 5-6 | 11초 | +| 7-8 | 9초 | +| 9+ | 8초 | + +### 점수 계산 +``` +점수 = 기본점수(10) + 시간보너스 + 길이보너스 + +시간보너스 = 남은시간(초) +길이보너스 = (단어길이 - 4) × 2 (5글자 이상부터) +``` + +**예시:** +- 15초 제한에서 5초 만에 "elephant"(8글자) 제출 +- 점수 = 10 + 10 + 8 = 28점 + +### 게임 종료 조건 +- 1명만 남으면 게임 종료 +- 시작자가 `/stop` 호출 + +--- + +## 프론트엔드 구현 가이드 + +### 1. 타이머 동기화 +```javascript +// 서버 시간과 클라이언트 시간 차이 계산 +const serverTimeDiff = message.serverTime - Date.now(); + +// 남은 시간 계산 +const elapsed = Date.now() + serverTimeDiff - message.turnStartTime; +const remaining = (message.timeLimit * 1000) - elapsed; +``` + +### 2. WebSocket 메시지 핸들러 +```javascript +socket.onmessage = (event) => { + const message = JSON.parse(event.data); + + if (message.domain !== 'wordchain') return; + + switch (message.messageType) { + case 'wordchain_start': + handleGameStart(message); + break; + case 'wordchain_correct': + handleCorrectAnswer(message); + break; + case 'wordchain_wrong': + handleWrongAnswer(message); + break; + case 'wordchain_timeout': + handleTimeout(message); + break; + case 'wordchain_end': + handleGameEnd(message); + break; + } +}; +``` + +### 3. 타임아웃 자동 전송 +```javascript +// 내 턴일 때 타이머 만료 시 자동으로 타임아웃 API 호출 +if (isMyTurn && remaining <= 0) { + fetch(`/chat/rooms/${roomId}/wordchain/timeout`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` } + }); +} +``` + +### 4. UI 구성 요소 +- 현재 단어 표시 +- 다음 시작 글자 강조 +- 타이머 (남은 시간) +- 현재 차례 플레이어 표시 +- 활성/탈락 플레이어 목록 +- 점수판 +- 사용된 단어 목록 +- 단어 입력 필드 (본인 차례일 때만 활성화) + +### 5. 게임 종료 후 학습 화면 +```javascript +// 게임 종료 시 사용된 단어와 뜻 표시 +message.usedWords.forEach(word => { + const definition = message.wordDefinitions[word]; + console.log(`${word}: ${definition}`); +}); +``` + +--- + +## 에러 코드 + +| 코드 | 메시지 | +|------|--------| +| GAME_001 | 게임 시작에 실패했습니다 | +| GAME_002 | 게임 중단에 실패했습니다 | +| GAME_010 | 게임 액션 처리에 실패했습니다 | +| INPUT_001 | 유효하지 않은 입력입니다 | + +--- + +## 참고 + +- Dictionary API: [Free Dictionary API](https://dictionaryapi.dev/) +- 최소 인원: 2명 +- 시작 단어: 서버에서 랜덤 선택 (apple, house, water 등)