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/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..0c3dea39 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 ############################################# @@ -2002,6 +2054,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 +2315,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