diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingConnectHandler.java
deleted file mode 100644
index e69de29b..00000000
diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingDisconnectHandler.java
deleted file mode 100644
index e69de29b..00000000
diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java
index ed6fbda0..2ebda605 100644
--- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java
+++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java
@@ -19,142 +19,145 @@
/**
* Speaking API 핸들러
- *
+ *
* POST /api/speaking/chat - 대화 (음성 또는 텍스트)
* POST /api/speaking/reset - 대화 초기화
*/
public class SpeakingHandler implements RequestHandler {
-
- private static final Logger logger = LoggerFactory.getLogger(SpeakingHandler.class);
- private static final Gson gson = new GsonBuilder().create();
-
- private static final Map CORS_HEADERS = Map.of(
- "Content-Type", "application/json",
- "Access-Control-Allow-Origin", "*",
- "Access-Control-Allow-Headers", "Content-Type,Authorization",
- "Access-Control-Allow-Methods", "POST,OPTIONS"
- );
-
- private final SpeakingService speakingService;
-
- public SpeakingHandler() {
- this.speakingService = new SpeakingService();
- }
-
- @Override
- public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent event, Context context) {
- logger.info("Speaking API request received");
-
- // OPTIONS 요청 처리 (CORS preflight)
- if ("OPTIONS".equalsIgnoreCase(event.getHttpMethod())) {
- return response(200, Map.of("message", "OK"));
- }
-
- try {
- // JWT 토큰 검증
- String authHeader = event.getHeaders().get("Authorization");
- if (authHeader == null || !authHeader.startsWith("Bearer ")) {
- return response(401, Map.of("error", "Authorization header is required"));
- }
-
- String token = authHeader.substring(7);
- if (!JwtUtil.isValid(token)) {
- return response(401, Map.of("error", "Invalid or expired token"));
- }
-
- Optional userIdOpt = JwtUtil.extractUserId(token);
- if (userIdOpt.isEmpty()) {
- return response(401, Map.of("error", "Invalid token"));
- }
-
- String userId = userIdOpt.get();
- String path = event.getPath();
- String body = event.getBody();
-
- logger.info("Processing request: path={}, userId={}", path, userId);
-
- // 라우팅
- if (path.endsWith("/chat")) {
- return handleChat(userId, body);
- } else if (path.endsWith("/reset")) {
- return handleReset(userId, body);
- } else {
- return response(404, Map.of("error", "Not found"));
- }
-
- } catch (Exception e) {
- logger.error("Error processing request: {}", e.getMessage(), e);
- return response(500, Map.of("error", "Internal server error: " + e.getMessage()));
- }
- }
-
- /**
- * 대화 처리 (음성 또는 텍스트)
- */
- private APIGatewayProxyResponseEvent handleChat(String userId, String body) {
- if (body == null || body.isEmpty()) {
- return response(400, Map.of("error", "Request body is required"));
- }
-
- JsonObject request = JsonParser.parseString(body).getAsJsonObject();
-
- String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null;
- String level = request.has("level") ? request.get("level").getAsString() : "INTERMEDIATE";
- String audio = request.has("audio") ? request.get("audio").getAsString() : null;
- String text = request.has("text") ? request.get("text").getAsString() : null;
-
- SpeakingResponse result;
-
- if (audio != null && !audio.isEmpty()) {
- // 음성 입력 처리
- logger.info("Processing voice input");
- result = speakingService.processVoiceInput(sessionId, userId, audio, level);
- } else if (text != null && !text.trim().isEmpty()) {
- // 텍스트 입력 처리
- logger.info("Processing text input: {}", text);
- result = speakingService.processTextInput(sessionId, userId, text.trim(), level);
- } else {
- return response(400, Map.of("error", "Either 'audio' or 'text' is required"));
- }
-
- return response(200, Map.of(
- "sessionId", result.sessionId(),
- "userTranscript", result.userTranscript(),
- "aiText", result.aiText(),
- "aiAudioUrl", result.aiAudioUrl(),
- "confidence", result.confidence()
- ));
- }
-
- /**
- * 대화 초기화
- */
- private APIGatewayProxyResponseEvent handleReset(String userId, String body) {
- if (body == null || body.isEmpty()) {
- return response(400, Map.of("error", "Request body is required"));
- }
-
- JsonObject request = JsonParser.parseString(body).getAsJsonObject();
- String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null;
-
- if (sessionId == null || sessionId.isEmpty()) {
- return response(400, Map.of("error", "sessionId is required"));
- }
-
- speakingService.resetConversation(sessionId);
-
- return response(200, Map.of(
- "message", "Conversation reset successfully",
- "sessionId", sessionId
- ));
- }
-
- private APIGatewayProxyResponseEvent response(int statusCode, Map body) {
- return new APIGatewayProxyResponseEvent()
- .withStatusCode(statusCode)
- .withHeaders(CORS_HEADERS)
- .withBody(gson.toJson(body));
- }
-
-
+
+ private static final Logger logger = LoggerFactory.getLogger(SpeakingHandler.class);
+ private static final Gson gson = new GsonBuilder().create();
+
+ private static final Map CORS_HEADERS = Map.of(
+ "Content-Type", "application/json",
+ "Access-Control-Allow-Origin", "*",
+ "Access-Control-Allow-Headers", "Content-Type,Authorization",
+ "Access-Control-Allow-Methods", "POST,OPTIONS"
+ );
+
+ private final SpeakingService speakingService;
+
+ public SpeakingHandler() {
+ this.speakingService = new SpeakingService();
+ }
+
+ @Override
+ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent event, Context context) {
+ logger.info("Speaking API request received");
+
+ // OPTIONS 요청 처리 (CORS preflight)
+ if ("OPTIONS".equalsIgnoreCase(event.getHttpMethod())) {
+ return response(200, Map.of("message", "OK"));
+ }
+
+ try {
+ // 사용자 인증 정보 추출 (Cognito Authorizer -> requestContext)
+ if (event.getRequestContext() == null || event.getRequestContext().getAuthorizer() == null) {
+ logger.error("No Authorizer found in request context");
+ return response(401, Map.of("error", "Unauthorized: User context missing"));
+ }
+
+ Map authorizer = event.getRequestContext().getAuthorizer();
+ Map claims = (Map) authorizer.get("claims");
+
+ if (claims == null) {
+ return response(401, Map.of("error", "Unauthorized: Claims missing"));
+ }
+
+ String userId = (String) claims.get("sub"); // Cognito User Pool의 고유 ID (UUID 형태)
+
+ // 요청 정보 추출
+ String path = event.getPath();
+ String body = event.getBody();
+
+ logger.info("Processing request: path={}, userId={}", path, userId);
+
+ // 라우팅
+ if (path != null && path.endsWith("/chat")) {
+ return handleChat(userId, body);
+ } else if (path != null && path.endsWith("/reset")) {
+ return handleReset(userId, body);
+ } else {
+ return response(404, Map.of("error", "Not found"));
+ }
+
+ } catch (Exception e) {
+ logger.error("Error processing request: {}", e.getMessage(), e);
+ return response(500, Map.of("error", "Internal server error: " + e.getMessage()));
+ }
+ }
+
+ /**
+ * 대화 처리 (음성 또는 텍스트)
+ */
+ private APIGatewayProxyResponseEvent handleChat(String userId, String body) {
+ if (body == null || body.isEmpty()) {
+ return response(400, Map.of("error", "Request body is required"));
+ }
+
+ JsonObject request = JsonParser.parseString(body).getAsJsonObject();
+
+ String sessionId = request.has("sessionId") && !request.get("sessionId").isJsonNull()
+ ? request.get("sessionId").getAsString() : null;
+ String level = request.has("level") && !request.get("level").isJsonNull()
+ ? request.get("level").getAsString() : "INTERMEDIATE";
+ String audio = request.has("audio") && !request.get("audio").isJsonNull()
+ ? request.get("audio").getAsString() : null;
+ String text = request.has("text") && !request.get("text").isJsonNull()
+ ? request.get("text").getAsString() : null;
+
+ SpeakingResponse result;
+
+ if (audio != null && !audio.isEmpty()) {
+ // 음성 입력 처리
+ logger.info("Processing voice event");
+ result = speakingService.processVoiceInput(sessionId, userId, audio, level);
+ } else if (text != null && !text.trim().isEmpty()) {
+ // 텍스트 입력 처리
+ logger.info("Processing text event: {}", text);
+ result = speakingService.processTextInput(sessionId, userId, text.trim(), level);
+ } else {
+ return response(400, Map.of("error", "Either 'audio' or 'text' is required"));
+ }
+
+ return response(200, Map.of(
+ "sessionId", result.sessionId(),
+ "userTranscript", result.userTranscript(),
+ "aiText", result.aiText(),
+ "aiAudioUrl", result.aiAudioUrl(),
+ "confidence", result.confidence()
+ ));
+ }
+
+ /**
+ * 대화 초기화
+ */
+ private APIGatewayProxyResponseEvent handleReset(String userId, String body) {
+ if (body == null || body.isEmpty()) {
+ return response(400, Map.of("error", "Request body is required"));
+ }
+
+ JsonObject request = JsonParser.parseString(body).getAsJsonObject();
+ String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null;
+
+ if (sessionId == null || sessionId.isEmpty()) {
+ return response(400, Map.of("error", "sessionId is required"));
+ }
+
+ speakingService.resetConversation(sessionId);
+
+ return response(200, Map.of(
+ "message", "Conversation reset successfully",
+ "sessionId", sessionId
+ ));
+ }
+
+ private APIGatewayProxyResponseEvent response(int statusCode, Map body) {
+ return new APIGatewayProxyResponseEvent()
+ .withStatusCode(statusCode)
+ .withHeaders(CORS_HEADERS)
+ .withBody(gson.toJson(body));
+ }
+
+
}
diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingMessageHandler.java
deleted file mode 100644
index e69de29b..00000000
diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java
deleted file mode 100644
index e69de29b..00000000
diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java
index aa7acb63..dadda7e7 100644
--- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java
+++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java
@@ -12,63 +12,63 @@
import java.util.Optional;
/**
- * Speaking WebSocket 연결 정보 Repository
+ * Speaking API 연결 정보 Repository
*/
public class SpeakingSessionRepository {
-
- private static final Logger logger = LoggerFactory.getLogger(SpeakingSessionRepository.class);
- private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME");
-
- private final DynamoDbTable table;
-
- public SpeakingSessionRepository() {
- this.table = AwsClients.dynamoDbEnhanced().table(
- TABLE_NAME,
- TableSchema.fromBean(SpeakingSession.class)
- );
- }
-
- /**
- * 연결 정보 저장
- */
- public void save(SpeakingSession session) {
- table.putItem(session);
- logger.debug("Speaking session saved: sessionId={}, userId={}",
- session.getSessionId(), session.getUserId());
- }
-
- /**
- * sessionId로 연결 정보 조회
- */
- public Optional findBySessionId(String sessionId) {
- Key key = Key.builder()
- .partitionValue(SpeakingSession.PK_PREFIX + sessionId)
- .sortValue(SpeakingSession.SK_METADATA)
- .build();
-
- SpeakingSession session = table.getItem(key);
- return Optional.ofNullable(session);
- }
-
- /**
- * 연결 정보 업데이트 (대화 히스토리 등)
- */
- public void update(SpeakingSession session) {
- session.touch(); // 업데이트 시간 및 TTL 갱신
- table.putItem(session);
- logger.debug("Speaking session updated: sessionId={}", session.getSessionId());
- }
-
- /**
- * 연결 정보 삭제
- */
- public void delete(String sessionId) {
- Key key = Key.builder()
- .partitionValue(SpeakingSession.PK_PREFIX + sessionId)
- .sortValue(SpeakingSession.SK_METADATA)
- .build();
-
- table.deleteItem(key);
- logger.info("Speaking session deleted: sessionId={}", sessionId);
- }
-}
+
+ private static final Logger logger = LoggerFactory.getLogger(SpeakingSessionRepository.class);
+ private static final String TABLE_NAME = EnvConfig.getRequired("SPEAKING_TABLE_NAME");
+
+ private final DynamoDbTable table;
+
+ public SpeakingSessionRepository() {
+ this.table = AwsClients.dynamoDbEnhanced().table(
+ TABLE_NAME,
+ TableSchema.fromBean(SpeakingSession.class)
+ );
+ }
+
+ /**
+ * 연결 정보 저장
+ */
+ public void save(SpeakingSession session) {
+ table.putItem(session);
+ logger.debug("Speaking session saved: sessionId={}, userId={}",
+ session.getSessionId(), session.getUserId());
+ }
+
+ /**
+ * sessionId로 연결 정보 조회
+ */
+ public Optional findBySessionId(String sessionId) {
+ Key key = Key.builder()
+ .partitionValue(SpeakingSession.PK_PREFIX + sessionId)
+ .sortValue(SpeakingSession.SK_METADATA)
+ .build();
+
+ SpeakingSession session = table.getItem(key);
+ return Optional.ofNullable(session);
+ }
+
+ /**
+ * 연결 정보 업데이트 (대화 히스토리 등)
+ */
+ public void update(SpeakingSession session) {
+ session.touch(); // 업데이트 시간 및 TTL 갱신
+ table.putItem(session);
+ logger.debug("Speaking session updated: sessionId={}", session.getSessionId());
+ }
+
+ /**
+ * 연결 정보 삭제
+ */
+ public void delete(String sessionId) {
+ Key key = Key.builder()
+ .partitionValue(SpeakingSession.PK_PREFIX + sessionId)
+ .sortValue(SpeakingSession.SK_METADATA)
+ .build();
+
+ table.deleteItem(key);
+ logger.info("Speaking session deleted: sessionId={}", sessionId);
+ }
+}
\ No newline at end of file
diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml
index 3fa504b5..50fbb74d 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}"
@@ -1551,6 +1552,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
#############################################
@@ -1935,6 +1977,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
#############################################
@@ -2164,6 +2238,10 @@ Outputs:
Description: OPIc DynamoDB Table Name
Value: !Ref OPIcTable
+ SpeakingTableName:
+ Description: Speaking DynamoDB Table Name
+ Value: !Ref SpeakingTable
+
NotificationStreamUrl:
Description: Notification SSE Stream Function URL
Value: !GetAtt NotificationStreamFunctionUrl.FunctionUrl