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 5515cc1b..0435d5cb 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) 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/news/service/NewsQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java index c1a0f328..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 @@ -40,12 +40,22 @@ public Optional getArticle(String articleId) { } /** - * 오늘의 뉴스 목록 조회 + * 오늘의 뉴스 목록 조회 (오늘 기사 없으면 어제 기사 조회) */ 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; } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingConnectHandler.java deleted file mode 100644 index e69de29b..00000000 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingDisconnectHandler.java deleted file mode 100644 index e69de29b..00000000 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 index ed6fbda0..2ebda605 100644 --- 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 @@ -19,142 +19,145 @@ /** * 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 { - // JWT 토큰 검증 - String authHeader = event.getHeaders().get("Authorization"); - if (authHeader == null || !authHeader.startsWith("Bearer ")) { - return response(401, Map.of("error", "Authorization header is required")); - } - - String token = authHeader.substring(7); - if (!JwtUtil.isValid(token)) { - return response(401, Map.of("error", "Invalid or expired token")); - } - - Optional userIdOpt = JwtUtil.extractUserId(token); - if (userIdOpt.isEmpty()) { - return response(401, Map.of("error", "Invalid token")); - } - - String userId = userIdOpt.get(); - String path = event.getPath(); - String body = event.getBody(); - - logger.info("Processing request: path={}, userId={}", path, userId); - - // 라우팅 - if (path.endsWith("/chat")) { - return handleChat(userId, body); - } else if (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").getAsString() : null; - String level = request.has("level") ? request.get("level").getAsString() : "INTERMEDIATE"; - String audio = request.has("audio") ? request.get("audio").getAsString() : null; - String text = request.has("text") ? request.get("text").getAsString() : null; - - SpeakingResponse result; - - if (audio != null && !audio.isEmpty()) { - // 음성 입력 처리 - logger.info("Processing voice input"); - result = speakingService.processVoiceInput(sessionId, userId, audio, level); - } else if (text != null && !text.trim().isEmpty()) { - // 텍스트 입력 처리 - logger.info("Processing text input: {}", 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)); - } - - + + 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/SpeakingMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingMessageHandler.java deleted file mode 100644 index e69de29b..00000000 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 e69de29b..00000000 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 index aa7acb63..dadda7e7 100644 --- 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 @@ -12,63 +12,63 @@ import java.util.Optional; /** - * Speaking WebSocket 연결 정보 Repository + * Speaking API 연결 정보 Repository */ public class SpeakingSessionRepository { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingSessionRepository.class); - private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_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); - } -} + + 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/user/service/UserService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java index 4f635106..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 @@ -31,11 +31,16 @@ public class UserService { 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"; @@ -66,6 +71,11 @@ public User getProfile(String userId, APIGatewayProxyRequestEvent request) { return user; } + + // 단순 프로필 조회 메서드 (채팅용) - DB 조회만 수행 + public User getUserProfile(String userId) { + return userRepository.findByCognitoSub(userId).orElse(null); + } public String getPresignedProfileUrl(String s3Url) { if (s3Url == null || s3Url.isEmpty()) { diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 8a7be954..225dc206 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -37,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" @@ -1618,6 +1620,56 @@ Resources: Auth: Authorizer: CognitoAuthorizer + ############################################# + # 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: CognitoAuthorizer + SpeakingReset: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /speaking/reset + Method: POST + Auth: + Authorizer: CognitoAuthorizer + ############################################# # DynamoDB Tables ############################################# @@ -1819,6 +1871,11 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref NewsTable + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + Resource: "*" Events: DailySchedule: Type: Schedule @@ -2002,6 +2059,38 @@ 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 ############################################# @@ -2231,6 +2320,10 @@ Outputs: 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 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 등)