diff --git a/ServerlessFunction/build.gradle b/ServerlessFunction/build.gradle index 34615a92..b8f86d93 100644 --- a/ServerlessFunction/build.gradle +++ b/ServerlessFunction/build.gradle @@ -36,6 +36,7 @@ dependencies { implementation 'software.amazon.awssdk:ssm' implementation 'software.amazon.awssdk:scheduler' implementation 'software.amazon.awssdk:sqs' + implementation 'software.amazon.awssdk:ses' // AWS X-Ray SDK (다운스트림 서비스 추적용) implementation 'com.amazonaws:aws-xray-recorder-sdk-core:2.15.0' diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java index a1d2286b..0b7416ba 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java @@ -10,6 +10,7 @@ import software.amazon.awssdk.services.polly.PollyClient; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.ses.SesClient; import software.amazon.awssdk.services.sns.SnsClient; import software.amazon.awssdk.services.sqs.SqsClient; import software.amazon.awssdk.services.ssm.SsmClient; @@ -67,6 +68,11 @@ public final class AwsClients { .overrideConfiguration(XRAY_CONFIG) .build(); + // SES + private static final SesClient SES_CLIENT = SesClient.builder() + .overrideConfiguration(XRAY_CONFIG) + .build(); + private AwsClients() { // 인스턴스화 방지 } @@ -114,4 +120,6 @@ public static SsmClient ssm() { public static SqsClient sqs() { return SQS_CLIENT; } + + public static SesClient ses() { return SES_CLIENT; } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/AnswerProcessHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/AnswerProcessHandler.java new file mode 100644 index 00000000..294c9c8c --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/AnswerProcessHandler.java @@ -0,0 +1,213 @@ +package com.mzc.secondproject.serverless.domain.opic.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.SNSEvent; +import com.google.gson.Gson; +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.domain.opic.dto.response.FeedbackResponse; +import com.mzc.secondproject.serverless.domain.opic.model.OPIcAnswer; +import com.mzc.secondproject.serverless.domain.opic.model.OPIcQuestion; +import com.mzc.secondproject.serverless.domain.opic.model.OPIcSession; +import com.mzc.secondproject.serverless.domain.opic.repository.OPIcRepository; +import com.mzc.secondproject.serverless.domain.opic.service.FeedbackService; +import com.mzc.secondproject.serverless.domain.opic.service.TranscribeProxyService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.Map; + +/** + * SNS 트리거로 답변 비동기 처리 + * - Transcribe (STT) + * - Bedrock 피드백 생성 + * - Answer 상태 업데이트 + */ +public class AnswerProcessHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(AnswerProcessHandler.class); + private static final String OPIC_BUCKET = System.getenv("OPIC_BUCKET_NAME"); + + private final Gson gson = new Gson(); + private final OPIcRepository repository = new OPIcRepository(); + private final TranscribeProxyService transcribeService = new TranscribeProxyService(); + private final FeedbackService feedbackService = new FeedbackService(); + + @Override + public Void handleRequest(SNSEvent event, Context context) { + for (SNSEvent.SNSRecord record : event.getRecords()) { + processMessage(record.getSNS().getMessage()); + } + return null; + } + + @SuppressWarnings("unchecked") + private void processMessage(String messageBody) { + Map message = gson.fromJson(messageBody, Map.class); + + String sessionId = (String) message.get("sessionId"); + String questionId = (String) message.get("questionId"); + String audioS3Key = (String) message.get("audioS3Key"); + String targetLevel = (String) message.get("targetLevel"); + int currentIndex = ((Number) message.get("currentIndex")).intValue(); + int totalQuestions = ((Number) message.get("totalQuestions")).intValue(); + + logger.info("비동기 처리 시작: sessionId={}, questionIndex={}", sessionId, currentIndex); + + try { + // Answer 조회 (sessionId + questionIndex로 조회) + OPIcAnswer answer = repository.findAnswer(sessionId, currentIndex) + .orElseThrow(() -> new RuntimeException( + String.format("Answer not found: sessionId=%s, questionIndex=%d", sessionId, currentIndex))); + + // Question 조회 + OPIcQuestion question = repository.findQuestionById(questionId) + .orElseThrow(() -> new RuntimeException("Question not found: " + questionId)); + + // 1. S3에서 오디오 로드 + logger.info("S3에서 오디오 파일 로드: {}", audioS3Key); + byte[] audioBytes = AwsClients.s3().getObjectAsBytes( + software.amazon.awssdk.services.s3.model.GetObjectRequest.builder() + .bucket(OPIC_BUCKET) + .key(audioS3Key) + .build() + ).asByteArray(); + + String audioBase64 = java.util.Base64.getEncoder().encodeToString(audioBytes); + logger.info("오디오 Base64 변환: {} bytes → {} chars", audioBytes.length, audioBase64.length()); + + // 2. Transcribe (STT) + TranscribeProxyService.TranscribeResult transcribeResult = + transcribeService.transcribe(audioBase64, sessionId); + + String transcript = transcribeResult.transcript(); + logger.info("STT 완료: transcript 길이={}", transcript.length()); + + // 3. Bedrock 피드백 + FeedbackResponse feedback = feedbackService.generateFeedback( + question.getQuestionText(), + transcript, + targetLevel + ); + logger.info("피드백 생성 완료"); + + // 4. Answer 업데이트 (COMPLETED) + answer.setQuestionText(question.getQuestionText()); + answer.setTranscript(transcript); + answer.setTranscriptConfidence(transcribeResult.confidence()); + answer.setGrammarFeedback(gson.toJson(feedback.errors())); + answer.setContentFeedback(feedback.correctedAnswer()); + answer.setSampleAnswer(feedback.sampleAnswer()); + answer.setStatus(OPIcAnswer.AnswerStatus.COMPLETED); + answer.setAttemptCount(answer.getAttemptCount() + 1); + answer.setCompletedAt(Instant.now()); + + repository.saveAnswer(answer); + + // 5. 세션 업데이트 (currentQuestionIndex 증가) + OPIcSession session = repository.findSessionById(sessionId).orElse(null); + if (session != null) { + session.setCurrentQuestionIndex(currentIndex + 1); + repository.updateSession(session); + } + + logger.info("비동기 처리 완료: sessionId={}, questionIndex={}", sessionId, currentIndex); + + } catch (Exception e) { + logger.error("비동기 처리 실패: sessionId={}, questionIndex={}, error={}", + sessionId, currentIndex, e.getMessage(), e); + + // 실패 상태로 업데이트 + try { + OPIcAnswer answer = repository.findAnswer(sessionId, currentIndex).orElse(null); + if (answer != null) { + answer.setStatus(OPIcAnswer.AnswerStatus.FAILED); + answer.setAttemptCount(answer.getAttemptCount() + 1); + repository.saveAnswer(answer); + logger.info("Answer 상태 FAILED로 업데이트: sessionId={}, questionIndex={}", sessionId, currentIndex); + } + } catch (Exception ex) { + logger.error("실패 상태 업데이트 실패", ex); + } + } + } +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/EmailAsyncHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/EmailAsyncHandler.java new file mode 100644 index 00000000..59a75133 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/EmailAsyncHandler.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.opic.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.SNSEvent; +import com.google.gson.Gson; +import com.mzc.secondproject.serverless.domain.opic.service.EmailService; + +public class EmailAsyncHandler implements RequestHandler { + private final EmailService emailService = new EmailService(); + private final Gson gson = new Gson(); + + @Override + public Void handleRequest(SNSEvent event, Context context) { + for (SNSEvent.SNSRecord record : event.getRecords()) { + String messageBody = record.getSNS().getMessage(); + processMessage(messageBody); + } + return null; + } + private void processMessage(String body) { + // 메시지 파싱 및 타입 확인 (OPIC_REPORT_EMAIL) + // emailService.sendOPIcReportEmail 호출 + } +} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/OPIcSessionHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/OPIcSessionHandler.java index 0eb3b1a2..8f9c13cb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/OPIcSessionHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/OPIcSessionHandler.java @@ -7,21 +7,27 @@ import com.google.gson.*; import com.mzc.secondproject.serverless.common.config.AwsClients; import com.mzc.secondproject.serverless.common.service.PollyService; +import com.mzc.secondproject.serverless.common.util.CognitoUtil; import com.mzc.secondproject.serverless.common.util.JwtUtil; import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.opic.dto.request.CreateSessionRequest; import com.mzc.secondproject.serverless.domain.opic.dto.request.SubmitAnswerRequest; +import com.mzc.secondproject.serverless.domain.opic.dto.response.CreateSessionResponse; import com.mzc.secondproject.serverless.domain.opic.dto.response.FeedbackResponse; +import com.mzc.secondproject.serverless.domain.opic.dto.response.QuestionResponse; +import com.mzc.secondproject.serverless.domain.opic.dto.response.SessionReportResponse; import com.mzc.secondproject.serverless.domain.opic.model.OPIcAnswer; import com.mzc.secondproject.serverless.domain.opic.model.OPIcQuestion; import com.mzc.secondproject.serverless.domain.opic.model.OPIcSession; import com.mzc.secondproject.serverless.domain.opic.repository.OPIcRepository; +import com.mzc.secondproject.serverless.domain.opic.service.EmailService; import com.mzc.secondproject.serverless.domain.opic.service.FeedbackService; import com.mzc.secondproject.serverless.domain.opic.service.TranscribeProxyService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; +import software.amazon.awssdk.services.sns.model.PublishRequest; import java.lang.reflect.Type; import java.time.Duration; @@ -33,115 +39,128 @@ * OPIc 세션 통합 Handler * - 세션 생성/조회 * - 질문 조회 (Polly 음성 URL 포함) - * - 답변 제출 (Transcribe + Bedrock 피드백) + * - 답변 제출 (비동기: SNS → AnswerProcessHandler) + * - 답변 상태 조회 (폴링) * - 세션 완료 (종합 리포트) */ public class OPIcSessionHandler implements RequestHandler { - + private static final Logger logger = LoggerFactory.getLogger(OPIcSessionHandler.class); private static final Gson gson = new GsonBuilder() .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") .registerTypeAdapter(Instant.class, new InstantTypeAdapter()) .create(); - + private static final String OPIC_BUCKET = System.getenv("OPIC_BUCKET_NAME"); - + private final OPIcRepository repository; private final PollyService pollyService; private final TranscribeProxyService transcribeService; private final FeedbackService feedbackService; - + private final EmailService emailService; + public OPIcSessionHandler() { this.repository = new OPIcRepository(); this.pollyService = new PollyService(OPIC_BUCKET, "opic/voice/questions/"); this.transcribeService = new TranscribeProxyService(); this.feedbackService = new FeedbackService(); + this.emailService = new EmailService(); } - + @Override public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent event, Context context) { String httpMethod = event.getHttpMethod(); String path = event.getPath(); - + try { - String userId = extractUserId(event); - - + // POST /opic/sessions - 세션 생성 if ("POST".equals(httpMethod) && path.equals("/opic/sessions")) { return createSession(event, userId); } - + // GET /opic/sessions - 세션 목록 조회 if ("GET".equals(httpMethod) && path.equals("/opic/sessions")) { return getSessions(userId); } - + // GET /opic/sessions/{sessionId} - 세션 상세 조회 if ("GET".equals(httpMethod) && path.matches("/opic/sessions/[^/]+") - && !path.contains("/questions") && !path.contains("/upload-url")) { + && !path.contains("/questions") && !path.contains("/upload-url") && !path.contains("/answers")) { return getSession(event, userId); } - + // GET /opic/sessions/{sessionId}/questions/next - 다음 질문 조회 if ("GET".equals(httpMethod) && path.contains("/questions/next")) { return getNextQuestion(event, userId); } - + // GET /opic/sessions/{sessionId}/upload-url - Presigned URL 발급 if ("GET".equals(httpMethod) && path.contains("/upload-url")) { return getUploadUrl(event, userId); } - - // POST /opic/sessions/{sessionId}/answers - 답변 제출 + + // POST /opic/sessions/{sessionId}/answers - 답변 제출 (비동기) if ("POST".equals(httpMethod) && path.contains("/answers")) { return submitAnswer(event, userId); } - + + // GET /opic/sessions/{sessionId}/answers/{questionIndex}/status - 답변 상태 조회 (폴링) + if ("GET".equals(httpMethod) && path.matches(".*/answers/\\d+/status")) { + return getAnswerStatus(event, userId); + } + // POST /opic/sessions/{sessionId}/complete - 세션 완료 if ("POST".equals(httpMethod) && path.contains("/complete")) { return completeSession(event, userId); } - + return ResponseGenerator.badRequest("지원하지 않는 요청입니다: " + httpMethod + " " + path); - + } catch (Exception e) { logger.error("OPIc Handler 에러", e); return ResponseGenerator.serverError(e.getMessage()); } } - - + + /** * POST /opic/sessions * 세션 생성 + 첫 질문 반환 */ private APIGatewayProxyResponseEvent createSession(APIGatewayProxyRequestEvent event, String userId) { CreateSessionRequest request = gson.fromJson(event.getBody(), CreateSessionRequest.class); - - logger.info("세션 생성 요청: userId={}, topic={}, level={}", - userId, request.topic(), request.targetLevel()); - - // 주제 + 소주제 + 레벨로 질문 세트 조회 - List questions = repository.findQuestionsByTopicSubTopicAndLevel( + + logger.info("세션 생성 요청: userId={}, topic={}, subTopic={}, targetLevel={}", + userId, request.topic(), request.subTopic(), request.targetLevel()); + + // 질문 세트 조회 (주제+소주제로 조회) + List questions = repository.findQuestionsByTopicAndSubTopic( request.topic(), - request.subTopic(), - request.targetLevel() + request.subTopic() ); - + + // 질문 데이터 없음 예외 처리 if (questions.isEmpty()) { - return ResponseGenerator.notFound("해당 주제/레벨의 질문이 없습니다."); + String msg = String.format("해당 주제(%s)와 소주제(%s)에 해당하는 질문이 없습니다.", + request.topic(), request.subTopic()); + return ResponseGenerator.notFound(msg); } - - // 최대 3개 질문 선택 (랜덤 셔플) + + // 질문 3개 랜덤 선택 Collections.shuffle(questions); - List questionIds = questions.stream() + List selectedQuestions = questions.stream() .limit(3) + .sorted(Comparator.comparingInt(OPIcQuestion::getOrderInSet)) + .collect(Collectors.toList()); + + // 질문 ID 목록 추출 + List questionIds = selectedQuestions.stream() .map(OPIcQuestion::getQuestionId) .collect(Collectors.toList()); - - // 세션 생성 + + // 세션 저장 OPIcSession session = repository.createSession( userId, request.topic(), @@ -149,169 +168,168 @@ private APIGatewayProxyResponseEvent createSession(APIGatewayProxyRequestEvent e request.targetLevel(), questionIds ); - - // 첫 질문 Polly 음성 URL 생성 (#368 PollyService 연동) - OPIcQuestion firstQuestion = questions.get(0); + + // 첫 번째 질문 응답 생성 + OPIcQuestion firstQuestion = selectedQuestions.get(0); String audioUrl = generateQuestionAudioUrl(firstQuestion); - - // Response - Map response = new LinkedHashMap<>(); - response.put("sessionId", session.getSessionId()); - response.put("totalQuestions", session.getTotalQuestions()); - response.put("firstQuestion", Map.of( - "questionId", firstQuestion.getQuestionId(), - "questionText", firstQuestion.getQuestionText(), - "audioUrl", audioUrl, - "questionNumber", 1, - "totalQuestions", session.getTotalQuestions() - )); - - logger.info("세션 생성 완료: sessionId={}", session.getSessionId()); - return ResponseGenerator.created("세션이 생성되었습니다.", response); + + QuestionResponse questionResponse = new QuestionResponse( + firstQuestion.getQuestionId(), + firstQuestion.getQuestionText(), + audioUrl, + 1, + 3 + ); + + return ResponseGenerator.ok( + new CreateSessionResponse(session.getSessionId(), questionResponse, 3) + ); } - + /** * GET /opic/sessions * 사용자의 세션 목록 조회 */ private APIGatewayProxyResponseEvent getSessions(String userId) { List sessions = repository.findSessionsByUserId(userId, 20); - + Map responseBody = new LinkedHashMap<>(); responseBody.put("isSuccess", true); responseBody.put("data", sessions); - + return new APIGatewayProxyResponseEvent() .withStatusCode(200) .withHeaders(Map.of("Content-Type", "application/json")) .withBody(gson.toJson(responseBody)); } - + /** * GET /opic/sessions/{sessionId} * 세션 상세 조회 */ private APIGatewayProxyResponseEvent getSession(APIGatewayProxyRequestEvent event, String userId) { String sessionId = event.getPathParameters().get("sessionId"); - + OPIcSession session = repository.findSessionById(sessionId).orElse(null); - + if (session == null) { return ResponseGenerator.notFound("세션을 찾을 수 없습니다."); } - + if (!session.getUserId().equals(userId)) { return ResponseGenerator.forbidden("접근 권한이 없습니다."); } - + // 세션에 포함된 답변들도 조회 List answers = repository.findAnswersBySessionId(sessionId); - + Map response = new LinkedHashMap<>(); response.put("session", session); response.put("answers", answers); - + return ResponseGenerator.ok(response); } - + /** * GET /opic/sessions/{sessionId}/questions/next * 다음 질문 조회 (Polly 음성 URL 포함) */ private APIGatewayProxyResponseEvent getNextQuestion(APIGatewayProxyRequestEvent event, String userId) { String sessionId = event.getPathParameters().get("sessionId"); - + OPIcSession session = repository.findSessionById(sessionId).orElse(null); - + if (session == null) { return ResponseGenerator.notFound("세션을 찾을 수 없습니다."); } - + if (!session.getUserId().equals(userId)) { return ResponseGenerator.forbidden("접근 권한이 없습니다."); } - - // 모든 질문 완료 확인 + int currentIndex = session.getCurrentQuestionIndex(); + + // 모든 질문 완료 확인 if (currentIndex >= session.getTotalQuestions()) { - return ResponseGenerator.ok(Map.of( - "completed", true, - "message", "모든 질문이 완료되었습니다. 세션을 완료해주세요.", - "sessionId", sessionId - )); + Map response = new LinkedHashMap<>(); + response.put("completed", true); + response.put("message", "모든 질문이 완료되었습니다. 세션을 완료해주세요."); + return ResponseGenerator.ok(response); } - + // 다음 질문 조회 String questionId = session.getQuestionIds().get(currentIndex); OPIcQuestion question = repository.findQuestionById(questionId) - .orElseThrow(() -> new RuntimeException("질문을 찾을 수 없습니다: " + questionId)); - - // Polly 음성 URL + .orElseThrow(() -> new RuntimeException("질문을 찾을 수 없습니다.")); + String audioUrl = generateQuestionAudioUrl(question); - + + QuestionResponse questionResponse = new QuestionResponse( + question.getQuestionId(), + question.getQuestionText(), + audioUrl, + currentIndex + 1, + session.getTotalQuestions() + ); + Map response = new LinkedHashMap<>(); - response.put("questionId", question.getQuestionId()); - response.put("questionText", question.getQuestionText()); - response.put("audioUrl", audioUrl); - response.put("questionNumber", currentIndex + 1); - response.put("totalQuestions", session.getTotalQuestions()); - response.put("completed", false); - + response.put("question", questionResponse); + response.put("hasNextQuestion", (currentIndex + 1) < session.getTotalQuestions()); + return ResponseGenerator.ok(response); } - + /** * GET /opic/sessions/{sessionId}/upload-url - * S3 Presigned URL 발급 (음성 업로드용) + * Presigned URL 발급 */ private APIGatewayProxyResponseEvent getUploadUrl(APIGatewayProxyRequestEvent event, String userId) { String sessionId = event.getPathParameters().get("sessionId"); - - // 세션 검증 + OPIcSession session = repository.findSessionById(sessionId).orElse(null); - if (session == null || !session.getUserId().equals(userId)) { + if (session == null) { + return ResponseGenerator.notFound("세션을 찾을 수 없습니다."); + } + if (!session.getUserId().equals(userId)) { return ResponseGenerator.forbidden("접근 권한이 없습니다."); } - - // S3 키 생성 - String s3Key = String.format("opic/answers/%s/%s/%s.webm", - userId, - sessionId, - UUID.randomUUID().toString() - ); - - // Presigned URL 생성 (5분 유효) - PutObjectRequest putRequest = PutObjectRequest.builder() + + String fileId = UUID.randomUUID().toString(); + String s3Key = String.format("opic/answers/%s/%s/%s.webm", userId, sessionId, fileId); + + PutObjectRequest putObjectRequest = PutObjectRequest.builder() .bucket(OPIC_BUCKET) .key(s3Key) .contentType("audio/webm") .build(); - - String presignedUrl = AwsClients.s3Presigner() - .presignPutObject(PutObjectPresignRequest.builder() - .putObjectRequest(putRequest) - .signatureDuration(Duration.ofMinutes(5)) - .build()) + + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .putObjectRequest(putObjectRequest) + .signatureDuration(Duration.ofMinutes(10)) + .build(); + + String uploadUrl = AwsClients.s3Presigner() + .presignPutObject(presignRequest) .url() .toString(); - - return ResponseGenerator.ok(Map.of( - "uploadUrl", presignedUrl, - "s3Key", s3Key, - "expiresIn", 300 - )); + + Map response = new LinkedHashMap<>(); + response.put("uploadUrl", uploadUrl); + response.put("s3Key", s3Key); + + return ResponseGenerator.ok(response); } - + /** * POST /opic/sessions/{sessionId}/answers - * 답변 제출 → STT → AI 피드백 + * 답변 제출 (비동기 - SNS로 처리 위임) */ private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent event, String userId) { String sessionId = event.getPathParameters().get("sessionId"); SubmitAnswerRequest request = gson.fromJson(event.getBody(), SubmitAnswerRequest.class); - - logger.info("답변 제출: sessionId={}, s3Key={}", sessionId, request.audioS3Key()); - + + logger.info("답변 제출 (비동기): sessionId={}, s3Key={}", sessionId, request.audioS3Key()); + // 세션 검증 OPIcSession session = repository.findSessionById(sessionId).orElse(null); if (session == null) { @@ -320,95 +338,154 @@ private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent ev if (!session.getUserId().equals(userId)) { return ResponseGenerator.forbidden("접근 권한이 없습니다."); } - - // 현재 질문 조회 + + // 현재 질문 인덱스 확인 int currentIndex = session.getCurrentQuestionIndex(); if (currentIndex >= session.getTotalQuestions()) { return ResponseGenerator.badRequest("이미 모든 질문에 답변했습니다."); } - + String questionId = session.getQuestionIds().get(currentIndex); - OPIcQuestion question = repository.findQuestionById(questionId) - .orElseThrow(() -> new RuntimeException("질문을 찾을 수 없습니다.")); - - // Transcribe Proxy 호출 (음성 → 텍스트) - logger.info("S3에서 오디오 파일 로드: {}", request.audioS3Key()); - - byte[] audioBytes = AwsClients.s3().getObjectAsBytes( - software.amazon.awssdk.services.s3.model.GetObjectRequest.builder() - .bucket(OPIC_BUCKET) - .key(request.audioS3Key()) - .build() - ).asByteArray(); - - String audioBase64 = java.util.Base64.getEncoder().encodeToString(audioBytes); - logger.info("오디오 파일 Base64 변환 완료: {} bytes → {} chars", - audioBytes.length, audioBase64.length()); - - // 4. Transcribe Proxy 호출 (Base64 데이터 전송) - TranscribeProxyService.TranscribeResult transcribeResult = - transcribeService.transcribe(audioBase64, sessionId); - - String transcript = transcribeResult.transcript(); - logger.info("STT 변환 완료: transcript 길이={}", transcript.length()); - - // Bedrock 피드백 생성 - FeedbackResponse feedback = feedbackService.generateFeedback( - question.getQuestionText(), - transcript, - session.getTargetLevel() - ); - - // Answer 저장 - 개별 필드로 분리 저장 + + // Answer 레코드 생성 (PROCESSING 상태) OPIcAnswer answer = new OPIcAnswer(); answer.setSessionId(sessionId); answer.setQuestionId(questionId); answer.setQuestionIndex(currentIndex); - answer.setQuestionText(question.getQuestionText()); // 비정규화 answer.setAudioS3Key(request.audioS3Key()); - answer.setTranscript(transcript); - answer.setTranscriptConfidence(transcribeResult.confidence()); - - // 피드백 개별 필드 저장 - answer.setGrammarFeedback(gson.toJson(feedback.errors())); // errors → grammarFeedback - answer.setContentFeedback(feedback.correctedAnswer()); // correctedAnswer → contentFeedback - answer.setSampleAnswer(feedback.sampleAnswer()); // 모범 답변 - answer.setStatus(OPIcAnswer.AnswerStatus.COMPLETED); - answer.setAttemptCount(1); + answer.setStatus(OPIcAnswer.AnswerStatus.PROCESSING); + answer.setAttemptCount(0); answer.setCreatedAt(Instant.now()); - answer.setCompletedAt(Instant.now()); - + repository.saveAnswer(answer); - - // 세션 진행 상태 업데이트 - session.setCurrentQuestionIndex(currentIndex + 1); - repository.updateSession(session); - - // Response - boolean hasNext = (currentIndex + 1) < session.getTotalQuestions(); - + logger.info("Answer 생성 (PROCESSING): sessionId={}, questionIndex={}", sessionId, currentIndex); + + // SNS로 비동기 처리 요청 + publishToSNS(sessionId, questionId, request.audioS3Key(), + session.getTargetLevel(), currentIndex, session.getTotalQuestions()); + + // 즉시 응답 (HTTP 202 Accepted) Map response = new LinkedHashMap<>(); - response.put("transcript", transcript); + response.put("sessionId", sessionId); + response.put("questionIndex", currentIndex); + response.put("status", "PROCESSING"); + response.put("message", "답변을 처리 중입니다. 잠시 후 결과를 확인하세요."); + response.put("pollingUrl", String.format("/opic/sessions/%s/answers/%d/status", sessionId, currentIndex)); + + return ResponseGenerator.ok(response); + } + + /** + * SNS 발행 (비동기 처리 요청) + */ + private void publishToSNS(String sessionId, String questionId, String audioS3Key, + String targetLevel, int currentIndex, int totalQuestions) { + try { + String topicArn = System.getenv("ANSWER_PROCESS_TOPIC_ARN"); + + if (topicArn == null || topicArn.isEmpty()) { + logger.error("ANSWER_PROCESS_TOPIC_ARN 환경변수가 설정되지 않았습니다."); + return; + } + + Map message = new LinkedHashMap<>(); + message.put("sessionId", sessionId); + message.put("questionId", questionId); + message.put("audioS3Key", audioS3Key); + message.put("targetLevel", targetLevel); + message.put("currentIndex", currentIndex); + message.put("totalQuestions", totalQuestions); + + AwsClients.sns().publish(PublishRequest.builder() + .topicArn(topicArn) + .message(gson.toJson(message)) + .build()); + + logger.info("SNS 발행 완료: sessionId={}, questionIndex={}", sessionId, currentIndex); + } catch (Exception e) { + logger.error("SNS 발행 실패: {}", e.getMessage(), e); + // 실패해도 일단 진행 (폴링에서 PROCESSING 상태로 계속 보임) + } + } + + /** + * GET /opic/sessions/{sessionId}/answers/{questionIndex}/status + * 답변 상태 조회 (폴링용) + */ + private APIGatewayProxyResponseEvent getAnswerStatus(APIGatewayProxyRequestEvent event, String userId) { + String sessionId = event.getPathParameters().get("sessionId"); + String questionIndexStr = event.getPathParameters().get("questionIndex"); + int questionIndex = Integer.parseInt(questionIndexStr); + + logger.info("답변 상태 조회: sessionId={}, questionIndex={}", sessionId, questionIndex); + + // 세션 권한 확인 + OPIcSession session = repository.findSessionById(sessionId).orElse(null); + if (session == null) { + return ResponseGenerator.notFound("세션을 찾을 수 없습니다."); + } + if (!session.getUserId().equals(userId)) { + return ResponseGenerator.forbidden("접근 권한이 없습니다."); + } + + // 답변 조회 + OPIcAnswer answer = repository.findAnswer(sessionId, questionIndex).orElse(null); + if (answer == null) { + return ResponseGenerator.notFound("답변을 찾을 수 없습니다."); + } + + Map response = new LinkedHashMap<>(); + response.put("sessionId", sessionId); + response.put("questionIndex", questionIndex); + response.put("status", answer.getStatus().name()); + + if (answer.getStatus() == OPIcAnswer.AnswerStatus.PROCESSING) { + response.put("message", "아직 처리 중입니다..."); + return ResponseGenerator.ok(response); + } + + if (answer.getStatus() == OPIcAnswer.AnswerStatus.FAILED) { + response.put("message", "처리에 실패했습니다. 다시 시도해주세요."); + return ResponseGenerator.ok(response); + } + + // COMPLETED - 전체 결과 반환 + response.put("transcript", answer.getTranscript()); + + // feedback 객체 구성 + Map feedback = new LinkedHashMap<>(); + if (answer.getGrammarFeedback() != null && !answer.getGrammarFeedback().isEmpty()) { + try { + feedback.put("errors", gson.fromJson(answer.getGrammarFeedback(), List.class)); + } catch (Exception e) { + feedback.put("errors", new ArrayList<>()); + } + } else { + feedback.put("errors", new ArrayList<>()); + } + feedback.put("correctedAnswer", answer.getContentFeedback()); + feedback.put("sampleAnswer", answer.getSampleAnswer()); response.put("feedback", feedback); + + boolean hasNext = (questionIndex + 1) < session.getTotalQuestions(); response.put("hasNextQuestion", hasNext); - response.put("currentQuestion", currentIndex + 1); + response.put("currentQuestion", questionIndex + 1); response.put("totalQuestions", session.getTotalQuestions()); - + if (hasNext) { - response.put("nextQuestionNumber", currentIndex + 2); + response.put("nextQuestionNumber", questionIndex + 2); } - - logger.info("답변 처리 완료: sessionId={}, questionIndex={}", sessionId, currentIndex); - return ResponseGenerator.ok("피드백이 생성되었습니다.", response); + + return ResponseGenerator.ok(response); } - + /** * POST /opic/sessions/{sessionId}/complete * 세션 완료 + 종합 리포트 생성 */ private APIGatewayProxyResponseEvent completeSession(APIGatewayProxyRequestEvent event, String userId) { String sessionId = event.getPathParameters().get("sessionId"); - + OPIcSession session = repository.findSessionById(sessionId).orElse(null); if (session == null) { return ResponseGenerator.notFound("세션을 찾을 수 없습니다."); @@ -416,50 +493,92 @@ private APIGatewayProxyResponseEvent completeSession(APIGatewayProxyRequestEvent if (!session.getUserId().equals(userId)) { return ResponseGenerator.forbidden("접근 권한이 없습니다."); } - + // 모든 질문 답변 완료 확인 List answers = repository.findAnswersBySessionId(sessionId); - if (answers.size() < session.getTotalQuestions()) { + + // COMPLETED 상태인 답변만 카운트 + long completedAnswers = answers.stream() + .filter(a -> a.getStatus() == OPIcAnswer.AnswerStatus.COMPLETED) + .count(); + + if (completedAnswers < session.getTotalQuestions()) { return ResponseGenerator.badRequest( String.format("아직 %d개의 질문에 답변하지 않았습니다.", - session.getTotalQuestions() - answers.size()) + session.getTotalQuestions() - completedAnswers) ); } - + // 세션 요약 생성 (피드백용) StringBuilder summaryBuilder = new StringBuilder(); for (int i = 0; i < answers.size(); i++) { OPIcAnswer answer = answers.get(i); OPIcQuestion question = repository.findQuestionById(answer.getQuestionId()).orElse(null); - + summaryBuilder.append(String.format("### Question %d\n", i + 1)); if (question != null) { summaryBuilder.append("Q: ").append(question.getQuestionText()).append("\n"); } summaryBuilder.append("A: ").append(answer.getTranscript()).append("\n\n"); } - + // 종합 리포트 생성 (Bedrock) var sessionReport = feedbackService.generateSessionReport( summaryBuilder.toString(), session.getTargetLevel() ); - + + // 이메일 발송 + try { + String userEmail = CognitoUtil.extractEmail(event).orElse(null); + String userName = CognitoUtil.extractNickname(event).orElse("학습자"); + + if (userEmail != null && !userEmail.isEmpty()) { + publishEmailToSNS(userEmail, userName, sessionReport); + logger.info("이메일 발송 SNS 요청 발행: to={}", userEmail); + } + } catch (Exception e) { + // 이메일 실패해도 세션 완료는 성공 처리 + logger.warn("리포트 이메일 발송 실패 (무시됨): {}", e.getMessage()); + } + // 세션 완료 처리 repository.completeSession( session, sessionReport.estimatedLevel(), gson.toJson(sessionReport) ); - + logger.info("세션 완료: sessionId={}, estimatedLevel={}", sessionId, sessionReport.estimatedLevel()); - + return ResponseGenerator.ok("세션이 완료되었습니다.", sessionReport); } - - // ==================== 유틸리티 ==================== - + + /** + * 이메일 발송용 SNS 메시지 발행 + */ + private void publishEmailToSNS(String email, String userName, SessionReportResponse report) { + try { + String topicArn = System.getenv("NOTIFICATION_TOPIC_ARN"); + + Map message = new HashMap<>(); + message.put("type", "OPIC_REPORT_EMAIL"); + message.put("recipientEmail", email); + message.put("userName", userName); + message.put("report", report); // 세션 리포트 객체 전달 + + AwsClients.sns().publish(PublishRequest.builder() + .topicArn(topicArn) + .message(gson.toJson(message)) + .build()); + } catch (Exception e) { + logger.error("이메일 SNS 발행 실패", e); + } + } + + // ==================== 유틸리티 ====================Z + /** * 질문 음성 URL 생성 (Polly + S3 캐싱) */ @@ -476,27 +595,27 @@ private String generateQuestionAudioUrl(OPIcQuestion question) { return null; } } - + /** * JWT 토큰에서 userId 추출 */ private String extractUserId(APIGatewayProxyRequestEvent event) { String authHeader = event.getHeaders().get("Authorization"); - + if (authHeader == null || authHeader.isEmpty()) { authHeader = event.getHeaders().get("authorization"); } - + return JwtUtil.extractUserId(authHeader) .orElseThrow(() -> new RuntimeException("인증 정보를 찾을 수 없습니다.")); } - + private static class InstantTypeAdapter implements JsonSerializer, JsonDeserializer { @Override public JsonElement serialize(Instant src, Type typeOfSrc, JsonSerializationContext context) { return new JsonPrimitive(src.toString()); } - + @Override public Instant deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcQuestion.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcQuestion.java index 6a2056b1..0a85b9d4 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcQuestion.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcQuestion.java @@ -10,12 +10,12 @@ public class OPIcQuestion { private String pk; // QUESTION#questionId private String sk; // METADATA - private String gsi1pk; // TOPIC#travel - private String gsi1sk; // LEVEL#IM2 + private String gsi1pk; // TOPIC#DESCRIPTION (질문 유형 - 대주제) + private String gsi1sk; // SUBTOPIC#HOMES (질문 소재 - 소주제) private String questionId; - private String topic; // 대주제 - private String subTopic; // 소주제 + private String topic; // DESCRIPTION, HABIT, PAST_EXPERIENCE ... + private String subTopic; // HOMES, BANKS, MUSIC ... private String level; // 난이도 (IM1, IM2, IM3, IH, AL) private String questionText; // 질문 텍스트 (영어) private String questionTextKo; // 질문 텍스트 (한국어, 참고용) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java index 62251d18..3b53f323 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java @@ -151,20 +151,23 @@ public Optional findQuestionById(String questionId) { return Optional.ofNullable(questionTable.getItem(key)); } - + /** - * 주제 + 레벨로 질문 조회 (GSI1) + * 질문 유형 + 질문 주제로 조회 */ - public List findQuestionsByTopicAndLevel(String topic, String level) { + public List findQuestionsByTopicAndSubTopic(String topic, String subTopic) { DynamoDbIndex gsi1 = questionTable.index("GSI1"); - - QueryConditional queryConditional = QueryConditional.keyEqualTo( + + String pkVal = "TOPIC#" + topic.toUpperCase(); + String skVal = "SUBTOPIC#" + subTopic.toUpperCase(); + + QueryConditional queryConditional = QueryConditional.sortBeginsWith( Key.builder() - .partitionValue("TOPIC#" + topic) - .sortValue("LEVEL#" + level) + .partitionValue(pkVal) + .sortValue(skVal) .build() ); - + return gsi1.query(queryConditional) .stream() .flatMap(page -> page.items().stream()) @@ -172,17 +175,6 @@ public List findQuestionsByTopicAndLevel(String topic, String leve .collect(Collectors.toList()); } - /** - * 주제 + 소주제 + 레벨로 질문 조회 (subTopic 필터 추가) - */ - public List findQuestionsByTopicSubTopicAndLevel( - String topic, String subTopic, String level) { - - return findQuestionsByTopicAndLevel(topic, level).stream() - .filter(q -> subTopic == null || subTopic.equals(q.getSubTopic())) - .collect(Collectors.toList()); - } - /** * 여러 질문 ID로 조회 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/EmailService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/EmailService.java new file mode 100644 index 00000000..d7ba36ce --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/EmailService.java @@ -0,0 +1,294 @@ +package com.mzc.secondproject.serverless.domain.opic.service; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.domain.opic.dto.response.SessionReportResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.ses.model.*; + +/** +* AWS SES를 이용한 이메일 발송 서비스 +*/ +public class EmailService { + + private static final Logger logger = LoggerFactory.getLogger(EmailService.class); + + private static final String SENDER_EMAIL = System.getenv("SES_SENDER_EMAIL"); + private static final String APP_NAME = "English Study"; + + /** + * OPIc 세션 리포트 이메일 발송 + */ + public void sendOPIcReportEmail(String recipientEmail, String userName, SessionReportResponse report) { + logger.info("OPIc 리포트 이메일 발송: to={}", recipientEmail); + + String subject = String.format("[%s] OPIc 스피킹 테스트 결과 - %s 등급", + APP_NAME, report.estimatedLevel()); + + String htmlBody = buildOPIcReportHtml(userName, report); + String textBody = buildOPIcReportText(userName, report); + + sendEmail(recipientEmail, subject, htmlBody, textBody); + } + + /** + * 이메일 발송 (HTML + Text) + */ + private void sendEmail(String to, String subject, String htmlBody, String textBody) { + try { + SendEmailRequest request = SendEmailRequest.builder() + .source(SENDER_EMAIL) + .destination(Destination.builder() + .toAddresses(to) + .build()) + .message(Message.builder() + .subject(Content.builder() + .charset("UTF-8") + .data(subject) + .build()) + .body(Body.builder() + .html(Content.builder() + .charset("UTF-8") + .data(htmlBody) + .build()) + .text(Content.builder() + .charset("UTF-8") + .data(textBody) + .build()) + .build()) + .build()) + .build(); + + SendEmailResponse response = AwsClients.ses().sendEmail(request); + logger.info("이메일 발송 성공: messageId={}", response.messageId()); + + } catch (SesException e) { + logger.error("이메일 발송 실패: {}", e.getMessage(), e); + // 이메일 실패해도 세션 완료는 진행되도록 예외를 던지지 않음 + } + } + + /** + * OPIc 리포트 HTML 템플릿 + */ + private String buildOPIcReportHtml(String userName, SessionReportResponse report) { + StringBuilder html = new StringBuilder(); + + html.append(""); + html.append(""); + html.append(""); + + // Header + html.append("
"); + html.append("

🎯 OPIc 스피킹 테스트 결과

"); + html.append("
"); + + // Main Content + html.append("
"); + + // Greeting + html.append("

안녕하세요, ") + .append(userName != null ? userName : "학습자") + .append("님!

"); + html.append("

OPIc 스피킹 테스트 결과를 알려드립니다.

"); + + // Score Cards + html.append("
"); + + // Estimated Level + html.append("
"); + html.append("

예상 등급

"); + html.append("

") + .append(report.estimatedLevel()).append("

"); + html.append("
"); + + // Overall Score + html.append("
"); + html.append("

종합 점수

"); + html.append("

") + .append(report.overallScore()).append("

"); + html.append("
"); + + html.append("
"); + + // Feedback + html.append("
"); + html.append("

📝 종합 피드백

"); + html.append("

") + .append(report.feedback()).append("

"); + html.append("
"); + + // Strengths + html.append("
"); + html.append("

💪 잘한 점

"); + html.append("
    "); + for (String strength : report.strengths()) { + html.append("
  • ").append(strength).append("
  • "); + } + html.append("
"); + html.append("
"); + + // Weaknesses + html.append("
"); + html.append("

📈 개선할 점

"); + html.append("
    "); + for (String weakness : report.weaknesses()) { + html.append("
  • ").append(weakness).append("
  • "); + } + html.append("
"); + html.append("
"); + + // Recommendations + html.append("
"); + html.append("

💡 학습 추천

"); + html.append("
    "); + for (String rec : report.recommendations()) { + html.append("
  1. ").append(rec).append("
  2. "); + } + html.append("
"); + html.append("
"); + + // CTA Button + html.append("
"); + html.append(""); + html.append("전체 리포트 보기"); + html.append("
"); + + // Footer + html.append("
"); + html.append("

"); + html.append("본 이메일은 English Study 서비스에서 자동으로 발송되었습니다.
"); + html.append("© 2025 English Study. All rights reserved.

"); + + html.append("
"); + html.append(""); + + return html.toString(); + } + + /** + * OPIc 리포트 텍스트 버전 (HTML 미지원 이메일 클라이언트용) + */ + private String buildOPIcReportText(String userName, SessionReportResponse report) { + StringBuilder text = new StringBuilder(); + + text.append("OPIc 스피킹 테스트 결과\n"); + text.append("================================\n\n"); + + text.append("안녕하세요, ").append(userName != null ? userName : "학습자").append("님!\n"); + text.append("OPIc 스피킹 테스트 결과를 알려드립니다.\n\n"); + + text.append("결과 요약\n"); + text.append("------------\n"); + text.append("예상 등급: ").append(report.estimatedLevel()).append("\n"); + text.append("종합 점수: ").append(report.overallScore()).append("점\n\n"); + + text.append("종합 피드백\n"); + text.append("------------\n"); + text.append(report.feedback()).append("\n\n"); + + text.append("잘한 점\n"); + text.append("------------\n"); + for (String strength : report.strengths()) { + text.append("• ").append(strength).append("\n"); + } + text.append("\n"); + + text.append("개선할 점\n"); + text.append("------------\n"); + for (String weakness : report.weaknesses()) { + text.append("• ").append(weakness).append("\n"); + } + text.append("\n"); + + text.append("학습 추천\n"); + text.append("------------\n"); + int i = 1; + for (String rec : report.recommendations()) { + text.append(i++).append(". ").append(rec).append("\n"); + } + text.append("\n"); + + text.append("================================\n"); + text.append("© 2025 English Study\n"); + + return text.toString(); + } + + /** + * 레벨별 색상 반환 + */ + private String getLevelColor(String level) { + return switch (level) { + case "NL", "NM", "NH" -> "#6b7280"; + case "IL" -> "#22c55e"; + case "IM1" -> "#10b981"; + case "IM2" -> "#3b82f6"; + case "IM3" -> "#8b5cf6"; + case "IH" -> "#f97316"; + case "AL" -> "#ef4444"; + default -> "#3b82f6"; + }; + } + + /** + * 점수별 색상 반환 + */ + private String getScoreColor(int score) { + if (score >= 90) return "#059669"; + if (score >= 70) return "#3b82f6"; + if (score >= 50) return "#f97316"; + return "#ef4444"; + } +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index ed741aeb..5680182c 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -30,6 +30,7 @@ Globals: Architectures: - x86_64 Tracing: Active + AutoPublishAlias: live Environment: Variables: USER_TABLE_NAME: !Ref UserTable @@ -48,6 +49,7 @@ Globals: 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" + SES_SENDER_EMAIL: "hye.ina0130@gmail.com" Api: TracingEnabled: true @@ -1511,6 +1513,16 @@ Resources: Description: Daily word learning stats aggregation Enabled: true + ############################################# + # OPIc SNS Topics + ############################################# + + AnswerProcessTopic: + Type: AWS::SNS::Topic + Properties: + TopicName: !Sub "${AWS::StackName}-opic-answer-process" + DisplayName: "OPIc Answer Processing Topic" + ############################################# # OPIc Lambda Functions ############################################# @@ -1522,7 +1534,7 @@ Resources: CodeUri: . Handler: com.mzc.secondproject.serverless.domain.opic.handler.OPIcSessionHandler::handleRequest Description: Handle OPIc speaking practice sessions - Timeout: 180 + Timeout: 30 MemorySize: 1024 SnapStart: ApplyOn: PublishedVersions @@ -1530,11 +1542,14 @@ Resources: Variables: TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic + ANSWER_PROCESS_TOPIC_ARN: !Ref AnswerProcessTopic Policies: - DynamoDBCrudPolicy: TableName: !Ref OPIcTable - S3CrudPolicy: BucketName: !Sub "${AWS::StackName}" + - SNSPublishMessagePolicy: + TopicName: !GetAtt AnswerProcessTopic.TopicName - Statement: - Effect: Allow Action: @@ -1553,6 +1568,12 @@ Resources: Action: - ssm:GetParameter Resource: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/opic/*" + - Statement: + - Effect: Allow + Action: + - ses:SendEmail + - ses:SendRawEmail + Resource: "*" - SNSPublishMessagePolicy: TopicName: !GetAtt NotificationTopic.TopicName Events: @@ -1601,6 +1622,15 @@ Resources: Method: POST Auth: Authorizer: CognitoAuthV2 + # 답변 상태 조회 (폴링용) + GetAnswerStatus: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /opic/sessions/{sessionId}/answers/{questionIndex}/status + Method: GET + Auth: + Authorizer: CognitoAuthV2 # 세션 완료 CompleteSession: Type: Api @@ -1620,6 +1650,62 @@ Resources: Auth: Authorizer: CognitoAuthV2 + # 비동기 답변 처리 Lambda + OPIcAnswerProcessFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-opic-answer-processor" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.opic.handler.AnswerProcessHandler::handleRequest + Description: Process OPIc answers asynchronously (STT + AI Feedback) + Timeout: 600 + MemorySize: 1024 + SnapStart: + ApplyOn: PublishedVersions + Environment: + Variables: + TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref OPIcTable + - S3ReadPolicy: + BucketName: !Sub "${AWS::StackName}" + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + - bedrock:InvokeModelWithResponseStream + Resource: "*" + - Statement: + - Effect: Allow + Action: + - ssm:GetParameter + Resource: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/opic/*" + Events: + SNSTrigger: + Type: SNS + Properties: + Topic: !Ref AnswerProcessTopic + + # 이메일 발송 전용 비동기 Lambda + OPIcEmailFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-opic-email-handler" + Handler: com.mzc.secondproject.serverless.domain.opic.handler.EmailAsyncHandler::handleRequest + Events: + SNSEvent: + Type: SNS + Properties: + Topic: !Ref NotificationTopic + Policies: + - Statement: + - Effect: Allow + Action: + - ses:SendEmail + - ses:SendRawEmail + Resource: "*" + ############################################# # Speaking Lambda Functions ############################################# diff --git a/seed/opic/question-homes.json b/seed/opic/question-homes.json deleted file mode 100644 index ddd67703..00000000 --- a/seed/opic/question-homes.json +++ /dev/null @@ -1,108 +0,0 @@ -{ - "questions": [ - { - "questionId": "desc-homes-001", - "topic": "DESCRIPTION", - "subTopic": "HOMES", - "questionText": "I would like to know about where you live. Talk about the different rooms at your place. Tell me about your favorite room in your home. What does it look like?", - "difficulty": "IM2", - "questionNumber": 44 - }, - { - "questionId": "desc-homes-002", - "topic": "DESCRIPTION", - "subTopic": "HOMES", - "questionText": "I would like to know where you live. Can you describe your home to me? What does it look like? How many rooms does it have? Give me a description with lots of details.", - "difficulty": "IM2", - "questionNumber": 45 - }, - { - "questionId": "desc-homes-003", - "topic": "DESCRIPTION", - "subTopic": "HOMES", - "questionText": "Now, let's talk about your bedroom. What's inside? What kind of furniture do you have in your room?", - "difficulty": "IM1", - "questionNumber": 46 - }, - { - "questionId": "habit-homes-001", - "topic": "HABIT", - "subTopic": "HOMES", - "questionText": "What kinds of home improvement projects do you enjoy doing and why?", - "difficulty": "IM2", - "questionNumber": 139 - }, - { - "questionId": "habit-homes-002", - "topic": "HABIT", - "subTopic": "HOMES", - "questionText": "Tell me about your normal routine at home. What do you do on weekdays and on weekends?", - "difficulty": "IM1", - "questionNumber": 140 - }, - { - "questionId": "habit-homes-003", - "topic": "HABIT", - "subTopic": "HOMES", - "questionText": "What is your normal routine at home? What things do you usually do on weekdays and on weekends?", - "difficulty": "IM1", - "questionNumber": 141 - }, - { - "questionId": "habit-homes-004", - "topic": "HABIT", - "subTopic": "HOMES", - "questionText": "What is your responsibility at home? What is your role? Tell me in detail.", - "difficulty": "IM2", - "questionNumber": 142 - }, - { - "questionId": "habit-homes-005", - "topic": "HABIT", - "subTopic": "HOMES", - "questionText": "What kinds of things do you do to keep your house clean and comfortable? What kinds of housework do you do at home?", - "difficulty": "IM1", - "questionNumber": 143 - }, - { - "questionId": "habit-homes-006", - "topic": "HABIT", - "subTopic": "HOMES", - "questionText": "What kind of house work do you usually do at home? Do you share your chores with your family?", - "difficulty": "IM1", - "questionNumber": 144 - }, - { - "questionId": "habit-homes-007", - "topic": "HABIT", - "subTopic": "HOMES", - "questionText": "Tell me about all the steps you take for a home improvement project. What steps do you usually take? How do you complete the project?", - "difficulty": "IH", - "questionNumber": 145 - }, - { - "questionId": "past-homes-001", - "topic": "PAST_EXPERIENCE", - "subTopic": "HOMES", - "questionText": "Tell me about a memorable experience you had at home. What happened and why was it memorable?", - "difficulty": "IM2", - "questionNumber": 0 - }, - { - "questionId": "past-homes-002", - "topic": "PAST_EXPERIENCE", - "subTopic": "HOMES", - "questionText": "Have you ever had any problems at home? Maybe something broke or there was an issue with your neighbors. Tell me about that experience and how you solved it.", - "difficulty": "IH", - "questionNumber": 0 - }, - { - "questionId": "past-homes-003", - "topic": "PAST_EXPERIENCE", - "subTopic": "HOMES", - "questionText": "Tell me about a time when you had to do a major cleaning or organizing at home. What did you do and how did it turn out?", - "difficulty": "IM2", - "questionNumber": 0 - } - ] -}