From 8296952b74f648ea6550ed5dcf7e8f567b442621 Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 10:50:53 +0900 Subject: [PATCH 01/15] =?UTF-8?q?feat=20:=20WebSocket=20Connect,=20Disconn?= =?UTF-8?q?ect=20=ED=95=B8=EB=93=A4=EB=9F=AC=20&=20SpeakingConnecion=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websocket/SpeakingConnectHandler.java | 88 +++++++++++++++++++ .../websocket/SpeakingDisconnectHandler.java | 44 ++++++++++ .../speaking/model/SpeakingConnection.java | 84 ++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java 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 new file mode 100644 index 00000000..535a3fc1 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java @@ -0,0 +1,88 @@ +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"); + } + } +} \ No newline at end of file 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 new file mode 100644 index 00000000..4d82a9d1 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java @@ -0,0 +1,44 @@ +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"); + } + } +} \ No newline at end of file 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/SpeakingConnection.java new file mode 100644 index 00000000..6ea0c185 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java @@ -0,0 +1,84 @@ +package com.mzc.secondproject.serverless.domain.speaking.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +/** + * Speaking WebSocket 연결 정보 + * connectionId ↔ userId 매핑 + 대화 히스토리 저장 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class SpeakingConnection { + + // DynamoDB Key Prefixes + public static final String PK_PREFIX = "SPEAKING_CONN#"; + public static final String SK_METADATA = "METADATA"; + public static final String GSI1PK_PREFIX = "SPEAKING_USER#"; + public static final String GSI1SK_PREFIX = "CONN#"; + + private String pk; // SPEAKING_CONN#{connectionId} + private String sk; // METADATA + private String gsi1pk; // SPEAKING_USER#{userId} + private String gsi1sk; // CONN#{connectionId} + + private String connectionId; + private String userId; + private String connectedAt; + private Long ttl; // 자동 삭제용 + + // Speaking 전용 필드 + private String conversationHistory; // 대화 히스토리 (JSON) + private String targetLevel; // 목표 레벨 (BEGINNER, INTERMEDIATE, ADVANCED) + + /** + * 연결 정보 생성 팩토리 메서드 + */ + public static SpeakingConnection create(String connectionId, String userId, long ttlSeconds) { + String now = java.time.Instant.now().toString(); + long ttl = java.time.Instant.now().plusSeconds(ttlSeconds).getEpochSecond(); + + return SpeakingConnection.builder() + .pk(PK_PREFIX + connectionId) + .sk(SK_METADATA) + .gsi1pk(GSI1PK_PREFIX + userId) + .gsi1sk(GSI1SK_PREFIX + connectionId) + .connectionId(connectionId) + .userId(userId) + .connectedAt(now) + .ttl(ttl) + .conversationHistory("[]") // 빈 배열로 초기화 + .targetLevel("INTERMEDIATE") // 기본값 + .build(); + } + + @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; + } +} \ No newline at end of file From fc6f9e6d8a298ae7d9b1f7154e45303bce592b2f Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 10:51:44 +0900 Subject: [PATCH 02/15] =?UTF-8?q?feat=20:=20WebSocket=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EC=B2=98=EB=A6=AC=20handler,=20service=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websocket/SpeakingMessageHandler.java | 217 ++++++++++++ .../speaking/service/SpeakingService.java | 317 ++++++++++++++++++ 2 files changed, 534 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java 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 new file mode 100644 index 00000000..89ec0a44 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java @@ -0,0 +1,217 @@ +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/service/SpeakingService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java new file mode 100644 index 00000000..3462bbfb --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java @@ -0,0 +1,317 @@ +package com.mzc.secondproject.serverless.domain.speaking.service; + +import com.google.gson.*; +import com.mzc.secondproject.serverless.common.config.AwsClients; +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 org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelRequest; +import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelResponse; + + +import java.util.ArrayList; +import java.util.List; + +/** + * AI와 대화하기 서비스 + * 음성 입력 → STT → Bedrock → TTS → 음성 출력 + */ +public class SpeakingService { + + private static final Logger logger = LoggerFactory.getLogger(SpeakingService.class); + private static final Gson gson = new GsonBuilder().create(); + + private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; + private static final int MAX_TOKENS = 500; + private static final int MAX_HISTORY_SIZE = 10; // 최근 10턴만 유지 + + private final TranscribeProxyService transcribeService; + private final PollyService pollyService; + private final SpeakingConnectionRepository connectionRepository; + + public SpeakingService() { + this.transcribeService = new TranscribeProxyService(); + this.pollyService = new PollyService( + EnvConfig.getRequired("BUCKET_NAME"), + "speaking/voice/" + ); + this.connectionRepository = new SpeakingConnectionRepository(); + } + + /** + * 음성 입력 처리 (전체 플로우) + */ + public SpeakingResponse processVoiceInput(String connectionId, String audioBase64) { + logger.info("Processing voice input for connectionId: {}", connectionId); + + // 연결 정보 조회 + SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) + .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + + String targetLevel = connection.getTargetLevel(); + + // STT: 음성 → 텍스트 (Transcribe Proxy 사용) + logger.info("Step 1: Transcribing audio..."); + TranscribeProxyService.TranscribeResult sttResult = transcribeService.transcribe( + audioBase64, + connectionId, + "en-US" + ); + String userText = sttResult.transcript(); + logger.info("Transcription complete: {} (confidence: {})", userText, sttResult.confidence()); + + // 대화 히스토리 로드 + List history = parseHistory(connection.getConversationHistory()); + + // Bedrock: AI 응답 생성 + logger.info("Step 2: Generating AI response..."); + String aiResponse = generateAiResponse(userText, history, targetLevel); + logger.info("AI response generated: {}", aiResponse); + + // 히스토리 업데이트 (최근 N턴만 유지) + history.add(new Message("user", userText)); + history.add(new Message("assistant", aiResponse)); + 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); + + // TTS: 텍스트 → 음성 (Polly 사용) + logger.info("Step 3: Synthesizing speech..."); + String audioId = connectionId + "_" + System.currentTimeMillis(); + PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( + audioId, + aiResponse, + "FEMALE" + ); + logger.info("Speech synthesis complete: cached={}", ttsResult.isCached()); + + return new SpeakingResponse( + userText, + aiResponse, + ttsResult.getAudioUrl(), + sttResult.confidence() + ); + } + + /** + * 텍스트 입력 처리 (음성 없이 텍스트만) + */ + public SpeakingResponse processTextInput(String connectionId, String userText) { + logger.info("Processing text input for connectionId: {}", connectionId); + + // 연결 정보 조회 + SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) + .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + + String targetLevel = connection.getTargetLevel(); + + // 대화 히스토리 로드 + List history = parseHistory(connection.getConversationHistory()); + + // AI 응답 생성 + String aiResponse = generateAiResponse(userText, history, targetLevel); + + // 히스토리 업데이트 + history.add(new Message("user", userText)); + history.add(new Message("assistant", aiResponse)); + 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); + + // TTS 생성 + String audioId = connectionId + "_" + System.currentTimeMillis(); + PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( + audioId, aiResponse, "FEMALE" + ); + + return new SpeakingResponse(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)); + + connection.setTargetLevel(level.toUpperCase()); + connectionRepository.update(connection); + logger.info("Level updated for connectionId {}: {}", connectionId, level); + } + + /** + * 대화 히스토리 초기화 + */ + public void resetConversation(String connectionId) { + SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) + .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + + connection.setConversationHistory("[]"); + connectionRepository.update(connection); + logger.info("Conversation reset for connectionId: {}", connectionId); + } + + + /** + * Bedrock Claude 호출하여 AI 응답 생성 + */ + private String generateAiResponse(String userText, List history, String targetLevel) { + String systemPrompt = buildSystemPrompt(targetLevel); + + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); + requestBody.addProperty("max_tokens", MAX_TOKENS); + requestBody.addProperty("system", systemPrompt); + + // 메시지 배열 구성 + JsonArray messages = new JsonArray(); + + // 기존 히스토리 추가 + for (Message msg : history) { + JsonObject m = new JsonObject(); + m.addProperty("role", msg.role()); + m.addProperty("content", msg.content()); + messages.add(m); + } + + // 현재 사용자 입력 추가 + JsonObject userMsg = new JsonObject(); + userMsg.addProperty("role", "user"); + userMsg.addProperty("content", userText); + messages.add(userMsg); + + requestBody.add("messages", messages); + + // Bedrock 호출 + InvokeModelResponse response = AwsClients.bedrock().invokeModel( + InvokeModelRequest.builder() + .modelId(MODEL_ID) + .contentType("application/json") + .body(SdkBytes.fromUtf8String(requestBody.toString())) + .build() + ); + + // 응답 파싱 + JsonObject result = JsonParser.parseString( + response.body().asUtf8String() + ).getAsJsonObject(); + + return result.getAsJsonArray("content") + .get(0).getAsJsonObject() + .get("text").getAsString(); + } + + /** + * 레벨별 시스템 프롬프트 생성 + */ + private String buildSystemPrompt(String targetLevel) { + String levelGuidance = switch (targetLevel.toUpperCase()) { + case "BEGINNER" -> """ + - Use simple vocabulary and short sentences + - Speak slowly and clearly + - Use basic grammar structures + - Provide Korean translations for difficult words in parentheses + """; + case "ADVANCED" -> """ + - Use sophisticated vocabulary and complex sentences + - Include idiomatic expressions and phrasal verbs + - Discuss abstract concepts naturally + - Challenge the user with nuanced topics + """; + default -> """ + - Use moderate vocabulary appropriate for intermediate learners + - Mix simple and compound sentences + - Introduce useful expressions gradually + - Balance challenge with accessibility + """; + }; + + return String.format(""" + You are a friendly English conversation partner for Korean learners. + Your name is "Amy" and you're an American English teacher living in Seoul. + + ## Target Level: %s + + ## Level-Specific Guidelines: + %s + + ## General Guidelines: + - Keep responses conversational (2-4 sentences) + - Be warm, encouraging, and supportive + - If the user makes grammar mistakes, gently correct them naturally in your response + - Ask follow-up questions to keep the conversation going + - Respond in English only (except for occasional Korean translations for difficult words) + - Match the conversation topic to the user's interests + - Use natural filler words occasionally (well, you know, actually) + + ## Correction Style: + Instead of: "You said 'I go to store.' It should be 'I went to the store.'" + Do this: "Oh, so you went to the store? That's nice! What did you buy?" + + Remember: Your goal is to make the user feel comfortable practicing English! + """, targetLevel, levelGuidance); + } + + /** + * 히스토리 JSON 파싱 + */ + private List parseHistory(String historyJson) { + List history = new ArrayList<>(); + + if (historyJson == null || historyJson.isEmpty() || historyJson.equals("[]")) { + return history; + } + + try { + JsonArray array = JsonParser.parseString(historyJson).getAsJsonArray(); + for (JsonElement el : array) { + JsonObject obj = el.getAsJsonObject(); + history.add(new Message( + obj.get("role").getAsString(), + obj.get("content").getAsString() + )); + } + } catch (Exception e) { + logger.warn("Failed to parse history, starting fresh: {}", e.getMessage()); + } + + return history; + } + + /** + * 히스토리 JSON 변환 + */ + private String toJson(List history) { + JsonArray array = new JsonArray(); + for (Message msg : history) { + JsonObject obj = new JsonObject(); + obj.addProperty("role", msg.role()); + obj.addProperty("content", msg.content()); + array.add(obj); + } + 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 + ) {} +} \ No newline at end of file From f39bdf971a2d22e05b0b8918f7f08b59730cdd3b Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 10:52:33 +0900 Subject: [PATCH 03/15] =?UTF-8?q?feat=20:=20WebSocket=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=20Repository=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SpeakingConnectionRepository.java | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java 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 new file mode 100644 index 00000000..cbef094a --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java @@ -0,0 +1,73 @@ +현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); + } +} \ No newline at end of file From ccaa03411d6841bf934a29ad97b5fdebda5bc307 Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 11:18:04 +0900 Subject: [PATCH 04/15] =?UTF-8?q?fix=20:=20=EC=98=A4=ED=83=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../speaking/repository/SpeakingConnectionRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index cbef094a..14b468d1 100644 --- 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 @@ -1,4 +1,4 @@ -현package com.mzc.secondproject.serverless.domain.speaking.repository; +package com.mzc.secondproject.serverless.domain.speaking.repository; import com.mzc.secondproject.serverless.common.config.AwsClients; import com.mzc.secondproject.serverless.common.config.EnvConfig; From 6810f4138bffdc0a0a4314e790ef819b321f474c Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 12:36:40 +0900 Subject: [PATCH 05/15] =?UTF-8?q?refactor=20:=20websocket=20->=20rest=20ap?= =?UTF-8?q?i=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websocket/SpeakingDisconnectHandler.java | 44 ---- .../websocket/SpeakingMessageHandler.java | 217 ------------------ .../SpeakingConnectionRepository.java | 73 ------ 3 files changed, 334 deletions(-) delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java 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 4d82a9d1..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"); - } - } -} \ No newline at end of file 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 89ec0a44..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/repository/SpeakingConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java deleted file mode 100644 index 14b468d1..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); - } -} \ No newline at end of file From 76045eb86ebf27c8b2411311f9aacc5c42f3f496 Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 12:37:28 +0900 Subject: [PATCH 06/15] =?UTF-8?q?feat=20:=20speaking=20handler=20REST?= =?UTF-8?q?=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/websocket/SpeakingHandler.java | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java new file mode 100644 index 00000000..69375925 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java @@ -0,0 +1,157 @@ +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.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.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 { + // 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; + + SpeakingService.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)); + } +} From 2c7094a0099a1c9cb3a918cfeec5d4d59544819f Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 12:37:44 +0900 Subject: [PATCH 07/15] =?UTF-8?q?feat=20:=20speaking=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20dto=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../speaking/dto/request/ResetRequest.java | 12 +++++++ .../speaking/dto/request/SpeakingRequest.java | 32 +++++++++++++++++++ .../dto/response/SpeakingResponse.java | 12 +++++++ 3 files changed, 56 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/ResetRequest.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/SpeakingRequest.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/response/SpeakingResponse.java 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..8f600c66 --- /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(); + } +} \ No newline at end of file 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..58ad78c1 --- /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(); + } +} \ No newline at end of file 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..49d714dd --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/response/SpeakingResponse.java @@ -0,0 +1,12 @@ +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 신뢰도 +) {} \ No newline at end of file From 5ad8fd63fac51eaa0892d06933639836670d485e Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 12:38:45 +0900 Subject: [PATCH 08/15] =?UTF-8?q?refacotor=20:=20=EA=B8=B0=EC=A1=B4=20serv?= =?UTF-8?q?ice=20=EC=BD=94=EB=93=9C=20=EB=A1=9C=EC=A7=81=20=EC=9E=AC?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20=EB=B0=8F=20repository=20=EB=A6=AC?= =?UTF-8?q?=ED=8E=99=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websocket/SpeakingConnectHandler.java | 88 -------------- ...ngConnection.java => SpeakingSession.java} | 48 +++++--- .../repository/SpeakingSessionRepository.java | 74 ++++++++++++ .../speaking/service/SpeakingService.java | 108 +++++++++++------- 4 files changed, 172 insertions(+), 146 deletions(-) delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/{SpeakingConnection.java => SpeakingSession.java} (56%) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java 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 535a3fc1..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"); - } - } -} \ No newline at end of file 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 6ea0c185..07956b2f 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/SpeakingSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java new file mode 100644 index 00000000..fed1cd66 --- /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 WebSocket 연결 정보 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); + } +} \ 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 3462bbfb..010c4afe 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.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; @@ -16,6 +17,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.UUID; /** * AI와 대화하기 서비스 @@ -32,7 +34,29 @@ public class SpeakingService { private final TranscribeProxyService transcribeService; private final PollyService pollyService; - private final SpeakingConnectionRepository connectionRepository; + private final SpeakingSessionRepository sessionRepository; + + /** + * 세션 생성 또는 조회 + */ + 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 SpeakingService() { this.transcribeService = new TranscribeProxyService(); @@ -40,33 +64,32 @@ public SpeakingService() { EnvConfig.getRequired("BUCKET_NAME"), "speaking/voice/" ); - this.connectionRepository = new SpeakingConnectionRepository(); + this.sessionRepository = new SpeakingSessionRepository(); } /** * 음성 입력 처리 (전체 플로우) */ - 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, + sessionId, "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..."); @@ -79,12 +102,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, @@ -93,6 +116,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(), @@ -103,20 +127,17 @@ public SpeakingResponse processVoiceInput(String connectionId, String audioBase6 /** * 텍스트 입력 처리 (음성 없이 텍스트만) */ - public SpeakingResponse processTextInput(String connectionId, String userText) { - logger.info("Processing text input for connectionId: {}", connectionId); - - // 연결 정보 조회 - SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) - .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + public SpeakingResponse processTextInput(String sessionId, String userId, String userText, String level){ + logger.info("Processing text input for sessionId: {}", sessionId); - 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)); @@ -124,40 +145,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); } @@ -309,6 +336,7 @@ private record Message(String role, String content) {} * Speaking 응답 DTO */ public record SpeakingResponse( + String sessionId, // 세션 ID (다음 요청에 사용) String userTranscript, // 사용자가 말한 내용 (STT 결과) String aiText, // AI 응답 텍스트 String aiAudioUrl, // AI 응답 음성 URL (Polly) From 6c4cc89cf3234f7fa3c2de871f17e652c924daf3 Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 15:52:31 +0900 Subject: [PATCH 09/15] =?UTF-8?q?feat=20:=20speaking=20rest=20API=20?= =?UTF-8?q?=EB=9E=8C=EB=8B=A4=20=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ServerlessFunction/template.yaml | 56 ++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 488fc843..0f64417e 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1433,6 +1433,62 @@ Resources: Description: Daily word learning stats aggregation Enabled: true + ############################################# + # Speaking REST API (AI와 대화하기) + ############################################# + + SpeakingFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-speaking-handler + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.speaking.handler.SpeakingHandler::handleRequest + Description: Handle Speaking AI conversation (REST API) + Timeout: 120 + MemorySize: 1024 + SnapStart: + ApplyOn: PublishedVersions + Environment: + Variables: + TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ChatTable + - S3CrudPolicy: + BucketName: group2-englishstudy + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + Resource: "*" + - Statement: + - Effect: Allow + Action: + - polly:SynthesizeSpeech + Resource: "*" + - Statement: + - Effect: Allow + Action: + - ssm:GetParameter + Resource: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/opic/*" + Events: + SpeakingChat: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /api/speaking/chat + Method: POST + Auth: + Authorizer: CognitoAuthorizer + SpeakingReset: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /api/speaking/reset + Method: POST + Auth: + Authorizer: CognitoAuthorizer + ############################################# # OPIc Lambda Functions ############################################# From 158dcee69ca2c82ab0577e124ca29bf310ed6132 Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 21:57:47 +0900 Subject: [PATCH 10/15] =?UTF-8?q?refactor=20:=20speaking=20service=20?= =?UTF-8?q?=EC=9E=AC=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/domain/speaking/service/SpeakingService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 010c4afe..3dffac92 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 @@ -342,4 +342,4 @@ public record SpeakingResponse( String aiAudioUrl, // AI 응답 음성 URL (Polly) double confidence // STT 신뢰도comp ) {} -} \ No newline at end of file +} From 2cc5db062dd6dce9008886e5b701c9d205192619 Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 22:28:40 +0900 Subject: [PATCH 11/15] =?UTF-8?q?refactor=20:=20AI=20=EC=98=81=EC=96=B4=20?= =?UTF-8?q?=ED=9A=8C=ED=99=94=20=EC=97=B0=EC=8A=B5=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websocket/SpeakingConnectHandler.java | 0 .../websocket/SpeakingDisconnectHandler.java | 0 .../websocket/SpeakingMessageHandler.java | 0 .../SpeakingConnectionRepository.java | 0 .../repository/SpeakingSessionRepository.java | 2 +- ServerlessFunction/template.yaml | 45 +++++++++++++++++-- 6 files changed, 42 insertions(+), 5 deletions(-) delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java 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 e69de29b..00000000 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 e69de29b..00000000 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 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 fed1cd66..a6d9e26a 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 @@ -17,7 +17,7 @@ public class SpeakingSessionRepository { private static final Logger logger = LoggerFactory.getLogger(SpeakingSessionRepository.class); - private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); + private static final String TABLE_NAME = EnvConfig.getRequired("SPEAKING_TABLE_NAME"); private final DynamoDbTable table; diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index fdd9351f..d6d2ea4e 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1395,9 +1395,9 @@ Resources: SpeakingFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-speaking-handler + FunctionName: !Sub "${AWS::StackName}-speaking-handler" CodeUri: . - Handler: com.mzc.secondproject.serverless.domain.speaking.handler.SpeakingHandler::handleRequest + Handler: com.mzc.secondproject.serverless.domain.speaking.handler.websocket.SpeakingHandler::handleRequest Description: Handle Speaking AI conversation (REST API) Timeout: 120 MemorySize: 1024 @@ -1405,12 +1405,13 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: + SPEAKING_TABLE_NAME: !Ref SpeakingTable TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" Policies: - DynamoDBCrudPolicy: - TableName: !Ref ChatTable + TableName: !Ref SpeakingTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" - Statement: - Effect: Allow Action: @@ -1735,6 +1736,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 + ############################################# # News Collection Scheduled Lambda ############################################# @@ -2072,3 +2105,7 @@ Outputs: OPIcTableName: Description: OPIc DynamoDB Table Name Value: !Ref OPIcTable + + SpeakingTableName: + Description: Speaking DynamoDB Table Name + Value: !Ref SpeakingTable From 726efb7e567eff9fc8e3f942fa74bb5545e2cdad Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 23:06:33 +0900 Subject: [PATCH 12/15] =?UTF-8?q?feat=20:=20handleChat=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20JsonNull=20=EC=B2=B4=ED=81=AC=20=ED=91=B8?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/websocket/SpeakingHandler.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java index 69375925..c515f950 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java @@ -148,6 +148,28 @@ private APIGatewayProxyResponseEvent handleReset(String userId, String body) { )); } + /** + * JSON에서 문자열 추출 (null 또는 JsonNull이면 null 반환) + */ + private String getStringOrNull(JsonObject json, String key) { + if (!json.has(key) || json.get(key).isJsonNull()) { + return null; + } + return json.get(key).getAsString(); + } + + /** + * JSON에서 문자열 추출 (null 또는 JsonNull이면 기본값 반환) + */ + private String getStringOrDefault(JsonObject json, String key, String defaultValue) { + if (!json.has(key) || json.get(key).isJsonNull()) { + return defaultValue; + } + String value = json.get(key).getAsString(); + return (value == null || value.isEmpty()) ? defaultValue : value; + } + + private APIGatewayProxyResponseEvent response(int statusCode, Map body) { return new APIGatewayProxyResponseEvent() .withStatusCode(statusCode) From 8aed923304ebff972f3fca152128d0833ec87aba Mon Sep 17 00:00:00 2001 From: hye-inA Date: Fri, 23 Jan 2026 12:48:45 +0900 Subject: [PATCH 13/15] =?UTF-8?q?refactor=20:=20session=5Fid=EA=B0=80=20nu?= =?UTF-8?q?ll=20=EC=B2=B4=ED=81=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{websocket => }/SpeakingHandler.java | 9 ++++++--- .../speaking/service/SpeakingService.java | 19 ++++++------------- 2 files changed, 12 insertions(+), 16 deletions(-) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/{websocket => }/SpeakingHandler.java (97%) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java similarity index 97% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java index c515f950..90e041a7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; +package com.mzc.secondproject.serverless.domain.speaking.handler; import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; @@ -9,6 +9,7 @@ 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; @@ -102,7 +103,7 @@ private APIGatewayProxyResponseEvent handleChat(String userId, String body) { String audio = request.has("audio") ? request.get("audio").getAsString() : null; String text = request.has("text") ? request.get("text").getAsString() : null; - SpeakingService.SpeakingResponse result; + SpeakingResponse result; if (audio != null && !audio.isEmpty()) { // 음성 입력 처리 @@ -134,7 +135,7 @@ private APIGatewayProxyResponseEvent handleReset(String userId, String body) { } JsonObject request = JsonParser.parseString(body).getAsJsonObject(); - String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; + String sessionId = getStringOrNull(request, "sessionId"); if (sessionId == null || sessionId.isEmpty()) { return response(400, Map.of("error", "sessionId is required")); @@ -176,4 +177,6 @@ private APIGatewayProxyResponseEvent response(int statusCode, Map parseHistory(String historyJson) { return history; } + /** * 히스토리 JSON 변환 */ @@ -328,18 +330,9 @@ private String toJson(List history) { return array.toString(); } - // ==================== Inner Classes ==================== - - private record Message(String role, String content) {} - /** - * Speaking 응답 DTO + * 대화 메시지 (히스토리용) */ - public record SpeakingResponse( - String sessionId, // 세션 ID (다음 요청에 사용) - String userTranscript, // 사용자가 말한 내용 (STT 결과) - String aiText, // AI 응답 텍스트 - String aiAudioUrl, // AI 응답 음성 URL (Polly) - double confidence // STT 신뢰도comp - ) {} + private record Message(String role, String content) {} + } From 50cf8632e7c95a3f4b5345ae62ca55db2f2dcee2 Mon Sep 17 00:00:00 2001 From: hye-inA Date: Fri, 23 Jan 2026 14:26:31 +0900 Subject: [PATCH 14/15] =?UTF-8?q?feat=20:=20template=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=EB=B3=80=EC=88=98=20=EB=A6=AC=ED=8E=99=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ServerlessFunction/template.yaml | 553 +++++++++---------------------- 1 file changed, 149 insertions(+), 404 deletions(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 97fe03e6..0f64417e 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -2,25 +2,6 @@ AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Description: Group2 English Study - Unified API (Chatting + Vocabulary) -Parameters: - Environment: - Type: String - Default: dev - AllowedValues: - - dev - - test - - prod - Description: Deployment environment - - ExistingCognitoUserPoolId: - Type: String - Default: "" - Description: Existing Cognito User Pool ID (leave empty to create new) - - ExistingCognitoClientId: - Type: String - Description: Existing Cognito User Pool Client ID - Globals: Function: Timeout: 30 @@ -35,13 +16,11 @@ Globals: CHAT_TABLE_NAME: !Ref ChatTable VOCAB_TABLE_NAME: !Ref VocabTable OPIC_TABLE_NAME: !Ref OPIcTable - NEWS_TABLE_NAME: !Ref NewsTable - 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}" + BUCKET_NAME: group2-englishstudy + CHAT_BUCKET_NAME: group2-englishstudy + VOCAB_BUCKET_NAME: group2-englishstudy + PROFILE_BUCKET_NAME: group2-englishstudy + OPIC_BUCKET_NAME: group2-englishstudy 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" @@ -50,10 +29,65 @@ Globals: Resources: ############################################# - # Cognito - Using Existing User Pool - # (Cognito resources are managed in group2-englishstudy-chatting stack) + # Cognito User Pool ############################################# + 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 @@ -66,7 +100,7 @@ Resources: Timeout: 10 Environment: Variables: - DEFAULT_PROFILE_URL: !Sub "https://${AWS::StackName}.s3.amazonaws.com/profile/default.png" + DEFAULT_PROFILE_URL: https://group2-englishstudy.s3.amazonaws.com/profile/default.png # 회원가입 시점에 사용자 모든 정보가 DB에 저장 Lambda 함수 PostConfirmationFunction: @@ -84,6 +118,18 @@ 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) ############################################# @@ -91,11 +137,11 @@ Resources: MainApi: Type: AWS::Serverless::Api Properties: - Name: !Sub "${AWS::StackName}-api" - StageName: !Ref Environment + Name: group2-englishstudy-api + StageName: dev Cors: - AllowMethods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" - AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Requested-With,Accept'" + AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" + AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" AllowOrigin: "'*'" AllowCredentials: false GatewayResponses: @@ -105,7 +151,7 @@ Resources: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" ResponseTemplates: application/json: '{"message": "Unauthorized", "statusCode": 401}' ACCESS_DENIED: @@ -114,7 +160,7 @@ Resources: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" ResponseTemplates: application/json: '{"message": "Access Denied", "statusCode": 403}' DEFAULT_4XX: @@ -122,28 +168,27 @@ Resources: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" DEFAULT_5XX: ResponseParameters: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" EXPIRED_TOKEN: StatusCode: 401 ResponseParameters: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" ResponseTemplates: application/json: '{"message": "Token expired", "statusCode": 401}' Auth: DefaultAuthorizer: CognitoAuthorizer - AddDefaultAuthorizerToCorsPreflight: false Authorizers: CognitoAuthorizer: - UserPoolArn: !Sub "arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/${ExistingCognitoUserPoolId}" + UserPoolArn: !GetAtt CognitoUserPool.Arn Identity: Header: Authorization @@ -154,7 +199,7 @@ Resources: WebSocketApi: Type: AWS::ApiGatewayV2::Api Properties: - Name: !Sub "${AWS::StackName}-websocket" + Name: group2-englishstudy-websocket ProtocolType: WEBSOCKET RouteSelectionExpression: "$request.body.action" @@ -162,7 +207,7 @@ Resources: Type: AWS::ApiGatewayV2::Stage Properties: ApiId: !Ref WebSocketApi - StageName: !Ref Environment + StageName: dev AutoDeploy: true # WebSocket Connect Route @@ -220,7 +265,7 @@ Resources: WebSocketConnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-ws-connect" + FunctionName: group2-englishstudy-ws-connect CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.websocket.WebSocketConnectHandler::handleRequest Description: Handle WebSocket $connect @@ -229,7 +274,7 @@ Resources: Environment: Variables: WEBSOCKET_CONNECTION_TTL_SECONDS: "600" - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -250,7 +295,7 @@ Resources: WebSocketDisconnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-ws-disconnect" + FunctionName: group2-englishstudy-ws-disconnect CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.websocket.WebSocketDisconnectHandler::handleRequest Description: Handle WebSocket $disconnect @@ -258,7 +303,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -279,7 +324,7 @@ Resources: WebSocketMessageFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-ws-message" + FunctionName: group2-englishstudy-ws-message CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.websocket.WebSocketMessageHandler::handleRequest Description: Handle WebSocket sendMessage @@ -287,7 +332,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" GAME_AUTO_CLOSE_LAMBDA_ARN: !GetAtt GameAutoCloseFunction.Arn SCHEDULER_ROLE_ARN: !GetAtt GameSchedulerRole.Arn Policies: @@ -307,7 +352,7 @@ Resources: - scheduler:CreateSchedule - scheduler:DeleteSchedule - scheduler:GetSchedule - Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/${AWS::StackName}-game-auto-close/*" + Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/game-auto-close/*" - Statement: - Effect: Allow Action: @@ -339,7 +384,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref UserTable - S3CrudPolicy: - BucketName: !Sub "${AWS::StackName}" + BucketName: group2-englishstudy Events: GetMyProfile: Type: Api @@ -367,7 +412,7 @@ Resources: ChatRoomFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-chat-room-handler" + FunctionName: group2-englishstudy-chat-room-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatRoomHandler::handleRequest Description: Handle chat room CRUD operations @@ -375,7 +420,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -439,7 +484,7 @@ Resources: GameFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-game-handler" + FunctionName: group2-englishstudy-game-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.GameHandler::handleRequest Description: Handle catch-mind game operations @@ -447,7 +492,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" GAME_AUTO_CLOSE_LAMBDA_ARN: !GetAtt GameAutoCloseFunction.Arn SCHEDULER_ROLE_ARN: !GetAtt GameSchedulerRole.Arn Policies: @@ -467,7 +512,7 @@ Resources: - scheduler:CreateSchedule - scheduler:DeleteSchedule - scheduler:GetSchedule - Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/${AWS::StackName}-game-auto-close/*" + Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/game-auto-close/*" - Statement: - Effect: Allow Action: @@ -511,7 +556,7 @@ Resources: GameAutoCloseFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-game-auto-close" + FunctionName: group2-englishstudy-game-auto-close CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.GameAutoCloseHandler::handleRequest Description: Auto-close game after 7 minutes @@ -521,7 +566,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -557,12 +602,12 @@ Resources: GameScheduleGroup: Type: AWS::Scheduler::ScheduleGroup Properties: - Name: !Sub "${AWS::StackName}-game-auto-close" + Name: game-auto-close ChatMessageFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-chat-message-handler" + FunctionName: group2-englishstudy-chat-message-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatMessageHandler::handleRequest Description: Handle chat messages @@ -572,7 +617,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref ChatTable - S3CrudPolicy: - BucketName: !Sub "${AWS::StackName}" + BucketName: group2-englishstudy - Statement: - Effect: Allow Action: @@ -614,7 +659,7 @@ Resources: ChatVoiceFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-chat-voice-handler" + FunctionName: group2-englishstudy-chat-voice-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatVoiceHandler::handleRequest Description: Convert text to speech using Polly @@ -624,7 +669,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref ChatTable - S3CrudPolicy: - BucketName: !Sub "${AWS::StackName}" + BucketName: group2-englishstudy - Statement: - Effect: Allow Action: @@ -648,7 +693,7 @@ Resources: WordFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-vocab-word-handler" + FunctionName: group2-englishstudy-vocab-word-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.WordHandler::handleRequest Description: Handle word CRUD operations @@ -726,7 +771,7 @@ Resources: UserWordFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-vocab-userword-handler" + FunctionName: group2-englishstudy-vocab-userword-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.UserWordHandler::handleRequest Description: Handle user word learning status @@ -788,7 +833,7 @@ Resources: WordGroupFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-vocab-wordgroup-handler" + FunctionName: group2-englishstudy-vocab-wordgroup-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.WordGroupHandler::handleRequest Description: Handle user custom word groups @@ -858,7 +903,7 @@ Resources: DailyStudyFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-vocab-daily-handler" + FunctionName: group2-englishstudy-vocab-daily-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.DailyStudyHandler::handleRequest Description: Handle daily study word assignment @@ -888,7 +933,7 @@ Resources: TestFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-vocab-test-handler" + FunctionName: group2-englishstudy-vocab-test-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.TestHandler::handleRequest Description: Handle vocabulary tests @@ -947,7 +992,7 @@ Resources: StatsFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-vocab-stats-handler" + FunctionName: group2-englishstudy-vocab-stats-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.StatsHandler::handleRequest Description: Handle user learning statistics @@ -985,7 +1030,7 @@ Resources: VocabVoiceFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-vocab-voice-handler" + FunctionName: group2-englishstudy-vocab-voice-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.VoiceHandler::handleRequest Description: Convert word to speech using Polly @@ -995,7 +1040,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref VocabTable - S3CrudPolicy: - BucketName: !Sub "${AWS::StackName}" + BucketName: group2-englishstudy - Statement: - Effect: Allow Action: @@ -1020,7 +1065,7 @@ Resources: StatsStreamFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-stats-stream-handler" + FunctionName: group2-englishstudy-stats-stream-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.stats.handler.StatsStreamHandler::handleRequest Description: Process DynamoDB Streams for stats aggregation @@ -1045,7 +1090,7 @@ Resources: UserStatsFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-user-stats-handler" + FunctionName: group2-englishstudy-user-stats-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.stats.handler.UserStatsHandler::handleRequest Description: Handle user learning statistics API @@ -1055,14 +1100,6 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref VocabTable Events: - GetDashboardStats: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /stats/dashboard - Method: GET - Auth: - Authorizer: CognitoAuthorizer GetDailyStats: Type: Api Properties: @@ -1108,7 +1145,7 @@ Resources: BadgeFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-badge-handler" + FunctionName: group2-englishstudy-badge-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.badge.handler.BadgeHandler::handleRequest Description: Handle user badges and achievements @@ -1118,7 +1155,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref VocabTable - S3ReadPolicy: - BucketName: !Sub "${AWS::StackName}" + BucketName: group2-englishstudy Events: GetAllBadges: Type: Api @@ -1144,7 +1181,7 @@ Resources: GrammarFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-grammar-handler" + FunctionName: group2-englishstudy-grammar-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.GrammarHandler::handleRequest Description: Handle grammar check using Bedrock AI @@ -1223,7 +1260,7 @@ Resources: GrammarWebSocketApi: Type: AWS::ApiGatewayV2::Api Properties: - Name: !Sub "${AWS::StackName}-grammar-websocket" + Name: group2-englishstudy-grammar-websocket ProtocolType: WEBSOCKET RouteSelectionExpression: "$request.body.action" @@ -1231,7 +1268,7 @@ Resources: Type: AWS::ApiGatewayV2::Stage Properties: ApiId: !Ref GrammarWebSocketApi - StageName: !Ref Environment + StageName: dev AutoDeploy: true # Grammar WebSocket Connect Route @@ -1286,13 +1323,13 @@ Resources: GrammarStreamingConnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-grammar-ws-connect" + FunctionName: group2-grammar-ws-connect CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingConnectHandler::handleRequest Description: Handle Grammar WebSocket $connect with JWT auth Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" + WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1313,13 +1350,13 @@ Resources: GrammarStreamingDisconnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-grammar-ws-disconnect" + FunctionName: group2-grammar-ws-disconnect CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingDisconnectHandler::handleRequest Description: Handle Grammar WebSocket $disconnect Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" + WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1340,7 +1377,7 @@ Resources: GrammarStreamingFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-grammar-ws-streaming" + FunctionName: group2-grammar-ws-streaming CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingHandler::handleRequest Description: Handle Grammar streaming with Bedrock @@ -1348,7 +1385,7 @@ Resources: MemorySize: 1024 Environment: Variables: - GRAMMAR_WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" + GRAMMAR_WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1376,7 +1413,7 @@ Resources: ScheduledStatsFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-scheduled-stats" + FunctionName: group2-englishstudy-scheduled-stats CodeUri: . Handler: com.mzc.secondproject.serverless.domain.stats.handler.ScheduledStatsHandler::handleRequest Description: Daily scheduled job for word learning stats aggregation @@ -1392,7 +1429,7 @@ Resources: Type: Schedule Properties: Schedule: cron(0 15 * * ? *) # UTC 15:00 = KST 00:00 (자정) - Name: !Sub "${AWS::StackName}-daily-stats-aggregation" + Name: daily-stats-aggregation Description: Daily word learning stats aggregation Enabled: true @@ -1403,7 +1440,7 @@ Resources: SpeakingFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-speaking-handler" + FunctionName: group2-englishstudy-speaking-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.speaking.handler.SpeakingHandler::handleRequest Description: Handle Speaking AI conversation (REST API) @@ -1413,13 +1450,12 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - SPEAKING_TABLE_NAME: !Ref SpeakingTable TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" Policies: - DynamoDBCrudPolicy: - TableName: !Ref SpeakingTable + TableName: !Ref ChatTable - S3CrudPolicy: - BucketName: !Ref ContentBucket + BucketName: group2-englishstudy - Statement: - Effect: Allow Action: @@ -1460,7 +1496,7 @@ Resources: OPIcSessionFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-opic-session-handler" + FunctionName: group2-englishstudy-opic-session-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.opic.handler.OPIcSessionHandler::handleRequest Description: Handle OPIc speaking practice sessions @@ -1475,7 +1511,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref OPIcTable - S3CrudPolicy: - BucketName: !Sub "${AWS::StackName}" + BucketName: group2-englishstudy - Statement: - Effect: Allow Action: @@ -1568,7 +1604,7 @@ Resources: DeletionPolicy: Retain # UpdateReplacePolicy: Retain Properties: - TableName: !Sub "${AWS::StackName}-user" + TableName: group2-englishstudy-user BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: PK @@ -1613,7 +1649,7 @@ Resources: ChatTable: Type: AWS::DynamoDB::Table Properties: - TableName: !Sub "${AWS::StackName}-chat" + TableName: group2-englishstudy-chat BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: PK @@ -1657,7 +1693,7 @@ Resources: VocabTable: Type: AWS::DynamoDB::Table Properties: - TableName: !Sub "${AWS::StackName}-vocab" + TableName: group2-englishstudy-vocab BillingMode: PAY_PER_REQUEST StreamSpecification: StreamViewType: NEW_IMAGE @@ -1715,255 +1751,7 @@ Resources: OPIcTable: Type: AWS::DynamoDB::Table Properties: - TableName: !Sub "${AWS::StackName}-opic" - 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 - - 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 - - ############################################# - # News Collection Scheduled Lambda - ############################################# - - NewsCollectionFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: !Sub "${AWS::StackName}-news-collection" - CodeUri: . - Handler: com.mzc.secondproject.serverless.domain.news.handler.NewsCollectionHandler::handleRequest - Description: 매일 18시에 영어 뉴스를 수집하는 Lambda - MemorySize: 512 - Timeout: 300 - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref NewsTable - - Statement: - - Effect: Allow - Action: - - bedrock:InvokeModel - Resource: "*" - - Statement: - - Effect: Allow - Action: - - comprehend:DetectKeyPhrases - Resource: "*" - Events: - DailySchedule: - Type: Schedule - Properties: - Schedule: cron(0 9 * * ? *) - Name: !Sub "${AWS::StackName}-news-collection-daily-schedule" - Description: 매일 18시 KST (09:00 UTC)에 뉴스 수집 - Enabled: true - - NewsFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: !Sub "${AWS::StackName}-news" - CodeUri: . - Handler: com.mzc.secondproject.serverless.domain.news.handler.NewsHandler::handleRequest - Description: 뉴스 학습 API - MemorySize: 256 - Timeout: 30 - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref NewsTable - - DynamoDBCrudPolicy: - TableName: !Ref VocabTable - - S3CrudPolicy: - BucketName: !Sub "${AWS::StackName}" - - Statement: - - Effect: Allow - Action: - - polly:SynthesizeSpeech - Resource: "*" - Events: - GetNewsList: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news - Method: GET - GetTodayNews: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/today - Method: GET - GetRecommendedNews: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/recommended - Method: GET - GetNewsStats: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/stats - Method: GET - Auth: - Authorizer: CognitoAuthorizer - GetBookmarks: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/bookmarks - Method: GET - Auth: - Authorizer: CognitoAuthorizer - GetUserWords: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/words - Method: GET - Auth: - Authorizer: CognitoAuthorizer - SyncWordToVocab: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/words/{word}/sync - Method: POST - Auth: - Authorizer: CognitoAuthorizer - CollectWord: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/{articleId}/words - Method: POST - Auth: - Authorizer: CognitoAuthorizer - DeleteWord: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/{articleId}/words/{word} - Method: DELETE - Auth: - Authorizer: CognitoAuthorizer - GetWordDetail: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/{articleId}/words/{word} - Method: GET - Auth: - Authorizer: CognitoAuthorizer - GetQuizHistory: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/quiz/history - Method: GET - Auth: - Authorizer: CognitoAuthorizer - GetQuiz: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/{articleId}/quiz - Method: GET - SubmitQuiz: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/{articleId}/quiz - Method: POST - Auth: - Authorizer: CognitoAuthorizer - MarkAsRead: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/{articleId}/read - Method: POST - Auth: - Authorizer: CognitoAuthorizer - ToggleBookmark: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/{articleId}/bookmark - Method: POST - Auth: - Authorizer: CognitoAuthorizer - GetAudio: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/{articleId}/audio - Method: GET - Auth: - Authorizer: CognitoAuthorizer - GetNewsDetail: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/{articleId} - Method: GET - - NewsTable: - Type: AWS::DynamoDB::Table - Properties: - TableName: !Sub "${AWS::StackName}-news" + TableName: group2-englishstudy-opic BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: PK @@ -1974,10 +1762,6 @@ Resources: AttributeType: S - AttributeName: GSI1SK AttributeType: S - - AttributeName: GSI2PK - AttributeType: S - - AttributeName: GSI2SK - AttributeType: S KeySchema: - AttributeName: PK KeyType: HASH @@ -1992,45 +1776,10 @@ Resources: KeyType: RANGE Projection: ProjectionType: ALL - - IndexName: GSI2 - KeySchema: - - AttributeName: GSI2PK - KeyType: HASH - - AttributeName: GSI2SK - 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 ############################################# @@ -2039,20 +1788,20 @@ Resources: TestResultTopic: Type: AWS::SNS::Topic Properties: - TopicName: !Sub "${AWS::StackName}-test-result-topic" + TopicName: group2-englishstudy-test-result-topic # SQS Dead Letter Queue - 실패한 메시지 보관 StatisticsDeadLetterQueue: Type: AWS::SQS::Queue Properties: - QueueName: !Sub "${AWS::StackName}-statistics-dlq" + QueueName: group2-englishstudy-statistics-dlq MessageRetentionPeriod: 1209600 # 14일 # SQS Queue - 통계 처리용 StatisticsQueue: Type: AWS::SQS::Queue Properties: - QueueName: !Sub "${AWS::StackName}-statistics-queue" + QueueName: group2-englishstudy-statistics-queue VisibilityTimeout: 60 RedrivePolicy: deadLetterTargetArn: !GetAtt StatisticsDeadLetterQueue.Arn @@ -2088,7 +1837,7 @@ Resources: StatisticsProcessorFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-statistics-processor" + FunctionName: group2-englishstudy-statistics-processor CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.StatisticsHandler::handleRequest Description: Process test results and update user word statistics @@ -2114,15 +1863,15 @@ Resources: Outputs: ApiUrl: Description: Unified API Gateway endpoint URL - Value: !Sub 'https://${MainApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}/' + Value: !Sub 'https://${MainApi}.execute-api.${AWS::Region}.amazonaws.com/dev/' WebSocketUrl: Description: WebSocket API Gateway endpoint URL - Value: !Sub 'wss://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}' + Value: !Sub 'wss://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev' GrammarWebSocketUrl: Description: Grammar Streaming WebSocket API endpoint URL - Value: !Sub 'wss://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}' + Value: !Sub 'wss://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev' ChatTableName: Description: Chat DynamoDB Table Name @@ -2134,20 +1883,16 @@ Outputs: BucketName: Description: S3 Bucket Name - Value: !Ref ContentBucket + Value: group2-englishstudy CognitoUserPoolId: Description: Cognito User Pool ID - Value: !Ref ExistingCognitoUserPoolId + Value: !Ref CognitoUserPool CognitoUserPoolClientId: Description: Cognito User Pool Client ID - Value: !Ref ExistingCognitoClientId + Value: !Ref CognitoUserPoolClient OPIcTableName: Description: OPIc DynamoDB Table Name Value: !Ref OPIcTable - - SpeakingTableName: - Description: Speaking DynamoDB Table Name - Value: !Ref SpeakingTable From b73aa2e69b77563187609dd262435fc598c905c5 Mon Sep 17 00:00:00 2001 From: hye-inA Date: Fri, 23 Jan 2026 16:33:50 +0900 Subject: [PATCH 15/15] =?UTF-8?q?feat=20:=20Speaking=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20template=20=EB=9E=8C=EB=8B=A4=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EB=B0=8F=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/SpeakingConnectHandler.java | 0 .../handler/SpeakingDisconnectHandler.java | 0 .../speaking/handler/SpeakingHandler.java | 43 +++++----- .../handler/SpeakingMessageHandler.java | 0 .../SpeakingConnectionRepository.java | 0 .../repository/SpeakingSessionRepository.java | 4 +- ServerlessFunction/template.yaml | 78 +++++++++++++++++++ 7 files changed, 103 insertions(+), 22 deletions(-) delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingConnectHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingDisconnectHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingMessageHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java 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 c4166ed8..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 @@ -51,32 +51,31 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent ev } try { - // JWT 토큰 검증 - String authHeader = event.getHeaders().get("Authorization"); - if (authHeader == null || !authHeader.startsWith("Bearer ")) { - return response(401, Map.of("error", "Authorization header is required")); + // 사용자 인증 정보 추출 (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")); } - String token = authHeader.substring(7); - if (!JwtUtil.isValid(token)) { - return response(401, Map.of("error", "Invalid or expired token")); - } + Map authorizer = event.getRequestContext().getAuthorizer(); + Map claims = (Map) authorizer.get("claims"); - Optional userIdOpt = JwtUtil.extractUserId(token); - if (userIdOpt.isEmpty()) { - return response(401, Map.of("error", "Invalid token")); + if (claims == null) { + return response(401, Map.of("error", "Unauthorized: Claims missing")); } - String userId = userIdOpt.get(); + 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.endsWith("/chat")) { + if (path != null && path.endsWith("/chat")) { return handleChat(userId, body); - } else if (path.endsWith("/reset")) { + } else if (path != null && path.endsWith("/reset")) { return handleReset(userId, body); } else { return response(404, Map.of("error", "Not found")); @@ -98,20 +97,24 @@ private APIGatewayProxyResponseEvent handleChat(String userId, String body) { 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; + 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 input"); + logger.info("Processing voice event"); result = speakingService.processVoiceInput(sessionId, userId, audio, level); } else if (text != null && !text.trim().isEmpty()) { // 텍스트 입력 처리 - logger.info("Processing text input: {}", text); + 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")); 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 fed1cd66..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,12 +12,12 @@ 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 static final String TABLE_NAME = EnvConfig.getRequired("SPEAKING_TABLE_NAME"); private final DynamoDbTable table; diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index bb4adbd6..6cb55800 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -37,6 +37,7 @@ 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}" @@ -1495,6 +1496,47 @@ 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 + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref SpeakingTable + - S3CrudPolicy: + BucketName: !Sub "${AWS::StackName}" + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + 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 ############################################# @@ -1874,6 +1916,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 ############################################# @@ -2017,3 +2091,7 @@ Outputs: OPIcTableName: Description: OPIc DynamoDB Table Name Value: !Ref OPIcTable + + SpeakingTableName: + Description: Speaking DynamoDB Table Name + Value: !Ref SpeakingTable