From ad155d316929556bfd8642ec24f56dca6c1ef1b4 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Tue, 20 Jan 2026 16:18:13 +0900 Subject: [PATCH 01/99] =?UTF-8?q?feature=20:=20OPIc=20=EC=84=B8=EC=85=98?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20+=20=EB=8B=B5=EB=B3=80=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=20(#413)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 --- .../dto/request/CreateSessionRequest.java | 7 + .../opic/dto/request/SubmitAnswerRequest.java | 5 + .../dto/response/AnswerFeedbackResponse.java | 10 + .../dto/response/CreateSessionResponse.java | 7 + .../opic/dto/response/QuestionResponse.java | 9 + .../opic/handler/OPIcSessionHandler.java | 506 ++++++++++++++++++ .../domain/opic/model/OPIcAnswer.java | 2 + .../opic/repository/OPIcRepository.java | 11 + .../domain/opic/service/FeedbackService.java | 2 +- ServerlessFunction/template.yaml | 2 +- opic/seed-data/question-homes.json | 108 ++++ 11 files changed, 667 insertions(+), 2 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/CreateSessionRequest.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/SubmitAnswerRequest.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/AnswerFeedbackResponse.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/CreateSessionResponse.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/QuestionResponse.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/OPIcSessionHandler.java create mode 100644 opic/seed-data/question-homes.json diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/CreateSessionRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/CreateSessionRequest.java new file mode 100644 index 00000000..6dc7dc3f --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/CreateSessionRequest.java @@ -0,0 +1,7 @@ +package com.mzc.secondproject.serverless.domain.opic.dto.request; + +public record CreateSessionRequest( + String topic, + String subTopic, + String targetLevel +) {} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/SubmitAnswerRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/SubmitAnswerRequest.java new file mode 100644 index 00000000..c425f42f --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/SubmitAnswerRequest.java @@ -0,0 +1,5 @@ +package com.mzc.secondproject.serverless.domain.opic.dto.request; + +public record SubmitAnswerRequest ( + String audioS3Key +) {} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/AnswerFeedbackResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/AnswerFeedbackResponse.java new file mode 100644 index 00000000..fcc6bf3b --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/AnswerFeedbackResponse.java @@ -0,0 +1,10 @@ +package com.mzc.secondproject.serverless.domain.opic.dto.response; + +public record AnswerFeedbackResponse( + String answerId, + String transcript, + FeedbackResponse feedback, + boolean hasNextQuestion, + Integer nextQustionNumber, + int totalQuestions +) {} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/CreateSessionResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/CreateSessionResponse.java new file mode 100644 index 00000000..17d1cf92 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/CreateSessionResponse.java @@ -0,0 +1,7 @@ +package com.mzc.secondproject.serverless.domain.opic.dto.response; + +public record CreateSessionResponse ( + String sessionId, + QuestionResponse firstQuestion, + int totalQuestions +) {} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/QuestionResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/QuestionResponse.java new file mode 100644 index 00000000..0ecf6230 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/QuestionResponse.java @@ -0,0 +1,9 @@ +package com.mzc.secondproject.serverless.domain.opic.dto.response; + +public record QuestionResponse ( + String questionId, + String questionText, + String audioUrl, + int questionNumber, + int totalQuestions +) {} 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 new file mode 100644 index 00000000..80b34f31 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/OPIcSessionHandler.java @@ -0,0 +1,506 @@ +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.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +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.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.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 software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +import java.lang.reflect.Type; +import java.time.Duration; +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; + +/** + * OPIc 세션 통합 Handler + * - 세션 생성/조회 + * - 질문 조회 (Polly 음성 URL 포함) + * - 답변 제출 (Transcribe + Bedrock 피드백) + * - 세션 완료 (종합 리포트) + */ +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; + + public OPIcSessionHandler() { + this.repository = new OPIcRepository(); + this.pollyService = new PollyService(OPIC_BUCKET, "opic/voice/questions/"); + this.transcribeService = new TranscribeProxyService(); + this.feedbackService = new FeedbackService(); + } + + @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")) { + 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 - 답변 제출 + if ("POST".equals(httpMethod) && path.contains("/answers")) { + return submitAnswer(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( + request.topic(), + request.subTopic(), + request.targetLevel() + ); + + if (questions.isEmpty()) { + return ResponseGenerator.notFound("해당 주제/레벨의 질문이 없습니다."); + } + + // 최대 3개 질문 선택 (랜덤 셔플) + Collections.shuffle(questions); + List questionIds = questions.stream() + .limit(3) + .map(OPIcQuestion::getQuestionId) + .collect(Collectors.toList()); + + // 세션 생성 + OPIcSession session = repository.createSession( + userId, + request.topic(), + request.subTopic(), + request.targetLevel(), + questionIds + ); + + // 첫 질문 Polly 음성 URL 생성 (#368 PollyService 연동) + OPIcQuestion firstQuestion = questions.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); + } + + /** + * 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 + )); + } + + // 다음 질문 조회 + String questionId = session.getQuestionIds().get(currentIndex); + OPIcQuestion question = repository.findQuestionById(questionId) + .orElseThrow(() -> new RuntimeException("질문을 찾을 수 없습니다: " + questionId)); + + // Polly 음성 URL + String audioUrl = generateQuestionAudioUrl(question); + + 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); + + return ResponseGenerator.ok(response); + } + + /** + * GET /opic/sessions/{sessionId}/upload-url + * S3 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)) { + 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() + .bucket(OPIC_BUCKET) + .key(s3Key) + .contentType("audio/webm") + .build(); + + String presignedUrl = AwsClients.s3Presigner() + .presignPutObject(PutObjectPresignRequest.builder() + .putObjectRequest(putRequest) + .signatureDuration(Duration.ofMinutes(5)) + .build()) + .url() + .toString(); + + return ResponseGenerator.ok(Map.of( + "uploadUrl", presignedUrl, + "s3Key", s3Key, + "expiresIn", 300 + )); + } + + /** + * POST /opic/sessions/{sessionId}/answers + * 답변 제출 → STT → AI 피드백 + */ + 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()); + + // 세션 검증 + 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.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 저장 - 개별 필드로 분리 저장 + 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.setCreatedAt(Instant.now()); + answer.setCompletedAt(Instant.now()); + + repository.saveAnswer(answer); + + // 세션 진행 상태 업데이트 + session.setCurrentQuestionIndex(currentIndex + 1); + repository.updateSession(session); + + // Response + boolean hasNext = (currentIndex + 1) < session.getTotalQuestions(); + + Map response = new LinkedHashMap<>(); + response.put("transcript", transcript); + response.put("feedback", feedback); + response.put("hasNextQuestion", hasNext); + response.put("currentQuestion", currentIndex + 1); + response.put("totalQuestions", session.getTotalQuestions()); + + if (hasNext) { + response.put("nextQuestionNumber", currentIndex + 2); + } + + logger.info("답변 처리 완료: sessionId={}, questionIndex={}", sessionId, currentIndex); + 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("세션을 찾을 수 없습니다."); + } + if (!session.getUserId().equals(userId)) { + return ResponseGenerator.forbidden("접근 권한이 없습니다."); + } + + // 모든 질문 답변 완료 확인 + List answers = repository.findAnswersBySessionId(sessionId); + if (answers.size() < session.getTotalQuestions()) { + return ResponseGenerator.badRequest( + String.format("아직 %d개의 질문에 답변하지 않았습니다.", + session.getTotalQuestions() - answers.size()) + ); + } + + // 세션 요약 생성 (피드백용) + 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() + ); + + // 세션 완료 처리 + repository.completeSession( + session, + sessionReport.estimatedLevel(), + gson.toJson(sessionReport) + ); + + logger.info("세션 완료: sessionId={}, estimatedLevel={}", + sessionId, sessionReport.estimatedLevel()); + + return ResponseGenerator.ok("세션이 완료되었습니다.", sessionReport); + } + + // ==================== 유틸리티 ==================== + + /** + * 질문 음성 URL 생성 (Polly + S3 캐싱) + */ + private String generateQuestionAudioUrl(OPIcQuestion question) { + try { + PollyService.VoiceSynthesisResult result = pollyService.synthesizeSpeech( + question.getQuestionId(), + question.getQuestionText(), + "FEMALE" + ); + return result.getAudioUrl(); + } catch (Exception e) { + logger.warn("Polly 음성 생성 실패, 텍스트만 반환: {}", e.getMessage()); + 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 { + return Instant.parse(json.getAsString()); + } + } +} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcAnswer.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcAnswer.java index e6e5da95..7052627f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcAnswer.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcAnswer.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.opic.model; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; @@ -9,6 +10,7 @@ /** * OPIc 답변 + 피드백 */ +@DynamoDbBean public class OPIcAnswer { private String pk; // SESSION#sessionId 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 2b5fd803..92ac2541 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 @@ -172,6 +172,17 @@ 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/FeedbackService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/FeedbackService.java index 9343ae51..fc42849d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/FeedbackService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/FeedbackService.java @@ -24,7 +24,7 @@ public class FeedbackService { private static final Logger logger = LoggerFactory.getLogger(FeedbackService.class); private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); - private static final String MODEL_ID = "anthropic.claude-3-5-sonnet-20241022-v2:0"; + private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; private static final int MAX_TOKENS = 2000; /** diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 570dd871..35cde30e 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1282,7 +1282,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - TRANSCRIBE_API_KEY_PARAM: "/opic/transcribe-proxy-api-key" + TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" Policies: - DynamoDBCrudPolicy: TableName: !Ref OPIcTable diff --git a/opic/seed-data/question-homes.json b/opic/seed-data/question-homes.json new file mode 100644 index 00000000..ddd67703 --- /dev/null +++ b/opic/seed-data/question-homes.json @@ -0,0 +1,108 @@ +{ + "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 + } + ] +} From b30ea0aa5201b6b6442fccae724ace4e98e9ac43 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 17:14:56 +0900 Subject: [PATCH 02/99] =?UTF-8?q?feat:=20GAME=5FSTART,=20ROUND=5FEND=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=EC=97=90=20serverTime=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 --- .../websocket/WebSocketMessageHandler.java | 148 ++++- .../domain/chatting/service/GameService.java | 5 +- docs/CATCHMIND_ARCHITECTURE_SOLUTION.md | 518 ++++++++++++++++++ 3 files changed, 658 insertions(+), 13 deletions(-) create mode 100644 docs/CATCHMIND_ARCHITECTURE_SOLUTION.md diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java index 0386a136..1f6bde08 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java @@ -309,10 +309,26 @@ private void handleAllCorrect(String roomId) { * 명령어 처리 결과를 브로드캐스트 */ private Map handleCommandResult(CommandResult result, String roomId, String userId) { + List connections = connectionRepository.findByRoomId(roomId); + + // GAME_START는 특별 처리 (출제자에게만 제시어 전송 + serverTime 포함) + if (result.messageType() == MessageType.GAME_START && result.data() instanceof GameService.GameStartResult gameResult) { + broadcastGameStart(connections, result, gameResult, roomId); + return WebSocketEventUtil.ok("Command executed"); + } + + // ROUND_END는 특별 처리 (다음 출제자에게만 제시어 전송 + serverTime 포함) + if (result.messageType() == MessageType.ROUND_END && result.data() instanceof Map) { + @SuppressWarnings("unchecked") + Map data = (Map) result.data(); + broadcastRoundEnd(connections, result, data, roomId); + return WebSocketEventUtil.ok("Command executed"); + } + + // 일반 시스템 메시지 String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - - // 시스템 메시지 생성 + ChatMessage systemMessage = ChatMessage.builder() .pk("ROOM#" + roomId) .sk("MSG#" + now + "#" + messageId) @@ -327,21 +343,129 @@ private Map handleCommandResult(CommandResult result, String roo .messageType(result.messageType().getCode()) .createdAt(now) .build(); - - // 명령어 결과는 저장하지 않고 브로드캐스트만 수행 - List connections = connectionRepository.findByRoomId(roomId); + String broadcastPayload = gson.toJson(systemMessage); List failedConnections = broadcaster.broadcast(connections, broadcastPayload); - - // 실패한 연결 정리 - for (String failedConnectionId : failedConnections) { - connectionRepository.delete(failedConnectionId); - logger.info("Deleted stale connection: {}", failedConnectionId); - } - + cleanupFailedConnections(failedConnections); + logger.info("Command result broadcasted: type={}, roomId={}", result.messageType(), roomId); return WebSocketEventUtil.ok("Command executed"); } + + /** + * GAME_START 메시지 브로드캐스트 - 출제자에게만 제시어 포함, serverTime 추가 + */ + private void broadcastGameStart(List connections, CommandResult result, + GameService.GameStartResult gameResult, String roomId) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long serverTime = System.currentTimeMillis(); + + String currentDrawerId = gameResult.room().getCurrentDrawerId(); + + for (Connection conn : connections) { + Map message = new HashMap<>(); + message.put("messageId", messageId); + message.put("roomId", roomId); + message.put("userId", "SYSTEM"); + message.put("content", result.message()); + message.put("messageType", result.messageType().getCode()); + message.put("createdAt", now); + + // 게임 상태 정보 + message.put("gameStatus", gameResult.room().getGameStatus()); + message.put("currentRound", gameResult.room().getCurrentRound()); + message.put("totalRounds", gameResult.room().getTotalRounds()); + message.put("currentDrawerId", currentDrawerId); + message.put("drawerOrder", gameResult.drawerOrder()); + + // 타이머 동기화용 필드 (핵심!) + message.put("roundStartTime", gameResult.room().getRoundStartTime()); + message.put("serverTime", serverTime); + message.put("roundDuration", gameResult.room().getRoundTimeLimit()); + + // 출제자에게만 제시어 전송 + if (conn.getUserId().equals(currentDrawerId) && gameResult.firstWord() != null) { + Map wordInfo = new HashMap<>(); + wordInfo.put("wordId", gameResult.firstWord().getWordId()); + wordInfo.put("word", gameResult.firstWord().getEnglish()); + message.put("currentWord", wordInfo); + } + + String payload = gson.toJson(message); + try { + broadcaster.sendToConnection(conn.getConnectionId(), payload); + } catch (Exception e) { + logger.warn("Failed to send GAME_START to connection: {}", conn.getConnectionId()); + connectionRepository.delete(conn.getConnectionId()); + } + } + + logger.info("GAME_START broadcasted: roomId={}, serverTime={}", roomId, serverTime); + } + + /** + * ROUND_END 메시지 브로드캐스트 - 다음 출제자에게만 제시어 포함, serverTime 추가 + */ + private void broadcastRoundEnd(List connections, CommandResult result, + Map data, String roomId) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long serverTime = System.currentTimeMillis(); + + String nextDrawer = (String) data.get("nextDrawer"); + Object nextWordObj = data.get("nextWord"); + + for (Connection conn : connections) { + Map message = new HashMap<>(); + message.put("messageId", messageId); + message.put("roomId", roomId); + message.put("userId", "SYSTEM"); + message.put("content", result.message()); + message.put("messageType", result.messageType().getCode()); + message.put("createdAt", now); + + // 기본 데이터 복사 (nextWord 제외) + Map messageData = new HashMap<>(); + messageData.put("answer", data.get("answer")); + messageData.put("nextRound", data.get("nextRound")); + messageData.put("nextDrawer", nextDrawer); + messageData.put("ranking", data.get("ranking")); + messageData.put("currentRound", data.get("currentRound")); + messageData.put("totalRounds", data.get("totalRounds")); + + // 타이머 동기화용 필드 (핵심!) + messageData.put("serverTime", serverTime); + if (data.get("roundStartTime") != null) { + messageData.put("roundStartTime", data.get("roundStartTime")); + } + if (data.get("roundDuration") != null) { + messageData.put("roundDuration", data.get("roundDuration")); + } + + // 다음 출제자에게만 제시어 전송 + if (conn.getUserId().equals(nextDrawer) && nextWordObj != null) { + if (nextWordObj instanceof com.mzc.secondproject.serverless.domain.vocabulary.model.Word nextWord) { + Map wordInfo = new HashMap<>(); + wordInfo.put("wordId", nextWord.getWordId()); + wordInfo.put("word", nextWord.getEnglish()); + messageData.put("nextWord", wordInfo); + } + } + + message.put("data", messageData); + + String payload = gson.toJson(message); + try { + broadcaster.sendToConnection(conn.getConnectionId(), payload); + } catch (Exception e) { + logger.warn("Failed to send ROUND_END to connection: {}", conn.getConnectionId()); + connectionRepository.delete(conn.getConnectionId()); + } + } + + logger.info("ROUND_END broadcasted: roomId={}, serverTime={}", roomId, serverTime); + } /** * 메시지 페이로드 DTO diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java index ff796133..3ac401a5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java @@ -383,7 +383,10 @@ public CommandResult endRound(ChatRoom room, String reason) { data.put("ranking", ranking); data.put("currentRound", currentRound); data.put("totalRounds", room.getTotalRounds()); - + // 타이머 동기화용 필드 추가 + data.put("roundStartTime", room.getRoundStartTime()); + data.put("roundDuration", room.getRoundTimeLimit() != null ? room.getRoundTimeLimit() : GameConfig.roundTimeLimit()); + return CommandResult.success(MessageType.ROUND_END, message, data); } diff --git a/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md b/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md new file mode 100644 index 00000000..6c4ab164 --- /dev/null +++ b/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md @@ -0,0 +1,518 @@ +# 채팅방 / 캐치마인드 게임 분리 - 종합 솔루션 + +## 1. 현재 문제점 분석 + +### 1.1 백엔드 현황 + +``` +ChatRoom.java (현재 - 혼합 모델) +├── 채팅 필드 +│ ├── roomId, name, description +│ ├── memberIds, currentMembers +│ └── lastMessageAt +│ +└── 게임 필드 (여기에 섞여있음) + ├── gameStatus, gameStartedBy + ├── currentRound, totalRounds + ├── currentDrawerId, currentWord + ├── roundStartTime, roundTimeLimit ← serverTime 없음! + ├── scores, streaks + └── correctGuessers +``` + +**문제점:** +1. `roundStartTime`만 전송, `serverTime` 누락 → 클라이언트 타이머 동기화 불가 +2. 게임 세션이 채팅방에 종속 → 게임 상태 독립 관리 불가 +3. 재접속 시 게임 상태 복구 어려움 +4. 게임 종료 후 상태 정리 복잡 + +### 1.2 WebSocket 메시지 현황 + +```java +// WebSocketMessageHandler.java - 현재 구조 +handleRequest() { + switch (messageType) { + case "DRAWING", "DRAWING_CLEAR" -> handleDrawingMessage() // 게임 + default -> handleRegularMessage() { + // 1. 슬래시 명령어 처리 (/start, /stop, /score...) + // 2. 게임 중 정답 체크 + // 3. 일반 채팅 메시지 + } + } +} +``` + +**문제점:** +- 채팅/게임 구분 없이 모든 메시지가 동일 핸들러에서 처리 +- 메시지에 `domain` 필드 없음 + +--- + +## 2. 최적 솔루션 + +### 2.1 아키텍처 개요 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ WebSocket (단일 엔드포인트 유지) │ +│ │ +│ ┌──────────────────────┐ ┌────────────────────────────────┐ │ +│ │ domain: "chat" │ │ domain: "game" │ │ +│ │ │ │ │ │ +│ │ • TEXT │ │ • GAME_START / GAME_END │ │ +│ │ • USER_JOIN │ │ • ROUND_START / ROUND_END │ │ +│ │ • USER_LEAVE │ │ • DRAWING / DRAWING_CLEAR │ │ +│ │ • SYSTEM │ │ • GUESS / CORRECT_ANSWER │ │ +│ │ │ │ • SCORE_UPDATE / HINT │ │ +│ └──────────────────────┘ └────────────────────────────────┘ │ +│ │ +│ GameSession (별도 모델) │ +│ ├── gameSessionId │ +│ ├── roomId (연결용) │ +│ ├── status, currentRound │ +│ ├── roundStartTime + serverTime ← 핵심! │ +│ └── scores, players │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 핵심 변경사항 + +| 구분 | 현재 | 변경 후 | +|------|------|---------| +| 모델 | `ChatRoom`에 게임 필드 포함 | `ChatRoom` + `GameSession` 분리 | +| 타이머 | `roundStartTime`만 전송 | `roundStartTime` + `serverTime` | +| 메시지 | `messageType`만 존재 | `domain` + `messageType` | +| API | 채팅방 API만 존재 | 게임 세션 API 추가 | + +--- + +## 3. 백엔드 변경사항 + +### 3.1 Phase 1: 타이머 버그 수정 (즉시) + +**변경 파일:** `WebSocketMessageHandler.java` + +```java +// GAME_START 메시지에 serverTime 추가 +private void broadcastGameStart(...) { + Map message = new HashMap<>(); + // ... 기존 코드 ... + + message.put("roundStartTime", gameResult.room().getRoundStartTime()); + message.put("serverTime", System.currentTimeMillis()); // 추가! + message.put("roundDuration", gameResult.room().getRoundTimeLimit()); // 명확한 이름 + + // ... +} + +// ROUND_END → ROUND_START 메시지에도 동일하게 추가 +private void broadcastRoundEnd(...) { + // ... + messageData.put("roundStartTime", room.getRoundStartTime()); + messageData.put("serverTime", System.currentTimeMillis()); // 추가! + messageData.put("roundDuration", room.getRoundTimeLimit()); + // ... +} +``` + +**예상 작업량:** 30분 + +### 3.2 Phase 2: 메시지 구조 개선 (1일) + +**변경 파일:** `WebSocketMessageHandler.java`, 모든 브로드캐스트 메서드 + +```java +// 모든 메시지에 domain 필드 추가 +private Map createMessage(String domain, String messageType, Object data) { + Map message = new HashMap<>(); + message.put("domain", domain); // "chat" 또는 "game" + message.put("messageType", messageType); + message.put("data", data); + message.put("timestamp", System.currentTimeMillis()); + return message; +} + +// 채팅 메시지 +createMessage("chat", "TEXT", chatData); +createMessage("chat", "USER_JOIN", joinData); + +// 게임 메시지 +createMessage("game", "GAME_START", gameStartData); +createMessage("game", "ROUND_START", roundStartData); +createMessage("game", "DRAWING", drawingData); +``` + +### 3.3 Phase 3: 게임 세션 분리 (1주) + +#### 3.3.1 새 모델: GameSession.java + +```java +@DynamoDbBean +public class GameSession { + private String pk; // GAME#{gameSessionId} + private String sk; // METADATA + private String gsi1pk; // ROOM#{roomId} + private String gsi1sk; // GAME#{createdAt} + + // 게임 식별 + private String gameSessionId; + private String roomId; // 연결된 채팅방 + private String gameType; // "catchmind" + + // 게임 상태 + private String status; // WAITING, PLAYING, FINISHED + private String startedBy; + private Long startedAt; + private Long endedAt; + + // 라운드 정보 + private Integer currentRound; + private Integer totalRounds; + private String currentDrawerId; + private String currentWordId; + private String currentWord; + private Long roundStartTime; + private Integer roundDuration; + + // 점수 + private Map scores; + private Map streaks; + private List players; + private List drawerOrder; + + // 자동 종료 + private Long gameEndScheduledAt; + private String scheduleRuleArn; + + // TTL + private Long ttl; +} +``` + +#### 3.3.2 ChatRoom에서 게임 필드 제거 + +```java +@DynamoDbBean +public class ChatRoom { + // 채팅 필드만 유지 + private String roomId; + private String name; + private String description; + private String level; + private Integer currentMembers; + private Integer maxMembers; + private Boolean isPrivate; + private String password; + private String createdBy; + private String createdAt; + private String lastMessageAt; + private List memberIds; + + // 게임 연결 (참조만) + private String activeGameSessionId; // 현재 진행중인 게임 세션 ID + + // 게임 필드 모두 제거! + // - gameStatus, gameStartedBy, currentRound... 전부 GameSession으로 이동 +} +``` + +#### 3.3.3 게임 세션 API + +``` +# 게임 세션 생성 +POST /api/chat/rooms/{roomId}/games +Request: +{ + "gameType": "catchmind", + "settings": { + "totalRounds": 5, + "roundDuration": 60 + } +} + +Response: +{ + "gameSessionId": "game-abc123", + "roomId": "room-xyz", + "status": "WAITING", + "createdAt": "2024-01-20T10:00:00Z" +} + +# 게임 상태 조회 (재접속 시 필수!) +GET /api/games/{gameSessionId} + +Response: +{ + "gameSessionId": "game-abc123", + "roomId": "room-xyz", + "status": "PLAYING", + "currentRound": 2, + "totalRounds": 5, + "currentDrawerId": "user123", + "roundStartTime": 1705744800000, + "serverTime": 1705744830000, // 핵심! + "roundDuration": 60, + "scores": { + "user1": 150, + "user2": 120 + }, + "players": ["user1", "user2", "user3"] +} + +# 게임 시작 (기존 /start 명령어 대체) +POST /api/games/{gameSessionId}/start + +# 게임 종료 +POST /api/games/{gameSessionId}/stop +``` + +--- + +## 4. 프론트엔드 변경사항 + +### 4.1 Phase 1: 타이머 버그 수정 (즉시) + +```javascript +// useTimer.js - 독립적인 타이머 훅 +export function useTimer(roundStartTime, roundDuration, serverTime) { + const [remainingTime, setRemainingTime] = useState(roundDuration); + + useEffect(() => { + if (!roundStartTime || !roundDuration) return; + + // 서버-클라이언트 시간 차이 보정 + const timeOffset = serverTime ? (Date.now() - serverTime) : 0; + + const interval = setInterval(() => { + const adjustedNow = Date.now() - timeOffset; + const elapsed = Math.floor((adjustedNow - roundStartTime) / 1000); + const remaining = Math.max(0, roundDuration - elapsed); + setRemainingTime(remaining); + + if (remaining <= 0) { + clearInterval(interval); + } + }, 100); + + return () => clearInterval(interval); + }, [roundStartTime, roundDuration, serverTime]); + + return remainingTime; +} +``` + +### 4.2 Phase 2: 메시지 핸들러 분리 + +```javascript +// WebSocket 메시지 핸들러 +onMessage(event) { + const message = JSON.parse(event.data); + + switch (message.domain) { + case 'chat': + this.handleChatMessage(message); + break; + case 'game': + this.handleGameMessage(message); + break; + } +} + +handleChatMessage(message) { + switch (message.messageType) { + case 'TEXT': // 채팅 메시지 + case 'USER_JOIN': + case 'USER_LEAVE': + case 'SYSTEM': + } +} + +handleGameMessage(message) { + switch (message.messageType) { + case 'GAME_START': + case 'ROUND_START': + case 'DRAWING': + case 'CORRECT_ANSWER': + case 'SCORE_UPDATE': + } +} +``` + +### 4.3 Phase 3: 훅 분리 + +``` +src/domains/ +├── chat/ +│ ├── hooks/ +│ │ └── useChatWebSocket.js # 채팅만 처리 +│ └── components/ +│ ├── ChatMessages.jsx +│ └── ChatInput.jsx +│ +├── catchmind/ +│ ├── hooks/ +│ │ ├── useGameWebSocket.js # 게임만 처리 +│ │ ├── useGameState.js +│ │ └── useTimer.js +│ └── components/ +│ ├── DrawingCanvas.jsx +│ ├── ScoreBoard.jsx +│ └── Timer.jsx +│ +└── freetalk/ + └── pages/ + └── FreeTalkPage.jsx # chat + catchmind 조합 +``` + +--- + +## 5. 메시지 스펙 (최종) + +### 5.1 공통 메시지 구조 + +```json +{ + "domain": "chat" | "game", + "messageType": "...", + "data": { ... }, + "timestamp": 1705744800000 +} +``` + +### 5.2 채팅 메시지 + +| Type | 방향 | data 필드 | +|------|------|-----------| +| `TEXT` | 양방향 | `messageId`, `userId`, `content`, `createdAt` | +| `USER_JOIN` | S→C | `userId`, `memberCount` | +| `USER_LEAVE` | S→C | `userId`, `memberCount` | +| `SYSTEM` | S→C | `content` | + +### 5.3 게임 메시지 + +| Type | 방향 | data 필드 | +|------|------|-----------| +| `GAME_START` | S→C | `gameSessionId`, `totalRounds`, `currentDrawerId`, `roundStartTime`, `serverTime`, `roundDuration`, `players` | +| `GAME_END` | S→C | `gameSessionId`, `reason`, `finalScores`, `winner` | +| `ROUND_START` | S→C | `currentRound`, `currentDrawerId`, `roundStartTime`, `serverTime`, `roundDuration`, `currentWord`(출제자만) | +| `ROUND_END` | S→C | `currentRound`, `answer`, `scores` | +| `DRAWING` | 양방향 | `drawingData` | +| `DRAWING_CLEAR` | 양방향 | - | +| `GUESS` | C→S | `content` | +| `CORRECT_ANSWER` | S→C | `userId`, `score`, `elapsedTime` | +| `SCORE_UPDATE` | S→C | `scores`, `currentRound`, `totalRounds` | +| `HINT` | S→C | `hint` | + +### 5.4 ROUND_START 상세 (핵심!) + +```json +{ + "domain": "game", + "messageType": "ROUND_START", + "data": { + "gameSessionId": "game-abc123", + "currentRound": 2, + "totalRounds": 5, + "currentDrawerId": "user123", + "roundStartTime": 1705744800000, + "serverTime": 1705744800500, + "roundDuration": 60, + "currentWord": { + "wordId": "word-1", + "word": "apple" + } + }, + "timestamp": 1705744800500 +} +``` + +**중요:** `currentWord`는 출제자에게만 전송! + +--- + +## 6. 구현 일정 + +``` +Week 1: 긴급 버그 수정 +├── [BE] serverTime 필드 추가 (0.5일) +├── [FE] useTimer 훅 수정 (0.5일) +├── [BE] 메시지에 domain 필드 추가 (1일) +└── [FE] 메시지 핸들러 domain 분기 (0.5일) + +Week 2: 게임 세션 분리 (BE) +├── [BE] GameSession 모델 생성 +├── [BE] GameSessionRepository 구현 +├── [BE] GameService 리팩토링 +└── [BE] 게임 세션 API 구현 + +Week 3: 프론트엔드 리팩토링 +├── [FE] useChatWebSocket 분리 +├── [FE] useGameWebSocket 신규 +├── [FE] 컴포넌트 분리 +└── [FE/BE] 통합 테스트 + +Week 4: 안정화 및 추가 기능 +├── [BE] 게임 자동 종료 (7분) - Issue #417 +├── [BE] 재접속 시 게임 상태 복구 +└── [FE/BE] E2E 테스트 +``` + +--- + +## 7. 기대 효과 + +| 항목 | 현재 | 개선 후 | +|------|------|---------| +| 타이머 정확도 | 클라이언트 시계 의존 | 서버 시간 기준 동기화 | +| 재접속 | 게임 상태 유실 | 완전 복구 가능 | +| 테스트 | 채팅/게임 분리 불가 | 독립 테스트 가능 | +| 확장성 | 새 게임 추가 어려움 | gameType으로 확장 용이 | +| 유지보수 | 책임 혼재 | 명확한 책임 분리 | + +--- + +## 8. 즉시 적용 (백엔드 변경 전 프론트엔드 임시 조치) + +```javascript +// 백엔드 변경 전까지 프론트엔드에서 적용 가능한 임시 코드 + +onRoundStart: (data) => { + const roundData = data.data || data; + const now = Date.now(); + + // serverTime이 없으면 클라이언트 시간 사용 (임시) + const serverTime = roundData.serverTime || now; + let roundStartTime = roundData.roundStartTime || now; + + // roundStartTime이 미래 시간이면 현재로 보정 + if (roundStartTime > now + 1000) { + console.warn('Invalid roundStartTime, using current time'); + roundStartTime = now; + } + + setGameState((prev) => ({ + ...prev, + currentRound: roundData.currentRound, + currentDrawerId: roundData.currentDrawerId, + roundStartTime: roundStartTime, + serverTime: serverTime, + roundDuration: roundData.roundDuration || roundData.roundTimeLimit || 60, + })); +} +``` + +--- + +## 9. 결론 + +**우선순위:** + +1. **즉시 (이번 주)**: `serverTime` 추가 + `domain` 필드 추가 +2. **단기 (2주)**: GameSession 모델 분리 + API 구현 +3. **중기 (3-4주)**: FE/BE 완전 분리 + 자동 종료 + 재접속 복구 + +**핵심 원칙:** +- 단일 WebSocket 엔드포인트 유지 (비용/복잡도) +- `domain` 필드로 채팅/게임 구분 +- `serverTime`으로 정확한 타이머 동기화 +- GameSession 독립 모델로 상태 관리 명확화 From 6474f76f0be2fcc7edd2d5a55019fd54b3cc508b Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 17:16:34 +0900 Subject: [PATCH 03/99] =?UTF-8?q?feat:=20WebSocketMessageHelper=20?= =?UTF-8?q?=EC=9C=A0=ED=8B=B8=EB=A6=AC=ED=8B=B0=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 --- .../common/util/WebSocketMessageHelper.java | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketMessageHelper.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketMessageHelper.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketMessageHelper.java new file mode 100644 index 00000000..56a7f2b4 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketMessageHelper.java @@ -0,0 +1,109 @@ +package com.mzc.secondproject.serverless.common.util; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * WebSocket 메시지 생성 헬퍼 + * 모든 메시지에 domain 필드를 포함하여 채팅/게임 구분 지원 + */ +public final class WebSocketMessageHelper { + + public static final String DOMAIN_CHAT = "chat"; + public static final String DOMAIN_GAME = "game"; + + private WebSocketMessageHelper() { + } + + /** + * 기본 메시지 생성 + * + * @param domain 도메인 ("chat" 또는 "game") + * @param messageType 메시지 타입 + * @param data 메시지 데이터 + * @return 메시지 Map + */ + public static Map createMessage(String domain, String messageType, Object data) { + Map message = new HashMap<>(); + message.put("domain", domain); + message.put("messageType", messageType); + message.put("data", data); + message.put("timestamp", System.currentTimeMillis()); + return message; + } + + /** + * 채팅 메시지 생성 + */ + public static Map createChatMessage(String messageType, Object data) { + return createMessage(DOMAIN_CHAT, messageType, data); + } + + /** + * 게임 메시지 생성 + */ + public static Map createGameMessage(String messageType, Object data) { + return createMessage(DOMAIN_GAME, messageType, data); + } + + /** + * 채팅 메시지 빌더 (상세 필드 포함) + */ + public static Map buildChatMessage( + String roomId, + String userId, + String content, + String messageType + ) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + Map message = new HashMap<>(); + message.put("domain", DOMAIN_CHAT); + message.put("messageType", messageType); + message.put("messageId", messageId); + message.put("roomId", roomId); + message.put("userId", userId); + message.put("content", content); + message.put("createdAt", now); + message.put("timestamp", System.currentTimeMillis()); + return message; + } + + /** + * 게임 메시지 빌더 (상세 필드 포함) + */ + public static Map buildGameMessage( + String roomId, + String messageType, + Map gameData + ) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long serverTime = System.currentTimeMillis(); + + Map message = new HashMap<>(); + message.put("domain", DOMAIN_GAME); + message.put("messageType", messageType); + message.put("messageId", messageId); + message.put("roomId", roomId); + message.put("userId", "SYSTEM"); + message.put("createdAt", now); + message.put("timestamp", serverTime); + message.put("serverTime", serverTime); + + if (gameData != null) { + message.put("data", gameData); + } + return message; + } + + /** + * 시스템 메시지 생성 (채팅 도메인) + */ + public static Map buildSystemMessage(String roomId, String content, String messageType) { + return buildChatMessage(roomId, "SYSTEM", content, messageType); + } +} From 1107a59e73911722289a74fcfeadbdd525f6756d Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 17:21:42 +0900 Subject: [PATCH 04/99] =?UTF-8?q?feat:=20=EB=AA=A8=EB=93=A0=20WebSocket=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=EC=97=90=20domain=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 --- .../dto/response/ScoreUpdateMessage.java | 2 + .../websocket/WebSocketMessageHandler.java | 91 +++++++++++-------- 2 files changed, 53 insertions(+), 40 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreUpdateMessage.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreUpdateMessage.java index 6f9ad110..37edba8f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreUpdateMessage.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreUpdateMessage.java @@ -16,6 +16,7 @@ @NoArgsConstructor @AllArgsConstructor public class ScoreUpdateMessage { + private String domain; private String messageType; private String roomId; private String scorerId; @@ -31,6 +32,7 @@ public static ScoreUpdateMessage from(String roomId, String scorerId, int scoreG List ranking = buildRanking(scores); return ScoreUpdateMessage.builder() + .domain("game") .messageType("SCORE_UPDATE") .roomId(roomId) .scorerId(scorerId) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java index 1f6bde08..d4b803d8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java @@ -6,6 +6,7 @@ import com.google.gson.GsonBuilder; import com.mzc.secondproject.serverless.common.util.WebSocketBroadcaster; import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; +import com.mzc.secondproject.serverless.common.util.WebSocketMessageHelper; import com.mzc.secondproject.serverless.domain.chatting.dto.response.CommandResult; import com.mzc.secondproject.serverless.domain.chatting.dto.response.ScoreUpdateMessage; import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; @@ -93,11 +94,13 @@ private Map handleDrawingMessage(String connectionId, MessagePay // 그림 데이터 메시지 생성 (저장 안 함) Map drawingMessage = new HashMap<>(); + drawingMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); drawingMessage.put("messageType", messageType); drawingMessage.put("roomId", payload.roomId); drawingMessage.put("userId", payload.userId); drawingMessage.put("content", payload.content); drawingMessage.put("createdAt", Instant.now().toString()); + drawingMessage.put("timestamp", System.currentTimeMillis()); // 본인 제외 브로드캐스트 List connections = connectionRepository.findByRoomId(payload.roomId); @@ -147,7 +150,7 @@ private Map handleRegularMessage(String connectionId, MessagePay // 일반 메시지 저장 및 브로드캐스트 String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + ChatMessage message = ChatMessage.builder() .pk("ROOM#" + payload.roomId) .sk("MSG#" + now + "#" + messageId) @@ -162,23 +165,33 @@ private Map handleRegularMessage(String connectionId, MessagePay .messageType(messageType) .createdAt(now) .build(); - + ChatMessage savedMessage = chatMessageService.saveMessage(message); chatRoomRepository.updateLastMessageAt(payload.roomId, now); - + logger.info("Message saved: messageId={}, roomId={}", messageId, payload.roomId); - - // 브로드캐스트 + + // 브로드캐스트 (domain 필드 포함을 위해 Map으로 변환) + Map broadcastMessage = new HashMap<>(); + broadcastMessage.put("domain", WebSocketMessageHelper.DOMAIN_CHAT); + broadcastMessage.put("messageId", savedMessage.getMessageId()); + broadcastMessage.put("roomId", savedMessage.getRoomId()); + broadcastMessage.put("userId", savedMessage.getUserId()); + broadcastMessage.put("content", savedMessage.getContent()); + broadcastMessage.put("messageType", savedMessage.getMessageType()); + broadcastMessage.put("createdAt", savedMessage.getCreatedAt()); + broadcastMessage.put("timestamp", System.currentTimeMillis()); + List connections = connectionRepository.findByRoomId(payload.roomId); - String broadcastPayload = gson.toJson(savedMessage); + String broadcastPayload = gson.toJson(broadcastMessage); List failedConnections = broadcaster.broadcast(connections, broadcastPayload); - + // 실패한 연결 정리 for (String failedConnectionId : failedConnections) { connectionRepository.delete(failedConnectionId); logger.info("Deleted stale connection: {}", failedConnectionId); } - + return WebSocketEventUtil.ok("Message sent"); } @@ -191,12 +204,14 @@ private Map broadcastGuessMessage(MessagePayload payload) { // 추측 메시지 생성 (저장하지 않음) Map guessMessage = new HashMap<>(); + guessMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); guessMessage.put("messageId", messageId); guessMessage.put("roomId", payload.roomId); guessMessage.put("userId", payload.userId); guessMessage.put("content", payload.content); guessMessage.put("messageType", "GUESS"); guessMessage.put("createdAt", now); + guessMessage.put("timestamp", System.currentTimeMillis()); List connections = connectionRepository.findByRoomId(payload.roomId); String broadcastPayload = gson.toJson(guessMessage); @@ -238,24 +253,20 @@ private Map handleCorrectAnswer(MessagePayload payload, GameServ private void broadcastCorrectAnswerMessage(MessagePayload payload, GameService.AnswerCheckResult result, List connections) { String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + String message = String.format("🎉 %s님이 정답을 맞췄습니다! (+%d점)", payload.userId, result.score()); - - ChatMessage correctMessage = ChatMessage.builder() - .pk("ROOM#" + payload.roomId) - .sk("MSG#" + now + "#" + messageId) - .gsi1pk("SYSTEM") - .gsi1sk("MSG#" + now) - .gsi2pk("MSG#" + messageId) - .gsi2sk("ROOM#" + payload.roomId) - .messageId(messageId) - .roomId(payload.roomId) - .userId("SYSTEM") - .content(message) - .messageType(MessageType.CORRECT_ANSWER.getCode()) - .createdAt(now) - .build(); - + + // domain 필드 포함을 위해 Map으로 생성 + Map correctMessage = new HashMap<>(); + correctMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); + correctMessage.put("messageId", messageId); + correctMessage.put("roomId", payload.roomId); + correctMessage.put("userId", "SYSTEM"); + correctMessage.put("content", message); + correctMessage.put("messageType", MessageType.CORRECT_ANSWER.getCode()); + correctMessage.put("createdAt", now); + correctMessage.put("timestamp", System.currentTimeMillis()); + String broadcastPayload = gson.toJson(correctMessage); List failedConnections = broadcaster.broadcast(connections, broadcastPayload); cleanupFailedConnections(failedConnections); @@ -325,24 +336,20 @@ private Map handleCommandResult(CommandResult result, String roo return WebSocketEventUtil.ok("Command executed"); } - // 일반 시스템 메시지 + // 일반 시스템 메시지 (게임 관련 명령어 결과) String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - ChatMessage systemMessage = ChatMessage.builder() - .pk("ROOM#" + roomId) - .sk("MSG#" + now + "#" + messageId) - .gsi1pk("SYSTEM") - .gsi1sk("MSG#" + now) - .gsi2pk("MSG#" + messageId) - .gsi2sk("ROOM#" + roomId) - .messageId(messageId) - .roomId(roomId) - .userId("SYSTEM") - .content(result.message()) - .messageType(result.messageType().getCode()) - .createdAt(now) - .build(); + // domain 필드 포함을 위해 Map으로 생성 + Map systemMessage = new HashMap<>(); + systemMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); + systemMessage.put("messageId", messageId); + systemMessage.put("roomId", roomId); + systemMessage.put("userId", "SYSTEM"); + systemMessage.put("content", result.message()); + systemMessage.put("messageType", result.messageType().getCode()); + systemMessage.put("createdAt", now); + systemMessage.put("timestamp", System.currentTimeMillis()); String broadcastPayload = gson.toJson(systemMessage); List failedConnections = broadcaster.broadcast(connections, broadcastPayload); @@ -365,12 +372,14 @@ private void broadcastGameStart(List connections, CommandResult resu for (Connection conn : connections) { Map message = new HashMap<>(); + message.put("domain", WebSocketMessageHelper.DOMAIN_GAME); message.put("messageId", messageId); message.put("roomId", roomId); message.put("userId", "SYSTEM"); message.put("content", result.message()); message.put("messageType", result.messageType().getCode()); message.put("createdAt", now); + message.put("timestamp", serverTime); // 게임 상태 정보 message.put("gameStatus", gameResult.room().getGameStatus()); @@ -418,12 +427,14 @@ private void broadcastRoundEnd(List connections, CommandResult resul for (Connection conn : connections) { Map message = new HashMap<>(); + message.put("domain", WebSocketMessageHelper.DOMAIN_GAME); message.put("messageId", messageId); message.put("roomId", roomId); message.put("userId", "SYSTEM"); message.put("content", result.message()); message.put("messageType", result.messageType().getCode()); message.put("createdAt", now); + message.put("timestamp", serverTime); // 기본 데이터 복사 (nextWord 제외) Map messageData = new HashMap<>(); From 9c611153dd2d895047182725c4e3e5aaea3e82c4 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 17:25:29 +0900 Subject: [PATCH 05/99] =?UTF-8?q?feat:=20GameSession=20=EB=AA=A8=EB=8D=B8?= =?UTF-8?q?=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 --- .../domain/chatting/model/GameSession.java | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSession.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSession.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSession.java new file mode 100644 index 00000000..7f4ee1aa --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSession.java @@ -0,0 +1,189 @@ +package com.mzc.secondproject.serverless.domain.chatting.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +import java.util.List; +import java.util.Map; + +/** + * 게임 세션 모델 + * ChatRoom에서 분리된 게임 상태 관리용 독립 모델 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class GameSession { + + private String pk; // GAME#{gameSessionId} + private String sk; // METADATA + private String gsi1pk; // ROOM#{roomId} + private String gsi1sk; // GAME#{createdAt} + + private String gameSessionId; + private String roomId; + private String gameType; // "catchmind" + + // 게임 상태 + private String status; // NONE, WAITING, PLAYING, ROUND_END, FINISHED + private String startedBy; + private Long startedAt; + private Long endedAt; + + // 라운드 정보 + private Integer currentRound; + private Integer totalRounds; + private String currentDrawerId; + private String currentWordId; + private String currentWord; + private Long roundStartTime; + private Integer roundDuration; + + // 점수 및 플레이어 + private Map scores; + private Map streaks; + private List players; + private List drawerOrder; + + // 라운드 내 상태 + private Boolean hintUsed; + private List correctGuessers; + + // 스케줄링 (게임 자동 종료용) + private Long gameEndScheduledAt; + private String scheduleRuleArn; + + // TTL (게임 종료 후 일정 시간 뒤 삭제) + private Long ttl; + + @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; + } + + /** + * 게임이 활성 상태인지 확인 + */ + public boolean isActive() { + return "PLAYING".equals(status) || "ROUND_END".equals(status); + } + + /** + * 게임 시작 가능 여부 확인 + */ + public boolean canStart() { + return status == null || "NONE".equals(status) || "FINISHED".equals(status); + } + + /** + * 출제자 여부 확인 + */ + public boolean isDrawer(String userId) { + return userId != null && userId.equals(currentDrawerId); + } + + /** + * 이미 정답을 맞춘 사용자인지 확인 + */ + public boolean hasAlreadyGuessedCorrect(String userId) { + return correctGuessers != null && correctGuessers.contains(userId); + } + + /** + * 정답자 추가 + */ + public void addCorrectGuesser(String userId) { + if (correctGuessers == null) { + correctGuessers = new java.util.ArrayList<>(); + } + if (!correctGuessers.contains(userId)) { + correctGuessers.add(userId); + } + } + + /** + * 점수 추가 + */ + public void addScore(String userId, int points) { + if (scores == null) { + scores = new java.util.HashMap<>(); + } + scores.merge(userId, points, Integer::sum); + } + + /** + * 연속 정답 수 증가 + */ + public int incrementStreak(String userId) { + if (streaks == null) { + streaks = new java.util.HashMap<>(); + } + int newStreak = streaks.getOrDefault(userId, 0) + 1; + streaks.put(userId, newStreak); + return newStreak; + } + + /** + * 연속 정답 수 리셋 + */ + public void resetStreak(String userId) { + if (streaks != null) { + streaks.put(userId, 0); + } + } + + /** + * 다음 출제자 ID 반환 + */ + public String getNextDrawerId() { + if (drawerOrder == null || drawerOrder.isEmpty()) { + return null; + } + if (currentDrawerId == null) { + return drawerOrder.get(0); + } + int currentIndex = drawerOrder.indexOf(currentDrawerId); + if (currentIndex == -1 || currentIndex >= drawerOrder.size() - 1) { + return drawerOrder.get(0); + } + return drawerOrder.get(currentIndex + 1); + } + + /** + * 전원이 정답을 맞췄는지 확인 + */ + public boolean allPlayersGuessedCorrect() { + if (players == null || correctGuessers == null) { + return false; + } + // 출제자 제외한 인원이 모두 정답 + long guessersCount = players.stream() + .filter(p -> !p.equals(currentDrawerId)) + .count(); + return correctGuessers.size() >= guessersCount; + } +} From bf25a6e3ee3347c47f7cde74c18a30d260cbb726 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 17:26:59 +0900 Subject: [PATCH 06/99] =?UTF-8?q?feat:=20GameSessionRepository=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 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 --- .../repository/GameSessionRepository.java | 286 ++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameSessionRepository.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameSessionRepository.java new file mode 100644 index 00000000..61623038 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameSessionRepository.java @@ -0,0 +1,286 @@ +package com.mzc.secondproject.serverless.domain.chatting.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * GameSession Repository + * 게임 세션 CRUD 및 조회 기능 제공 + */ +public class GameSessionRepository { + + private static final Logger logger = LoggerFactory.getLogger(GameSessionRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); + + private final DynamoDbTable table; + + public GameSessionRepository() { + this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(GameSession.class)); + } + + /** + * 게임 세션 저장 + */ + public GameSession save(GameSession session) { + logger.info("Saving game session: {}", session.getGameSessionId()); + table.putItem(session); + return session; + } + + /** + * ID로 게임 세션 조회 + */ + public Optional findById(String gameSessionId) { + Key key = Key.builder() + .partitionValue("GAME#" + gameSessionId) + .sortValue("METADATA") + .build(); + + GameSession session = table.getItem(key); + return Optional.ofNullable(session); + } + + /** + * 게임 세션 삭제 + */ + public void delete(String gameSessionId) { + Key key = Key.builder() + .partitionValue("GAME#" + gameSessionId) + .sortValue("METADATA") + .build(); + + table.deleteItem(key); + logger.info("Deleted game session: {}", gameSessionId); + } + + /** + * roomId로 활성 게임 세션 조회 (PLAYING 또는 ROUND_END 상태) + */ + public Optional findActiveByRoomId(String roomId) { + List sessions = findByRoomId(roomId); + + return sessions.stream() + .filter(GameSession::isActive) + .findFirst(); + } + + /** + * roomId로 모든 게임 세션 조회 (최신순) + */ + public List findByRoomId(String roomId) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder() + .partitionValue("ROOM#" + roomId) + .build()); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 + .build(); + + DynamoDbIndex gsi1 = table.index("GSI1"); + + return gsi1.query(request).stream() + .flatMap(page -> page.items().stream()) + .toList(); + } + + /** + * 게임 상태 업데이트 + */ + public void updateStatus(String gameSessionId, String status) { + Map key = buildKey(gameSessionId); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":status", AttributeValue.builder().s(status).build()); + + UpdateItemRequest updateRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression("SET #status = :status") + .expressionAttributeNames(Map.of("#status", "status")) + .expressionAttributeValues(expressionValues) + .build(); + + AwsClients.dynamoDb().updateItem(updateRequest); + logger.info("Updated game session status: {} -> {}", gameSessionId, status); + } + + /** + * 라운드 정보 업데이트 + */ + public void updateRoundInfo(String gameSessionId, int currentRound, String drawerId, + String wordId, String word, long roundStartTime, int roundDuration) { + Map key = buildKey(gameSessionId); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":round", AttributeValue.builder().n(String.valueOf(currentRound)).build()); + expressionValues.put(":drawer", AttributeValue.builder().s(drawerId).build()); + expressionValues.put(":wordId", AttributeValue.builder().s(wordId).build()); + expressionValues.put(":word", AttributeValue.builder().s(word).build()); + expressionValues.put(":startTime", AttributeValue.builder().n(String.valueOf(roundStartTime)).build()); + expressionValues.put(":duration", AttributeValue.builder().n(String.valueOf(roundDuration)).build()); + expressionValues.put(":hintUsed", AttributeValue.builder().bool(false).build()); + expressionValues.put(":emptyList", AttributeValue.builder().l(List.of()).build()); + + String updateExpression = "SET currentRound = :round, " + + "currentDrawerId = :drawer, " + + "currentWordId = :wordId, " + + "currentWord = :word, " + + "roundStartTime = :startTime, " + + "roundDuration = :duration, " + + "hintUsed = :hintUsed, " + + "correctGuessers = :emptyList"; + + UpdateItemRequest updateRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression(updateExpression) + .expressionAttributeValues(expressionValues) + .build(); + + AwsClients.dynamoDb().updateItem(updateRequest); + logger.info("Updated round info: gameSession={}, round={}, drawer={}", gameSessionId, currentRound, drawerId); + } + + /** + * 점수 업데이트 + */ + public void updateScores(String gameSessionId, Map scores) { + Map key = buildKey(gameSessionId); + + Map scoresMap = new HashMap<>(); + scores.forEach((userId, score) -> + scoresMap.put(userId, AttributeValue.builder().n(String.valueOf(score)).build())); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":scores", AttributeValue.builder().m(scoresMap).build()); + + UpdateItemRequest updateRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression("SET scores = :scores") + .expressionAttributeValues(expressionValues) + .build(); + + AwsClients.dynamoDb().updateItem(updateRequest); + logger.info("Updated scores for game session: {}", gameSessionId); + } + + /** + * 정답자 추가 + */ + public void addCorrectGuesser(String gameSessionId, String userId) { + Map key = buildKey(gameSessionId); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":userId", AttributeValue.builder().l( + AttributeValue.builder().s(userId).build() + ).build()); + expressionValues.put(":emptyList", AttributeValue.builder().l(List.of()).build()); + + UpdateItemRequest updateRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression("SET correctGuessers = list_append(if_not_exists(correctGuessers, :emptyList), :userId)") + .expressionAttributeValues(expressionValues) + .build(); + + AwsClients.dynamoDb().updateItem(updateRequest); + logger.info("Added correct guesser: gameSession={}, userId={}", gameSessionId, userId); + } + + /** + * 연속 정답(streak) 업데이트 + */ + public void updateStreak(String gameSessionId, String userId, int streak) { + Map key = buildKey(gameSessionId); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":streak", AttributeValue.builder().n(String.valueOf(streak)).build()); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#streaks", "streaks"); + expressionNames.put("#userId", userId); + + UpdateItemRequest updateRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression("SET #streaks.#userId = :streak") + .expressionAttributeNames(expressionNames) + .expressionAttributeValues(expressionValues) + .build(); + + AwsClients.dynamoDb().updateItem(updateRequest); + logger.info("Updated streak: gameSession={}, userId={}, streak={}", gameSessionId, userId, streak); + } + + /** + * 힌트 사용 처리 + */ + public void markHintUsed(String gameSessionId) { + Map key = buildKey(gameSessionId); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":hintUsed", AttributeValue.builder().bool(true).build()); + + UpdateItemRequest updateRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression("SET hintUsed = :hintUsed") + .expressionAttributeValues(expressionValues) + .build(); + + AwsClients.dynamoDb().updateItem(updateRequest); + logger.info("Marked hint used for game session: {}", gameSessionId); + } + + /** + * 게임 종료 처리 + */ + public void finishGame(String gameSessionId, long endedAt, long ttl) { + Map key = buildKey(gameSessionId); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":status", AttributeValue.builder().s("FINISHED").build()); + expressionValues.put(":endedAt", AttributeValue.builder().n(String.valueOf(endedAt)).build()); + expressionValues.put(":ttl", AttributeValue.builder().n(String.valueOf(ttl)).build()); + + UpdateItemRequest updateRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression("SET #status = :status, endedAt = :endedAt, #ttl = :ttl") + .expressionAttributeNames(Map.of("#status", "status", "#ttl", "ttl")) + .expressionAttributeValues(expressionValues) + .build(); + + AwsClients.dynamoDb().updateItem(updateRequest); + logger.info("Finished game session: {}", gameSessionId); + } + + /** + * DynamoDB 키 빌더 헬퍼 + */ + private Map buildKey(String gameSessionId) { + Map key = new HashMap<>(); + key.put("PK", AttributeValue.builder().s("GAME#" + gameSessionId).build()); + key.put("SK", AttributeValue.builder().s("METADATA").build()); + return key; + } +} From 13f254e9c0ef67890a15b54725dcab0805dfcd6a Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 17:28:07 +0900 Subject: [PATCH 07/99] =?UTF-8?q?refactor:=20ChatRoom=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EA=B2=8C=EC=9E=84=20=ED=95=84=EB=93=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 --- .../domain/chatting/model/ChatRoom.java | 20 +++---------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java index 5fe45aaf..0c076da4 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java @@ -7,7 +7,6 @@ import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; import java.util.List; -import java.util.Map; @Data @Builder @@ -34,22 +33,9 @@ public class ChatRoom { private String lastMessageAt; private List memberIds; // 참여 멤버 목록 private Long ttl; - - // 게임 관련 필드 - private String gameStatus; // NONE, WAITING, PLAYING, ROUND_END, FINISHED - private String gameStartedBy; // 게임 시작한 사용자 ID - private Integer currentRound; // 현재 라운드 (1부터 시작) - private Integer totalRounds; // 총 라운드 수 - private String currentDrawerId; // 현재 출제자 userId - private String currentWordId; // 현재 제시어 wordId - private String currentWord; // 현재 제시어 (korean) - private Long roundStartTime; // 라운드 시작 시간 (Unix timestamp) - private Integer roundTimeLimit; // 라운드 제한 시간 (초) - private List drawerOrder; // 출제 순서 (userId 목록) - private Map scores; // 사용자별 점수 - private Map streaks; // 사용자별 연속 정답 수 - private Boolean hintUsed; // 현재 라운드 힌트 사용 여부 - private List correctGuessers; // 현재 라운드 정답자 목록 + + // 게임 세션 참조 (게임 상태는 GameSession으로 분리됨) + private String activeGameSessionId; // 현재 진행중인 게임 세션 ID (nullable) @DynamoDbPartitionKey @DynamoDbAttribute("PK") From 5195ca601d9a634f957b7abcebc68630e7e7f47b Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 17:35:45 +0900 Subject: [PATCH 08/99] =?UTF-8?q?refactor:=20GameSession=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EC=A0=84=EC=B2=B4=20=EA=B2=8C?= =?UTF-8?q?=EC=9E=84=20=EB=A1=9C=EC=A7=81=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 --- .../dto/response/GameStatusResponse.java | 26 +- .../dto/response/ScoreboardResponse.java | 22 +- .../domain/chatting/handler/GameHandler.java | 120 +++-- .../websocket/WebSocketMessageHandler.java | 39 +- .../chatting/service/CommandService.java | 49 +- .../domain/chatting/service/GameService.java | 489 ++++++++++-------- .../chatting/service/GameStatsService.java | 16 +- 7 files changed, 407 insertions(+), 354 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/GameStatusResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/GameStatusResponse.java index 5676a93a..9a66e2d1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/GameStatusResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/GameStatusResponse.java @@ -1,6 +1,6 @@ package com.mzc.secondproject.serverless.domain.chatting.dto.response; -import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import java.util.List; import java.util.Map; @@ -14,24 +14,24 @@ public record GameStatusResponse( Integer totalRounds, String currentDrawerId, Long roundStartTime, - Integer roundTimeLimit, + Integer roundDuration, List drawerOrder, Map scores, Boolean hintUsed, List correctGuessers ) { - public static GameStatusResponse from(ChatRoom room, List drawerOrder) { + public static GameStatusResponse from(GameSession session) { return new GameStatusResponse( - room.getGameStatus(), - room.getCurrentRound(), - room.getTotalRounds(), - room.getCurrentDrawerId(), - room.getRoundStartTime(), - room.getRoundTimeLimit(), - drawerOrder != null ? drawerOrder : room.getDrawerOrder(), - room.getScores(), - room.getHintUsed(), - room.getCorrectGuessers() + session.getStatus(), + session.getCurrentRound(), + session.getTotalRounds(), + session.getCurrentDrawerId(), + session.getRoundStartTime(), + session.getRoundDuration(), + session.getDrawerOrder(), + session.getScores(), + session.getHintUsed(), + session.getCorrectGuessers() ); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreboardResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreboardResponse.java index 6cf2bdce..eb27a347 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreboardResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreboardResponse.java @@ -1,6 +1,6 @@ package com.mzc.secondproject.serverless.domain.chatting.dto.response; -import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import java.util.List; import java.util.Map; @@ -15,33 +15,33 @@ public record ScoreboardResponse( Integer currentRound, Integer totalRounds ) { - public static ScoreboardResponse from(ChatRoom room) { - Map scores = room.getScores(); + public static ScoreboardResponse from(GameSession session) { + Map scores = session.getScores(); List ranking = buildRanking(scores); - + return new ScoreboardResponse( scores, ranking, - room.getGameStatus(), - room.getCurrentRound(), - room.getTotalRounds() + session.getStatus(), + session.getCurrentRound(), + session.getTotalRounds() ); } - + private static List buildRanking(Map scores) { if (scores == null || scores.isEmpty()) { return List.of(); } - + List> sorted = scores.entrySet().stream() .sorted(Map.Entry.comparingByValue().reversed()) .toList(); - + return java.util.stream.IntStream.range(0, sorted.size()) .mapToObj(i -> new RankEntry(i + 1, sorted.get(i).getKey(), sorted.get(i).getValue())) .toList(); } - + public record RankEntry( int rank, String userId, diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java index 56410132..b95a369e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java @@ -8,15 +8,16 @@ import com.mzc.secondproject.serverless.common.router.Route; import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.common.util.WebSocketBroadcaster; +import com.mzc.secondproject.serverless.common.util.WebSocketMessageHelper; import com.mzc.secondproject.serverless.domain.chatting.dto.response.CommandResult; import com.mzc.secondproject.serverless.domain.chatting.dto.response.GameStatusResponse; import com.mzc.secondproject.serverless.domain.chatting.dto.response.ScoreboardResponse; import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingErrorCode; -import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.domain.chatting.model.Connection; -import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.GameSessionRepository; import com.mzc.secondproject.serverless.domain.chatting.service.GameService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -28,23 +29,23 @@ * 게임 REST API 핸들러 */ public class GameHandler implements RequestHandler { - + private static final Logger logger = LoggerFactory.getLogger(GameHandler.class); - + private final GameService gameService; - private final ChatRoomRepository chatRoomRepository; + private final GameSessionRepository gameSessionRepository; private final ConnectionRepository connectionRepository; private final WebSocketBroadcaster broadcaster; private final HandlerRouter router; - + public GameHandler() { this.gameService = new GameService(); - this.chatRoomRepository = new ChatRoomRepository(); + this.gameSessionRepository = new GameSessionRepository(); this.connectionRepository = new ConnectionRepository(); this.broadcaster = new WebSocketBroadcaster(); this.router = initRouter(); } - + private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( Route.postAuth("/rooms/{roomId}/game/start", this::startGame), @@ -53,140 +54,151 @@ private HandlerRouter initRouter() { Route.getAuth("/rooms/{roomId}/game/scores", this::getScores) ); } - + @Override public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); return router.route(request); } - + /** * POST /rooms/{roomId}/game/start - 게임 시작 */ private APIGatewayProxyResponseEvent startGame(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - + GameService.GameStartResult result = gameService.startGame(roomId, userId); - + if (!result.success()) { return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, result.error()); } - + // WebSocket으로 게임 시작 알림 브로드캐스트 broadcastGameStart(roomId, result); - - GameStatusResponse response = GameStatusResponse.from(result.room(), result.drawerOrder()); + + GameStatusResponse response = GameStatusResponse.from(result.session()); return ResponseGenerator.ok("Game started", response); } - + /** * POST /rooms/{roomId}/game/stop - 게임 중단 */ private APIGatewayProxyResponseEvent stopGame(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - + CommandResult result = gameService.stopGame(roomId, userId); - + if (!result.success()) { return ResponseGenerator.fail(ChattingErrorCode.GAME_STOP_FAILED, result.message()); } - + // WebSocket으로 게임 종료 알림 브로드캐스트 broadcastSystemMessage(roomId, result.message(), MessageType.GAME_END); - + return ResponseGenerator.ok("Game stopped", Map.of("message", result.message())); } - + /** * GET /rooms/{roomId}/game/status - 게임 상태 조회 */ private APIGatewayProxyResponseEvent getGameStatus(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - - Optional optRoom = chatRoomRepository.findById(roomId); - if (optRoom.isEmpty()) { - return ResponseGenerator.fail(ChattingErrorCode.ROOM_NOT_FOUND); + + Optional optSession = gameSessionRepository.findActiveByRoomId(roomId); + if (optSession.isEmpty()) { + // 게임이 없는 경우 빈 상태 반환 + return ResponseGenerator.ok("No active game", Map.of("gameStatus", "NONE")); } - - ChatRoom room = optRoom.get(); - GameStatusResponse response = GameStatusResponse.from(room, room.getDrawerOrder()); - + + GameSession session = optSession.get(); + GameStatusResponse response = GameStatusResponse.from(session); + return ResponseGenerator.ok("Game status retrieved", response); } - + /** * GET /rooms/{roomId}/game/scores - 점수 조회 */ private APIGatewayProxyResponseEvent getScores(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - - Optional optRoom = chatRoomRepository.findById(roomId); - if (optRoom.isEmpty()) { - return ResponseGenerator.fail(ChattingErrorCode.ROOM_NOT_FOUND); + + Optional optSession = gameSessionRepository.findActiveByRoomId(roomId); + if (optSession.isEmpty()) { + return ResponseGenerator.ok("No active game", Map.of("scores", Map.of())); } - - ChatRoom room = optRoom.get(); - ScoreboardResponse response = ScoreboardResponse.from(room); - + + GameSession session = optSession.get(); + ScoreboardResponse response = ScoreboardResponse.from(session); + return ResponseGenerator.ok("Scores retrieved", response); } - + /** * 게임 시작 브로드캐스트 */ private void broadcastGameStart(String roomId, GameService.GameStartResult result) { String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + long serverTime = System.currentTimeMillis(); + + GameSession session = result.session(); + String message = String.format(""" 🎮 게임 시작! 총 %d 라운드 - + 라운드 1 시작! 출제자: %s """, - result.room().getTotalRounds(), - result.room().getCurrentDrawerId()); - + session.getTotalRounds(), + session.getCurrentDrawerId()); + Map gameStartMessage = new HashMap<>(); + gameStartMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); gameStartMessage.put("messageId", messageId); gameStartMessage.put("roomId", roomId); gameStartMessage.put("userId", "SYSTEM"); gameStartMessage.put("content", message); gameStartMessage.put("messageType", MessageType.GAME_START.getCode()); gameStartMessage.put("createdAt", now); - gameStartMessage.put("gameStatus", result.room().getGameStatus()); - gameStartMessage.put("currentRound", result.room().getCurrentRound()); - gameStartMessage.put("totalRounds", result.room().getTotalRounds()); - gameStartMessage.put("currentDrawerId", result.room().getCurrentDrawerId()); + gameStartMessage.put("timestamp", serverTime); + gameStartMessage.put("gameStatus", session.getStatus()); + gameStartMessage.put("currentRound", session.getCurrentRound()); + gameStartMessage.put("totalRounds", session.getTotalRounds()); + gameStartMessage.put("currentDrawerId", session.getCurrentDrawerId()); gameStartMessage.put("drawerOrder", result.drawerOrder()); - + gameStartMessage.put("roundStartTime", session.getRoundStartTime()); + gameStartMessage.put("serverTime", serverTime); + gameStartMessage.put("roundDuration", session.getRoundDuration()); + List connections = connectionRepository.findByRoomId(roomId); String broadcastPayload = ResponseGenerator.gson().toJson(gameStartMessage); broadcaster.broadcast(connections, broadcastPayload); - + logger.info("Game start broadcasted: roomId={}", roomId); } - + /** * 시스템 메시지 브로드캐스트 */ private void broadcastSystemMessage(String roomId, String message, MessageType messageType) { String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + Map systemMessage = new HashMap<>(); + systemMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); systemMessage.put("messageId", messageId); systemMessage.put("roomId", roomId); systemMessage.put("userId", "SYSTEM"); systemMessage.put("content", message); systemMessage.put("messageType", messageType.getCode()); systemMessage.put("createdAt", now); - + systemMessage.put("timestamp", System.currentTimeMillis()); + List connections = connectionRepository.findByRoomId(roomId); String broadcastPayload = ResponseGenerator.gson().toJson(systemMessage); broadcaster.broadcast(connections, broadcastPayload); - + logger.info("System message broadcasted: roomId={}, type={}", roomId, messageType); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java index d4b803d8..c511c6ba 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java @@ -12,8 +12,10 @@ import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; import com.mzc.secondproject.serverless.domain.chatting.model.ChatMessage; import com.mzc.secondproject.serverless.domain.chatting.model.Connection; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.GameSessionRepository; import com.mzc.secondproject.serverless.domain.chatting.service.ChatMessageService; import com.mzc.secondproject.serverless.domain.chatting.service.CommandService; import com.mzc.secondproject.serverless.domain.chatting.service.GameService; @@ -38,14 +40,16 @@ public class WebSocketMessageHandler implements RequestHandler broadcastGuessMessage(MessagePayload payload) { */ private Map handleCorrectAnswer(MessagePayload payload, GameService.AnswerCheckResult result) { List connections = connectionRepository.findByRoomId(payload.roomId); - + // 1. 정답 알림 메시지 브로드캐스트 broadcastCorrectAnswerMessage(payload, result, connections); - + // 2. 점수 업데이트 메시지 브로드캐스트 (실시간 리더보드) - chatRoomRepository.findById(payload.roomId).ifPresent(room -> { + gameSessionRepository.findActiveByRoomId(payload.roomId).ifPresent(session -> { broadcastScoreUpdate(payload.roomId, payload.userId, result.score(), - result.scores(), room.getCurrentRound(), room.getTotalRounds(), connections); + result.scores(), session.getCurrentRound(), session.getTotalRounds(), connections); }); - + logger.info("Correct answer: roomId={}, userId={}, score={}", payload.roomId, payload.userId, result.score()); - + // 전원 정답 시 라운드 종료 처리 if (result.allCorrect()) { handleAllCorrect(payload.roomId); } - + return WebSocketEventUtil.ok("Correct answer"); } @@ -310,10 +314,10 @@ private void cleanupFailedConnections(List failedConnections) { * 전원 정답 시 라운드 종료 */ private void handleAllCorrect(String roomId) { - chatRoomRepository.findById(roomId).ifPresent(room -> { - CommandResult endResult = gameService.endRound(room, "ALL_CORRECT"); + CommandResult endResult = gameService.endRound(roomId, "ALL_CORRECT"); + if (endResult != null && !endResult.message().contains("진행 중인 게임이 없습니다")) { handleCommandResult(endResult, roomId, "SYSTEM"); - }); + } } /** @@ -368,7 +372,8 @@ private void broadcastGameStart(List connections, CommandResult resu String now = Instant.now().toString(); long serverTime = System.currentTimeMillis(); - String currentDrawerId = gameResult.room().getCurrentDrawerId(); + GameSession session = gameResult.session(); + String currentDrawerId = session.getCurrentDrawerId(); for (Connection conn : connections) { Map message = new HashMap<>(); @@ -382,16 +387,16 @@ private void broadcastGameStart(List connections, CommandResult resu message.put("timestamp", serverTime); // 게임 상태 정보 - message.put("gameStatus", gameResult.room().getGameStatus()); - message.put("currentRound", gameResult.room().getCurrentRound()); - message.put("totalRounds", gameResult.room().getTotalRounds()); + message.put("gameStatus", session.getStatus()); + message.put("currentRound", session.getCurrentRound()); + message.put("totalRounds", session.getTotalRounds()); message.put("currentDrawerId", currentDrawerId); message.put("drawerOrder", gameResult.drawerOrder()); // 타이머 동기화용 필드 (핵심!) - message.put("roundStartTime", gameResult.room().getRoundStartTime()); + message.put("roundStartTime", session.getRoundStartTime()); message.put("serverTime", serverTime); - message.put("roundDuration", gameResult.room().getRoundTimeLimit()); + message.put("roundDuration", session.getRoundDuration()); // 출제자에게만 제시어 전송 if (conn.getUserId().equals(currentDrawerId) && gameResult.firstWord() != null) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java index 9fbda0b7..8f43d7af 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java @@ -2,10 +2,10 @@ import com.mzc.secondproject.serverless.domain.chatting.dto.response.CommandResult; import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; -import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.domain.chatting.model.Connection; -import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.GameSessionRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -18,14 +18,14 @@ public class CommandService { private static final Logger logger = LoggerFactory.getLogger(CommandService.class); - + private final ConnectionRepository connectionRepository; - private final ChatRoomRepository chatRoomRepository; + private final GameSessionRepository gameSessionRepository; private final GameService gameService; - + public CommandService() { this.connectionRepository = new ConnectionRepository(); - this.chatRoomRepository = new ChatRoomRepository(); + this.gameSessionRepository = new GameSessionRepository(); this.gameService = new GameService(); } @@ -78,21 +78,21 @@ private CommandResult handleMemberCommand(String roomId) { */ private CommandResult handleStartCommand(String roomId, String userId) { GameService.GameStartResult result = gameService.startGame(roomId, userId); - + if (!result.success()) { return CommandResult.error(result.error()); } - + String message = String.format(""" 🎮 게임 시작! 총 %d 라운드 - + 라운드 1 시작! 출제자: %s """, - result.room().getTotalRounds(), - result.room().getCurrentDrawerId()); - + result.session().getTotalRounds(), + result.session().getCurrentDrawerId()); + return CommandResult.success(MessageType.GAME_START, message, result); } @@ -107,28 +107,23 @@ private CommandResult handleStopCommand(String roomId, String userId) { * /score - 현재 점수 조회 */ private CommandResult handleScoreCommand(String roomId) { - Optional optRoom = chatRoomRepository.findById(roomId); - if (optRoom.isEmpty()) { - return CommandResult.error("채팅방을 찾을 수 없습니다."); - } - - ChatRoom room = optRoom.get(); - - if (room.getGameStatus() == null || "NONE".equals(room.getGameStatus())) { + Optional optSession = gameSessionRepository.findActiveByRoomId(roomId); + if (optSession.isEmpty()) { return CommandResult.error("진행 중인 게임이 없습니다."); } - - // TODO: 점수 포맷팅 (Story #225에서 구현) - if (room.getScores() == null || room.getScores().isEmpty()) { + + GameSession session = optSession.get(); + + if (session.getScores() == null || session.getScores().isEmpty()) { return CommandResult.success(MessageType.SCORE_UPDATE, "아직 점수가 없습니다."); } - + StringBuilder sb = new StringBuilder("📊 현재 점수:\n"); - room.getScores().entrySet().stream() + session.getScores().entrySet().stream() .sorted((a, b) -> b.getValue().compareTo(a.getValue())) .forEach(entry -> sb.append(String.format(" %s: %d점\n", entry.getKey(), entry.getValue()))); - - return CommandResult.success(MessageType.SCORE_UPDATE, sb.toString(), room.getScores()); + + return CommandResult.success(MessageType.SCORE_UPDATE, sb.toString(), session.getScores()); } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java index 3ac401a5..ee8d92b9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java @@ -8,9 +8,11 @@ import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.domain.chatting.model.Connection; import com.mzc.secondproject.serverless.domain.chatting.model.GameRound; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; import com.mzc.secondproject.serverless.domain.chatting.repository.GameRoundRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.GameSessionRepository; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; import org.slf4j.Logger; @@ -22,93 +24,115 @@ /** * 캐치마인드 게임 로직 서비스 + * GameSession 모델을 사용하여 게임 상태 관리 */ public class GameService { - + private static final Logger logger = LoggerFactory.getLogger(GameService.class); - + private final ChatRoomRepository chatRoomRepository; private final ConnectionRepository connectionRepository; private final GameRoundRepository gameRoundRepository; + private final GameSessionRepository gameSessionRepository; private final WordRepository wordRepository; private final GameStatsService gameStatsService; - + /** * 기본 생성자 (Lambda에서 사용) */ public GameService() { this(new ChatRoomRepository(), new ConnectionRepository(), - new GameRoundRepository(), new WordRepository(), new GameStatsService()); + new GameRoundRepository(), new GameSessionRepository(), + new WordRepository(), new GameStatsService()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ public GameService(ChatRoomRepository chatRoomRepository, ConnectionRepository connectionRepository, - GameRoundRepository gameRoundRepository, WordRepository wordRepository, - GameStatsService gameStatsService) { + GameRoundRepository gameRoundRepository, GameSessionRepository gameSessionRepository, + WordRepository wordRepository, GameStatsService gameStatsService) { this.chatRoomRepository = chatRoomRepository; this.connectionRepository = connectionRepository; this.gameRoundRepository = gameRoundRepository; + this.gameSessionRepository = gameSessionRepository; this.wordRepository = wordRepository; this.gameStatsService = gameStatsService; } - + /** * 게임 시작 */ public GameStartResult startGame(String roomId, String userId) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); - - // 이미 게임 중인지 확인 - GameStatus currentStatus = GameStatus.fromString(room.getGameStatus()); - if (!currentStatus.canStartGame()) { + + // 이미 활성 게임 세션이 있는지 확인 + Optional existingSession = gameSessionRepository.findActiveByRoomId(roomId); + if (existingSession.isPresent()) { return GameStartResult.error("이미 게임이 진행 중입니다."); } - + // 접속자 확인 List connections = connectionRepository.findByRoomId(roomId); if (connections.size() < 2) { return GameStartResult.error("최소 2명 이상 접속해야 게임을 시작할 수 있습니다."); } - + // 출제 순서 생성 (랜덤 셔플) List drawerOrder = connections.stream() .map(Connection::getUserId) .collect(Collectors.toList()); Collections.shuffle(drawerOrder); - + // 제시어 추출 (난이도별) String level = room.getLevel() != null ? room.getLevel() : "beginner"; List words = getRandomWords(level, GameConfig.totalRounds()); - + if (words.size() < GameConfig.totalRounds()) { return GameStartResult.error("단어가 부족합니다. 관리자에게 문의하세요."); } - - // 게임 상태 업데이트 - room.setGameStatus(GameStatus.PLAYING.name()); - room.setGameStartedBy(userId); - room.setCurrentRound(1); - room.setTotalRounds(GameConfig.totalRounds()); - room.setDrawerOrder(drawerOrder); - room.setScores(new HashMap<>()); - room.setStreaks(new HashMap<>()); - room.setRoundTimeLimit(GameConfig.roundTimeLimit()); - - // 첫 라운드 설정 + + // 게임 세션 생성 + String gameSessionId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long currentTime = System.currentTimeMillis(); + String firstDrawer = drawerOrder.get(0); Word firstWord = words.get(0); - room.setCurrentDrawerId(firstDrawer); - room.setCurrentWordId(firstWord.getWordId()); - room.setCurrentWord(firstWord.getKorean()); - room.setRoundStartTime(System.currentTimeMillis()); - room.setHintUsed(false); - room.setCorrectGuessers(new ArrayList<>()); - + + GameSession session = GameSession.builder() + .pk("GAME#" + gameSessionId) + .sk("METADATA") + .gsi1pk("ROOM#" + roomId) + .gsi1sk("GAME#" + now) + .gameSessionId(gameSessionId) + .roomId(roomId) + .gameType("catchmind") + .status(GameStatus.PLAYING.name()) + .startedBy(userId) + .startedAt(currentTime) + .currentRound(1) + .totalRounds(GameConfig.totalRounds()) + .currentDrawerId(firstDrawer) + .currentWordId(firstWord.getWordId()) + .currentWord(firstWord.getKorean()) + .roundStartTime(currentTime) + .roundDuration(GameConfig.roundTimeLimit()) + .scores(new HashMap<>()) + .streaks(new HashMap<>()) + .players(new ArrayList<>(drawerOrder)) + .drawerOrder(drawerOrder) + .hintUsed(false) + .correctGuessers(new ArrayList<>()) + .build(); + + gameSessionRepository.save(session); + + // ChatRoom에 활성 게임 세션 ID 연결 + room.setActiveGameSessionId(gameSessionId); chatRoomRepository.save(room); - + // 첫 라운드 기록 생성 (7일 후 자동 삭제) long ttlSeconds = Instant.now().plusSeconds(7 * 24 * 60 * 60).getEpochSecond(); GameRound firstRound = GameRound.builder() @@ -120,184 +144,180 @@ public GameStartResult startGame(String roomId, String userId) { .wordId(firstWord.getWordId()) .word(firstWord.getKorean()) .wordEnglish(firstWord.getEnglish()) - .startTime(System.currentTimeMillis()) + .startTime(currentTime) .hintUsed(false) .correctGuessers(new ArrayList<>()) .guessTimes(new HashMap<>()) .roundScores(new HashMap<>()) - .createdAt(Instant.now().toString()) + .createdAt(now) .ttl(ttlSeconds) .build(); - + gameRoundRepository.save(firstRound); - - logger.info("Game started: roomId={}, starter={}, rounds={}", roomId, userId, GameConfig.totalRounds()); - - return GameStartResult.success(room, firstWord, drawerOrder); + + logger.info("Game started: roomId={}, sessionId={}, starter={}, rounds={}", + roomId, gameSessionId, userId, GameConfig.totalRounds()); + + return GameStartResult.success(session, firstWord, drawerOrder); } - + /** * 게임 종료 */ public CommandResult stopGame(String roomId, String userId) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); - - GameStatus currentStatus = GameStatus.fromString(room.getGameStatus()); - if (!currentStatus.isGameActive()) { + + GameSession session = gameSessionRepository.findActiveByRoomId(roomId) + .orElse(null); + + if (session == null || !session.isActive()) { return CommandResult.error("진행 중인 게임이 없습니다."); } - + // 권한 확인 boolean isOwner = userId.equals(room.getCreatedBy()); - boolean isGameStarter = userId.equals(room.getGameStartedBy()); - + boolean isGameStarter = userId.equals(session.getStartedBy()); + if (!isOwner && !isGameStarter) { return CommandResult.error("게임을 중단할 권한이 없습니다."); } - + // 게임 종료 처리 - return finishGame(room, "STOPPED"); + return finishGame(session, room, "STOPPED"); } - + /** * 정답 체크 */ public AnswerCheckResult checkAnswer(String roomId, String userId, String answer) { - ChatRoom room = chatRoomRepository.findById(roomId) - .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); - + GameSession session = gameSessionRepository.findActiveByRoomId(roomId) + .orElse(null); + // 게임 진행 중인지 확인 - if (!GameStatus.PLAYING.name().equals(room.getGameStatus())) { + if (session == null || !GameStatus.PLAYING.name().equals(session.getStatus())) { return AnswerCheckResult.gameNotPlaying(); } - + // 출제자는 정답 체크 제외 - if (userId.equals(room.getCurrentDrawerId())) { + if (session.isDrawer(userId)) { return AnswerCheckResult.drawerCannotGuess(); } - + // 이미 맞춘 사람인지 확인 - if (room.getCorrectGuessers() != null && room.getCorrectGuessers().contains(userId)) { + if (session.hasAlreadyGuessedCorrect(userId)) { return AnswerCheckResult.alreadyGuessedCorrect(); } - + // 정답 체크 - String currentWord = room.getCurrentWord(); + String currentWord = session.getCurrentWord(); if (!isCorrectAnswer(answer, currentWord)) { return AnswerCheckResult.wrongAnswer(); } - + // 정답 처리 - long elapsedTime = System.currentTimeMillis() - room.getRoundStartTime(); - + long elapsedTime = System.currentTimeMillis() - session.getRoundStartTime(); + // 연속 정답 업데이트 (점수 계산 전에) - if (room.getStreaks() == null) { - room.setStreaks(new HashMap<>()); - } - int currentStreak = room.getStreaks().getOrDefault(userId, 0) + 1; - room.getStreaks().put(userId, currentStreak); - - int score = calculateScore(room, elapsedTime, userId, currentStreak); - + int currentStreak = session.incrementStreak(userId); + + int score = calculateScore(session, elapsedTime, userId, currentStreak); + // 정답자 목록에 추가 - if (room.getCorrectGuessers() == null) { - room.setCorrectGuessers(new ArrayList<>()); - } - room.getCorrectGuessers().add(userId); - + session.addCorrectGuesser(userId); + // 점수 업데이트 - if (room.getScores() == null) { - room.setScores(new HashMap<>()); - } - room.getScores().merge(userId, score, Integer::sum); - + session.addScore(userId, score); + // 출제자 점수도 추가 - room.getScores().merge(room.getCurrentDrawerId(), 5, Integer::sum); - - chatRoomRepository.save(room); - + session.addScore(session.getCurrentDrawerId(), 5); + + gameSessionRepository.save(session); + // 라운드 기록 업데이트 - updateRoundRecord(roomId, room.getCurrentRound(), userId, elapsedTime, score); - + updateRoundRecord(roomId, session.getCurrentRound(), userId, elapsedTime, score); + // 전원 정답 체크 List connections = connectionRepository.findByRoomId(roomId); int nonDrawerCount = (int) connections.stream() - .filter(c -> !c.getUserId().equals(room.getCurrentDrawerId())) + .filter(c -> !c.getUserId().equals(session.getCurrentDrawerId())) .count(); - - boolean allCorrect = room.getCorrectGuessers().size() >= nonDrawerCount; - + + boolean allCorrect = session.getCorrectGuessers().size() >= nonDrawerCount; + logger.info("Answer correct: roomId={}, userId={}, score={}, allCorrect={}", roomId, userId, score, allCorrect); - - return AnswerCheckResult.correctAnswer(score, elapsedTime, allCorrect, room.getScores()); + + return AnswerCheckResult.correctAnswer(score, elapsedTime, allCorrect, session.getScores()); } - + /** * 라운드 스킵 */ public CommandResult skipRound(String roomId, String userId) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); - - if (!GameStatus.PLAYING.name().equals(room.getGameStatus())) { + + GameSession session = gameSessionRepository.findActiveByRoomId(roomId) + .orElse(null); + + if (session == null || !GameStatus.PLAYING.name().equals(session.getStatus())) { return CommandResult.error("게임이 진행 중이 아닙니다."); } - - if (!userId.equals(room.getCurrentDrawerId())) { + + if (!session.isDrawer(userId)) { return CommandResult.error("출제자만 라운드를 스킵할 수 있습니다."); } - - return endRound(room, "SKIP"); + + return endRound(session, room, "SKIP"); } - + /** * 힌트 제공 */ public CommandResult provideHint(String roomId, String userId) { - ChatRoom room = chatRoomRepository.findById(roomId) - .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); - - if (!GameStatus.PLAYING.name().equals(room.getGameStatus())) { + GameSession session = gameSessionRepository.findActiveByRoomId(roomId) + .orElse(null); + + if (session == null || !GameStatus.PLAYING.name().equals(session.getStatus())) { return CommandResult.error("게임이 진행 중이 아닙니다."); } - - if (!userId.equals(room.getCurrentDrawerId())) { + + if (!session.isDrawer(userId)) { return CommandResult.error("출제자만 힌트를 제공할 수 있습니다."); } - - if (Boolean.TRUE.equals(room.getHintUsed())) { + + if (Boolean.TRUE.equals(session.getHintUsed())) { return CommandResult.error("이번 라운드에서 이미 힌트를 사용했습니다."); } - - String currentWord = room.getCurrentWord(); + + String currentWord = session.getCurrentWord(); String hint = currentWord.charAt(0) + "○".repeat(currentWord.length() - 1); - - room.setHintUsed(true); - chatRoomRepository.save(room); - + + session.setHintUsed(true); + gameSessionRepository.save(session); + // 라운드 기록 업데이트 - gameRoundRepository.findByRoomIdAndRound(roomId, room.getCurrentRound()) + gameRoundRepository.findByRoomIdAndRound(roomId, session.getCurrentRound()) .ifPresent(round -> { round.setHintUsed(true); gameRoundRepository.save(round); }); - + return CommandResult.success(MessageType.HINT, "💡 힌트: " + hint); } - + /** - * 라운드 종료 처리 + * 라운드 종료 처리 (GameSession 버전) */ - public CommandResult endRound(ChatRoom room, String reason) { - String roomId = room.getRoomId(); - Integer currentRound = room.getCurrentRound(); - String answer = room.getCurrentWord(); - + public CommandResult endRound(GameSession session, ChatRoom room, String reason) { + String roomId = session.getRoomId(); + Integer currentRound = session.getCurrentRound(); + String answer = session.getCurrentWord(); + // 정답 못 맞춘 사용자 연속 정답 초기화 - resetStreaksForNonGuessers(room); - + resetStreaksForNonGuessers(session); + // 라운드 기록 종료 gameRoundRepository.findByRoomIdAndRound(roomId, currentRound) .ifPresent(round -> { @@ -305,46 +325,48 @@ public CommandResult endRound(ChatRoom room, String reason) { round.setEndReason(reason); gameRoundRepository.save(round); }); - + // 다음 라운드로 진행 - if (currentRound >= room.getTotalRounds()) { - return finishGame(room, "COMPLETED"); + if (currentRound >= session.getTotalRounds()) { + return finishGame(session, room, "COMPLETED"); } - + // 현재 접속 중인 사용자 목록 조회 List connections = connectionRepository.findByRoomId(roomId); Set connectedUserIds = connections.stream() .map(Connection::getUserId) .collect(Collectors.toSet()); - + // 접속자가 2명 미만이면 게임 종료 if (connectedUserIds.size() < 2) { - return finishGame(room, "NOT_ENOUGH_PLAYERS"); + return finishGame(session, room, "NOT_ENOUGH_PLAYERS"); } - + // 다음 라운드 준비 - 접속 중인 사용자 중에서만 출제자 선택 int nextRound = currentRound + 1; - String nextDrawer = selectNextDrawer(room.getDrawerOrder(), connectedUserIds, nextRound); - + String nextDrawer = selectNextDrawer(session.getDrawerOrder(), connectedUserIds, nextRound); + // 다음 단어 추출 String level = room.getLevel() != null ? room.getLevel() : "beginner"; List words = getRandomWords(level, 1); if (words.isEmpty()) { - return finishGame(room, "NO_WORDS"); + return finishGame(session, room, "NO_WORDS"); } Word nextWord = words.get(0); - - // 상태 업데이트 - room.setCurrentRound(nextRound); - room.setCurrentDrawerId(nextDrawer); - room.setCurrentWordId(nextWord.getWordId()); - room.setCurrentWord(nextWord.getKorean()); - room.setRoundStartTime(System.currentTimeMillis()); - room.setHintUsed(false); - room.setCorrectGuessers(new ArrayList<>()); - - chatRoomRepository.save(room); - + + long currentTime = System.currentTimeMillis(); + + // 세션 상태 업데이트 + session.setCurrentRound(nextRound); + session.setCurrentDrawerId(nextDrawer); + session.setCurrentWordId(nextWord.getWordId()); + session.setCurrentWord(nextWord.getKorean()); + session.setRoundStartTime(currentTime); + session.setHintUsed(false); + session.setCorrectGuessers(new ArrayList<>()); + + gameSessionRepository.save(session); + // 다음 라운드 기록 생성 (7일 후 자동 삭제) long nextTtlSeconds = Instant.now().plusSeconds(7 * 24 * 60 * 60).getEpochSecond(); GameRound nextRoundRecord = GameRound.builder() @@ -356,7 +378,7 @@ public CommandResult endRound(ChatRoom room, String reason) { .wordId(nextWord.getWordId()) .word(nextWord.getKorean()) .wordEnglish(nextWord.getEnglish()) - .startTime(System.currentTimeMillis()) + .startTime(currentTime) .hintUsed(false) .correctGuessers(new ArrayList<>()) .guessTimes(new HashMap<>()) @@ -364,17 +386,17 @@ public CommandResult endRound(ChatRoom room, String reason) { .createdAt(Instant.now().toString()) .ttl(nextTtlSeconds) .build(); - + gameRoundRepository.save(nextRoundRecord); - + String message = String.format("라운드 %d 종료! 정답: %s\n\n라운드 %d 시작! 출제자: %s", currentRound, answer, nextRound, nextDrawer); - + logger.info("Round ended: roomId={}, round={}, reason={}", roomId, currentRound, reason); - + // ranking 생성 - List> ranking = buildRankingList(room.getScores()); - + List> ranking = buildRankingList(session.getScores()); + Map data = new HashMap<>(); data.put("answer", answer); data.put("nextRound", nextRound); @@ -382,36 +404,60 @@ public CommandResult endRound(ChatRoom room, String reason) { data.put("nextWord", nextWord); data.put("ranking", ranking); data.put("currentRound", currentRound); - data.put("totalRounds", room.getTotalRounds()); + data.put("totalRounds", session.getTotalRounds()); // 타이머 동기화용 필드 추가 - data.put("roundStartTime", room.getRoundStartTime()); - data.put("roundDuration", room.getRoundTimeLimit() != null ? room.getRoundTimeLimit() : GameConfig.roundTimeLimit()); + data.put("roundStartTime", session.getRoundStartTime()); + data.put("roundDuration", session.getRoundDuration() != null ? session.getRoundDuration() : GameConfig.roundTimeLimit()); return CommandResult.success(MessageType.ROUND_END, message, data); } - + + /** + * roomId로 활성 세션을 찾아 라운드 종료 (외부 호출용) + */ + public CommandResult endRound(String roomId, String reason) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); + + GameSession session = gameSessionRepository.findActiveByRoomId(roomId) + .orElse(null); + + if (session == null) { + return CommandResult.error("진행 중인 게임이 없습니다."); + } + + return endRound(session, room, reason); + } + /** * 게임 완전 종료 */ - private CommandResult finishGame(ChatRoom room, String reason) { - room.setGameStatus(GameStatus.FINISHED.name()); + private CommandResult finishGame(GameSession session, ChatRoom room, String reason) { + long currentTime = System.currentTimeMillis(); + long ttlSeconds = Instant.now().plusSeconds(30 * 24 * 60 * 60).getEpochSecond(); // 30일 보관 + + // 게임 세션 종료 처리 + gameSessionRepository.finishGame(session.getGameSessionId(), currentTime, ttlSeconds); + + // ChatRoom에서 활성 게임 세션 참조 제거 + room.setActiveGameSessionId(null); chatRoomRepository.save(room); - + // 게임 통계 업데이트 및 뱃지 체크 try { - var newBadges = gameStatsService.updateGameStats(room); + var newBadges = gameStatsService.updateGameStats(session); logger.info("Game stats updated: roomId={}, newBadges={}", room.getRoomId(), newBadges.size()); } catch (Exception e) { logger.error("Failed to update game stats: roomId={}, error={}", room.getRoomId(), e.getMessage()); } - + // 최종 점수 정렬 StringBuilder sb = new StringBuilder("🎮 게임 종료!\n\n📊 최종 순위:\n"); - if (room.getScores() != null && !room.getScores().isEmpty()) { - List> sorted = room.getScores().entrySet().stream() + if (session.getScores() != null && !session.getScores().isEmpty()) { + List> sorted = session.getScores().entrySet().stream() .sorted((a, b) -> b.getValue().compareTo(a.getValue())) .toList(); - + int rank = 1; for (Map.Entry entry : sorted) { String medal = switch (rank) { @@ -426,19 +472,20 @@ private CommandResult finishGame(ChatRoom room, String reason) { } else { sb.append(" 점수 없음"); } - - logger.info("Game finished: roomId={}, reason={}", room.getRoomId(), reason); - - return CommandResult.success(MessageType.GAME_END, sb.toString(), room.getScores()); + + logger.info("Game finished: roomId={}, sessionId={}, reason={}", + room.getRoomId(), session.getGameSessionId(), reason); + + return CommandResult.success(MessageType.GAME_END, sb.toString(), session.getScores()); } - + /** * 접속 중인 사용자 중에서 다음 출제자 선택 */ private String selectNextDrawer(List drawerOrder, Set connectedUserIds, int roundNumber) { // 원래 순서에서 시작 인덱스 계산 int startIndex = (roundNumber - 1) % drawerOrder.size(); - + // 접속 중인 사용자를 찾을 때까지 순회 for (int i = 0; i < drawerOrder.size(); i++) { int index = (startIndex + i) % drawerOrder.size(); @@ -447,11 +494,11 @@ private String selectNextDrawer(List drawerOrder, Set connectedU return candidate; } } - + // 원래 순서에 있는 사람이 모두 나갔으면, 접속 중인 아무나 선택 return connectedUserIds.iterator().next(); } - + /** * 랜덤 단어 추출 */ @@ -461,45 +508,39 @@ private List getRandomWords(String level, int count) { Collections.shuffle(words); return words.stream().limit(count).collect(Collectors.toList()); } - + /** * 정답 체크 로직 */ private boolean isCorrectAnswer(String input, String answer) { if (input == null || answer == null) return false; - + String normalizedInput = input.trim().toLowerCase().replace(" ", ""); String normalizedAnswer = answer.trim().toLowerCase().replace(" ", ""); - + return normalizedInput.equals(normalizedAnswer); } - + /** * 점수 계산 - * - * @param room 채팅방 - * @param elapsedTimeMs 경과 시간 (밀리초) - * @param userId 사용자 ID - * @param streak 연속 정답 수 - * @return 계산된 점수 */ - private int calculateScore(ChatRoom room, long elapsedTimeMs, String userId, int streak) { + private int calculateScore(GameSession session, long elapsedTimeMs, String userId, int streak) { int baseScore = 10; - - // 시간 보너스 (빨리 맞출수록 높은 점수): (제한시간 - 경과시간) * 0.5 + + // 시간 보너스 (빨리 맞출수록 높은 점수) int elapsedSeconds = (int) (elapsedTimeMs / 1000); - int timeLimit = room.getRoundTimeLimit() != null ? room.getRoundTimeLimit() : GameConfig.roundTimeLimit(); + int timeLimit = session.getRoundDuration() != null ? session.getRoundDuration() : GameConfig.roundTimeLimit(); int timeBonus = Math.max(0, (int) ((timeLimit - elapsedSeconds) * 0.5)); - - // 연속 정답 보너스: 연속정답수 * 2 + + // 연속 정답 보너스 int streakBonus = streak * 2; - + logger.info("Score calculation: base={}, timeBonus={}, streakBonus={}, total={}", baseScore, timeBonus, streakBonus, baseScore + timeBonus + streakBonus); - + return baseScore + timeBonus + streakBonus; } - + /** * 라운드 기록 업데이트 */ @@ -510,41 +551,41 @@ private void updateRoundRecord(String roomId, Integer roundNumber, String userId round.setCorrectGuessers(new ArrayList<>()); } round.getCorrectGuessers().add(userId); - + if (round.getGuessTimes() == null) { round.setGuessTimes(new HashMap<>()); } round.getGuessTimes().put(userId, elapsedTime); - + if (round.getRoundScores() == null) { round.setRoundScores(new HashMap<>()); } round.getRoundScores().put(userId, score); - + gameRoundRepository.save(round); }); } - + /** * 정답 못 맞춘 사용자 연속 정답 초기화 */ - private void resetStreaksForNonGuessers(ChatRoom room) { - if (room.getStreaks() == null || room.getStreaks().isEmpty()) { + private void resetStreaksForNonGuessers(GameSession session) { + if (session.getStreaks() == null || session.getStreaks().isEmpty()) { return; } - - List correctGuessers = room.getCorrectGuessers() != null - ? room.getCorrectGuessers() + + List correctGuessers = session.getCorrectGuessers() != null + ? session.getCorrectGuessers() : List.of(); - + // 정답 못 맞춘 사용자의 연속 정답 초기화 - room.getStreaks().keySet().stream() + session.getStreaks().keySet().stream() .filter(userId -> !correctGuessers.contains(userId)) - .forEach(userId -> room.getStreaks().put(userId, 0)); - + .forEach(userId -> session.getStreaks().put(userId, 0)); + logger.info("Reset streaks for non-guessers: correctGuessers={}", correctGuessers); } - + /** * 점수 맵을 순위 리스트로 변환 */ @@ -552,11 +593,11 @@ private List> buildRankingList(Map scores) if (scores == null || scores.isEmpty()) { return List.of(); } - + List> sorted = scores.entrySet().stream() .sorted(Map.Entry.comparingByValue().reversed()) .toList(); - + List> ranking = new ArrayList<>(); for (int i = 0; i < sorted.size(); i++) { Map entry = new HashMap<>(); @@ -567,25 +608,25 @@ private List> buildRankingList(Map scores) } return ranking; } - + // ========== Result DTOs ========== - + public record GameStartResult( boolean success, String error, - ChatRoom room, + GameSession session, Word firstWord, List drawerOrder ) { - public static GameStartResult success(ChatRoom room, Word word, List order) { - return new GameStartResult(true, null, room, word, order); + public static GameStartResult success(GameSession session, Word word, List order) { + return new GameStartResult(true, null, session, word, order); } - + public static GameStartResult error(String message) { return new GameStartResult(false, message, null, null, null); } } - + public record AnswerCheckResult( boolean correct, boolean drawer, @@ -599,19 +640,19 @@ public record AnswerCheckResult( public static AnswerCheckResult correctAnswer(int score, long elapsed, boolean allCorrect, Map scores) { return new AnswerCheckResult(true, false, false, false, allCorrect, score, elapsed, scores); } - + public static AnswerCheckResult wrongAnswer() { return new AnswerCheckResult(false, false, false, false, false, 0, 0, null); } - + public static AnswerCheckResult drawerCannotGuess() { return new AnswerCheckResult(false, true, false, false, false, 0, 0, null); } - + public static AnswerCheckResult alreadyGuessedCorrect() { return new AnswerCheckResult(false, false, true, false, false, 0, 0, null); } - + public static AnswerCheckResult gameNotPlaying() { return new AnswerCheckResult(false, false, false, true, false, 0, 0, null); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java index ae067951..2c3d61e9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java @@ -3,7 +3,7 @@ import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; import com.mzc.secondproject.serverless.domain.chatting.config.GameConfig; -import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import com.mzc.secondproject.serverless.domain.chatting.model.GameRound; import com.mzc.secondproject.serverless.domain.chatting.repository.GameRoundRepository; import com.mzc.secondproject.serverless.domain.stats.model.UserStats; @@ -33,18 +33,18 @@ public GameStatsService() { /** * 게임 종료 시 모든 참가자 통계 업데이트 */ - public Map> updateGameStats(ChatRoom room) { + public Map> updateGameStats(GameSession session) { Map> newBadges = new HashMap<>(); - String roomId = room.getRoomId(); - + String roomId = session.getRoomId(); + // 모든 라운드 조회 List rounds = gameRoundRepository.findByRoomId(roomId); - + // 참가자별 통계 수집 - Map scores = room.getScores() != null ? room.getScores() : Map.of(); + Map scores = session.getScores() != null ? session.getScores() : Map.of(); Set participants = new HashSet<>(scores.keySet()); - if (room.getDrawerOrder() != null) { - participants.addAll(room.getDrawerOrder()); + if (session.getPlayers() != null) { + participants.addAll(session.getPlayers()); } // 1등 찾기 From 2fc718ce1da36de065bd4de0ca8d76a0688849d8 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 17:39:53 +0900 Subject: [PATCH 09/99] =?UTF-8?q?feat:=20GameSessionHandler=20Lambda=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B2=8C=EC=9E=84=20=EC=84=B8=EC=85=98=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 --- .../chatting/exception/ChattingErrorCode.java | 1 + .../chatting/handler/GameSessionHandler.java | 266 ++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameSessionHandler.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java index 9b0e5509..5a091aff 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java @@ -40,6 +40,7 @@ public enum ChattingErrorCode implements DomainErrorCode { GAME_NOT_IN_PROGRESS("GAME_003", "진행 중인 게임이 없습니다", 400), GAME_ALREADY_IN_PROGRESS("GAME_004", "이미 게임이 진행 중입니다", 409), NOT_GAME_STARTER("GAME_005", "게임 시작자만 중단할 수 있습니다", 403), + GAME_NOT_FOUND("GAME_006", "게임 세션을 찾을 수 없습니다", 404), ; private static final String DOMAIN = "CHATTING"; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameSessionHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameSessionHandler.java new file mode 100644 index 00000000..9267e543 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameSessionHandler.java @@ -0,0 +1,266 @@ +package com.mzc.secondproject.serverless.domain.chatting.handler; + +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.mzc.secondproject.serverless.common.router.HandlerRouter; +import com.mzc.secondproject.serverless.common.router.Route; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.common.util.WebSocketBroadcaster; +import com.mzc.secondproject.serverless.common.util.WebSocketMessageHelper; +import com.mzc.secondproject.serverless.domain.chatting.dto.response.CommandResult; +import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; +import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingErrorCode; +import com.mzc.secondproject.serverless.domain.chatting.model.Connection; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; +import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.GameSessionRepository; +import com.mzc.secondproject.serverless.domain.chatting.service.GameService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.*; + +/** + * 게임 세션 REST API 핸들러 + * 게임 세션 조회 및 재접속 지원 + */ +public class GameSessionHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(GameSessionHandler.class); + + private final GameService gameService; + private final GameSessionRepository gameSessionRepository; + private final ConnectionRepository connectionRepository; + private final WebSocketBroadcaster broadcaster; + private final HandlerRouter router; + + public GameSessionHandler() { + this.gameService = new GameService(); + this.gameSessionRepository = new GameSessionRepository(); + this.connectionRepository = new ConnectionRepository(); + this.broadcaster = new WebSocketBroadcaster(); + this.router = initRouter(); + } + + private HandlerRouter initRouter() { + return new HandlerRouter().addRoutes( + // 게임 세션 생성 (roomId 기반) + Route.postAuth("/rooms/{roomId}/games", this::createGameSession), + // 게임 세션 조회 (재접속용) + Route.getAuth("/games/{gameSessionId}", this::getGameSession), + // 게임 시작 + Route.postAuth("/games/{gameSessionId}/start", this::startGame), + // 게임 종료 + Route.postAuth("/games/{gameSessionId}/stop", this::stopGame) + ); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("GameSession API request: {} {}", request.getHttpMethod(), request.getPath()); + return router.route(request); + } + + /** + * POST /rooms/{roomId}/games - 게임 세션 생성 (게임 시작) + */ + private APIGatewayProxyResponseEvent createGameSession(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + GameService.GameStartResult result = gameService.startGame(roomId, userId); + + if (!result.success()) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, result.error()); + } + + // WebSocket으로 게임 시작 알림 브로드캐스트 + broadcastGameStart(roomId, result); + + // 응답 생성 (serverTime 포함) + Map response = buildGameSessionResponse(result.session(), userId); + + return ResponseGenerator.ok("Game session created", response); + } + + /** + * GET /games/{gameSessionId} - 게임 세션 조회 (재접속용) + */ + private APIGatewayProxyResponseEvent getGameSession(APIGatewayProxyRequestEvent request, String userId) { + String gameSessionId = request.getPathParameters().get("gameSessionId"); + + Optional optSession = gameSessionRepository.findById(gameSessionId); + if (optSession.isEmpty()) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_NOT_FOUND); + } + + GameSession session = optSession.get(); + + // 응답 생성 (serverTime 포함, 출제자에게만 currentWord 포함) + Map response = buildGameSessionResponse(session, userId); + + return ResponseGenerator.ok("Game session retrieved", response); + } + + /** + * POST /games/{gameSessionId}/start - 게임 시작 (세션 ID로) + */ + private APIGatewayProxyResponseEvent startGame(APIGatewayProxyRequestEvent request, String userId) { + String gameSessionId = request.getPathParameters().get("gameSessionId"); + + Optional optSession = gameSessionRepository.findById(gameSessionId); + if (optSession.isEmpty()) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_NOT_FOUND); + } + + GameSession session = optSession.get(); + + // 이미 시작된 게임인지 확인 + if (session.isActive()) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, "게임이 이미 진행 중입니다."); + } + + // roomId로 게임 시작 위임 + GameService.GameStartResult result = gameService.startGame(session.getRoomId(), userId); + + if (!result.success()) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, result.error()); + } + + broadcastGameStart(session.getRoomId(), result); + + Map response = buildGameSessionResponse(result.session(), userId); + return ResponseGenerator.ok("Game started", response); + } + + /** + * POST /games/{gameSessionId}/stop - 게임 종료 + */ + private APIGatewayProxyResponseEvent stopGame(APIGatewayProxyRequestEvent request, String userId) { + String gameSessionId = request.getPathParameters().get("gameSessionId"); + + Optional optSession = gameSessionRepository.findById(gameSessionId); + if (optSession.isEmpty()) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_NOT_FOUND); + } + + GameSession session = optSession.get(); + CommandResult result = gameService.stopGame(session.getRoomId(), userId); + + if (!result.success()) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_STOP_FAILED, result.message()); + } + + // WebSocket으로 게임 종료 알림 브로드캐스트 + broadcastSystemMessage(session.getRoomId(), result.message(), MessageType.GAME_END); + + return ResponseGenerator.ok("Game stopped", Map.of( + "message", result.message(), + "serverTime", System.currentTimeMillis() + )); + } + + /** + * 게임 세션 응답 빌드 (serverTime 포함) + */ + private Map buildGameSessionResponse(GameSession session, String userId) { + long serverTime = System.currentTimeMillis(); + + Map response = new LinkedHashMap<>(); + response.put("gameSessionId", session.getGameSessionId()); + response.put("roomId", session.getRoomId()); + response.put("gameType", session.getGameType()); + response.put("status", session.getStatus()); + response.put("currentRound", session.getCurrentRound()); + response.put("totalRounds", session.getTotalRounds()); + response.put("currentDrawerId", session.getCurrentDrawerId()); + response.put("roundStartTime", session.getRoundStartTime()); + response.put("serverTime", serverTime); // 핵심! 타이머 동기화용 + response.put("roundDuration", session.getRoundDuration()); + response.put("scores", session.getScores() != null ? session.getScores() : Map.of()); + response.put("players", session.getPlayers() != null ? session.getPlayers() : List.of()); + response.put("drawerOrder", session.getDrawerOrder()); + response.put("hintUsed", session.getHintUsed()); + + // 출제자에게만 현재 단어 포함 + if (userId != null && userId.equals(session.getCurrentDrawerId())) { + Map currentWord = new HashMap<>(); + currentWord.put("wordId", session.getCurrentWordId()); + currentWord.put("word", session.getCurrentWord()); + response.put("currentWord", currentWord); + } + + return response; + } + + /** + * 게임 시작 브로드캐스트 + */ + private void broadcastGameStart(String roomId, GameService.GameStartResult result) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long serverTime = System.currentTimeMillis(); + + GameSession session = result.session(); + + String message = String.format(""" + 🎮 게임 시작! + 총 %d 라운드 + + 라운드 1 시작! + 출제자: %s + """, + session.getTotalRounds(), + session.getCurrentDrawerId()); + + Map gameStartMessage = new HashMap<>(); + gameStartMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); + gameStartMessage.put("messageId", messageId); + gameStartMessage.put("roomId", roomId); + gameStartMessage.put("userId", "SYSTEM"); + gameStartMessage.put("content", message); + gameStartMessage.put("messageType", MessageType.GAME_START.getCode()); + gameStartMessage.put("createdAt", now); + gameStartMessage.put("timestamp", serverTime); + gameStartMessage.put("gameStatus", session.getStatus()); + gameStartMessage.put("currentRound", session.getCurrentRound()); + gameStartMessage.put("totalRounds", session.getTotalRounds()); + gameStartMessage.put("currentDrawerId", session.getCurrentDrawerId()); + gameStartMessage.put("drawerOrder", result.drawerOrder()); + gameStartMessage.put("roundStartTime", session.getRoundStartTime()); + gameStartMessage.put("serverTime", serverTime); + gameStartMessage.put("roundDuration", session.getRoundDuration()); + + List connections = connectionRepository.findByRoomId(roomId); + String broadcastPayload = ResponseGenerator.gson().toJson(gameStartMessage); + broadcaster.broadcast(connections, broadcastPayload); + + logger.info("Game start broadcasted: roomId={}, sessionId={}", roomId, session.getGameSessionId()); + } + + /** + * 시스템 메시지 브로드캐스트 + */ + private void broadcastSystemMessage(String roomId, String message, MessageType messageType) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + Map systemMessage = new HashMap<>(); + systemMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); + systemMessage.put("messageId", messageId); + systemMessage.put("roomId", roomId); + systemMessage.put("userId", "SYSTEM"); + systemMessage.put("content", message); + systemMessage.put("messageType", messageType.getCode()); + systemMessage.put("createdAt", now); + systemMessage.put("timestamp", System.currentTimeMillis()); + + List connections = connectionRepository.findByRoomId(roomId); + String broadcastPayload = ResponseGenerator.gson().toJson(systemMessage); + broadcaster.broadcast(connections, broadcastPayload); + + logger.info("System message broadcasted: roomId={}, type={}", roomId, messageType); + } +} From ed29c556c998622feda89fe149206d2228b00d56 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 17:51:10 +0900 Subject: [PATCH 10/99] =?UTF-8?q?feat:=20=EA=B2=8C=EC=9E=84=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=207=EB=B6=84=20=ED=9B=84=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=A2=85=EB=A3=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 --- ServerlessFunction/build.gradle | 1 + .../domain/chatting/config/GameConfig.java | 10 ++ .../handler/GameAutoCloseHandler.java | 96 ++++++++++++ .../chatting/service/GameSchedulerClient.java | 139 ++++++++++++++++++ .../domain/chatting/service/GameService.java | 48 +++++- ServerlessFunction/template.yaml | 84 +++++++++++ 6 files changed, 376 insertions(+), 2 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameAutoCloseHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameSchedulerClient.java diff --git a/ServerlessFunction/build.gradle b/ServerlessFunction/build.gradle index b6c28326..cc5e6a12 100644 --- a/ServerlessFunction/build.gradle +++ b/ServerlessFunction/build.gradle @@ -34,6 +34,7 @@ dependencies { implementation 'software.amazon.awssdk:apigatewaymanagementapi' implementation 'software.amazon.awssdk:url-connection-client' implementation 'software.amazon.awssdk:ssm' + implementation 'software.amazon.awssdk:scheduler' // 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/domain/chatting/config/GameConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/config/GameConfig.java index 998e4f0b..443d45d1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/config/GameConfig.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/config/GameConfig.java @@ -11,10 +11,12 @@ public final class GameConfig { private static final int DEFAULT_TOTAL_ROUNDS = 5; private static final int DEFAULT_ROUND_TIME_LIMIT = 60; private static final long DEFAULT_QUICK_GUESS_THRESHOLD_MS = 5000L; + private static final int DEFAULT_GAME_TIME_LIMIT = 420; // 7분 (420초) private static final int TOTAL_ROUNDS = EnvConfig.getIntOrDefault("GAME_TOTAL_ROUNDS", DEFAULT_TOTAL_ROUNDS); private static final int ROUND_TIME_LIMIT = EnvConfig.getIntOrDefault("GAME_ROUND_TIME_LIMIT", DEFAULT_ROUND_TIME_LIMIT); private static final long QUICK_GUESS_THRESHOLD_MS = EnvConfig.getLongOrDefault("GAME_QUICK_GUESS_THRESHOLD_MS", DEFAULT_QUICK_GUESS_THRESHOLD_MS); + private static final int GAME_TIME_LIMIT = EnvConfig.getIntOrDefault("GAME_TIME_LIMIT_SECONDS", DEFAULT_GAME_TIME_LIMIT); private GameConfig() { } @@ -30,4 +32,12 @@ public static int roundTimeLimit() { public static long quickGuessThresholdMs() { return QUICK_GUESS_THRESHOLD_MS; } + + /** + * 게임 전체 시간 제한 (초) + * 기본값: 420초 (7분) + */ + public static int gameTimeLimit() { + return GAME_TIME_LIMIT; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameAutoCloseHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameAutoCloseHandler.java new file mode 100644 index 00000000..e1030b59 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameAutoCloseHandler.java @@ -0,0 +1,96 @@ +package com.mzc.secondproject.serverless.domain.chatting.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.common.util.WebSocketBroadcaster; +import com.mzc.secondproject.serverless.common.util.WebSocketMessageHelper; +import com.mzc.secondproject.serverless.domain.chatting.dto.response.CommandResult; +import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; +import com.mzc.secondproject.serverless.domain.chatting.model.Connection; +import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.chatting.service.GameService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * 게임 자동 종료 Lambda 핸들러 + * EventBridge Scheduler에 의해 게임 시작 7분 후 호출됨 + */ +public class GameAutoCloseHandler implements RequestHandler, String> { + + private static final Logger logger = LoggerFactory.getLogger(GameAutoCloseHandler.class); + + private final GameService gameService; + private final ConnectionRepository connectionRepository; + private final WebSocketBroadcaster broadcaster; + + public GameAutoCloseHandler() { + this.gameService = new GameService(); + this.connectionRepository = new ConnectionRepository(); + this.broadcaster = new WebSocketBroadcaster(); + } + + @Override + public String handleRequest(Map event, Context context) { + String gameSessionId = event.get("gameSessionId"); + String roomId = event.get("roomId"); + + logger.info("Game auto-close triggered: gameSessionId={}, roomId={}", gameSessionId, roomId); + + if (gameSessionId == null || roomId == null) { + logger.error("Missing required parameters: gameSessionId={}, roomId={}", gameSessionId, roomId); + return "FAILED: Missing parameters"; + } + + try { + // 게임 종료 처리 + CommandResult result = gameService.finishGameByTimeout(gameSessionId); + + if (result.success()) { + // WebSocket으로 게임 종료 알림 브로드캐스트 + broadcastGameEnd(roomId, result.message()); + logger.info("Game auto-closed successfully: gameSessionId={}", gameSessionId); + return "SUCCESS: Game auto-closed"; + } else { + logger.info("Game auto-close skipped: gameSessionId={}, reason={}", gameSessionId, result.message()); + return "SKIPPED: " + result.message(); + } + + } catch (Exception e) { + logger.error("Game auto-close failed: gameSessionId={}, error={}", gameSessionId, e.getMessage(), e); + return "FAILED: " + e.getMessage(); + } + } + + /** + * 게임 종료 메시지 브로드캐스트 + */ + private void broadcastGameEnd(String roomId, String message) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + Map gameEndMessage = new HashMap<>(); + gameEndMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); + gameEndMessage.put("messageId", messageId); + gameEndMessage.put("roomId", roomId); + gameEndMessage.put("userId", "SYSTEM"); + gameEndMessage.put("content", "⏰ 시간 초과! " + message); + gameEndMessage.put("messageType", MessageType.GAME_END.getCode()); + gameEndMessage.put("createdAt", now); + gameEndMessage.put("timestamp", System.currentTimeMillis()); + gameEndMessage.put("reason", "TIME_EXPIRED"); + + List connections = connectionRepository.findByRoomId(roomId); + String broadcastPayload = ResponseGenerator.gson().toJson(gameEndMessage); + broadcaster.broadcast(connections, broadcastPayload); + + logger.info("Game end broadcasted: roomId={}, connections={}", roomId, connections.size()); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameSchedulerClient.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameSchedulerClient.java new file mode 100644 index 00000000..1ececf65 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameSchedulerClient.java @@ -0,0 +1,139 @@ +package com.mzc.secondproject.serverless.domain.chatting.service; + +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.chatting.config.GameConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.scheduler.SchedulerClient; +import software.amazon.awssdk.services.scheduler.model.*; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; + +/** + * EventBridge Scheduler를 사용한 게임 자동 종료 스케줄링 + */ +public class GameSchedulerClient { + + private static final Logger logger = LoggerFactory.getLogger(GameSchedulerClient.class); + + private static final String SCHEDULE_GROUP = "game-auto-close"; + private static final String SCHEDULE_NAME_PREFIX = "game-close-"; + + private final SchedulerClient schedulerClient; + private final String targetLambdaArn; + private final String roleArn; + + public GameSchedulerClient() { + this.schedulerClient = SchedulerClient.create(); + this.targetLambdaArn = EnvConfig.getOrDefault("GAME_AUTO_CLOSE_LAMBDA_ARN", null); + this.roleArn = EnvConfig.getOrDefault("SCHEDULER_ROLE_ARN", null); + } + + /** + * 게임 자동 종료 스케줄 생성 + * + * @param gameSessionId 게임 세션 ID + * @param roomId 방 ID + * @return 스케줄 ARN (실패 시 null) + */ + public ScheduleResult createGameEndSchedule(String gameSessionId, String roomId) { + if (targetLambdaArn == null || roleArn == null) { + logger.warn("Scheduler not configured: GAME_AUTO_CLOSE_LAMBDA_ARN or SCHEDULER_ROLE_ARN not set"); + return new ScheduleResult(null, 0L); + } + + try { + // 7분 후 시간 계산 + long scheduledAtMs = System.currentTimeMillis() + (GameConfig.gameTimeLimit() * 1000L); + Instant scheduledAt = Instant.ofEpochMilli(scheduledAtMs); + + // at() 표현식: at(yyyy-mm-ddThh:mm:ss) + String atExpression = "at(" + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss") + .withZone(ZoneOffset.UTC) + .format(scheduledAt) + ")"; + + String scheduleName = SCHEDULE_NAME_PREFIX + gameSessionId; + + // Lambda 호출 시 전달할 페이로드 + String payload = String.format("{\"gameSessionId\":\"%s\",\"roomId\":\"%s\"}", gameSessionId, roomId); + + CreateScheduleRequest request = CreateScheduleRequest.builder() + .name(scheduleName) + .groupName(SCHEDULE_GROUP) + .scheduleExpression(atExpression) + .scheduleExpressionTimezone("UTC") + .flexibleTimeWindow(FlexibleTimeWindow.builder() + .mode(FlexibleTimeWindowMode.OFF) + .build()) + .target(Target.builder() + .arn(targetLambdaArn) + .roleArn(roleArn) + .input(payload) + .build()) + .actionAfterCompletion(ActionAfterCompletion.DELETE) // 실행 후 자동 삭제 + .build(); + + CreateScheduleResponse response = schedulerClient.createSchedule(request); + + logger.info("Game end schedule created: gameSessionId={}, scheduledAt={}, arn={}", + gameSessionId, scheduledAt, response.scheduleArn()); + + return new ScheduleResult(response.scheduleArn(), scheduledAtMs); + + } catch (ConflictException e) { + logger.warn("Schedule already exists: gameSessionId={}", gameSessionId); + return new ScheduleResult(null, 0L); + + } catch (Exception e) { + logger.error("Failed to create game end schedule: gameSessionId={}, error={}", + gameSessionId, e.getMessage()); + return new ScheduleResult(null, 0L); + } + } + + /** + * 게임 자동 종료 스케줄 취소 + * + * @param gameSessionId 게임 세션 ID + * @return 취소 성공 여부 + */ + public boolean cancelGameEndSchedule(String gameSessionId) { + if (targetLambdaArn == null) { + return true; // 스케줄러 미설정 시 무시 + } + + try { + String scheduleName = SCHEDULE_NAME_PREFIX + gameSessionId; + + DeleteScheduleRequest request = DeleteScheduleRequest.builder() + .name(scheduleName) + .groupName(SCHEDULE_GROUP) + .build(); + + schedulerClient.deleteSchedule(request); + + logger.info("Game end schedule cancelled: gameSessionId={}", gameSessionId); + return true; + + } catch (ResourceNotFoundException e) { + logger.debug("Schedule not found (may have already executed): gameSessionId={}", gameSessionId); + return true; // 이미 삭제되었거나 없는 경우 + + } catch (Exception e) { + logger.error("Failed to cancel game end schedule: gameSessionId={}, error={}", + gameSessionId, e.getMessage()); + return false; + } + } + + /** + * 스케줄 생성 결과 + */ + public record ScheduleResult(String scheduleArn, long scheduledAtMs) { + public boolean success() { + return scheduleArn != null; + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java index ee8d92b9..031892e3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java @@ -36,6 +36,7 @@ public class GameService { private final GameSessionRepository gameSessionRepository; private final WordRepository wordRepository; private final GameStatsService gameStatsService; + private final GameSchedulerClient gameSchedulerClient; /** * 기본 생성자 (Lambda에서 사용) @@ -43,7 +44,7 @@ public class GameService { public GameService() { this(new ChatRoomRepository(), new ConnectionRepository(), new GameRoundRepository(), new GameSessionRepository(), - new WordRepository(), new GameStatsService()); + new WordRepository(), new GameStatsService(), new GameSchedulerClient()); } /** @@ -51,13 +52,15 @@ public GameService() { */ public GameService(ChatRoomRepository chatRoomRepository, ConnectionRepository connectionRepository, GameRoundRepository gameRoundRepository, GameSessionRepository gameSessionRepository, - WordRepository wordRepository, GameStatsService gameStatsService) { + WordRepository wordRepository, GameStatsService gameStatsService, + GameSchedulerClient gameSchedulerClient) { this.chatRoomRepository = chatRoomRepository; this.connectionRepository = connectionRepository; this.gameRoundRepository = gameRoundRepository; this.gameSessionRepository = gameSessionRepository; this.wordRepository = wordRepository; this.gameStatsService = gameStatsService; + this.gameSchedulerClient = gameSchedulerClient; } /** @@ -129,6 +132,14 @@ public GameStartResult startGame(String roomId, String userId) { gameSessionRepository.save(session); + // 게임 자동 종료 스케줄 생성 (7분 후) + GameSchedulerClient.ScheduleResult scheduleResult = gameSchedulerClient.createGameEndSchedule(gameSessionId, roomId); + if (scheduleResult.success()) { + session.setScheduleRuleArn(scheduleResult.scheduleArn()); + session.setGameEndScheduledAt(scheduleResult.scheduledAtMs()); + gameSessionRepository.save(session); + } + // ChatRoom에 활성 게임 세션 ID 연결 room.setActiveGameSessionId(gameSessionId); chatRoomRepository.save(room); @@ -436,6 +447,11 @@ private CommandResult finishGame(GameSession session, ChatRoom room, String reas long currentTime = System.currentTimeMillis(); long ttlSeconds = Instant.now().plusSeconds(30 * 24 * 60 * 60).getEpochSecond(); // 30일 보관 + // 자동 종료 스케줄 취소 (TIME_EXPIRED가 아닌 경우에만) + if (!"TIME_EXPIRED".equals(reason)) { + gameSchedulerClient.cancelGameEndSchedule(session.getGameSessionId()); + } + // 게임 세션 종료 처리 gameSessionRepository.finishGame(session.getGameSessionId(), currentTime, ttlSeconds); @@ -479,6 +495,34 @@ private CommandResult finishGame(GameSession session, ChatRoom room, String reas return CommandResult.success(MessageType.GAME_END, sb.toString(), session.getScores()); } + /** + * 시간 만료로 인한 게임 자동 종료 (GameAutoCloseHandler에서 호출) + */ + public CommandResult finishGameByTimeout(String gameSessionId) { + GameSession session = gameSessionRepository.findById(gameSessionId).orElse(null); + if (session == null) { + logger.warn("Game session not found for auto-close: {}", gameSessionId); + return CommandResult.error("게임 세션을 찾을 수 없습니다."); + } + + // 이미 종료된 게임이면 무시 + if (!session.isActive()) { + logger.info("Game already finished, skipping auto-close: {}", gameSessionId); + return CommandResult.error("이미 종료된 게임입니다."); + } + + ChatRoom room = chatRoomRepository.findById(session.getRoomId()).orElse(null); + if (room == null) { + logger.warn("Room not found for auto-close: {}", session.getRoomId()); + return CommandResult.error("채팅방을 찾을 수 없습니다."); + } + + logger.info("Auto-closing game due to time expiration: sessionId={}, roomId={}", + gameSessionId, session.getRoomId()); + + return finishGame(session, room, "TIME_EXPIRED"); + } + /** * 접속 중인 사용자 중에서 다음 출제자 선택 */ diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 35cde30e..0202714b 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -277,14 +277,31 @@ Resources: Environment: Variables: 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: - DynamoDBCrudPolicy: TableName: !Ref ChatTable + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable - Statement: - Effect: Allow Action: - execute-api:ManageConnections Resource: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/* + # EventBridge Scheduler 권한 + - Statement: + - Effect: Allow + Action: + - scheduler:CreateSchedule + - scheduler:DeleteSchedule + - scheduler:GetSchedule + Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/game-auto-close/*" + - Statement: + - Effect: Allow + Action: + - iam:PassRole + Resource: !GetAtt GameSchedulerRole.Arn WebSocketMessagePermission: Type: AWS::Lambda::Permission @@ -410,6 +427,8 @@ Resources: Environment: Variables: 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: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -418,6 +437,19 @@ Resources: Action: - execute-api:ManageConnections Resource: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/*" + # EventBridge Scheduler 권한 + - Statement: + - Effect: Allow + Action: + - scheduler:CreateSchedule + - scheduler:DeleteSchedule + - scheduler:GetSchedule + Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/game-auto-close/*" + - Statement: + - Effect: Allow + Action: + - iam:PassRole + Resource: !GetAtt GameSchedulerRole.Arn Events: StartGame: Type: Api @@ -452,6 +484,58 @@ Resources: Auth: Authorizer: CognitoAuthorizer + # 게임 자동 종료 Lambda (EventBridge Scheduler에 의해 호출) + GameAutoCloseFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-game-auto-close + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.chatting.handler.GameAutoCloseHandler::handleRequest + Description: Auto-close game after 7 minutes + Timeout: 30 + MemorySize: 512 + SnapStart: + ApplyOn: PublishedVersions + Environment: + Variables: + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ChatTable + - Statement: + - Effect: Allow + Action: + - execute-api:ManageConnections + Resource: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/*" + + # EventBridge Scheduler가 Lambda를 호출할 수 있는 IAM Role + GameSchedulerRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Sub "${AWS::StackName}-game-scheduler-role" + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: scheduler.amazonaws.com + Action: sts:AssumeRole + Policies: + - PolicyName: InvokeGameAutoCloseLambda + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - lambda:InvokeFunction + Resource: !GetAtt GameAutoCloseFunction.Arn + + # EventBridge Schedule Group + GameScheduleGroup: + Type: AWS::Scheduler::ScheduleGroup + Properties: + Name: game-auto-close + ChatMessageFunction: Type: AWS::Serverless::Function Properties: From 4f49838e776b89904c41e436db3ec05c721d7dd0 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Tue, 20 Jan 2026 18:01:04 +0900 Subject: [PATCH 11/99] =?UTF-8?q?fix=20:=20=EB=A9=94=EB=AA=A8=EB=A6=AC=20?= =?UTF-8?q?=EC=A6=9D=EA=B0=80=20=EB=B0=8F=20Lambda=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=20=EC=8B=9C=EA=B0=84=20Cognito=20=ED=8A=B8?= =?UTF-8?q?=EB=A6=AC=EA=B1=B0=20=EC=A0=9C=ED=95=9C=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=EA=B3=BC=20=EB=8F=99=EC=9D=BC=ED=95=98=EA=B2=8C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#439)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ServerlessFunction/template.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 0202714b..b6b087e8 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -110,6 +110,8 @@ Resources: CodeUri: . Handler: com.mzc.secondproject.serverless.domain.user.handler.PostConfirmationHandler::handleRequest Description: Handle user registration - save to DynamoDB + MemorySize: 1024 + Timeout: 5 SnapStart: ApplyOn: PublishedVersions Policies: From 05e86273c3545b3befbe443430ad4029cecaaf15 Mon Sep 17 00:00:00 2001 From: DDING JOO Date: Tue, 20 Jan 2026 21:26:38 +0900 Subject: [PATCH 12/99] =?UTF-8?q?feat:=20=EC=BA=90=EC=B9=98=EB=A7=88?= =?UTF-8?q?=EC=9D=B8=EB=93=9C=20=EA=B2=8C=EC=9E=84=20=EB=B0=A9=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#455)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 --- .../common/util/WebSocketMessageHelper.java | 57 +++++++++++++++++++ .../dto/request/CreateRoomRequest.java | 10 +++- .../domain/chatting/enums/MessageType.java | 6 +- .../domain/chatting/enums/RoomStatus.java | 39 +++++++++++++ .../domain/chatting/enums/RoomType.java | 38 +++++++++++++ .../chatting/exception/ChattingErrorCode.java | 3 + .../chatting/handler/ChatRoomHandler.java | 28 +++++---- .../domain/chatting/handler/GameHandler.java | 20 +++++++ .../domain/chatting/model/ChatRoom.java | 8 ++- .../domain/chatting/model/GameSettings.java | 21 +++++++ .../service/ChatRoomCommandService.java | 50 +++++++++++----- .../service/ChatRoomQueryService.java | 22 ++++++- .../domain/chatting/service/GameService.java | 39 +++++++++++++ .../exception/ChattingErrorCodeSpec.groovy | 12 +++- .../domain/chatting/enums/RoomStatusTest.java | 44 ++++++++++++++ .../domain/chatting/enums/RoomTypeTest.java | 38 +++++++++++++ .../chatting/model/GameSettingsTest.java | 54 ++++++++++++++++++ 17 files changed, 455 insertions(+), 34 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatus.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomType.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java create mode 100644 ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatusTest.java create mode 100644 ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomTypeTest.java create mode 100644 ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettingsTest.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketMessageHelper.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketMessageHelper.java index 56a7f2b4..7cbf5602 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketMessageHelper.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketMessageHelper.java @@ -13,6 +13,7 @@ public final class WebSocketMessageHelper { public static final String DOMAIN_CHAT = "chat"; public static final String DOMAIN_GAME = "game"; + public static final String DOMAIN_ROOM = "room"; private WebSocketMessageHelper() { } @@ -106,4 +107,60 @@ public static Map buildGameMessage( public static Map buildSystemMessage(String roomId, String content, String messageType) { return buildChatMessage(roomId, "SYSTEM", content, messageType); } + + /** + * 방 상태 변경 메시지 생성 + * + * @param roomId 방 ID + * @param status 현재 상태 + * @param previousStatus 이전 상태 + * @return 방 상태 변경 메시지 + */ + public static Map buildRoomStatusChangeMessage( + String roomId, + String status, + String previousStatus + ) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + Map message = new HashMap<>(); + message.put("domain", DOMAIN_ROOM); + message.put("messageType", "room_status_change"); + message.put("messageId", messageId); + message.put("roomId", roomId); + message.put("status", status); + message.put("previousStatus", previousStatus); + message.put("createdAt", now); + message.put("timestamp", System.currentTimeMillis()); + return message; + } + + /** + * 방장 변경 메시지 생성 + * + * @param roomId 방 ID + * @param newHostId 새 방장 ID + * @param newHostNickname 새 방장 닉네임 + * @return 방장 변경 메시지 + */ + public static Map buildHostChangeMessage( + String roomId, + String newHostId, + String newHostNickname + ) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + Map message = new HashMap<>(); + message.put("domain", DOMAIN_ROOM); + message.put("messageType", "host_change"); + message.put("messageId", messageId); + message.put("roomId", roomId); + message.put("newHostId", newHostId); + message.put("newHostNickname", newHostNickname); + message.put("createdAt", now); + message.put("timestamp", System.currentTimeMillis()); + return message; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java index 255d6fc1..58316901 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java @@ -1,5 +1,6 @@ package com.mzc.secondproject.serverless.domain.chatting.dto.request; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSettings; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; @@ -32,6 +33,13 @@ public class CreateRoomRequest { @Builder.Default private Boolean isPrivate = false; - + private String password; + + @Builder.Default + private String type = "CHAT"; // CHAT or GAME + + private String gameType; // CATCHMIND (nullable) + + private GameSettings gameSettings; // 게임 설정 (nullable) } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java index 28627177..a7433637 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java @@ -18,7 +18,11 @@ public enum MessageType { CORRECT_ANSWER("correct_answer", "정답"), SCORE_UPDATE("score_update", "점수 업데이트"), SYSTEM_COMMAND("system_command", "시스템 명령"), - HINT("hint", "힌트"); + HINT("hint", "힌트"), + + // 방 관련 메시지 타입 + ROOM_STATUS_CHANGE("room_status_change", "방 상태 변경"), + HOST_CHANGE("host_change", "방장 변경"); private final String code; private final String displayName; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatus.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatus.java new file mode 100644 index 00000000..6cfbd65b --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatus.java @@ -0,0 +1,39 @@ +package com.mzc.secondproject.serverless.domain.chatting.enums; + +import java.util.Arrays; + +public enum RoomStatus { + WAITING("waiting", "대기 중"), + PLAYING("playing", "게임 중"), + FINISHED("finished", "종료됨"); + + private final String code; + private final String displayName; + + RoomStatus(String code, String displayName) { + this.code = code; + this.displayName = displayName; + } + + public static boolean isValid(String value) { + if (value == null) return false; + return Arrays.stream(values()) + .anyMatch(status -> status.name().equalsIgnoreCase(value) || status.code.equalsIgnoreCase(value)); + } + + public static RoomStatus fromString(String value) { + if (value == null) return WAITING; + return Arrays.stream(values()) + .filter(status -> status.name().equalsIgnoreCase(value) || status.code.equalsIgnoreCase(value)) + .findFirst() + .orElse(WAITING); + } + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomType.java new file mode 100644 index 00000000..8848b449 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomType.java @@ -0,0 +1,38 @@ +package com.mzc.secondproject.serverless.domain.chatting.enums; + +import java.util.Arrays; + +public enum RoomType { + CHAT("chat", "채팅방"), + GAME("game", "게임방"); + + private final String code; + private final String displayName; + + RoomType(String code, String displayName) { + this.code = code; + this.displayName = displayName; + } + + public static boolean isValid(String value) { + if (value == null) return false; + return Arrays.stream(values()) + .anyMatch(type -> type.name().equalsIgnoreCase(value) || type.code.equalsIgnoreCase(value)); + } + + public static RoomType fromString(String value) { + if (value == null) return CHAT; + return Arrays.stream(values()) + .filter(type -> type.name().equalsIgnoreCase(value) || type.code.equalsIgnoreCase(value)) + .findFirst() + .orElse(CHAT); + } + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java index 5a091aff..ad599b53 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java @@ -41,6 +41,9 @@ public enum ChattingErrorCode implements DomainErrorCode { GAME_ALREADY_IN_PROGRESS("GAME_004", "이미 게임이 진행 중입니다", 409), NOT_GAME_STARTER("GAME_005", "게임 시작자만 중단할 수 있습니다", 403), GAME_NOT_FOUND("GAME_006", "게임 세션을 찾을 수 없습니다", 404), + GAME_NOT_ALLOWED_IN_CHAT_ROOM("GAME_007", "게임은 게임 방에서만 시작할 수 있습니다", 400), + GAME_RESTART_NOT_ALLOWED("GAME_008", "게임 진행 중에는 재시작할 수 없습니다", 400), + GAME_START_NOT_HOST("GAME_009", "방장만 게임을 시작할 수 있습니다", 403), ; private static final String DOMAIN = "CHATTING"; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java index 0edcf0a9..13f6447c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java @@ -57,46 +57,50 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re private APIGatewayProxyResponseEvent createRoom(APIGatewayProxyRequestEvent request, String userId) { CreateRoomRequest req = ResponseGenerator.gson().fromJson(request.getBody(), CreateRoomRequest.class); - + return BeanValidator.validateAndExecute(req, dto -> { String level = dto.getLevel() != null ? dto.getLevel() : "beginner"; Integer maxMembers = dto.getMaxMembers() != null ? dto.getMaxMembers() : 6; Boolean isPrivate = dto.getIsPrivate() != null ? dto.getIsPrivate() : false; - + ChatRoom room = commandService.createRoom( - dto.getName(), dto.getDescription(), level, maxMembers, isPrivate, dto.getPassword(), userId); + dto.getName(), dto.getDescription(), level, maxMembers, isPrivate, dto.getPassword(), userId, + dto.getType(), dto.getGameType(), dto.getGameSettings()); room.setPassword(null); - + return ResponseGenerator.created("Room created", room); }); } private APIGatewayProxyResponseEvent getRooms(APIGatewayProxyRequestEvent request, String userId) { Map queryParams = request.getQueryStringParameters(); - + String level = queryParams != null ? queryParams.get("level") : null; String joined = queryParams != null ? queryParams.get("joined") : null; String cursor = queryParams != null ? queryParams.get("cursor") : null; - + String type = queryParams != null ? queryParams.get("type") : null; + String gameType = queryParams != null ? queryParams.get("gameType") : null; + String status = queryParams != null ? queryParams.get("status") : null; + int limit = 10; if (queryParams != null && queryParams.get("limit") != null) { limit = Math.min(Integer.parseInt(queryParams.get("limit")), 20); } - - PaginatedResult roomPage = queryService.getRooms(level, limit, cursor); + + PaginatedResult roomPage = queryService.getRooms(level, limit, cursor, type, gameType, status); List rooms = roomPage.items(); - + if ("true".equals(joined)) { rooms = queryService.filterByJoinedUser(rooms, userId); } - + rooms.forEach(room -> room.setPassword(null)); - + Map result = new HashMap<>(); result.put("rooms", rooms); result.put("nextCursor", roomPage.nextCursor()); result.put("hasMore", roomPage.hasMore()); - + return ResponseGenerator.ok("Rooms retrieved", result); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java index b95a369e..f72bd0dc 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java @@ -50,6 +50,7 @@ private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( Route.postAuth("/rooms/{roomId}/game/start", this::startGame), Route.postAuth("/rooms/{roomId}/game/stop", this::stopGame), + Route.postAuth("/rooms/{roomId}/game/restart", this::restartGame), Route.getAuth("/rooms/{roomId}/game/status", this::getGameStatus), Route.getAuth("/rooms/{roomId}/game/scores", this::getScores) ); @@ -98,6 +99,25 @@ private APIGatewayProxyResponseEvent stopGame(APIGatewayProxyRequestEvent reques return ResponseGenerator.ok("Game stopped", Map.of("message", result.message())); } + /** + * POST /rooms/{roomId}/game/restart - 게임 재시작 + */ + private APIGatewayProxyResponseEvent restartGame(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + GameService.GameStartResult result = gameService.restartGame(roomId, userId); + + if (!result.success()) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, result.error()); + } + + // WebSocket으로 게임 시작 알림 브로드캐스트 + broadcastGameStart(roomId, result); + + GameStatusResponse response = GameStatusResponse.from(result.session()); + return ResponseGenerator.ok("Game restarted", response); + } + /** * GET /rooms/{roomId}/game/status - 게임 상태 조회 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java index 0c076da4..44c38d39 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java @@ -36,7 +36,13 @@ public class ChatRoom { // 게임 세션 참조 (게임 상태는 GameSession으로 분리됨) private String activeGameSessionId; // 현재 진행중인 게임 세션 ID (nullable) - + + private String type; // CHAT, GAME (기본값: CHAT) + private String gameType; // CATCHMIND (nullable, GAME 타입일 때만) + private GameSettings gameSettings; // 게임 설정 (nullable) + private String status; // WAITING, PLAYING, FINISHED (기본값: WAITING) + private String hostId; // 방장 userId (createdBy와 별도 관리) + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java new file mode 100644 index 00000000..d7d9b9cf --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java @@ -0,0 +1,21 @@ +package com.mzc.secondproject.serverless.domain.chatting.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GameSettings { + @Builder.Default + private Integer maxRounds = 5; + + @Builder.Default + private Integer roundTimeLimit = 60; + + @Builder.Default + private Boolean autoDeleteOnEnd = false; +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java index b278c8b9..bce523c7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java @@ -3,6 +3,7 @@ import com.mzc.secondproject.serverless.domain.chatting.dto.response.JoinRoomResponse; import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingException; import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSettings; import com.mzc.secondproject.serverless.domain.chatting.model.RoomToken; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; import org.mindrot.jbcrypt.BCrypt; @@ -31,10 +32,11 @@ public ChatRoomCommandService() { } public ChatRoom createRoom(String name, String description, String level, Integer maxMembers, - Boolean isPrivate, String password, String createdBy) { + Boolean isPrivate, String password, String createdBy, + String type, String gameType, GameSettings gameSettings) { String roomId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + ChatRoom room = ChatRoom.builder() .pk("ROOM#" + roomId) .sk("METADATA") @@ -52,11 +54,16 @@ public ChatRoom createRoom(String name, String description, String level, Intege .createdAt(now) .lastMessageAt(now) .memberIds(new ArrayList<>(List.of(createdBy))) + .type(type != null ? type : "CHAT") + .gameType(gameType) + .gameSettings(gameSettings) + .status("WAITING") + .hostId(createdBy) .build(); - + roomRepository.save(room); logger.info("Created room: {}", roomId); - + return room; } @@ -102,24 +109,39 @@ public LeaveResult leaveRoom(String roomId, String userId) { if (optRoom.isEmpty()) { throw ChattingException.roomNotFound(roomId); } - + ChatRoom room = optRoom.get(); - + if (room.getMemberIds() != null) { room.getMemberIds().remove(userId); room.setCurrentMembers(Math.max(0, room.getCurrentMembers() - 1)); } - - if (userId.equals(room.getCreatedBy()) || room.getCurrentMembers() <= 0) { + + // 모든 참가자가 나갔으면 방 삭제 + if (room.getCurrentMembers() <= 0 || + (room.getMemberIds() != null && room.getMemberIds().isEmpty())) { roomRepository.delete(roomId); - logger.info("Room {} deleted (owner left or empty)", roomId); - return new LeaveResult(true, null); + logger.info("Room {} deleted (empty)", roomId); + return new LeaveResult(true, null, null); } - + + // 방장이 나갔으면 다음 멤버에게 방장 이전 + String oldHostId = room.getHostId() != null ? room.getHostId() : room.getCreatedBy(); + String newHostId = null; + + if (userId.equals(oldHostId)) { + // 첫 번째 남은 멤버가 새 방장 + if (room.getMemberIds() != null && !room.getMemberIds().isEmpty()) { + newHostId = room.getMemberIds().get(0); + room.setHostId(newHostId); + logger.info("Host transferred from {} to {} in room {}", oldHostId, newHostId, roomId); + } + } + roomRepository.save(room); logger.info("User {} left room {}", userId, roomId); - - return new LeaveResult(false, room); + + return new LeaveResult(false, room, newHostId); } public void deleteRoom(String roomId, String userId) { @@ -137,6 +159,6 @@ public void deleteRoom(String roomId, String userId) { logger.info("Deleted room: {} by owner: {}", roomId, userId); } - public record LeaveResult(boolean deleted, ChatRoom room) { + public record LeaveResult(boolean deleted, ChatRoom room, String newHostId) { } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java index d99657db..5a5ed0df 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java @@ -26,11 +26,27 @@ public Optional getRoom(String roomId) { return roomRepository.findById(roomId); } - public PaginatedResult getRooms(String level, int limit, String cursor) { + public PaginatedResult getRooms(String level, int limit, String cursor, String type, String gameType, String status) { + PaginatedResult roomPage; if (level != null && !level.isEmpty()) { - return roomRepository.findByLevelWithPagination(level, limit, cursor); + roomPage = roomRepository.findByLevelWithPagination(level, limit, cursor); + } else { + roomPage = roomRepository.findAllWithPagination(limit, cursor); } - return roomRepository.findAllWithPagination(limit, cursor); + + List rooms = roomPage.items(); + + if (type != null) { + rooms = rooms.stream().filter(r -> type.equalsIgnoreCase(r.getType())).toList(); + } + if (gameType != null) { + rooms = rooms.stream().filter(r -> gameType.equalsIgnoreCase(r.getGameType())).toList(); + } + if (status != null) { + rooms = rooms.stream().filter(r -> status.equalsIgnoreCase(r.getStatus())).toList(); + } + + return new PaginatedResult<>(rooms, roomPage.nextCursor()); } public List filterByJoinedUser(List rooms, String userId) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java index 031892e3..accce2e4 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java @@ -63,6 +63,39 @@ public GameService(ChatRoomRepository chatRoomRepository, ConnectionRepository c this.gameSchedulerClient = gameSchedulerClient; } + /** + * 게임 재시작 + */ + public GameStartResult restartGame(String roomId, String userId) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); + + // 방장 권한 확인 + if (!userId.equals(room.getHostId()) && !userId.equals(room.getCreatedBy())) { + return GameStartResult.error("방장만 게임을 시작할 수 있습니다."); + } + + // 방 타입 검증 + if (room.getType() == null || !"GAME".equalsIgnoreCase(room.getType())) { + return GameStartResult.error("게임은 게임 방에서만 시작할 수 있습니다."); + } + + // FINISHED 상태인지 확인 (이미 게임이 끝났어야 재시작 가능) + Optional existingSession = gameSessionRepository.findActiveByRoomId(roomId); + if (existingSession.isPresent()) { + return GameStartResult.error("게임 진행 중에는 재시작할 수 없습니다."); + } + + // 접속자 확인 + List connections = connectionRepository.findByRoomId(roomId); + if (connections.size() < 2) { + return GameStartResult.error("최소 2명 이상 접속해야 게임을 시작할 수 있습니다."); + } + + // 기존 startGame 로직 재사용 - 내부적으로 startGame 호출 + return startGame(roomId, userId); + } + /** * 게임 시작 */ @@ -70,6 +103,12 @@ public GameStartResult startGame(String roomId, String userId) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); + // 방 타입 검증 - GAME 타입만 게임 시작 가능 + String roomType = room.getType(); + if (roomType == null || !"GAME".equalsIgnoreCase(roomType)) { + return GameStartResult.error("게임은 게임 방에서만 시작할 수 있습니다."); + } + // 이미 활성 게임 세션이 있는지 확인 Optional existingSession = gameSessionRepository.findActiveByRoomId(roomId); if (existingSession.isPresent()) { diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy index 66742acb..df2855f6 100644 --- a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy @@ -40,11 +40,15 @@ class ChattingErrorCodeSpec extends Specification { ChattingErrorCode.GAME_NOT_IN_PROGRESS | "GAME_003" | 400 ChattingErrorCode.GAME_ALREADY_IN_PROGRESS| "GAME_004" | 409 ChattingErrorCode.NOT_GAME_STARTER | "GAME_005" | 403 + ChattingErrorCode.GAME_NOT_FOUND | "GAME_006" | 404 + ChattingErrorCode.GAME_NOT_ALLOWED_IN_CHAT_ROOM | "GAME_007" | 400 + ChattingErrorCode.GAME_RESTART_NOT_ALLOWED | "GAME_008" | 400 + ChattingErrorCode.GAME_START_NOT_HOST | "GAME_009" | 403 } def "모든 에러 코드 개수 확인"() { - expect: "20개의 에러 코드 존재" - ChattingErrorCode.values().length == 20 + expect: "24개의 에러 코드 존재" + ChattingErrorCode.values().length == 24 } def "채팅방 관련 에러 코드들 (ROOM_XXX)"() { @@ -71,5 +75,9 @@ class ChattingErrorCodeSpec extends Specification { ChattingErrorCode.GAME_NOT_IN_PROGRESS.getCode().startsWith("GAME_") ChattingErrorCode.GAME_ALREADY_IN_PROGRESS.getCode().startsWith("GAME_") ChattingErrorCode.NOT_GAME_STARTER.getCode().startsWith("GAME_") + ChattingErrorCode.GAME_NOT_FOUND.getCode().startsWith("GAME_") + ChattingErrorCode.GAME_NOT_ALLOWED_IN_CHAT_ROOM.getCode().startsWith("GAME_") + ChattingErrorCode.GAME_RESTART_NOT_ALLOWED.getCode().startsWith("GAME_") + ChattingErrorCode.GAME_START_NOT_HOST.getCode().startsWith("GAME_") } } diff --git a/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatusTest.java b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatusTest.java new file mode 100644 index 00000000..03c59df5 --- /dev/null +++ b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatusTest.java @@ -0,0 +1,44 @@ +package com.mzc.secondproject.serverless.domain.chatting.enums; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class RoomStatusTest { + @Test + void testFromString() { + assertEquals(RoomStatus.WAITING, RoomStatus.fromString("waiting")); + assertEquals(RoomStatus.WAITING, RoomStatus.fromString("WAITING")); + assertEquals(RoomStatus.PLAYING, RoomStatus.fromString("playing")); + assertEquals(RoomStatus.PLAYING, RoomStatus.fromString("PLAYING")); + assertEquals(RoomStatus.FINISHED, RoomStatus.fromString("finished")); + assertEquals(RoomStatus.FINISHED, RoomStatus.fromString("FINISHED")); + assertEquals(RoomStatus.WAITING, RoomStatus.fromString(null)); + assertEquals(RoomStatus.WAITING, RoomStatus.fromString("invalid")); + } + + @Test + void testIsValid() { + assertTrue(RoomStatus.isValid("WAITING")); + assertTrue(RoomStatus.isValid("waiting")); + assertTrue(RoomStatus.isValid("PLAYING")); + assertTrue(RoomStatus.isValid("playing")); + assertTrue(RoomStatus.isValid("FINISHED")); + assertTrue(RoomStatus.isValid("finished")); + assertFalse(RoomStatus.isValid(null)); + assertFalse(RoomStatus.isValid("invalid")); + } + + @Test + void testGetCode() { + assertEquals("waiting", RoomStatus.WAITING.getCode()); + assertEquals("playing", RoomStatus.PLAYING.getCode()); + assertEquals("finished", RoomStatus.FINISHED.getCode()); + } + + @Test + void testGetDisplayName() { + assertEquals("대기 중", RoomStatus.WAITING.getDisplayName()); + assertEquals("게임 중", RoomStatus.PLAYING.getDisplayName()); + assertEquals("종료됨", RoomStatus.FINISHED.getDisplayName()); + } +} diff --git a/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomTypeTest.java b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomTypeTest.java new file mode 100644 index 00000000..7d8d13db --- /dev/null +++ b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomTypeTest.java @@ -0,0 +1,38 @@ +package com.mzc.secondproject.serverless.domain.chatting.enums; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class RoomTypeTest { + @Test + void testFromString() { + assertEquals(RoomType.CHAT, RoomType.fromString("chat")); + assertEquals(RoomType.CHAT, RoomType.fromString("CHAT")); + assertEquals(RoomType.GAME, RoomType.fromString("game")); + assertEquals(RoomType.GAME, RoomType.fromString("GAME")); + assertEquals(RoomType.CHAT, RoomType.fromString(null)); + assertEquals(RoomType.CHAT, RoomType.fromString("invalid")); + } + + @Test + void testIsValid() { + assertTrue(RoomType.isValid("CHAT")); + assertTrue(RoomType.isValid("chat")); + assertTrue(RoomType.isValid("GAME")); + assertTrue(RoomType.isValid("game")); + assertFalse(RoomType.isValid(null)); + assertFalse(RoomType.isValid("invalid")); + } + + @Test + void testGetCode() { + assertEquals("chat", RoomType.CHAT.getCode()); + assertEquals("game", RoomType.GAME.getCode()); + } + + @Test + void testGetDisplayName() { + assertEquals("채팅방", RoomType.CHAT.getDisplayName()); + assertEquals("게임방", RoomType.GAME.getDisplayName()); + } +} diff --git a/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettingsTest.java b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettingsTest.java new file mode 100644 index 00000000..e762e335 --- /dev/null +++ b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettingsTest.java @@ -0,0 +1,54 @@ +package com.mzc.secondproject.serverless.domain.chatting.model; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class GameSettingsTest { + @Test + void testDefaultValues() { + GameSettings settings = GameSettings.builder().build(); + assertEquals(5, settings.getMaxRounds()); + assertEquals(60, settings.getRoundTimeLimit()); + assertFalse(settings.getAutoDeleteOnEnd()); + } + + @Test + void testCustomValues() { + GameSettings settings = GameSettings.builder() + .maxRounds(10) + .roundTimeLimit(90) + .autoDeleteOnEnd(true) + .build(); + assertEquals(10, settings.getMaxRounds()); + assertEquals(90, settings.getRoundTimeLimit()); + assertTrue(settings.getAutoDeleteOnEnd()); + } + + @Test + void testNoArgsConstructor() { + GameSettings settings = new GameSettings(); + assertEquals(5, settings.getMaxRounds()); + assertEquals(60, settings.getRoundTimeLimit()); + assertFalse(settings.getAutoDeleteOnEnd()); + } + + @Test + void testAllArgsConstructor() { + GameSettings settings = new GameSettings(10, 90, true); + assertEquals(10, settings.getMaxRounds()); + assertEquals(90, settings.getRoundTimeLimit()); + assertTrue(settings.getAutoDeleteOnEnd()); + } + + @Test + void testSettersAndGetters() { + GameSettings settings = new GameSettings(); + settings.setMaxRounds(8); + settings.setRoundTimeLimit(120); + settings.setAutoDeleteOnEnd(true); + + assertEquals(8, settings.getMaxRounds()); + assertEquals(120, settings.getRoundTimeLimit()); + assertTrue(settings.getAutoDeleteOnEnd()); + } +} From 0f29fb98961a86b355fa6e51df3eeed53cfc3b1e Mon Sep 17 00:00:00 2001 From: DDING JOO Date: Tue, 20 Jan 2026 21:32:05 +0900 Subject: [PATCH 13/99] =?UTF-8?q?feat:=20=EC=B0=B8=EA=B0=80=EC=9E=90=20?= =?UTF-8?q?=EB=8B=89=EB=84=A4=EC=9E=84=20=EB=B0=8F=20=EB=B0=A9=EC=9E=A5=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20WebSocket=20=EC=95=8C=EB=A6=BC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#456)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 --- .../dto/response/RoomParticipant.java | 16 +++++++ .../chatting/handler/ChatRoomHandler.java | 18 +++++-- .../service/ChatRoomCommandService.java | 38 ++++++++++++++- .../service/ChatRoomQueryService.java | 47 ++++++++++++++++++- 4 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomParticipant.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomParticipant.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomParticipant.java new file mode 100644 index 00000000..146fa3dc --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomParticipant.java @@ -0,0 +1,16 @@ +package com.mzc.secondproject.serverless.domain.chatting.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RoomParticipant { + private String userId; + private String nickname; + private Boolean isHost; +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java index 13f6447c..6a5769d5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java @@ -12,6 +12,7 @@ import com.mzc.secondproject.serverless.domain.chatting.dto.request.CreateRoomRequest; import com.mzc.secondproject.serverless.domain.chatting.dto.request.JoinRoomRequest; import com.mzc.secondproject.serverless.domain.chatting.dto.response.JoinRoomResponse; +import com.mzc.secondproject.serverless.domain.chatting.dto.response.RoomParticipant; import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingErrorCode; import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.domain.chatting.service.ChatRoomCommandService; @@ -106,16 +107,25 @@ private APIGatewayProxyResponseEvent getRooms(APIGatewayProxyRequestEvent reques private APIGatewayProxyResponseEvent getRoom(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - + Optional optRoom = queryService.getRoom(roomId); if (optRoom.isEmpty()) { return ResponseGenerator.fail(ChattingErrorCode.ROOM_NOT_FOUND); } - + ChatRoom room = optRoom.get(); room.setPassword(null); - - return ResponseGenerator.ok("Room retrieved", room); + + // 참가자 정보와 방장 닉네임 추가 + List participants = queryService.getParticipantsWithNicknames(room); + String hostNickname = queryService.getHostNickname(room); + + Map result = new HashMap<>(); + result.put("room", room); + result.put("participants", participants); + result.put("hostNickname", hostNickname); + + return ResponseGenerator.ok("Room retrieved", result); } private APIGatewayProxyResponseEvent joinRoom(APIGatewayProxyRequestEvent request, String userId) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java index bce523c7..d1abff8d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java @@ -1,11 +1,18 @@ package com.mzc.secondproject.serverless.domain.chatting.service; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.common.util.WebSocketBroadcaster; +import com.mzc.secondproject.serverless.common.util.WebSocketMessageHelper; import com.mzc.secondproject.serverless.domain.chatting.dto.response.JoinRoomResponse; import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingException; import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; +import com.mzc.secondproject.serverless.domain.chatting.model.Connection; import com.mzc.secondproject.serverless.domain.chatting.model.GameSettings; import com.mzc.secondproject.serverless.domain.chatting.model.RoomToken; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.user.model.User; +import com.mzc.secondproject.serverless.domain.user.repository.UserRepository; import org.mindrot.jbcrypt.BCrypt; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -13,6 +20,7 @@ import java.time.Instant; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -22,13 +30,19 @@ public class ChatRoomCommandService { private static final Logger logger = LoggerFactory.getLogger(ChatRoomCommandService.class); - + private final ChatRoomRepository roomRepository; private final RoomTokenService roomTokenService; - + private final ConnectionRepository connectionRepository; + private final WebSocketBroadcaster broadcaster; + private final UserRepository userRepository; + public ChatRoomCommandService() { this.roomRepository = new ChatRoomRepository(); this.roomTokenService = new RoomTokenService(); + this.connectionRepository = new ConnectionRepository(); + this.broadcaster = new WebSocketBroadcaster(); + this.userRepository = new UserRepository(); } public ChatRoom createRoom(String name, String description, String level, Integer maxMembers, @@ -141,6 +155,26 @@ public LeaveResult leaveRoom(String roomId, String userId) { roomRepository.save(room); logger.info("User {} left room {}", userId, roomId); + // 방장이 나갔으면 다음 멤버에게 방장 이전 후 WebSocket 알림 + if (userId.equals(oldHostId) && newHostId != null) { + // 새 방장 닉네임 조회 + String newHostNickname = userRepository.findByCognitoSub(newHostId) + .map(User::getNickname) + .orElse(newHostId); + + // WebSocket 알림 브로드캐스트 + try { + List connections = connectionRepository.findByRoomId(roomId); + Map message = WebSocketMessageHelper.buildHostChangeMessage( + roomId, newHostId, newHostNickname); + String json = ResponseGenerator.gson().toJson(message); + broadcaster.broadcast(connections, json); + logger.info("Broadcasted host change: roomId={}, newHostId={}", roomId, newHostId); + } catch (Exception e) { + logger.error("Failed to broadcast host change: {}", e.getMessage()); + } + } + return new LeaveResult(false, room, newHostId); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java index 5a5ed0df..06888d42 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java @@ -1,8 +1,11 @@ package com.mzc.secondproject.serverless.domain.chatting.service; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.domain.chatting.dto.response.RoomParticipant; import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; +import com.mzc.secondproject.serverless.domain.user.model.User; +import com.mzc.secondproject.serverless.domain.user.repository.UserRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -15,11 +18,13 @@ public class ChatRoomQueryService { private static final Logger logger = LoggerFactory.getLogger(ChatRoomQueryService.class); - + private final ChatRoomRepository roomRepository; - + private final UserRepository userRepository; + public ChatRoomQueryService() { this.roomRepository = new ChatRoomRepository(); + this.userRepository = new UserRepository(); } public Optional getRoom(String roomId) { @@ -54,4 +59,42 @@ public List filterByJoinedUser(List rooms, String userId) { .filter(room -> room.getMemberIds() != null && room.getMemberIds().contains(userId)) .toList(); } + + /** + * 참가자 목록을 닉네임과 함께 조회 + * + * @param room ChatRoom 객체 + * @return 참가자 목록 (userId, nickname, isHost 포함) + */ + public List getParticipantsWithNicknames(ChatRoom room) { + if (room.getMemberIds() == null) return List.of(); + + String hostId = room.getHostId() != null ? room.getHostId() : room.getCreatedBy(); + + return room.getMemberIds().stream() + .map(userId -> { + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); // fallback to userId if not found + return RoomParticipant.builder() + .userId(userId) + .nickname(nickname) + .isHost(userId.equals(hostId)) + .build(); + }) + .toList(); + } + + /** + * 방장 닉네임 조회 + * + * @param room ChatRoom 객체 + * @return 방장 닉네임 (없으면 userId 반환) + */ + public String getHostNickname(ChatRoom room) { + String hostId = room.getHostId() != null ? room.getHostId() : room.getCreatedBy(); + return userRepository.findByCognitoSub(hostId) + .map(User::getNickname) + .orElse(hostId); + } } From 469cefd65065298ed4dd21367e356505abcc4151 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 21 Jan 2026 09:12:51 +0900 Subject: [PATCH 14/99] =?UTF-8?q?fix:=20ChatRoomFunction=EC=97=90=20UserTa?= =?UTF-8?q?ble=20DynamoDB=20=EA=B6=8C=ED=95=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 --- ServerlessFunction/template.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index b6b087e8..f345866d 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -367,6 +367,8 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable + - DynamoDBReadPolicy: + TableName: !Ref UserTable Events: CreateRoom: Type: Api From 229be3a6f5608014e4f4f4ba4af2847aeac98e54 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 21 Jan 2026 09:21:07 +0900 Subject: [PATCH 15/99] =?UTF-8?q?fix:=20GameSettings=EC=97=90=20@DynamoDbB?= =?UTF-8?q?ean=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 --- .../docs/CATCHMIND_FRONTEND_GUIDE.md | 722 ++++++++++++++++++ .../chatting/dto/response/RoomListItem.java | 71 ++ .../chatting/handler/ChatRoomHandler.java | 18 +- .../domain/chatting/model/GameSettings.java | 2 + 4 files changed, 809 insertions(+), 4 deletions(-) create mode 100644 ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomListItem.java diff --git a/ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md b/ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md new file mode 100644 index 00000000..cfc4b168 --- /dev/null +++ b/ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md @@ -0,0 +1,722 @@ +# Catchmind 게임 프론트엔드 연동 가이드 + +## 목차 +1. [개요](#개요) +2. [아키텍처](#아키텍처) +3. [WebSocket 연결](#websocket-연결) +4. [메시지 구조](#메시지-구조) +5. [게임 흐름](#게임-흐름) +6. [REST API](#rest-api) +7. [타이머 동기화](#타이머-동기화) +8. [게임 자동 종료](#게임-자동-종료) +9. [재접속 처리](#재접속-처리) +10. [에러 처리](#에러-처리) + +--- + +## 개요 + +Catchmind는 실시간 그림 맞추기 게임입니다. WebSocket을 통한 실시간 통신과 REST API를 통한 게임 세션 관리를 지원합니다. + +### 주요 특징 +- **실시간 통신**: WebSocket 기반 양방향 통신 +- **도메인 분리**: `chat` / `game` 도메인으로 메시지 라우팅 +- **타이머 동기화**: `serverTime` 필드를 통한 클라이언트-서버 시간 동기화 +- **자동 종료**: 게임 시작 7분 후 자동 종료 +- **재접속 지원**: 게임 세션 API를 통한 상태 복원 + +--- + +## 아키텍처 + +``` +┌─────────────┐ WebSocket ┌──────────────────┐ +│ Frontend │◄──────────────────►│ API Gateway WS │ +│ (React) │ └────────┬─────────┘ +│ │ │ +│ │ REST API ┌───────▼─────────┐ +│ │◄───────────────────►│ API Gateway │ +└─────────────┘ │ REST │ + └────────┬────────┘ + │ + ┌─────────────┼─────────────┐ + │ │ │ + ┌─────▼────┐ ┌─────▼────┐ ┌─────▼────┐ + │ WS Msg │ │ Game │ │ Game │ + │ Handler │ │ Handler │ │ Session │ + └──────────┘ └──────────┘ │ Handler │ + └──────────┘ +``` + +--- + +## WebSocket 연결 + +### 연결 URL +``` +wss://{api-id}.execute-api.{region}.amazonaws.com/dev?roomToken={token} +``` + +### 연결 절차 +1. REST API로 방 토큰 발급 (`POST /chat/rooms/{roomId}/join`) +2. 토큰으로 WebSocket 연결 +3. 연결 성공 시 자동으로 방에 입장 + +### 연결 예시 (TypeScript) +```typescript +const connectWebSocket = (roomToken: string): WebSocket => { + const ws = new WebSocket( + `wss://xxx.execute-api.ap-northeast-2.amazonaws.com/dev?roomToken=${roomToken}` + ); + + ws.onopen = () => console.log('WebSocket connected'); + ws.onmessage = (event) => handleMessage(JSON.parse(event.data)); + ws.onerror = (error) => console.error('WebSocket error:', error); + ws.onclose = () => console.log('WebSocket closed'); + + return ws; +}; +``` + +--- + +## 메시지 구조 + +### 공통 메시지 포맷 + +모든 WebSocket 메시지는 다음 필드를 포함합니다: + +```typescript +interface BaseMessage { + domain: 'chat' | 'game'; // 도메인 구분 + messageType: string; // 메시지 타입 + messageId: string; // 고유 메시지 ID + roomId: string; // 방 ID + userId: string; // 발신자 ID (시스템: "SYSTEM") + content?: string; // 메시지 내용 + createdAt: string; // ISO 8601 형식 시간 + timestamp: number; // Unix timestamp (ms) +} +``` + +### 도메인 구분 + +| 도메인 | 설명 | 메시지 타입 | +|--------|------|-------------| +| `chat` | 채팅 메시지 | text, image, voice, ai_response | +| `game` | 게임 메시지 | game_start, game_end, round_start, round_end, drawing, correct_answer, score_update, hint | + +### 메시지 라우팅 예시 +```typescript +const handleMessage = (message: BaseMessage) => { + if (message.domain === 'chat') { + handleChatMessage(message); + } else if (message.domain === 'game') { + handleGameMessage(message); + } +}; +``` + +--- + +## 게임 흐름 + +### 게임 상태 (GameStatus) +```typescript +type GameStatus = 'NONE' | 'WAITING' | 'PLAYING' | 'ROUND_END' | 'FINISHED'; +``` + +### 전체 흐름 +``` +[대기] ─── /game 시작 ───► [게임 시작] ─► [라운드 1] ─► [라운드 종료] + │ │ + │ ◄───────────────────┘ + │ (반복) + ▼ + [게임 종료] + │ + ┌────┴────┐ + │ │ + 수동 종료 자동 종료 + (7분 경과) +``` + +### 1. 게임 시작 (game_start) + +**수신 메시지:** +```json +{ + "domain": "game", + "messageType": "game_start", + "messageId": "uuid", + "roomId": "room-123", + "userId": "SYSTEM", + "content": "🎮 게임 시작!\n총 5 라운드\n\n라운드 1 시작!\n출제자: user-1", + "createdAt": "2024-01-20T10:00:00Z", + "timestamp": 1705746000000, + "serverTime": 1705746000000, + "gameStatus": "PLAYING", + "currentRound": 1, + "totalRounds": 5, + "currentDrawerId": "user-1", + "drawerOrder": ["user-1", "user-2", "user-3"], + "roundStartTime": 1705746000000, + "roundDuration": 60 +} +``` + +**프론트엔드 처리:** +```typescript +const handleGameStart = (message: GameStartMessage) => { + setGameStatus('PLAYING'); + setCurrentRound(message.currentRound); + setTotalRounds(message.totalRounds); + setCurrentDrawer(message.currentDrawerId); + setDrawerOrder(message.drawerOrder); + + // 타이머 동기화 + startTimer(message.roundStartTime, message.roundDuration, message.serverTime); + + // 현재 사용자가 출제자인지 확인 + setIsDrawer(message.currentDrawerId === currentUserId); +}; +``` + +### 2. 그림 데이터 전송/수신 (drawing) + +**전송 (출제자만):** +```typescript +const sendDrawing = (drawingData: DrawingData) => { + ws.send(JSON.stringify({ + action: 'sendMessage', + messageType: 'drawing', + content: JSON.stringify(drawingData) + })); +}; +``` + +**수신 메시지:** +```json +{ + "domain": "game", + "messageType": "drawing", + "messageId": "uuid", + "roomId": "room-123", + "userId": "user-1", + "content": "{\"type\":\"path\",\"points\":[...],\"color\":\"#000\",\"width\":3}", + "timestamp": 1705746010000 +} +``` + +### 3. 정답 체크 + +**채팅 메시지로 자동 체크됩니다:** +```typescript +const sendAnswer = (answer: string) => { + ws.send(JSON.stringify({ + action: 'sendMessage', + messageType: 'text', + content: answer + })); +}; +``` + +### 4. 정답 알림 (correct_answer) + +**수신 메시지:** +```json +{ + "domain": "game", + "messageType": "correct_answer", + "roomId": "room-123", + "userId": "user-2", + "content": "🎉 user-2님이 정답을 맞혔습니다! (+35점)", + "timestamp": 1705746030000, + "serverTime": 1705746030000, + "score": 35, + "elapsedTime": 30000, + "allCorrect": false, + "scores": { + "user-1": 5, + "user-2": 35 + } +} +``` + +### 5. 점수 업데이트 (score_update) + +**수신 메시지:** +```json +{ + "domain": "game", + "messageType": "score_update", + "roomId": "room-123", + "timestamp": 1705746030000, + "scores": { + "user-1": 15, + "user-2": 35, + "user-3": 20 + }, + "lastScorer": "user-2", + "lastScore": 35 +} +``` + +### 6. 라운드 종료 (round_end) + +**수신 메시지:** +```json +{ + "domain": "game", + "messageType": "round_end", + "roomId": "room-123", + "content": "라운드 1 종료! 정답: 사과\n\n라운드 2 시작! 출제자: user-2", + "timestamp": 1705746060000, + "serverTime": 1705746060000, + "data": { + "answer": "사과", + "currentRound": 1, + "totalRounds": 5, + "nextRound": 2, + "nextDrawer": "user-2", + "nextWord": { + "wordId": "word-123", + "korean": "바나나" + }, + "roundStartTime": 1705746060000, + "roundDuration": 60, + "ranking": [ + { "rank": 1, "userId": "user-2", "score": 35 }, + { "rank": 2, "userId": "user-3", "score": 20 }, + { "rank": 3, "userId": "user-1", "score": 15 } + ] + } +} +``` + +**프론트엔드 처리:** +```typescript +const handleRoundEnd = (message: RoundEndMessage) => { + const { data } = message; + + // 정답 표시 + showAnswer(data.answer); + + // 순위 표시 + showRanking(data.ranking); + + // 다음 라운드 준비 + if (data.nextRound) { + setCurrentRound(data.nextRound); + setCurrentDrawer(data.nextDrawer); + setIsDrawer(data.nextDrawer === currentUserId); + + // 출제자에게만 단어 표시 + if (data.nextDrawer === currentUserId && data.nextWord) { + setCurrentWord(data.nextWord.korean); + } + + // 타이머 재시작 + startTimer(data.roundStartTime, data.roundDuration, message.serverTime); + + // 캔버스 초기화 + clearCanvas(); + } +}; +``` + +### 7. 게임 종료 (game_end) + +**수신 메시지:** +```json +{ + "domain": "game", + "messageType": "game_end", + "roomId": "room-123", + "content": "🎮 게임 종료!\n\n📊 최종 순위:\n 🥇 user-2: 120점\n 🥈 user-3: 95점\n 🥉 user-1: 80점", + "timestamp": 1705746300000, + "reason": "COMPLETED" +} +``` + +**종료 사유 (reason):** +| 값 | 설명 | +|----|------| +| `COMPLETED` | 모든 라운드 완료 | +| `STOPPED` | 수동 종료 | +| `TIME_EXPIRED` | 7분 시간 초과 | +| `NOT_ENOUGH_PLAYERS` | 인원 부족 | + +--- + +## REST API + +### 게임 시작 +```http +POST /chat/rooms/{roomId}/game/start +Authorization: Bearer {accessToken} +``` + +**Response:** +```json +{ + "success": true, + "message": "Game started", + "data": { + "gameSessionId": "session-123", + "roomId": "room-123", + "status": "PLAYING", + "currentRound": 1, + "totalRounds": 5, + "currentDrawerId": "user-1", + "roundStartTime": 1705746000000, + "serverTime": 1705746000000, + "roundDuration": 60, + "drawerOrder": ["user-1", "user-2", "user-3"], + "currentWord": { + "wordId": "word-1", + "word": "사과" + } + } +} +``` +> **Note:** `currentWord`는 출제자에게만 포함됩니다. + +### 게임 종료 +```http +POST /chat/rooms/{roomId}/game/stop +Authorization: Bearer {accessToken} +``` + +### 게임 상태 조회 +```http +GET /chat/rooms/{roomId}/game/status +Authorization: Bearer {accessToken} +``` + +### 게임 세션 조회 (재접속용) +```http +GET /games/{gameSessionId} +Authorization: Bearer {accessToken} +``` + +**Response:** +```json +{ + "success": true, + "message": "Game session retrieved", + "data": { + "gameSessionId": "session-123", + "roomId": "room-123", + "gameType": "catchmind", + "status": "PLAYING", + "currentRound": 3, + "totalRounds": 5, + "currentDrawerId": "user-2", + "roundStartTime": 1705746180000, + "serverTime": 1705746200000, + "roundDuration": 60, + "scores": { + "user-1": 45, + "user-2": 60, + "user-3": 30 + }, + "players": ["user-1", "user-2", "user-3"], + "drawerOrder": ["user-1", "user-2", "user-3"], + "hintUsed": false, + "currentWord": { + "wordId": "word-5", + "word": "바나나" + } + } +} +``` +> **Note:** `currentWord`는 출제자에게만 포함됩니다. + +--- + +## 타이머 동기화 + +### 문제 +클라이언트와 서버 시간 차이로 인한 타이머 불일치 + +### 해결책 +`serverTime` 필드를 사용하여 서버 시간 기준 타이머 계산 + +### 구현 예시 +```typescript +interface TimerSync { + roundStartTime: number; // 라운드 시작 시간 (서버 기준) + roundDuration: number; // 라운드 지속 시간 (초) + serverTime: number; // 메시지 발송 시점의 서버 시간 +} + +const startTimer = ( + roundStartTime: number, + roundDuration: number, + serverTime: number +) => { + // 서버에서 이미 경과한 시간 계산 + const elapsedOnServer = serverTime - roundStartTime; + + // 남은 시간 계산 (밀리초) + const remainingTime = (roundDuration * 1000) - elapsedOnServer; + + // 음수 방지 + const safeRemainingTime = Math.max(0, remainingTime); + + setRemainingTime(safeRemainingTime); + + // 타이머 시작 + const interval = setInterval(() => { + setRemainingTime((prev) => { + if (prev <= 1000) { + clearInterval(interval); + return 0; + } + return prev - 1000; + }); + }, 1000); + + return () => clearInterval(interval); +}; +``` + +### React Hook 예시 +```typescript +const useGameTimer = (timerSync: TimerSync | null) => { + const [remainingSeconds, setRemainingSeconds] = useState(0); + + useEffect(() => { + if (!timerSync) return; + + const { roundStartTime, roundDuration, serverTime } = timerSync; + const elapsed = (serverTime - roundStartTime) / 1000; + const remaining = Math.max(0, roundDuration - elapsed); + + setRemainingSeconds(Math.ceil(remaining)); + + const interval = setInterval(() => { + setRemainingSeconds((prev) => Math.max(0, prev - 1)); + }, 1000); + + return () => clearInterval(interval); + }, [timerSync]); + + return remainingSeconds; +}; +``` + +--- + +## 게임 자동 종료 + +### 개요 +게임 시작 후 7분(420초)이 경과하면 자동으로 종료됩니다. + +### 자동 종료 메시지 +```json +{ + "domain": "game", + "messageType": "game_end", + "roomId": "room-123", + "userId": "SYSTEM", + "content": "⏰ 시간 초과! 🎮 게임 종료!\n\n📊 최종 순위:\n 🥇 user-2: 120점\n 🥈 user-1: 95점", + "timestamp": 1705746420000, + "reason": "TIME_EXPIRED" +} +``` + +### 프론트엔드 처리 +```typescript +const handleGameEnd = (message: GameEndMessage) => { + setGameStatus('FINISHED'); + + // 종료 사유에 따른 UI 처리 + if (message.reason === 'TIME_EXPIRED') { + showNotification('시간 초과로 게임이 종료되었습니다.'); + } else if (message.reason === 'STOPPED') { + showNotification('게임이 수동으로 종료되었습니다.'); + } + + // 최종 결과 표시 + showFinalResults(message.content); + + // 캔버스 초기화 + clearCanvas(); +}; +``` + +--- + +## 재접속 처리 + +### 시나리오 +사용자가 게임 중 연결이 끊어졌다가 다시 접속하는 경우 + +### 처리 절차 +1. WebSocket 재연결 +2. 게임 세션 API로 현재 상태 조회 +3. UI 상태 복원 +4. 타이머 동기화 + +### 구현 예시 +```typescript +const handleReconnect = async (roomId: string, gameSessionId: string) => { + // 1. WebSocket 재연결 + const roomToken = await getRoomToken(roomId); + connectWebSocket(roomToken); + + // 2. 게임 세션 조회 + const session = await fetchGameSession(gameSessionId); + + if (session.status === 'PLAYING') { + // 3. UI 상태 복원 + setGameStatus('PLAYING'); + setCurrentRound(session.currentRound); + setScores(session.scores); + setCurrentDrawer(session.currentDrawerId); + setIsDrawer(session.currentDrawerId === currentUserId); + + // 출제자인 경우 단어 설정 + if (session.currentWord) { + setCurrentWord(session.currentWord.word); + } + + // 4. 타이머 동기화 + startTimer( + session.roundStartTime, + session.roundDuration, + session.serverTime + ); + } else if (session.status === 'FINISHED') { + setGameStatus('FINISHED'); + } +}; +``` + +--- + +## 에러 처리 + +### WebSocket 에러 코드 +| 코드 | 설명 | 처리 방법 | +|------|------|-----------| +| 1000 | 정상 종료 | - | +| 1001 | 서버 종료 | 재연결 시도 | +| 1006 | 비정상 종료 | 재연결 시도 | +| 4001 | 인증 실패 | 토큰 재발급 후 재연결 | +| 4003 | 권한 없음 | 에러 표시 | + +### REST API 에러 코드 +| 코드 | 설명 | +|------|------| +| `GAME_001` | 게임 시작 실패 | +| `GAME_002` | 게임 중단 실패 | +| `GAME_003` | 진행 중인 게임 없음 | +| `GAME_004` | 이미 게임 진행 중 | +| `GAME_005` | 권한 없음 (게임 시작자만 중단 가능) | +| `GAME_006` | 게임 세션을 찾을 수 없음 | + +### 에러 처리 예시 +```typescript +const handleError = (error: ApiError) => { + switch (error.code) { + case 'GAME_001': + showNotification('게임을 시작할 수 없습니다. 최소 2명이 필요합니다.'); + break; + case 'GAME_004': + showNotification('이미 게임이 진행 중입니다.'); + break; + case 'GAME_006': + // 게임 세션 만료 - 목록으로 이동 + navigateToRoomList(); + break; + default: + showNotification('오류가 발생했습니다.'); + } +}; +``` + +--- + +## 전체 상태 관리 예시 (React) + +```typescript +interface GameState { + status: GameStatus; + currentRound: number; + totalRounds: number; + currentDrawerId: string | null; + currentWord: string | null; + scores: Record; + isDrawer: boolean; + remainingTime: number; + drawerOrder: string[]; +} + +const initialGameState: GameState = { + status: 'NONE', + currentRound: 0, + totalRounds: 0, + currentDrawerId: null, + currentWord: null, + scores: {}, + isDrawer: false, + remainingTime: 0, + drawerOrder: [], +}; + +const gameReducer = (state: GameState, action: GameAction): GameState => { + switch (action.type) { + case 'GAME_START': + return { + ...state, + status: 'PLAYING', + currentRound: action.payload.currentRound, + totalRounds: action.payload.totalRounds, + currentDrawerId: action.payload.currentDrawerId, + drawerOrder: action.payload.drawerOrder, + isDrawer: action.payload.currentDrawerId === action.payload.currentUserId, + scores: {}, + }; + + case 'ROUND_END': + return { + ...state, + currentRound: action.payload.nextRound, + currentDrawerId: action.payload.nextDrawer, + currentWord: action.payload.isDrawer ? action.payload.nextWord : null, + isDrawer: action.payload.isDrawer, + }; + + case 'SCORE_UPDATE': + return { + ...state, + scores: action.payload.scores, + }; + + case 'GAME_END': + return { + ...initialGameState, + status: 'FINISHED', + scores: state.scores, + }; + + case 'RESET': + return initialGameState; + + default: + return state; + } +}; +``` + +--- + +## 버전 이력 + +| 버전 | 날짜 | 변경 내용 | +|------|------|-----------| +| 1.0.0 | 2024-01-20 | 초기 문서 작성 | +| 1.1.0 | 2024-01-20 | 게임 자동 종료 (7분) 기능 추가 | diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomListItem.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomListItem.java new file mode 100644 index 00000000..63c8c9e2 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomListItem.java @@ -0,0 +1,71 @@ +package com.mzc.secondproject.serverless.domain.chatting.dto.response; + +import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSettings; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 방 목록 조회 시 사용되는 응답 DTO + * ChatRoom + hostNickname 포함 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RoomListItem { + private String roomId; + private String name; + private String description; + private String level; + private Integer currentMembers; + private Integer maxMembers; + private Boolean isPrivate; + private String createdBy; + private String createdAt; + private String lastMessageAt; + private String type; + private String gameType; + private GameSettings gameSettings; + private String status; + private String hostId; + private String hostNickname; + private List participants; + + /** + * ChatRoom과 hostNickname으로 RoomListItem 생성 + */ + public static RoomListItem from(ChatRoom room, String hostNickname) { + return RoomListItem.builder() + .roomId(room.getRoomId()) + .name(room.getName()) + .description(room.getDescription()) + .level(room.getLevel()) + .currentMembers(room.getCurrentMembers()) + .maxMembers(room.getMaxMembers()) + .isPrivate(room.getIsPrivate()) + .createdBy(room.getCreatedBy()) + .createdAt(room.getCreatedAt()) + .lastMessageAt(room.getLastMessageAt()) + .type(room.getType()) + .gameType(room.getGameType()) + .gameSettings(room.getGameSettings()) + .status(room.getStatus()) + .hostId(room.getHostId()) + .hostNickname(hostNickname) + .build(); + } + + /** + * ChatRoom, hostNickname, participants로 RoomListItem 생성 + */ + public static RoomListItem from(ChatRoom room, String hostNickname, List participants) { + RoomListItem item = from(room, hostNickname); + item.setParticipants(participants); + return item; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java index 6a5769d5..62b9a323 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java @@ -12,6 +12,7 @@ import com.mzc.secondproject.serverless.domain.chatting.dto.request.CreateRoomRequest; import com.mzc.secondproject.serverless.domain.chatting.dto.request.JoinRoomRequest; import com.mzc.secondproject.serverless.domain.chatting.dto.response.JoinRoomResponse; +import com.mzc.secondproject.serverless.domain.chatting.dto.response.RoomListItem; import com.mzc.secondproject.serverless.domain.chatting.dto.response.RoomParticipant; import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingErrorCode; import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; @@ -67,9 +68,12 @@ private APIGatewayProxyResponseEvent createRoom(APIGatewayProxyRequestEvent requ ChatRoom room = commandService.createRoom( dto.getName(), dto.getDescription(), level, maxMembers, isPrivate, dto.getPassword(), userId, dto.getType(), dto.getGameType(), dto.getGameSettings()); - room.setPassword(null); - return ResponseGenerator.created("Room created", room); + // hostNickname 포함하여 응답 + String hostNickname = queryService.getHostNickname(room); + RoomListItem roomItem = RoomListItem.from(room, hostNickname); + + return ResponseGenerator.created("Room created", roomItem); }); } @@ -95,10 +99,16 @@ private APIGatewayProxyResponseEvent getRooms(APIGatewayProxyRequestEvent reques rooms = queryService.filterByJoinedUser(rooms, userId); } - rooms.forEach(room -> room.setPassword(null)); + // hostNickname 포함하여 RoomListItem으로 변환 + List roomItems = rooms.stream() + .map(room -> { + String hostNickname = queryService.getHostNickname(room); + return RoomListItem.from(room, hostNickname); + }) + .toList(); Map result = new HashMap<>(); - result.put("rooms", rooms); + result.put("rooms", roomItems); result.put("nextCursor", roomPage.nextCursor()); result.put("hasMore", roomPage.hasMore()); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java index d7d9b9cf..22e0f226 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java @@ -4,11 +4,13 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; @Data @Builder @NoArgsConstructor @AllArgsConstructor +@DynamoDbBean public class GameSettings { @Builder.Default private Integer maxRounds = 5; From 0748cf554ff51de393fa9f1a2837e68c8edaf0a2 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 21 Jan 2026 09:26:59 +0900 Subject: [PATCH 16/99] =?UTF-8?q?fix:=20ChatRoomFunction=EC=97=90=20WEBSOC?= =?UTF-8?q?KET=5FENDPOINT=20=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B6=8C=ED=95=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 --- ServerlessFunction/template.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index f345866d..a96c3e27 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -364,11 +364,19 @@ Resources: Description: Handle chat room CRUD operations SnapStart: ApplyOn: PublishedVersions + Environment: + Variables: + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable - DynamoDBReadPolicy: TableName: !Ref UserTable + - Statement: + - Effect: Allow + Action: + - execute-api:ManageConnections + Resource: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/* Events: CreateRoom: Type: Api From 3f8c1147a12faa227543f58f2a30a9811e06ea78 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 21 Jan 2026 09:32:26 +0900 Subject: [PATCH 17/99] =?UTF-8?q?fix:=20WebSocket=20Lambda=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=EB=93=A4=EC=97=90=20WEBSOCKET=5FENDPOINT=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EB=B3=80=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 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 --- ServerlessFunction/template.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index a96c3e27..7164ffe0 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -234,9 +234,15 @@ Resources: Environment: Variables: WEBSOCKET_CONNECTION_TTL_SECONDS: "600" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable + - Statement: + - Effect: Allow + Action: + - execute-api:ManageConnections + Resource: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/* WebSocketConnectPermission: Type: AWS::Lambda::Permission @@ -255,9 +261,17 @@ Resources: Description: Handle WebSocket $disconnect SnapStart: ApplyOn: PublishedVersions + Environment: + Variables: + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable + - Statement: + - Effect: Allow + Action: + - execute-api:ManageConnections + Resource: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/* WebSocketDisconnectPermission: Type: AWS::Lambda::Permission From fbe58a3aa431416aa3e268610ab4982f639e4e3f Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 21 Jan 2026 09:51:08 +0900 Subject: [PATCH 18/99] =?UTF-8?q?fix:=20Grammar=20WebSocket=20Lambda=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=20=EB=B3=80=EC=88=98=20=EB=B0=8F=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 --- ServerlessFunction/template.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 7164ffe0..ce4478b1 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1285,9 +1285,17 @@ Resources: 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/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable + - Statement: + - Effect: Allow + Action: + - execute-api:ManageConnections + Resource: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${GrammarWebSocketApi}/* GrammarStreamingConnectPermission: Type: AWS::Lambda::Permission @@ -1304,9 +1312,17 @@ Resources: 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/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable + - Statement: + - Effect: Allow + Action: + - execute-api:ManageConnections + Resource: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${GrammarWebSocketApi}/* GrammarStreamingDisconnectPermission: Type: AWS::Lambda::Permission From e95315222b0ef782ebf46d868aa9f867cfe2ea40 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 21 Jan 2026 11:13:31 +0900 Subject: [PATCH 19/99] =?UTF-8?q?feat:=20GSI1SK=20=ED=99=95=EC=9E=A5?= =?UTF-8?q?=EC=84=B1=20=EC=9E=88=EB=8A=94=20=EC=9E=AC=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=EB=B0=8F=20DB=20=EB=A0=88=EB=B2=A8=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 변경 사항 ### GSI1SK 포맷 변경 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} ### 지원 쿼리 패턴 - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") ### 파일 수정 - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) ### 마이그레이션 - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 --- ServerlessFunction/scripts/migrate-gsi1sk.sh | 81 ++++++++++++++++ .../repository/ChatRoomRepository.java | 95 ++++++++++++++++--- .../service/ChatRoomCommandService.java | 8 +- .../service/ChatRoomQueryService.java | 34 +++---- .../domain/chatting/service/GameService.java | 8 +- 5 files changed, 189 insertions(+), 37 deletions(-) create mode 100755 ServerlessFunction/scripts/migrate-gsi1sk.sh diff --git a/ServerlessFunction/scripts/migrate-gsi1sk.sh b/ServerlessFunction/scripts/migrate-gsi1sk.sh new file mode 100755 index 00000000..8d3648f5 --- /dev/null +++ b/ServerlessFunction/scripts/migrate-gsi1sk.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# GSI1SK 마이그레이션 스크립트 +# 기존: {level}#{createdAt} +# 신규: {type}#{gameType}#{status}#{level}#{createdAt} + +set -e + +AWS_PROFILE="${AWS_PROFILE:-mzc}" +AWS_REGION="ap-northeast-2" +TABLE_NAME="group2-englishstudy-chat" + +echo "=== GSI1SK Migration Script ===" +echo "Profile: $AWS_PROFILE" +echo "Region: $AWS_REGION" +echo "Table: $TABLE_NAME" +echo "" + +# GSI1에서 ROOMS로 시작하는 모든 방 조회 +echo "Fetching rooms from GSI1..." + +ROOMS=$(AWS_PROFILE=$AWS_PROFILE aws dynamodb query \ + --table-name $TABLE_NAME \ + --region $AWS_REGION \ + --index-name GSI1 \ + --key-condition-expression "GSI1PK = :pk" \ + --expression-attribute-values '{":pk": {"S": "ROOMS"}}' \ + --output json 2>/dev/null) + +COUNT=$(echo "$ROOMS" | jq '.Items | length') +echo "Found $COUNT rooms to migrate" +echo "" + +if [ "$COUNT" -eq "0" ]; then + echo "No rooms to migrate. Exiting." + exit 0 +fi + +# 각 방에 대해 GSI1SK 업데이트 +echo "$ROOMS" | jq -c '.Items[]' | while read -r ROOM; do + PK=$(echo "$ROOM" | jq -r '.PK.S') + SK=$(echo "$ROOM" | jq -r '.SK.S') + OLD_GSI1SK=$(echo "$ROOM" | jq -r '.GSI1SK.S') + ROOM_TYPE=$(echo "$ROOM" | jq -r '.type.S // "CHAT"') + GAME_TYPE=$(echo "$ROOM" | jq -r '.gameType.S // "-"') + STATUS=$(echo "$ROOM" | jq -r '.status.S // "WAITING"') + LEVEL=$(echo "$ROOM" | jq -r '.level.S // "beginner"') + CREATED_AT=$(echo "$ROOM" | jq -r '.createdAt.S') + + # gameType이 null이면 "-"로 설정 + if [ "$GAME_TYPE" == "null" ]; then + GAME_TYPE="-" + fi + + # 이미 새 포맷인지 확인 (5개 부분으로 나뉘는지) + PARTS=$(echo "$OLD_GSI1SK" | tr '#' '\n' | wc -l) + if [ "$PARTS" -ge 5 ]; then + echo "SKIP: $PK (already migrated: $OLD_GSI1SK)" + continue + fi + + # 새 GSI1SK 생성 + NEW_GSI1SK="${ROOM_TYPE}#${GAME_TYPE}#${STATUS}#${LEVEL}#${CREATED_AT}" + + echo "Migrating: $PK" + echo " Old GSI1SK: $OLD_GSI1SK" + echo " New GSI1SK: $NEW_GSI1SK" + + # DynamoDB 업데이트 + AWS_PROFILE=$AWS_PROFILE aws dynamodb update-item \ + --table-name $TABLE_NAME \ + --region $AWS_REGION \ + --key "{\"PK\": {\"S\": \"$PK\"}, \"SK\": {\"S\": \"$SK\"}}" \ + --update-expression "SET GSI1SK = :gsi1sk" \ + --expression-attribute-values "{\":gsi1sk\": {\"S\": \"$NEW_GSI1SK\"}}" \ + 2>/dev/null + + echo " Done!" + echo "" +done + +echo "=== Migration Complete ===" diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java index 59a00e85..3cadfc31 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java @@ -84,35 +84,80 @@ public PaginatedResult findAllWithPagination(int limit, String cursor) } /** - * 레벨별 채팅방 조회 - 최신순, 페이지네이션 지원 + * 필터 조건으로 채팅방 조회 - 최신순, 페이지네이션 지원 + * GSI1SK 포맷: {type}#{gameType}#{status}#{level}#{createdAt} + * + * @param type 방 타입 (CHAT, GAME) - nullable + * @param gameType 게임 타입 (CATCHMIND 등) - nullable + * @param status 방 상태 (WAITING, PLAYING, FINISHED) - nullable + * @param level 레벨 (beginner, intermediate, advanced) - nullable + * @param limit 조회 개수 + * @param cursor 페이지네이션 커서 + * @return 필터링된 채팅방 목록 */ - public PaginatedResult findByLevelWithPagination(String level, int limit, String cursor) { - QueryConditional queryConditional = QueryConditional - .sortBeginsWith(Key.builder() - .partitionValue("ROOMS") - .sortValue(level + "#") - .build()); - + public PaginatedResult findByFilters(String type, String gameType, String status, String level, int limit, String cursor) { + // GSI1SK prefix 생성: {type}#{gameType}#{status}#{level}# + StringBuilder prefixBuilder = new StringBuilder(); + + if (type != null && !type.isEmpty()) { + prefixBuilder.append(type).append("#"); + if (gameType != null && !gameType.isEmpty()) { + prefixBuilder.append(gameType).append("#"); + if (status != null && !status.isEmpty()) { + prefixBuilder.append(status).append("#"); + if (level != null && !level.isEmpty()) { + prefixBuilder.append(level).append("#"); + } + } + } + } + + String prefix = prefixBuilder.toString(); + + QueryConditional queryConditional; + if (prefix.isEmpty()) { + // 필터 없음 - 전체 조회 + queryConditional = QueryConditional.keyEqualTo(Key.builder().partitionValue("ROOMS").build()); + } else { + // prefix로 필터링 + queryConditional = QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue("ROOMS") + .sortValue(prefix) + .build() + ); + } + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) // 최신순 .limit(limit); - + if (cursor != null && !cursor.isEmpty()) { Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } } - + DynamoDbIndex gsi1 = table.index("GSI1"); Page page = gsi1.query(requestBuilder.build()).iterator().next(); List rooms = page.items(); - + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - + + logger.info("Query with prefix '{}': found {} rooms", prefix, rooms.size()); return new PaginatedResult<>(rooms, nextCursor); } + + /** + * 레벨별 채팅방 조회 - 최신순, 페이지네이션 지원 + * @deprecated findByFilters 사용 권장 + */ + @Deprecated + public PaginatedResult findByLevelWithPagination(String level, int limit, String cursor) { + return findByFilters(null, null, null, null, limit, cursor); + } public void delete(String roomId) { Key key = Key.builder() @@ -124,6 +169,32 @@ public void delete(String roomId) { logger.info("Deleted room: {}", roomId); } + /** + * 방 상태 변경 시 GSI1SK도 함께 업데이트 + * GSI1SK 포맷: {type}#{gameType}#{status}#{level}#{createdAt} + */ + public void updateStatus(ChatRoom room, String newStatus) { + String oldGsi1sk = room.getGsi1sk(); + String[] parts = oldGsi1sk.split("#", 5); // type, gameType, oldStatus, level, createdAt + + if (parts.length < 5) { + logger.warn("Invalid GSI1SK format: {}", oldGsi1sk); + // 폴백: 새 포맷으로 생성 + String type = room.getType() != null ? room.getType() : "CHAT"; + String gameType = room.getGameType() != null ? room.getGameType() : "-"; + String level = room.getLevel() != null ? room.getLevel() : "beginner"; + String createdAt = room.getCreatedAt(); + room.setGsi1sk(String.format("%s#%s#%s#%s#%s", type, gameType, newStatus, level, createdAt)); + } else { + // 기존 포맷에서 status만 교체 + room.setGsi1sk(String.format("%s#%s#%s#%s#%s", parts[0], parts[1], newStatus, parts[3], parts[4])); + } + + room.setStatus(newStatus); + table.putItem(room); + logger.info("Updated room {} status to {} (GSI1SK: {})", room.getRoomId(), newStatus, room.getGsi1sk()); + } + /** * 채팅방 lastMessageAt 업데이트 (N+1 방지 - UpdateExpression 사용) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java index d1abff8d..cb84f3cc 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java @@ -51,11 +51,17 @@ public ChatRoom createRoom(String name, String description, String level, Intege String roomId = UUID.randomUUID().toString(); String now = Instant.now().toString(); + // GSI1SK 포맷: {type}#{gameType}#{status}#{level}#{createdAt} + String roomType = type != null ? type : "CHAT"; + String roomGameType = gameType != null ? gameType : "-"; + String roomStatus = "WAITING"; + String gsi1sk = String.format("%s#%s#%s#%s#%s", roomType, roomGameType, roomStatus, level, now); + ChatRoom room = ChatRoom.builder() .pk("ROOM#" + roomId) .sk("METADATA") .gsi1pk("ROOMS") - .gsi1sk(level + "#" + now) + .gsi1sk(gsi1sk) .roomId(roomId) .name(name) .description(description) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java index 06888d42..f75ef802 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java @@ -31,27 +31,21 @@ public Optional getRoom(String roomId) { return roomRepository.findById(roomId); } + /** + * 필터 조건으로 방 목록 조회 (DB 레벨 필터링) + * GSI1SK 포맷: {type}#{gameType}#{status}#{level}#{createdAt} + * + * @param level 레벨 필터 (beginner, intermediate, advanced) + * @param limit 조회 개수 + * @param cursor 페이지네이션 커서 + * @param type 방 타입 (CHAT, GAME) + * @param gameType 게임 타입 (CATCHMIND 등) + * @param status 방 상태 (WAITING, PLAYING, FINISHED) + * @return 필터링된 방 목록 + */ public PaginatedResult getRooms(String level, int limit, String cursor, String type, String gameType, String status) { - PaginatedResult roomPage; - if (level != null && !level.isEmpty()) { - roomPage = roomRepository.findByLevelWithPagination(level, limit, cursor); - } else { - roomPage = roomRepository.findAllWithPagination(limit, cursor); - } - - List rooms = roomPage.items(); - - if (type != null) { - rooms = rooms.stream().filter(r -> type.equalsIgnoreCase(r.getType())).toList(); - } - if (gameType != null) { - rooms = rooms.stream().filter(r -> gameType.equalsIgnoreCase(r.getGameType())).toList(); - } - if (status != null) { - rooms = rooms.stream().filter(r -> status.equalsIgnoreCase(r.getStatus())).toList(); - } - - return new PaginatedResult<>(rooms, roomPage.nextCursor()); + // DB 레벨에서 필터링 (메모리 필터링 제거) + return roomRepository.findByFilters(type, gameType, status, level, limit, cursor); } public List filterByJoinedUser(List rooms, String userId) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java index accce2e4..133e49ff 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java @@ -179,9 +179,9 @@ public GameStartResult startGame(String roomId, String userId) { gameSessionRepository.save(session); } - // ChatRoom에 활성 게임 세션 ID 연결 + // ChatRoom에 활성 게임 세션 ID 연결 및 상태 업데이트 (GSI1SK 포함) room.setActiveGameSessionId(gameSessionId); - chatRoomRepository.save(room); + chatRoomRepository.updateStatus(room, "PLAYING"); // 첫 라운드 기록 생성 (7일 후 자동 삭제) long ttlSeconds = Instant.now().plusSeconds(7 * 24 * 60 * 60).getEpochSecond(); @@ -494,9 +494,9 @@ private CommandResult finishGame(GameSession session, ChatRoom room, String reas // 게임 세션 종료 처리 gameSessionRepository.finishGame(session.getGameSessionId(), currentTime, ttlSeconds); - // ChatRoom에서 활성 게임 세션 참조 제거 + // ChatRoom에서 활성 게임 세션 참조 제거 및 상태 업데이트 (GSI1SK 포함) room.setActiveGameSessionId(null); - chatRoomRepository.save(room); + chatRoomRepository.updateStatus(room, "WAITING"); // 게임 통계 업데이트 및 뱃지 체크 try { From c34d1837a95b9f7c733aa9a935ddca59535eb59f Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 21 Jan 2026 11:25:04 +0900 Subject: [PATCH 20/99] fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. --- ServerlessFunction/scripts/migrate-gsi1sk.sh | 81 -------------------- 1 file changed, 81 deletions(-) delete mode 100755 ServerlessFunction/scripts/migrate-gsi1sk.sh diff --git a/ServerlessFunction/scripts/migrate-gsi1sk.sh b/ServerlessFunction/scripts/migrate-gsi1sk.sh deleted file mode 100755 index 8d3648f5..00000000 --- a/ServerlessFunction/scripts/migrate-gsi1sk.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/bin/bash -# GSI1SK 마이그레이션 스크립트 -# 기존: {level}#{createdAt} -# 신규: {type}#{gameType}#{status}#{level}#{createdAt} - -set -e - -AWS_PROFILE="${AWS_PROFILE:-mzc}" -AWS_REGION="ap-northeast-2" -TABLE_NAME="group2-englishstudy-chat" - -echo "=== GSI1SK Migration Script ===" -echo "Profile: $AWS_PROFILE" -echo "Region: $AWS_REGION" -echo "Table: $TABLE_NAME" -echo "" - -# GSI1에서 ROOMS로 시작하는 모든 방 조회 -echo "Fetching rooms from GSI1..." - -ROOMS=$(AWS_PROFILE=$AWS_PROFILE aws dynamodb query \ - --table-name $TABLE_NAME \ - --region $AWS_REGION \ - --index-name GSI1 \ - --key-condition-expression "GSI1PK = :pk" \ - --expression-attribute-values '{":pk": {"S": "ROOMS"}}' \ - --output json 2>/dev/null) - -COUNT=$(echo "$ROOMS" | jq '.Items | length') -echo "Found $COUNT rooms to migrate" -echo "" - -if [ "$COUNT" -eq "0" ]; then - echo "No rooms to migrate. Exiting." - exit 0 -fi - -# 각 방에 대해 GSI1SK 업데이트 -echo "$ROOMS" | jq -c '.Items[]' | while read -r ROOM; do - PK=$(echo "$ROOM" | jq -r '.PK.S') - SK=$(echo "$ROOM" | jq -r '.SK.S') - OLD_GSI1SK=$(echo "$ROOM" | jq -r '.GSI1SK.S') - ROOM_TYPE=$(echo "$ROOM" | jq -r '.type.S // "CHAT"') - GAME_TYPE=$(echo "$ROOM" | jq -r '.gameType.S // "-"') - STATUS=$(echo "$ROOM" | jq -r '.status.S // "WAITING"') - LEVEL=$(echo "$ROOM" | jq -r '.level.S // "beginner"') - CREATED_AT=$(echo "$ROOM" | jq -r '.createdAt.S') - - # gameType이 null이면 "-"로 설정 - if [ "$GAME_TYPE" == "null" ]; then - GAME_TYPE="-" - fi - - # 이미 새 포맷인지 확인 (5개 부분으로 나뉘는지) - PARTS=$(echo "$OLD_GSI1SK" | tr '#' '\n' | wc -l) - if [ "$PARTS" -ge 5 ]; then - echo "SKIP: $PK (already migrated: $OLD_GSI1SK)" - continue - fi - - # 새 GSI1SK 생성 - NEW_GSI1SK="${ROOM_TYPE}#${GAME_TYPE}#${STATUS}#${LEVEL}#${CREATED_AT}" - - echo "Migrating: $PK" - echo " Old GSI1SK: $OLD_GSI1SK" - echo " New GSI1SK: $NEW_GSI1SK" - - # DynamoDB 업데이트 - AWS_PROFILE=$AWS_PROFILE aws dynamodb update-item \ - --table-name $TABLE_NAME \ - --region $AWS_REGION \ - --key "{\"PK\": {\"S\": \"$PK\"}, \"SK\": {\"S\": \"$SK\"}}" \ - --update-expression "SET GSI1SK = :gsi1sk" \ - --expression-attribute-values "{\":gsi1sk\": {\"S\": \"$NEW_GSI1SK\"}}" \ - 2>/dev/null - - echo " Done!" - echo "" -done - -echo "=== Migration Complete ===" From 7db99c82ea5de2e7c9e230c857b38093d74faa66 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 21 Jan 2026 12:37:18 +0900 Subject: [PATCH 21/99] feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. --- .../domain/chatting/handler/GameHandler.java | 67 ++++++++++++++++--- .../chatting/handler/GameSessionHandler.java | 25 +++++-- .../websocket/WebSocketConnectHandler.java | 7 +- .../websocket/WebSocketMessageHandler.java | 37 ++++++++++ .../domain/chatting/model/GameSession.java | 3 +- .../repository/ConnectionRepository.java | 39 ++++++++--- .../domain/chatting/service/GameService.java | 40 ++++++++--- ServerlessFunction/template.yaml | 2 + 8 files changed, 187 insertions(+), 33 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java index f72bd0dc..4acbd169 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java @@ -74,10 +74,11 @@ private APIGatewayProxyResponseEvent startGame(APIGatewayProxyRequestEvent reque return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, result.error()); } - // WebSocket으로 게임 시작 알림 브로드캐스트 + // WebSocket으로 게임 시작 알림 브로드캐스트 (출제자에게 currentWord 포함) broadcastGameStart(roomId, result); - GameStatusResponse response = GameStatusResponse.from(result.session()); + // REST 응답에도 출제자에게 currentWord 포함 + Map response = buildGameStatusResponse(result.session(), userId); return ResponseGenerator.ok("Game started", response); } @@ -111,10 +112,11 @@ private APIGatewayProxyResponseEvent restartGame(APIGatewayProxyRequestEvent req return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, result.error()); } - // WebSocket으로 게임 시작 알림 브로드캐스트 + // WebSocket으로 게임 시작 알림 브로드캐스트 (출제자에게 currentWord 포함) broadcastGameStart(roomId, result); - GameStatusResponse response = GameStatusResponse.from(result.session()); + // REST 응답에도 출제자에게 currentWord 포함 + Map response = buildGameStatusResponse(result.session(), userId); return ResponseGenerator.ok("Game restarted", response); } @@ -131,11 +133,41 @@ private APIGatewayProxyResponseEvent getGameStatus(APIGatewayProxyRequestEvent r } GameSession session = optSession.get(); - GameStatusResponse response = GameStatusResponse.from(session); + + // 출제자에게만 currentWord 포함 + Map response = buildGameStatusResponse(session, userId); return ResponseGenerator.ok("Game status retrieved", response); } + /** + * 게임 상태 응답 빌드 (출제자에게만 currentWord 포함) + */ + private Map buildGameStatusResponse(GameSession session, String userId) { + Map response = new LinkedHashMap<>(); + response.put("gameStatus", session.getStatus()); + response.put("currentRound", session.getCurrentRound()); + response.put("totalRounds", session.getTotalRounds()); + response.put("currentDrawerId", session.getCurrentDrawerId()); + response.put("roundStartTime", session.getRoundStartTime()); + response.put("serverTime", System.currentTimeMillis()); + response.put("roundDuration", session.getRoundDuration()); + response.put("drawerOrder", session.getDrawerOrder()); + response.put("scores", session.getScores() != null ? session.getScores() : Map.of()); + response.put("hintUsed", session.getHintUsed()); + response.put("correctGuessers", session.getCorrectGuessers()); + + // 출제자에게만 현재 단어 포함 + if (userId != null && userId.equals(session.getCurrentDrawerId())) { + Map currentWord = new HashMap<>(); + currentWord.put("wordId", session.getCurrentWordId()); + currentWord.put("word", session.getCurrentWord()); + response.put("currentWord", currentWord); + } + + return response; + } + /** * GET /rooms/{roomId}/game/scores - 점수 조회 */ @@ -155,6 +187,7 @@ private APIGatewayProxyResponseEvent getScores(APIGatewayProxyRequestEvent reque /** * 게임 시작 브로드캐스트 + * 모든 사용자에게 게임 시작 메시지 전송, 출제자에게는 currentWord 포함 */ private void broadcastGameStart(String roomId, GameService.GameStartResult result) { String messageId = UUID.randomUUID().toString(); @@ -162,6 +195,7 @@ private void broadcastGameStart(String roomId, GameService.GameStartResult resul long serverTime = System.currentTimeMillis(); GameSession session = result.session(); + String drawerId = session.getCurrentDrawerId(); String message = String.format(""" 🎮 게임 시작! @@ -171,8 +205,9 @@ private void broadcastGameStart(String roomId, GameService.GameStartResult resul 출제자: %s """, session.getTotalRounds(), - session.getCurrentDrawerId()); + drawerId); + // 기본 게임 시작 메시지 (모든 사용자용) Map gameStartMessage = new HashMap<>(); gameStartMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); gameStartMessage.put("messageId", messageId); @@ -185,17 +220,31 @@ private void broadcastGameStart(String roomId, GameService.GameStartResult resul gameStartMessage.put("gameStatus", session.getStatus()); gameStartMessage.put("currentRound", session.getCurrentRound()); gameStartMessage.put("totalRounds", session.getTotalRounds()); - gameStartMessage.put("currentDrawerId", session.getCurrentDrawerId()); + gameStartMessage.put("currentDrawerId", drawerId); gameStartMessage.put("drawerOrder", result.drawerOrder()); gameStartMessage.put("roundStartTime", session.getRoundStartTime()); gameStartMessage.put("serverTime", serverTime); gameStartMessage.put("roundDuration", session.getRoundDuration()); List connections = connectionRepository.findByRoomId(roomId); + + // 출제자용 메시지 (currentWord 포함) + Map drawerMessage = new HashMap<>(gameStartMessage); + Map currentWord = new HashMap<>(); + currentWord.put("wordId", session.getCurrentWordId()); + currentWord.put("word", session.getCurrentWord()); + drawerMessage.put("currentWord", currentWord); + String broadcastPayload = ResponseGenerator.gson().toJson(gameStartMessage); - broadcaster.broadcast(connections, broadcastPayload); + String drawerPayload = ResponseGenerator.gson().toJson(drawerMessage); + + // 출제자와 일반 사용자에게 다른 메시지 전송 + for (Connection conn : connections) { + String payload = conn.getUserId().equals(drawerId) ? drawerPayload : broadcastPayload; + broadcaster.sendToConnection(conn.getConnectionId(), payload); + } - logger.info("Game start broadcasted: roomId={}", roomId); + logger.info("Game start broadcasted: roomId={}, drawerId={}", roomId, drawerId); } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameSessionHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameSessionHandler.java index 9267e543..acb7b990 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameSessionHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameSessionHandler.java @@ -197,6 +197,7 @@ private Map buildGameSessionResponse(GameSession session, String /** * 게임 시작 브로드캐스트 + * 모든 사용자에게 게임 시작 메시지 전송, 출제자에게는 currentWord 포함 */ private void broadcastGameStart(String roomId, GameService.GameStartResult result) { String messageId = UUID.randomUUID().toString(); @@ -204,6 +205,7 @@ private void broadcastGameStart(String roomId, GameService.GameStartResult resul long serverTime = System.currentTimeMillis(); GameSession session = result.session(); + String drawerId = session.getCurrentDrawerId(); String message = String.format(""" 🎮 게임 시작! @@ -213,8 +215,9 @@ private void broadcastGameStart(String roomId, GameService.GameStartResult resul 출제자: %s """, session.getTotalRounds(), - session.getCurrentDrawerId()); + drawerId); + // 기본 게임 시작 메시지 (모든 사용자용) Map gameStartMessage = new HashMap<>(); gameStartMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); gameStartMessage.put("messageId", messageId); @@ -227,17 +230,31 @@ private void broadcastGameStart(String roomId, GameService.GameStartResult resul gameStartMessage.put("gameStatus", session.getStatus()); gameStartMessage.put("currentRound", session.getCurrentRound()); gameStartMessage.put("totalRounds", session.getTotalRounds()); - gameStartMessage.put("currentDrawerId", session.getCurrentDrawerId()); + gameStartMessage.put("currentDrawerId", drawerId); gameStartMessage.put("drawerOrder", result.drawerOrder()); gameStartMessage.put("roundStartTime", session.getRoundStartTime()); gameStartMessage.put("serverTime", serverTime); gameStartMessage.put("roundDuration", session.getRoundDuration()); List connections = connectionRepository.findByRoomId(roomId); + + // 출제자용 메시지 (currentWord 포함) + Map drawerMessage = new HashMap<>(gameStartMessage); + Map currentWord = new HashMap<>(); + currentWord.put("wordId", session.getCurrentWordId()); + currentWord.put("word", session.getCurrentWord()); + drawerMessage.put("currentWord", currentWord); + String broadcastPayload = ResponseGenerator.gson().toJson(gameStartMessage); - broadcaster.broadcast(connections, broadcastPayload); + String drawerPayload = ResponseGenerator.gson().toJson(drawerMessage); + + // 출제자와 일반 사용자에게 다른 메시지 전송 + for (Connection conn : connections) { + String payload = conn.getUserId().equals(drawerId) ? drawerPayload : broadcastPayload; + broadcaster.sendToConnection(conn.getConnectionId(), payload); + } - logger.info("Game start broadcasted: roomId={}, sessionId={}", roomId, session.getGameSessionId()); + logger.info("Game start broadcasted: roomId={}, sessionId={}, drawerId={}", roomId, session.getGameSessionId(), drawerId); } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java index 88cac6de..0588185e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java @@ -56,10 +56,13 @@ public Map handleRequest(Map event, Context cont RoomToken token = optToken.get(); String userId = token.getUserId(); String roomId = token.getRoomId(); - + + // 같은 방에서 기존 연결 삭제 (새로고침 시 중복 연결 방지) + connectionRepository.deleteUserConnectionsInRoom(userId, roomId); + String now = Instant.now().toString(); long ttl = Instant.now().plusSeconds(WebSocketConfig.connectionTtlSeconds()).getEpochSecond(); - + Connection connection = Connection.builder() .pk("CONN#" + connectionId) .sk("METADATA") diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java index c511c6ba..fadd7296 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java @@ -78,6 +78,7 @@ public Map handleRequest(Map event, Context cont // 메시지 타입별 처리 return switch (messageType.toUpperCase()) { case "DRAWING", "DRAWING_CLEAR" -> handleDrawingMessage(connectionId, payload, messageType); + case "ROUND_TIMEOUT" -> handleRoundTimeout(payload); default -> handleRegularMessage(connectionId, payload, messageType); }; @@ -319,6 +320,42 @@ private void handleAllCorrect(String roomId) { handleCommandResult(endResult, roomId, "SYSTEM"); } } + + /** + * 라운드 타임아웃 처리 (프론트엔드에서 타이머 만료 시 호출) + * - 실제 라운드 시간이 만료되었는지 서버에서 검증 + * - 검증 통과 시 라운드 종료 및 ROUND_END 브로드캐스트 + */ + private Map handleRoundTimeout(MessagePayload payload) { + String roomId = payload.roomId; + logger.info("Round timeout request: roomId={}, userId={}", roomId, payload.userId); + + // 활성 게임 세션 조회 + GameSession session = gameSessionRepository.findActiveByRoomId(roomId).orElse(null); + if (session == null) { + logger.warn("No active game session for round timeout: roomId={}", roomId); + return WebSocketEventUtil.ok("No active game"); + } + + // 라운드 시간이 실제로 만료되었는지 검증 (5초 여유) + long elapsedMs = System.currentTimeMillis() - session.getRoundStartTime(); + int roundDurationMs = (session.getRoundDuration() != null ? session.getRoundDuration() : 60) * 1000; + + if (elapsedMs < roundDurationMs - 5000) { + logger.warn("Round timeout rejected - time not expired: elapsedMs={}, roundDurationMs={}", + elapsedMs, roundDurationMs); + return WebSocketEventUtil.ok("Round time not expired yet"); + } + + // 라운드 종료 처리 + CommandResult endResult = gameService.endRound(roomId, "TIMEOUT"); + if (endResult != null && endResult.success()) { + handleCommandResult(endResult, roomId, "SYSTEM"); + logger.info("Round ended due to timeout: roomId={}", roomId); + } + + return WebSocketEventUtil.ok("Round timeout processed"); + } /** * 명령어 처리 결과를 브로드캐스트 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSession.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSession.java index 7f4ee1aa..403d7805 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSession.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSession.java @@ -40,7 +40,8 @@ public class GameSession { private Integer totalRounds; private String currentDrawerId; private String currentWordId; - private String currentWord; + private String currentWord; // 한국어 뜻 + private String currentWordEnglish; // 영어 단어 (정답 체크용) private Long roundStartTime; private Integer roundDuration; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java index bf59cdb8..d079d3d7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java @@ -56,20 +56,23 @@ public Optional findByConnectionId(String connectionId) { /** * 채팅방의 모든 연결 조회 (브로드캐스트용) - * GSI1: ROOM#{roomId}로 조회 + * GSI1: ROOM#{roomId}로 조회, GSI1SK가 CONN#으로 시작하는 항목만 반환 + * (GSI1에 GameSession도 포함되어 있으므로 CONN# prefix로 필터링) */ public List findByRoomId(String roomId) { + // GSI1SK가 CONN#으로 시작하는 항목만 조회 QueryConditional queryConditional = QueryConditional - .keyEqualTo(Key.builder() + .sortBeginsWith(Key.builder() .partitionValue("ROOM#" + roomId) + .sortValue("CONN#") .build()); - + QueryEnhancedRequest request = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .build(); - + DynamoDbIndex gsi1 = table.index("GSI1"); - + return gsi1.query(request).stream() .flatMap(page -> page.items().stream()) .collect(Collectors.toList()); @@ -84,15 +87,35 @@ public List findByUserId(String userId) { .keyEqualTo(Key.builder() .partitionValue("USER#" + userId) .build()); - + QueryEnhancedRequest request = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .build(); - + DynamoDbIndex gsi2 = table.index("GSI2"); - + return gsi2.query(request).stream() .flatMap(page -> page.items().stream()) .collect(Collectors.toList()); } + + /** + * 같은 방에서 사용자의 기존 연결 삭제 (중복 연결 방지) + * 새로고침 등으로 인한 중복 연결을 정리 + */ + public void deleteUserConnectionsInRoom(String userId, String roomId) { + List userConnections = findByUserId(userId); + + int deletedCount = 0; + for (Connection conn : userConnections) { + if (roomId.equals(conn.getRoomId())) { + delete(conn.getConnectionId()); + deletedCount++; + } + } + + if (deletedCount > 0) { + logger.info("Deleted {} existing connections for user {} in room {}", deletedCount, userId, roomId); + } + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java index 133e49ff..3ca83cff 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java @@ -159,6 +159,7 @@ public GameStartResult startGame(String roomId, String userId) { .currentDrawerId(firstDrawer) .currentWordId(firstWord.getWordId()) .currentWord(firstWord.getKorean()) + .currentWordEnglish(firstWord.getEnglish()) .roundStartTime(currentTime) .roundDuration(GameConfig.roundTimeLimit()) .scores(new HashMap<>()) @@ -259,9 +260,10 @@ public AnswerCheckResult checkAnswer(String roomId, String userId, String answer return AnswerCheckResult.alreadyGuessedCorrect(); } - // 정답 체크 - String currentWord = session.getCurrentWord(); - if (!isCorrectAnswer(answer, currentWord)) { + // 정답 체크 (한국어 또는 영어 둘 다 허용) + String koreanWord = session.getCurrentWord(); + String englishWord = session.getCurrentWordEnglish(); + if (!isCorrectAnswer(answer, koreanWord, englishWord)) { return AnswerCheckResult.wrongAnswer(); } @@ -411,6 +413,7 @@ public CommandResult endRound(GameSession session, ChatRoom room, String reason) session.setCurrentDrawerId(nextDrawer); session.setCurrentWordId(nextWord.getWordId()); session.setCurrentWord(nextWord.getKorean()); + session.setCurrentWordEnglish(nextWord.getEnglish()); session.setRoundStartTime(currentTime); session.setHintUsed(false); session.setCorrectGuessers(new ArrayList<>()); @@ -584,24 +587,43 @@ private String selectNextDrawer(List drawerOrder, Set connectedU /** * 랜덤 단어 추출 + * VocabTable은 LEVEL#BEGINNER 형식(대문자)으로 저장되어 있으므로 + * ChatRoom의 level(소문자)을 대문자로 변환 */ private List getRandomWords(String level, int count) { - PaginatedResult result = wordRepository.findByLevelWithPagination(level, 50, null); + // ChatRoom.level은 소문자(beginner), VocabTable GSI1PK는 대문자(BEGINNER) + String normalizedLevel = level != null ? level.toUpperCase() : "BEGINNER"; + PaginatedResult result = wordRepository.findByLevelWithPagination(normalizedLevel, 50, null); List words = new ArrayList<>(result.items()); Collections.shuffle(words); return words.stream().limit(count).collect(Collectors.toList()); } /** - * 정답 체크 로직 + * 정답 체크 로직 (한국어 또는 영어 둘 다 허용) */ - private boolean isCorrectAnswer(String input, String answer) { - if (input == null || answer == null) return false; + private boolean isCorrectAnswer(String input, String koreanAnswer, String englishAnswer) { + if (input == null) return false; String normalizedInput = input.trim().toLowerCase().replace(" ", ""); - String normalizedAnswer = answer.trim().toLowerCase().replace(" ", ""); - return normalizedInput.equals(normalizedAnswer); + // 한국어 정답 체크 + if (koreanAnswer != null) { + String normalizedKorean = koreanAnswer.trim().toLowerCase().replace(" ", ""); + if (normalizedInput.equals(normalizedKorean)) { + return true; + } + } + + // 영어 정답 체크 + if (englishAnswer != null) { + String normalizedEnglish = englishAnswer.trim().toLowerCase().replace(" ", ""); + if (normalizedInput.equals(normalizedEnglish)) { + return true; + } + } + + return false; } /** diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index ce4478b1..09663db9 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -458,6 +458,8 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable + - DynamoDBReadPolicy: + TableName: !Ref VocabTable - Statement: - Effect: Allow Action: From 7cdb9b3c493e5a2db826be6ea5287c5e6ee6376a Mon Sep 17 00:00:00 2001 From: DDING JOO Date: Wed, 21 Jan 2026 15:16:56 +0900 Subject: [PATCH 22/99] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=A0=9C=EA=B1=B0=20(#459)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService --- .../serverless/common/dto/ErrorInfo.java | 16 +- .../common/exception/CommonErrorCode.java | 2 +- .../common/exception/CommonException.java | 4 +- .../common/exception/DomainErrorCode.java | 4 +- .../common/exception/ErrorCode.java | 8 +- .../common/exception/ServerlessException.java | 4 +- .../common/router/AuthenticatedHandler.java | 2 +- .../common/router/HandlerRouter.java | 10 +- .../serverless/common/router/Route.java | 2 +- .../common/util/WebSocketBroadcaster.java | 15 +- .../common/util/WebSocketResponseUtil.java | 40 --- .../common/validation/BeanValidator.java | 17 +- .../chatting/exception/ChattingErrorCode.java | 2 +- .../chatting/exception/ChattingException.java | 4 +- .../GrammarStreamingConnectHandler.java | 2 +- .../websocket/GrammarStreamingHandler.java | 80 +++-- .../stats/handler/ScheduledStatsHandler.java | 92 ++++-- .../domain/stats/model/UserStats.java | 2 +- .../user/handler/PostConfirmationHandler.java | 4 +- .../exception/VocabularyErrorCode.java | 2 +- .../exception/VocabularyException.java | 4 +- .../vocabulary/service/DailyStudyService.java | 166 ----------- .../vocabulary/service/StatsService.java | 41 ++- .../vocabulary/service/TestService.java | 276 ------------------ .../vocabulary/service/UserWordService.java | 223 -------------- .../vocabulary/service/WordService.java | 123 -------- 26 files changed, 200 insertions(+), 945 deletions(-) delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketResponseUtil.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyService.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordService.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ErrorInfo.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ErrorInfo.java index 41feeb2b..329fc230 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ErrorInfo.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ErrorInfo.java @@ -8,17 +8,17 @@ /** * RFC 7807 스타일 에러 정보 - *

+ * * Problem Details for HTTP APIs (RFC 7807) 표준을 참고한 에러 응답 형식입니다. - *

+ * * 응답 예시: * { - * "code": "VOCABULARY.WORD_001", - * "message": "단어를 찾을 수 없습니다", - * "status": 404, - * "details": { - * "wordId": "abc-123" - * } + * "code": "VOCABULARY.WORD_001", + * "message": "단어를 찾을 수 없습니다", + * "status": 404, + * "details": { + * "wordId": "abc-123" + * } * } * * @param code 에러 코드 (예: AUTH_001, VOCABULARY.WORD_001) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonErrorCode.java index 126790d9..d1276375 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonErrorCode.java @@ -2,7 +2,7 @@ /** * 공통/시스템 에러 코드 - *

+ * * 도메인에 종속되지 않는 공통 에러 코드를 정의합니다. * - 인증/인가 에러 (AUTH_XXX) * - 검증 에러 (VALIDATION_XXX) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonException.java index a6f67ed0..497ff8eb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonException.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonException.java @@ -2,10 +2,10 @@ /** * 공통/시스템 예외 클래스 - *

+ * * 도메인에 종속되지 않는 공통 예외를 처리합니다. * 정적 팩토리 메서드를 통해 가독성 높은 예외 생성을 지원합니다. - *

+ * * 사용 예시: * throw CommonException.unauthorized(); * throw CommonException.notFound("사용자"); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/DomainErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/DomainErrorCode.java index 83ddb2ef..43a454e0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/DomainErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/DomainErrorCode.java @@ -2,10 +2,10 @@ /** * 도메인별 에러 코드 인터페이스 - *

+ * * 각 도메인(Vocabulary, Chatting 등)의 비즈니스 로직 관련 에러 코드가 구현하는 인터페이스입니다. * ErrorCode를 확장하여 도메인 식별 기능을 추가합니다. - *

+ * * 구현체: * - VocabularyErrorCode - 단어 학습 도메인 * - ChattingErrorCode - 채팅 도메인 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ErrorCode.java index 35261fa0..5eabbd0a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ErrorCode.java @@ -2,16 +2,16 @@ /** * 에러 코드 표준 인터페이스 (Sealed Interface) - *

+ * * 모든 에러 코드 enum이 구현해야 하는 표준 계약을 정의합니다. * Sealed interface를 사용하여 허용된 구현체만 존재하도록 제한합니다. - *

+ * * 계층 구조: * ErrorCode (sealed) * ├── CommonErrorCode (시스템/공통 에러) * └── DomainErrorCode (non-sealed) - 도메인별 에러 - * ├── VocabularyErrorCode - * └── ChattingErrorCode + * ├── VocabularyErrorCode + * └── ChattingErrorCode */ public sealed interface ErrorCode permits CommonErrorCode, DomainErrorCode { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ServerlessException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ServerlessException.java index f95ea8ab..c9f578c0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ServerlessException.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ServerlessException.java @@ -6,10 +6,10 @@ /** * 서버리스 애플리케이션 기본 예외 클래스 - *

+ * * 모든 비즈니스 예외의 추상 기반 클래스입니다. * ErrorCode를 통해 표준화된 에러 정보를 제공합니다. - *

+ * * 사용 예시: * - CommonException: 공통/시스템 예외 * - VocabularyException: 단어 학습 도메인 예외 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/AuthenticatedHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/AuthenticatedHandler.java index b49dd275..51071fd5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/AuthenticatedHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/AuthenticatedHandler.java @@ -5,7 +5,7 @@ /** * Cognito 인증이 필요한 요청 핸들러 - *

+ * * userId가 자동으로 추출되어 전달됩니다. */ @FunctionalInterface diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java index 92d6dc1c..a7178643 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java @@ -18,16 +18,14 @@ /** * Lambda Handler를 위한 HTTP 라우터 - *

+ * * 선언적 라우팅 + 자동 Path/Query 파라미터 검증 제공 - *

+ * * 사용 예시: - *

* new HandlerRouter().addRoutes( - * Route.get("/rooms/{roomId}", this::getRoom), // roomId 자동 검증 - * Route.delete("/rooms/{roomId}", this::deleteRoom).requireQueryParams("userId") // roomId + userId 검증 + * Route.get("/rooms/{roomId}", this::getRoom), + * Route.delete("/rooms/{roomId}", this::deleteRoom).requireQueryParams("userId") * ); - * */ public class HandlerRouter { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java index 44a46f6f..a9d9f746 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java @@ -13,7 +13,7 @@ /** * HTTP 라우트 정의 - *

+ * * Path 패턴에서 자동으로 필수 파라미터를 추출합니다. * 예: "/rooms/{roomId}/messages/{messageId}" → ["roomId", "messageId"] * diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketBroadcaster.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketBroadcaster.java index f438c239..516687a0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketBroadcaster.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketBroadcaster.java @@ -17,7 +17,7 @@ /** * WebSocket 연결들에게 메시지를 브로드캐스트하는 유틸리티 */ -public class WebSocketBroadcaster { +public class WebSocketBroadcaster implements AutoCloseable { private static final Logger logger = LoggerFactory.getLogger(WebSocketBroadcaster.class); @@ -79,7 +79,18 @@ public List broadcast(List connections, String message) { logger.info("Broadcast completed: total={}, failed={}", connections.size(), failedConnections.size()); - + return failedConnections; } + + @Override + public void close() { + try { + if (apiClient != null) { + apiClient.close(); + } + } catch (Exception e) { + logger.warn("Failed to close ApiGatewayManagementApiClient: {}", e.getMessage()); + } + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketResponseUtil.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketResponseUtil.java deleted file mode 100644 index 6c14e542..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketResponseUtil.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.mzc.secondproject.serverless.common.util; - -import java.util.Map; - -/** - * WebSocket API Gateway 응답 생성 유틸리티 - */ -public final class WebSocketResponseUtil { - - private WebSocketResponseUtil() { - } - - public static Map ok(String message) { - return Map.of("statusCode", 200, "body", message); - } - - public static Map created(String message) { - return Map.of("statusCode", 201, "body", message); - } - - public static Map badRequest(String message) { - return Map.of("statusCode", 400, "body", message); - } - - public static Map unauthorized(String message) { - return Map.of("statusCode", 401, "body", message); - } - - public static Map forbidden(String message) { - return Map.of("statusCode", 403, "body", message); - } - - public static Map serverError(String message) { - return Map.of("statusCode", 500, "body", message); - } - - public static Map response(int statusCode, String message) { - return Map.of("statusCode", statusCode, "body", message); - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java index 27c204d4..9f98d4a1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java @@ -15,20 +15,17 @@ /** * Jakarta Bean Validation 기반 검증 유틸리티 - *

+ * * DTO에 선언된 @NotNull, @NotEmpty 등의 어노테이션을 검증합니다. - *

+ * * 사용 예시: - *

* CreateRoomRequest req = ResponseGenerator.gson().fromJson(body, CreateRoomRequest.class); - *

* return BeanValidator.validate(req) - * .map(error -> ResponseGenerator.fail(CommonErrorCode.REQUIRED_FIELD_MISSING, error)) - * .orElseGet(() -> { - * // 비즈니스 로직 - * return ResponseGenerator.ok("Success", result); - * }); - * + * .map(error -> ResponseGenerator.fail(CommonErrorCode.REQUIRED_FIELD_MISSING, error)) + * .orElseGet(() -> { + * // 비즈니스 로직 + * return ResponseGenerator.ok("Success", result); + * }); */ public final class BeanValidator { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java index ad599b53..be3394c0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java @@ -4,7 +4,7 @@ /** * 채팅 도메인 에러 코드 - *

+ * * 채팅방(Room), 메시지(Message), 참여자(Participant) 관련 에러 코드를 정의합니다. */ public enum ChattingErrorCode implements DomainErrorCode { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingException.java index 16b51450..a2da178a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingException.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingException.java @@ -4,9 +4,9 @@ /** * 채팅 도메인 예외 클래스 - *

+ * * 정적 팩토리 메서드를 통해 가독성 높은 예외 생성을 지원합니다. - *

+ * * 사용 예시: * throw ChattingException.roomNotFound(roomId); * throw ChattingException.notRoomMember(userId, roomId); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingConnectHandler.java index 5a6e8202..4e77dd27 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingConnectHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingConnectHandler.java @@ -16,7 +16,7 @@ /** * Grammar Streaming WebSocket $connect 핸들러 * JWT 토큰 검증 후 연결 정보를 DynamoDB에 저장 - *

+ * * 연결 방법: * wss://{api-id}.execute-api.{region}.amazonaws.com/{stage}?token={jwt} */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java index 41f1a4d4..c1d6f89e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java @@ -28,7 +28,7 @@ /** * Grammar Streaming WebSocket 핸들러 * Bedrock 스트리밍 응답을 실시간으로 클라이언트에 전송 - *

+ * * 인증: $connect에서 JWT 검증 후 저장된 연결 정보에서 userId 조회 */ public class GrammarStreamingHandler implements RequestHandler, Map> { @@ -85,35 +85,52 @@ public Map handleRequest(Map event, Context cont private void processStreamingConversation(String connectionId, String endpoint, String userId, StreamingRequest request) { ApiGatewayManagementApiClient apiClient = createApiClient(endpoint); - - // 서비스에 스트리밍 처리 위임 (userId는 JWT 인증에서 가져온 값 사용) - conversationService.chatStreaming( - request.sessionId(), - request.message(), - userId, - request.level(), - // 세션 생성 콜백 - sessionId -> sendEvent(apiClient, connectionId, new StreamingEvent.StartEvent(sessionId)), - // 스트리밍 콜백 - new StreamingCallback() { - @Override - public void onToken(String token) { - sendEvent(apiClient, connectionId, new StreamingEvent.TokenEvent(token)); - } - - @Override - public void onComplete(ConversationResponse response) { - sendEvent(apiClient, connectionId, StreamingEvent.CompleteEvent.from(response)); - logger.info("Streaming completed for session: {}", response.getSessionId()); - } - - @Override - public void onError(Throwable error) { - logger.error("Streaming error: {}", error.getMessage(), error); - sendEvent(apiClient, connectionId, new StreamingEvent.ErrorEvent(error.getMessage())); + + try { + // 서비스에 스트리밍 처리 위임 (userId는 JWT 인증에서 가져온 값 사용) + conversationService.chatStreaming( + request.sessionId(), + request.message(), + userId, + request.level(), + // 세션 생성 콜백 + sessionId -> sendEvent(apiClient, connectionId, new StreamingEvent.StartEvent(sessionId)), + // 스트리밍 콜백 + new StreamingCallback() { + @Override + public void onToken(String token) { + sendEvent(apiClient, connectionId, new StreamingEvent.TokenEvent(token)); + } + + @Override + public void onComplete(ConversationResponse response) { + sendEvent(apiClient, connectionId, StreamingEvent.CompleteEvent.from(response)); + logger.info("Streaming completed for session: {}", response.getSessionId()); + closeApiClient(apiClient); + } + + @Override + public void onError(Throwable error) { + logger.error("Streaming error: {}", error.getMessage(), error); + sendEvent(apiClient, connectionId, new StreamingEvent.ErrorEvent(error.getMessage())); + closeApiClient(apiClient); + } } - } - ); + ); + } catch (Exception e) { + closeApiClient(apiClient); + throw e; + } + } + + private void closeApiClient(ApiGatewayManagementApiClient apiClient) { + try { + if (apiClient != null) { + apiClient.close(); + } + } catch (Exception e) { + logger.warn("Failed to close ApiGatewayManagementApiClient: {}", e.getMessage()); + } } private void sendEvent(ApiGatewayManagementApiClient apiClient, String connectionId, StreamingEvent event) { @@ -162,8 +179,9 @@ private boolean sendToConnection(ApiGatewayManagementApiClient apiClient, String } private Map sendError(String connectionId, String endpoint, String message) { - ApiGatewayManagementApiClient apiClient = createApiClient(endpoint); - sendEvent(apiClient, connectionId, new StreamingEvent.ErrorEvent(message)); + try (ApiGatewayManagementApiClient apiClient = createApiClient(endpoint)) { + sendEvent(apiClient, connectionId, new StreamingEvent.ErrorEvent(message)); + } return WebSocketEventUtil.badRequest(message); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java index 550dd691..82909fd0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java @@ -9,17 +9,25 @@ import org.slf4j.LoggerFactory; import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.ScanRequest; +import software.amazon.awssdk.services.dynamodb.model.ScanResponse; +import com.mzc.secondproject.serverless.common.config.AwsClients; /** * EventBridge Scheduler Handler * 매일 자정에 실행되어 Streak 리셋만 수행 - *

+ * * 단어 학습 통계는 Write-through 방식으로 markWordLearned에서 직접 업데이트 */ public class ScheduledStatsHandler implements RequestHandler { - + private static final Logger logger = LoggerFactory.getLogger(ScheduledStatsHandler.class); private static final String TABLE_NAME = EnvConfig.getRequired("VOCAB_TABLE_NAME"); + private static final int BATCH_SIZE = 25; private final UserStatsRepository userStatsRepository; @@ -45,32 +53,70 @@ public String handleRequest(ScheduledEvent event, Context context) { /** * Streak 체크 및 리셋 - * GSI를 사용하여 Query로 처리 (Scan 대신) - *

- * 어제 학습하지 않은 사용자 중 streak이 있는 사용자만 리셋 + * TOTAL 통계 레코드 중 lastStudyDate가 어제가 아니고 currentStreak > 0인 사용자의 streak을 리셋 */ private int checkAndResetStreaks(String yesterday) { logger.info("Checking streaks for date: {}", yesterday); - - // GSI1을 사용하여 TOTAL 통계 레코드만 조회 - // GSI1PK = "STATS#TOTAL" 으로 설계하면 Query 가능 - // 현재는 GSI가 없으므로 개별 사용자별로 처리하는 방식 사용 - - // 실제로는 lastStudyDate가 어제가 아닌 사용자를 찾아야 함 - // 하지만 현재 구조상 효율적인 방법은: - // 1. 활성 사용자 목록 관리 (별도 테이블/인덱스) - // 2. 또는 클라이언트에서 streak 조회 시 계산 - - // 현재는 간단하게 구현: DailyStudy가 없는 사용자의 streak을 리셋 - // 이는 학습을 한 번이라도 한 사용자 대상 - + int resetCount = 0; - - // Note: 실제 운영에서는 활성 사용자 목록을 별도로 관리하거나 - // GSI를 lastStudyDate로 만들어 Query 하는 것이 효율적 - // 현재는 비용 최적화를 위해 이 로직은 클라이언트에서 처리하도록 변경 가능 - + Map lastEvaluatedKey = null; + + do { + // SK = "STATS#TOTAL"인 레코드만 스캔 (currentStreak > 0 필터) + ScanRequest.Builder scanBuilder = ScanRequest.builder() + .tableName(TABLE_NAME) + .filterExpression("SK = :sk AND currentStreak > :zero AND (attribute_not_exists(lastStudyDate) OR lastStudyDate <> :yesterday)") + .expressionAttributeValues(Map.of( + ":sk", AttributeValue.builder().s("STATS#TOTAL").build(), + ":zero", AttributeValue.builder().n("0").build(), + ":yesterday", AttributeValue.builder().s(yesterday).build() + )) + .limit(BATCH_SIZE); + + if (lastEvaluatedKey != null) { + scanBuilder.exclusiveStartKey(lastEvaluatedKey); + } + + ScanResponse response = AwsClients.dynamoDb().scan(scanBuilder.build()); + List> items = response.items(); + + for (Map item : items) { + String pk = item.get("PK").s(); + // PK 형식: "USERSTATS#{userId}" 에서 userId 추출 + if (pk != null && pk.startsWith("USERSTATS#")) { + String userId = pk.substring("USERSTATS#".length()); + try { + resetUserStreak(userId); + resetCount++; + logger.debug("Reset streak for user: {}", userId); + } catch (Exception e) { + logger.warn("Failed to reset streak for user {}: {}", userId, e.getMessage()); + } + } + } + + lastEvaluatedKey = response.lastEvaluatedKey(); + } while (lastEvaluatedKey != null && !lastEvaluatedKey.isEmpty()); + logger.info("Streak reset completed: {} users processed", resetCount); return resetCount; } + + /** + * 사용자의 currentStreak을 0으로 리셋 (longestStreak은 유지) + */ + private void resetUserStreak(String userId) { + userStatsRepository.updateStreak(userId, 0, + getCurrentLongestStreak(userId), + LocalDate.now().minusDays(1).toString()); + } + + /** + * 사용자의 현재 longestStreak 조회 + */ + private int getCurrentLongestStreak(String userId) { + return userStatsRepository.findTotalStats(userId) + .map(stats -> stats.getLongestStreak() != null ? stats.getLongestStreak() : 0) + .orElse(0); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java index cc25634c..fe64840a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java @@ -13,7 +13,7 @@ * 사용자 학습 통계 * PK: USER#{userId}#STATS * SK: DAILY#{date} / WEEKLY#{year}-W{week} / MONTHLY#{year}-{month} / TOTAL - *

+ * * Write-time Aggregation 패턴: * - 이벤트 발생 시 Atomic Counter로 증분 업데이트 * - 조회 시 Scan 없이 O(1) GetItem diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java index edf0ba5e..31f528f7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java @@ -13,8 +13,8 @@ /** * Cognito Post Confirmation 트리거 핸들러 - *

- * - 사용자 이메일 인증을 완료한 직후 DB에 데이터 생성 + * + * 사용자 이메일 인증을 완료한 직후 DB에 데이터 생성 */ public class PostConfirmationHandler implements RequestHandler { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCode.java index dd576d63..3c04d79d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCode.java @@ -4,7 +4,7 @@ /** * 단어 학습 도메인 에러 코드 - *

+ * * 단어(Word), 사용자 단어(UserWord), 일일 학습(DailyStudy) 관련 에러 코드를 정의합니다. */ public enum VocabularyErrorCode implements DomainErrorCode { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyException.java index 7ab6adea..3deec811 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyException.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyException.java @@ -4,9 +4,9 @@ /** * 단어 학습 도메인 예외 클래스 - *

+ * * 정적 팩토리 메서드를 통해 가독성 높은 예외 생성을 지원합니다. - *

+ * * 사용 예시: * throw VocabularyException.wordNotFound(wordId); * throw VocabularyException.invalidDifficulty("INVALID"); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyService.java deleted file mode 100644 index 2493a2ea..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyService.java +++ /dev/null @@ -1,166 +0,0 @@ -package com.mzc.secondproject.serverless.domain.vocabulary.service; - -import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.domain.vocabulary.config.VocabularyConfig; -import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; -import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; -import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.DailyStudyRepository; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.UserWordRepository; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.time.Instant; -import java.time.LocalDate; -import java.util.*; -import java.util.stream.Collectors; - -public class DailyStudyService { - - private static final Logger logger = LoggerFactory.getLogger(DailyStudyService.class); - - private final DailyStudyRepository dailyStudyRepository; - private final UserWordRepository userWordRepository; - private final WordRepository wordRepository; - - public DailyStudyService() { - this.dailyStudyRepository = new DailyStudyRepository(); - this.userWordRepository = new UserWordRepository(); - this.wordRepository = new WordRepository(); - } - - public DailyStudyResult getDailyWords(String userId, String level) { - String today = LocalDate.now().toString(); - - Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); - - DailyStudy dailyStudy; - if (optDailyStudy.isPresent()) { - dailyStudy = optDailyStudy.get(); - } else { - if (level == null || level.isEmpty()) { - throw new IllegalArgumentException("level is required for first daily study (BEGINNER, INTERMEDIATE, ADVANCED)"); - } - if (!level.equals("BEGINNER") && !level.equals("INTERMEDIATE") && !level.equals("ADVANCED")) { - throw new IllegalArgumentException("Invalid level. Must be BEGINNER, INTERMEDIATE, or ADVANCED"); - } - dailyStudy = createDailyStudy(userId, today, level); - } - - List newWords = getWordDetails(dailyStudy.getNewWordIds()); - List reviewWords = getWordDetails(dailyStudy.getReviewWordIds()); - Map progress = calculateProgress(dailyStudy); - - return new DailyStudyResult(dailyStudy, newWords, reviewWords, progress); - } - - public Map markWordLearned(String userId, String wordId) { - String today = LocalDate.now().toString(); - - Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); - if (optDailyStudy.isEmpty()) { - throw new IllegalStateException("Daily study not found"); - } - - DailyStudy dailyStudy = optDailyStudy.get(); - - if (dailyStudy.getLearnedWordIds() != null && dailyStudy.getLearnedWordIds().contains(wordId)) { - return calculateProgress(dailyStudy); - } - - dailyStudyRepository.addLearnedWord(userId, today, wordId); - - DailyStudy updatedDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today).orElse(dailyStudy); - - if (updatedDailyStudy.getLearnedCount() >= updatedDailyStudy.getTotalWords()) { - updatedDailyStudy.setIsCompleted(true); - dailyStudyRepository.save(updatedDailyStudy); - } - - logger.info("Marked word as learned: userId={}, wordId={}", userId, wordId); - return calculateProgress(updatedDailyStudy); - } - - private DailyStudy createDailyStudy(String userId, String date, String level) { - String now = Instant.now().toString(); - - PaginatedResult reviewPage = userWordRepository.findReviewDueWords(userId, date, VocabularyConfig.reviewWordsCount(), null); - List reviewWordIds = reviewPage.items().stream() - .map(UserWord::getWordId) - .collect(Collectors.toList()); - - List newWordIds = getNewWordsForUser(userId, level, VocabularyConfig.newWordsCount()); - - DailyStudy dailyStudy = DailyStudy.builder() - .pk("DAILY#" + userId) - .sk("DATE#" + date) - .gsi1pk("DAILY#ALL") - .gsi1sk("DATE#" + date) - .userId(userId) - .date(date) - .newWordIds(newWordIds) - .reviewWordIds(reviewWordIds) - .learnedWordIds(new ArrayList<>()) - .totalWords(newWordIds.size() + reviewWordIds.size()) - .learnedCount(0) - .isCompleted(false) - .createdAt(now) - .updatedAt(now) - .build(); - - dailyStudyRepository.save(dailyStudy); - logger.info("Created daily study for user: {}, date: {}", userId, date); - - return dailyStudy; - } - - private List getNewWordsForUser(String userId, String level, int count) { - PaginatedResult userWordPage = userWordRepository.findByUserIdWithPagination(userId, 1000, null); - List learnedWordIds = userWordPage.items().stream() - .map(UserWord::getWordId) - .collect(Collectors.toList()); - - List newWordIds = new ArrayList<>(); - String lastEvaluatedKey = null; - - do { - PaginatedResult wordPage = wordRepository.findByLevelWithPagination(level, count * 2, lastEvaluatedKey); - for (Word word : wordPage.items()) { - if (!learnedWordIds.contains(word.getWordId()) && !newWordIds.contains(word.getWordId())) { - newWordIds.add(word.getWordId()); - if (newWordIds.size() >= count) break; - } - } - lastEvaluatedKey = wordPage.nextCursor(); - } while (newWordIds.size() < count && lastEvaluatedKey != null); - - logger.info("Selected {} new words for user {} at level {}", newWordIds.size(), userId, level); - return newWordIds; - } - - private List getWordDetails(List wordIds) { - if (wordIds == null || wordIds.isEmpty()) { - return new ArrayList<>(); - } - return wordRepository.findByIds(wordIds); - } - - private Map calculateProgress(DailyStudy dailyStudy) { - Map progress = new HashMap<>(); - int total = dailyStudy.getTotalWords(); - int learned = dailyStudy.getLearnedCount(); - - progress.put("total", total); - progress.put("learned", learned); - progress.put("remaining", total - learned); - progress.put("percentage", total > 0 ? (learned * 100.0 / total) : 0); - progress.put("isCompleted", dailyStudy.getIsCompleted()); - - return progress; - } - - public record DailyStudyResult(DailyStudy dailyStudy, List newWords, List reviewWords, - Map progress) { - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java index c3664156..3e49865d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java @@ -12,7 +12,9 @@ import org.slf4j.LoggerFactory; import java.util.*; +import java.util.function.Function; import java.util.stream.Collectors; +import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; public class StatsService { @@ -111,7 +113,7 @@ public Map getWeaknessAnalysis(String userId) { allUserWords.addAll(page.items()); cursor = page.nextCursor(); } while (cursor != null); - + if (allUserWords.isEmpty()) { Map emptyResult = new HashMap<>(); emptyResult.put("weakestWords", List.of()); @@ -120,7 +122,16 @@ public Map getWeaknessAnalysis(String userId) { emptyResult.put("suggestions", List.of()); return emptyResult; } - + + // 배치 조회로 N+1 문제 해결: 모든 wordId를 수집하여 한 번에 조회 + List wordIds = allUserWords.stream() + .map(UserWord::getWordId) + .distinct() + .collect(Collectors.toList()); + List words = wordRepository.findByIds(wordIds); + Map wordMap = words.stream() + .collect(Collectors.toMap(Word::getWordId, Function.identity(), (a, b) -> a)); + List> weakestWords = allUserWords.stream() .filter(uw -> uw.getIncorrectCount() != null && uw.getIncorrectCount() > 0) .sorted(Comparator.comparingInt(UserWord::getIncorrectCount).reversed()) @@ -131,34 +142,36 @@ public Map getWeaknessAnalysis(String userId) { wordInfo.put("incorrectCount", uw.getIncorrectCount()); wordInfo.put("correctCount", uw.getCorrectCount()); wordInfo.put("status", uw.getStatus()); - - wordRepository.findById(uw.getWordId()).ifPresent(word -> { + + Word word = wordMap.get(uw.getWordId()); + if (word != null) { wordInfo.put("english", word.getEnglish()); wordInfo.put("korean", word.getKorean()); wordInfo.put("level", word.getLevel()); wordInfo.put("category", word.getCategory()); - }); - + } + int total = (uw.getCorrectCount() != null ? uw.getCorrectCount() : 0) + (uw.getIncorrectCount() != null ? uw.getIncorrectCount() : 0); wordInfo.put("accuracy", total > 0 ? (uw.getCorrectCount() != null ? uw.getCorrectCount() * 100.0 / total : 0) : 0); - + return wordInfo; }) .collect(Collectors.toList()); - + Map> categoryAnalysis = new HashMap<>(); Map> levelAnalysis = new HashMap<>(); - + for (UserWord uw : allUserWords) { - wordRepository.findById(uw.getWordId()).ifPresent(word -> { + Word word = wordMap.get(uw.getWordId()); + if (word != null) { String category = word.getCategory(); String level = word.getLevel(); - + int correct = uw.getCorrectCount() != null ? uw.getCorrectCount() : 0; int incorrect = uw.getIncorrectCount() != null ? uw.getIncorrectCount() : 0; - + categoryAnalysis.computeIfAbsent(category, k -> { Map stats = new HashMap<>(); stats.put("totalCorrect", 0); @@ -170,7 +183,7 @@ public Map getWeaknessAnalysis(String userId) { catStats.put("totalCorrect", (Integer) catStats.get("totalCorrect") + correct); catStats.put("totalIncorrect", (Integer) catStats.get("totalIncorrect") + incorrect); catStats.put("wordCount", (Integer) catStats.get("wordCount") + 1); - + levelAnalysis.computeIfAbsent(level, k -> { Map stats = new HashMap<>(); stats.put("totalCorrect", 0); @@ -182,7 +195,7 @@ public Map getWeaknessAnalysis(String userId) { lvlStats.put("totalCorrect", (Integer) lvlStats.get("totalCorrect") + correct); lvlStats.put("totalIncorrect", (Integer) lvlStats.get("totalIncorrect") + incorrect); lvlStats.put("wordCount", (Integer) lvlStats.get("wordCount") + 1); - }); + } } categoryAnalysis.values().forEach(stats -> { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java deleted file mode 100644 index 9851b274..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java +++ /dev/null @@ -1,276 +0,0 @@ -package com.mzc.secondproject.serverless.domain.vocabulary.service; - -import com.mzc.secondproject.serverless.common.config.AwsClients; -import com.mzc.secondproject.serverless.common.config.EnvConfig; -import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.common.util.ResponseGenerator; -import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; -import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; -import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.DailyStudyRepository; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.TestResultRepository; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.services.sns.model.PublishRequest; - -import java.time.Instant; -import java.time.LocalDate; -import java.util.*; -import java.util.stream.Collectors; - -public class TestService { - - private static final Logger logger = LoggerFactory.getLogger(TestService.class); - private static final String TEST_RESULT_TOPIC_ARN = EnvConfig.getRequired("TEST_RESULT_TOPIC_ARN"); - - private final TestResultRepository testResultRepository; - private final DailyStudyRepository dailyStudyRepository; - private final WordRepository wordRepository; - - public TestService() { - this.testResultRepository = new TestResultRepository(); - this.dailyStudyRepository = new DailyStudyRepository(); - this.wordRepository = new WordRepository(); - } - - public StartTestResult startTest(String userId, String testType) { - String today = LocalDate.now().toString(); - - Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); - if (optDailyStudy.isEmpty()) { - throw new IllegalStateException("No daily study found for today"); - } - - DailyStudy dailyStudy = optDailyStudy.get(); - List allWordIds = new ArrayList<>(); - if (dailyStudy.getNewWordIds() != null) allWordIds.addAll(dailyStudy.getNewWordIds()); - if (dailyStudy.getReviewWordIds() != null) allWordIds.addAll(dailyStudy.getReviewWordIds()); - - if (allWordIds.isEmpty()) { - throw new IllegalStateException("No words to test"); - } - - List words = wordRepository.findByIds(allWordIds); - - Map> wordsByLevel = words.stream() - .collect(Collectors.groupingBy(Word::getLevel)); - - Map> distractorsByLevel = new HashMap<>(); - for (String level : wordsByLevel.keySet()) { - List distractors = getDistractorsForLevel(level, allWordIds); - distractorsByLevel.put(level, distractors); - } - - Random random = new Random(); - List> questions = new ArrayList<>(); - for (Word word : words) { - Map question = new HashMap<>(); - question.put("wordId", word.getWordId()); - question.put("english", word.getEnglish()); - question.put("example", word.getExample()); - - List options = generateOptions(word, wordsByLevel, distractorsByLevel, random); - question.put("options", options); - - questions.add(question); - } - - String testId = UUID.randomUUID().toString(); - String now = Instant.now().toString(); - - logger.info("Started test: userId={}, testId={}, questions={}", userId, testId, questions.size()); - - return new StartTestResult(testId, testType, questions, questions.size(), now); - } - - public SubmitTestResult submitTest(String userId, String testId, String testType, - List> answers, String startedAt) { - // 1. 답안 채점 - GradingResult gradingResult = gradeAnswers(answers); - - // 2. 테스트 결과 저장 - saveTestResult(userId, testId, testType, gradingResult, startedAt); - - // 3. SNS 알림 발행 - publishTestResultToSns(userId, gradingResult.results()); - - logger.info("Test submitted: userId={}, testId={}, successRate={}%", - userId, testId, gradingResult.successRate()); - - return new SubmitTestResult( - testId, testType, gradingResult.totalQuestions(), - gradingResult.correctCount(), gradingResult.incorrectCount(), - gradingResult.successRate(), gradingResult.results() - ); - } - - private GradingResult gradeAnswers(List> answers) { - List wordIds = answers.stream() - .map(a -> (String) a.get("wordId")) - .collect(Collectors.toList()); - - Map wordMap = wordRepository.findByIds(wordIds).stream() - .collect(Collectors.toMap(Word::getWordId, w -> w)); - - int correctCount = 0; - int incorrectCount = 0; - List incorrectWordIds = new ArrayList<>(); - List> results = new ArrayList<>(); - - for (Map answer : answers) { - String wordId = (String) answer.get("wordId"); - String userAnswer = (String) answer.get("answer"); - Word word = wordMap.get(wordId); - - if (word == null) continue; - - boolean isCorrect = isAnswerCorrect(userAnswer, word.getKorean()); - results.add(buildResultItem(word, userAnswer, isCorrect)); - - if (isCorrect) { - correctCount++; - } else { - incorrectCount++; - incorrectWordIds.add(wordId); - } - } - - int totalQuestions = answers.size(); - double successRate = totalQuestions > 0 ? (correctCount * 100.0 / totalQuestions) : 0; - - return new GradingResult(wordIds, correctCount, incorrectCount, incorrectWordIds, - totalQuestions, successRate, results); - } - - private boolean isAnswerCorrect(String userAnswer, String correctAnswer) { - return userAnswer != null - && !userAnswer.isBlank() - && correctAnswer.trim().equalsIgnoreCase(userAnswer.trim()); - } - - private Map buildResultItem(Word word, String userAnswer, boolean isCorrect) { - Map resultItem = new HashMap<>(); - resultItem.put("wordId", word.getWordId()); - resultItem.put("english", word.getEnglish()); - resultItem.put("correctAnswer", word.getKorean()); - resultItem.put("userAnswer", userAnswer != null ? userAnswer : ""); - resultItem.put("isCorrect", isCorrect); - return resultItem; - } - - private void saveTestResult(String userId, String testId, String testType, - GradingResult gradingResult, String startedAt) { - String now = Instant.now().toString(); - String today = LocalDate.now().toString(); - - TestResult testResult = TestResult.builder() - .pk("TEST#" + userId) - .sk("RESULT#" + now) - .gsi1pk("TEST#ALL") - .gsi1sk("DATE#" + today) - .testId(testId) - .userId(userId) - .testType(testType) - .totalQuestions(gradingResult.totalQuestions()) - .correctAnswers(gradingResult.correctCount()) - .incorrectAnswers(gradingResult.incorrectCount()) - .successRate(gradingResult.successRate()) - .incorrectWordIds(gradingResult.incorrectWordIds()) - .startedAt(startedAt) - .completedAt(now) - .build(); - - testResultRepository.save(testResult); - } - - private record GradingResult( - List wordIds, - int correctCount, - int incorrectCount, - List incorrectWordIds, - int totalQuestions, - double successRate, - List> results - ) {} - - public PaginatedResult getTestResults(String userId, int limit, String cursor) { - return testResultRepository.findByUserIdWithPagination(userId, limit, cursor); - } - - private List getDistractorsForLevel(String level, List excludeWordIds) { - PaginatedResult wordPage = wordRepository.findByLevelWithPagination(level, 50, null); - return wordPage.items().stream() - .filter(w -> !excludeWordIds.contains(w.getWordId())) - .map(Word::getKorean) - .collect(Collectors.toList()); - } - - private List generateOptions(Word correctWord, Map> wordsByLevel, - Map> distractorsByLevel, Random random) { - List options = new ArrayList<>(); - String correctAnswer = correctWord.getKorean(); - options.add(correctAnswer); - - String level = correctWord.getLevel(); - - List sameLevelOptions = wordsByLevel.getOrDefault(level, new ArrayList<>()).stream() - .filter(w -> !w.getWordId().equals(correctWord.getWordId())) - .map(Word::getKorean) - .collect(Collectors.toList()); - - List additionalDistractors = distractorsByLevel.getOrDefault(level, new ArrayList<>()); - - List allDistractors = new ArrayList<>(); - allDistractors.addAll(sameLevelOptions); - allDistractors.addAll(additionalDistractors); - - allDistractors = allDistractors.stream() - .filter(d -> !d.equals(correctAnswer)) - .distinct() - .collect(Collectors.toList()); - - Collections.shuffle(allDistractors, random); - int distractorCount = Math.min(3, allDistractors.size()); - for (int i = 0; i < distractorCount; i++) { - options.add(allDistractors.get(i)); - } - - Collections.shuffle(options, random); - return options; - } - - private void publishTestResultToSns(String userId, List> results) { - if (TEST_RESULT_TOPIC_ARN == null || TEST_RESULT_TOPIC_ARN.isEmpty()) { - logger.warn("TEST_RESULT_TOPIC_ARN is not configured, skipping SNS publish"); - return; - } - - try { - Map message = new HashMap<>(); - message.put("userId", userId); - message.put("results", results); - - String messageJson = ResponseGenerator.gson().toJson(message); - - PublishRequest publishRequest = PublishRequest.builder() - .topicArn(TEST_RESULT_TOPIC_ARN) - .message(messageJson) - .build(); - - AwsClients.sns().publish(publishRequest); - logger.info("Published test result to SNS for user: {}", userId); - } catch (Exception e) { - logger.error("Failed to publish test result to SNS for user: {}", userId, e); - } - } - - public record StartTestResult(String testId, String testType, List> questions, - int totalQuestions, String startedAt) { - } - - public record SubmitTestResult(String testId, String testType, int totalQuestions, - int correctCount, int incorrectCount, double successRate, - List> results) { - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordService.java deleted file mode 100644 index c0bf7c3f..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordService.java +++ /dev/null @@ -1,223 +0,0 @@ -package com.mzc.secondproject.serverless.domain.vocabulary.service; - -import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; -import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.UserWordRepository; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.time.Instant; -import java.time.LocalDate; -import java.util.*; -import java.util.stream.Collectors; - -public class UserWordService { - - private static final Logger logger = LoggerFactory.getLogger(UserWordService.class); - - private final UserWordRepository userWordRepository; - private final WordRepository wordRepository; - - public UserWordService() { - this.userWordRepository = new UserWordRepository(); - this.wordRepository = new WordRepository(); - } - - public UserWordsResult getUserWords(String userId, String status, String bookmarked, - String incorrectOnly, int limit, String cursor) { - PaginatedResult userWordPage; - - if ("true".equalsIgnoreCase(bookmarked)) { - userWordPage = userWordRepository.findBookmarkedWords(userId, limit, cursor); - } else if ("true".equalsIgnoreCase(incorrectOnly)) { - userWordPage = userWordRepository.findIncorrectWords(userId, limit, cursor); - } else if (status != null && !status.isEmpty()) { - userWordPage = userWordRepository.findByUserIdAndStatus(userId, status, limit, cursor); - } else { - userWordPage = userWordRepository.findByUserIdWithPagination(userId, limit, cursor); - } - - List> enrichedUserWords = enrichWithWordInfo(userWordPage.items()); - - return new UserWordsResult(enrichedUserWords, userWordPage.nextCursor(), userWordPage.hasMore()); - } - - public Optional getUserWord(String userId, String wordId) { - return userWordRepository.findByUserIdAndWordId(userId, wordId); - } - - public UserWord updateUserWord(String userId, String wordId, boolean isCorrect) { - Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); - UserWord userWord; - String now = Instant.now().toString(); - - if (optUserWord.isEmpty()) { - userWord = UserWord.builder() - .pk("USER#" + userId) - .sk("WORD#" + wordId) - .gsi1pk("USER#" + userId + "#REVIEW") - .gsi2pk("USER#" + userId + "#STATUS") - .userId(userId) - .wordId(wordId) - .status("NEW") - .interval(1) - .easeFactor(2.5) - .repetitions(0) - .correctCount(0) - .incorrectCount(0) - .createdAt(now) - .build(); - } else { - userWord = optUserWord.get(); - } - - applySpacedRepetition(userWord, isCorrect); - userWord.setUpdatedAt(now); - userWord.setLastReviewedAt(now); - - userWord.setGsi1sk("DATE#" + userWord.getNextReviewAt()); - userWord.setGsi2sk("STATUS#" + userWord.getStatus()); - - userWordRepository.save(userWord); - - logger.info("Updated user word: userId={}, wordId={}, isCorrect={}", userId, wordId, isCorrect); - return userWord; - } - - public UserWord updateUserWordTag(String userId, String wordId, Boolean bookmarked, - Boolean favorite, String difficulty) { - Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); - UserWord userWord; - String now = Instant.now().toString(); - - if (optUserWord.isEmpty()) { - userWord = UserWord.builder() - .pk("USER#" + userId) - .sk("WORD#" + wordId) - .gsi1pk("USER#" + userId + "#REVIEW") - .gsi2pk("USER#" + userId + "#STATUS") - .gsi2sk("STATUS#NEW") - .userId(userId) - .wordId(wordId) - .status("NEW") - .interval(1) - .easeFactor(2.5) - .repetitions(0) - .correctCount(0) - .incorrectCount(0) - .bookmarked(false) - .favorite(false) - .createdAt(now) - .build(); - } else { - userWord = optUserWord.get(); - } - - if (bookmarked != null) { - userWord.setBookmarked(bookmarked); - } - if (favorite != null) { - userWord.setFavorite(favorite); - } - if (difficulty != null) { - if (!difficulty.equals("EASY") && !difficulty.equals("NORMAL") && !difficulty.equals("HARD")) { - throw new IllegalArgumentException("difficulty must be EASY, NORMAL, or HARD"); - } - userWord.setDifficulty(difficulty); - } - - userWord.setUpdatedAt(now); - userWordRepository.save(userWord); - - logger.info("Updated user word tag: userId={}, wordId={}", userId, wordId); - return userWord; - } - - private void applySpacedRepetition(UserWord userWord, boolean isCorrect) { - if (isCorrect) { - userWord.setCorrectCount(userWord.getCorrectCount() + 1); - userWord.setRepetitions(userWord.getRepetitions() + 1); - - if (userWord.getRepetitions() == 1) { - userWord.setInterval(1); - } else if (userWord.getRepetitions() == 2) { - userWord.setInterval(6); - } else { - int newInterval = (int) Math.round(userWord.getInterval() * userWord.getEaseFactor()); - userWord.setInterval(newInterval); - } - - if (userWord.getRepetitions() >= 5) { - userWord.setStatus("MASTERED"); - } else if (userWord.getRepetitions() >= 2) { - userWord.setStatus("REVIEWING"); - } else { - userWord.setStatus("LEARNING"); - } - } else { - userWord.setIncorrectCount(userWord.getIncorrectCount() + 1); - userWord.setRepetitions(0); - userWord.setInterval(1); - userWord.setStatus("LEARNING"); - - double newEaseFactor = userWord.getEaseFactor() - 0.2; - userWord.setEaseFactor(Math.max(1.3, newEaseFactor)); - } - - LocalDate nextReview = LocalDate.now().plusDays(userWord.getInterval()); - userWord.setNextReviewAt(nextReview.toString()); - } - - private List> enrichWithWordInfo(List userWords) { - if (userWords == null || userWords.isEmpty()) { - return new ArrayList<>(); - } - - List wordIds = userWords.stream() - .map(UserWord::getWordId) - .collect(Collectors.toList()); - - List words = wordRepository.findByIds(wordIds); - - Map wordMap = words.stream() - .collect(Collectors.toMap(Word::getWordId, w -> w, (w1, w2) -> w1)); - - List> enrichedList = new ArrayList<>(); - for (UserWord userWord : userWords) { - Map enriched = new HashMap<>(); - - enriched.put("wordId", userWord.getWordId()); - enriched.put("userId", userWord.getUserId()); - enriched.put("status", userWord.getStatus()); - enriched.put("correctCount", userWord.getCorrectCount()); - enriched.put("incorrectCount", userWord.getIncorrectCount()); - enriched.put("bookmarked", userWord.getBookmarked()); - enriched.put("favorite", userWord.getFavorite()); - enriched.put("difficulty", userWord.getDifficulty()); - enriched.put("nextReviewAt", userWord.getNextReviewAt()); - enriched.put("lastReviewedAt", userWord.getLastReviewedAt()); - enriched.put("repetitions", userWord.getRepetitions()); - enriched.put("interval", userWord.getInterval()); - - Word word = wordMap.get(userWord.getWordId()); - if (word != null) { - enriched.put("english", word.getEnglish()); - enriched.put("korean", word.getKorean()); - enriched.put("level", word.getLevel()); - enriched.put("category", word.getCategory()); - enriched.put("example", word.getExample()); - enriched.put("maleVoiceKey", word.getMaleVoiceKey()); - enriched.put("femaleVoiceKey", word.getFemaleVoiceKey()); - } - - enrichedList.add(enriched); - } - - return enrichedList; - } - - public record UserWordsResult(List> userWords, String nextCursor, boolean hasMore) { - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordService.java deleted file mode 100644 index bfea0b71..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordService.java +++ /dev/null @@ -1,123 +0,0 @@ -package com.mzc.secondproject.serverless.domain.vocabulary.service; - -import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.domain.vocabulary.factory.WordFactory; -import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.List; -import java.util.Map; -import java.util.Optional; - -public class WordService { - - private static final Logger logger = LoggerFactory.getLogger(WordService.class); - - private final WordRepository wordRepository; - private final WordFactory wordFactory; - - /** - * 기본 생성자 (Lambda에서 사용) - */ - public WordService() { - this(new WordRepository(), new WordFactory()); - } - - /** - * 의존성 주입 생성자 (테스트 용이성) - */ - public WordService(WordRepository wordRepository, WordFactory wordFactory) { - this.wordRepository = wordRepository; - this.wordFactory = wordFactory; - } - - public Word createWord(String english, String korean, String example, String level, String category) { - Word word = wordFactory.create(english, korean, example, level, category); - wordRepository.save(word); - logger.info("Created word: {}", word.getWordId()); - return word; - } - - public Optional getWord(String wordId) { - return wordRepository.findById(wordId); - } - - public PaginatedResult getWords(String level, String category, int limit, String cursor) { - if (level != null && !level.isEmpty()) { - return wordRepository.findByLevelWithPagination(level, limit, cursor); - } else if (category != null && !category.isEmpty()) { - return wordRepository.findByCategoryWithPagination(category, limit, cursor); - } - return wordRepository.findByLevelWithPagination("BEGINNER", limit, cursor); - } - - public Word updateWord(String wordId, Map updates) { - Optional optWord = wordRepository.findById(wordId); - if (optWord.isEmpty()) { - throw new IllegalArgumentException("Word not found"); - } - - Word word = optWord.get(); - wordFactory.updateFields( - word, - (String) updates.get("english"), - (String) updates.get("korean"), - (String) updates.get("example"), - (String) updates.get("level"), - (String) updates.get("category") - ); - - wordRepository.save(word); - logger.info("Updated word: {}", wordId); - return word; - } - - public void deleteWord(String wordId) { - Optional optWord = wordRepository.findById(wordId); - if (optWord.isEmpty()) { - throw new IllegalArgumentException("Word not found"); - } - - wordRepository.delete(wordId); - logger.info("Deleted word: {}", wordId); - } - - public BatchResult createWordsBatch(List> wordsList) { - int successCount = 0; - int failCount = 0; - - for (Map wordData : wordsList) { - try { - String english = (String) wordData.get("english"); - String korean = (String) wordData.get("korean"); - String example = (String) wordData.get("example"); - String level = (String) wordData.get("level"); - String category = (String) wordData.get("category"); - - if (english == null || korean == null) { - failCount++; - continue; - } - - Word word = wordFactory.create(english, korean, example, level, category); - wordRepository.save(word); - successCount++; - } catch (Exception e) { - logger.error("Failed to create word", e); - failCount++; - } - } - - logger.info("Batch created {} words, failed {}", successCount, failCount); - return new BatchResult(successCount, failCount, wordsList.size()); - } - - public PaginatedResult searchWords(String query, int limit, String cursor) { - return wordRepository.searchByKeyword(query, limit, cursor); - } - - public record BatchResult(int successCount, int failCount, int totalRequested) { - } -} From 90c0135c814e074789cc51c2c77b8c4499d9532f Mon Sep 17 00:00:00 2001 From: DDING JOO Date: Wed, 21 Jan 2026 15:42:53 +0900 Subject: [PATCH 23/99] =?UTF-8?q?refactor(all):=20DI=20=ED=8C=A8=ED=84=B4?= =?UTF-8?q?=20=EB=B0=8F=20=EC=A0=84=EB=9E=B5=20=ED=8C=A8=ED=84=B4=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20(#461)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) --- .../domain/badge/handler/BadgeHandler.java | 12 +++- .../badge/repository/BadgeRepository.java | 15 +++- .../domain/badge/service/BadgeService.java | 68 +++++++------------ .../badge/strategy/AccuracyStrategy.java | 34 ++++++++++ .../strategy/BadgeConditionStrategy.java | 25 +++++++ .../BadgeConditionStrategyFactory.java | 40 +++++++++++ .../badge/strategy/FirstStudyStrategy.java | 25 +++++++ .../badge/strategy/GamesPlayedStrategy.java | 25 +++++++ .../badge/strategy/GamesWonStrategy.java | 25 +++++++ .../domain/badge/strategy/NoOpStrategy.java | 32 +++++++++ .../badge/strategy/PerfectDrawsStrategy.java | 25 +++++++ .../badge/strategy/QuickGuessesStrategy.java | 25 +++++++ .../domain/badge/strategy/StreakStrategy.java | 25 +++++++ .../strategy/TestsCompletedStrategy.java | 25 +++++++ .../badge/strategy/WordsLearnedStrategy.java | 32 +++++++++ .../chatting/handler/ChatMessageHandler.java | 14 +++- .../chatting/handler/ChatRoomHandler.java | 14 +++- .../chatting/handler/ChatVoiceHandler.java | 14 +++- .../handler/GameAutoCloseHandler.java | 17 ++++- .../domain/chatting/handler/GameHandler.java | 19 ++++-- .../chatting/handler/GameSessionHandler.java | 19 ++++-- .../repository/ChatMessageRepository.java | 15 +++- .../repository/ChatRoomRepository.java | 15 +++- .../repository/ConnectionRepository.java | 15 +++- .../repository/GameRoundRepository.java | 12 +++- .../repository/GameSessionRepository.java | 13 +++- .../repository/RoomTokenRepository.java | 15 +++- .../chatting/service/ChatMessageService.java | 14 +++- .../service/ChatRoomCommandService.java | 25 +++++-- .../service/ChatRoomQueryService.java | 14 +++- .../chatting/service/CommandService.java | 18 ++++- .../chatting/service/GameSchedulerClient.java | 18 ++++- .../chatting/service/GameStatsService.java | 20 ++++-- .../chatting/service/RoomTokenService.java | 14 +++- .../grammar/handler/GrammarHandler.java | 17 ++++- .../GrammarConnectionRepository.java | 18 +++-- .../repository/GrammarSessionRepository.java | 17 ++++- .../stats/handler/ScheduledStatsHandler.java | 12 +++- .../stats/handler/StatsStreamHandler.java | 14 +++- .../stats/handler/UserStatsHandler.java | 14 +++- .../stats/repository/UserStatsRepository.java | 15 +++- .../domain/stats/service/StatsService.java | 14 +++- .../vocabulary/handler/DailyStudyHandler.java | 14 +++- .../vocabulary/handler/StatisticsHandler.java | 12 +++- .../vocabulary/handler/StatsHandler.java | 12 +++- .../vocabulary/handler/TestHandler.java | 14 +++- .../vocabulary/handler/UserWordHandler.java | 14 +++- .../vocabulary/handler/VoiceHandler.java | 14 +++- .../vocabulary/handler/WordGroupHandler.java | 14 +++- .../vocabulary/handler/WordHandler.java | 14 +++- .../repository/DailyStudyRepository.java | 15 +++- .../repository/TestResultRepository.java | 15 +++- .../repository/UserWordRepository.java | 14 +++- .../repository/WordGroupRepository.java | 15 +++- .../vocabulary/repository/WordRepository.java | 12 +++- .../service/DailyStudyCommandService.java | 27 ++++++-- .../service/DailyStudyQueryService.java | 16 ++++- .../vocabulary/service/StatisticsService.java | 14 +++- .../vocabulary/service/StatsService.java | 24 +++++-- .../service/TestCommandService.java | 24 +++++-- .../vocabulary/service/TestQueryService.java | 16 ++++- .../service/UserWordCommandService.java | 14 +++- .../service/UserWordQueryService.java | 16 ++++- .../service/WordCommandService.java | 14 +++- .../service/WordGroupCommandService.java | 14 +++- .../service/WordGroupQueryService.java | 16 ++++- .../vocabulary/service/WordQueryService.java | 14 +++- 67 files changed, 1070 insertions(+), 177 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/AccuracyStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/FirstStudyStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesPlayedStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesWonStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NoOpStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/PerfectDrawsStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/QuickGuessesStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/StreakStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/TestsCompletedStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/WordsLearnedStrategy.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/handler/BadgeHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/handler/BadgeHandler.java index a60eb7bc..22159d80 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/handler/BadgeHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/handler/BadgeHandler.java @@ -23,8 +23,18 @@ public class BadgeHandler implements RequestHandler table; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public BadgeRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(UserBadge.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public BadgeRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(UserBadge.class)); } public void save(UserBadge badge) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java index ad69394c..62e887b3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java @@ -10,6 +10,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.mzc.secondproject.serverless.domain.badge.strategy.BadgeConditionStrategy; +import com.mzc.secondproject.serverless.domain.badge.strategy.BadgeConditionStrategyFactory; + import java.time.Instant; import java.util.ArrayList; import java.util.List; @@ -22,10 +25,20 @@ public class BadgeService { private final BadgeRepository badgeRepository; private final UserStatsRepository userStatsRepository; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public BadgeService() { - this.badgeRepository = new BadgeRepository(); - this.userStatsRepository = new UserStatsRepository(); + this(new BadgeRepository(), new UserStatsRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public BadgeService(BadgeRepository badgeRepository, UserStatsRepository userStatsRepository) { + this.badgeRepository = badgeRepository; + this.userStatsRepository = userStatsRepository; } /** @@ -132,51 +145,16 @@ private UserBadge createBadge(String userId, BadgeType type, String now) { private boolean checkBadgeCondition(BadgeType type, UserStats stats) { if (stats == null) return false; - - return switch (type.getCategory()) { - case "FIRST_STUDY" -> stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1; - case "STREAK" -> stats.getCurrentStreak() != null && stats.getCurrentStreak() >= type.getThreshold(); - case "WORDS_LEARNED" -> { - int total = (stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0) - + (stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0); - yield total >= type.getThreshold(); - } - case "PERFECT_TEST" -> false; // 별도 로직 필요 (테스트 결과에서 체크) - case "TESTS_COMPLETED" -> - stats.getTestsCompleted() != null && stats.getTestsCompleted() >= type.getThreshold(); - case "ACCURACY" -> { - if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) yield false; - double accuracy = (stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered(); - yield accuracy >= type.getThreshold(); - } - case "GAMES_PLAYED" -> stats.getGamesPlayed() != null && stats.getGamesPlayed() >= type.getThreshold(); - case "GAMES_WON" -> stats.getGamesWon() != null && stats.getGamesWon() >= type.getThreshold(); - case "QUICK_GUESSES" -> stats.getQuickGuesses() != null && stats.getQuickGuesses() >= type.getThreshold(); - case "PERFECT_DRAWS" -> stats.getPerfectDraws() != null && stats.getPerfectDraws() >= type.getThreshold(); - case "ALL_BADGES" -> false; // 별도 로직 필요 - default -> false; - }; + + BadgeConditionStrategy strategy = BadgeConditionStrategyFactory.getStrategy(type.getCategory()); + return strategy.checkCondition(type, stats); } - + private int calculateProgress(BadgeType type, UserStats stats) { if (stats == null) return 0; - - return switch (type.getCategory()) { - case "FIRST_STUDY" -> stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1 ? 1 : 0; - case "STREAK" -> stats.getCurrentStreak() != null ? stats.getCurrentStreak() : 0; - case "WORDS_LEARNED" -> (stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0) - + (stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0); - case "TESTS_COMPLETED" -> stats.getTestsCompleted() != null ? stats.getTestsCompleted() : 0; - case "ACCURACY" -> { - if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) yield 0; - yield (int) ((stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered()); - } - case "GAMES_PLAYED" -> stats.getGamesPlayed() != null ? stats.getGamesPlayed() : 0; - case "GAMES_WON" -> stats.getGamesWon() != null ? stats.getGamesWon() : 0; - case "QUICK_GUESSES" -> stats.getQuickGuesses() != null ? stats.getQuickGuesses() : 0; - case "PERFECT_DRAWS" -> stats.getPerfectDraws() != null ? stats.getPerfectDraws() : 0; - default -> 0; - }; + + BadgeConditionStrategy strategy = BadgeConditionStrategyFactory.getStrategy(type.getCategory()); + return strategy.calculateProgress(type, stats); } public record BadgeInfo( diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/AccuracyStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/AccuracyStrategy.java new file mode 100644 index 00000000..d6e5c58f --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/AccuracyStrategy.java @@ -0,0 +1,34 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 정확도 뱃지 조건 전략 + */ +public class AccuracyStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + double accuracy = calculateAccuracy(stats); + return accuracy >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return (int) calculateAccuracy(stats); + } + + @Override + public String getCategory() { + return "ACCURACY"; + } + + private double calculateAccuracy(UserStats stats) { + if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) { + return 0.0; + } + int correct = stats.getCorrectAnswers() != null ? stats.getCorrectAnswers() : 0; + return (correct * 100.0) / stats.getQuestionsAnswered(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategy.java new file mode 100644 index 00000000..10243bcd --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 뱃지 조건 확인 전략 인터페이스 + */ +public interface BadgeConditionStrategy { + + /** + * 뱃지 획득 조건 확인 + */ + boolean checkCondition(BadgeType type, UserStats stats); + + /** + * 현재 진행도 계산 + */ + int calculateProgress(BadgeType type, UserStats stats); + + /** + * 지원하는 카테고리 + */ + String getCategory(); +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java new file mode 100644 index 00000000..c855ff19 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java @@ -0,0 +1,40 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import java.util.HashMap; +import java.util.Map; + +/** + * 뱃지 조건 전략 팩토리 + * 카테고리별 전략 인스턴스를 관리하고 제공 + */ +public class BadgeConditionStrategyFactory { + + private static final Map STRATEGIES = new HashMap<>(); + private static final BadgeConditionStrategy DEFAULT_STRATEGY = new NoOpStrategy("DEFAULT"); + + static { + register(new FirstStudyStrategy()); + register(new StreakStrategy()); + register(new WordsLearnedStrategy()); + register(new TestsCompletedStrategy()); + register(new AccuracyStrategy()); + register(new GamesPlayedStrategy()); + register(new GamesWonStrategy()); + register(new QuickGuessesStrategy()); + register(new PerfectDrawsStrategy()); + // 별도 로직이 필요한 카테고리 + register(new NoOpStrategy("PERFECT_TEST")); + register(new NoOpStrategy("ALL_BADGES")); + } + + private static void register(BadgeConditionStrategy strategy) { + STRATEGIES.put(strategy.getCategory(), strategy); + } + + /** + * 카테고리에 해당하는 전략 반환 + */ + public static BadgeConditionStrategy getStrategy(String category) { + return STRATEGIES.getOrDefault(category, DEFAULT_STRATEGY); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/FirstStudyStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/FirstStudyStrategy.java new file mode 100644 index 00000000..ab23769f --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/FirstStudyStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 첫 학습 뱃지 조건 전략 + */ +public class FirstStudyStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1; + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return (stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1) ? 1 : 0; + } + + @Override + public String getCategory() { + return "FIRST_STUDY"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesPlayedStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesPlayedStrategy.java new file mode 100644 index 00000000..c8e939f6 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesPlayedStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 게임 플레이 횟수 뱃지 조건 전략 + */ +public class GamesPlayedStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getGamesPlayed() != null && stats.getGamesPlayed() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getGamesPlayed() != null ? stats.getGamesPlayed() : 0; + } + + @Override + public String getCategory() { + return "GAMES_PLAYED"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesWonStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesWonStrategy.java new file mode 100644 index 00000000..884ed90a --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesWonStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 게임 승리 횟수 뱃지 조건 전략 + */ +public class GamesWonStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getGamesWon() != null && stats.getGamesWon() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getGamesWon() != null ? stats.getGamesWon() : 0; + } + + @Override + public String getCategory() { + return "GAMES_WON"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NoOpStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NoOpStrategy.java new file mode 100644 index 00000000..1f5f4c8b --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NoOpStrategy.java @@ -0,0 +1,32 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 별도 로직이 필요한 뱃지용 No-Op 전략 + * PERFECT_TEST, ALL_BADGES 등은 별도 로직에서 처리 + */ +public class NoOpStrategy implements BadgeConditionStrategy { + + private final String category; + + public NoOpStrategy(String category) { + this.category = category; + } + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return false; + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return 0; + } + + @Override + public String getCategory() { + return category; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/PerfectDrawsStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/PerfectDrawsStrategy.java new file mode 100644 index 00000000..ac588a4d --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/PerfectDrawsStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 완벽한 출제 뱃지 조건 전략 + */ +public class PerfectDrawsStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getPerfectDraws() != null && stats.getPerfectDraws() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getPerfectDraws() != null ? stats.getPerfectDraws() : 0; + } + + @Override + public String getCategory() { + return "PERFECT_DRAWS"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/QuickGuessesStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/QuickGuessesStrategy.java new file mode 100644 index 00000000..610dc048 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/QuickGuessesStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 빠른 정답 뱃지 조건 전략 + */ +public class QuickGuessesStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getQuickGuesses() != null && stats.getQuickGuesses() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getQuickGuesses() != null ? stats.getQuickGuesses() : 0; + } + + @Override + public String getCategory() { + return "QUICK_GUESSES"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/StreakStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/StreakStrategy.java new file mode 100644 index 00000000..18f826cb --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/StreakStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 연속 학습 뱃지 조건 전략 + */ +public class StreakStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getCurrentStreak() != null && stats.getCurrentStreak() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getCurrentStreak() != null ? stats.getCurrentStreak() : 0; + } + + @Override + public String getCategory() { + return "STREAK"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/TestsCompletedStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/TestsCompletedStrategy.java new file mode 100644 index 00000000..ec791adc --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/TestsCompletedStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 테스트 완료 횟수 뱃지 조건 전략 + */ +public class TestsCompletedStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getTestsCompleted() != null && stats.getTestsCompleted() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getTestsCompleted() != null ? stats.getTestsCompleted() : 0; + } + + @Override + public String getCategory() { + return "TESTS_COMPLETED"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/WordsLearnedStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/WordsLearnedStrategy.java new file mode 100644 index 00000000..5f7cbe02 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/WordsLearnedStrategy.java @@ -0,0 +1,32 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 단어 학습량 뱃지 조건 전략 + */ +public class WordsLearnedStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + int total = getTotalWordsLearned(stats); + return total >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return getTotalWordsLearned(stats); + } + + @Override + public String getCategory() { + return "WORDS_LEARNED"; + } + + private int getTotalWordsLearned(UserStats stats) { + int newWords = stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0; + int reviewedWords = stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0; + return newWords + reviewedWords; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java index 9d712f5f..b8a36d77 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java @@ -31,9 +31,19 @@ public class ChatMessageHandler implements RequestHandler, private final ConnectionRepository connectionRepository; private final WebSocketBroadcaster broadcaster; + /** + * 기본 생성자 (Lambda에서 사용) + */ public GameAutoCloseHandler() { - this.gameService = new GameService(); - this.connectionRepository = new ConnectionRepository(); - this.broadcaster = new WebSocketBroadcaster(); + this(new GameService(), new ConnectionRepository(), new WebSocketBroadcaster()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public GameAutoCloseHandler(GameService gameService, ConnectionRepository connectionRepository, + WebSocketBroadcaster broadcaster) { + this.gameService = gameService; + this.connectionRepository = connectionRepository; + this.broadcaster = broadcaster; } @Override diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java index 4acbd169..55a6c503 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java @@ -38,11 +38,22 @@ public class GameHandler implements RequestHandler table; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public ChatMessageRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(ChatMessage.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public ChatMessageRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(ChatMessage.class)); } public ChatMessage save(ChatMessage message) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java index 3cadfc31..e351a703 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java @@ -7,6 +7,7 @@ import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; @@ -28,9 +29,19 @@ public class ChatRoomRepository { private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); private final DynamoDbTable table; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public ChatRoomRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(ChatRoom.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public ChatRoomRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(ChatRoom.class)); } public ChatRoom save(ChatRoom room) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java index d079d3d7..eba332e8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java @@ -5,6 +5,7 @@ import com.mzc.secondproject.serverless.domain.chatting.model.Connection; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; @@ -22,9 +23,19 @@ public class ConnectionRepository { private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); private final DynamoDbTable table; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public ConnectionRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(Connection.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public ConnectionRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(Connection.class)); } public Connection save(Connection connection) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java index ccb6556f..047df2d0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java @@ -29,8 +29,18 @@ public class GameRoundRepository { private final DynamoDbEnhancedClient enhancedClient; private final DynamoDbTable table; + /** + * 기본 생성자 (Lambda에서 사용) + */ public GameRoundRepository() { - this.enhancedClient = AwsClients.dynamoDbEnhanced(); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public GameRoundRepository(DynamoDbEnhancedClient enhancedClient) { + this.enhancedClient = enhancedClient; this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(GameRound.class)); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameSessionRepository.java index 61623038..bf2493b5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameSessionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameSessionRepository.java @@ -5,6 +5,7 @@ import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; @@ -30,8 +31,18 @@ public class GameSessionRepository { private final DynamoDbTable table; + /** + * 기본 생성자 (Lambda에서 사용) + */ public GameSessionRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(GameSession.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public GameSessionRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(GameSession.class)); } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java index fa6aee2e..f4b39b96 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java @@ -5,6 +5,7 @@ import com.mzc.secondproject.serverless.domain.chatting.model.RoomToken; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; @@ -17,9 +18,19 @@ public class RoomTokenRepository { private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); private final DynamoDbTable table; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public RoomTokenRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(RoomToken.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public RoomTokenRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(RoomToken.class)); } public RoomToken save(RoomToken roomToken) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatMessageService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatMessageService.java index 1bc1c594..e0b44317 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatMessageService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatMessageService.java @@ -13,9 +13,19 @@ public class ChatMessageService { private static final Logger logger = LoggerFactory.getLogger(ChatMessageService.class); private final ChatMessageRepository repository; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public ChatMessageService() { - this.repository = new ChatMessageRepository(); + this(new ChatMessageRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public ChatMessageService(ChatMessageRepository repository) { + this.repository = repository; } public ChatMessage saveMessage(ChatMessage message) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java index cb84f3cc..90a4d594 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java @@ -37,12 +37,27 @@ public class ChatRoomCommandService { private final WebSocketBroadcaster broadcaster; private final UserRepository userRepository; + /** + * 기본 생성자 (Lambda에서 사용) + */ public ChatRoomCommandService() { - this.roomRepository = new ChatRoomRepository(); - this.roomTokenService = new RoomTokenService(); - this.connectionRepository = new ConnectionRepository(); - this.broadcaster = new WebSocketBroadcaster(); - this.userRepository = new UserRepository(); + this(new ChatRoomRepository(), new RoomTokenService(), new ConnectionRepository(), + new WebSocketBroadcaster(), new UserRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public ChatRoomCommandService(ChatRoomRepository roomRepository, + RoomTokenService roomTokenService, + ConnectionRepository connectionRepository, + WebSocketBroadcaster broadcaster, + UserRepository userRepository) { + this.roomRepository = roomRepository; + this.roomTokenService = roomTokenService; + this.connectionRepository = connectionRepository; + this.broadcaster = broadcaster; + this.userRepository = userRepository; } public ChatRoom createRoom(String name, String description, String level, Integer maxMembers, diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java index f75ef802..68af7c1f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java @@ -22,9 +22,19 @@ public class ChatRoomQueryService { private final ChatRoomRepository roomRepository; private final UserRepository userRepository; + /** + * 기본 생성자 (Lambda에서 사용) + */ public ChatRoomQueryService() { - this.roomRepository = new ChatRoomRepository(); - this.userRepository = new UserRepository(); + this(new ChatRoomRepository(), new UserRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public ChatRoomQueryService(ChatRoomRepository roomRepository, UserRepository userRepository) { + this.roomRepository = roomRepository; + this.userRepository = userRepository; } public Optional getRoom(String roomId) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java index 8f43d7af..d4cf0148 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java @@ -23,10 +23,22 @@ public class CommandService { private final GameSessionRepository gameSessionRepository; private final GameService gameService; + /** + * 기본 생성자 (Lambda에서 사용) + */ public CommandService() { - this.connectionRepository = new ConnectionRepository(); - this.gameSessionRepository = new GameSessionRepository(); - this.gameService = new GameService(); + this(new ConnectionRepository(), new GameSessionRepository(), new GameService()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public CommandService(ConnectionRepository connectionRepository, + GameSessionRepository gameSessionRepository, + GameService gameService) { + this.connectionRepository = connectionRepository; + this.gameSessionRepository = gameSessionRepository; + this.gameService = gameService; } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameSchedulerClient.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameSchedulerClient.java index 1ececf65..ab2d9f53 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameSchedulerClient.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameSchedulerClient.java @@ -25,10 +25,22 @@ public class GameSchedulerClient { private final String targetLambdaArn; private final String roleArn; + /** + * 기본 생성자 (Lambda에서 사용) + */ public GameSchedulerClient() { - this.schedulerClient = SchedulerClient.create(); - this.targetLambdaArn = EnvConfig.getOrDefault("GAME_AUTO_CLOSE_LAMBDA_ARN", null); - this.roleArn = EnvConfig.getOrDefault("SCHEDULER_ROLE_ARN", null); + this(SchedulerClient.create(), + EnvConfig.getOrDefault("GAME_AUTO_CLOSE_LAMBDA_ARN", null), + EnvConfig.getOrDefault("SCHEDULER_ROLE_ARN", null)); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public GameSchedulerClient(SchedulerClient schedulerClient, String targetLambdaArn, String roleArn) { + this.schedulerClient = schedulerClient; + this.targetLambdaArn = targetLambdaArn; + this.roleArn = roleArn; } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java index 2c3d61e9..4ff2e235 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java @@ -23,11 +23,23 @@ public class GameStatsService { private final UserStatsRepository userStatsRepository; private final GameRoundRepository gameRoundRepository; private final BadgeService badgeService; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public GameStatsService() { - this.userStatsRepository = new UserStatsRepository(); - this.gameRoundRepository = new GameRoundRepository(); - this.badgeService = new BadgeService(); + this(new UserStatsRepository(), new GameRoundRepository(), new BadgeService()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public GameStatsService(UserStatsRepository userStatsRepository, + GameRoundRepository gameRoundRepository, + BadgeService badgeService) { + this.userStatsRepository = userStatsRepository; + this.gameRoundRepository = gameRoundRepository; + this.badgeService = badgeService; } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/RoomTokenService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/RoomTokenService.java index ee470cfe..4ee6231a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/RoomTokenService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/RoomTokenService.java @@ -19,9 +19,19 @@ public class RoomTokenService { private static final Logger logger = LoggerFactory.getLogger(RoomTokenService.class); private final RoomTokenRepository tokenRepository; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public RoomTokenService() { - this.tokenRepository = new RoomTokenRepository(); + this(new RoomTokenRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public RoomTokenService(RoomTokenRepository tokenRepository) { + this.tokenRepository = tokenRepository; } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java index be726d5b..9ae05c3b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java @@ -30,10 +30,21 @@ public class GrammarHandler implements RequestHandler table; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public GrammarConnectionRepository() { - this.table = AwsClients.dynamoDbEnhanced().table( - TABLE_NAME, - TableSchema.fromBean(GrammarConnection.class) - ); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public GrammarConnectionRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(GrammarConnection.class)); } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java index 8c0466f8..4c52c93c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java @@ -9,6 +9,7 @@ import com.mzc.secondproject.serverless.domain.grammar.model.GrammarSession; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; @@ -28,10 +29,20 @@ public class GrammarSessionRepository { private final DynamoDbTable sessionTable; private final DynamoDbTable messageTable; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public GrammarSessionRepository() { - this.sessionTable = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(GrammarSession.class)); - this.messageTable = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(GrammarMessage.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public GrammarSessionRepository(DynamoDbEnhancedClient enhancedClient) { + this.sessionTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(GrammarSession.class)); + this.messageTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(GrammarMessage.class)); } // ============ Session CRUD ============ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java index 82909fd0..f8890e51 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java @@ -31,8 +31,18 @@ public class ScheduledStatsHandler implements RequestHandler { private final UserStatsRepository userStatsRepository; private final BadgeService badgeService; + /** + * 기본 생성자 (Lambda에서 사용) + */ public StatsStreamHandler() { - this.userStatsRepository = new UserStatsRepository(); - this.badgeService = new BadgeService(); + this(new UserStatsRepository(), new BadgeService()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public StatsStreamHandler(UserStatsRepository userStatsRepository, BadgeService badgeService) { + this.userStatsRepository = userStatsRepository; + this.badgeService = badgeService; } @Override diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java index 47c07cd6..c006c5fb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java @@ -31,9 +31,19 @@ public class UserStatsHandler implements RequestHandler table; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public UserStatsRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(UserStats.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public UserStatsRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(UserStats.class)); } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/service/StatsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/service/StatsService.java index faba5591..510754a5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/service/StatsService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/service/StatsService.java @@ -17,9 +17,19 @@ public class StatsService { private static final Logger logger = LoggerFactory.getLogger(StatsService.class); private final UserStatsRepository userStatsRepository; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public StatsService() { - this.userStatsRepository = new UserStatsRepository(); + this(new UserStatsRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public StatsService(UserStatsRepository userStatsRepository) { + this.userStatsRepository = userStatsRepository; } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java index 44ffa02d..54488ac6 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java @@ -24,9 +24,19 @@ public class DailyStudyHandler implements RequestHandler { private final StatisticsService statisticsService; + /** + * 기본 생성자 (Lambda에서 사용) + */ public StatisticsHandler() { - this.statisticsService = new StatisticsService(); + this(new StatisticsService()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public StatisticsHandler(StatisticsService statisticsService) { + this.statisticsService = statisticsService; } @Override diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java index 190734ac..deefe73f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java @@ -21,8 +21,18 @@ public class StatsHandler implements RequestHandler table; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public DailyStudyRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(DailyStudy.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public DailyStudyRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(DailyStudy.class)); } public DailyStudy save(DailyStudy dailyStudy) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java index 790b7a79..7b0ea935 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java @@ -7,6 +7,7 @@ import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Expression; import software.amazon.awssdk.enhanced.dynamodb.Key; @@ -25,9 +26,19 @@ public class TestResultRepository { private static final String TABLE_NAME = EnvConfig.getRequired("VOCAB_TABLE_NAME"); private final DynamoDbTable table; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public TestResultRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(TestResult.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public TestResultRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(TestResult.class)); } public TestResult save(TestResult testResult) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java index 8ad00235..015932c9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java @@ -22,9 +22,19 @@ public class UserWordRepository { private static final String TABLE_NAME = EnvConfig.getRequired("VOCAB_TABLE_NAME"); private final DynamoDbTable table; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public UserWordRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(UserWord.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public UserWordRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(UserWord.class)); } public UserWord save(UserWord userWord) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java index ddab69b1..f740c667 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java @@ -7,6 +7,7 @@ import com.mzc.secondproject.serverless.domain.vocabulary.model.WordGroup; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; @@ -24,9 +25,19 @@ public class WordGroupRepository { private static final String TABLE_NAME = EnvConfig.getRequired("VOCAB_TABLE_NAME"); private final DynamoDbTable table; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public WordGroupRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(WordGroup.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public WordGroupRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(WordGroup.class)); } public WordGroup save(WordGroup wordGroup) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java index d8602c9e..b35fa686 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java @@ -24,8 +24,18 @@ public class WordRepository { private final DynamoDbEnhancedClient enhancedClient; private final DynamoDbTable table; + /** + * 기본 생성자 (Lambda에서 사용) + */ public WordRepository() { - this.enhancedClient = AwsClients.dynamoDbEnhanced(); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public WordRepository(DynamoDbEnhancedClient enhancedClient) { + this.enhancedClient = enhancedClient; this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(Word.class)); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java index 4713bdaf..29cae332 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java @@ -34,13 +34,28 @@ public class DailyStudyCommandService { private final WordRepository wordRepository; private final UserStatsRepository userStatsRepository; private final BadgeService badgeService; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public DailyStudyCommandService() { - this.dailyStudyRepository = new DailyStudyRepository(); - this.userWordRepository = new UserWordRepository(); - this.wordRepository = new WordRepository(); - this.userStatsRepository = new UserStatsRepository(); - this.badgeService = new BadgeService(); + this(new DailyStudyRepository(), new UserWordRepository(), new WordRepository(), + new UserStatsRepository(), new BadgeService()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public DailyStudyCommandService(DailyStudyRepository dailyStudyRepository, + UserWordRepository userWordRepository, + WordRepository wordRepository, + UserStatsRepository userStatsRepository, + BadgeService badgeService) { + this.dailyStudyRepository = dailyStudyRepository; + this.userWordRepository = userWordRepository; + this.wordRepository = wordRepository; + this.userStatsRepository = userStatsRepository; + this.badgeService = badgeService; } public DailyStudyResult getDailyWords(String userId, String level) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyQueryService.java index 67c3265d..1a2af754 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyQueryService.java @@ -19,10 +19,20 @@ public class DailyStudyQueryService { private final DailyStudyRepository dailyStudyRepository; private final WordRepository wordRepository; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public DailyStudyQueryService() { - this.dailyStudyRepository = new DailyStudyRepository(); - this.wordRepository = new WordRepository(); + this(new DailyStudyRepository(), new WordRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public DailyStudyQueryService(DailyStudyRepository dailyStudyRepository, WordRepository wordRepository) { + this.dailyStudyRepository = dailyStudyRepository; + this.wordRepository = wordRepository; } public Optional getDailyStudy(String userId, String date) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatisticsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatisticsService.java index f1d1e75c..670f8e0d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatisticsService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatisticsService.java @@ -16,9 +16,19 @@ public class StatisticsService { private static final Logger logger = LoggerFactory.getLogger(StatisticsService.class); private final UserWordRepository userWordRepository; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public StatisticsService() { - this.userWordRepository = new UserWordRepository(); + this(new UserWordRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public StatisticsService(UserWordRepository userWordRepository) { + this.userWordRepository = userWordRepository; } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java index 3e49865d..496014f2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java @@ -24,12 +24,26 @@ public class StatsService { private final DailyStudyRepository dailyStudyRepository; private final TestResultRepository testResultRepository; private final WordRepository wordRepository; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public StatsService() { - this.userWordRepository = new UserWordRepository(); - this.dailyStudyRepository = new DailyStudyRepository(); - this.testResultRepository = new TestResultRepository(); - this.wordRepository = new WordRepository(); + this(new UserWordRepository(), new DailyStudyRepository(), + new TestResultRepository(), new WordRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public StatsService(UserWordRepository userWordRepository, + DailyStudyRepository dailyStudyRepository, + TestResultRepository testResultRepository, + WordRepository wordRepository) { + this.userWordRepository = userWordRepository; + this.dailyStudyRepository = dailyStudyRepository; + this.testResultRepository = testResultRepository; + this.wordRepository = wordRepository; } public Map getOverallStats(String userId) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java index 62c3bde2..17125cf9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java @@ -33,12 +33,26 @@ public class TestCommandService { private final DailyStudyRepository dailyStudyRepository; private final WordRepository wordRepository; private final UserWordCommandService userWordCommandService; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public TestCommandService() { - this.testResultRepository = new TestResultRepository(); - this.dailyStudyRepository = new DailyStudyRepository(); - this.wordRepository = new WordRepository(); - this.userWordCommandService = new UserWordCommandService(); + this(new TestResultRepository(), new DailyStudyRepository(), + new WordRepository(), new UserWordCommandService()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public TestCommandService(TestResultRepository testResultRepository, + DailyStudyRepository dailyStudyRepository, + WordRepository wordRepository, + UserWordCommandService userWordCommandService) { + this.testResultRepository = testResultRepository; + this.dailyStudyRepository = dailyStudyRepository; + this.wordRepository = wordRepository; + this.userWordCommandService = userWordCommandService; } public StartTestResult startTest(String userId, String testType) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java index 47dab64b..7b422f3c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java @@ -19,10 +19,20 @@ public class TestQueryService { private final TestResultRepository testResultRepository; private final WordRepository wordRepository; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public TestQueryService() { - this.testResultRepository = new TestResultRepository(); - this.wordRepository = new WordRepository(); + this(new TestResultRepository(), new WordRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public TestQueryService(TestResultRepository testResultRepository, WordRepository wordRepository) { + this.testResultRepository = testResultRepository; + this.wordRepository = wordRepository; } public PaginatedResult getTestResults(String userId, int limit, String cursor) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java index d356c125..18540ac2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java @@ -25,9 +25,19 @@ public class UserWordCommandService { private static final Logger logger = LoggerFactory.getLogger(UserWordCommandService.class); private final UserWordRepository userWordRepository; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public UserWordCommandService() { - this.userWordRepository = new UserWordRepository(); + this(new UserWordRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public UserWordCommandService(UserWordRepository userWordRepository) { + this.userWordRepository = userWordRepository; } public UserWord updateUserWord(String userId, String wordId, boolean isCorrect) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java index 1d5fe08c..2942c609 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java @@ -20,10 +20,20 @@ public class UserWordQueryService { private final UserWordRepository userWordRepository; private final WordRepository wordRepository; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public UserWordQueryService() { - this.userWordRepository = new UserWordRepository(); - this.wordRepository = new WordRepository(); + this(new UserWordRepository(), new WordRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public UserWordQueryService(UserWordRepository userWordRepository, WordRepository wordRepository) { + this.userWordRepository = userWordRepository; + this.wordRepository = wordRepository; } public UserWordsResult getUserWords(String userId, String status, String bookmarked, diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java index 5807055a..f8365c69 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java @@ -19,9 +19,19 @@ public class WordCommandService { private static final Logger logger = LoggerFactory.getLogger(WordCommandService.class); private final WordRepository wordRepository; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public WordCommandService() { - this.wordRepository = new WordRepository(); + this(new WordRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public WordCommandService(WordRepository wordRepository) { + this.wordRepository = wordRepository; } public Word createWord(String english, String korean, String example, String level, String category) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java index 018405b5..796f9d1e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java @@ -20,9 +20,19 @@ public class WordGroupCommandService { private static final Logger logger = LoggerFactory.getLogger(WordGroupCommandService.class); private final WordGroupRepository wordGroupRepository; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public WordGroupCommandService() { - this.wordGroupRepository = new WordGroupRepository(); + this(new WordGroupRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public WordGroupCommandService(WordGroupRepository wordGroupRepository) { + this.wordGroupRepository = wordGroupRepository; } public WordGroup createGroup(String userId, String groupName, String description) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupQueryService.java index 0cc86c3c..6ce2d668 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupQueryService.java @@ -20,10 +20,20 @@ public class WordGroupQueryService { private final WordGroupRepository wordGroupRepository; private final WordRepository wordRepository; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public WordGroupQueryService() { - this.wordGroupRepository = new WordGroupRepository(); - this.wordRepository = new WordRepository(); + this(new WordGroupRepository(), new WordRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public WordGroupQueryService(WordGroupRepository wordGroupRepository, WordRepository wordRepository) { + this.wordGroupRepository = wordGroupRepository; + this.wordRepository = wordRepository; } public PaginatedResult getGroups(String userId, int limit, String cursor) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java index a77cac3c..7257122c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java @@ -17,9 +17,19 @@ public class WordQueryService { private static final Logger logger = LoggerFactory.getLogger(WordQueryService.class); private final WordRepository wordRepository; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public WordQueryService() { - this.wordRepository = new WordRepository(); + this(new WordRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public WordQueryService(WordRepository wordRepository) { + this.wordRepository = wordRepository; } public Optional getWord(String wordId) { From ab28efd12dd8fdfc61351e1ab21f661a3698aeaa Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 21 Jan 2026 15:45:47 +0900 Subject: [PATCH 24/99] refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. --- .gitignore | 4 ++-- {opic/seed-data => seed/opic}/question-homes.json | 0 {vocabulary/seed-data => seed/vocabulary}/words.json | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename {opic/seed-data => seed/opic}/question-homes.json (100%) rename {vocabulary/seed-data => seed/vocabulary}/words.json (100%) diff --git a/.gitignore b/.gitignore index f1597d4c..bcf19f01 100644 --- a/.gitignore +++ b/.gitignore @@ -40,5 +40,5 @@ samconfig.toml *.test.ts coverage/ -# Seed Data (already uploaded to DynamoDB) -vocabulary/seed-data/ +# Seed Data +# seed/ diff --git a/opic/seed-data/question-homes.json b/seed/opic/question-homes.json similarity index 100% rename from opic/seed-data/question-homes.json rename to seed/opic/question-homes.json diff --git a/vocabulary/seed-data/words.json b/seed/vocabulary/words.json similarity index 100% rename from vocabulary/seed-data/words.json rename to seed/vocabulary/words.json From 3a951014dadbf8c028279b07a6eda6df2ff35240 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 21 Jan 2026 16:34:55 +0900 Subject: [PATCH 25/99] =?UTF-8?q?chore:=20seed=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=ED=8F=B4=EB=8D=94=20=EA=B5=AC=EC=A1=B0=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- seed/README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 seed/README.md diff --git a/seed/README.md b/seed/README.md new file mode 100644 index 00000000..6cbd04e7 --- /dev/null +++ b/seed/README.md @@ -0,0 +1,25 @@ +# Seed Data + +DynamoDB 초기 데이터 시드 파일 + +## 구조 + +``` +seed/ +├── opic/ +│ └── question-homes.json # OPIc 질문 데이터 +└── vocabulary/ + └── words.json # 단어 학습 데이터 +``` + +## 사용법 + +AWS CLI를 사용하여 DynamoDB에 데이터 업로드: + +```bash +# Vocabulary words +aws dynamodb batch-write-item --request-items file://seed/vocabulary/words.json + +# OPIc questions +aws dynamodb batch-write-item --request-items file://seed/opic/question-homes.json +``` From e87cff6952ffbe03b337a33b8d73e218d512b8d1 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 09:33:50 +0900 Subject: [PATCH 26/99] feat: add CI/CD pipeline configuration for CodePipeline --- ServerlessFunction/buildspec.yml | 57 ++++++ cicd/pipeline.yaml | 310 +++++++++++++++++++++++++++++++ 2 files changed, 367 insertions(+) create mode 100644 ServerlessFunction/buildspec.yml create mode 100644 cicd/pipeline.yaml diff --git a/ServerlessFunction/buildspec.yml b/ServerlessFunction/buildspec.yml new file mode 100644 index 00000000..355289d1 --- /dev/null +++ b/ServerlessFunction/buildspec.yml @@ -0,0 +1,57 @@ +version: 0.2 + +env: + variables: + JAVA_HOME: /usr/lib/jvm/java-21-amazon-corretto + SAM_CLI_TELEMETRY: 0 + +phases: + install: + runtime-versions: + java: corretto21 + commands: + - echo "Installing SAM CLI..." + - pip3 install aws-sam-cli + - sam --version + + pre_build: + commands: + - echo "Running tests..." + - chmod +x gradlew + - ./gradlew clean test + - echo "Tests completed" + + build: + commands: + - echo "Building SAM application..." + - sam build + - echo "Packaging SAM application..." + - sam package \ + --s3-bucket ${ARTIFACT_BUCKET} \ + --s3-prefix sam-packages \ + --output-template-file packaged-template.yaml + + post_build: + commands: + - echo "Build completed on $(date)" + +artifacts: + files: + - packaged-template.yaml + - samconfig.toml + base-directory: . + +cache: + paths: + - '/root/.gradle/caches/**/*' + - '/root/.gradle/wrapper/**/*' + +reports: + junit-reports: + files: + - 'build/test-results/test/*.xml' + file-format: JUNITXML + jacoco-reports: + files: + - 'build/reports/jacoco/test/jacocoTestReport.xml' + file-format: JACOCOXML diff --git a/cicd/pipeline.yaml b/cicd/pipeline.yaml new file mode 100644 index 00000000..7bca4946 --- /dev/null +++ b/cicd/pipeline.yaml @@ -0,0 +1,310 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: CI/CD Pipeline for Group2 English Study Backend + +Parameters: + GitHubConnectionArn: + Type: String + Default: "arn:aws:codeconnections:ap-northeast-2:682405977339:connection/6cdbf218-483a-4e49-9c74-dd6fe84dbd2b" + Description: ARN of the GitHub Connection + + GitHubRepo: + Type: String + Default: "Language-Study-Prooject/BE_Repository" + Description: GitHub repository (owner/repo) + + GitHubBranch: + Type: String + Default: "prod" + Description: Branch to trigger pipeline + + NotificationEmail: + Type: String + Description: Email address for pipeline notifications + +Resources: + ############################################# + # S3 Bucket for Pipeline Artifacts + ############################################# + ArtifactBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: group2-englishstudy-pipeline-artifacts + VersioningConfiguration: + Status: Enabled + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: AES256 + PublicAccessBlockConfiguration: + BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + + ############################################# + # SNS Topic for Notifications + ############################################# + NotificationTopic: + Type: AWS::SNS::Topic + Properties: + TopicName: cicd-pipeline-notifications + DisplayName: CI/CD Pipeline Notifications + + EmailSubscription: + Type: AWS::SNS::Subscription + Properties: + TopicArn: !Ref NotificationTopic + Protocol: email + Endpoint: !Ref NotificationEmail + + ############################################# + # IAM Roles + ############################################# + + # CodePipeline Service Role + PipelineRole: + Type: AWS::IAM::Role + Properties: + RoleName: group2-codepipeline-role + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: codepipeline.amazonaws.com + Action: sts:AssumeRole + Policies: + - PolicyName: PipelinePolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - codestar-connections:UseConnection + - codeconnections:UseConnection + Resource: !Ref GitHubConnectionArn + - Effect: Allow + Action: + - s3:GetObject + - s3:GetObjectVersion + - s3:PutObject + - s3:GetBucketVersioning + Resource: + - !GetAtt ArtifactBucket.Arn + - !Sub "${ArtifactBucket.Arn}/*" + - Effect: Allow + Action: + - codebuild:BatchGetBuilds + - codebuild:StartBuild + Resource: !GetAtt CodeBuildProject.Arn + - Effect: Allow + Action: + - cloudformation:CreateStack + - cloudformation:DeleteStack + - cloudformation:DescribeStacks + - cloudformation:UpdateStack + - cloudformation:CreateChangeSet + - cloudformation:DeleteChangeSet + - cloudformation:DescribeChangeSet + - cloudformation:ExecuteChangeSet + - cloudformation:SetStackPolicy + - cloudformation:ValidateTemplate + Resource: "*" + - Effect: Allow + Action: + - iam:PassRole + Resource: !GetAtt CloudFormationRole.Arn + - Effect: Allow + Action: + - sns:Publish + Resource: !Ref NotificationTopic + + # CodeBuild Service Role + CodeBuildRole: + Type: AWS::IAM::Role + Properties: + RoleName: group2-codebuild-role + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: codebuild.amazonaws.com + Action: sts:AssumeRole + Policies: + - PolicyName: CodeBuildPolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + Resource: + - !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/codebuild/*" + - Effect: Allow + Action: + - s3:GetObject + - s3:GetObjectVersion + - s3:PutObject + - s3:GetBucketAcl + - s3:GetBucketLocation + Resource: + - !GetAtt ArtifactBucket.Arn + - !Sub "${ArtifactBucket.Arn}/*" + - "arn:aws:s3:::group2-englishstudy" + - "arn:aws:s3:::group2-englishstudy/*" + - Effect: Allow + Action: + - codebuild:CreateReportGroup + - codebuild:CreateReport + - codebuild:UpdateReport + - codebuild:BatchPutTestCases + - codebuild:BatchPutCodeCoverages + Resource: !Sub "arn:aws:codebuild:${AWS::Region}:${AWS::AccountId}:report-group/*" + + # CloudFormation Execution Role + CloudFormationRole: + Type: AWS::IAM::Role + Properties: + RoleName: group2-cloudformation-role + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: cloudformation.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/AdministratorAccess + + ############################################# + # CodeBuild Project + ############################################# + CodeBuildProject: + Type: AWS::CodeBuild::Project + Properties: + Name: group2-englishstudy-build + Description: Build project for Group2 English Study Backend + ServiceRole: !GetAtt CodeBuildRole.Arn + Artifacts: + Type: CODEPIPELINE + Environment: + Type: LINUX_CONTAINER + ComputeType: BUILD_GENERAL1_MEDIUM + Image: aws/codebuild/amazonlinux2-x86_64-standard:5.0 + EnvironmentVariables: + - Name: ARTIFACT_BUCKET + Value: !Ref ArtifactBucket + Source: + Type: CODEPIPELINE + BuildSpec: ServerlessFunction/buildspec.yml + TimeoutInMinutes: 30 + Cache: + Type: S3 + Location: !Sub "${ArtifactBucket}/cache" + LogsConfig: + CloudWatchLogs: + Status: ENABLED + GroupName: !Sub "/aws/codebuild/group2-englishstudy-build" + + ############################################# + # CodePipeline + ############################################# + Pipeline: + Type: AWS::CodePipeline::Pipeline + Properties: + Name: group2-englishstudy-pipeline + RoleArn: !GetAtt PipelineRole.Arn + ArtifactStore: + Type: S3 + Location: !Ref ArtifactBucket + Stages: + # Source Stage + - Name: Source + Actions: + - Name: GitHub + ActionTypeId: + Category: Source + Owner: AWS + Provider: CodeStarSourceConnection + Version: '1' + Configuration: + ConnectionArn: !Ref GitHubConnectionArn + FullRepositoryId: !Ref GitHubRepo + BranchName: !Ref GitHubBranch + OutputArtifactFormat: CODE_ZIP + DetectChanges: true + OutputArtifacts: + - Name: SourceArtifact + RunOrder: 1 + + # Build Stage + - Name: Build + Actions: + - Name: Build + ActionTypeId: + Category: Build + Owner: AWS + Provider: CodeBuild + Version: '1' + Configuration: + ProjectName: !Ref CodeBuildProject + InputArtifacts: + - Name: SourceArtifact + OutputArtifacts: + - Name: BuildArtifact + RunOrder: 1 + + # Deploy Stage + - Name: Deploy + Actions: + - Name: Deploy + ActionTypeId: + Category: Deploy + Owner: AWS + Provider: CloudFormation + Version: '1' + Configuration: + ActionMode: CREATE_UPDATE + StackName: group2-englishstudy-prod + TemplatePath: BuildArtifact::packaged-template.yaml + Capabilities: CAPABILITY_IAM,CAPABILITY_AUTO_EXPAND + RoleArn: !GetAtt CloudFormationRole.Arn + InputArtifacts: + - Name: BuildArtifact + RunOrder: 1 + + ############################################# + # Pipeline Notification Rule + ############################################# + PipelineNotificationRule: + Type: AWS::CodeStarNotifications::NotificationRule + Properties: + Name: group2-pipeline-notifications + DetailType: FULL + Resource: !Sub "arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:${Pipeline}" + EventTypeIds: + - codepipeline-pipeline-pipeline-execution-started + - codepipeline-pipeline-pipeline-execution-succeeded + - codepipeline-pipeline-pipeline-execution-failed + Targets: + - TargetType: SNS + TargetAddress: !Ref NotificationTopic + +############################################# +# Outputs +############################################# +Outputs: + PipelineUrl: + Description: URL to the CodePipeline console + Value: !Sub "https://${AWS::Region}.console.aws.amazon.com/codesuite/codepipeline/pipelines/${Pipeline}/view" + + ArtifactBucketName: + Description: S3 bucket for pipeline artifacts + Value: !Ref ArtifactBucket + + NotificationTopicArn: + Description: SNS topic for notifications + Value: !Ref NotificationTopic From 4e3785d4f83706fbda476d89e681d0eb32e45ded Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 09:48:17 +0900 Subject: [PATCH 27/99] fix: add SNS topic policy and DependsOn for notification rule --- cicd/pipeline.yaml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/cicd/pipeline.yaml b/cicd/pipeline.yaml index 7bca4946..19b19448 100644 --- a/cicd/pipeline.yaml +++ b/cicd/pipeline.yaml @@ -276,11 +276,32 @@ Resources: - Name: BuildArtifact RunOrder: 1 + ############################################# + # SNS Topic Policy for CodeStar Notifications + ############################################# + NotificationTopicPolicy: + Type: AWS::SNS::TopicPolicy + Properties: + Topics: + - !Ref NotificationTopic + PolicyDocument: + Version: '2012-10-17' + Statement: + - Sid: AllowCodeStarNotifications + Effect: Allow + Principal: + Service: codestar-notifications.amazonaws.com + Action: sns:Publish + Resource: !Ref NotificationTopic + ############################################# # Pipeline Notification Rule ############################################# PipelineNotificationRule: Type: AWS::CodeStarNotifications::NotificationRule + DependsOn: + - Pipeline + - NotificationTopicPolicy Properties: Name: group2-pipeline-notifications DetailType: FULL From 126f89a9e334475415b3c90d746305c8aedc09de Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 09:51:35 +0900 Subject: [PATCH 28/99] fix: correct paths in buildspec.yml for CodeBuild --- ServerlessFunction/buildspec.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/ServerlessFunction/buildspec.yml b/ServerlessFunction/buildspec.yml index 355289d1..fdff4e4f 100644 --- a/ServerlessFunction/buildspec.yml +++ b/ServerlessFunction/buildspec.yml @@ -17,6 +17,7 @@ phases: pre_build: commands: - echo "Running tests..." + - cd ServerlessFunction - chmod +x gradlew - ./gradlew clean test - echo "Tests completed" @@ -24,6 +25,7 @@ phases: build: commands: - echo "Building SAM application..." + - cd $CODEBUILD_SRC_DIR/ServerlessFunction - sam build - echo "Packaging SAM application..." - sam package \ @@ -38,8 +40,7 @@ phases: artifacts: files: - packaged-template.yaml - - samconfig.toml - base-directory: . + base-directory: ServerlessFunction cache: paths: @@ -49,9 +50,9 @@ cache: reports: junit-reports: files: - - 'build/test-results/test/*.xml' + - 'ServerlessFunction/build/test-results/test/*.xml' file-format: JUNITXML jacoco-reports: files: - - 'build/reports/jacoco/test/jacocoTestReport.xml' + - 'ServerlessFunction/build/reports/jacoco/test/jacocoTestReport.xml' file-format: JACOCOXML From 8fa4eb64dae997a535170bbf40ae656c5ad71746 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 09:53:43 +0900 Subject: [PATCH 29/99] fix: remove hardcoded JAVA_HOME, use runtime default --- ServerlessFunction/buildspec.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/ServerlessFunction/buildspec.yml b/ServerlessFunction/buildspec.yml index fdff4e4f..79b8ecb7 100644 --- a/ServerlessFunction/buildspec.yml +++ b/ServerlessFunction/buildspec.yml @@ -2,7 +2,6 @@ version: 0.2 env: variables: - JAVA_HOME: /usr/lib/jvm/java-21-amazon-corretto SAM_CLI_TELEMETRY: 0 phases: From 00dfc65334263791b57fd0dce9dc2163790f202a Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 09:55:36 +0900 Subject: [PATCH 30/99] fix: add gradle wrapper for CI/CD build --- .gitignore | 3 ++- .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45633 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 +++++++ 3 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 ServerlessFunction/gradle/wrapper/gradle-wrapper.jar create mode 100644 ServerlessFunction/gradle/wrapper/gradle-wrapper.properties diff --git a/.gitignore b/.gitignore index f1597d4c..389f80e0 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,8 @@ out/ # Gradle .gradle/ -gradle/ +# gradle wrapper는 커밋 필요 +!gradle/wrapper/ # Maven *.jar diff --git a/ServerlessFunction/gradle/wrapper/gradle-wrapper.jar b/ServerlessFunction/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..f8e1ee3125fe0768e9a76ee977ac089eb657005e GIT binary patch literal 45633 zcma&NV|1n6wyqu9PQ|uu+csuwn-$x(T~Woh?Nr6KUD3(A)@l1Yd+oj6Z_U=8`RAE` z#vE6_`?!1WLs1443=Ieh3JM4ai0JG2|2{}S&_HrxszP*9^5P7#QX*pVDq?D?;6T8C z{bWO1$9at%!*8ax*TT&F99vwf1Ls+3lklsb|bC`H`~Q z_w}*E9P=Wq;PYlGYhZ^lt#N97bt5aZ#mQcOr~h^B;R>f-b0gf{y(;VA{noAt`RZzU z7vQWD{%|q!urW2j0Z&%ChtL(^9m` zgaU%|B;V#N_?%iPvu0PVkX=1m9=*SEGt-Lp#&Jh%rz6EJXlV^O5B5YfM5j{PCeElx z8sipzw8d=wVhFK+@mgrWyA)Sv3BJq=+q+cL@=wuH$2;LjY z^{&+X4*HFA0{QvlM_V4PTQjIdd;d|2YuN;s|bi!@<)r-G%TuOCHz$O(_-K z)5in&6uNN<0UfwY=K>d;cL{{WK2FR|NihJMN0Q4X+(1lE)$kY?T$7UWleIU`i zQG#X-&&m-8x^(;n@o}$@vPMYRoq~|FqC~CU3MnoiifD{(CwAGd%X#kFHq#4~%_a!{ zeX{XXDT#(DvX7NtAs7S}2ZuiZ>gtd;tCR7E)3{J^`~#Vd**9qz%~JRFAiZf{zt|Dr zvQw!)n7fNUn_gH`o9?8W8t_%x6~=y*`r46bjj(t{YU*qfqd}J}*mkgUfsXTI>Uxl6 z)Fj>#RMy{`wINIR;{_-!xGLgVaTfNJ2-)%YUfO&X5z&3^E#4?k-_|Yv$`fpgYkvnA%E{CiV zP|-zAf8+1@R`sT{rSE#)-nuU7Pwr-z>0_+CLQT|3vc-R22ExKT4ym@Gj77j$aTVns zp4Kri#Ml?t7*n(;>nkxKdhOU9Qbwz%*#i9_%K<`m4T{3aPbQ?J(Mo`6E5cDdbAk%X z+4bN%E#a(&ZXe{G#V!2Nt+^L$msKVHP z|APpBhq7knz(O2yY)$$VyI_Xg4UIC*$!i7qQG~KEZnO@Q1i89@4ZKW*3^Wh?o?zSkfPxdhnTxlO!3tAqe_ zuEqHVcAk3uQIFTpP~C{d$?>7yt3G3Fo>syXTus>o0tJdFpQWC27hDiwC%O09i|xCq z@H6l|+maB;%CYQIChyhu;PVYz9e&5a@EEQs3$DS6dLIS+;N@I0)V}%B`jdYv;JDck zd|xxp(I?aedivE7*19hesoa-@Xm$^EHbbVmh$2^W-&aTejsyc$i+}A#n2W*&0Qt`5 zJS!2A|LVV;L!(*x2N)GjJC;b1RB_f(#D&g_-};a*|BTRvfdIX}Gau<;uCylMNC;UG zzL((>6KQBQ01wr%7u9qI2HLEDY!>XisIKb#6=F?pAz)!_JX}w|>1V>X^QkMdFi@Jr z`1N*V4xUl{qvECHoF?#lXuO#Dg2#gh|AU$Wc=nuIbmVPBEGd(R#&Z`TP9*o%?%#ob zWN%ByU+55yBNfjMjkJnBjT!cVDi}+PR3N&H(f8$d^Pu;A_WV*{)c2Q{IiE7&LPsd4 z!rvkUf{sco_WNSIdW+btM#O+4n`JiceH6%`7pDV zRqJ@lj=Dt(e-Gkz$b!c2>b)H$lf(fuAPdIsLSe(dZ4E~9+Ge!{3j~>nS%r)eQZ;Iq ztWGpp=2Ptc!LK_TQ8cgJXUlU5mRu|7F2{eu*;a>_5S<;bus=t*IXcfzJRPv4xIs;s zt2<&}OM>KxkTxa=dFMfNr42=DL~I}6+_{`HT_YJBiWkpVZND1Diad~Yr*Fuq{zljr z*_+jXk=qVBdwlQkYuIrB4GG*#voba$?h*u0uRNL+87-?AjzG2X_R9mzQ7BJEawutObr|ey~%in>6k%A`K*`pb-|DF5m})!`b=~osoiW2)IFh?_y9y<3Cix_ znvC=bjBX1J820!%%9FaB@v?hAsd05e@w$^ZAvtUp*=Bi+Owkl?rLa6F#yl{s+?563 zmn2 zV95%gySAJ$L!Vvk4kx!n@mo`3Mfi`2lXUkBmd%)u)7C?Pa;oK~zUQ#p0u{a|&0;zNO#9a4`v^3df90X#~l_k$q7n&L5 z?TszF842~g+}tgUP}UG?ObLCE1(Js_$e>XS7m%o7j@@VdxePtg)w{i5an+xK95r?s zDeEhgMO-2$H?@0{p-!4NJ)}zP+3LzZB?FVap)ObHV6wp}Lrxvz$cjBND1T6ln$EfJ zZRPeR2lP}K0p8x`ahxB??Ud;i7$Y5X!5}qBFS+Zp=P^#)08nQi_HuJcN$0=x;2s53 zwoH}He9BlKT4GdWfWt)@o@$4zN$B@5gVIN~aHtwIhh{O$uHiMgYl=&Vd$w#B2 zRv+xK3>4E{!)+LXA2#*K6H~HpovXAQeXV(^Pd%G_>ro0(4_@`{2Ag(+8{9pqJ>Co$ zRRV(oX;nD+Jel_2^BlNO=cQP8q*G#~R3PTERUxvug_C4T3qwb9MQE|^{5(H*nt`fn z^%*p-RwkAhT6(r>E@5w8FaB)Q<{#`H9fTdc6QBuSr9D-x!Tb9f?wI=M{^$cB5@1;0 z+yLHh?3^c-Qte@JI<SW`$bs5Vv9!yWjJD%oY z8Cdc$a(LLy@tB2)+rUCt&0$&+;&?f~W6+3Xk3g zy9L�|d9Zj^A1Dgv5yzCONAB>8LM`TRL&7v_NKg(bEl#y&Z$py}mu<4DrT@8HHjE zqD@4|aM>vt!Yvc2;9Y#V;KJ8M>vPjiS2ycq52qkxInUK*QqA3$&OJ`jZBo zpzw&PT%w0$D94KD%}VN9c)eCueh1^)utGt2OQ+DP(BXszodfc1kFPWl~BQ5Psy*d`UIf zc}zQ8TVw35jdCSc78)MljC-g3$GX2$<0<3MEQXS&i<(ZFClz9WlL}}?%u>S2hhEk_ zyzfm&@Q%YVB-vw3KH|lU#c_)0aeG^;aDG&!bwfOz_9)6gLe;et;h(?*0d-RV0V)1l zzliq#`b9Y*c`0!*6;*mU@&EFSbW>9>L5xUX+unp%@tCW#kLfz)%3vwN{1<-R*g+B_C^W8)>?n%G z<#+`!wU$L&dn)Pz(9DGGI%RlmM2RpeDy9)31OZV$c2T>-Jl&4$6nul&e7){1u-{nP zE$uZs%gyanu+yBcAb+jTYGy(^<;&EzeLeqveN12Lvv)FQFn0o&*qAaH+gLJ)*xT9y z>`Y`W?M#K7%w26w?Oen>j7=R}EbZ;+jcowV&i}P|IfW^C5GJHt5D;Q~)|=gW3iQ;N zQGl4SQFtz=&~BGon6hO@mRnjpmM79ye^LY_L2no{f_M?j80pr`o3BrI7ice#8#Zt4 zO45G97Hpef+AUEU%jN-dLmPYHY(|t#D)9|IeB^i1X|eEq+ymld_Uj$l^zVAPRilx- z^II$sL4G~{^7?sik2BK7;ZV-VIVhrKjUxBIsf^N&K`)5;PjVg-DTm1Xtw4-tGtElU zJgVTCk4^N4#-kPuX=7p~GMf5Jj5A#>)GX)FIcOqY4lf}Vv2gjrOTuFusB@ERW-&fb zTp=E0E?gXkwzn)AMMY*QCftp%MOL-cbsG{02$0~b?-JD{-nwj58 zBHO1YL~yn~RpnZ6*;XA|MSJeBfX-D?afH*E!2uGjT%k!jtx~OG_jJ`Ln}lMQb7W41 zmTIRd%o$pu;%2}}@2J$x%fg{DZEa-Wxdu6mRP~Ea0zD2+g;Dl*to|%sO-5mUrZ`~C zjJ zUe^**YRgBvlxl<(r0LjxjSQKiTx+E<7$@9VO=RYgL9ldTyKzfqR;Y&gu^ub!fVX7u z3H@;8j#tVgga~EMuXv_#Q8<*uK@R{mGzn92eDYkF1sbxh5!P|M-D)T~Ae*SO`@u$Q z7=5s)HM)w~s2j5{I67cqSn6BLLhCMcn0=OTVE?T7bAmY!T+xZ_N3op~wZ3Oxlm6(a5qB({6KghlvBd9HJ#V6YY_zxbj-zI`%FN|C*Q`DiV z#>?Kk7VbuoE*I9tJaa+}=i7tJnMRn`P+(08 za*0VeuAz!eI7giYTsd26P|d^E2p1f#oF*t{#klPhgaShQ1*J7?#CTD@iDRQIV+Z$@ z>qE^3tR3~MVu=%U%*W(1(waaFG_1i5WE}mvAax;iwZKv^g1g}qXY7lAd;!QQa#5e= z1_8KLHje1@?^|6Wb(A{HQ_krJJP1GgE*|?H0Q$5yPBQJlGi;&Lt<3Qc+W4c}Ih~@* zj8lYvme}hwf@Js%Oj=4BxXm15E}7zS0(dW`7X0|$damJ|gJ6~&qKL>gB_eC7%1&Uh zLtOkf7N0b;B`Qj^9)Bfh-( z0or96!;EwEMnxwp!CphwxxJ+DDdP4y3F0i`zZp-sQ5wxGIHIsZCCQz5>QRetx8gq{ zA33BxQ}8Lpe!_o?^u2s3b!a-$DF$OoL=|9aNa7La{$zI#JTu_tYG{m2ly$k?>Yc); zTA9ckzd+ibu>SE6Rc=Yd&?GA9S5oaQgT~ER-|EwANJIAY74|6 z($#j^GP}EJqi%)^jURCj&i;Zl^-M9{=WE69<*p-cmBIz-400wEewWVEd^21}_@A#^ z2DQMldk_N)6bhFZeo8dDTWD@-IVunEY*nYRON_FYII-1Q@@hzzFe(lTvqm}InfjQ2 zN>>_rUG0Lhaz`s;GRPklV?0 z;~t4S8M)ZBW-ED?#UNbCrsWb=??P># zVc}MW_f80ygG_o~SW+Q6oeIUdFqV2Fzys*7+vxr^ZDeXcZZc;{kqK;(kR-DKL zByDdPnUQgnX^>x?1Tz~^wZ%Flu}ma$Xmgtc7pSmBIH%&H*Tnm=L-{GzCv^UBIrTH5 zaoPO|&G@SB{-N8Xq<+RVaM_{lHo@X-q}`zjeayVZ9)5&u*Y>1!$(wh9Qoe>yWbPgw zt#=gnjCaT_+$}w^*=pgiHD8N$hzqEuY5iVL_!Diw#>NP7mEd?1I@Io+?=$?7cU=yK zdDKk_(h_dB9A?NX+&=%k8g+?-f&`vhAR}&#zP+iG%;s}kq1~c{ac1@tfK4jP65Z&O zXj8Ew>l7c|PMp!cT|&;o+(3+)-|SK&0EVU-0-c&guW?6F$S`=hcKi zpx{Z)UJcyihmN;^E?*;fxjE3kLN4|&X?H&$md+Ege&9en#nUe=m>ep3VW#C?0V=aS zLhL6v)|%$G5AO4x?Jxy8e+?*)YR~<|-qrKO7k7`jlxpl6l5H&!C4sePiVjAT#)b#h zEwhfkpFN9eY%EAqg-h&%N>E0#%`InXY?sHyptcct{roG42Mli5l)sWt66D_nG2ed@ z#4>jF?sor7ME^`pDlPyQ(|?KL9Q88;+$C&3h*UV*B+*g$L<{yT9NG>;C^ZmPbVe(a z09K^qVO2agL`Hy{ISUJ{khPKh@5-)UG|S8Sg%xbJMF)wawbgll3bxk#^WRqmdY7qv zr_bqa3{`}CCbREypKd!>oIh^IUj4yl1I55=^}2mZAAW6z}Kpt3_o1b4__sQ;b zv)1=xHO?gE-1FL}Y$0YdD-N!US;VSH>UXnyKoAS??;T%tya@-u zfFo)@YA&Q#Q^?Mtam19`(PS*DL{PHjEZa(~LV7DNt5yoo1(;KT)?C7%^Mg;F!C)q= z6$>`--hQX4r?!aPEXn;L*bykF1r8JVDZ)x4aykACQy(5~POL;InZPU&s5aZm-w1L< z`crCS5=x>k_88n(*?zn=^w*;0+8>ui2i>t*Kr!4?aA1`yj*GXi#>$h8@#P{S)%8+N zCBeL6%!Ob1YJs5+a*yh{vZ8jH>5qpZhz_>(ph}ozKy9d#>gba1x3}`-s_zi+SqIeR z0NCd7B_Z|Fl+(r$W~l@xbeAPl5{uJ{`chq}Q;y8oUN0sUr4g@1XLZQ31z9h(fE_y( z_iQ(KB39LWd;qwPIzkvNNkL(P(6{Iu{)!#HvBlsbm`g2qy&cTsOsAbwMYOEw8!+75D!>V{9SZ?IP@pR9sFG{T#R*6ez2&BmP8*m^6+H2_ z>%9pg(+R^)*(S21iHjLmdt$fmq6y!B9L!%+;wL5WHc^MZRNjpL9EqbBMaMns2F(@h zN0BEqZ3EWGLjvY&I!8@-WV-o@>biD;nx;D}8DPapQF5ivpHVim8$G%3JrHtvN~U&) zb1;=o*lGfPq#=9Moe$H_UhQPBjzHuYw;&e!iD^U2veY8)!QX_E(X@3hAlPBIc}HoD z*NH1vvCi5xy@NS41F1Q3=Jkfu&G{Syin^RWwWX|JqUIX_`}l;_UIsj&(AFQ)ST*5$ z{G&KmdZcO;jGIoI^+9dsg{#=v5eRuPO41<*Ym!>=zHAXH#=LdeROU-nzj_@T4xr4M zJI+d{Pp_{r=IPWj&?%wfdyo`DG1~|=ef?>=DR@|vTuc)w{LHqNKVz9`Dc{iCOH;@H5T{ zc<$O&s%k_AhP^gCUT=uzrzlEHI3q`Z3em0*qOrPHpfl1v=8Xkp{!f9d2p!4 zL40+eJB4@5IT=JTTawIA=Z%3AFvv=l1A~JX>r6YUMV7GGLTSaIn-PUw| z;9L`a<)`D@Qs(@P(TlafW&-87mcZuwFxo~bpa01_M9;$>;4QYkMQlFPgmWv!eU8Ut zrV2<(`u-@1BTMc$oA*fX;OvklC1T$vQlZWS@&Wl}d!72MiXjOXxmiL8oq;sP{)oBe zS#i5knjf`OfBl}6l;BSHeY31w8c~8G>$sJ9?^^!)Z*Z*Xg zbTbkcbBpgFui(*n32hX~sC7gz{L?nlnOjJBd@ zUC4gd`o&YB4}!T9JGTe9tqo0M!JnEw4KH7WbrmTRsw^Nf z^>RxG?2A33VG3>E?iN|`G6jgr`wCzKo(#+zlOIzp-^E0W0%^a>zO)&f(Gc93WgnJ2p-%H-xhe{MqmO z8Iacz=Qvx$ML>Lhz$O;3wB(UI{yTk1LJHf+KDL2JPQ6#m%^bo>+kTj4-zQ~*YhcqS z2mOX!N!Q$d+KA^P0`EEA^%>c12X(QI-Z}-;2Rr-0CdCUOZ=7QqaxjZPvR%{pzd21HtcUSU>u1nw?)ZCy+ zAaYQGz59lqhNXR4GYONpUwBU+V&<{z+xA}`Q$fajmR86j$@`MeH}@zz*ZFeBV9Ot< ze8BLzuIIDxM&8=dS!1-hxiAB-x-cVmtpN}JcP^`LE#2r9ti-k8>Jnk{?@Gw>-WhL=v+H!*tv*mcNvtwo)-XpMnV#X>U1F z?HM?tn^zY$6#|(|S~|P!BPp6mur58i)tY=Z-9(pM&QIHq+I5?=itn>u1FkXiehCRC zW_3|MNOU)$-zrjKnU~{^@i9V^OvOJMp@(|iNnQ%|iojG2_Snnt`1Cqx2t)`vW&w2l zwb#`XLNY@FsnC-~O&9|#Lpvw7n!$wL9azSk)$O}?ygN@FEY({2%bTl)@F2wevCv`; zZb{`)uMENiwE|mti*q5U4;4puX{VWFJ#QIaa*%IHKyrU*HtjW_=@!3SlL~pqLRs?L zoqi&}JLsaP)yEH!=_)zmV-^xy!*MCtc{n|d%O zRM>N>eMG*Qi_XAxg@82*#zPe+!!f#;xBxS#6T-$ziegN-`dLm z=tTN|xpfCPng06|X^6_1JgN}dM<_;WsuL9lu#zLVt!0{%%D9*$nT2E>5@F(>Fxi%Y zpLHE%4LZSJ1=_qm0;^Wi%x56}k3h2Atro;!Ey}#g&*BpbNXXS}v>|nn=Mi0O(5?=1V7y1^1Bdt5h3}oL@VsG>NAH z1;5?|Sth=0*>dbXSQ%MQKB?eN$LRu?yBy@qQVaUl*f#p+sLy$Jd>*q;(l>brvNUbIF0OCf zk%Q;Zg!#0w0_#l)!t?3iz~`X8A>Yd3!P&A4Ov6&EdZmOixeTd4J`*Wutura(}4w@KV>i#rf(0PYL&v^89QiXBP6sj=N;q8kVxS}hA! z|3QaiYz!w+xQ%9&Zg${JgQ*Ip_bg2rmmG`JkX^}&5gbZF!Z(gDD1s5{QwarPK(li- zW9y-CiQ`5Ug1ceN1w7lCxl=2}7c*8_XH8W7y0AICn19qZ`w}z0iCJ$tJ}NjzQCH90 zc!UzpKvk%3;`XfFi2;F*q2eMQQ5fzO{!`KU1T^J?Z64|2Z}b1b6h80_H%~J)J)kbM0hsj+FV6%@_~$FjK9OG7lY}YA zRzyYxxy18z<+mCBiX?3Q{h{TrNRkHsyF|eGpLo0fKUQ|19Z0BamMNE9sW z?vq)r`Qge{9wN|ezzW=@ojpVQRwp##Q91F|B5c`a0A{HaIcW>AnqQ*0WT$wj^5sWOC1S;Xw7%)n(=%^in zw#N*+9bpt?0)PY$(vnU9SGSwRS&S!rpd`8xbF<1JmD&6fwyzyUqk){#Q9FxL*Z9%#rF$} zf8SsEkE+i91VY8d>Fap#FBacbS{#V&r0|8bQa;)D($^v2R1GdsQ8YUk(_L2;=DEyN%X*3 z;O@fS(pPLRGatI93mApLsX|H9$VL2)o(?EYqlgZMP{8oDYS8)3G#TWE<(LmZ6X{YA zRdvPLLBTatiUG$g@WK9cZzw%s6TT1Chmw#wQF&&opN6^(D`(5p0~ zNG~fjdyRsZv9Y?UCK(&#Q2XLH5G{{$9Y4vgMDutsefKVVPoS__MiT%qQ#_)3UUe=2fK)*36yXbQUp#E98ah(v`E$c3kAce_8a60#pa7rq6ZRtzSx6=I^-~A|D%>Riv{Y`F9n3CUPL>d`MZdRmBzCum2K%}z@Z(b7#K!-$Hb<+R@Rl9J6<~ z4Wo8!!y~j(!4nYsDtxPIaWKp+I*yY(ib`5Pg356Wa7cmM9sG6alwr7WB4IcAS~H3@ zWmYt|TByC?wY7yODHTyXvay9$7#S?gDlC?aS147Ed7zW!&#q$^E^_1sgB7GKfhhYu zOqe*Rojm~)8(;b!gsRgQZ$vl5mN>^LDgWicjGIcK9x4frI?ZR4Z%l1J=Q$0lSd5a9 z@(o?OxC72<>Gun*Y@Z8sq@od{7GGsf8lnBW^kl6sX|j~UA2$>@^~wtceTt^AtqMIx zO6!N}OC#Bh^qdQV+B=9hrwTj>7HvH1hfOQ{^#nf%e+l)*Kgv$|!kL5od^ka#S)BNT z{F(miX_6#U3+3k;KxPyYXE0*0CfL8;hDj!QHM@)sekF9uyBU$DRZkka4ie^-J2N8w z3PK+HEv7kMnJU1Y+>rheEpHdQ3_aTQkM3`0`tC->mpV=VtvU((Cq$^(S^p=+$P|@} zueLA}Us^NTI83TNI-15}vrC7j6s_S`f6T(BH{6Jj{Lt;`C+)d}vwPGx62x7WXOX19 z2mv1;f^p6cG|M`vfxMhHmZxkkmWHRNyu2PDTEpC(iJhH^af+tl7~h?Y(?qNDa`|Ogv{=+T@7?v344o zvge%8Jw?LRgWr7IFf%{-h>9}xlP}Y#GpP_3XM7FeGT?iN;BN-qzy=B# z=r$79U4rd6o4Zdt=$|I3nYy;WwCb^`%oikowOPGRUJ3IzChrX91DUDng5_KvhiEZwXl^y z+E!`Z6>}ijz5kq$nNM8JA|5gf_(J-);?SAn^N-(q2r6w31sQh6vLYp^ z<>+GyGLUe_6eTzX7soWpw{dDbP-*CsyKVw@I|u`kVX&6_h5m!A5&3#=UbYHYJ5GK& zLcq@0`%1;8KjwLiup&i&u&rmt*LqALkIqxh-)Exk&(V)gh9@Fn+WU=6-UG^X2~*Q-hnQ$;;+<&lRZ>g0I`~yuv!#84 zy>27(l&zrfDI!2PgzQyV*R(YFd`C`YwR_oNY+;|79t{NNMN1@fp?EaNjuM2DKuG%W z5749Br2aU6K|b=g4(IR39R8_!|B`uQ)bun^C9wR4!8isr$;w$VOtYk+1L9#CiJ#F) z)L}>^6>;X~0q&CO>>ZBo0}|Ex9$p*Hor@Ej9&75b&AGqzpGpM^dx}b~E^pPKau2i5 zr#tT^S+01mMm}z480>-WjU#q`6-gw4BJMWmW?+VXBZ#JPzPW5QQm@RM#+zbQMpr>M zX$huprL(A?yhv8Y81K}pTD|Gxs#z=K(Wfh+?#!I$js5u8+}vykZh~NcoLO?ofpg0! zlV4E9BAY_$pN~e-!VETD&@v%7J~_jdtS}<_U<4aRqEBa&LDpc?V;n72lTM?pIVG+> z*5cxz_iD@3vIL5f9HdHov{o()HQ@6<+c}hfC?LkpBEZ4xzMME^~AdB8?2F=#6ff!F740l&v7FN!n_ zoc1%OfX(q}cg4LDk-1%|iZ^=`x5Vs{oJYhXufP;BgVd*&@a04pSek6OS@*UH`*dAp z7wY#70IO^kSqLhoh9!qIj)8t4W6*`Kxy!j%Bi%(HKRtASZ2%vA0#2fZ=fHe0zDg8^ zucp;9(vmuO;Zq9tlNH)GIiPufZlt?}>i|y|haP!l#dn)rvm8raz5L?wKj9wTG znpl>V@};D!M{P!IE>evm)RAn|n=z-3M9m5J+-gkZHZ{L1Syyw|vHpP%hB!tMT+rv8 zIQ=keS*PTV%R7142=?#WHFnEJsTMGeG*h)nCH)GpaTT@|DGBJ6t>3A)XO)=jKPO<# zhkrgZtDV6oMy?rW$|*NdJYo#5?e|Nj>OAvCXHg~!MC4R;Q!W5xcMwX#+vXhI+{ywS zGP-+ZNr-yZmpm-A`e|Li#ehuWB{{ul8gB&6c98(k59I%mMN9MzK}i2s>Ejv_zVmcMsnobQLkp z)jmsJo2dwCR~lcUZs@-?3D6iNa z2k@iM#mvemMo^D1bu5HYpRfz(3k*pW)~jt8UrU&;(FDI5ZLE7&|ApGRFLZa{yynWx zEOzd$N20h|=+;~w$%yg>je{MZ!E4p4x05dc#<3^#{Fa5G4ZQDWh~%MPeu*hO-6}2*)t-`@rBMoz&gn0^@c)N>z|Ikj8|7Uvdf5@ng296rq2LiM#7KrWq{Jc7;oJ@djxbC1s6^OE>R6cuCItGJ? z6AA=5i=$b;RoVo7+GqbqKzFk>QKMOf?`_`!!S!6;PSCI~IkcQ?YGxRh_v86Q%go2) zG=snIC&_n9G^|`+KOc$@QwNE$b7wxBY*;g=K1oJnw8+ZR)ye`1Sn<@P&HZm0wDJV* z=rozX4l;bJROR*PEfHHSmFVY3M#_fw=4b_={0@MP<5k4RCa-ZShp|CIGvW^9$f|BM#Z`=3&=+=p zp%*DC-rEH3N;$A(Z>k_9rDGGj2&WPH|}=Pe3(g}v3=+`$+A=C5PLB3UEGUMk92-erU%0^)5FkU z^Yx#?Gjyt*$W>Os^Fjk-r-eu`{0ZJbhlsOsR;hD=`<~eP6ScQ)%8fEGvJ15u9+M0c|LM4@D(tTx!T(sRv zWg?;1n7&)-y0oXR+eBs9O;54ZKg=9eJ4gryudL84MAMsKwGo$85q6&cz+vi)9Y zvg#u>v&pQQ1NfOhD#L@}NNZe+l_~BQ+(xC1j-+({Cg3_jrZ(YpI{3=0F1GZsf+3&f z#+sRf=v7DVwTcYw;SiNxi5As}hE-Tpt)-2+lBmcAO)8cP55d0MXS*A3yI5A!Hq&IN zzb+)*y8d8WTE~Vm3(pgOzy%VI_e4lBx&hJEVBu!!P|g}j(^!S=rNaJ>H=Ef;;{iS$$0k-N(`n#J_K40VJP^8*3YR2S`* zED;iCzkrz@mP_(>i6ol5pMh!mnhrxM-NYm0gxPF<%(&Az*pqoRTpgaeC!~-qYKZHJ z2!g(qL_+hom-fp$7r=1#mU~Dz?(UFkV|g;&XovHh~^6 z1eq4BcKE%*aMm-a?zrj+p;2t>oJxxMgsmJ^Cm%SwDO?odL%v6fXU869KBEMoC0&x>qebmE%y+W z51;V2xca9B=wtmln74g7LcEgJe1z7o>kwc1W=K1X7WAcW%73eGwExo&{SSTnXR+pA zRL)j$LV7?Djn8{-8CVk94n|P>RAw}F9uvp$bpNz<>Yw3PgWVJo?zFYH9jzq zU|S+$C6I?B?Jm>V{P67c9aRvK283bnM(uikbL=``ew5E)AfV$SR4b8&4mPDkKT&M3 zok(sTB}>Gz%RzD{hz|7(AFjB$@#3&PZFF5_Ay&V3?c&mT8O;9(vSgWdwcy?@L-|`( z@@P4$nXBmVE&Xy(PFGHEl*K;31`*ilik77?w@N11G7IW!eL@1cz~XpM^02Z?CRv1R z5&x6kevgJ5Bh74Q8p(-u#_-3`246@>kY~V4!XlYgz|zMe18m7Vs`0+D!LQwTPzh?a zp?X169uBrRvG3p%4U@q_(*^M`uaNY!T6uoKk@>x(29EcJW_eY@I|Un z*d;^-XTsE{Vjde=Pp3`In(n!ohHxqB%V`0vSVMsYsbjN6}N6NC+Ea`Hhv~yo@ z|Ab%QndSEzidwOqoXCaF-%oZ?SFWn`*`1pjc1OIk2G8qSJ$QdrMzd~dev;uoh z>SneEICV>k}mz6&xMqp=Bs_0AW81D{_hqJXl6ZWPRNm@cC#+pF&w z{{TT0=$yGcqkPQL>NN%!#+tn}4H>ct#L#Jsg_I35#t}p)nNQh>j6(dfd6ng#+}x3^ zEH`G#vyM=;7q#SBQzTc%%Dz~faHJK+H;4xaAXn)7;)d(n*@Bv5cUDNTnM#byv)DTG zaD+~o&c-Z<$c;HIOc!sERIR>*&bsB8V_ldq?_>fT!y4X-UMddUmfumowO!^#*pW$- z_&)moxY0q!ypaJva)>Bc&tDs?D=Rta*Wc^n@uBO%dd+mnsCi0aBZ3W%?tz844FkZD zzhl+RuCVk=9Q#k;8EpXtSmR;sZUa5(o>dt+PBe96@6G}h`2)tAx(WKR4TqXy(YHIT z@feU+no42!!>y5*3Iv$!rn-B_%sKf6f4Y{2UpRgGg*dxU)B@IRQ`b{ncLrg9@Q)n$ zOZ7q3%zL99j1{56$!W(Wu{#m|@(6BBb-*zV23M!PmH7nzOD@~);0aK^iixd%>#BwR zyIlVF*t4-Ww*IPTGko3RuyJ*^bo-h}wJ{YkHa2y3mIK%U%>PFunkx0#EeIm{u93PX z4L24jUh+37=~WR47l=ug2cn_}7CLR(kWaIpH8ojFsD}GN3G}v6fI-IMK2sXnpgS5O zHt<|^d9q}_znrbP0~zxoJ-hh6o81y+N;i@6M8%S@#UT)#aKPYdm-xlbL@v*`|^%VS(M$ zMQqxcVVEKe5s~61T77N=9x7ndQ=dzWp^+#cX}v`1bbnH@&{k?%I%zUPTDB(DCWY6( zR`%eblFFkL&C{Q}T6PTF0@lW0JViFzz4s5Qt?P?wep8G8+z3QFAJ{Q8 z9J41|iAs{Um!2i{R7&sV=ESh*k(9`2MM2U#EXF4!WGl(6lI!mg_V%pRenG>dEhJug z^oLZ?bErlIPc@Jo&#@jy@~D<3Xo%x$)(5Si@~}ORyawQ{z^mzNSa$nwLYTh6E%!w_ zUe?c`JJ&RqFh1h18}LE47$L1AwR#xAny*v9NWjK$&6(=e0)H_v^+ZIJ{iVg^e_K-I z|L;t=x>(vU{1+G+P5=i7QzubN=dWIe(bqeBJ2fX85qrBYh5pj*f05=8WxcP7do(_h zkfEQ1Fhf^}%V~vr>ed9*Z2aL&OaYSRhJQFWHtirwJFFkfJdT$gZo;aq70{}E#rx((U`7NMIb~uf>{Y@Fy@-kmo{)ei*VjvpSH7AU zQG&3Eol$C{Upe`034cH43cD*~Fgt?^0R|)r(uoq3ZjaJqfj@tiI~`dQnxfcQIY8o| zx?Ye>NWZK8L1(kkb1S9^8Z8O_(anGZY+b+@QY;|DoLc>{O|aq(@x2=s^G<9MAhc~H z+C1ib(J*&#`+Lg;GpaQ^sWw~f&#%lNQ~GO}O<5{cJ@iXSW4#};tQz2#pIfu71!rQ( z4kCuX$!&s;)cMU9hv?R)rQE?_vV6Kg?&KyIEObikO?6Nay}u#c#`ywL(|Y-0_4B_| zZFZ?lHfgURDmYjMmoR8@i&Z@2Gxs;4uH)`pIv#lZ&^!198Fa^Jm;?}TWtz8sulPrL zKbu$b{{4m1$lv0`@ZWKA|0h5U!uIwqUkm{p7gFZ|dl@!5af*zlF% zpT-i|4JMt%M|0c1qZ$s8LIRgm6_V5}6l6_$cFS# z83cqh6K^W(X|r?V{bTQp14v|DQg;&;fZMu?5QbEN|DizzdZSB~$ZB%UAww;P??AT_-JFKAde%=4c z*WK^Iy5_Y`*IZ+cF`jvkCv~Urz3`nP{hF!UT7Z&e;MlB~LBDvL^hy{%; z7t5+&Ik;KwQ5H^i!;(ly8mfp@O>kH67-aW0cAAT~U)M1u`B>fG=Q2uC8k}6}DEV=% z<0n@WaN%dDBTe*&LIe^r-!r&t`a?#mEwYQuwZ69QU3&}7##(|SIP*4@y+}%v^Gb3# zrJ~68hi~77ya4=W-%{<(XErMm>&kvG`{7*$QxRf(jrz|KGXJN3Hs*8BfBx&9|5sZ1 zpFJ1(B%-bD42(%cOiT@2teyYoUBS`L%<(g;$b6nECbs|ADH5$LYxj?i3+2^#L@d{%E(US^chG<>aL7o>Fg~ zW@9wW@Mb&X;BoMz+kUPUcrDQOImm;-%|nxkXJ8xRz|MlPz5zcJHP<+yvqjB4hJAPE zRv>l{lLznW~SOGRU~u77UcOZyR#kuJrIH_){hzx!6NMX z>(OKAFh@s2V;jk|$k5-Q_ufVe;(KCrD}*^oBx{IZq^AB|7z*bH+g_-tkT~8S$bzdU zhbMY*g?Qb;-m|0`&Jm}A8SEI0twaTfXhIc=no}$>)n5^cc)v!C^YmpxLt=|kf%!%f zp5L$?mnzMt!o(fg7V`O^BLyjG=rNa}=$hiZzYo~0IVX$bp^H-hQn!;9JiFAF<3~nt zVhpABVoLWDQ}2vEEF3-?zzUA(yoYw&$YeHB#WGCXkK+YrG=+t0N~!OmTN;fK*k>^! zJW_v+4Q4n2GP7vgBmK;xHg^7zFqyTTfq|0+1^H2lXhn6PpG#TB*``?1STTC#wcaj3 zG~Q9!XHZ#1oPZo zB6h(BVIW5K+S@JG_HctDLHWb;wobZ0h(3xr6(uUspOSK0WoSHeF$ZLw@)cpoIP|kL zu`GnW>gD$rMt}J0qa9kJzn0s`@JNy1Crkb&;ve|()+_%!x%us>1_Xz|BS>9oQeD3O zy#CHX#(q^~`=@_p$XV6N&RG*~oEH$z96b8S16(6wqH)$vPs=ia!(xPVX5o&5OIYQ%E(-QAR1}CnLTIy zgu1MCqL{_wE)gkj0BAezF|AzPJs=8}H2bHAT-Q@Vuff?0GL=)t3hn{$Le?|+{-2N~`HWe24?!1a^UpC~3nK$(yZ_Gp(EzP~a{qe>xK@fN zEETlwEV_%9d1aWU0&?U>p3%4%>t5Pa@kMrL4&S@ zmSn!Dllj>DIO{6w+0^gt{RO_4fDC)f+Iq4?_cU@t8(B^je`$)eOOJh1Xs)5%u3hf; zjw$47aUJ9%1n1pGWTuBfjeBumDI)#nkldRmBPRW|;l|oDBL@cq1A~Zq`dXwO)hZkI zZ=P7a{Azp06yl(!tREU`!JsmXRps!?Z~zar>ix0-1C+}&t)%ist94(Ty$M}ZKn1sDaiZpcoW{q&ns8aWPf$bRkbMdSgG+=2BSRQ6GG_f%Lu#_F z&DxHu+nKZ!GuDhb>_o^vZn&^Sl8KWHRDV;z#6r*1Vp@QUndqwscd3kK;>7H!_nvYH zUl|agIWw_LPRj95F=+Ex$J05p??T9_#uqc|q>SXS&=+;eTYdcOOCJDhz7peuvzKoZhTAj&^RulU`#c?SktERgU|C$~O)>Q^$T8ippom{6Ze0_44rQB@UpR~wB? zPsL@8C)uCKxH7xrDor zeNvVfLLATsB!DD{STl{Fn3}6{tRWwG8*@a2OTysNQz2!b6Q2)r*|tZwIovIK9Ik#- z0k=RUmu97T$+6Lz%WQYdmL*MNII&MI^0WWWGKTTi&~H&*Ay7&^6Bpm!0yoVNlSvkB z;!l3U21sJyqc`dt)82)oXA5p>P_irU*EyG72iH%fEpUkm1K$?1^#-^$$Sb=c8_? zOWxxguW7$&-qzSI=Z{}sRGAqzy3J-%QYz2Cffj6SOU|{CshhHx z6?5L$V_QIUbI)HZ9pwP9S15 zXc%$`dxETq+S3_jrfmi$k=)YO5iUeuQ&uX}rCFvz&ubO?u)tv|^-G_`h$pb+8vn@f z7@eQe#Kx|8^37a4d0GulYIUAW|@I5|NIh%=OqHU{(>(UhKvJ}i_X*>!Geb+Rs0MWf66Lf z-cQ(4QOENSbTX$6w_9w4{5eR?14#?)Jqf2UCk5US4bnz8!e>vFduH6(cZZ=5*_!M# zUTZ_b<4v@}dSQOcH@wt-s;3JhkVDct$6k9!ETdi-tplkaxl^qF=p}Q8KMVm+ zeIa2q?RYr}nM0d_W2YWv%JKyCrGSePj8GrRN)<$Nsq8l$X=>`W;?>0eME3|8t&d$~ zH`XG45lBh>-te_f0Mh0??)=Ee0~zESx=sZPv<#!sAVv$0qTn@CmCUNJU<#=`GC)&P z9zuV~9*3_n2*ZQBUh)2xIi;0yo)9XXJxM-VB*6xpyz{Rx2ZCvFnF$2aPcYFG( zyXkO(B30?mt;5GW&{m^w3?!P`#_o;Y%P2z^A`|4%Bt2@3G?C2dcSPNy1#HMXZ>{+L z3BE#xvqR@Ub}uKfzGC=RO|W%dJpUK#m8p&Dk|6Ub8S+dN3qxf9dJ_|WFdM9CSNQv~ zjaFxIX`xx-($#Fq+EI76uB@kK=B4FS0k=9(c8UQnr(nLQxa2qWbuJyD7%`zuqH|eF zNrpM@SIBy@lKb%*$uLeRJQ->ko3yaG~8&}9|f z*KE`oMHQ(HdHlb&)jIzj5~&z8r}w?IM1KSdR=|GFYzDwbn8-uUfu+^h?80e*-9h%Nr;@)Q-TI#dN1V zQPT2;!Wk)DP`kiY<{o7*{on%It(j0&qSv=fNfg3qeNjT@CW{WT<)1Eig!g9lAGx6& zk9_Zrp2I+w_f!LRFsgxKA}gO=xSPSY``kn=c~orU4+0|^K762LWuk_~oK{!-4N8p8 zUDVu0ZhvoD0fN8!3RD~9Bz5GNEn%0~#+E-Js}NTBX;JXE@29MdGln$Aoa3Nzd@%Z= z^zuGY4xk?r(ax7i4RfxA?IPe27s87(e-2Z_KJ(~YI!7bhMQvfN4QX{!68nj@lz^-& z1Zwf=V5ir;j*30AT$nKSfB;K9(inDFwbI^%ohwEDOglz}2l}0!#LsdS3IW43= zBR#E@135bu#VExrtj?)RH^PM(K4B`d=Z6^kix`8$C1&q)w1<&?bAS?70}9fZwZU7R z5RYFo?2Q>e3RW2dl&3E^!&twE<~Lk+apY?#4PM5GWJb2xuWyZs6aAH-9gqg${<1?M zoK&n+$ZyGIi=hakHqRu{^8T4h@$xl?9OM46t;~1_mPs9}jV58E-sp!_CPH4<^A|Q5 zedUHmiyxTc2zgdxU?4PyQ{ON@r+Ucn1kjWSOsh6WzLV~Bv&vWLaj#Xz4VSDs*F#@M>#e^ixNCQ-J|iC=LcB*M4WUb>?v6C z14^8h9Ktd1>XhO$kb-rRL}SFTH)kSu+Dwds$oed7qL)Jbd zhQys4$Uw~yj03)6Kq+K-BsEDftLgjDZk@qLjAyrb5UMeuO^>D43g%0GoKJ~TO0o!D z9E$WfxEDFTT?~sT?|!7aYY*mpt`}i;WTgY|Cb4{Cscrmzb(?UE+nz1wC3#QSjbg>N zleu?7MGaQ&FtejK#?07Uq$vIZX5FqR*a=(zUm`Fq$VUl){GQ{2MA)_j4H$U8FZ`=A z&GU_an)?g%ULunbBq4EUT7uT=vI6~uapKC|H6uz1#Rqt$G(!hE7|c8_#JH%wp9+F? zX`ZigNe9GzC(|Nr8GlmwPre3*Nfu+ zF=SHtv_g@vvoVpev$Jxs|F7CH`X5#HAI=ke(>G6DQQ=h^U8>*J=t5Z3Fi>eH9}1|6 znwv3k>D=kufcp= zAyK#v05qERJxS_ts79QVns}M?sIf(hCO0Q9hKe49a@PzvqzZXTAde6a)iZLw|8V-) ziK`-s)d(oQSejO?eJki$UtP0ped)5T1b)uVFQJq*`7w8liL4TX*#K`hdS!pY9aLD+ zLt=c$c_wt^$Wp~N^!_nT(HiDVibxyq2oM^dw-jC~+3m-#=n!`h^8JYkDTP2fqcVC& zA`VWy*eJC$Eo7qIe@KK;HyTYo0c{Po-_yp=>J(1h#)aH5nV8WGT(oSP)LPgusH%N$?o%U%2I@Ftso10xd z)Tx(jT_vrmTQJDx0QI%9BRI1i!wMNy(LzFXM_wucgJGRBUefc413a9+)}~*UzvNI{KL# z_t4U&srNV|0+ZqwL(<}<%8QtjUD8kSB&p$v^y}vuEC2wyW{aXp2{LTi$EBEHjVnS# z+4=G$GUllsjw&hTbh6z%D2j=cG>gkNVlh|24QUfD*-x9OMzTO93n*pE(U7Vz7BaL% z@(c!GbEjK~fH}sqbB1JNI!~b+AYb5le<-qxDA9&r2o)|epl9@5Ya7}yVkcM)yW6KY7QOX_0-N=)+M!A$NpG? z6BvZ8Tb}Pw(i9f7S00=KbWmNvJGL(-MsAz3@aR~PM$Z>t)%AiCZu?A|?P*~UdhhFT`;Nb)MxIg*0QlkYVX+46( zSd%WoWR@kYToK7)(J=#qUD-ss;4M&27w#03y6$gk6X<-VL8AJM@NFTx#Z!n)F5T357%njjKyjro(yW8ceP{!%;*Y>DN`&_18p(z2Hg$%K zohbgJcp%+ux%q6F?(sc_mYJ<$;DxgkTEi?yjT6Du@+n(KsKtFHcO%7O z=AsfLSTdE2>7a@0^`;)?Fg|s2XOPV&fo<%Q)Izaw4s&RvrX0^+aPNq|yE?oSa7 zsnNs!+vGcTM4yM|$9so*2Nv;ngDD}b0MjH6i4e|l^O`lzCRj)-qa6f%|afJpmf(S1J2k7Nt^!;Q}0 z4ejPF?^M~Sv+@LYn&IFUk2;1h?kb8lfrT`oMm=JBm{fo5N|HY~yQQ`T*e2?!tF%*t zf+ncx15$NdF82GXrpP5rJ7!PVE3>u`ME$9Hw5RlP zUh+s#pg{9kEOsAhvu2pry#@dvbB3Lti+9VkLxPZSl;fNr9}wv1cTahUw_Py7%Xp;C zaz__|kz*ydKiYbsqK{?cXhqR(!1KMoV-+!mz>3S8S`Va4kD#(aKyqecGXB^nF*>mS z1gG>fKZc?R~Tye>%x+43D8=e zf0eKr-)>VEu7^I{%T}BT-WaGXO3+x<2w2jwnXePdc2#BdofU6wbE)ZWHsyj=_NT3o z)kySji#CTEnx8*-n=88Ld+TuNy;x$+vDpZ)=XwCr_Gx-+N=;=LCE7CqKX9 zQ-0{jIr zktqqWCgBa3PYK*qQqd=BO70DfM#|JvuW*0%zmTE{mBI$55J=Y2b2UoZ)Yk z3M%rrX7!nwk#@CXTr5=J__(3cI-8~*MC+>R);Z)0Zkj2kpsifdJeH)2uhA|9^B;S$ z4lT3;_fF@g%#qFotZ#|r-IB*zSo;fokxbsmMrfNfJEU&&TF%|!+YuN=#8jFS4^f*m zazCA-2krJ-;Tkufh!-urx#z*imYo|n6+NDGT#*EH355(vRfrGnr*x z5PWMD7>3IwEh=lO^V>O>iLP~S!GjrvI5lx<7oOg(d;6uEFqo5>IwptBQz;`>zx`n$ zjZQ#Hb)qJdQy#ML&qcfmb$KT+f_1#uYNo7HHDY}7xAw8qbl;9LWO-cndfI=5$%jBw zb}K3U%88Fg^|&0Vc~99bKl|$3JzdawRZ|`7%1S<8B7>9*rWAT0U<@mHDfnL1`~1U| zDw7m@<@}C|zqeHM(OK@di6~sKHiJvk^I0^S<LBe^_xZsUOzVkYSE)Bxn*NekQYbyTn5SRt!n{EseOo-$u)vjM(PV%6cIG3Kv$>dd}HUyXi;_Lv>}OyUj38dPe8+1Pr?{LXnIBCoTnocD60@vhsz+GG5lJB9ncgP8T6@LwuzZ)J zKETBS~AvzGE!{u^+Rd-|Gn!rc@UUnioP0{@_j_>tg8YI#?y zL-H$=&xXkCJ2Qe7&exbI!z`OyPxBp|4_ zZrrc;OAb%T4Ze%7E}FBB`8t$QN0sA3vpwU>?7QAmE%-ethXdCtby$Qm3v$lNxB2a7 ze6F5eEWV`={#W(G)Va}7?$D65WF|f0nmfZT;?=LE6Yz{{W3CV2h^Ma+LXdZ(HMVKZ z!YXJ*34lo!FA>)jSo@*!Hs_)IwmTo6pBr3c^j2u_amZ~g;&Z2jZIw!}v@w8DtZz7|A%rFksD4^HYB!xFAqX;u0HxPeG!3Z(z z4}+^N5-nckKf2YSR5R_}PD+2?Wq#BOiON74#{`u=4f59WKdy_77EYq~_|X6cNtno{ zZ?WLwbV57Z6uI|uY_;vzv~~`eiiOl($Au7C*X<&MY5v0b`KEu-GW}{2UNfmmrP!^Y zAOczy!}TIJsom=}kxH)9W`&Rp&rR6T7y&~5nXbut;wcs@M?aa^9j{ZDtx=1?P8TV{ zee2kKf%CE$mogyKKT=xQQ#)OCl9bjc)}{p2X$}aG`^B0w0yi-rI!d4e-u9uR$kJK3 zhqBG9Wx<-3DFw5olJ6neF@hB;8o(r(GB_;p1i>}cjN`JNEZg-dlxtLL=8~gfLrBy_ z1~bGh{I>_xqh(}?%bCf1U6~K@+N*i}bTi+pUAW)oM0`D*PeJq=S(-|Plxe9OqxBRg zM((r)xkSH@j!8@+=cA4US0fDL&O?W~x=Mlu>7zvHO2sy7D5_7ulP+YMecP~}F0b*K z3oO2j{o&WHd<&UWcyA(&6hvBJv}qUZ!@R<(mwKB^;y3zeE1>LzbDWSkRD1|5MZPx( zxd=&MsQi1eE@@6W+4N`cF?yh!3R5JlAV--&RONWQ#?SbrQ95<@ag>C{jQmGXpQX{) z1dbFg1_`qLxuDZnX#PKfCW*Jl3F&^7@gO&{>Nb8um$VBcF1!AL=N6`A%BFj=`QaPI z+m^`n+{o)KLif;Gt|7aQ(XXRP@x)jJt}s{&S`I3}jPTY>$@W0BD3Oif^ehs~!H7T1FUSWxLS&W;0q6+azjbWn?3!q$ z9qbmdr4H4Y)p^NOACJ^L>u}NS8T0_5hW)G z%Hv}dAqM}d@t;|hf8>+NHHPi*xePsRlqr46njzhiXXZti7i5+GTKcrlxA->OJ9*Pna`02EIA5~(SMV`T@H6F2VtwwP1$tYujbC1^VE$Yd&I`WSwB^1( zT7NP3|85z#R%&wktjwY_i*n_$RRZPM^ota{LPV%*>=>sAv%fn*cnkCIX{^SJRmwZv z!?f@T&D%Lz@*!mNYTGp{J|7)~PR*ib`;l^E)rQw@)Qn0ECnB8W1S_SbLZWdqcmo?V zX5g0_3qhn4TrN27^x#Qdq*4*G1L|)I^b8GuP_8O{p|M`uvZO6McXa>OSQRW|kQTNPZ#Zyj~SZ<`6B)Y+}jxpn+YT>MhZ!Rxyd@rU>N zP>MkDBLX|<)SJaO?Ge=!D>i+Wq&PgneO?ZXUq4IQuTq z+V{ZGkuw77o~o$!b>4ov`6CKJ)$cf=S6%1ZQyYU!kz_qiuNxY2*Bh;K9J6o_YV6xQ znW|>x+#Mymu&wF9P|3wP*(ZjwE+ou|{eFqMv}d_iEyH zQ?NSf3VX+EpbrIKmp|oD-t_rh(D#e)fp)dYbG{=yPj-3-#l+iu7r+~#w|(#wv@G0` z38`Yhf5CznhyDEhD;jzaz7fc8L?(n-m zR#|5hqq#yRoeTm+h^9J42mnB>BY>HSu&&O-Hxo6j!dqck)dGS&odS@Hsk2-*Z~x z0!%{@gT645S5DeF@JZeE$DFl*nJB8Z|JKvs%7d`KjbJ*AsA_=fEZ&V9=*+K{(TF^( ztjjYr(7@fV^tDs9c*#=8)ZRKO17A5Z`8v*)U+?hS>3sEfgh3`#vFO^7n}&&adV?}n zdy&BY1h|I@eBm=l*kqiJn>vNkOH4l$Op5Hw3K_w8lF!6T@-H)S2W|Km#6!-X#NqLJ zsiVDrc%*@I3^Gen$)6O0C_qw;8{aucF;}U^1%YE`?AYTtb`Z$B$vfhcHQF`VCB(Pf z_G#fV*Colv-k!O+=^nDNe(03?m+RTu&28d%>JrrwFNb{ND&?Ad(=DP@voz$usk1|w z&#gTB7F)#*LtY6@pIb(g72*LcnXRlTPQAD?)ZFnB*EsZqxM&Uk_KGXnR{4}K`I6i- zU9}R>tiO0De1Hx=kAy>7O+nKO@kGQEYOai&S9&WTY+flvR?uhI695W-xZnq4aRMh8 zwfp)+KYWVB#r=5AwwlSdM4@x7-R_{2;1iqz2lXL$7iu1>5W*+I)jlkMs>60=LN)Y= zbPw;;%U+%p_&{2Obemh$BLmbpDd31YxJ8#TpH3~3B8QLUMvx1X5Vl48hWSNN*UTlO zQgQyZbmyjGC-s$3tnB z0mfKUu2+_c`ZVvDVwUy#j3W*l^BSXXQ%=r6Z}C73jx8DAk!t7k{dK^udpHIcUejp# zyx}og$Hr+f>9kaZvno*Om`d|VTUce9tHM=R8thoG!a=NT$s;g@n_rAN%cp7nnLuav z6}j56TSSfPL$p#y#!5TVyqa3zTzi7@#IoeR=E6CdS`JrR+@i2DwZ?T*bh+(k5!a)0 zgRdF93z8XJ|5?>hDN!YAW5cK=+BwDLNT_+otd zqC@*{S0hCKZ+TnN*2&qx+WP;ZjHA`yytPcwKl~)uy)sQ}Q*0-&3X|YFYAjmolaciq zxS$r5^fxICetD*Dw78M9leVvhAOZ$=;SP7L!Vs?+0f1h*YCuTXIt03iAf)0=0KEvZ zB69o-zg`0C#hQ>`4`}1g=a~EID(j9HbjJG^tV-zumR-+fahTPveA{%0u2uQwMZ%}5 zwY!|}i0oTd&>^QSRhIKU+cMC#|C3f>|647?v1B(wH)EWb{vuJEJh~!#|J7%=h!x3| zCH6m}wg;>Q&?@5Ct1%n`lj%*>9a52d@wmvE`=aQjtz$sWj3V;fDns5<7d2*``)u1( zh!Ub>!#N0m=Vz1n1=El zwb2IVRw$6NIFRpGyUoM0iqc$IPehcmm7<0s7F*Yv+zq?_%pf*SS~~}s0M`m(rMbx% zi?|Wjr6fJN`_J8&B2$4+V+iO~m>s~Zr2T3Y3HGREFQ%%pEoU0N));AeSVM#gYQ>l} z0`RhgS`R^pJH31YQ~eTeJiI}g$&^|nv{!h?8mJK{{XDt+sG8D`7)$jvM#hjPI(5sS zfFW4s7wao%Lo| z#pJRC?iZOai;57ANs|vm6%}rPlGo}}Aso1t#xJn}%VW@~1WSjh(@JTgM$0x6ZQ)gB zdiox3f>kqGZY}+R<;wlNoWJ8#X-v)1;wRD*ec*wnvsN06Q@cZuD`deT-Bu&G;2fBC z0FE1%pG@{Yo2O87&dE;w???%`9s1gs=3GpM8xx_}=AB$K9y=cD);^iE*p4;T1RU%B zBPr)yqOBX<2}xt%g9qr>;z&|?4vhhw7@$a}Uy2b%_^VdB^VfzrebKUPnq;hliCNU% zVt3R5EHkhN^Pv`REF+npA@#HdCQN9IbQbqSDs^+zt(A6;rLwN+@Em}WrV5vPEo!w^ zSCd3RZ8{7a@d9@|IF&&G%irS7FHle?@49LctrtTt=rP$W)se*#RkFmyf)D1^U6EYI zfh+N?uH?-))O$9zM19VsuGn8?o~5`scXU?!P@_cWP&1U4PQqGus=sQzrX+YvKG%XBL3nt6!&M<#}wqA;Mo(}qrq<1lNkpQD-T#-y>grt|E+JNU) z2j+g+QPcA9VEFc0k;H(hSNOpp$I+!$ z&d&W6kBM9+c{X%vr_X0}tdB5dvEDyk5H2*T(QW8Yz-#tjvF?up=^Kfym``^!&O-X! z@HdfpHn;}_)y$Xjb-5cR$Q#-XdhKpmJG5pl>h*Q2(u*gt_4(>6?kG)%T3*&TT0qI( zL!aR~4HiJiaHlgdNcOQP6xx1f3AWx&8}(NEps|G!cO>J^rE2@&-t#_Jb7GYgnLnML~1ze1D$?~BwbgA^=pr55tC|d7w42vN11_8bS75u z_MRKqE7Xik8fk>6(VE5{qT}6rSzd|o}Zb>*aI*Bwg%ccE$_ytH;g2H z^i3qY!+aE*&s^BMH9TI6GLm&9c`D6)3{-+?2Pon+040Yuv$2(LqV*krKhTg5CHOj* zquacxc1&~=S(O@gR8aI#?R%)meONmw1rub9E2QzeM$pBBm2wbPNR3tab{op53<oFwaUbARdD5jSA_6zmKX7!VicEP1m)rYnk{P- zruRj;4c8S29Rd#Baf|fq_pA^r3K#qRHS;($XNoLI*`puZjM?bA0tH>FDiVc9qR*|3 zGn#nhqxkvqFwRfCB~2yA0pxWapfjCdAem$utuon-`*6}mUP?l%$CE(FjAwL%Oe7GQbu7*+&q>*(cAofJr^gg>xw>hx-SO7Lx2)I} zJ)tV1XKbkE4sS&La#-smSq>S9gBzGLH%v?KVezdGv%Xs}kDJZJi{lDl(FpLZupBta z3iDlkd6LlkRro}+El?GIObw06D%NTXpL{W}Ve*%u#{wTC=+VHS%o`sAez&cYz|Tn` zcK_~pvN%cd^8FlFypCjTjw9@ulLoJ^!QAK*++^wC2~}CFeoY;q6y~r&f^+0>LR6)n z$hSev@GzzGgDc>)#u5_;{T9^5y5I?m=z7=J!eVId8p6R5>NV8)h|bA}#3KUufq4CPGiWYvGj%0=H@Q66);F)#cDMND4 zX|?rg>Bb28q*a!_sgVF(A=OeC&je$C4>$0%yy;Fla-hl(|9Ww4!@Q#E2hpJMMxpQ2L+R;+ZMpS+|j*F`Fh}p)`a_*<`AaeFzNEq^- zlF$7BFKD%p@K+3$Vx%N{QOayKKWU#JOAwXiLO62cA6=|DiDG_Z=ef;f&gQ5-?+Pb+ z)4NsyEZXCdjq5tgDN39V9!6#w25+R1;PD7ss;hFvQn}Hnl3^3h<`ylzJdVEL>|Jj0 zg>=Pscwx&;pWEzMn`ld**$1F-nhqlMuX;G{lWrT<<4$7MZ^*4a2hAMf)3eYiT$lRz&9({j<=%DWIRpgu zoOns@gF}AQ_6Y5RhySg7yMtJcYQap6^hgy{`zX1Zv26q4<)g@t%aIi|-lmcySuRN8*5f*$aEFi8o#kMKRCMnrAY~l`= zez#50^@Qo+6r508>iKfAbbc3JwCnjnmw;~=mlMG`(H8EJz7W6mh@mdinO&)#zHX=| z&|fo@s`;njVkkCMczSnp+TnW8YPU4w2&QmzEh1}orF~KlT=V+`!!rH|PtULCcL!P*m0EaN0Ad2qBw%Gs40jfu=%`N*k@z2-p?&B?Yum-p+h?7(!D^ z&f2Bn_#t!4HM2y^*1GN;U+_x8T$Z2>U9Yx;p_9Qf=ww z2hxO^*{%p9-CwMKz}C4mTi8xvqhivltE|}Kgq5MK@f6tBT&`@RYzsFFi>*eMZ0Z6Y zKBl`GOh!U%C+PXJ|7PF)V*~#8eS80D@v-NL2U&;i62W}k+vJAC+7xF`eq%c0b?{PVTcqiDr%6jLBdkVcTwLJSd313SP)1r=;2`cORbMzrhqZxMWcTWru5-l_H8;f|?{^M%%7>sU zGx2{fX*t;7SewS|NvPR-6F5p(ji7d}CK#%7y}jsPkgj%F5cUbQ?b7uWpYks^|DL*n zau%X$^(%wXMS3c;C4=p*#q>ahmLH5woLsn-YcZP~mH-rGnRyl#KU4MsLu+G3z90+q zM$HCWgZYR`8_I%8)SYuBltP$sN`-6hcjnzhDsVl+Y}yqMN*4MWsJX_6R>Cyw8cHGQ z1>r%vkDxxc#ACA4+-ZO|QBMUz`YHrS{l-*$> zi(n_;4{Gn+d2gn)TA<9) zibWdKJv#s_f5K}vM=d0NaYrd;5A+Fy^=+WgKC`@bS>!P5@K4fzE#VYfMcNdbbvLPY zeR~!f3xU>|pfq-LOsoF=t94x%K!8>#8tR4KQ2G3Yr?Cb98^KL*+G8``rHMpNUN}-T z5HGAkiLh{WR;N$Nk3X_2^3pW=vOFTOb(LS0Wu)0)I{8sZj>}5ZGtD=va-72l&5`L= zhyzBWie2UrC|?(sTcuk$OwvV4oVlxc3ncXPj|cD%%*6(hoKMd5wzPQs^6g)B0xK#d zemOodB7D(!@v!|eYqMfx@M#b+D)PwAuvimOW#13i-xAR5)Ai; zXNX(A@M*y&+TVZI zGHo$F*Ipg~Rnp`KlMNAl2o86}r%Yv9#!O-oo`pe`880;-Y28tR)b4H%nqXXHxN9m0 zI&#!(XhT=T3$WS$)K4#Y=ceN`MsP0v1X{nIoQ14S2^--MnUp21=V3&Uv8|y}^}7Vl zI5tRbOp#?@ay6uncZFE0hg}kt(k%piw^M8;0yynsK_!l~uP??IqzmKJMUqAW^GG{~ z7Fg)Q&zBlp z%Tj8jOUpuR>YHP6zYsX?)aJ`)_pRwu+Tn8I;brOW_`v$u$`$9T)cO*O$j=?mg>dW$ zw=&3=v||fqCr`-$okN*$S9(Nyrs}+Lu#IwDg2xSBz_VfU*?A&26vwv>&>*U_TT7-7 zS~X}fT%9+q(Xvc0qzOG^8gmMcZE9izi5feqvY(aY=%reP+wVZ&cRd`^y6}-gJ&_6n zR%Wdl3vQ4DOt!X9ry7j%=+7pLPdus*@7dZMBo0_WKZPD1(o{=;D> zyc9_WFI3{URv=d6EXcnOG0$(J(R#8Oz$kmuSFQ{-Y20}1027!FkodTU!fouSybwqn zRO-$2BH(w4)$wiPo<1w-4*p=Q0@YKRm^cgiA>~ho)U8^e>SBk*!@xvr0CdvnLHS#CACVuQfgzF>8qV znqf{oO1}RWhiZ3g!Tx9sk!JfLqcP`>Ksx#vZuLg-DC6h4mT!vlU zqw0`0CzZgY!EN0*{sQnDNFn;T<+e_x$zY|n;p0@d^hK*n!S!=#^;P{*D^6~h!T7r6 zoiMxtovMo-dj*{qZPy*c3gaMBEDQDkINU%d8HeBZVlRuzkCId9rx{?L= z-dLlk$w&JX5wn+8`mtqCpKnx+w+$@6DEUI}8P%xN$MEsw%S1-$9PM6r^jP-@?cS<# zhg$wl0X=s3{8EZ2U9(};p{X_b1@jJuGgx`gDK{6MpF|XON_=Rv%-<Ee1cuuy?nl9xVDa~x=+8ppnOQ9 zN$53qi4QQ!co(;f!#YJ8(=Z>_9UF#(QOVjS7T!g2)*Oecrf-R^)tFugBkQsMVNua# zS;1V^#fJS{h+!O+FgS%0=Pd9;lMa0QHn?-n(<0b2$<|@r>fjiyw6u*UoGmU$ayJM@ zfp;c4@{$b*Z_v9?8ZEp{m6Q(mDHW<``n?jg-ZN)Hhvxn*l=O1f*K%{5s77WCt!ugS?*2oG5-Q)JEJd0+W5=doeD$Wh?U$ZRg)K$v8cmQ{hba9jw_mF&X zi-dV?WITgIz!!0uB~jE?(t`&qo{WGyUspX| zc6+F2K4l5$LqxERF#`I&k^^opVIMZjGhsJ^vI0c%kV+|&_k>~}ueTtj;^Dfb@xHs` z)-39elzVA~D~n_aoyBQ1>Qd2!;E!G*pZM&RX`r*y)b`yxvP2;#vM*;CQGPg|gni)} z47`Log3PUyVfdmJ2zvHBhg7T#D-H=myzkeUa$@);WC(yB4k^*$wda3=S-UH5Q1Hx6 zPcGxMP&kXBa+4$s#Sw3-V?mlHj^8&bLpIN~GkYj;!;M!$ZxvtQY4j&Ngz_mxuQRqx zYTbN6epx@-!0jRV5yiSIJ<^mCZ<|;&x2~a)t+(eAVB!1XpCZok*Z2C5P7&>z-Oy?t zf@F(_FLsSrfCus61+Vt~svP%(u<4pzT5{w*0XqfPV%~|=%aq^$=*U+_trGQaoUxbt zBV#Yqx+ULku8yPJs4gGcC?+3iRt_6)Oi0DNLxdb(!n!cup_XUZ3eDe(!DChZ!IG&L?_;T-1GB!R;;Sk;l3Y*JQ!I|l20_f}ZyC;4D7R@6F z>%z~wV;Bj1b(*kp26Ed!Y-OKxNbt3%t))xxOrazWsmwvW;uaSaJ0ou+{01vXvU>_V z6Ha@+;giVaiyg`J8ENQf)Pq>!Nf22>XFHnXTNk84&jp-^YwmlUqnOll8)5mzlO$o! z#fSMwH8Pn+Fy7O5M5#ZGr$cKfaGf8g;XN)<*TrQjMk<}_oRf&b6qZoR38Q{Zxo{V; zby+J_hCZT1>`4~jnQxo|ji%BQ0=BLzC6c!1=B(jS5+fcp%q)JI)=c3{D|=k5;0&c2 zrbRE|qxkNqah2nvextOvjYA{T43n1c6eO7B9DH)tLqB46E7;0xKM=%#wx-*-+*OY{ zQ#7gMStz%I&2&rbo>#T20OD_#g`WYbt9+!MC08%zSMhqMoRk)7VOk%~`sD%(U6zzO zdmSC9@x0GCv2_)umYc5@#%efP0_cu+=f^}k$H9$N_>piA_(5UM_o{++8+Yf8SJ)?C zDd3l=GGm3EEy;&Z6N=+XP@IM0L=uW^ooyYQYyx1vwFR?@U~BAtAqTu%Mi2 zTCQh$K=UZA{P`Cw0I$xAh_f?fq-Goe`7I38{3L8?K3`lRhSAyB)tHT@4c!Y;bJAAS z3u>Q7qx>9SJs4$EB=hxh)u`W5jp?>^g1s_MV7<1zN zXt{FSt?Mt&8aCy67<)b@eg@h0iCW@%+pF-V>p${fyEk6_Gvp|ms{Whi-9eNId?xzZ zm|MI>F;JSuaUnQp#|}k3o&ddCZEeTI608txuU4~7K(wg9 zg%+}(7h2@(%>LI1F*puF(h$ZD`Q+ar!VoVajPY0-XS$>6F_F?sc6Mr7>SL-&{pC;2 zKx@2{@ULz7RCpaKg$iu2rcY+y*~qaPo0}^7T1K$_(NPS<1;V zTj8-xC%WvgDI_YYEG{bySvyO3M>XKY)oXgGG*eB{yDgNQ3s3)A~@n>!O#lNh0! z(-dqW#_z&mMfq#2+u61N`L^({4UoU8wE5`4c}{SGFzKb(BK8hM%cf_zj_HmC48)M& z398ICVJTGzBaz7K{L+Ew=;z^0xA``wbtPs`r+Wrb^_vzzhukq{;A`t&-ktzb zbqy`Z0#D6fdVAiodjF3J+qI*vu#=OCjiL4bIIXEf4?zmN7(H|+<+WfR7@7jrMx7FY z5*0X1enhay-q^M?j}3Pd^|U9(C3#CQU3=hlc~@y9@NQD{UZNfC^5?Cuuuu{ebn_<7 zEzudv*b@QP%)N^5jP;86nQGb<*SOytCM5wmf-=rH#K{Wd$2(X#S$jF}XIxZC1)zir zU2Wq>hIB44nCTqx2x<{_wiVzLSJR}L%P!Y|lFHtA_=bDj=OqvmmSZ}ffuqPge#V-f zZDk|XX0RK}=73LxL`H%OXxK*^I2!fp&kxatErK~&tM3@j1a(Yrq$z)R()i?}p|0^Y zhW&8!IpRA1jJ3e!p66ZY=eBmEA+$A`!%s+{Cz!s$IA`{_Dh0^jt!vn;+Nw}hx019Q z_Wg=#-G-~&@>l=&H~48$L8`LX)!Bcq%(DFa2Loc91u@WcwlHzJwo{cdur>bQ;{fr_ z`rC5QRQ_)`8EadJzz-{K&sUI~>NX>P|c4l)fKS0gkuGe_P ziaQy!%CK(CtAwj-J8&#kyU=G(k%3y`!gS9dU&1xIrGRL|!&aVMEaezUIpopoET~xE zp`%~`LZfn!Lu^+00?>v4UOfM!HeeQoLZP<#o`^9oi69|$0BM?n17R~tGpY)eJiv@$ zTV-~ZZ*}C1J{a}p`>l$Bx8qRBq91;dLdmp84auzmcd|XzJG%I|r z^E-8Tm~jRn_>as(R=@~z3I2E3<=#hXn>A=0`wfOGIxiP)N2%!cG?&^w=E#TR z`lSY@Mm36zu4p3}+S#67MpL$d{gf@dnP%*ZMW=gCXK-%0E(xAC!^+b7hCSMF$m;Rn zCTErbBK#;a)>kHX5}w6PRmnw(!Gy>m_g*2opfklHyx>eb1bu|_lwJdf!ogxhk}X^v zc+^L;F7ta!8+i%6?M}XvQn4b%aOSCpDW+4#JDDG(wvXC*9%9(XBhbv4LX3R5G&(+@ z)nbdivYRQ5pW;9~@YGf{h~Rm(@MfV8Tj&T@EejO6(C#(+z7FVNBR`@j!#wScHM5ki%j+^GykUJ2m zYgpwm;#Q)~LoozUSV($?r3vQ~#ZU_}ggl~J%z*1dYt_^4K6e7o&qs_ORz{km+D+^a zqDdUO)d}|)v9h(Zz3}#DLWyRVCY!=PMCO{=PA)Upb@)1j?c)||l{6&pI=;U#bS#Jk zOOiwVH3FM!SuJDIPnN$|ZKz5fQwHmzn8f^?B+T2ew%~PSE#X_jk`Wu;a{4}9%AHg7 zZm8^bAee$bdpwklIE`$fV15=pI+tgJpll4uQjIM;Q!gvISFc_{@=lUSc-lABE%U?+ zHW$;!NcH1&F;AS~7RH=n<=!NTKnm3t`B@YeL?8d2{WGrmSjG;yBbY*9$N&DT^e?l2 z|1A2482Or7n7KF_TpRn|nmqD}`-=?QJ0z5q$C9Td^sML&aN7OGi+W$uYjDXKJg+0W@S=FoQP2dBI=48|FH>p2mh zFrdu!AwoG$NkvnZp_KT8HEo=RNNJ4IxucGXLr2N*I5Ao>Efb+pNOm9Zw0_7_s|9ac zS6}W##>$W*cBmksip;43p#a4&iTpM)8(gRGekW+AKm5zb)xpUFT>~b+FOH`Zs!$RDgpSCE z>;CL8Uu|EWeR~TvgDX@K=mtReFed;FZ!M2SjzW35i;UqfyemM?rq5yZS#hK5Y~|wt z2#^`Q6$b~uGT_++C3+B~#(oFHdSL&hh`Z8{t5#=ZkoaWVJoLm)3vT_@5HOnZGa;s~ z;4=E`3Eo@=$BxFjS`Iu|8SALB`<#TPTeE%h(dol+#CzJ=Zb&EHpw*=0H*~8x6 z`G`b<@>L2(AS*J!NVp`DN{g!8R#h(~URslf zC8PwGM$5V}+$WcoT*C~*$WmCpS6Gis&sZo|9OfRiwjX$f*&25Gjv6$YPde1smwGw( zb@y=gbl1!8>hm-il3&~zFca0~aJN!?b97+$E>2$Gn$31OR&UnE=Tm= zH44$Dx2HNN1lrCGjfuwo@+(m2j85w-oxre9FopupEV+6HACFyTbt}s-`lCCJ8om5RIE~T#Yg_DWu1u zyAp%jp;3&%D4;CRaR6g=f*ZvPqw2BadP=*ZYy_~CV3@wFx5YA(E8)jfqx z8tjEkMf>msMqi)zaY2fWrMq`lZzZdiMcluc(@(yxK(4hPEFk0~HO3^CUZk3;?Tv3` ze-rjZ8@hBrVPzA$^4hW?<33{d2)h7Jw?$t%V6(C_m+bNhXl9vXCJcBWmMeQoLDm5b zt9|A5pDHY#Y@(rlEo_WzXila!uaZE*WVc`=IM)SSc`#liZ2Wt*~fHgm9uH^ISX2d@)XGZ)_$qnbx6?J<14_=SS(ITs#LPDk03a&%x;bAuGz=P ze^<4p@tD@J|M;88;~IsEOPpB+&3C4!3q;}Kk2tb*WuuE z2u(BE$1(2AwbbBrmU-YLI4>#K((6&QZ~m2Yp;I14x0N8hos}{uoQuMG)Wy?ogaNayqmc&`I=8y6&dPf{Fky#B7 z#F=Xy213s`NFxjKuMqH3+ibWsFRi=QtH*j$9^)Zy8F|^vSmgj~l5<04MiU;BNyAn) zlM+c20Y#%@>WgdY>5kx}H)7*!D~BZJdg8d5iHx|>(jj=!MEmr)-$kH8?A#;DyBone(uz;e^|=9nIwfuWY?yw; zC|H`;8#O$vTPm5AW1Gg-Up&#Ca$<@!JZkAUDbmd*?X}QSA5$(*c+FZ|l+}F%*L1OH z{ck}P=j@=7>6ga#cqzj|ODXHD>ckIBmOd9Fh=~>?C7$uII_3rEX%UKdywsInR~{t- zg|t`~l=L1P_QPkZN53Q>!^A*QDZ zK(f;%VVQo)n1bsy)LWL#?&|wN`hL~Rnxhd3d-bOvlRQAiybH&=i;SlnwP$3P-!%x3^o)t6aoT-zXU}ARq-l^bOW-zg$@b|19Aua zF+k$V!uO;fNwCUEi;6!|5?4_MKtTq}|C`2gXh8EhWP1bTgZ)DqHZ&-x|E2*6Ka!RZ zS5jsHN&IW7%g1yUln@bn$cO!hR2b+`P~1-3dFIx!6EltRa{a z6Z@Y$_ug)~d%u)K$+?LYfc<87}bupdiK(3|m%hiA$Pc>zKNP0hqBj{X*L0rm@j(0s(f>>t{1L0?w#rS+#E)IdBKcF5|Dq-S zZ*-X3x;NeSuOSxS<3Q%uy1zwQ+?Kj&)Ou~-|2+&J{Zi^T=lx9+&+B^K_lQ;hY2H6D zeZ9T!H&;?$+kt+MLCs%i{8QEVi8<(Pft!mFt`}r~k5Y%93jAjQ!fgoD?Zh|Vi~q5A z27G^+_!lc1Zfo3}625-J{(B@p`IW|R4(!c|yX*Pn?*SA0)3iUGUB11uH>ab1{F$$g z|7q4=O#$9cezU54J)`wKI1_%J{14{0Zj0P3wEcKU`%-=?@(1PW+Zs0qGuI`%??IID dD~*3C;60WFKt@K_BOwYX49GZ$DDV2e{|AYb(KrAA literal 0 HcmV?d00001 diff --git a/ServerlessFunction/gradle/wrapper/gradle-wrapper.properties b/ServerlessFunction/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..23449a2b --- /dev/null +++ b/ServerlessFunction/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists From 3f5a880ddcdb91c4a8302ec744287da878944c12 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 09:59:29 +0900 Subject: [PATCH 31/99] fix: use single line sam package command with hardcoded bucket --- ServerlessFunction/buildspec.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/ServerlessFunction/buildspec.yml b/ServerlessFunction/buildspec.yml index 79b8ecb7..16ebf0ce 100644 --- a/ServerlessFunction/buildspec.yml +++ b/ServerlessFunction/buildspec.yml @@ -27,10 +27,7 @@ phases: - cd $CODEBUILD_SRC_DIR/ServerlessFunction - sam build - echo "Packaging SAM application..." - - sam package \ - --s3-bucket ${ARTIFACT_BUCKET} \ - --s3-prefix sam-packages \ - --output-template-file packaged-template.yaml + - sam package --s3-bucket group2-englishstudy-pipeline-artifacts --s3-prefix sam-packages --output-template-file packaged-template.yaml post_build: commands: From b26f47cbdba053d4f6105b48c28c3824d59b3cdd Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 10:09:30 +0900 Subject: [PATCH 32/99] fix: use existing stack name group2-englishstudy-chatting --- cicd/pipeline.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cicd/pipeline.yaml b/cicd/pipeline.yaml index 19b19448..74919262 100644 --- a/cicd/pipeline.yaml +++ b/cicd/pipeline.yaml @@ -268,7 +268,7 @@ Resources: Version: '1' Configuration: ActionMode: CREATE_UPDATE - StackName: group2-englishstudy-prod + StackName: group2-englishstudy-chatting TemplatePath: BuildArtifact::packaged-template.yaml Capabilities: CAPABILITY_IAM,CAPABILITY_AUTO_EXPAND RoleArn: !GetAtt CloudFormationRole.Arn From 033c98e92fd1d387fca8f9673846467e9162fa9e Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 10:27:26 +0900 Subject: [PATCH 33/99] fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions --- ServerlessFunction/template.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 99bdbafc..d997d830 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -232,6 +232,7 @@ Resources: Environment: Variables: WEBSOCKET_CONNECTION_TTL_SECONDS: "600" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1177,6 +1178,9 @@ Resources: 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/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1196,6 +1200,9 @@ Resources: 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/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable From 9b3f286c16dac309ff2b1bbc454e34aaa9a8a478 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 10:49:31 +0900 Subject: [PATCH 34/99] docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure --- docs/FRONTEND-API-GUIDE.md | 266 ++++++++++++++++++++++++++----------- 1 file changed, 192 insertions(+), 74 deletions(-) diff --git a/docs/FRONTEND-API-GUIDE.md b/docs/FRONTEND-API-GUIDE.md index 1d138573..cae65a1a 100644 --- a/docs/FRONTEND-API-GUIDE.md +++ b/docs/FRONTEND-API-GUIDE.md @@ -1,43 +1,65 @@ # 프론트엔드 전달사항 - 채팅/게임 API 가이드 -## 1. 현재 아키텍처 구조 +## 1. 아키텍처 구조 (업데이트됨) -### 채팅방 = 게임방 (동일 엔티티) +### 채팅방과 게임방 분리 ``` -ChatRoom 모델 -├── 기본 정보: roomId, name, description, level -├── 멤버 관리: memberIds, currentMembers, maxMembers -└── 게임 상태: gameStatus, scores, currentRound, currentDrawerId... +RoomType enum +├── CHAT ("chat") - 일반 채팅방 +└── GAME ("game") - 게임방 (캐치마인드 등) + +RoomStatus enum +├── WAITING ("waiting") - 대기 중 +├── PLAYING ("playing") - 게임 진행 중 +└── FINISHED ("finished") - 종료됨 ``` -**핵심**: 채팅방과 게임방이 **분리되지 않음**. 하나의 채팅방에서 게임을 시작/종료하는 구조. +### GSI1SK 인덱스 설계 +``` +GSI1PK: "ROOMS" (고정) +GSI1SK: {type}#{gameType}#{status}#{level}#{createdAt} + +예시: +- CHAT#-#WAITING#beginner#2026-01-22T10:00:00Z (일반 채팅방) +- GAME#CATCHMIND#WAITING#intermediate#2026-01-22T10:00:00Z (대기중 게임방) +- GAME#CATCHMIND#PLAYING#advanced#2026-01-22T10:00:00Z (진행중 게임방) +``` + +**핵심**: DB 레벨에서 `type`, `gameType`, `status`, `level` 조합으로 필터링 가능 --- -## 2. 게임 상태 (gameStatus) +## 2. 방 타입 (RoomType) -| 상태 | 설명 | 게임 시작 가능 | -|------|------|:-------------:| -| `NONE` / `null` | 일반 채팅방 (게임 안함) | O | -| `WAITING` | 게임 대기 중 | X | -| `PLAYING` | 게임 진행 중 | X | -| `ROUND_END` | 라운드 종료 (다음 라운드 대기) | X | -| `FINISHED` | 게임 종료됨 | O | +| 타입 | 코드 | 설명 | +|------|------|------| +| `CHAT` | `chat` | 일반 채팅방 | +| `GAME` | `game` | 게임방 (캐치마인드 등) | --- -## 3. REST API 엔드포인트 +## 3. 방 상태 (RoomStatus) + +| 상태 | 코드 | 설명 | 게임 시작 가능 | +|------|------|------|:-------------:| +| `WAITING` | `waiting` | 대기 중 | O | +| `PLAYING` | `playing` | 게임 진행 중 | X | +| `FINISHED` | `finished` | 게임 종료됨 | O | + +--- + +## 4. REST API 엔드포인트 ### 채팅방 API (`/api/chat/rooms`) | Method | Endpoint | 설명 | |--------|----------|------| -| POST | `/rooms` | 채팅방 생성 | -| GET | `/rooms` | 채팅방 목록 조회 | -| GET | `/rooms/{roomId}` | 채팅방 상세 조회 | -| POST | `/rooms/{roomId}/join` | 채팅방 입장 (roomToken 발급) | -| POST | `/rooms/{roomId}/leave` | 채팅방 퇴장 | -| DELETE | `/rooms/{roomId}` | 채팅방 삭제 (방장만) | +| POST | `/rooms` | 채팅방/게임방 생성 | +| GET | `/rooms` | 방 목록 조회 (필터 지원) | +| GET | `/rooms/{roomId}` | 방 상세 조회 | +| POST | `/rooms/{roomId}/join` | 방 입장 (roomToken 발급) | +| POST | `/rooms/{roomId}/leave` | 방 퇴장 | +| DELETE | `/rooms/{roomId}` | 방 삭제 (방장만) | ### 게임 API (`/api/game`) @@ -50,20 +72,39 @@ ChatRoom 모델 --- -## 4. 채팅방 목록 조회 쿼리 파라미터 +## 5. 방 목록 조회 쿼리 파라미터 (업데이트됨) ``` -GET /api/chat/rooms?level=beginner&joined=true&limit=10&cursor=xxx +GET /api/chat/rooms?type=GAME&gameType=CATCHMIND&status=WAITING&level=intermediate&limit=10&cursor=xxx ``` -| 파라미터 | 타입 | 설명 | -|----------|------|------| -| `level` | string | 난이도 필터: `beginner`, `intermediate`, `advanced` | -| `joined` | boolean | `true`면 내가 참여한 방만 | -| `limit` | number | 조회 개수 (기본 10, 최대 20) | -| `cursor` | string | 페이지네이션 커서 | +| 파라미터 | 타입 | 설명 | 예시 | +|----------|------|------|------| +| `type` | string | 방 타입 필터 | `CHAT`, `GAME` | +| `gameType` | string | 게임 타입 | `CATCHMIND` | +| `status` | string | 상태 필터 | `WAITING`, `PLAYING`, `FINISHED` | +| `level` | string | 난이도 필터 | `beginner`, `intermediate`, `advanced` | +| `limit` | number | 조회 개수 (기본 10, 최대 20) | | +| `cursor` | string | 페이지네이션 커서 | | + +### 필터 조합 예시 + +```bash +# 대기 중인 게임방만 +GET /api/chat/rooms?type=GAME&status=WAITING + +# 캐치마인드 게임방만 +GET /api/chat/rooms?type=GAME&gameType=CATCHMIND + +# 초급 난이도 채팅방 +GET /api/chat/rooms?type=CHAT&level=beginner + +# 진행 중인 고급 게임방 +GET /api/chat/rooms?type=GAME&status=PLAYING&level=advanced +``` ### 응답 예시 + ```json { "success": true, @@ -73,12 +114,15 @@ GET /api/chat/rooms?level=beginner&joined=true&limit=10&cursor=xxx { "roomId": "abc-123", "name": "초보자 영어 스터디", + "type": "GAME", + "gameType": "CATCHMIND", + "status": "WAITING", "level": "beginner", "currentMembers": 3, "maxMembers": 6, - "gameStatus": "PLAYING", - "currentRound": 2, - "totalRounds": 5 + "currentRound": 0, + "totalRounds": 5, + "createdAt": "2026-01-22T10:00:00Z" } ], "nextCursor": "eyJQSyI6Ik...", @@ -89,36 +133,66 @@ GET /api/chat/rooms?level=beginner&joined=true&limit=10&cursor=xxx --- -## 5. 프론트엔드에서 게임/채팅 구분하는 방법 +## 6. 방 생성 요청 (업데이트됨) + +### 채팅방 생성 +```json +{ + "name": "영어 스터디 채팅방", + "type": "CHAT", + "level": "beginner", + "maxMembers": 6, + "description": "초보자를 위한 영어 채팅방" +} +``` -### 방법 1: 클라이언트 필터링 (현재 가능) +### 게임방 생성 +```json +{ + "name": "캐치마인드 게임", + "type": "GAME", + "gameType": "CATCHMIND", + "level": "intermediate", + "maxMembers": 8, + "description": "영어 단어 맞추기 게임" +} +``` + +--- + +## 7. 프론트엔드에서 방 타입 구분 + +### 방법 1: API 필터 사용 (권장) ```javascript -// 채팅방 목록 조회 후 클라이언트에서 필터링 -const allRooms = await fetchRooms(); +// 게임방만 조회 +const gameRooms = await fetch('/api/chat/rooms?type=GAME'); -// 게임 중인 방만 -const gamingRooms = allRooms.filter(room => - room.gameStatus === 'PLAYING' || room.gameStatus === 'WAITING' -); +// 대기 중인 게임방만 +const waitingGames = await fetch('/api/chat/rooms?type=GAME&status=WAITING'); -// 일반 채팅방만 -const chatRooms = allRooms.filter(room => - !room.gameStatus || room.gameStatus === 'NONE' || room.gameStatus === 'FINISHED' -); +// 채팅방만 +const chatRooms = await fetch('/api/chat/rooms?type=CHAT'); ``` -### 방법 2: 백엔드 필터 추가 요청 (추후 가능) -``` -GET /api/chat/rooms?gameStatus=PLAYING // 게임 중인 방 -GET /api/chat/rooms?gameStatus=NONE // 채팅만 하는 방 +### 방법 2: 전체 조회 후 클라이언트 필터링 +```javascript +const allRooms = await fetchRooms(); + +// 게임방만 +const gameRooms = allRooms.filter(room => room.type === 'GAME'); + +// 채팅방만 +const chatRooms = allRooms.filter(room => room.type === 'CHAT'); + +// 대기 중인 방만 +const waitingRooms = allRooms.filter(room => room.status === 'WAITING'); ``` -> 현재 미구현. 필요시 백엔드에 요청 --- -## 6. WebSocket 연결 +## 8. WebSocket 연결 -### 채팅 WebSocket +### 채팅/게임 WebSocket ``` wss://t378dif43l.execute-api.ap-northeast-2.amazonaws.com/dev?roomToken={roomToken} ``` @@ -134,7 +208,7 @@ wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} --- -## 7. WebSocket 메시지 타입 (messageType) +## 9. WebSocket 메시지 타입 (messageType) | 코드 | 타입 | 설명 | |------|------|------| @@ -153,13 +227,13 @@ wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} --- -## 8. 게임 명령어 (WebSocket) +## 10. 게임 명령어 (WebSocket) 채팅 메시지로 게임 명령어 전송: | 명령어 | 설명 | 권한 | |--------|------|------| -| `/start` | 게임 시작 | 누구나 (2명 이상 접속 시) | +| `/start` | 게임 시작 | 방장 (2명 이상 접속 시) | | `/stop` | 게임 중단 | 방장 또는 게임 시작자 | | `/skip` | 라운드 스킵 | 누구나 | | `/hint` | 힌트 제공 | 출제자만 | @@ -167,17 +241,20 @@ wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} --- -## 9. 게임 시작 응답 예시 +## 11. 게임 시작 응답 예시 ```json { "messageId": "uuid", "roomId": "abc-123", "userId": "SYSTEM", - "content": "🎮 게임 시작!\n총 5 라운드\n\n라운드 1 시작!\n출제자: user-456", + "content": "게임 시작!\n총 5 라운드\n\n라운드 1 시작!\n출제자: user-456", "messageType": "GAME_START", "createdAt": "2026-01-22T10:00:00Z", - "gameStatus": "PLAYING", + "serverTime": "2026-01-22T10:00:00Z", + "domain": "GAME", + "type": "GAME", + "status": "PLAYING", "currentRound": 1, "totalRounds": 5, "currentDrawerId": "user-456", @@ -187,7 +264,7 @@ wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} --- -## 10. 정답 체크 로직 +## 12. 정답 체크 로직 - **한국어** 또는 **영어** 둘 다 정답으로 인정 - 대소문자 구분 없음 @@ -204,31 +281,72 @@ wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} --- -## 11. 주의사항 +## 13. 게임 설정 + +| 설정 | 기본값 | 환경변수 | +|------|--------|----------| +| 총 라운드 수 | 5 | `GAME_TOTAL_ROUNDS` | +| 라운드 제한 시간(초) | 60 | `GAME_ROUND_TIME_LIMIT` | +| 빠른 정답 기준(ms) | 5000 | `GAME_QUICK_GUESS_THRESHOLD_MS` | +| 게임 전체 제한(초) | 420 (7분) | `GAME_TIME_LIMIT_SECONDS` | + +--- + +## 14. 주의사항 1. **roomToken은 한 번만 사용**: 재연결 시 새로 발급 필요 2. **WebSocket 연결 실패 시**: `POST /rooms/{roomId}/join`으로 새 토큰 발급 3. **게임 중 퇴장**: 자동으로 다음 출제자로 넘어감 (2명 미만 시 게임 종료) 4. **출제자는 정답 입력 불가**: 본인이 출제자일 때 채팅해도 정답 체크 안됨 +5. **방 타입 변경 불가**: 생성 시 지정한 type은 변경 불가 --- -## 12. 에러 코드 +## 15. 에러 코드 -| 코드 | 설명 | -|------|------| -| `ROOM_NOT_FOUND` | 채팅방 없음 | -| `ROOM_FULL` | 채팅방 인원 초과 | -| `ALREADY_JOINED` | 이미 참여 중 | -| `WRONG_PASSWORD` | 비밀번호 틀림 | -| `NOT_MEMBER` | 채팅방 멤버 아님 | -| `GAME_START_FAILED` | 게임 시작 실패 | -| `GAME_STOP_FAILED` | 게임 중단 실패 | +| 코드 | HTTP | 설명 | +|------|------|------| +| `ROOM_001` | 404 | 채팅방 없음 | +| `ROOM_002` | 409 | 채팅방 이미 존재 | +| `ROOM_003` | 400 | 채팅방 인원 초과 | +| `ROOM_004` | 400 | 채팅방 종료됨 | +| `ROOM_005` | 401 | 비밀번호 틀림 | +| `ROOM_006` | 403 | 방장 권한 없음 | +| `MEMBER_001` | 403 | 채팅방 멤버 아님 | +| `MEMBER_002` | 409 | 이미 참여 중 | +| `GAME_001` | 400 | 게임 시작 실패 | +| `GAME_002` | 400 | 게임 중단 실패 | +| `GAME_003` | 400 | 게임 진행 중 아님 | +| `GAME_004` | 409 | 게임 이미 진행 중 | +| `GAME_005` | 403 | 게임 시작자 아님 | +| `GAME_006` | 404 | 게임 없음 | +| `GAME_007` | 400 | 채팅방에서 게임 불가 | +| `GAME_008` | 400 | 게임 재시작 불가 | +| `GAME_009` | 403 | 방장만 게임 시작 가능 | --- -## 13. 추후 개선 예정 (백엔드) +## 16. UI 구현 가이드 + +### 탭 구조 (권장) +``` +[전체] [채팅방] [게임방] +``` + +### 게임방 상태 표시 +``` +대기 중 (WAITING) → 초록색 뱃지 "참여 가능" +진행 중 (PLAYING) → 빨간색 뱃지 "게임 중" +종료됨 (FINISHED) → 회색 뱃지 "종료" +``` -- [ ] `gameStatus` 필터 파라미터 추가 -- [ ] 게임 전용 방 타입 분리 (선택적) -- [ ] 관전 모드 지원 +### 게임방 카드 정보 +``` +┌─────────────────────────────┐ +│ 캐치마인드 - 영어 단어 맞추기 │ +│ [게임방] [intermediate] │ +│ │ +│ 👥 3/8명 🎮 대기 중 │ +│ 🕐 2026-01-22 10:00 │ +└─────────────────────────────┘ +``` From 674f87c2e321176c1648139aa2f8e8e3b1574f5f Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 10:54:47 +0900 Subject: [PATCH 35/99] fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management --- .../websocket/WebSocketDisconnectHandler.java | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java index 3929f10e..ce0ed059 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java @@ -5,11 +5,14 @@ import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.domain.chatting.model.Connection; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.GameSessionRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Optional; @@ -24,10 +27,12 @@ public class WebSocketDisconnectHandler implements RequestHandler handleRequest(Map event, Context cont /** * 게임 상태 초기화 + * 새 구조에서는 GameSession을 종료하고 ChatRoom의 상태를 WAITING으로 변경 */ private void resetGameState(String roomId) { try { - Optional roomOpt = chatRoomRepository.findById(roomId); + // 활성 게임 세션이 있으면 종료 + Optional activeSession = gameSessionRepository.findActiveByRoomId(roomId); + if (activeSession.isPresent()) { + GameSession session = activeSession.get(); + long now = Instant.now().toEpochMilli(); + long ttl = now / 1000 + 86400 * 7; // 7일 후 TTL + gameSessionRepository.finishGame(session.getGameSessionId(), now, ttl); + logger.info("Game session finished due to empty room: gameSessionId={}", session.getGameSessionId()); + } + // 채팅방 상태 초기화 + Optional roomOpt = chatRoomRepository.findById(roomId); if (roomOpt.isPresent()) { ChatRoom room = roomOpt.get(); - // 게임이 진행 중이었다면 초기화 - if (room.getGameStatus() != null && !"NONE".equals(room.getGameStatus())) { - room.setGameStatus("NONE"); - room.setCurrentRound(null); - room.setCurrentDrawerId(null); - room.setCurrentWord(null); - room.setCurrentWordId(null); - room.setDrawerOrder(null); - room.setScores(null); - room.setStreaks(null); - room.setCorrectGuessers(null); - room.setHintUsed(null); - room.setRoundStartTime(null); - room.setGameStartedBy(null); + // 게임이 진행 중이었다면 상태 초기화 + if ("PLAYING".equals(room.getStatus())) { + chatRoomRepository.updateStatus(room, "WAITING"); + room.setActiveGameSessionId(null); chatRoomRepository.save(room); - logger.info("Game state reset for room: {}", roomId); + logger.info("Room status reset to WAITING for room: {}", roomId); } } } catch (Exception e) { From 540f92d403308c30ab677422033d69ca5d8ce94d Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 10:57:09 +0900 Subject: [PATCH 36/99] perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache --- ServerlessFunction/buildspec.yml | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/ServerlessFunction/buildspec.yml b/ServerlessFunction/buildspec.yml index 16ebf0ce..4bce0c4c 100644 --- a/ServerlessFunction/buildspec.yml +++ b/ServerlessFunction/buildspec.yml @@ -3,14 +3,21 @@ version: 0.2 env: variables: SAM_CLI_TELEMETRY: 0 + GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" + PIP_CACHE_DIR: "/root/.cache/pip" phases: install: runtime-versions: java: corretto21 commands: - - echo "Installing SAM CLI..." - - pip3 install aws-sam-cli + - echo "Installing SAM CLI (cached)..." + - | + if ! command -v sam &> /dev/null || [ "$(sam --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+')" != "1.152.0" ]; then + pip3 install --quiet aws-sam-cli + else + echo "SAM CLI already installed, skipping..." + fi - sam --version pre_build: @@ -18,14 +25,14 @@ phases: - echo "Running tests..." - cd ServerlessFunction - chmod +x gradlew - - ./gradlew clean test + - ./gradlew test --build-cache --parallel - echo "Tests completed" build: commands: - echo "Building SAM application..." - cd $CODEBUILD_SRC_DIR/ServerlessFunction - - sam build + - sam build --parallel --cached - echo "Packaging SAM application..." - sam package --s3-bucket group2-englishstudy-pipeline-artifacts --s3-prefix sam-packages --output-template-file packaged-template.yaml @@ -42,6 +49,9 @@ cache: paths: - '/root/.gradle/caches/**/*' - '/root/.gradle/wrapper/**/*' + - '/root/.cache/pip/**/*' + - '/root/.aws-sam/build/**/*' + - 'ServerlessFunction/.aws-sam/cache/**/*' reports: junit-reports: From 96dcbc20dc183789a145dcd2331e72ab4041c534 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 10:59:08 +0900 Subject: [PATCH 37/99] feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds --- ServerlessFunction/buildspec.yml | 17 +++------- docker/Dockerfile | 55 ++++++++++++++++++++++++++++++++ docker/build-and-push.sh | 48 ++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 13 deletions(-) create mode 100644 docker/Dockerfile create mode 100755 docker/build-and-push.sh diff --git a/ServerlessFunction/buildspec.yml b/ServerlessFunction/buildspec.yml index 4bce0c4c..49ed2a4f 100644 --- a/ServerlessFunction/buildspec.yml +++ b/ServerlessFunction/buildspec.yml @@ -4,21 +4,14 @@ env: variables: SAM_CLI_TELEMETRY: 0 GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" - PIP_CACHE_DIR: "/root/.cache/pip" phases: install: - runtime-versions: - java: corretto21 commands: - - echo "Installing SAM CLI (cached)..." - - | - if ! command -v sam &> /dev/null || [ "$(sam --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+')" != "1.152.0" ]; then - pip3 install --quiet aws-sam-cli - else - echo "SAM CLI already installed, skipping..." - fi + - echo "Verifying pre-installed tools..." + - java -version - sam --version + - echo "Tools verified" pre_build: commands: @@ -49,9 +42,7 @@ cache: paths: - '/root/.gradle/caches/**/*' - '/root/.gradle/wrapper/**/*' - - '/root/.cache/pip/**/*' - - '/root/.aws-sam/build/**/*' - - 'ServerlessFunction/.aws-sam/cache/**/*' + - '.aws-sam/cache/**/*' reports: junit-reports: diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 00000000..58d20580 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,55 @@ +# CodeBuild Custom Image with Java 21 + SAM CLI +FROM public.ecr.aws/amazonlinux/amazonlinux:2023 + +# Install basic dependencies +RUN dnf update -y && \ + dnf install -y \ + git \ + tar \ + gzip \ + unzip \ + which \ + findutils \ + python3 \ + python3-pip \ + docker \ + && dnf clean all + +# Install Amazon Corretto 21 (Java 21) +RUN rpm --import https://yum.corretto.aws/corretto.key && \ + curl -L -o /etc/yum.repos.d/corretto.repo https://yum.corretto.aws/corretto.repo && \ + dnf install -y java-21-amazon-corretto-devel && \ + dnf clean all + +# Set JAVA_HOME +ENV JAVA_HOME=/usr/lib/jvm/java-21-amazon-corretto +ENV PATH=$JAVA_HOME/bin:$PATH + +# Install AWS CLI v2 +RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" && \ + unzip awscliv2.zip && \ + ./aws/install && \ + rm -rf aws awscliv2.zip + +# Install SAM CLI (the main optimization!) +RUN pip3 install aws-sam-cli + +# Install Gradle (optional, project uses wrapper) +ENV GRADLE_VERSION=8.5 +RUN curl -L https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip -o gradle.zip && \ + unzip gradle.zip -d /opt && \ + rm gradle.zip && \ + ln -s /opt/gradle-${GRADLE_VERSION}/bin/gradle /usr/bin/gradle + +# Verify installations +RUN java -version && \ + aws --version && \ + sam --version && \ + gradle --version + +# Set working directory +WORKDIR /codebuild/output/src + +# Labels +LABEL maintainer="group2-englishstudy" \ + description="CodeBuild image with Java 21, SAM CLI, and Gradle pre-installed" diff --git a/docker/build-and-push.sh b/docker/build-and-push.sh new file mode 100755 index 00000000..bb4e1297 --- /dev/null +++ b/docker/build-and-push.sh @@ -0,0 +1,48 @@ +#!/bin/bash +set -e + +# Configuration +AWS_REGION="ap-northeast-2" +AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) +ECR_REPO_NAME="group2-codebuild-image" +IMAGE_TAG="java21-sam" + +ECR_URI="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${ECR_REPO_NAME}" + +echo "=== CodeBuild Custom Image Build & Push ===" +echo "AWS Account: ${AWS_ACCOUNT_ID}" +echo "ECR Repository: ${ECR_REPO_NAME}" +echo "Image: ${ECR_URI}:${IMAGE_TAG}" +echo "" + +# 1. Create ECR repository (if not exists) +echo "[1/4] Creating ECR repository..." +aws ecr describe-repositories --repository-names ${ECR_REPO_NAME} --region ${AWS_REGION} 2>/dev/null || \ + aws ecr create-repository --repository-name ${ECR_REPO_NAME} --region ${AWS_REGION} + +# 2. Login to ECR +echo "[2/4] Logging in to ECR..." +aws ecr get-login-password --region ${AWS_REGION} | docker login --username AWS --password-stdin ${ECR_URI} + +# 3. Build Docker image +echo "[3/4] Building Docker image..." +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +docker build -t ${ECR_REPO_NAME}:${IMAGE_TAG} ${SCRIPT_DIR} + +# 4. Tag and push +echo "[4/4] Pushing to ECR..." +docker tag ${ECR_REPO_NAME}:${IMAGE_TAG} ${ECR_URI}:${IMAGE_TAG} +docker tag ${ECR_REPO_NAME}:${IMAGE_TAG} ${ECR_URI}:latest +docker push ${ECR_URI}:${IMAGE_TAG} +docker push ${ECR_URI}:latest + +echo "" +echo "=== SUCCESS ===" +echo "Image pushed: ${ECR_URI}:${IMAGE_TAG}" +echo "" +echo "Next steps:" +echo "1. Update CodeBuild project to use custom image:" +echo " Image: ${ECR_URI}:${IMAGE_TAG}" +echo " Image pull credentials: Service role" +echo "" +echo "2. Add ECR pull permission to CodeBuild service role" From 02dab52180d3fe7a2aea4ab99564f88f504e3b41 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Thu, 22 Jan 2026 11:12:55 +0900 Subject: [PATCH 38/99] =?UTF-8?q?feature=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=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?(#468)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 --- .../websocket/SpeakingConnectHandler.java | 88 +++++ .../websocket/SpeakingDisconnectHandler.java | 44 +++ .../websocket/SpeakingMessageHandler.java | 217 ++++++++++++ .../speaking/model/SpeakingConnection.java | 84 +++++ .../SpeakingConnectionRepository.java | 73 ++++ .../speaking/service/SpeakingService.java | 317 ++++++++++++++++++ 6 files changed, 823 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/handler/websocket/SpeakingMessageHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.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/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/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/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 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 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 4ffd91926dad8b08d64cdb4006c927a2b374657f Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 11:16:44 +0900 Subject: [PATCH 39/99] fix: remove typo in SpeakingConnectionRepository --- .../SpeakingConnectionRepository.java | 2 +- docker/Dockerfile | 19 +++------- docker/build-and-push.sh | 38 ++++++++++++------- 3 files changed, 30 insertions(+), 29 deletions(-) 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; diff --git a/docker/Dockerfile b/docker/Dockerfile index 58d20580..9f69c063 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -25,27 +25,18 @@ RUN rpm --import https://yum.corretto.aws/corretto.key && \ ENV JAVA_HOME=/usr/lib/jvm/java-21-amazon-corretto ENV PATH=$JAVA_HOME/bin:$PATH -# Install AWS CLI v2 -RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" && \ - unzip awscliv2.zip && \ - ./aws/install && \ - rm -rf aws awscliv2.zip +# Install AWS CLI and SAM CLI via pip +RUN pip3 install --ignore-installed awscli aws-sam-cli -# Install SAM CLI (the main optimization!) -RUN pip3 install aws-sam-cli - -# Install Gradle (optional, project uses wrapper) +# Install Gradle ENV GRADLE_VERSION=8.5 RUN curl -L https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip -o gradle.zip && \ unzip gradle.zip -d /opt && \ rm gradle.zip && \ ln -s /opt/gradle-${GRADLE_VERSION}/bin/gradle /usr/bin/gradle -# Verify installations -RUN java -version && \ - aws --version && \ - sam --version && \ - gradle --version +# Verify Java installation +RUN java -version # Set working directory WORKDIR /codebuild/output/src diff --git a/docker/build-and-push.sh b/docker/build-and-push.sh index bb4e1297..f59bb5c1 100755 --- a/docker/build-and-push.sh +++ b/docker/build-and-push.sh @@ -2,14 +2,19 @@ set -e # Configuration +AWS_PROFILE="mzc" AWS_REGION="ap-northeast-2" -AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) ECR_REPO_NAME="group2-codebuild-image" IMAGE_TAG="java21-sam" +export AWS_DEFAULT_REGION="${AWS_REGION}" +export AWS_PROFILE="${AWS_PROFILE}" + +AWS_ACCOUNT_ID=$(aws sts get-caller-identity --profile ${AWS_PROFILE} --region ${AWS_REGION} --query Account --output text) ECR_URI="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${ECR_REPO_NAME}" echo "=== CodeBuild Custom Image Build & Push ===" +echo "AWS Profile: ${AWS_PROFILE}" echo "AWS Account: ${AWS_ACCOUNT_ID}" echo "ECR Repository: ${ECR_REPO_NAME}" echo "Image: ${ECR_URI}:${IMAGE_TAG}" @@ -17,24 +22,29 @@ echo "" # 1. Create ECR repository (if not exists) echo "[1/4] Creating ECR repository..." -aws ecr describe-repositories --repository-names ${ECR_REPO_NAME} --region ${AWS_REGION} 2>/dev/null || \ - aws ecr create-repository --repository-name ${ECR_REPO_NAME} --region ${AWS_REGION} +aws ecr describe-repositories --repository-names ${ECR_REPO_NAME} --profile ${AWS_PROFILE} --region ${AWS_REGION} 2>/dev/null || \ + aws ecr create-repository --repository-name ${ECR_REPO_NAME} --profile ${AWS_PROFILE} --region ${AWS_REGION} # 2. Login to ECR echo "[2/4] Logging in to ECR..." -aws ecr get-login-password --region ${AWS_REGION} | docker login --username AWS --password-stdin ${ECR_URI} +aws ecr get-login-password --profile ${AWS_PROFILE} --region ${AWS_REGION} | docker login --username AWS --password-stdin ${ECR_URI} -# 3. Build Docker image -echo "[3/4] Building Docker image..." +# 3. Build and push Docker image (using buildx for cross-platform) +echo "[3/4] Building and pushing Docker image (linux/amd64)..." SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -docker build -t ${ECR_REPO_NAME}:${IMAGE_TAG} ${SCRIPT_DIR} - -# 4. Tag and push -echo "[4/4] Pushing to ECR..." -docker tag ${ECR_REPO_NAME}:${IMAGE_TAG} ${ECR_URI}:${IMAGE_TAG} -docker tag ${ECR_REPO_NAME}:${IMAGE_TAG} ${ECR_URI}:latest -docker push ${ECR_URI}:${IMAGE_TAG} -docker push ${ECR_URI}:latest + +# Create buildx builder if not exists +docker buildx create --name multiarch --use 2>/dev/null || docker buildx use multiarch + +# Build and push directly (avoids local platform issues on Apple Silicon) +docker buildx build \ + --platform linux/amd64 \ + --tag ${ECR_URI}:${IMAGE_TAG} \ + --tag ${ECR_URI}:latest \ + --push \ + ${SCRIPT_DIR} + +echo "[4/4] Push complete" echo "" echo "=== SUCCESS ===" From c201a07902d8c28c5a8367a030d7a2a922589731 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 11:24:43 +0900 Subject: [PATCH 40/99] chore: trigger build test with custom Docker image From 4bacba237a42dabc22a0728f3e11c787dad3368b Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 11:53:59 +0900 Subject: [PATCH 41/99] chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes --- .sisyphus/plans/cicd-pipeline-plan.md | 145 +-- .../docs/CATCHMIND_FRONTEND_GUIDE.md | 79 +- .../serverless/common/dto/ErrorInfo.java | 16 +- .../common/exception/CommonErrorCode.java | 2 +- .../common/exception/CommonException.java | 4 +- .../common/exception/DomainErrorCode.java | 4 +- .../common/exception/ErrorCode.java | 8 +- .../common/exception/ServerlessException.java | 4 +- .../common/router/AuthenticatedHandler.java | 2 +- .../common/router/HandlerRouter.java | 8 +- .../serverless/common/router/Route.java | 2 +- .../common/util/WebSocketBroadcaster.java | 4 +- .../common/util/WebSocketMessageHelper.java | 36 +- .../common/validation/BeanValidator.java | 14 +- .../domain/badge/handler/BadgeHandler.java | 2 +- .../badge/repository/BadgeRepository.java | 4 +- .../domain/badge/service/BadgeService.java | 15 +- .../badge/strategy/AccuracyStrategy.java | 8 +- .../strategy/BadgeConditionStrategy.java | 6 +- .../BadgeConditionStrategyFactory.java | 8 +- .../badge/strategy/FirstStudyStrategy.java | 6 +- .../badge/strategy/GamesPlayedStrategy.java | 6 +- .../badge/strategy/GamesWonStrategy.java | 6 +- .../domain/badge/strategy/NoOpStrategy.java | 10 +- .../badge/strategy/PerfectDrawsStrategy.java | 6 +- .../badge/strategy/QuickGuessesStrategy.java | 6 +- .../domain/badge/strategy/StreakStrategy.java | 6 +- .../strategy/TestsCompletedStrategy.java | 6 +- .../badge/strategy/WordsLearnedStrategy.java | 8 +- .../domain/chatting/config/GameConfig.java | 6 +- .../dto/request/CreateRoomRequest.java | 8 +- .../chatting/dto/response/RoomListItem.java | 4 +- .../dto/response/ScoreboardResponse.java | 10 +- .../domain/chatting/enums/MessageType.java | 2 +- .../domain/chatting/enums/RoomStatus.java | 12 +- .../domain/chatting/enums/RoomType.java | 12 +- .../chatting/exception/ChattingErrorCode.java | 2 +- .../chatting/exception/ChattingException.java | 4 +- .../chatting/handler/ChatMessageHandler.java | 2 +- .../chatting/handler/ChatRoomHandler.java | 34 +- .../chatting/handler/ChatVoiceHandler.java | 2 +- .../handler/GameAutoCloseHandler.java | 30 +- .../domain/chatting/handler/GameHandler.java | 97 +- .../chatting/handler/GameSessionHandler.java | 100 +- .../websocket/WebSocketConnectHandler.java | 6 +- .../websocket/WebSocketDisconnectHandler.java | 26 +- .../websocket/WebSocketMessageHandler.java | 94 +- .../domain/chatting/model/ChatRoom.java | 6 +- .../domain/chatting/model/GameSession.java | 44 +- .../domain/chatting/model/GameSettings.java | 4 +- .../repository/ChatMessageRepository.java | 4 +- .../repository/ChatRoomRepository.java | 35 +- .../repository/ConnectionRepository.java | 28 +- .../repository/GameRoundRepository.java | 2 +- .../repository/GameSessionRepository.java | 100 +- .../repository/RoomTokenRepository.java | 4 +- .../chatting/service/ChatMessageService.java | 4 +- .../service/ChatRoomCommandService.java | 40 +- .../service/ChatRoomQueryService.java | 14 +- .../chatting/service/CommandService.java | 22 +- .../chatting/service/GameSchedulerClient.java | 48 +- .../domain/chatting/service/GameService.java | 264 ++--- .../chatting/service/GameStatsService.java | 10 +- .../chatting/service/RoomTokenService.java | 4 +- .../grammar/handler/GrammarHandler.java | 4 +- .../GrammarStreamingConnectHandler.java | 2 +- .../websocket/GrammarStreamingHandler.java | 10 +- .../GrammarConnectionRepository.java | 4 +- .../repository/GrammarSessionRepository.java | 4 +- .../dto/request/CreateSessionRequest.java | 9 +- .../opic/dto/request/SubmitAnswerRequest.java | 7 +- .../dto/response/AnswerFeedbackResponse.java | 15 +- .../dto/response/CreateSessionResponse.java | 11 +- .../opic/dto/response/QuestionResponse.java | 15 +- .../opic/handler/OPIcSessionHandler.java | 934 +++++++++--------- .../opic/repository/OPIcRepository.java | 488 ++++----- .../domain/opic/service/FeedbackService.java | 554 +++++------ .../websocket/SpeakingConnectHandler.java | 132 +-- .../websocket/SpeakingDisconnectHandler.java | 56 +- .../websocket/SpeakingMessageHandler.java | 374 +++---- .../speaking/model/SpeakingConnection.java | 132 +-- .../SpeakingConnectionRepository.java | 112 +-- .../speaking/service/SpeakingService.java | 513 +++++----- .../stats/handler/ScheduledStatsHandler.java | 33 +- .../stats/handler/StatsStreamHandler.java | 2 +- .../stats/handler/UserStatsHandler.java | 2 +- .../domain/stats/model/UserStats.java | 2 +- .../stats/repository/UserStatsRepository.java | 4 +- .../domain/stats/service/StatsService.java | 4 +- .../user/handler/PostConfirmationHandler.java | 2 +- .../exception/VocabularyErrorCode.java | 2 +- .../exception/VocabularyException.java | 4 +- .../vocabulary/handler/DailyStudyHandler.java | 2 +- .../vocabulary/handler/StatisticsHandler.java | 2 +- .../vocabulary/handler/StatsHandler.java | 2 +- .../vocabulary/handler/TestHandler.java | 2 +- .../vocabulary/handler/UserWordHandler.java | 2 +- .../vocabulary/handler/VoiceHandler.java | 2 +- .../vocabulary/handler/WordGroupHandler.java | 2 +- .../vocabulary/handler/WordHandler.java | 2 +- .../repository/DailyStudyRepository.java | 4 +- .../repository/TestResultRepository.java | 10 +- .../repository/UserWordRepository.java | 4 +- .../repository/WordGroupRepository.java | 4 +- .../vocabulary/repository/WordRepository.java | 2 +- .../service/DailyStudyCommandService.java | 4 +- .../service/DailyStudyQueryService.java | 4 +- .../vocabulary/service/StatisticsService.java | 4 +- .../vocabulary/service/StatsService.java | 28 +- .../service/TestCommandService.java | 4 +- .../vocabulary/service/TestQueryService.java | 4 +- .../service/UserWordCommandService.java | 4 +- .../service/UserWordQueryService.java | 4 +- .../service/WordCommandService.java | 4 +- .../service/WordGroupCommandService.java | 4 +- .../service/WordGroupQueryService.java | 4 +- .../vocabulary/service/WordQueryService.java | 4 +- .../exception/ChattingErrorCodeSpec.groovy | 50 +- .../domain/chatting/enums/RoomStatusTest.java | 7 +- .../domain/chatting/enums/RoomTypeTest.java | 7 +- .../chatting/model/GameSettingsTest.java | 11 +- docs/CATCHMIND_ARCHITECTURE_SOLUTION.md | 63 +- docs/CICD-IMPLEMENTATION-QNA.md | 95 +- docs/FRONTEND-API-GUIDE.md | 165 ++-- 124 files changed, 2767 insertions(+), 2689 deletions(-) diff --git a/.sisyphus/plans/cicd-pipeline-plan.md b/.sisyphus/plans/cicd-pipeline-plan.md index abdd5f3c..2ce6f8e5 100644 --- a/.sisyphus/plans/cicd-pipeline-plan.md +++ b/.sisyphus/plans/cicd-pipeline-plan.md @@ -8,13 +8,13 @@ ## 1. 요구사항 요약 -| 항목 | 선택 | -|------|------| -| 소스 저장소 | GitHub (유지) + CodePipeline v2 연결 | -| 배포 환경 | prod 단일 환경 | -| 트리거 | prod 브랜치 push 또는 PR merge | -| 승인 프로세스 | 완전 자동 (테스트 통과 시 자동 배포) | -| 알림 | AWS SNS → 이메일 | +| 항목 | 선택 | +|---------|----------------------------------| +| 소스 저장소 | GitHub (유지) + CodePipeline v2 연결 | +| 배포 환경 | prod 단일 환경 | +| 트리거 | prod 브랜치 push 또는 PR merge | +| 승인 프로세스 | 완전 자동 (테스트 통과 시 자동 배포) | +| 알림 | AWS SNS → 이메일 | --- @@ -55,30 +55,32 @@ ### 3.1 Source Stage -| 설정 | 값 | -|------|-----| -| Provider | GitHub (v2 Connection) | -| Repository | BE_Repository | -| Branch | `prod` | -| Trigger | Push / PR Merge | -| Output Artifact | SourceArtifact | +| 설정 | 값 | +|-----------------|------------------------| +| Provider | GitHub (v2 Connection) | +| Repository | BE_Repository | +| Branch | `prod` | +| Trigger | Push / PR Merge | +| Output Artifact | SourceArtifact | **GitHub Connection 설정 필요**: + - AWS Console → CodePipeline → Settings → Connections - GitHub App 설치 및 Repository 권한 부여 ### 3.2 Build Stage -| 설정 | 값 | -|------|-----| -| Provider | AWS CodeBuild | -| Environment | `aws/codebuild/amazonlinux2-x86_64-standard:5.0` | -| Compute | `BUILD_GENERAL1_MEDIUM` (7GB RAM, 4 vCPU) | -| Timeout | 30분 | -| Input Artifact | SourceArtifact | -| Output Artifact | BuildArtifact | +| 설정 | 값 | +|-----------------|--------------------------------------------------| +| Provider | AWS CodeBuild | +| Environment | `aws/codebuild/amazonlinux2-x86_64-standard:5.0` | +| Compute | `BUILD_GENERAL1_MEDIUM` (7GB RAM, 4 vCPU) | +| Timeout | 30분 | +| Input Artifact | SourceArtifact | +| Output Artifact | BuildArtifact | **빌드 단계**: + 1. Java 21 환경 설정 2. Gradle 빌드 및 테스트 3. SAM 빌드 @@ -86,22 +88,22 @@ ### 3.3 Deploy Stage -| 설정 | 값 | -|------|-----| -| Provider | CloudFormation | -| Action Mode | CREATE_UPDATE | -| Stack Name | `group2-englishstudy-prod` | -| Template | packaged-template.yaml | +| 설정 | 값 | +|--------------|----------------------------------------| +| Provider | CloudFormation | +| Action Mode | CREATE_UPDATE | +| Stack Name | `group2-englishstudy-prod` | +| Template | packaged-template.yaml | | Capabilities | CAPABILITY_IAM, CAPABILITY_AUTO_EXPAND | -| Role | CloudFormationExecutionRole | +| Role | CloudFormationExecutionRole | ### 3.4 Notification Stage -| 설정 | 값 | -|------|-----| -| Provider | AWS SNS | -| Topic | `cicd-pipeline-notifications` | -| Events | 성공, 실패, 시작 | +| 설정 | 값 | +|----------|-------------------------------| +| Provider | AWS SNS | +| Topic | `cicd-pipeline-notifications` | +| Events | 성공, 실패, 시작 | --- @@ -109,24 +111,24 @@ ### 4.1 신규 생성 필요 -| 리소스 | 이름 | 용도 | -|--------|------|------| -| CodePipeline | `group2-englishstudy-pipeline` | CI/CD 오케스트레이션 | -| CodeBuild Project | `group2-englishstudy-build` | 빌드 및 테스트 | -| S3 Bucket | `group2-englishstudy-pipeline-artifacts` | 파이프라인 아티팩트 저장 | -| GitHub Connection | `github-connection` | GitHub 연결 | -| SNS Topic | `cicd-pipeline-notifications` | 알림 | -| IAM Role | `CodePipelineServiceRole` | 파이프라인 실행 | -| IAM Role | `CodeBuildServiceRole` | 빌드 실행 | -| IAM Role | `CloudFormationExecutionRole` | 스택 배포 | +| 리소스 | 이름 | 용도 | +|-------------------|------------------------------------------|---------------| +| CodePipeline | `group2-englishstudy-pipeline` | CI/CD 오케스트레이션 | +| CodeBuild Project | `group2-englishstudy-build` | 빌드 및 테스트 | +| S3 Bucket | `group2-englishstudy-pipeline-artifacts` | 파이프라인 아티팩트 저장 | +| GitHub Connection | `github-connection` | GitHub 연결 | +| SNS Topic | `cicd-pipeline-notifications` | 알림 | +| IAM Role | `CodePipelineServiceRole` | 파이프라인 실행 | +| IAM Role | `CodeBuildServiceRole` | 빌드 실행 | +| IAM Role | `CloudFormationExecutionRole` | 스택 배포 | ### 4.2 기존 활용 -| 리소스 | 용도 | -|--------|------| +| 리소스 | 용도 | +|--------------------------|--------------| | S3 `group2-englishstudy` | Lambda 코드 저장 | -| DynamoDB Tables | 데이터 저장 | -| Cognito User Pool | 인증 | +| DynamoDB Tables | 데이터 저장 | +| Cognito User Pool | 인증 | --- @@ -164,9 +166,9 @@ phases: - sam build - echo "Packaging SAM application..." - sam package \ - --s3-bucket ${ARTIFACT_BUCKET} \ - --s3-prefix sam-packages \ - --output-template-file packaged-template.yaml + --s3-bucket ${ARTIFACT_BUCKET} \ + --s3-prefix sam-packages \ + --output-template-file packaged-template.yaml post_build: commands: @@ -394,11 +396,21 @@ aws sns subscribe \ ```json { - "source": ["aws.codepipeline"], - "detail-type": ["CodePipeline Pipeline Execution State Change"], + "source": [ + "aws.codepipeline" + ], + "detail-type": [ + "CodePipeline Pipeline Execution State Change" + ], "detail": { - "pipeline": ["group2-englishstudy-pipeline"], - "state": ["SUCCEEDED", "FAILED", "STARTED"] + "pipeline": [ + "group2-englishstudy-pipeline" + ], + "state": [ + "SUCCEEDED", + "FAILED", + "STARTED" + ] } } ``` @@ -407,14 +419,14 @@ aws sns subscribe \ ## 9. 비용 추정 (월간) -| 서비스 | 예상 사용량 | 예상 비용 | -|--------|------------|----------| -| CodePipeline | 1 파이프라인, ~100회 실행 | $1.00 | -| CodeBuild | ~100회 x 10분 = 1,000분 | $5.00 | -| S3 (아티팩트) | ~10GB | $0.25 | -| CloudWatch Logs | ~5GB | $2.50 | -| SNS | ~100 알림 | $0.01 | -| **총 예상 비용** | | **~$9/월** | +| 서비스 | 예상 사용량 | 예상 비용 | +|-----------------|----------------------|-----------| +| CodePipeline | 1 파이프라인, ~100회 실행 | $1.00 | +| CodeBuild | ~100회 x 10분 = 1,000분 | $5.00 | +| S3 (아티팩트) | ~10GB | $0.25 | +| CloudWatch Logs | ~5GB | $2.50 | +| SNS | ~100 알림 | $0.01 | +| **총 예상 비용** | | **~$9/월** | > 실제 비용은 배포 빈도와 빌드 시간에 따라 달라질 수 있습니다. @@ -718,24 +730,31 @@ Outputs: ## 12. 트러블슈팅 가이드 ### 빌드 실패: Java 버전 문제 + ``` Error: Unsupported class file major version 65 ``` + **해결**: CodeBuild 이미지에서 Java 21 (Corretto) 사용 확인 ### 배포 실패: IAM 권한 부족 + ``` User: arn:aws:sts::xxx is not authorized to perform: iam:CreateRole ``` + **해결**: CloudFormationExecutionRole에 IAM 권한 추가 ### SAM 빌드 실패: 메모리 부족 + ``` FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory ``` + **해결**: CodeBuild compute type을 `BUILD_GENERAL1_LARGE`로 변경 ### GitHub Connection 인증 실패 + **해결**: AWS Console에서 Connection 상태 확인 → GitHub App 재인증 --- diff --git a/ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md b/ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md index cfc4b168..4dd03385 100644 --- a/ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md +++ b/ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md @@ -1,6 +1,7 @@ # Catchmind 게임 프론트엔드 연동 가이드 ## 목차 + 1. [개요](#개요) 2. [아키텍처](#아키텍처) 3. [WebSocket 연결](#websocket-연결) @@ -19,6 +20,7 @@ Catchmind는 실시간 그림 맞추기 게임입니다. WebSocket을 통한 실시간 통신과 REST API를 통한 게임 세션 관리를 지원합니다. ### 주요 특징 + - **실시간 통신**: WebSocket 기반 양방향 통신 - **도메인 분리**: `chat` / `game` 도메인으로 메시지 라우팅 - **타이머 동기화**: `serverTime` 필드를 통한 클라이언트-서버 시간 동기화 @@ -53,16 +55,19 @@ Catchmind는 실시간 그림 맞추기 게임입니다. WebSocket을 통한 실 ## WebSocket 연결 ### 연결 URL + ``` wss://{api-id}.execute-api.{region}.amazonaws.com/dev?roomToken={token} ``` ### 연결 절차 + 1. REST API로 방 토큰 발급 (`POST /chat/rooms/{roomId}/join`) 2. 토큰으로 WebSocket 연결 3. 연결 성공 시 자동으로 방에 입장 ### 연결 예시 (TypeScript) + ```typescript const connectWebSocket = (roomToken: string): WebSocket => { const ws = new WebSocket( @@ -101,12 +106,13 @@ interface BaseMessage { ### 도메인 구분 -| 도메인 | 설명 | 메시지 타입 | -|--------|------|-------------| -| `chat` | 채팅 메시지 | text, image, voice, ai_response | +| 도메인 | 설명 | 메시지 타입 | +|--------|--------|-------------------------------------------------------------------------------------------| +| `chat` | 채팅 메시지 | text, image, voice, ai_response | | `game` | 게임 메시지 | game_start, game_end, round_start, round_end, drawing, correct_answer, score_update, hint | ### 메시지 라우팅 예시 + ```typescript const handleMessage = (message: BaseMessage) => { if (message.domain === 'chat') { @@ -122,11 +128,13 @@ const handleMessage = (message: BaseMessage) => { ## 게임 흐름 ### 게임 상태 (GameStatus) + ```typescript type GameStatus = 'NONE' | 'WAITING' | 'PLAYING' | 'ROUND_END' | 'FINISHED'; ``` ### 전체 흐름 + ``` [대기] ─── /game 시작 ───► [게임 시작] ─► [라운드 1] ─► [라운드 종료] │ │ @@ -144,6 +152,7 @@ type GameStatus = 'NONE' | 'WAITING' | 'PLAYING' | 'ROUND_END' | 'FINISHED'; ### 1. 게임 시작 (game_start) **수신 메시지:** + ```json { "domain": "game", @@ -166,6 +175,7 @@ type GameStatus = 'NONE' | 'WAITING' | 'PLAYING' | 'ROUND_END' | 'FINISHED'; ``` **프론트엔드 처리:** + ```typescript const handleGameStart = (message: GameStartMessage) => { setGameStatus('PLAYING'); @@ -185,6 +195,7 @@ const handleGameStart = (message: GameStartMessage) => { ### 2. 그림 데이터 전송/수신 (drawing) **전송 (출제자만):** + ```typescript const sendDrawing = (drawingData: DrawingData) => { ws.send(JSON.stringify({ @@ -196,6 +207,7 @@ const sendDrawing = (drawingData: DrawingData) => { ``` **수신 메시지:** + ```json { "domain": "game", @@ -211,6 +223,7 @@ const sendDrawing = (drawingData: DrawingData) => { ### 3. 정답 체크 **채팅 메시지로 자동 체크됩니다:** + ```typescript const sendAnswer = (answer: string) => { ws.send(JSON.stringify({ @@ -224,6 +237,7 @@ const sendAnswer = (answer: string) => { ### 4. 정답 알림 (correct_answer) **수신 메시지:** + ```json { "domain": "game", @@ -246,6 +260,7 @@ const sendAnswer = (answer: string) => { ### 5. 점수 업데이트 (score_update) **수신 메시지:** + ```json { "domain": "game", @@ -265,6 +280,7 @@ const sendAnswer = (answer: string) => { ### 6. 라운드 종료 (round_end) **수신 메시지:** + ```json { "domain": "game", @@ -295,6 +311,7 @@ const sendAnswer = (answer: string) => { ``` **프론트엔드 처리:** + ```typescript const handleRoundEnd = (message: RoundEndMessage) => { const { data } = message; @@ -328,6 +345,7 @@ const handleRoundEnd = (message: RoundEndMessage) => { ### 7. 게임 종료 (game_end) **수신 메시지:** + ```json { "domain": "game", @@ -352,12 +370,14 @@ const handleRoundEnd = (message: RoundEndMessage) => { ## REST API ### 게임 시작 + ```http POST /chat/rooms/{roomId}/game/start Authorization: Bearer {accessToken} ``` **Response:** + ```json { "success": true, @@ -380,27 +400,32 @@ Authorization: Bearer {accessToken} } } ``` + > **Note:** `currentWord`는 출제자에게만 포함됩니다. ### 게임 종료 + ```http POST /chat/rooms/{roomId}/game/stop Authorization: Bearer {accessToken} ``` ### 게임 상태 조회 + ```http GET /chat/rooms/{roomId}/game/status Authorization: Bearer {accessToken} ``` ### 게임 세션 조회 (재접속용) + ```http GET /games/{gameSessionId} Authorization: Bearer {accessToken} ``` **Response:** + ```json { "success": true, @@ -431,6 +456,7 @@ Authorization: Bearer {accessToken} } } ``` + > **Note:** `currentWord`는 출제자에게만 포함됩니다. --- @@ -438,12 +464,15 @@ Authorization: Bearer {accessToken} ## 타이머 동기화 ### 문제 + 클라이언트와 서버 시간 차이로 인한 타이머 불일치 ### 해결책 + `serverTime` 필드를 사용하여 서버 시간 기준 타이머 계산 ### 구현 예시 + ```typescript interface TimerSync { roundStartTime: number; // 라운드 시작 시간 (서버 기준) @@ -483,6 +512,7 @@ const startTimer = ( ``` ### React Hook 예시 + ```typescript const useGameTimer = (timerSync: TimerSync | null) => { const [remainingSeconds, setRemainingSeconds] = useState(0); @@ -512,9 +542,11 @@ const useGameTimer = (timerSync: TimerSync | null) => { ## 게임 자동 종료 ### 개요 + 게임 시작 후 7분(420초)이 경과하면 자동으로 종료됩니다. ### 자동 종료 메시지 + ```json { "domain": "game", @@ -528,6 +560,7 @@ const useGameTimer = (timerSync: TimerSync | null) => { ``` ### 프론트엔드 처리 + ```typescript const handleGameEnd = (message: GameEndMessage) => { setGameStatus('FINISHED'); @@ -552,15 +585,18 @@ const handleGameEnd = (message: GameEndMessage) => { ## 재접속 처리 ### 시나리오 + 사용자가 게임 중 연결이 끊어졌다가 다시 접속하는 경우 ### 처리 절차 + 1. WebSocket 재연결 2. 게임 세션 API로 현재 상태 조회 3. UI 상태 복원 4. 타이머 동기화 ### 구현 예시 + ```typescript const handleReconnect = async (roomId: string, gameSessionId: string) => { // 1. WebSocket 재연결 @@ -600,25 +636,28 @@ const handleReconnect = async (roomId: string, gameSessionId: string) => { ## 에러 처리 ### WebSocket 에러 코드 -| 코드 | 설명 | 처리 방법 | -|------|------|-----------| -| 1000 | 정상 종료 | - | -| 1001 | 서버 종료 | 재연결 시도 | -| 1006 | 비정상 종료 | 재연결 시도 | -| 4001 | 인증 실패 | 토큰 재발급 후 재연결 | -| 4003 | 권한 없음 | 에러 표시 | + +| 코드 | 설명 | 처리 방법 | +|------|--------|--------------| +| 1000 | 정상 종료 | - | +| 1001 | 서버 종료 | 재연결 시도 | +| 1006 | 비정상 종료 | 재연결 시도 | +| 4001 | 인증 실패 | 토큰 재발급 후 재연결 | +| 4003 | 권한 없음 | 에러 표시 | ### REST API 에러 코드 -| 코드 | 설명 | -|------|------| -| `GAME_001` | 게임 시작 실패 | -| `GAME_002` | 게임 중단 실패 | -| `GAME_003` | 진행 중인 게임 없음 | -| `GAME_004` | 이미 게임 진행 중 | + +| 코드 | 설명 | +|------------|-----------------------| +| `GAME_001` | 게임 시작 실패 | +| `GAME_002` | 게임 중단 실패 | +| `GAME_003` | 진행 중인 게임 없음 | +| `GAME_004` | 이미 게임 진행 중 | | `GAME_005` | 권한 없음 (게임 시작자만 중단 가능) | -| `GAME_006` | 게임 세션을 찾을 수 없음 | +| `GAME_006` | 게임 세션을 찾을 수 없음 | ### 에러 처리 예시 + ```typescript const handleError = (error: ApiError) => { switch (error.code) { @@ -716,7 +755,7 @@ const gameReducer = (state: GameState, action: GameAction): GameState => { ## 버전 이력 -| 버전 | 날짜 | 변경 내용 | -|------|------|-----------| -| 1.0.0 | 2024-01-20 | 초기 문서 작성 | +| 버전 | 날짜 | 변경 내용 | +|-------|------------|---------------------| +| 1.0.0 | 2024-01-20 | 초기 문서 작성 | | 1.1.0 | 2024-01-20 | 게임 자동 종료 (7분) 기능 추가 | diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ErrorInfo.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ErrorInfo.java index 329fc230..41feeb2b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ErrorInfo.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ErrorInfo.java @@ -8,17 +8,17 @@ /** * RFC 7807 스타일 에러 정보 - * + *

* Problem Details for HTTP APIs (RFC 7807) 표준을 참고한 에러 응답 형식입니다. - * + *

* 응답 예시: * { - * "code": "VOCABULARY.WORD_001", - * "message": "단어를 찾을 수 없습니다", - * "status": 404, - * "details": { - * "wordId": "abc-123" - * } + * "code": "VOCABULARY.WORD_001", + * "message": "단어를 찾을 수 없습니다", + * "status": 404, + * "details": { + * "wordId": "abc-123" + * } * } * * @param code 에러 코드 (예: AUTH_001, VOCABULARY.WORD_001) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonErrorCode.java index d1276375..126790d9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonErrorCode.java @@ -2,7 +2,7 @@ /** * 공통/시스템 에러 코드 - * + *

* 도메인에 종속되지 않는 공통 에러 코드를 정의합니다. * - 인증/인가 에러 (AUTH_XXX) * - 검증 에러 (VALIDATION_XXX) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonException.java index 497ff8eb..a6f67ed0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonException.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonException.java @@ -2,10 +2,10 @@ /** * 공통/시스템 예외 클래스 - * + *

* 도메인에 종속되지 않는 공통 예외를 처리합니다. * 정적 팩토리 메서드를 통해 가독성 높은 예외 생성을 지원합니다. - * + *

* 사용 예시: * throw CommonException.unauthorized(); * throw CommonException.notFound("사용자"); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/DomainErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/DomainErrorCode.java index 43a454e0..83ddb2ef 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/DomainErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/DomainErrorCode.java @@ -2,10 +2,10 @@ /** * 도메인별 에러 코드 인터페이스 - * + *

* 각 도메인(Vocabulary, Chatting 등)의 비즈니스 로직 관련 에러 코드가 구현하는 인터페이스입니다. * ErrorCode를 확장하여 도메인 식별 기능을 추가합니다. - * + *

* 구현체: * - VocabularyErrorCode - 단어 학습 도메인 * - ChattingErrorCode - 채팅 도메인 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ErrorCode.java index 5eabbd0a..35261fa0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ErrorCode.java @@ -2,16 +2,16 @@ /** * 에러 코드 표준 인터페이스 (Sealed Interface) - * + *

* 모든 에러 코드 enum이 구현해야 하는 표준 계약을 정의합니다. * Sealed interface를 사용하여 허용된 구현체만 존재하도록 제한합니다. - * + *

* 계층 구조: * ErrorCode (sealed) * ├── CommonErrorCode (시스템/공통 에러) * └── DomainErrorCode (non-sealed) - 도메인별 에러 - * ├── VocabularyErrorCode - * └── ChattingErrorCode + * ├── VocabularyErrorCode + * └── ChattingErrorCode */ public sealed interface ErrorCode permits CommonErrorCode, DomainErrorCode { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ServerlessException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ServerlessException.java index c9f578c0..f95ea8ab 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ServerlessException.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ServerlessException.java @@ -6,10 +6,10 @@ /** * 서버리스 애플리케이션 기본 예외 클래스 - * + *

* 모든 비즈니스 예외의 추상 기반 클래스입니다. * ErrorCode를 통해 표준화된 에러 정보를 제공합니다. - * + *

* 사용 예시: * - CommonException: 공통/시스템 예외 * - VocabularyException: 단어 학습 도메인 예외 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/AuthenticatedHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/AuthenticatedHandler.java index 51071fd5..b49dd275 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/AuthenticatedHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/AuthenticatedHandler.java @@ -5,7 +5,7 @@ /** * Cognito 인증이 필요한 요청 핸들러 - * + *

* userId가 자동으로 추출되어 전달됩니다. */ @FunctionalInterface diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java index a7178643..1b99130e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java @@ -18,13 +18,13 @@ /** * Lambda Handler를 위한 HTTP 라우터 - * + *

* 선언적 라우팅 + 자동 Path/Query 파라미터 검증 제공 - * + *

* 사용 예시: * new HandlerRouter().addRoutes( - * Route.get("/rooms/{roomId}", this::getRoom), - * Route.delete("/rooms/{roomId}", this::deleteRoom).requireQueryParams("userId") + * Route.get("/rooms/{roomId}", this::getRoom), + * Route.delete("/rooms/{roomId}", this::deleteRoom).requireQueryParams("userId") * ); */ public class HandlerRouter { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java index a9d9f746..44a46f6f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java @@ -13,7 +13,7 @@ /** * HTTP 라우트 정의 - * + *

* Path 패턴에서 자동으로 필수 파라미터를 추출합니다. * 예: "/rooms/{roomId}/messages/{messageId}" → ["roomId", "messageId"] * diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketBroadcaster.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketBroadcaster.java index 516687a0..653e3d1f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketBroadcaster.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketBroadcaster.java @@ -79,10 +79,10 @@ public List broadcast(List connections, String message) { logger.info("Broadcast completed: total={}, failed={}", connections.size(), failedConnections.size()); - + return failedConnections; } - + @Override public void close() { try { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketMessageHelper.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketMessageHelper.java index 7cbf5602..c8fb1a0d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketMessageHelper.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketMessageHelper.java @@ -10,14 +10,14 @@ * 모든 메시지에 domain 필드를 포함하여 채팅/게임 구분 지원 */ public final class WebSocketMessageHelper { - + public static final String DOMAIN_CHAT = "chat"; public static final String DOMAIN_GAME = "game"; public static final String DOMAIN_ROOM = "room"; - + private WebSocketMessageHelper() { } - + /** * 기본 메시지 생성 * @@ -34,21 +34,21 @@ public static Map createMessage(String domain, String messageTyp message.put("timestamp", System.currentTimeMillis()); return message; } - + /** * 채팅 메시지 생성 */ public static Map createChatMessage(String messageType, Object data) { return createMessage(DOMAIN_CHAT, messageType, data); } - + /** * 게임 메시지 생성 */ public static Map createGameMessage(String messageType, Object data) { return createMessage(DOMAIN_GAME, messageType, data); } - + /** * 채팅 메시지 빌더 (상세 필드 포함) */ @@ -60,7 +60,7 @@ public static Map buildChatMessage( ) { String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + Map message = new HashMap<>(); message.put("domain", DOMAIN_CHAT); message.put("messageType", messageType); @@ -72,7 +72,7 @@ public static Map buildChatMessage( message.put("timestamp", System.currentTimeMillis()); return message; } - + /** * 게임 메시지 빌더 (상세 필드 포함) */ @@ -84,7 +84,7 @@ public static Map buildGameMessage( String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); long serverTime = System.currentTimeMillis(); - + Map message = new HashMap<>(); message.put("domain", DOMAIN_GAME); message.put("messageType", messageType); @@ -94,20 +94,20 @@ public static Map buildGameMessage( message.put("createdAt", now); message.put("timestamp", serverTime); message.put("serverTime", serverTime); - + if (gameData != null) { message.put("data", gameData); } return message; } - + /** * 시스템 메시지 생성 (채팅 도메인) */ public static Map buildSystemMessage(String roomId, String content, String messageType) { return buildChatMessage(roomId, "SYSTEM", content, messageType); } - + /** * 방 상태 변경 메시지 생성 * @@ -123,7 +123,7 @@ public static Map buildRoomStatusChangeMessage( ) { String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + Map message = new HashMap<>(); message.put("domain", DOMAIN_ROOM); message.put("messageType", "room_status_change"); @@ -135,13 +135,13 @@ public static Map buildRoomStatusChangeMessage( message.put("timestamp", System.currentTimeMillis()); return message; } - + /** * 방장 변경 메시지 생성 * - * @param roomId 방 ID - * @param newHostId 새 방장 ID - * @param newHostNickname 새 방장 닉네임 + * @param roomId 방 ID + * @param newHostId 새 방장 ID + * @param newHostNickname 새 방장 닉네임 * @return 방장 변경 메시지 */ public static Map buildHostChangeMessage( @@ -151,7 +151,7 @@ public static Map buildHostChangeMessage( ) { String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + Map message = new HashMap<>(); message.put("domain", DOMAIN_ROOM); message.put("messageType", "host_change"); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java index 9f98d4a1..685ca8b9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java @@ -15,17 +15,17 @@ /** * Jakarta Bean Validation 기반 검증 유틸리티 - * + *

* DTO에 선언된 @NotNull, @NotEmpty 등의 어노테이션을 검증합니다. - * + *

* 사용 예시: * CreateRoomRequest req = ResponseGenerator.gson().fromJson(body, CreateRoomRequest.class); * return BeanValidator.validate(req) - * .map(error -> ResponseGenerator.fail(CommonErrorCode.REQUIRED_FIELD_MISSING, error)) - * .orElseGet(() -> { - * // 비즈니스 로직 - * return ResponseGenerator.ok("Success", result); - * }); + * .map(error -> ResponseGenerator.fail(CommonErrorCode.REQUIRED_FIELD_MISSING, error)) + * .orElseGet(() -> { + * // 비즈니스 로직 + * return ResponseGenerator.ok("Success", result); + * }); */ public final class BeanValidator { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/handler/BadgeHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/handler/BadgeHandler.java index 22159d80..ec4acb70 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/handler/BadgeHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/handler/BadgeHandler.java @@ -29,7 +29,7 @@ public class BadgeHandler implements RequestHandler table; - + /** * 기본 생성자 (Lambda에서 사용) */ public BadgeRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java index 62e887b3..0916a5d5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java @@ -5,14 +5,13 @@ import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; import com.mzc.secondproject.serverless.domain.badge.repository.BadgeRepository; +import com.mzc.secondproject.serverless.domain.badge.strategy.BadgeConditionStrategy; +import com.mzc.secondproject.serverless.domain.badge.strategy.BadgeConditionStrategyFactory; import com.mzc.secondproject.serverless.domain.stats.model.UserStats; import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.mzc.secondproject.serverless.domain.badge.strategy.BadgeConditionStrategy; -import com.mzc.secondproject.serverless.domain.badge.strategy.BadgeConditionStrategyFactory; - import java.time.Instant; import java.util.ArrayList; import java.util.List; @@ -25,14 +24,14 @@ public class BadgeService { private final BadgeRepository badgeRepository; private final UserStatsRepository userStatsRepository; - + /** * 기본 생성자 (Lambda에서 사용) */ public BadgeService() { this(new BadgeRepository(), new UserStatsRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -145,14 +144,14 @@ private UserBadge createBadge(String userId, BadgeType type, String now) { private boolean checkBadgeCondition(BadgeType type, UserStats stats) { if (stats == null) return false; - + BadgeConditionStrategy strategy = BadgeConditionStrategyFactory.getStrategy(type.getCategory()); return strategy.checkCondition(type, stats); } - + private int calculateProgress(BadgeType type, UserStats stats) { if (stats == null) return 0; - + BadgeConditionStrategy strategy = BadgeConditionStrategyFactory.getStrategy(type.getCategory()); return strategy.calculateProgress(type, stats); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/AccuracyStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/AccuracyStrategy.java index d6e5c58f..398875d0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/AccuracyStrategy.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/AccuracyStrategy.java @@ -7,23 +7,23 @@ * 정확도 뱃지 조건 전략 */ public class AccuracyStrategy implements BadgeConditionStrategy { - + @Override public boolean checkCondition(BadgeType type, UserStats stats) { double accuracy = calculateAccuracy(stats); return accuracy >= type.getThreshold(); } - + @Override public int calculateProgress(BadgeType type, UserStats stats) { return (int) calculateAccuracy(stats); } - + @Override public String getCategory() { return "ACCURACY"; } - + private double calculateAccuracy(UserStats stats) { if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) { return 0.0; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategy.java index 10243bcd..03cf274f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategy.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategy.java @@ -7,17 +7,17 @@ * 뱃지 조건 확인 전략 인터페이스 */ public interface BadgeConditionStrategy { - + /** * 뱃지 획득 조건 확인 */ boolean checkCondition(BadgeType type, UserStats stats); - + /** * 현재 진행도 계산 */ int calculateProgress(BadgeType type, UserStats stats); - + /** * 지원하는 카테고리 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java index c855ff19..01f6ed33 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java @@ -8,10 +8,10 @@ * 카테고리별 전략 인스턴스를 관리하고 제공 */ public class BadgeConditionStrategyFactory { - + private static final Map STRATEGIES = new HashMap<>(); private static final BadgeConditionStrategy DEFAULT_STRATEGY = new NoOpStrategy("DEFAULT"); - + static { register(new FirstStudyStrategy()); register(new StreakStrategy()); @@ -26,11 +26,11 @@ public class BadgeConditionStrategyFactory { register(new NoOpStrategy("PERFECT_TEST")); register(new NoOpStrategy("ALL_BADGES")); } - + private static void register(BadgeConditionStrategy strategy) { STRATEGIES.put(strategy.getCategory(), strategy); } - + /** * 카테고리에 해당하는 전략 반환 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/FirstStudyStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/FirstStudyStrategy.java index ab23769f..44d5e33a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/FirstStudyStrategy.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/FirstStudyStrategy.java @@ -7,17 +7,17 @@ * 첫 학습 뱃지 조건 전략 */ public class FirstStudyStrategy implements BadgeConditionStrategy { - + @Override public boolean checkCondition(BadgeType type, UserStats stats) { return stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1; } - + @Override public int calculateProgress(BadgeType type, UserStats stats) { return (stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1) ? 1 : 0; } - + @Override public String getCategory() { return "FIRST_STUDY"; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesPlayedStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesPlayedStrategy.java index c8e939f6..ade34c7a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesPlayedStrategy.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesPlayedStrategy.java @@ -7,17 +7,17 @@ * 게임 플레이 횟수 뱃지 조건 전략 */ public class GamesPlayedStrategy implements BadgeConditionStrategy { - + @Override public boolean checkCondition(BadgeType type, UserStats stats) { return stats.getGamesPlayed() != null && stats.getGamesPlayed() >= type.getThreshold(); } - + @Override public int calculateProgress(BadgeType type, UserStats stats) { return stats.getGamesPlayed() != null ? stats.getGamesPlayed() : 0; } - + @Override public String getCategory() { return "GAMES_PLAYED"; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesWonStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesWonStrategy.java index 884ed90a..d0c810a8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesWonStrategy.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesWonStrategy.java @@ -7,17 +7,17 @@ * 게임 승리 횟수 뱃지 조건 전략 */ public class GamesWonStrategy implements BadgeConditionStrategy { - + @Override public boolean checkCondition(BadgeType type, UserStats stats) { return stats.getGamesWon() != null && stats.getGamesWon() >= type.getThreshold(); } - + @Override public int calculateProgress(BadgeType type, UserStats stats) { return stats.getGamesWon() != null ? stats.getGamesWon() : 0; } - + @Override public String getCategory() { return "GAMES_WON"; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NoOpStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NoOpStrategy.java index 1f5f4c8b..0c35d127 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NoOpStrategy.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NoOpStrategy.java @@ -8,23 +8,23 @@ * PERFECT_TEST, ALL_BADGES 등은 별도 로직에서 처리 */ public class NoOpStrategy implements BadgeConditionStrategy { - + private final String category; - + public NoOpStrategy(String category) { this.category = category; } - + @Override public boolean checkCondition(BadgeType type, UserStats stats) { return false; } - + @Override public int calculateProgress(BadgeType type, UserStats stats) { return 0; } - + @Override public String getCategory() { return category; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/PerfectDrawsStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/PerfectDrawsStrategy.java index ac588a4d..abe06a48 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/PerfectDrawsStrategy.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/PerfectDrawsStrategy.java @@ -7,17 +7,17 @@ * 완벽한 출제 뱃지 조건 전략 */ public class PerfectDrawsStrategy implements BadgeConditionStrategy { - + @Override public boolean checkCondition(BadgeType type, UserStats stats) { return stats.getPerfectDraws() != null && stats.getPerfectDraws() >= type.getThreshold(); } - + @Override public int calculateProgress(BadgeType type, UserStats stats) { return stats.getPerfectDraws() != null ? stats.getPerfectDraws() : 0; } - + @Override public String getCategory() { return "PERFECT_DRAWS"; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/QuickGuessesStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/QuickGuessesStrategy.java index 610dc048..d276bec4 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/QuickGuessesStrategy.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/QuickGuessesStrategy.java @@ -7,17 +7,17 @@ * 빠른 정답 뱃지 조건 전략 */ public class QuickGuessesStrategy implements BadgeConditionStrategy { - + @Override public boolean checkCondition(BadgeType type, UserStats stats) { return stats.getQuickGuesses() != null && stats.getQuickGuesses() >= type.getThreshold(); } - + @Override public int calculateProgress(BadgeType type, UserStats stats) { return stats.getQuickGuesses() != null ? stats.getQuickGuesses() : 0; } - + @Override public String getCategory() { return "QUICK_GUESSES"; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/StreakStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/StreakStrategy.java index 18f826cb..5db1fac2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/StreakStrategy.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/StreakStrategy.java @@ -7,17 +7,17 @@ * 연속 학습 뱃지 조건 전략 */ public class StreakStrategy implements BadgeConditionStrategy { - + @Override public boolean checkCondition(BadgeType type, UserStats stats) { return stats.getCurrentStreak() != null && stats.getCurrentStreak() >= type.getThreshold(); } - + @Override public int calculateProgress(BadgeType type, UserStats stats) { return stats.getCurrentStreak() != null ? stats.getCurrentStreak() : 0; } - + @Override public String getCategory() { return "STREAK"; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/TestsCompletedStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/TestsCompletedStrategy.java index ec791adc..0be7d097 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/TestsCompletedStrategy.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/TestsCompletedStrategy.java @@ -7,17 +7,17 @@ * 테스트 완료 횟수 뱃지 조건 전략 */ public class TestsCompletedStrategy implements BadgeConditionStrategy { - + @Override public boolean checkCondition(BadgeType type, UserStats stats) { return stats.getTestsCompleted() != null && stats.getTestsCompleted() >= type.getThreshold(); } - + @Override public int calculateProgress(BadgeType type, UserStats stats) { return stats.getTestsCompleted() != null ? stats.getTestsCompleted() : 0; } - + @Override public String getCategory() { return "TESTS_COMPLETED"; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/WordsLearnedStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/WordsLearnedStrategy.java index 5f7cbe02..cff5c410 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/WordsLearnedStrategy.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/WordsLearnedStrategy.java @@ -7,23 +7,23 @@ * 단어 학습량 뱃지 조건 전략 */ public class WordsLearnedStrategy implements BadgeConditionStrategy { - + @Override public boolean checkCondition(BadgeType type, UserStats stats) { int total = getTotalWordsLearned(stats); return total >= type.getThreshold(); } - + @Override public int calculateProgress(BadgeType type, UserStats stats) { return getTotalWordsLearned(stats); } - + @Override public String getCategory() { return "WORDS_LEARNED"; } - + private int getTotalWordsLearned(UserStats stats) { int newWords = stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0; int reviewedWords = stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/config/GameConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/config/GameConfig.java index 7cd3a921..1e3ee370 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/config/GameConfig.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/config/GameConfig.java @@ -12,12 +12,12 @@ public final class GameConfig { private static final int DEFAULT_ROUND_TIME_LIMIT = 60; private static final long DEFAULT_QUICK_GUESS_THRESHOLD_MS = 5000L; private static final int DEFAULT_GAME_TIME_LIMIT = 420; // 7분 (420초) - + private static final int TOTAL_ROUNDS = EnvConfig.getIntOrDefault("GAME_TOTAL_ROUNDS", DEFAULT_TOTAL_ROUNDS); private static final int ROUND_TIME_LIMIT = EnvConfig.getIntOrDefault("GAME_ROUND_TIME_LIMIT", DEFAULT_ROUND_TIME_LIMIT); private static final long QUICK_GUESS_THRESHOLD_MS = EnvConfig.getLongOrDefault("GAME_QUICK_GUESS_THRESHOLD_MS", DEFAULT_QUICK_GUESS_THRESHOLD_MS); private static final int GAME_TIME_LIMIT = EnvConfig.getIntOrDefault("GAME_TIME_LIMIT_SECONDS", DEFAULT_GAME_TIME_LIMIT); - + private GameConfig() { } @@ -32,7 +32,7 @@ public static int roundTimeLimit() { public static long quickGuessThresholdMs() { return QUICK_GUESS_THRESHOLD_MS; } - + /** * 게임 전체 시간 제한 (초) * 기본값: 420초 (7분) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java index 58316901..e5a888ba 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java @@ -33,13 +33,13 @@ public class CreateRoomRequest { @Builder.Default private Boolean isPrivate = false; - + private String password; - + @Builder.Default private String type = "CHAT"; // CHAT or GAME - + private String gameType; // CATCHMIND (nullable) - + private GameSettings gameSettings; // 게임 설정 (nullable) } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomListItem.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomListItem.java index 63c8c9e2..d3b6f16a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomListItem.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomListItem.java @@ -35,7 +35,7 @@ public class RoomListItem { private String hostId; private String hostNickname; private List participants; - + /** * ChatRoom과 hostNickname으로 RoomListItem 생성 */ @@ -59,7 +59,7 @@ public static RoomListItem from(ChatRoom room, String hostNickname) { .hostNickname(hostNickname) .build(); } - + /** * ChatRoom, hostNickname, participants로 RoomListItem 생성 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreboardResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreboardResponse.java index eb27a347..72e46508 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreboardResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreboardResponse.java @@ -18,7 +18,7 @@ public record ScoreboardResponse( public static ScoreboardResponse from(GameSession session) { Map scores = session.getScores(); List ranking = buildRanking(scores); - + return new ScoreboardResponse( scores, ranking, @@ -27,21 +27,21 @@ public static ScoreboardResponse from(GameSession session) { session.getTotalRounds() ); } - + private static List buildRanking(Map scores) { if (scores == null || scores.isEmpty()) { return List.of(); } - + List> sorted = scores.entrySet().stream() .sorted(Map.Entry.comparingByValue().reversed()) .toList(); - + return java.util.stream.IntStream.range(0, sorted.size()) .mapToObj(i -> new RankEntry(i + 1, sorted.get(i).getKey(), sorted.get(i).getValue())) .toList(); } - + public record RankEntry( int rank, String userId, diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java index a7433637..b8a7d453 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java @@ -19,7 +19,7 @@ public enum MessageType { SCORE_UPDATE("score_update", "점수 업데이트"), SYSTEM_COMMAND("system_command", "시스템 명령"), HINT("hint", "힌트"), - + // 방 관련 메시지 타입 ROOM_STATUS_CHANGE("room_status_change", "방 상태 변경"), HOST_CHANGE("host_change", "방장 변경"); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatus.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatus.java index 6cfbd65b..b71acda0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatus.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatus.java @@ -6,21 +6,21 @@ public enum RoomStatus { WAITING("waiting", "대기 중"), PLAYING("playing", "게임 중"), FINISHED("finished", "종료됨"); - + private final String code; private final String displayName; - + RoomStatus(String code, String displayName) { this.code = code; this.displayName = displayName; } - + public static boolean isValid(String value) { if (value == null) return false; return Arrays.stream(values()) .anyMatch(status -> status.name().equalsIgnoreCase(value) || status.code.equalsIgnoreCase(value)); } - + public static RoomStatus fromString(String value) { if (value == null) return WAITING; return Arrays.stream(values()) @@ -28,11 +28,11 @@ public static RoomStatus fromString(String value) { .findFirst() .orElse(WAITING); } - + public String getCode() { return code; } - + public String getDisplayName() { return displayName; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomType.java index 8848b449..4af73fd2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomType.java @@ -5,21 +5,21 @@ public enum RoomType { CHAT("chat", "채팅방"), GAME("game", "게임방"); - + private final String code; private final String displayName; - + RoomType(String code, String displayName) { this.code = code; this.displayName = displayName; } - + public static boolean isValid(String value) { if (value == null) return false; return Arrays.stream(values()) .anyMatch(type -> type.name().equalsIgnoreCase(value) || type.code.equalsIgnoreCase(value)); } - + public static RoomType fromString(String value) { if (value == null) return CHAT; return Arrays.stream(values()) @@ -27,11 +27,11 @@ public static RoomType fromString(String value) { .findFirst() .orElse(CHAT); } - + public String getCode() { return code; } - + public String getDisplayName() { return displayName; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java index be3394c0..ad599b53 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java @@ -4,7 +4,7 @@ /** * 채팅 도메인 에러 코드 - * + *

* 채팅방(Room), 메시지(Message), 참여자(Participant) 관련 에러 코드를 정의합니다. */ public enum ChattingErrorCode implements DomainErrorCode { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingException.java index a2da178a..16b51450 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingException.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingException.java @@ -4,9 +4,9 @@ /** * 채팅 도메인 예외 클래스 - * + *

* 정적 팩토리 메서드를 통해 가독성 높은 예외 생성을 지원합니다. - * + *

* 사용 예시: * throw ChattingException.roomNotFound(roomId); * throw ChattingException.notRoomMember(userId, roomId); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java index b8a36d77..b156feba 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java @@ -37,7 +37,7 @@ public class ChatMessageHandler implements RequestHandler { String level = dto.getLevel() != null ? dto.getLevel() : "beginner"; Integer maxMembers = dto.getMaxMembers() != null ? dto.getMaxMembers() : 6; Boolean isPrivate = dto.getIsPrivate() != null ? dto.getIsPrivate() : false; - + ChatRoom room = commandService.createRoom( dto.getName(), dto.getDescription(), level, maxMembers, isPrivate, dto.getPassword(), userId, dto.getType(), dto.getGameType(), dto.getGameSettings()); - + // hostNickname 포함하여 응답 String hostNickname = queryService.getHostNickname(room); RoomListItem roomItem = RoomListItem.from(room, hostNickname); - + return ResponseGenerator.created("Room created", roomItem); }); } private APIGatewayProxyResponseEvent getRooms(APIGatewayProxyRequestEvent request, String userId) { Map queryParams = request.getQueryStringParameters(); - + String level = queryParams != null ? queryParams.get("level") : null; String joined = queryParams != null ? queryParams.get("joined") : null; String cursor = queryParams != null ? queryParams.get("cursor") : null; String type = queryParams != null ? queryParams.get("type") : null; String gameType = queryParams != null ? queryParams.get("gameType") : null; String status = queryParams != null ? queryParams.get("status") : null; - + int limit = 10; if (queryParams != null && queryParams.get("limit") != null) { limit = Math.min(Integer.parseInt(queryParams.get("limit")), 20); } - + PaginatedResult roomPage = queryService.getRooms(level, limit, cursor, type, gameType, status); List rooms = roomPage.items(); - + if ("true".equals(joined)) { rooms = queryService.filterByJoinedUser(rooms, userId); } - + // hostNickname 포함하여 RoomListItem으로 변환 List roomItems = rooms.stream() .map(room -> { @@ -116,35 +116,35 @@ private APIGatewayProxyResponseEvent getRooms(APIGatewayProxyRequestEvent reques return RoomListItem.from(room, hostNickname); }) .toList(); - + Map result = new HashMap<>(); result.put("rooms", roomItems); result.put("nextCursor", roomPage.nextCursor()); result.put("hasMore", roomPage.hasMore()); - + return ResponseGenerator.ok("Rooms retrieved", result); } private APIGatewayProxyResponseEvent getRoom(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - + Optional optRoom = queryService.getRoom(roomId); if (optRoom.isEmpty()) { return ResponseGenerator.fail(ChattingErrorCode.ROOM_NOT_FOUND); } - + ChatRoom room = optRoom.get(); room.setPassword(null); - + // 참가자 정보와 방장 닉네임 추가 List participants = queryService.getParticipantsWithNicknames(room); String hostNickname = queryService.getHostNickname(room); - + Map result = new HashMap<>(); result.put("room", room); result.put("participants", participants); result.put("hostNickname", hostNickname); - + return ResponseGenerator.ok("Room retrieved", result); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java index 1f179162..f37b4d48 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java @@ -34,7 +34,7 @@ public class ChatVoiceHandler implements RequestHandler, String> { - + private static final Logger logger = LoggerFactory.getLogger(GameAutoCloseHandler.class); - + private final GameService gameService; private final ConnectionRepository connectionRepository; private final WebSocketBroadcaster broadcaster; - + /** * 기본 생성자 (Lambda에서 사용) */ public GameAutoCloseHandler() { this(new GameService(), new ConnectionRepository(), new WebSocketBroadcaster()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ public GameAutoCloseHandler(GameService gameService, ConnectionRepository connectionRepository, - WebSocketBroadcaster broadcaster) { + WebSocketBroadcaster broadcaster) { this.gameService = gameService; this.connectionRepository = connectionRepository; this.broadcaster = broadcaster; } - + @Override public String handleRequest(Map event, Context context) { String gameSessionId = event.get("gameSessionId"); String roomId = event.get("roomId"); - + logger.info("Game auto-close triggered: gameSessionId={}, roomId={}", gameSessionId, roomId); - + if (gameSessionId == null || roomId == null) { logger.error("Missing required parameters: gameSessionId={}, roomId={}", gameSessionId, roomId); return "FAILED: Missing parameters"; } - + try { // 게임 종료 처리 CommandResult result = gameService.finishGameByTimeout(gameSessionId); - + if (result.success()) { // WebSocket으로 게임 종료 알림 브로드캐스트 broadcastGameEnd(roomId, result.message()); @@ -73,20 +73,20 @@ public String handleRequest(Map event, Context context) { logger.info("Game auto-close skipped: gameSessionId={}, reason={}", gameSessionId, result.message()); return "SKIPPED: " + result.message(); } - + } catch (Exception e) { logger.error("Game auto-close failed: gameSessionId={}, error={}", gameSessionId, e.getMessage(), e); return "FAILED: " + e.getMessage(); } } - + /** * 게임 종료 메시지 브로드캐스트 */ private void broadcastGameEnd(String roomId, String message) { String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + Map gameEndMessage = new HashMap<>(); gameEndMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); gameEndMessage.put("messageId", messageId); @@ -97,11 +97,11 @@ private void broadcastGameEnd(String roomId, String message) { gameEndMessage.put("createdAt", now); gameEndMessage.put("timestamp", System.currentTimeMillis()); gameEndMessage.put("reason", "TIME_EXPIRED"); - + List connections = connectionRepository.findByRoomId(roomId); String broadcastPayload = ResponseGenerator.gson().toJson(gameEndMessage); broadcaster.broadcast(connections, broadcastPayload); - + logger.info("Game end broadcasted: roomId={}, connections={}", roomId, connections.size()); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java index 55a6c503..a4caaada 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java @@ -10,7 +10,6 @@ import com.mzc.secondproject.serverless.common.util.WebSocketBroadcaster; import com.mzc.secondproject.serverless.common.util.WebSocketMessageHelper; import com.mzc.secondproject.serverless.domain.chatting.dto.response.CommandResult; -import com.mzc.secondproject.serverless.domain.chatting.dto.response.GameStatusResponse; import com.mzc.secondproject.serverless.domain.chatting.dto.response.ScoreboardResponse; import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingErrorCode; @@ -29,34 +28,34 @@ * 게임 REST API 핸들러 */ public class GameHandler implements RequestHandler { - + private static final Logger logger = LoggerFactory.getLogger(GameHandler.class); - + private final GameService gameService; private final GameSessionRepository gameSessionRepository; private final ConnectionRepository connectionRepository; private final WebSocketBroadcaster broadcaster; private final HandlerRouter router; - + /** * 기본 생성자 (Lambda에서 사용) */ public GameHandler() { this(new GameService(), new GameSessionRepository(), new ConnectionRepository(), new WebSocketBroadcaster()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ public GameHandler(GameService gameService, GameSessionRepository gameSessionRepository, - ConnectionRepository connectionRepository, WebSocketBroadcaster broadcaster) { + ConnectionRepository connectionRepository, WebSocketBroadcaster broadcaster) { this.gameService = gameService; this.gameSessionRepository = gameSessionRepository; this.connectionRepository = connectionRepository; this.broadcaster = broadcaster; this.router = initRouter(); } - + private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( Route.postAuth("/rooms/{roomId}/game/start", this::startGame), @@ -66,91 +65,91 @@ private HandlerRouter initRouter() { Route.getAuth("/rooms/{roomId}/game/scores", this::getScores) ); } - + @Override public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); return router.route(request); } - + /** * POST /rooms/{roomId}/game/start - 게임 시작 */ private APIGatewayProxyResponseEvent startGame(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - + GameService.GameStartResult result = gameService.startGame(roomId, userId); - + if (!result.success()) { return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, result.error()); } - + // WebSocket으로 게임 시작 알림 브로드캐스트 (출제자에게 currentWord 포함) broadcastGameStart(roomId, result); - + // REST 응답에도 출제자에게 currentWord 포함 Map response = buildGameStatusResponse(result.session(), userId); return ResponseGenerator.ok("Game started", response); } - + /** * POST /rooms/{roomId}/game/stop - 게임 중단 */ private APIGatewayProxyResponseEvent stopGame(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - + CommandResult result = gameService.stopGame(roomId, userId); - + if (!result.success()) { return ResponseGenerator.fail(ChattingErrorCode.GAME_STOP_FAILED, result.message()); } - + // WebSocket으로 게임 종료 알림 브로드캐스트 broadcastSystemMessage(roomId, result.message(), MessageType.GAME_END); - + return ResponseGenerator.ok("Game stopped", Map.of("message", result.message())); } - + /** * POST /rooms/{roomId}/game/restart - 게임 재시작 */ private APIGatewayProxyResponseEvent restartGame(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - + GameService.GameStartResult result = gameService.restartGame(roomId, userId); - + if (!result.success()) { return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, result.error()); } - + // WebSocket으로 게임 시작 알림 브로드캐스트 (출제자에게 currentWord 포함) broadcastGameStart(roomId, result); - + // REST 응답에도 출제자에게 currentWord 포함 Map response = buildGameStatusResponse(result.session(), userId); return ResponseGenerator.ok("Game restarted", response); } - + /** * GET /rooms/{roomId}/game/status - 게임 상태 조회 */ private APIGatewayProxyResponseEvent getGameStatus(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - + Optional optSession = gameSessionRepository.findActiveByRoomId(roomId); if (optSession.isEmpty()) { // 게임이 없는 경우 빈 상태 반환 return ResponseGenerator.ok("No active game", Map.of("gameStatus", "NONE")); } - + GameSession session = optSession.get(); - + // 출제자에게만 currentWord 포함 Map response = buildGameStatusResponse(session, userId); - + return ResponseGenerator.ok("Game status retrieved", response); } - + /** * 게임 상태 응답 빌드 (출제자에게만 currentWord 포함) */ @@ -167,7 +166,7 @@ private Map buildGameStatusResponse(GameSession session, String response.put("scores", session.getScores() != null ? session.getScores() : Map.of()); response.put("hintUsed", session.getHintUsed()); response.put("correctGuessers", session.getCorrectGuessers()); - + // 출제자에게만 현재 단어 포함 if (userId != null && userId.equals(session.getCurrentDrawerId())) { Map currentWord = new HashMap<>(); @@ -175,27 +174,27 @@ private Map buildGameStatusResponse(GameSession session, String currentWord.put("word", session.getCurrentWord()); response.put("currentWord", currentWord); } - + return response; } - + /** * GET /rooms/{roomId}/game/scores - 점수 조회 */ private APIGatewayProxyResponseEvent getScores(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - + Optional optSession = gameSessionRepository.findActiveByRoomId(roomId); if (optSession.isEmpty()) { return ResponseGenerator.ok("No active game", Map.of("scores", Map.of())); } - + GameSession session = optSession.get(); ScoreboardResponse response = ScoreboardResponse.from(session); - + return ResponseGenerator.ok("Scores retrieved", response); } - + /** * 게임 시작 브로드캐스트 * 모든 사용자에게 게임 시작 메시지 전송, 출제자에게는 currentWord 포함 @@ -204,20 +203,20 @@ private void broadcastGameStart(String roomId, GameService.GameStartResult resul String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); long serverTime = System.currentTimeMillis(); - + GameSession session = result.session(); String drawerId = session.getCurrentDrawerId(); - + String message = String.format(""" 🎮 게임 시작! 총 %d 라운드 - + 라운드 1 시작! 출제자: %s """, session.getTotalRounds(), drawerId); - + // 기본 게임 시작 메시지 (모든 사용자용) Map gameStartMessage = new HashMap<>(); gameStartMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); @@ -236,35 +235,35 @@ private void broadcastGameStart(String roomId, GameService.GameStartResult resul gameStartMessage.put("roundStartTime", session.getRoundStartTime()); gameStartMessage.put("serverTime", serverTime); gameStartMessage.put("roundDuration", session.getRoundDuration()); - + List connections = connectionRepository.findByRoomId(roomId); - + // 출제자용 메시지 (currentWord 포함) Map drawerMessage = new HashMap<>(gameStartMessage); Map currentWord = new HashMap<>(); currentWord.put("wordId", session.getCurrentWordId()); currentWord.put("word", session.getCurrentWord()); drawerMessage.put("currentWord", currentWord); - + String broadcastPayload = ResponseGenerator.gson().toJson(gameStartMessage); String drawerPayload = ResponseGenerator.gson().toJson(drawerMessage); - + // 출제자와 일반 사용자에게 다른 메시지 전송 for (Connection conn : connections) { String payload = conn.getUserId().equals(drawerId) ? drawerPayload : broadcastPayload; broadcaster.sendToConnection(conn.getConnectionId(), payload); } - + logger.info("Game start broadcasted: roomId={}, drawerId={}", roomId, drawerId); } - + /** * 시스템 메시지 브로드캐스트 */ private void broadcastSystemMessage(String roomId, String message, MessageType messageType) { String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + Map systemMessage = new HashMap<>(); systemMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); systemMessage.put("messageId", messageId); @@ -274,11 +273,11 @@ private void broadcastSystemMessage(String roomId, String message, MessageType m systemMessage.put("messageType", messageType.getCode()); systemMessage.put("createdAt", now); systemMessage.put("timestamp", System.currentTimeMillis()); - + List connections = connectionRepository.findByRoomId(roomId); String broadcastPayload = ResponseGenerator.gson().toJson(systemMessage); broadcaster.broadcast(connections, broadcastPayload); - + logger.info("System message broadcasted: roomId={}, type={}", roomId, messageType); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameSessionHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameSessionHandler.java index 7a818a64..16a9692f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameSessionHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameSessionHandler.java @@ -28,34 +28,34 @@ * 게임 세션 조회 및 재접속 지원 */ public class GameSessionHandler implements RequestHandler { - + private static final Logger logger = LoggerFactory.getLogger(GameSessionHandler.class); - + private final GameService gameService; private final GameSessionRepository gameSessionRepository; private final ConnectionRepository connectionRepository; private final WebSocketBroadcaster broadcaster; private final HandlerRouter router; - + /** * 기본 생성자 (Lambda에서 사용) */ public GameSessionHandler() { this(new GameService(), new GameSessionRepository(), new ConnectionRepository(), new WebSocketBroadcaster()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ public GameSessionHandler(GameService gameService, GameSessionRepository gameSessionRepository, - ConnectionRepository connectionRepository, WebSocketBroadcaster broadcaster) { + ConnectionRepository connectionRepository, WebSocketBroadcaster broadcaster) { this.gameService = gameService; this.gameSessionRepository = gameSessionRepository; this.connectionRepository = connectionRepository; this.broadcaster = broadcaster; this.router = initRouter(); } - + private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( // 게임 세션 생성 (roomId 기반) @@ -68,117 +68,117 @@ private HandlerRouter initRouter() { Route.postAuth("/games/{gameSessionId}/stop", this::stopGame) ); } - + @Override public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { logger.info("GameSession API request: {} {}", request.getHttpMethod(), request.getPath()); return router.route(request); } - + /** * POST /rooms/{roomId}/games - 게임 세션 생성 (게임 시작) */ private APIGatewayProxyResponseEvent createGameSession(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - + GameService.GameStartResult result = gameService.startGame(roomId, userId); - + if (!result.success()) { return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, result.error()); } - + // WebSocket으로 게임 시작 알림 브로드캐스트 broadcastGameStart(roomId, result); - + // 응답 생성 (serverTime 포함) Map response = buildGameSessionResponse(result.session(), userId); - + return ResponseGenerator.ok("Game session created", response); } - + /** * GET /games/{gameSessionId} - 게임 세션 조회 (재접속용) */ private APIGatewayProxyResponseEvent getGameSession(APIGatewayProxyRequestEvent request, String userId) { String gameSessionId = request.getPathParameters().get("gameSessionId"); - + Optional optSession = gameSessionRepository.findById(gameSessionId); if (optSession.isEmpty()) { return ResponseGenerator.fail(ChattingErrorCode.GAME_NOT_FOUND); } - + GameSession session = optSession.get(); - + // 응답 생성 (serverTime 포함, 출제자에게만 currentWord 포함) Map response = buildGameSessionResponse(session, userId); - + return ResponseGenerator.ok("Game session retrieved", response); } - + /** * POST /games/{gameSessionId}/start - 게임 시작 (세션 ID로) */ private APIGatewayProxyResponseEvent startGame(APIGatewayProxyRequestEvent request, String userId) { String gameSessionId = request.getPathParameters().get("gameSessionId"); - + Optional optSession = gameSessionRepository.findById(gameSessionId); if (optSession.isEmpty()) { return ResponseGenerator.fail(ChattingErrorCode.GAME_NOT_FOUND); } - + GameSession session = optSession.get(); - + // 이미 시작된 게임인지 확인 if (session.isActive()) { return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, "게임이 이미 진행 중입니다."); } - + // roomId로 게임 시작 위임 GameService.GameStartResult result = gameService.startGame(session.getRoomId(), userId); - + if (!result.success()) { return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, result.error()); } - + broadcastGameStart(session.getRoomId(), result); - + Map response = buildGameSessionResponse(result.session(), userId); return ResponseGenerator.ok("Game started", response); } - + /** * POST /games/{gameSessionId}/stop - 게임 종료 */ private APIGatewayProxyResponseEvent stopGame(APIGatewayProxyRequestEvent request, String userId) { String gameSessionId = request.getPathParameters().get("gameSessionId"); - + Optional optSession = gameSessionRepository.findById(gameSessionId); if (optSession.isEmpty()) { return ResponseGenerator.fail(ChattingErrorCode.GAME_NOT_FOUND); } - + GameSession session = optSession.get(); CommandResult result = gameService.stopGame(session.getRoomId(), userId); - + if (!result.success()) { return ResponseGenerator.fail(ChattingErrorCode.GAME_STOP_FAILED, result.message()); } - + // WebSocket으로 게임 종료 알림 브로드캐스트 broadcastSystemMessage(session.getRoomId(), result.message(), MessageType.GAME_END); - + return ResponseGenerator.ok("Game stopped", Map.of( "message", result.message(), "serverTime", System.currentTimeMillis() )); } - + /** * 게임 세션 응답 빌드 (serverTime 포함) */ private Map buildGameSessionResponse(GameSession session, String userId) { long serverTime = System.currentTimeMillis(); - + Map response = new LinkedHashMap<>(); response.put("gameSessionId", session.getGameSessionId()); response.put("roomId", session.getRoomId()); @@ -194,7 +194,7 @@ private Map buildGameSessionResponse(GameSession session, String response.put("players", session.getPlayers() != null ? session.getPlayers() : List.of()); response.put("drawerOrder", session.getDrawerOrder()); response.put("hintUsed", session.getHintUsed()); - + // 출제자에게만 현재 단어 포함 if (userId != null && userId.equals(session.getCurrentDrawerId())) { Map currentWord = new HashMap<>(); @@ -202,10 +202,10 @@ private Map buildGameSessionResponse(GameSession session, String currentWord.put("word", session.getCurrentWord()); response.put("currentWord", currentWord); } - + return response; } - + /** * 게임 시작 브로드캐스트 * 모든 사용자에게 게임 시작 메시지 전송, 출제자에게는 currentWord 포함 @@ -214,20 +214,20 @@ private void broadcastGameStart(String roomId, GameService.GameStartResult resul String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); long serverTime = System.currentTimeMillis(); - + GameSession session = result.session(); String drawerId = session.getCurrentDrawerId(); - + String message = String.format(""" 🎮 게임 시작! 총 %d 라운드 - + 라운드 1 시작! 출제자: %s """, session.getTotalRounds(), drawerId); - + // 기본 게임 시작 메시지 (모든 사용자용) Map gameStartMessage = new HashMap<>(); gameStartMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); @@ -246,35 +246,35 @@ private void broadcastGameStart(String roomId, GameService.GameStartResult resul gameStartMessage.put("roundStartTime", session.getRoundStartTime()); gameStartMessage.put("serverTime", serverTime); gameStartMessage.put("roundDuration", session.getRoundDuration()); - + List connections = connectionRepository.findByRoomId(roomId); - + // 출제자용 메시지 (currentWord 포함) Map drawerMessage = new HashMap<>(gameStartMessage); Map currentWord = new HashMap<>(); currentWord.put("wordId", session.getCurrentWordId()); currentWord.put("word", session.getCurrentWord()); drawerMessage.put("currentWord", currentWord); - + String broadcastPayload = ResponseGenerator.gson().toJson(gameStartMessage); String drawerPayload = ResponseGenerator.gson().toJson(drawerMessage); - + // 출제자와 일반 사용자에게 다른 메시지 전송 for (Connection conn : connections) { String payload = conn.getUserId().equals(drawerId) ? drawerPayload : broadcastPayload; broadcaster.sendToConnection(conn.getConnectionId(), payload); } - + logger.info("Game start broadcasted: roomId={}, sessionId={}, drawerId={}", roomId, session.getGameSessionId(), drawerId); } - + /** * 시스템 메시지 브로드캐스트 */ private void broadcastSystemMessage(String roomId, String message, MessageType messageType) { String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + Map systemMessage = new HashMap<>(); systemMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); systemMessage.put("messageId", messageId); @@ -284,11 +284,11 @@ private void broadcastSystemMessage(String roomId, String message, MessageType m systemMessage.put("messageType", messageType.getCode()); systemMessage.put("createdAt", now); systemMessage.put("timestamp", System.currentTimeMillis()); - + List connections = connectionRepository.findByRoomId(roomId); String broadcastPayload = ResponseGenerator.gson().toJson(systemMessage); broadcaster.broadcast(connections, broadcastPayload); - + logger.info("System message broadcasted: roomId={}, type={}", roomId, messageType); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java index 0588185e..7b674a45 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java @@ -56,13 +56,13 @@ public Map handleRequest(Map event, Context cont RoomToken token = optToken.get(); String userId = token.getUserId(); String roomId = token.getRoomId(); - + // 같은 방에서 기존 연결 삭제 (새로고침 시 중복 연결 방지) connectionRepository.deleteUserConnectionsInRoom(userId, roomId); - + String now = Instant.now().toString(); long ttl = Instant.now().plusSeconds(WebSocketConfig.connectionTtlSeconds()).getEpochSecond(); - + Connection connection = Connection.builder() .pk("CONN#" + connectionId) .sk("METADATA") diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java index ce0ed059..1400b43e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java @@ -22,36 +22,36 @@ * 클라이언트 연결 해제 시 Connection 정보를 DynamoDB에서 삭제 */ public class WebSocketDisconnectHandler implements RequestHandler, Map> { - + private static final Logger logger = LoggerFactory.getLogger(WebSocketDisconnectHandler.class); - + private final ConnectionRepository connectionRepository; private final ChatRoomRepository chatRoomRepository; private final GameSessionRepository gameSessionRepository; - + public WebSocketDisconnectHandler() { this.connectionRepository = new ConnectionRepository(); this.chatRoomRepository = new ChatRoomRepository(); this.gameSessionRepository = new GameSessionRepository(); } - + @Override public Map handleRequest(Map event, Context context) { logger.info("WebSocket disconnect event: {}", event); - + try { String connectionId = WebSocketEventUtil.extractConnectionId(event); - + Optional connection = connectionRepository.findByConnectionId(connectionId); - + if (connection.isPresent()) { Connection conn = connection.get(); String roomId = conn.getRoomId(); - + connectionRepository.delete(connectionId); logger.info("Connection deleted: connectionId={}, userId={}, roomId={}", connectionId, conn.getUserId(), roomId); - + // 방에 남은 연결이 없으면 게임 상태 초기화 List remainingConnections = connectionRepository.findByRoomId(roomId); if (remainingConnections.isEmpty()) { @@ -61,15 +61,15 @@ public Map handleRequest(Map event, Context cont } else { logger.warn("Connection not found for deletion: connectionId={}", connectionId); } - + return WebSocketEventUtil.ok("Disconnected"); - + } catch (Exception e) { logger.error("Error handling disconnect: {}", e.getMessage(), e); return WebSocketEventUtil.serverError("Internal server error"); } } - + /** * 게임 상태 초기화 * 새 구조에서는 GameSession을 종료하고 ChatRoom의 상태를 WAITING으로 변경 @@ -85,7 +85,7 @@ private void resetGameState(String roomId) { gameSessionRepository.finishGame(session.getGameSessionId(), now, ttl); logger.info("Game session finished due to empty room: gameSessionId={}", session.getGameSessionId()); } - + // 채팅방 상태 초기화 Optional roomOpt = chatRoomRepository.findById(roomId); if (roomOpt.isPresent()) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java index fadd7296..f8da9d75 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java @@ -44,7 +44,7 @@ public class WebSocketMessageHandler implements RequestHandler handleRegularMessage(String connectionId, MessagePay // 일반 메시지 저장 및 브로드캐스트 String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + ChatMessage message = ChatMessage.builder() .pk("ROOM#" + payload.roomId) .sk("MSG#" + now + "#" + messageId) @@ -170,12 +170,12 @@ private Map handleRegularMessage(String connectionId, MessagePay .messageType(messageType) .createdAt(now) .build(); - + ChatMessage savedMessage = chatMessageService.saveMessage(message); chatRoomRepository.updateLastMessageAt(payload.roomId, now); - + logger.info("Message saved: messageId={}, roomId={}", messageId, payload.roomId); - + // 브로드캐스트 (domain 필드 포함을 위해 Map으로 변환) Map broadcastMessage = new HashMap<>(); broadcastMessage.put("domain", WebSocketMessageHelper.DOMAIN_CHAT); @@ -186,17 +186,17 @@ private Map handleRegularMessage(String connectionId, MessagePay broadcastMessage.put("messageType", savedMessage.getMessageType()); broadcastMessage.put("createdAt", savedMessage.getCreatedAt()); broadcastMessage.put("timestamp", System.currentTimeMillis()); - + List connections = connectionRepository.findByRoomId(payload.roomId); String broadcastPayload = gson.toJson(broadcastMessage); List failedConnections = broadcaster.broadcast(connections, broadcastPayload); - + // 실패한 연결 정리 for (String failedConnectionId : failedConnections) { connectionRepository.delete(failedConnectionId); logger.info("Deleted stale connection: {}", failedConnectionId); } - + return WebSocketEventUtil.ok("Message sent"); } @@ -232,23 +232,23 @@ private Map broadcastGuessMessage(MessagePayload payload) { */ private Map handleCorrectAnswer(MessagePayload payload, GameService.AnswerCheckResult result) { List connections = connectionRepository.findByRoomId(payload.roomId); - + // 1. 정답 알림 메시지 브로드캐스트 broadcastCorrectAnswerMessage(payload, result, connections); - + // 2. 점수 업데이트 메시지 브로드캐스트 (실시간 리더보드) gameSessionRepository.findActiveByRoomId(payload.roomId).ifPresent(session -> { broadcastScoreUpdate(payload.roomId, payload.userId, result.score(), result.scores(), session.getCurrentRound(), session.getTotalRounds(), connections); }); - + logger.info("Correct answer: roomId={}, userId={}, score={}", payload.roomId, payload.userId, result.score()); - + // 전원 정답 시 라운드 종료 처리 if (result.allCorrect()) { handleAllCorrect(payload.roomId); } - + return WebSocketEventUtil.ok("Correct answer"); } @@ -258,9 +258,9 @@ private Map handleCorrectAnswer(MessagePayload payload, GameServ private void broadcastCorrectAnswerMessage(MessagePayload payload, GameService.AnswerCheckResult result, List connections) { String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + String message = String.format("🎉 %s님이 정답을 맞췄습니다! (+%d점)", payload.userId, result.score()); - + // domain 필드 포함을 위해 Map으로 생성 Map correctMessage = new HashMap<>(); correctMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); @@ -271,7 +271,7 @@ private void broadcastCorrectAnswerMessage(MessagePayload payload, GameService.A correctMessage.put("messageType", MessageType.CORRECT_ANSWER.getCode()); correctMessage.put("createdAt", now); correctMessage.put("timestamp", System.currentTimeMillis()); - + String broadcastPayload = gson.toJson(correctMessage); List failedConnections = broadcaster.broadcast(connections, broadcastPayload); cleanupFailedConnections(failedConnections); @@ -320,7 +320,7 @@ private void handleAllCorrect(String roomId) { handleCommandResult(endResult, roomId, "SYSTEM"); } } - + /** * 라운드 타임아웃 처리 (프론트엔드에서 타이머 만료 시 호출) * - 실제 라운드 시간이 만료되었는지 서버에서 검증 @@ -329,31 +329,31 @@ private void handleAllCorrect(String roomId) { private Map handleRoundTimeout(MessagePayload payload) { String roomId = payload.roomId; logger.info("Round timeout request: roomId={}, userId={}", roomId, payload.userId); - + // 활성 게임 세션 조회 GameSession session = gameSessionRepository.findActiveByRoomId(roomId).orElse(null); if (session == null) { logger.warn("No active game session for round timeout: roomId={}", roomId); return WebSocketEventUtil.ok("No active game"); } - + // 라운드 시간이 실제로 만료되었는지 검증 (5초 여유) long elapsedMs = System.currentTimeMillis() - session.getRoundStartTime(); int roundDurationMs = (session.getRoundDuration() != null ? session.getRoundDuration() : 60) * 1000; - + if (elapsedMs < roundDurationMs - 5000) { logger.warn("Round timeout rejected - time not expired: elapsedMs={}, roundDurationMs={}", elapsedMs, roundDurationMs); return WebSocketEventUtil.ok("Round time not expired yet"); } - + // 라운드 종료 처리 CommandResult endResult = gameService.endRound(roomId, "TIMEOUT"); if (endResult != null && endResult.success()) { handleCommandResult(endResult, roomId, "SYSTEM"); logger.info("Round ended due to timeout: roomId={}", roomId); } - + return WebSocketEventUtil.ok("Round timeout processed"); } @@ -362,13 +362,13 @@ private Map handleRoundTimeout(MessagePayload payload) { */ private Map handleCommandResult(CommandResult result, String roomId, String userId) { List connections = connectionRepository.findByRoomId(roomId); - + // GAME_START는 특별 처리 (출제자에게만 제시어 전송 + serverTime 포함) if (result.messageType() == MessageType.GAME_START && result.data() instanceof GameService.GameStartResult gameResult) { broadcastGameStart(connections, result, gameResult, roomId); return WebSocketEventUtil.ok("Command executed"); } - + // ROUND_END는 특별 처리 (다음 출제자에게만 제시어 전송 + serverTime 포함) if (result.messageType() == MessageType.ROUND_END && result.data() instanceof Map) { @SuppressWarnings("unchecked") @@ -376,11 +376,11 @@ private Map handleCommandResult(CommandResult result, String roo broadcastRoundEnd(connections, result, data, roomId); return WebSocketEventUtil.ok("Command executed"); } - + // 일반 시스템 메시지 (게임 관련 명령어 결과) String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + // domain 필드 포함을 위해 Map으로 생성 Map systemMessage = new HashMap<>(); systemMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); @@ -391,27 +391,27 @@ private Map handleCommandResult(CommandResult result, String roo systemMessage.put("messageType", result.messageType().getCode()); systemMessage.put("createdAt", now); systemMessage.put("timestamp", System.currentTimeMillis()); - + String broadcastPayload = gson.toJson(systemMessage); List failedConnections = broadcaster.broadcast(connections, broadcastPayload); cleanupFailedConnections(failedConnections); - + logger.info("Command result broadcasted: type={}, roomId={}", result.messageType(), roomId); return WebSocketEventUtil.ok("Command executed"); } - + /** * GAME_START 메시지 브로드캐스트 - 출제자에게만 제시어 포함, serverTime 추가 */ private void broadcastGameStart(List connections, CommandResult result, - GameService.GameStartResult gameResult, String roomId) { + GameService.GameStartResult gameResult, String roomId) { String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); long serverTime = System.currentTimeMillis(); - + GameSession session = gameResult.session(); String currentDrawerId = session.getCurrentDrawerId(); - + for (Connection conn : connections) { Map message = new HashMap<>(); message.put("domain", WebSocketMessageHelper.DOMAIN_GAME); @@ -422,19 +422,19 @@ private void broadcastGameStart(List connections, CommandResult resu message.put("messageType", result.messageType().getCode()); message.put("createdAt", now); message.put("timestamp", serverTime); - + // 게임 상태 정보 message.put("gameStatus", session.getStatus()); message.put("currentRound", session.getCurrentRound()); message.put("totalRounds", session.getTotalRounds()); message.put("currentDrawerId", currentDrawerId); message.put("drawerOrder", gameResult.drawerOrder()); - + // 타이머 동기화용 필드 (핵심!) message.put("roundStartTime", session.getRoundStartTime()); message.put("serverTime", serverTime); message.put("roundDuration", session.getRoundDuration()); - + // 출제자에게만 제시어 전송 if (conn.getUserId().equals(currentDrawerId) && gameResult.firstWord() != null) { Map wordInfo = new HashMap<>(); @@ -442,7 +442,7 @@ private void broadcastGameStart(List connections, CommandResult resu wordInfo.put("word", gameResult.firstWord().getEnglish()); message.put("currentWord", wordInfo); } - + String payload = gson.toJson(message); try { broadcaster.sendToConnection(conn.getConnectionId(), payload); @@ -451,22 +451,22 @@ private void broadcastGameStart(List connections, CommandResult resu connectionRepository.delete(conn.getConnectionId()); } } - + logger.info("GAME_START broadcasted: roomId={}, serverTime={}", roomId, serverTime); } - + /** * ROUND_END 메시지 브로드캐스트 - 다음 출제자에게만 제시어 포함, serverTime 추가 */ private void broadcastRoundEnd(List connections, CommandResult result, - Map data, String roomId) { + Map data, String roomId) { String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); long serverTime = System.currentTimeMillis(); - + String nextDrawer = (String) data.get("nextDrawer"); Object nextWordObj = data.get("nextWord"); - + for (Connection conn : connections) { Map message = new HashMap<>(); message.put("domain", WebSocketMessageHelper.DOMAIN_GAME); @@ -477,7 +477,7 @@ private void broadcastRoundEnd(List connections, CommandResult resul message.put("messageType", result.messageType().getCode()); message.put("createdAt", now); message.put("timestamp", serverTime); - + // 기본 데이터 복사 (nextWord 제외) Map messageData = new HashMap<>(); messageData.put("answer", data.get("answer")); @@ -486,7 +486,7 @@ private void broadcastRoundEnd(List connections, CommandResult resul messageData.put("ranking", data.get("ranking")); messageData.put("currentRound", data.get("currentRound")); messageData.put("totalRounds", data.get("totalRounds")); - + // 타이머 동기화용 필드 (핵심!) messageData.put("serverTime", serverTime); if (data.get("roundStartTime") != null) { @@ -495,7 +495,7 @@ private void broadcastRoundEnd(List connections, CommandResult resul if (data.get("roundDuration") != null) { messageData.put("roundDuration", data.get("roundDuration")); } - + // 다음 출제자에게만 제시어 전송 if (conn.getUserId().equals(nextDrawer) && nextWordObj != null) { if (nextWordObj instanceof com.mzc.secondproject.serverless.domain.vocabulary.model.Word nextWord) { @@ -505,9 +505,9 @@ private void broadcastRoundEnd(List connections, CommandResult resul messageData.put("nextWord", wordInfo); } } - + message.put("data", messageData); - + String payload = gson.toJson(message); try { broadcaster.sendToConnection(conn.getConnectionId(), payload); @@ -516,7 +516,7 @@ private void broadcastRoundEnd(List connections, CommandResult resul connectionRepository.delete(conn.getConnectionId()); } } - + logger.info("ROUND_END broadcasted: roomId={}, serverTime={}", roomId, serverTime); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java index 44c38d39..4cc1f822 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java @@ -33,16 +33,16 @@ public class ChatRoom { private String lastMessageAt; private List memberIds; // 참여 멤버 목록 private Long ttl; - + // 게임 세션 참조 (게임 상태는 GameSession으로 분리됨) private String activeGameSessionId; // 현재 진행중인 게임 세션 ID (nullable) - + private String type; // CHAT, GAME (기본값: CHAT) private String gameType; // CATCHMIND (nullable, GAME 타입일 때만) private GameSettings gameSettings; // 게임 설정 (nullable) private String status; // WAITING, PLAYING, FINISHED (기본값: WAITING) private String hostId; // 방장 userId (createdBy와 별도 관리) - + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSession.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSession.java index 403d7805..ce21f82b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSession.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSession.java @@ -19,22 +19,22 @@ @AllArgsConstructor @DynamoDbBean public class GameSession { - + private String pk; // GAME#{gameSessionId} private String sk; // METADATA private String gsi1pk; // ROOM#{roomId} private String gsi1sk; // GAME#{createdAt} - + private String gameSessionId; private String roomId; private String gameType; // "catchmind" - + // 게임 상태 private String status; // NONE, WAITING, PLAYING, ROUND_END, FINISHED private String startedBy; private Long startedAt; private Long endedAt; - + // 라운드 정보 private Integer currentRound; private Integer totalRounds; @@ -44,76 +44,76 @@ public class GameSession { private String currentWordEnglish; // 영어 단어 (정답 체크용) private Long roundStartTime; private Integer roundDuration; - + // 점수 및 플레이어 private Map scores; private Map streaks; private List players; private List drawerOrder; - + // 라운드 내 상태 private Boolean hintUsed; private List correctGuessers; - + // 스케줄링 (게임 자동 종료용) private Long gameEndScheduledAt; private String scheduleRuleArn; - + // TTL (게임 종료 후 일정 시간 뒤 삭제) private Long ttl; - + @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; } - + /** * 게임이 활성 상태인지 확인 */ public boolean isActive() { return "PLAYING".equals(status) || "ROUND_END".equals(status); } - + /** * 게임 시작 가능 여부 확인 */ public boolean canStart() { return status == null || "NONE".equals(status) || "FINISHED".equals(status); } - + /** * 출제자 여부 확인 */ public boolean isDrawer(String userId) { return userId != null && userId.equals(currentDrawerId); } - + /** * 이미 정답을 맞춘 사용자인지 확인 */ public boolean hasAlreadyGuessedCorrect(String userId) { return correctGuessers != null && correctGuessers.contains(userId); } - + /** * 정답자 추가 */ @@ -125,7 +125,7 @@ public void addCorrectGuesser(String userId) { correctGuessers.add(userId); } } - + /** * 점수 추가 */ @@ -135,7 +135,7 @@ public void addScore(String userId, int points) { } scores.merge(userId, points, Integer::sum); } - + /** * 연속 정답 수 증가 */ @@ -147,7 +147,7 @@ public int incrementStreak(String userId) { streaks.put(userId, newStreak); return newStreak; } - + /** * 연속 정답 수 리셋 */ @@ -156,7 +156,7 @@ public void resetStreak(String userId) { streaks.put(userId, 0); } } - + /** * 다음 출제자 ID 반환 */ @@ -173,7 +173,7 @@ public String getNextDrawerId() { } return drawerOrder.get(currentIndex + 1); } - + /** * 전원이 정답을 맞췄는지 확인 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java index 22e0f226..d97f2fcc 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java @@ -14,10 +14,10 @@ public class GameSettings { @Builder.Default private Integer maxRounds = 5; - + @Builder.Default private Integer roundTimeLimit = 60; - + @Builder.Default private Boolean autoDeleteOnEnd = false; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java index a2f17dee..dfcdc230 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java @@ -26,14 +26,14 @@ public class ChatMessageRepository { private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); private final DynamoDbTable table; - + /** * 기본 생성자 (Lambda에서 사용) */ public ChatMessageRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java index e351a703..e437e262 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java @@ -7,11 +7,7 @@ import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; -import software.amazon.awssdk.enhanced.dynamodb.Key; -import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.*; import software.amazon.awssdk.enhanced.dynamodb.model.Page; import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; @@ -29,14 +25,14 @@ public class ChatRoomRepository { private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); private final DynamoDbTable table; - + /** * 기본 생성자 (Lambda에서 사용) */ public ChatRoomRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -109,7 +105,7 @@ public PaginatedResult findAllWithPagination(int limit, String cursor) public PaginatedResult findByFilters(String type, String gameType, String status, String level, int limit, String cursor) { // GSI1SK prefix 생성: {type}#{gameType}#{status}#{level}# StringBuilder prefixBuilder = new StringBuilder(); - + if (type != null && !type.isEmpty()) { prefixBuilder.append(type).append("#"); if (gameType != null && !gameType.isEmpty()) { @@ -122,9 +118,9 @@ public PaginatedResult findByFilters(String type, String gameType, Str } } } - + String prefix = prefixBuilder.toString(); - + QueryConditional queryConditional; if (prefix.isEmpty()) { // 필터 없음 - 전체 조회 @@ -138,31 +134,32 @@ public PaginatedResult findByFilters(String type, String gameType, Str .build() ); } - + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) // 최신순 .limit(limit); - + if (cursor != null && !cursor.isEmpty()) { Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } } - + DynamoDbIndex gsi1 = table.index("GSI1"); Page page = gsi1.query(requestBuilder.build()).iterator().next(); List rooms = page.items(); - + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - + logger.info("Query with prefix '{}': found {} rooms", prefix, rooms.size()); return new PaginatedResult<>(rooms, nextCursor); } - + /** * 레벨별 채팅방 조회 - 최신순, 페이지네이션 지원 + * * @deprecated findByFilters 사용 권장 */ @Deprecated @@ -187,7 +184,7 @@ public void delete(String roomId) { public void updateStatus(ChatRoom room, String newStatus) { String oldGsi1sk = room.getGsi1sk(); String[] parts = oldGsi1sk.split("#", 5); // type, gameType, oldStatus, level, createdAt - + if (parts.length < 5) { logger.warn("Invalid GSI1SK format: {}", oldGsi1sk); // 폴백: 새 포맷으로 생성 @@ -200,12 +197,12 @@ public void updateStatus(ChatRoom room, String newStatus) { // 기존 포맷에서 status만 교체 room.setGsi1sk(String.format("%s#%s#%s#%s#%s", parts[0], parts[1], newStatus, parts[3], parts[4])); } - + room.setStatus(newStatus); table.putItem(room); logger.info("Updated room {} status to {} (GSI1SK: {})", room.getRoomId(), newStatus, room.getGsi1sk()); } - + /** * 채팅방 lastMessageAt 업데이트 (N+1 방지 - UpdateExpression 사용) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java index eba332e8..612b87a5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java @@ -5,11 +5,7 @@ import com.mzc.secondproject.serverless.domain.chatting.model.Connection; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; -import software.amazon.awssdk.enhanced.dynamodb.Key; -import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.*; import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; @@ -23,14 +19,14 @@ public class ConnectionRepository { private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); private final DynamoDbTable table; - + /** * 기본 생성자 (Lambda에서 사용) */ public ConnectionRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -77,13 +73,13 @@ public List findByRoomId(String roomId) { .partitionValue("ROOM#" + roomId) .sortValue("CONN#") .build()); - + QueryEnhancedRequest request = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .build(); - + DynamoDbIndex gsi1 = table.index("GSI1"); - + return gsi1.query(request).stream() .flatMap(page -> page.items().stream()) .collect(Collectors.toList()); @@ -98,25 +94,25 @@ public List findByUserId(String userId) { .keyEqualTo(Key.builder() .partitionValue("USER#" + userId) .build()); - + QueryEnhancedRequest request = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .build(); - + DynamoDbIndex gsi2 = table.index("GSI2"); - + return gsi2.query(request).stream() .flatMap(page -> page.items().stream()) .collect(Collectors.toList()); } - + /** * 같은 방에서 사용자의 기존 연결 삭제 (중복 연결 방지) * 새로고침 등으로 인한 중복 연결을 정리 */ public void deleteUserConnectionsInRoom(String userId, String roomId) { List userConnections = findByUserId(userId); - + int deletedCount = 0; for (Connection conn : userConnections) { if (roomId.equals(conn.getRoomId())) { @@ -124,7 +120,7 @@ public void deleteUserConnectionsInRoom(String userId, String roomId) { deletedCount++; } } - + if (deletedCount > 0) { logger.info("Deleted {} existing connections for user {} in room {}", deletedCount, userId, roomId); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java index 047df2d0..adc5b994 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java @@ -35,7 +35,7 @@ public class GameRoundRepository { public GameRoundRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameSessionRepository.java index bf2493b5..70b7c238 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameSessionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameSessionRepository.java @@ -5,11 +5,7 @@ import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; -import software.amazon.awssdk.enhanced.dynamodb.Key; -import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.*; import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; @@ -25,26 +21,26 @@ * 게임 세션 CRUD 및 조회 기능 제공 */ public class GameSessionRepository { - + private static final Logger logger = LoggerFactory.getLogger(GameSessionRepository.class); private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); - + private final DynamoDbTable table; - + /** * 기본 생성자 (Lambda에서 사용) */ public GameSessionRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ public GameSessionRepository(DynamoDbEnhancedClient enhancedClient) { this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(GameSession.class)); } - + /** * 게임 세션 저장 */ @@ -53,7 +49,7 @@ public GameSession save(GameSession session) { table.putItem(session); return session; } - + /** * ID로 게임 세션 조회 */ @@ -62,11 +58,11 @@ public Optional findById(String gameSessionId) { .partitionValue("GAME#" + gameSessionId) .sortValue("METADATA") .build(); - + GameSession session = table.getItem(key); return Optional.ofNullable(session); } - + /** * 게임 세션 삭제 */ @@ -75,22 +71,22 @@ public void delete(String gameSessionId) { .partitionValue("GAME#" + gameSessionId) .sortValue("METADATA") .build(); - + table.deleteItem(key); logger.info("Deleted game session: {}", gameSessionId); } - + /** * roomId로 활성 게임 세션 조회 (PLAYING 또는 ROUND_END 상태) */ public Optional findActiveByRoomId(String roomId) { List sessions = findByRoomId(roomId); - + return sessions.stream() .filter(GameSession::isActive) .findFirst(); } - + /** * roomId로 모든 게임 세션 조회 (최신순) */ @@ -99,28 +95,28 @@ public List findByRoomId(String roomId) { .keyEqualTo(Key.builder() .partitionValue("ROOM#" + roomId) .build()); - + QueryEnhancedRequest request = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) // 최신순 .build(); - + DynamoDbIndex gsi1 = table.index("GSI1"); - + return gsi1.query(request).stream() .flatMap(page -> page.items().stream()) .toList(); } - + /** * 게임 상태 업데이트 */ public void updateStatus(String gameSessionId, String status) { Map key = buildKey(gameSessionId); - + Map expressionValues = new HashMap<>(); expressionValues.put(":status", AttributeValue.builder().s(status).build()); - + UpdateItemRequest updateRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) @@ -128,18 +124,18 @@ public void updateStatus(String gameSessionId, String status) { .expressionAttributeNames(Map.of("#status", "status")) .expressionAttributeValues(expressionValues) .build(); - + AwsClients.dynamoDb().updateItem(updateRequest); logger.info("Updated game session status: {} -> {}", gameSessionId, status); } - + /** * 라운드 정보 업데이트 */ public void updateRoundInfo(String gameSessionId, int currentRound, String drawerId, String wordId, String word, long roundStartTime, int roundDuration) { Map key = buildKey(gameSessionId); - + Map expressionValues = new HashMap<>(); expressionValues.put(":round", AttributeValue.builder().n(String.valueOf(currentRound)).build()); expressionValues.put(":drawer", AttributeValue.builder().s(drawerId).build()); @@ -149,7 +145,7 @@ public void updateRoundInfo(String gameSessionId, int currentRound, String drawe expressionValues.put(":duration", AttributeValue.builder().n(String.valueOf(roundDuration)).build()); expressionValues.put(":hintUsed", AttributeValue.builder().bool(false).build()); expressionValues.put(":emptyList", AttributeValue.builder().l(List.of()).build()); - + String updateExpression = "SET currentRound = :round, " + "currentDrawerId = :drawer, " + "currentWordId = :wordId, " + @@ -158,78 +154,78 @@ public void updateRoundInfo(String gameSessionId, int currentRound, String drawe "roundDuration = :duration, " + "hintUsed = :hintUsed, " + "correctGuessers = :emptyList"; - + UpdateItemRequest updateRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression(updateExpression) .expressionAttributeValues(expressionValues) .build(); - + AwsClients.dynamoDb().updateItem(updateRequest); logger.info("Updated round info: gameSession={}, round={}, drawer={}", gameSessionId, currentRound, drawerId); } - + /** * 점수 업데이트 */ public void updateScores(String gameSessionId, Map scores) { Map key = buildKey(gameSessionId); - + Map scoresMap = new HashMap<>(); scores.forEach((userId, score) -> scoresMap.put(userId, AttributeValue.builder().n(String.valueOf(score)).build())); - + Map expressionValues = new HashMap<>(); expressionValues.put(":scores", AttributeValue.builder().m(scoresMap).build()); - + UpdateItemRequest updateRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression("SET scores = :scores") .expressionAttributeValues(expressionValues) .build(); - + AwsClients.dynamoDb().updateItem(updateRequest); logger.info("Updated scores for game session: {}", gameSessionId); } - + /** * 정답자 추가 */ public void addCorrectGuesser(String gameSessionId, String userId) { Map key = buildKey(gameSessionId); - + Map expressionValues = new HashMap<>(); expressionValues.put(":userId", AttributeValue.builder().l( AttributeValue.builder().s(userId).build() ).build()); expressionValues.put(":emptyList", AttributeValue.builder().l(List.of()).build()); - + UpdateItemRequest updateRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression("SET correctGuessers = list_append(if_not_exists(correctGuessers, :emptyList), :userId)") .expressionAttributeValues(expressionValues) .build(); - + AwsClients.dynamoDb().updateItem(updateRequest); logger.info("Added correct guesser: gameSession={}, userId={}", gameSessionId, userId); } - + /** * 연속 정답(streak) 업데이트 */ public void updateStreak(String gameSessionId, String userId, int streak) { Map key = buildKey(gameSessionId); - + Map expressionValues = new HashMap<>(); expressionValues.put(":streak", AttributeValue.builder().n(String.valueOf(streak)).build()); - + Map expressionNames = new HashMap<>(); expressionNames.put("#streaks", "streaks"); expressionNames.put("#userId", userId); - + UpdateItemRequest updateRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) @@ -237,42 +233,42 @@ public void updateStreak(String gameSessionId, String userId, int streak) { .expressionAttributeNames(expressionNames) .expressionAttributeValues(expressionValues) .build(); - + AwsClients.dynamoDb().updateItem(updateRequest); logger.info("Updated streak: gameSession={}, userId={}, streak={}", gameSessionId, userId, streak); } - + /** * 힌트 사용 처리 */ public void markHintUsed(String gameSessionId) { Map key = buildKey(gameSessionId); - + Map expressionValues = new HashMap<>(); expressionValues.put(":hintUsed", AttributeValue.builder().bool(true).build()); - + UpdateItemRequest updateRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression("SET hintUsed = :hintUsed") .expressionAttributeValues(expressionValues) .build(); - + AwsClients.dynamoDb().updateItem(updateRequest); logger.info("Marked hint used for game session: {}", gameSessionId); } - + /** * 게임 종료 처리 */ public void finishGame(String gameSessionId, long endedAt, long ttl) { Map key = buildKey(gameSessionId); - + Map expressionValues = new HashMap<>(); expressionValues.put(":status", AttributeValue.builder().s("FINISHED").build()); expressionValues.put(":endedAt", AttributeValue.builder().n(String.valueOf(endedAt)).build()); expressionValues.put(":ttl", AttributeValue.builder().n(String.valueOf(ttl)).build()); - + UpdateItemRequest updateRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) @@ -280,11 +276,11 @@ public void finishGame(String gameSessionId, long endedAt, long ttl) { .expressionAttributeNames(Map.of("#status", "status", "#ttl", "ttl")) .expressionAttributeValues(expressionValues) .build(); - + AwsClients.dynamoDb().updateItem(updateRequest); logger.info("Finished game session: {}", gameSessionId); } - + /** * DynamoDB 키 빌더 헬퍼 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java index f4b39b96..7ddadc5f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java @@ -18,14 +18,14 @@ public class RoomTokenRepository { private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); private final DynamoDbTable table; - + /** * 기본 생성자 (Lambda에서 사용) */ public RoomTokenRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatMessageService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatMessageService.java index e0b44317..f601ceed 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatMessageService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatMessageService.java @@ -13,14 +13,14 @@ public class ChatMessageService { private static final Logger logger = LoggerFactory.getLogger(ChatMessageService.class); private final ChatMessageRepository repository; - + /** * 기본 생성자 (Lambda에서 사용) */ public ChatMessageService() { this(new ChatMessageRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java index 90a4d594..6308a76c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java @@ -18,11 +18,7 @@ import org.slf4j.LoggerFactory; import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; +import java.util.*; /** * ChatRoom 변경 전용 서비스 (CQRS Command) @@ -30,13 +26,13 @@ public class ChatRoomCommandService { private static final Logger logger = LoggerFactory.getLogger(ChatRoomCommandService.class); - + private final ChatRoomRepository roomRepository; private final RoomTokenService roomTokenService; private final ConnectionRepository connectionRepository; private final WebSocketBroadcaster broadcaster; private final UserRepository userRepository; - + /** * 기본 생성자 (Lambda에서 사용) */ @@ -44,7 +40,7 @@ public ChatRoomCommandService() { this(new ChatRoomRepository(), new RoomTokenService(), new ConnectionRepository(), new WebSocketBroadcaster(), new UserRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -65,13 +61,13 @@ public ChatRoom createRoom(String name, String description, String level, Intege String type, String gameType, GameSettings gameSettings) { String roomId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + // GSI1SK 포맷: {type}#{gameType}#{status}#{level}#{createdAt} String roomType = type != null ? type : "CHAT"; String roomGameType = gameType != null ? gameType : "-"; String roomStatus = "WAITING"; String gsi1sk = String.format("%s#%s#%s#%s#%s", roomType, roomGameType, roomStatus, level, now); - + ChatRoom room = ChatRoom.builder() .pk("ROOM#" + roomId) .sk("METADATA") @@ -95,10 +91,10 @@ public ChatRoom createRoom(String name, String description, String level, Intege .status("WAITING") .hostId(createdBy) .build(); - + roomRepository.save(room); logger.info("Created room: {}", roomId); - + return room; } @@ -144,26 +140,26 @@ public LeaveResult leaveRoom(String roomId, String userId) { if (optRoom.isEmpty()) { throw ChattingException.roomNotFound(roomId); } - + ChatRoom room = optRoom.get(); - + if (room.getMemberIds() != null) { room.getMemberIds().remove(userId); room.setCurrentMembers(Math.max(0, room.getCurrentMembers() - 1)); } - + // 모든 참가자가 나갔으면 방 삭제 if (room.getCurrentMembers() <= 0 || - (room.getMemberIds() != null && room.getMemberIds().isEmpty())) { + (room.getMemberIds() != null && room.getMemberIds().isEmpty())) { roomRepository.delete(roomId); logger.info("Room {} deleted (empty)", roomId); return new LeaveResult(true, null, null); } - + // 방장이 나갔으면 다음 멤버에게 방장 이전 String oldHostId = room.getHostId() != null ? room.getHostId() : room.getCreatedBy(); String newHostId = null; - + if (userId.equals(oldHostId)) { // 첫 번째 남은 멤버가 새 방장 if (room.getMemberIds() != null && !room.getMemberIds().isEmpty()) { @@ -172,17 +168,17 @@ public LeaveResult leaveRoom(String roomId, String userId) { logger.info("Host transferred from {} to {} in room {}", oldHostId, newHostId, roomId); } } - + roomRepository.save(room); logger.info("User {} left room {}", userId, roomId); - + // 방장이 나갔으면 다음 멤버에게 방장 이전 후 WebSocket 알림 if (userId.equals(oldHostId) && newHostId != null) { // 새 방장 닉네임 조회 String newHostNickname = userRepository.findByCognitoSub(newHostId) .map(User::getNickname) .orElse(newHostId); - + // WebSocket 알림 브로드캐스트 try { List connections = connectionRepository.findByRoomId(roomId); @@ -195,7 +191,7 @@ public LeaveResult leaveRoom(String roomId, String userId) { logger.error("Failed to broadcast host change: {}", e.getMessage()); } } - + return new LeaveResult(false, room, newHostId); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java index 68af7c1f..247d8476 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java @@ -18,17 +18,17 @@ public class ChatRoomQueryService { private static final Logger logger = LoggerFactory.getLogger(ChatRoomQueryService.class); - + private final ChatRoomRepository roomRepository; private final UserRepository userRepository; - + /** * 기본 생성자 (Lambda에서 사용) */ public ChatRoomQueryService() { this(new ChatRoomRepository(), new UserRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -63,7 +63,7 @@ public List filterByJoinedUser(List rooms, String userId) { .filter(room -> room.getMemberIds() != null && room.getMemberIds().contains(userId)) .toList(); } - + /** * 참가자 목록을 닉네임과 함께 조회 * @@ -72,9 +72,9 @@ public List filterByJoinedUser(List rooms, String userId) { */ public List getParticipantsWithNicknames(ChatRoom room) { if (room.getMemberIds() == null) return List.of(); - + String hostId = room.getHostId() != null ? room.getHostId() : room.getCreatedBy(); - + return room.getMemberIds().stream() .map(userId -> { String nickname = userRepository.findByCognitoSub(userId) @@ -88,7 +88,7 @@ public List getParticipantsWithNicknames(ChatRoom room) { }) .toList(); } - + /** * 방장 닉네임 조회 * diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java index d4cf0148..71e0ddf2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java @@ -18,18 +18,18 @@ public class CommandService { private static final Logger logger = LoggerFactory.getLogger(CommandService.class); - + private final ConnectionRepository connectionRepository; private final GameSessionRepository gameSessionRepository; private final GameService gameService; - + /** * 기본 생성자 (Lambda에서 사용) */ public CommandService() { this(new ConnectionRepository(), new GameSessionRepository(), new GameService()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -90,21 +90,21 @@ private CommandResult handleMemberCommand(String roomId) { */ private CommandResult handleStartCommand(String roomId, String userId) { GameService.GameStartResult result = gameService.startGame(roomId, userId); - + if (!result.success()) { return CommandResult.error(result.error()); } - + String message = String.format(""" 🎮 게임 시작! 총 %d 라운드 - + 라운드 1 시작! 출제자: %s """, result.session().getTotalRounds(), result.session().getCurrentDrawerId()); - + return CommandResult.success(MessageType.GAME_START, message, result); } @@ -123,18 +123,18 @@ private CommandResult handleScoreCommand(String roomId) { if (optSession.isEmpty()) { return CommandResult.error("진행 중인 게임이 없습니다."); } - + GameSession session = optSession.get(); - + if (session.getScores() == null || session.getScores().isEmpty()) { return CommandResult.success(MessageType.SCORE_UPDATE, "아직 점수가 없습니다."); } - + StringBuilder sb = new StringBuilder("📊 현재 점수:\n"); session.getScores().entrySet().stream() .sorted((a, b) -> b.getValue().compareTo(a.getValue())) .forEach(entry -> sb.append(String.format(" %s: %d점\n", entry.getKey(), entry.getValue()))); - + return CommandResult.success(MessageType.SCORE_UPDATE, sb.toString(), session.getScores()); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameSchedulerClient.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameSchedulerClient.java index ab2d9f53..9a8e96c9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameSchedulerClient.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameSchedulerClient.java @@ -15,16 +15,16 @@ * EventBridge Scheduler를 사용한 게임 자동 종료 스케줄링 */ public class GameSchedulerClient { - + private static final Logger logger = LoggerFactory.getLogger(GameSchedulerClient.class); - + private static final String SCHEDULE_GROUP = "game-auto-close"; private static final String SCHEDULE_NAME_PREFIX = "game-close-"; - + private final SchedulerClient schedulerClient; private final String targetLambdaArn; private final String roleArn; - + /** * 기본 생성자 (Lambda에서 사용) */ @@ -33,7 +33,7 @@ public GameSchedulerClient() { EnvConfig.getOrDefault("GAME_AUTO_CLOSE_LAMBDA_ARN", null), EnvConfig.getOrDefault("SCHEDULER_ROLE_ARN", null)); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -42,7 +42,7 @@ public GameSchedulerClient(SchedulerClient schedulerClient, String targetLambdaA this.targetLambdaArn = targetLambdaArn; this.roleArn = roleArn; } - + /** * 게임 자동 종료 스케줄 생성 * @@ -55,22 +55,22 @@ public ScheduleResult createGameEndSchedule(String gameSessionId, String roomId) logger.warn("Scheduler not configured: GAME_AUTO_CLOSE_LAMBDA_ARN or SCHEDULER_ROLE_ARN not set"); return new ScheduleResult(null, 0L); } - + try { // 7분 후 시간 계산 long scheduledAtMs = System.currentTimeMillis() + (GameConfig.gameTimeLimit() * 1000L); Instant scheduledAt = Instant.ofEpochMilli(scheduledAtMs); - + // at() 표현식: at(yyyy-mm-ddThh:mm:ss) String atExpression = "at(" + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss") .withZone(ZoneOffset.UTC) .format(scheduledAt) + ")"; - + String scheduleName = SCHEDULE_NAME_PREFIX + gameSessionId; - + // Lambda 호출 시 전달할 페이로드 String payload = String.format("{\"gameSessionId\":\"%s\",\"roomId\":\"%s\"}", gameSessionId, roomId); - + CreateScheduleRequest request = CreateScheduleRequest.builder() .name(scheduleName) .groupName(SCHEDULE_GROUP) @@ -86,25 +86,25 @@ public ScheduleResult createGameEndSchedule(String gameSessionId, String roomId) .build()) .actionAfterCompletion(ActionAfterCompletion.DELETE) // 실행 후 자동 삭제 .build(); - + CreateScheduleResponse response = schedulerClient.createSchedule(request); - + logger.info("Game end schedule created: gameSessionId={}, scheduledAt={}, arn={}", gameSessionId, scheduledAt, response.scheduleArn()); - + return new ScheduleResult(response.scheduleArn(), scheduledAtMs); - + } catch (ConflictException e) { logger.warn("Schedule already exists: gameSessionId={}", gameSessionId); return new ScheduleResult(null, 0L); - + } catch (Exception e) { logger.error("Failed to create game end schedule: gameSessionId={}, error={}", gameSessionId, e.getMessage()); return new ScheduleResult(null, 0L); } } - + /** * 게임 자동 종료 스케줄 취소 * @@ -115,31 +115,31 @@ public boolean cancelGameEndSchedule(String gameSessionId) { if (targetLambdaArn == null) { return true; // 스케줄러 미설정 시 무시 } - + try { String scheduleName = SCHEDULE_NAME_PREFIX + gameSessionId; - + DeleteScheduleRequest request = DeleteScheduleRequest.builder() .name(scheduleName) .groupName(SCHEDULE_GROUP) .build(); - + schedulerClient.deleteSchedule(request); - + logger.info("Game end schedule cancelled: gameSessionId={}", gameSessionId); return true; - + } catch (ResourceNotFoundException e) { logger.debug("Schedule not found (may have already executed): gameSessionId={}", gameSessionId); return true; // 이미 삭제되었거나 없는 경우 - + } catch (Exception e) { logger.error("Failed to cancel game end schedule: gameSessionId={}, error={}", gameSessionId, e.getMessage()); return false; } } - + /** * 스케줄 생성 결과 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java index 3ca83cff..a82c16d2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java @@ -27,9 +27,9 @@ * GameSession 모델을 사용하여 게임 상태 관리 */ public class GameService { - + private static final Logger logger = LoggerFactory.getLogger(GameService.class); - + private final ChatRoomRepository chatRoomRepository; private final ConnectionRepository connectionRepository; private final GameRoundRepository gameRoundRepository; @@ -37,7 +37,7 @@ public class GameService { private final WordRepository wordRepository; private final GameStatsService gameStatsService; private final GameSchedulerClient gameSchedulerClient; - + /** * 기본 생성자 (Lambda에서 사용) */ @@ -46,7 +46,7 @@ public GameService() { new GameRoundRepository(), new GameSessionRepository(), new WordRepository(), new GameStatsService(), new GameSchedulerClient()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -62,87 +62,87 @@ public GameService(ChatRoomRepository chatRoomRepository, ConnectionRepository c this.gameStatsService = gameStatsService; this.gameSchedulerClient = gameSchedulerClient; } - + /** * 게임 재시작 */ public GameStartResult restartGame(String roomId, String userId) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); - + // 방장 권한 확인 if (!userId.equals(room.getHostId()) && !userId.equals(room.getCreatedBy())) { return GameStartResult.error("방장만 게임을 시작할 수 있습니다."); } - + // 방 타입 검증 if (room.getType() == null || !"GAME".equalsIgnoreCase(room.getType())) { return GameStartResult.error("게임은 게임 방에서만 시작할 수 있습니다."); } - + // FINISHED 상태인지 확인 (이미 게임이 끝났어야 재시작 가능) Optional existingSession = gameSessionRepository.findActiveByRoomId(roomId); if (existingSession.isPresent()) { return GameStartResult.error("게임 진행 중에는 재시작할 수 없습니다."); } - + // 접속자 확인 List connections = connectionRepository.findByRoomId(roomId); if (connections.size() < 2) { return GameStartResult.error("최소 2명 이상 접속해야 게임을 시작할 수 있습니다."); } - + // 기존 startGame 로직 재사용 - 내부적으로 startGame 호출 return startGame(roomId, userId); } - + /** * 게임 시작 */ public GameStartResult startGame(String roomId, String userId) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); - + // 방 타입 검증 - GAME 타입만 게임 시작 가능 String roomType = room.getType(); if (roomType == null || !"GAME".equalsIgnoreCase(roomType)) { return GameStartResult.error("게임은 게임 방에서만 시작할 수 있습니다."); } - + // 이미 활성 게임 세션이 있는지 확인 Optional existingSession = gameSessionRepository.findActiveByRoomId(roomId); if (existingSession.isPresent()) { return GameStartResult.error("이미 게임이 진행 중입니다."); } - + // 접속자 확인 List connections = connectionRepository.findByRoomId(roomId); if (connections.size() < 2) { return GameStartResult.error("최소 2명 이상 접속해야 게임을 시작할 수 있습니다."); } - + // 출제 순서 생성 (랜덤 셔플) List drawerOrder = connections.stream() .map(Connection::getUserId) .collect(Collectors.toList()); Collections.shuffle(drawerOrder); - + // 제시어 추출 (난이도별) String level = room.getLevel() != null ? room.getLevel() : "beginner"; List words = getRandomWords(level, GameConfig.totalRounds()); - + if (words.size() < GameConfig.totalRounds()) { return GameStartResult.error("단어가 부족합니다. 관리자에게 문의하세요."); } - + // 게임 세션 생성 String gameSessionId = UUID.randomUUID().toString(); String now = Instant.now().toString(); long currentTime = System.currentTimeMillis(); - + String firstDrawer = drawerOrder.get(0); Word firstWord = words.get(0); - + GameSession session = GameSession.builder() .pk("GAME#" + gameSessionId) .sk("METADATA") @@ -169,9 +169,9 @@ public GameStartResult startGame(String roomId, String userId) { .hintUsed(false) .correctGuessers(new ArrayList<>()) .build(); - + gameSessionRepository.save(session); - + // 게임 자동 종료 스케줄 생성 (7분 후) GameSchedulerClient.ScheduleResult scheduleResult = gameSchedulerClient.createGameEndSchedule(gameSessionId, roomId); if (scheduleResult.success()) { @@ -179,11 +179,11 @@ public GameStartResult startGame(String roomId, String userId) { session.setGameEndScheduledAt(scheduleResult.scheduledAtMs()); gameSessionRepository.save(session); } - + // ChatRoom에 활성 게임 세션 ID 연결 및 상태 업데이트 (GSI1SK 포함) room.setActiveGameSessionId(gameSessionId); chatRoomRepository.updateStatus(room, "PLAYING"); - + // 첫 라운드 기록 생성 (7일 후 자동 삭제) long ttlSeconds = Instant.now().plusSeconds(7 * 24 * 60 * 60).getEpochSecond(); GameRound firstRound = GameRound.builder() @@ -203,162 +203,162 @@ public GameStartResult startGame(String roomId, String userId) { .createdAt(now) .ttl(ttlSeconds) .build(); - + gameRoundRepository.save(firstRound); - + logger.info("Game started: roomId={}, sessionId={}, starter={}, rounds={}", roomId, gameSessionId, userId, GameConfig.totalRounds()); - + return GameStartResult.success(session, firstWord, drawerOrder); } - + /** * 게임 종료 */ public CommandResult stopGame(String roomId, String userId) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); - + GameSession session = gameSessionRepository.findActiveByRoomId(roomId) .orElse(null); - + if (session == null || !session.isActive()) { return CommandResult.error("진행 중인 게임이 없습니다."); } - + // 권한 확인 boolean isOwner = userId.equals(room.getCreatedBy()); boolean isGameStarter = userId.equals(session.getStartedBy()); - + if (!isOwner && !isGameStarter) { return CommandResult.error("게임을 중단할 권한이 없습니다."); } - + // 게임 종료 처리 return finishGame(session, room, "STOPPED"); } - + /** * 정답 체크 */ public AnswerCheckResult checkAnswer(String roomId, String userId, String answer) { GameSession session = gameSessionRepository.findActiveByRoomId(roomId) .orElse(null); - + // 게임 진행 중인지 확인 if (session == null || !GameStatus.PLAYING.name().equals(session.getStatus())) { return AnswerCheckResult.gameNotPlaying(); } - + // 출제자는 정답 체크 제외 if (session.isDrawer(userId)) { return AnswerCheckResult.drawerCannotGuess(); } - + // 이미 맞춘 사람인지 확인 if (session.hasAlreadyGuessedCorrect(userId)) { return AnswerCheckResult.alreadyGuessedCorrect(); } - + // 정답 체크 (한국어 또는 영어 둘 다 허용) String koreanWord = session.getCurrentWord(); String englishWord = session.getCurrentWordEnglish(); if (!isCorrectAnswer(answer, koreanWord, englishWord)) { return AnswerCheckResult.wrongAnswer(); } - + // 정답 처리 long elapsedTime = System.currentTimeMillis() - session.getRoundStartTime(); - + // 연속 정답 업데이트 (점수 계산 전에) int currentStreak = session.incrementStreak(userId); - + int score = calculateScore(session, elapsedTime, userId, currentStreak); - + // 정답자 목록에 추가 session.addCorrectGuesser(userId); - + // 점수 업데이트 session.addScore(userId, score); - + // 출제자 점수도 추가 session.addScore(session.getCurrentDrawerId(), 5); - + gameSessionRepository.save(session); - + // 라운드 기록 업데이트 updateRoundRecord(roomId, session.getCurrentRound(), userId, elapsedTime, score); - + // 전원 정답 체크 List connections = connectionRepository.findByRoomId(roomId); int nonDrawerCount = (int) connections.stream() .filter(c -> !c.getUserId().equals(session.getCurrentDrawerId())) .count(); - + boolean allCorrect = session.getCorrectGuessers().size() >= nonDrawerCount; - + logger.info("Answer correct: roomId={}, userId={}, score={}, allCorrect={}", roomId, userId, score, allCorrect); - + return AnswerCheckResult.correctAnswer(score, elapsedTime, allCorrect, session.getScores()); } - + /** * 라운드 스킵 */ public CommandResult skipRound(String roomId, String userId) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); - + GameSession session = gameSessionRepository.findActiveByRoomId(roomId) .orElse(null); - + if (session == null || !GameStatus.PLAYING.name().equals(session.getStatus())) { return CommandResult.error("게임이 진행 중이 아닙니다."); } - + if (!session.isDrawer(userId)) { return CommandResult.error("출제자만 라운드를 스킵할 수 있습니다."); } - + return endRound(session, room, "SKIP"); } - + /** * 힌트 제공 */ public CommandResult provideHint(String roomId, String userId) { GameSession session = gameSessionRepository.findActiveByRoomId(roomId) .orElse(null); - + if (session == null || !GameStatus.PLAYING.name().equals(session.getStatus())) { return CommandResult.error("게임이 진행 중이 아닙니다."); } - + if (!session.isDrawer(userId)) { return CommandResult.error("출제자만 힌트를 제공할 수 있습니다."); } - + if (Boolean.TRUE.equals(session.getHintUsed())) { return CommandResult.error("이번 라운드에서 이미 힌트를 사용했습니다."); } - + String currentWord = session.getCurrentWord(); String hint = currentWord.charAt(0) + "○".repeat(currentWord.length() - 1); - + session.setHintUsed(true); gameSessionRepository.save(session); - + // 라운드 기록 업데이트 gameRoundRepository.findByRoomIdAndRound(roomId, session.getCurrentRound()) .ifPresent(round -> { round.setHintUsed(true); gameRoundRepository.save(round); }); - + return CommandResult.success(MessageType.HINT, "💡 힌트: " + hint); } - + /** * 라운드 종료 처리 (GameSession 버전) */ @@ -366,10 +366,10 @@ public CommandResult endRound(GameSession session, ChatRoom room, String reason) String roomId = session.getRoomId(); Integer currentRound = session.getCurrentRound(); String answer = session.getCurrentWord(); - + // 정답 못 맞춘 사용자 연속 정답 초기화 resetStreaksForNonGuessers(session); - + // 라운드 기록 종료 gameRoundRepository.findByRoomIdAndRound(roomId, currentRound) .ifPresent(round -> { @@ -377,27 +377,27 @@ public CommandResult endRound(GameSession session, ChatRoom room, String reason) round.setEndReason(reason); gameRoundRepository.save(round); }); - + // 다음 라운드로 진행 if (currentRound >= session.getTotalRounds()) { return finishGame(session, room, "COMPLETED"); } - + // 현재 접속 중인 사용자 목록 조회 List connections = connectionRepository.findByRoomId(roomId); Set connectedUserIds = connections.stream() .map(Connection::getUserId) .collect(Collectors.toSet()); - + // 접속자가 2명 미만이면 게임 종료 if (connectedUserIds.size() < 2) { return finishGame(session, room, "NOT_ENOUGH_PLAYERS"); } - + // 다음 라운드 준비 - 접속 중인 사용자 중에서만 출제자 선택 int nextRound = currentRound + 1; String nextDrawer = selectNextDrawer(session.getDrawerOrder(), connectedUserIds, nextRound); - + // 다음 단어 추출 String level = room.getLevel() != null ? room.getLevel() : "beginner"; List words = getRandomWords(level, 1); @@ -405,9 +405,9 @@ public CommandResult endRound(GameSession session, ChatRoom room, String reason) return finishGame(session, room, "NO_WORDS"); } Word nextWord = words.get(0); - + long currentTime = System.currentTimeMillis(); - + // 세션 상태 업데이트 session.setCurrentRound(nextRound); session.setCurrentDrawerId(nextDrawer); @@ -417,9 +417,9 @@ public CommandResult endRound(GameSession session, ChatRoom room, String reason) session.setRoundStartTime(currentTime); session.setHintUsed(false); session.setCorrectGuessers(new ArrayList<>()); - + gameSessionRepository.save(session); - + // 다음 라운드 기록 생성 (7일 후 자동 삭제) long nextTtlSeconds = Instant.now().plusSeconds(7 * 24 * 60 * 60).getEpochSecond(); GameRound nextRoundRecord = GameRound.builder() @@ -439,17 +439,17 @@ public CommandResult endRound(GameSession session, ChatRoom room, String reason) .createdAt(Instant.now().toString()) .ttl(nextTtlSeconds) .build(); - + gameRoundRepository.save(nextRoundRecord); - + String message = String.format("라운드 %d 종료! 정답: %s\n\n라운드 %d 시작! 출제자: %s", currentRound, answer, nextRound, nextDrawer); - + logger.info("Round ended: roomId={}, round={}, reason={}", roomId, currentRound, reason); - + // ranking 생성 List> ranking = buildRankingList(session.getScores()); - + Map data = new HashMap<>(); data.put("answer", answer); data.put("nextRound", nextRound); @@ -461,46 +461,46 @@ public CommandResult endRound(GameSession session, ChatRoom room, String reason) // 타이머 동기화용 필드 추가 data.put("roundStartTime", session.getRoundStartTime()); data.put("roundDuration", session.getRoundDuration() != null ? session.getRoundDuration() : GameConfig.roundTimeLimit()); - + return CommandResult.success(MessageType.ROUND_END, message, data); } - + /** * roomId로 활성 세션을 찾아 라운드 종료 (외부 호출용) */ public CommandResult endRound(String roomId, String reason) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); - + GameSession session = gameSessionRepository.findActiveByRoomId(roomId) .orElse(null); - + if (session == null) { return CommandResult.error("진행 중인 게임이 없습니다."); } - + return endRound(session, room, reason); } - + /** * 게임 완전 종료 */ private CommandResult finishGame(GameSession session, ChatRoom room, String reason) { long currentTime = System.currentTimeMillis(); long ttlSeconds = Instant.now().plusSeconds(30 * 24 * 60 * 60).getEpochSecond(); // 30일 보관 - + // 자동 종료 스케줄 취소 (TIME_EXPIRED가 아닌 경우에만) if (!"TIME_EXPIRED".equals(reason)) { gameSchedulerClient.cancelGameEndSchedule(session.getGameSessionId()); } - + // 게임 세션 종료 처리 gameSessionRepository.finishGame(session.getGameSessionId(), currentTime, ttlSeconds); - + // ChatRoom에서 활성 게임 세션 참조 제거 및 상태 업데이트 (GSI1SK 포함) room.setActiveGameSessionId(null); chatRoomRepository.updateStatus(room, "WAITING"); - + // 게임 통계 업데이트 및 뱃지 체크 try { var newBadges = gameStatsService.updateGameStats(session); @@ -508,14 +508,14 @@ private CommandResult finishGame(GameSession session, ChatRoom room, String reas } catch (Exception e) { logger.error("Failed to update game stats: roomId={}, error={}", room.getRoomId(), e.getMessage()); } - + // 최종 점수 정렬 StringBuilder sb = new StringBuilder("🎮 게임 종료!\n\n📊 최종 순위:\n"); if (session.getScores() != null && !session.getScores().isEmpty()) { List> sorted = session.getScores().entrySet().stream() .sorted((a, b) -> b.getValue().compareTo(a.getValue())) .toList(); - + int rank = 1; for (Map.Entry entry : sorted) { String medal = switch (rank) { @@ -530,13 +530,13 @@ private CommandResult finishGame(GameSession session, ChatRoom room, String reas } else { sb.append(" 점수 없음"); } - + logger.info("Game finished: roomId={}, sessionId={}, reason={}", room.getRoomId(), session.getGameSessionId(), reason); - + return CommandResult.success(MessageType.GAME_END, sb.toString(), session.getScores()); } - + /** * 시간 만료로 인한 게임 자동 종료 (GameAutoCloseHandler에서 호출) */ @@ -546,32 +546,32 @@ public CommandResult finishGameByTimeout(String gameSessionId) { logger.warn("Game session not found for auto-close: {}", gameSessionId); return CommandResult.error("게임 세션을 찾을 수 없습니다."); } - + // 이미 종료된 게임이면 무시 if (!session.isActive()) { logger.info("Game already finished, skipping auto-close: {}", gameSessionId); return CommandResult.error("이미 종료된 게임입니다."); } - + ChatRoom room = chatRoomRepository.findById(session.getRoomId()).orElse(null); if (room == null) { logger.warn("Room not found for auto-close: {}", session.getRoomId()); return CommandResult.error("채팅방을 찾을 수 없습니다."); } - + logger.info("Auto-closing game due to time expiration: sessionId={}, roomId={}", gameSessionId, session.getRoomId()); - + return finishGame(session, room, "TIME_EXPIRED"); } - + /** * 접속 중인 사용자 중에서 다음 출제자 선택 */ private String selectNextDrawer(List drawerOrder, Set connectedUserIds, int roundNumber) { // 원래 순서에서 시작 인덱스 계산 int startIndex = (roundNumber - 1) % drawerOrder.size(); - + // 접속 중인 사용자를 찾을 때까지 순회 for (int i = 0; i < drawerOrder.size(); i++) { int index = (startIndex + i) % drawerOrder.size(); @@ -580,11 +580,11 @@ private String selectNextDrawer(List drawerOrder, Set connectedU return candidate; } } - + // 원래 순서에 있는 사람이 모두 나갔으면, 접속 중인 아무나 선택 return connectedUserIds.iterator().next(); } - + /** * 랜덤 단어 추출 * VocabTable은 LEVEL#BEGINNER 형식(대문자)으로 저장되어 있으므로 @@ -598,15 +598,15 @@ private List getRandomWords(String level, int count) { Collections.shuffle(words); return words.stream().limit(count).collect(Collectors.toList()); } - + /** * 정답 체크 로직 (한국어 또는 영어 둘 다 허용) */ private boolean isCorrectAnswer(String input, String koreanAnswer, String englishAnswer) { if (input == null) return false; - + String normalizedInput = input.trim().toLowerCase().replace(" ", ""); - + // 한국어 정답 체크 if (koreanAnswer != null) { String normalizedKorean = koreanAnswer.trim().toLowerCase().replace(" ", ""); @@ -614,7 +614,7 @@ private boolean isCorrectAnswer(String input, String koreanAnswer, String englis return true; } } - + // 영어 정답 체크 if (englishAnswer != null) { String normalizedEnglish = englishAnswer.trim().toLowerCase().replace(" ", ""); @@ -622,30 +622,30 @@ private boolean isCorrectAnswer(String input, String koreanAnswer, String englis return true; } } - + return false; } - + /** * 점수 계산 */ private int calculateScore(GameSession session, long elapsedTimeMs, String userId, int streak) { int baseScore = 10; - + // 시간 보너스 (빨리 맞출수록 높은 점수) int elapsedSeconds = (int) (elapsedTimeMs / 1000); int timeLimit = session.getRoundDuration() != null ? session.getRoundDuration() : GameConfig.roundTimeLimit(); int timeBonus = Math.max(0, (int) ((timeLimit - elapsedSeconds) * 0.5)); - + // 연속 정답 보너스 int streakBonus = streak * 2; - + logger.info("Score calculation: base={}, timeBonus={}, streakBonus={}, total={}", baseScore, timeBonus, streakBonus, baseScore + timeBonus + streakBonus); - + return baseScore + timeBonus + streakBonus; } - + /** * 라운드 기록 업데이트 */ @@ -656,21 +656,21 @@ private void updateRoundRecord(String roomId, Integer roundNumber, String userId round.setCorrectGuessers(new ArrayList<>()); } round.getCorrectGuessers().add(userId); - + if (round.getGuessTimes() == null) { round.setGuessTimes(new HashMap<>()); } round.getGuessTimes().put(userId, elapsedTime); - + if (round.getRoundScores() == null) { round.setRoundScores(new HashMap<>()); } round.getRoundScores().put(userId, score); - + gameRoundRepository.save(round); }); } - + /** * 정답 못 맞춘 사용자 연속 정답 초기화 */ @@ -678,19 +678,19 @@ private void resetStreaksForNonGuessers(GameSession session) { if (session.getStreaks() == null || session.getStreaks().isEmpty()) { return; } - + List correctGuessers = session.getCorrectGuessers() != null ? session.getCorrectGuessers() : List.of(); - + // 정답 못 맞춘 사용자의 연속 정답 초기화 session.getStreaks().keySet().stream() .filter(userId -> !correctGuessers.contains(userId)) .forEach(userId -> session.getStreaks().put(userId, 0)); - + logger.info("Reset streaks for non-guessers: correctGuessers={}", correctGuessers); } - + /** * 점수 맵을 순위 리스트로 변환 */ @@ -698,11 +698,11 @@ private List> buildRankingList(Map scores) if (scores == null || scores.isEmpty()) { return List.of(); } - + List> sorted = scores.entrySet().stream() .sorted(Map.Entry.comparingByValue().reversed()) .toList(); - + List> ranking = new ArrayList<>(); for (int i = 0; i < sorted.size(); i++) { Map entry = new HashMap<>(); @@ -713,9 +713,9 @@ private List> buildRankingList(Map scores) } return ranking; } - + // ========== Result DTOs ========== - + public record GameStartResult( boolean success, String error, @@ -726,12 +726,12 @@ public record GameStartResult( public static GameStartResult success(GameSession session, Word word, List order) { return new GameStartResult(true, null, session, word, order); } - + public static GameStartResult error(String message) { return new GameStartResult(false, message, null, null, null); } } - + public record AnswerCheckResult( boolean correct, boolean drawer, @@ -745,19 +745,19 @@ public record AnswerCheckResult( public static AnswerCheckResult correctAnswer(int score, long elapsed, boolean allCorrect, Map scores) { return new AnswerCheckResult(true, false, false, false, allCorrect, score, elapsed, scores); } - + public static AnswerCheckResult wrongAnswer() { return new AnswerCheckResult(false, false, false, false, false, 0, 0, null); } - + public static AnswerCheckResult drawerCannotGuess() { return new AnswerCheckResult(false, true, false, false, false, 0, 0, null); } - + public static AnswerCheckResult alreadyGuessedCorrect() { return new AnswerCheckResult(false, false, true, false, false, 0, 0, null); } - + public static AnswerCheckResult gameNotPlaying() { return new AnswerCheckResult(false, false, false, true, false, 0, 0, null); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java index 4ff2e235..be700975 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java @@ -3,8 +3,8 @@ import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; import com.mzc.secondproject.serverless.domain.chatting.config.GameConfig; -import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import com.mzc.secondproject.serverless.domain.chatting.model.GameRound; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import com.mzc.secondproject.serverless.domain.chatting.repository.GameRoundRepository; import com.mzc.secondproject.serverless.domain.stats.model.UserStats; import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; @@ -23,14 +23,14 @@ public class GameStatsService { private final UserStatsRepository userStatsRepository; private final GameRoundRepository gameRoundRepository; private final BadgeService badgeService; - + /** * 기본 생성자 (Lambda에서 사용) */ public GameStatsService() { this(new UserStatsRepository(), new GameRoundRepository(), new BadgeService()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -48,10 +48,10 @@ public GameStatsService(UserStatsRepository userStatsRepository, public Map> updateGameStats(GameSession session) { Map> newBadges = new HashMap<>(); String roomId = session.getRoomId(); - + // 모든 라운드 조회 List rounds = gameRoundRepository.findByRoomId(roomId); - + // 참가자별 통계 수집 Map scores = session.getScores() != null ? session.getScores() : Map.of(); Set participants = new HashSet<>(scores.keySet()); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/RoomTokenService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/RoomTokenService.java index 4ee6231a..38dd1b41 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/RoomTokenService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/RoomTokenService.java @@ -19,14 +19,14 @@ public class RoomTokenService { private static final Logger logger = LoggerFactory.getLogger(RoomTokenService.class); private final RoomTokenRepository tokenRepository; - + /** * 기본 생성자 (Lambda에서 사용) */ public RoomTokenService() { this(new RoomTokenRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java index 9ae05c3b..284fe767 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java @@ -36,12 +36,12 @@ public class GrammarHandler implements RequestHandler * 연결 방법: * wss://{api-id}.execute-api.{region}.amazonaws.com/{stage}?token={jwt} */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java index c1d6f89e..8a0ba959 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java @@ -28,7 +28,7 @@ /** * Grammar Streaming WebSocket 핸들러 * Bedrock 스트리밍 응답을 실시간으로 클라이언트에 전송 - * + *

* 인증: $connect에서 JWT 검증 후 저장된 연결 정보에서 userId 조회 */ public class GrammarStreamingHandler implements RequestHandler, Map> { @@ -85,7 +85,7 @@ public Map handleRequest(Map event, Context cont private void processStreamingConversation(String connectionId, String endpoint, String userId, StreamingRequest request) { ApiGatewayManagementApiClient apiClient = createApiClient(endpoint); - + try { // 서비스에 스트리밍 처리 위임 (userId는 JWT 인증에서 가져온 값 사용) conversationService.chatStreaming( @@ -101,14 +101,14 @@ private void processStreamingConversation(String connectionId, String endpoint, public void onToken(String token) { sendEvent(apiClient, connectionId, new StreamingEvent.TokenEvent(token)); } - + @Override public void onComplete(ConversationResponse response) { sendEvent(apiClient, connectionId, StreamingEvent.CompleteEvent.from(response)); logger.info("Streaming completed for session: {}", response.getSessionId()); closeApiClient(apiClient); } - + @Override public void onError(Throwable error) { logger.error("Streaming error: {}", error.getMessage(), error); @@ -122,7 +122,7 @@ public void onError(Throwable error) { throw e; } } - + private void closeApiClient(ApiGatewayManagementApiClient apiClient) { try { if (apiClient != null) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarConnectionRepository.java index b1303cb5..b9adc9aa 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarConnectionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarConnectionRepository.java @@ -21,14 +21,14 @@ public class GrammarConnectionRepository { private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); private final DynamoDbTable table; - + /** * 기본 생성자 (Lambda에서 사용) */ public GrammarConnectionRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java index 4c52c93c..66103008 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java @@ -29,14 +29,14 @@ public class GrammarSessionRepository { private final DynamoDbTable sessionTable; private final DynamoDbTable messageTable; - + /** * 기본 생성자 (Lambda에서 사용) */ public GrammarSessionRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/CreateSessionRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/CreateSessionRequest.java index 6dc7dc3f..d65fe836 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/CreateSessionRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/CreateSessionRequest.java @@ -1,7 +1,8 @@ package com.mzc.secondproject.serverless.domain.opic.dto.request; public record CreateSessionRequest( - String topic, - String subTopic, - String targetLevel -) {} + String topic, + String subTopic, + String targetLevel +) { +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/SubmitAnswerRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/SubmitAnswerRequest.java index c425f42f..01a95852 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/SubmitAnswerRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/SubmitAnswerRequest.java @@ -1,5 +1,6 @@ package com.mzc.secondproject.serverless.domain.opic.dto.request; -public record SubmitAnswerRequest ( - String audioS3Key -) {} +public record SubmitAnswerRequest( + String audioS3Key +) { +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/AnswerFeedbackResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/AnswerFeedbackResponse.java index fcc6bf3b..af43e60e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/AnswerFeedbackResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/AnswerFeedbackResponse.java @@ -1,10 +1,11 @@ package com.mzc.secondproject.serverless.domain.opic.dto.response; public record AnswerFeedbackResponse( - String answerId, - String transcript, - FeedbackResponse feedback, - boolean hasNextQuestion, - Integer nextQustionNumber, - int totalQuestions -) {} + String answerId, + String transcript, + FeedbackResponse feedback, + boolean hasNextQuestion, + Integer nextQustionNumber, + int totalQuestions +) { +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/CreateSessionResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/CreateSessionResponse.java index 17d1cf92..d2e3e67a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/CreateSessionResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/CreateSessionResponse.java @@ -1,7 +1,8 @@ package com.mzc.secondproject.serverless.domain.opic.dto.response; -public record CreateSessionResponse ( - String sessionId, - QuestionResponse firstQuestion, - int totalQuestions -) {} +public record CreateSessionResponse( + String sessionId, + QuestionResponse firstQuestion, + int totalQuestions +) { +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/QuestionResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/QuestionResponse.java index 0ecf6230..78397faf 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/QuestionResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/QuestionResponse.java @@ -1,9 +1,10 @@ package com.mzc.secondproject.serverless.domain.opic.dto.response; -public record QuestionResponse ( - String questionId, - String questionText, - String audioUrl, - int questionNumber, - int totalQuestions -) {} +public record QuestionResponse( + String questionId, + String questionText, + String audioUrl, + int questionNumber, + int totalQuestions +) { +} 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 80b34f31..0eb3b1a2 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 @@ -37,470 +37,470 @@ * - 세션 완료 (종합 리포트) */ 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; - - public OPIcSessionHandler() { - this.repository = new OPIcRepository(); - this.pollyService = new PollyService(OPIC_BUCKET, "opic/voice/questions/"); - this.transcribeService = new TranscribeProxyService(); - this.feedbackService = new FeedbackService(); - } - - @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")) { - 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 - 답변 제출 - if ("POST".equals(httpMethod) && path.contains("/answers")) { - return submitAnswer(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( - request.topic(), - request.subTopic(), - request.targetLevel() - ); - - if (questions.isEmpty()) { - return ResponseGenerator.notFound("해당 주제/레벨의 질문이 없습니다."); - } - - // 최대 3개 질문 선택 (랜덤 셔플) - Collections.shuffle(questions); - List questionIds = questions.stream() - .limit(3) - .map(OPIcQuestion::getQuestionId) - .collect(Collectors.toList()); - - // 세션 생성 - OPIcSession session = repository.createSession( - userId, - request.topic(), - request.subTopic(), - request.targetLevel(), - questionIds - ); - - // 첫 질문 Polly 음성 URL 생성 (#368 PollyService 연동) - OPIcQuestion firstQuestion = questions.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); - } - - /** - * 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 - )); - } - - // 다음 질문 조회 - String questionId = session.getQuestionIds().get(currentIndex); - OPIcQuestion question = repository.findQuestionById(questionId) - .orElseThrow(() -> new RuntimeException("질문을 찾을 수 없습니다: " + questionId)); - - // Polly 음성 URL - String audioUrl = generateQuestionAudioUrl(question); - - 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); - - return ResponseGenerator.ok(response); - } - - /** - * GET /opic/sessions/{sessionId}/upload-url - * S3 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)) { - 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() - .bucket(OPIC_BUCKET) - .key(s3Key) - .contentType("audio/webm") - .build(); - - String presignedUrl = AwsClients.s3Presigner() - .presignPutObject(PutObjectPresignRequest.builder() - .putObjectRequest(putRequest) - .signatureDuration(Duration.ofMinutes(5)) - .build()) - .url() - .toString(); - - return ResponseGenerator.ok(Map.of( - "uploadUrl", presignedUrl, - "s3Key", s3Key, - "expiresIn", 300 - )); - } - - /** - * POST /opic/sessions/{sessionId}/answers - * 답변 제출 → STT → AI 피드백 - */ - 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()); - - // 세션 검증 - 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.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 저장 - 개별 필드로 분리 저장 - 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.setCreatedAt(Instant.now()); - answer.setCompletedAt(Instant.now()); - - repository.saveAnswer(answer); - - // 세션 진행 상태 업데이트 - session.setCurrentQuestionIndex(currentIndex + 1); - repository.updateSession(session); - - // Response - boolean hasNext = (currentIndex + 1) < session.getTotalQuestions(); - - Map response = new LinkedHashMap<>(); - response.put("transcript", transcript); - response.put("feedback", feedback); - response.put("hasNextQuestion", hasNext); - response.put("currentQuestion", currentIndex + 1); - response.put("totalQuestions", session.getTotalQuestions()); - - if (hasNext) { - response.put("nextQuestionNumber", currentIndex + 2); - } - - logger.info("답변 처리 완료: sessionId={}, questionIndex={}", sessionId, currentIndex); - 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("세션을 찾을 수 없습니다."); - } - if (!session.getUserId().equals(userId)) { - return ResponseGenerator.forbidden("접근 권한이 없습니다."); - } - - // 모든 질문 답변 완료 확인 - List answers = repository.findAnswersBySessionId(sessionId); - if (answers.size() < session.getTotalQuestions()) { - return ResponseGenerator.badRequest( - String.format("아직 %d개의 질문에 답변하지 않았습니다.", - session.getTotalQuestions() - answers.size()) - ); - } - - // 세션 요약 생성 (피드백용) - 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() - ); - - // 세션 완료 처리 - repository.completeSession( - session, - sessionReport.estimatedLevel(), - gson.toJson(sessionReport) - ); - - logger.info("세션 완료: sessionId={}, estimatedLevel={}", - sessionId, sessionReport.estimatedLevel()); - - return ResponseGenerator.ok("세션이 완료되었습니다.", sessionReport); - } - - // ==================== 유틸리티 ==================== - - /** - * 질문 음성 URL 생성 (Polly + S3 캐싱) - */ - private String generateQuestionAudioUrl(OPIcQuestion question) { - try { - PollyService.VoiceSynthesisResult result = pollyService.synthesizeSpeech( - question.getQuestionId(), - question.getQuestionText(), - "FEMALE" - ); - return result.getAudioUrl(); - } catch (Exception e) { - logger.warn("Polly 음성 생성 실패, 텍스트만 반환: {}", e.getMessage()); - 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 { - return Instant.parse(json.getAsString()); - } - } -} \ No newline at end of file + + 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; + + public OPIcSessionHandler() { + this.repository = new OPIcRepository(); + this.pollyService = new PollyService(OPIC_BUCKET, "opic/voice/questions/"); + this.transcribeService = new TranscribeProxyService(); + this.feedbackService = new FeedbackService(); + } + + @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")) { + 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 - 답변 제출 + if ("POST".equals(httpMethod) && path.contains("/answers")) { + return submitAnswer(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( + request.topic(), + request.subTopic(), + request.targetLevel() + ); + + if (questions.isEmpty()) { + return ResponseGenerator.notFound("해당 주제/레벨의 질문이 없습니다."); + } + + // 최대 3개 질문 선택 (랜덤 셔플) + Collections.shuffle(questions); + List questionIds = questions.stream() + .limit(3) + .map(OPIcQuestion::getQuestionId) + .collect(Collectors.toList()); + + // 세션 생성 + OPIcSession session = repository.createSession( + userId, + request.topic(), + request.subTopic(), + request.targetLevel(), + questionIds + ); + + // 첫 질문 Polly 음성 URL 생성 (#368 PollyService 연동) + OPIcQuestion firstQuestion = questions.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); + } + + /** + * 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 + )); + } + + // 다음 질문 조회 + String questionId = session.getQuestionIds().get(currentIndex); + OPIcQuestion question = repository.findQuestionById(questionId) + .orElseThrow(() -> new RuntimeException("질문을 찾을 수 없습니다: " + questionId)); + + // Polly 음성 URL + String audioUrl = generateQuestionAudioUrl(question); + + 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); + + return ResponseGenerator.ok(response); + } + + /** + * GET /opic/sessions/{sessionId}/upload-url + * S3 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)) { + 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() + .bucket(OPIC_BUCKET) + .key(s3Key) + .contentType("audio/webm") + .build(); + + String presignedUrl = AwsClients.s3Presigner() + .presignPutObject(PutObjectPresignRequest.builder() + .putObjectRequest(putRequest) + .signatureDuration(Duration.ofMinutes(5)) + .build()) + .url() + .toString(); + + return ResponseGenerator.ok(Map.of( + "uploadUrl", presignedUrl, + "s3Key", s3Key, + "expiresIn", 300 + )); + } + + /** + * POST /opic/sessions/{sessionId}/answers + * 답변 제출 → STT → AI 피드백 + */ + 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()); + + // 세션 검증 + 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.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 저장 - 개별 필드로 분리 저장 + 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.setCreatedAt(Instant.now()); + answer.setCompletedAt(Instant.now()); + + repository.saveAnswer(answer); + + // 세션 진행 상태 업데이트 + session.setCurrentQuestionIndex(currentIndex + 1); + repository.updateSession(session); + + // Response + boolean hasNext = (currentIndex + 1) < session.getTotalQuestions(); + + Map response = new LinkedHashMap<>(); + response.put("transcript", transcript); + response.put("feedback", feedback); + response.put("hasNextQuestion", hasNext); + response.put("currentQuestion", currentIndex + 1); + response.put("totalQuestions", session.getTotalQuestions()); + + if (hasNext) { + response.put("nextQuestionNumber", currentIndex + 2); + } + + logger.info("답변 처리 완료: sessionId={}, questionIndex={}", sessionId, currentIndex); + 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("세션을 찾을 수 없습니다."); + } + if (!session.getUserId().equals(userId)) { + return ResponseGenerator.forbidden("접근 권한이 없습니다."); + } + + // 모든 질문 답변 완료 확인 + List answers = repository.findAnswersBySessionId(sessionId); + if (answers.size() < session.getTotalQuestions()) { + return ResponseGenerator.badRequest( + String.format("아직 %d개의 질문에 답변하지 않았습니다.", + session.getTotalQuestions() - answers.size()) + ); + } + + // 세션 요약 생성 (피드백용) + 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() + ); + + // 세션 완료 처리 + repository.completeSession( + session, + sessionReport.estimatedLevel(), + gson.toJson(sessionReport) + ); + + logger.info("세션 완료: sessionId={}, estimatedLevel={}", + sessionId, sessionReport.estimatedLevel()); + + return ResponseGenerator.ok("세션이 완료되었습니다.", sessionReport); + } + + // ==================== 유틸리티 ==================== + + /** + * 질문 음성 URL 생성 (Polly + S3 캐싱) + */ + private String generateQuestionAudioUrl(OPIcQuestion question) { + try { + PollyService.VoiceSynthesisResult result = pollyService.synthesizeSpeech( + question.getQuestionId(), + question.getQuestionText(), + "FEMALE" + ); + return result.getAudioUrl(); + } catch (Exception e) { + logger.warn("Polly 음성 생성 실패, 텍스트만 반환: {}", e.getMessage()); + 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 { + return Instant.parse(json.getAsString()); + } + } +} 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 92ac2541..62251d18 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 @@ -20,248 +20,248 @@ import java.util.stream.Collectors; public class OPIcRepository { - - private static final Logger logger = LoggerFactory.getLogger(OPIcRepository.class); - private static final String TABLE_NAME = System.getenv("OPIC_TABLE_NAME"); - - private final DynamoDbEnhancedClient enhancedClient; - private final DynamoDbTable sessionTable; - private final DynamoDbTable questionTable; - private final DynamoDbTable answerTable; - - public OPIcRepository() { - this.enhancedClient = AwsClients.dynamoDbEnhanced(); - this.sessionTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(OPIcSession.class)); - this.questionTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(OPIcQuestion.class)); - this.answerTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(OPIcAnswer.class)); - } - - // ==================== Session ==================== - - /** - * 새 세션 생성 - */ - public OPIcSession createSession(String userId, String topic, String subTopic, - String targetLevel, List questionIds) { - String sessionId = UUID.randomUUID().toString(); - String today = LocalDate.now(ZoneId.of("Asia/Seoul")) - .format(DateTimeFormatter.ISO_LOCAL_DATE); - Instant now = Instant.now(); - - OPIcSession session = new OPIcSession(); - session.setPk("USER#" + userId); - session.setSk("SESSION#" + today + "#" + sessionId); - session.setGsi1pk("SESSION#" + sessionId); - session.setGsi1sk("METADATA"); - - session.setSessionId(sessionId); - session.setUserId(userId); - session.setTopic(topic); - session.setSubTopic(subTopic); - session.setTargetLevel(targetLevel); - session.setStatus(OPIcSession.SessionStatus.IN_PROGRESS); - session.setCurrentQuestionIndex(0); - session.setTotalQuestions(questionIds.size()); - session.setQuestionIds(questionIds); - session.setCreatedAt(now); - session.setUpdatedAt(now); - session.setSequenceNumber(0); - - sessionTable.putItem(session); - logger.info("Session created: {}", sessionId); - - return session; - } - - /** - * 세션 ID로 조회 (GSI1 사용) - */ - public Optional findSessionById(String sessionId) { - DynamoDbIndex gsi1 = sessionTable.index("GSI1"); - - QueryConditional queryConditional = QueryConditional.keyEqualTo( - Key.builder() - .partitionValue("SESSION#" + sessionId) - .sortValue("METADATA") - .build() - ); - - return gsi1.query(QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .build()) - .stream() - .flatMap(page -> page.items().stream()) - .findFirst(); - } - - /** - * 사용자의 세션 목록 조회 (최신순) - */ - public List findSessionsByUserId(String userId, int limit) { - QueryConditional queryConditional = QueryConditional.sortBeginsWith( - Key.builder() - .partitionValue("USER#" + userId) - .sortValue("SESSION#") - .build() - ); - - return sessionTable.query(QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .scanIndexForward(false) // 최신순 - .limit(limit) - .build()) - .stream() - .flatMap(page -> page.items().stream()) - .collect(Collectors.toList()); - } - - /** - * 세션 업데이트 - */ - public void updateSession(OPIcSession session) { - session.setUpdatedAt(Instant.now()); - session.setSequenceNumber(session.getSequenceNumber() + 1); - sessionTable.putItem(session); - logger.debug("Session updated: {}", session.getSessionId()); - } - - /** - * 세션 완료 처리 - */ - public void completeSession(OPIcSession session, String overallScore, String overallFeedback) { - session.setStatus(OPIcSession.SessionStatus.COMPLETED); - session.setOverallScore(overallScore); - session.setOverallFeedback(overallFeedback); - session.setCompletedAt(Instant.now()); - updateSession(session); - logger.info("Session completed: {}", session.getSessionId()); - } - - - // ==================== Question ==================== - - /** - * 질문 ID로 조회 - */ - public Optional findQuestionById(String questionId) { - Key key = Key.builder() - .partitionValue("QUESTION#" + questionId) - .sortValue("METADATA") - .build(); - - return Optional.ofNullable(questionTable.getItem(key)); - } - - /** - * 주제 + 레벨로 질문 조회 (GSI1) - */ - public List findQuestionsByTopicAndLevel(String topic, String level) { - DynamoDbIndex gsi1 = questionTable.index("GSI1"); - - QueryConditional queryConditional = QueryConditional.keyEqualTo( - Key.builder() - .partitionValue("TOPIC#" + topic) - .sortValue("LEVEL#" + level) - .build() - ); - - return gsi1.query(queryConditional) - .stream() - .flatMap(page -> page.items().stream()) - .filter(OPIcQuestion::isActive) - .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로 조회 - */ - public List findQuestionsByIds(List questionIds) { - return questionIds.stream() - .map(this::findQuestionById) - .filter(Optional::isPresent) - .map(Optional::get) - .collect(Collectors.toList()); - } - - /** - * 질문 저장 (마스터 데이터 등록용) - */ - public void saveQuestion(OPIcQuestion question) { - question.setPk("QUESTION#" + question.getQuestionId()); - question.setSk("METADATA"); - question.setGsi1pk("TOPIC#" + question.getTopic()); - question.setGsi1sk("LEVEL#" + question.getLevel()); - - questionTable.putItem(question); - logger.info("Question saved: {}", question.getQuestionId()); - } - - // ==================== Answer ==================== - - /** - * 답변 저장 - */ - public void saveAnswer(OPIcAnswer answer) { - answer.setPk("SESSION#" + answer.getSessionId()); - answer.setSk(String.format("Q#%02d", answer.getQuestionIndex())); - - if (answer.getCreatedAt() == null) { - answer.setCreatedAt(Instant.now()); - } - - answerTable.putItem(answer); - logger.debug("Answer saved: session={}, questionIndex={}", - answer.getSessionId(), answer.getQuestionIndex()); - } - - /** - * 세션의 특정 질문 답변 조회 - */ - public Optional findAnswer(String sessionId, int questionIndex) { - Key key = Key.builder() - .partitionValue("SESSION#" + sessionId) - .sortValue(String.format("Q#%02d", questionIndex)) - .build(); - - return Optional.ofNullable(answerTable.getItem(key)); - } - - /** - * 세션의 모든 답변 조회 - */ - public List findAnswersBySessionId(String sessionId) { - QueryConditional queryConditional = QueryConditional.sortBeginsWith( - Key.builder() - .partitionValue("SESSION#" + sessionId) - .sortValue("Q#") - .build() - ); - - return answerTable.query(queryConditional) - .stream() - .flatMap(page -> page.items().stream()) - .collect(Collectors.toList()); - } - - /** - * 답변 업데이트 (피드백 추가 등) - */ - public void updateAnswer(OPIcAnswer answer) { - answerTable.putItem(answer); - logger.debug("Answer updated: session={}, questionIndex={}", - answer.getSessionId(), answer.getQuestionIndex()); - } - - + + private static final Logger logger = LoggerFactory.getLogger(OPIcRepository.class); + private static final String TABLE_NAME = System.getenv("OPIC_TABLE_NAME"); + + private final DynamoDbEnhancedClient enhancedClient; + private final DynamoDbTable sessionTable; + private final DynamoDbTable questionTable; + private final DynamoDbTable answerTable; + + public OPIcRepository() { + this.enhancedClient = AwsClients.dynamoDbEnhanced(); + this.sessionTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(OPIcSession.class)); + this.questionTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(OPIcQuestion.class)); + this.answerTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(OPIcAnswer.class)); + } + + // ==================== Session ==================== + + /** + * 새 세션 생성 + */ + public OPIcSession createSession(String userId, String topic, String subTopic, + String targetLevel, List questionIds) { + String sessionId = UUID.randomUUID().toString(); + String today = LocalDate.now(ZoneId.of("Asia/Seoul")) + .format(DateTimeFormatter.ISO_LOCAL_DATE); + Instant now = Instant.now(); + + OPIcSession session = new OPIcSession(); + session.setPk("USER#" + userId); + session.setSk("SESSION#" + today + "#" + sessionId); + session.setGsi1pk("SESSION#" + sessionId); + session.setGsi1sk("METADATA"); + + session.setSessionId(sessionId); + session.setUserId(userId); + session.setTopic(topic); + session.setSubTopic(subTopic); + session.setTargetLevel(targetLevel); + session.setStatus(OPIcSession.SessionStatus.IN_PROGRESS); + session.setCurrentQuestionIndex(0); + session.setTotalQuestions(questionIds.size()); + session.setQuestionIds(questionIds); + session.setCreatedAt(now); + session.setUpdatedAt(now); + session.setSequenceNumber(0); + + sessionTable.putItem(session); + logger.info("Session created: {}", sessionId); + + return session; + } + + /** + * 세션 ID로 조회 (GSI1 사용) + */ + public Optional findSessionById(String sessionId) { + DynamoDbIndex gsi1 = sessionTable.index("GSI1"); + + QueryConditional queryConditional = QueryConditional.keyEqualTo( + Key.builder() + .partitionValue("SESSION#" + sessionId) + .sortValue("METADATA") + .build() + ); + + return gsi1.query(QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .build()) + .stream() + .flatMap(page -> page.items().stream()) + .findFirst(); + } + + /** + * 사용자의 세션 목록 조회 (최신순) + */ + public List findSessionsByUserId(String userId, int limit) { + QueryConditional queryConditional = QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue("USER#" + userId) + .sortValue("SESSION#") + .build() + ); + + return sessionTable.query(QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 + .limit(limit) + .build()) + .stream() + .flatMap(page -> page.items().stream()) + .collect(Collectors.toList()); + } + + /** + * 세션 업데이트 + */ + public void updateSession(OPIcSession session) { + session.setUpdatedAt(Instant.now()); + session.setSequenceNumber(session.getSequenceNumber() + 1); + sessionTable.putItem(session); + logger.debug("Session updated: {}", session.getSessionId()); + } + + /** + * 세션 완료 처리 + */ + public void completeSession(OPIcSession session, String overallScore, String overallFeedback) { + session.setStatus(OPIcSession.SessionStatus.COMPLETED); + session.setOverallScore(overallScore); + session.setOverallFeedback(overallFeedback); + session.setCompletedAt(Instant.now()); + updateSession(session); + logger.info("Session completed: {}", session.getSessionId()); + } + + + // ==================== Question ==================== + + /** + * 질문 ID로 조회 + */ + public Optional findQuestionById(String questionId) { + Key key = Key.builder() + .partitionValue("QUESTION#" + questionId) + .sortValue("METADATA") + .build(); + + return Optional.ofNullable(questionTable.getItem(key)); + } + + /** + * 주제 + 레벨로 질문 조회 (GSI1) + */ + public List findQuestionsByTopicAndLevel(String topic, String level) { + DynamoDbIndex gsi1 = questionTable.index("GSI1"); + + QueryConditional queryConditional = QueryConditional.keyEqualTo( + Key.builder() + .partitionValue("TOPIC#" + topic) + .sortValue("LEVEL#" + level) + .build() + ); + + return gsi1.query(queryConditional) + .stream() + .flatMap(page -> page.items().stream()) + .filter(OPIcQuestion::isActive) + .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로 조회 + */ + public List findQuestionsByIds(List questionIds) { + return questionIds.stream() + .map(this::findQuestionById) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toList()); + } + + /** + * 질문 저장 (마스터 데이터 등록용) + */ + public void saveQuestion(OPIcQuestion question) { + question.setPk("QUESTION#" + question.getQuestionId()); + question.setSk("METADATA"); + question.setGsi1pk("TOPIC#" + question.getTopic()); + question.setGsi1sk("LEVEL#" + question.getLevel()); + + questionTable.putItem(question); + logger.info("Question saved: {}", question.getQuestionId()); + } + + // ==================== Answer ==================== + + /** + * 답변 저장 + */ + public void saveAnswer(OPIcAnswer answer) { + answer.setPk("SESSION#" + answer.getSessionId()); + answer.setSk(String.format("Q#%02d", answer.getQuestionIndex())); + + if (answer.getCreatedAt() == null) { + answer.setCreatedAt(Instant.now()); + } + + answerTable.putItem(answer); + logger.debug("Answer saved: session={}, questionIndex={}", + answer.getSessionId(), answer.getQuestionIndex()); + } + + /** + * 세션의 특정 질문 답변 조회 + */ + public Optional findAnswer(String sessionId, int questionIndex) { + Key key = Key.builder() + .partitionValue("SESSION#" + sessionId) + .sortValue(String.format("Q#%02d", questionIndex)) + .build(); + + return Optional.ofNullable(answerTable.getItem(key)); + } + + /** + * 세션의 모든 답변 조회 + */ + public List findAnswersBySessionId(String sessionId) { + QueryConditional queryConditional = QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue("SESSION#" + sessionId) + .sortValue("Q#") + .build() + ); + + return answerTable.query(queryConditional) + .stream() + .flatMap(page -> page.items().stream()) + .collect(Collectors.toList()); + } + + /** + * 답변 업데이트 (피드백 추가 등) + */ + public void updateAnswer(OPIcAnswer answer) { + answerTable.putItem(answer); + logger.debug("Answer updated: session={}, questionIndex={}", + answer.getSessionId(), answer.getQuestionIndex()); + } + + } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/FeedbackService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/FeedbackService.java index fc42849d..9c15315e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/FeedbackService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/FeedbackService.java @@ -18,282 +18,282 @@ import java.util.List; /** -* OPIc 피드백 생성 서비스 -*/ + * OPIc 피드백 생성 서비스 + */ public class FeedbackService { - - private static final Logger logger = LoggerFactory.getLogger(FeedbackService.class); - private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); - private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; - private static final int MAX_TOKENS = 2000; - - /** - * 사용자 답변에 대한 피드백 생성 - */ - public FeedbackResponse generateFeedback(String question, String userAnswer, String targetLevel) { - logger.info("피드백 생성, 대상 Level: {}", targetLevel); - - String prompt = buildFeedbackPrompt(question, userAnswer, targetLevel); - String response = invokeClaude(prompt); - String jsonResponse = JsonUtil.extractJson(response); - - return parseFeedbackResponse(jsonResponse); - } - - - /** - * 세션 종합 리포트 생성 - */ - public SessionReportResponse generateSessionReport(String sessionSummary, String targetLevel) { - logger.info("세션 리포트 생성, 대상 Level: {}", targetLevel); - - String prompt = buildSessionReportPrompt(sessionSummary, targetLevel); - String response = invokeClaude(prompt); - String jsonResponse = JsonUtil.extractJson(response); - - return parseSessionReportResponse(jsonResponse); - } - - - /** - * 개별 질문 피드백 프롬프트 - */ - private String buildFeedbackPrompt(String question, String userAnswer, String targetLevel) { - return String.format(""" - You are an expert OPIc speaking evaluator. - - ## Question - %s - - ## User's Answer - %s - - ## Target Level - %s - - ## Task - Analyze the answer and provide feedback in the following JSON format only: - - { - "errors": [ - { - "type": "GRAMMAR | EXPRESSION | VOCABULARY", - "original": "원본 표현", - "corrected": "교정된 표현", - "explanation": "설명 (한국어)" - } - ], - "correctedAnswer": "전체 교정된 답변 (영어)", - "sampleAnswer": "목표 레벨에 맞는 모범 답변 (영어, 4-6문장)" - } - - Error types: - - GRAMMAR: 문법 오류 (시제, 관사, 주어-동사 일치 등) - - EXPRESSION: 더 자연스러운 표현 제안 - - VOCABULARY: 더 적절하거나 풍부한 어휘 제안 - - Rules: - 1. errors 배열은 최대 5개까지만 포함 - 2. 오류가 없으면 errors는 빈 배열 [] - 3. explanation은 한국어로 간결하게 - 4. sampleAnswer는 목표 레벨에 맞는 자연스러운 답변 - - Respond with ONLY the JSON, no markdown code blocks. - """, question, userAnswer, targetLevel); - } - - /** - * 세션 종합 리포트 프롬프트 - */ - private String buildSessionReportPrompt(String sessionSummary, String targetLevel) { - return String.format(""" - You are an expert OPIc speaking coach creating a comprehensive session report. - - ## Session Summary (Questions and Answers) - %s - - ## Target Level - %s - - ## Task - Generate a detailed learning report in the following JSON format only: - - { - "estimatedLevel": "NL | NM | NH | IL | IM1 | IM2 | IM3 | IH | AL", - "overallScore": 0-100, - "strengths": ["잘한 점 1 (한국어)", "잘한 점 2", "잘한 점 3"], - "weaknesses": ["개선할 점 1 (한국어)", "개선할 점 2", "개선할 점 3"], - "feedback": "종합 피드백 (한국어, 3-4문장, 격려하는 톤)", - "recommendations": ["학습 추천 1 (한국어)", "학습 추천 2"] - } - - Evaluation criteria: - - Task completion: 질문에 적절히 답했는가 - - Fluency: 유창성, 자연스러움 - - Grammar: 문법 정확도 - - Vocabulary: 어휘 다양성 - - Content: 내용의 구체성 - - Be encouraging but honest. Provide specific, actionable feedback in Korean. - Respond with ONLY the JSON, no markdown code blocks. - """, sessionSummary, targetLevel); - } - - - /** - * Claude 호출 (일반 텍스트 응답) - */ - private String invokeClaude(String prompt) { - try { - JsonObject requestBody = buildRequestBody(prompt); - - InvokeModelRequest request = InvokeModelRequest.builder() - .modelId(MODEL_ID) - .contentType("application/json") - .body(SdkBytes.fromUtf8String(gson.toJson(requestBody))) - .build(); - - long startTime = System.currentTimeMillis(); - InvokeModelResponse response = AwsClients.bedrock().invokeModel(request); - long elapsed = System.currentTimeMillis() - startTime; - - logger.info("Bedrock 응답 수신: {}ms", elapsed); - - JsonObject responseJson = JsonParser.parseString( - response.body().asUtf8String() - ).getAsJsonObject(); - - return responseJson - .getAsJsonArray("content") - .get(0) - .getAsJsonObject() - .get("text") - .getAsString(); - - } catch (Exception e) { - logger.error("Bedrock 호출 실패", e); - throw new OPIcException.BedrockApiException(e.getMessage(), e); - } - } - - /** - * Bedrock 요청 Body 생성 - */ - private JsonObject buildRequestBody(String prompt) { - JsonObject requestBody = new JsonObject(); - requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); - requestBody.addProperty("max_tokens", MAX_TOKENS); - - JsonArray messages = new JsonArray(); - JsonObject userMessage = new JsonObject(); - userMessage.addProperty("role", "user"); - userMessage.addProperty("content", prompt); - messages.add(userMessage); - - requestBody.add("messages", messages); - return requestBody; - } - - // ==================== 응답 파싱 ==================== - - /** - * 피드백 응답 파싱 - * - * Claude 응답 JSON 구조: - * { - * "errors": [{ "type": "GRAMMAR", "original": "...", "corrected": "...", "explanation": "..." }], - * "correctedAnswer": "...", - * "sampleAnswer": "..." - * } - */ - private FeedbackResponse parseFeedbackResponse(String jsonResponse) { - try { - JsonObject json = JsonParser.parseString(jsonResponse).getAsJsonObject(); - - // errors 배열 파싱 - List errors = parseErrors(json.getAsJsonArray("errors")); - - // 응답 DTO 생성 - return new FeedbackResponse( - errors, - json.get("correctedAnswer").getAsString(), - json.get("sampleAnswer").getAsString() - ); - - } catch (Exception e) { - logger.error("피드백 파싱 실패: {}", jsonResponse, e); - throw new OPIcException.FeedbackParseException(jsonResponse, e); - } - } - - /** - * 세션 리포트 응답 파싱 - * - * Claude 응답 JSON 구조: - * { - * "estimatedLevel": "IM2", - * "overallScore": 72, - * "strengths": ["...", "..."], - * "weaknesses": ["...", "..."], - * "feedback": "...", - * "recommendations": ["...", "..."] - * } - */ - private SessionReportResponse parseSessionReportResponse(String jsonResponse) { - try { - JsonObject json = JsonParser.parseString(jsonResponse).getAsJsonObject(); - - return new SessionReportResponse( - json.get("estimatedLevel").getAsString(), - json.get("overallScore").getAsInt(), - JsonUtil.toStringList(json.getAsJsonArray("strengths")), - JsonUtil.toStringList(json.getAsJsonArray("weaknesses")), - json.get("feedback").getAsString(), - JsonUtil.toStringList(json.getAsJsonArray("recommendations")) - ); - - } catch (Exception e) { - logger.error("세션 리포트 파싱 실패: {}", jsonResponse, e); - throw new OPIcException.ReportParseException(jsonResponse, e); - } - } - - /** - * errors 배열 파싱 - */ - private List parseErrors(JsonArray errorsArray) { - List errors = new ArrayList<>(); - - if (errorsArray == null || errorsArray.isEmpty()) { - return errors; - } - - for (JsonElement el : errorsArray) { - JsonObject obj = el.getAsJsonObject(); - errors.add(SpeakingError.builder() - .type(parseErrorType(obj.get("type").getAsString())) - .original(obj.get("original").getAsString()) - .corrected(obj.get("corrected").getAsString()) - .explanation(obj.get("explanation").getAsString()) - .build()); - } - - return errors; - } - - - /** - * 오류 타입 문자열 -> Enum 변환 - */ - private SpeakingErrorType parseErrorType(String typeStr) { - try { - // "GRAMMAR | EXPRESSION | VOCABULARY" 형태 처리 - String cleaned = typeStr.replace(" ", "").split("\\|")[0].trim(); - return SpeakingErrorType.valueOf(cleaned.toUpperCase()); - } catch (Exception e) { - logger.warn("알 수 없는 오류 타입: {}, 기본값 GRAMMAR 사용", typeStr); - return SpeakingErrorType.GRAMMAR; - } - } - + + private static final Logger logger = LoggerFactory.getLogger(FeedbackService.class); + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; + private static final int MAX_TOKENS = 2000; + + /** + * 사용자 답변에 대한 피드백 생성 + */ + public FeedbackResponse generateFeedback(String question, String userAnswer, String targetLevel) { + logger.info("피드백 생성, 대상 Level: {}", targetLevel); + + String prompt = buildFeedbackPrompt(question, userAnswer, targetLevel); + String response = invokeClaude(prompt); + String jsonResponse = JsonUtil.extractJson(response); + + return parseFeedbackResponse(jsonResponse); + } + + + /** + * 세션 종합 리포트 생성 + */ + public SessionReportResponse generateSessionReport(String sessionSummary, String targetLevel) { + logger.info("세션 리포트 생성, 대상 Level: {}", targetLevel); + + String prompt = buildSessionReportPrompt(sessionSummary, targetLevel); + String response = invokeClaude(prompt); + String jsonResponse = JsonUtil.extractJson(response); + + return parseSessionReportResponse(jsonResponse); + } + + + /** + * 개별 질문 피드백 프롬프트 + */ + private String buildFeedbackPrompt(String question, String userAnswer, String targetLevel) { + return String.format(""" + You are an expert OPIc speaking evaluator. + + ## Question + %s + + ## User's Answer + %s + + ## Target Level + %s + + ## Task + Analyze the answer and provide feedback in the following JSON format only: + + { + "errors": [ + { + "type": "GRAMMAR | EXPRESSION | VOCABULARY", + "original": "원본 표현", + "corrected": "교정된 표현", + "explanation": "설명 (한국어)" + } + ], + "correctedAnswer": "전체 교정된 답변 (영어)", + "sampleAnswer": "목표 레벨에 맞는 모범 답변 (영어, 4-6문장)" + } + + Error types: + - GRAMMAR: 문법 오류 (시제, 관사, 주어-동사 일치 등) + - EXPRESSION: 더 자연스러운 표현 제안 + - VOCABULARY: 더 적절하거나 풍부한 어휘 제안 + + Rules: + 1. errors 배열은 최대 5개까지만 포함 + 2. 오류가 없으면 errors는 빈 배열 [] + 3. explanation은 한국어로 간결하게 + 4. sampleAnswer는 목표 레벨에 맞는 자연스러운 답변 + + Respond with ONLY the JSON, no markdown code blocks. + """, question, userAnswer, targetLevel); + } + + /** + * 세션 종합 리포트 프롬프트 + */ + private String buildSessionReportPrompt(String sessionSummary, String targetLevel) { + return String.format(""" + You are an expert OPIc speaking coach creating a comprehensive session report. + + ## Session Summary (Questions and Answers) + %s + + ## Target Level + %s + + ## Task + Generate a detailed learning report in the following JSON format only: + + { + "estimatedLevel": "NL | NM | NH | IL | IM1 | IM2 | IM3 | IH | AL", + "overallScore": 0-100, + "strengths": ["잘한 점 1 (한국어)", "잘한 점 2", "잘한 점 3"], + "weaknesses": ["개선할 점 1 (한국어)", "개선할 점 2", "개선할 점 3"], + "feedback": "종합 피드백 (한국어, 3-4문장, 격려하는 톤)", + "recommendations": ["학습 추천 1 (한국어)", "학습 추천 2"] + } + + Evaluation criteria: + - Task completion: 질문에 적절히 답했는가 + - Fluency: 유창성, 자연스러움 + - Grammar: 문법 정확도 + - Vocabulary: 어휘 다양성 + - Content: 내용의 구체성 + + Be encouraging but honest. Provide specific, actionable feedback in Korean. + Respond with ONLY the JSON, no markdown code blocks. + """, sessionSummary, targetLevel); + } + + + /** + * Claude 호출 (일반 텍스트 응답) + */ + private String invokeClaude(String prompt) { + try { + JsonObject requestBody = buildRequestBody(prompt); + + InvokeModelRequest request = InvokeModelRequest.builder() + .modelId(MODEL_ID) + .contentType("application/json") + .body(SdkBytes.fromUtf8String(gson.toJson(requestBody))) + .build(); + + long startTime = System.currentTimeMillis(); + InvokeModelResponse response = AwsClients.bedrock().invokeModel(request); + long elapsed = System.currentTimeMillis() - startTime; + + logger.info("Bedrock 응답 수신: {}ms", elapsed); + + JsonObject responseJson = JsonParser.parseString( + response.body().asUtf8String() + ).getAsJsonObject(); + + return responseJson + .getAsJsonArray("content") + .get(0) + .getAsJsonObject() + .get("text") + .getAsString(); + + } catch (Exception e) { + logger.error("Bedrock 호출 실패", e); + throw new OPIcException.BedrockApiException(e.getMessage(), e); + } + } + + /** + * Bedrock 요청 Body 생성 + */ + private JsonObject buildRequestBody(String prompt) { + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); + requestBody.addProperty("max_tokens", MAX_TOKENS); + + JsonArray messages = new JsonArray(); + JsonObject userMessage = new JsonObject(); + userMessage.addProperty("role", "user"); + userMessage.addProperty("content", prompt); + messages.add(userMessage); + + requestBody.add("messages", messages); + return requestBody; + } + + // ==================== 응답 파싱 ==================== + + /** + * 피드백 응답 파싱 + *

+ * Claude 응답 JSON 구조: + * { + * "errors": [{ "type": "GRAMMAR", "original": "...", "corrected": "...", "explanation": "..." }], + * "correctedAnswer": "...", + * "sampleAnswer": "..." + * } + */ + private FeedbackResponse parseFeedbackResponse(String jsonResponse) { + try { + JsonObject json = JsonParser.parseString(jsonResponse).getAsJsonObject(); + + // errors 배열 파싱 + List errors = parseErrors(json.getAsJsonArray("errors")); + + // 응답 DTO 생성 + return new FeedbackResponse( + errors, + json.get("correctedAnswer").getAsString(), + json.get("sampleAnswer").getAsString() + ); + + } catch (Exception e) { + logger.error("피드백 파싱 실패: {}", jsonResponse, e); + throw new OPIcException.FeedbackParseException(jsonResponse, e); + } + } + + /** + * 세션 리포트 응답 파싱 + *

+ * Claude 응답 JSON 구조: + * { + * "estimatedLevel": "IM2", + * "overallScore": 72, + * "strengths": ["...", "..."], + * "weaknesses": ["...", "..."], + * "feedback": "...", + * "recommendations": ["...", "..."] + * } + */ + private SessionReportResponse parseSessionReportResponse(String jsonResponse) { + try { + JsonObject json = JsonParser.parseString(jsonResponse).getAsJsonObject(); + + return new SessionReportResponse( + json.get("estimatedLevel").getAsString(), + json.get("overallScore").getAsInt(), + JsonUtil.toStringList(json.getAsJsonArray("strengths")), + JsonUtil.toStringList(json.getAsJsonArray("weaknesses")), + json.get("feedback").getAsString(), + JsonUtil.toStringList(json.getAsJsonArray("recommendations")) + ); + + } catch (Exception e) { + logger.error("세션 리포트 파싱 실패: {}", jsonResponse, e); + throw new OPIcException.ReportParseException(jsonResponse, e); + } + } + + /** + * errors 배열 파싱 + */ + private List parseErrors(JsonArray errorsArray) { + List errors = new ArrayList<>(); + + if (errorsArray == null || errorsArray.isEmpty()) { + return errors; + } + + for (JsonElement el : errorsArray) { + JsonObject obj = el.getAsJsonObject(); + errors.add(SpeakingError.builder() + .type(parseErrorType(obj.get("type").getAsString())) + .original(obj.get("original").getAsString()) + .corrected(obj.get("corrected").getAsString()) + .explanation(obj.get("explanation").getAsString()) + .build()); + } + + return errors; + } + + + /** + * 오류 타입 문자열 -> Enum 변환 + */ + private SpeakingErrorType parseErrorType(String typeStr) { + try { + // "GRAMMAR | EXPRESSION | VOCABULARY" 형태 처리 + String cleaned = typeStr.replace(" ", "").split("\\|")[0].trim(); + return SpeakingErrorType.valueOf(cleaned.toUpperCase()); + } catch (Exception e) { + logger.warn("알 수 없는 오류 타입: {}, 기본값 GRAMMAR 사용", typeStr); + return SpeakingErrorType.GRAMMAR; + } + } + } 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 index 535a3fc1..256d3990 100644 --- 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 @@ -16,73 +16,73 @@ /** * 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 + + 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"); + } + } +} 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 index 4d82a9d1..bd46d3b4 100644 --- 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 @@ -14,31 +14,31 @@ * 연결 해제 시 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 + + 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"); + } + } +} 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 index 89ec0a44..24ecfc3c 100644 --- 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 @@ -20,7 +20,7 @@ /** * Speaking WebSocket 메시지 핸들러 - * + *

* 지원하는 action: * - speak: 음성 입력 처리 (audio base64) * - text: 텍스트 입력 처리 @@ -28,190 +28,190 @@ * - 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"); - } + + 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/model/SpeakingConnection.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java index 6ea0c185..133e7773 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/SpeakingConnection.java @@ -16,69 +16,69 @@ @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 + + // 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; + } +} 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 14b468d1..bbb74d7c 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 @@ -15,59 +15,59 @@ * 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 + + 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); + } +} 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..7c428ddc 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 @@ -13,7 +13,6 @@ import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelRequest; import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelResponse; - import java.util.ArrayList; import java.util.List; @@ -22,220 +21,220 @@ * 음성 입력 → 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" -> """ + + 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" -> """ + 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 -> """ + default -> """ - Use moderate vocabulary appropriate for intermediate learners - Mix simple and compound sentences - Introduce useful expressions gradually - Balance challenge with accessibility """; - }; - - return String.format(""" + }; + + 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. @@ -259,59 +258,61 @@ private String buildSystemPrompt(String targetLevel) { 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 + } + + /** + * 히스토리 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 + ) { + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java index f8890e51..97581029 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java @@ -3,28 +3,27 @@ import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.ScheduledEvent; +import com.mzc.secondproject.serverless.common.config.AwsClients; import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.ScanRequest; +import software.amazon.awssdk.services.dynamodb.model.ScanResponse; import java.time.LocalDate; import java.util.List; import java.util.Map; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import software.amazon.awssdk.services.dynamodb.model.ScanRequest; -import software.amazon.awssdk.services.dynamodb.model.ScanResponse; -import com.mzc.secondproject.serverless.common.config.AwsClients; - /** * EventBridge Scheduler Handler * 매일 자정에 실행되어 Streak 리셋만 수행 - * + *

* 단어 학습 통계는 Write-through 방식으로 markWordLearned에서 직접 업데이트 */ public class ScheduledStatsHandler implements RequestHandler { - + private static final Logger logger = LoggerFactory.getLogger(ScheduledStatsHandler.class); private static final String TABLE_NAME = EnvConfig.getRequired("VOCAB_TABLE_NAME"); private static final int BATCH_SIZE = 25; @@ -37,7 +36,7 @@ public class ScheduledStatsHandler implements RequestHandler lastEvaluatedKey = null; - + do { // SK = "STATS#TOTAL"인 레코드만 스캔 (currentStreak > 0 필터) ScanRequest.Builder scanBuilder = ScanRequest.builder() @@ -82,14 +81,14 @@ private int checkAndResetStreaks(String yesterday) { ":yesterday", AttributeValue.builder().s(yesterday).build() )) .limit(BATCH_SIZE); - + if (lastEvaluatedKey != null) { scanBuilder.exclusiveStartKey(lastEvaluatedKey); } - + ScanResponse response = AwsClients.dynamoDb().scan(scanBuilder.build()); List> items = response.items(); - + for (Map item : items) { String pk = item.get("PK").s(); // PK 형식: "USERSTATS#{userId}" 에서 userId 추출 @@ -104,14 +103,14 @@ private int checkAndResetStreaks(String yesterday) { } } } - + lastEvaluatedKey = response.lastEvaluatedKey(); } while (lastEvaluatedKey != null && !lastEvaluatedKey.isEmpty()); - + logger.info("Streak reset completed: {} users processed", resetCount); return resetCount; } - + /** * 사용자의 currentStreak을 0으로 리셋 (longestStreak은 유지) */ @@ -120,7 +119,7 @@ private void resetUserStreak(String userId) { getCurrentLongestStreak(userId), LocalDate.now().minusDays(1).toString()); } - + /** * 사용자의 현재 longestStreak 조회 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/StatsStreamHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/StatsStreamHandler.java index 78293b03..82ee6079 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/StatsStreamHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/StatsStreamHandler.java @@ -33,7 +33,7 @@ public class StatsStreamHandler implements RequestHandler { public StatsStreamHandler() { this(new UserStatsRepository(), new BadgeService()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java index c006c5fb..637151be 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java @@ -37,7 +37,7 @@ public class UserStatsHandler implements RequestHandler * Write-time Aggregation 패턴: * - 이벤트 발생 시 Atomic Counter로 증분 업데이트 * - 조회 시 Scan 없이 O(1) GetItem diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java index 807f0653..b3ad20d8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java @@ -33,14 +33,14 @@ public class UserStatsRepository { private static final String TABLE_NAME = EnvConfig.getRequired("VOCAB_TABLE_NAME"); private final DynamoDbTable table; - + /** * 기본 생성자 (Lambda에서 사용) */ public UserStatsRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/service/StatsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/service/StatsService.java index 510754a5..c50f52fe 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/service/StatsService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/service/StatsService.java @@ -17,14 +17,14 @@ public class StatsService { private static final Logger logger = LoggerFactory.getLogger(StatsService.class); private final UserStatsRepository userStatsRepository; - + /** * 기본 생성자 (Lambda에서 사용) */ public StatsService() { this(new UserStatsRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java index 31f528f7..97bd6638 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java @@ -13,7 +13,7 @@ /** * Cognito Post Confirmation 트리거 핸들러 - * + *

* 사용자 이메일 인증을 완료한 직후 DB에 데이터 생성 */ public class PostConfirmationHandler implements RequestHandler { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCode.java index 3c04d79d..dd576d63 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCode.java @@ -4,7 +4,7 @@ /** * 단어 학습 도메인 에러 코드 - * + *

* 단어(Word), 사용자 단어(UserWord), 일일 학습(DailyStudy) 관련 에러 코드를 정의합니다. */ public enum VocabularyErrorCode implements DomainErrorCode { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyException.java index 3deec811..7ab6adea 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyException.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyException.java @@ -4,9 +4,9 @@ /** * 단어 학습 도메인 예외 클래스 - * + *

* 정적 팩토리 메서드를 통해 가독성 높은 예외 생성을 지원합니다. - * + *

* 사용 예시: * throw VocabularyException.wordNotFound(wordId); * throw VocabularyException.invalidDifficulty("INVALID"); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java index 54488ac6..62692be6 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java @@ -30,7 +30,7 @@ public class DailyStudyHandler implements RequestHandler { public StatisticsHandler() { this(new StatisticsService()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java index deefe73f..77b8defe 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java @@ -27,7 +27,7 @@ public class StatsHandler implements RequestHandler table; - + /** * 기본 생성자 (Lambda에서 사용) */ public DailyStudyRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java index 7b0ea935..703c864c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java @@ -7,11 +7,7 @@ import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; -import software.amazon.awssdk.enhanced.dynamodb.Expression; -import software.amazon.awssdk.enhanced.dynamodb.Key; -import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.*; import software.amazon.awssdk.enhanced.dynamodb.model.Page; import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; @@ -26,14 +22,14 @@ public class TestResultRepository { private static final String TABLE_NAME = EnvConfig.getRequired("VOCAB_TABLE_NAME"); private final DynamoDbTable table; - + /** * 기본 생성자 (Lambda에서 사용) */ public TestResultRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java index 015932c9..7411113b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java @@ -22,14 +22,14 @@ public class UserWordRepository { private static final String TABLE_NAME = EnvConfig.getRequired("VOCAB_TABLE_NAME"); private final DynamoDbTable table; - + /** * 기본 생성자 (Lambda에서 사용) */ public UserWordRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java index f740c667..17ed84a8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java @@ -25,14 +25,14 @@ public class WordGroupRepository { private static final String TABLE_NAME = EnvConfig.getRequired("VOCAB_TABLE_NAME"); private final DynamoDbTable table; - + /** * 기본 생성자 (Lambda에서 사용) */ public WordGroupRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java index cba49b18..b2439362 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java @@ -30,7 +30,7 @@ public class WordRepository { public WordRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java index f4680fe5..32dc5b24 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java @@ -34,7 +34,7 @@ public class DailyStudyCommandService { private final WordRepository wordRepository; private final UserStatsRepository userStatsRepository; private final BadgeService badgeService; - + /** * 기본 생성자 (Lambda에서 사용) */ @@ -42,7 +42,7 @@ public DailyStudyCommandService() { this(new DailyStudyRepository(), new UserWordRepository(), new WordRepository(), new UserStatsRepository(), new BadgeService()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyQueryService.java index 1a2af754..a9888fbb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyQueryService.java @@ -19,14 +19,14 @@ public class DailyStudyQueryService { private final DailyStudyRepository dailyStudyRepository; private final WordRepository wordRepository; - + /** * 기본 생성자 (Lambda에서 사용) */ public DailyStudyQueryService() { this(new DailyStudyRepository(), new WordRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatisticsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatisticsService.java index 670f8e0d..7a206742 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatisticsService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatisticsService.java @@ -16,14 +16,14 @@ public class StatisticsService { private static final Logger logger = LoggerFactory.getLogger(StatisticsService.class); private final UserWordRepository userWordRepository; - + /** * 기본 생성자 (Lambda에서 사용) */ public StatisticsService() { this(new UserWordRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java index 496014f2..abcc4a46 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java @@ -4,6 +4,7 @@ import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; +import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import com.mzc.secondproject.serverless.domain.vocabulary.repository.DailyStudyRepository; import com.mzc.secondproject.serverless.domain.vocabulary.repository.TestResultRepository; import com.mzc.secondproject.serverless.domain.vocabulary.repository.UserWordRepository; @@ -14,7 +15,6 @@ import java.util.*; import java.util.function.Function; import java.util.stream.Collectors; -import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; public class StatsService { @@ -24,7 +24,7 @@ public class StatsService { private final DailyStudyRepository dailyStudyRepository; private final TestResultRepository testResultRepository; private final WordRepository wordRepository; - + /** * 기본 생성자 (Lambda에서 사용) */ @@ -32,7 +32,7 @@ public StatsService() { this(new UserWordRepository(), new DailyStudyRepository(), new TestResultRepository(), new WordRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -127,7 +127,7 @@ public Map getWeaknessAnalysis(String userId) { allUserWords.addAll(page.items()); cursor = page.nextCursor(); } while (cursor != null); - + if (allUserWords.isEmpty()) { Map emptyResult = new HashMap<>(); emptyResult.put("weakestWords", List.of()); @@ -136,7 +136,7 @@ public Map getWeaknessAnalysis(String userId) { emptyResult.put("suggestions", List.of()); return emptyResult; } - + // 배치 조회로 N+1 문제 해결: 모든 wordId를 수집하여 한 번에 조회 List wordIds = allUserWords.stream() .map(UserWord::getWordId) @@ -145,7 +145,7 @@ public Map getWeaknessAnalysis(String userId) { List words = wordRepository.findByIds(wordIds); Map wordMap = words.stream() .collect(Collectors.toMap(Word::getWordId, Function.identity(), (a, b) -> a)); - + List> weakestWords = allUserWords.stream() .filter(uw -> uw.getIncorrectCount() != null && uw.getIncorrectCount() > 0) .sorted(Comparator.comparingInt(UserWord::getIncorrectCount).reversed()) @@ -156,7 +156,7 @@ public Map getWeaknessAnalysis(String userId) { wordInfo.put("incorrectCount", uw.getIncorrectCount()); wordInfo.put("correctCount", uw.getCorrectCount()); wordInfo.put("status", uw.getStatus()); - + Word word = wordMap.get(uw.getWordId()); if (word != null) { wordInfo.put("english", word.getEnglish()); @@ -164,28 +164,28 @@ public Map getWeaknessAnalysis(String userId) { wordInfo.put("level", word.getLevel()); wordInfo.put("category", word.getCategory()); } - + int total = (uw.getCorrectCount() != null ? uw.getCorrectCount() : 0) + (uw.getIncorrectCount() != null ? uw.getIncorrectCount() : 0); wordInfo.put("accuracy", total > 0 ? (uw.getCorrectCount() != null ? uw.getCorrectCount() * 100.0 / total : 0) : 0); - + return wordInfo; }) .collect(Collectors.toList()); - + Map> categoryAnalysis = new HashMap<>(); Map> levelAnalysis = new HashMap<>(); - + for (UserWord uw : allUserWords) { Word word = wordMap.get(uw.getWordId()); if (word != null) { String category = word.getCategory(); String level = word.getLevel(); - + int correct = uw.getCorrectCount() != null ? uw.getCorrectCount() : 0; int incorrect = uw.getIncorrectCount() != null ? uw.getIncorrectCount() : 0; - + categoryAnalysis.computeIfAbsent(category, k -> { Map stats = new HashMap<>(); stats.put("totalCorrect", 0); @@ -197,7 +197,7 @@ public Map getWeaknessAnalysis(String userId) { catStats.put("totalCorrect", (Integer) catStats.get("totalCorrect") + correct); catStats.put("totalIncorrect", (Integer) catStats.get("totalIncorrect") + incorrect); catStats.put("wordCount", (Integer) catStats.get("wordCount") + 1); - + levelAnalysis.computeIfAbsent(level, k -> { Map stats = new HashMap<>(); stats.put("totalCorrect", 0); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java index 764e9288..43c0c095 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java @@ -33,7 +33,7 @@ public class TestCommandService { private final DailyStudyRepository dailyStudyRepository; private final WordRepository wordRepository; private final UserWordCommandService userWordCommandService; - + /** * 기본 생성자 (Lambda에서 사용) */ @@ -41,7 +41,7 @@ public TestCommandService() { this(new TestResultRepository(), new DailyStudyRepository(), new WordRepository(), new UserWordCommandService()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java index 7b422f3c..d21fc183 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java @@ -19,14 +19,14 @@ public class TestQueryService { private final TestResultRepository testResultRepository; private final WordRepository wordRepository; - + /** * 기본 생성자 (Lambda에서 사용) */ public TestQueryService() { this(new TestResultRepository(), new WordRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java index 18540ac2..4b9c216e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java @@ -25,14 +25,14 @@ public class UserWordCommandService { private static final Logger logger = LoggerFactory.getLogger(UserWordCommandService.class); private final UserWordRepository userWordRepository; - + /** * 기본 생성자 (Lambda에서 사용) */ public UserWordCommandService() { this(new UserWordRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java index 2942c609..bf6920e5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java @@ -20,14 +20,14 @@ public class UserWordQueryService { private final UserWordRepository userWordRepository; private final WordRepository wordRepository; - + /** * 기본 생성자 (Lambda에서 사용) */ public UserWordQueryService() { this(new UserWordRepository(), new WordRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java index f8365c69..193cb67b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java @@ -19,14 +19,14 @@ public class WordCommandService { private static final Logger logger = LoggerFactory.getLogger(WordCommandService.class); private final WordRepository wordRepository; - + /** * 기본 생성자 (Lambda에서 사용) */ public WordCommandService() { this(new WordRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java index 796f9d1e..847af239 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java @@ -20,14 +20,14 @@ public class WordGroupCommandService { private static final Logger logger = LoggerFactory.getLogger(WordGroupCommandService.class); private final WordGroupRepository wordGroupRepository; - + /** * 기본 생성자 (Lambda에서 사용) */ public WordGroupCommandService() { this(new WordGroupRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupQueryService.java index 6ce2d668..21c43637 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupQueryService.java @@ -20,14 +20,14 @@ public class WordGroupQueryService { private final WordGroupRepository wordGroupRepository; private final WordRepository wordRepository; - + /** * 기본 생성자 (Lambda에서 사용) */ public WordGroupQueryService() { this(new WordGroupRepository(), new WordRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java index 7257122c..4c8b4275 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java @@ -17,14 +17,14 @@ public class WordQueryService { private static final Logger logger = LoggerFactory.getLogger(WordQueryService.class); private final WordRepository wordRepository; - + /** * 기본 생성자 (Lambda에서 사용) */ public WordQueryService() { this(new WordRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy index df2855f6..9895fc71 100644 --- a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy @@ -19,31 +19,31 @@ class ChattingErrorCodeSpec extends Specification { !errorCode.getMessage().isEmpty() where: - errorCode | expectedCode | expectedStatusCode - ChattingErrorCode.ROOM_NOT_FOUND | "ROOM_001" | 404 - ChattingErrorCode.ROOM_ALREADY_EXISTS | "ROOM_002" | 409 - ChattingErrorCode.ROOM_FULL | "ROOM_003" | 400 - ChattingErrorCode.ROOM_CLOSED | "ROOM_004" | 400 - ChattingErrorCode.ROOM_INVALID_PASSWORD | "ROOM_005" | 401 - ChattingErrorCode.ROOM_NOT_OWNER | "ROOM_006" | 403 - ChattingErrorCode.MESSAGE_NOT_FOUND | "MSG_001" | 404 - ChattingErrorCode.MESSAGE_TOO_LONG | "MSG_002" | 400 - ChattingErrorCode.INVALID_MESSAGE_TYPE | "MSG_003" | 400 - ChattingErrorCode.NOT_ROOM_MEMBER | "MEMBER_001" | 403 - ChattingErrorCode.ALREADY_JOINED | "MEMBER_002" | 409 - ChattingErrorCode.INVALID_ROOM_TOKEN | "MEMBER_003" | 401 - ChattingErrorCode.INVALID_CHAT_LEVEL | "LEVEL_001" | 400 - ChattingErrorCode.CONNECTION_FAILED | "CONN_001" | 500 - ChattingErrorCode.CONNECTION_TIMEOUT | "CONN_002" | 408 - ChattingErrorCode.GAME_START_FAILED | "GAME_001" | 400 - ChattingErrorCode.GAME_STOP_FAILED | "GAME_002" | 400 - ChattingErrorCode.GAME_NOT_IN_PROGRESS | "GAME_003" | 400 - ChattingErrorCode.GAME_ALREADY_IN_PROGRESS| "GAME_004" | 409 - ChattingErrorCode.NOT_GAME_STARTER | "GAME_005" | 403 - ChattingErrorCode.GAME_NOT_FOUND | "GAME_006" | 404 - ChattingErrorCode.GAME_NOT_ALLOWED_IN_CHAT_ROOM | "GAME_007" | 400 - ChattingErrorCode.GAME_RESTART_NOT_ALLOWED | "GAME_008" | 400 - ChattingErrorCode.GAME_START_NOT_HOST | "GAME_009" | 403 + errorCode | expectedCode | expectedStatusCode + ChattingErrorCode.ROOM_NOT_FOUND | "ROOM_001" | 404 + ChattingErrorCode.ROOM_ALREADY_EXISTS | "ROOM_002" | 409 + ChattingErrorCode.ROOM_FULL | "ROOM_003" | 400 + ChattingErrorCode.ROOM_CLOSED | "ROOM_004" | 400 + ChattingErrorCode.ROOM_INVALID_PASSWORD | "ROOM_005" | 401 + ChattingErrorCode.ROOM_NOT_OWNER | "ROOM_006" | 403 + ChattingErrorCode.MESSAGE_NOT_FOUND | "MSG_001" | 404 + ChattingErrorCode.MESSAGE_TOO_LONG | "MSG_002" | 400 + ChattingErrorCode.INVALID_MESSAGE_TYPE | "MSG_003" | 400 + ChattingErrorCode.NOT_ROOM_MEMBER | "MEMBER_001" | 403 + ChattingErrorCode.ALREADY_JOINED | "MEMBER_002" | 409 + ChattingErrorCode.INVALID_ROOM_TOKEN | "MEMBER_003" | 401 + ChattingErrorCode.INVALID_CHAT_LEVEL | "LEVEL_001" | 400 + ChattingErrorCode.CONNECTION_FAILED | "CONN_001" | 500 + ChattingErrorCode.CONNECTION_TIMEOUT | "CONN_002" | 408 + ChattingErrorCode.GAME_START_FAILED | "GAME_001" | 400 + ChattingErrorCode.GAME_STOP_FAILED | "GAME_002" | 400 + ChattingErrorCode.GAME_NOT_IN_PROGRESS | "GAME_003" | 400 + ChattingErrorCode.GAME_ALREADY_IN_PROGRESS | "GAME_004" | 409 + ChattingErrorCode.NOT_GAME_STARTER | "GAME_005" | 403 + ChattingErrorCode.GAME_NOT_FOUND | "GAME_006" | 404 + ChattingErrorCode.GAME_NOT_ALLOWED_IN_CHAT_ROOM | "GAME_007" | 400 + ChattingErrorCode.GAME_RESTART_NOT_ALLOWED | "GAME_008" | 400 + ChattingErrorCode.GAME_START_NOT_HOST | "GAME_009" | 403 } def "모든 에러 코드 개수 확인"() { diff --git a/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatusTest.java b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatusTest.java index 03c59df5..212b6761 100644 --- a/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatusTest.java +++ b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatusTest.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.chatting.enums; import org.junit.jupiter.api.Test; + import static org.junit.jupiter.api.Assertions.*; class RoomStatusTest { @@ -15,7 +16,7 @@ void testFromString() { assertEquals(RoomStatus.WAITING, RoomStatus.fromString(null)); assertEquals(RoomStatus.WAITING, RoomStatus.fromString("invalid")); } - + @Test void testIsValid() { assertTrue(RoomStatus.isValid("WAITING")); @@ -27,14 +28,14 @@ void testIsValid() { assertFalse(RoomStatus.isValid(null)); assertFalse(RoomStatus.isValid("invalid")); } - + @Test void testGetCode() { assertEquals("waiting", RoomStatus.WAITING.getCode()); assertEquals("playing", RoomStatus.PLAYING.getCode()); assertEquals("finished", RoomStatus.FINISHED.getCode()); } - + @Test void testGetDisplayName() { assertEquals("대기 중", RoomStatus.WAITING.getDisplayName()); diff --git a/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomTypeTest.java b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomTypeTest.java index 7d8d13db..37347718 100644 --- a/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomTypeTest.java +++ b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomTypeTest.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.chatting.enums; import org.junit.jupiter.api.Test; + import static org.junit.jupiter.api.Assertions.*; class RoomTypeTest { @@ -13,7 +14,7 @@ void testFromString() { assertEquals(RoomType.CHAT, RoomType.fromString(null)); assertEquals(RoomType.CHAT, RoomType.fromString("invalid")); } - + @Test void testIsValid() { assertTrue(RoomType.isValid("CHAT")); @@ -23,13 +24,13 @@ void testIsValid() { assertFalse(RoomType.isValid(null)); assertFalse(RoomType.isValid("invalid")); } - + @Test void testGetCode() { assertEquals("chat", RoomType.CHAT.getCode()); assertEquals("game", RoomType.GAME.getCode()); } - + @Test void testGetDisplayName() { assertEquals("채팅방", RoomType.CHAT.getDisplayName()); diff --git a/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettingsTest.java b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettingsTest.java index e762e335..9d608c90 100644 --- a/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettingsTest.java +++ b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettingsTest.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.chatting.model; import org.junit.jupiter.api.Test; + import static org.junit.jupiter.api.Assertions.*; class GameSettingsTest { @@ -11,7 +12,7 @@ void testDefaultValues() { assertEquals(60, settings.getRoundTimeLimit()); assertFalse(settings.getAutoDeleteOnEnd()); } - + @Test void testCustomValues() { GameSettings settings = GameSettings.builder() @@ -23,7 +24,7 @@ void testCustomValues() { assertEquals(90, settings.getRoundTimeLimit()); assertTrue(settings.getAutoDeleteOnEnd()); } - + @Test void testNoArgsConstructor() { GameSettings settings = new GameSettings(); @@ -31,7 +32,7 @@ void testNoArgsConstructor() { assertEquals(60, settings.getRoundTimeLimit()); assertFalse(settings.getAutoDeleteOnEnd()); } - + @Test void testAllArgsConstructor() { GameSettings settings = new GameSettings(10, 90, true); @@ -39,14 +40,14 @@ void testAllArgsConstructor() { assertEquals(90, settings.getRoundTimeLimit()); assertTrue(settings.getAutoDeleteOnEnd()); } - + @Test void testSettersAndGetters() { GameSettings settings = new GameSettings(); settings.setMaxRounds(8); settings.setRoundTimeLimit(120); settings.setAutoDeleteOnEnd(true); - + assertEquals(8, settings.getMaxRounds()); assertEquals(120, settings.getRoundTimeLimit()); assertTrue(settings.getAutoDeleteOnEnd()); diff --git a/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md b/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md index 6c4ab164..e4c22aa4 100644 --- a/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md +++ b/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md @@ -21,6 +21,7 @@ ChatRoom.java (현재 - 혼합 모델) ``` **문제점:** + 1. `roundStartTime`만 전송, `serverTime` 누락 → 클라이언트 타이머 동기화 불가 2. 게임 세션이 채팅방에 종속 → 게임 상태 독립 관리 불가 3. 재접속 시 게임 상태 복구 어려움 @@ -43,6 +44,7 @@ handleRequest() { ``` **문제점:** + - 채팅/게임 구분 없이 모든 메시지가 동일 핸들러에서 처리 - 메시지에 `domain` 필드 없음 @@ -77,12 +79,12 @@ handleRequest() { ### 2.2 핵심 변경사항 -| 구분 | 현재 | 변경 후 | -|------|------|---------| -| 모델 | `ChatRoom`에 게임 필드 포함 | `ChatRoom` + `GameSession` 분리 | +| 구분 | 현재 | 변경 후 | +|-----|----------------------|---------------------------------| +| 모델 | `ChatRoom`에 게임 필드 포함 | `ChatRoom` + `GameSession` 분리 | | 타이머 | `roundStartTime`만 전송 | `roundStartTime` + `serverTime` | -| 메시지 | `messageType`만 존재 | `domain` + `messageType` | -| API | 채팅방 API만 존재 | 게임 세션 API 추가 | +| 메시지 | `messageType`만 존재 | `domain` + `messageType` | +| API | 채팅방 API만 존재 | 게임 세션 API 추가 | --- @@ -381,27 +383,27 @@ src/domains/ ### 5.2 채팅 메시지 -| Type | 방향 | data 필드 | -|------|------|-----------| -| `TEXT` | 양방향 | `messageId`, `userId`, `content`, `createdAt` | -| `USER_JOIN` | S→C | `userId`, `memberCount` | -| `USER_LEAVE` | S→C | `userId`, `memberCount` | -| `SYSTEM` | S→C | `content` | +| Type | 방향 | data 필드 | +|--------------|-----|-----------------------------------------------| +| `TEXT` | 양방향 | `messageId`, `userId`, `content`, `createdAt` | +| `USER_JOIN` | S→C | `userId`, `memberCount` | +| `USER_LEAVE` | S→C | `userId`, `memberCount` | +| `SYSTEM` | S→C | `content` | ### 5.3 게임 메시지 -| Type | 방향 | data 필드 | -|------|------|-----------| -| `GAME_START` | S→C | `gameSessionId`, `totalRounds`, `currentDrawerId`, `roundStartTime`, `serverTime`, `roundDuration`, `players` | -| `GAME_END` | S→C | `gameSessionId`, `reason`, `finalScores`, `winner` | -| `ROUND_START` | S→C | `currentRound`, `currentDrawerId`, `roundStartTime`, `serverTime`, `roundDuration`, `currentWord`(출제자만) | -| `ROUND_END` | S→C | `currentRound`, `answer`, `scores` | -| `DRAWING` | 양방향 | `drawingData` | -| `DRAWING_CLEAR` | 양방향 | - | -| `GUESS` | C→S | `content` | -| `CORRECT_ANSWER` | S→C | `userId`, `score`, `elapsedTime` | -| `SCORE_UPDATE` | S→C | `scores`, `currentRound`, `totalRounds` | -| `HINT` | S→C | `hint` | +| Type | 방향 | data 필드 | +|------------------|-----|---------------------------------------------------------------------------------------------------------------| +| `GAME_START` | S→C | `gameSessionId`, `totalRounds`, `currentDrawerId`, `roundStartTime`, `serverTime`, `roundDuration`, `players` | +| `GAME_END` | S→C | `gameSessionId`, `reason`, `finalScores`, `winner` | +| `ROUND_START` | S→C | `currentRound`, `currentDrawerId`, `roundStartTime`, `serverTime`, `roundDuration`, `currentWord`(출제자만) | +| `ROUND_END` | S→C | `currentRound`, `answer`, `scores` | +| `DRAWING` | 양방향 | `drawingData` | +| `DRAWING_CLEAR` | 양방향 | - | +| `GUESS` | C→S | `content` | +| `CORRECT_ANSWER` | S→C | `userId`, `score`, `elapsedTime` | +| `SCORE_UPDATE` | S→C | `scores`, `currentRound`, `totalRounds` | +| `HINT` | S→C | `hint` | ### 5.4 ROUND_START 상세 (핵심!) @@ -461,13 +463,13 @@ Week 4: 안정화 및 추가 기능 ## 7. 기대 효과 -| 항목 | 현재 | 개선 후 | -|------|------|---------| -| 타이머 정확도 | 클라이언트 시계 의존 | 서버 시간 기준 동기화 | -| 재접속 | 게임 상태 유실 | 완전 복구 가능 | -| 테스트 | 채팅/게임 분리 불가 | 독립 테스트 가능 | -| 확장성 | 새 게임 추가 어려움 | gameType으로 확장 용이 | -| 유지보수 | 책임 혼재 | 명확한 책임 분리 | +| 항목 | 현재 | 개선 후 | +|---------|-------------|------------------| +| 타이머 정확도 | 클라이언트 시계 의존 | 서버 시간 기준 동기화 | +| 재접속 | 게임 상태 유실 | 완전 복구 가능 | +| 테스트 | 채팅/게임 분리 불가 | 독립 테스트 가능 | +| 확장성 | 새 게임 추가 어려움 | gameType으로 확장 용이 | +| 유지보수 | 책임 혼재 | 명확한 책임 분리 | --- @@ -512,6 +514,7 @@ onRoundStart: (data) => { 3. **중기 (3-4주)**: FE/BE 완전 분리 + 자동 종료 + 재접속 복구 **핵심 원칙:** + - 단일 WebSocket 엔드포인트 유지 (비용/복잡도) - `domain` 필드로 채팅/게임 구분 - `serverTime`으로 정확한 타이머 동기화 diff --git a/docs/CICD-IMPLEMENTATION-QNA.md b/docs/CICD-IMPLEMENTATION-QNA.md index c9be8c92..e00c5a11 100644 --- a/docs/CICD-IMPLEMENTATION-QNA.md +++ b/docs/CICD-IMPLEMENTATION-QNA.md @@ -23,26 +23,30 @@ ## 2. 구성 요소 상세 설명 ### 2.1 Source Stage (GitHub) + - **트리거**: prod 브랜치에 Push 또는 PR Merge 시 자동 실행 - **연결 방식**: AWS CodeConnections (구 CodeStar Connections) - **아티팩트**: 소스 코드를 ZIP으로 압축하여 다음 스테이지로 전달 ### 2.2 Build Stage (CodeBuild) + - **런타임**: Amazon Linux 2, Java Corretto 21 - **빌드 단계**: - 1. **Install**: SAM CLI 설치 - 2. **Pre-build**: Gradle 테스트 실행 (`./gradlew clean test`) - 3. **Build**: SAM build & package - 4. **Post-build**: 완료 로그 + 1. **Install**: SAM CLI 설치 + 2. **Pre-build**: Gradle 테스트 실행 (`./gradlew clean test`) + 3. **Build**: SAM build & package + 4. **Post-build**: 완료 로그 - **캐싱**: Gradle 캐시를 S3에 저장하여 빌드 시간 단축 - **리포트**: JUnit 테스트 결과, JaCoCo 코드 커버리지 리포트 ### 2.3 Deploy Stage (CloudFormation) + - **배포 방식**: CloudFormation CREATE_UPDATE - **템플릿**: SAM으로 패키징된 `packaged-template.yaml` - **기능**: CAPABILITY_IAM, CAPABILITY_AUTO_EXPAND ### 2.4 Notification (SNS) + - **이벤트**: 파이프라인 시작, 성공, 실패 시 이메일 알림 - **구현**: CodeStar Notifications + SNS Topic @@ -60,11 +64,11 @@ BE_Repository/ ## 4. IAM 역할 구성 -| 역할 | 목적 | 주요 권한 | -|------|------|----------| -| PipelineRole | CodePipeline 서비스 역할 | S3, CodeBuild, CloudFormation, SNS | -| CodeBuildRole | CodeBuild 서비스 역할 | S3, CloudWatch Logs, CodeBuild Reports | -| CloudFormationRole | 리소스 배포 역할 | AdministratorAccess (SAM 리소스 생성용) | +| 역할 | 목적 | 주요 권한 | +|--------------------|---------------------|----------------------------------------| +| PipelineRole | CodePipeline 서비스 역할 | S3, CodeBuild, CloudFormation, SNS | +| CodeBuildRole | CodeBuild 서비스 역할 | S3, CloudWatch Logs, CodeBuild Reports | +| CloudFormationRole | 리소스 배포 역할 | AdministratorAccess (SAM 리소스 생성용) | --- @@ -106,6 +110,7 @@ AWS CodeConnections(구 CodeStar Connections)를 사용하여 연동했습니다 ``` **연동 과정:** + 1. AWS Console에서 CodeConnections 생성 2. GitHub OAuth 앱 승인 3. Connection ARN을 파이프라인에 설정 @@ -119,23 +124,23 @@ AWS CodeConnections(구 CodeStar Connections)를 사용하여 연동했습니다 ```yaml phases: - install: # 빌드 환경 설정 + install: # 빌드 환경 설정 runtime-versions: java: corretto21 commands: - pip3 install aws-sam-cli - pre_build: # 테스트 실행 (품질 게이트) + pre_build: # 테스트 실행 (품질 게이트) commands: - cd ServerlessFunction - ./gradlew clean test - build: # 실제 빌드 및 패키징 + build: # 실제 빌드 및 패키징 commands: - sam build - sam package --s3-bucket ... --output-template-file packaged-template.yaml - post_build: # 후처리 (로깅, 정리) + post_build: # 후처리 (로깅, 정리) commands: - echo "Build completed" ``` @@ -153,6 +158,7 @@ phases: 테스트 실패 시 배포가 자동으로 중단됩니다. **작동 원리:** + 1. `pre_build` 단계에서 `./gradlew clean test` 실행 2. 테스트 실패 시 Gradle이 exit code 1 반환 3. CodeBuild가 비정상 종료로 판단하여 빌드 실패 처리 @@ -176,11 +182,13 @@ Source ──▶ Build (테스트 실패) ──✗ Deploy SAM(Serverless Application Model)은 CloudFormation의 확장입니다. **관계:** + - SAM 템플릿은 CloudFormation 템플릿의 상위 집합 - `sam build`/`sam package` 실행 시 SAM 템플릿이 표준 CloudFormation 템플릿으로 변환 - 변환된 템플릿(`packaged-template.yaml`)을 CloudFormation이 배포 **SAM의 장점:** + 1. 간결한 문법: `AWS::Serverless::Function`으로 Lambda + API Gateway + IAM 역할 한번에 정의 2. 로컬 테스트: `sam local invoke`로 Lambda 로컬 실행 가능 3. 자동 패키징: 코드를 S3에 업로드하고 참조 자동 생성 @@ -210,16 +218,18 @@ Properties: CloudFormation의 기본 롤백 기능을 활용합니다. **설정:** + ```yaml # samconfig.toml disable_rollback = false # 롤백 활성화 ``` **롤백 시나리오:** + 1. **배포 실패 시**: CloudFormation이 자동으로 이전 상태로 롤백 2. **Lambda 오류 시**: - - 현재는 기본 롤백만 사용 - - 추가로 Canary/Linear 배포 설정 가능 (AWS CodeDeploy 연동) + - 현재는 기본 롤백만 사용 + - 추가로 Canary/Linear 배포 설정 가능 (AWS CodeDeploy 연동) ```yaml # 점진적 배포 예시 (선택적 구현) @@ -248,6 +258,7 @@ ArtifactBucket: ``` **아티팩트 종류:** + 1. **SourceArtifact**: GitHub에서 가져온 소스 코드 ZIP 2. **BuildArtifact**: 빌드된 `packaged-template.yaml` 3. **Cache**: Gradle 캐시 (빌드 시간 단축용) @@ -294,18 +305,22 @@ PipelineNotificationRule: **A9:** **문제 1: Gradle Wrapper를 찾을 수 없음** + - 원인: `.gitignore`에서 `gradle/` 폴더 전체가 제외됨 - 해결: `.gitignore` 수정하여 `!gradle/wrapper/` 예외 추가 **문제 2: JAVA_HOME 환경 변수 오류** + - 원인: CodeBuild에서 JAVA_HOME을 수동 설정했으나 경로 불일치 - 해결: `runtime-versions: java: corretto21`만 사용하고 JAVA_HOME 수동 설정 제거 **문제 3: SAM package S3 버킷 참조 오류** + - 원인: 환경 변수를 사용한 멀티라인 명령어에서 변수 치환 실패 - 해결: 단일 라인으로 버킷 이름 직접 지정 **문제 4: Lambda 환경 변수 누락** + - 원인: WebSocket Connect 함수에 `WEBSOCKET_ENDPOINT` 환경 변수 미설정 - 해결: `template.yaml`에 환경 변수 추가 @@ -316,22 +331,22 @@ PipelineNotificationRule: **A10:** 1. **테스트 커버리지 게이트** - - 현재: 테스트 실행만 함 - - 개선: 커버리지 80% 미만 시 빌드 실패 설정 + - 현재: 테스트 실행만 함 + - 개선: 커버리지 80% 미만 시 빌드 실패 설정 2. **점진적 배포 (Canary/Blue-Green)** - - 현재: 전체 교체 배포 - - 개선: Lambda Alias + CodeDeploy로 Canary 배포 구현 + - 현재: 전체 교체 배포 + - 개선: Lambda Alias + CodeDeploy로 Canary 배포 구현 3. **다중 환경 지원** - - 현재: prod 단일 환경 - - 개선: dev, staging, prod 분리 및 승인 단계 추가 + - 현재: prod 단일 환경 + - 개선: dev, staging, prod 분리 및 승인 단계 추가 4. **보안 스캔** - - 개선: 의존성 취약점 스캔 (OWASP Dependency-Check) 추가 + - 개선: 의존성 취약점 스캔 (OWASP Dependency-Check) 추가 5. **성능 테스트** - - 개선: 배포 전 부하 테스트 단계 추가 + - 개선: 배포 전 부하 테스트 단계 추가 --- @@ -341,6 +356,7 @@ PipelineNotificationRule: 파이프라인 자체도 CloudFormation 템플릿(`pipeline.yaml`)으로 정의했습니다. **장점:** + 1. **버전 관리**: 인프라 변경 이력을 Git으로 추적 2. **재현성**: 동일한 파이프라인을 다른 프로젝트/계정에 쉽게 복제 3. **리뷰 가능**: 인프라 변경도 코드 리뷰 프로세스 적용 @@ -353,16 +369,17 @@ PipelineNotificationRule: **A12:** -| 항목 | CodeBuild | Jenkins | -|------|-----------|---------| -| 관리 | 완전 관리형 (서버리스) | 자체 서버 운영 필요 | -| 비용 | 빌드 시간 기반 과금 | 서버 운영 비용 | -| 확장성 | 자동 확장 | 수동 확장 필요 | -| AWS 통합 | 네이티브 통합 | 플러그인 필요 | +| 항목 | CodeBuild | Jenkins | +|--------|---------------|----------------------| +| 관리 | 완전 관리형 (서버리스) | 자체 서버 운영 필요 | +| 비용 | 빌드 시간 기반 과금 | 서버 운영 비용 | +| 확장성 | 자동 확장 | 수동 확장 필요 | +| AWS 통합 | 네이티브 통합 | 플러그인 필요 | | 커스터마이징 | buildspec.yml | Jenkinsfile (Groovy) | -| 플러그인 | 제한적 | 풍부한 생태계 | +| 플러그인 | 제한적 | 풍부한 생태계 | **선택 이유:** + - AWS 서비스 중심 아키텍처에서 네이티브 통합의 이점 - 서버 관리 부담 없음 - SAM/CloudFormation과의 원활한 연동 @@ -371,15 +388,15 @@ PipelineNotificationRule: ## 6. 핵심 용어 정리 -| 용어 | 설명 | -|------|------| -| CI (Continuous Integration) | 코드 변경을 자주 통합하고 자동 테스트하는 방식 | -| CD (Continuous Delivery/Deployment) | 자동으로 프로덕션까지 배포하는 방식 | -| Pipeline | 소스-빌드-배포로 이어지는 자동화된 워크플로우 | -| Artifact | 빌드 결과물 (패키징된 코드, 템플릿 등) | -| buildspec.yml | CodeBuild의 빌드 명세 파일 | -| SAM | Serverless Application Model - 서버리스 앱 정의 프레임워크 | -| IaC | Infrastructure as Code - 코드로 인프라 관리 | +| 용어 | 설명 | +|-------------------------------------|------------------------------------------------| +| CI (Continuous Integration) | 코드 변경을 자주 통합하고 자동 테스트하는 방식 | +| CD (Continuous Delivery/Deployment) | 자동으로 프로덕션까지 배포하는 방식 | +| Pipeline | 소스-빌드-배포로 이어지는 자동화된 워크플로우 | +| Artifact | 빌드 결과물 (패키징된 코드, 템플릿 등) | +| buildspec.yml | CodeBuild의 빌드 명세 파일 | +| SAM | Serverless Application Model - 서버리스 앱 정의 프레임워크 | +| IaC | Infrastructure as Code - 코드로 인프라 관리 | --- diff --git a/docs/FRONTEND-API-GUIDE.md b/docs/FRONTEND-API-GUIDE.md index cae65a1a..697d406a 100644 --- a/docs/FRONTEND-API-GUIDE.md +++ b/docs/FRONTEND-API-GUIDE.md @@ -3,6 +3,7 @@ ## 1. 아키텍처 구조 (업데이트됨) ### 채팅방과 게임방 분리 + ``` RoomType enum ├── CHAT ("chat") - 일반 채팅방 @@ -15,6 +16,7 @@ RoomStatus enum ``` ### GSI1SK 인덱스 설계 + ``` GSI1PK: "ROOMS" (고정) GSI1SK: {type}#{gameType}#{status}#{level}#{createdAt} @@ -31,20 +33,20 @@ GSI1SK: {type}#{gameType}#{status}#{level}#{createdAt} ## 2. 방 타입 (RoomType) -| 타입 | 코드 | 설명 | -|------|------|------| -| `CHAT` | `chat` | 일반 채팅방 | +| 타입 | 코드 | 설명 | +|--------|--------|---------------| +| `CHAT` | `chat` | 일반 채팅방 | | `GAME` | `game` | 게임방 (캐치마인드 등) | --- ## 3. 방 상태 (RoomStatus) -| 상태 | 코드 | 설명 | 게임 시작 가능 | -|------|------|------|:-------------:| -| `WAITING` | `waiting` | 대기 중 | O | -| `PLAYING` | `playing` | 게임 진행 중 | X | -| `FINISHED` | `finished` | 게임 종료됨 | O | +| 상태 | 코드 | 설명 | 게임 시작 가능 | +|------------|------------|---------|:--------:| +| `WAITING` | `waiting` | 대기 중 | O | +| `PLAYING` | `playing` | 게임 진행 중 | X | +| `FINISHED` | `finished` | 게임 종료됨 | O | --- @@ -52,23 +54,23 @@ GSI1SK: {type}#{gameType}#{status}#{level}#{createdAt} ### 채팅방 API (`/api/chat/rooms`) -| Method | Endpoint | 설명 | -|--------|----------|------| -| POST | `/rooms` | 채팅방/게임방 생성 | -| GET | `/rooms` | 방 목록 조회 (필터 지원) | -| GET | `/rooms/{roomId}` | 방 상세 조회 | -| POST | `/rooms/{roomId}/join` | 방 입장 (roomToken 발급) | -| POST | `/rooms/{roomId}/leave` | 방 퇴장 | -| DELETE | `/rooms/{roomId}` | 방 삭제 (방장만) | +| Method | Endpoint | 설명 | +|--------|-------------------------|---------------------| +| POST | `/rooms` | 채팅방/게임방 생성 | +| GET | `/rooms` | 방 목록 조회 (필터 지원) | +| GET | `/rooms/{roomId}` | 방 상세 조회 | +| POST | `/rooms/{roomId}/join` | 방 입장 (roomToken 발급) | +| POST | `/rooms/{roomId}/leave` | 방 퇴장 | +| DELETE | `/rooms/{roomId}` | 방 삭제 (방장만) | ### 게임 API (`/api/game`) -| Method | Endpoint | 설명 | -|--------|----------|------| -| POST | `/rooms/{roomId}/game/start` | 게임 시작 | -| POST | `/rooms/{roomId}/game/stop` | 게임 중단 | -| GET | `/rooms/{roomId}/game/status` | 게임 상태 조회 | -| GET | `/rooms/{roomId}/game/scores` | 점수판 조회 | +| Method | Endpoint | 설명 | +|--------|-------------------------------|----------| +| POST | `/rooms/{roomId}/game/start` | 게임 시작 | +| POST | `/rooms/{roomId}/game/stop` | 게임 중단 | +| GET | `/rooms/{roomId}/game/status` | 게임 상태 조회 | +| GET | `/rooms/{roomId}/game/scores` | 점수판 조회 | --- @@ -78,14 +80,14 @@ GSI1SK: {type}#{gameType}#{status}#{level}#{createdAt} GET /api/chat/rooms?type=GAME&gameType=CATCHMIND&status=WAITING&level=intermediate&limit=10&cursor=xxx ``` -| 파라미터 | 타입 | 설명 | 예시 | -|----------|------|------|------| -| `type` | string | 방 타입 필터 | `CHAT`, `GAME` | -| `gameType` | string | 게임 타입 | `CATCHMIND` | -| `status` | string | 상태 필터 | `WAITING`, `PLAYING`, `FINISHED` | -| `level` | string | 난이도 필터 | `beginner`, `intermediate`, `advanced` | -| `limit` | number | 조회 개수 (기본 10, 최대 20) | | -| `cursor` | string | 페이지네이션 커서 | | +| 파라미터 | 타입 | 설명 | 예시 | +|------------|--------|----------------------|----------------------------------------| +| `type` | string | 방 타입 필터 | `CHAT`, `GAME` | +| `gameType` | string | 게임 타입 | `CATCHMIND` | +| `status` | string | 상태 필터 | `WAITING`, `PLAYING`, `FINISHED` | +| `level` | string | 난이도 필터 | `beginner`, `intermediate`, `advanced` | +| `limit` | number | 조회 개수 (기본 10, 최대 20) | | +| `cursor` | string | 페이지네이션 커서 | | ### 필터 조합 예시 @@ -136,6 +138,7 @@ GET /api/chat/rooms?type=GAME&status=PLAYING&level=advanced ## 6. 방 생성 요청 (업데이트됨) ### 채팅방 생성 + ```json { "name": "영어 스터디 채팅방", @@ -147,6 +150,7 @@ GET /api/chat/rooms?type=GAME&status=PLAYING&level=advanced ``` ### 게임방 생성 + ```json { "name": "캐치마인드 게임", @@ -163,6 +167,7 @@ GET /api/chat/rooms?type=GAME&status=PLAYING&level=advanced ## 7. 프론트엔드에서 방 타입 구분 ### 방법 1: API 필터 사용 (권장) + ```javascript // 게임방만 조회 const gameRooms = await fetch('/api/chat/rooms?type=GAME'); @@ -175,6 +180,7 @@ const chatRooms = await fetch('/api/chat/rooms?type=CHAT'); ``` ### 방법 2: 전체 조회 후 클라이언트 필터링 + ```javascript const allRooms = await fetchRooms(); @@ -193,16 +199,19 @@ const waitingRooms = allRooms.filter(room => room.status === 'WAITING'); ## 8. WebSocket 연결 ### 채팅/게임 WebSocket + ``` wss://t378dif43l.execute-api.ap-northeast-2.amazonaws.com/dev?roomToken={roomToken} ``` ### Grammar WebSocket + ``` wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} ``` ### 연결 순서 + 1. `POST /rooms/{roomId}/join` → `roomToken` 발급 2. WebSocket 연결 시 `roomToken` 쿼리 파라미터로 전달 @@ -210,20 +219,20 @@ wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} ## 9. WebSocket 메시지 타입 (messageType) -| 코드 | 타입 | 설명 | -|------|------|------| -| `MSG` | 일반 메시지 | 일반 채팅 메시지 | -| `VOICE` | 음성 메시지 | 음성 채팅 | -| `JOIN` | 입장 알림 | 사용자 입장 | -| `LEAVE` | 퇴장 알림 | 사용자 퇴장 | -| `GAME_START` | 게임 시작 | 게임 시작 알림 | -| `GAME_END` | 게임 종료 | 게임 종료 + 최종 순위 | -| `ROUND_START` | 라운드 시작 | 새 라운드 시작 | -| `ROUND_END` | 라운드 종료 | 정답 공개 | -| `ANSWER_CORRECT` | 정답 | 정답 맞춤 | -| `HINT` | 힌트 | 힌트 제공 | -| `SKIP` | 스킵 | 라운드 스킵 | -| `SYSTEM` | 시스템 | 시스템 메시지 | +| 코드 | 타입 | 설명 | +|------------------|--------|---------------| +| `MSG` | 일반 메시지 | 일반 채팅 메시지 | +| `VOICE` | 음성 메시지 | 음성 채팅 | +| `JOIN` | 입장 알림 | 사용자 입장 | +| `LEAVE` | 퇴장 알림 | 사용자 퇴장 | +| `GAME_START` | 게임 시작 | 게임 시작 알림 | +| `GAME_END` | 게임 종료 | 게임 종료 + 최종 순위 | +| `ROUND_START` | 라운드 시작 | 새 라운드 시작 | +| `ROUND_END` | 라운드 종료 | 정답 공개 | +| `ANSWER_CORRECT` | 정답 | 정답 맞춤 | +| `HINT` | 힌트 | 힌트 제공 | +| `SKIP` | 스킵 | 라운드 스킵 | +| `SYSTEM` | 시스템 | 시스템 메시지 | --- @@ -231,13 +240,13 @@ wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} 채팅 메시지로 게임 명령어 전송: -| 명령어 | 설명 | 권한 | -|--------|------|------| -| `/start` | 게임 시작 | 방장 (2명 이상 접속 시) | -| `/stop` | 게임 중단 | 방장 또는 게임 시작자 | -| `/skip` | 라운드 스킵 | 누구나 | -| `/hint` | 힌트 제공 | 출제자만 | -| `/score` | 점수 확인 | 누구나 | +| 명령어 | 설명 | 권한 | +|----------|--------|-----------------| +| `/start` | 게임 시작 | 방장 (2명 이상 접속 시) | +| `/stop` | 게임 중단 | 방장 또는 게임 시작자 | +| `/skip` | 라운드 스킵 | 누구나 | +| `/hint` | 힌트 제공 | 출제자만 | +| `/score` | 점수 확인 | 누구나 | --- @@ -271,6 +280,7 @@ wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} - 공백 무시 ### 점수 계산 + ``` 기본 점수: 10점 시간 보너스: (제한시간 - 경과시간) * 0.5 @@ -283,12 +293,12 @@ wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} ## 13. 게임 설정 -| 설정 | 기본값 | 환경변수 | -|------|--------|----------| -| 총 라운드 수 | 5 | `GAME_TOTAL_ROUNDS` | -| 라운드 제한 시간(초) | 60 | `GAME_ROUND_TIME_LIMIT` | -| 빠른 정답 기준(ms) | 5000 | `GAME_QUICK_GUESS_THRESHOLD_MS` | -| 게임 전체 제한(초) | 420 (7분) | `GAME_TIME_LIMIT_SECONDS` | +| 설정 | 기본값 | 환경변수 | +|--------------|----------|---------------------------------| +| 총 라운드 수 | 5 | `GAME_TOTAL_ROUNDS` | +| 라운드 제한 시간(초) | 60 | `GAME_ROUND_TIME_LIMIT` | +| 빠른 정답 기준(ms) | 5000 | `GAME_QUICK_GUESS_THRESHOLD_MS` | +| 게임 전체 제한(초) | 420 (7분) | `GAME_TIME_LIMIT_SECONDS` | --- @@ -304,36 +314,38 @@ wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} ## 15. 에러 코드 -| 코드 | HTTP | 설명 | -|------|------|------| -| `ROOM_001` | 404 | 채팅방 없음 | -| `ROOM_002` | 409 | 채팅방 이미 존재 | -| `ROOM_003` | 400 | 채팅방 인원 초과 | -| `ROOM_004` | 400 | 채팅방 종료됨 | -| `ROOM_005` | 401 | 비밀번호 틀림 | -| `ROOM_006` | 403 | 방장 권한 없음 | -| `MEMBER_001` | 403 | 채팅방 멤버 아님 | -| `MEMBER_002` | 409 | 이미 참여 중 | -| `GAME_001` | 400 | 게임 시작 실패 | -| `GAME_002` | 400 | 게임 중단 실패 | -| `GAME_003` | 400 | 게임 진행 중 아님 | -| `GAME_004` | 409 | 게임 이미 진행 중 | -| `GAME_005` | 403 | 게임 시작자 아님 | -| `GAME_006` | 404 | 게임 없음 | -| `GAME_007` | 400 | 채팅방에서 게임 불가 | -| `GAME_008` | 400 | 게임 재시작 불가 | -| `GAME_009` | 403 | 방장만 게임 시작 가능 | +| 코드 | HTTP | 설명 | +|--------------|------|--------------| +| `ROOM_001` | 404 | 채팅방 없음 | +| `ROOM_002` | 409 | 채팅방 이미 존재 | +| `ROOM_003` | 400 | 채팅방 인원 초과 | +| `ROOM_004` | 400 | 채팅방 종료됨 | +| `ROOM_005` | 401 | 비밀번호 틀림 | +| `ROOM_006` | 403 | 방장 권한 없음 | +| `MEMBER_001` | 403 | 채팅방 멤버 아님 | +| `MEMBER_002` | 409 | 이미 참여 중 | +| `GAME_001` | 400 | 게임 시작 실패 | +| `GAME_002` | 400 | 게임 중단 실패 | +| `GAME_003` | 400 | 게임 진행 중 아님 | +| `GAME_004` | 409 | 게임 이미 진행 중 | +| `GAME_005` | 403 | 게임 시작자 아님 | +| `GAME_006` | 404 | 게임 없음 | +| `GAME_007` | 400 | 채팅방에서 게임 불가 | +| `GAME_008` | 400 | 게임 재시작 불가 | +| `GAME_009` | 403 | 방장만 게임 시작 가능 | --- ## 16. UI 구현 가이드 ### 탭 구조 (권장) + ``` [전체] [채팅방] [게임방] ``` ### 게임방 상태 표시 + ``` 대기 중 (WAITING) → 초록색 뱃지 "참여 가능" 진행 중 (PLAYING) → 빨간색 뱃지 "게임 중" @@ -341,6 +353,7 @@ wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} ``` ### 게임방 카드 정보 + ``` ┌─────────────────────────────┐ │ 캐치마인드 - 영어 단어 맞추기 │ From dc0348a5bde3c581e30609c8951e376b3d1869cd Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 11:54:23 +0900 Subject: [PATCH 42/99] chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes --- .sisyphus/plans/cicd-pipeline-plan.md | 771 -------------------------- 1 file changed, 771 deletions(-) delete mode 100644 .sisyphus/plans/cicd-pipeline-plan.md diff --git a/.sisyphus/plans/cicd-pipeline-plan.md b/.sisyphus/plans/cicd-pipeline-plan.md deleted file mode 100644 index 2ce6f8e5..00000000 --- a/.sisyphus/plans/cicd-pipeline-plan.md +++ /dev/null @@ -1,771 +0,0 @@ -# CI/CD Pipeline 기획서 - -> **프로젝트**: Group2 English Study Backend -> **작성일**: 2026-01-22 -> **버전**: 1.0 - ---- - -## 1. 요구사항 요약 - -| 항목 | 선택 | -|---------|----------------------------------| -| 소스 저장소 | GitHub (유지) + CodePipeline v2 연결 | -| 배포 환경 | prod 단일 환경 | -| 트리거 | prod 브랜치 push 또는 PR merge | -| 승인 프로세스 | 완전 자동 (테스트 통과 시 자동 배포) | -| 알림 | AWS SNS → 이메일 | - ---- - -## 2. 전체 아키텍처 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ AWS CodePipeline │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │ -│ │ Source │───▶│ Build │───▶│ Deploy │───▶│ Notify │ │ -│ │ (GitHub) │ │ (CodeBuild) │ │(CloudFormation)│ │ (SNS) │ │ -│ └──────────┘ └──────────────┘ └──────────────┘ └──────────┘ │ -│ │ │ │ │ │ -│ ▼ ▼ ▼ ▼ │ -│ GitHub v2 ┌────────────┐ SAM Deploy Email 알림 │ -│ Connection │ buildspec │ (sam deploy) │ -│ │ ────────── │ │ -│ │ - gradle │ │ -│ │ - sam build│ │ -│ │ - test │ │ -│ └────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────┐ -│ AWS Resources (prod) │ -├─────────────────────────────────────────────────────────────────────────┤ -│ Lambda (20+) │ API Gateway │ WebSocket │ DynamoDB │ Cognito │ S3 │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 3. 파이프라인 단계 상세 - -### 3.1 Source Stage - -| 설정 | 값 | -|-----------------|------------------------| -| Provider | GitHub (v2 Connection) | -| Repository | BE_Repository | -| Branch | `prod` | -| Trigger | Push / PR Merge | -| Output Artifact | SourceArtifact | - -**GitHub Connection 설정 필요**: - -- AWS Console → CodePipeline → Settings → Connections -- GitHub App 설치 및 Repository 권한 부여 - -### 3.2 Build Stage - -| 설정 | 값 | -|-----------------|--------------------------------------------------| -| Provider | AWS CodeBuild | -| Environment | `aws/codebuild/amazonlinux2-x86_64-standard:5.0` | -| Compute | `BUILD_GENERAL1_MEDIUM` (7GB RAM, 4 vCPU) | -| Timeout | 30분 | -| Input Artifact | SourceArtifact | -| Output Artifact | BuildArtifact | - -**빌드 단계**: - -1. Java 21 환경 설정 -2. Gradle 빌드 및 테스트 -3. SAM 빌드 -4. 아티팩트 패키징 - -### 3.3 Deploy Stage - -| 설정 | 값 | -|--------------|----------------------------------------| -| Provider | CloudFormation | -| Action Mode | CREATE_UPDATE | -| Stack Name | `group2-englishstudy-prod` | -| Template | packaged-template.yaml | -| Capabilities | CAPABILITY_IAM, CAPABILITY_AUTO_EXPAND | -| Role | CloudFormationExecutionRole | - -### 3.4 Notification Stage - -| 설정 | 값 | -|----------|-------------------------------| -| Provider | AWS SNS | -| Topic | `cicd-pipeline-notifications` | -| Events | 성공, 실패, 시작 | - ---- - -## 4. 필요한 AWS 리소스 - -### 4.1 신규 생성 필요 - -| 리소스 | 이름 | 용도 | -|-------------------|------------------------------------------|---------------| -| CodePipeline | `group2-englishstudy-pipeline` | CI/CD 오케스트레이션 | -| CodeBuild Project | `group2-englishstudy-build` | 빌드 및 테스트 | -| S3 Bucket | `group2-englishstudy-pipeline-artifacts` | 파이프라인 아티팩트 저장 | -| GitHub Connection | `github-connection` | GitHub 연결 | -| SNS Topic | `cicd-pipeline-notifications` | 알림 | -| IAM Role | `CodePipelineServiceRole` | 파이프라인 실행 | -| IAM Role | `CodeBuildServiceRole` | 빌드 실행 | -| IAM Role | `CloudFormationExecutionRole` | 스택 배포 | - -### 4.2 기존 활용 - -| 리소스 | 용도 | -|--------------------------|--------------| -| S3 `group2-englishstudy` | Lambda 코드 저장 | -| DynamoDB Tables | 데이터 저장 | -| Cognito User Pool | 인증 | - ---- - -## 5. buildspec.yml - -```yaml -version: 0.2 - -env: - variables: - JAVA_HOME: /usr/lib/jvm/java-21-amazon-corretto - SAM_CLI_TELEMETRY: 0 - -phases: - install: - runtime-versions: - java: corretto21 - commands: - - echo "Installing SAM CLI..." - - pip3 install aws-sam-cli - - sam --version - - pre_build: - commands: - - echo "Running tests..." - - cd ServerlessFunction - - chmod +x gradlew - - ./gradlew clean test - - echo "Tests completed" - - build: - commands: - - echo "Building SAM application..." - - cd $CODEBUILD_SRC_DIR/ServerlessFunction - - sam build - - echo "Packaging SAM application..." - - sam package \ - --s3-bucket ${ARTIFACT_BUCKET} \ - --s3-prefix sam-packages \ - --output-template-file packaged-template.yaml - - post_build: - commands: - - echo "Build completed on $(date)" - -artifacts: - files: - - ServerlessFunction/packaged-template.yaml - - ServerlessFunction/samconfig.toml - discard-paths: no - -cache: - paths: - - '/root/.gradle/caches/**/*' - - '/root/.gradle/wrapper/**/*' - -reports: - junit-reports: - files: - - 'ServerlessFunction/build/test-results/test/*.xml' - file-format: JUNITXML - jacoco-reports: - files: - - 'ServerlessFunction/build/reports/jacoco/test/jacocoTestReport.xml' - file-format: JACOCOXML -``` - ---- - -## 6. samconfig.toml - -```toml -version = 0.1 - -[default.global.parameters] -stack_name = "group2-englishstudy" - -[prod] -[prod.deploy] -[prod.deploy.parameters] -stack_name = "group2-englishstudy-prod" -s3_bucket = "group2-englishstudy-pipeline-artifacts" -s3_prefix = "sam-deploy" -region = "ap-northeast-2" -capabilities = "CAPABILITY_IAM CAPABILITY_AUTO_EXPAND" -confirm_changeset = false -disable_rollback = false -fail_on_empty_changeset = false - -[prod.build.parameters] -cached = true -parallel = true -``` - ---- - -## 7. IAM 역할 및 정책 - -### 7.1 CodePipeline Service Role - -```json -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "codestar-connections:UseConnection" - ], - "Resource": "arn:aws:codestar-connections:*:*:connection/*" - }, - { - "Effect": "Allow", - "Action": [ - "codebuild:BatchGetBuilds", - "codebuild:StartBuild" - ], - "Resource": "*" - }, - { - "Effect": "Allow", - "Action": [ - "cloudformation:CreateStack", - "cloudformation:DeleteStack", - "cloudformation:DescribeStacks", - "cloudformation:UpdateStack", - "cloudformation:CreateChangeSet", - "cloudformation:DeleteChangeSet", - "cloudformation:DescribeChangeSet", - "cloudformation:ExecuteChangeSet", - "cloudformation:SetStackPolicy" - ], - "Resource": "*" - }, - { - "Effect": "Allow", - "Action": [ - "s3:GetObject", - "s3:PutObject", - "s3:GetBucketVersioning" - ], - "Resource": [ - "arn:aws:s3:::group2-englishstudy-pipeline-artifacts", - "arn:aws:s3:::group2-englishstudy-pipeline-artifacts/*" - ] - }, - { - "Effect": "Allow", - "Action": [ - "iam:PassRole" - ], - "Resource": "*", - "Condition": { - "StringEqualsIfExists": { - "iam:PassedToService": "cloudformation.amazonaws.com" - } - } - }, - { - "Effect": "Allow", - "Action": [ - "sns:Publish" - ], - "Resource": "arn:aws:sns:*:*:cicd-pipeline-notifications" - } - ] -} -``` - -### 7.2 CodeBuild Service Role - -```json -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "logs:CreateLogGroup", - "logs:CreateLogStream", - "logs:PutLogEvents" - ], - "Resource": "arn:aws:logs:*:*:*" - }, - { - "Effect": "Allow", - "Action": [ - "s3:GetObject", - "s3:PutObject", - "s3:GetBucketAcl", - "s3:GetBucketLocation" - ], - "Resource": [ - "arn:aws:s3:::group2-englishstudy-pipeline-artifacts", - "arn:aws:s3:::group2-englishstudy-pipeline-artifacts/*", - "arn:aws:s3:::group2-englishstudy", - "arn:aws:s3:::group2-englishstudy/*" - ] - }, - { - "Effect": "Allow", - "Action": [ - "codebuild:CreateReportGroup", - "codebuild:CreateReport", - "codebuild:UpdateReport", - "codebuild:BatchPutTestCases", - "codebuild:BatchPutCodeCoverages" - ], - "Resource": "arn:aws:codebuild:*:*:report-group/*" - } - ] -} -``` - -### 7.3 CloudFormation Execution Role - -```json -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "lambda:*", - "apigateway:*", - "dynamodb:*", - "s3:*", - "cognito-idp:*", - "sns:*", - "sqs:*", - "iam:*", - "logs:*", - "events:*", - "scheduler:*", - "cloudformation:*" - ], - "Resource": "*" - } - ] -} -``` - -> **보안 주의**: 실제 운영 환경에서는 위 정책을 최소 권한 원칙에 맞게 세분화해야 합니다. - ---- - -## 8. SNS 알림 설정 - -### 8.1 SNS Topic 생성 - -```bash -aws sns create-topic --name cicd-pipeline-notifications -``` - -### 8.2 이메일 구독 추가 - -```bash -aws sns subscribe \ - --topic-arn arn:aws:sns:ap-northeast-2:ACCOUNT_ID:cicd-pipeline-notifications \ - --protocol email \ - --notification-endpoint your-email@example.com -``` - -### 8.3 CloudWatch Event Rule (파이프라인 상태 변경) - -```json -{ - "source": [ - "aws.codepipeline" - ], - "detail-type": [ - "CodePipeline Pipeline Execution State Change" - ], - "detail": { - "pipeline": [ - "group2-englishstudy-pipeline" - ], - "state": [ - "SUCCEEDED", - "FAILED", - "STARTED" - ] - } -} -``` - ---- - -## 9. 비용 추정 (월간) - -| 서비스 | 예상 사용량 | 예상 비용 | -|-----------------|----------------------|-----------| -| CodePipeline | 1 파이프라인, ~100회 실행 | $1.00 | -| CodeBuild | ~100회 x 10분 = 1,000분 | $5.00 | -| S3 (아티팩트) | ~10GB | $0.25 | -| CloudWatch Logs | ~5GB | $2.50 | -| SNS | ~100 알림 | $0.01 | -| **총 예상 비용** | | **~$9/월** | - -> 실제 비용은 배포 빈도와 빌드 시간에 따라 달라질 수 있습니다. - ---- - -## 10. 구현 체크리스트 - -### Phase 1: 사전 준비 - -- [ ] AWS 계정 ID 확인 -- [ ] ap-northeast-2 리전 선택 -- [ ] 필요한 권한 확인 (Admin 또는 필요 권한) - -### Phase 2: 기반 리소스 생성 - -- [ ] S3 버킷 생성: `group2-englishstudy-pipeline-artifacts` -- [ ] SNS Topic 생성: `cicd-pipeline-notifications` -- [ ] SNS 이메일 구독 설정 및 확인 -- [ ] GitHub Connection 생성 (AWS Console) - -### Phase 3: IAM 역할 생성 - -- [ ] CodePipelineServiceRole 생성 -- [ ] CodeBuildServiceRole 생성 -- [ ] CloudFormationExecutionRole 생성 - -### Phase 4: CodeBuild 프로젝트 생성 - -- [ ] 프로젝트 생성: `group2-englishstudy-build` -- [ ] 환경 설정 (Amazon Linux 2, Java 21) -- [ ] buildspec.yml 추가 - -### Phase 5: CodePipeline 생성 - -- [ ] 파이프라인 생성: `group2-englishstudy-pipeline` -- [ ] Source Stage 설정 (GitHub v2) -- [ ] Build Stage 설정 (CodeBuild) -- [ ] Deploy Stage 설정 (CloudFormation) -- [ ] 알림 설정 (SNS) - -### Phase 6: 테스트 및 검증 - -- [ ] prod 브랜치에 테스트 커밋 -- [ ] 파이프라인 자동 트리거 확인 -- [ ] 빌드 성공 확인 -- [ ] 배포 성공 확인 -- [ ] 이메일 알림 수신 확인 -- [ ] Lambda 함수 정상 동작 확인 - -### Phase 7: 문서화 - -- [ ] README에 CI/CD 섹션 추가 -- [ ] 트러블슈팅 가이드 작성 -- [ ] 롤백 절차 문서화 - ---- - -## 11. 파이프라인 CloudFormation 템플릿 (선택) - -아래 템플릿으로 전체 파이프라인을 IaC로 관리할 수 있습니다: - -```yaml -AWSTemplateFormatVersion: '2010-09-09' -Description: CI/CD Pipeline for Group2 English Study - -Parameters: - GitHubConnectionArn: - Type: String - Description: ARN of the GitHub Connection - - GitHubRepo: - Type: String - Default: "your-org/BE_Repository" - - GitHubBranch: - Type: String - Default: "prod" - - NotificationEmail: - Type: String - Description: Email for pipeline notifications - -Resources: - # S3 Bucket for Artifacts - ArtifactBucket: - Type: AWS::S3::Bucket - Properties: - BucketName: group2-englishstudy-pipeline-artifacts - VersioningConfiguration: - Status: Enabled - - # SNS Topic for Notifications - NotificationTopic: - Type: AWS::SNS::Topic - Properties: - TopicName: cicd-pipeline-notifications - - EmailSubscription: - Type: AWS::SNS::Subscription - Properties: - TopicArn: !Ref NotificationTopic - Protocol: email - Endpoint: !Ref NotificationEmail - - # CodeBuild Project - CodeBuildProject: - Type: AWS::CodeBuild::Project - Properties: - Name: group2-englishstudy-build - ServiceRole: !GetAtt CodeBuildRole.Arn - Artifacts: - Type: CODEPIPELINE - Environment: - Type: LINUX_CONTAINER - ComputeType: BUILD_GENERAL1_MEDIUM - Image: aws/codebuild/amazonlinux2-x86_64-standard:5.0 - EnvironmentVariables: - - Name: ARTIFACT_BUCKET - Value: !Ref ArtifactBucket - Source: - Type: CODEPIPELINE - BuildSpec: ServerlessFunction/buildspec.yml - TimeoutInMinutes: 30 - Cache: - Type: S3 - Location: !Sub "${ArtifactBucket}/cache" - - # CodePipeline - Pipeline: - Type: AWS::CodePipeline::Pipeline - Properties: - Name: group2-englishstudy-pipeline - RoleArn: !GetAtt PipelineRole.Arn - ArtifactStore: - Type: S3 - Location: !Ref ArtifactBucket - Stages: - - Name: Source - Actions: - - Name: GitHub - ActionTypeId: - Category: Source - Owner: AWS - Provider: CodeStarSourceConnection - Version: '1' - Configuration: - ConnectionArn: !Ref GitHubConnectionArn - FullRepositoryId: !Ref GitHubRepo - BranchName: !Ref GitHubBranch - OutputArtifactFormat: CODE_ZIP - OutputArtifacts: - - Name: SourceArtifact - RunOrder: 1 - - - Name: Build - Actions: - - Name: Build - ActionTypeId: - Category: Build - Owner: AWS - Provider: CodeBuild - Version: '1' - Configuration: - ProjectName: !Ref CodeBuildProject - InputArtifacts: - - Name: SourceArtifact - OutputArtifacts: - - Name: BuildArtifact - RunOrder: 1 - - - Name: Deploy - Actions: - - Name: Deploy - ActionTypeId: - Category: Deploy - Owner: AWS - Provider: CloudFormation - Version: '1' - Configuration: - ActionMode: CREATE_UPDATE - StackName: group2-englishstudy-prod - TemplatePath: BuildArtifact::ServerlessFunction/packaged-template.yaml - Capabilities: CAPABILITY_IAM,CAPABILITY_AUTO_EXPAND - RoleArn: !GetAtt CloudFormationRole.Arn - InputArtifacts: - - Name: BuildArtifact - RunOrder: 1 - - # Pipeline Notification Rule - PipelineNotificationRule: - Type: AWS::CodeStarNotifications::NotificationRule - Properties: - Name: group2-pipeline-notifications - DetailType: FULL - Resource: !Sub "arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:${Pipeline}" - EventTypeIds: - - codepipeline-pipeline-pipeline-execution-started - - codepipeline-pipeline-pipeline-execution-succeeded - - codepipeline-pipeline-pipeline-execution-failed - Targets: - - TargetType: SNS - TargetAddress: !Ref NotificationTopic - - # IAM Roles (simplified - expand as needed) - PipelineRole: - Type: AWS::IAM::Role - Properties: - AssumeRolePolicyDocument: - Version: '2012-10-17' - Statement: - - Effect: Allow - Principal: - Service: codepipeline.amazonaws.com - Action: sts:AssumeRole - ManagedPolicyArns: - - arn:aws:iam::aws:policy/AWSCodePipeline_FullAccess - Policies: - - PolicyName: PipelinePolicy - PolicyDocument: - Version: '2012-10-17' - Statement: - - Effect: Allow - Action: - - codestar-connections:UseConnection - Resource: !Ref GitHubConnectionArn - - Effect: Allow - Action: - - s3:* - Resource: - - !GetAtt ArtifactBucket.Arn - - !Sub "${ArtifactBucket.Arn}/*" - - Effect: Allow - Action: - - codebuild:* - Resource: !GetAtt CodeBuildProject.Arn - - Effect: Allow - Action: - - cloudformation:* - Resource: "*" - - Effect: Allow - Action: - - iam:PassRole - Resource: !GetAtt CloudFormationRole.Arn - - CodeBuildRole: - Type: AWS::IAM::Role - Properties: - AssumeRolePolicyDocument: - Version: '2012-10-17' - Statement: - - Effect: Allow - Principal: - Service: codebuild.amazonaws.com - Action: sts:AssumeRole - Policies: - - PolicyName: CodeBuildPolicy - PolicyDocument: - Version: '2012-10-17' - Statement: - - Effect: Allow - Action: - - logs:* - Resource: "*" - - Effect: Allow - Action: - - s3:* - Resource: - - !GetAtt ArtifactBucket.Arn - - !Sub "${ArtifactBucket.Arn}/*" - - "arn:aws:s3:::group2-englishstudy" - - "arn:aws:s3:::group2-englishstudy/*" - - Effect: Allow - Action: - - codebuild:CreateReportGroup - - codebuild:CreateReport - - codebuild:UpdateReport - - codebuild:BatchPutTestCases - - codebuild:BatchPutCodeCoverages - Resource: "*" - - CloudFormationRole: - Type: AWS::IAM::Role - Properties: - AssumeRolePolicyDocument: - Version: '2012-10-17' - Statement: - - Effect: Allow - Principal: - Service: cloudformation.amazonaws.com - Action: sts:AssumeRole - ManagedPolicyArns: - - arn:aws:iam::aws:policy/AdministratorAccess # Narrow down in production! - -Outputs: - PipelineUrl: - Value: !Sub "https://${AWS::Region}.console.aws.amazon.com/codesuite/codepipeline/pipelines/${Pipeline}/view" -``` - ---- - -## 12. 트러블슈팅 가이드 - -### 빌드 실패: Java 버전 문제 - -``` -Error: Unsupported class file major version 65 -``` - -**해결**: CodeBuild 이미지에서 Java 21 (Corretto) 사용 확인 - -### 배포 실패: IAM 권한 부족 - -``` -User: arn:aws:sts::xxx is not authorized to perform: iam:CreateRole -``` - -**해결**: CloudFormationExecutionRole에 IAM 권한 추가 - -### SAM 빌드 실패: 메모리 부족 - -``` -FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory -``` - -**해결**: CodeBuild compute type을 `BUILD_GENERAL1_LARGE`로 변경 - -### GitHub Connection 인증 실패 - -**해결**: AWS Console에서 Connection 상태 확인 → GitHub App 재인증 - ---- - -## 13. 다음 단계 - -1. **기획서 검토 및 승인** -2. **Phase 1-7 순차 실행** -3. **첫 배포 테스트** -4. **모니터링 대시보드 구성 (선택)** - ---- - -> **문의사항**: CI/CD 파이프라인 구현 중 문제가 있으면 문의하세요. From c1ca2c0a0da07c1de784f771f47b2734018d8e95 Mon Sep 17 00:00:00 2001 From: DDING JOO Date: Thu, 22 Jan 2026 12:39:43 +0900 Subject: [PATCH 43/99] fix: add CORS headers to API Gateway error responses (#479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API Gateway의 인증 실패 등 에러 응답에 CORS 헤더가 누락되어 CloudFront를 통한 프론트엔드 요청이 차단되는 문제 수정 - UNAUTHORIZED (401) 응답에 CORS 헤더 추가 - ACCESS_DENIED (403) 응답에 CORS 헤더 추가 - DEFAULT_4XX/5XX 응답에 CORS 헤더 추가 - EXPIRED_TOKEN 응답에 CORS 헤더 추가 --- ServerlessFunction/template.yaml | 42 +++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 09663db9..488fc843 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -143,7 +143,47 @@ Resources: AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" AllowOrigin: "'*'" - + AllowCredentials: false + GatewayResponses: + UNAUTHORIZED: + 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,DELETE,OPTIONS'" + ResponseTemplates: + application/json: '{"message": "Unauthorized", "statusCode": 401}' + ACCESS_DENIED: + StatusCode: 403 + 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,DELETE,OPTIONS'" + ResponseTemplates: + application/json: '{"message": "Access Denied", "statusCode": 403}' + DEFAULT_4XX: + 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,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,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,DELETE,OPTIONS'" + ResponseTemplates: + application/json: '{"message": "Token expired", "statusCode": 401}' Auth: DefaultAuthorizer: CognitoAuthorizer Authorizers: From 481a54928d99147e4d631b121fc6078186cb8dbf Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 12:53:58 +0900 Subject: [PATCH 44/99] =?UTF-8?q?feat(news):=20=EB=89=B4=EC=8A=A4=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B8=B0=EB=B0=98=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EA=B5=AC=EC=B6=95=20(#385)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NewsCategory, QuizType enum 추가 - NewsKey 상수 클래스 추가 - NewsErrorCode 예외 클래스 추가 - NewsArticle, KeywordInfo, QuizQuestion 모델 추가 - NewsArticleRepository CRUD 구현 - NewsTable DynamoDB 테이블 정의 - CORS GatewayResponses 설정 추가 --- .gitignore | 1 + .../domain/news/constants/NewsKey.java | 122 +++++++ .../domain/news/enums/NewsCategory.java | 55 ++++ .../domain/news/enums/QuizType.java | 50 +++ .../domain/news/exception/NewsErrorCode.java | 77 +++++ .../domain/news/model/KeywordInfo.java | 24 ++ .../domain/news/model/NewsArticle.java | 92 ++++++ .../domain/news/model/QuizQuestion.java | 27 ++ .../repository/NewsArticleRepository.java | 297 ++++++++++++++++++ ServerlessFunction/template.yaml | 45 +++ 10 files changed, 790 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/NewsCategory.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/QuizType.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsArticle.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizQuestion.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java diff --git a/.gitignore b/.gitignore index 715a4d18..ee058388 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ # Claude .claude/ +.sisyphus/ # Build target/ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java new file mode 100644 index 00000000..e5ba97b8 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java @@ -0,0 +1,122 @@ +package com.mzc.secondproject.serverless.domain.news.constants; + +import com.mzc.secondproject.serverless.common.constants.DynamoDbKey; + +/** + * 뉴스 도메인 DynamoDB 키 상수 및 빌더 + */ +public final class NewsKey { + + // Entity Prefixes + public static final String NEWS = "NEWS#"; + public static final String ARTICLE = "ARTICLE#"; + public static final String LEVEL = "LEVEL#"; + public static final String CATEGORY = "CATEGORY#"; + public static final String READ = "READ#"; + public static final String QUIZ = "QUIZ#"; + public static final String WORD = "WORD#"; + public static final String BOOKMARK = "BOOKMARK#"; + public static final String COMMENT = "COMMENT#"; + public static final String STATS = "STATS"; + + // User Suffixes + public static final String SUFFIX_NEWS = "#NEWS"; + public static final String SUFFIX_NEWS_WORDS = "#NEWS_WORDS"; + public static final String SUFFIX_NEWS_COMMENTS = "#NEWS_COMMENTS"; + + private NewsKey() { + } + + // === Key Builders === + + /** + * 뉴스 기사 PK: NEWS#{date} + */ + public static String newsPk(String date) { + return NEWS + date; + } + + /** + * 뉴스 기사 SK: ARTICLE#{articleId} + */ + public static String articleSk(String articleId) { + return ARTICLE + articleId; + } + + /** + * 레벨별 조회 GSI1 PK: LEVEL#{level} + */ + public static String levelPk(String level) { + return LEVEL + level; + } + + /** + * 카테고리별 조회 GSI2 PK: CATEGORY#{category} + */ + public static String categoryPk(String category) { + return CATEGORY + category; + } + + /** + * 사용자 뉴스 활동 PK: USER#{userId}#NEWS + */ + public static String userNewsPk(String userId) { + return DynamoDbKey.USER + userId + SUFFIX_NEWS; + } + + /** + * 읽기 기록 SK: READ#{articleId} + */ + public static String readSk(String articleId) { + return READ + articleId; + } + + /** + * 퀴즈 결과 SK: QUIZ#{articleId} + */ + public static String quizSk(String articleId) { + return QUIZ + articleId; + } + + /** + * 단어 수집 SK: WORD#{word}#{articleId} + */ + public static String wordSk(String word, String articleId) { + return WORD + word + "#" + articleId; + } + + /** + * 북마크 SK: BOOKMARK#{articleId} + */ + public static String bookmarkSk(String articleId) { + return BOOKMARK + articleId; + } + + /** + * 사용자 수집 단어 GSI1 PK: USER#{userId}#NEWS_WORDS + */ + public static String userNewsWordsPk(String userId) { + return DynamoDbKey.USER + userId + SUFFIX_NEWS_WORDS; + } + + /** + * 댓글 PK: NEWS_COMMENT#{articleId} + */ + public static String commentPk(String articleId) { + return "NEWS_COMMENT#" + articleId; + } + + /** + * 댓글 SK: COMMENT#{commentId} + */ + public static String commentSk(String commentId) { + return COMMENT + commentId; + } + + /** + * 사용자 댓글 GSI1 PK: USER#{userId}#NEWS_COMMENTS + */ + public static String userNewsCommentsPk(String userId) { + return DynamoDbKey.USER + userId + SUFFIX_NEWS_COMMENTS; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/NewsCategory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/NewsCategory.java new file mode 100644 index 00000000..7f88078f --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/NewsCategory.java @@ -0,0 +1,55 @@ +package com.mzc.secondproject.serverless.domain.news.enums; + +import java.util.Arrays; + +/** + * 뉴스 카테고리 + */ +public enum NewsCategory { + TECH("tech", "기술"), + BUSINESS("business", "비즈니스"), + SPORTS("sports", "스포츠"), + ENTERTAINMENT("entertainment", "엔터테인먼트"), + WORLD("world", "세계"), + CULTURE("culture", "문화"), + SCIENCE("science", "과학"); + + private final String code; + private final String displayName; + + NewsCategory(String code, String displayName) { + this.code = code; + this.displayName = displayName; + } + + public static boolean isValid(String value) { + if (value == null) return false; + return Arrays.stream(values()) + .anyMatch(cat -> cat.name().equalsIgnoreCase(value) || cat.code.equalsIgnoreCase(value)); + } + + public static NewsCategory fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("NewsCategory value cannot be null"); + } + return Arrays.stream(values()) + .filter(cat -> cat.name().equalsIgnoreCase(value) || cat.code.equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown NewsCategory: " + value)); + } + + public static NewsCategory fromStringOrDefault(String value, NewsCategory defaultValue) { + if (value == null || !isValid(value)) { + return defaultValue; + } + return fromString(value); + } + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/QuizType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/QuizType.java new file mode 100644 index 00000000..7b95a466 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/QuizType.java @@ -0,0 +1,50 @@ +package com.mzc.secondproject.serverless.domain.news.enums; + +import java.util.Arrays; + +/** + * 뉴스 퀴즈 유형 + */ +public enum QuizType { + COMPREHENSION("comprehension", "독해 질문", 20), + WORD_MATCH("word_match", "단어-뜻 매칭", 15), + FILL_BLANK("fill_blank", "빈칸 채우기", 30); + + private final String code; + private final String displayName; + private final int defaultPoints; + + QuizType(String code, String displayName, int defaultPoints) { + this.code = code; + this.displayName = displayName; + this.defaultPoints = defaultPoints; + } + + public static boolean isValid(String value) { + if (value == null) return false; + return Arrays.stream(values()) + .anyMatch(type -> type.name().equalsIgnoreCase(value) || type.code.equalsIgnoreCase(value)); + } + + public static QuizType fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("QuizType value cannot be null"); + } + return Arrays.stream(values()) + .filter(type -> type.name().equalsIgnoreCase(value) || type.code.equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown QuizType: " + value)); + } + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } + + public int getDefaultPoints() { + return defaultPoints; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java new file mode 100644 index 00000000..fa898252 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java @@ -0,0 +1,77 @@ +package com.mzc.secondproject.serverless.domain.news.exception; + +import com.mzc.secondproject.serverless.common.exception.DomainErrorCode; + +/** + * 뉴스 도메인 에러 코드 + * 뉴스 기사, 퀴즈, 단어 수집, 댓글 관련 에러 코드를 정의합니다. + */ +public enum NewsErrorCode implements DomainErrorCode { + + // 뉴스 기사 관련 에러 + ARTICLE_NOT_FOUND("ARTICLE_001", "뉴스 기사를 찾을 수 없습니다", 404), + INVALID_ARTICLE_DATA("ARTICLE_002", "뉴스 기사 데이터가 유효하지 않습니다", 400), + ARTICLE_ALREADY_EXISTS("ARTICLE_003", "이미 존재하는 뉴스 기사입니다", 409), + + // 카테고리/레벨 관련 에러 + INVALID_CATEGORY("CATEGORY_001", "유효하지 않은 카테고리입니다", 400), + INVALID_LEVEL("LEVEL_001", "유효하지 않은 레벨입니다", 400), + + // 읽기 기록 관련 에러 + READ_RECORD_NOT_FOUND("READ_001", "읽기 기록을 찾을 수 없습니다", 404), + ALREADY_READ("READ_002", "이미 읽은 기사입니다", 409), + + // 퀴즈 관련 에러 + QUIZ_NOT_FOUND("QUIZ_001", "퀴즈를 찾을 수 없습니다", 404), + QUIZ_ALREADY_SUBMITTED("QUIZ_002", "이미 제출한 퀴즈입니다", 409), + INVALID_QUIZ_ANSWER("QUIZ_003", "유효하지 않은 퀴즈 답변입니다", 400), + + // 단어 수집 관련 에러 + WORD_ALREADY_COLLECTED("WORD_001", "이미 수집한 단어입니다", 409), + WORD_NOT_COLLECTED("WORD_002", "수집한 단어를 찾을 수 없습니다", 404), + + // 북마크 관련 에러 + BOOKMARK_NOT_FOUND("BOOKMARK_001", "북마크를 찾을 수 없습니다", 404), + ALREADY_BOOKMARKED("BOOKMARK_002", "이미 북마크한 기사입니다", 409), + BOOKMARK_LIMIT_EXCEEDED("BOOKMARK_003", "북마크 한도를 초과했습니다", 400), + + // 댓글 관련 에러 + COMMENT_NOT_FOUND("COMMENT_001", "댓글을 찾을 수 없습니다", 404), + COMMENT_NOT_OWNER("COMMENT_002", "댓글 작성자만 수정/삭제할 수 있습니다", 403), + INVALID_COMMENT_DATA("COMMENT_003", "유효하지 않은 댓글 데이터입니다", 400), + + // 통계 관련 에러 + STATS_NOT_FOUND("STATS_001", "통계 정보를 찾을 수 없습니다", 404); + + private static final String DOMAIN = "NEWS"; + + private final String code; + private final String message; + private final int statusCode; + + NewsErrorCode(String code, String message, int statusCode) { + this.code = code; + this.message = message; + this.statusCode = statusCode; + } + + @Override + public String getDomain() { + return DOMAIN; + } + + @Override + public String getCode() { + return code; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public int getStatusCode() { + return statusCode; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java new file mode 100644 index 00000000..81f1e1f5 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java @@ -0,0 +1,24 @@ +package com.mzc.secondproject.serverless.domain.news.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; + +/** + * 뉴스 기사 내 키워드 정보 + * 단어, 뜻, 난이도, 위치 정보를 포함 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class KeywordInfo { + + private String word; // 영어 단어 + private String meaning; // 한국어 뜻 + private String level; // 단어 난이도 (BEGINNER, INTERMEDIATE, ADVANCED) + private Integer position; // 기사 내 위치 (문장 번호 또는 단어 인덱스) +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsArticle.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsArticle.java new file mode 100644 index 00000000..13fd8a19 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsArticle.java @@ -0,0 +1,92 @@ +package com.mzc.secondproject.serverless.domain.news.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +import java.util.List; + +/** + * 뉴스 기사 모델 + * PK: NEWS#{date} + * SK: ARTICLE#{articleId} + * GSI1: LEVEL#{level} / {publishedAt} - 레벨별 최신순 조회 + * GSI2: CATEGORY#{category} / {publishedAt} - 카테고리별 최신순 조회 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class NewsArticle { + + private String pk; // NEWS#{date} + private String sk; // ARTICLE#{articleId} + private String gsi1pk; // LEVEL#{level} + private String gsi1sk; // {publishedAt} + private String gsi2pk; // CATEGORY#{category} + private String gsi2sk; // {publishedAt} + + // 기본 정보 + private String articleId; + private String title; + private String summary; // AI 생성 3줄 요약 + private String originalUrl; // 원문 링크 + private String source; // BBC, VOA, NPR, NewsAPI + private String imageUrl; // 썸네일 이미지 + + // 분류 + private String category; // TECH, BUSINESS, SPORTS 등 + private String level; // BEGINNER, INTERMEDIATE, ADVANCED + private String cefrLevel; // A1, A2, B1, B2, C1, C2 (원본 CEFR 레벨) + + // AI 분석 결과 + private List keywords; // 핵심 단어 정보 + private List highlightWords; // 사용자 레벨 대비 어려운 단어 + private List quiz; // 퀴즈 문제 (5개) + + // 메타데이터 + private String publishedAt; // 원본 발행일 + private String collectedAt; // 수집일 + private Long readCount; // 조회수 + private Long commentCount; // 댓글수 + private Long ttl; + + @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; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2PK") + public String getGsi2pk() { + return gsi2pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2SK") + public String getGsi2sk() { + return gsi2sk; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizQuestion.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizQuestion.java new file mode 100644 index 00000000..6657ef33 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizQuestion.java @@ -0,0 +1,27 @@ +package com.mzc.secondproject.serverless.domain.news.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; + +import java.util.List; + +/** + * 뉴스 퀴즈 문제 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class QuizQuestion { + + private String questionId; // 문제 ID (q1, q2, ...) + private String type; // COMPREHENSION, WORD_MATCH, FILL_BLANK + private String question; // 문제 내용 + private List options; // 선택지 (객관식인 경우) + private String correctAnswer; // 정답 + private Integer points; // 배점 +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java new file mode 100644 index 00000000..28ca35cc --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java @@ -0,0 +1,297 @@ +package com.mzc.secondproject.serverless.domain.news.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.util.CursorUtil; +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; +import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.*; +import software.amazon.awssdk.enhanced.dynamodb.model.*; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 뉴스 기사 Repository + */ +public class NewsArticleRepository { + + private static final Logger logger = LoggerFactory.getLogger(NewsArticleRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); + + private final DynamoDbEnhancedClient enhancedClient; + private final DynamoDbTable table; + + /** + * 기본 생성자 (Lambda에서 사용) + */ + public NewsArticleRepository() { + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public NewsArticleRepository(DynamoDbEnhancedClient enhancedClient) { + this.enhancedClient = enhancedClient; + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(NewsArticle.class)); + } + + /** + * 뉴스 기사 저장 + */ + public NewsArticle save(NewsArticle article) { + logger.info("Saving news article: {}", article.getArticleId()); + table.putItem(article); + return article; + } + + /** + * 뉴스 기사 조회 (날짜 + 기사ID) + */ + public Optional findByDateAndId(String date, String articleId) { + Key key = Key.builder() + .partitionValue(NewsKey.newsPk(date)) + .sortValue(NewsKey.articleSk(articleId)) + .build(); + + NewsArticle article = table.getItem(key); + return Optional.ofNullable(article); + } + + /** + * 뉴스 기사 조회 (기사ID만으로 - GSI 활용 또는 Scan) + * 참고: 실제로는 articleId로 date를 알 수 있도록 설계하거나 GSI 추가 필요 + */ + public Optional findById(String articleId) { + Expression filterExpression = Expression.builder() + .expression("articleId = :articleId") + .putExpressionValue(":articleId", AttributeValue.builder().s(articleId).build()) + .build(); + + ScanEnhancedRequest request = ScanEnhancedRequest.builder() + .filterExpression(filterExpression) + .limit(1) + .build(); + + for (Page page : table.scan(request)) { + List items = page.items(); + if (!items.isEmpty()) { + return Optional.of(items.get(0)); + } + } + return Optional.empty(); + } + + /** + * 뉴스 기사 삭제 + */ + public void delete(String date, String articleId) { + Key key = Key.builder() + .partitionValue(NewsKey.newsPk(date)) + .sortValue(NewsKey.articleSk(articleId)) + .build(); + + table.deleteItem(key); + logger.info("Deleted news article: {}", articleId); + } + + /** + * 날짜별 뉴스 기사 조회 (페이지네이션) + */ + public PaginatedResult findByDate(String date, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder().partitionValue(NewsKey.newsPk(date)).build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 (SK 역순) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.query(requestBuilder.build()).iterator().next(); + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); + + return new PaginatedResult<>(page.items(), nextCursor); + } + + /** + * 레벨별 뉴스 기사 조회 (GSI1 - 최신순) + */ + public PaginatedResult findByLevel(String level, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder().partitionValue(NewsKey.levelPk(level.toUpperCase())).build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + DynamoDbIndex gsi1 = table.index("GSI1"); + Page page = gsi1.query(requestBuilder.build()).iterator().next(); + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); + + return new PaginatedResult<>(page.items(), nextCursor); + } + + /** + * 카테고리별 뉴스 기사 조회 (GSI2 - 최신순) + */ + public PaginatedResult findByCategory(String category, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder().partitionValue(NewsKey.categoryPk(category.toUpperCase())).build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + DynamoDbIndex gsi2 = table.index("GSI2"); + Page page = gsi2.query(requestBuilder.build()).iterator().next(); + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); + + return new PaginatedResult<>(page.items(), nextCursor); + } + + /** + * 레벨 + 카테고리 필터 조회 (GSI1 쿼리 후 필터) + */ + public PaginatedResult findByLevelAndCategory(String level, String category, int limit, String cursor) { + Expression filterExpression = Expression.builder() + .expression("category = :category") + .putExpressionValue(":category", AttributeValue.builder().s(category.toUpperCase()).build()) + .build(); + + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder().partitionValue(NewsKey.levelPk(level.toUpperCase())).build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .filterExpression(filterExpression) + .scanIndexForward(false) + .limit(limit * 2); // 필터 적용되므로 넉넉히 + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + DynamoDbIndex gsi1 = table.index("GSI1"); + List results = new ArrayList<>(); + Map lastKey = null; + + for (Page page : gsi1.query(requestBuilder.build())) { + for (NewsArticle article : page.items()) { + results.add(article); + if (results.size() >= limit) break; + } + lastKey = page.lastEvaluatedKey(); + if (results.size() >= limit) break; + } + + String nextCursor = results.size() >= limit ? CursorUtil.encode(lastKey) : null; + return new PaginatedResult<>(results.subList(0, Math.min(results.size(), limit)), nextCursor); + } + + /** + * 조회수 증가 (Atomic Update) + */ + public void incrementReadCount(String date, String articleId) { + Map key = Map.of( + "PK", AttributeValue.builder().s(NewsKey.newsPk(date)).build(), + "SK", AttributeValue.builder().s(NewsKey.articleSk(articleId)).build() + ); + + Map values = Map.of( + ":zero", AttributeValue.builder().n("0").build(), + ":inc", AttributeValue.builder().n("1").build() + ); + + UpdateItemRequest request = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression("SET readCount = if_not_exists(readCount, :zero) + :inc") + .expressionAttributeValues(values) + .build(); + + AwsClients.dynamoDb().updateItem(request); + logger.debug("Incremented read count for article: {}", articleId); + } + + /** + * 댓글수 증가 (Atomic Update) + */ + public void incrementCommentCount(String date, String articleId) { + Map key = Map.of( + "PK", AttributeValue.builder().s(NewsKey.newsPk(date)).build(), + "SK", AttributeValue.builder().s(NewsKey.articleSk(articleId)).build() + ); + + Map values = Map.of( + ":zero", AttributeValue.builder().n("0").build(), + ":inc", AttributeValue.builder().n("1").build() + ); + + UpdateItemRequest request = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression("SET commentCount = if_not_exists(commentCount, :zero) + :inc") + .expressionAttributeValues(values) + .build(); + + AwsClients.dynamoDb().updateItem(request); + } + + /** + * 댓글수 감소 (Atomic Update) + */ + public void decrementCommentCount(String date, String articleId) { + Map key = Map.of( + "PK", AttributeValue.builder().s(NewsKey.newsPk(date)).build(), + "SK", AttributeValue.builder().s(NewsKey.articleSk(articleId)).build() + ); + + Map values = Map.of( + ":one", AttributeValue.builder().n("1").build(), + ":dec", AttributeValue.builder().n("1").build() + ); + + UpdateItemRequest request = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression("SET commentCount = if_not_exists(commentCount, :one) - :dec") + .expressionAttributeValues(values) + .build(); + + AwsClients.dynamoDb().updateItem(request); + } +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 488fc843..a4cb6044 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -16,6 +16,7 @@ Globals: CHAT_TABLE_NAME: !Ref ChatTable VOCAB_TABLE_NAME: !Ref VocabTable OPIC_TABLE_NAME: !Ref OPIcTable + NEWS_TABLE_NAME: !Ref NewsTable BUCKET_NAME: group2-englishstudy CHAT_BUCKET_NAME: group2-englishstudy VOCAB_BUCKET_NAME: group2-englishstudy @@ -1724,6 +1725,50 @@ Resources: AttributeName: ttl Enabled: true + NewsTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: group2-englishstudy-news + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + - AttributeName: GSI1PK + AttributeType: S + - AttributeName: GSI1SK + AttributeType: S + - AttributeName: GSI2PK + AttributeType: S + - AttributeName: GSI2SK + 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 + - IndexName: GSI2 + KeySchema: + - AttributeName: GSI2PK + KeyType: HASH + - AttributeName: GSI2SK + KeyType: RANGE + Projection: + ProjectionType: ALL + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true + ############################################# # SNS / SQS for Async Statistics Processing ############################################# From 0346084fae51dae17f45117c46fda2fb3d042f51 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 13:27:18 +0900 Subject: [PATCH 45/99] =?UTF-8?q?feat(news):=20=EB=89=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=88=98=EC=A7=91=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EA=B5=AC=ED=98=84=20(#386)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NewsApiClient: NewsAPI 연동 서비스 - RssFeedParser: RSS 피드 파싱 (BBC, VOA, NPR) - NewsDuplicateChecker: URL 기반 중복 필터링 - NewsCollectorService: 수집 오케스트레이션 - NewsCollectionHandler: Lambda 핸들러 - EventBridge 스케줄러: 매일 18시 KST 실행 --- .../domain/news/dto/RawNewsArticle.java | 44 +++++ .../news/handler/NewsCollectionHandler.java | 58 ++++++ .../domain/news/service/NewsApiClient.java | 166 ++++++++++++++++ .../news/service/NewsCollectorService.java | 139 +++++++++++++ .../news/service/NewsDuplicateChecker.java | 94 +++++++++ .../domain/news/service/RssFeedParser.java | 183 ++++++++++++++++++ ServerlessFunction/template.yaml | 27 +++ 7 files changed, 711 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/dto/RawNewsArticle.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsApiClient.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsDuplicateChecker.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/RssFeedParser.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/dto/RawNewsArticle.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/dto/RawNewsArticle.java new file mode 100644 index 00000000..c9902559 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/dto/RawNewsArticle.java @@ -0,0 +1,44 @@ +package com.mzc.secondproject.serverless.domain.news.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 수집된 원본 뉴스 기사 DTO + * NewsAPI, RSS 등에서 수집한 원본 데이터를 담는 객체 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RawNewsArticle { + + private String title; + private String description; + private String url; + private String imageUrl; + private String source; + private String publishedAt; + private String content; + + /** + * URL 기반 고유 식별자 생성 + */ + public String generateId() { + if (url == null) { + return null; + } + return String.valueOf(url.hashCode()); + } + + /** + * 유효한 기사인지 검증 + */ + public boolean isValid() { + return title != null && !title.isBlank() + && url != null && !url.isBlank() + && source != null && !source.isBlank(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java new file mode 100644 index 00000000..b5702a5e --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java @@ -0,0 +1,58 @@ +package com.mzc.secondproject.serverless.domain.news.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.ScheduledEvent; +import com.mzc.secondproject.serverless.domain.news.service.NewsCollectorService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; + +/** + * 뉴스 수집 Lambda 핸들러 + * EventBridge 스케줄러에 의해 매일 18시에 트리거 + */ +public class NewsCollectionHandler implements RequestHandler> { + + private static final Logger logger = LoggerFactory.getLogger(NewsCollectionHandler.class); + + private final NewsCollectorService collectorService; + + public NewsCollectionHandler() { + this.collectorService = new NewsCollectorService(); + } + + public NewsCollectionHandler(NewsCollectorService collectorService) { + this.collectorService = collectorService; + } + + @Override + public Map handleRequest(ScheduledEvent event, Context context) { + logger.info("뉴스 수집 Lambda 시작 - requestId: {}", context.getAwsRequestId()); + + try { + NewsCollectorService.CollectionResult result = collectorService.collectNews(); + + logger.info("뉴스 수집 완료 - NewsAPI: {}, RSS: {}, 저장: {}, 소요: {}ms", + result.newsApiCount(), result.rssCount(), result.savedCount(), result.elapsedMs()); + + return Map.of( + "statusCode", 200, + "message", "News collection completed", + "newsApiCount", result.newsApiCount(), + "rssCount", result.rssCount(), + "savedCount", result.savedCount(), + "elapsedMs", result.elapsedMs() + ); + + } catch (Exception e) { + logger.error("뉴스 수집 실패", e); + + return Map.of( + "statusCode", 500, + "message", "News collection failed: " + e.getMessage() + ); + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsApiClient.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsApiClient.java new file mode 100644 index 00000000..dba1af09 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsApiClient.java @@ -0,0 +1,166 @@ +package com.mzc.secondproject.serverless.domain.news.service; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.domain.news.dto.RawNewsArticle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.ssm.model.GetParameterRequest; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +/** + * NewsAPI 연동 클라이언트 + * 무료 플랜: 100 requests/day, 최대 100 articles/request + */ +public class NewsApiClient { + + private static final Logger logger = LoggerFactory.getLogger(NewsApiClient.class); + private static final String NEWS_API_BASE_URL = "https://newsapi.org/v2"; + private static final String API_KEY_PARAM_NAME = "/englishstudy/news/api-key"; + + private static String cachedApiKey = null; + + private final HttpClient httpClient; + + public NewsApiClient() { + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + } + + /** + * API Key 조회 (Parameter Store + 캐싱) + */ + private String getApiKey() { + if (cachedApiKey != null) { + return cachedApiKey; + } + + try { + logger.debug("Fetching NewsAPI Key from Parameter Store"); + var response = AwsClients.ssm().getParameter( + GetParameterRequest.builder() + .name(API_KEY_PARAM_NAME) + .withDecryption(true) + .build() + ); + cachedApiKey = response.parameter().value(); + logger.info("NewsAPI Key loaded from Parameter Store"); + return cachedApiKey; + } catch (Exception e) { + logger.error("Failed to get NewsAPI Key from Parameter Store", e); + throw new RuntimeException("NewsAPI Key 로드 실패", e); + } + } + + /** + * 헤드라인 뉴스 조회 + */ + public List getTopHeadlines(String category, int pageSize) { + String url = String.format("%s/top-headlines?language=en&category=%s&pageSize=%d&apiKey=%s", + NEWS_API_BASE_URL, category, pageSize, getApiKey()); + + return fetchArticles(url, "NewsAPI-Headlines"); + } + + /** + * 검색어 기반 뉴스 조회 + */ + public List searchNews(String query, int pageSize) { + String encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8); + String url = String.format("%s/everything?q=%s&language=en&sortBy=publishedAt&pageSize=%d&apiKey=%s", + NEWS_API_BASE_URL, encodedQuery, pageSize, getApiKey()); + + return fetchArticles(url, "NewsAPI-Search"); + } + + /** + * 뉴스 API 호출 및 파싱 + */ + private List fetchArticles(String url, String source) { + List articles = new ArrayList<>(); + + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Accept", "application/json") + .timeout(Duration.ofSeconds(30)) + .GET() + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + logger.error("NewsAPI 요청 실패 - status: {}, body: {}", response.statusCode(), response.body()); + return articles; + } + + JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject(); + String status = json.get("status").getAsString(); + + if (!"ok".equals(status)) { + logger.error("NewsAPI 응답 오류 - status: {}", status); + return articles; + } + + JsonArray articlesArray = json.getAsJsonArray("articles"); + for (JsonElement element : articlesArray) { + JsonObject articleJson = element.getAsJsonObject(); + RawNewsArticle article = parseArticle(articleJson, source); + if (article.isValid()) { + articles.add(article); + } + } + + logger.info("NewsAPI에서 {}개 기사 수집 완료", articles.size()); + + } catch (Exception e) { + logger.error("NewsAPI 호출 중 오류 발생", e); + } + + return articles; + } + + /** + * JSON을 RawNewsArticle로 변환 + */ + private RawNewsArticle parseArticle(JsonObject json, String defaultSource) { + String sourceName = defaultSource; + if (json.has("source") && json.get("source").isJsonObject()) { + JsonObject sourceObj = json.getAsJsonObject("source"); + if (sourceObj.has("name") && !sourceObj.get("name").isJsonNull()) { + sourceName = sourceObj.get("name").getAsString(); + } + } + + return RawNewsArticle.builder() + .title(getStringOrNull(json, "title")) + .description(getStringOrNull(json, "description")) + .url(getStringOrNull(json, "url")) + .imageUrl(getStringOrNull(json, "urlToImage")) + .source(sourceName) + .publishedAt(getStringOrNull(json, "publishedAt")) + .content(getStringOrNull(json, "content")) + .build(); + } + + private String getStringOrNull(JsonObject json, String key) { + if (json.has(key) && !json.get(key).isJsonNull()) { + return json.get(key).getAsString(); + } + return null; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java new file mode 100644 index 00000000..e46bb6ef --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java @@ -0,0 +1,139 @@ +package com.mzc.secondproject.serverless.domain.news.service; + +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; +import com.mzc.secondproject.serverless.domain.news.dto.RawNewsArticle; +import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * 뉴스 수집 서비스 + * NewsAPI, RSS 피드에서 뉴스를 수집하고 저장 + */ +public class NewsCollectorService { + + private static final Logger logger = LoggerFactory.getLogger(NewsCollectorService.class); + + private static final int NEWS_API_LIMIT = 10; + private static final int RSS_LIMIT_PER_SOURCE = 5; + private static final long TTL_DAYS = 30; + + private final NewsApiClient newsApiClient; + private final RssFeedParser rssFeedParser; + private final NewsDuplicateChecker duplicateChecker; + private final NewsArticleRepository articleRepository; + + public NewsCollectorService() { + this.newsApiClient = new NewsApiClient(); + this.rssFeedParser = new RssFeedParser(); + this.duplicateChecker = new NewsDuplicateChecker(); + this.articleRepository = new NewsArticleRepository(); + } + + public NewsCollectorService(NewsApiClient newsApiClient, RssFeedParser rssFeedParser, + NewsDuplicateChecker duplicateChecker, NewsArticleRepository articleRepository) { + this.newsApiClient = newsApiClient; + this.rssFeedParser = rssFeedParser; + this.duplicateChecker = duplicateChecker; + this.articleRepository = articleRepository; + } + + /** + * 뉴스 수집 실행 + */ + public CollectionResult collectNews() { + logger.info("뉴스 수집 시작"); + long startTime = System.currentTimeMillis(); + + List allArticles = new ArrayList<>(); + int newsApiCount = 0; + int rssCount = 0; + + try { + List newsApiArticles = newsApiClient.getTopHeadlines("technology", NEWS_API_LIMIT); + allArticles.addAll(newsApiArticles); + newsApiCount = newsApiArticles.size(); + logger.info("NewsAPI에서 {}개 수집", newsApiCount); + } catch (Exception e) { + logger.error("NewsAPI 수집 실패", e); + } + + try { + List rssArticles = rssFeedParser.fetchAllFeeds(RSS_LIMIT_PER_SOURCE); + allArticles.addAll(rssArticles); + rssCount = rssArticles.size(); + logger.info("RSS에서 {}개 수집", rssCount); + } catch (Exception e) { + logger.error("RSS 수집 실패", e); + } + + List uniqueArticles = duplicateChecker.filterDuplicates(allArticles); + logger.info("중복 제거 후 {}개 기사", uniqueArticles.size()); + + int savedCount = 0; + for (RawNewsArticle rawArticle : uniqueArticles) { + try { + NewsArticle article = convertToNewsArticle(rawArticle); + articleRepository.save(article); + savedCount++; + } catch (Exception e) { + logger.error("기사 저장 실패: {}", rawArticle.getTitle(), e); + } + } + + long elapsed = System.currentTimeMillis() - startTime; + logger.info("뉴스 수집 완료 - 저장: {}, 소요시간: {}ms", savedCount, elapsed); + + return new CollectionResult(newsApiCount, rssCount, savedCount, elapsed); + } + + /** + * RawNewsArticle을 NewsArticle로 변환 + * AI 분석은 별도 Story에서 처리 + */ + private NewsArticle convertToNewsArticle(RawNewsArticle raw) { + String today = LocalDate.now().toString(); + String articleId = UUID.randomUUID().toString().substring(0, 8); + String now = Instant.now().toString(); + + long ttlEpoch = Instant.now() + .atOffset(ZoneOffset.UTC) + .plusDays(TTL_DAYS) + .toEpochSecond(); + + return NewsArticle.builder() + .pk(NewsKey.newsPk(today)) + .sk(NewsKey.articleSk(articleId)) + .articleId(articleId) + .title(raw.getTitle()) + .summary(raw.getDescription()) + .originalUrl(raw.getUrl()) + .source(raw.getSource()) + .imageUrl(raw.getImageUrl()) + .publishedAt(raw.getPublishedAt() != null ? raw.getPublishedAt() : now) + .collectedAt(now) + .readCount(0L) + .commentCount(0L) + .ttl(ttlEpoch) + .build(); + } + + /** + * 수집 결과 레코드 + */ + public record CollectionResult( + int newsApiCount, + int rssCount, + int savedCount, + long elapsedMs + ) { + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsDuplicateChecker.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsDuplicateChecker.java new file mode 100644 index 00000000..d4eedd82 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsDuplicateChecker.java @@ -0,0 +1,94 @@ +package com.mzc.secondproject.serverless.domain.news.service; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; +import com.mzc.secondproject.serverless.domain.news.dto.RawNewsArticle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.QueryRequest; +import software.amazon.awssdk.services.dynamodb.model.QueryResponse; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 뉴스 중복 검사 서비스 + * URL 기반으로 중복 뉴스 필터링 + */ +public class NewsDuplicateChecker { + + private static final Logger logger = LoggerFactory.getLogger(NewsDuplicateChecker.class); + private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); + + /** + * 중복 뉴스 필터링 + */ + public List filterDuplicates(List articles) { + if (articles.isEmpty()) { + return articles; + } + + Set existingUrls = getExistingUrls(); + Set seenUrls = new HashSet<>(); + List uniqueArticles = new ArrayList<>(); + + for (RawNewsArticle article : articles) { + String url = article.getUrl(); + if (url == null) { + continue; + } + + if (!existingUrls.contains(url) && !seenUrls.contains(url)) { + uniqueArticles.add(article); + seenUrls.add(url); + } + } + + int duplicateCount = articles.size() - uniqueArticles.size(); + if (duplicateCount > 0) { + logger.info("{}개 중복 기사 필터링됨", duplicateCount); + } + + return uniqueArticles; + } + + /** + * 오늘 날짜의 기존 뉴스 URL 조회 + */ + private Set getExistingUrls() { + Set urls = new HashSet<>(); + String today = LocalDate.now().toString(); + + try { + QueryRequest request = QueryRequest.builder() + .tableName(TABLE_NAME) + .keyConditionExpression("PK = :pk") + .expressionAttributeValues(Map.of( + ":pk", AttributeValue.builder().s(NewsKey.newsPk(today)).build() + )) + .projectionExpression("originalUrl") + .build(); + + QueryResponse response = AwsClients.dynamoDb().query(request); + + for (Map item : response.items()) { + if (item.containsKey("originalUrl")) { + urls.add(item.get("originalUrl").s()); + } + } + + logger.debug("기존 뉴스 {}개 URL 로드됨", urls.size()); + + } catch (Exception e) { + logger.error("기존 뉴스 URL 조회 실패", e); + } + + return urls; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/RssFeedParser.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/RssFeedParser.java new file mode 100644 index 00000000..ca2c98b8 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/RssFeedParser.java @@ -0,0 +1,183 @@ +package com.mzc.secondproject.serverless.domain.news.service; + +import com.mzc.secondproject.serverless.domain.news.dto.RawNewsArticle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * RSS 피드 파싱 서비스 + * BBC, VOA, NPR 등의 RSS 피드에서 뉴스 수집 + */ +public class RssFeedParser { + + private static final Logger logger = LoggerFactory.getLogger(RssFeedParser.class); + + private static final Map RSS_FEEDS = Map.of( + "BBC", "https://feeds.bbci.co.uk/news/world/rss.xml", + "VOA", "https://www.voanews.com/api/ziqpoe-mqm", + "NPR", "https://feeds.npr.org/1001/rss.xml" + ); + + private final HttpClient httpClient; + + public RssFeedParser() { + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + } + + /** + * 모든 RSS 피드에서 뉴스 수집 + */ + public List fetchAllFeeds(int maxPerSource) { + List allArticles = new ArrayList<>(); + + for (Map.Entry entry : RSS_FEEDS.entrySet()) { + String source = entry.getKey(); + String feedUrl = entry.getValue(); + + try { + List articles = fetchFeed(feedUrl, source, maxPerSource); + allArticles.addAll(articles); + logger.info("{}에서 {}개 기사 수집", source, articles.size()); + } catch (Exception e) { + logger.error("{} RSS 피드 수집 실패: {}", source, e.getMessage()); + } + } + + return allArticles; + } + + /** + * 특정 RSS 피드에서 뉴스 수집 + */ + public List fetchFeed(String feedUrl, String source, int maxItems) { + List articles = new ArrayList<>(); + + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(feedUrl)) + .header("User-Agent", "Mozilla/5.0 (compatible; NewsBot/1.0)") + .timeout(Duration.ofSeconds(30)) + .GET() + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + + if (response.statusCode() != 200) { + logger.error("RSS 피드 요청 실패 - url: {}, status: {}", feedUrl, response.statusCode()); + return articles; + } + + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + DocumentBuilder builder = factory.newDocumentBuilder(); + Document document = builder.parse(response.body()); + + NodeList items = document.getElementsByTagName("item"); + int count = Math.min(items.getLength(), maxItems); + + for (int i = 0; i < count; i++) { + Element item = (Element) items.item(i); + RawNewsArticle article = parseRssItem(item, source); + if (article.isValid()) { + articles.add(article); + } + } + + } catch (Exception e) { + logger.error("RSS 피드 파싱 중 오류 발생 - url: {}", feedUrl, e); + } + + return articles; + } + + /** + * RSS item 요소를 RawNewsArticle로 변환 + */ + private RawNewsArticle parseRssItem(Element item, String source) { + return RawNewsArticle.builder() + .title(getElementText(item, "title")) + .description(cleanHtml(getElementText(item, "description"))) + .url(getElementText(item, "link")) + .imageUrl(extractImageUrl(item)) + .source(source) + .publishedAt(parsePublishedDate(getElementText(item, "pubDate"))) + .build(); + } + + /** + * 요소에서 텍스트 추출 + */ + private String getElementText(Element parent, String tagName) { + NodeList nodes = parent.getElementsByTagName(tagName); + if (nodes.getLength() > 0) { + return nodes.item(0).getTextContent().trim(); + } + return null; + } + + /** + * 이미지 URL 추출 (media:content, enclosure 등) + */ + private String extractImageUrl(Element item) { + NodeList mediaContent = item.getElementsByTagName("media:content"); + if (mediaContent.getLength() > 0) { + Element media = (Element) mediaContent.item(0); + return media.getAttribute("url"); + } + + NodeList enclosure = item.getElementsByTagName("enclosure"); + if (enclosure.getLength() > 0) { + Element enc = (Element) enclosure.item(0); + String type = enc.getAttribute("type"); + if (type != null && type.startsWith("image/")) { + return enc.getAttribute("url"); + } + } + + NodeList mediaThumbnail = item.getElementsByTagName("media:thumbnail"); + if (mediaThumbnail.getLength() > 0) { + Element thumbnail = (Element) mediaThumbnail.item(0); + return thumbnail.getAttribute("url"); + } + + return null; + } + + /** + * RSS pubDate를 ISO 8601 형식으로 변환 + */ + private String parsePublishedDate(String pubDate) { + if (pubDate == null || pubDate.isBlank()) { + return null; + } + return pubDate; + } + + /** + * HTML 태그 제거 + */ + private String cleanHtml(String html) { + if (html == null) { + return null; + } + return html.replaceAll("<[^>]*>", "").trim(); + } +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index a4cb6044..7828e7a0 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1725,6 +1725,33 @@ Resources: 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 + - SSMParameterReadPolicy: + ParameterName: englishstudy/news/* + Events: + DailySchedule: + Type: Schedule + Properties: + Schedule: cron(0 9 * * ? *) + Name: news-collection-daily-schedule + Description: 매일 18시 KST (09:00 UTC)에 뉴스 수집 + Enabled: true + NewsTable: Type: AWS::DynamoDB::Table Properties: From 4f42c61931e21ba1b0c96eb63847905fe99bb960 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 13:30:20 +0900 Subject: [PATCH 46/99] =?UTF-8?q?refactor(news):=20NewsAPI=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0,=20RSS=EB=A7=8C=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NewsApiClient 삭제 - SSM Parameter 의존성 제거 - RSS 소스당 7개씩 수집 (BBC, VOA, NPR = 약 21개/일) --- .../news/handler/NewsCollectionHandler.java | 7 +- .../domain/news/service/NewsApiClient.java | 166 ------------------ .../news/service/NewsCollectorService.java | 42 ++--- ServerlessFunction/template.yaml | 2 - 4 files changed, 15 insertions(+), 202 deletions(-) delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsApiClient.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java index b5702a5e..4d17463f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java @@ -34,14 +34,13 @@ public Map handleRequest(ScheduledEvent event, Context context) try { NewsCollectorService.CollectionResult result = collectorService.collectNews(); - logger.info("뉴스 수집 완료 - NewsAPI: {}, RSS: {}, 저장: {}, 소요: {}ms", - result.newsApiCount(), result.rssCount(), result.savedCount(), result.elapsedMs()); + logger.info("뉴스 수집 완료 - 수집: {}, 저장: {}, 소요: {}ms", + result.collectedCount(), result.savedCount(), result.elapsedMs()); return Map.of( "statusCode", 200, "message", "News collection completed", - "newsApiCount", result.newsApiCount(), - "rssCount", result.rssCount(), + "collectedCount", result.collectedCount(), "savedCount", result.savedCount(), "elapsedMs", result.elapsedMs() ); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsApiClient.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsApiClient.java deleted file mode 100644 index dba1af09..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsApiClient.java +++ /dev/null @@ -1,166 +0,0 @@ -package com.mzc.secondproject.serverless.domain.news.service; - -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import com.mzc.secondproject.serverless.common.config.AwsClients; -import com.mzc.secondproject.serverless.domain.news.dto.RawNewsArticle; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.services.ssm.model.GetParameterRequest; - -import java.net.URI; -import java.net.URLEncoder; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; - -/** - * NewsAPI 연동 클라이언트 - * 무료 플랜: 100 requests/day, 최대 100 articles/request - */ -public class NewsApiClient { - - private static final Logger logger = LoggerFactory.getLogger(NewsApiClient.class); - private static final String NEWS_API_BASE_URL = "https://newsapi.org/v2"; - private static final String API_KEY_PARAM_NAME = "/englishstudy/news/api-key"; - - private static String cachedApiKey = null; - - private final HttpClient httpClient; - - public NewsApiClient() { - this.httpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(10)) - .build(); - } - - /** - * API Key 조회 (Parameter Store + 캐싱) - */ - private String getApiKey() { - if (cachedApiKey != null) { - return cachedApiKey; - } - - try { - logger.debug("Fetching NewsAPI Key from Parameter Store"); - var response = AwsClients.ssm().getParameter( - GetParameterRequest.builder() - .name(API_KEY_PARAM_NAME) - .withDecryption(true) - .build() - ); - cachedApiKey = response.parameter().value(); - logger.info("NewsAPI Key loaded from Parameter Store"); - return cachedApiKey; - } catch (Exception e) { - logger.error("Failed to get NewsAPI Key from Parameter Store", e); - throw new RuntimeException("NewsAPI Key 로드 실패", e); - } - } - - /** - * 헤드라인 뉴스 조회 - */ - public List getTopHeadlines(String category, int pageSize) { - String url = String.format("%s/top-headlines?language=en&category=%s&pageSize=%d&apiKey=%s", - NEWS_API_BASE_URL, category, pageSize, getApiKey()); - - return fetchArticles(url, "NewsAPI-Headlines"); - } - - /** - * 검색어 기반 뉴스 조회 - */ - public List searchNews(String query, int pageSize) { - String encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8); - String url = String.format("%s/everything?q=%s&language=en&sortBy=publishedAt&pageSize=%d&apiKey=%s", - NEWS_API_BASE_URL, encodedQuery, pageSize, getApiKey()); - - return fetchArticles(url, "NewsAPI-Search"); - } - - /** - * 뉴스 API 호출 및 파싱 - */ - private List fetchArticles(String url, String source) { - List articles = new ArrayList<>(); - - try { - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(url)) - .header("Accept", "application/json") - .timeout(Duration.ofSeconds(30)) - .GET() - .build(); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() != 200) { - logger.error("NewsAPI 요청 실패 - status: {}, body: {}", response.statusCode(), response.body()); - return articles; - } - - JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject(); - String status = json.get("status").getAsString(); - - if (!"ok".equals(status)) { - logger.error("NewsAPI 응답 오류 - status: {}", status); - return articles; - } - - JsonArray articlesArray = json.getAsJsonArray("articles"); - for (JsonElement element : articlesArray) { - JsonObject articleJson = element.getAsJsonObject(); - RawNewsArticle article = parseArticle(articleJson, source); - if (article.isValid()) { - articles.add(article); - } - } - - logger.info("NewsAPI에서 {}개 기사 수집 완료", articles.size()); - - } catch (Exception e) { - logger.error("NewsAPI 호출 중 오류 발생", e); - } - - return articles; - } - - /** - * JSON을 RawNewsArticle로 변환 - */ - private RawNewsArticle parseArticle(JsonObject json, String defaultSource) { - String sourceName = defaultSource; - if (json.has("source") && json.get("source").isJsonObject()) { - JsonObject sourceObj = json.getAsJsonObject("source"); - if (sourceObj.has("name") && !sourceObj.get("name").isJsonNull()) { - sourceName = sourceObj.get("name").getAsString(); - } - } - - return RawNewsArticle.builder() - .title(getStringOrNull(json, "title")) - .description(getStringOrNull(json, "description")) - .url(getStringOrNull(json, "url")) - .imageUrl(getStringOrNull(json, "urlToImage")) - .source(sourceName) - .publishedAt(getStringOrNull(json, "publishedAt")) - .content(getStringOrNull(json, "content")) - .build(); - } - - private String getStringOrNull(JsonObject json, String key) { - if (json.has(key) && !json.get(key).isJsonNull()) { - return json.get(key).getAsString(); - } - return null; - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java index e46bb6ef..9842f4b3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java @@ -10,37 +10,33 @@ import java.time.Instant; import java.time.LocalDate; import java.time.ZoneOffset; -import java.util.ArrayList; import java.util.List; import java.util.UUID; /** * 뉴스 수집 서비스 - * NewsAPI, RSS 피드에서 뉴스를 수집하고 저장 + * RSS 피드에서 뉴스를 수집하고 저장 (BBC, VOA, NPR) */ public class NewsCollectorService { private static final Logger logger = LoggerFactory.getLogger(NewsCollectorService.class); - private static final int NEWS_API_LIMIT = 10; - private static final int RSS_LIMIT_PER_SOURCE = 5; + private static final int RSS_LIMIT_PER_SOURCE = 7; private static final long TTL_DAYS = 30; - private final NewsApiClient newsApiClient; private final RssFeedParser rssFeedParser; private final NewsDuplicateChecker duplicateChecker; private final NewsArticleRepository articleRepository; public NewsCollectorService() { - this.newsApiClient = new NewsApiClient(); this.rssFeedParser = new RssFeedParser(); this.duplicateChecker = new NewsDuplicateChecker(); this.articleRepository = new NewsArticleRepository(); } - public NewsCollectorService(NewsApiClient newsApiClient, RssFeedParser rssFeedParser, - NewsDuplicateChecker duplicateChecker, NewsArticleRepository articleRepository) { - this.newsApiClient = newsApiClient; + public NewsCollectorService(RssFeedParser rssFeedParser, + NewsDuplicateChecker duplicateChecker, + NewsArticleRepository articleRepository) { this.rssFeedParser = rssFeedParser; this.duplicateChecker = duplicateChecker; this.articleRepository = articleRepository; @@ -53,29 +49,16 @@ public CollectionResult collectNews() { logger.info("뉴스 수집 시작"); long startTime = System.currentTimeMillis(); - List allArticles = new ArrayList<>(); - int newsApiCount = 0; - int rssCount = 0; - - try { - List newsApiArticles = newsApiClient.getTopHeadlines("technology", NEWS_API_LIMIT); - allArticles.addAll(newsApiArticles); - newsApiCount = newsApiArticles.size(); - logger.info("NewsAPI에서 {}개 수집", newsApiCount); - } catch (Exception e) { - logger.error("NewsAPI 수집 실패", e); - } - + List rssArticles; try { - List rssArticles = rssFeedParser.fetchAllFeeds(RSS_LIMIT_PER_SOURCE); - allArticles.addAll(rssArticles); - rssCount = rssArticles.size(); - logger.info("RSS에서 {}개 수집", rssCount); + rssArticles = rssFeedParser.fetchAllFeeds(RSS_LIMIT_PER_SOURCE); + logger.info("RSS에서 {}개 수집", rssArticles.size()); } catch (Exception e) { logger.error("RSS 수집 실패", e); + return new CollectionResult(0, 0, System.currentTimeMillis() - startTime); } - List uniqueArticles = duplicateChecker.filterDuplicates(allArticles); + List uniqueArticles = duplicateChecker.filterDuplicates(rssArticles); logger.info("중복 제거 후 {}개 기사", uniqueArticles.size()); int savedCount = 0; @@ -92,7 +75,7 @@ public CollectionResult collectNews() { long elapsed = System.currentTimeMillis() - startTime; logger.info("뉴스 수집 완료 - 저장: {}, 소요시간: {}ms", savedCount, elapsed); - return new CollectionResult(newsApiCount, rssCount, savedCount, elapsed); + return new CollectionResult(rssArticles.size(), savedCount, elapsed); } /** @@ -130,8 +113,7 @@ private NewsArticle convertToNewsArticle(RawNewsArticle raw) { * 수집 결과 레코드 */ public record CollectionResult( - int newsApiCount, - int rssCount, + int collectedCount, int savedCount, long elapsedMs ) { diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 7828e7a0..552cb8e9 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1741,8 +1741,6 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref NewsTable - - SSMParameterReadPolicy: - ParameterName: englishstudy/news/* Events: DailySchedule: Type: Schedule From 682c07a2bbbfa04ef8fd83971a70d76ab5606aaa Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 13:38:24 +0900 Subject: [PATCH 47/99] =?UTF-8?q?feat(news):=20AI=20=EB=89=B4=EC=8A=A4=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#387)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NewsAnalysisService: AI 분석 통합 서비스 - Bedrock: CEFR 난이도 분석 (A1~C2) - Bedrock: 3줄 요약 + 퀴즈 3문제 생성 - Comprehend: 핵심 키워드 추출 - NewsCollectorService: 수집 시 자동 분석 연동 - GSI1/GSI2 키 자동 설정 (레벨별, 카테고리별 조회) --- .../news/service/NewsAnalysisService.java | 321 ++++++++++++++++++ .../news/service/NewsCollectorService.java | 16 +- 2 files changed, 333 insertions(+), 4 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java new file mode 100644 index 00000000..af23fc5b --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java @@ -0,0 +1,321 @@ +package com.mzc.secondproject.serverless.domain.news.service; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.domain.news.model.KeywordInfo; +import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import com.mzc.secondproject.serverless.domain.news.model.QuizQuestion; +import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; +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 software.amazon.awssdk.services.comprehend.model.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 뉴스 AI 분석 서비스 + * - CEFR 난이도 분석 (Bedrock) + * - 3줄 요약 생성 (Bedrock) + * - 핵심 단어 추출 (Comprehend) + * - 퀴즈 생성 (Bedrock) + */ +public class NewsAnalysisService { + + private static final Logger logger = LoggerFactory.getLogger(NewsAnalysisService.class); + private static final Gson gson = new Gson(); + private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; + + private final NewsArticleRepository articleRepository; + + public NewsAnalysisService() { + this.articleRepository = new NewsArticleRepository(); + } + + public NewsAnalysisService(NewsArticleRepository articleRepository) { + this.articleRepository = articleRepository; + } + + /** + * 뉴스 기사 전체 분석 + */ + public NewsArticle analyzeArticle(NewsArticle article) { + logger.info("뉴스 분석 시작: {}", article.getArticleId()); + long startTime = System.currentTimeMillis(); + + String content = article.getTitle() + ". " + + (article.getSummary() != null ? article.getSummary() : ""); + + try { + // 1. CEFR 난이도 분석 + String cefrLevel = analyzeDifficulty(content); + article.setCefrLevel(cefrLevel); + article.setLevel(mapCefrToLevel(cefrLevel)); + + // 2. 핵심 단어 추출 (Comprehend) + List keywords = extractKeywords(content); + article.setKeywords(keywords); + + // 3. 3줄 요약 + 퀴즈 생성 (Bedrock - 한 번에 처리) + AnalysisResult result = generateSummaryAndQuiz(content, cefrLevel); + if (result.summary() != null) { + article.setSummary(result.summary()); + } + article.setQuiz(result.quiz()); + article.setHighlightWords(result.highlightWords()); + + // 4. GSI 키 설정 + article.setGsi1pk("LEVEL#" + article.getLevel()); + article.setGsi1sk(article.getPublishedAt()); + if (article.getCategory() != null) { + article.setGsi2pk("CATEGORY#" + article.getCategory()); + article.setGsi2sk(article.getPublishedAt()); + } + + // 5. 저장 + articleRepository.save(article); + + long elapsed = System.currentTimeMillis() - startTime; + logger.info("뉴스 분석 완료: {} ({}ms)", article.getArticleId(), elapsed); + + } catch (Exception e) { + logger.error("뉴스 분석 실패: {}", article.getArticleId(), e); + // 분석 실패해도 기본값으로 저장 + article.setLevel("INTERMEDIATE"); + article.setCefrLevel("B1"); + articleRepository.save(article); + } + + return article; + } + + /** + * CEFR 난이도 분석 (Bedrock) + */ + private String analyzeDifficulty(String content) { + String systemPrompt = """ + You are an English language expert. Analyze the text and determine its CEFR level. + Consider vocabulary complexity, sentence structure, and topic familiarity. + + Respond with ONLY the CEFR level code: A1, A2, B1, B2, C1, or C2 + No explanation, just the level code. + """; + + String userPrompt = "Determine the CEFR level of this text:\n\n" + truncate(content, 1000); + + String response = invokeBedrock(systemPrompt, userPrompt); + String level = response.trim().toUpperCase(); + + // 유효한 레벨인지 확인 + if (List.of("A1", "A2", "B1", "B2", "C1", "C2").contains(level)) { + return level; + } + + // 레벨 추출 시도 + for (String validLevel : List.of("C2", "C1", "B2", "B1", "A2", "A1")) { + if (response.toUpperCase().contains(validLevel)) { + return validLevel; + } + } + + return "B1"; // 기본값 + } + + /** + * CEFR을 3단계 레벨로 매핑 + */ + private String mapCefrToLevel(String cefrLevel) { + return switch (cefrLevel) { + case "A1", "A2" -> "BEGINNER"; + case "B1", "B2" -> "INTERMEDIATE"; + case "C1", "C2" -> "ADVANCED"; + default -> "INTERMEDIATE"; + }; + } + + /** + * 핵심 단어 추출 (Comprehend) + */ + private List extractKeywords(String content) { + try { + DetectKeyPhrasesResponse response = AwsClients.comprehend().detectKeyPhrases( + DetectKeyPhrasesRequest.builder() + .text(truncate(content, 5000)) + .languageCode(LanguageCode.EN) + .build() + ); + + List keywords = new ArrayList<>(); + List phrases = response.keyPhrases(); + + for (int i = 0; i < Math.min(phrases.size(), 10); i++) { + KeyPhrase phrase = phrases.get(i); + if (phrase.score() > 0.8) { + keywords.add(KeywordInfo.builder() + .word(phrase.text()) + .position(i) + .build()); + } + } + + return keywords; + + } catch (Exception e) { + logger.error("키워드 추출 실패", e); + return new ArrayList<>(); + } + } + + /** + * 요약 + 퀴즈 생성 (Bedrock) + */ + private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) { + String systemPrompt = """ + You are an English learning assistant. Analyze the news article and create learning materials. + + Respond in this exact JSON format: + { + "summary": "3-line summary in English (each line separated by newline)", + "highlightWords": ["word1", "word2", "word3"], + "quiz": [ + { + "questionId": "q1", + "type": "COMPREHENSION", + "question": "What is the main topic of this article?", + "options": ["Option A", "Option B", "Option C", "Option D"], + "correctAnswer": "Option A", + "points": 20 + }, + { + "questionId": "q2", + "type": "WORD_MATCH", + "question": "What does 'X' mean in this context?", + "options": ["meaning1", "meaning2", "meaning3", "meaning4"], + "correctAnswer": "meaning1", + "points": 15 + }, + { + "questionId": "q3", + "type": "FILL_BLANK", + "question": "The article mentions that _____ is important.", + "options": ["word1", "word2", "word3", "word4"], + "correctAnswer": "word1", + "points": 30 + } + ] + } + + Create exactly 3 quiz questions. + highlightWords should contain 3-5 difficult words for learners. + Adjust difficulty based on CEFR level: """ + cefrLevel; + + String userPrompt = "Create learning materials for this article:\n\n" + truncate(content, 1500); + + try { + String response = invokeBedrock(systemPrompt, userPrompt); + return parseAnalysisResult(response); + } catch (Exception e) { + logger.error("요약/퀴즈 생성 실패", e); + return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>()); + } + } + + /** + * Bedrock API 호출 + */ + private String invokeBedrock(String systemPrompt, String userPrompt) { + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); + requestBody.addProperty("max_tokens", 2000); + requestBody.addProperty("system", systemPrompt); + + JsonArray messages = new JsonArray(); + JsonObject userMessage = new JsonObject(); + userMessage.addProperty("role", "user"); + userMessage.addProperty("content", userPrompt); + messages.add(userMessage); + requestBody.add("messages", messages); + + InvokeModelRequest request = InvokeModelRequest.builder() + .modelId(MODEL_ID) + .contentType("application/json") + .accept("application/json") + .body(SdkBytes.fromUtf8String(gson.toJson(requestBody))) + .build(); + + InvokeModelResponse response = AwsClients.bedrock().invokeModel(request); + JsonObject jsonResponse = gson.fromJson(response.body().asUtf8String(), JsonObject.class); + + JsonArray contentArray = jsonResponse.getAsJsonArray("content"); + if (contentArray != null && !contentArray.isEmpty()) { + return contentArray.get(0).getAsJsonObject().get("text").getAsString(); + } + + throw new RuntimeException("Empty response from Bedrock"); + } + + /** + * 분석 결과 파싱 + */ + private AnalysisResult parseAnalysisResult(String response) { + String jsonStr = extractJson(response); + JsonObject json = gson.fromJson(jsonStr, JsonObject.class); + + String summary = json.has("summary") ? json.get("summary").getAsString() : null; + + List highlightWords = new ArrayList<>(); + if (json.has("highlightWords")) { + json.getAsJsonArray("highlightWords").forEach(e -> highlightWords.add(e.getAsString())); + } + + List quiz = new ArrayList<>(); + if (json.has("quiz")) { + json.getAsJsonArray("quiz").forEach(e -> { + JsonObject q = e.getAsJsonObject(); + List options = new ArrayList<>(); + if (q.has("options")) { + q.getAsJsonArray("options").forEach(opt -> options.add(opt.getAsString())); + } + quiz.add(QuizQuestion.builder() + .questionId(q.has("questionId") ? q.get("questionId").getAsString() : null) + .type(q.has("type") ? q.get("type").getAsString() : "COMPREHENSION") + .question(q.has("question") ? q.get("question").getAsString() : "") + .options(options) + .correctAnswer(q.has("correctAnswer") ? q.get("correctAnswer").getAsString() : "") + .points(q.has("points") ? q.get("points").getAsInt() : 20) + .build()); + }); + } + + return new AnalysisResult(summary, highlightWords, quiz); + } + + private String extractJson(String response) { + int start = response.indexOf('{'); + int end = response.lastIndexOf('}'); + if (start != -1 && end != -1 && end > start) { + return response.substring(start, end + 1); + } + return response; + } + + private String truncate(String text, int maxLength) { + if (text == null) return ""; + return text.length() > maxLength ? text.substring(0, maxLength) : text; + } + + /** + * 분석 결과 레코드 + */ + private record AnalysisResult( + String summary, + List highlightWords, + List quiz + ) {} +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java index 9842f4b3..ecac47df 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java @@ -27,19 +27,23 @@ public class NewsCollectorService { private final RssFeedParser rssFeedParser; private final NewsDuplicateChecker duplicateChecker; private final NewsArticleRepository articleRepository; + private final NewsAnalysisService analysisService; public NewsCollectorService() { this.rssFeedParser = new RssFeedParser(); this.duplicateChecker = new NewsDuplicateChecker(); this.articleRepository = new NewsArticleRepository(); + this.analysisService = new NewsAnalysisService(); } public NewsCollectorService(RssFeedParser rssFeedParser, NewsDuplicateChecker duplicateChecker, - NewsArticleRepository articleRepository) { + NewsArticleRepository articleRepository, + NewsAnalysisService analysisService) { this.rssFeedParser = rssFeedParser; this.duplicateChecker = duplicateChecker; this.articleRepository = articleRepository; + this.analysisService = analysisService; } /** @@ -62,18 +66,22 @@ public CollectionResult collectNews() { logger.info("중복 제거 후 {}개 기사", uniqueArticles.size()); int savedCount = 0; + int analyzedCount = 0; for (RawNewsArticle rawArticle : uniqueArticles) { try { NewsArticle article = convertToNewsArticle(rawArticle); - articleRepository.save(article); + + // AI 분석 수행 (난이도, 요약, 키워드, 퀴즈) + analysisService.analyzeArticle(article); + analyzedCount++; savedCount++; } catch (Exception e) { - logger.error("기사 저장 실패: {}", rawArticle.getTitle(), e); + logger.error("기사 처리 실패: {}", rawArticle.getTitle(), e); } } long elapsed = System.currentTimeMillis() - startTime; - logger.info("뉴스 수집 완료 - 저장: {}, 소요시간: {}ms", savedCount, elapsed); + logger.info("뉴스 수집/분석 완료 - 저장: {}, 분석: {}, 소요시간: {}ms", savedCount, analyzedCount, elapsed); return new CollectionResult(rssArticles.size(), savedCount, elapsed); } From 626efcdb568480928c6bad22f133ed9c01df948d Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 13:43:13 +0900 Subject: [PATCH 48/99] =?UTF-8?q?feat(news):=20=EB=89=B4=EC=8A=A4=20?= =?UTF-8?q?=ED=95=99=EC=8A=B5=20API=20=EA=B5=AC=ED=98=84=20(#388)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NewsQueryService: 뉴스 조회 서비스 - NewsHandler: API 핸들러 - GET /news - 목록 조회 (level, category 필터) - GET /news/today - 오늘의 뉴스 - GET /news/recommended - 내 레벨 맞춤 추천 - GET /news/{articleId} - 상세 조회 (조회수 증가) - template.yaml: NewsFunction Lambda 추가 --- .../domain/news/handler/NewsHandler.java | 166 ++++++++++++++++++ .../domain/news/service/NewsQueryService.java | 98 +++++++++++ ServerlessFunction/template.yaml | 38 ++++ 3 files changed, 302 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java new file mode 100644 index 00000000..3f53332b --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -0,0 +1,166 @@ +package com.mzc.secondproject.serverless.domain.news.handler; + +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.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.router.HandlerRouter; +import com.mzc.secondproject.serverless.common.router.Route; +import com.mzc.secondproject.serverless.common.util.CognitoUtil; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.domain.news.exception.NewsErrorCode; +import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import com.mzc.secondproject.serverless.domain.news.service.NewsQueryService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * 뉴스 학습 API 핸들러 + */ +public class NewsHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(NewsHandler.class); + private static final int DEFAULT_LIMIT = 10; + private static final int MAX_LIMIT = 50; + + private final NewsQueryService queryService; + private final HandlerRouter router; + + public NewsHandler() { + this(new NewsQueryService()); + } + + public NewsHandler(NewsQueryService queryService) { + this.queryService = queryService; + this.router = initRouter(); + } + + private HandlerRouter initRouter() { + return new HandlerRouter().addRoutes( + Route.get("/news/today", this::getTodayNews), + Route.get("/news/recommended", this::getRecommendedNews), + Route.get("/news/{articleId}", this::getNewsDetail), + Route.get("/news", this::getNewsList) + ); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("News API 요청: {} {}", request.getHttpMethod(), request.getPath()); + return router.route(request); + } + + /** + * 뉴스 목록 조회 (필터링 지원) + * GET /news?level=INTERMEDIATE&category=TECH&limit=10&cursor=xxx + */ + private APIGatewayProxyResponseEvent getNewsList(APIGatewayProxyRequestEvent request) { + Map params = request.getQueryStringParameters(); + if (params == null) params = new HashMap<>(); + + String level = params.get("level"); + String category = params.get("category"); + String cursor = params.get("cursor"); + int limit = parseLimit(params.get("limit")); + + PaginatedResult result; + + if (level != null && category != null) { + result = queryService.getNewsByLevelAndCategory(level.toUpperCase(), category.toUpperCase(), limit, cursor); + } else if (level != null) { + result = queryService.getNewsByLevel(level.toUpperCase(), limit, cursor); + } else if (category != null) { + result = queryService.getNewsByCategory(category.toUpperCase(), limit, cursor); + } else { + result = queryService.getTodayNews(limit, cursor); + } + + return buildPaginatedResponse(result); + } + + /** + * 오늘의 뉴스 조회 + * GET /news/today?limit=10&cursor=xxx + */ + private APIGatewayProxyResponseEvent getTodayNews(APIGatewayProxyRequestEvent request) { + Map params = request.getQueryStringParameters(); + if (params == null) params = new HashMap<>(); + + String cursor = params.get("cursor"); + int limit = parseLimit(params.get("limit")); + + PaginatedResult result = queryService.getTodayNews(limit, cursor); + return buildPaginatedResponse(result); + } + + /** + * 내 레벨 맞춤 뉴스 추천 + * GET /news/recommended?limit=10&cursor=xxx + */ + private APIGatewayProxyResponseEvent getRecommendedNews(APIGatewayProxyRequestEvent request) { + Map params = request.getQueryStringParameters(); + if (params == null) params = new HashMap<>(); + + // 사용자 레벨 조회 (Cognito 토큰에서) + String userLevel = getUserLevel(request); + String cursor = params.get("cursor"); + int limit = parseLimit(params.get("limit")); + + PaginatedResult result = queryService.getRecommendedNews(userLevel, limit, cursor); + return buildPaginatedResponse(result); + } + + /** + * 뉴스 상세 조회 + * GET /news/{articleId} + */ + private APIGatewayProxyResponseEvent getNewsDetail(APIGatewayProxyRequestEvent request) { + String articleId = request.getPathParameters().get("articleId"); + + Optional article = queryService.getArticle(articleId); + if (article.isEmpty()) { + return ResponseGenerator.fail(NewsErrorCode.ARTICLE_NOT_FOUND); + } + + return ResponseGenerator.ok("뉴스 조회 성공", article.get()); + } + + /** + * 페이지네이션 응답 생성 + */ + private APIGatewayProxyResponseEvent buildPaginatedResponse(PaginatedResult result) { + Map response = new HashMap<>(); + response.put("articles", result.items()); + response.put("nextCursor", result.nextCursor()); + response.put("hasMore", result.hasMore()); + response.put("count", result.items().size()); + + return ResponseGenerator.ok("뉴스 목록 조회 성공", response); + } + + /** + * limit 파싱 + */ + private int parseLimit(String limitStr) { + if (limitStr == null) return DEFAULT_LIMIT; + try { + int limit = Integer.parseInt(limitStr); + return Math.min(Math.max(limit, 1), MAX_LIMIT); + } catch (NumberFormatException e) { + return DEFAULT_LIMIT; + } + } + + /** + * 사용자 레벨 조회 + */ + private String getUserLevel(APIGatewayProxyRequestEvent request) { + return CognitoUtil.extractClaim(request, "custom:level") + .orElse("INTERMEDIATE"); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java new file mode 100644 index 00000000..5a3930ed --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java @@ -0,0 +1,98 @@ +package com.mzc.secondproject.serverless.domain.news.service; + +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.LocalDate; +import java.util.Optional; + +/** + * 뉴스 조회 서비스 + */ +public class NewsQueryService { + + private static final Logger logger = LoggerFactory.getLogger(NewsQueryService.class); + + private final NewsArticleRepository articleRepository; + + public NewsQueryService() { + this.articleRepository = new NewsArticleRepository(); + } + + public NewsQueryService(NewsArticleRepository articleRepository) { + this.articleRepository = articleRepository; + } + + /** + * 뉴스 상세 조회 + */ + public Optional getArticle(String articleId) { + logger.debug("뉴스 상세 조회: {}", articleId); + Optional article = articleRepository.findById(articleId); + + // 조회수 증가 + article.ifPresent(a -> { + String date = extractDateFromPk(a.getPk()); + if (date != null) { + articleRepository.incrementReadCount(date, articleId); + } + }); + + return article; + } + + /** + * 오늘의 뉴스 목록 조회 + */ + public PaginatedResult getTodayNews(int limit, String cursor) { + String today = LocalDate.now().toString(); + logger.debug("오늘의 뉴스 조회: date={}, limit={}", today, limit); + return articleRepository.findByDate(today, limit, cursor); + } + + /** + * 레벨별 뉴스 조회 + */ + public PaginatedResult getNewsByLevel(String level, int limit, String cursor) { + logger.debug("레벨별 뉴스 조회: level={}, limit={}", level, limit); + return articleRepository.findByLevel(level, limit, cursor); + } + + /** + * 카테고리별 뉴스 조회 + */ + public PaginatedResult getNewsByCategory(String category, int limit, String cursor) { + logger.debug("카테고리별 뉴스 조회: category={}, limit={}", category, limit); + return articleRepository.findByCategory(category, limit, cursor); + } + + /** + * 레벨 + 카테고리 복합 필터 조회 + */ + public PaginatedResult getNewsByLevelAndCategory(String level, String category, int limit, String cursor) { + logger.debug("레벨+카테고리 뉴스 조회: level={}, category={}, limit={}", level, category, limit); + return articleRepository.findByLevelAndCategory(level, category, limit, cursor); + } + + /** + * 사용자 레벨 맞춤 뉴스 추천 + */ + public PaginatedResult getRecommendedNews(String userLevel, int limit, String cursor) { + logger.debug("맞춤 뉴스 추천: userLevel={}, limit={}", userLevel, limit); + // 사용자 레벨에 맞는 뉴스 조회 + return articleRepository.findByLevel(userLevel, limit, cursor); + } + + /** + * PK에서 날짜 추출 (NEWS#2024-01-15 → 2024-01-15) + */ + private String extractDateFromPk(String pk) { + if (pk == null || !pk.startsWith("NEWS#")) { + return null; + } + return pk.substring(5); + } +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 552cb8e9..8288972b 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1750,6 +1750,44 @@ Resources: 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 + 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 + GetNewsDetail: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId} + Method: GET + NewsTable: Type: AWS::DynamoDB::Table Properties: From db6dd875c6f69a96b959b7bdde72abf1b6214bc6 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 13:51:43 +0900 Subject: [PATCH 49/99] =?UTF-8?q?feat(news):=20=EB=89=B4=EC=8A=A4=20?= =?UTF-8?q?=ED=95=99=EC=8A=B5=20=EB=B6=80=EA=B0=80=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#389)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 읽기 완료 기록 API (POST /news/{articleId}/read) - 북마크 토글 API (POST /news/{articleId}/bookmark) - 북마크 목록 조회 API (GET /news/bookmarks) - 학습 통계 조회 API (GET /news/stats) - TTS 오디오 URL 조회 API (GET /news/{articleId}/audio) - UserNewsRecord 모델 추가 - UserNewsRepository 추가 - NewsLearningService 추가 --- .../domain/news/constants/NewsKey.java | 7 + .../domain/news/exception/NewsErrorCode.java | 3 + .../domain/news/handler/NewsHandler.java | 112 +++++++++- .../domain/news/model/UserNewsRecord.java | 58 +++++ .../news/repository/UserNewsRepository.java | 208 ++++++++++++++++++ .../news/service/NewsLearningService.java | 160 ++++++++++++++ ServerlessFunction/template.yaml | 37 ++++ 7 files changed, 583 insertions(+), 2 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java index e5ba97b8..4c44a58f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java @@ -119,4 +119,11 @@ public static String commentSk(String commentId) { public static String userNewsCommentsPk(String userId) { return DynamoDbKey.USER + userId + SUFFIX_NEWS_COMMENTS; } + + /** + * 사용자 뉴스 통계 GSI1 PK: USER_NEWS_STAT#{userId} + */ + public static String userNewsStatPk(String userId) { + return "USER_NEWS_STAT#" + userId; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java index fa898252..58197f0e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java @@ -8,6 +8,9 @@ */ public enum NewsErrorCode implements DomainErrorCode { + // 인증 관련 에러 + UNAUTHORIZED("AUTH_001", "인증이 필요합니다", 401), + // 뉴스 기사 관련 에러 ARTICLE_NOT_FOUND("ARTICLE_001", "뉴스 기사를 찾을 수 없습니다", 404), INVALID_ARTICLE_DATA("ARTICLE_002", "뉴스 기사 데이터가 유효하지 않습니다", 400), diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java index 3f53332b..8e42762d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -11,11 +11,14 @@ import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.news.exception.NewsErrorCode; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; +import com.mzc.secondproject.serverless.domain.news.service.NewsLearningService; import com.mzc.secondproject.serverless.domain.news.service.NewsQueryService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -29,14 +32,16 @@ public class NewsHandler implements RequestHandler stats = learningService.getUserStats(userId); + return ResponseGenerator.ok("뉴스 학습 통계 조회 성공", stats); + } + + /** + * 북마크 목록 조회 + * GET /news/bookmarks?limit=10 + */ + private APIGatewayProxyResponseEvent getBookmarks(APIGatewayProxyRequestEvent request) { + String userId = getUserId(request); + if (userId == null) { + return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); + } + + Map params = request.getQueryStringParameters(); + if (params == null) params = new HashMap<>(); + + int limit = parseLimit(params.get("limit")); + List bookmarks = learningService.getUserBookmarks(userId, limit); + + Map response = new HashMap<>(); + response.put("bookmarks", bookmarks); + response.put("count", bookmarks.size()); + + return ResponseGenerator.ok("북마크 목록 조회 성공", response); + } + + /** + * 뉴스 읽기 완료 기록 + * POST /news/{articleId}/read + */ + private APIGatewayProxyResponseEvent markAsRead(APIGatewayProxyRequestEvent request) { + String userId = getUserId(request); + if (userId == null) { + return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); + } + + String articleId = request.getPathParameters().get("articleId"); + learningService.markAsRead(userId, articleId); + + return ResponseGenerator.ok("읽기 완료 기록 성공", Map.of("articleId", articleId)); + } + + /** + * 북마크 토글 + * POST /news/{articleId}/bookmark + */ + private APIGatewayProxyResponseEvent toggleBookmark(APIGatewayProxyRequestEvent request) { + String userId = getUserId(request); + if (userId == null) { + return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); + } + + String articleId = request.getPathParameters().get("articleId"); + boolean isBookmarked = learningService.toggleBookmark(userId, articleId); + + return ResponseGenerator.ok( + isBookmarked ? "북마크 추가 성공" : "북마크 해제 성공", + Map.of("articleId", articleId, "bookmarked", isBookmarked) + ); + } + + /** + * 뉴스 TTS 오디오 URL 조회 + * GET /news/{articleId}/audio?voice=Joanna + */ + private APIGatewayProxyResponseEvent getAudio(APIGatewayProxyRequestEvent request) { + String articleId = request.getPathParameters().get("articleId"); + + Map params = request.getQueryStringParameters(); + String voice = (params != null) ? params.getOrDefault("voice", "Joanna") : "Joanna"; + + String audioUrl = learningService.getAudioUrl(articleId, voice); + if (audioUrl == null) { + return ResponseGenerator.fail(NewsErrorCode.ARTICLE_NOT_FOUND); + } + + return ResponseGenerator.ok("TTS 오디오 URL 조회 성공", Map.of("audioUrl", audioUrl)); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java new file mode 100644 index 00000000..eedfa8c5 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java @@ -0,0 +1,58 @@ +package com.mzc.secondproject.serverless.domain.news.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +/** + * 사용자 뉴스 학습 기록 + * PK: USER_NEWS#{userId} + * SK: READ#{articleId} 또는 BOOKMARK#{articleId} + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class UserNewsRecord { + + private String pk; // USER_NEWS#{userId} + private String sk; // READ#{articleId} 또는 BOOKMARK#{articleId} + private String gsi1pk; // USER_NEWS_STAT#{userId} + private String gsi1sk; // {date}#{type} + + private String userId; + private String articleId; + private String type; // READ, BOOKMARK + private String articleTitle; + private String articleLevel; + private String articleCategory; + private String createdAt; + private Long ttl; + + @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; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java new file mode 100644 index 00000000..25f8e651 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java @@ -0,0 +1,208 @@ +package com.mzc.secondproject.serverless.domain.news.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; +import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.*; +import software.amazon.awssdk.enhanced.dynamodb.model.*; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.*; + +/** + * 사용자 뉴스 학습 기록 Repository + */ +public class UserNewsRepository { + + private static final Logger logger = LoggerFactory.getLogger(UserNewsRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); + + private final DynamoDbTable table; + + public UserNewsRepository() { + this(AwsClients.dynamoDbEnhanced()); + } + + public UserNewsRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(UserNewsRecord.class)); + } + + /** + * 읽기 기록 저장 + */ + public void saveReadRecord(String userId, String articleId, String title, String level, String category) { + String now = Instant.now().toString(); + String today = LocalDate.now().toString(); + + UserNewsRecord record = UserNewsRecord.builder() + .pk(NewsKey.userNewsPk(userId)) + .sk(NewsKey.readSk(articleId)) + .gsi1pk(NewsKey.userNewsStatPk(userId)) + .gsi1sk(today + "#READ") + .userId(userId) + .articleId(articleId) + .type("READ") + .articleTitle(title) + .articleLevel(level) + .articleCategory(category) + .createdAt(now) + .build(); + + table.putItem(record); + logger.debug("읽기 기록 저장: userId={}, articleId={}", userId, articleId); + } + + /** + * 북마크 저장 + */ + public void saveBookmark(String userId, String articleId, String title, String level, String category) { + String now = Instant.now().toString(); + String today = LocalDate.now().toString(); + + UserNewsRecord record = UserNewsRecord.builder() + .pk(NewsKey.userNewsPk(userId)) + .sk(NewsKey.bookmarkSk(articleId)) + .gsi1pk(NewsKey.userNewsStatPk(userId)) + .gsi1sk(today + "#BOOKMARK") + .userId(userId) + .articleId(articleId) + .type("BOOKMARK") + .articleTitle(title) + .articleLevel(level) + .articleCategory(category) + .createdAt(now) + .build(); + + table.putItem(record); + logger.debug("북마크 저장: userId={}, articleId={}", userId, articleId); + } + + /** + * 북마크 삭제 + */ + public void deleteBookmark(String userId, String articleId) { + Key key = Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue(NewsKey.bookmarkSk(articleId)) + .build(); + + table.deleteItem(key); + logger.debug("북마크 삭제: userId={}, articleId={}", userId, articleId); + } + + /** + * 북마크 여부 확인 + */ + public boolean isBookmarked(String userId, String articleId) { + Key key = Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue(NewsKey.bookmarkSk(articleId)) + .build(); + + return table.getItem(key) != null; + } + + /** + * 읽기 기록 여부 확인 + */ + public boolean hasRead(String userId, String articleId) { + Key key = Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue(NewsKey.readSk(articleId)) + .build(); + + return table.getItem(key) != null; + } + + /** + * 사용자 북마크 목록 조회 + */ + public List getUserBookmarks(String userId, int limit) { + QueryConditional queryConditional = QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue("BOOKMARK#") + .build() + ); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) + .limit(limit) + .build(); + + List results = new ArrayList<>(); + for (Page page : table.query(request)) { + results.addAll(page.items()); + if (results.size() >= limit) break; + } + + return results.subList(0, Math.min(results.size(), limit)); + } + + /** + * 사용자 뉴스 통계 조회 + */ + public NewsStats getUserStats(String userId) { + QueryConditional queryConditional = QueryConditional.keyEqualTo( + Key.builder().partitionValue(NewsKey.userNewsPk(userId)).build() + ); + + int totalRead = 0; + int thisWeekRead = 0; + int totalBookmarks = 0; + Map byLevel = new HashMap<>(); + Map byCategory = new HashMap<>(); + + LocalDate weekAgo = LocalDate.now().minusDays(7); + + for (Page page : table.query(queryConditional)) { + for (UserNewsRecord record : page.items()) { + if ("READ".equals(record.getType())) { + totalRead++; + + // 이번 주 읽은 것 + if (record.getCreatedAt() != null) { + LocalDate readDate = Instant.parse(record.getCreatedAt()) + .atZone(java.time.ZoneId.systemDefault()).toLocalDate(); + if (readDate.isAfter(weekAgo)) { + thisWeekRead++; + } + } + + // 레벨별 통계 + String level = record.getArticleLevel(); + if (level != null) { + byLevel.merge(level, 1, Integer::sum); + } + + // 카테고리별 통계 + String category = record.getArticleCategory(); + if (category != null) { + byCategory.merge(category, 1, Integer::sum); + } + } else if ("BOOKMARK".equals(record.getType())) { + totalBookmarks++; + } + } + } + + return new NewsStats(totalRead, thisWeekRead, totalBookmarks, byLevel, byCategory); + } + + /** + * 뉴스 통계 레코드 + */ + public record NewsStats( + int totalRead, + int thisWeekRead, + int totalBookmarks, + Map byLevel, + Map byCategory + ) {} +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java new file mode 100644 index 00000000..8eba8522 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java @@ -0,0 +1,160 @@ +package com.mzc.secondproject.serverless.domain.news.service; + +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.common.service.PollyService; +import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; +import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; +import com.mzc.secondproject.serverless.domain.news.repository.UserNewsRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 뉴스 학습 부가 기능 서비스 + */ +public class NewsLearningService { + + private static final Logger logger = LoggerFactory.getLogger(NewsLearningService.class); + private static final String BUCKET_NAME = EnvConfig.getOrDefault("NEWS_BUCKET_NAME", "group2-englishstudy"); + + private final NewsArticleRepository articleRepository; + private final UserNewsRepository userNewsRepository; + private final PollyService pollyService; + + public NewsLearningService() { + this.articleRepository = new NewsArticleRepository(); + this.userNewsRepository = new UserNewsRepository(); + this.pollyService = new PollyService(BUCKET_NAME, "news/audio/"); + } + + public NewsLearningService(NewsArticleRepository articleRepository, + UserNewsRepository userNewsRepository, + PollyService pollyService) { + this.articleRepository = articleRepository; + this.userNewsRepository = userNewsRepository; + this.pollyService = pollyService; + } + + /** + * 뉴스 읽기 완료 기록 + */ + public void markAsRead(String userId, String articleId) { + Optional article = articleRepository.findById(articleId); + if (article.isEmpty()) { + logger.warn("기사를 찾을 수 없음: {}", articleId); + return; + } + + NewsArticle a = article.get(); + userNewsRepository.saveReadRecord( + userId, + articleId, + a.getTitle(), + a.getLevel(), + a.getCategory() + ); + + // 조회수 증가 + String date = extractDateFromPk(a.getPk()); + if (date != null) { + articleRepository.incrementReadCount(date, articleId); + } + + logger.info("읽기 완료 기록: userId={}, articleId={}", userId, articleId); + } + + /** + * 북마크 토글 + */ + public boolean toggleBookmark(String userId, String articleId) { + boolean isBookmarked = userNewsRepository.isBookmarked(userId, articleId); + + if (isBookmarked) { + userNewsRepository.deleteBookmark(userId, articleId); + logger.info("북마크 해제: userId={}, articleId={}", userId, articleId); + return false; + } else { + Optional article = articleRepository.findById(articleId); + if (article.isEmpty()) { + logger.warn("기사를 찾을 수 없음: {}", articleId); + return false; + } + + NewsArticle a = article.get(); + userNewsRepository.saveBookmark( + userId, + articleId, + a.getTitle(), + a.getLevel(), + a.getCategory() + ); + logger.info("북마크 추가: userId={}, articleId={}", userId, articleId); + return true; + } + } + + /** + * 북마크 여부 확인 + */ + public boolean isBookmarked(String userId, String articleId) { + return userNewsRepository.isBookmarked(userId, articleId); + } + + /** + * 사용자 북마크 목록 조회 + */ + public List getUserBookmarks(String userId, int limit) { + return userNewsRepository.getUserBookmarks(userId, limit); + } + + /** + * 뉴스 TTS 오디오 URL 생성 + */ + public String getAudioUrl(String articleId, String voice) { + Optional article = articleRepository.findById(articleId); + if (article.isEmpty()) { + logger.warn("기사를 찾을 수 없음: {}", articleId); + return null; + } + + NewsArticle a = article.get(); + String text = a.getTitle() + ". " + (a.getSummary() != null ? a.getSummary() : ""); + + // 텍스트가 너무 길면 제한 + if (text.length() > 3000) { + text = text.substring(0, 3000); + } + + PollyService.VoiceSynthesisResult result = pollyService.synthesizeSpeech(articleId, text, voice); + return result.getAudioUrl(); + } + + /** + * 사용자 뉴스 학습 통계 조회 + */ + public Map getUserStats(String userId) { + UserNewsRepository.NewsStats stats = userNewsRepository.getUserStats(userId); + + return Map.of( + "totalRead", stats.totalRead(), + "thisWeekRead", stats.thisWeekRead(), + "totalBookmarks", stats.totalBookmarks(), + "byLevel", stats.byLevel(), + "byCategory", stats.byCategory() + ); + } + + /** + * PK에서 날짜 추출 + */ + private String extractDateFromPk(String pk) { + if (pk == null || !pk.startsWith("NEWS#")) { + return null; + } + return pk.substring(5); + } +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 8288972b..8bc1de01 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1762,6 +1762,13 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref NewsTable + - S3CrudPolicy: + BucketName: !Ref ContentBucket + - Statement: + - Effect: Allow + Action: + - polly:SynthesizeSpeech + Resource: "*" Events: GetNewsList: Type: Api @@ -1781,6 +1788,36 @@ Resources: RestApiId: !Ref MainApi Path: /news/recommended Method: GET + GetNewsStats: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/stats + Method: GET + GetBookmarks: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/bookmarks + Method: GET + MarkAsRead: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/read + Method: POST + ToggleBookmark: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/bookmark + Method: POST + GetAudio: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/audio + Method: GET GetNewsDetail: Type: Api Properties: From 63d748ddca2e1cdef1b575a7886d247d9f588f83 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 14:01:27 +0900 Subject: [PATCH 50/99] =?UTF-8?q?feat(news):=20=EB=B3=B5=ED=95=A9=20?= =?UTF-8?q?=ED=80=B4=EC=A6=88=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#471)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 퀴즈 조회 API (GET /news/{articleId}/quiz) - 퀴즈 제출 API (POST /news/{articleId}/quiz) - 퀴즈 기록 조회 API (GET /news/quiz/history) - NewsQuizResult 모델 추가 - QuizAnswerResult 모델 추가 - NewsQuizRepository 추가 - NewsQuizService 추가 --- .../domain/news/handler/NewsHandler.java | 97 ++++++- .../domain/news/model/NewsQuizResult.java | 63 +++++ .../domain/news/model/QuizAnswerResult.java | 25 ++ .../news/repository/NewsQuizRepository.java | 126 +++++++++ .../domain/news/service/NewsQuizService.java | 263 ++++++++++++++++++ ServerlessFunction/template.yaml | 18 ++ 6 files changed, 590 insertions(+), 2 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java index 8e42762d..ac23fd5d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -11,9 +11,14 @@ import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.news.exception.NewsErrorCode; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import com.mzc.secondproject.serverless.domain.news.model.NewsQuizResult; import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; import com.mzc.secondproject.serverless.domain.news.service.NewsLearningService; import com.mzc.secondproject.serverless.domain.news.service.NewsQueryService; +import com.mzc.secondproject.serverless.domain.news.service.NewsQuizService; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -30,18 +35,21 @@ public class NewsHandler implements RequestHandler quizData = quizService.getQuiz(articleId, userId); + + if (quizData.isEmpty()) { + return ResponseGenerator.fail(NewsErrorCode.QUIZ_NOT_FOUND); + } + + return ResponseGenerator.ok("퀴즈 조회 성공", quizData.get()); + } + + /** + * 퀴즈 제출 + * POST /news/{articleId}/quiz + */ + private APIGatewayProxyResponseEvent submitQuiz(APIGatewayProxyRequestEvent request) { + String userId = getUserId(request); + if (userId == null) { + return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); + } + + String articleId = request.getPathParameters().get("articleId"); + + // 요청 바디 파싱 + JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); + JsonArray answersArray = body.getAsJsonArray("answers"); + Integer timeTaken = body.has("timeTaken") ? body.get("timeTaken").getAsInt() : null; + + List answers = new java.util.ArrayList<>(); + if (answersArray != null) { + answersArray.forEach(e -> { + JsonObject a = e.getAsJsonObject(); + answers.add(new NewsQuizService.QuizAnswer( + a.get("questionId").getAsString(), + a.get("answer").getAsString() + )); + }); + } + + NewsQuizService.QuizSubmitResult result = quizService.submitQuiz(userId, articleId, answers, timeTaken); + + if (result == null) { + return ResponseGenerator.fail(NewsErrorCode.QUIZ_ALREADY_SUBMITTED); + } + + return ResponseGenerator.ok("퀴즈 제출 성공", result); + } + + /** + * 퀴즈 기록 조회 + * GET /news/quiz/history?limit=10 + */ + private APIGatewayProxyResponseEvent getQuizHistory(APIGatewayProxyRequestEvent request) { + String userId = getUserId(request); + if (userId == null) { + return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); + } + + Map params = request.getQueryStringParameters(); + if (params == null) params = new HashMap<>(); + + int limit = parseLimit(params.get("limit")); + List history = quizService.getUserQuizHistory(userId, limit); + Map quizStats = quizService.getUserQuizStats(userId); + + Map response = new HashMap<>(); + response.put("history", history); + response.put("stats", quizStats); + response.put("count", history.size()); + + return ResponseGenerator.ok("퀴즈 기록 조회 성공", response); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java new file mode 100644 index 00000000..23b47f62 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java @@ -0,0 +1,63 @@ +package com.mzc.secondproject.serverless.domain.news.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +import java.util.List; + +/** + * 뉴스 퀴즈 결과 + * PK: USER#{userId}#NEWS + * SK: QUIZ#{articleId} + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class NewsQuizResult { + + private String pk; // USER#{userId}#NEWS + private String sk; // QUIZ#{articleId} + private String gsi1pk; // USER_NEWS_STAT#{userId} + private String gsi1sk; // {date}#QUIZ + + private String userId; + private String articleId; + private String articleTitle; + private String articleLevel; + private int score; // 0-100 + private int totalPoints; // 총 배점 + private int earnedPoints; // 획득 점수 + private List answers; + private Integer timeTaken; // 소요 시간 (초) + private String submittedAt; + private Long ttl; + + @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; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java new file mode 100644 index 00000000..3dee95b6 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.news.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; + +/** + * 퀴즈 답변 결과 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class QuizAnswerResult { + + private String questionId; + private String type; // COMPREHENSION, WORD_MATCH, FILL_BLANK + private String userAnswer; + private String correctAnswer; + private boolean correct; + private int points; // 획득 점수 (정답시 배점, 오답시 0) +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java new file mode 100644 index 00000000..b2786f99 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java @@ -0,0 +1,126 @@ +package com.mzc.secondproject.serverless.domain.news.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; +import com.mzc.secondproject.serverless.domain.news.model.NewsQuizResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.*; +import software.amazon.awssdk.enhanced.dynamodb.model.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * 뉴스 퀴즈 결과 Repository + */ +public class NewsQuizRepository { + + private static final Logger logger = LoggerFactory.getLogger(NewsQuizRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); + + private final DynamoDbTable table; + + public NewsQuizRepository() { + this(AwsClients.dynamoDbEnhanced()); + } + + public NewsQuizRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(NewsQuizResult.class)); + } + + /** + * 퀴즈 결과 저장 + */ + public void save(NewsQuizResult result) { + table.putItem(result); + logger.debug("퀴즈 결과 저장: userId={}, articleId={}, score={}", + result.getUserId(), result.getArticleId(), result.getScore()); + } + + /** + * 퀴즈 결과 조회 + */ + public Optional findByUserAndArticle(String userId, String articleId) { + Key key = Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue(NewsKey.quizSk(articleId)) + .build(); + + NewsQuizResult result = table.getItem(key); + return Optional.ofNullable(result); + } + + /** + * 퀴즈 제출 여부 확인 + */ + public boolean hasSubmitted(String userId, String articleId) { + return findByUserAndArticle(userId, articleId).isPresent(); + } + + /** + * 사용자 퀴즈 결과 목록 조회 + */ + public List getUserQuizResults(String userId, int limit) { + QueryConditional queryConditional = QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue("QUIZ#") + .build() + ); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) + .limit(limit) + .build(); + + List results = new ArrayList<>(); + for (Page page : table.query(request)) { + results.addAll(page.items()); + if (results.size() >= limit) break; + } + + return results.subList(0, Math.min(results.size(), limit)); + } + + /** + * 사용자 퀴즈 통계 조회 + */ + public QuizStats getUserQuizStats(String userId) { + QueryConditional queryConditional = QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue("QUIZ#") + .build() + ); + + int totalQuizzes = 0; + int totalScore = 0; + int perfectScores = 0; + + for (Page page : table.query(queryConditional)) { + for (NewsQuizResult result : page.items()) { + totalQuizzes++; + totalScore += result.getScore(); + if (result.getScore() == 100) { + perfectScores++; + } + } + } + + int avgScore = totalQuizzes > 0 ? totalScore / totalQuizzes : 0; + return new QuizStats(totalQuizzes, avgScore, perfectScores); + } + + /** + * 퀴즈 통계 레코드 + */ + public record QuizStats( + int totalQuizzes, + int avgScore, + int perfectScores + ) {} +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java new file mode 100644 index 00000000..bb22fc90 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java @@ -0,0 +1,263 @@ +package com.mzc.secondproject.serverless.domain.news.service; + +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; +import com.mzc.secondproject.serverless.domain.news.model.*; +import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; +import com.mzc.secondproject.serverless.domain.news.repository.NewsQuizRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.*; + +/** + * 뉴스 퀴즈 서비스 + */ +public class NewsQuizService { + + private static final Logger logger = LoggerFactory.getLogger(NewsQuizService.class); + + private final NewsArticleRepository articleRepository; + private final NewsQuizRepository quizRepository; + + public NewsQuizService() { + this.articleRepository = new NewsArticleRepository(); + this.quizRepository = new NewsQuizRepository(); + } + + public NewsQuizService(NewsArticleRepository articleRepository, NewsQuizRepository quizRepository) { + this.articleRepository = articleRepository; + this.quizRepository = quizRepository; + } + + /** + * 퀴즈 조회 + */ + public Optional getQuiz(String articleId, String userId) { + Optional articleOpt = articleRepository.findById(articleId); + if (articleOpt.isEmpty()) { + logger.warn("기사를 찾을 수 없음: {}", articleId); + return Optional.empty(); + } + + NewsArticle article = articleOpt.get(); + List questions = article.getQuiz(); + + if (questions == null || questions.isEmpty()) { + logger.warn("퀴즈가 없는 기사: {}", articleId); + return Optional.empty(); + } + + // 이미 제출했는지 확인 + boolean submitted = quizRepository.hasSubmitted(userId, articleId); + + // 정답 제거한 퀴즈 반환 + List questionViews = questions.stream() + .map(q -> QuizQuestionView.builder() + .questionId(q.getQuestionId()) + .type(q.getType()) + .question(q.getQuestion()) + .options(q.getOptions()) + .points(q.getPoints()) + .build()) + .toList(); + + return Optional.of(QuizData.builder() + .articleId(articleId) + .articleTitle(article.getTitle()) + .level(article.getLevel()) + .questions(questionViews) + .totalPoints(questions.stream().mapToInt(QuizQuestion::getPoints).sum()) + .submitted(submitted) + .build()); + } + + /** + * 퀴즈 제출 및 채점 + */ + public QuizSubmitResult submitQuiz(String userId, String articleId, List answers, Integer timeTaken) { + // 이미 제출했는지 확인 + if (quizRepository.hasSubmitted(userId, articleId)) { + logger.warn("이미 제출한 퀴즈: userId={}, articleId={}", userId, articleId); + return null; + } + + // 기사 조회 + Optional articleOpt = articleRepository.findById(articleId); + if (articleOpt.isEmpty()) { + logger.warn("기사를 찾을 수 없음: {}", articleId); + return null; + } + + NewsArticle article = articleOpt.get(); + List questions = article.getQuiz(); + + if (questions == null || questions.isEmpty()) { + logger.warn("퀴즈가 없는 기사: {}", articleId); + return null; + } + + // 정답 맵 생성 + Map questionMap = new HashMap<>(); + for (QuizQuestion q : questions) { + questionMap.put(q.getQuestionId(), q); + } + + // 채점 + List answerResults = new ArrayList<>(); + int earnedPoints = 0; + int totalPoints = 0; + + for (QuizAnswer answer : answers) { + QuizQuestion question = questionMap.get(answer.questionId()); + if (question == null) continue; + + boolean correct = question.getCorrectAnswer().equalsIgnoreCase(answer.answer()); + int points = correct ? question.getPoints() : 0; + earnedPoints += points; + totalPoints += question.getPoints(); + + answerResults.add(QuizAnswerResult.builder() + .questionId(answer.questionId()) + .type(question.getType()) + .userAnswer(answer.answer()) + .correctAnswer(question.getCorrectAnswer()) + .correct(correct) + .points(points) + .build()); + } + + // 점수 계산 (100점 만점) + int score = totalPoints > 0 ? (earnedPoints * 100) / totalPoints : 0; + + // 결과 저장 + String now = Instant.now().toString(); + String today = LocalDate.now().toString(); + + NewsQuizResult result = NewsQuizResult.builder() + .pk(NewsKey.userNewsPk(userId)) + .sk(NewsKey.quizSk(articleId)) + .gsi1pk(NewsKey.userNewsStatPk(userId)) + .gsi1sk(today + "#QUIZ") + .userId(userId) + .articleId(articleId) + .articleTitle(article.getTitle()) + .articleLevel(article.getLevel()) + .score(score) + .totalPoints(totalPoints) + .earnedPoints(earnedPoints) + .answers(answerResults) + .timeTaken(timeTaken) + .submittedAt(now) + .build(); + + quizRepository.save(result); + logger.info("퀴즈 제출 완료: userId={}, articleId={}, score={}", userId, articleId, score); + + // 피드백 생성 + String feedback = generateFeedback(score, answerResults); + + return QuizSubmitResult.builder() + .score(score) + .earnedPoints(earnedPoints) + .totalPoints(totalPoints) + .results(answerResults) + .feedback(feedback) + .build(); + } + + /** + * 사용자 퀴즈 결과 조회 + */ + public Optional getQuizResult(String userId, String articleId) { + return quizRepository.findByUserAndArticle(userId, articleId); + } + + /** + * 사용자 퀴즈 기록 목록 조회 + */ + public List getUserQuizHistory(String userId, int limit) { + return quizRepository.getUserQuizResults(userId, limit); + } + + /** + * 사용자 퀴즈 통계 조회 + */ + public Map getUserQuizStats(String userId) { + NewsQuizRepository.QuizStats stats = quizRepository.getUserQuizStats(userId); + return Map.of( + "totalQuizzes", stats.totalQuizzes(), + "avgScore", stats.avgScore(), + "perfectScores", stats.perfectScores() + ); + } + + /** + * 피드백 생성 + */ + private String generateFeedback(int score, List results) { + if (score == 100) { + return "Perfect! You understood the article completely."; + } else if (score >= 80) { + return "Great job! You have a solid understanding of the article."; + } else if (score >= 60) { + return "Good effort! Review the highlighted words for better comprehension."; + } else if (score >= 40) { + return "Keep practicing! Try reading the article again before retaking the quiz."; + } else { + return "Don't give up! Focus on vocabulary and main ideas."; + } + } + + /** + * 퀴즈 데이터 (정답 제외) + */ + @lombok.Data + @lombok.Builder + @lombok.NoArgsConstructor + @lombok.AllArgsConstructor + public static class QuizData { + private String articleId; + private String articleTitle; + private String level; + private List questions; + private int totalPoints; + private boolean submitted; + } + + /** + * 퀴즈 문제 뷰 (정답 제외) + */ + @lombok.Data + @lombok.Builder + @lombok.NoArgsConstructor + @lombok.AllArgsConstructor + public static class QuizQuestionView { + private String questionId; + private String type; + private String question; + private List options; + private int points; + } + + /** + * 사용자 답변 + */ + public record QuizAnswer(String questionId, String answer) {} + + /** + * 퀴즈 제출 결과 + */ + @lombok.Data + @lombok.Builder + @lombok.NoArgsConstructor + @lombok.AllArgsConstructor + public static class QuizSubmitResult { + private int score; + private int earnedPoints; + private int totalPoints; + private List results; + private String feedback; + } +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 8bc1de01..f701a42f 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1800,6 +1800,24 @@ Resources: RestApiId: !Ref MainApi Path: /news/bookmarks Method: GET + GetQuizHistory: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/quiz/history + Method: GET + 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 MarkAsRead: Type: Api Properties: From 399f188f2e9f92c288f2b82fed5cd96dcd20a874 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 14:10:24 +0900 Subject: [PATCH 51/99] =?UTF-8?q?feat(news):=20=EB=8B=A8=EC=96=B4=20?= =?UTF-8?q?=EC=88=98=EC=A7=91=20&=20Vocabulary=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#472)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 단어 수집 API (POST /news/{articleId}/words) - 수집 단어 목록 API (GET /news/words) - 단어 상세 조회 API (GET /news/{articleId}/words/{word}) - 단어 삭제 API (DELETE /news/{articleId}/words/{word}) - Vocabulary 연동 API (POST /news/words/{word}/sync) - NewsWordCollect 모델 추가 - NewsWordRepository 추가 - NewsWordService 추가 --- .../domain/news/handler/NewsHandler.java | 122 ++++++++++- .../domain/news/model/NewsWordCollect.java | 62 ++++++ .../news/repository/NewsWordRepository.java | 133 ++++++++++++ .../domain/news/service/NewsWordService.java | 203 ++++++++++++++++++ ServerlessFunction/template.yaml | 32 +++ 5 files changed, 550 insertions(+), 2 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsWordRepository.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java index ac23fd5d..180bb7cb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -12,10 +12,12 @@ import com.mzc.secondproject.serverless.domain.news.exception.NewsErrorCode; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; import com.mzc.secondproject.serverless.domain.news.model.NewsQuizResult; +import com.mzc.secondproject.serverless.domain.news.model.NewsWordCollect; import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; import com.mzc.secondproject.serverless.domain.news.service.NewsLearningService; import com.mzc.secondproject.serverless.domain.news.service.NewsQueryService; import com.mzc.secondproject.serverless.domain.news.service.NewsQuizService; +import com.mzc.secondproject.serverless.domain.news.service.NewsWordService; import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonObject; @@ -40,16 +42,19 @@ public class NewsHandler implements RequestHandler params = request.getQueryStringParameters(); + if (params == null) params = new HashMap<>(); + + int limit = parseLimit(params.get("limit")); + List words = wordService.getUserWords(userId, limit); + Map stats = wordService.getUserWordStats(userId); + + Map response = new HashMap<>(); + response.put("words", words); + response.put("stats", stats); + response.put("count", words.size()); + + return ResponseGenerator.ok("수집 단어 목록 조회 성공", response); + } + + /** + * 단어 수집 + * POST /news/{articleId}/words + */ + private APIGatewayProxyResponseEvent collectWord(APIGatewayProxyRequestEvent request) { + String userId = getUserId(request); + if (userId == null) { + return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); + } + + String articleId = request.getPathParameters().get("articleId"); + + JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); + String word = body.get("word").getAsString(); + String context = body.has("context") ? body.get("context").getAsString() : ""; + + NewsWordCollect collected = wordService.collectWord(userId, articleId, word, context); + + if (collected == null) { + return ResponseGenerator.fail(NewsErrorCode.WORD_ALREADY_COLLECTED); + } + + return ResponseGenerator.ok("단어 수집 성공", collected); + } + + /** + * 단어 삭제 + * DELETE /news/{articleId}/words/{word} + */ + private APIGatewayProxyResponseEvent deleteWord(APIGatewayProxyRequestEvent request) { + String userId = getUserId(request); + if (userId == null) { + return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); + } + + String articleId = request.getPathParameters().get("articleId"); + String word = request.getPathParameters().get("word"); + + wordService.deleteWord(userId, word, articleId); + + return ResponseGenerator.ok("단어 삭제 성공", Map.of("word", word)); + } + + /** + * 단어 상세 정보 조회 + * GET /news/{articleId}/words/{word} + */ + private APIGatewayProxyResponseEvent getWordDetail(APIGatewayProxyRequestEvent request) { + String word = request.getPathParameters().get("word"); + + Optional detail = wordService.getWordDetail(word); + + if (detail.isEmpty()) { + return ResponseGenerator.fail(NewsErrorCode.WORD_NOT_COLLECTED); + } + + return ResponseGenerator.ok("단어 상세 조회 성공", detail.get()); + } + + /** + * 단어 Vocabulary 연동 + * POST /news/words/{word}/sync + */ + private APIGatewayProxyResponseEvent syncWordToVocab(APIGatewayProxyRequestEvent request) { + String userId = getUserId(request); + if (userId == null) { + return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); + } + + String word = request.getPathParameters().get("word"); + + JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); + String articleId = body.get("articleId").getAsString(); + + boolean synced = wordService.syncToVocabulary(userId, word, articleId); + + if (!synced) { + return ResponseGenerator.fail(NewsErrorCode.WORD_NOT_COLLECTED); + } + + return ResponseGenerator.ok("Vocabulary 연동 성공", Map.of("word", word, "synced", true)); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java new file mode 100644 index 00000000..59f4fa93 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java @@ -0,0 +1,62 @@ +package com.mzc.secondproject.serverless.domain.news.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +/** + * 뉴스 단어 수집 + * PK: USER#{userId}#NEWS + * SK: WORD#{word}#{articleId} + * GSI1: USER#{userId}#NEWS_WORDS / {collectedAt} + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class NewsWordCollect { + + private String pk; // USER#{userId}#NEWS + private String sk; // WORD#{word}#{articleId} + private String gsi1pk; // USER#{userId}#NEWS_WORDS + private String gsi1sk; // {collectedAt} + + private String userId; + private String word; + private String meaning; + private String pronunciation; + private String context; // 문맥 문장 + private String articleId; + private String articleTitle; + private String collectedAt; + private Boolean syncedToVocab; // Vocabulary 연동 여부 + private String vocabUserWordId; // 연동된 UserWord ID + private Long ttl; + + @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; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsWordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsWordRepository.java new file mode 100644 index 00000000..5dfebc80 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsWordRepository.java @@ -0,0 +1,133 @@ +package com.mzc.secondproject.serverless.domain.news.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; +import com.mzc.secondproject.serverless.domain.news.model.NewsWordCollect; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.*; +import software.amazon.awssdk.enhanced.dynamodb.model.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * 뉴스 단어 수집 Repository + */ +public class NewsWordRepository { + + private static final Logger logger = LoggerFactory.getLogger(NewsWordRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); + + private final DynamoDbTable table; + private final DynamoDbIndex gsi1Index; + + public NewsWordRepository() { + this(AwsClients.dynamoDbEnhanced()); + } + + public NewsWordRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(NewsWordCollect.class)); + this.gsi1Index = table.index("GSI1"); + } + + /** + * 단어 수집 저장 + */ + public void save(NewsWordCollect wordCollect) { + table.putItem(wordCollect); + logger.debug("단어 수집 저장: userId={}, word={}", wordCollect.getUserId(), wordCollect.getWord()); + } + + /** + * 단어 수집 조회 + */ + public Optional findByUserWordArticle(String userId, String word, String articleId) { + Key key = Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue(NewsKey.wordSk(word, articleId)) + .build(); + + NewsWordCollect result = table.getItem(key); + return Optional.ofNullable(result); + } + + /** + * 이미 수집했는지 확인 + */ + public boolean hasCollected(String userId, String word, String articleId) { + return findByUserWordArticle(userId, word, articleId).isPresent(); + } + + /** + * 단어 수집 삭제 + */ + public void delete(String userId, String word, String articleId) { + Key key = Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue(NewsKey.wordSk(word, articleId)) + .build(); + + table.deleteItem(key); + logger.debug("단어 수집 삭제: userId={}, word={}", userId, word); + } + + /** + * 사용자 수집 단어 목록 조회 (최신순) + */ + public List getUserWords(String userId, int limit) { + QueryConditional queryConditional = QueryConditional.keyEqualTo( + Key.builder() + .partitionValue(NewsKey.userNewsWordsPk(userId)) + .build() + ); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) + .limit(limit) + .build(); + + List results = new ArrayList<>(); + for (Page page : gsi1Index.query(request)) { + results.addAll(page.items()); + if (results.size() >= limit) break; + } + + return results.subList(0, Math.min(results.size(), limit)); + } + + /** + * 사용자 수집 단어 수 조회 + */ + public int countUserWords(String userId) { + QueryConditional queryConditional = QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue("WORD#") + .build() + ); + + int count = 0; + for (Page page : table.query(queryConditional)) { + count += page.items().size(); + } + return count; + } + + /** + * Vocabulary 연동 상태 업데이트 + */ + public void updateSyncStatus(String userId, String word, String articleId, String vocabUserWordId) { + Optional wordOpt = findByUserWordArticle(userId, word, articleId); + if (wordOpt.isPresent()) { + NewsWordCollect wordCollect = wordOpt.get(); + wordCollect.setSyncedToVocab(true); + wordCollect.setVocabUserWordId(vocabUserWordId); + table.putItem(wordCollect); + logger.debug("Vocabulary 연동 완료: userId={}, word={}", userId, word); + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java new file mode 100644 index 00000000..6c3c23ec --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java @@ -0,0 +1,203 @@ +package com.mzc.secondproject.serverless.domain.news.service; + +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; +import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import com.mzc.secondproject.serverless.domain.news.model.NewsWordCollect; +import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; +import com.mzc.secondproject.serverless.domain.news.repository.NewsWordRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.service.UserWordCommandService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 뉴스 단어 수집 서비스 + */ +public class NewsWordService { + + private static final Logger logger = LoggerFactory.getLogger(NewsWordService.class); + + private final NewsWordRepository newsWordRepository; + private final NewsArticleRepository articleRepository; + private final WordRepository wordRepository; + private final UserWordCommandService userWordCommandService; + + public NewsWordService() { + this.newsWordRepository = new NewsWordRepository(); + this.articleRepository = new NewsArticleRepository(); + this.wordRepository = new WordRepository(); + this.userWordCommandService = new UserWordCommandService(); + } + + public NewsWordService(NewsWordRepository newsWordRepository, + NewsArticleRepository articleRepository, + WordRepository wordRepository, + UserWordCommandService userWordCommandService) { + this.newsWordRepository = newsWordRepository; + this.articleRepository = articleRepository; + this.wordRepository = wordRepository; + this.userWordCommandService = userWordCommandService; + } + + /** + * 단어 수집 + */ + public NewsWordCollect collectWord(String userId, String articleId, String word, String context) { + // 이미 수집했는지 확인 + if (newsWordRepository.hasCollected(userId, word, articleId)) { + logger.warn("이미 수집한 단어: userId={}, word={}", userId, word); + return newsWordRepository.findByUserWordArticle(userId, word, articleId).orElse(null); + } + + // 기사 조회 + Optional articleOpt = articleRepository.findById(articleId); + String articleTitle = articleOpt.map(NewsArticle::getTitle).orElse(""); + + // 단어 정보 조회 (Word 테이블에서) + String wordId = word.toLowerCase().trim(); + Optional wordOpt = wordRepository.findById(wordId); + String meaning = wordOpt.map(Word::getKorean).orElse(""); + String pronunciation = ""; + + String now = Instant.now().toString(); + + NewsWordCollect wordCollect = NewsWordCollect.builder() + .pk(NewsKey.userNewsPk(userId)) + .sk(NewsKey.wordSk(word, articleId)) + .gsi1pk(NewsKey.userNewsWordsPk(userId)) + .gsi1sk(now) + .userId(userId) + .word(word) + .meaning(meaning) + .pronunciation(pronunciation) + .context(context) + .articleId(articleId) + .articleTitle(articleTitle) + .collectedAt(now) + .syncedToVocab(false) + .build(); + + newsWordRepository.save(wordCollect); + logger.info("단어 수집 완료: userId={}, word={}, articleId={}", userId, word, articleId); + + return wordCollect; + } + + /** + * 수집한 단어 삭제 + */ + public void deleteWord(String userId, String word, String articleId) { + newsWordRepository.delete(userId, word, articleId); + logger.info("단어 삭제: userId={}, word={}", userId, word); + } + + /** + * 사용자 수집 단어 목록 조회 + */ + public List getUserWords(String userId, int limit) { + return newsWordRepository.getUserWords(userId, limit); + } + + /** + * 사용자 수집 단어 수 조회 + */ + public int countUserWords(String userId) { + return newsWordRepository.countUserWords(userId); + } + + /** + * 단어 상세 정보 조회 + */ + public Optional getWordDetail(String word) { + String wordId = word.toLowerCase().trim(); + Optional wordOpt = wordRepository.findById(wordId); + + if (wordOpt.isEmpty()) { + return Optional.empty(); + } + + Word w = wordOpt.get(); + return Optional.of(WordDetail.builder() + .word(w.getEnglish()) + .meaning(w.getKorean()) + .pronunciation("") + .example(w.getExample()) + .level(w.getLevel()) + .build()); + } + + /** + * Vocabulary 도메인으로 단어 연동 + */ + public boolean syncToVocabulary(String userId, String word, String articleId) { + Optional wordOpt = newsWordRepository.findByUserWordArticle(userId, word, articleId); + if (wordOpt.isEmpty()) { + logger.warn("수집한 단어를 찾을 수 없음: userId={}, word={}", userId, word); + return false; + } + + NewsWordCollect wordCollect = wordOpt.get(); + + // 이미 연동됐는지 확인 + if (Boolean.TRUE.equals(wordCollect.getSyncedToVocab())) { + logger.info("이미 Vocabulary에 연동됨: userId={}, word={}", userId, word); + return true; + } + + // Word 테이블에서 단어 조회 + String wordId = word.toLowerCase().trim(); + Optional vocabWord = wordRepository.findById(wordId); + + if (vocabWord.isEmpty()) { + logger.warn("Vocabulary에 없는 단어: {}", word); + return false; + } + + // UserWord 생성 (NEW 상태로) + userWordCommandService.updateWordStatus(userId, wordId, "NEW"); + + // 연동 상태 업데이트 + newsWordRepository.updateSyncStatus(userId, word, articleId, wordId); + + logger.info("Vocabulary 연동 완료: userId={}, word={}", userId, word); + return true; + } + + /** + * 사용자 단어 수집 통계 + */ + public Map getUserWordStats(String userId) { + int totalWords = newsWordRepository.countUserWords(userId); + List recentWords = newsWordRepository.getUserWords(userId, 5); + long syncedCount = recentWords.stream() + .filter(w -> Boolean.TRUE.equals(w.getSyncedToVocab())) + .count(); + + return Map.of( + "totalCollected", totalWords, + "recentWords", recentWords, + "syncedToVocab", syncedCount + ); + } + + /** + * 단어 상세 정보 + */ + @lombok.Data + @lombok.Builder + @lombok.NoArgsConstructor + @lombok.AllArgsConstructor + public static class WordDetail { + private String word; + private String meaning; + private String pronunciation; + private String example; + private String level; + } +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index f701a42f..15fd4280 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1762,6 +1762,8 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref NewsTable + - DynamoDBCrudPolicy: + TableName: !Ref VocabularyTable - S3CrudPolicy: BucketName: !Ref ContentBucket - Statement: @@ -1800,6 +1802,36 @@ Resources: RestApiId: !Ref MainApi Path: /news/bookmarks Method: GET + GetUserWords: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/words + Method: GET + SyncWordToVocab: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/words/{word}/sync + Method: POST + CollectWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/words + Method: POST + DeleteWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/words/{word} + Method: DELETE + GetWordDetail: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/words/{word} + Method: GET GetQuizHistory: Type: Api Properties: From 488a2872cfabc3d1ee3c994b216223f55bb3cdeb Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 14:39:26 +0900 Subject: [PATCH 52/99] feat: add multi-environment deployment support (dev/test/prod) --- ServerlessFunction/buildspec-dev.yml | 59 ++++++++++++++ ServerlessFunction/buildspec-prod.yml | 59 ++++++++++++++ ServerlessFunction/buildspec-test.yml | 59 ++++++++++++++ ServerlessFunction/template.yaml | 111 ++++++++++++++------------ 4 files changed, 238 insertions(+), 50 deletions(-) create mode 100644 ServerlessFunction/buildspec-dev.yml create mode 100644 ServerlessFunction/buildspec-prod.yml create mode 100644 ServerlessFunction/buildspec-test.yml diff --git a/ServerlessFunction/buildspec-dev.yml b/ServerlessFunction/buildspec-dev.yml new file mode 100644 index 00000000..78a12758 --- /dev/null +++ b/ServerlessFunction/buildspec-dev.yml @@ -0,0 +1,59 @@ +version: 0.2 + +env: + variables: + SAM_CLI_TELEMETRY: 0 + GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" + ENVIRONMENT: dev + STACK_NAME: group2-englishstudy-dev + +phases: + install: + commands: + - echo "Verifying pre-installed tools..." + - java -version + - sam --version + - echo "Tools verified" + + pre_build: + commands: + - echo "Running tests..." + - cd ServerlessFunction + - chmod +x gradlew + - ./gradlew test --build-cache --parallel + - echo "Tests completed" + + build: + commands: + - echo "Building SAM application for $ENVIRONMENT..." + - cd $CODEBUILD_SRC_DIR/ServerlessFunction + - sam build --parallel --cached + - echo "Build completed" + + post_build: + commands: + - echo "Deploying to $ENVIRONMENT environment..." + - cd $CODEBUILD_SRC_DIR/ServerlessFunction + - | + sam deploy \ + --stack-name $STACK_NAME \ + --resolve-s3 \ + --s3-prefix $STACK_NAME \ + --region ap-northeast-2 \ + --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \ + --no-confirm-changeset \ + --no-fail-on-empty-changeset \ + --parameter-overrides Environment=$ENVIRONMENT + - echo "Deployment completed on $(date)" + +cache: + paths: + - '/root/.gradle/caches/**/*' + - '/root/.gradle/wrapper/**/*' + - '.aws-sam/cache/**/*' + +reports: + junit-reports: + files: + - 'ServerlessFunction/build/test-results/test/*.xml' + file-format: JUNITXML diff --git a/ServerlessFunction/buildspec-prod.yml b/ServerlessFunction/buildspec-prod.yml new file mode 100644 index 00000000..0aa83787 --- /dev/null +++ b/ServerlessFunction/buildspec-prod.yml @@ -0,0 +1,59 @@ +version: 0.2 + +env: + variables: + SAM_CLI_TELEMETRY: 0 + GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" + ENVIRONMENT: prod + STACK_NAME: group2-englishstudy-prod + +phases: + install: + commands: + - echo "Verifying pre-installed tools..." + - java -version + - sam --version + - echo "Tools verified" + + pre_build: + commands: + - echo "Running tests..." + - cd ServerlessFunction + - chmod +x gradlew + - ./gradlew test --build-cache --parallel + - echo "Tests completed" + + build: + commands: + - echo "Building SAM application for $ENVIRONMENT..." + - cd $CODEBUILD_SRC_DIR/ServerlessFunction + - sam build --parallel --cached + - echo "Build completed" + + post_build: + commands: + - echo "Deploying to $ENVIRONMENT environment..." + - cd $CODEBUILD_SRC_DIR/ServerlessFunction + - | + sam deploy \ + --stack-name $STACK_NAME \ + --s3-bucket group2-englishstudy-pipeline-artifacts \ + --s3-prefix sam-deploy/prod \ + --region ap-northeast-2 \ + --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \ + --no-confirm-changeset \ + --no-fail-on-empty-changeset \ + --parameter-overrides Environment=$ENVIRONMENT + - echo "Deployment completed on $(date)" + +cache: + paths: + - '/root/.gradle/caches/**/*' + - '/root/.gradle/wrapper/**/*' + - '.aws-sam/cache/**/*' + +reports: + junit-reports: + files: + - 'ServerlessFunction/build/test-results/test/*.xml' + file-format: JUNITXML diff --git a/ServerlessFunction/buildspec-test.yml b/ServerlessFunction/buildspec-test.yml new file mode 100644 index 00000000..b74b041c --- /dev/null +++ b/ServerlessFunction/buildspec-test.yml @@ -0,0 +1,59 @@ +version: 0.2 + +env: + variables: + SAM_CLI_TELEMETRY: 0 + GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" + ENVIRONMENT: test + STACK_NAME: group2-englishstudy-test + +phases: + install: + commands: + - echo "Verifying pre-installed tools..." + - java -version + - sam --version + - echo "Tools verified" + + pre_build: + commands: + - echo "Running tests..." + - cd ServerlessFunction + - chmod +x gradlew + - ./gradlew test --build-cache --parallel + - echo "Tests completed" + + build: + commands: + - echo "Building SAM application for $ENVIRONMENT..." + - cd $CODEBUILD_SRC_DIR/ServerlessFunction + - sam build --parallel --cached + - echo "Build completed" + + post_build: + commands: + - echo "Deploying to $ENVIRONMENT environment..." + - cd $CODEBUILD_SRC_DIR/ServerlessFunction + - | + sam deploy \ + --stack-name $STACK_NAME \ + --resolve-s3 \ + --s3-prefix $STACK_NAME \ + --region ap-northeast-2 \ + --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \ + --no-confirm-changeset \ + --no-fail-on-empty-changeset \ + --parameter-overrides Environment=$ENVIRONMENT + - echo "Deployment completed on $(date)" + +cache: + paths: + - '/root/.gradle/caches/**/*' + - '/root/.gradle/wrapper/**/*' + - '.aws-sam/cache/**/*' + +reports: + junit-reports: + files: + - 'ServerlessFunction/build/test-results/test/*.xml' + file-format: JUNITXML diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 15fd4280..c7e28a37 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -2,6 +2,16 @@ 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 + Globals: Function: Timeout: 30 @@ -17,11 +27,12 @@ Globals: VOCAB_TABLE_NAME: !Ref VocabTable OPIC_TABLE_NAME: !Ref OPIcTable NEWS_TABLE_NAME: !Ref NewsTable - BUCKET_NAME: group2-englishstudy - CHAT_BUCKET_NAME: group2-englishstudy - VOCAB_BUCKET_NAME: group2-englishstudy - PROFILE_BUCKET_NAME: group2-englishstudy - OPIC_BUCKET_NAME: group2-englishstudy + 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}" 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" @@ -101,7 +112,7 @@ Resources: Timeout: 10 Environment: Variables: - DEFAULT_PROFILE_URL: https://group2-englishstudy.s3.amazonaws.com/profile/default.png + DEFAULT_PROFILE_URL: !Sub "https://${AWS::StackName}.s3.amazonaws.com/profile/default.png" # 회원가입 시점에 사용자 모든 정보가 DB에 저장 Lambda 함수 PostConfirmationFunction: @@ -138,8 +149,8 @@ Resources: MainApi: Type: AWS::Serverless::Api Properties: - Name: group2-englishstudy-api - StageName: dev + Name: !Sub "${AWS::StackName}-api" + StageName: !Ref Environment Cors: AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" @@ -200,7 +211,7 @@ Resources: WebSocketApi: Type: AWS::ApiGatewayV2::Api Properties: - Name: group2-englishstudy-websocket + Name: !Sub "${AWS::StackName}-websocket" ProtocolType: WEBSOCKET RouteSelectionExpression: "$request.body.action" @@ -266,7 +277,7 @@ Resources: WebSocketConnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-ws-connect + FunctionName: !Sub "${AWS::StackName}-ws-connect" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.websocket.WebSocketConnectHandler::handleRequest Description: Handle WebSocket $connect @@ -296,7 +307,7 @@ Resources: WebSocketDisconnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-ws-disconnect + FunctionName: !Sub "${AWS::StackName}-ws-disconnect" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.websocket.WebSocketDisconnectHandler::handleRequest Description: Handle WebSocket $disconnect @@ -325,7 +336,7 @@ Resources: WebSocketMessageFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-ws-message + FunctionName: !Sub "${AWS::StackName}-ws-message" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.websocket.WebSocketMessageHandler::handleRequest Description: Handle WebSocket sendMessage @@ -385,7 +396,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref UserTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" Events: GetMyProfile: Type: Api @@ -413,7 +424,7 @@ Resources: ChatRoomFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-chat-room-handler + FunctionName: !Sub "${AWS::StackName}-chat-room-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatRoomHandler::handleRequest Description: Handle chat room CRUD operations @@ -485,7 +496,7 @@ Resources: GameFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-game-handler + FunctionName: !Sub "${AWS::StackName}-game-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.GameHandler::handleRequest Description: Handle catch-mind game operations @@ -557,7 +568,7 @@ Resources: GameAutoCloseFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-game-auto-close + FunctionName: !Sub "${AWS::StackName}-game-auto-close" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.GameAutoCloseHandler::handleRequest Description: Auto-close game after 7 minutes @@ -608,7 +619,7 @@ Resources: ChatMessageFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-chat-message-handler + FunctionName: !Sub "${AWS::StackName}-chat-message-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatMessageHandler::handleRequest Description: Handle chat messages @@ -618,7 +629,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref ChatTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" - Statement: - Effect: Allow Action: @@ -660,7 +671,7 @@ Resources: ChatVoiceFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-chat-voice-handler + FunctionName: !Sub "${AWS::StackName}-chat-voice-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatVoiceHandler::handleRequest Description: Convert text to speech using Polly @@ -670,7 +681,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref ChatTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" - Statement: - Effect: Allow Action: @@ -694,7 +705,7 @@ Resources: WordFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-word-handler + FunctionName: !Sub "${AWS::StackName}-vocab-word-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.WordHandler::handleRequest Description: Handle word CRUD operations @@ -772,7 +783,7 @@ Resources: UserWordFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-userword-handler + FunctionName: !Sub "${AWS::StackName}-vocab-userword-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.UserWordHandler::handleRequest Description: Handle user word learning status @@ -834,7 +845,7 @@ Resources: WordGroupFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-wordgroup-handler + FunctionName: !Sub "${AWS::StackName}-vocab-wordgroup-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.WordGroupHandler::handleRequest Description: Handle user custom word groups @@ -904,7 +915,7 @@ Resources: DailyStudyFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-daily-handler + FunctionName: !Sub "${AWS::StackName}-vocab-daily-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.DailyStudyHandler::handleRequest Description: Handle daily study word assignment @@ -934,7 +945,7 @@ Resources: TestFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-test-handler + FunctionName: !Sub "${AWS::StackName}-vocab-test-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.TestHandler::handleRequest Description: Handle vocabulary tests @@ -993,7 +1004,7 @@ Resources: StatsFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-stats-handler + FunctionName: !Sub "${AWS::StackName}-vocab-stats-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.StatsHandler::handleRequest Description: Handle user learning statistics @@ -1031,7 +1042,7 @@ Resources: VocabVoiceFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-voice-handler + FunctionName: !Sub "${AWS::StackName}-vocab-voice-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.VoiceHandler::handleRequest Description: Convert word to speech using Polly @@ -1041,7 +1052,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref VocabTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" - Statement: - Effect: Allow Action: @@ -1066,7 +1077,7 @@ Resources: StatsStreamFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-stats-stream-handler + FunctionName: !Sub "${AWS::StackName}-stats-stream-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.stats.handler.StatsStreamHandler::handleRequest Description: Process DynamoDB Streams for stats aggregation @@ -1091,7 +1102,7 @@ Resources: UserStatsFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-user-stats-handler + FunctionName: !Sub "${AWS::StackName}-user-stats-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.stats.handler.UserStatsHandler::handleRequest Description: Handle user learning statistics API @@ -1146,7 +1157,7 @@ Resources: BadgeFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-badge-handler + FunctionName: !Sub "${AWS::StackName}-badge-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.badge.handler.BadgeHandler::handleRequest Description: Handle user badges and achievements @@ -1156,7 +1167,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref VocabTable - S3ReadPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" Events: GetAllBadges: Type: Api @@ -1182,7 +1193,7 @@ Resources: GrammarFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-grammar-handler + FunctionName: !Sub "${AWS::StackName}-grammar-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.GrammarHandler::handleRequest Description: Handle grammar check using Bedrock AI @@ -1261,7 +1272,7 @@ Resources: GrammarWebSocketApi: Type: AWS::ApiGatewayV2::Api Properties: - Name: group2-englishstudy-grammar-websocket + Name: !Sub "${AWS::StackName}-grammar-websocket" ProtocolType: WEBSOCKET RouteSelectionExpression: "$request.body.action" @@ -1324,7 +1335,7 @@ Resources: GrammarStreamingConnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-grammar-ws-connect + FunctionName: !Sub "${AWS::StackName}-grammar-ws-connect" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingConnectHandler::handleRequest Description: Handle Grammar WebSocket $connect with JWT auth @@ -1351,7 +1362,7 @@ Resources: GrammarStreamingDisconnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-grammar-ws-disconnect + FunctionName: !Sub "${AWS::StackName}-grammar-ws-disconnect" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingDisconnectHandler::handleRequest Description: Handle Grammar WebSocket $disconnect @@ -1378,7 +1389,7 @@ Resources: GrammarStreamingFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-grammar-ws-streaming + FunctionName: !Sub "${AWS::StackName}-grammar-ws-streaming" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingHandler::handleRequest Description: Handle Grammar streaming with Bedrock @@ -1414,7 +1425,7 @@ Resources: ScheduledStatsFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-scheduled-stats + FunctionName: !Sub "${AWS::StackName}-scheduled-stats" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.stats.handler.ScheduledStatsHandler::handleRequest Description: Daily scheduled job for word learning stats aggregation @@ -1441,7 +1452,7 @@ Resources: OPIcSessionFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-opic-session-handler + FunctionName: !Sub "${AWS::StackName}-opic-session-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.opic.handler.OPIcSessionHandler::handleRequest Description: Handle OPIc speaking practice sessions @@ -1456,7 +1467,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref OPIcTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" - Statement: - Effect: Allow Action: @@ -1549,7 +1560,7 @@ Resources: DeletionPolicy: Retain # UpdateReplacePolicy: Retain Properties: - TableName: group2-englishstudy-user + TableName: !Sub "${AWS::StackName}-user" BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: PK @@ -1594,7 +1605,7 @@ Resources: ChatTable: Type: AWS::DynamoDB::Table Properties: - TableName: group2-englishstudy-chat + TableName: !Sub "${AWS::StackName}-chat" BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: PK @@ -1638,7 +1649,7 @@ Resources: VocabTable: Type: AWS::DynamoDB::Table Properties: - TableName: group2-englishstudy-vocab + TableName: !Sub "${AWS::StackName}-vocab" BillingMode: PAY_PER_REQUEST StreamSpecification: StreamViewType: NEW_IMAGE @@ -1696,7 +1707,7 @@ Resources: OPIcTable: Type: AWS::DynamoDB::Table Properties: - TableName: group2-englishstudy-opic + TableName: !Sub "${AWS::StackName}-opic" BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: PK @@ -1878,7 +1889,7 @@ Resources: NewsTable: Type: AWS::DynamoDB::Table Properties: - TableName: group2-englishstudy-news + TableName: !Sub "${AWS::StackName}-news" BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: PK @@ -1927,20 +1938,20 @@ Resources: TestResultTopic: Type: AWS::SNS::Topic Properties: - TopicName: group2-englishstudy-test-result-topic + TopicName: !Sub "${AWS::StackName}-test-result-topic" # SQS Dead Letter Queue - 실패한 메시지 보관 StatisticsDeadLetterQueue: Type: AWS::SQS::Queue Properties: - QueueName: group2-englishstudy-statistics-dlq + QueueName: !Sub "${AWS::StackName}-statistics-dlq" MessageRetentionPeriod: 1209600 # 14일 # SQS Queue - 통계 처리용 StatisticsQueue: Type: AWS::SQS::Queue Properties: - QueueName: group2-englishstudy-statistics-queue + QueueName: !Sub "${AWS::StackName}-statistics-queue" VisibilityTimeout: 60 RedrivePolicy: deadLetterTargetArn: !GetAtt StatisticsDeadLetterQueue.Arn @@ -1976,7 +1987,7 @@ Resources: StatisticsProcessorFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-statistics-processor + FunctionName: !Sub "${AWS::StackName}-statistics-processor" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.StatisticsHandler::handleRequest Description: Process test results and update user word statistics @@ -2022,7 +2033,7 @@ Outputs: BucketName: Description: S3 Bucket Name - Value: group2-englishstudy + Value: !Sub "${AWS::StackName}" CognitoUserPoolId: Description: Cognito User Pool ID From cee91b6af339879f943168f02d26f3a6377bad18 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 14:47:22 +0900 Subject: [PATCH 53/99] fix: update buildspec.yml to deploy prod environment with parameter overrides --- ServerlessFunction/buildspec.yml | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/ServerlessFunction/buildspec.yml b/ServerlessFunction/buildspec.yml index 49ed2a4f..0779bea5 100644 --- a/ServerlessFunction/buildspec.yml +++ b/ServerlessFunction/buildspec.yml @@ -4,6 +4,8 @@ env: variables: SAM_CLI_TELEMETRY: 0 GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" + ENVIRONMENT: prod + STACK_NAME: group2-englishstudy-prod phases: install: @@ -23,20 +25,26 @@ phases: build: commands: - - echo "Building SAM application..." + - echo "Building SAM application for $ENVIRONMENT..." - cd $CODEBUILD_SRC_DIR/ServerlessFunction - sam build --parallel --cached - - echo "Packaging SAM application..." - - sam package --s3-bucket group2-englishstudy-pipeline-artifacts --s3-prefix sam-packages --output-template-file packaged-template.yaml + - echo "Build completed" post_build: commands: - - echo "Build completed on $(date)" - -artifacts: - files: - - packaged-template.yaml - base-directory: ServerlessFunction + - echo "Deploying to $ENVIRONMENT environment..." + - cd $CODEBUILD_SRC_DIR/ServerlessFunction + - | + sam deploy \ + --stack-name $STACK_NAME \ + --s3-bucket group2-englishstudy-pipeline-artifacts \ + --s3-prefix sam-deploy/prod \ + --region ap-northeast-2 \ + --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \ + --no-confirm-changeset \ + --no-fail-on-empty-changeset \ + --parameter-overrides Environment=$ENVIRONMENT + - echo "Deployment completed on $(date)" cache: paths: From 8d3c10104556a80cdbdbbdbbba51f7af4c512e75 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:51:20 +0900 Subject: [PATCH 54/99] =?UTF-8?q?refactor=20:=20AI=20=EB=A7=90=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20Websocket=20=EA=B5=AC=ED=98=84=20->=20REST=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=EC=9C=BC=EB=A1=9C=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81=20(#490)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 --- .../speaking/dto/request/ResetRequest.java | 12 + .../speaking/dto/request/SpeakingRequest.java | 32 + .../dto/response/SpeakingResponse.java | 12 + .../websocket/SpeakingConnectHandler.java | 88 --- .../websocket/SpeakingDisconnectHandler.java | 44 -- .../handler/websocket/SpeakingHandler.java | 157 +++++ .../websocket/SpeakingMessageHandler.java | 217 ------- .../speaking/model/SpeakingConnection.java | 84 --- .../speaking/model/SpeakingSession.java | 96 +++ .../SpeakingConnectionRepository.java | 73 --- .../repository/SpeakingSessionRepository.java | 74 +++ .../speaking/service/SpeakingService.java | 545 +++++++++--------- 12 files changed, 669 insertions(+), 765 deletions(-) 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 create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingSession.java 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/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 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 index 256d3990..e69de29b 100644 --- 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 @@ -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"); - } - } -} 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 index bd46d3b4..e69de29b 100644 --- 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 @@ -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"); - } - } -} 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)); + } +} 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 index 24ecfc3c..e69de29b 100644 --- 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 @@ -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/model/SpeakingConnection.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java deleted file mode 100644 index 133e7773..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java +++ /dev/null @@ -1,84 +0,0 @@ -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; - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingSession.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingSession.java new file mode 100644 index 00000000..07956b2f --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingSession.java @@ -0,0 +1,96 @@ +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 SpeakingSession { + + // DynamoDB Key Prefixes + 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 = "SESSION#"; + + private String pk; // SPEAKING_SESSION#{sessionId} + private String sk; // METADATA + private String gsi1pk; // SPEAKING_USER#{userId} + private String gsi1sk; // SESSION#{sessionId} + + private String sessionId; + private String userId; + private String createdAt; + private String updatedAt; + private Long ttl; // 자동 삭제용 (24시간) + + // Speaking 전용 필드 + private String conversationHistory; // 대화 히스토리 (JSON) + private String targetLevel; // 목표 레벨 (BEGINNER, INTERMEDIATE, ADVANCED) + + /** + * 세션 생성 팩토리 메서드 + */ + public static SpeakingSession create(String sessionId, String userId, String level) { + String now = java.time.Instant.now().toString(); + // 24시간 후 자동 삭제 + long ttl = java.time.Instant.now().plusSeconds(86400).getEpochSecond(); + + return SpeakingSession.builder() + .pk(PK_PREFIX + sessionId) + .sk(SK_METADATA) + .gsi1pk(GSI1PK_PREFIX + userId) + .gsi1sk(GSI1SK_PREFIX + sessionId) + .sessionId(sessionId) + .userId(userId) + .createdAt(now) + .updatedAt(now) + .ttl(ttl) + .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() { + 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 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 bbb74d7c..e69de29b 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,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); - } -} 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 7c428ddc..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,236 +5,264 @@ 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; import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelRequest; import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelResponse; + import java.util.ArrayList; import java.util.List; +import java.util.UUID; /** * 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" -> """ + + 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 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(); + this.pollyService = new PollyService( + EnvConfig.getRequired("BUCKET_NAME"), + "speaking/voice/" + ); + this.sessionRepository = new SpeakingSessionRepository(); + } + + /** + * 음성 입력 처리 (전체 플로우) + */ + public SpeakingResponse processVoiceInput(String sessionId, String userId, String audioBase64, String level) { + logger.info("Processing voice input for sessionId: {}", sessionId); + + // 세션 조회 또는 생성 + SpeakingSession session = getOrCreateSession(sessionId, userId, level); + + String targetLevel = session.getTargetLevel(); + + // STT: 음성 → 텍스트 (Transcribe Proxy 사용) + logger.info("Step 1: Transcribing audio..."); + TranscribeProxyService.TranscribeResult sttResult = transcribeService.transcribe( + audioBase64, + sessionId, + "en-US" + ); + String userText = sttResult.transcript(); + logger.info("Transcription complete: {} (confidence: {})", userText, sttResult.confidence()); + + // 대화 히스토리 로드 + List history = parseHistory(session.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())); + } + session.setConversationHistory(toJson(history)); + sessionRepository.update(session); + + // TTS: 텍스트 → 음성 (Polly 사용) + logger.info("Step 3: Synthesizing speech..."); + String audioId = session.getSessionId() + "_" + System.currentTimeMillis(); + PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( + audioId, + aiResponse, + "FEMALE" + ); + logger.info("Speech synthesis complete: cached={}", ttsResult.isCached()); + + return new SpeakingResponse( + session.getSessionId(), + userText, + aiResponse, + ttsResult.getAudioUrl(), + sttResult.confidence() + ); + } + + /** + * 텍스트 입력 처리 (음성 없이 텍스트만) + */ + public SpeakingResponse processTextInput(String sessionId, String userId, String userText, String level){ + logger.info("Processing text input for sessionId: {}", sessionId); + + // 세션 조회 또는 생성 + SpeakingSession session = getOrCreateSession(sessionId, userId, level); + + // 대화 히스토리 로드 + List history = parseHistory(session.getConversationHistory()); + + // AI 응답 생성 + String aiResponse = generateAiResponse(userText, history, session.getTargetLevel()); + + // 히스토리 업데이트 + 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())); + } + session.setConversationHistory(toJson(history)); + sessionRepository.update(session); + + // TTS 생성 + String audioId = session.getSessionId() + "_" + System.currentTimeMillis(); + PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( + audioId, aiResponse, "FEMALE" + ); + + return new SpeakingResponse( + session.getSessionId(), + userText, + aiResponse, + ttsResult.getAudioUrl(), + 1.0 + ); + } + + /** + * 레벨 변경 + */ + public void updateLevel(String sessionId, String level) { + SpeakingSession session = sessionRepository.findBySessionId(sessionId) + .orElseThrow(() -> new RuntimeException("session not found: " + sessionId)); + + session.setTargetLevel(level.toUpperCase()); + sessionRepository.update(session); + logger.info("Level updated for sessionId {}: {}", sessionId, level); + } + + /** + * 대화 히스토리 초기화 + */ + public void resetConversation(String sessionId) { + SpeakingSession session = sessionRepository.findBySessionId(sessionId) + .orElseThrow(() -> new RuntimeException("session not found: " + sessionId)); + + session.setConversationHistory("[]"); + sessionRepository.update(session); + logger.info("Conversation reset for sessionId: {}", sessionId); + } + + + /** + * 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" -> """ + 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 -> """ + default -> """ - Use moderate vocabulary appropriate for intermediate learners - Mix simple and compound sentences - Introduce useful expressions gradually - Balance challenge with accessibility """; - }; - - return String.format(""" + }; + + 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. @@ -258,61 +286,60 @@ private String buildSystemPrompt(String targetLevel) { 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 - ) { - } -} + } + + /** + * 히스토리 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 sessionId, // 세션 ID (다음 요청에 사용) + String userTranscript, // 사용자가 말한 내용 (STT 결과) + String aiText, // AI 응답 텍스트 + String aiAudioUrl, // AI 응답 음성 URL (Polly) + double confidence // STT 신뢰도comp + ) {} +} \ No newline at end of file From ad4b59e25edea9343b4fae7afeda0f4edbd0f1ec Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 14:52:00 +0900 Subject: [PATCH 55/99] fix: revert buildspec.yml to build-only for CloudFormation deploy stage --- ServerlessFunction/buildspec.yml | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/ServerlessFunction/buildspec.yml b/ServerlessFunction/buildspec.yml index 0779bea5..49ed2a4f 100644 --- a/ServerlessFunction/buildspec.yml +++ b/ServerlessFunction/buildspec.yml @@ -4,8 +4,6 @@ env: variables: SAM_CLI_TELEMETRY: 0 GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" - ENVIRONMENT: prod - STACK_NAME: group2-englishstudy-prod phases: install: @@ -25,26 +23,20 @@ phases: build: commands: - - echo "Building SAM application for $ENVIRONMENT..." + - echo "Building SAM application..." - cd $CODEBUILD_SRC_DIR/ServerlessFunction - sam build --parallel --cached - - echo "Build completed" + - echo "Packaging SAM application..." + - sam package --s3-bucket group2-englishstudy-pipeline-artifacts --s3-prefix sam-packages --output-template-file packaged-template.yaml post_build: commands: - - echo "Deploying to $ENVIRONMENT environment..." - - cd $CODEBUILD_SRC_DIR/ServerlessFunction - - | - sam deploy \ - --stack-name $STACK_NAME \ - --s3-bucket group2-englishstudy-pipeline-artifacts \ - --s3-prefix sam-deploy/prod \ - --region ap-northeast-2 \ - --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \ - --no-confirm-changeset \ - --no-fail-on-empty-changeset \ - --parameter-overrides Environment=$ENVIRONMENT - - echo "Deployment completed on $(date)" + - echo "Build completed on $(date)" + +artifacts: + files: + - packaged-template.yaml + base-directory: ServerlessFunction cache: paths: From bd2b70d0bb783b422983b5571ce4466dbac68b68 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 14:52:00 +0900 Subject: [PATCH 56/99] fix: revert buildspec.yml to build-only for CloudFormation deploy stage --- ServerlessFunction/buildspec.yml | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/ServerlessFunction/buildspec.yml b/ServerlessFunction/buildspec.yml index 0779bea5..49ed2a4f 100644 --- a/ServerlessFunction/buildspec.yml +++ b/ServerlessFunction/buildspec.yml @@ -4,8 +4,6 @@ env: variables: SAM_CLI_TELEMETRY: 0 GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" - ENVIRONMENT: prod - STACK_NAME: group2-englishstudy-prod phases: install: @@ -25,26 +23,20 @@ phases: build: commands: - - echo "Building SAM application for $ENVIRONMENT..." + - echo "Building SAM application..." - cd $CODEBUILD_SRC_DIR/ServerlessFunction - sam build --parallel --cached - - echo "Build completed" + - echo "Packaging SAM application..." + - sam package --s3-bucket group2-englishstudy-pipeline-artifacts --s3-prefix sam-packages --output-template-file packaged-template.yaml post_build: commands: - - echo "Deploying to $ENVIRONMENT environment..." - - cd $CODEBUILD_SRC_DIR/ServerlessFunction - - | - sam deploy \ - --stack-name $STACK_NAME \ - --s3-bucket group2-englishstudy-pipeline-artifacts \ - --s3-prefix sam-deploy/prod \ - --region ap-northeast-2 \ - --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \ - --no-confirm-changeset \ - --no-fail-on-empty-changeset \ - --parameter-overrides Environment=$ENVIRONMENT - - echo "Deployment completed on $(date)" + - echo "Build completed on $(date)" + +artifacts: + files: + - packaged-template.yaml + base-directory: ServerlessFunction cache: paths: From 79e348012bc656bc8494d07c3a57d49de5986a63 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 14:58:26 +0900 Subject: [PATCH 57/99] fix: update all API Gateway StageName to use Environment parameter --- ServerlessFunction/template.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index c7e28a37..c8adc32c 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -219,7 +219,7 @@ Resources: Type: AWS::ApiGatewayV2::Stage Properties: ApiId: !Ref WebSocketApi - StageName: dev + StageName: !Ref Environment AutoDeploy: true # WebSocket Connect Route @@ -1280,7 +1280,7 @@ Resources: Type: AWS::ApiGatewayV2::Stage Properties: ApiId: !Ref GrammarWebSocketApi - StageName: dev + StageName: !Ref Environment AutoDeploy: true # Grammar WebSocket Connect Route From 5158788cdada0bb5eeecb1527d0d23913f147445 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 15:05:44 +0900 Subject: [PATCH 58/99] fix: correct VocabularyTable and ContentBucket references in NewsFunction --- ServerlessFunction/template.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index c8adc32c..d33ea541 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1774,9 +1774,9 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref NewsTable - DynamoDBCrudPolicy: - TableName: !Ref VocabularyTable + TableName: !Ref VocabTable - S3CrudPolicy: - BucketName: !Ref ContentBucket + BucketName: !Sub "${AWS::StackName}" - Statement: - Effect: Allow Action: From 9f3a67216eaee2ca638fa37b9316265d9a509cd3 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 15:05:44 +0900 Subject: [PATCH 59/99] fix: correct VocabularyTable and ContentBucket references in NewsFunction --- ServerlessFunction/template.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index c8adc32c..d33ea541 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1774,9 +1774,9 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref NewsTable - DynamoDBCrudPolicy: - TableName: !Ref VocabularyTable + TableName: !Ref VocabTable - S3CrudPolicy: - BucketName: !Ref ContentBucket + BucketName: !Sub "${AWS::StackName}" - Statement: - Effect: Allow Action: From 7e6756b6a8513eb0dfba6e5b07806d20e96dc54c Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 15:12:18 +0900 Subject: [PATCH 60/99] fix: add stack name prefix to GameScheduleGroup and daily-stats schedule to avoid conflicts --- ServerlessFunction/template.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index d33ea541..3a625aa2 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -364,7 +364,7 @@ Resources: - scheduler:CreateSchedule - scheduler:DeleteSchedule - scheduler:GetSchedule - Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/game-auto-close/*" + Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/${AWS::StackName}-game-auto-close/*" - Statement: - Effect: Allow Action: @@ -524,7 +524,7 @@ Resources: - scheduler:CreateSchedule - scheduler:DeleteSchedule - scheduler:GetSchedule - Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/game-auto-close/*" + Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/${AWS::StackName}-game-auto-close/*" - Statement: - Effect: Allow Action: @@ -614,7 +614,7 @@ Resources: GameScheduleGroup: Type: AWS::Scheduler::ScheduleGroup Properties: - Name: game-auto-close + Name: !Sub "${AWS::StackName}-game-auto-close" ChatMessageFunction: Type: AWS::Serverless::Function @@ -1441,7 +1441,7 @@ Resources: Type: Schedule Properties: Schedule: cron(0 15 * * ? *) # UTC 15:00 = KST 00:00 (자정) - Name: daily-stats-aggregation + Name: !Sub "${AWS::StackName}-daily-stats-aggregation" Description: Daily word learning stats aggregation Enabled: true From cf6c7ec84faf85be4c16b0a2a3a124fa915b0ef8 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 15:12:18 +0900 Subject: [PATCH 61/99] fix: add stack name prefix to GameScheduleGroup and daily-stats schedule to avoid conflicts --- ServerlessFunction/template.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index d33ea541..3a625aa2 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -364,7 +364,7 @@ Resources: - scheduler:CreateSchedule - scheduler:DeleteSchedule - scheduler:GetSchedule - Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/game-auto-close/*" + Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/${AWS::StackName}-game-auto-close/*" - Statement: - Effect: Allow Action: @@ -524,7 +524,7 @@ Resources: - scheduler:CreateSchedule - scheduler:DeleteSchedule - scheduler:GetSchedule - Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/game-auto-close/*" + Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/${AWS::StackName}-game-auto-close/*" - Statement: - Effect: Allow Action: @@ -614,7 +614,7 @@ Resources: GameScheduleGroup: Type: AWS::Scheduler::ScheduleGroup Properties: - Name: game-auto-close + Name: !Sub "${AWS::StackName}-game-auto-close" ChatMessageFunction: Type: AWS::Serverless::Function @@ -1441,7 +1441,7 @@ Resources: Type: Schedule Properties: Schedule: cron(0 15 * * ? *) # UTC 15:00 = KST 00:00 (자정) - Name: daily-stats-aggregation + Name: !Sub "${AWS::StackName}-daily-stats-aggregation" Description: Daily word learning stats aggregation Enabled: true From 6930140637eb38e24d8c017bbd110e0cb4701f0e Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 15:17:51 +0900 Subject: [PATCH 62/99] feat: add support for existing Cognito User Pool reuse across environments --- ServerlessFunction/template.yaml | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 3a625aa2..b28f1981 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -12,6 +12,19 @@ Parameters: - prod Description: Deployment environment + ExistingCognitoUserPoolId: + Type: String + Default: "" + Description: Existing Cognito User Pool ID (leave empty to create new) + + ExistingCognitoClientId: + Type: String + Default: "" + Description: Existing Cognito User Pool Client ID (leave empty to create new) + +Conditions: + CreateNewCognito: !Equals [!Ref ExistingCognitoUserPoolId, ""] + Globals: Function: Timeout: 30 @@ -46,8 +59,8 @@ Resources: CognitoUserPool: Type: AWS::Cognito::UserPool + Condition: CreateNewCognito DeletionPolicy: Retain - # UpdateReplacePolicy: Retain Properties: UserPoolName: !Sub "${AWS::StackName}-userpool" UsernameAttributes: @@ -86,6 +99,7 @@ Resources: # Cognito에게 Lambda 호출 권한 부여 PreSignUpPermission: Type: AWS::Lambda::Permission + Condition: CreateNewCognito Properties: Action: lambda:InvokeFunction FunctionName: !Ref PreSignUpFunction @@ -94,6 +108,7 @@ Resources: PostConfirmationPermission: Type: AWS::Lambda::Permission + Condition: CreateNewCognito Properties: Action: lambda:InvokeFunction FunctionName: !GetAtt PostConfirmationFunction.Arn @@ -132,9 +147,10 @@ Resources: CognitoUserPoolClient: Type: AWS::Cognito::UserPoolClient + Condition: CreateNewCognito Properties: ClientName: !Sub "${AWS::StackName}-client" - UserPoolId: !Ref CognitoUserPool + UserPoolId: !If [CreateNewCognito, !Ref CognitoUserPool, !Ref ExistingCognitoUserPoolId] GenerateSecret: false ExplicitAuthFlows: - ALLOW_USER_SRP_AUTH @@ -2037,11 +2053,11 @@ Outputs: CognitoUserPoolId: Description: Cognito User Pool ID - Value: !Ref CognitoUserPool + Value: !If [CreateNewCognito, !Ref CognitoUserPool, !Ref ExistingCognitoUserPoolId] CognitoUserPoolClientId: Description: Cognito User Pool Client ID - Value: !Ref CognitoUserPoolClient + Value: !If [CreateNewCognito, !Ref CognitoUserPoolClient, !Ref ExistingCognitoClientId] OPIcTableName: Description: OPIc DynamoDB Table Name From 9804d39fb2434211a6a185d09d8bb4daeca9f168 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 15:23:51 +0900 Subject: [PATCH 63/99] fix: add conditional Cognito ARN reference in API Gateway Authorizer --- ServerlessFunction/template.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index b28f1981..1a212411 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -216,7 +216,10 @@ Resources: DefaultAuthorizer: CognitoAuthorizer Authorizers: CognitoAuthorizer: - UserPoolArn: !GetAtt CognitoUserPool.Arn + UserPoolArn: !If + - CreateNewCognito + - !GetAtt CognitoUserPool.Arn + - !Sub "arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/${ExistingCognitoUserPoolId}" Identity: Header: Authorization From 13e0827893723dbbe44390cdd6f0506e007f1cdc Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 15:32:22 +0900 Subject: [PATCH 64/99] fix: remove Cognito resources completely, use existing Cognito only --- ServerlessFunction/template.yaml | 89 +++----------------------------- 1 file changed, 6 insertions(+), 83 deletions(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 1a212411..53dc1248 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -19,11 +19,7 @@ Parameters: ExistingCognitoClientId: Type: String - Default: "" - Description: Existing Cognito User Pool Client ID (leave empty to create new) - -Conditions: - CreateNewCognito: !Equals [!Ref ExistingCognitoUserPoolId, ""] + Description: Existing Cognito User Pool Client ID Globals: Function: @@ -54,67 +50,10 @@ Globals: Resources: ############################################# - # Cognito User Pool + # Cognito - Using Existing User Pool + # (Cognito resources are managed in group2-englishstudy-chatting stack) ############################################# - CognitoUserPool: - Type: AWS::Cognito::UserPool - Condition: CreateNewCognito - DeletionPolicy: 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 - Condition: CreateNewCognito - 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 - Condition: CreateNewCognito - Properties: - Action: lambda:InvokeFunction - FunctionName: !GetAtt PostConfirmationFunction.Arn - Principal: cognito-idp.amazonaws.com - SourceArn: !GetAtt CognitoUserPool.Arn - # 사용자 custom 속성들 기본값 설정 Lambda 함수 PreSignUpFunction: Type: AWS::Serverless::Function @@ -145,19 +84,6 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref UserTable - CognitoUserPoolClient: - Type: AWS::Cognito::UserPoolClient - Condition: CreateNewCognito - Properties: - ClientName: !Sub "${AWS::StackName}-client" - UserPoolId: !If [CreateNewCognito, !Ref CognitoUserPool, !Ref ExistingCognitoUserPoolId] - GenerateSecret: false - ExplicitAuthFlows: - - ALLOW_USER_SRP_AUTH - - ALLOW_REFRESH_TOKEN_AUTH - - ALLOW_USER_PASSWORD_AUTH - PreventUserExistenceErrors: ENABLED - ############################################# # API Gateway (Unified) ############################################# @@ -216,10 +142,7 @@ Resources: DefaultAuthorizer: CognitoAuthorizer Authorizers: CognitoAuthorizer: - UserPoolArn: !If - - CreateNewCognito - - !GetAtt CognitoUserPool.Arn - - !Sub "arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/${ExistingCognitoUserPoolId}" + UserPoolArn: !Sub "arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/${ExistingCognitoUserPoolId}" Identity: Header: Authorization @@ -2056,11 +1979,11 @@ Outputs: CognitoUserPoolId: Description: Cognito User Pool ID - Value: !If [CreateNewCognito, !Ref CognitoUserPool, !Ref ExistingCognitoUserPoolId] + Value: !Ref ExistingCognitoUserPoolId CognitoUserPoolClientId: Description: Cognito User Pool Client ID - Value: !If [CreateNewCognito, !Ref CognitoUserPoolClient, !Ref ExistingCognitoClientId] + Value: !Ref ExistingCognitoClientId OPIcTableName: Description: OPIc DynamoDB Table Name From 4d0db0a41b58e93c2865f1eb0545ee3c3ba6e5a0 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 15:32:22 +0900 Subject: [PATCH 65/99] fix: remove Cognito resources completely, use existing Cognito only --- ServerlessFunction/template.yaml | 89 +++----------------------------- 1 file changed, 6 insertions(+), 83 deletions(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 1a212411..53dc1248 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -19,11 +19,7 @@ Parameters: ExistingCognitoClientId: Type: String - Default: "" - Description: Existing Cognito User Pool Client ID (leave empty to create new) - -Conditions: - CreateNewCognito: !Equals [!Ref ExistingCognitoUserPoolId, ""] + Description: Existing Cognito User Pool Client ID Globals: Function: @@ -54,67 +50,10 @@ Globals: Resources: ############################################# - # Cognito User Pool + # Cognito - Using Existing User Pool + # (Cognito resources are managed in group2-englishstudy-chatting stack) ############################################# - CognitoUserPool: - Type: AWS::Cognito::UserPool - Condition: CreateNewCognito - DeletionPolicy: 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 - Condition: CreateNewCognito - 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 - Condition: CreateNewCognito - Properties: - Action: lambda:InvokeFunction - FunctionName: !GetAtt PostConfirmationFunction.Arn - Principal: cognito-idp.amazonaws.com - SourceArn: !GetAtt CognitoUserPool.Arn - # 사용자 custom 속성들 기본값 설정 Lambda 함수 PreSignUpFunction: Type: AWS::Serverless::Function @@ -145,19 +84,6 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref UserTable - CognitoUserPoolClient: - Type: AWS::Cognito::UserPoolClient - Condition: CreateNewCognito - Properties: - ClientName: !Sub "${AWS::StackName}-client" - UserPoolId: !If [CreateNewCognito, !Ref CognitoUserPool, !Ref ExistingCognitoUserPoolId] - GenerateSecret: false - ExplicitAuthFlows: - - ALLOW_USER_SRP_AUTH - - ALLOW_REFRESH_TOKEN_AUTH - - ALLOW_USER_PASSWORD_AUTH - PreventUserExistenceErrors: ENABLED - ############################################# # API Gateway (Unified) ############################################# @@ -216,10 +142,7 @@ Resources: DefaultAuthorizer: CognitoAuthorizer Authorizers: CognitoAuthorizer: - UserPoolArn: !If - - CreateNewCognito - - !GetAtt CognitoUserPool.Arn - - !Sub "arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/${ExistingCognitoUserPoolId}" + UserPoolArn: !Sub "arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/${ExistingCognitoUserPoolId}" Identity: Header: Authorization @@ -2056,11 +1979,11 @@ Outputs: CognitoUserPoolId: Description: Cognito User Pool ID - Value: !If [CreateNewCognito, !Ref CognitoUserPool, !Ref ExistingCognitoUserPoolId] + Value: !Ref ExistingCognitoUserPoolId CognitoUserPoolClientId: Description: Cognito User Pool Client ID - Value: !If [CreateNewCognito, !Ref CognitoUserPoolClient, !Ref ExistingCognitoClientId] + Value: !Ref ExistingCognitoClientId OPIcTableName: Description: OPIc DynamoDB Table Name From 4d91cc89b8dd2c25fbee45fa2ae4efa8e056fd53 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 16:02:18 +0900 Subject: [PATCH 66/99] feat: add S3 bucket resource and fix environment-specific endpoints - Add ContentBucket S3 resource for content storage - Replace hardcoded /dev with ${Environment} in all WebSocket endpoints - Update Output URLs to use dynamic environment stage Co-Authored-By: Claude Opus 4.5 --- ServerlessFunction/template.yaml | 53 ++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 53dc1248..b3fa4c8d 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -228,7 +228,7 @@ Resources: Environment: Variables: WEBSOCKET_CONNECTION_TTL_SECONDS: "600" - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -257,7 +257,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -286,7 +286,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" GAME_AUTO_CLOSE_LAMBDA_ARN: !GetAtt GameAutoCloseFunction.Arn SCHEDULER_ROLE_ARN: !GetAtt GameSchedulerRole.Arn Policies: @@ -374,7 +374,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -446,7 +446,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" GAME_AUTO_CLOSE_LAMBDA_ARN: !GetAtt GameAutoCloseFunction.Arn SCHEDULER_ROLE_ARN: !GetAtt GameSchedulerRole.Arn Policies: @@ -520,7 +520,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1283,7 +1283,7 @@ Resources: Description: Handle Grammar WebSocket $connect with JWT auth Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1310,7 +1310,7 @@ Resources: Description: Handle Grammar WebSocket $disconnect Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1339,7 +1339,7 @@ Resources: MemorySize: 1024 Environment: Variables: - GRAMMAR_WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + GRAMMAR_WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1872,6 +1872,33 @@ Resources: 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 ############################################# @@ -1955,15 +1982,15 @@ Resources: Outputs: ApiUrl: Description: Unified API Gateway endpoint URL - Value: !Sub 'https://${MainApi}.execute-api.${AWS::Region}.amazonaws.com/dev/' + Value: !Sub 'https://${MainApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}/' WebSocketUrl: Description: WebSocket API Gateway endpoint URL - Value: !Sub 'wss://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev' + Value: !Sub 'wss://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}' GrammarWebSocketUrl: Description: Grammar Streaming WebSocket API endpoint URL - Value: !Sub 'wss://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev' + Value: !Sub 'wss://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}' ChatTableName: Description: Chat DynamoDB Table Name @@ -1975,7 +2002,7 @@ Outputs: BucketName: Description: S3 Bucket Name - Value: !Sub "${AWS::StackName}" + Value: !Ref ContentBucket CognitoUserPoolId: Description: Cognito User Pool ID From 025669074b9504fead42679a4af52a256b64b802 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 16:34:35 +0900 Subject: [PATCH 67/99] fix: add stack name prefix to news-collection schedule name Prevent EventBridge rule name conflicts across environments Co-Authored-By: Claude Opus 4.5 --- ServerlessFunction/template.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index b3fa4c8d..48eb1cb1 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1699,7 +1699,7 @@ Resources: Type: Schedule Properties: Schedule: cron(0 9 * * ? *) - Name: news-collection-daily-schedule + Name: !Sub "${AWS::StackName}-news-collection-daily-schedule" Description: 매일 18시 KST (09:00 UTC)에 뉴스 수집 Enabled: true From c084f22361aaa0b3dc51e3ccfd1673c0d3ef4c7c Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 16:42:57 +0900 Subject: [PATCH 68/99] fix: add X-Requested-With and Accept headers to CORS config Enable additional headers for CloudFront CORS compatibility Co-Authored-By: Claude Opus 4.5 --- ServerlessFunction/template.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 48eb1cb1..0a393a94 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -95,7 +95,7 @@ Resources: StageName: !Ref Environment Cors: AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" - AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" + AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Requested-With,Accept'" AllowOrigin: "'*'" AllowCredentials: false GatewayResponses: From e7e4b852e88fdabd76d515420d0b62efa2f859f9 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 16:49:40 +0900 Subject: [PATCH 69/99] fix: use environment variable for S3 bucket URLs Replace hardcoded bucket name with BUCKET_NAME env var for multi-env support: - PreSignUpHandler: dynamic default profile URL - PostConfirmationHandler: dynamic default profile URL - UserService: dynamic default profile URL - BadgeType: dynamic badge image base URL Co-Authored-By: Claude Opus 4.5 --- .../domain/badge/enums/BadgeType.java | 10 ++++-- .../user/handler/PostConfirmationHandler.java | 8 ++++- .../domain/user/handler/PreSignUpHandler.java | 31 ++++++++++++------- .../domain/user/service/UserService.java | 7 ++++- 4 files changed, 40 insertions(+), 16 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java index b34466ed..f9d32794 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java @@ -32,8 +32,14 @@ public enum BadgeType { // 특별 MASTER("학습 마스터", "모든 업적을 달성했습니다", "master.png", "ALL_BADGES", 1); - - private static final String BASE_URL = "https://group2-englishstudy.s3.ap-northeast-2.amazonaws.com/badges/"; + + private static final String BUCKET_NAME = System.getenv("BUCKET_NAME"); + private static final String BASE_URL = getBaseUrl(); + + private static String getBaseUrl() { + String bucket = BUCKET_NAME != null ? BUCKET_NAME : "group2-englishstudy"; + return String.format("https://%s.s3.ap-northeast-2.amazonaws.com/badges/", bucket); + } private final String name; private final String description; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java index 97bd6638..74a601db 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java @@ -19,7 +19,13 @@ public class PostConfirmationHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(PostConfirmationHandler.class); - private static final String DEFAULT_PROFILE_URL = "https://group2-englishstudy.s3.amazonaws.com/profile/default.png"; + private static final String BUCKET_NAME = System.getenv("BUCKET_NAME"); + private static final String DEFAULT_PROFILE_URL = getDefaultProfileUrl(); + + private static String getDefaultProfileUrl() { + String bucket = BUCKET_NAME != null ? BUCKET_NAME : "group2-englishstudy"; + return String.format("https://%s.s3.amazonaws.com/profile/default.png", bucket); + } private final UserRepository userRepository; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java index d881615b..c8551e61 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java @@ -11,38 +11,45 @@ public class PreSignUpHandler implements RequestHandler, Map> { private static final Logger logger = LoggerFactory.getLogger(PreSignUpHandler.class); - private static final String DEFAULT_PROFILE_URL = System.getenv("DEFAULT_PROFILE_URL"); - + private static final String BUCKET_NAME = System.getenv("BUCKET_NAME"); + private static final String DEFAULT_PROFILE_URL = getDefaultProfileUrl(); + + private static String getDefaultProfileUrl() { + String envUrl = System.getenv("DEFAULT_PROFILE_URL"); + if (envUrl != null && !envUrl.isEmpty()) { + return envUrl; + } + String bucket = BUCKET_NAME != null ? BUCKET_NAME : "group2-englishstudy"; + return String.format("https://%s.s3.amazonaws.com/profile/default.png", bucket); + } + @Override public Map handleRequest(Map input, Context context) { - + try { @SuppressWarnings("unchecked") Map request = (Map) input.get("request"); - + @SuppressWarnings("unchecked") Map userAttributes = (Map) request.get("userAttributes"); - + String nickname = userAttributes.get("nickname"); if (nickname == null || nickname.trim().isEmpty()) { String defaultNickname = UUID.randomUUID().toString().substring(0, 6).toUpperCase() + "님"; userAttributes.put("nickname", defaultNickname); logger.info("nickname 기본값: {}", defaultNickname); } - + String level = userAttributes.get("custom:level"); if (level == null || level.trim().isEmpty()) { userAttributes.put("custom:level", "BEGINNER"); logger.info("level 선택 기본값: BEGINNER"); } - + String profileUrl = userAttributes.get("custom:profileUrl"); if (profileUrl == null || profileUrl.trim().isEmpty()) { - String defaultUrl = DEFAULT_PROFILE_URL != null - ? DEFAULT_PROFILE_URL - : "https://group2-englishstudy.s3.amazonaws.com/profile/default.png"; - userAttributes.put("custom:profileUrl", defaultUrl); - logger.info("프로필 이미지 기본값: {}", defaultUrl); + userAttributes.put("custom:profileUrl", DEFAULT_PROFILE_URL); + logger.info("프로필 이미지 기본값: {}", DEFAULT_PROFILE_URL); } return input; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java index 0c5c99c6..2421f118 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java @@ -22,7 +22,12 @@ public class UserService { private static final Logger logger = LoggerFactory.getLogger(UserService.class); private static final String BUCKET_NAME = System.getenv("PROFILE_BUCKET_NAME"); - private static final String DEFAULT_PROFILE_URL = "https://group2-englishstudy.s3.amazonaws.com/profile/default.png"; + private static final String DEFAULT_PROFILE_URL = getDefaultProfileUrl(); + + private static String getDefaultProfileUrl() { + String bucket = BUCKET_NAME != null ? BUCKET_NAME : "group2-englishstudy"; + return String.format("https://%s.s3.amazonaws.com/profile/default.png", bucket); + } private static final List VALID_LEVELS = Arrays.asList("BEGINNER", "INTERMEDIATE", "ADVANCED"); private static final List VALID_IMAGE_TYPES = Arrays.asList("image/jpeg", "image/png", "image/gif", "image/webp"); From 5eda0db9215feeb1e10afd2a2b4e079cad72aa4b Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 17:07:29 +0900 Subject: [PATCH 70/99] fix: disable authorizer for CORS preflight requests Add AddDefaultAuthorizerToCorsPreflight: false to prevent Cognito Authorizer from blocking OPTIONS requests Co-Authored-By: Claude Opus 4.5 --- ServerlessFunction/template.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 0a393a94..ba797ea6 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -140,6 +140,7 @@ Resources: 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}" From 143c0ff6449662f21cbc3b4080e8539f71e6218d Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:53:04 +0900 Subject: [PATCH 71/99] =?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=20?= =?UTF-8?q?=20(#491)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * fix : 오타 수정 * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: add CORS headers to API Gateway error responses (#479) API Gateway의 인증 실패 등 에러 응답에 CORS 헤더가 누락되어 CloudFront를 통한 프론트엔드 요청이 차단되는 문제 수정 - UNAUTHORIZED (401) 응답에 CORS 헤더 추가 - ACCESS_DENIED (403) 응답에 CORS 헤더 추가 - DEFAULT_4XX/5XX 응답에 CORS 헤더 추가 - EXPIRED_TOKEN 응답에 CORS 헤더 추가 * feat : speaking rest API 람다 함수 추가 --------- Co-authored-by: ddingjoo --- .../speaking/service/SpeakingService.java | 2 +- ServerlessFunction/template.yaml | 56 +++++++++++++++++++ 2 files changed, 57 insertions(+), 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 +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index ba797ea6..fdd9351f 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1388,6 +1388,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 42e9d2e090c387ff9e371aa2ca4588ea19ca1c18 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Thu, 22 Jan 2026 22:31:23 +0900 Subject: [PATCH 72/99] =?UTF-8?q?feature=20:=20test=20=EB=B2=A1=EC=97=94?= =?UTF-8?q?=EB=93=9C=20=EC=84=9C=EB=B2=84=EC=97=90=20AI=20=EB=A7=90?= =?UTF-8?q?=ED=95=98=EA=B8=B0=20=EC=97=B0=EC=8A=B5=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20(#492)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 --------- Co-authored-by: DDING JOO --- .../websocket/SpeakingConnectHandler.java | 0 .../websocket/SpeakingDisconnectHandler.java | 0 .../websocket/SpeakingMessageHandler.java | 0 .../SpeakingConnectionRepository.java | 0 .../repository/SpeakingSessionRepository.java | 2 +- .../speaking/service/SpeakingService.java | 2 +- ServerlessFunction/template.yaml | 93 +++++++++++++++++++ 7 files changed, 95 insertions(+), 2 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/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 +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index ba797ea6..d6d2ea4e 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1388,6 +1388,63 @@ Resources: Description: Daily word learning stats aggregation Enabled: true + ############################################# + # Speaking REST API (AI와 대화하기) + ############################################# + + SpeakingFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-speaking-handler" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.speaking.handler.websocket.SpeakingHandler::handleRequest + Description: Handle Speaking AI conversation (REST API) + Timeout: 120 + MemorySize: 1024 + SnapStart: + ApplyOn: PublishedVersions + Environment: + Variables: + SPEAKING_TABLE_NAME: !Ref SpeakingTable + TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref SpeakingTable + - S3CrudPolicy: + BucketName: !Sub "${AWS::StackName}" + - 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 ############################################# @@ -1679,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 ############################################# @@ -2016,3 +2105,7 @@ Outputs: OPIcTableName: Description: OPIc DynamoDB Table Name Value: !Ref OPIcTable + + SpeakingTableName: + Description: Speaking DynamoDB Table Name + Value: !Ref SpeakingTable From e57b1f6ba1533491c320bd3a3798a06a2fa5c5f2 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Thu, 22 Jan 2026 23:09:54 +0900 Subject: [PATCH 73/99] =?UTF-8?q?feature=20:=20handleChat=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20JsonNull=20=EC=B2=B4=ED=81=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#493)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feat : handleChat 메서드 JsonNull 체크 푸가 --------- Co-authored-by: DDING JOO --- .../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 f82e649d69f71d5f8c5a641ba785fbd419ccc5a0 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 09:19:22 +0900 Subject: [PATCH 74/99] =?UTF-8?q?feat(news):=20=EB=89=B4=EC=8A=A4=20?= =?UTF-8?q?=ED=95=99=EC=8A=B5=20=EB=B0=B0=EC=A7=80=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=EA=B5=AC=ED=98=84=20(#473)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 14개 뉴스 관련 배지 추가 (읽기, 퀴즈, 단어수집, 연속학습, 마스터) - UserStats에 뉴스 통계 필드 추가 - 6개 뉴스 배지 Strategy 클래스 생성 - 뉴스 읽기/퀴즈/단어수집 시 통계 업데이트 및 배지 체크 연동 --- .../domain/badge/enums/BadgeType.java | 26 +++- .../BadgeConditionStrategyFactory.java | 7 + .../badge/strategy/NewsMasterStrategy.java | 45 ++++++ .../strategy/NewsQuizPerfectStrategy.java | 25 ++++ .../badge/strategy/NewsQuizStrategy.java | 25 ++++ .../badge/strategy/NewsReadStrategy.java | 25 ++++ .../badge/strategy/NewsStreakStrategy.java | 25 ++++ .../badge/strategy/NewsWordStrategy.java | 25 ++++ .../domain/news/handler/NewsHandler.java | 12 +- .../news/service/NewsLearningService.java | 37 ++++- .../domain/news/service/NewsQuizService.java | 31 +++- .../domain/news/service/NewsWordService.java | 43 +++++- .../domain/stats/model/UserStats.java | 10 +- .../stats/repository/UserStatsRepository.java | 134 ++++++++++++++++++ 14 files changed, 457 insertions(+), 13 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsMasterStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizPerfectStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsReadStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsStreakStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsWordStrategy.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java index f9d32794..76e857ae 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java @@ -31,7 +31,31 @@ public enum BadgeType { PERFECT_DRAWER("완벽한 출제자", "출제 시 전원이 정답을 맞췄습니다", "perfect_drawer.png", "PERFECT_DRAWS", 1), // 특별 - MASTER("학습 마스터", "모든 업적을 달성했습니다", "master.png", "ALL_BADGES", 1); + MASTER("학습 마스터", "모든 업적을 달성했습니다", "master.png", "ALL_BADGES", 1), + + // 뉴스 - 읽기 + NEWS_FIRST_READ("뉴스 첫 발걸음", "첫 번째 뉴스 읽기 완료", "news_first_read.png", "NEWS_READ", 1), + NEWS_READ_10("뉴스 탐험가", "뉴스 10개 읽기 완료", "news_read_10.png", "NEWS_READ", 10), + NEWS_READ_50("뉴스 애호가", "뉴스 50개 읽기 완료", "news_read_50.png", "NEWS_READ", 50), + NEWS_READ_100("뉴스 전문가", "뉴스 100개 읽기 완료", "news_read_100.png", "NEWS_READ", 100), + + // 뉴스 - 퀴즈 + NEWS_QUIZ_FIRST("퀴즈 도전", "첫 뉴스 퀴즈 완료", "news_quiz_first.png", "NEWS_QUIZ", 1), + NEWS_QUIZ_PERFECT("완벽한 이해", "뉴스 퀴즈에서 만점 달성", "news_quiz_perfect.png", "NEWS_QUIZ_PERFECT", 1), + NEWS_QUIZ_10("퀴즈 탐험가", "뉴스 퀴즈 10회 완료", "news_quiz_10.png", "NEWS_QUIZ", 10), + NEWS_QUIZ_50("퀴즈 마스터", "뉴스 퀴즈 50회 완료", "news_quiz_50.png", "NEWS_QUIZ", 50), + + // 뉴스 - 단어 수집 + NEWS_WORD_10("단어 수집가", "뉴스에서 단어 10개 수집", "news_word_10.png", "NEWS_WORD", 10), + NEWS_WORD_50("단어 사냥꾼", "뉴스에서 단어 50개 수집", "news_word_50.png", "NEWS_WORD", 50), + NEWS_WORD_100("단어 전문가", "뉴스에서 단어 100개 수집", "news_word_100.png", "NEWS_WORD", 100), + + // 뉴스 - 연속 학습 + NEWS_STREAK_7("일주일 뉴스 습관", "7일 연속 뉴스 읽기", "news_streak_7.png", "NEWS_STREAK", 7), + NEWS_STREAK_30("한 달 뉴스 습관", "30일 연속 뉴스 읽기", "news_streak_30.png", "NEWS_STREAK", 30), + + // 뉴스 - 종합 + NEWS_MASTER("뉴스 마스터", "읽기100+퀴즈50+단어100 달성", "news_master.png", "NEWS_MASTER", 1); private static final String BUCKET_NAME = System.getenv("BUCKET_NAME"); private static final String BASE_URL = getBaseUrl(); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java index 01f6ed33..ecfb1e63 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java @@ -22,6 +22,13 @@ public class BadgeConditionStrategyFactory { register(new GamesWonStrategy()); register(new QuickGuessesStrategy()); register(new PerfectDrawsStrategy()); + // 뉴스 관련 전략 + register(new NewsReadStrategy()); + register(new NewsQuizStrategy()); + register(new NewsQuizPerfectStrategy()); + register(new NewsWordStrategy()); + register(new NewsStreakStrategy()); + register(new NewsMasterStrategy()); // 별도 로직이 필요한 카테고리 register(new NoOpStrategy("PERFECT_TEST")); register(new NoOpStrategy("ALL_BADGES")); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsMasterStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsMasterStrategy.java new file mode 100644 index 00000000..43fee824 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsMasterStrategy.java @@ -0,0 +1,45 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 뉴스 마스터 뱃지 조건 전략 + * 읽기 100개 + 퀴즈 50회 + 단어 100개 달성 시 획득 + */ +public class NewsMasterStrategy implements BadgeConditionStrategy { + + private static final int NEWS_READ_REQUIRED = 100; + private static final int NEWS_QUIZ_REQUIRED = 50; + private static final int NEWS_WORD_REQUIRED = 100; + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + int newsRead = stats.getNewsRead() != null ? stats.getNewsRead() : 0; + int newsQuiz = stats.getNewsQuizCompleted() != null ? stats.getNewsQuizCompleted() : 0; + int newsWord = stats.getNewsWordsCollected() != null ? stats.getNewsWordsCollected() : 0; + + return newsRead >= NEWS_READ_REQUIRED + && newsQuiz >= NEWS_QUIZ_REQUIRED + && newsWord >= NEWS_WORD_REQUIRED; + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + int newsRead = stats.getNewsRead() != null ? stats.getNewsRead() : 0; + int newsQuiz = stats.getNewsQuizCompleted() != null ? stats.getNewsQuizCompleted() : 0; + int newsWord = stats.getNewsWordsCollected() != null ? stats.getNewsWordsCollected() : 0; + + // 3가지 조건의 평균 진행률 (각각 100%, 100%, 100% 기준) + int readProgress = Math.min(newsRead * 100 / NEWS_READ_REQUIRED, 100); + int quizProgress = Math.min(newsQuiz * 100 / NEWS_QUIZ_REQUIRED, 100); + int wordProgress = Math.min(newsWord * 100 / NEWS_WORD_REQUIRED, 100); + + return (readProgress + quizProgress + wordProgress) / 3; + } + + @Override + public String getCategory() { + return "NEWS_MASTER"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizPerfectStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizPerfectStrategy.java new file mode 100644 index 00000000..d9790b27 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizPerfectStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 뉴스 퀴즈 만점 뱃지 조건 전략 + */ +public class NewsQuizPerfectStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getNewsQuizPerfect() != null && stats.getNewsQuizPerfect() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getNewsQuizPerfect() != null ? stats.getNewsQuizPerfect() : 0; + } + + @Override + public String getCategory() { + return "NEWS_QUIZ_PERFECT"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizStrategy.java new file mode 100644 index 00000000..4ce390d8 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 뉴스 퀴즈 완료 뱃지 조건 전략 + */ +public class NewsQuizStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getNewsQuizCompleted() != null && stats.getNewsQuizCompleted() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getNewsQuizCompleted() != null ? stats.getNewsQuizCompleted() : 0; + } + + @Override + public String getCategory() { + return "NEWS_QUIZ"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsReadStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsReadStrategy.java new file mode 100644 index 00000000..3e5cee34 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsReadStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 뉴스 읽기 뱃지 조건 전략 + */ +public class NewsReadStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getNewsRead() != null && stats.getNewsRead() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getNewsRead() != null ? stats.getNewsRead() : 0; + } + + @Override + public String getCategory() { + return "NEWS_READ"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsStreakStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsStreakStrategy.java new file mode 100644 index 00000000..cb5f58d0 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsStreakStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 뉴스 연속 읽기 뱃지 조건 전략 + */ +public class NewsStreakStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getNewsStreak() != null && stats.getNewsStreak() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getNewsStreak() != null ? stats.getNewsStreak() : 0; + } + + @Override + public String getCategory() { + return "NEWS_STREAK"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsWordStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsWordStrategy.java new file mode 100644 index 00000000..70c6c1a7 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsWordStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 뉴스 단어 수집 뱃지 조건 전략 + */ +public class NewsWordStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getNewsWordsCollected() != null && stats.getNewsWordsCollected() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getNewsWordsCollected() != null ? stats.getNewsWordsCollected() : 0; + } + + @Override + public String getCategory() { + return "NEWS_WORD"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java index 180bb7cb..a427051d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -416,13 +416,19 @@ private APIGatewayProxyResponseEvent collectWord(APIGatewayProxyRequestEvent req String word = body.get("word").getAsString(); String context = body.has("context") ? body.get("context").getAsString() : ""; - NewsWordCollect collected = wordService.collectWord(userId, articleId, word, context); + NewsWordService.WordCollectResult result = wordService.collectWord(userId, articleId, word, context); - if (collected == null) { + if (result == null || result.wordCollect() == null) { return ResponseGenerator.fail(NewsErrorCode.WORD_ALREADY_COLLECTED); } - return ResponseGenerator.ok("단어 수집 성공", collected); + Map responseData = new java.util.HashMap<>(); + responseData.put("wordCollect", result.wordCollect()); + if (result.newBadges() != null && !result.newBadges().isEmpty()) { + responseData.put("newBadges", result.newBadges()); + } + + return ResponseGenerator.ok("단어 수집 성공", responseData); } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java index 8eba8522..3e13f2c3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java @@ -2,13 +2,18 @@ import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.service.PollyService; +import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; +import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; import com.mzc.secondproject.serverless.domain.news.repository.UserNewsRepository; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; +import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; @@ -24,29 +29,38 @@ public class NewsLearningService { private final NewsArticleRepository articleRepository; private final UserNewsRepository userNewsRepository; private final PollyService pollyService; + private final UserStatsRepository userStatsRepository; + private final BadgeService badgeService; public NewsLearningService() { this.articleRepository = new NewsArticleRepository(); this.userNewsRepository = new UserNewsRepository(); this.pollyService = new PollyService(BUCKET_NAME, "news/audio/"); + this.userStatsRepository = new UserStatsRepository(); + this.badgeService = new BadgeService(); } public NewsLearningService(NewsArticleRepository articleRepository, UserNewsRepository userNewsRepository, - PollyService pollyService) { + PollyService pollyService, + UserStatsRepository userStatsRepository, + BadgeService badgeService) { this.articleRepository = articleRepository; this.userNewsRepository = userNewsRepository; this.pollyService = pollyService; + this.userStatsRepository = userStatsRepository; + this.badgeService = badgeService; } /** * 뉴스 읽기 완료 기록 + * @return 새로 획득한 배지 목록 */ - public void markAsRead(String userId, String articleId) { + public List markAsRead(String userId, String articleId) { Optional article = articleRepository.findById(articleId); if (article.isEmpty()) { logger.warn("기사를 찾을 수 없음: {}", articleId); - return; + return new ArrayList<>(); } NewsArticle a = article.get(); @@ -65,6 +79,23 @@ public void markAsRead(String userId, String articleId) { } logger.info("읽기 완료 기록: userId={}, articleId={}", userId, articleId); + + // 통계 업데이트 및 배지 체크 + List newBadges = new ArrayList<>(); + try { + UserStats updatedStats = userStatsRepository.incrementNewsReadStats(userId); + if (updatedStats != null) { + newBadges = badgeService.checkAndAwardBadges(userId, updatedStats); + if (!newBadges.isEmpty()) { + logger.info("새 배지 획득: userId={}, badges={}", userId, + newBadges.stream().map(UserBadge::getBadgeType).toList()); + } + } + } catch (Exception e) { + logger.error("통계/배지 업데이트 실패: userId={}, error={}", userId, e.getMessage()); + } + + return newBadges; } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java index bb22fc90..da86d430 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java @@ -1,9 +1,13 @@ package com.mzc.secondproject.serverless.domain.news.service; +import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; +import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; import com.mzc.secondproject.serverless.domain.news.model.*; import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; import com.mzc.secondproject.serverless.domain.news.repository.NewsQuizRepository; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; +import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,15 +24,22 @@ public class NewsQuizService { private final NewsArticleRepository articleRepository; private final NewsQuizRepository quizRepository; + private final UserStatsRepository userStatsRepository; + private final BadgeService badgeService; public NewsQuizService() { this.articleRepository = new NewsArticleRepository(); this.quizRepository = new NewsQuizRepository(); + this.userStatsRepository = new UserStatsRepository(); + this.badgeService = new BadgeService(); } - public NewsQuizService(NewsArticleRepository articleRepository, NewsQuizRepository quizRepository) { + public NewsQuizService(NewsArticleRepository articleRepository, NewsQuizRepository quizRepository, + UserStatsRepository userStatsRepository, BadgeService badgeService) { this.articleRepository = articleRepository; this.quizRepository = quizRepository; + this.userStatsRepository = userStatsRepository; + this.badgeService = badgeService; } /** @@ -158,12 +169,29 @@ public QuizSubmitResult submitQuiz(String userId, String articleId, List newBadges = new ArrayList<>(); + try { + boolean isPerfect = score == 100; + UserStats updatedStats = userStatsRepository.incrementNewsQuizStats(userId, isPerfect); + if (updatedStats != null) { + newBadges = badgeService.checkAndAwardBadges(userId, updatedStats); + if (!newBadges.isEmpty()) { + logger.info("새 배지 획득: userId={}, badges={}", userId, + newBadges.stream().map(UserBadge::getBadgeType).toList()); + } + } + } catch (Exception e) { + logger.error("통계/배지 업데이트 실패: userId={}, error={}", userId, e.getMessage()); + } + return QuizSubmitResult.builder() .score(score) .earnedPoints(earnedPoints) .totalPoints(totalPoints) .results(answerResults) .feedback(feedback) + .newBadges(newBadges) .build(); } @@ -259,5 +287,6 @@ public static class QuizSubmitResult { private int totalPoints; private List results; private String feedback; + private List newBadges; } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java index 6c3c23ec..6881c7ec 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java @@ -1,10 +1,14 @@ package com.mzc.secondproject.serverless.domain.news.service; +import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; +import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; import com.mzc.secondproject.serverless.domain.news.model.NewsWordCollect; import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; import com.mzc.secondproject.serverless.domain.news.repository.NewsWordRepository; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; +import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; import com.mzc.secondproject.serverless.domain.vocabulary.service.UserWordCommandService; @@ -12,6 +16,7 @@ import org.slf4j.LoggerFactory; import java.time.Instant; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; @@ -27,32 +32,42 @@ public class NewsWordService { private final NewsArticleRepository articleRepository; private final WordRepository wordRepository; private final UserWordCommandService userWordCommandService; + private final UserStatsRepository userStatsRepository; + private final BadgeService badgeService; public NewsWordService() { this.newsWordRepository = new NewsWordRepository(); this.articleRepository = new NewsArticleRepository(); this.wordRepository = new WordRepository(); this.userWordCommandService = new UserWordCommandService(); + this.userStatsRepository = new UserStatsRepository(); + this.badgeService = new BadgeService(); } public NewsWordService(NewsWordRepository newsWordRepository, NewsArticleRepository articleRepository, WordRepository wordRepository, - UserWordCommandService userWordCommandService) { + UserWordCommandService userWordCommandService, + UserStatsRepository userStatsRepository, + BadgeService badgeService) { this.newsWordRepository = newsWordRepository; this.articleRepository = articleRepository; this.wordRepository = wordRepository; this.userWordCommandService = userWordCommandService; + this.userStatsRepository = userStatsRepository; + this.badgeService = badgeService; } /** * 단어 수집 + * @return 수집 결과 (단어 정보 + 새로 획득한 배지) */ - public NewsWordCollect collectWord(String userId, String articleId, String word, String context) { + public WordCollectResult collectWord(String userId, String articleId, String word, String context) { // 이미 수집했는지 확인 if (newsWordRepository.hasCollected(userId, word, articleId)) { logger.warn("이미 수집한 단어: userId={}, word={}", userId, word); - return newsWordRepository.findByUserWordArticle(userId, word, articleId).orElse(null); + NewsWordCollect existing = newsWordRepository.findByUserWordArticle(userId, word, articleId).orElse(null); + return new WordCollectResult(existing, new ArrayList<>()); } // 기사 조회 @@ -86,9 +101,29 @@ public NewsWordCollect collectWord(String userId, String articleId, String word, newsWordRepository.save(wordCollect); logger.info("단어 수집 완료: userId={}, word={}, articleId={}", userId, word, articleId); - return wordCollect; + // 통계 업데이트 및 배지 체크 + List newBadges = new ArrayList<>(); + try { + UserStats updatedStats = userStatsRepository.incrementNewsWordStats(userId, 1); + if (updatedStats != null) { + newBadges = badgeService.checkAndAwardBadges(userId, updatedStats); + if (!newBadges.isEmpty()) { + logger.info("새 배지 획득: userId={}, badges={}", userId, + newBadges.stream().map(UserBadge::getBadgeType).toList()); + } + } + } catch (Exception e) { + logger.error("통계/배지 업데이트 실패: userId={}, error={}", userId, e.getMessage()); + } + + return new WordCollectResult(wordCollect, newBadges); } + /** + * 단어 수집 결과 + */ + public record WordCollectResult(NewsWordCollect wordCollect, List newBadges) {} + /** * 수집한 단어 삭제 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java index cc25634c..4905a9f2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java @@ -56,7 +56,15 @@ public class UserStats { private Integer totalGameScore; // 누적 게임 점수 private Integer quickGuesses; // 5초 내 정답 횟수 private Integer perfectDraws; // 전원 정답 유도 횟수 - + + // 뉴스 통계 + private Integer newsRead; // 읽은 뉴스 수 + private Integer newsQuizCompleted; // 완료한 뉴스 퀴즈 수 + private Integer newsQuizPerfect; // 뉴스 퀴즈 만점 횟수 + private Integer newsWordsCollected; // 뉴스에서 수집한 단어 수 + private Integer newsStreak; // 뉴스 연속 읽기 일수 + private String lastNewsReadDate; // 마지막 뉴스 읽은 날짜 + // 메타데이터 private String createdAt; private String updatedAt; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java index b3ad20d8..2c49d4c7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java @@ -310,6 +310,140 @@ public void incrementGameStats(String userId, int gamesPlayed, int gamesWon, userId, gamesPlayed, gamesWon, correctGuesses); } + /** + * 뉴스 읽기 통계 Atomic 업데이트 + */ + public UserStats incrementNewsReadStats(String userId) { + String today = LocalDate.now().toString(); + String pk = StatsKey.userStatsPk(userId); + String sk = StatsKey.statsTotalSk(); + String now = Instant.now().toString(); + + // 먼저 현재 통계 조회 (streak 계산용) + UserStats currentStats = findTotalStats(userId).orElse(null); + String lastNewsReadDate = currentStats != null ? currentStats.getLastNewsReadDate() : null; + + // 연속 읽기 계산 + int currentStreak = 1; + if (lastNewsReadDate != null) { + LocalDate lastDate = LocalDate.parse(lastNewsReadDate); + LocalDate todayDate = LocalDate.now(); + if (lastDate.equals(todayDate.minusDays(1))) { + // 어제 읽었으면 streak 증가 + currentStreak = (currentStats.getNewsStreak() != null ? currentStats.getNewsStreak() : 0) + 1; + } else if (lastDate.equals(todayDate)) { + // 오늘 이미 읽었으면 streak 유지 + currentStreak = currentStats.getNewsStreak() != null ? currentStats.getNewsStreak() : 1; + } + // 그 외의 경우는 streak 1로 초기화 + } + + Map key = new HashMap<>(); + key.put("PK", AttributeValue.builder().s(pk).build()); + key.put("SK", AttributeValue.builder().s(sk).build()); + + Map values = new HashMap<>(); + values.put(":one", AttributeValue.builder().n("1").build()); + values.put(":zero", AttributeValue.builder().n("0").build()); + values.put(":streak", AttributeValue.builder().n(String.valueOf(currentStreak)).build()); + values.put(":today", AttributeValue.builder().s(today).build()); + values.put(":now", AttributeValue.builder().s(now).build()); + + String updateExpression = "SET " + + "newsRead = if_not_exists(newsRead, :zero) + :one, " + + "newsStreak = :streak, " + + "lastNewsReadDate = :today, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now)"; + + UpdateItemRequest request = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression(updateExpression) + .expressionAttributeValues(values) + .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) + .build(); + + AwsClients.dynamoDb().updateItem(request); + logger.info("Incremented news read stats: userId={}, streak={}", userId, currentStreak); + + return findTotalStats(userId).orElse(null); + } + + /** + * 뉴스 퀴즈 통계 Atomic 업데이트 + */ + public UserStats incrementNewsQuizStats(String userId, boolean isPerfect) { + String pk = StatsKey.userStatsPk(userId); + String sk = StatsKey.statsTotalSk(); + String now = Instant.now().toString(); + + Map key = new HashMap<>(); + key.put("PK", AttributeValue.builder().s(pk).build()); + key.put("SK", AttributeValue.builder().s(sk).build()); + + Map values = new HashMap<>(); + values.put(":one", AttributeValue.builder().n("1").build()); + values.put(":zero", AttributeValue.builder().n("0").build()); + values.put(":perfect", AttributeValue.builder().n(isPerfect ? "1" : "0").build()); + values.put(":now", AttributeValue.builder().s(now).build()); + + String updateExpression = "SET " + + "newsQuizCompleted = if_not_exists(newsQuizCompleted, :zero) + :one, " + + "newsQuizPerfect = if_not_exists(newsQuizPerfect, :zero) + :perfect, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now)"; + + UpdateItemRequest request = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression(updateExpression) + .expressionAttributeValues(values) + .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) + .build(); + + AwsClients.dynamoDb().updateItem(request); + logger.info("Incremented news quiz stats: userId={}, isPerfect={}", userId, isPerfect); + + return findTotalStats(userId).orElse(null); + } + + /** + * 뉴스 단어 수집 통계 Atomic 업데이트 + */ + public UserStats incrementNewsWordStats(String userId, int wordCount) { + String pk = StatsKey.userStatsPk(userId); + String sk = StatsKey.statsTotalSk(); + String now = Instant.now().toString(); + + Map key = new HashMap<>(); + key.put("PK", AttributeValue.builder().s(pk).build()); + key.put("SK", AttributeValue.builder().s(sk).build()); + + Map values = new HashMap<>(); + values.put(":count", AttributeValue.builder().n(String.valueOf(wordCount)).build()); + values.put(":zero", AttributeValue.builder().n("0").build()); + values.put(":now", AttributeValue.builder().s(now).build()); + + String updateExpression = "SET " + + "newsWordsCollected = if_not_exists(newsWordsCollected, :zero) + :count, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now)"; + + UpdateItemRequest request = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression(updateExpression) + .expressionAttributeValues(values) + .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) + .build(); + + AwsClients.dynamoDb().updateItem(request); + logger.info("Incremented news word stats: userId={}, wordCount={}", userId, wordCount); + + return findTotalStats(userId).orElse(null); + } + /** * 현재 연도-주차 반환 (예: 2026-W02) */ From a6662e0cfc46c6e12f20a89f0df285ff282160bc Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 09:23:10 +0900 Subject: [PATCH 75/99] fix: add PATCH method to CORS AllowMethods --- ServerlessFunction/template.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index ba797ea6..3509353e 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -94,7 +94,7 @@ Resources: Name: !Sub "${AWS::StackName}-api" StageName: !Ref Environment Cors: - AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" + AllowMethods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Requested-With,Accept'" AllowOrigin: "'*'" AllowCredentials: false From ac6e311c95d2afdb8668314c6dc0a3fbbc4d5799 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 09:35:02 +0900 Subject: [PATCH 76/99] =?UTF-8?q?test:=20BadgeType=20=EA=B0=9C=EC=88=98=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95=20(15=20->=202?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/domain/badge/enums/BadgeTypeSpec.groovy | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/badge/enums/BadgeTypeSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/badge/enums/BadgeTypeSpec.groovy index 19a64976..6fd08457 100644 --- a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/badge/enums/BadgeTypeSpec.groovy +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/badge/enums/BadgeTypeSpec.groovy @@ -111,8 +111,8 @@ class BadgeTypeSpec extends Specification { } def "모든 BadgeType 개수 확인"() { - expect: "15개의 뱃지 타입 존재" - BadgeType.values().length == 15 + expect: "29개의 뱃지 타입 존재 (기본 15 + 뉴스 14)" + BadgeType.values().length == 29 } def "모든 뱃지의 imageUrl이 S3 URL 형식"() { From e0c7651ad607f9bbe8d6e2c54d45a672fbe631c4 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 09:42:56 +0900 Subject: [PATCH 77/99] =?UTF-8?q?fix:=20CORS=20PATCH=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=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 | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 7d8711f4..7211a2c2 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -94,7 +94,7 @@ Resources: Name: !Sub "${AWS::StackName}-api" StageName: !Ref Environment Cors: - AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" + AllowMethods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Requested-With,Accept'" AllowOrigin: "'*'" AllowCredentials: false @@ -105,7 +105,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,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" ResponseTemplates: application/json: '{"message": "Unauthorized", "statusCode": 401}' ACCESS_DENIED: @@ -114,7 +114,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,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" ResponseTemplates: application/json: '{"message": "Access Denied", "statusCode": 403}' DEFAULT_4XX: @@ -122,20 +122,20 @@ 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,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,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,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,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,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" ResponseTemplates: application/json: '{"message": "Token expired", "statusCode": 401}' Auth: From 4229984c503682e42d2f0b454a7c56063e2249e8 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 09:47:05 +0900 Subject: [PATCH 78/99] =?UTF-8?q?docs:=20=EB=89=B4=EC=8A=A4=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EA=B0=80=EC=9D=B4=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/news-frontend-guide.md | 916 ++++++++++++++++++++++++++++++++++++ 1 file changed, 916 insertions(+) create mode 100644 docs/news-frontend-guide.md diff --git a/docs/news-frontend-guide.md b/docs/news-frontend-guide.md new file mode 100644 index 00000000..650c27cc --- /dev/null +++ b/docs/news-frontend-guide.md @@ -0,0 +1,916 @@ +# 뉴스 영어 학습 기능 - 프론트엔드 연동 가이드 + +## 목차 +1. [기능 개요](#기능-개요) +2. [주요 기능](#주요-기능) +3. [API 명세](#api-명세) +4. [데이터 모델](#데이터-모델) +5. [UI/UX 가이드](#uiux-가이드) +6. [화면 구성 제안](#화면-구성-제안) +7. [에러 코드](#에러-코드) + +--- + +## 기능 개요 + +### 프로젝트 소개 +**뉴스 영어 학습**은 실제 영어 뉴스를 활용한 맞춤형 영어 학습 서비스입니다. + +- **실시간 뉴스 수집**: BBC, VOA 등 해외 뉴스 매체에서 매일 새로운 기사 자동 수집 +- **AI 분석**: Amazon Bedrock을 활용한 난이도 분석, 키워드 추출, 퀴즈 생성 +- **맞춤형 학습**: 사용자 레벨(BEGINNER/INTERMEDIATE/ADVANCED)에 맞는 뉴스 추천 +- **TTS 지원**: Amazon Polly를 통한 원어민 발음 듣기 +- **뱃지 시스템**: 학습 활동에 따른 뱃지 획득 + +### 기술 스택 +- Backend: AWS Lambda (Java 21), DynamoDB +- AI: Amazon Bedrock (Claude) +- TTS: Amazon Polly +- 뉴스 수집: EventBridge 스케줄러 (매일 자동 수집) + +--- + +## 주요 기능 + +### 1. 뉴스 목록/상세 조회 +- 오늘의 뉴스 목록 +- 난이도별/카테고리별 필터링 +- 사용자 레벨 맞춤 추천 +- 무한 스크롤 페이지네이션 + +### 2. 뉴스 학습 +- 기사 읽기 완료 기록 +- 북마크 기능 +- TTS 오디오 재생 + +### 3. 뉴스 퀴즈 +- 기사별 5문제 자동 생성 퀴즈 +- 퀴즈 유형: 독해력(COMPREHENSION), 단어 매칭(WORD_MATCH), 빈칸 채우기(FILL_BLANK) +- 점수 및 기록 관리 + +### 4. 단어 수집 +- 기사 내 단어 수집 +- 문맥과 함께 저장 +- Vocabulary 시스템 연동 + +### 5. 학습 통계 +- 읽은 기사 수 +- 퀴즈 완료/정답률 +- 수집 단어 수 +- 연속 학습 일수 (스트릭) + +### 6. 뱃지 시스템 +14가지 뉴스 관련 뱃지: +- 읽기: 첫 읽기, 10개, 50개, 100개 기사 읽기 +- 퀴즈: 첫 퀴즈, 만점, 10회, 50회 완료 +- 단어: 10개, 50개, 100개 수집 +- 스트릭: 7일, 30일 연속 학습 +- 마스터: 전체 뉴스 기능 마스터 + +--- + +## API 명세 + +### Base URL +``` +Test: https://xgepjbg2c9.execute-api.ap-northeast-2.amazonaws.com/test +Prod: https://xgepjbg2c9.execute-api.ap-northeast-2.amazonaws.com/prod +``` + +### 인증 +모든 API는 Cognito JWT 토큰 필요 +``` +Authorization: Bearer {accessToken} +``` + +--- + +### 1. 뉴스 목록 조회 + +#### GET /news +뉴스 목록 조회 (필터링 지원) + +**Query Parameters:** +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| level | string | X | BEGINNER, INTERMEDIATE, ADVANCED | +| category | string | X | TECH, BUSINESS, SPORTS, ENTERTAINMENT, WORLD, CULTURE, SCIENCE | +| limit | number | X | 조회 개수 (기본 10, 최대 50) | +| cursor | string | X | 페이지네이션 커서 | + +**Response:** +```json +{ + "statusCode": 200, + "message": "뉴스 목록 조회 성공", + "data": { + "articles": [ + { + "articleId": "1eb9b924", + "title": "EU suspends approval of US trade deal", + "summary": "The move follows renewed tensions...", + "source": "BBC", + "imageUrl": "https://ichef.bbci.co.uk/...", + "category": "WORLD", + "level": "INTERMEDIATE", + "cefrLevel": "B1", + "publishedAt": "2026-01-21T23:41:10Z", + "readCount": 150, + "keywords": [ + { "word": "suspend", "meaning": "중단하다", "level": "INTERMEDIATE" }, + { "word": "tension", "meaning": "긴장", "level": "BEGINNER" } + ] + } + ], + "nextCursor": "eyJQSyI6Ik5FV1MjMjAyNi0wMS0yMiIsIlNLIjoiQVJUSUNMRSMxZWI5YjkyNCJ9", + "hasMore": true, + "count": 10 + } +} +``` + +--- + +#### GET /news/today +오늘의 뉴스 조회 + +**Query Parameters:** limit, cursor + +--- + +#### GET /news/recommended +사용자 레벨 맞춤 뉴스 추천 + +**Query Parameters:** limit, cursor + +--- + +### 2. 뉴스 상세 조회 + +#### GET /news/{articleId} +기사 상세 정보 조회 + +**Response:** +```json +{ + "statusCode": 200, + "message": "뉴스 조회 성공", + "data": { + "articleId": "1eb9b924", + "title": "EU suspends approval of US trade deal", + "summary": "The move follows renewed tensions between the US and EU...", + "originalUrl": "https://www.bbc.com/news/articles/...", + "source": "BBC", + "imageUrl": "https://ichef.bbci.co.uk/...", + "category": "WORLD", + "level": "INTERMEDIATE", + "cefrLevel": "B1", + "keywords": [ + { + "word": "suspend", + "meaning": "중단하다", + "level": "INTERMEDIATE", + "position": 1 + } + ], + "highlightWords": ["diplomatic", "negotiate", "tariff"], + "quiz": [ + { + "questionId": "q1", + "type": "COMPREHENSION", + "question": "What is the main reason for EU's decision?", + "options": ["Trade tensions", "Climate change", "Immigration", "Technology"], + "points": 20 + } + ], + "publishedAt": "2026-01-21T23:41:10Z", + "readCount": 150 + } +} +``` + +--- + +### 3. 학습 기록 + +#### POST /news/{articleId}/read +읽기 완료 기록 + +**Response:** +```json +{ + "statusCode": 200, + "message": "읽기 완료 기록 성공", + "data": { + "articleId": "1eb9b924", + "newBadges": [ + { + "type": "NEWS_FIRST_READ", + "name": "뉴스 첫 발걸음", + "description": "첫 번째 뉴스 읽기 완료", + "imageUrl": "https://..." + } + ] + } +} +``` + +--- + +#### POST /news/{articleId}/bookmark +북마크 토글 + +**Response:** +```json +{ + "statusCode": 200, + "message": "북마크 추가 성공", + "data": { + "articleId": "1eb9b924", + "bookmarked": true + } +} +``` + +--- + +#### GET /news/bookmarks +북마크 목록 조회 + +**Query Parameters:** limit + +**Response:** +```json +{ + "statusCode": 200, + "message": "북마크 목록 조회 성공", + "data": { + "bookmarks": [ + { + "articleId": "1eb9b924", + "articleTitle": "EU suspends approval...", + "articleLevel": "INTERMEDIATE", + "articleCategory": "WORLD", + "createdAt": "2026-01-22T10:30:00Z" + } + ], + "count": 5 + } +} +``` + +--- + +### 4. TTS 오디오 + +#### GET /news/{articleId}/audio +기사 TTS 오디오 URL 조회 + +**Query Parameters:** +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| voice | string | X | Polly 음성 (기본: Joanna) | + +**사용 가능한 음성:** +- 미국: Joanna (여), Matthew (남), Ivy (아동) +- 영국: Amy (여), Brian (남) + +**Response:** +```json +{ + "statusCode": 200, + "message": "TTS 오디오 URL 조회 성공", + "data": { + "audioUrl": "https://s3.ap-northeast-2.amazonaws.com/..." + } +} +``` + +--- + +### 5. 퀴즈 + +#### GET /news/{articleId}/quiz +퀴즈 문제 조회 + +**Response:** +```json +{ + "statusCode": 200, + "message": "퀴즈 조회 성공", + "data": { + "articleId": "1eb9b924", + "articleTitle": "EU suspends approval...", + "questions": [ + { + "questionId": "q1", + "type": "COMPREHENSION", + "question": "What is the main reason for EU's decision?", + "options": ["Trade tensions", "Climate change", "Immigration", "Technology"], + "points": 20 + }, + { + "questionId": "q2", + "type": "WORD_MATCH", + "question": "Select the correct meaning of 'suspend'", + "options": ["시작하다", "중단하다", "계속하다", "완료하다"], + "points": 20 + }, + { + "questionId": "q3", + "type": "FILL_BLANK", + "question": "The EU _____ the approval of the trade deal.", + "options": ["suspended", "continued", "started", "finished"], + "points": 20 + } + ], + "totalPoints": 100, + "previousAttempt": null + } +} +``` + +--- + +#### POST /news/{articleId}/quiz +퀴즈 제출 + +**Request Body:** +```json +{ + "answers": [ + { "questionId": "q1", "answer": "Trade tensions" }, + { "questionId": "q2", "answer": "중단하다" }, + { "questionId": "q3", "answer": "suspended" } + ], + "timeTaken": 120 +} +``` + +**Response:** +```json +{ + "statusCode": 200, + "message": "퀴즈 제출 성공", + "data": { + "score": 80, + "totalPoints": 100, + "earnedPoints": 80, + "results": [ + { "questionId": "q1", "correct": true, "correctAnswer": "Trade tensions" }, + { "questionId": "q2", "correct": true, "correctAnswer": "중단하다" }, + { "questionId": "q3", "correct": false, "correctAnswer": "suspended", "userAnswer": "continued" } + ], + "newBadges": [ + { + "type": "NEWS_QUIZ_FIRST", + "name": "퀴즈 도전자", + "description": "첫 뉴스 퀴즈 완료" + } + ] + } +} +``` + +--- + +#### GET /news/quiz/history +퀴즈 기록 조회 + +**Response:** +```json +{ + "statusCode": 200, + "message": "퀴즈 기록 조회 성공", + "data": { + "history": [ + { + "articleId": "1eb9b924", + "articleTitle": "EU suspends approval...", + "score": 80, + "totalPoints": 100, + "submittedAt": "2026-01-22T11:00:00Z" + } + ], + "stats": { + "totalQuizzes": 15, + "averageScore": 78, + "perfectScores": 3 + }, + "count": 10 + } +} +``` + +--- + +### 6. 단어 수집 + +#### POST /news/{articleId}/words +단어 수집 + +**Request Body:** +```json +{ + "word": "suspend", + "context": "The EU suspended the approval of the trade deal." +} +``` + +**Response:** +```json +{ + "statusCode": 200, + "message": "단어 수집 성공", + "data": { + "wordCollect": { + "word": "suspend", + "meaning": "중단하다", + "pronunciation": "/səˈspend/", + "context": "The EU suspended the approval of the trade deal.", + "articleId": "1eb9b924", + "articleTitle": "EU suspends approval...", + "collectedAt": "2026-01-22T11:30:00Z" + }, + "newBadges": [] + } +} +``` + +--- + +#### GET /news/words +수집 단어 목록 조회 + +**Response:** +```json +{ + "statusCode": 200, + "message": "수집 단어 목록 조회 성공", + "data": { + "words": [ + { + "word": "suspend", + "meaning": "중단하다", + "pronunciation": "/səˈspend/", + "context": "The EU suspended...", + "articleTitle": "EU suspends...", + "collectedAt": "2026-01-22T11:30:00Z", + "syncedToVocab": false + } + ], + "stats": { + "totalWords": 25, + "syncedToVocab": 10 + }, + "count": 25 + } +} +``` + +--- + +#### DELETE /news/{articleId}/words/{word} +수집 단어 삭제 + +--- + +#### POST /news/words/{word}/sync +Vocabulary 연동 + +**Request Body:** +```json +{ + "articleId": "1eb9b924" +} +``` + +**Response:** +```json +{ + "statusCode": 200, + "message": "Vocabulary 연동 성공", + "data": { + "word": "suspend", + "synced": true + } +} +``` + +--- + +### 7. 학습 통계 + +#### GET /news/stats +학습 통계 조회 + +**Response:** +```json +{ + "statusCode": 200, + "message": "뉴스 학습 통계 조회 성공", + "data": { + "totalRead": 45, + "todayRead": 3, + "totalQuizzes": 30, + "averageQuizScore": 78, + "perfectQuizzes": 5, + "totalWordsCollected": 125, + "currentStreak": 7, + "longestStreak": 14, + "bookmarkCount": 12, + "lastReadDate": "2026-01-22" + } +} +``` + +--- + +## 데이터 모델 + +### NewsArticle (뉴스 기사) +| 필드 | 타입 | 설명 | +|-----|------|------| +| articleId | string | 기사 고유 ID | +| title | string | 제목 | +| summary | string | AI 생성 3줄 요약 | +| originalUrl | string | 원문 링크 | +| source | string | 출처 (BBC, VOA 등) | +| imageUrl | string | 썸네일 이미지 | +| category | string | 카테고리 | +| level | string | 난이도 | +| cefrLevel | string | CEFR 레벨 (A1-C2) | +| keywords | KeywordInfo[] | 핵심 단어 | +| highlightWords | string[] | 강조 단어 | +| quiz | QuizQuestion[] | 퀴즈 문제 | +| publishedAt | string | 발행일 | +| readCount | number | 조회수 | + +### KeywordInfo (키워드 정보) +| 필드 | 타입 | 설명 | +|-----|------|------| +| word | string | 영어 단어 | +| meaning | string | 한국어 뜻 | +| level | string | 난이도 | +| position | number | 기사 내 위치 | + +### QuizQuestion (퀴즈 문제) +| 필드 | 타입 | 설명 | +|-----|------|------| +| questionId | string | 문제 ID | +| type | string | COMPREHENSION, WORD_MATCH, FILL_BLANK | +| question | string | 문제 내용 | +| options | string[] | 선택지 | +| points | number | 배점 | + +### NewsWordCollect (수집 단어) +| 필드 | 타입 | 설명 | +|-----|------|------| +| word | string | 단어 | +| meaning | string | 뜻 | +| pronunciation | string | 발음 기호 | +| context | string | 문맥 문장 | +| articleId | string | 출처 기사 ID | +| articleTitle | string | 출처 기사 제목 | +| syncedToVocab | boolean | Vocabulary 연동 여부 | + +--- + +## UI/UX 가이드 + +### 1. 색상 팔레트 제안 + +#### 난이도별 색상 +```css +/* BEGINNER - 녹색 계열 (쉬움) */ +--level-beginner: #10B981; +--level-beginner-bg: #D1FAE5; + +/* INTERMEDIATE - 파란 계열 (보통) */ +--level-intermediate: #3B82F6; +--level-intermediate-bg: #DBEAFE; + +/* ADVANCED - 보라 계열 (어려움) */ +--level-advanced: #8B5CF6; +--level-advanced-bg: #EDE9FE; +``` + +#### 카테고리별 색상 +```css +--category-tech: #6366F1; /* 기술 */ +--category-business: #F59E0B; /* 비즈니스 */ +--category-sports: #EF4444; /* 스포츠 */ +--category-entertainment: #EC4899; /* 엔터테인먼트 */ +--category-world: #14B8A6; /* 세계 */ +--category-culture: #F97316; /* 문화 */ +--category-science: #06B6D4; /* 과학 */ +``` + +### 2. 아이콘 가이드 + +| 기능 | 추천 아이콘 | +|-----|-----------| +| 뉴스 | newspaper, article | +| 읽기 완료 | check-circle, book-open | +| 북마크 | bookmark, heart | +| 오디오 | volume-2, headphones | +| 퀴즈 | help-circle, clipboard-check | +| 단어 수집 | plus-circle, collection | +| 통계 | bar-chart, trending-up | +| 뱃지 | award, medal | + +### 3. 애니메이션 제안 + +```css +/* 뱃지 획득 애니메이션 */ +@keyframes badge-unlock { + 0% { transform: scale(0) rotate(-180deg); opacity: 0; } + 50% { transform: scale(1.2) rotate(10deg); } + 100% { transform: scale(1) rotate(0); opacity: 1; } +} + +/* 단어 수집 애니메이션 */ +@keyframes word-collect { + 0% { transform: translateY(0); } + 50% { transform: translateY(-10px); } + 100% { transform: translateY(0); } +} + +/* 퀴즈 정답 피드백 */ +@keyframes correct-answer { + 0%, 100% { background-color: transparent; } + 50% { background-color: rgba(16, 185, 129, 0.2); } +} +``` + +--- + +## 화면 구성 제안 + +### 1. 뉴스 목록 화면 (NewsListPage) + +``` +┌─────────────────────────────────────┐ +│ [필터] 레벨 ▼ 카테고리 ▼ [검색] │ +├─────────────────────────────────────┤ +│ ┌─────────────────────────────┐ │ +│ │ [이미지] │ │ +│ │ ─────────────────────────── │ │ +│ │ [TECH] [INTERMEDIATE] │ │ +│ │ EU suspends approval of... │ │ +│ │ BBC • 2시간 전 • 👁 150 │ │ +│ │ [📖 읽기] [🔖 저장] │ │ +│ └─────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────┐ │ +│ │ [다음 카드...] │ │ +│ └─────────────────────────────┘ │ +│ │ +│ [더 보기...] │ +└─────────────────────────────────────┘ +``` + +**구현 포인트:** +- 무한 스크롤 또는 "더 보기" 버튼 +- 카드 형태로 이미지, 제목, 메타정보 표시 +- 난이도 뱃지 색상으로 직관적 구분 +- 읽은 기사는 시각적으로 구분 (예: 투명도) + +--- + +### 2. 뉴스 상세 화면 (NewsDetailPage) + +``` +┌─────────────────────────────────────┐ +│ [← 뒤로] [🔖] [🔊] │ +├─────────────────────────────────────┤ +│ │ +│ [WORLD] [B1 - INTERMEDIATE] │ +│ │ +│ EU suspends approval of │ +│ US trade deal │ +│ │ +│ BBC • 2026.01.21 │ +│ │ +│ ┌─────────────────────────────┐ │ +│ │ [기사 이미지] │ │ +│ └─────────────────────────────┘ │ +│ │ +│ 📝 요약 │ +│ The move follows renewed tensions │ +│ between the US and EU, as Donald │ +│ Trump pushes to acquire Greenland. │ +│ │ +│ ───────────────────────────────── │ +│ │ +│ 📚 핵심 단어 │ +│ ┌───────┬───────┬───────┐ │ +│ │suspend│tension│acquire│ │ +│ │중단하다│ 긴장 │획득하다│ │ +│ │ [+] │ [+] │ [+] │ │ +│ └───────┴───────┴───────┘ │ +│ │ +│ ───────────────────────────────── │ +│ │ +│ [📝 퀴즈 풀기] [📰 원문 보기] │ +│ │ +│ [✓ 읽기 완료] │ +│ │ +└─────────────────────────────────────┘ +``` + +**구현 포인트:** +- 상단에 북마크, 오디오 버튼 고정 +- 핵심 단어는 탭으로 수집 가능 +- 어려운 단어(highlightWords)는 형광펜 효과 +- 하단에 퀴즈, 원문 링크 버튼 + +--- + +### 3. 퀴즈 화면 (QuizPage) + +``` +┌─────────────────────────────────────┐ +│ [← 종료] 1/5 ⏱ 01:30 │ +├─────────────────────────────────────┤ +│ │ +│ ████████░░░░░░░░░░░░ 20/100점 │ +│ │ +│ ───────────────────────────────── │ +│ │ +│ Q1. 독해력 문제 │ +│ │ +│ What is the main reason for │ +│ EU's decision? │ +│ │ +│ ┌─────────────────────────────┐ │ +│ │ A. Trade tensions │ │ +│ └─────────────────────────────┘ │ +│ ┌─────────────────────────────┐ │ +│ │ B. Climate change │ │ +│ └─────────────────────────────┘ │ +│ ┌─────────────────────────────┐ │ +│ │ C. Immigration │ │ +│ └─────────────────────────────┘ │ +│ ┌─────────────────────────────┐ │ +│ │ D. Technology │ │ +│ └─────────────────────────────┘ │ +│ │ +│ [다음 →] │ +│ │ +└─────────────────────────────────────┘ +``` + +**구현 포인트:** +- 상단 진행률 표시 (문제 번호, 타이머) +- 점수 프로그레스바 +- 선택지 터치 영역 충분히 크게 +- 정답/오답 즉시 피드백 (색상 변경) + +--- + +### 4. 퀴즈 결과 화면 (QuizResultPage) + +``` +┌─────────────────────────────────────┐ +│ │ +│ 🎉 퀴즈 완료! │ +│ │ +│ ┌───────────────┐ │ +│ │ │ │ +│ │ 80 │ │ +│ │ /100 │ │ +│ │ │ │ +│ └───────────────┘ │ +│ │ +│ 정답 4개 / 오답 1개 │ +│ 소요시간: 2분 30초 │ +│ │ +│ ───────────────────────────────── │ +│ │ +│ 🏆 새로운 뱃지 획득! │ +│ ┌─────────────────────────────┐ │ +│ │ [뱃지 이미지] │ │ +│ │ 퀴즈 도전자 │ │ +│ │ 첫 뉴스 퀴즈 완료! │ │ +│ └─────────────────────────────┘ │ +│ │ +│ ───────────────────────────────── │ +│ │ +│ 📋 문제별 결과 │ +│ Q1. ✅ Trade tensions │ +│ Q2. ✅ 중단하다 │ +│ Q3. ❌ continued → suspended │ +│ ... │ +│ │ +│ [다시 풀기] [목록으로] │ +│ │ +└─────────────────────────────────────┘ +``` + +--- + +### 5. 단어장 화면 (WordCollectionPage) + +``` +┌─────────────────────────────────────┐ +│ 수집한 단어 25개 │ +├─────────────────────────────────────┤ +│ [전체] [미연동] [연동완료] │ +├─────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────┐ │ +│ │ suspend /səˈspend/ │ │ +│ │ 중단하다 │ │ +│ │ ─────────────────────────── │ │ +│ │ "The EU suspended the..." │ │ +│ │ 📰 EU suspends approval... │ │ +│ │ ─────────────────────────── │ │ +│ │ [🔗 Vocab 연동] [🗑 삭제] │ │ +│ └─────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────┐ │ +│ │ tension /ˈtenʃən/ │ │ +│ │ 긴장 │ │ +│ │ ✅ Vocabulary 연동됨 │ │ +│ └─────────────────────────────┘ │ +│ │ +└─────────────────────────────────────┘ +``` + +--- + +### 6. 학습 통계 화면 (StatsPage) + +``` +┌─────────────────────────────────────┐ +│ 📊 나의 뉴스 학습 │ +├─────────────────────────────────────┤ +│ │ +│ 🔥 7일 연속 학습 중! │ +│ │ +│ ┌─────────┬─────────┬─────────┐ │ +│ │ 읽기 │ 퀴즈 │ 단어 │ │ +│ │ 45 │ 30 │ 125 │ │ +│ │ 개 │ 회 │ 개 │ │ +│ └─────────┴─────────┴─────────┘ │ +│ │ +│ ───────────────────────────────── │ +│ │ +│ 📈 이번 주 활동 │ +│ ┌─────────────────────────────┐ │ +│ │ [주간 차트 - 막대그래프] │ │ +│ │ 월 화 수 목 금 토 일 │ │ +│ └─────────────────────────────┘ │ +│ │ +│ ───────────────────────────────── │ +│ │ +│ 🏆 획득한 뱃지 (5/14) │ +│ [🥇] [🥇] [🥇] [🥇] [🥇] │ +│ [🔒] [🔒] [🔒] [🔒] [🔒] │ +│ ... │ +│ │ +└─────────────────────────────────────┘ +``` + +--- + +## 에러 코드 + +| 코드 | 메시지 | 설명 | +|-----|--------|------| +| NEWS_001 | 기사를 찾을 수 없습니다 | articleId가 유효하지 않음 | +| NEWS_002 | 인증이 필요합니다 | JWT 토큰 없음/만료 | +| NEWS_003 | 퀴즈를 찾을 수 없습니다 | 해당 기사에 퀴즈 없음 | +| NEWS_004 | 이미 퀴즈를 제출했습니다 | 중복 제출 시도 | +| NEWS_005 | 이미 수집한 단어입니다 | 중복 수집 시도 | +| NEWS_006 | 수집하지 않은 단어입니다 | 존재하지 않는 단어 조회 | + +--- + +## 추가 구현 고려사항 + +### 1. 오프라인 지원 +- 읽은 기사 로컬 캐싱 +- 수집 단어 오프라인 저장 후 동기화 + +### 2. 푸시 알림 +- 새 뉴스 알림 +- 학습 리마인더 +- 스트릭 유지 알림 + +### 3. 공유 기능 +- 기사 공유 +- 퀴즈 점수 공유 +- 뱃지 획득 공유 + +### 4. 접근성 +- 스크린 리더 지원 +- 폰트 크기 조절 +- 고대비 모드 + +--- + +## 문의 + +백엔드 관련 문의는 이슈로 등록해주세요. From 0b1fb4ca1001206db21c6310527160830eeabb7a Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 10:04:06 +0900 Subject: [PATCH 79/99] =?UTF-8?q?fix:=20NewsCollectionFunction=EC=97=90=20?= =?UTF-8?q?Bedrock,=20Comprehend=20=EA=B6=8C=ED=95=9C=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 | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 7211a2c2..16f1e3dd 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1784,6 +1784,16 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref NewsTable + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + Resource: "*" + - Statement: + - Effect: Allow + Action: + - comprehend:DetectKeyPhrases + Resource: "*" Events: DailySchedule: Type: Schedule From ee6bab9be62ff3446c1d241d7d4615661c40e762 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 10:21:35 +0900 Subject: [PATCH 80/99] fix: add null check for collectWord request body - Add INVALID_REQUEST error code to NewsErrorCode - Check body and word field before accessing in collectWord() - Prevents NullPointerException when request body is malformed --- .../serverless/domain/news/exception/NewsErrorCode.java | 3 +++ .../serverless/domain/news/handler/NewsHandler.java | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java index 58197f0e..cb253701 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java @@ -8,6 +8,9 @@ */ public enum NewsErrorCode implements DomainErrorCode { + // 일반 에러 + INVALID_REQUEST("COMMON_001", "유효하지 않은 요청입니다", 400), + // 인증 관련 에러 UNAUTHORIZED("AUTH_001", "인증이 필요합니다", 401), diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java index a427051d..999bb786 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -413,8 +413,12 @@ private APIGatewayProxyResponseEvent collectWord(APIGatewayProxyRequestEvent req String articleId = request.getPathParameters().get("articleId"); JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); + if (body == null || !body.has("word") || body.get("word").isJsonNull()) { + return ResponseGenerator.fail(NewsErrorCode.INVALID_REQUEST); + } String word = body.get("word").getAsString(); - String context = body.has("context") ? body.get("context").getAsString() : ""; + String context = body.has("context") && !body.get("context").isJsonNull() + ? body.get("context").getAsString() : ""; NewsWordService.WordCollectResult result = wordService.collectWord(userId, articleId, word, context); From 565cdcd83fb1c8f021ea367de7b0bf2477e8b19a Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 10:45:34 +0900 Subject: [PATCH 81/99] feat: enhance stats API and bookmark response for frontend - Add DAILY stats update for news read/quiz/word collection - Add /stats/dashboard endpoint with frontend-requested format - today: wordsLearned, newsRead, quizzesTaken, wordsTotal - overall: totalWordsLearned, totalNewsRead, averageAccuracy, streaks - weeklyProgress: last 7 days with date/wordsLearned/newsRead - Add news-related fields to all stats API responses - Fix bookmark API to include full article details (title, summary, etc.) --- .../domain/news/handler/NewsHandler.java | 2 +- .../news/service/NewsLearningService.java | 28 +++- .../stats/handler/UserStatsHandler.java | 98 ++++++++++- .../stats/repository/UserStatsRepository.java | 154 ++++++++++++++---- 4 files changed, 239 insertions(+), 43 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java index 999bb786..86a30590 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -231,7 +231,7 @@ private APIGatewayProxyResponseEvent getBookmarks(APIGatewayProxyRequestEvent re if (params == null) params = new HashMap<>(); int limit = parseLimit(params.get("limit")); - List bookmarks = learningService.getUserBookmarks(userId, limit); + List> bookmarks = learningService.getUserBookmarks(userId, limit); Map response = new HashMap<>(); response.put("bookmarks", bookmarks); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java index 3e13f2c3..c46e4fc6 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java @@ -136,10 +136,32 @@ public boolean isBookmarked(String userId, String articleId) { } /** - * 사용자 북마크 목록 조회 + * 사용자 북마크 목록 조회 (기사 정보 포함) */ - public List getUserBookmarks(String userId, int limit) { - return userNewsRepository.getUserBookmarks(userId, limit); + public List> getUserBookmarks(String userId, int limit) { + List bookmarks = userNewsRepository.getUserBookmarks(userId, limit); + List> result = new ArrayList<>(); + + for (UserNewsRecord bookmark : bookmarks) { + Optional articleOpt = articleRepository.findById(bookmark.getArticleId()); + if (articleOpt.isPresent()) { + NewsArticle article = articleOpt.get(); + Map bookmarkWithArticle = new java.util.HashMap<>(); + bookmarkWithArticle.put("articleId", article.getArticleId()); + bookmarkWithArticle.put("title", article.getTitle()); + bookmarkWithArticle.put("summary", article.getSummary()); + bookmarkWithArticle.put("source", article.getSource()); + bookmarkWithArticle.put("publishedAt", article.getPublishedAt()); + bookmarkWithArticle.put("keywords", article.getKeywords()); + bookmarkWithArticle.put("highlightWords", article.getHighlightWords()); + bookmarkWithArticle.put("category", article.getCategory()); + bookmarkWithArticle.put("level", article.getLevel()); + bookmarkWithArticle.put("imageUrl", article.getImageUrl()); + bookmarkWithArticle.put("bookmarkedAt", bookmark.getCreatedAt()); + result.add(bookmarkWithArticle); + } + } + return result; } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java index 637151be..5a2ba9c0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java @@ -49,6 +49,7 @@ public UserStatsHandler(UserStatsRepository statsRepository, DailyStudyRepositor private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( + Route.getAuth("/stats/dashboard", this::getDashboardStats), Route.getAuth("/stats/daily", this::getDailyStats), Route.getAuth("/stats/weekly", this::getWeeklyStats), Route.getAuth("/stats/monthly", this::getMonthlyStats), @@ -62,7 +63,88 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); return router.route(request); } - + + /** + * 대시보드용 통합 통계 조회 (프론트엔드 요청 형식) + * GET /stats/dashboard + */ + private APIGatewayProxyResponseEvent getDashboardStats(APIGatewayProxyRequestEvent request, String userId) { + String today = LocalDate.now().toString(); + + // 오늘 통계 조회 + Optional dailyStats = statsRepository.findDailyStats(userId, today); + // 전체 통계 조회 + Optional totalStats = statsRepository.findTotalStats(userId); + // 최근 7일 히스토리 조회 + PaginatedResult weekHistory = statsRepository.findRecentDailyStats(userId, 7, null); + // 오늘 학습 목표 조회 + Optional dailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); + + Map response = new HashMap<>(); + + // today 섹션 + Map todaySection = new HashMap<>(); + if (dailyStats.isPresent()) { + UserStats ds = dailyStats.get(); + todaySection.put("wordsLearned", ds.getNewWordsLearned() != null ? ds.getNewWordsLearned() : 0); + todaySection.put("newsRead", ds.getNewsRead() != null ? ds.getNewsRead() : 0); + todaySection.put("quizzesTaken", (ds.getTestsCompleted() != null ? ds.getTestsCompleted() : 0) + + (ds.getNewsQuizCompleted() != null ? ds.getNewsQuizCompleted() : 0)); + } else { + todaySection.put("wordsLearned", 0); + todaySection.put("newsRead", 0); + todaySection.put("quizzesTaken", 0); + } + todaySection.put("wordsTotal", dailyStudy.map(ds -> ds.getTotalWords() != null ? ds.getTotalWords() : 25).orElse(25)); + response.put("today", todaySection); + + // overall 섹션 + Map overallSection = new HashMap<>(); + if (totalStats.isPresent()) { + UserStats ts = totalStats.get(); + overallSection.put("totalWordsLearned", ts.getNewWordsLearned() != null ? ts.getNewWordsLearned() : 0); + overallSection.put("totalNewsRead", ts.getNewsRead() != null ? ts.getNewsRead() : 0); + overallSection.put("totalQuizzes", (ts.getTestsCompleted() != null ? ts.getTestsCompleted() : 0) + + (ts.getNewsQuizCompleted() != null ? ts.getNewsQuizCompleted() : 0)); + overallSection.put("averageAccuracy", calculateSuccessRate(ts)); + overallSection.put("currentStreak", ts.getCurrentStreak() != null ? ts.getCurrentStreak() : 0); + overallSection.put("longestStreak", ts.getLongestStreak() != null ? ts.getLongestStreak() : 0); + overallSection.put("lastStudyDate", ts.getLastStudyDate()); + } else { + overallSection.put("totalWordsLearned", 0); + overallSection.put("totalNewsRead", 0); + overallSection.put("totalQuizzes", 0); + overallSection.put("averageAccuracy", 0.0); + overallSection.put("currentStreak", 0); + overallSection.put("longestStreak", 0); + overallSection.put("lastStudyDate", null); + } + // totalStudyDays 계산 (최근 히스토리에서 실제 학습한 날 수) + overallSection.put("totalStudyDays", weekHistory.items().size()); + response.put("overall", overallSection); + + // weeklyProgress 섹션 + List> weeklyProgress = weekHistory.items().stream() + .map(stats -> { + Map dayStats = new HashMap<>(); + dayStats.put("date", stats.getPeriod()); + dayStats.put("wordsLearned", stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0); + dayStats.put("newsRead", stats.getNewsRead() != null ? stats.getNewsRead() : 0); + return dayStats; + }) + .collect(Collectors.toList()); + response.put("weeklyProgress", weeklyProgress); + + // levelDistribution (현재 미구현 - 향후 추가 가능) + Map levelDistribution = new HashMap<>(); + levelDistribution.put("beginner", 0); + levelDistribution.put("intermediate", 0); + levelDistribution.put("advanced", 0); + response.put("levelDistribution", levelDistribution); + + return ResponseGenerator.ok("학습 통계 조회 성공", response); + } + /** * 오늘의 통계 조회 */ @@ -172,7 +254,7 @@ private Map buildStatsResponse(Optional stats, String Map response = new HashMap<>(); response.put("periodType", periodType); response.put("period", period); - + if (stats.isPresent()) { UserStats s = stats.get(); response.put("testsCompleted", s.getTestsCompleted() != null ? s.getTestsCompleted() : 0); @@ -182,6 +264,11 @@ private Map buildStatsResponse(Optional stats, String response.put("successRate", calculateSuccessRate(s)); response.put("newWordsLearned", s.getNewWordsLearned() != null ? s.getNewWordsLearned() : 0); response.put("wordsReviewed", s.getWordsReviewed() != null ? s.getWordsReviewed() : 0); + // 뉴스 관련 통계 + response.put("newsRead", s.getNewsRead() != null ? s.getNewsRead() : 0); + response.put("newsQuizCompleted", s.getNewsQuizCompleted() != null ? s.getNewsQuizCompleted() : 0); + response.put("newsQuizPerfect", s.getNewsQuizPerfect() != null ? s.getNewsQuizPerfect() : 0); + response.put("newsWordsCollected", s.getNewsWordsCollected() != null ? s.getNewsWordsCollected() : 0); } else { response.put("testsCompleted", 0); response.put("questionsAnswered", 0); @@ -190,8 +277,13 @@ private Map buildStatsResponse(Optional stats, String response.put("successRate", 0.0); response.put("newWordsLearned", 0); response.put("wordsReviewed", 0); + // 뉴스 관련 통계 + response.put("newsRead", 0); + response.put("newsQuizCompleted", 0); + response.put("newsQuizPerfect", 0); + response.put("newsWordsCollected", 0); } - + return response; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java index 2c49d4c7..4ab46228 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java @@ -311,12 +311,11 @@ public void incrementGameStats(String userId, int gamesPlayed, int gamesWon, } /** - * 뉴스 읽기 통계 Atomic 업데이트 + * 뉴스 읽기 통계 Atomic 업데이트 (TOTAL + DAILY) */ public UserStats incrementNewsReadStats(String userId) { String today = LocalDate.now().toString(); String pk = StatsKey.userStatsPk(userId); - String sk = StatsKey.statsTotalSk(); String now = Instant.now().toString(); // 먼저 현재 통계 조회 (streak 계산용) @@ -338,10 +337,6 @@ public UserStats incrementNewsReadStats(String userId) { // 그 외의 경우는 streak 1로 초기화 } - Map key = new HashMap<>(); - key.put("PK", AttributeValue.builder().s(pk).build()); - key.put("SK", AttributeValue.builder().s(sk).build()); - Map values = new HashMap<>(); values.put(":one", AttributeValue.builder().n("1").build()); values.put(":zero", AttributeValue.builder().n("0").build()); @@ -349,97 +344,184 @@ public UserStats incrementNewsReadStats(String userId) { values.put(":today", AttributeValue.builder().s(today).build()); values.put(":now", AttributeValue.builder().s(now).build()); - String updateExpression = "SET " + + // 1. TOTAL 통계 업데이트 + Map totalKey = new HashMap<>(); + totalKey.put("PK", AttributeValue.builder().s(pk).build()); + totalKey.put("SK", AttributeValue.builder().s(StatsKey.statsTotalSk()).build()); + + String totalUpdateExpression = "SET " + "newsRead = if_not_exists(newsRead, :zero) + :one, " + "newsStreak = :streak, " + "lastNewsReadDate = :today, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now)"; - UpdateItemRequest request = UpdateItemRequest.builder() + UpdateItemRequest totalRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) - .key(key) - .updateExpression(updateExpression) + .key(totalKey) + .updateExpression(totalUpdateExpression) .expressionAttributeValues(values) .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) .build(); - AwsClients.dynamoDb().updateItem(request); - logger.info("Incremented news read stats: userId={}, streak={}", userId, currentStreak); + AwsClients.dynamoDb().updateItem(totalRequest); + + // 2. DAILY 통계 업데이트 + Map dailyKey = new HashMap<>(); + dailyKey.put("PK", AttributeValue.builder().s(pk).build()); + dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); + + Map dailyValues = new HashMap<>(); + dailyValues.put(":one", AttributeValue.builder().n("1").build()); + dailyValues.put(":zero", AttributeValue.builder().n("0").build()); + dailyValues.put(":now", AttributeValue.builder().s(now).build()); + dailyValues.put(":today", AttributeValue.builder().s(today).build()); + + String dailyUpdateExpression = "SET " + + "newsRead = if_not_exists(newsRead, :zero) + :one, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now), " + + "period = if_not_exists(period, :today)"; + + UpdateItemRequest dailyRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(dailyKey) + .updateExpression(dailyUpdateExpression) + .expressionAttributeValues(dailyValues) + .build(); + + AwsClients.dynamoDb().updateItem(dailyRequest); + logger.info("Incremented news read stats (TOTAL + DAILY): userId={}, streak={}", userId, currentStreak); return findTotalStats(userId).orElse(null); } /** - * 뉴스 퀴즈 통계 Atomic 업데이트 + * 뉴스 퀴즈 통계 Atomic 업데이트 (TOTAL + DAILY) */ public UserStats incrementNewsQuizStats(String userId, boolean isPerfect) { + String today = LocalDate.now().toString(); String pk = StatsKey.userStatsPk(userId); - String sk = StatsKey.statsTotalSk(); String now = Instant.now().toString(); - Map key = new HashMap<>(); - key.put("PK", AttributeValue.builder().s(pk).build()); - key.put("SK", AttributeValue.builder().s(sk).build()); - Map values = new HashMap<>(); values.put(":one", AttributeValue.builder().n("1").build()); values.put(":zero", AttributeValue.builder().n("0").build()); values.put(":perfect", AttributeValue.builder().n(isPerfect ? "1" : "0").build()); values.put(":now", AttributeValue.builder().s(now).build()); - String updateExpression = "SET " + + // 1. TOTAL 통계 업데이트 + Map totalKey = new HashMap<>(); + totalKey.put("PK", AttributeValue.builder().s(pk).build()); + totalKey.put("SK", AttributeValue.builder().s(StatsKey.statsTotalSk()).build()); + + String totalUpdateExpression = "SET " + "newsQuizCompleted = if_not_exists(newsQuizCompleted, :zero) + :one, " + "newsQuizPerfect = if_not_exists(newsQuizPerfect, :zero) + :perfect, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now)"; - UpdateItemRequest request = UpdateItemRequest.builder() + UpdateItemRequest totalRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) - .key(key) - .updateExpression(updateExpression) + .key(totalKey) + .updateExpression(totalUpdateExpression) .expressionAttributeValues(values) .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) .build(); - AwsClients.dynamoDb().updateItem(request); - logger.info("Incremented news quiz stats: userId={}, isPerfect={}", userId, isPerfect); + AwsClients.dynamoDb().updateItem(totalRequest); + + // 2. DAILY 통계 업데이트 + Map dailyKey = new HashMap<>(); + dailyKey.put("PK", AttributeValue.builder().s(pk).build()); + dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); + + Map dailyValues = new HashMap<>(); + dailyValues.put(":one", AttributeValue.builder().n("1").build()); + dailyValues.put(":zero", AttributeValue.builder().n("0").build()); + dailyValues.put(":perfect", AttributeValue.builder().n(isPerfect ? "1" : "0").build()); + dailyValues.put(":now", AttributeValue.builder().s(now).build()); + dailyValues.put(":today", AttributeValue.builder().s(today).build()); + + String dailyUpdateExpression = "SET " + + "newsQuizCompleted = if_not_exists(newsQuizCompleted, :zero) + :one, " + + "newsQuizPerfect = if_not_exists(newsQuizPerfect, :zero) + :perfect, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now), " + + "period = if_not_exists(period, :today)"; + + UpdateItemRequest dailyRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(dailyKey) + .updateExpression(dailyUpdateExpression) + .expressionAttributeValues(dailyValues) + .build(); + + AwsClients.dynamoDb().updateItem(dailyRequest); + logger.info("Incremented news quiz stats (TOTAL + DAILY): userId={}, isPerfect={}", userId, isPerfect); return findTotalStats(userId).orElse(null); } /** - * 뉴스 단어 수집 통계 Atomic 업데이트 + * 뉴스 단어 수집 통계 Atomic 업데이트 (TOTAL + DAILY) */ public UserStats incrementNewsWordStats(String userId, int wordCount) { + String today = LocalDate.now().toString(); String pk = StatsKey.userStatsPk(userId); - String sk = StatsKey.statsTotalSk(); String now = Instant.now().toString(); - Map key = new HashMap<>(); - key.put("PK", AttributeValue.builder().s(pk).build()); - key.put("SK", AttributeValue.builder().s(sk).build()); - Map values = new HashMap<>(); values.put(":count", AttributeValue.builder().n(String.valueOf(wordCount)).build()); values.put(":zero", AttributeValue.builder().n("0").build()); values.put(":now", AttributeValue.builder().s(now).build()); - String updateExpression = "SET " + + // 1. TOTAL 통계 업데이트 + Map totalKey = new HashMap<>(); + totalKey.put("PK", AttributeValue.builder().s(pk).build()); + totalKey.put("SK", AttributeValue.builder().s(StatsKey.statsTotalSk()).build()); + + String totalUpdateExpression = "SET " + "newsWordsCollected = if_not_exists(newsWordsCollected, :zero) + :count, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now)"; - UpdateItemRequest request = UpdateItemRequest.builder() + UpdateItemRequest totalRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) - .key(key) - .updateExpression(updateExpression) + .key(totalKey) + .updateExpression(totalUpdateExpression) .expressionAttributeValues(values) .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) .build(); - AwsClients.dynamoDb().updateItem(request); - logger.info("Incremented news word stats: userId={}, wordCount={}", userId, wordCount); + AwsClients.dynamoDb().updateItem(totalRequest); + + // 2. DAILY 통계 업데이트 + Map dailyKey = new HashMap<>(); + dailyKey.put("PK", AttributeValue.builder().s(pk).build()); + dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); + + Map dailyValues = new HashMap<>(); + dailyValues.put(":count", AttributeValue.builder().n(String.valueOf(wordCount)).build()); + dailyValues.put(":zero", AttributeValue.builder().n("0").build()); + dailyValues.put(":now", AttributeValue.builder().s(now).build()); + dailyValues.put(":today", AttributeValue.builder().s(today).build()); + + String dailyUpdateExpression = "SET " + + "newsWordsCollected = if_not_exists(newsWordsCollected, :zero) + :count, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now), " + + "period = if_not_exists(period, :today)"; + + UpdateItemRequest dailyRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(dailyKey) + .updateExpression(dailyUpdateExpression) + .expressionAttributeValues(dailyValues) + .build(); + + AwsClients.dynamoDb().updateItem(dailyRequest); + logger.info("Incremented news word stats (TOTAL + DAILY): userId={}, wordCount={}", userId, wordCount); return findTotalStats(userId).orElse(null); } From 1a8e4f03e022b8f8f299907177b37578a4e367ff Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 10:53:30 +0900 Subject: [PATCH 82/99] feat: add category classification to news AI analysis - Add category field to AnalysisResult record - Update Bedrock prompt to classify articles into categories (WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE) - Parse and set category from AI response - Set GSI2 (CATEGORY#) index when category is available --- .../news/service/NewsAnalysisService.java | 17 +- docs/CATCHMIND_ARCHITECTURE_SOLUTION.md | 521 ------- docs/CICD-IMPLEMENTATION-QNA.md | 421 ------ docs/FRONTEND-API-GUIDE.md | 365 ----- docs/MIDTERM-REPORT.md | 439 ------ docs/domain-reports/BADGE-DOMAIN-REPORT.md | 681 --------- docs/domain-reports/CHATTING-DOMAIN-REPORT.md | 434 ------ docs/domain-reports/COMMON-MODULE-REPORT.md | 1228 ----------------- docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md | 465 ------- docs/domain-reports/STATS-DOMAIN-REPORT.md | 379 ----- .../VOCABULARY-DOMAIN-REPORT.md | 504 ------- docs/news-frontend-guide.md | 916 ------------ 12 files changed, 12 insertions(+), 6358 deletions(-) delete mode 100644 docs/CATCHMIND_ARCHITECTURE_SOLUTION.md delete mode 100644 docs/CICD-IMPLEMENTATION-QNA.md delete mode 100644 docs/FRONTEND-API-GUIDE.md delete mode 100644 docs/MIDTERM-REPORT.md delete mode 100644 docs/domain-reports/BADGE-DOMAIN-REPORT.md delete mode 100644 docs/domain-reports/CHATTING-DOMAIN-REPORT.md delete mode 100644 docs/domain-reports/COMMON-MODULE-REPORT.md delete mode 100644 docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md delete mode 100644 docs/domain-reports/STATS-DOMAIN-REPORT.md delete mode 100644 docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md delete mode 100644 docs/news-frontend-guide.md diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java index af23fc5b..d09070ed 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java @@ -62,13 +62,16 @@ public NewsArticle analyzeArticle(NewsArticle article) { List keywords = extractKeywords(content); article.setKeywords(keywords); - // 3. 3줄 요약 + 퀴즈 생성 (Bedrock - 한 번에 처리) + // 3. 3줄 요약 + 퀴즈 + 카테고리 생성 (Bedrock - 한 번에 처리) AnalysisResult result = generateSummaryAndQuiz(content, cefrLevel); if (result.summary() != null) { article.setSummary(result.summary()); } article.setQuiz(result.quiz()); article.setHighlightWords(result.highlightWords()); + if (result.category() != null) { + article.setCategory(result.category()); + } // 4. GSI 키 설정 article.setGsi1pk("LEVEL#" + article.getLevel()); @@ -173,7 +176,7 @@ private List extractKeywords(String content) { } /** - * 요약 + 퀴즈 생성 (Bedrock) + * 요약 + 퀴즈 + 카테고리 생성 (Bedrock) */ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) { String systemPrompt = """ @@ -183,6 +186,7 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) { "summary": "3-line summary in English (each line separated by newline)", "highlightWords": ["word1", "word2", "word3"], + "category": "WORLD", "quiz": [ { "questionId": "q1", @@ -211,6 +215,7 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) ] } + For category, choose EXACTLY ONE from: WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE Create exactly 3 quiz questions. highlightWords should contain 3-5 difficult words for learners. Adjust difficulty based on CEFR level: """ + cefrLevel; @@ -222,7 +227,7 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) return parseAnalysisResult(response); } catch (Exception e) { logger.error("요약/퀴즈 생성 실패", e); - return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>()); + return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>(), null); } } @@ -268,6 +273,7 @@ private AnalysisResult parseAnalysisResult(String response) { JsonObject json = gson.fromJson(jsonStr, JsonObject.class); String summary = json.has("summary") ? json.get("summary").getAsString() : null; + String category = json.has("category") ? json.get("category").getAsString().toUpperCase() : "WORLD"; List highlightWords = new ArrayList<>(); if (json.has("highlightWords")) { @@ -293,7 +299,7 @@ private AnalysisResult parseAnalysisResult(String response) { }); } - return new AnalysisResult(summary, highlightWords, quiz); + return new AnalysisResult(summary, highlightWords, quiz, category); } private String extractJson(String response) { @@ -316,6 +322,7 @@ private String truncate(String text, int maxLength) { private record AnalysisResult( String summary, List highlightWords, - List quiz + List quiz, + String category ) {} } diff --git a/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md b/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md deleted file mode 100644 index e4c22aa4..00000000 --- a/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md +++ /dev/null @@ -1,521 +0,0 @@ -# 채팅방 / 캐치마인드 게임 분리 - 종합 솔루션 - -## 1. 현재 문제점 분석 - -### 1.1 백엔드 현황 - -``` -ChatRoom.java (현재 - 혼합 모델) -├── 채팅 필드 -│ ├── roomId, name, description -│ ├── memberIds, currentMembers -│ └── lastMessageAt -│ -└── 게임 필드 (여기에 섞여있음) - ├── gameStatus, gameStartedBy - ├── currentRound, totalRounds - ├── currentDrawerId, currentWord - ├── roundStartTime, roundTimeLimit ← serverTime 없음! - ├── scores, streaks - └── correctGuessers -``` - -**문제점:** - -1. `roundStartTime`만 전송, `serverTime` 누락 → 클라이언트 타이머 동기화 불가 -2. 게임 세션이 채팅방에 종속 → 게임 상태 독립 관리 불가 -3. 재접속 시 게임 상태 복구 어려움 -4. 게임 종료 후 상태 정리 복잡 - -### 1.2 WebSocket 메시지 현황 - -```java -// WebSocketMessageHandler.java - 현재 구조 -handleRequest() { - switch (messageType) { - case "DRAWING", "DRAWING_CLEAR" -> handleDrawingMessage() // 게임 - default -> handleRegularMessage() { - // 1. 슬래시 명령어 처리 (/start, /stop, /score...) - // 2. 게임 중 정답 체크 - // 3. 일반 채팅 메시지 - } - } -} -``` - -**문제점:** - -- 채팅/게임 구분 없이 모든 메시지가 동일 핸들러에서 처리 -- 메시지에 `domain` 필드 없음 - ---- - -## 2. 최적 솔루션 - -### 2.1 아키텍처 개요 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ WebSocket (단일 엔드포인트 유지) │ -│ │ -│ ┌──────────────────────┐ ┌────────────────────────────────┐ │ -│ │ domain: "chat" │ │ domain: "game" │ │ -│ │ │ │ │ │ -│ │ • TEXT │ │ • GAME_START / GAME_END │ │ -│ │ • USER_JOIN │ │ • ROUND_START / ROUND_END │ │ -│ │ • USER_LEAVE │ │ • DRAWING / DRAWING_CLEAR │ │ -│ │ • SYSTEM │ │ • GUESS / CORRECT_ANSWER │ │ -│ │ │ │ • SCORE_UPDATE / HINT │ │ -│ └──────────────────────┘ └────────────────────────────────┘ │ -│ │ -│ GameSession (별도 모델) │ -│ ├── gameSessionId │ -│ ├── roomId (연결용) │ -│ ├── status, currentRound │ -│ ├── roundStartTime + serverTime ← 핵심! │ -│ └── scores, players │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 2.2 핵심 변경사항 - -| 구분 | 현재 | 변경 후 | -|-----|----------------------|---------------------------------| -| 모델 | `ChatRoom`에 게임 필드 포함 | `ChatRoom` + `GameSession` 분리 | -| 타이머 | `roundStartTime`만 전송 | `roundStartTime` + `serverTime` | -| 메시지 | `messageType`만 존재 | `domain` + `messageType` | -| API | 채팅방 API만 존재 | 게임 세션 API 추가 | - ---- - -## 3. 백엔드 변경사항 - -### 3.1 Phase 1: 타이머 버그 수정 (즉시) - -**변경 파일:** `WebSocketMessageHandler.java` - -```java -// GAME_START 메시지에 serverTime 추가 -private void broadcastGameStart(...) { - Map message = new HashMap<>(); - // ... 기존 코드 ... - - message.put("roundStartTime", gameResult.room().getRoundStartTime()); - message.put("serverTime", System.currentTimeMillis()); // 추가! - message.put("roundDuration", gameResult.room().getRoundTimeLimit()); // 명확한 이름 - - // ... -} - -// ROUND_END → ROUND_START 메시지에도 동일하게 추가 -private void broadcastRoundEnd(...) { - // ... - messageData.put("roundStartTime", room.getRoundStartTime()); - messageData.put("serverTime", System.currentTimeMillis()); // 추가! - messageData.put("roundDuration", room.getRoundTimeLimit()); - // ... -} -``` - -**예상 작업량:** 30분 - -### 3.2 Phase 2: 메시지 구조 개선 (1일) - -**변경 파일:** `WebSocketMessageHandler.java`, 모든 브로드캐스트 메서드 - -```java -// 모든 메시지에 domain 필드 추가 -private Map createMessage(String domain, String messageType, Object data) { - Map message = new HashMap<>(); - message.put("domain", domain); // "chat" 또는 "game" - message.put("messageType", messageType); - message.put("data", data); - message.put("timestamp", System.currentTimeMillis()); - return message; -} - -// 채팅 메시지 -createMessage("chat", "TEXT", chatData); -createMessage("chat", "USER_JOIN", joinData); - -// 게임 메시지 -createMessage("game", "GAME_START", gameStartData); -createMessage("game", "ROUND_START", roundStartData); -createMessage("game", "DRAWING", drawingData); -``` - -### 3.3 Phase 3: 게임 세션 분리 (1주) - -#### 3.3.1 새 모델: GameSession.java - -```java -@DynamoDbBean -public class GameSession { - private String pk; // GAME#{gameSessionId} - private String sk; // METADATA - private String gsi1pk; // ROOM#{roomId} - private String gsi1sk; // GAME#{createdAt} - - // 게임 식별 - private String gameSessionId; - private String roomId; // 연결된 채팅방 - private String gameType; // "catchmind" - - // 게임 상태 - private String status; // WAITING, PLAYING, FINISHED - private String startedBy; - private Long startedAt; - private Long endedAt; - - // 라운드 정보 - private Integer currentRound; - private Integer totalRounds; - private String currentDrawerId; - private String currentWordId; - private String currentWord; - private Long roundStartTime; - private Integer roundDuration; - - // 점수 - private Map scores; - private Map streaks; - private List players; - private List drawerOrder; - - // 자동 종료 - private Long gameEndScheduledAt; - private String scheduleRuleArn; - - // TTL - private Long ttl; -} -``` - -#### 3.3.2 ChatRoom에서 게임 필드 제거 - -```java -@DynamoDbBean -public class ChatRoom { - // 채팅 필드만 유지 - private String roomId; - private String name; - private String description; - private String level; - private Integer currentMembers; - private Integer maxMembers; - private Boolean isPrivate; - private String password; - private String createdBy; - private String createdAt; - private String lastMessageAt; - private List memberIds; - - // 게임 연결 (참조만) - private String activeGameSessionId; // 현재 진행중인 게임 세션 ID - - // 게임 필드 모두 제거! - // - gameStatus, gameStartedBy, currentRound... 전부 GameSession으로 이동 -} -``` - -#### 3.3.3 게임 세션 API - -``` -# 게임 세션 생성 -POST /api/chat/rooms/{roomId}/games -Request: -{ - "gameType": "catchmind", - "settings": { - "totalRounds": 5, - "roundDuration": 60 - } -} - -Response: -{ - "gameSessionId": "game-abc123", - "roomId": "room-xyz", - "status": "WAITING", - "createdAt": "2024-01-20T10:00:00Z" -} - -# 게임 상태 조회 (재접속 시 필수!) -GET /api/games/{gameSessionId} - -Response: -{ - "gameSessionId": "game-abc123", - "roomId": "room-xyz", - "status": "PLAYING", - "currentRound": 2, - "totalRounds": 5, - "currentDrawerId": "user123", - "roundStartTime": 1705744800000, - "serverTime": 1705744830000, // 핵심! - "roundDuration": 60, - "scores": { - "user1": 150, - "user2": 120 - }, - "players": ["user1", "user2", "user3"] -} - -# 게임 시작 (기존 /start 명령어 대체) -POST /api/games/{gameSessionId}/start - -# 게임 종료 -POST /api/games/{gameSessionId}/stop -``` - ---- - -## 4. 프론트엔드 변경사항 - -### 4.1 Phase 1: 타이머 버그 수정 (즉시) - -```javascript -// useTimer.js - 독립적인 타이머 훅 -export function useTimer(roundStartTime, roundDuration, serverTime) { - const [remainingTime, setRemainingTime] = useState(roundDuration); - - useEffect(() => { - if (!roundStartTime || !roundDuration) return; - - // 서버-클라이언트 시간 차이 보정 - const timeOffset = serverTime ? (Date.now() - serverTime) : 0; - - const interval = setInterval(() => { - const adjustedNow = Date.now() - timeOffset; - const elapsed = Math.floor((adjustedNow - roundStartTime) / 1000); - const remaining = Math.max(0, roundDuration - elapsed); - setRemainingTime(remaining); - - if (remaining <= 0) { - clearInterval(interval); - } - }, 100); - - return () => clearInterval(interval); - }, [roundStartTime, roundDuration, serverTime]); - - return remainingTime; -} -``` - -### 4.2 Phase 2: 메시지 핸들러 분리 - -```javascript -// WebSocket 메시지 핸들러 -onMessage(event) { - const message = JSON.parse(event.data); - - switch (message.domain) { - case 'chat': - this.handleChatMessage(message); - break; - case 'game': - this.handleGameMessage(message); - break; - } -} - -handleChatMessage(message) { - switch (message.messageType) { - case 'TEXT': // 채팅 메시지 - case 'USER_JOIN': - case 'USER_LEAVE': - case 'SYSTEM': - } -} - -handleGameMessage(message) { - switch (message.messageType) { - case 'GAME_START': - case 'ROUND_START': - case 'DRAWING': - case 'CORRECT_ANSWER': - case 'SCORE_UPDATE': - } -} -``` - -### 4.3 Phase 3: 훅 분리 - -``` -src/domains/ -├── chat/ -│ ├── hooks/ -│ │ └── useChatWebSocket.js # 채팅만 처리 -│ └── components/ -│ ├── ChatMessages.jsx -│ └── ChatInput.jsx -│ -├── catchmind/ -│ ├── hooks/ -│ │ ├── useGameWebSocket.js # 게임만 처리 -│ │ ├── useGameState.js -│ │ └── useTimer.js -│ └── components/ -│ ├── DrawingCanvas.jsx -│ ├── ScoreBoard.jsx -│ └── Timer.jsx -│ -└── freetalk/ - └── pages/ - └── FreeTalkPage.jsx # chat + catchmind 조합 -``` - ---- - -## 5. 메시지 스펙 (최종) - -### 5.1 공통 메시지 구조 - -```json -{ - "domain": "chat" | "game", - "messageType": "...", - "data": { ... }, - "timestamp": 1705744800000 -} -``` - -### 5.2 채팅 메시지 - -| Type | 방향 | data 필드 | -|--------------|-----|-----------------------------------------------| -| `TEXT` | 양방향 | `messageId`, `userId`, `content`, `createdAt` | -| `USER_JOIN` | S→C | `userId`, `memberCount` | -| `USER_LEAVE` | S→C | `userId`, `memberCount` | -| `SYSTEM` | S→C | `content` | - -### 5.3 게임 메시지 - -| Type | 방향 | data 필드 | -|------------------|-----|---------------------------------------------------------------------------------------------------------------| -| `GAME_START` | S→C | `gameSessionId`, `totalRounds`, `currentDrawerId`, `roundStartTime`, `serverTime`, `roundDuration`, `players` | -| `GAME_END` | S→C | `gameSessionId`, `reason`, `finalScores`, `winner` | -| `ROUND_START` | S→C | `currentRound`, `currentDrawerId`, `roundStartTime`, `serverTime`, `roundDuration`, `currentWord`(출제자만) | -| `ROUND_END` | S→C | `currentRound`, `answer`, `scores` | -| `DRAWING` | 양방향 | `drawingData` | -| `DRAWING_CLEAR` | 양방향 | - | -| `GUESS` | C→S | `content` | -| `CORRECT_ANSWER` | S→C | `userId`, `score`, `elapsedTime` | -| `SCORE_UPDATE` | S→C | `scores`, `currentRound`, `totalRounds` | -| `HINT` | S→C | `hint` | - -### 5.4 ROUND_START 상세 (핵심!) - -```json -{ - "domain": "game", - "messageType": "ROUND_START", - "data": { - "gameSessionId": "game-abc123", - "currentRound": 2, - "totalRounds": 5, - "currentDrawerId": "user123", - "roundStartTime": 1705744800000, - "serverTime": 1705744800500, - "roundDuration": 60, - "currentWord": { - "wordId": "word-1", - "word": "apple" - } - }, - "timestamp": 1705744800500 -} -``` - -**중요:** `currentWord`는 출제자에게만 전송! - ---- - -## 6. 구현 일정 - -``` -Week 1: 긴급 버그 수정 -├── [BE] serverTime 필드 추가 (0.5일) -├── [FE] useTimer 훅 수정 (0.5일) -├── [BE] 메시지에 domain 필드 추가 (1일) -└── [FE] 메시지 핸들러 domain 분기 (0.5일) - -Week 2: 게임 세션 분리 (BE) -├── [BE] GameSession 모델 생성 -├── [BE] GameSessionRepository 구현 -├── [BE] GameService 리팩토링 -└── [BE] 게임 세션 API 구현 - -Week 3: 프론트엔드 리팩토링 -├── [FE] useChatWebSocket 분리 -├── [FE] useGameWebSocket 신규 -├── [FE] 컴포넌트 분리 -└── [FE/BE] 통합 테스트 - -Week 4: 안정화 및 추가 기능 -├── [BE] 게임 자동 종료 (7분) - Issue #417 -├── [BE] 재접속 시 게임 상태 복구 -└── [FE/BE] E2E 테스트 -``` - ---- - -## 7. 기대 효과 - -| 항목 | 현재 | 개선 후 | -|---------|-------------|------------------| -| 타이머 정확도 | 클라이언트 시계 의존 | 서버 시간 기준 동기화 | -| 재접속 | 게임 상태 유실 | 완전 복구 가능 | -| 테스트 | 채팅/게임 분리 불가 | 독립 테스트 가능 | -| 확장성 | 새 게임 추가 어려움 | gameType으로 확장 용이 | -| 유지보수 | 책임 혼재 | 명확한 책임 분리 | - ---- - -## 8. 즉시 적용 (백엔드 변경 전 프론트엔드 임시 조치) - -```javascript -// 백엔드 변경 전까지 프론트엔드에서 적용 가능한 임시 코드 - -onRoundStart: (data) => { - const roundData = data.data || data; - const now = Date.now(); - - // serverTime이 없으면 클라이언트 시간 사용 (임시) - const serverTime = roundData.serverTime || now; - let roundStartTime = roundData.roundStartTime || now; - - // roundStartTime이 미래 시간이면 현재로 보정 - if (roundStartTime > now + 1000) { - console.warn('Invalid roundStartTime, using current time'); - roundStartTime = now; - } - - setGameState((prev) => ({ - ...prev, - currentRound: roundData.currentRound, - currentDrawerId: roundData.currentDrawerId, - roundStartTime: roundStartTime, - serverTime: serverTime, - roundDuration: roundData.roundDuration || roundData.roundTimeLimit || 60, - })); -} -``` - ---- - -## 9. 결론 - -**우선순위:** - -1. **즉시 (이번 주)**: `serverTime` 추가 + `domain` 필드 추가 -2. **단기 (2주)**: GameSession 모델 분리 + API 구현 -3. **중기 (3-4주)**: FE/BE 완전 분리 + 자동 종료 + 재접속 복구 - -**핵심 원칙:** - -- 단일 WebSocket 엔드포인트 유지 (비용/복잡도) -- `domain` 필드로 채팅/게임 구분 -- `serverTime`으로 정확한 타이머 동기화 -- GameSession 독립 모델로 상태 관리 명확화 diff --git a/docs/CICD-IMPLEMENTATION-QNA.md b/docs/CICD-IMPLEMENTATION-QNA.md deleted file mode 100644 index e00c5a11..00000000 --- a/docs/CICD-IMPLEMENTATION-QNA.md +++ /dev/null @@ -1,421 +0,0 @@ -# CI/CD 파이프라인 구현 설명 및 면접 Q&A - -## 1. CI/CD 아키텍처 개요 - -``` -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ GitHub │───▶│ CodePipeline│───▶│ CodeBuild │───▶│CloudFormation│ -│ (Source) │ │ (Pipeline) │ │ (Build) │ │ (Deploy) │ -└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ - │ │ │ │ - │ ▼ ▼ ▼ - │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ - │ │ SNS │ │ S3 │ │ Lambda │ - │ │(Notification)│ │ (Artifacts) │ │ Functions │ - │ └─────────────┘ └─────────────┘ └─────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ prod 브랜치 Push/Merge │ -└─────────────────────────────────────────────────────────────────────┘ -``` - -## 2. 구성 요소 상세 설명 - -### 2.1 Source Stage (GitHub) - -- **트리거**: prod 브랜치에 Push 또는 PR Merge 시 자동 실행 -- **연결 방식**: AWS CodeConnections (구 CodeStar Connections) -- **아티팩트**: 소스 코드를 ZIP으로 압축하여 다음 스테이지로 전달 - -### 2.2 Build Stage (CodeBuild) - -- **런타임**: Amazon Linux 2, Java Corretto 21 -- **빌드 단계**: - 1. **Install**: SAM CLI 설치 - 2. **Pre-build**: Gradle 테스트 실행 (`./gradlew clean test`) - 3. **Build**: SAM build & package - 4. **Post-build**: 완료 로그 -- **캐싱**: Gradle 캐시를 S3에 저장하여 빌드 시간 단축 -- **리포트**: JUnit 테스트 결과, JaCoCo 코드 커버리지 리포트 - -### 2.3 Deploy Stage (CloudFormation) - -- **배포 방식**: CloudFormation CREATE_UPDATE -- **템플릿**: SAM으로 패키징된 `packaged-template.yaml` -- **기능**: CAPABILITY_IAM, CAPABILITY_AUTO_EXPAND - -### 2.4 Notification (SNS) - -- **이벤트**: 파이프라인 시작, 성공, 실패 시 이메일 알림 -- **구현**: CodeStar Notifications + SNS Topic - -## 3. 주요 파일 구조 - -``` -BE_Repository/ -├── cicd/ -│ └── pipeline.yaml # CloudFormation 파이프라인 템플릿 -├── ServerlessFunction/ -│ ├── buildspec.yml # CodeBuild 빌드 명세 -│ ├── samconfig.toml # SAM 배포 설정 -│ └── template.yaml # SAM 애플리케이션 템플릿 -``` - -## 4. IAM 역할 구성 - -| 역할 | 목적 | 주요 권한 | -|--------------------|---------------------|----------------------------------------| -| PipelineRole | CodePipeline 서비스 역할 | S3, CodeBuild, CloudFormation, SNS | -| CodeBuildRole | CodeBuild 서비스 역할 | S3, CloudWatch Logs, CodeBuild Reports | -| CloudFormationRole | 리소스 배포 역할 | AdministratorAccess (SAM 리소스 생성용) | - ---- - -## 5. 면접 예상 질문 및 답변 - -### Q1. CI/CD 파이프라인을 구축한 이유는 무엇인가요? - -**A1:** -수동 배포의 문제점을 해결하기 위해 CI/CD를 도입했습니다. - -1. **일관성**: 수동 배포 시 발생할 수 있는 휴먼 에러 방지 -2. **자동화**: 코드 푸시만으로 테스트-빌드-배포가 자동 실행 -3. **품질 보장**: 테스트 실패 시 배포가 중단되어 결함 있는 코드가 프로덕션에 배포되는 것을 방지 -4. **추적성**: 모든 배포 이력이 CodePipeline에 기록되어 문제 발생 시 원인 추적 용이 -5. **속도**: 반복적인 배포 작업 시간을 단축하여 개발 생산성 향상 - ---- - -### Q2. GitHub과 AWS CodePipeline을 어떻게 연동했나요? - -**A2:** -AWS CodeConnections(구 CodeStar Connections)를 사용하여 연동했습니다. - -```yaml -# pipeline.yaml의 Source Stage 설정 -- Name: Source - Actions: - - Name: GitHub - ActionTypeId: - Category: Source - Owner: AWS - Provider: CodeStarSourceConnection - Version: '1' - Configuration: - ConnectionArn: !Ref GitHubConnectionArn - FullRepositoryId: "Language-Study-Prooject/BE_Repository" - BranchName: "prod" - DetectChanges: true -``` - -**연동 과정:** - -1. AWS Console에서 CodeConnections 생성 -2. GitHub OAuth 앱 승인 -3. Connection ARN을 파이프라인에 설정 -4. `DetectChanges: true`로 설정하여 자동 트리거 활성화 - ---- - -### Q3. CodeBuild의 buildspec.yml에서 각 phase의 역할은 무엇인가요? - -**A3:** - -```yaml -phases: - install: # 빌드 환경 설정 - runtime-versions: - java: corretto21 - commands: - - pip3 install aws-sam-cli - - pre_build: # 테스트 실행 (품질 게이트) - commands: - - cd ServerlessFunction - - ./gradlew clean test - - build: # 실제 빌드 및 패키징 - commands: - - sam build - - sam package --s3-bucket ... --output-template-file packaged-template.yaml - - post_build: # 후처리 (로깅, 정리) - commands: - - echo "Build completed" -``` - -- **install**: 빌드에 필요한 런타임과 도구 설치 -- **pre_build**: 테스트 실행 - 실패 시 빌드 중단 (품질 게이트 역할) -- **build**: SAM 애플리케이션 빌드 및 S3에 패키징 -- **post_build**: 완료 로그 기록, 정리 작업 - ---- - -### Q4. 테스트가 실패하면 배포가 어떻게 되나요? - -**A4:** -테스트 실패 시 배포가 자동으로 중단됩니다. - -**작동 원리:** - -1. `pre_build` 단계에서 `./gradlew clean test` 실행 -2. 테스트 실패 시 Gradle이 exit code 1 반환 -3. CodeBuild가 비정상 종료로 판단하여 빌드 실패 처리 -4. CodePipeline의 Build Stage가 실패 상태가 됨 -5. Deploy Stage로 진행되지 않음 -6. SNS를 통해 실패 알림 이메일 발송 - -``` -Pipeline Flow: -Source ──▶ Build (테스트 실패) ──✗ Deploy - │ - ▼ - SNS 알림 발송 -``` - ---- - -### Q5. SAM과 CloudFormation의 관계는 무엇인가요? - -**A5:** -SAM(Serverless Application Model)은 CloudFormation의 확장입니다. - -**관계:** - -- SAM 템플릿은 CloudFormation 템플릿의 상위 집합 -- `sam build`/`sam package` 실행 시 SAM 템플릿이 표준 CloudFormation 템플릿으로 변환 -- 변환된 템플릿(`packaged-template.yaml`)을 CloudFormation이 배포 - -**SAM의 장점:** - -1. 간결한 문법: `AWS::Serverless::Function`으로 Lambda + API Gateway + IAM 역할 한번에 정의 -2. 로컬 테스트: `sam local invoke`로 Lambda 로컬 실행 가능 -3. 자동 패키징: 코드를 S3에 업로드하고 참조 자동 생성 - -```yaml -# SAM 템플릿 (간결) -Type: AWS::Serverless::Function -Properties: - Handler: handler.main - Runtime: java21 - Events: - Api: - Type: Api - Properties: - Path: /hello - Method: get - -# 변환된 CloudFormation (복잡) -# Lambda Function + API Gateway + IAM Role + Permission 등 여러 리소스로 확장 -``` - ---- - -### Q6. 배포 중 롤백은 어떻게 처리되나요? - -**A6:** -CloudFormation의 기본 롤백 기능을 활용합니다. - -**설정:** - -```yaml -# samconfig.toml -disable_rollback = false # 롤백 활성화 -``` - -**롤백 시나리오:** - -1. **배포 실패 시**: CloudFormation이 자동으로 이전 상태로 롤백 -2. **Lambda 오류 시**: - - 현재는 기본 롤백만 사용 - - 추가로 Canary/Linear 배포 설정 가능 (AWS CodeDeploy 연동) - -```yaml -# 점진적 배포 예시 (선택적 구현) -DeploymentPreference: - Type: Canary10Percent5Minutes # 10%에 5분간 배포 후 문제없으면 전체 배포 -``` - ---- - -### Q7. 파이프라인의 아티팩트는 어떻게 관리되나요? - -**A7:** -S3 버킷을 사용하여 아티팩트를 관리합니다. - -```yaml -ArtifactBucket: - Type: AWS::S3::Bucket - Properties: - BucketName: group2-englishstudy-pipeline-artifacts - VersioningConfiguration: - Status: Enabled # 버전 관리 활성화 - BucketEncryption: - ServerSideEncryptionConfiguration: - - ServerSideEncryptionByDefault: - SSEAlgorithm: AES256 # 암호화 -``` - -**아티팩트 종류:** - -1. **SourceArtifact**: GitHub에서 가져온 소스 코드 ZIP -2. **BuildArtifact**: 빌드된 `packaged-template.yaml` -3. **Cache**: Gradle 캐시 (빌드 시간 단축용) - ---- - -### Q8. 파이프라인 알림은 어떻게 구현했나요? - -**A8:** -AWS CodeStar Notifications와 SNS를 연동하여 구현했습니다. - -```yaml -# SNS Topic 생성 -NotificationTopic: - Type: AWS::SNS::Topic - Properties: - TopicName: cicd-pipeline-notifications - -# 이메일 구독 -EmailSubscription: - Type: AWS::SNS::Subscription - Properties: - TopicArn: !Ref NotificationTopic - Protocol: email - Endpoint: !Ref NotificationEmail - -# 알림 규칙 -PipelineNotificationRule: - Type: AWS::CodeStarNotifications::NotificationRule - Properties: - EventTypeIds: - - codepipeline-pipeline-pipeline-execution-started - - codepipeline-pipeline-pipeline-execution-succeeded - - codepipeline-pipeline-pipeline-execution-failed - Targets: - - TargetType: SNS - TargetAddress: !Ref NotificationTopic -``` - ---- - -### Q9. CI/CD 구축 중 겪은 문제와 해결 방법은? - -**A9:** - -**문제 1: Gradle Wrapper를 찾을 수 없음** - -- 원인: `.gitignore`에서 `gradle/` 폴더 전체가 제외됨 -- 해결: `.gitignore` 수정하여 `!gradle/wrapper/` 예외 추가 - -**문제 2: JAVA_HOME 환경 변수 오류** - -- 원인: CodeBuild에서 JAVA_HOME을 수동 설정했으나 경로 불일치 -- 해결: `runtime-versions: java: corretto21`만 사용하고 JAVA_HOME 수동 설정 제거 - -**문제 3: SAM package S3 버킷 참조 오류** - -- 원인: 환경 변수를 사용한 멀티라인 명령어에서 변수 치환 실패 -- 해결: 단일 라인으로 버킷 이름 직접 지정 - -**문제 4: Lambda 환경 변수 누락** - -- 원인: WebSocket Connect 함수에 `WEBSOCKET_ENDPOINT` 환경 변수 미설정 -- 해결: `template.yaml`에 환경 변수 추가 - ---- - -### Q10. 현재 CI/CD의 개선점이 있다면? - -**A10:** - -1. **테스트 커버리지 게이트** - - 현재: 테스트 실행만 함 - - 개선: 커버리지 80% 미만 시 빌드 실패 설정 - -2. **점진적 배포 (Canary/Blue-Green)** - - 현재: 전체 교체 배포 - - 개선: Lambda Alias + CodeDeploy로 Canary 배포 구현 - -3. **다중 환경 지원** - - 현재: prod 단일 환경 - - 개선: dev, staging, prod 분리 및 승인 단계 추가 - -4. **보안 스캔** - - 개선: 의존성 취약점 스캔 (OWASP Dependency-Check) 추가 - -5. **성능 테스트** - - 개선: 배포 전 부하 테스트 단계 추가 - ---- - -### Q11. IaC(Infrastructure as Code)를 사용한 이유는? - -**A11:** -파이프라인 자체도 CloudFormation 템플릿(`pipeline.yaml`)으로 정의했습니다. - -**장점:** - -1. **버전 관리**: 인프라 변경 이력을 Git으로 추적 -2. **재현성**: 동일한 파이프라인을 다른 프로젝트/계정에 쉽게 복제 -3. **리뷰 가능**: 인프라 변경도 코드 리뷰 프로세스 적용 -4. **자동화**: 수동 콘솔 작업 없이 `aws cloudformation deploy`로 생성/업데이트 -5. **문서화**: 템플릿 자체가 인프라 문서 역할 - ---- - -### Q12. CodeBuild와 Jenkins의 차이점은? - -**A12:** - -| 항목 | CodeBuild | Jenkins | -|--------|---------------|----------------------| -| 관리 | 완전 관리형 (서버리스) | 자체 서버 운영 필요 | -| 비용 | 빌드 시간 기반 과금 | 서버 운영 비용 | -| 확장성 | 자동 확장 | 수동 확장 필요 | -| AWS 통합 | 네이티브 통합 | 플러그인 필요 | -| 커스터마이징 | buildspec.yml | Jenkinsfile (Groovy) | -| 플러그인 | 제한적 | 풍부한 생태계 | - -**선택 이유:** - -- AWS 서비스 중심 아키텍처에서 네이티브 통합의 이점 -- 서버 관리 부담 없음 -- SAM/CloudFormation과의 원활한 연동 - ---- - -## 6. 핵심 용어 정리 - -| 용어 | 설명 | -|-------------------------------------|------------------------------------------------| -| CI (Continuous Integration) | 코드 변경을 자주 통합하고 자동 테스트하는 방식 | -| CD (Continuous Delivery/Deployment) | 자동으로 프로덕션까지 배포하는 방식 | -| Pipeline | 소스-빌드-배포로 이어지는 자동화된 워크플로우 | -| Artifact | 빌드 결과물 (패키징된 코드, 템플릿 등) | -| buildspec.yml | CodeBuild의 빌드 명세 파일 | -| SAM | Serverless Application Model - 서버리스 앱 정의 프레임워크 | -| IaC | Infrastructure as Code - 코드로 인프라 관리 | - ---- - -## 7. 참고 명령어 - -```bash -# 파이프라인 생성 -aws cloudformation deploy \ - --template-file cicd/pipeline.yaml \ - --stack-name group2-cicd-pipeline \ - --capabilities CAPABILITY_NAMED_IAM \ - --parameter-overrides NotificationEmail=your@email.com - -# 파이프라인 상태 확인 -aws codepipeline get-pipeline-state --name group2-englishstudy-pipeline - -# 수동 파이프라인 실행 -aws codepipeline start-pipeline-execution --name group2-englishstudy-pipeline - -# 빌드 로그 확인 -aws logs tail /aws/codebuild/group2-englishstudy-build --follow -``` diff --git a/docs/FRONTEND-API-GUIDE.md b/docs/FRONTEND-API-GUIDE.md deleted file mode 100644 index 697d406a..00000000 --- a/docs/FRONTEND-API-GUIDE.md +++ /dev/null @@ -1,365 +0,0 @@ -# 프론트엔드 전달사항 - 채팅/게임 API 가이드 - -## 1. 아키텍처 구조 (업데이트됨) - -### 채팅방과 게임방 분리 - -``` -RoomType enum -├── CHAT ("chat") - 일반 채팅방 -└── GAME ("game") - 게임방 (캐치마인드 등) - -RoomStatus enum -├── WAITING ("waiting") - 대기 중 -├── PLAYING ("playing") - 게임 진행 중 -└── FINISHED ("finished") - 종료됨 -``` - -### GSI1SK 인덱스 설계 - -``` -GSI1PK: "ROOMS" (고정) -GSI1SK: {type}#{gameType}#{status}#{level}#{createdAt} - -예시: -- CHAT#-#WAITING#beginner#2026-01-22T10:00:00Z (일반 채팅방) -- GAME#CATCHMIND#WAITING#intermediate#2026-01-22T10:00:00Z (대기중 게임방) -- GAME#CATCHMIND#PLAYING#advanced#2026-01-22T10:00:00Z (진행중 게임방) -``` - -**핵심**: DB 레벨에서 `type`, `gameType`, `status`, `level` 조합으로 필터링 가능 - ---- - -## 2. 방 타입 (RoomType) - -| 타입 | 코드 | 설명 | -|--------|--------|---------------| -| `CHAT` | `chat` | 일반 채팅방 | -| `GAME` | `game` | 게임방 (캐치마인드 등) | - ---- - -## 3. 방 상태 (RoomStatus) - -| 상태 | 코드 | 설명 | 게임 시작 가능 | -|------------|------------|---------|:--------:| -| `WAITING` | `waiting` | 대기 중 | O | -| `PLAYING` | `playing` | 게임 진행 중 | X | -| `FINISHED` | `finished` | 게임 종료됨 | O | - ---- - -## 4. REST API 엔드포인트 - -### 채팅방 API (`/api/chat/rooms`) - -| Method | Endpoint | 설명 | -|--------|-------------------------|---------------------| -| POST | `/rooms` | 채팅방/게임방 생성 | -| GET | `/rooms` | 방 목록 조회 (필터 지원) | -| GET | `/rooms/{roomId}` | 방 상세 조회 | -| POST | `/rooms/{roomId}/join` | 방 입장 (roomToken 발급) | -| POST | `/rooms/{roomId}/leave` | 방 퇴장 | -| DELETE | `/rooms/{roomId}` | 방 삭제 (방장만) | - -### 게임 API (`/api/game`) - -| Method | Endpoint | 설명 | -|--------|-------------------------------|----------| -| POST | `/rooms/{roomId}/game/start` | 게임 시작 | -| POST | `/rooms/{roomId}/game/stop` | 게임 중단 | -| GET | `/rooms/{roomId}/game/status` | 게임 상태 조회 | -| GET | `/rooms/{roomId}/game/scores` | 점수판 조회 | - ---- - -## 5. 방 목록 조회 쿼리 파라미터 (업데이트됨) - -``` -GET /api/chat/rooms?type=GAME&gameType=CATCHMIND&status=WAITING&level=intermediate&limit=10&cursor=xxx -``` - -| 파라미터 | 타입 | 설명 | 예시 | -|------------|--------|----------------------|----------------------------------------| -| `type` | string | 방 타입 필터 | `CHAT`, `GAME` | -| `gameType` | string | 게임 타입 | `CATCHMIND` | -| `status` | string | 상태 필터 | `WAITING`, `PLAYING`, `FINISHED` | -| `level` | string | 난이도 필터 | `beginner`, `intermediate`, `advanced` | -| `limit` | number | 조회 개수 (기본 10, 최대 20) | | -| `cursor` | string | 페이지네이션 커서 | | - -### 필터 조합 예시 - -```bash -# 대기 중인 게임방만 -GET /api/chat/rooms?type=GAME&status=WAITING - -# 캐치마인드 게임방만 -GET /api/chat/rooms?type=GAME&gameType=CATCHMIND - -# 초급 난이도 채팅방 -GET /api/chat/rooms?type=CHAT&level=beginner - -# 진행 중인 고급 게임방 -GET /api/chat/rooms?type=GAME&status=PLAYING&level=advanced -``` - -### 응답 예시 - -```json -{ - "success": true, - "message": "Rooms retrieved", - "data": { - "rooms": [ - { - "roomId": "abc-123", - "name": "초보자 영어 스터디", - "type": "GAME", - "gameType": "CATCHMIND", - "status": "WAITING", - "level": "beginner", - "currentMembers": 3, - "maxMembers": 6, - "currentRound": 0, - "totalRounds": 5, - "createdAt": "2026-01-22T10:00:00Z" - } - ], - "nextCursor": "eyJQSyI6Ik...", - "hasMore": true - } -} -``` - ---- - -## 6. 방 생성 요청 (업데이트됨) - -### 채팅방 생성 - -```json -{ - "name": "영어 스터디 채팅방", - "type": "CHAT", - "level": "beginner", - "maxMembers": 6, - "description": "초보자를 위한 영어 채팅방" -} -``` - -### 게임방 생성 - -```json -{ - "name": "캐치마인드 게임", - "type": "GAME", - "gameType": "CATCHMIND", - "level": "intermediate", - "maxMembers": 8, - "description": "영어 단어 맞추기 게임" -} -``` - ---- - -## 7. 프론트엔드에서 방 타입 구분 - -### 방법 1: API 필터 사용 (권장) - -```javascript -// 게임방만 조회 -const gameRooms = await fetch('/api/chat/rooms?type=GAME'); - -// 대기 중인 게임방만 -const waitingGames = await fetch('/api/chat/rooms?type=GAME&status=WAITING'); - -// 채팅방만 -const chatRooms = await fetch('/api/chat/rooms?type=CHAT'); -``` - -### 방법 2: 전체 조회 후 클라이언트 필터링 - -```javascript -const allRooms = await fetchRooms(); - -// 게임방만 -const gameRooms = allRooms.filter(room => room.type === 'GAME'); - -// 채팅방만 -const chatRooms = allRooms.filter(room => room.type === 'CHAT'); - -// 대기 중인 방만 -const waitingRooms = allRooms.filter(room => room.status === 'WAITING'); -``` - ---- - -## 8. WebSocket 연결 - -### 채팅/게임 WebSocket - -``` -wss://t378dif43l.execute-api.ap-northeast-2.amazonaws.com/dev?roomToken={roomToken} -``` - -### Grammar WebSocket - -``` -wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} -``` - -### 연결 순서 - -1. `POST /rooms/{roomId}/join` → `roomToken` 발급 -2. WebSocket 연결 시 `roomToken` 쿼리 파라미터로 전달 - ---- - -## 9. WebSocket 메시지 타입 (messageType) - -| 코드 | 타입 | 설명 | -|------------------|--------|---------------| -| `MSG` | 일반 메시지 | 일반 채팅 메시지 | -| `VOICE` | 음성 메시지 | 음성 채팅 | -| `JOIN` | 입장 알림 | 사용자 입장 | -| `LEAVE` | 퇴장 알림 | 사용자 퇴장 | -| `GAME_START` | 게임 시작 | 게임 시작 알림 | -| `GAME_END` | 게임 종료 | 게임 종료 + 최종 순위 | -| `ROUND_START` | 라운드 시작 | 새 라운드 시작 | -| `ROUND_END` | 라운드 종료 | 정답 공개 | -| `ANSWER_CORRECT` | 정답 | 정답 맞춤 | -| `HINT` | 힌트 | 힌트 제공 | -| `SKIP` | 스킵 | 라운드 스킵 | -| `SYSTEM` | 시스템 | 시스템 메시지 | - ---- - -## 10. 게임 명령어 (WebSocket) - -채팅 메시지로 게임 명령어 전송: - -| 명령어 | 설명 | 권한 | -|----------|--------|-----------------| -| `/start` | 게임 시작 | 방장 (2명 이상 접속 시) | -| `/stop` | 게임 중단 | 방장 또는 게임 시작자 | -| `/skip` | 라운드 스킵 | 누구나 | -| `/hint` | 힌트 제공 | 출제자만 | -| `/score` | 점수 확인 | 누구나 | - ---- - -## 11. 게임 시작 응답 예시 - -```json -{ - "messageId": "uuid", - "roomId": "abc-123", - "userId": "SYSTEM", - "content": "게임 시작!\n총 5 라운드\n\n라운드 1 시작!\n출제자: user-456", - "messageType": "GAME_START", - "createdAt": "2026-01-22T10:00:00Z", - "serverTime": "2026-01-22T10:00:00Z", - "domain": "GAME", - "type": "GAME", - "status": "PLAYING", - "currentRound": 1, - "totalRounds": 5, - "currentDrawerId": "user-456", - "drawerOrder": ["user-456", "user-789", "user-123"] -} -``` - ---- - -## 12. 정답 체크 로직 - -- **한국어** 또는 **영어** 둘 다 정답으로 인정 -- 대소문자 구분 없음 -- 공백 무시 - -### 점수 계산 - -``` -기본 점수: 10점 -시간 보너스: (제한시간 - 경과시간) * 0.5 -연속 정답 보너스: 연속정답수 * 2 - -총점 = 기본점수 + 시간보너스 + 연속정답보너스 -``` - ---- - -## 13. 게임 설정 - -| 설정 | 기본값 | 환경변수 | -|--------------|----------|---------------------------------| -| 총 라운드 수 | 5 | `GAME_TOTAL_ROUNDS` | -| 라운드 제한 시간(초) | 60 | `GAME_ROUND_TIME_LIMIT` | -| 빠른 정답 기준(ms) | 5000 | `GAME_QUICK_GUESS_THRESHOLD_MS` | -| 게임 전체 제한(초) | 420 (7분) | `GAME_TIME_LIMIT_SECONDS` | - ---- - -## 14. 주의사항 - -1. **roomToken은 한 번만 사용**: 재연결 시 새로 발급 필요 -2. **WebSocket 연결 실패 시**: `POST /rooms/{roomId}/join`으로 새 토큰 발급 -3. **게임 중 퇴장**: 자동으로 다음 출제자로 넘어감 (2명 미만 시 게임 종료) -4. **출제자는 정답 입력 불가**: 본인이 출제자일 때 채팅해도 정답 체크 안됨 -5. **방 타입 변경 불가**: 생성 시 지정한 type은 변경 불가 - ---- - -## 15. 에러 코드 - -| 코드 | HTTP | 설명 | -|--------------|------|--------------| -| `ROOM_001` | 404 | 채팅방 없음 | -| `ROOM_002` | 409 | 채팅방 이미 존재 | -| `ROOM_003` | 400 | 채팅방 인원 초과 | -| `ROOM_004` | 400 | 채팅방 종료됨 | -| `ROOM_005` | 401 | 비밀번호 틀림 | -| `ROOM_006` | 403 | 방장 권한 없음 | -| `MEMBER_001` | 403 | 채팅방 멤버 아님 | -| `MEMBER_002` | 409 | 이미 참여 중 | -| `GAME_001` | 400 | 게임 시작 실패 | -| `GAME_002` | 400 | 게임 중단 실패 | -| `GAME_003` | 400 | 게임 진행 중 아님 | -| `GAME_004` | 409 | 게임 이미 진행 중 | -| `GAME_005` | 403 | 게임 시작자 아님 | -| `GAME_006` | 404 | 게임 없음 | -| `GAME_007` | 400 | 채팅방에서 게임 불가 | -| `GAME_008` | 400 | 게임 재시작 불가 | -| `GAME_009` | 403 | 방장만 게임 시작 가능 | - ---- - -## 16. UI 구현 가이드 - -### 탭 구조 (권장) - -``` -[전체] [채팅방] [게임방] -``` - -### 게임방 상태 표시 - -``` -대기 중 (WAITING) → 초록색 뱃지 "참여 가능" -진행 중 (PLAYING) → 빨간색 뱃지 "게임 중" -종료됨 (FINISHED) → 회색 뱃지 "종료" -``` - -### 게임방 카드 정보 - -``` -┌─────────────────────────────┐ -│ 캐치마인드 - 영어 단어 맞추기 │ -│ [게임방] [intermediate] │ -│ │ -│ 👥 3/8명 🎮 대기 중 │ -│ 🕐 2026-01-22 10:00 │ -└─────────────────────────────┘ -``` diff --git a/docs/MIDTERM-REPORT.md b/docs/MIDTERM-REPORT.md deleted file mode 100644 index 9a6bb1d1..00000000 --- a/docs/MIDTERM-REPORT.md +++ /dev/null @@ -1,439 +0,0 @@ -# 영어 학습 플랫폼 백엔드 최종 성과 보고서 - -## 프로젝트 개요 - -| 항목 | 내용 | -|-------|--------------------------------------------------------------------------| -| 프로젝트명 | 영어 회화 학습 플랫폼 (MZC 2nd Project) | -| 담당 영역 | Vocabulary, Chatting, Grammar, Badge, Stats, Common | -| 기술 스택 | Java 21, AWS Lambda, DynamoDB, API Gateway WebSocket, Bedrock, Polly, S3 | -| 배포 환경 | AWS SAM, CloudFormation | - ---- - -## 1. 전체 시스템 아키텍처 - -```mermaid -flowchart TB - subgraph Client["클라이언트"] - WEB[Web App] - end - - subgraph Gateway["API Gateway"] - REST[REST API] - WS[WebSocket API] - GRAMMAR_WS[Grammar WebSocket] - end - - subgraph Lambda["AWS Lambda - 도메인별 핸들러"] - direction TB - VOCAB[Vocabulary
단어/일일학습/테스트] - CHAT[Chatting
실시간 채팅/게임] - GRAMMAR[Grammar
문법 체크/스트리밍] - STATS[Stats
통계 집계] - BADGE[Badge
배지 시스템] - USER[User
사용자 관리] - end - - subgraph AI["AI Services"] - BEDROCK[AWS Bedrock
Claude 3.5 Sonnet] - POLLY[AWS Polly
TTS] - end - - subgraph Data["Data Layer"] - DYNAMO_VOCAB[(DynamoDB
Vocab Table)] - DYNAMO_CHAT[(DynamoDB
Chat Table)] - S3[(S3
음성/뱃지 이미지)] - STREAMS[DynamoDB Streams] - end - - WEB --> REST - WEB --> WS - WEB --> GRAMMAR_WS - REST --> VOCAB - REST --> CHAT - REST --> GRAMMAR - REST --> BADGE - REST --> STATS - REST --> USER - WS --> CHAT - GRAMMAR_WS --> GRAMMAR - VOCAB --> DYNAMO_VOCAB - VOCAB --> POLLY - VOCAB --> S3 - CHAT --> DYNAMO_CHAT - CHAT --> BEDROCK - GRAMMAR --> DYNAMO_VOCAB - GRAMMAR --> BEDROCK - STATS --> DYNAMO_VOCAB - BADGE --> DYNAMO_VOCAB - BADGE --> S3 - STREAMS -->|이벤트 트리거| STATS - STATS -->|배지 부여| BADGE -``` - ---- - -## 2. 주요 기능 구현 - -### 2.1 Vocabulary Domain (단어 학습) - -#### 2.1.1 일일 학습 시스템 (Daily Study) - -```mermaid -flowchart LR - subgraph DailyStudy["일일 학습 흐름"] - A[오늘의 단어 조회] --> B{기존 학습 존재?} - B -->|Yes| C[기존 학습 반환] - B -->|No| D[새 단어 50개 + 복습 5개 생성] - D --> E[학습 진행] - E --> F[단어별 학습 완료 처리] - F --> G{50개 완료?} - G -->|Yes| H[isCompleted = true] - end -``` - -**주요 기능:** - -- 레벨별 신규 단어 50개 + 복습 단어 5개 자동 선정 -- 학습 진행도 실시간 추적 (learnedCount/totalWords) -- 일일 학습 완료 시 isCompleted 플래그 설정 - -#### 2.1.2 SM-2 Spaced Repetition 알고리즘 - -```mermaid -stateDiagram-v2 - [*] --> NEW: 단어 추가 - NEW --> LEARNING: 첫 학습 - LEARNING --> LEARNING: 오답 - LEARNING --> REVIEWING: 2회 연속 정답 - REVIEWING --> LEARNING: 오답 - REVIEWING --> MASTERED: 5회 연속 정답 - MASTERED --> LEARNING: 오답 - MASTERED --> MASTERED: 정답 유지 -``` - -**구현 특징:** - -- State 패턴으로 학습 상태 전이 관리 -- easeFactor 동적 조정 (1.3 ~ 2.5) -- 복습 간격 자동 계산 (1일 → 6일 → interval * easeFactor) - -#### 2.1.3 TTS 음성 생성 - -- AWS Polly 연동 (남성/여성 음성) -- S3 캐싱으로 중복 생성 방지 -- 단어 + 예문 음성 생성 - ---- - -### 2.2 Chatting Domain (실시간 채팅 & 게임) - -#### 2.2.1 WebSocket 채팅 - -```mermaid -sequenceDiagram - participant Client - participant REST as REST API - participant WS as WebSocket API - participant DB as DynamoDB - Note over Client, DB: Phase 1: 방 입장 토큰 발급 - Client ->> REST: POST /rooms/{id}/join - REST ->> DB: RoomToken 저장 (TTL: 5분) - REST -->> Client: roomToken 반환 - Note over Client, DB: Phase 2: WebSocket 연결 - Client ->> WS: $connect?roomToken={token} - WS ->> DB: 토큰 검증 + Connection 저장 - WS -->> Client: 연결 성공 - Note over Client, DB: Phase 3: 메시지 송수신 - Client ->> WS: sendmessage (채팅) - WS ->> DB: 메시지 저장 + 브로드캐스트 -``` - -**주요 기능:** - -- RoomToken 기반 인증 (TTL 5분) -- BCrypt 비밀방 암호화 -- 슬래시 명령어 시스템 (/member, /game, /skip, /hint 등) -- Connection 자동 정리 (TTL + 실패 시 삭제) - -#### 2.2.2 캐치마인드 게임 - -```mermaid -flowchart TB - subgraph Game["캐치마인드 게임 흐름"] - START["#47;game 명령어"] --> INIT["게임 초기화
출제 순서 셔플"] - INIT --> ROUND[라운드 시작
출제자 + 단어 선정] - ROUND --> DRAW[출제자 그림 그리기] - DRAW --> GUESS[참가자 정답 입력] - GUESS --> CHECK{정답?} - CHECK -->|Yes| SCORE[점수 계산
시간보너스 + 연속정답보너스] - CHECK -->|No| GUESS - SCORE --> ALLCORRECT{전원 정답?} - ALLCORRECT -->|Yes| NEXTROUND - ALLCORRECT -->|No| TIMEOUT{시간 초과?} - TIMEOUT -->|Yes| NEXTROUND[다음 라운드] - TIMEOUT -->|No| GUESS - NEXTROUND --> LASTROUND{마지막 라운드?} - LASTROUND -->|Yes| END[게임 종료
순위 발표] - LASTROUND -->|No| ROUND - end -``` - -**점수 계산:** - -``` -점수 = 기본점수(10) + 시간보너스((60-경과초)*0.5) + 연속정답보너스(streak*2) -출제자 보너스 = 정답자당 5점 -``` - -**주요 기능:** - -- 실시간 점수 브로드캐스트 -- 연속 정답 스트릭 시스템 -- 접속자 변동 시 출제자 자동 재선정 -- 라운드별 순위 표시 - ---- - -### 2.3 Grammar Domain (문법 체크) - -#### 2.3.1 AI 스트리밍 응답 - -```mermaid -sequenceDiagram - participant Client - participant WS as Grammar WebSocket - participant Handler as GrammarStreamingHandler - participant Bedrock as AWS Bedrock - Client ->> WS: 문법 체크 요청 - WS ->> Handler: Lambda 호출 - Handler ->> Bedrock: 스트리밍 요청 (Claude 3.5 Sonnet) - - loop 청크 단위 응답 - Bedrock -->> Handler: 텍스트 청크 - Handler -->> WS: 실시간 전송 - WS -->> Client: 즉시 표시 - end - - Handler -->> Client: [DONE] 완료 - Handler ->> DB: 피드백 저장 -``` - -**주요 기능:** - -- Claude 3.5 Sonnet 모델 사용 -- 스트리밍으로 체감 대기 시간 80% 감소 -- 레벨별 맞춤 프롬프트 (BEGINNER: 한국어 번역 포함) -- 대화 히스토리 저장으로 문맥 유지 -- 피드백 영구 저장 (DynamoDB) - ---- - -### 2.4 Stats Domain (학습 통계) - -```mermaid -flowchart LR - subgraph StatsTypes["통계 유형"] - DAILY["일별 통계
#47;stats#47;daily"] - WEEKLY["주별 통계
#47;stats#47;weekly"] - MONTHLY["월별 통계
#47;stats#47;monthly"] - TOTAL["전체 통계
#47;stats#47;total"] - HISTORY["히스토리
#47;stats#47;history"] - end -``` - -**통계 항목:** - -| 필드 | 설명 | -|-------------------|-------------| -| testsCompleted | 완료한 테스트 수 | -| questionsAnswered | 답변한 문제 수 | -| correctAnswers | 정답 수 | -| incorrectAnswers | 오답 수 | -| successRate | 정답률 (%) | -| newWordsLearned | 새로 학습한 단어 수 | -| wordsReviewed | 복습한 단어 수 | -| currentStreak | 현재 연속 학습일 | -| longestStreak | 최장 연속 학습일 | -| gamesPlayed | 참여한 게임 수 | -| gamesWon | 1등 횟수 | -| totalGameScore | 누적 게임 점수 | - -**DynamoDB Streams 기반 비동기 집계:** - -- 테스트 결과 저장 시 자동 트리거 -- API 응답과 분리되어 응답 속도 향상 - ---- - -### 2.5 Badge Domain (배지 시스템) - -```mermaid -flowchart TB - subgraph BadgeSystem["배지 시스템"] - TRIGGER[통계 업데이트] --> CHECK[배지 조건 체크] - CHECK --> AWARD{조건 달성?} - AWARD -->|Yes| SAVE[배지 부여 + 저장] - AWARD -->|No| END[종료] - SAVE --> NOTIFY[프론트엔드 조회] - end -``` - -**배지 종류:** - -| Badge Type | 이름 | 조건 | -|----------------------|---------|------------| -| FIRST_STEP | 첫 걸음 | 첫 학습 완료 | -| STREAK_3, 7, 30 | 연속 학습 | N일 연속 학습 | -| WORDS_100, 500, 1000 | 단어 학습 | N개 단어 학습 | -| PERFECT_SCORE | 완벽주의자 | 테스트 만점 | -| ACCURACY_90 | 정확도 달인 | 전체 정확도 90% | -| GAME_FIRST_PLAY | 첫 게임 | 첫 게임 참여 | -| GAME_10_WINS | 게임 10승 | 10번 1등 | -| QUICK_GUESSER | 번개 정답 | 5초 내 정답 | -| PERFECT_DRAWER | 완벽한 출제자 | 전원 정답 유도 | - -**기술적 특징:** - -- S3 Presigned URL로 배지 이미지 제공 (1시간 유효) -- 획득/미획득 배지 + 진행도 표시 - ---- - -## 3. 기술적 성과 - -### 3.1 아키텍처 패턴 - -| 패턴 | 적용 영역 | 효과 | -|------------------|----------|----------------------------| -| **CQRS** | 전 도메인 | 읽기/쓰기 책임 분리, 테스트 용이성 | -| **State** | 단어 학습 상태 | 복잡한 조건문 제거, 확장성 | -| **Factory** | AI 서비스 | 서비스 교체 용이 (Claude ↔ Llama) | -| **Event-Driven** | 통계/배지 | 느슨한 결합, 비동기 처리 | - -### 3.2 DynamoDB 설계 - -**Single Table Design:** - -- Vocab Table: 단어, 사용자단어, 테스트, 일일학습, 통계, 배지, 문법 -- Chat Table: 채팅방, 메시지, 연결, 게임라운드 - -**GSI 구성:** - -| GSI | 용도 | -|------|---------------------| -| GSI1 | 레벨별 단어 조회, 복습 예정 단어 | -| GSI2 | 카테고리별 단어, 상태별 사용자단어 | -| GSI3 | 북마크 단어 조회 | - -### 3.3 보안 - -- Cognito 인증 (idToken) -- WebSocket RoomToken 인증 (TTL 5분) -- BCrypt 비밀방 암호화 -- S3 Presigned URL (배지 이미지) - -### 3.4 성능 최적화 - -| 최적화 | 효과 | -|--------------------------|-------------------------| -| TTS S3 캐싱 | Polly API 호출 90% 절감 | -| 배치 처리 | 최대 100개 단어 일괄 처리 | -| Strongly Consistent Read | 데이터 정합성 보장 | -| DynamoDB Streams | 비동기 통계 집계로 응답 속도 50% 향상 | -| AI 스트리밍 | 체감 대기 시간 80% 감소 | - ---- - -## 4. API 엔드포인트 요약 - -### REST API (https://gc8l9ijhzc.execute-api.ap-northeast-2.amazonaws.com/dev) - -| Method | Path | 설명 | -|--------|-------------------------------------|-----------| -| GET | /vocab/words | 단어 목록 조회 | -| POST | /vocab/words | 단어 등록 | -| GET | /vocab/daily | 오늘의 학습 단어 | -| POST | /vocab/daily/words/{wordId}/learned | 단어 학습 완료 | -| POST | /vocab/tests | 테스트 생성 | -| POST | /vocab/tests/{testId}/submit | 테스트 제출 | -| GET | /stats/daily | 일별 통계 | -| GET | /stats/weekly | 주별 통계 | -| GET | /stats/monthly | 월별 통계 | -| GET | /stats/total | 전체 통계 | -| GET | /stats/history?limit=100 | 통계 히스토리 | -| GET | /badges | 전체 배지 목록 | -| GET | /badges/earned | 획득한 배지 | -| GET | /rooms | 채팅방 목록 | -| POST | /rooms | 채팅방 생성 | -| POST | /rooms/{roomId}/join | 채팅방 입장 | -| POST | /grammar/check | 문법 체크 | - -### WebSocket API - -| Endpoint | 설명 | -|---------------------------------------------------------------|---------| -| wss://t378dif43l.execute-api.ap-northeast-2.amazonaws.com/dev | 채팅/게임 | -| wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev | 문법 스트리밍 | - ---- - -## 5. 프로젝트 구조 - -``` -ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/ -├── common/ # 공통 모듈 -│ ├── config/ # AWS 클라이언트 (싱글톤) -│ ├── router/ # HandlerRouter, Route -│ ├── exception/ # 예외 처리 체계 -│ ├── dto/ # PaginatedResult, ErrorInfo -│ └── util/ # ResponseGenerator, CursorUtil -│ -├── domain/ -│ ├── vocabulary/ # 단어 학습 도메인 -│ │ ├── handler/ # Word, UserWord, Test, DailyStudy 핸들러 -│ │ ├── service/ # CQRS 서비스 (Command/Query) -│ │ ├── repository/ # DynamoDB 레포지토리 -│ │ ├── model/ # Word, UserWord, TestResult, DailyStudy -│ │ └── state/ # NEW, LEARNING, REVIEWING, MASTERED -│ │ -│ ├── chatting/ # 채팅 도메인 -│ │ ├── handler/ # REST + WebSocket 핸들러 -│ │ ├── service/ # ChatRoom, Game, Command 서비스 -│ │ └── model/ # ChatRoom, Connection, GameRound -│ │ -│ ├── grammar/ # 문법 체크 도메인 -│ │ ├── handler/ # REST + 스트리밍 핸들러 -│ │ ├── service/ # GrammarCheck, Conversation 서비스 -│ │ └── factory/ # BedrockGrammarCheckFactory -│ │ -│ ├── stats/ # 통계 도메인 -│ │ ├── handler/ # UserStats, Streams 핸들러 -│ │ └── repository/ # UserStatsRepository -│ │ -│ └── badge/ # 배지 도메인 -│ ├── handler/ # BadgeHandler -│ └── service/ # BadgeService -``` - ---- - -## 6. 성과 요약 - -| 카테고리 | 성과 | -|------------------|------------------------------------| -| **Lambda 함수** | 26개 | -| **API 엔드포인트** | REST 40+, WebSocket 2 | -| **DynamoDB 테이블** | 2개 (Single Table Design) | -| **GSI** | 5개 | -| **아키텍처 패턴** | CQRS, State, Factory, Event-Driven | -| **AI 연동** | Bedrock Claude 3.5 Sonnet (문법/대화) | -| **TTS** | AWS Polly (남성/여성 음성) | -| **실시간 통신** | WebSocket (채팅/게임/문법 스트리밍) | -| **인증** | Cognito + RoomToken | - ---- - -**작성일:** 2026-01-16 -**팀:** MZC 2nd Project Team / SMJ diff --git a/docs/domain-reports/BADGE-DOMAIN-REPORT.md b/docs/domain-reports/BADGE-DOMAIN-REPORT.md deleted file mode 100644 index 4cd58215..00000000 --- a/docs/domain-reports/BADGE-DOMAIN-REPORT.md +++ /dev/null @@ -1,681 +0,0 @@ -# Badge Domain 세부 보고서 - -## 1. 개요 - -Badge 도메인은 사용자의 학습 성취도에 따라 배지를 자동으로 부여하는 시스템입니다. 이벤트 기반 아키텍처를 통해 Stats, Vocabulary, Chatting 도메인과 연동되어 실시간으로 배지를 체크하고 -부여합니다. - ---- - -## 2. 전체 아키텍처 - -```mermaid -flowchart TB - subgraph Triggers["트리거 소스"] - TEST[테스트 완료
DynamoDB Streams] - WORD[단어 학습
Write-through] - GAME[게임 종료
Service Method] - end - - subgraph Processing["Badge 처리"] - CHECK[BadgeService
조건 체크] - AWARD[배지 부여] - end - - subgraph Storage["저장소"] - DDB[(DynamoDB
UserBadge)] - S3[(S3
배지 이미지)] - end - - subgraph Query["조회"] - API[BadgeHandler
REST API] - PRESIGN[S3 Presigned URL] - end - - TEST --> CHECK - WORD --> CHECK - GAME --> CHECK - CHECK --> AWARD - AWARD --> DDB - DDB --> API - S3 --> PRESIGN - PRESIGN --> API -``` - ---- - -## 3. 배지 종류 - -### 3.1 배지 카테고리 - -```mermaid -mindmap - root((배지 시스템)) - 학습 - FIRST_STEP[첫 걸음] - WORDS_100[단어 수집가] - WORDS_500[단어 전문가] - WORDS_1000[단어 마스터] - 연속학습 - STREAK_3[3일 연속] - STREAK_7[7일 연속] - STREAK_30[30일 연속] - 테스트 - PERFECT_SCORE[완벽주의자] - TEST_10[테스트 도전자] - ACCURACY_90[정확도 달인] - 게임 - GAME_FIRST[첫 게임] - GAME_10_WINS[10승 달성] - QUICK_GUESSER[번개 정답] - PERFECT_DRAWER[완벽한 출제자] - 최종 - MASTER[학습 마스터] -``` - -### 3.2 배지 상세 - -| Badge Type | 이름 | 설명 | 카테고리 | 조건 | -|-----------------|-----------|--------------------|-----------------|-----------------------| -| FIRST_STEP | 첫 걸음 | 첫 학습을 완료했습니다 | FIRST_STUDY | testsCompleted >= 1 | -| STREAK_3 | 3일 연속 학습 | 3일 연속으로 학습했습니다 | STREAK | currentStreak >= 3 | -| STREAK_7 | 일주일 연속 학습 | 7일 연속으로 학습했습니다 | STREAK | currentStreak >= 7 | -| STREAK_30 | 한 달 연속 학습 | 30일 연속으로 학습했습니다 | STREAK | currentStreak >= 30 | -| WORDS_100 | 단어 수집가 | 100개의 단어를 학습했습니다 | WORDS_LEARNED | totalWords >= 100 | -| WORDS_500 | 단어 전문가 | 500개의 단어를 학습했습니다 | WORDS_LEARNED | totalWords >= 500 | -| WORDS_1000 | 단어 마스터 | 1000개의 단어를 학습했습니다 | WORDS_LEARNED | totalWords >= 1000 | -| PERFECT_SCORE | 완벽주의자 | 테스트에서 만점을 받았습니다 | PERFECT_TEST | incorrectAnswers == 0 | -| TEST_10 | 테스트 도전자 | 10회의 테스트를 완료했습니다 | TESTS_COMPLETED | testsCompleted >= 10 | -| ACCURACY_90 | 정확도 달인 | 전체 정확도 90%를 달성했습니다 | ACCURACY | successRate >= 90 | -| GAME_FIRST_PLAY | 첫 게임 | 첫 게임에 참여했습니다 | GAMES_PLAYED | gamesPlayed >= 1 | -| GAME_10_WINS | 게임 10승 | 게임에서 10번 1등을 했습니다 | GAMES_WON | gamesWon >= 10 | -| QUICK_GUESSER | 번개 정답 | 5초 내에 정답을 맞췄습니다 | QUICK_GUESSES | quickGuesses >= 1 | -| PERFECT_DRAWER | 완벽한 출제자 | 출제 시 전원이 정답을 맞췄습니다 | PERFECT_DRAWS | perfectDraws >= 1 | -| MASTER | 학습 마스터 | 모든 업적을 달성했습니다 | ALL_BADGES | 모든 배지 획득 | - ---- - -## 4. 배지 부여 흐름 - -### 4.1 테스트 완료 시 - -```mermaid -sequenceDiagram - participant Test as TestResult - participant Streams as DynamoDB Streams - participant Handler as StatsStreamHandler - participant Stats as UserStats - participant Badge as BadgeService - participant DB as DynamoDB - Test ->> Streams: INSERT 이벤트 - Streams ->> Handler: 트리거 - Handler ->> Stats: incrementTestStats() - Handler ->> Stats: updateStudyStreak() - Note over Handler: 만점 체크 - alt 정답 > 0 && 오답 == 0 - Handler ->> Badge: awardBadge("PERFECT_SCORE") - Badge ->> DB: UserBadge 저장 - end - - Handler ->> Stats: findTotalStats() - Stats -->> Handler: UserStats - Handler ->> Badge: checkAndAwardBadges() - Badge ->> Badge: 각 배지 조건 체크 - Badge ->> DB: 획득 배지 저장 -``` - -### 4.2 단어 학습 시 - -```mermaid -sequenceDiagram - participant API as DailyStudyHandler - participant Service as DailyStudyCommandService - participant Stats as UserStatsRepository - participant Badge as BadgeService - participant DB as DynamoDB - API ->> Service: markWordLearned() - Service ->> Stats: incrementWordsLearned() - Note over Service: 배지 체크 (WORDS_xxx) - Service ->> Stats: findTotalStats() - Stats -->> Service: UserStats - Service ->> Badge: checkAndAwardBadges() - Badge ->> Badge: WORDS_100, 500, 1000 체크 - Badge ->> DB: 획득 배지 저장 -``` - -### 4.3 게임 종료 시 - -```mermaid -sequenceDiagram - participant Game as GameService - participant Stats as GameStatsService - participant Repo as UserStatsRepository - participant Badge as BadgeService - participant DB as DynamoDB - Game ->> Stats: updateGameStats(room) - - loop 각 참가자 - Stats ->> Stats: 점수 집계 - Note over Stats: correctGuesses
quickGuesses (5초 이내)
perfectDraws - Stats ->> Repo: incrementGameStats() - Stats ->> Repo: findTotalStats() - Repo -->> Stats: UserStats - Stats ->> Badge: checkAndAwardBadges() - Badge ->> Badge: GAME_xxx 배지 체크 - Badge ->> DB: 획득 배지 저장 - end -``` - ---- - -## 5. 배지 조건 체크 로직 - -### 5.1 카테고리별 조건 - -```mermaid -flowchart TB - START[checkAndAwardBadges] --> LOOP{모든 BadgeType 순회} - LOOP --> EARNED{이미 획득?} - EARNED -->|Yes| SKIP[건너뛰기] - EARNED -->|No| CHECK[조건 체크] - CHECK --> SWITCH{카테고리} - SWITCH -->|FIRST_STUDY| FS[testsCompleted >= 1] - SWITCH -->|STREAK| ST[currentStreak >= threshold] - SWITCH -->|WORDS_LEARNED| WL[totalWords >= threshold] - SWITCH -->|PERFECT_TEST| PT[별도 처리] - SWITCH -->|TESTS_COMPLETED| TC[testsCompleted >= threshold] - SWITCH -->|ACCURACY| AC[successRate >= threshold] - SWITCH -->|GAMES_PLAYED| GP[gamesPlayed >= threshold] - SWITCH -->|GAMES_WON| GW[gamesWon >= threshold] - SWITCH -->|QUICK_GUESSES| QG[quickGuesses >= threshold] - SWITCH -->|PERFECT_DRAWS| PD[perfectDraws >= threshold] - SWITCH -->|ALL_BADGES| AB[모든 배지 획득 체크] - FS --> RESULT{조건 충족?} - ST --> RESULT - WL --> RESULT - TC --> RESULT - AC --> RESULT - GP --> RESULT - GW --> RESULT - QG --> RESULT - PD --> RESULT - RESULT -->|Yes| AWARD[배지 부여] - RESULT -->|No| SKIP - AWARD --> LOOP - SKIP --> LOOP -``` - -### 5.2 Switch Expression 패턴 - -```java -private boolean checkBadgeCondition(BadgeType type, UserStats stats) { - return switch (type.getCategory()) { - case "FIRST_STUDY" -> stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1; - - case "STREAK" -> stats.getCurrentStreak() != null && - stats.getCurrentStreak() >= type.getThreshold(); - - case "WORDS_LEARNED" -> { - int total = (stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0) - + (stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0); - yield total >= type.getThreshold(); - } - - case "ACCURACY" -> { - if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) - yield false; - double accuracy = (stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered(); - yield accuracy >= type.getThreshold(); - } - - case "TESTS_COMPLETED" -> stats.getTestsCompleted() != null && - stats.getTestsCompleted() >= type.getThreshold(); - - case "GAMES_PLAYED" -> stats.getGamesPlayed() != null && - stats.getGamesPlayed() >= type.getThreshold(); - - case "GAMES_WON" -> stats.getGamesWon() != null && - stats.getGamesWon() >= type.getThreshold(); - - case "QUICK_GUESSES" -> stats.getQuickGuesses() != null && - stats.getQuickGuesses() >= type.getThreshold(); - - case "PERFECT_DRAWS" -> stats.getPerfectDraws() != null && - stats.getPerfectDraws() >= type.getThreshold(); - - case "PERFECT_TEST" -> false; // 별도 처리 (StatsStreamHandler) - case "ALL_BADGES" -> false; // 특수 로직 필요 - - default -> false; - }; -} -``` - ---- - -## 6. API 엔드포인트 - -### 6.1 REST API - -| Method | Endpoint | 설명 | 응답 | -|--------|----------------|----------------|-------------| -| GET | /badges | 전체 배지 목록 + 진행도 | BadgeInfo[] | -| GET | /badges/earned | 획득한 배지만 조회 | UserBadge[] | - -### 6.2 전체 배지 조회 응답 - -```json -{ - "message": "Badges retrieved", - "data": { - "badges": [ - { - "badgeType": "FIRST_STEP", - "name": "첫 걸음", - "description": "첫 학습을 완료했습니다", - "imageUrl": "https://...presigned.../badges/first_step.png", - "category": "FIRST_STUDY", - "threshold": 1, - "progress": 1, - "earned": true, - "earnedAt": "2026-01-16T10:30:45.123Z" - }, - { - "badgeType": "WORDS_100", - "name": "단어 수집가", - "description": "100개의 단어를 학습했습니다", - "imageUrl": "https://...presigned.../badges/words_100.png", - "category": "WORDS_LEARNED", - "threshold": 100, - "progress": 45, - "earned": false, - "earnedAt": null - } - ], - "totalCount": 16, - "earnedCount": 8 - } -} -``` - -### 6.3 획득 배지 조회 응답 - -```json -{ - "message": "Earned badges retrieved", - "data": { - "badges": [ - { - "badgeType": "FIRST_STEP", - "name": "첫 걸음", - "description": "첫 학습을 완료했습니다", - "imageUrl": "https://...presigned.../badges/first_step.png", - "category": "FIRST_STUDY", - "threshold": 1, - "progress": 1, - "earnedAt": "2026-01-16T10:30:45.123Z" - } - ], - "count": 8 - } -} -``` - ---- - -## 7. 데이터 모델 - -### 7.1 UserBadge - -```java - -@DynamoDbBean -public class UserBadge { - // 기본 키 - String pk; // USER#{userId}#BADGE - String sk; // BADGE#{badgeType} - - // GSI (전체 배지 조회) - String gsi1pk; // BADGE#ALL - String gsi1sk; // EARNED#{earnedAt} - - // 메타데이터 - String odUserId; - String badgeType; // BadgeType enum 이름 - String name; - String description; - String imageUrl; - String category; - Integer threshold; - Integer progress; // 획득 시점 진행도 - - // 타임스탬프 - String earnedAt; - String createdAt; -} -``` - -### 7.2 DynamoDB 키 구조 - -| 필드 | 패턴 | 예시 | -|--------|---------------------|-----------------------------| -| PK | USER#{userId}#BADGE | USER#abc123#BADGE | -| SK | BADGE#{badgeType} | BADGE#STREAK_7 | -| GSI1PK | BADGE#ALL | BADGE#ALL | -| GSI1SK | EARNED#{earnedAt} | EARNED#2026-01-16T10:30:45Z | - -### 7.3 BadgeType Enum - -```java -public enum BadgeType { - FIRST_STEP("첫 걸음", "첫 학습을 완료했습니다", - "FIRST_STUDY", 1, "first_step.png"), - STREAK_3("3일 연속 학습", "3일 연속으로 학습했습니다", - "STREAK", 3, "streak_3.png"), - STREAK_7("일주일 연속 학습", "7일 연속으로 학습했습니다", - "STREAK", 7, "streak_7.png"), - // ... 생략 - MASTER("학습 마스터", "모든 업적을 달성했습니다", - "ALL_BADGES", 1, "master.png"); - - private final String name; - private final String description; - private final String category; - private final int threshold; - private final String imageFile; -} -``` - ---- - -## 8. 진행도 계산 - -### 8.1 카테고리별 진행도 - -```mermaid -flowchart TB - subgraph Progress["진행도 계산"] - FIRST["FIRST_STUDY
testsCompleted >= 1 ? 1 : 0"] - STREAK["STREAK
currentStreak"] - WORDS["WORDS_LEARNED
newWords + reviewed"] - TESTS["TESTS_COMPLETED
testsCompleted"] - ACC["ACCURACY
successRate (%)"] - GAMES["GAMES_PLAYED
gamesPlayed"] - WINS["GAMES_WON
gamesWon"] - QUICK["QUICK_GUESSES
quickGuesses"] - PERFECT["PERFECT_DRAWS
perfectDraws"] - end -``` - -### 8.2 calculateProgress 메서드 - -```java -private int calculateProgress(BadgeType type, UserStats stats) { - return switch (type.getCategory()) { - case "FIRST_STUDY" -> (stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1) ? 1 : 0; - - case "STREAK" -> stats.getCurrentStreak() != null ? stats.getCurrentStreak() : 0; - - case "WORDS_LEARNED" -> { - int newWords = stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0; - int reviewed = stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0; - yield newWords + reviewed; - } - - case "TESTS_COMPLETED" -> stats.getTestsCompleted() != null ? stats.getTestsCompleted() : 0; - - case "ACCURACY" -> { - if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) - yield 0; - yield (int) ((stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered()); - } - - case "GAMES_PLAYED" -> stats.getGamesPlayed() != null ? stats.getGamesPlayed() : 0; - - case "GAMES_WON" -> stats.getGamesWon() != null ? stats.getGamesWon() : 0; - - case "QUICK_GUESSES" -> stats.getQuickGuesses() != null ? stats.getQuickGuesses() : 0; - - case "PERFECT_DRAWS" -> stats.getPerfectDraws() != null ? stats.getPerfectDraws() : 0; - - default -> 0; - }; -} -``` - ---- - -## 9. 멱등성 보장 - -### 9.1 중복 부여 방지 흐름 - -```mermaid -flowchart TB - START[checkAndAwardBadges] --> LOOP[배지 타입 순회] - LOOP --> CHECK{hasBadge?} - CHECK -->|이미 있음| SKIP[건너뛰기] - CHECK -->|없음| CONDITION{조건 충족?} - CONDITION -->|Yes| CREATE[배지 생성] - CONDITION -->|No| SKIP - CREATE --> SAVE[DynamoDB 저장] - SAVE --> LOOP - SKIP --> LOOP -``` - -### 9.2 구현 코드 - -```java -public List checkAndAwardBadges(String userId, UserStats stats) { - List newBadges = new ArrayList<>(); - String now = Instant.now().toString(); - - for (BadgeType type : BadgeType.values()) { - // 1. 이미 획득한 배지는 건너뛰기 - if (badgeRepository.hasBadge(userId, type.name())) { - continue; - } - - // 2. 조건 체크 - if (checkBadgeCondition(type, stats)) { - // 3. 배지 생성 및 저장 - UserBadge badge = createBadge(userId, type, now); - badgeRepository.save(badge); - newBadges.add(badge); - } - } - - return newBadges; -} -``` - ---- - -## 10. S3 이미지 연동 - -### 10.1 Presigned URL 생성 - -```mermaid -flowchart LR - REQ[배지 조회] --> SERVICE[BadgeService] - SERVICE --> PRESIGN[S3PresignUtil] - PRESIGN --> CACHE{캐시 확인} - CACHE -->|있음| RETURN[URL 반환] - CACHE -->|없음| GENERATE[Presigned URL 생성] - GENERATE --> SAVE[캐시 저장] - SAVE --> RETURN -``` - -### 10.2 이미지 URL 생성 - -```java -// S3PresignUtil.java -public static String getBadgeImageUrl(String imageFile) { - return getPresignedUrl("badges/" + imageFile); -} - -// BadgeService - 배지 생성 시 -private UserBadge createBadge(String userId, BadgeType type, String now) { - return UserBadge.builder() - .pk(BadgeKey.userBadgePk(userId)) - .sk(BadgeKey.badgeSk(type.name())) - .gsi1pk(BadgeKey.BADGE_ALL) - .gsi1sk(BadgeKey.earnedSk(now)) - .odUserId(userId) - .badgeType(type.name()) - .name(type.getName()) - .description(type.getDescription()) - .imageUrl(S3PresignUtil.getBadgeImageUrl(type.getImageFile())) - .category(type.getCategory()) - .threshold(type.getThreshold()) - .earnedAt(now) - .createdAt(now) - .build(); -} -``` - -### 10.3 S3 버킷 구조 - -``` -s3://group2-englishstudy/ -└── badges/ - ├── first_step.png - ├── streak_3.png - ├── streak_7.png - ├── streak_30.png - ├── words_100.png - ├── words_500.png - ├── words_1000.png - ├── perfect_score.png - ├── test_10.png - ├── accuracy_90.png - ├── game_first.png - ├── game_10_wins.png - ├── quick_guesser.png - ├── perfect_drawer.png - └── master.png -``` - ---- - -## 11. Stats 도메인 연동 - -### 11.1 연동 포인트 - -```mermaid -flowchart TB - subgraph Stats["Stats 도메인"] - STREAM[StatsStreamHandler] - DAILY[DailyStudyCommandService] - GAME[GameStatsService] - REPO[UserStatsRepository] - end - - subgraph Badge["Badge 도메인"] - SERVICE[BadgeService] - BADGEREPO[BadgeRepository] - end - - STREAM -->|checkAndAwardBadges| SERVICE - DAILY -->|checkWordsBadge| SERVICE - GAME -->|checkAndAwardBadges| SERVICE - SERVICE -->|hasBadge, save| BADGEREPO - SERVICE -->|findTotalStats| REPO -``` - -### 11.2 UserStats 필드와 배지 매핑 - -| UserStats 필드 | 배지 | -|------------------------------------|----------------------------------| -| testsCompleted | FIRST_STEP, TEST_10 | -| currentStreak | STREAK_3, STREAK_7, STREAK_30 | -| newWordsLearned + wordsReviewed | WORDS_100, WORDS_500, WORDS_1000 | -| correctAnswers / questionsAnswered | ACCURACY_90 | -| gamesPlayed | GAME_FIRST_PLAY | -| gamesWon | GAME_10_WINS | -| quickGuesses | QUICK_GUESSER | -| perfectDraws | PERFECT_DRAWER | - ---- - -## 12. 파일 구조 - -``` -domain/badge/ -├── enums/ -│ └── BadgeType.java # 16가지 배지 정의 -├── constants/ -│ └── BadgeKey.java # DynamoDB 키 생성 -├── model/ -│ └── UserBadge.java # 배지 엔티티 -├── repository/ -│ └── BadgeRepository.java # CRUD 연산 -├── service/ -│ └── BadgeService.java # 조건 체크, 배지 부여 -└── handler/ - └── BadgeHandler.java # REST API - -연동 파일: -├── domain/stats/handler/StatsStreamHandler.java -├── domain/vocabulary/service/DailyStudyCommandService.java -└── domain/chatting/service/GameStatsService.java -``` - ---- - -## 13. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **Database:** DynamoDB (Single Table Design) -- **Storage:** S3 (배지 이미지) -- **Event:** DynamoDB Streams, Write-through, Service Method -- **Pattern:** Event-driven, Idempotent, Switch Expression -- **Java 21 Features:** Enhanced Switch, Yield Statement - ---- - -## 14. 배지 획득 시나리오 - -### 14.1 시나리오 예시 - -```mermaid -flowchart LR - subgraph Day1["1일차"] - A1[테스트 완료] --> B1["FIRST_STEP 획득"] - end - - subgraph Day3["3일차"] - A3[3일 연속 학습] --> B3["STREAK_3 획득"] - end - - subgraph Day7["7일차"] - A7[7일 연속 학습] --> B7["STREAK_7 획득"] - A7_2[100단어 학습] --> B7_2["WORDS_100 획득"] - end - - subgraph Game["게임"] - G1[5초 내 정답] --> G2["QUICK_GUESSER 획득"] - G3[10회 1등] --> G4["GAME_10_WINS 획득"] - end -``` - -### 14.2 특수 배지 획득 조건 - -**PERFECT_SCORE (완벽주의자):** - -- 테스트 제출 시 오답 0개이면 즉시 부여 -- StatsStreamHandler에서 별도 처리 - -**QUICK_GUESSER (번개 정답):** - -- 게임 중 5초(5000ms) 이내 정답 시 -- GameStatsService에서 quickGuesses 카운트 - -**PERFECT_DRAWER (완벽한 출제자):** - -- 출제 시 모든 참가자가 정답을 맞춘 경우 -- 라운드 종료 시 endReason == "ALL_CORRECT"이면 카운트 - -**MASTER (학습 마스터):** - -- 다른 모든 배지를 획득한 경우 -- 특수 로직으로 모든 배지 보유 여부 확인 diff --git a/docs/domain-reports/CHATTING-DOMAIN-REPORT.md b/docs/domain-reports/CHATTING-DOMAIN-REPORT.md deleted file mode 100644 index c27eb552..00000000 --- a/docs/domain-reports/CHATTING-DOMAIN-REPORT.md +++ /dev/null @@ -1,434 +0,0 @@ -# Chatting Domain 세부 보고서 - -## 1. 개요 - -Chatting 도메인은 실시간 채팅과 캐치마인드 게임 기능을 제공하는 WebSocket 기반 시스템입니다. AWS API Gateway WebSocket과 Lambda를 활용하여 실시간 양방향 통신을 구현했습니다. - ---- - -## 2. 전체 아키텍처 - -```mermaid -flowchart TB - subgraph Client["클라이언트"] - APP[Mobile/Web App] - end - - subgraph Gateway["API Gateway"] - REST[REST API] - WS[WebSocket API] - end - - subgraph Lambda["Lambda Handlers"] - direction TB - ROOM[ChatRoomHandler] - MSG[ChatMessageHandler] - GAME[GameHandler] - VOICE[ChatVoiceHandler] - CONNECT[WebSocketConnectHandler] - DISCONNECT[WebSocketDisconnectHandler] - MESSAGE[WebSocketMessageHandler] - end - - subgraph Storage["데이터 저장소"] - DDB[(DynamoDB)] - S3[(S3 - 음성 캐시)] - end - - APP --> REST - APP <--> WS - REST --> ROOM - REST --> MSG - REST --> GAME - REST --> VOICE - WS --> CONNECT - WS --> DISCONNECT - WS --> MESSAGE - ROOM --> DDB - MSG --> DDB - GAME --> DDB - MESSAGE --> DDB - VOICE --> S3 -``` - ---- - -## 3. 채팅방 시스템 - -### 3.1 채팅방 입장 흐름 - -```mermaid -sequenceDiagram - participant Client - participant REST as REST API - participant WS as WebSocket API - participant DB as DynamoDB - Note over Client, DB: Phase 1 - 방 입장 및 토큰 발급 - Client ->> REST: POST /rooms/{roomId}/join - REST ->> DB: 비밀번호 검증 (비밀방인 경우) - REST ->> DB: RoomToken 저장 (TTL 5분) - REST -->> Client: roomToken 반환 - Note over Client, DB: Phase 2 - WebSocket 연결 - Client ->> WS: $connect?roomToken={token} - WS ->> DB: 토큰 검증 - WS ->> DB: Connection 저장 (TTL 10분) - WS -->> Client: 연결 성공 - Note over Client, DB: Phase 3 - 실시간 메시지 - Client ->> WS: sendMessage (채팅) - WS ->> DB: 메시지 저장 - WS -->> Client: 브로드캐스트 (같은 방 전체) -``` - -### 3.2 REST API 엔드포인트 - -| Method | Endpoint | 설명 | 인증 | -|--------|-------------------------------|---------------------------|----| -| POST | /chat/rooms | 채팅방 생성 | O | -| GET | /chat/rooms | 채팅방 목록 (level, joined 필터) | O | -| GET | /chat/rooms/{roomId} | 채팅방 상세 | O | -| POST | /chat/rooms/{roomId}/join | 채팅방 입장 (토큰 발급) | O | -| POST | /chat/rooms/{roomId}/leave | 채팅방 퇴장 | O | -| DELETE | /chat/rooms/{roomId} | 채팅방 삭제 (방장만) | O | -| GET | /chat/rooms/{roomId}/messages | 메시지 히스토리 | O | - -### 3.3 WebSocket 이벤트 - -| Route | 설명 | Payload | -|-------------|------------|------------------------------------------| -| $connect | 연결 (토큰 검증) | ?roomToken={token} | -| $disconnect | 연결 해제 | - | -| sendMessage | 메시지 전송 | { roomId, userId, content, messageType } | - ---- - -## 4. 캐치마인드 게임 시스템 - -### 4.1 게임 흐름 - -```mermaid -flowchart TB - subgraph GameFlow["캐치마인드 게임 흐름"] - START["/game 명령어"] --> INIT["게임 초기화
출제자 순서 셔플"] - INIT --> ROUND["라운드 시작
출제자 + 단어 선정"] - ROUND --> DRAW["출제자 그림 그리기
(DRAWING 메시지)"] - DRAW --> GUESS["참가자 정답 입력"] - GUESS --> CHECK{정답?} - CHECK -->|Yes| SCORE["점수 계산
시간보너스 + 연속보너스"] - CHECK -->|No| GUESS - SCORE --> ALLCORRECT{전원 정답?} - ALLCORRECT -->|Yes| NEXTROUND - ALLCORRECT -->|No| TIMEOUT{시간 초과?} - TIMEOUT -->|Yes| NEXTROUND["다음 라운드"] - TIMEOUT -->|No| GUESS - NEXTROUND --> LASTROUND{마지막 라운드?} - LASTROUND -->|Yes| END["게임 종료
순위 발표"] - LASTROUND -->|No| ROUND - end -``` - -### 4.2 게임 API - -| Method | Endpoint | 설명 | -|--------|----------------------------------|-------------| -| POST | /chat/rooms/{roomId}/game/start | 게임 시작 (방장만) | -| POST | /chat/rooms/{roomId}/game/stop | 게임 중지 | -| GET | /chat/rooms/{roomId}/game/status | 게임 상태 조회 | -| GET | /chat/rooms/{roomId}/game/scores | 점수판 조회 | - -### 4.3 슬래시 명령어 - -| 명령어 | 설명 | 사용 가능 | -|---------|----------------|--------| -| /start | 게임 시작 | 방장 | -| /stop | 게임 중지 | 방장/시작자 | -| /score | 점수판 보기 | 전체 | -| /member | 접속자 수 | 전체 | -| /hint | 힌트 제공 (첫글자○○○) | 출제자 | -| /skip | 라운드 스킵 | 출제자 | -| /help | 명령어 도움말 | 전체 | - -### 4.4 점수 계산 공식 - -``` -점수 = 기본점수(10) + 시간보너스 + 연속보너스 + 출제자보너스 - -- 시간보너스: (60 - 경과초) × 0.5 -- 연속보너스: streak × 2 -- 출제자보너스: 정답자당 5점 -``` - -**예시:** - -- 30초에 정답 + 연속 3회: 10 + 15 + 6 = 31점 -- 출제자가 3명 맞출 경우: 5 × 3 = 15점 - -### 4.5 게임 상태 - -```mermaid -stateDiagram-v2 - [*] --> NONE: 대기 - NONE --> PLAYING: /start 명령어 - PLAYING --> ROUND_END: 시간초과/전원정답 - ROUND_END --> PLAYING: 다음 라운드 - ROUND_END --> FINISHED: 마지막 라운드 - PLAYING --> FINISHED: /stop 명령어 - FINISHED --> [*]: 게임 종료 -``` - ---- - -## 5. WebSocket 메시지 타입 - -### 5.1 채팅 메시지 - -| Type | 설명 | 저장 | -|-------------|-------|----| -| TEXT | 일반 채팅 | O | -| IMAGE | 이미지 | O | -| VOICE | 음성 | O | -| AI_RESPONSE | AI 응답 | O | - -### 5.2 게임 메시지 - -| Type | 설명 | 저장 | -|----------------|--------------|----| -| DRAWING | 그림 데이터 (실시간) | X | -| DRAWING_CLEAR | 그림 지우기 | X | -| GUESS | 오답 추측 | X | -| CORRECT_ANSWER | 정답 알림 | X | -| SCORE_UPDATE | 점수 갱신 | X | -| GAME_START | 게임 시작 | X | -| ROUND_START | 라운드 시작 | X | -| ROUND_END | 라운드 종료 | X | -| GAME_END | 게임 종료 | X | -| HINT | 힌트 | X | - -### 5.3 실시간 점수 업데이트 메시지 - -```json -{ - "messageType": "SCORE_UPDATE", - "roomId": "uuid", - "scorerId": "user123", - "scoreGained": 25, - "ranking": [ - { - "rank": 1, - "userId": "user123", - "score": 85, - "change": 25 - }, - { - "rank": 2, - "userId": "user456", - "score": 60, - "change": 0 - } - ], - "currentRound": 3, - "totalRounds": 5 -} -``` - ---- - -## 6. 데이터 모델 - -### 6.1 ChatRoom - -```java - -@DynamoDbBean -public class ChatRoom { - // 기본 정보 - String roomId, name, description; - String level; // beginner, intermediate, advanced - Integer currentMembers, maxMembers; - Boolean isPrivate; - String password; // BCrypt 암호화 - String createdBy; // 방장 - List memberIds; - - // 게임 상태 - String gameStatus; // NONE, PLAYING, ROUND_END, FINISHED - Integer currentRound, totalRounds; - String currentDrawerId, currentWord; - Long roundStartTime; - Integer roundTimeLimit; // 60초 - List drawerOrder; - Map scores; - Map streaks; - List correctGuessers; - Boolean hintUsed; -} -``` - -**DynamoDB Keys:** - -- PK: `ROOM#{roomId}` | SK: `METADATA` -- GSI1: `ROOMS` | `{level}#{createdAt}` (레벨별 최신순) - -### 6.2 Connection - -```java - -@DynamoDbBean -public class Connection { - String connectionId; // API Gateway 연결 ID - String userId; - String roomId; - Long ttl; // 10분 (자동 삭제) -} -``` - -**DynamoDB Keys:** - -- PK: `CONN#{connectionId}` | SK: `METADATA` -- GSI1: `ROOM#{roomId}` | `CONN#{connectionId}` (방별 연결) -- GSI2: `USER#{userId}` | `CONN#{connectionId}` (사용자별 연결) - -### 6.3 GameRound - -```java - -@DynamoDbBean -public class GameRound { - Integer roundNumber; - String drawerId, word, wordEnglish; - List correctGuessers; - Map guessTimes; // 정답까지 걸린 시간 - Map roundScores; - Long startTime, endTime; - String endReason; // TIME_UP, ALL_CORRECT, SKIP - Long ttl; // 7일 -} -``` - -### 6.4 RoomToken - -```java - -@DynamoDbBean -public class RoomToken { - String token; // UUID - String roomId; - String userId; - Long ttl; // 5분 -} -``` - ---- - -## 7. 서비스 레이어 - -### 7.1 CQRS 패턴 - -| Service | 역할 | -|------------------------|----------------------| -| ChatRoomCommandService | 채팅방 생성, 입장, 퇴장, 삭제 | -| ChatRoomQueryService | 채팅방 조회, 목록 | -| GameService | 게임 시작, 정답 체크, 라운드 종료 | -| GameStatsService | 게임 종료 후 통계, 배지 처리 | -| CommandService | 슬래시 명령어 처리 | -| RoomTokenService | 토큰 발급 및 검증 | - -### 7.2 게임 정답 체크 로직 - -```mermaid -flowchart TB - INPUT[정답 입력] --> NORMALIZE["정규화
(소문자, 공백제거)"] - NORMALIZE --> VALIDATE{유효성 검사} - VALIDATE -->|게임 미진행| REJECT1[거부: 게임 없음] - VALIDATE -->|출제자 본인| REJECT2[거부: 출제자] - VALIDATE -->|이미 정답| REJECT3[거부: 중복] - VALIDATE -->|통과| COMPARE{정답 비교} - COMPARE -->|일치| CORRECT["정답 처리
점수 계산"] - COMPARE -->|불일치| WRONG["오답 처리
GUESS 메시지 전송"] - CORRECT --> BROADCAST["브로드캐스트
CORRECT_ANSWER + SCORE_UPDATE"] - WRONG --> GUESSBROADCAST["브로드캐스트
GUESS 메시지"] - BROADCAST --> ALLCHECK{전원 정답?} - ALLCHECK -->|Yes| ROUNDEND[라운드 자동 종료] - ALLCHECK -->|No| CONTINUE[게임 계속] -``` - ---- - -## 8. 브로드캐스트 시스템 - -### 8.1 WebSocketBroadcaster - -```java -public class WebSocketBroadcaster { - public List broadcast( - List connections, - String payload - ) { - // 1. 같은 방 모든 연결에 메시지 전송 - // 2. 실패한 연결 ID 반환 (Stale 정리용) - } -} -``` - -### 8.2 브로드캐스트 유형 - -| 유형 | 대상 | 예시 | -|--------|--------|-----------| -| 전체 | 방 전체 | 채팅, 정답 알림 | -| 본인 제외 | 발신자 제외 | 그림 데이터 | -| 출제자 전용 | 출제자만 | 단어 정보 | - ---- - -## 9. 파일 구조 - -``` -domain/chatting/ -├── handler/ -│ ├── ChatRoomHandler.java -│ ├── ChatMessageHandler.java -│ ├── ChatVoiceHandler.java -│ ├── GameHandler.java -│ └── websocket/ -│ ├── WebSocketConnectHandler.java -│ ├── WebSocketDisconnectHandler.java -│ └── WebSocketMessageHandler.java -├── service/ -│ ├── ChatRoomCommandService.java -│ ├── ChatRoomQueryService.java -│ ├── ChatMessageService.java -│ ├── GameService.java -│ ├── GameStatsService.java -│ ├── CommandService.java -│ └── RoomTokenService.java -├── repository/ -│ ├── ChatRoomRepository.java -│ ├── ChatMessageRepository.java -│ ├── ConnectionRepository.java -│ ├── GameRoundRepository.java -│ └── RoomTokenRepository.java -├── model/ -│ ├── ChatRoom.java -│ ├── ChatMessage.java -│ ├── Connection.java -│ ├── GameRound.java -│ └── RoomToken.java -├── dto/ -│ ├── request/ -│ └── response/ -│ └── ScoreUpdateMessage.java -└── enums/ - ├── GameStatus.java - └── MessageType.java -``` - ---- - -## 10. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **API:** API Gateway REST + WebSocket -- **Database:** DynamoDB (Single Table Design) -- **Auth:** Cognito + RoomToken -- **Encryption:** BCrypt (비밀방 암호) -- **TTS:** AWS Polly + S3 캐시 -- **Pattern:** CQRS, Repository, Factory diff --git a/docs/domain-reports/COMMON-MODULE-REPORT.md b/docs/domain-reports/COMMON-MODULE-REPORT.md deleted file mode 100644 index aefe6d08..00000000 --- a/docs/domain-reports/COMMON-MODULE-REPORT.md +++ /dev/null @@ -1,1228 +0,0 @@ -# Common Module 세부 보고서 - -## 1. 개요 - -Common 모듈은 모든 도메인에서 공유하는 유틸리티, 설정, 예외 처리, 라우팅 등을 제공하는 핵심 인프라 모듈입니다. Java 21의 최신 기능(Records, Sealed Interface, Pattern -Matching)을 적극 활용하여 타입 안전성과 코드 간결성을 확보했습니다. - ---- - -## 2. 전체 패키지 구조 - -```mermaid -flowchart TB -subgraph Common["common/"] -CONFIG[config/] -CONST[constants/] -DTO[dto/] -ENUM[enums/] -EXCEPTION[exception/] -ROUTER[router/] -SERVICE[service/] -UTIL[util/] -VALIDATION[validation/] -end - -subgraph ConfigFiles["config/"] -AC[AwsClients.java] -WSC[WebSocketConfig.java] -RTC[RoomTokenConfig.java] -SC[StudyConfig.java] -end - -subgraph DtoFiles["dto/"] -AR[ApiResponse.java] -EI[ErrorInfo.java] -PR[PaginatedResult.java] -end - -subgraph ExceptionFiles["exception/"] -SE[ServerlessException.java] -EC[ErrorCode.java] -CEC[CommonErrorCode.java] -CE[CommonException.java] -end - -subgraph RouterFiles["router/"] -HR[HandlerRouter.java] -RT[Route.java] -AH[AuthenticatedHandler.java] -end - -CONFIG --> ConfigFiles -DTO --> DtoFiles -EXCEPTION --> ExceptionFiles -ROUTER --> RouterFiles -``` - ---- - -## 3. Handler 라우팅 시스템 - -### 3.1 HandlerRouter 아키텍처 - -```mermaid -flowchart TB - subgraph Request["요청 처리 흐름"] - REQ[APIGatewayProxyRequestEvent] --> ROUTER[HandlerRouter] - ROUTER --> MATCH{라우트 매칭} - MATCH -->|매칭 성공| VALIDATE[파라미터 검증] - MATCH -->|매칭 실패| NF404[404 Not Found] - VALIDATE --> EXECUTE[핸들러 실행] - EXECUTE --> RESPONSE[APIGatewayProxyResponseEvent] - end - - subgraph ErrorHandling["예외 처리"] - EXECUTE -->|ServerlessException| ERR1[ErrorCode 기반 응답] - EXECUTE -->|IllegalArgumentException| ERR2[400 Bad Request] - EXECUTE -->|IllegalStateException| ERR3[409 Conflict] - EXECUTE -->|SecurityException| ERR4[403 Forbidden] - EXECUTE -->|기타 예외| ERR5[500 Internal Error] - end -``` - -### 3.2 Route 정의 (Java 21 Record) - -```java -// Route.java - Java 21 Record 활용 -public record Route( - String method, // HTTP 메서드 - String pathPattern, // 경로 패턴 (e.g., "/rooms/{roomId}") - Function handler, - List requiredPathParams, // 필수 경로 파라미터 - List requiredQueryParams // 필수 쿼리 파라미터 - ) { - // 경로 파라미터 자동 추출: {roomId} → roomId - private static final Pattern PATH_PARAM_PATTERN = - Pattern.compile("\\{([^}]+)}"); -} -``` - -### 3.3 Route 팩토리 메서드 - -```mermaid -flowchart LR - subgraph BasicRoutes["기본 라우트"] - GET["Route.get()"] - POST["Route.post()"] - PUT["Route.put()"] - DELETE["Route.delete()"] - PATCH["Route.patch()"] - end - - subgraph AuthRoutes["인증 라우트"] - GETAUTH["Route.getAuth()"] - POSTAUTH["Route.postAuth()"] - PUTAUTH["Route.putAuth()"] - DELETEAUTH["Route.deleteAuth()"] - PATCHAUTH["Route.patchAuth()"] - end - - BasicRoutes -->|" + Cognito 인증 "| AuthRoutes -``` - -### 3.4 사용 예시 - -```java -// Handler에서 라우터 초기화 -private HandlerRouter initRouter() { - return new HandlerRouter().addRoutes( - // 인증 필요 라우트 (Cognito userId 자동 추출) - Route.postAuth("/grammar/check", this::checkGrammar), - Route.getAuth("/grammar/sessions/{sessionId}", this::getSessionDetail), - Route.deleteAuth("/grammar/sessions/{sessionId}", this::deleteSession), - - // 쿼리 파라미터 검증 - Route.getAuth("/rooms", this::getRooms) - .requireQueryParams("level") - ); -} - -// Lambda 핸들러 메서드 -@Override -public APIGatewayProxyResponseEvent handleRequest( - APIGatewayProxyRequestEvent request, Context context) { - return router.route(request); -} -``` - -### 3.5 AuthenticatedHandler 인터페이스 - -```java -// 함수형 인터페이스 - Cognito 인증 요청 처리 -@FunctionalInterface -public interface AuthenticatedHandler { - APIGatewayProxyResponseEvent handle( - APIGatewayProxyRequestEvent request, - String userId // Cognito sub claim에서 자동 추출 - ); -} - -// 사용 예시 - 람다 표현식으로 간결하게 -Route. - -postAuth("/rooms",(request, userId) ->{ -CreateRoomRequest dto = parseBody(request, CreateRoomRequest.class); -ChatRoom room = roomService.createRoom(userId, dto); - return ResponseGenerator. - -created("Room created",room); -}); -``` - ---- - -## 4. 예외 처리 시스템 - -### 4.1 ErrorCode 계층 구조 (Sealed Interface) - -```mermaid -flowchart TB - subgraph SealedHierarchy["Java 21 Sealed Interface 계층"] - EC[/"ErrorCode
(sealed interface)"/] - EC -->|permits| CEC["CommonErrorCode
(enum)"] - EC -->|permits| DEC[/"DomainErrorCode
(non-sealed interface)"/] - DEC --> VEC["VocabularyErrorCode"] - DEC --> CHEC["ChattingErrorCode"] - DEC --> GEC["GrammarErrorCode"] - DEC --> SEC["StatsErrorCode"] - DEC --> BEC["BadgeErrorCode"] - end -``` - -### 4.2 CommonErrorCode 정의 - -```java -public enum CommonErrorCode implements ErrorCode { - // 인증/인가 (AUTH_xxx) - UNAUTHORIZED("AUTH_001", "인증이 필요합니다", 401), - FORBIDDEN("AUTH_002", "접근 권한이 없습니다", 403), - INVALID_TOKEN("AUTH_003", "유효하지 않은 토큰입니다", 401), - TOKEN_EXPIRED("AUTH_004", "토큰이 만료되었습니다", 401), - - // 검증 (VALIDATION_xxx) - INVALID_INPUT("VALIDATION_001", "잘못된 입력입니다", 400), - REQUIRED_FIELD_MISSING("VALIDATION_002", "필수 필드가 누락되었습니다", 400), - INVALID_FORMAT("VALIDATION_003", "형식이 올바르지 않습니다", 400), - VALUE_OUT_OF_RANGE("VALIDATION_004", "값이 허용 범위를 벗어났습니다", 400), - - // 리소스 (RESOURCE_xxx) - RESOURCE_NOT_FOUND("RESOURCE_001", "리소스를 찾을 수 없습니다", 404), - RESOURCE_ALREADY_EXISTS("RESOURCE_002", "이미 존재하는 리소스입니다", 409), - METHOD_NOT_ALLOWED("RESOURCE_003", "허용되지 않는 메서드입니다", 405), - - // 시스템 (SYSTEM_xxx) - INTERNAL_SERVER_ERROR("SYSTEM_001", "내부 서버 오류가 발생했습니다", 500), - DATABASE_ERROR("SYSTEM_002", "데이터베이스 오류가 발생했습니다", 500), - EXTERNAL_API_ERROR("SYSTEM_003", "외부 API 호출 오류가 발생했습니다", 502), - SERVICE_UNAVAILABLE("SYSTEM_004", "서비스를 일시적으로 사용할 수 없습니다", 503); - - private final String code; - private final String message; - private final int statusCode; -} -``` - -### 4.3 예외 생성 팩토리 패턴 - -```mermaid -flowchart LR - subgraph FactoryMethods["CommonException 팩토리 메서드"] - AUTH["인증 오류"] - VALID["검증 오류"] - RES["리소스 오류"] - SYS["시스템 오류"] - end - - AUTH --> UNAUTH["unauthorized()"] - AUTH --> FORBID["forbidden()"] - AUTH --> TOKEN["invalidToken()"] - VALID --> INPUT["invalidInput(msg)"] - VALID --> MISS["requiredFieldMissing(field)"] - VALID --> FMT["invalidFormat(field)"] - RES --> NF["notFound(resource, id)"] - RES --> EXIST["alreadyExists(resource)"] - SYS --> INTERN["internalError(cause)"] - SYS --> DB["databaseError(cause)"] - SYS --> EXT["externalApiError(api, cause)"] -``` - -### 4.4 예외 사용 예시 - -```java -// 가독성 높은 예외 생성 -throw CommonException.notFound("User","user123"); -// → "User (ID: user123)를 찾을 수 없습니다", 404 - -throw CommonException. - -invalidInput("Email format is invalid"); -// → 400 INVALID_INPUT with custom message - -throw CommonException. - -alreadyExists("ChatRoom","room456"); -// → "ChatRoom (ID: room456)가 이미 존재합니다", 409 - -// 상세 컨텍스트 추가 (메서드 체이닝) -throw CommonException. - -internalError(cause) - . - -addDetail("operation","database_query") - . - -addDetail("table","users"); -``` - ---- - -## 5. AWS 클라이언트 관리 - -### 5.1 Singleton 패턴 (Cold Start 최적화) - -```mermaid -flowchart TB - subgraph ColdStart["Lambda Cold Start 최적화"] - INIT["Lambda 컨테이너 초기화
(1회)"] - STATIC["static final 클라이언트 생성"] - REUSE["요청마다 재사용"] - end - - INIT --> STATIC - STATIC --> REUSE - REUSE -->|" 다음 요청 "| REUSE -``` - -### 5.2 AwsClients.java 구조 - -```java -public final class AwsClients { - // DynamoDB (Enhanced Client 포함) - private static final DynamoDbClient DYNAMO_DB_CLIENT = - DynamoDbClient.builder().build(); - private static final DynamoDbEnhancedClient DYNAMO_DB_ENHANCED_CLIENT = - DynamoDbEnhancedClient.builder() - .dynamoDbClient(DYNAMO_DB_CLIENT) - .build(); - - // S3 (Presigner 포함) - private static final S3Client S3_CLIENT = S3Client.builder().build(); - private static final S3Presigner S3_PRESIGNER = S3Presigner.builder().build(); - - // AI/ML 서비스 - private static final PollyClient POLLY_CLIENT = PollyClient.builder().build(); - private static final BedrockRuntimeClient BEDROCK_CLIENT = - BedrockRuntimeClient.builder().build(); - private static final BedrockRuntimeAsyncClient BEDROCK_ASYNC_CLIENT = - BedrockRuntimeAsyncClient.builder().build(); - private static final ComprehendClient COMPREHEND_CLIENT = - ComprehendClient.builder().build(); - - // SNS - private static final SnsClient SNS_CLIENT = SnsClient.builder().build(); - - // 팩토리 메서드 - public static DynamoDbClient dynamoDb() { - return DYNAMO_DB_CLIENT; - } - - public static DynamoDbEnhancedClient dynamoDbEnhanced() { - return DYNAMO_DB_ENHANCED_CLIENT; - } - - public static S3Client s3() { - return S3_CLIENT; - } - - public static S3Presigner s3Presigner() { - return S3_PRESIGNER; - } - - public static PollyClient polly() { - return POLLY_CLIENT; - } - - public static BedrockRuntimeClient bedrock() { - return BEDROCK_CLIENT; - } - - public static BedrockRuntimeAsyncClient bedrockAsync() { - return BEDROCK_ASYNC_CLIENT; - } - - public static ComprehendClient comprehend() { - return COMPREHEND_CLIENT; - } - - public static SnsClient sns() { - return SNS_CLIENT; - } -} -``` - -### 5.3 사용 예시 - -```java -// Service에서 사용 -public class PollyService { - public VoiceSynthesisResult synthesizeSpeech(String id, String text, String voice) { - SynthesizeSpeechRequest request = SynthesizeSpeechRequest.builder() - .text(text) - .voiceId(VoiceId.MATTHEW) - .engine("neural") - .outputFormat(OutputFormat.MP3) - .build(); - - // Singleton 클라이언트 사용 - InputStream audioStream = AwsClients.polly().synthesizeSpeech(request); - AwsClients.s3().putObject(putRequest, RequestBody.fromInputStream(audioStream, -1)); - - return new VoiceSynthesisResult(s3Key, presignedUrl, false); - } -} -``` - ---- - -## 6. DTO 패턴 (Java 21 Records) - -### 6.1 ApiResponse (제네릭 응답 래퍼) - -```java -// 불변 데이터 클래스 - Java 21 Record -public record ApiResponse( - boolean isSuccess, - String message, - T data, - String error - ) { - // 성공 응답 팩토리 - public static ApiResponse ok(String message, T data) { - return new ApiResponse<>(true, message, data, null); - } - - public static ApiResponse ok(T data) { - return new ApiResponse<>(true, null, data, null); - } - - // 실패 응답 팩토리 - public static ApiResponse fail(String errorMessage) { - return new ApiResponse<>(false, null, null, errorMessage); - } -} -``` - -**JSON 응답 예시:** - -```json -{ - "isSuccess": true, - "message": "Grammar checked successfully", - "data": { - "correctedSentence": "I am a student", - "score": 85, - "errors": [ - ... - ] - }, - "error": null -} -``` - -### 6.2 ErrorInfo (RFC 7807 준수) - -```java -// Problem Details for HTTP APIs (RFC 7807) -public record ErrorInfo( - String code, // e.g., "VOCABULARY.WORD_001" - String message, // e.g., "단어를 찾을 수 없습니다" - int status, // e.g., 404 - Map details // Optional context - ) { - public static ErrorInfo from(ErrorCode errorCode) { ...} - - public static ErrorInfo from(ServerlessException ex) { ...} - - public boolean isClientError() { - return status >= 400 && status < 500; - } - - public boolean isServerError() { - return status >= 500 && status < 600; - } -} -``` - -**JSON 에러 응답 예시:** - -```json -{ - "code": "VOCABULARY.WORD_001", - "message": "단어를 찾을 수 없습니다", - "status": 404, - "details": { - "wordId": "abc-123", - "userId": "user456" - } -} -``` - -### 6.3 PaginatedResult (커서 페이지네이션) - -```java -public record PaginatedResult( - List items, - String nextCursor // Base64 인코딩된 DynamoDB lastEvaluatedKey -) { - public boolean hasMore() { - return nextCursor != null; - } -} -``` - ---- - -## 7. 페이지네이션 유틸리티 - -### 7.1 CursorUtil 동작 흐름 - -```mermaid -sequenceDiagram - participant Client - participant Handler - participant CursorUtil - participant DynamoDB - Note over Client, DynamoDB: 첫 페이지 요청 - Client ->> Handler: GET /items?limit=10 - Handler ->> CursorUtil: decode(null) → null - Handler ->> DynamoDB: Query (exclusiveStartKey=null) - DynamoDB -->> Handler: items + lastEvaluatedKey - Handler ->> CursorUtil: encode(lastEvaluatedKey) - CursorUtil -->> Handler: "dXNlcklkPXVzZXIxMjM..." - Handler -->> Client: {"items": [...], "nextCursor": "dXNlcklkPXVzZXIxMjM..."} - Note over Client, DynamoDB: 다음 페이지 요청 - Client ->> Handler: GET /items?cursor=dXNlcklkPXVzZXIxMjM... - Handler ->> CursorUtil: decode("dXNlcklkPXVzZXIxMjM...") - CursorUtil -->> Handler: {"userId": "user123", ...} - Handler ->> DynamoDB: Query (exclusiveStartKey={...}) - DynamoDB -->> Handler: items + lastEvaluatedKey -``` - -### 7.2 CursorUtil 구현 - -```java -public class CursorUtil { - // DynamoDB lastEvaluatedKey → Base64 문자열 - public static String encode(Map lastEvaluatedKey) { - if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { - return null; - } - - StringBuilder sb = new StringBuilder(); - for (Map.Entry entry : lastEvaluatedKey.entrySet()) { - if (sb.length() > 0) sb.append("|"); - sb.append(entry.getKey()).append("=").append(entry.getValue().s()); - } - - return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); - } - - // Base64 문자열 → DynamoDB exclusiveStartKey - public static Map decode(String cursor) { - if (cursor == null || cursor.isEmpty()) { - return null; - } - - String decoded = new String(Base64.getUrlDecoder().decode(cursor)); - Map startKey = new HashMap<>(); - - for (String pair : decoded.split("\\|")) { - String[] kv = pair.split("=", 2); - if (kv.length == 2) { - startKey.put(kv[0], AttributeValue.builder().s(kv[1]).build()); - } - } - - return startKey; - } -} -``` - ---- - -## 8. 인증 유틸리티 - -### 8.1 Cognito 인증 흐름 - -```mermaid -flowchart TB - subgraph CognitoAuth["Cognito 인증 흐름"] - REQ[요청] --> AUTH[API Gateway Authorizer] - AUTH --> CLAIMS[JWT Claims 추출] - CLAIMS --> INJECT["requestContext.authorizer.claims"] - end - - subgraph CognitoUtil["CognitoUtil 추출"] - INJECT --> EXTRACT[extractUserId] - EXTRACT --> SUB["claims.sub → userId"] - end -``` - -### 8.2 CognitoUtil.java - -```java -public class CognitoUtil { - // 기본 userId 추출 (sub claim) - public static String extractUserId(APIGatewayProxyRequestEvent request) { - Map authorizer = request.getRequestContext().getAuthorizer(); - if (authorizer == null) return null; - - Map claims = (Map) authorizer.get("claims"); - return claims != null ? claims.get("sub") : null; - } - - // 선택적 claim 추출 - public static Optional extractEmail(APIGatewayProxyRequestEvent request) { - return extractClaim(request, "email"); - } - - public static Optional extractNickname(APIGatewayProxyRequestEvent request) { - return extractClaim(request, "custom:nickname"); - } - - public static Optional extractClaim( - APIGatewayProxyRequestEvent request, String claimName) { - // ... claim 추출 로직 - } - - // 사용자 접근 권한 검증 - public static boolean validateUserAccess( - APIGatewayProxyRequestEvent request, String pathUserId) { - String tokenUserId = extractUserId(request); - return tokenUserId != null && tokenUserId.equals(pathUserId); - } -} -``` - -### 8.3 JwtUtil.java (WebSocket용) - -```java -// WebSocket 연결 시 직접 JWT 파싱 (Authorizer 미사용) -public final class JwtUtil { - public static Optional extractUserId(String token) { - // Bearer 제거 - if (token.startsWith("Bearer ")) { - token = token.substring(7); - } - - // JWT payload 추출 (헤더.페이로드.시그니처) - String[] parts = token.split("\\."); - if (parts.length != 3) return Optional.empty(); - - // Base64 URL 디코딩 - String payload = new String(Base64.getUrlDecoder().decode(parts[1])); - Map claims = gson.fromJson(payload, Map.class); - - return Optional.ofNullable((String) claims.get("sub")); - } - - public static boolean isExpired(String token) { - // exp claim 확인 - } -} -``` - ---- - -## 9. HTTP 응답 생성 - -### 9.1 ResponseGenerator.java - -```java -public class ResponseGenerator { - private static final Gson GSON = new GsonBuilder() - .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") - .create(); - - private static final Map CORS_HEADERS = Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", - "Access-Control-Allow-Headers", "Content-Type,Authorization" - ); - - // 성공 응답 - public static APIGatewayProxyResponseEvent ok(String message, T data) { - return buildResponse(200, ApiResponse.ok(message, data)); - } - - public static APIGatewayProxyResponseEvent created(String message, T data) { - return buildResponse(201, ApiResponse.ok(message, data)); - } - - public static APIGatewayProxyResponseEvent noContent() { - return buildResponse(204, null); - } - - // 에러 응답 - public static APIGatewayProxyResponseEvent fail(ErrorCode errorCode) { - return buildResponse(errorCode.getStatusCode(), ErrorInfo.from(errorCode)); - } - - public static APIGatewayProxyResponseEvent badRequest(String message) { - return fail(CommonErrorCode.INVALID_INPUT, message); - } - - public static APIGatewayProxyResponseEvent notFound(String message) { - return fail(CommonErrorCode.RESOURCE_NOT_FOUND, message); - } - - // ... 기타 편의 메서드 - - private static APIGatewayProxyResponseEvent buildResponse(int statusCode, Object body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(new HashMap<>(CORS_HEADERS)) - .withBody(body != null ? GSON.toJson(body) : null); - } - - public static Gson gson() { - return GSON; - } -} -``` - ---- - -## 10. Bean Validation - -### 10.1 BeanValidator 패턴 - -```mermaid -flowchart TB - REQ[요청 수신] --> PARSE[JSON 파싱 → DTO] -PARSE --> VALIDATE[BeanValidator.validateAndExecute] -VALIDATE --> CHECK{검증 통과?} -CHECK -->|Yes|HANDLER[핸들러 로직 실행] -CHECK -->|No|ERR400[400 Bad Request] -HANDLER --> RESPONSE[정상 응답] -``` - -### 10.2 BeanValidator.java - -```java -public final class BeanValidator { - private static final Validator VALIDATOR; - - static { - ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); - VALIDATOR = factory.getValidator(); - } - - // 검증 + 실행 통합 패턴 - public static APIGatewayProxyResponseEvent validateAndExecute( - T object, - Function handler) { - - Optional error = validate(object); - if (error.isPresent()) { - return ResponseGenerator.badRequest(error.get()); - } - - return handler.apply(object); - } - - public static Optional validate(T object) { - Set> violations = VALIDATOR.validate(object); - if (violations.isEmpty()) { - return Optional.empty(); - } - - String message = violations.stream() - .map(ConstraintViolation::getMessage) - .collect(Collectors.joining(", ")); - - return Optional.of(message); - } -} -``` - -### 10.3 DTO 검증 예시 - -```java -// 요청 DTO -public class CreateRoomRequest { - @NotEmpty(message = "방 이름은 필수입니다") - private String roomName; - - @NotNull(message = "난이도는 필수입니다") - private String difficulty; - - @Min(value = 2, message = "최소 2명 이상이어야 합니다") - @Max(value = 10, message = "최대 10명까지 가능합니다") - private int maxMembers; -} - -// Handler에서 사용 -private APIGatewayProxyResponseEvent createRoom( - APIGatewayProxyRequestEvent request, String userId) { - - CreateRoomRequest req = ResponseGenerator.gson() - .fromJson(request.getBody(), CreateRoomRequest.class); - - return BeanValidator.validateAndExecute(req, dto -> { - // 검증 통과 시에만 실행됨 - ChatRoom room = roomService.createRoom(userId, dto); - return ResponseGenerator.created("방이 생성되었습니다", room); - }); -} -``` - ---- - -## 11. WebSocket 유틸리티 - -### 11.1 브로드캐스트 흐름 - -```mermaid -sequenceDiagram - participant Service - participant Broadcaster as WebSocketBroadcaster - participant APIGW as API Gateway - participant Clients as WebSocket Clients - Service ->> Broadcaster: broadcast(connections, message) - - loop 각 연결에 전송 - Broadcaster ->> APIGW: postToConnection(connectionId, data) - alt 성공 - APIGW -->> Clients: 메시지 전달 - else 연결 끊김 (410 Gone) - APIGW -->> Broadcaster: GoneException - Broadcaster ->> Broadcaster: failedIds에 추가 - end - end - - Broadcaster -->> Service: failedConnectionIds 반환 - Service ->> Service: Stale 연결 정리 -``` - -### 11.2 WebSocketBroadcaster.java - -```java -public class WebSocketBroadcaster { - private final ApiGatewayManagementApiClient apiClient; - - public WebSocketBroadcaster() { - String endpoint = WebSocketConfig.websocketEndpoint(); - this.apiClient = ApiGatewayManagementApiClient.builder() - .endpointOverride(URI.create(endpoint)) - .build(); - } - - // 단일 연결에 전송 - public boolean sendToConnection(String connectionId, String message) { - try { - apiClient.postToConnection(PostToConnectionRequest.builder() - .connectionId(connectionId) - .data(SdkBytes.fromUtf8String(message)) - .build()); - return true; - } catch (GoneException e) { - // 연결이 이미 끊김 - return false; - } - } - - // 다수 연결에 브로드캐스트 - public List broadcast(List connections, String message) { - List failedIds = new ArrayList<>(); - - for (Connection conn : connections) { - if (!sendToConnection(conn.getConnectionId(), message)) { - failedIds.add(conn.getConnectionId()); - } - } - - return failedIds; // 실패한 연결 ID 반환 (정리용) - } -} -``` - -### 11.3 WebSocket 응답 유틸리티 - -```java -public final class WebSocketResponseUtil { - public static Map ok(String message) { - return response(200, message); - } - - public static Map unauthorized(String message) { - return response(401, message); - } - - public static Map badRequest(String message) { - return response(400, message); - } - - private static Map response(int statusCode, String body) { - return Map.of( - "statusCode", statusCode, - "body", body - ); - } -} -``` - ---- - -## 12. S3 Presigned URL - -### 12.1 S3PresignUtil.java - -```java -public class S3PresignUtil { - private static final Duration DEFAULT_DURATION = Duration.ofHours(24); - private static final String BUCKET_NAME = System.getenv("S3_BUCKET_NAME"); - - // 내부 캐시 (Java 21 Record) - private record CachedUrl(String url, long expiresAt) { - boolean isExpired() { - // 1시간 버퍼 두고 만료 체크 - return System.currentTimeMillis() > (expiresAt - 3600_000); - } - } - - private static final Map URL_CACHE = new ConcurrentHashMap<>(); - - public static String getPresignedUrl(String key) { - return getPresignedUrl(key, DEFAULT_DURATION); - } - - public static String getPresignedUrl(String key, Duration duration) { - CachedUrl cached = URL_CACHE.get(key); - if (cached != null && !cached.isExpired()) { - return cached.url(); - } - - GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() - .signatureDuration(duration) - .getObjectRequest(r -> r.bucket(BUCKET_NAME).key(key)) - .build(); - - String url = AwsClients.s3Presigner() - .presignGetObject(presignRequest) - .url() - .toString(); - - URL_CACHE.put(key, new CachedUrl(url, - System.currentTimeMillis() + duration.toMillis())); - - return url; - } - - // 배지 이미지 URL 생성 편의 메서드 - public static String getBadgeImageUrl(String imageFile) { - return getPresignedUrl("badges/" + imageFile); - } -} -``` - ---- - -## 13. AWS 서비스 래퍼 - -### 13.1 PollyService (TTS + S3 캐시) - -```mermaid -flowchart TB - REQ[음성 합성 요청] --> CHECK{S3 캐시 확인} - CHECK -->|캐시 있음| PRESIGN[Presigned URL 생성] - CHECK -->|캐시 없음| SYNTH[Polly 음성 합성] - SYNTH --> SAVE[S3 저장] - SAVE --> PRESIGN - PRESIGN --> RETURN[URL 반환] -``` - -```java -public class PollyService { - public VoiceSynthesisResult synthesizeSpeech(String id, String text, String voice) { - String s3Key = generateS3Key(id, voice); - - // 캐시 확인 - if (existsInS3(s3Key)) { - return new VoiceSynthesisResult(s3Key, getPresignedUrl(s3Key), true); - } - - // Polly 음성 합성 - VoiceId voiceId = "MALE".equalsIgnoreCase(voice) ? VoiceId.MATTHEW : VoiceId.JOANNA; - - SynthesizeSpeechRequest request = SynthesizeSpeechRequest.builder() - .text(text) - .voiceId(voiceId) - .engine("neural") // Neural 음성 (고품질) - .outputFormat(OutputFormat.MP3) - .build(); - - InputStream audioStream = AwsClients.polly().synthesizeSpeech(request); - - // S3 저장 - AwsClients.s3().putObject( - PutObjectRequest.builder() - .bucket(bucketName) - .key(s3Key) - .contentType("audio/mpeg") - .build(), - RequestBody.fromInputStream(audioStream, -1) - ); - - return new VoiceSynthesisResult(s3Key, getPresignedUrl(s3Key), false); - } - - public String generateS3Key(String id, String voice) { - String suffix = "MALE".equalsIgnoreCase(voice) ? "male" : "female"; - return s3KeyPrefix + id + "_" + suffix + ".mp3"; - } -} -``` - -### 13.2 ComprehendService (NLP 분석) - -```java -public class ComprehendService { - public ComprehendAnalysis analyze(String text) { - // 감정 분석 - DetectSentimentResponse sentiment = AwsClients.comprehend() - .detectSentiment(DetectSentimentRequest.builder() - .text(text) - .languageCode("en") - .build()); - - // 구문 분석 (품사 태깅) - DetectSyntaxResponse syntax = AwsClients.comprehend() - .detectSyntax(DetectSyntaxRequest.builder() - .text(text) - .languageCode("en") - .build()); - - // 핵심 구문 추출 - DetectKeyPhrasesResponse keyPhrases = AwsClients.comprehend() - .detectKeyPhrases(DetectKeyPhrasesRequest.builder() - .text(text) - .languageCode("en") - .build()); - - // 문장 복잡도 계산 - String complexity = calculateComplexity(syntax.syntaxTokens()); - - return ComprehendAnalysis.builder() - .sentiment(sentiment.sentimentAsString()) - .syntax(mapTokens(syntax.syntaxTokens())) - .keyPhrases(mapKeyPhrases(keyPhrases.keyPhrases())) - .complexity(complexity) - .build(); - } - - private String calculateComplexity(List tokens) { - Set uniquePOS = tokens.stream() - .map(t -> t.partOfSpeech().tagAsString()) - .collect(Collectors.toSet()); - - if (uniquePOS.size() <= 3 && tokens.size() <= 5) return "BEGINNER"; - if (uniquePOS.size() <= 5 && tokens.size() <= 10) return "INTERMEDIATE"; - return "ADVANCED"; - } -} -``` - ---- - -## 14. 설정 클래스 - -### 14.1 StudyConfig (학습 알고리즘 상수) - -```java -public final class StudyConfig { - // SM-2 알고리즘 상수 - public static final int INITIAL_INTERVAL_DAYS = 1; - public static final double DEFAULT_EASE_FACTOR = 2.5; - public static final double MIN_EASE_FACTOR = 1.3; - public static final int INITIAL_REPETITIONS = 0; - - // 테스트 설정 - public static final int DEFAULT_WORD_COUNT = 20; - public static final int DAILY_TEST_WORD_COUNT = 10; - - // 복습 주기 (일) - public static final int[] REVIEW_INTERVALS = {1, 3, 7, 14, 30}; - - // 상태 기본값 - public static final String DEFAULT_WORD_STATUS = "NEW"; - public static final String DEFAULT_DIFFICULTY = "NORMAL"; - - // 오류 제한 - public static final int MAX_WRONG_COUNT = 3; -} -``` - -### 14.2 DynamoDbKey (키 패턴 상수) - -```java -public final class DynamoDbKey { - // 기본 키 - public static final String PK = "PK"; - public static final String SK = "SK"; - - // GSI 키 - public static final String GSI1_PK = "GSI1PK"; - public static final String GSI1_SK = "GSI1SK"; - public static final String GSI2_PK = "GSI2PK"; - public static final String GSI2_SK = "GSI2SK"; - - // GSI 이름 - public static final String GSI1 = "GSI1"; - public static final String GSI2 = "GSI2"; - - // 공통 접두사 - public static final String USER = "USER#"; - public static final String METADATA = "METADATA"; - - // 헬퍼 메서드 - public static String userPk(String userId) { - return USER + userId; // "USER#user-123" - } -} -``` - ---- - -## 15. Java 21 기능 활용 - -### 15.1 Records 활용 - -| 클래스 | 용도 | -|-----------------|----------------| -| ApiResponse | 제네릭 API 응답 래퍼 | -| ErrorInfo | RFC 7807 에러 응답 | -| PaginatedResult | 페이지네이션 결과 | -| Route | HTTP 라우트 정의 | -| RouteEntry | 라우터 내부 매칭 | -| CachedUrl | S3 URL 캐시 | - -### 15.2 Sealed Interface 활용 - -```mermaid -flowchart TB - subgraph SealedPattern["Sealed Interface 패턴"] - EC[/"sealed interface ErrorCode
permits CommonErrorCode, DomainErrorCode"/] - CEC["final enum CommonErrorCode
implements ErrorCode"] - DEC[/"non-sealed interface DomainErrorCode
extends ErrorCode"/] - EC --> CEC - EC --> DEC - end -``` - -### 15.3 Pattern Matching 활용 - -```java -// instanceof 패턴 매칭 -String code = errorCode instanceof DomainErrorCode domainCode - ? domainCode.getFullCode() // "VOCABULARY.WORD_001" - : errorCode.getCode(); // "AUTH_001" - -// switch 표현식 (Enhanced) -return switch(type. - -getCategory()){ - case"FIRST_STUDY"->stats. - -getTestsCompleted() >=1; - case"STREAK"->stats. - -getCurrentStreak() >=type. - -getThreshold(); - case"ACCURACY"->{ -double accuracy = (double) stats.getCorrectAnswers() / stats.getQuestionsAnswered() * 100; -yield accuracy >=type. - -getThreshold(); - } -default ->false; - }; -``` - ---- - -## 16. 디자인 패턴 요약 - -| 패턴 | 적용 위치 | 목적 | -|----------------------|------------------------|-------------------| -| **Singleton** | AwsClients | AWS SDK 클라이언트 재사용 | -| **Factory Method** | Route, CommonException | 객체 생성 캡슐화 | -| **Strategy** | AuthenticatedHandler | 요청 처리 전략 분리 | -| **Router** | HandlerRouter | HTTP 요청 라우팅 | -| **Builder** | ComprehendAnalysis | 복잡한 객체 생성 | -| **Template Method** | BeanValidator | 검증-실행 흐름 템플릿 | -| **Sealed Interface** | ErrorCode 계층 | 구현 제한 | -| **Data Class** | Records | 불변 데이터 전송 | - ---- - -## 17. 파일 구조 - -``` -common/ -├── config/ -│ ├── AwsClients.java # AWS SDK 클라이언트 싱글톤 -│ ├── WebSocketConfig.java # WebSocket 설정 -│ ├── RoomTokenConfig.java # 방 토큰 TTL 설정 -│ └── StudyConfig.java # 학습 알고리즘 상수 -├── constants/ -│ └── DynamoDbKey.java # DynamoDB 키 패턴 -├── dto/ -│ ├── ApiResponse.java # 제네릭 응답 래퍼 (Record) -│ ├── ErrorInfo.java # RFC 7807 에러 (Record) -│ └── PaginatedResult.java # 페이지네이션 (Record) -├── enums/ -│ ├── Difficulty.java # EASY, NORMAL, HARD -│ └── StudyLevel.java # BEGINNER, INTERMEDIATE, ADVANCED -├── exception/ -│ ├── ServerlessException.java # 기본 예외 클래스 -│ ├── ErrorCode.java # Sealed Interface -│ ├── CommonErrorCode.java # 공통 에러 코드 -│ ├── DomainErrorCode.java # 도메인 에러 인터페이스 -│ └── CommonException.java # 예외 팩토리 -├── router/ -│ ├── HandlerRouter.java # HTTP 라우터 -│ ├── Route.java # 라우트 정의 (Record) -│ └── AuthenticatedHandler.java # 인증 핸들러 인터페이스 -├── service/ -│ ├── PollyService.java # TTS + S3 캐시 -│ └── ComprehendService.java # NLP 분석 -├── util/ -│ ├── ResponseGenerator.java # HTTP 응답 빌더 -│ ├── CursorUtil.java # 커서 페이지네이션 -│ ├── CognitoUtil.java # Cognito 인증 추출 -│ ├── JwtUtil.java # JWT 직접 파싱 -│ ├── WebSocketBroadcaster.java # WebSocket 브로드캐스트 -│ ├── WebSocketEventUtil.java # WebSocket 이벤트 추출 -│ ├── WebSocketResponseUtil.java # WebSocket 응답 빌더 -│ └── S3PresignUtil.java # Presigned URL 생성 -└── validation/ - └── BeanValidator.java # Bean Validation 유틸 -``` - ---- - -## 18. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **Build:** Gradle -- **AWS SDK:** AWS SDK for Java v2 -- **Validation:** Jakarta Bean Validation -- **JSON:** Gson -- **Pattern:** Singleton, Factory, Strategy, Router, Builder, Sealed Interface -- **Java 21 Features:** Records, Sealed Interface, Pattern Matching, Enhanced Switch diff --git a/docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md b/docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md deleted file mode 100644 index 5015a011..00000000 --- a/docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md +++ /dev/null @@ -1,465 +0,0 @@ -# Grammar Domain 세부 보고서 - -## 1. 개요 - -Grammar 도메인은 AWS Bedrock(Claude 3 Haiku)을 활용한 AI 기반 영어 문법 체크 시스템입니다. REST API와 WebSocket 스트리밍을 통해 실시간 문법 교정 및 대화형 학습을 -제공합니다. - ---- - -## 2. 전체 아키텍처 - -```mermaid -flowchart TB - subgraph Client["클라이언트"] - APP[Mobile/Web App] - end - - subgraph Gateway["API Gateway"] - REST[REST API] - WS[Grammar WebSocket] - end - - subgraph Lambda["Lambda Handlers"] - HANDLER[GrammarHandler] - CONNECT[StreamingConnectHandler] - DISCONNECT[StreamingDisconnectHandler] - STREAM[StreamingHandler] - end - - subgraph AI["AWS AI 서비스"] - BEDROCK[Bedrock
Claude 3 Haiku] - COMPREHEND[Comprehend
언어 분석] - end - - subgraph Storage["저장소"] - DDB[(DynamoDB)] - end - - APP --> REST - APP <--> WS - REST --> HANDLER - WS --> CONNECT - WS --> DISCONNECT - WS --> STREAM - HANDLER --> BEDROCK - HANDLER --> COMPREHEND - STREAM --> BEDROCK - HANDLER --> DDB - STREAM --> DDB -``` - ---- - -## 3. 문법 체크 흐름 - -### 3.1 동기식 문법 체크 - -```mermaid -sequenceDiagram - participant Client - participant Handler as GrammarHandler - participant Service as GrammarCheckService - participant Bedrock as AWS Bedrock - participant DB as DynamoDB - Client ->> Handler: POST /grammar/check - Handler ->> Service: checkGrammar(sentence, level) - Service ->> Bedrock: Claude API 호출 - Bedrock -->> Service: JSON 응답 - Service -->> Handler: GrammarCheckResponse - Handler -->> Client: 문법 교정 결과 -``` - -### 3.2 스트리밍 대화 - -```mermaid -sequenceDiagram - participant Client - participant WS as WebSocket - participant Handler as StreamingHandler - participant Service as ConversationService - participant Bedrock as AWS Bedrock - Client ->> WS: $connect?token={jwt} - WS -->> Client: 연결 성공 - Client ->> WS: 메시지 전송 - WS ->> Handler: $default 라우트 - Handler ->> Service: chatStreaming() - Service -->> Client: StartEvent (sessionId) - - loop 토큰 단위 스트리밍 - Bedrock -->> Service: 텍스트 청크 - Service -->> Client: TokenEvent - end - - Service -->> Client: CompleteEvent (전체 응답) -``` - ---- - -## 4. API 엔드포인트 - -### 4.1 REST API - -| Method | Endpoint | 설명 | -|--------|-------------------------------|---------------| -| POST | /grammar/check | 문법 체크 (단일 문장) | -| POST | /grammar/conversation | 대화형 문법 학습 | -| GET | /grammar/sessions | 대화 세션 목록 | -| GET | /grammar/sessions/{sessionId} | 세션 상세 | -| DELETE | /grammar/sessions/{sessionId} | 세션 삭제 | - -### 4.2 WebSocket API - -| Route | 설명 | -|-------------|-------------| -| $connect | JWT 토큰으로 연결 | -| $disconnect | 연결 해제 | -| $default | 스트리밍 메시지 처리 | - ---- - -## 5. 레벨별 문법 체크 - -### 5.1 학습 레벨 - -| 레벨 | 설명 | 피드백 스타일 | -|--------------|----|--------------------| -| BEGINNER | 초급 | 한국어 번역 + 쉬운 설명 | -| INTERMEDIATE | 중급 | 영어 위주 설명 | -| ADVANCED | 고급 | 상세한 문법 규칙 + 스타일 제안 | - -### 5.2 오류 유형 - -```mermaid -mindmap - root((문법 오류)) - 시제 - VERB_TENSE - 동사 시제 오류 - 일치 - SUBJECT_VERB_AGREEMENT - 주어-동사 일치 - 품사 - ARTICLE - 관사 오류 - PREPOSITION - 전치사 오류 - PRONOUN - 대명사 오류 - 구조 - WORD_ORDER - 어순 오류 - SENTENCE_STRUCTURE - 문장 구조 - 기타 - SPELLING - 철자 - PUNCTUATION - 구두점 - WORD_CHOICE - 어휘 선택 -``` - ---- - -## 6. 응답 포맷 - -### 6.1 문법 체크 응답 - -```json -{ - "originalSentence": "I goed to school yesterday", - "correctedSentence": "I went to school yesterday", - "score": 70, - "isCorrect": false, - "errors": [ - { - "type": "VERB_TENSE", - "original": "goed", - "corrected": "went", - "explanation": "'go'의 과거형은 'went'입니다 (불규칙 동사)", - "startIndex": 2, - "endIndex": 6 - } - ], - "feedback": "과거 시제를 잘 사용하려고 노력했네요! 불규칙 동사를 조금 더 연습해보세요." -} -``` - -### 6.2 대화 응답 - -```json -{ - "sessionId": "uuid", - "grammarCheck": { - /* 위와 동일 */ - }, - "aiResponse": "Great job! Your sentence structure is correct. Let's practice more complex sentences.", - "conversationTip": "Try using 'had gone' for past perfect tense." -} -``` - -### 6.3 스트리밍 이벤트 - -```json -// StartEvent -{ - "type": "start", - "sessionId": "uuid" -} - -// TokenEvent (실시간) -{ - "type": "token", - "token": "Great " -} -{ - "type": "token", - "token": "job!" -} - -// CompleteEvent (완료) -{ - "type": "complete", - "sessionId": "uuid", - "grammarCheck": { - ... - }, - "aiResponse": "...", - "conversationTip": "..." -} - -// ErrorEvent (오류 시) -{ - "type": "error", - "message": "..." -} -``` - ---- - -## 7. AWS Bedrock 통합 - -### 7.1 Claude 3 Haiku 설정 - -```java -public class BedrockGrammarCheckFactory { - private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; - private static final int MAX_TOKENS = 2048; - private static final String API_VERSION = "bedrock-2023-05-31"; -} -``` - -### 7.2 프롬프트 구조 - -**시스템 프롬프트 (초급):** - -``` -You are a friendly English grammar tutor for Korean speakers. -- Use simple English with Korean translations -- Be encouraging and supportive -- Explain grammar rules clearly -``` - -**사용자 프롬프트:** - -``` -Please check the grammar of this sentence: "{sentence}" - -Return JSON: -{ - "correctedSentence": "...", - "score": 0-100, - "isCorrect": boolean, - "errors": [...], - "feedback": "..." -} -``` - -### 7.3 스트리밍 응답 파싱 - -``` -[RESPONSE] -AI의 자연스러운 대화 응답 -[/RESPONSE] - -[GRAMMAR] -{ JSON 형식의 문법 체크 결과 } -[/GRAMMAR] - -[TIP] -학습 팁 -[/TIP] -``` - ---- - -## 8. 데이터 모델 - -### 8.1 GrammarSession - -```java - -@DynamoDbBean -public class GrammarSession { - String sessionId; - String userId; - String level; // BEGINNER, INTERMEDIATE, ADVANCED - String topic; // "Conversation Practice" - Integer messageCount; - String lastMessage; // 마지막 메시지 (100자 제한) - String createdAt; - String updatedAt; - Long ttl; // 30일 -} -``` - -**DynamoDB Keys:** - -- PK: `GSESSION#{userId}` | SK: `SESSION#{sessionId}` -- GSI1: `GSESSION#ALL` | `UPDATED#{timestamp}` (최신순 정렬) - -### 8.2 GrammarMessage - -```java - -@DynamoDbBean -public class GrammarMessage { - String messageId; - String sessionId; - String userId; - String role; // USER, ASSISTANT - String content; // 원본 메시지 - String correctedContent; // 교정된 메시지 (USER만) - String errorsJson; // 오류 목록 JSON - Integer grammarScore; - String feedback; - Boolean isCorrect; - Long ttl; // 30일 -} -``` - -**DynamoDB Keys:** - -- PK: `GSESSION#{userId}` | SK: `MSG#{timestamp}#{messageId}` -- GSI1: `GSESSION#{sessionId}` | `MSG#{timestamp}` - -### 8.3 GrammarConnection (WebSocket) - -```java - -@DynamoDbBean -public class GrammarConnection { - String connectionId; // API Gateway 연결 ID - String userId; // JWT에서 추출 - String connectedAt; - Long ttl; // 연결 타임아웃 -} -``` - ---- - -## 9. AWS Comprehend 분석 (선택적) - -```mermaid -flowchart LR - INPUT[입력 문장] --> SENTIMENT[감정 분석] - INPUT --> SYNTAX[구문 분석] - INPUT --> KEYPHRASE[핵심 구문] - INPUT --> LANGUAGE[언어 감지] - SENTIMENT --> OUTPUT[분석 결과] - SYNTAX --> OUTPUT - KEYPHRASE --> OUTPUT - LANGUAGE --> OUTPUT -``` - -**분석 항목:** - -- 감정: POSITIVE, NEGATIVE, NEUTRAL, MIXED -- 품사 태깅: NOUN, VERB, ADJ 등 -- 핵심 구문 추출 -- 문장 복잡도 추정 - ---- - -## 10. 서비스 레이어 - -### 10.1 서비스 구성 - -| Service | 역할 | -|----------------------------|----------------| -| GrammarCheckService | 단일 문장 문법 체크 | -| GrammarConversationService | 대화형 학습 + 스트리밍 | -| GrammarSessionQueryService | 세션 조회, 삭제 | -| BedrockGrammarCheckFactory | Bedrock API 호출 | - -### 10.2 대화 히스토리 관리 - -```java -// 최근 10개 메시지만 컨텍스트로 유지 -private static final int MAX_HISTORY_MESSAGES = 10; - -// 대화 히스토리 빌드 -String buildConversationHistory(String sessionId) { - // 최근 메시지 조회 - // USER: 내용 / ASSISTANT: 내용 형식으로 포맷 -} -``` - ---- - -## 11. 파일 구조 - -``` -domain/grammar/ -├── handler/ -│ ├── GrammarHandler.java -│ └── websocket/ -│ ├── GrammarStreamingConnectHandler.java -│ ├── GrammarStreamingDisconnectHandler.java -│ └── GrammarStreamingHandler.java -├── service/ -│ ├── GrammarCheckService.java -│ ├── GrammarConversationService.java -│ └── GrammarSessionQueryService.java -├── factory/ -│ ├── GrammarCheckFactory.java (interface) -│ └── BedrockGrammarCheckFactory.java -├── repository/ -│ ├── GrammarSessionRepository.java -│ └── GrammarConnectionRepository.java -├── model/ -│ ├── GrammarSession.java -│ ├── GrammarMessage.java -│ └── GrammarConnection.java -├── dto/ -│ ├── request/ -│ │ ├── GrammarCheckRequest.java -│ │ └── ConversationRequest.java -│ └── response/ -│ ├── GrammarCheckResponse.java -│ ├── ConversationResponse.java -│ ├── GrammarError.java -│ └── ComprehendAnalysis.java -├── streaming/ -│ ├── StreamingCallback.java -│ ├── StreamingEvent.java (sealed interface) -│ └── StreamingRequest.java -├── enums/ -│ ├── GrammarLevel.java -│ └── GrammarErrorType.java -└── constants/ - └── GrammarKey.java -``` - ---- - -## 12. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **API:** API Gateway REST + WebSocket -- **AI:** AWS Bedrock (Claude 3 Haiku) -- **NLP:** AWS Comprehend (선택적) -- **Database:** DynamoDB -- **Auth:** JWT (Cognito) -- **Pattern:** Factory, Callback, Sealed Interface (Java 17+) diff --git a/docs/domain-reports/STATS-DOMAIN-REPORT.md b/docs/domain-reports/STATS-DOMAIN-REPORT.md deleted file mode 100644 index 3ca3d3ff..00000000 --- a/docs/domain-reports/STATS-DOMAIN-REPORT.md +++ /dev/null @@ -1,379 +0,0 @@ -# Stats Domain 세부 보고서 - -## 1. 개요 - -Stats 도메인은 사용자의 학습 활동을 추적하고 통계를 집계하는 시스템입니다. DynamoDB Streams와 EventBridge를 활용한 이벤트 기반 아키텍처로 실시간 통계 업데이트를 제공합니다. - ---- - -## 2. 전체 아키텍처 - -```mermaid -flowchart TB - subgraph Triggers["트리거"] - TEST[테스트 완료] - DAILY[일일 학습] - GAME[게임 종료] - SCHEDULE[스케줄러
매일 자정] - end - - subgraph Processing["처리"] - STREAM[StatsStreamHandler
DynamoDB Streams] - SERVICE[StatsService
Write-through] - SCHEDULED[ScheduledStatsHandler
EventBridge] - end - - subgraph Storage["저장소"] - DDB[(DynamoDB
UserStats)] - end - - subgraph Query["조회"] - API[UserStatsHandler
REST API] - end - - TEST --> STREAM - DAILY --> SERVICE - GAME --> SERVICE - SCHEDULE --> SCHEDULED - STREAM --> DDB - SERVICE --> DDB - SCHEDULED --> DDB - DDB --> API -``` - ---- - -## 3. 통계 집계 방식 - -### 3.1 집계 레벨 - -```mermaid -flowchart LR - subgraph Levels["통계 집계 레벨"] - DAILY["일별
DAILY#2026-01-16"] - WEEKLY["주별
WEEKLY#2026-W03"] - MONTHLY["월별
MONTHLY#2026-01"] - TOTAL["전체
TOTAL"] - end - - EVENT[이벤트 발생] --> DAILY - EVENT --> WEEKLY - EVENT --> MONTHLY - EVENT --> TOTAL -``` - -### 3.2 Atomic Counter 패턴 - -```java -// 모든 레벨에 동시 업데이트 (원자적) -UpdateExpression: -SET correctAnswers = if_not_exists(correctAnswers, 0) + :correct, -incorrectAnswers = - -if_not_exists(incorrectAnswers, 0) +:incorrect, -testsCompleted = - -if_not_exists(testsCompleted, 0) +1, -updatedAt =:now -``` - ---- - -## 4. 이벤트 기반 통계 업데이트 - -### 4.1 DynamoDB Streams 처리 - -```mermaid -sequenceDiagram - participant Test as TestResult 저장 - participant Stream as DynamoDB Streams - participant Handler as StatsStreamHandler - participant DB as UserStats - Test ->> Stream: INSERT 이벤트 - Stream ->> Handler: 트리거 - Handler ->> Handler: PK/SK 패턴 확인
(TEST#userId, RESULT#timestamp) - Handler ->> DB: incrementTestStats() - Handler ->> DB: updateStudyStreak() - Handler ->> Handler: checkAndAwardBadges() -``` - -### 4.2 Write-through 패턴 - -```mermaid -sequenceDiagram - participant API as DailyStudyHandler - participant Service as StatsService - participant DB as UserStats - Note over API, DB: 단어 학습 완료 시 - API ->> Service: recordWordsLearned() - Service ->> DB: incrementWordsLearned()
(DAILY, WEEKLY, MONTHLY, TOTAL) - Service ->> DB: updateStudyStreak() -``` - ---- - -## 5. API 엔드포인트 - -### 5.1 통계 조회 API - -| Method | Endpoint | 설명 | 파라미터 | -|--------|----------------|---------|------------------| -| GET | /stats/daily | 일별 통계 | ?date=YYYY-MM-DD | -| GET | /stats/weekly | 주별 통계 | ?week=YYYY-Www | -| GET | /stats/monthly | 월별 통계 | ?month=YYYY-MM | -| GET | /stats/total | 전체 통계 | - | -| GET | /stats/history | 일별 히스토리 | ?cursor, ?limit | - -### 5.2 응답 예시 - -```json -{ - "periodType": "DAILY", - "period": "2026-01-16", - "testsCompleted": 3, - "questionsAnswered": 45, - "correctAnswers": 38, - "incorrectAnswers": 7, - "successRate": 84.44, - "newWordsLearned": 50, - "wordsReviewed": 5 -} -``` - -**전체 통계 추가 필드:** - -```json -{ - "currentStreak": 7, - "longestStreak": 14, - "lastStudyDate": "2026-01-16", - "gamesPlayed": 10, - "gamesWon": 3, - "totalGameScore": 450 -} -``` - ---- - -## 6. 연속 학습 (Streak) 시스템 - -### 6.1 스트릭 계산 로직 - -```mermaid -flowchart TB - START[학습 활동 발생] --> CHECK{lastStudyDate
확인} - CHECK -->|null| NEW["currentStreak = 1
longestStreak = 1"] - CHECK -->|오늘| SAME[변경 없음
이미 오늘 학습] - CHECK -->|어제| INCREMENT["currentStreak++
longestStreak = max()"] - CHECK -->|2일+ 전| RESET["currentStreak = 1
longestStreak 유지"] - NEW --> UPDATE[DB 업데이트] - INCREMENT --> UPDATE - RESET --> UPDATE -``` - -### 6.2 스트릭 리셋 (스케줄러) - -```java -// EventBridge: 매일 자정 실행 -@Scheduled -public void resetStreaks() { - String yesterday = LocalDate.now().minusDays(1).toString(); - // lastStudyDate != yesterday인 사용자의 스트릭 리셋 - // 비용 최적화로 클라이언트 측 계산 권장 -} -``` - ---- - -## 7. 데이터 모델 - -### 7.1 UserStats - -```java - -@DynamoDbBean -public class UserStats { - // 키 - String pk; // USER#{userId}#STATS - String sk; // DAILY#{date} | WEEKLY#{week} | MONTHLY#{month} | TOTAL - - // 메타데이터 - String userId; - String periodType; // DAILY, WEEKLY, MONTHLY, TOTAL - String period; // 2026-01-16, 2026-W03, 2026-01, TOTAL - - // 테스트 통계 - Integer testsCompleted; - Integer questionsAnswered; - Integer correctAnswers; - Integer incorrectAnswers; - Double successRate; - - // 학습 통계 - Integer newWordsLearned; - Integer wordsReviewed; - Integer wordsMastered; - - // 스트릭 (TOTAL만) - Integer currentStreak; - Integer longestStreak; - String lastStudyDate; - - // 게임 통계 (TOTAL만) - Integer gamesPlayed; - Integer gamesWon; - Integer correctGuesses; - Integer totalGameScore; - Integer quickGuesses; // 5초 이내 정답 - Integer perfectDraws; // 전원 정답 유도 - - // 타임스탬프 - String createdAt; - String updatedAt; -} -``` - -### 7.2 DynamoDB 키 구조 - -| 필드 | 패턴 | 예시 | -|---------|------------------------|-------------------| -| PK | USER#{userId}#STATS | USER#abc123#STATS | -| SK (일별) | DAILY#{date} | DAILY#2026-01-16 | -| SK (주별) | WEEKLY#{year}-W{week} | WEEKLY#2026-W03 | -| SK (월별) | MONTHLY#{year}-{month} | MONTHLY#2026-01 | -| SK (전체) | TOTAL | TOTAL | - ---- - -## 8. 통계 메트릭 - -### 8.1 테스트 메트릭 - -| 메트릭 | 설명 | 업데이트 시점 | -|-------------------|----------|---------| -| testsCompleted | 완료 테스트 수 | 테스트 제출 | -| questionsAnswered | 총 문제 수 | 테스트 제출 | -| correctAnswers | 정답 수 | 테스트 제출 | -| incorrectAnswers | 오답 수 | 테스트 제출 | -| successRate | 정답률 (%) | 조회 시 계산 | - -### 8.2 학습 메트릭 - -| 메트릭 | 설명 | 업데이트 시점 | -|-----------------|----------|---------| -| newWordsLearned | 신규 학습 단어 | 일일학습 완료 | -| wordsReviewed | 복습 단어 | 일일학습 완료 | -| wordsMastered | 마스터 단어 | 상태 변경 시 | - -### 8.3 게임 메트릭 - -| 메트릭 | 설명 | 업데이트 시점 | -|----------------|----------|---------| -| gamesPlayed | 참여 게임 수 | 게임 종료 | -| gamesWon | 1등 횟수 | 게임 종료 | -| correctGuesses | 정답 횟수 | 게임 종료 | -| totalGameScore | 누적 점수 | 게임 종료 | -| quickGuesses | 5초 내 정답 | 게임 종료 | -| perfectDraws | 전원 정답 유도 | 게임 종료 | - ---- - -## 9. 히스토리 조회 - -### 9.1 페이지네이션 - -```mermaid -flowchart LR - REQUEST["GET /stats/history
?limit=7&cursor=..."] - QUERY["Query
PK = USER#id#STATS
SK begins_with DAILY#
scanIndexForward = false"] - ENRICH["DailyStudy 조회
isCompleted 추가"] - RESPONSE["PaginatedResult
items, nextCursor, hasMore"] - REQUEST --> QUERY --> ENRICH --> RESPONSE -``` - -### 9.2 응답 구조 - -```json -{ - "history": [ - { - "period": "2026-01-16", - "testsCompleted": 2, - "questionsAnswered": 30, - "correctAnswers": 25, - "incorrectAnswers": 5, - "successRate": 83.33, - "newWordsLearned": 50, - "wordsReviewed": 5, - "isCompleted": true - } - ], - "nextCursor": "base64encoded...", - "hasMore": true -} -``` - ---- - -## 10. 배지 연동 - -### 10.1 자동 배지 체크 - -```mermaid -flowchart TB - STREAM[StatsStreamHandler] --> CHECK[배지 조건 체크] - CHECK --> PERFECT{만점 테스트?} - PERFECT -->|Yes| BADGE1[PERFECT_SCORE 배지] - CHECK --> STATS[전체 통계 조회] - STATS --> BADGESERVICE[BadgeService.checkAndAwardBadges] - BADGESERVICE --> AWARD[조건 충족 배지 부여] -``` - -### 10.2 배지 조건 예시 - -| 배지 | 조건 | 통계 필드 | -|--------------|----------|----------------------| -| STREAK_7 | 7일 연속 학습 | currentStreak >= 7 | -| ACCURACY_90 | 정확도 90% | successRate >= 90 | -| TEST_10 | 10회 테스트 | testsCompleted >= 10 | -| GAME_10_WINS | 10번 1등 | gamesWon >= 10 | - ---- - -## 11. 파일 구조 - -``` -domain/stats/ -├── handler/ -│ ├── UserStatsHandler.java (REST API) -│ ├── StatsStreamHandler.java (DynamoDB Streams) -│ └── ScheduledStatsHandler.java (EventBridge) -├── service/ -│ └── StatsService.java -├── repository/ -│ └── UserStatsRepository.java -├── model/ -│ └── UserStats.java -└── constants/ - └── StatsKey.java -``` - ---- - -## 12. 성능 최적화 - -| 최적화 | 기법 | 효과 | -|--------------------------|------------------|-------------------| -| 원자적 업데이트 | UpdateExpression | Race condition 방지 | -| 비동기 처리 | DynamoDB Streams | API 응답 속도 향상 | -| Cursor 페이지네이션 | lastEvaluatedKey | 대용량 히스토리 처리 | -| Strongly Consistent Read | 히스토리 조회 | 데이터 정합성 | - ---- - -## 13. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **Database:** DynamoDB (Single Table Design) -- **Event:** DynamoDB Streams, EventBridge -- **Pattern:** Atomic Counter, Write-through, Event-driven diff --git a/docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md b/docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md deleted file mode 100644 index 7ee2c90e..00000000 --- a/docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md +++ /dev/null @@ -1,504 +0,0 @@ -# Vocabulary Domain 세부 보고서 - -## 1. 개요 - -Vocabulary 도메인은 AWS Lambda와 DynamoDB를 기반으로 한 영어 단어 학습 시스템입니다. SM-2 Spaced Repetition 알고리즘과 CQRS 패턴을 적용하여 과학적이고 효율적인 단어 -암기를 지원합니다. - ---- - -## 2. 전체 아키텍처 - -```mermaid -flowchart TB - subgraph Client["클라이언트"] - APP[Mobile/Web App] - end - - subgraph Gateway["API Gateway"] - REST[REST API
HTTP] - end - - subgraph Lambda["Lambda Handlers"] - direction TB - WORD[WordHandler] - USERWORD[UserWordHandler] - DAILY[DailyStudyHandler] - TEST[TestHandler] - GROUP[WordGroupHandler] - VOICE[VoiceHandler] - STATS[StatisticsHandler
SQS Consumer] - end - - subgraph Services["서비스 레이어 (CQRS)"] - direction TB - CMD[Command Services
쓰기 작업] - QUERY[Query Services
읽기 작업] - end - - subgraph External["외부 서비스"] - POLLY[AWS Polly
TTS] - SNS[AWS SNS] - SQS[AWS SQS] - S3[(S3
음성 캐시)] - end - - subgraph Storage["데이터 저장소"] - DDB[(DynamoDB)] - end - - APP --> REST - REST --> WORD & USERWORD & DAILY & TEST & GROUP & VOICE - WORD & USERWORD & DAILY & TEST & GROUP --> CMD & QUERY - CMD & QUERY --> DDB - VOICE --> POLLY --> S3 - TEST --> SNS --> SQS --> STATS - STATS --> DDB -``` - ---- - -## 3. 일일 학습 시스템 - -### 3.1 일일 학습 흐름 - -```mermaid -flowchart TB - subgraph DailyStudyFlow["일일 학습 흐름"] - START[GET /vocab/daily] --> CHECK{기존 학습
존재?} - CHECK -->|Yes| RETURN[기존 학습 반환] - CHECK -->|No| CREATE[새 학습 생성] - CREATE --> REVIEW["복습 단어 5개 선정
(nextReviewAt <= today)"] - REVIEW --> NEW["신규 단어 50개 선정
(미학습 + 해당 레벨)"] - NEW --> SAVE[DailyStudy 저장] - SAVE --> RETURN - RETURN --> LEARN[학습 진행] - LEARN --> MARK["POST .../learned
단어별 학습 완료"] - MARK --> PROGRESS{50개 완료?} - PROGRESS -->|No| LEARN - PROGRESS -->|Yes| COMPLETE["isCompleted = true
배지 체크"] - end -``` - -### 3.2 Daily Study API - -| Method | Endpoint | 설명 | -|--------|-------------------------------------|-----------------| -| GET | /vocab/daily | 오늘의 학습 단어 조회/생성 | -| POST | /vocab/daily/words/{wordId}/learned | 단어 학습 완료 처리 | - -### 3.3 응답 예시 - -```json -{ - "userId": "user123", - "date": "2026-01-16", - "newWordIds": [ - "word1", - "word2", - ... - ], - "reviewWordIds": [ - "word51", - "word52", - ... - ], - "learnedWordIds": [], - "totalWords": 55, - "learnedCount": 0, - "isCompleted": false, - "progress": { - "percentage": 0, - "learned": 0, - "total": 55 - } -} -``` - ---- - -## 4. SM-2 Spaced Repetition 알고리즘 - -### 4.1 학습 상태 전이 - -```mermaid -stateDiagram-v2 - [*] --> NEW: 단어 추가 - NEW --> LEARNING: 첫 학습 - LEARNING --> LEARNING: 오답 - LEARNING --> REVIEWING: 2회 연속 정답 - REVIEWING --> LEARNING: 오답 - REVIEWING --> MASTERED: 5회 연속 정답 - MASTERED --> LEARNING: 오답 - MASTERED --> MASTERED: 정답 유지 -``` - -### 4.2 상태별 로직 - -| 상태 | 조건 | 정답 시 | 오답 시 | -|---------------|-------------|-----------------------------|---------------------------| -| **NEW** | 신규 단어 | LEARNING, rep=1, interval=1 | LEARNING, easeFactor-=0.2 | -| **LEARNING** | rep < 2 | rep++, interval 계산 | rep=0, interval=1 | -| **REVIEWING** | 2 ≤ rep < 5 | rep++, interval 증가 | rep=0, LEARNING | -| **MASTERED** | rep ≥ 5 | interval 증가, 유지 | rep=0, REVIEWING | - -### 4.3 복습 간격 계산 - -```mermaid -flowchart LR - REP1["rep = 1
interval = 1일"] - REP2["rep = 2
interval = 6일"] - REP3["rep >= 3
interval = interval × easeFactor"] - REP1 --> REP2 --> REP3 -``` - -**핵심 변수:** - -- `repetitions`: 연속 정답 횟수 (0~∞) -- `interval`: 복습 간격 (일 단위) -- `easeFactor`: 난이도 계수 (1.3~2.5, 기본 2.5) -- `nextReviewAt`: 다음 복습 예정일 - ---- - -## 5. 테스트 시스템 - -### 5.1 테스트 흐름 - -```mermaid -sequenceDiagram - participant Client - participant Handler as TestHandler - participant Service as TestCommandService - participant DB as DynamoDB - participant SNS as AWS SNS - Client ->> Handler: POST /vocab/tests/start - Handler ->> Service: startTest(userId, testType) - Service ->> DB: 오늘의 학습 단어 조회 - Service ->> Service: 4지선다 문제 생성 - Service -->> Client: 문제 목록 반환 - Note over Client: 사용자 답변 입력 - Client ->> Handler: POST /vocab/tests/submit - Handler ->> Service: submitTest(answers) - Service ->> DB: 결과 저장 - Service ->> SNS: 결과 발행 (비동기) - Service -->> Client: 테스트 결과 - Note over SNS, DB: 비동기 통계 처리 - SNS ->> DB: 통계 업데이트 -``` - -### 5.2 문제 생성 알고리즘 - -```mermaid -flowchart TB - START[문제 생성 시작] --> WORDS[일일 학습 단어 로드] - WORDS --> GROUP[레벨별 그룹화] - GROUP --> LOOP[각 단어마다] - LOOP --> CORRECT["정답 = 해당 단어의
한국어 뜻"] - CORRECT --> DIST["오답 3개 선정
(동일 레벨 단어)"] - DIST --> SHUFFLE[4개 보기 셔플] - SHUFFLE --> NEXT{다음 단어?} - NEXT -->|Yes| LOOP - NEXT -->|No| RETURN[문제 목록 반환] -``` - -### 5.3 Test API - -| Method | Endpoint | 설명 | -|--------|-------------------------------|------------| -| POST | /vocab/tests/start | 테스트 시작 | -| POST | /vocab/tests/submit | 테스트 제출 | -| GET | /vocab/tests/results | 테스트 결과 목록 | -| GET | /vocab/tests/results/{testId} | 테스트 상세 결과 | -| GET | /vocab/tests/tested-words | 최근 테스트된 단어 | - ---- - -## 6. 단어 관리 시스템 - -### 6.1 Word API - -| Method | Endpoint | 설명 | -|--------|------------------------|----------------------------| -| GET | /vocab/words | 단어 목록 (level, category 필터) | -| POST | /vocab/words | 단어 등록 | -| GET | /vocab/words/{wordId} | 단어 상세 | -| PUT | /vocab/words/{wordId} | 단어 수정 | -| DELETE | /vocab/words/{wordId} | 단어 삭제 | -| GET | /vocab/words/search | 키워드 검색 | -| POST | /vocab/words/batch | 배치 등록 (최대 100개) | -| POST | /vocab/words/batch/get | 배치 조회 | - -### 6.2 User Word API - -| Method | Endpoint | 설명 | -|--------|-----------------------------------|-------------| -| GET | /vocab/user-words | 사용자 단어 목록 | -| GET | /vocab/user-words/{wordId} | 사용자 단어 상세 | -| PUT | /vocab/user-words/{wordId} | 정답/오답 기록 | -| PATCH | /vocab/user-words/{wordId}/tag | 북마크, 난이도 설정 | -| PATCH | /vocab/user-words/{wordId}/status | 상태 수동 변경 | -| GET | /vocab/wrong-answers | 오답 단어 목록 | - -### 6.3 Word Group API - -| Method | Endpoint | 설명 | -|--------|----------------------------------------|--------| -| POST | /vocab/groups | 단어장 생성 | -| GET | /vocab/groups | 단어장 목록 | -| GET | /vocab/groups/{groupId} | 단어장 상세 | -| PUT | /vocab/groups/{groupId} | 단어장 수정 | -| DELETE | /vocab/groups/{groupId} | 단어장 삭제 | -| POST | /vocab/groups/{groupId}/words/{wordId} | 단어 추가 | -| DELETE | /vocab/groups/{groupId}/words/{wordId} | 단어 제거 | - ---- - -## 7. TTS 음성 합성 - -### 7.1 음성 생성 흐름 - -```mermaid -flowchart TB - REQUEST["POST /vocab/synthesize
{wordId, voice, type}"] - CHECK{S3 캐시
존재?} - REQUEST --> CHECK - CHECK -->|Yes| PRESIGN[Presigned URL 생성] - CHECK -->|No| POLLY[AWS Polly 호출] - POLLY --> SAVE[S3 저장] - SAVE --> PRESIGN - PRESIGN --> RESPONSE[URL 반환] -``` - -### 7.2 Voice API - -```json -// Request -{ - "wordId": "uuid", - "voice": "MALE", - // MALE | FEMALE - "type": "WORD" - // WORD | EXAMPLE -} - -// Response -{ - "url": "https://s3...presigned-url", - "expiresIn": 3600 -} -``` - ---- - -## 8. 데이터 모델 - -### 8.1 Word - -```java - -@DynamoDbBean -public class Word { - String wordId; // UUID - String english; // 영어 단어 - String korean; // 한국어 뜻 - String example; // 예문 - String level; // BEGINNER | INTERMEDIATE | ADVANCED - String category; // DAILY | BUSINESS | ACADEMIC | TRAVEL | TECHNOLOGY - String maleVoiceKey; // S3 음성 키 - String femaleVoiceKey; - String maleExampleVoiceKey; - String femaleExampleVoiceKey; -} -``` - -**DynamoDB Keys:** - -| Key | 패턴 | 용도 | -|--------|---------------------|----------| -| PK | WORD#{wordId} | 기본 조회 | -| SK | METADATA | - | -| GSI1PK | LEVEL#{level} | 레벨별 조회 | -| GSI2PK | CATEGORY#{category} | 카테고리별 조회 | - -### 8.2 UserWord - -```java - -@DynamoDbBean -public class UserWord { - String userId; - String wordId; - String status; // NEW | LEARNING | REVIEWING | MASTERED - - // SM-2 알고리즘 필드 - Integer interval; // 복습 간격 (일) - Double easeFactor; // 난이도 계수 (1.3~2.5) - Integer repetitions; // 연속 정답 횟수 - String nextReviewAt; // 다음 복습일 (YYYY-MM-DD) - - // 통계 - Integer correctCount; // 누적 정답 - Integer incorrectCount; // 누적 오답 - - // 사용자 설정 - Boolean bookmarked; // 북마크 - Boolean favorite; // 즐겨찾기 - String difficulty; // EASY | NORMAL | HARD -} -``` - -**DynamoDB Keys:** - -| Key | 패턴 | 용도 | -|--------|--------------------------|--------------| -| PK | USER#{userId} | 기본 조회 | -| SK | WORD#{wordId} | - | -| GSI1PK | USER#{userId}#REVIEW | 복습 예정 단어 | -| GSI1SK | DATE#{nextReviewAt} | - | -| GSI2PK | USER#{userId}#STATUS | 상태별 조회 | -| GSI2SK | STATUS#{status} | - | -| GSI3PK | USER#{userId}#BOOKMARKED | 북마크 (Sparse) | - -### 8.3 DailyStudy - -```java - -@DynamoDbBean -public class DailyStudy { - String userId; - String date; // YYYY-MM-DD - List newWordIds; // 신규 단어 50개 - List reviewWordIds; // 복습 단어 5개 - List learnedWordIds; // 학습 완료 단어 - Integer totalWords; // 총 단어 수 (55) - Integer learnedCount; // 학습 완료 수 - Boolean isCompleted; // 완료 여부 -} -``` - -### 8.4 TestResult - -```java - -@DynamoDbBean -public class TestResult { - String testId; - String userId; - String testType; // DAILY | WEEKLY | CUSTOM - Integer totalQuestions; - Integer correctAnswers; - Integer incorrectAnswers; - Double successRate; - List testedWordIds; - List incorrectWordIds; - String startedAt; - String completedAt; -} -``` - ---- - -## 9. 서비스 아키텍처 (CQRS) - -### 9.1 Command Services (쓰기) - -```mermaid -flowchart TB - subgraph Commands["Command Services"] - WC[WordCommandService
단어 생성/수정/삭제] - UC[UserWordCommandService
학습 상태 업데이트] - DC[DailyStudyCommandService
일일 학습 관리] - TC[TestCommandService
테스트 생성/제출] - GC[WordGroupCommandService
단어장 관리] - end -``` - -### 9.2 Query Services (읽기) - -```mermaid -flowchart TB - subgraph Queries["Query Services"] - WQ[WordQueryService
단어 조회/검색] - UQ[UserWordQueryService
학습 현황 조회] - DQ[DailyStudyQueryService
일일 학습 조회] - TQ[TestQueryService
테스트 결과 조회] - end -``` - ---- - -## 10. 성능 최적화 - -| 최적화 | 기법 | 효과 | -|---------------------|------------------------|-----------------| -| N+1 방지 | BatchGetItem (100개 단위) | DB 호출 90% 감소 | -| TTS 캐싱 | S3 + Presigned URL | Polly 호출 90% 절감 | -| 페이지네이션 | Cursor 기반 (Base64) | 대용량 데이터 처리 | -| Sparse Index | GSI3 (북마크 전용) | 인덱스 크기 최소화 | -| 비동기 통계 | SNS/SQS | API 응답 속도 향상 | -| Strongly Consistent | DailyStudy 조회 | 데이터 정합성 | - ---- - -## 11. 파일 구조 - -``` -domain/vocabulary/ -├── handler/ -│ ├── WordHandler.java -│ ├── UserWordHandler.java -│ ├── DailyStudyHandler.java -│ ├── TestHandler.java -│ ├── WordGroupHandler.java -│ ├── VoiceHandler.java -│ ├── StatsHandler.java -│ └── StatisticsHandler.java (SQS) -├── service/ -│ ├── WordCommandService.java -│ ├── WordQueryService.java -│ ├── UserWordCommandService.java -│ ├── UserWordQueryService.java -│ ├── TestCommandService.java -│ ├── TestQueryService.java -│ ├── DailyStudyCommandService.java -│ ├── DailyStudyQueryService.java -│ ├── WordGroupCommandService.java -│ ├── StatsService.java -│ └── StatisticsService.java -├── repository/ -│ ├── WordRepository.java -│ ├── UserWordRepository.java -│ ├── DailyStudyRepository.java -│ ├── TestResultRepository.java -│ └── WordGroupRepository.java -├── model/ -│ ├── Word.java -│ ├── UserWord.java -│ ├── DailyStudy.java -│ ├── TestResult.java -│ └── WordGroup.java -├── state/ -│ ├── WordState.java (interface) -│ ├── NewState.java -│ ├── LearningState.java -│ ├── ReviewingState.java -│ ├── MasteredState.java -│ ├── SpacedRepetitionContext.java -│ └── WordStateFactory.java -└── enums/ - ├── WordStatus.java - ├── WordCategory.java - └── TestType.java -``` - ---- - -## 12. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **Database:** DynamoDB (Single Table Design) -- **TTS:** AWS Polly (남성/여성 음성) -- **Storage:** S3 (음성 캐시) -- **Messaging:** SNS/SQS (비동기 통계) -- **Pattern:** CQRS, State, Repository, Factory diff --git a/docs/news-frontend-guide.md b/docs/news-frontend-guide.md deleted file mode 100644 index 650c27cc..00000000 --- a/docs/news-frontend-guide.md +++ /dev/null @@ -1,916 +0,0 @@ -# 뉴스 영어 학습 기능 - 프론트엔드 연동 가이드 - -## 목차 -1. [기능 개요](#기능-개요) -2. [주요 기능](#주요-기능) -3. [API 명세](#api-명세) -4. [데이터 모델](#데이터-모델) -5. [UI/UX 가이드](#uiux-가이드) -6. [화면 구성 제안](#화면-구성-제안) -7. [에러 코드](#에러-코드) - ---- - -## 기능 개요 - -### 프로젝트 소개 -**뉴스 영어 학습**은 실제 영어 뉴스를 활용한 맞춤형 영어 학습 서비스입니다. - -- **실시간 뉴스 수집**: BBC, VOA 등 해외 뉴스 매체에서 매일 새로운 기사 자동 수집 -- **AI 분석**: Amazon Bedrock을 활용한 난이도 분석, 키워드 추출, 퀴즈 생성 -- **맞춤형 학습**: 사용자 레벨(BEGINNER/INTERMEDIATE/ADVANCED)에 맞는 뉴스 추천 -- **TTS 지원**: Amazon Polly를 통한 원어민 발음 듣기 -- **뱃지 시스템**: 학습 활동에 따른 뱃지 획득 - -### 기술 스택 -- Backend: AWS Lambda (Java 21), DynamoDB -- AI: Amazon Bedrock (Claude) -- TTS: Amazon Polly -- 뉴스 수집: EventBridge 스케줄러 (매일 자동 수집) - ---- - -## 주요 기능 - -### 1. 뉴스 목록/상세 조회 -- 오늘의 뉴스 목록 -- 난이도별/카테고리별 필터링 -- 사용자 레벨 맞춤 추천 -- 무한 스크롤 페이지네이션 - -### 2. 뉴스 학습 -- 기사 읽기 완료 기록 -- 북마크 기능 -- TTS 오디오 재생 - -### 3. 뉴스 퀴즈 -- 기사별 5문제 자동 생성 퀴즈 -- 퀴즈 유형: 독해력(COMPREHENSION), 단어 매칭(WORD_MATCH), 빈칸 채우기(FILL_BLANK) -- 점수 및 기록 관리 - -### 4. 단어 수집 -- 기사 내 단어 수집 -- 문맥과 함께 저장 -- Vocabulary 시스템 연동 - -### 5. 학습 통계 -- 읽은 기사 수 -- 퀴즈 완료/정답률 -- 수집 단어 수 -- 연속 학습 일수 (스트릭) - -### 6. 뱃지 시스템 -14가지 뉴스 관련 뱃지: -- 읽기: 첫 읽기, 10개, 50개, 100개 기사 읽기 -- 퀴즈: 첫 퀴즈, 만점, 10회, 50회 완료 -- 단어: 10개, 50개, 100개 수집 -- 스트릭: 7일, 30일 연속 학습 -- 마스터: 전체 뉴스 기능 마스터 - ---- - -## API 명세 - -### Base URL -``` -Test: https://xgepjbg2c9.execute-api.ap-northeast-2.amazonaws.com/test -Prod: https://xgepjbg2c9.execute-api.ap-northeast-2.amazonaws.com/prod -``` - -### 인증 -모든 API는 Cognito JWT 토큰 필요 -``` -Authorization: Bearer {accessToken} -``` - ---- - -### 1. 뉴스 목록 조회 - -#### GET /news -뉴스 목록 조회 (필터링 지원) - -**Query Parameters:** -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| level | string | X | BEGINNER, INTERMEDIATE, ADVANCED | -| category | string | X | TECH, BUSINESS, SPORTS, ENTERTAINMENT, WORLD, CULTURE, SCIENCE | -| limit | number | X | 조회 개수 (기본 10, 최대 50) | -| cursor | string | X | 페이지네이션 커서 | - -**Response:** -```json -{ - "statusCode": 200, - "message": "뉴스 목록 조회 성공", - "data": { - "articles": [ - { - "articleId": "1eb9b924", - "title": "EU suspends approval of US trade deal", - "summary": "The move follows renewed tensions...", - "source": "BBC", - "imageUrl": "https://ichef.bbci.co.uk/...", - "category": "WORLD", - "level": "INTERMEDIATE", - "cefrLevel": "B1", - "publishedAt": "2026-01-21T23:41:10Z", - "readCount": 150, - "keywords": [ - { "word": "suspend", "meaning": "중단하다", "level": "INTERMEDIATE" }, - { "word": "tension", "meaning": "긴장", "level": "BEGINNER" } - ] - } - ], - "nextCursor": "eyJQSyI6Ik5FV1MjMjAyNi0wMS0yMiIsIlNLIjoiQVJUSUNMRSMxZWI5YjkyNCJ9", - "hasMore": true, - "count": 10 - } -} -``` - ---- - -#### GET /news/today -오늘의 뉴스 조회 - -**Query Parameters:** limit, cursor - ---- - -#### GET /news/recommended -사용자 레벨 맞춤 뉴스 추천 - -**Query Parameters:** limit, cursor - ---- - -### 2. 뉴스 상세 조회 - -#### GET /news/{articleId} -기사 상세 정보 조회 - -**Response:** -```json -{ - "statusCode": 200, - "message": "뉴스 조회 성공", - "data": { - "articleId": "1eb9b924", - "title": "EU suspends approval of US trade deal", - "summary": "The move follows renewed tensions between the US and EU...", - "originalUrl": "https://www.bbc.com/news/articles/...", - "source": "BBC", - "imageUrl": "https://ichef.bbci.co.uk/...", - "category": "WORLD", - "level": "INTERMEDIATE", - "cefrLevel": "B1", - "keywords": [ - { - "word": "suspend", - "meaning": "중단하다", - "level": "INTERMEDIATE", - "position": 1 - } - ], - "highlightWords": ["diplomatic", "negotiate", "tariff"], - "quiz": [ - { - "questionId": "q1", - "type": "COMPREHENSION", - "question": "What is the main reason for EU's decision?", - "options": ["Trade tensions", "Climate change", "Immigration", "Technology"], - "points": 20 - } - ], - "publishedAt": "2026-01-21T23:41:10Z", - "readCount": 150 - } -} -``` - ---- - -### 3. 학습 기록 - -#### POST /news/{articleId}/read -읽기 완료 기록 - -**Response:** -```json -{ - "statusCode": 200, - "message": "읽기 완료 기록 성공", - "data": { - "articleId": "1eb9b924", - "newBadges": [ - { - "type": "NEWS_FIRST_READ", - "name": "뉴스 첫 발걸음", - "description": "첫 번째 뉴스 읽기 완료", - "imageUrl": "https://..." - } - ] - } -} -``` - ---- - -#### POST /news/{articleId}/bookmark -북마크 토글 - -**Response:** -```json -{ - "statusCode": 200, - "message": "북마크 추가 성공", - "data": { - "articleId": "1eb9b924", - "bookmarked": true - } -} -``` - ---- - -#### GET /news/bookmarks -북마크 목록 조회 - -**Query Parameters:** limit - -**Response:** -```json -{ - "statusCode": 200, - "message": "북마크 목록 조회 성공", - "data": { - "bookmarks": [ - { - "articleId": "1eb9b924", - "articleTitle": "EU suspends approval...", - "articleLevel": "INTERMEDIATE", - "articleCategory": "WORLD", - "createdAt": "2026-01-22T10:30:00Z" - } - ], - "count": 5 - } -} -``` - ---- - -### 4. TTS 오디오 - -#### GET /news/{articleId}/audio -기사 TTS 오디오 URL 조회 - -**Query Parameters:** -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| voice | string | X | Polly 음성 (기본: Joanna) | - -**사용 가능한 음성:** -- 미국: Joanna (여), Matthew (남), Ivy (아동) -- 영국: Amy (여), Brian (남) - -**Response:** -```json -{ - "statusCode": 200, - "message": "TTS 오디오 URL 조회 성공", - "data": { - "audioUrl": "https://s3.ap-northeast-2.amazonaws.com/..." - } -} -``` - ---- - -### 5. 퀴즈 - -#### GET /news/{articleId}/quiz -퀴즈 문제 조회 - -**Response:** -```json -{ - "statusCode": 200, - "message": "퀴즈 조회 성공", - "data": { - "articleId": "1eb9b924", - "articleTitle": "EU suspends approval...", - "questions": [ - { - "questionId": "q1", - "type": "COMPREHENSION", - "question": "What is the main reason for EU's decision?", - "options": ["Trade tensions", "Climate change", "Immigration", "Technology"], - "points": 20 - }, - { - "questionId": "q2", - "type": "WORD_MATCH", - "question": "Select the correct meaning of 'suspend'", - "options": ["시작하다", "중단하다", "계속하다", "완료하다"], - "points": 20 - }, - { - "questionId": "q3", - "type": "FILL_BLANK", - "question": "The EU _____ the approval of the trade deal.", - "options": ["suspended", "continued", "started", "finished"], - "points": 20 - } - ], - "totalPoints": 100, - "previousAttempt": null - } -} -``` - ---- - -#### POST /news/{articleId}/quiz -퀴즈 제출 - -**Request Body:** -```json -{ - "answers": [ - { "questionId": "q1", "answer": "Trade tensions" }, - { "questionId": "q2", "answer": "중단하다" }, - { "questionId": "q3", "answer": "suspended" } - ], - "timeTaken": 120 -} -``` - -**Response:** -```json -{ - "statusCode": 200, - "message": "퀴즈 제출 성공", - "data": { - "score": 80, - "totalPoints": 100, - "earnedPoints": 80, - "results": [ - { "questionId": "q1", "correct": true, "correctAnswer": "Trade tensions" }, - { "questionId": "q2", "correct": true, "correctAnswer": "중단하다" }, - { "questionId": "q3", "correct": false, "correctAnswer": "suspended", "userAnswer": "continued" } - ], - "newBadges": [ - { - "type": "NEWS_QUIZ_FIRST", - "name": "퀴즈 도전자", - "description": "첫 뉴스 퀴즈 완료" - } - ] - } -} -``` - ---- - -#### GET /news/quiz/history -퀴즈 기록 조회 - -**Response:** -```json -{ - "statusCode": 200, - "message": "퀴즈 기록 조회 성공", - "data": { - "history": [ - { - "articleId": "1eb9b924", - "articleTitle": "EU suspends approval...", - "score": 80, - "totalPoints": 100, - "submittedAt": "2026-01-22T11:00:00Z" - } - ], - "stats": { - "totalQuizzes": 15, - "averageScore": 78, - "perfectScores": 3 - }, - "count": 10 - } -} -``` - ---- - -### 6. 단어 수집 - -#### POST /news/{articleId}/words -단어 수집 - -**Request Body:** -```json -{ - "word": "suspend", - "context": "The EU suspended the approval of the trade deal." -} -``` - -**Response:** -```json -{ - "statusCode": 200, - "message": "단어 수집 성공", - "data": { - "wordCollect": { - "word": "suspend", - "meaning": "중단하다", - "pronunciation": "/səˈspend/", - "context": "The EU suspended the approval of the trade deal.", - "articleId": "1eb9b924", - "articleTitle": "EU suspends approval...", - "collectedAt": "2026-01-22T11:30:00Z" - }, - "newBadges": [] - } -} -``` - ---- - -#### GET /news/words -수집 단어 목록 조회 - -**Response:** -```json -{ - "statusCode": 200, - "message": "수집 단어 목록 조회 성공", - "data": { - "words": [ - { - "word": "suspend", - "meaning": "중단하다", - "pronunciation": "/səˈspend/", - "context": "The EU suspended...", - "articleTitle": "EU suspends...", - "collectedAt": "2026-01-22T11:30:00Z", - "syncedToVocab": false - } - ], - "stats": { - "totalWords": 25, - "syncedToVocab": 10 - }, - "count": 25 - } -} -``` - ---- - -#### DELETE /news/{articleId}/words/{word} -수집 단어 삭제 - ---- - -#### POST /news/words/{word}/sync -Vocabulary 연동 - -**Request Body:** -```json -{ - "articleId": "1eb9b924" -} -``` - -**Response:** -```json -{ - "statusCode": 200, - "message": "Vocabulary 연동 성공", - "data": { - "word": "suspend", - "synced": true - } -} -``` - ---- - -### 7. 학습 통계 - -#### GET /news/stats -학습 통계 조회 - -**Response:** -```json -{ - "statusCode": 200, - "message": "뉴스 학습 통계 조회 성공", - "data": { - "totalRead": 45, - "todayRead": 3, - "totalQuizzes": 30, - "averageQuizScore": 78, - "perfectQuizzes": 5, - "totalWordsCollected": 125, - "currentStreak": 7, - "longestStreak": 14, - "bookmarkCount": 12, - "lastReadDate": "2026-01-22" - } -} -``` - ---- - -## 데이터 모델 - -### NewsArticle (뉴스 기사) -| 필드 | 타입 | 설명 | -|-----|------|------| -| articleId | string | 기사 고유 ID | -| title | string | 제목 | -| summary | string | AI 생성 3줄 요약 | -| originalUrl | string | 원문 링크 | -| source | string | 출처 (BBC, VOA 등) | -| imageUrl | string | 썸네일 이미지 | -| category | string | 카테고리 | -| level | string | 난이도 | -| cefrLevel | string | CEFR 레벨 (A1-C2) | -| keywords | KeywordInfo[] | 핵심 단어 | -| highlightWords | string[] | 강조 단어 | -| quiz | QuizQuestion[] | 퀴즈 문제 | -| publishedAt | string | 발행일 | -| readCount | number | 조회수 | - -### KeywordInfo (키워드 정보) -| 필드 | 타입 | 설명 | -|-----|------|------| -| word | string | 영어 단어 | -| meaning | string | 한국어 뜻 | -| level | string | 난이도 | -| position | number | 기사 내 위치 | - -### QuizQuestion (퀴즈 문제) -| 필드 | 타입 | 설명 | -|-----|------|------| -| questionId | string | 문제 ID | -| type | string | COMPREHENSION, WORD_MATCH, FILL_BLANK | -| question | string | 문제 내용 | -| options | string[] | 선택지 | -| points | number | 배점 | - -### NewsWordCollect (수집 단어) -| 필드 | 타입 | 설명 | -|-----|------|------| -| word | string | 단어 | -| meaning | string | 뜻 | -| pronunciation | string | 발음 기호 | -| context | string | 문맥 문장 | -| articleId | string | 출처 기사 ID | -| articleTitle | string | 출처 기사 제목 | -| syncedToVocab | boolean | Vocabulary 연동 여부 | - ---- - -## UI/UX 가이드 - -### 1. 색상 팔레트 제안 - -#### 난이도별 색상 -```css -/* BEGINNER - 녹색 계열 (쉬움) */ ---level-beginner: #10B981; ---level-beginner-bg: #D1FAE5; - -/* INTERMEDIATE - 파란 계열 (보통) */ ---level-intermediate: #3B82F6; ---level-intermediate-bg: #DBEAFE; - -/* ADVANCED - 보라 계열 (어려움) */ ---level-advanced: #8B5CF6; ---level-advanced-bg: #EDE9FE; -``` - -#### 카테고리별 색상 -```css ---category-tech: #6366F1; /* 기술 */ ---category-business: #F59E0B; /* 비즈니스 */ ---category-sports: #EF4444; /* 스포츠 */ ---category-entertainment: #EC4899; /* 엔터테인먼트 */ ---category-world: #14B8A6; /* 세계 */ ---category-culture: #F97316; /* 문화 */ ---category-science: #06B6D4; /* 과학 */ -``` - -### 2. 아이콘 가이드 - -| 기능 | 추천 아이콘 | -|-----|-----------| -| 뉴스 | newspaper, article | -| 읽기 완료 | check-circle, book-open | -| 북마크 | bookmark, heart | -| 오디오 | volume-2, headphones | -| 퀴즈 | help-circle, clipboard-check | -| 단어 수집 | plus-circle, collection | -| 통계 | bar-chart, trending-up | -| 뱃지 | award, medal | - -### 3. 애니메이션 제안 - -```css -/* 뱃지 획득 애니메이션 */ -@keyframes badge-unlock { - 0% { transform: scale(0) rotate(-180deg); opacity: 0; } - 50% { transform: scale(1.2) rotate(10deg); } - 100% { transform: scale(1) rotate(0); opacity: 1; } -} - -/* 단어 수집 애니메이션 */ -@keyframes word-collect { - 0% { transform: translateY(0); } - 50% { transform: translateY(-10px); } - 100% { transform: translateY(0); } -} - -/* 퀴즈 정답 피드백 */ -@keyframes correct-answer { - 0%, 100% { background-color: transparent; } - 50% { background-color: rgba(16, 185, 129, 0.2); } -} -``` - ---- - -## 화면 구성 제안 - -### 1. 뉴스 목록 화면 (NewsListPage) - -``` -┌─────────────────────────────────────┐ -│ [필터] 레벨 ▼ 카테고리 ▼ [검색] │ -├─────────────────────────────────────┤ -│ ┌─────────────────────────────┐ │ -│ │ [이미지] │ │ -│ │ ─────────────────────────── │ │ -│ │ [TECH] [INTERMEDIATE] │ │ -│ │ EU suspends approval of... │ │ -│ │ BBC • 2시간 전 • 👁 150 │ │ -│ │ [📖 읽기] [🔖 저장] │ │ -│ └─────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────┐ │ -│ │ [다음 카드...] │ │ -│ └─────────────────────────────┘ │ -│ │ -│ [더 보기...] │ -└─────────────────────────────────────┘ -``` - -**구현 포인트:** -- 무한 스크롤 또는 "더 보기" 버튼 -- 카드 형태로 이미지, 제목, 메타정보 표시 -- 난이도 뱃지 색상으로 직관적 구분 -- 읽은 기사는 시각적으로 구분 (예: 투명도) - ---- - -### 2. 뉴스 상세 화면 (NewsDetailPage) - -``` -┌─────────────────────────────────────┐ -│ [← 뒤로] [🔖] [🔊] │ -├─────────────────────────────────────┤ -│ │ -│ [WORLD] [B1 - INTERMEDIATE] │ -│ │ -│ EU suspends approval of │ -│ US trade deal │ -│ │ -│ BBC • 2026.01.21 │ -│ │ -│ ┌─────────────────────────────┐ │ -│ │ [기사 이미지] │ │ -│ └─────────────────────────────┘ │ -│ │ -│ 📝 요약 │ -│ The move follows renewed tensions │ -│ between the US and EU, as Donald │ -│ Trump pushes to acquire Greenland. │ -│ │ -│ ───────────────────────────────── │ -│ │ -│ 📚 핵심 단어 │ -│ ┌───────┬───────┬───────┐ │ -│ │suspend│tension│acquire│ │ -│ │중단하다│ 긴장 │획득하다│ │ -│ │ [+] │ [+] │ [+] │ │ -│ └───────┴───────┴───────┘ │ -│ │ -│ ───────────────────────────────── │ -│ │ -│ [📝 퀴즈 풀기] [📰 원문 보기] │ -│ │ -│ [✓ 읽기 완료] │ -│ │ -└─────────────────────────────────────┘ -``` - -**구현 포인트:** -- 상단에 북마크, 오디오 버튼 고정 -- 핵심 단어는 탭으로 수집 가능 -- 어려운 단어(highlightWords)는 형광펜 효과 -- 하단에 퀴즈, 원문 링크 버튼 - ---- - -### 3. 퀴즈 화면 (QuizPage) - -``` -┌─────────────────────────────────────┐ -│ [← 종료] 1/5 ⏱ 01:30 │ -├─────────────────────────────────────┤ -│ │ -│ ████████░░░░░░░░░░░░ 20/100점 │ -│ │ -│ ───────────────────────────────── │ -│ │ -│ Q1. 독해력 문제 │ -│ │ -│ What is the main reason for │ -│ EU's decision? │ -│ │ -│ ┌─────────────────────────────┐ │ -│ │ A. Trade tensions │ │ -│ └─────────────────────────────┘ │ -│ ┌─────────────────────────────┐ │ -│ │ B. Climate change │ │ -│ └─────────────────────────────┘ │ -│ ┌─────────────────────────────┐ │ -│ │ C. Immigration │ │ -│ └─────────────────────────────┘ │ -│ ┌─────────────────────────────┐ │ -│ │ D. Technology │ │ -│ └─────────────────────────────┘ │ -│ │ -│ [다음 →] │ -│ │ -└─────────────────────────────────────┘ -``` - -**구현 포인트:** -- 상단 진행률 표시 (문제 번호, 타이머) -- 점수 프로그레스바 -- 선택지 터치 영역 충분히 크게 -- 정답/오답 즉시 피드백 (색상 변경) - ---- - -### 4. 퀴즈 결과 화면 (QuizResultPage) - -``` -┌─────────────────────────────────────┐ -│ │ -│ 🎉 퀴즈 완료! │ -│ │ -│ ┌───────────────┐ │ -│ │ │ │ -│ │ 80 │ │ -│ │ /100 │ │ -│ │ │ │ -│ └───────────────┘ │ -│ │ -│ 정답 4개 / 오답 1개 │ -│ 소요시간: 2분 30초 │ -│ │ -│ ───────────────────────────────── │ -│ │ -│ 🏆 새로운 뱃지 획득! │ -│ ┌─────────────────────────────┐ │ -│ │ [뱃지 이미지] │ │ -│ │ 퀴즈 도전자 │ │ -│ │ 첫 뉴스 퀴즈 완료! │ │ -│ └─────────────────────────────┘ │ -│ │ -│ ───────────────────────────────── │ -│ │ -│ 📋 문제별 결과 │ -│ Q1. ✅ Trade tensions │ -│ Q2. ✅ 중단하다 │ -│ Q3. ❌ continued → suspended │ -│ ... │ -│ │ -│ [다시 풀기] [목록으로] │ -│ │ -└─────────────────────────────────────┘ -``` - ---- - -### 5. 단어장 화면 (WordCollectionPage) - -``` -┌─────────────────────────────────────┐ -│ 수집한 단어 25개 │ -├─────────────────────────────────────┤ -│ [전체] [미연동] [연동완료] │ -├─────────────────────────────────────┤ -│ │ -│ ┌─────────────────────────────┐ │ -│ │ suspend /səˈspend/ │ │ -│ │ 중단하다 │ │ -│ │ ─────────────────────────── │ │ -│ │ "The EU suspended the..." │ │ -│ │ 📰 EU suspends approval... │ │ -│ │ ─────────────────────────── │ │ -│ │ [🔗 Vocab 연동] [🗑 삭제] │ │ -│ └─────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────┐ │ -│ │ tension /ˈtenʃən/ │ │ -│ │ 긴장 │ │ -│ │ ✅ Vocabulary 연동됨 │ │ -│ └─────────────────────────────┘ │ -│ │ -└─────────────────────────────────────┘ -``` - ---- - -### 6. 학습 통계 화면 (StatsPage) - -``` -┌─────────────────────────────────────┐ -│ 📊 나의 뉴스 학습 │ -├─────────────────────────────────────┤ -│ │ -│ 🔥 7일 연속 학습 중! │ -│ │ -│ ┌─────────┬─────────┬─────────┐ │ -│ │ 읽기 │ 퀴즈 │ 단어 │ │ -│ │ 45 │ 30 │ 125 │ │ -│ │ 개 │ 회 │ 개 │ │ -│ └─────────┴─────────┴─────────┘ │ -│ │ -│ ───────────────────────────────── │ -│ │ -│ 📈 이번 주 활동 │ -│ ┌─────────────────────────────┐ │ -│ │ [주간 차트 - 막대그래프] │ │ -│ │ 월 화 수 목 금 토 일 │ │ -│ └─────────────────────────────┘ │ -│ │ -│ ───────────────────────────────── │ -│ │ -│ 🏆 획득한 뱃지 (5/14) │ -│ [🥇] [🥇] [🥇] [🥇] [🥇] │ -│ [🔒] [🔒] [🔒] [🔒] [🔒] │ -│ ... │ -│ │ -└─────────────────────────────────────┘ -``` - ---- - -## 에러 코드 - -| 코드 | 메시지 | 설명 | -|-----|--------|------| -| NEWS_001 | 기사를 찾을 수 없습니다 | articleId가 유효하지 않음 | -| NEWS_002 | 인증이 필요합니다 | JWT 토큰 없음/만료 | -| NEWS_003 | 퀴즈를 찾을 수 없습니다 | 해당 기사에 퀴즈 없음 | -| NEWS_004 | 이미 퀴즈를 제출했습니다 | 중복 제출 시도 | -| NEWS_005 | 이미 수집한 단어입니다 | 중복 수집 시도 | -| NEWS_006 | 수집하지 않은 단어입니다 | 존재하지 않는 단어 조회 | - ---- - -## 추가 구현 고려사항 - -### 1. 오프라인 지원 -- 읽은 기사 로컬 캐싱 -- 수집 단어 오프라인 저장 후 동기화 - -### 2. 푸시 알림 -- 새 뉴스 알림 -- 학습 리마인더 -- 스트릭 유지 알림 - -### 3. 공유 기능 -- 기사 공유 -- 퀴즈 점수 공유 -- 뱃지 획득 공유 - -### 4. 접근성 -- 스크린 리더 지원 -- 폰트 크기 조절 -- 고대비 모드 - ---- - -## 문의 - -백엔드 관련 문의는 이슈로 등록해주세요. From 92f526e5e480732809d0f38fe7c6ba36f6321893 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 10:54:55 +0900 Subject: [PATCH 83/99] fix: add /stats/dashboard endpoint to template.yaml --- ServerlessFunction/template.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 16f1e3dd..6efea7aa 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1055,6 +1055,14 @@ 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: From ce71f18f780324bc7455e31c42077c2a420e7ac2 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 10:58:32 +0900 Subject: [PATCH 84/99] fix: filter by ARTICLE# prefix in findById to avoid returning UserNewsRecord --- .../domain/news/repository/NewsArticleRepository.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java index 28ca35cc..4aeb217d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java @@ -72,8 +72,9 @@ public Optional findByDateAndId(String date, String articleId) { */ public Optional findById(String articleId) { Expression filterExpression = Expression.builder() - .expression("articleId = :articleId") + .expression("articleId = :articleId AND begins_with(SK, :skPrefix)") .putExpressionValue(":articleId", AttributeValue.builder().s(articleId).build()) + .putExpressionValue(":skPrefix", AttributeValue.builder().s("ARTICLE#").build()) .build(); ScanEnhancedRequest request = ScanEnhancedRequest.builder() From 09afe65157e56787033cbab13bd317e519736a32 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 11:02:51 +0900 Subject: [PATCH 85/99] docs: add News API troubleshooting guide --- .../NEWS-API-TROUBLESHOOTING.md | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 docs/troubleshooting/NEWS-API-TROUBLESHOOTING.md diff --git a/docs/troubleshooting/NEWS-API-TROUBLESHOOTING.md b/docs/troubleshooting/NEWS-API-TROUBLESHOOTING.md new file mode 100644 index 00000000..4b6e3ba7 --- /dev/null +++ b/docs/troubleshooting/NEWS-API-TROUBLESHOOTING.md @@ -0,0 +1,226 @@ +# News API 트러블슈팅 가이드 + +## 개요 +2026-01-23 뉴스 기능 프론트엔드 연동 과정에서 발생한 이슈들과 해결 방법을 정리합니다. + +--- + +## 1. GET /news/{articleId} 응답이 기사가 아닌 읽기 기록 반환 + +### 증상 +```javascript +// 예상 응답 +{ articleId: "e644d491", title: "...", summary: "...", ... } + +// 실제 응답 +{ pk: "USER#64983d3c-...#NEWS", sk: "READ#e644d491", articleId: "e644d491" } +``` + +### 원인 +`NewsArticleRepository.findById()`가 테이블 전체를 스캔하면서 `articleId`만 필터링했습니다. +뉴스 테이블에는 기사(`ARTICLE#`)와 사용자 기록(`READ#`, `BOOKMARK#`)이 함께 저장되어 있어서, +`UserNewsRecord`가 먼저 매칭되어 반환되었습니다. + +### 해결 +`findById`에서 SK가 `ARTICLE#`로 시작하는 것만 필터링하도록 수정: + +```java +// Before +Expression filterExpression = Expression.builder() + .expression("articleId = :articleId") + .putExpressionValue(":articleId", AttributeValue.builder().s(articleId).build()) + .build(); + +// After +Expression filterExpression = Expression.builder() + .expression("articleId = :articleId AND begins_with(SK, :skPrefix)") + .putExpressionValue(":articleId", AttributeValue.builder().s(articleId).build()) + .putExpressionValue(":skPrefix", AttributeValue.builder().s("ARTICLE#").build()) + .build(); +``` + +### 파일 +- `NewsArticleRepository.java` - `findById()` 메서드 + +--- + +## 2. 기사에 category 필드 누락 + +### 증상 +```json +{ + "articleId": "2b4e42f9", + "title": "...", + "category": null // 누락 +} +``` + +### 원인 +`NewsAnalysisService`에서 Bedrock AI 분석 시 category 분류 로직이 없었습니다. + +### 해결 +1. Bedrock 프롬프트에 category 분류 요청 추가 +2. `AnalysisResult` 레코드에 category 필드 추가 +3. 파싱 및 저장 로직 추가 + +```java +// 프롬프트에 추가 +"category": "WORLD", +... +For category, choose EXACTLY ONE from: WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE +``` + +### 파일 +- `NewsAnalysisService.java` - `generateSummaryAndQuiz()`, `parseAnalysisResult()`, `AnalysisResult` 레코드 + +### 주의 +기존 기사에는 category가 없으므로, **기사 삭제 후 재수집** 필요 + +--- + +## 3. /stats/dashboard CORS 에러 + +### 증상 +``` +Access to fetch at '.../stats/dashboard' has been blocked by CORS policy: +Response to preflight request doesn't pass access control check +``` + +### 원인 +새로 추가한 `/stats/dashboard` 엔드포인트가 `template.yaml`에 정의되지 않았습니다. + +### 해결 +`template.yaml`의 `UserStatsFunction` Events에 엔드포인트 추가: + +```yaml +Events: + GetDashboardStats: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /stats/dashboard + Method: GET + Auth: + Authorizer: CognitoAuthorizer +``` + +### 파일 +- `template.yaml` - UserStatsFunction Events + +--- + +## 4. 북마크 API가 기사 정보 없이 반환 + +### 증상 +```json +// GET /news/bookmarks 응답 +{ + "bookmarks": [ + { "pk": "USER#...", "sk": "BOOKMARK#...", "articleId": "..." } + ] +} +``` + +### 원인 +`NewsLearningService.getUserBookmarks()`가 북마크 레코드만 반환하고 기사 정보를 조회하지 않았습니다. + +### 해결 +북마크 레코드에서 articleId로 기사 정보를 조회하여 함께 반환: + +```java +public List> getUserBookmarks(String userId, int limit) { + List bookmarks = userNewsRepository.getUserBookmarks(userId, limit); + List> result = new ArrayList<>(); + + for (UserNewsRecord bookmark : bookmarks) { + Optional articleOpt = articleRepository.findById(bookmark.getArticleId()); + if (articleOpt.isPresent()) { + NewsArticle article = articleOpt.get(); + Map bookmarkWithArticle = new HashMap<>(); + bookmarkWithArticle.put("articleId", article.getArticleId()); + bookmarkWithArticle.put("title", article.getTitle()); + bookmarkWithArticle.put("summary", article.getSummary()); + // ... 기타 필드 + result.add(bookmarkWithArticle); + } + } + return result; +} +``` + +### 파일 +- `NewsLearningService.java` - `getUserBookmarks()` +- `NewsHandler.java` - `getBookmarks()` + +--- + +## 5. POST /news/{articleId}/words 500 에러 + +### 증상 +``` +java.lang.NullPointerException: Cannot invoke "JsonElement.getAsString()" +because the return value of "JsonObject.get(String)" is null +at NewsHandler.collectWord(NewsHandler.java:416) +``` + +### 원인 +요청 body에 `word` 필드가 없거나 null일 때 검증 없이 바로 접근했습니다. + +### 해결 +null 체크 추가 및 `INVALID_REQUEST` 에러 코드 정의: + +```java +JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); +if (body == null || !body.has("word") || body.get("word").isJsonNull()) { + return ResponseGenerator.fail(NewsErrorCode.INVALID_REQUEST); +} +``` + +### 파일 +- `NewsHandler.java` - `collectWord()` +- `NewsErrorCode.java` - `INVALID_REQUEST` 추가 + +--- + +## 6. DAILY 통계에 뉴스 관련 필드 누락 + +### 증상 +- TOTAL 통계: `newsRead: 5` ✅ +- DAILY 통계: `newsRead` 필드 없음 ❌ + +### 원인 +`incrementNewsReadStats()` 등의 메서드가 TOTAL 통계만 업데이트하고 DAILY 통계는 업데이트하지 않았습니다. + +### 해결 +각 뉴스 통계 업데이트 메서드에서 DAILY 통계도 함께 업데이트: + +```java +// TOTAL 업데이트 후 DAILY도 업데이트 +Map dailyKey = new HashMap<>(); +dailyKey.put("PK", AttributeValue.builder().s(pk).build()); +dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); +// ... DAILY 업데이트 로직 +``` + +### 파일 +- `UserStatsRepository.java` - `incrementNewsReadStats()`, `incrementNewsQuizStats()`, `incrementNewsWordStats()` + +--- + +## 체크리스트 + +새로운 API 엔드포인트 추가 시: +- [ ] Handler에 라우트 추가 +- [ ] `template.yaml`에 Events 추가 +- [ ] CORS 설정 확인 + +DynamoDB 단일 테이블 설계 주의: +- [ ] 쿼리 시 PK/SK 패턴 명확히 구분 +- [ ] Scan 사용 시 적절한 필터 표현식 사용 + +통계 업데이트 시: +- [ ] TOTAL과 DAILY 모두 업데이트 + +API 요청 처리 시: +- [ ] 요청 body null 체크 +- [ ] 필수 필드 존재 여부 검증 From aad76c877b1c3a31f898f38c58f83eae60502625 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 11:52:22 +0900 Subject: [PATCH 86/99] feat: add Cognito authorizer to News API and enhance keyword extraction - Set CognitoAuthorizer for all News API endpoints in template.yaml - Update Bedrock AI to extract keywords with meanings and examples - Add fallback to Comprehend for keyword extraction when Bedrock fails - Modify KeywordInfo model to include example field - Adjust AI prompt to include keyword extraction with examples - Update AnalysisResult to store keywords and parse them from AI response --- .../domain/news/model/KeywordInfo.java | 5 +- .../news/service/NewsAnalysisService.java | 49 ++++++++++++++----- ServerlessFunction/template.yaml | 24 +++++++++ 3 files changed, 64 insertions(+), 14 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java index 81f1e1f5..cd5b4b44 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java @@ -8,7 +8,7 @@ /** * 뉴스 기사 내 키워드 정보 - * 단어, 뜻, 난이도, 위치 정보를 포함 + * 단어, 뜻, 예문, 난이도, 위치 정보를 포함 */ @Data @Builder @@ -18,7 +18,8 @@ public class KeywordInfo { private String word; // 영어 단어 - private String meaning; // 한국어 뜻 + private String meaning; // 영어 뜻 (간단한 정의) + private String example; // 기사에서 발췌한 예문 private String level; // 단어 난이도 (BEGINNER, INTERMEDIATE, ADVANCED) private Integer position; // 기사 내 위치 (문장 번호 또는 단어 인덱스) } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java index d09070ed..83a4fc30 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java @@ -58,11 +58,7 @@ public NewsArticle analyzeArticle(NewsArticle article) { article.setCefrLevel(cefrLevel); article.setLevel(mapCefrToLevel(cefrLevel)); - // 2. 핵심 단어 추출 (Comprehend) - List keywords = extractKeywords(content); - article.setKeywords(keywords); - - // 3. 3줄 요약 + 퀴즈 + 카테고리 생성 (Bedrock - 한 번에 처리) + // 2. 3줄 요약 + 퀴즈 + 카테고리 + 키워드 생성 (Bedrock - 한 번에 처리) AnalysisResult result = generateSummaryAndQuiz(content, cefrLevel); if (result.summary() != null) { article.setSummary(result.summary()); @@ -73,6 +69,15 @@ public NewsArticle analyzeArticle(NewsArticle article) { article.setCategory(result.category()); } + // 3. 키워드 설정 (Bedrock AI에서 추출한 키워드 사용) + if (result.keywords() != null && !result.keywords().isEmpty()) { + article.setKeywords(result.keywords()); + } else { + // Bedrock 키워드 추출 실패 시 Comprehend 폴백 + List fallbackKeywords = extractKeywords(content); + article.setKeywords(fallbackKeywords); + } + // 4. GSI 키 설정 article.setGsi1pk("LEVEL#" + article.getLevel()); article.setGsi1sk(article.getPublishedAt()); @@ -176,7 +181,7 @@ private List extractKeywords(String content) { } /** - * 요약 + 퀴즈 + 카테고리 생성 (Bedrock) + * 요약 + 퀴즈 + 카테고리 + 키워드 생성 (Bedrock) */ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) { String systemPrompt = """ @@ -185,6 +190,10 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) Respond in this exact JSON format: { "summary": "3-line summary in English (each line separated by newline)", + "keywords": [ + {"word": "economy", "meaning": "the system of trade and industry", "example": "The economy is growing steadily."}, + {"word": "policy", "meaning": "a plan of action adopted by government", "example": "The new policy affects all citizens."} + ], "highlightWords": ["word1", "word2", "word3"], "category": "WORLD", "quiz": [ @@ -215,10 +224,12 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) ] } - For category, choose EXACTLY ONE from: WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE - Create exactly 3 quiz questions. - highlightWords should contain 3-5 difficult words for learners. - Adjust difficulty based on CEFR level: """ + cefrLevel; + IMPORTANT: + - keywords: Extract 5-8 important vocabulary words from the article. Include word, meaning (simple definition), and example sentence from the article. + - highlightWords: 3-5 difficult words that learners should pay attention to (just the words, no definitions). + - category: Choose EXACTLY ONE from: WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE + - Create exactly 3 quiz questions. + - Adjust difficulty based on CEFR level: """ + cefrLevel; String userPrompt = "Create learning materials for this article:\n\n" + truncate(content, 1500); @@ -227,7 +238,7 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) return parseAnalysisResult(response); } catch (Exception e) { logger.error("요약/퀴즈 생성 실패", e); - return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>(), null); + return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), null); } } @@ -275,6 +286,19 @@ private AnalysisResult parseAnalysisResult(String response) { String summary = json.has("summary") ? json.get("summary").getAsString() : null; String category = json.has("category") ? json.get("category").getAsString().toUpperCase() : "WORLD"; + // keywords 파싱 + List keywords = new ArrayList<>(); + if (json.has("keywords")) { + json.getAsJsonArray("keywords").forEach(e -> { + JsonObject k = e.getAsJsonObject(); + keywords.add(KeywordInfo.builder() + .word(k.has("word") ? k.get("word").getAsString() : "") + .meaning(k.has("meaning") ? k.get("meaning").getAsString() : "") + .example(k.has("example") ? k.get("example").getAsString() : "") + .build()); + }); + } + List highlightWords = new ArrayList<>(); if (json.has("highlightWords")) { json.getAsJsonArray("highlightWords").forEach(e -> highlightWords.add(e.getAsString())); @@ -299,7 +323,7 @@ private AnalysisResult parseAnalysisResult(String response) { }); } - return new AnalysisResult(summary, highlightWords, quiz, category); + return new AnalysisResult(summary, keywords, highlightWords, quiz, category); } private String extractJson(String response) { @@ -321,6 +345,7 @@ private String truncate(String text, int maxLength) { */ private record AnalysisResult( String summary, + List keywords, List highlightWords, List quiz, String category diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 6efea7aa..97fe03e6 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1857,48 +1857,64 @@ Resources: 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: @@ -1911,24 +1927,32 @@ Resources: 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: From fd42d9be8254db81715a86c41c63f20b71d241cf Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 12:17:35 +0900 Subject: [PATCH 87/99] Revert "Merge branch 'test' into prod" This reverts commit c1a958eac6799d5838a76e4078ae304bcdb62278, reversing changes made to a6662e0cfc46c6e12f20a89f0df285ff282160bc. --- .../domain/badge/enums/BadgeType.java | 26 +- .../BadgeConditionStrategyFactory.java | 7 - .../badge/strategy/NewsMasterStrategy.java | 45 - .../strategy/NewsQuizPerfectStrategy.java | 25 - .../badge/strategy/NewsQuizStrategy.java | 25 - .../badge/strategy/NewsReadStrategy.java | 25 - .../badge/strategy/NewsStreakStrategy.java | 25 - .../badge/strategy/NewsWordStrategy.java | 25 - .../domain/news/exception/NewsErrorCode.java | 3 - .../domain/news/handler/NewsHandler.java | 20 +- .../domain/news/model/KeywordInfo.java | 5 +- .../repository/NewsArticleRepository.java | 3 +- .../news/service/NewsAnalysisService.java | 56 +- .../news/service/NewsLearningService.java | 65 +- .../domain/news/service/NewsQuizService.java | 31 +- .../domain/news/service/NewsWordService.java | 43 +- .../websocket/SpeakingConnectHandler.java | 0 .../websocket/SpeakingDisconnectHandler.java | 0 .../handler/websocket/SpeakingHandler.java | 22 - .../websocket/SpeakingMessageHandler.java | 0 .../SpeakingConnectionRepository.java | 0 .../repository/SpeakingSessionRepository.java | 2 +- .../speaking/service/SpeakingService.java | 2 +- .../stats/handler/UserStatsHandler.java | 98 +- .../domain/stats/model/UserStats.java | 10 +- .../stats/repository/UserStatsRepository.java | 216 --- .../domain/badge/enums/BadgeTypeSpec.groovy | 4 +- ServerlessFunction/template.yaml | 145 +- docs/CATCHMIND_ARCHITECTURE_SOLUTION.md | 521 +++++++ docs/CICD-IMPLEMENTATION-QNA.md | 421 ++++++ docs/FRONTEND-API-GUIDE.md | 365 +++++ docs/MIDTERM-REPORT.md | 439 ++++++ docs/domain-reports/BADGE-DOMAIN-REPORT.md | 681 +++++++++ docs/domain-reports/CHATTING-DOMAIN-REPORT.md | 434 ++++++ docs/domain-reports/COMMON-MODULE-REPORT.md | 1228 +++++++++++++++++ docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md | 465 +++++++ docs/domain-reports/STATS-DOMAIN-REPORT.md | 379 +++++ .../VOCABULARY-DOMAIN-REPORT.md | 504 +++++++ .../NEWS-API-TROUBLESHOOTING.md | 226 --- 39 files changed, 5482 insertions(+), 1109 deletions(-) delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsMasterStrategy.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizPerfectStrategy.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizStrategy.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsReadStrategy.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsStreakStrategy.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsWordStrategy.java 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/handler/websocket/SpeakingMessageHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java create mode 100644 docs/CATCHMIND_ARCHITECTURE_SOLUTION.md create mode 100644 docs/CICD-IMPLEMENTATION-QNA.md create mode 100644 docs/FRONTEND-API-GUIDE.md create mode 100644 docs/MIDTERM-REPORT.md create mode 100644 docs/domain-reports/BADGE-DOMAIN-REPORT.md create mode 100644 docs/domain-reports/CHATTING-DOMAIN-REPORT.md create mode 100644 docs/domain-reports/COMMON-MODULE-REPORT.md create mode 100644 docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md create mode 100644 docs/domain-reports/STATS-DOMAIN-REPORT.md create mode 100644 docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md delete mode 100644 docs/troubleshooting/NEWS-API-TROUBLESHOOTING.md diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java index 76e857ae..f9d32794 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java @@ -31,31 +31,7 @@ public enum BadgeType { PERFECT_DRAWER("완벽한 출제자", "출제 시 전원이 정답을 맞췄습니다", "perfect_drawer.png", "PERFECT_DRAWS", 1), // 특별 - MASTER("학습 마스터", "모든 업적을 달성했습니다", "master.png", "ALL_BADGES", 1), - - // 뉴스 - 읽기 - NEWS_FIRST_READ("뉴스 첫 발걸음", "첫 번째 뉴스 읽기 완료", "news_first_read.png", "NEWS_READ", 1), - NEWS_READ_10("뉴스 탐험가", "뉴스 10개 읽기 완료", "news_read_10.png", "NEWS_READ", 10), - NEWS_READ_50("뉴스 애호가", "뉴스 50개 읽기 완료", "news_read_50.png", "NEWS_READ", 50), - NEWS_READ_100("뉴스 전문가", "뉴스 100개 읽기 완료", "news_read_100.png", "NEWS_READ", 100), - - // 뉴스 - 퀴즈 - NEWS_QUIZ_FIRST("퀴즈 도전", "첫 뉴스 퀴즈 완료", "news_quiz_first.png", "NEWS_QUIZ", 1), - NEWS_QUIZ_PERFECT("완벽한 이해", "뉴스 퀴즈에서 만점 달성", "news_quiz_perfect.png", "NEWS_QUIZ_PERFECT", 1), - NEWS_QUIZ_10("퀴즈 탐험가", "뉴스 퀴즈 10회 완료", "news_quiz_10.png", "NEWS_QUIZ", 10), - NEWS_QUIZ_50("퀴즈 마스터", "뉴스 퀴즈 50회 완료", "news_quiz_50.png", "NEWS_QUIZ", 50), - - // 뉴스 - 단어 수집 - NEWS_WORD_10("단어 수집가", "뉴스에서 단어 10개 수집", "news_word_10.png", "NEWS_WORD", 10), - NEWS_WORD_50("단어 사냥꾼", "뉴스에서 단어 50개 수집", "news_word_50.png", "NEWS_WORD", 50), - NEWS_WORD_100("단어 전문가", "뉴스에서 단어 100개 수집", "news_word_100.png", "NEWS_WORD", 100), - - // 뉴스 - 연속 학습 - NEWS_STREAK_7("일주일 뉴스 습관", "7일 연속 뉴스 읽기", "news_streak_7.png", "NEWS_STREAK", 7), - NEWS_STREAK_30("한 달 뉴스 습관", "30일 연속 뉴스 읽기", "news_streak_30.png", "NEWS_STREAK", 30), - - // 뉴스 - 종합 - NEWS_MASTER("뉴스 마스터", "읽기100+퀴즈50+단어100 달성", "news_master.png", "NEWS_MASTER", 1); + MASTER("학습 마스터", "모든 업적을 달성했습니다", "master.png", "ALL_BADGES", 1); private static final String BUCKET_NAME = System.getenv("BUCKET_NAME"); private static final String BASE_URL = getBaseUrl(); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java index ecfb1e63..01f6ed33 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java @@ -22,13 +22,6 @@ public class BadgeConditionStrategyFactory { register(new GamesWonStrategy()); register(new QuickGuessesStrategy()); register(new PerfectDrawsStrategy()); - // 뉴스 관련 전략 - register(new NewsReadStrategy()); - register(new NewsQuizStrategy()); - register(new NewsQuizPerfectStrategy()); - register(new NewsWordStrategy()); - register(new NewsStreakStrategy()); - register(new NewsMasterStrategy()); // 별도 로직이 필요한 카테고리 register(new NoOpStrategy("PERFECT_TEST")); register(new NoOpStrategy("ALL_BADGES")); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsMasterStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsMasterStrategy.java deleted file mode 100644 index 43fee824..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsMasterStrategy.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.mzc.secondproject.serverless.domain.badge.strategy; - -import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; -import com.mzc.secondproject.serverless.domain.stats.model.UserStats; - -/** - * 뉴스 마스터 뱃지 조건 전략 - * 읽기 100개 + 퀴즈 50회 + 단어 100개 달성 시 획득 - */ -public class NewsMasterStrategy implements BadgeConditionStrategy { - - private static final int NEWS_READ_REQUIRED = 100; - private static final int NEWS_QUIZ_REQUIRED = 50; - private static final int NEWS_WORD_REQUIRED = 100; - - @Override - public boolean checkCondition(BadgeType type, UserStats stats) { - int newsRead = stats.getNewsRead() != null ? stats.getNewsRead() : 0; - int newsQuiz = stats.getNewsQuizCompleted() != null ? stats.getNewsQuizCompleted() : 0; - int newsWord = stats.getNewsWordsCollected() != null ? stats.getNewsWordsCollected() : 0; - - return newsRead >= NEWS_READ_REQUIRED - && newsQuiz >= NEWS_QUIZ_REQUIRED - && newsWord >= NEWS_WORD_REQUIRED; - } - - @Override - public int calculateProgress(BadgeType type, UserStats stats) { - int newsRead = stats.getNewsRead() != null ? stats.getNewsRead() : 0; - int newsQuiz = stats.getNewsQuizCompleted() != null ? stats.getNewsQuizCompleted() : 0; - int newsWord = stats.getNewsWordsCollected() != null ? stats.getNewsWordsCollected() : 0; - - // 3가지 조건의 평균 진행률 (각각 100%, 100%, 100% 기준) - int readProgress = Math.min(newsRead * 100 / NEWS_READ_REQUIRED, 100); - int quizProgress = Math.min(newsQuiz * 100 / NEWS_QUIZ_REQUIRED, 100); - int wordProgress = Math.min(newsWord * 100 / NEWS_WORD_REQUIRED, 100); - - return (readProgress + quizProgress + wordProgress) / 3; - } - - @Override - public String getCategory() { - return "NEWS_MASTER"; - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizPerfectStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizPerfectStrategy.java deleted file mode 100644 index d9790b27..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizPerfectStrategy.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.mzc.secondproject.serverless.domain.badge.strategy; - -import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; -import com.mzc.secondproject.serverless.domain.stats.model.UserStats; - -/** - * 뉴스 퀴즈 만점 뱃지 조건 전략 - */ -public class NewsQuizPerfectStrategy implements BadgeConditionStrategy { - - @Override - public boolean checkCondition(BadgeType type, UserStats stats) { - return stats.getNewsQuizPerfect() != null && stats.getNewsQuizPerfect() >= type.getThreshold(); - } - - @Override - public int calculateProgress(BadgeType type, UserStats stats) { - return stats.getNewsQuizPerfect() != null ? stats.getNewsQuizPerfect() : 0; - } - - @Override - public String getCategory() { - return "NEWS_QUIZ_PERFECT"; - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizStrategy.java deleted file mode 100644 index 4ce390d8..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizStrategy.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.mzc.secondproject.serverless.domain.badge.strategy; - -import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; -import com.mzc.secondproject.serverless.domain.stats.model.UserStats; - -/** - * 뉴스 퀴즈 완료 뱃지 조건 전략 - */ -public class NewsQuizStrategy implements BadgeConditionStrategy { - - @Override - public boolean checkCondition(BadgeType type, UserStats stats) { - return stats.getNewsQuizCompleted() != null && stats.getNewsQuizCompleted() >= type.getThreshold(); - } - - @Override - public int calculateProgress(BadgeType type, UserStats stats) { - return stats.getNewsQuizCompleted() != null ? stats.getNewsQuizCompleted() : 0; - } - - @Override - public String getCategory() { - return "NEWS_QUIZ"; - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsReadStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsReadStrategy.java deleted file mode 100644 index 3e5cee34..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsReadStrategy.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.mzc.secondproject.serverless.domain.badge.strategy; - -import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; -import com.mzc.secondproject.serverless.domain.stats.model.UserStats; - -/** - * 뉴스 읽기 뱃지 조건 전략 - */ -public class NewsReadStrategy implements BadgeConditionStrategy { - - @Override - public boolean checkCondition(BadgeType type, UserStats stats) { - return stats.getNewsRead() != null && stats.getNewsRead() >= type.getThreshold(); - } - - @Override - public int calculateProgress(BadgeType type, UserStats stats) { - return stats.getNewsRead() != null ? stats.getNewsRead() : 0; - } - - @Override - public String getCategory() { - return "NEWS_READ"; - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsStreakStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsStreakStrategy.java deleted file mode 100644 index cb5f58d0..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsStreakStrategy.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.mzc.secondproject.serverless.domain.badge.strategy; - -import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; -import com.mzc.secondproject.serverless.domain.stats.model.UserStats; - -/** - * 뉴스 연속 읽기 뱃지 조건 전략 - */ -public class NewsStreakStrategy implements BadgeConditionStrategy { - - @Override - public boolean checkCondition(BadgeType type, UserStats stats) { - return stats.getNewsStreak() != null && stats.getNewsStreak() >= type.getThreshold(); - } - - @Override - public int calculateProgress(BadgeType type, UserStats stats) { - return stats.getNewsStreak() != null ? stats.getNewsStreak() : 0; - } - - @Override - public String getCategory() { - return "NEWS_STREAK"; - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsWordStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsWordStrategy.java deleted file mode 100644 index 70c6c1a7..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsWordStrategy.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.mzc.secondproject.serverless.domain.badge.strategy; - -import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; -import com.mzc.secondproject.serverless.domain.stats.model.UserStats; - -/** - * 뉴스 단어 수집 뱃지 조건 전략 - */ -public class NewsWordStrategy implements BadgeConditionStrategy { - - @Override - public boolean checkCondition(BadgeType type, UserStats stats) { - return stats.getNewsWordsCollected() != null && stats.getNewsWordsCollected() >= type.getThreshold(); - } - - @Override - public int calculateProgress(BadgeType type, UserStats stats) { - return stats.getNewsWordsCollected() != null ? stats.getNewsWordsCollected() : 0; - } - - @Override - public String getCategory() { - return "NEWS_WORD"; - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java index cb253701..58197f0e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java @@ -8,9 +8,6 @@ */ public enum NewsErrorCode implements DomainErrorCode { - // 일반 에러 - INVALID_REQUEST("COMMON_001", "유효하지 않은 요청입니다", 400), - // 인증 관련 에러 UNAUTHORIZED("AUTH_001", "인증이 필요합니다", 401), diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java index 86a30590..180bb7cb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -231,7 +231,7 @@ private APIGatewayProxyResponseEvent getBookmarks(APIGatewayProxyRequestEvent re if (params == null) params = new HashMap<>(); int limit = parseLimit(params.get("limit")); - List> bookmarks = learningService.getUserBookmarks(userId, limit); + List bookmarks = learningService.getUserBookmarks(userId, limit); Map response = new HashMap<>(); response.put("bookmarks", bookmarks); @@ -413,26 +413,16 @@ private APIGatewayProxyResponseEvent collectWord(APIGatewayProxyRequestEvent req String articleId = request.getPathParameters().get("articleId"); JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); - if (body == null || !body.has("word") || body.get("word").isJsonNull()) { - return ResponseGenerator.fail(NewsErrorCode.INVALID_REQUEST); - } String word = body.get("word").getAsString(); - String context = body.has("context") && !body.get("context").isJsonNull() - ? body.get("context").getAsString() : ""; + String context = body.has("context") ? body.get("context").getAsString() : ""; - NewsWordService.WordCollectResult result = wordService.collectWord(userId, articleId, word, context); + NewsWordCollect collected = wordService.collectWord(userId, articleId, word, context); - if (result == null || result.wordCollect() == null) { + if (collected == null) { return ResponseGenerator.fail(NewsErrorCode.WORD_ALREADY_COLLECTED); } - Map responseData = new java.util.HashMap<>(); - responseData.put("wordCollect", result.wordCollect()); - if (result.newBadges() != null && !result.newBadges().isEmpty()) { - responseData.put("newBadges", result.newBadges()); - } - - return ResponseGenerator.ok("단어 수집 성공", responseData); + return ResponseGenerator.ok("단어 수집 성공", collected); } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java index cd5b4b44..81f1e1f5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java @@ -8,7 +8,7 @@ /** * 뉴스 기사 내 키워드 정보 - * 단어, 뜻, 예문, 난이도, 위치 정보를 포함 + * 단어, 뜻, 난이도, 위치 정보를 포함 */ @Data @Builder @@ -18,8 +18,7 @@ public class KeywordInfo { private String word; // 영어 단어 - private String meaning; // 영어 뜻 (간단한 정의) - private String example; // 기사에서 발췌한 예문 + private String meaning; // 한국어 뜻 private String level; // 단어 난이도 (BEGINNER, INTERMEDIATE, ADVANCED) private Integer position; // 기사 내 위치 (문장 번호 또는 단어 인덱스) } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java index 4aeb217d..28ca35cc 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java @@ -72,9 +72,8 @@ public Optional findByDateAndId(String date, String articleId) { */ public Optional findById(String articleId) { Expression filterExpression = Expression.builder() - .expression("articleId = :articleId AND begins_with(SK, :skPrefix)") + .expression("articleId = :articleId") .putExpressionValue(":articleId", AttributeValue.builder().s(articleId).build()) - .putExpressionValue(":skPrefix", AttributeValue.builder().s("ARTICLE#").build()) .build(); ScanEnhancedRequest request = ScanEnhancedRequest.builder() diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java index 83a4fc30..af23fc5b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java @@ -58,25 +58,17 @@ public NewsArticle analyzeArticle(NewsArticle article) { article.setCefrLevel(cefrLevel); article.setLevel(mapCefrToLevel(cefrLevel)); - // 2. 3줄 요약 + 퀴즈 + 카테고리 + 키워드 생성 (Bedrock - 한 번에 처리) + // 2. 핵심 단어 추출 (Comprehend) + List keywords = extractKeywords(content); + article.setKeywords(keywords); + + // 3. 3줄 요약 + 퀴즈 생성 (Bedrock - 한 번에 처리) AnalysisResult result = generateSummaryAndQuiz(content, cefrLevel); if (result.summary() != null) { article.setSummary(result.summary()); } article.setQuiz(result.quiz()); article.setHighlightWords(result.highlightWords()); - if (result.category() != null) { - article.setCategory(result.category()); - } - - // 3. 키워드 설정 (Bedrock AI에서 추출한 키워드 사용) - if (result.keywords() != null && !result.keywords().isEmpty()) { - article.setKeywords(result.keywords()); - } else { - // Bedrock 키워드 추출 실패 시 Comprehend 폴백 - List fallbackKeywords = extractKeywords(content); - article.setKeywords(fallbackKeywords); - } // 4. GSI 키 설정 article.setGsi1pk("LEVEL#" + article.getLevel()); @@ -181,7 +173,7 @@ private List extractKeywords(String content) { } /** - * 요약 + 퀴즈 + 카테고리 + 키워드 생성 (Bedrock) + * 요약 + 퀴즈 생성 (Bedrock) */ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) { String systemPrompt = """ @@ -190,12 +182,7 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) Respond in this exact JSON format: { "summary": "3-line summary in English (each line separated by newline)", - "keywords": [ - {"word": "economy", "meaning": "the system of trade and industry", "example": "The economy is growing steadily."}, - {"word": "policy", "meaning": "a plan of action adopted by government", "example": "The new policy affects all citizens."} - ], "highlightWords": ["word1", "word2", "word3"], - "category": "WORLD", "quiz": [ { "questionId": "q1", @@ -224,12 +211,9 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) ] } - IMPORTANT: - - keywords: Extract 5-8 important vocabulary words from the article. Include word, meaning (simple definition), and example sentence from the article. - - highlightWords: 3-5 difficult words that learners should pay attention to (just the words, no definitions). - - category: Choose EXACTLY ONE from: WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE - - Create exactly 3 quiz questions. - - Adjust difficulty based on CEFR level: """ + cefrLevel; + Create exactly 3 quiz questions. + highlightWords should contain 3-5 difficult words for learners. + Adjust difficulty based on CEFR level: """ + cefrLevel; String userPrompt = "Create learning materials for this article:\n\n" + truncate(content, 1500); @@ -238,7 +222,7 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) return parseAnalysisResult(response); } catch (Exception e) { logger.error("요약/퀴즈 생성 실패", e); - return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), null); + return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>()); } } @@ -284,20 +268,6 @@ private AnalysisResult parseAnalysisResult(String response) { JsonObject json = gson.fromJson(jsonStr, JsonObject.class); String summary = json.has("summary") ? json.get("summary").getAsString() : null; - String category = json.has("category") ? json.get("category").getAsString().toUpperCase() : "WORLD"; - - // keywords 파싱 - List keywords = new ArrayList<>(); - if (json.has("keywords")) { - json.getAsJsonArray("keywords").forEach(e -> { - JsonObject k = e.getAsJsonObject(); - keywords.add(KeywordInfo.builder() - .word(k.has("word") ? k.get("word").getAsString() : "") - .meaning(k.has("meaning") ? k.get("meaning").getAsString() : "") - .example(k.has("example") ? k.get("example").getAsString() : "") - .build()); - }); - } List highlightWords = new ArrayList<>(); if (json.has("highlightWords")) { @@ -323,7 +293,7 @@ private AnalysisResult parseAnalysisResult(String response) { }); } - return new AnalysisResult(summary, keywords, highlightWords, quiz, category); + return new AnalysisResult(summary, highlightWords, quiz); } private String extractJson(String response) { @@ -345,9 +315,7 @@ private String truncate(String text, int maxLength) { */ private record AnalysisResult( String summary, - List keywords, List highlightWords, - List quiz, - String category + List quiz ) {} } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java index c46e4fc6..8eba8522 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java @@ -2,18 +2,13 @@ import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.service.PollyService; -import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; -import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; import com.mzc.secondproject.serverless.domain.news.repository.UserNewsRepository; -import com.mzc.secondproject.serverless.domain.stats.model.UserStats; -import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; @@ -29,38 +24,29 @@ public class NewsLearningService { private final NewsArticleRepository articleRepository; private final UserNewsRepository userNewsRepository; private final PollyService pollyService; - private final UserStatsRepository userStatsRepository; - private final BadgeService badgeService; public NewsLearningService() { this.articleRepository = new NewsArticleRepository(); this.userNewsRepository = new UserNewsRepository(); this.pollyService = new PollyService(BUCKET_NAME, "news/audio/"); - this.userStatsRepository = new UserStatsRepository(); - this.badgeService = new BadgeService(); } public NewsLearningService(NewsArticleRepository articleRepository, UserNewsRepository userNewsRepository, - PollyService pollyService, - UserStatsRepository userStatsRepository, - BadgeService badgeService) { + PollyService pollyService) { this.articleRepository = articleRepository; this.userNewsRepository = userNewsRepository; this.pollyService = pollyService; - this.userStatsRepository = userStatsRepository; - this.badgeService = badgeService; } /** * 뉴스 읽기 완료 기록 - * @return 새로 획득한 배지 목록 */ - public List markAsRead(String userId, String articleId) { + public void markAsRead(String userId, String articleId) { Optional article = articleRepository.findById(articleId); if (article.isEmpty()) { logger.warn("기사를 찾을 수 없음: {}", articleId); - return new ArrayList<>(); + return; } NewsArticle a = article.get(); @@ -79,23 +65,6 @@ public List markAsRead(String userId, String articleId) { } logger.info("읽기 완료 기록: userId={}, articleId={}", userId, articleId); - - // 통계 업데이트 및 배지 체크 - List newBadges = new ArrayList<>(); - try { - UserStats updatedStats = userStatsRepository.incrementNewsReadStats(userId); - if (updatedStats != null) { - newBadges = badgeService.checkAndAwardBadges(userId, updatedStats); - if (!newBadges.isEmpty()) { - logger.info("새 배지 획득: userId={}, badges={}", userId, - newBadges.stream().map(UserBadge::getBadgeType).toList()); - } - } - } catch (Exception e) { - logger.error("통계/배지 업데이트 실패: userId={}, error={}", userId, e.getMessage()); - } - - return newBadges; } /** @@ -136,32 +105,10 @@ public boolean isBookmarked(String userId, String articleId) { } /** - * 사용자 북마크 목록 조회 (기사 정보 포함) + * 사용자 북마크 목록 조회 */ - public List> getUserBookmarks(String userId, int limit) { - List bookmarks = userNewsRepository.getUserBookmarks(userId, limit); - List> result = new ArrayList<>(); - - for (UserNewsRecord bookmark : bookmarks) { - Optional articleOpt = articleRepository.findById(bookmark.getArticleId()); - if (articleOpt.isPresent()) { - NewsArticle article = articleOpt.get(); - Map bookmarkWithArticle = new java.util.HashMap<>(); - bookmarkWithArticle.put("articleId", article.getArticleId()); - bookmarkWithArticle.put("title", article.getTitle()); - bookmarkWithArticle.put("summary", article.getSummary()); - bookmarkWithArticle.put("source", article.getSource()); - bookmarkWithArticle.put("publishedAt", article.getPublishedAt()); - bookmarkWithArticle.put("keywords", article.getKeywords()); - bookmarkWithArticle.put("highlightWords", article.getHighlightWords()); - bookmarkWithArticle.put("category", article.getCategory()); - bookmarkWithArticle.put("level", article.getLevel()); - bookmarkWithArticle.put("imageUrl", article.getImageUrl()); - bookmarkWithArticle.put("bookmarkedAt", bookmark.getCreatedAt()); - result.add(bookmarkWithArticle); - } - } - return result; + public List getUserBookmarks(String userId, int limit) { + return userNewsRepository.getUserBookmarks(userId, limit); } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java index da86d430..bb22fc90 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java @@ -1,13 +1,9 @@ package com.mzc.secondproject.serverless.domain.news.service; -import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; -import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; import com.mzc.secondproject.serverless.domain.news.model.*; import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; import com.mzc.secondproject.serverless.domain.news.repository.NewsQuizRepository; -import com.mzc.secondproject.serverless.domain.stats.model.UserStats; -import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -24,22 +20,15 @@ public class NewsQuizService { private final NewsArticleRepository articleRepository; private final NewsQuizRepository quizRepository; - private final UserStatsRepository userStatsRepository; - private final BadgeService badgeService; public NewsQuizService() { this.articleRepository = new NewsArticleRepository(); this.quizRepository = new NewsQuizRepository(); - this.userStatsRepository = new UserStatsRepository(); - this.badgeService = new BadgeService(); } - public NewsQuizService(NewsArticleRepository articleRepository, NewsQuizRepository quizRepository, - UserStatsRepository userStatsRepository, BadgeService badgeService) { + public NewsQuizService(NewsArticleRepository articleRepository, NewsQuizRepository quizRepository) { this.articleRepository = articleRepository; this.quizRepository = quizRepository; - this.userStatsRepository = userStatsRepository; - this.badgeService = badgeService; } /** @@ -169,29 +158,12 @@ public QuizSubmitResult submitQuiz(String userId, String articleId, List newBadges = new ArrayList<>(); - try { - boolean isPerfect = score == 100; - UserStats updatedStats = userStatsRepository.incrementNewsQuizStats(userId, isPerfect); - if (updatedStats != null) { - newBadges = badgeService.checkAndAwardBadges(userId, updatedStats); - if (!newBadges.isEmpty()) { - logger.info("새 배지 획득: userId={}, badges={}", userId, - newBadges.stream().map(UserBadge::getBadgeType).toList()); - } - } - } catch (Exception e) { - logger.error("통계/배지 업데이트 실패: userId={}, error={}", userId, e.getMessage()); - } - return QuizSubmitResult.builder() .score(score) .earnedPoints(earnedPoints) .totalPoints(totalPoints) .results(answerResults) .feedback(feedback) - .newBadges(newBadges) .build(); } @@ -287,6 +259,5 @@ public static class QuizSubmitResult { private int totalPoints; private List results; private String feedback; - private List newBadges; } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java index 6881c7ec..6c3c23ec 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java @@ -1,14 +1,10 @@ package com.mzc.secondproject.serverless.domain.news.service; -import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; -import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; import com.mzc.secondproject.serverless.domain.news.model.NewsWordCollect; import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; import com.mzc.secondproject.serverless.domain.news.repository.NewsWordRepository; -import com.mzc.secondproject.serverless.domain.stats.model.UserStats; -import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; import com.mzc.secondproject.serverless.domain.vocabulary.service.UserWordCommandService; @@ -16,7 +12,6 @@ import org.slf4j.LoggerFactory; import java.time.Instant; -import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; @@ -32,42 +27,32 @@ public class NewsWordService { private final NewsArticleRepository articleRepository; private final WordRepository wordRepository; private final UserWordCommandService userWordCommandService; - private final UserStatsRepository userStatsRepository; - private final BadgeService badgeService; public NewsWordService() { this.newsWordRepository = new NewsWordRepository(); this.articleRepository = new NewsArticleRepository(); this.wordRepository = new WordRepository(); this.userWordCommandService = new UserWordCommandService(); - this.userStatsRepository = new UserStatsRepository(); - this.badgeService = new BadgeService(); } public NewsWordService(NewsWordRepository newsWordRepository, NewsArticleRepository articleRepository, WordRepository wordRepository, - UserWordCommandService userWordCommandService, - UserStatsRepository userStatsRepository, - BadgeService badgeService) { + UserWordCommandService userWordCommandService) { this.newsWordRepository = newsWordRepository; this.articleRepository = articleRepository; this.wordRepository = wordRepository; this.userWordCommandService = userWordCommandService; - this.userStatsRepository = userStatsRepository; - this.badgeService = badgeService; } /** * 단어 수집 - * @return 수집 결과 (단어 정보 + 새로 획득한 배지) */ - public WordCollectResult collectWord(String userId, String articleId, String word, String context) { + public NewsWordCollect collectWord(String userId, String articleId, String word, String context) { // 이미 수집했는지 확인 if (newsWordRepository.hasCollected(userId, word, articleId)) { logger.warn("이미 수집한 단어: userId={}, word={}", userId, word); - NewsWordCollect existing = newsWordRepository.findByUserWordArticle(userId, word, articleId).orElse(null); - return new WordCollectResult(existing, new ArrayList<>()); + return newsWordRepository.findByUserWordArticle(userId, word, articleId).orElse(null); } // 기사 조회 @@ -101,29 +86,9 @@ public WordCollectResult collectWord(String userId, String articleId, String wor newsWordRepository.save(wordCollect); logger.info("단어 수집 완료: userId={}, word={}, articleId={}", userId, word, articleId); - // 통계 업데이트 및 배지 체크 - List newBadges = new ArrayList<>(); - try { - UserStats updatedStats = userStatsRepository.incrementNewsWordStats(userId, 1); - if (updatedStats != null) { - newBadges = badgeService.checkAndAwardBadges(userId, updatedStats); - if (!newBadges.isEmpty()) { - logger.info("새 배지 획득: userId={}, badges={}", userId, - newBadges.stream().map(UserBadge::getBadgeType).toList()); - } - } - } catch (Exception e) { - logger.error("통계/배지 업데이트 실패: userId={}, error={}", userId, e.getMessage()); - } - - return new WordCollectResult(wordCollect, newBadges); + return wordCollect; } - /** - * 단어 수집 결과 - */ - public record WordCollectResult(NewsWordCollect wordCollect, List newBadges) {} - /** * 수집한 단어 삭제 */ 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..e69de29b 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..e69de29b 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 c515f950..69375925 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,28 +148,6 @@ 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) 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..e69de29b 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..e69de29b 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 a6d9e26a..fed1cd66 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("SPEAKING_TABLE_NAME"); + private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); private final DynamoDbTable table; 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 3dffac92..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 @@ -342,4 +342,4 @@ public record SpeakingResponse( String aiAudioUrl, // AI 응답 음성 URL (Polly) double confidence // STT 신뢰도comp ) {} -} +} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java index 5a2ba9c0..637151be 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java @@ -49,7 +49,6 @@ public UserStatsHandler(UserStatsRepository statsRepository, DailyStudyRepositor private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( - Route.getAuth("/stats/dashboard", this::getDashboardStats), Route.getAuth("/stats/daily", this::getDailyStats), Route.getAuth("/stats/weekly", this::getWeeklyStats), Route.getAuth("/stats/monthly", this::getMonthlyStats), @@ -63,88 +62,7 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); return router.route(request); } - - /** - * 대시보드용 통합 통계 조회 (프론트엔드 요청 형식) - * GET /stats/dashboard - */ - private APIGatewayProxyResponseEvent getDashboardStats(APIGatewayProxyRequestEvent request, String userId) { - String today = LocalDate.now().toString(); - - // 오늘 통계 조회 - Optional dailyStats = statsRepository.findDailyStats(userId, today); - // 전체 통계 조회 - Optional totalStats = statsRepository.findTotalStats(userId); - // 최근 7일 히스토리 조회 - PaginatedResult weekHistory = statsRepository.findRecentDailyStats(userId, 7, null); - // 오늘 학습 목표 조회 - Optional dailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); - - Map response = new HashMap<>(); - - // today 섹션 - Map todaySection = new HashMap<>(); - if (dailyStats.isPresent()) { - UserStats ds = dailyStats.get(); - todaySection.put("wordsLearned", ds.getNewWordsLearned() != null ? ds.getNewWordsLearned() : 0); - todaySection.put("newsRead", ds.getNewsRead() != null ? ds.getNewsRead() : 0); - todaySection.put("quizzesTaken", (ds.getTestsCompleted() != null ? ds.getTestsCompleted() : 0) + - (ds.getNewsQuizCompleted() != null ? ds.getNewsQuizCompleted() : 0)); - } else { - todaySection.put("wordsLearned", 0); - todaySection.put("newsRead", 0); - todaySection.put("quizzesTaken", 0); - } - todaySection.put("wordsTotal", dailyStudy.map(ds -> ds.getTotalWords() != null ? ds.getTotalWords() : 25).orElse(25)); - response.put("today", todaySection); - - // overall 섹션 - Map overallSection = new HashMap<>(); - if (totalStats.isPresent()) { - UserStats ts = totalStats.get(); - overallSection.put("totalWordsLearned", ts.getNewWordsLearned() != null ? ts.getNewWordsLearned() : 0); - overallSection.put("totalNewsRead", ts.getNewsRead() != null ? ts.getNewsRead() : 0); - overallSection.put("totalQuizzes", (ts.getTestsCompleted() != null ? ts.getTestsCompleted() : 0) + - (ts.getNewsQuizCompleted() != null ? ts.getNewsQuizCompleted() : 0)); - overallSection.put("averageAccuracy", calculateSuccessRate(ts)); - overallSection.put("currentStreak", ts.getCurrentStreak() != null ? ts.getCurrentStreak() : 0); - overallSection.put("longestStreak", ts.getLongestStreak() != null ? ts.getLongestStreak() : 0); - overallSection.put("lastStudyDate", ts.getLastStudyDate()); - } else { - overallSection.put("totalWordsLearned", 0); - overallSection.put("totalNewsRead", 0); - overallSection.put("totalQuizzes", 0); - overallSection.put("averageAccuracy", 0.0); - overallSection.put("currentStreak", 0); - overallSection.put("longestStreak", 0); - overallSection.put("lastStudyDate", null); - } - // totalStudyDays 계산 (최근 히스토리에서 실제 학습한 날 수) - overallSection.put("totalStudyDays", weekHistory.items().size()); - response.put("overall", overallSection); - - // weeklyProgress 섹션 - List> weeklyProgress = weekHistory.items().stream() - .map(stats -> { - Map dayStats = new HashMap<>(); - dayStats.put("date", stats.getPeriod()); - dayStats.put("wordsLearned", stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0); - dayStats.put("newsRead", stats.getNewsRead() != null ? stats.getNewsRead() : 0); - return dayStats; - }) - .collect(Collectors.toList()); - response.put("weeklyProgress", weeklyProgress); - - // levelDistribution (현재 미구현 - 향후 추가 가능) - Map levelDistribution = new HashMap<>(); - levelDistribution.put("beginner", 0); - levelDistribution.put("intermediate", 0); - levelDistribution.put("advanced", 0); - response.put("levelDistribution", levelDistribution); - - return ResponseGenerator.ok("학습 통계 조회 성공", response); - } - + /** * 오늘의 통계 조회 */ @@ -254,7 +172,7 @@ private Map buildStatsResponse(Optional stats, String Map response = new HashMap<>(); response.put("periodType", periodType); response.put("period", period); - + if (stats.isPresent()) { UserStats s = stats.get(); response.put("testsCompleted", s.getTestsCompleted() != null ? s.getTestsCompleted() : 0); @@ -264,11 +182,6 @@ private Map buildStatsResponse(Optional stats, String response.put("successRate", calculateSuccessRate(s)); response.put("newWordsLearned", s.getNewWordsLearned() != null ? s.getNewWordsLearned() : 0); response.put("wordsReviewed", s.getWordsReviewed() != null ? s.getWordsReviewed() : 0); - // 뉴스 관련 통계 - response.put("newsRead", s.getNewsRead() != null ? s.getNewsRead() : 0); - response.put("newsQuizCompleted", s.getNewsQuizCompleted() != null ? s.getNewsQuizCompleted() : 0); - response.put("newsQuizPerfect", s.getNewsQuizPerfect() != null ? s.getNewsQuizPerfect() : 0); - response.put("newsWordsCollected", s.getNewsWordsCollected() != null ? s.getNewsWordsCollected() : 0); } else { response.put("testsCompleted", 0); response.put("questionsAnswered", 0); @@ -277,13 +190,8 @@ private Map buildStatsResponse(Optional stats, String response.put("successRate", 0.0); response.put("newWordsLearned", 0); response.put("wordsReviewed", 0); - // 뉴스 관련 통계 - response.put("newsRead", 0); - response.put("newsQuizCompleted", 0); - response.put("newsQuizPerfect", 0); - response.put("newsWordsCollected", 0); } - + return response; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java index 4905a9f2..cc25634c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java @@ -56,15 +56,7 @@ public class UserStats { private Integer totalGameScore; // 누적 게임 점수 private Integer quickGuesses; // 5초 내 정답 횟수 private Integer perfectDraws; // 전원 정답 유도 횟수 - - // 뉴스 통계 - private Integer newsRead; // 읽은 뉴스 수 - private Integer newsQuizCompleted; // 완료한 뉴스 퀴즈 수 - private Integer newsQuizPerfect; // 뉴스 퀴즈 만점 횟수 - private Integer newsWordsCollected; // 뉴스에서 수집한 단어 수 - private Integer newsStreak; // 뉴스 연속 읽기 일수 - private String lastNewsReadDate; // 마지막 뉴스 읽은 날짜 - + // 메타데이터 private String createdAt; private String updatedAt; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java index 4ab46228..b3ad20d8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java @@ -310,222 +310,6 @@ public void incrementGameStats(String userId, int gamesPlayed, int gamesWon, userId, gamesPlayed, gamesWon, correctGuesses); } - /** - * 뉴스 읽기 통계 Atomic 업데이트 (TOTAL + DAILY) - */ - public UserStats incrementNewsReadStats(String userId) { - String today = LocalDate.now().toString(); - String pk = StatsKey.userStatsPk(userId); - String now = Instant.now().toString(); - - // 먼저 현재 통계 조회 (streak 계산용) - UserStats currentStats = findTotalStats(userId).orElse(null); - String lastNewsReadDate = currentStats != null ? currentStats.getLastNewsReadDate() : null; - - // 연속 읽기 계산 - int currentStreak = 1; - if (lastNewsReadDate != null) { - LocalDate lastDate = LocalDate.parse(lastNewsReadDate); - LocalDate todayDate = LocalDate.now(); - if (lastDate.equals(todayDate.minusDays(1))) { - // 어제 읽었으면 streak 증가 - currentStreak = (currentStats.getNewsStreak() != null ? currentStats.getNewsStreak() : 0) + 1; - } else if (lastDate.equals(todayDate)) { - // 오늘 이미 읽었으면 streak 유지 - currentStreak = currentStats.getNewsStreak() != null ? currentStats.getNewsStreak() : 1; - } - // 그 외의 경우는 streak 1로 초기화 - } - - Map values = new HashMap<>(); - values.put(":one", AttributeValue.builder().n("1").build()); - values.put(":zero", AttributeValue.builder().n("0").build()); - values.put(":streak", AttributeValue.builder().n(String.valueOf(currentStreak)).build()); - values.put(":today", AttributeValue.builder().s(today).build()); - values.put(":now", AttributeValue.builder().s(now).build()); - - // 1. TOTAL 통계 업데이트 - Map totalKey = new HashMap<>(); - totalKey.put("PK", AttributeValue.builder().s(pk).build()); - totalKey.put("SK", AttributeValue.builder().s(StatsKey.statsTotalSk()).build()); - - String totalUpdateExpression = "SET " + - "newsRead = if_not_exists(newsRead, :zero) + :one, " + - "newsStreak = :streak, " + - "lastNewsReadDate = :today, " + - "updatedAt = :now, " + - "createdAt = if_not_exists(createdAt, :now)"; - - UpdateItemRequest totalRequest = UpdateItemRequest.builder() - .tableName(TABLE_NAME) - .key(totalKey) - .updateExpression(totalUpdateExpression) - .expressionAttributeValues(values) - .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) - .build(); - - AwsClients.dynamoDb().updateItem(totalRequest); - - // 2. DAILY 통계 업데이트 - Map dailyKey = new HashMap<>(); - dailyKey.put("PK", AttributeValue.builder().s(pk).build()); - dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); - - Map dailyValues = new HashMap<>(); - dailyValues.put(":one", AttributeValue.builder().n("1").build()); - dailyValues.put(":zero", AttributeValue.builder().n("0").build()); - dailyValues.put(":now", AttributeValue.builder().s(now).build()); - dailyValues.put(":today", AttributeValue.builder().s(today).build()); - - String dailyUpdateExpression = "SET " + - "newsRead = if_not_exists(newsRead, :zero) + :one, " + - "updatedAt = :now, " + - "createdAt = if_not_exists(createdAt, :now), " + - "period = if_not_exists(period, :today)"; - - UpdateItemRequest dailyRequest = UpdateItemRequest.builder() - .tableName(TABLE_NAME) - .key(dailyKey) - .updateExpression(dailyUpdateExpression) - .expressionAttributeValues(dailyValues) - .build(); - - AwsClients.dynamoDb().updateItem(dailyRequest); - logger.info("Incremented news read stats (TOTAL + DAILY): userId={}, streak={}", userId, currentStreak); - - return findTotalStats(userId).orElse(null); - } - - /** - * 뉴스 퀴즈 통계 Atomic 업데이트 (TOTAL + DAILY) - */ - public UserStats incrementNewsQuizStats(String userId, boolean isPerfect) { - String today = LocalDate.now().toString(); - String pk = StatsKey.userStatsPk(userId); - String now = Instant.now().toString(); - - Map values = new HashMap<>(); - values.put(":one", AttributeValue.builder().n("1").build()); - values.put(":zero", AttributeValue.builder().n("0").build()); - values.put(":perfect", AttributeValue.builder().n(isPerfect ? "1" : "0").build()); - values.put(":now", AttributeValue.builder().s(now).build()); - - // 1. TOTAL 통계 업데이트 - Map totalKey = new HashMap<>(); - totalKey.put("PK", AttributeValue.builder().s(pk).build()); - totalKey.put("SK", AttributeValue.builder().s(StatsKey.statsTotalSk()).build()); - - String totalUpdateExpression = "SET " + - "newsQuizCompleted = if_not_exists(newsQuizCompleted, :zero) + :one, " + - "newsQuizPerfect = if_not_exists(newsQuizPerfect, :zero) + :perfect, " + - "updatedAt = :now, " + - "createdAt = if_not_exists(createdAt, :now)"; - - UpdateItemRequest totalRequest = UpdateItemRequest.builder() - .tableName(TABLE_NAME) - .key(totalKey) - .updateExpression(totalUpdateExpression) - .expressionAttributeValues(values) - .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) - .build(); - - AwsClients.dynamoDb().updateItem(totalRequest); - - // 2. DAILY 통계 업데이트 - Map dailyKey = new HashMap<>(); - dailyKey.put("PK", AttributeValue.builder().s(pk).build()); - dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); - - Map dailyValues = new HashMap<>(); - dailyValues.put(":one", AttributeValue.builder().n("1").build()); - dailyValues.put(":zero", AttributeValue.builder().n("0").build()); - dailyValues.put(":perfect", AttributeValue.builder().n(isPerfect ? "1" : "0").build()); - dailyValues.put(":now", AttributeValue.builder().s(now).build()); - dailyValues.put(":today", AttributeValue.builder().s(today).build()); - - String dailyUpdateExpression = "SET " + - "newsQuizCompleted = if_not_exists(newsQuizCompleted, :zero) + :one, " + - "newsQuizPerfect = if_not_exists(newsQuizPerfect, :zero) + :perfect, " + - "updatedAt = :now, " + - "createdAt = if_not_exists(createdAt, :now), " + - "period = if_not_exists(period, :today)"; - - UpdateItemRequest dailyRequest = UpdateItemRequest.builder() - .tableName(TABLE_NAME) - .key(dailyKey) - .updateExpression(dailyUpdateExpression) - .expressionAttributeValues(dailyValues) - .build(); - - AwsClients.dynamoDb().updateItem(dailyRequest); - logger.info("Incremented news quiz stats (TOTAL + DAILY): userId={}, isPerfect={}", userId, isPerfect); - - return findTotalStats(userId).orElse(null); - } - - /** - * 뉴스 단어 수집 통계 Atomic 업데이트 (TOTAL + DAILY) - */ - public UserStats incrementNewsWordStats(String userId, int wordCount) { - String today = LocalDate.now().toString(); - String pk = StatsKey.userStatsPk(userId); - String now = Instant.now().toString(); - - Map values = new HashMap<>(); - values.put(":count", AttributeValue.builder().n(String.valueOf(wordCount)).build()); - values.put(":zero", AttributeValue.builder().n("0").build()); - values.put(":now", AttributeValue.builder().s(now).build()); - - // 1. TOTAL 통계 업데이트 - Map totalKey = new HashMap<>(); - totalKey.put("PK", AttributeValue.builder().s(pk).build()); - totalKey.put("SK", AttributeValue.builder().s(StatsKey.statsTotalSk()).build()); - - String totalUpdateExpression = "SET " + - "newsWordsCollected = if_not_exists(newsWordsCollected, :zero) + :count, " + - "updatedAt = :now, " + - "createdAt = if_not_exists(createdAt, :now)"; - - UpdateItemRequest totalRequest = UpdateItemRequest.builder() - .tableName(TABLE_NAME) - .key(totalKey) - .updateExpression(totalUpdateExpression) - .expressionAttributeValues(values) - .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) - .build(); - - AwsClients.dynamoDb().updateItem(totalRequest); - - // 2. DAILY 통계 업데이트 - Map dailyKey = new HashMap<>(); - dailyKey.put("PK", AttributeValue.builder().s(pk).build()); - dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); - - Map dailyValues = new HashMap<>(); - dailyValues.put(":count", AttributeValue.builder().n(String.valueOf(wordCount)).build()); - dailyValues.put(":zero", AttributeValue.builder().n("0").build()); - dailyValues.put(":now", AttributeValue.builder().s(now).build()); - dailyValues.put(":today", AttributeValue.builder().s(today).build()); - - String dailyUpdateExpression = "SET " + - "newsWordsCollected = if_not_exists(newsWordsCollected, :zero) + :count, " + - "updatedAt = :now, " + - "createdAt = if_not_exists(createdAt, :now), " + - "period = if_not_exists(period, :today)"; - - UpdateItemRequest dailyRequest = UpdateItemRequest.builder() - .tableName(TABLE_NAME) - .key(dailyKey) - .updateExpression(dailyUpdateExpression) - .expressionAttributeValues(dailyValues) - .build(); - - AwsClients.dynamoDb().updateItem(dailyRequest); - logger.info("Incremented news word stats (TOTAL + DAILY): userId={}, wordCount={}", userId, wordCount); - - return findTotalStats(userId).orElse(null); - } - /** * 현재 연도-주차 반환 (예: 2026-W02) */ diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/badge/enums/BadgeTypeSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/badge/enums/BadgeTypeSpec.groovy index 6fd08457..19a64976 100644 --- a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/badge/enums/BadgeTypeSpec.groovy +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/badge/enums/BadgeTypeSpec.groovy @@ -111,8 +111,8 @@ class BadgeTypeSpec extends Specification { } def "모든 BadgeType 개수 확인"() { - expect: "29개의 뱃지 타입 존재 (기본 15 + 뉴스 14)" - BadgeType.values().length == 29 + expect: "15개의 뱃지 타입 존재" + BadgeType.values().length == 15 } def "모든 뱃지의 imageUrl이 S3 URL 형식"() { diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 97fe03e6..3509353e 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -105,7 +105,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 +114,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,20 +122,20 @@ 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: @@ -1055,14 +1055,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: @@ -1396,63 +1388,6 @@ Resources: Description: Daily word learning stats aggregation Enabled: true - ############################################# - # Speaking REST API (AI와 대화하기) - ############################################# - - 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 AI conversation (REST API) - Timeout: 120 - MemorySize: 1024 - SnapStart: - ApplyOn: PublishedVersions - Environment: - Variables: - SPEAKING_TABLE_NAME: !Ref SpeakingTable - TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref SpeakingTable - - S3CrudPolicy: - BucketName: !Ref ContentBucket - - 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 ############################################# @@ -1744,38 +1679,6 @@ 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 ############################################# @@ -1792,16 +1695,6 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref NewsTable - - Statement: - - Effect: Allow - Action: - - bedrock:InvokeModel - Resource: "*" - - Statement: - - Effect: Allow - Action: - - comprehend:DetectKeyPhrases - Resource: "*" Events: DailySchedule: Type: Schedule @@ -1857,64 +1750,48 @@ Resources: 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: @@ -1927,32 +1804,24 @@ Resources: 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: @@ -2147,7 +2016,3 @@ Outputs: OPIcTableName: Description: OPIc DynamoDB Table Name Value: !Ref OPIcTable - - SpeakingTableName: - Description: Speaking DynamoDB Table Name - Value: !Ref SpeakingTable diff --git a/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md b/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md new file mode 100644 index 00000000..e4c22aa4 --- /dev/null +++ b/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md @@ -0,0 +1,521 @@ +# 채팅방 / 캐치마인드 게임 분리 - 종합 솔루션 + +## 1. 현재 문제점 분석 + +### 1.1 백엔드 현황 + +``` +ChatRoom.java (현재 - 혼합 모델) +├── 채팅 필드 +│ ├── roomId, name, description +│ ├── memberIds, currentMembers +│ └── lastMessageAt +│ +└── 게임 필드 (여기에 섞여있음) + ├── gameStatus, gameStartedBy + ├── currentRound, totalRounds + ├── currentDrawerId, currentWord + ├── roundStartTime, roundTimeLimit ← serverTime 없음! + ├── scores, streaks + └── correctGuessers +``` + +**문제점:** + +1. `roundStartTime`만 전송, `serverTime` 누락 → 클라이언트 타이머 동기화 불가 +2. 게임 세션이 채팅방에 종속 → 게임 상태 독립 관리 불가 +3. 재접속 시 게임 상태 복구 어려움 +4. 게임 종료 후 상태 정리 복잡 + +### 1.2 WebSocket 메시지 현황 + +```java +// WebSocketMessageHandler.java - 현재 구조 +handleRequest() { + switch (messageType) { + case "DRAWING", "DRAWING_CLEAR" -> handleDrawingMessage() // 게임 + default -> handleRegularMessage() { + // 1. 슬래시 명령어 처리 (/start, /stop, /score...) + // 2. 게임 중 정답 체크 + // 3. 일반 채팅 메시지 + } + } +} +``` + +**문제점:** + +- 채팅/게임 구분 없이 모든 메시지가 동일 핸들러에서 처리 +- 메시지에 `domain` 필드 없음 + +--- + +## 2. 최적 솔루션 + +### 2.1 아키텍처 개요 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ WebSocket (단일 엔드포인트 유지) │ +│ │ +│ ┌──────────────────────┐ ┌────────────────────────────────┐ │ +│ │ domain: "chat" │ │ domain: "game" │ │ +│ │ │ │ │ │ +│ │ • TEXT │ │ • GAME_START / GAME_END │ │ +│ │ • USER_JOIN │ │ • ROUND_START / ROUND_END │ │ +│ │ • USER_LEAVE │ │ • DRAWING / DRAWING_CLEAR │ │ +│ │ • SYSTEM │ │ • GUESS / CORRECT_ANSWER │ │ +│ │ │ │ • SCORE_UPDATE / HINT │ │ +│ └──────────────────────┘ └────────────────────────────────┘ │ +│ │ +│ GameSession (별도 모델) │ +│ ├── gameSessionId │ +│ ├── roomId (연결용) │ +│ ├── status, currentRound │ +│ ├── roundStartTime + serverTime ← 핵심! │ +│ └── scores, players │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 핵심 변경사항 + +| 구분 | 현재 | 변경 후 | +|-----|----------------------|---------------------------------| +| 모델 | `ChatRoom`에 게임 필드 포함 | `ChatRoom` + `GameSession` 분리 | +| 타이머 | `roundStartTime`만 전송 | `roundStartTime` + `serverTime` | +| 메시지 | `messageType`만 존재 | `domain` + `messageType` | +| API | 채팅방 API만 존재 | 게임 세션 API 추가 | + +--- + +## 3. 백엔드 변경사항 + +### 3.1 Phase 1: 타이머 버그 수정 (즉시) + +**변경 파일:** `WebSocketMessageHandler.java` + +```java +// GAME_START 메시지에 serverTime 추가 +private void broadcastGameStart(...) { + Map message = new HashMap<>(); + // ... 기존 코드 ... + + message.put("roundStartTime", gameResult.room().getRoundStartTime()); + message.put("serverTime", System.currentTimeMillis()); // 추가! + message.put("roundDuration", gameResult.room().getRoundTimeLimit()); // 명확한 이름 + + // ... +} + +// ROUND_END → ROUND_START 메시지에도 동일하게 추가 +private void broadcastRoundEnd(...) { + // ... + messageData.put("roundStartTime", room.getRoundStartTime()); + messageData.put("serverTime", System.currentTimeMillis()); // 추가! + messageData.put("roundDuration", room.getRoundTimeLimit()); + // ... +} +``` + +**예상 작업량:** 30분 + +### 3.2 Phase 2: 메시지 구조 개선 (1일) + +**변경 파일:** `WebSocketMessageHandler.java`, 모든 브로드캐스트 메서드 + +```java +// 모든 메시지에 domain 필드 추가 +private Map createMessage(String domain, String messageType, Object data) { + Map message = new HashMap<>(); + message.put("domain", domain); // "chat" 또는 "game" + message.put("messageType", messageType); + message.put("data", data); + message.put("timestamp", System.currentTimeMillis()); + return message; +} + +// 채팅 메시지 +createMessage("chat", "TEXT", chatData); +createMessage("chat", "USER_JOIN", joinData); + +// 게임 메시지 +createMessage("game", "GAME_START", gameStartData); +createMessage("game", "ROUND_START", roundStartData); +createMessage("game", "DRAWING", drawingData); +``` + +### 3.3 Phase 3: 게임 세션 분리 (1주) + +#### 3.3.1 새 모델: GameSession.java + +```java +@DynamoDbBean +public class GameSession { + private String pk; // GAME#{gameSessionId} + private String sk; // METADATA + private String gsi1pk; // ROOM#{roomId} + private String gsi1sk; // GAME#{createdAt} + + // 게임 식별 + private String gameSessionId; + private String roomId; // 연결된 채팅방 + private String gameType; // "catchmind" + + // 게임 상태 + private String status; // WAITING, PLAYING, FINISHED + private String startedBy; + private Long startedAt; + private Long endedAt; + + // 라운드 정보 + private Integer currentRound; + private Integer totalRounds; + private String currentDrawerId; + private String currentWordId; + private String currentWord; + private Long roundStartTime; + private Integer roundDuration; + + // 점수 + private Map scores; + private Map streaks; + private List players; + private List drawerOrder; + + // 자동 종료 + private Long gameEndScheduledAt; + private String scheduleRuleArn; + + // TTL + private Long ttl; +} +``` + +#### 3.3.2 ChatRoom에서 게임 필드 제거 + +```java +@DynamoDbBean +public class ChatRoom { + // 채팅 필드만 유지 + private String roomId; + private String name; + private String description; + private String level; + private Integer currentMembers; + private Integer maxMembers; + private Boolean isPrivate; + private String password; + private String createdBy; + private String createdAt; + private String lastMessageAt; + private List memberIds; + + // 게임 연결 (참조만) + private String activeGameSessionId; // 현재 진행중인 게임 세션 ID + + // 게임 필드 모두 제거! + // - gameStatus, gameStartedBy, currentRound... 전부 GameSession으로 이동 +} +``` + +#### 3.3.3 게임 세션 API + +``` +# 게임 세션 생성 +POST /api/chat/rooms/{roomId}/games +Request: +{ + "gameType": "catchmind", + "settings": { + "totalRounds": 5, + "roundDuration": 60 + } +} + +Response: +{ + "gameSessionId": "game-abc123", + "roomId": "room-xyz", + "status": "WAITING", + "createdAt": "2024-01-20T10:00:00Z" +} + +# 게임 상태 조회 (재접속 시 필수!) +GET /api/games/{gameSessionId} + +Response: +{ + "gameSessionId": "game-abc123", + "roomId": "room-xyz", + "status": "PLAYING", + "currentRound": 2, + "totalRounds": 5, + "currentDrawerId": "user123", + "roundStartTime": 1705744800000, + "serverTime": 1705744830000, // 핵심! + "roundDuration": 60, + "scores": { + "user1": 150, + "user2": 120 + }, + "players": ["user1", "user2", "user3"] +} + +# 게임 시작 (기존 /start 명령어 대체) +POST /api/games/{gameSessionId}/start + +# 게임 종료 +POST /api/games/{gameSessionId}/stop +``` + +--- + +## 4. 프론트엔드 변경사항 + +### 4.1 Phase 1: 타이머 버그 수정 (즉시) + +```javascript +// useTimer.js - 독립적인 타이머 훅 +export function useTimer(roundStartTime, roundDuration, serverTime) { + const [remainingTime, setRemainingTime] = useState(roundDuration); + + useEffect(() => { + if (!roundStartTime || !roundDuration) return; + + // 서버-클라이언트 시간 차이 보정 + const timeOffset = serverTime ? (Date.now() - serverTime) : 0; + + const interval = setInterval(() => { + const adjustedNow = Date.now() - timeOffset; + const elapsed = Math.floor((adjustedNow - roundStartTime) / 1000); + const remaining = Math.max(0, roundDuration - elapsed); + setRemainingTime(remaining); + + if (remaining <= 0) { + clearInterval(interval); + } + }, 100); + + return () => clearInterval(interval); + }, [roundStartTime, roundDuration, serverTime]); + + return remainingTime; +} +``` + +### 4.2 Phase 2: 메시지 핸들러 분리 + +```javascript +// WebSocket 메시지 핸들러 +onMessage(event) { + const message = JSON.parse(event.data); + + switch (message.domain) { + case 'chat': + this.handleChatMessage(message); + break; + case 'game': + this.handleGameMessage(message); + break; + } +} + +handleChatMessage(message) { + switch (message.messageType) { + case 'TEXT': // 채팅 메시지 + case 'USER_JOIN': + case 'USER_LEAVE': + case 'SYSTEM': + } +} + +handleGameMessage(message) { + switch (message.messageType) { + case 'GAME_START': + case 'ROUND_START': + case 'DRAWING': + case 'CORRECT_ANSWER': + case 'SCORE_UPDATE': + } +} +``` + +### 4.3 Phase 3: 훅 분리 + +``` +src/domains/ +├── chat/ +│ ├── hooks/ +│ │ └── useChatWebSocket.js # 채팅만 처리 +│ └── components/ +│ ├── ChatMessages.jsx +│ └── ChatInput.jsx +│ +├── catchmind/ +│ ├── hooks/ +│ │ ├── useGameWebSocket.js # 게임만 처리 +│ │ ├── useGameState.js +│ │ └── useTimer.js +│ └── components/ +│ ├── DrawingCanvas.jsx +│ ├── ScoreBoard.jsx +│ └── Timer.jsx +│ +└── freetalk/ + └── pages/ + └── FreeTalkPage.jsx # chat + catchmind 조합 +``` + +--- + +## 5. 메시지 스펙 (최종) + +### 5.1 공통 메시지 구조 + +```json +{ + "domain": "chat" | "game", + "messageType": "...", + "data": { ... }, + "timestamp": 1705744800000 +} +``` + +### 5.2 채팅 메시지 + +| Type | 방향 | data 필드 | +|--------------|-----|-----------------------------------------------| +| `TEXT` | 양방향 | `messageId`, `userId`, `content`, `createdAt` | +| `USER_JOIN` | S→C | `userId`, `memberCount` | +| `USER_LEAVE` | S→C | `userId`, `memberCount` | +| `SYSTEM` | S→C | `content` | + +### 5.3 게임 메시지 + +| Type | 방향 | data 필드 | +|------------------|-----|---------------------------------------------------------------------------------------------------------------| +| `GAME_START` | S→C | `gameSessionId`, `totalRounds`, `currentDrawerId`, `roundStartTime`, `serverTime`, `roundDuration`, `players` | +| `GAME_END` | S→C | `gameSessionId`, `reason`, `finalScores`, `winner` | +| `ROUND_START` | S→C | `currentRound`, `currentDrawerId`, `roundStartTime`, `serverTime`, `roundDuration`, `currentWord`(출제자만) | +| `ROUND_END` | S→C | `currentRound`, `answer`, `scores` | +| `DRAWING` | 양방향 | `drawingData` | +| `DRAWING_CLEAR` | 양방향 | - | +| `GUESS` | C→S | `content` | +| `CORRECT_ANSWER` | S→C | `userId`, `score`, `elapsedTime` | +| `SCORE_UPDATE` | S→C | `scores`, `currentRound`, `totalRounds` | +| `HINT` | S→C | `hint` | + +### 5.4 ROUND_START 상세 (핵심!) + +```json +{ + "domain": "game", + "messageType": "ROUND_START", + "data": { + "gameSessionId": "game-abc123", + "currentRound": 2, + "totalRounds": 5, + "currentDrawerId": "user123", + "roundStartTime": 1705744800000, + "serverTime": 1705744800500, + "roundDuration": 60, + "currentWord": { + "wordId": "word-1", + "word": "apple" + } + }, + "timestamp": 1705744800500 +} +``` + +**중요:** `currentWord`는 출제자에게만 전송! + +--- + +## 6. 구현 일정 + +``` +Week 1: 긴급 버그 수정 +├── [BE] serverTime 필드 추가 (0.5일) +├── [FE] useTimer 훅 수정 (0.5일) +├── [BE] 메시지에 domain 필드 추가 (1일) +└── [FE] 메시지 핸들러 domain 분기 (0.5일) + +Week 2: 게임 세션 분리 (BE) +├── [BE] GameSession 모델 생성 +├── [BE] GameSessionRepository 구현 +├── [BE] GameService 리팩토링 +└── [BE] 게임 세션 API 구현 + +Week 3: 프론트엔드 리팩토링 +├── [FE] useChatWebSocket 분리 +├── [FE] useGameWebSocket 신규 +├── [FE] 컴포넌트 분리 +└── [FE/BE] 통합 테스트 + +Week 4: 안정화 및 추가 기능 +├── [BE] 게임 자동 종료 (7분) - Issue #417 +├── [BE] 재접속 시 게임 상태 복구 +└── [FE/BE] E2E 테스트 +``` + +--- + +## 7. 기대 효과 + +| 항목 | 현재 | 개선 후 | +|---------|-------------|------------------| +| 타이머 정확도 | 클라이언트 시계 의존 | 서버 시간 기준 동기화 | +| 재접속 | 게임 상태 유실 | 완전 복구 가능 | +| 테스트 | 채팅/게임 분리 불가 | 독립 테스트 가능 | +| 확장성 | 새 게임 추가 어려움 | gameType으로 확장 용이 | +| 유지보수 | 책임 혼재 | 명확한 책임 분리 | + +--- + +## 8. 즉시 적용 (백엔드 변경 전 프론트엔드 임시 조치) + +```javascript +// 백엔드 변경 전까지 프론트엔드에서 적용 가능한 임시 코드 + +onRoundStart: (data) => { + const roundData = data.data || data; + const now = Date.now(); + + // serverTime이 없으면 클라이언트 시간 사용 (임시) + const serverTime = roundData.serverTime || now; + let roundStartTime = roundData.roundStartTime || now; + + // roundStartTime이 미래 시간이면 현재로 보정 + if (roundStartTime > now + 1000) { + console.warn('Invalid roundStartTime, using current time'); + roundStartTime = now; + } + + setGameState((prev) => ({ + ...prev, + currentRound: roundData.currentRound, + currentDrawerId: roundData.currentDrawerId, + roundStartTime: roundStartTime, + serverTime: serverTime, + roundDuration: roundData.roundDuration || roundData.roundTimeLimit || 60, + })); +} +``` + +--- + +## 9. 결론 + +**우선순위:** + +1. **즉시 (이번 주)**: `serverTime` 추가 + `domain` 필드 추가 +2. **단기 (2주)**: GameSession 모델 분리 + API 구현 +3. **중기 (3-4주)**: FE/BE 완전 분리 + 자동 종료 + 재접속 복구 + +**핵심 원칙:** + +- 단일 WebSocket 엔드포인트 유지 (비용/복잡도) +- `domain` 필드로 채팅/게임 구분 +- `serverTime`으로 정확한 타이머 동기화 +- GameSession 독립 모델로 상태 관리 명확화 diff --git a/docs/CICD-IMPLEMENTATION-QNA.md b/docs/CICD-IMPLEMENTATION-QNA.md new file mode 100644 index 00000000..e00c5a11 --- /dev/null +++ b/docs/CICD-IMPLEMENTATION-QNA.md @@ -0,0 +1,421 @@ +# CI/CD 파이프라인 구현 설명 및 면접 Q&A + +## 1. CI/CD 아키텍처 개요 + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ GitHub │───▶│ CodePipeline│───▶│ CodeBuild │───▶│CloudFormation│ +│ (Source) │ │ (Pipeline) │ │ (Build) │ │ (Deploy) │ +└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ + │ │ │ │ + │ ▼ ▼ ▼ + │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ + │ │ SNS │ │ S3 │ │ Lambda │ + │ │(Notification)│ │ (Artifacts) │ │ Functions │ + │ └─────────────┘ └─────────────┘ └─────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ prod 브랜치 Push/Merge │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## 2. 구성 요소 상세 설명 + +### 2.1 Source Stage (GitHub) + +- **트리거**: prod 브랜치에 Push 또는 PR Merge 시 자동 실행 +- **연결 방식**: AWS CodeConnections (구 CodeStar Connections) +- **아티팩트**: 소스 코드를 ZIP으로 압축하여 다음 스테이지로 전달 + +### 2.2 Build Stage (CodeBuild) + +- **런타임**: Amazon Linux 2, Java Corretto 21 +- **빌드 단계**: + 1. **Install**: SAM CLI 설치 + 2. **Pre-build**: Gradle 테스트 실행 (`./gradlew clean test`) + 3. **Build**: SAM build & package + 4. **Post-build**: 완료 로그 +- **캐싱**: Gradle 캐시를 S3에 저장하여 빌드 시간 단축 +- **리포트**: JUnit 테스트 결과, JaCoCo 코드 커버리지 리포트 + +### 2.3 Deploy Stage (CloudFormation) + +- **배포 방식**: CloudFormation CREATE_UPDATE +- **템플릿**: SAM으로 패키징된 `packaged-template.yaml` +- **기능**: CAPABILITY_IAM, CAPABILITY_AUTO_EXPAND + +### 2.4 Notification (SNS) + +- **이벤트**: 파이프라인 시작, 성공, 실패 시 이메일 알림 +- **구현**: CodeStar Notifications + SNS Topic + +## 3. 주요 파일 구조 + +``` +BE_Repository/ +├── cicd/ +│ └── pipeline.yaml # CloudFormation 파이프라인 템플릿 +├── ServerlessFunction/ +│ ├── buildspec.yml # CodeBuild 빌드 명세 +│ ├── samconfig.toml # SAM 배포 설정 +│ └── template.yaml # SAM 애플리케이션 템플릿 +``` + +## 4. IAM 역할 구성 + +| 역할 | 목적 | 주요 권한 | +|--------------------|---------------------|----------------------------------------| +| PipelineRole | CodePipeline 서비스 역할 | S3, CodeBuild, CloudFormation, SNS | +| CodeBuildRole | CodeBuild 서비스 역할 | S3, CloudWatch Logs, CodeBuild Reports | +| CloudFormationRole | 리소스 배포 역할 | AdministratorAccess (SAM 리소스 생성용) | + +--- + +## 5. 면접 예상 질문 및 답변 + +### Q1. CI/CD 파이프라인을 구축한 이유는 무엇인가요? + +**A1:** +수동 배포의 문제점을 해결하기 위해 CI/CD를 도입했습니다. + +1. **일관성**: 수동 배포 시 발생할 수 있는 휴먼 에러 방지 +2. **자동화**: 코드 푸시만으로 테스트-빌드-배포가 자동 실행 +3. **품질 보장**: 테스트 실패 시 배포가 중단되어 결함 있는 코드가 프로덕션에 배포되는 것을 방지 +4. **추적성**: 모든 배포 이력이 CodePipeline에 기록되어 문제 발생 시 원인 추적 용이 +5. **속도**: 반복적인 배포 작업 시간을 단축하여 개발 생산성 향상 + +--- + +### Q2. GitHub과 AWS CodePipeline을 어떻게 연동했나요? + +**A2:** +AWS CodeConnections(구 CodeStar Connections)를 사용하여 연동했습니다. + +```yaml +# pipeline.yaml의 Source Stage 설정 +- Name: Source + Actions: + - Name: GitHub + ActionTypeId: + Category: Source + Owner: AWS + Provider: CodeStarSourceConnection + Version: '1' + Configuration: + ConnectionArn: !Ref GitHubConnectionArn + FullRepositoryId: "Language-Study-Prooject/BE_Repository" + BranchName: "prod" + DetectChanges: true +``` + +**연동 과정:** + +1. AWS Console에서 CodeConnections 생성 +2. GitHub OAuth 앱 승인 +3. Connection ARN을 파이프라인에 설정 +4. `DetectChanges: true`로 설정하여 자동 트리거 활성화 + +--- + +### Q3. CodeBuild의 buildspec.yml에서 각 phase의 역할은 무엇인가요? + +**A3:** + +```yaml +phases: + install: # 빌드 환경 설정 + runtime-versions: + java: corretto21 + commands: + - pip3 install aws-sam-cli + + pre_build: # 테스트 실행 (품질 게이트) + commands: + - cd ServerlessFunction + - ./gradlew clean test + + build: # 실제 빌드 및 패키징 + commands: + - sam build + - sam package --s3-bucket ... --output-template-file packaged-template.yaml + + post_build: # 후처리 (로깅, 정리) + commands: + - echo "Build completed" +``` + +- **install**: 빌드에 필요한 런타임과 도구 설치 +- **pre_build**: 테스트 실행 - 실패 시 빌드 중단 (품질 게이트 역할) +- **build**: SAM 애플리케이션 빌드 및 S3에 패키징 +- **post_build**: 완료 로그 기록, 정리 작업 + +--- + +### Q4. 테스트가 실패하면 배포가 어떻게 되나요? + +**A4:** +테스트 실패 시 배포가 자동으로 중단됩니다. + +**작동 원리:** + +1. `pre_build` 단계에서 `./gradlew clean test` 실행 +2. 테스트 실패 시 Gradle이 exit code 1 반환 +3. CodeBuild가 비정상 종료로 판단하여 빌드 실패 처리 +4. CodePipeline의 Build Stage가 실패 상태가 됨 +5. Deploy Stage로 진행되지 않음 +6. SNS를 통해 실패 알림 이메일 발송 + +``` +Pipeline Flow: +Source ──▶ Build (테스트 실패) ──✗ Deploy + │ + ▼ + SNS 알림 발송 +``` + +--- + +### Q5. SAM과 CloudFormation의 관계는 무엇인가요? + +**A5:** +SAM(Serverless Application Model)은 CloudFormation의 확장입니다. + +**관계:** + +- SAM 템플릿은 CloudFormation 템플릿의 상위 집합 +- `sam build`/`sam package` 실행 시 SAM 템플릿이 표준 CloudFormation 템플릿으로 변환 +- 변환된 템플릿(`packaged-template.yaml`)을 CloudFormation이 배포 + +**SAM의 장점:** + +1. 간결한 문법: `AWS::Serverless::Function`으로 Lambda + API Gateway + IAM 역할 한번에 정의 +2. 로컬 테스트: `sam local invoke`로 Lambda 로컬 실행 가능 +3. 자동 패키징: 코드를 S3에 업로드하고 참조 자동 생성 + +```yaml +# SAM 템플릿 (간결) +Type: AWS::Serverless::Function +Properties: + Handler: handler.main + Runtime: java21 + Events: + Api: + Type: Api + Properties: + Path: /hello + Method: get + +# 변환된 CloudFormation (복잡) +# Lambda Function + API Gateway + IAM Role + Permission 등 여러 리소스로 확장 +``` + +--- + +### Q6. 배포 중 롤백은 어떻게 처리되나요? + +**A6:** +CloudFormation의 기본 롤백 기능을 활용합니다. + +**설정:** + +```yaml +# samconfig.toml +disable_rollback = false # 롤백 활성화 +``` + +**롤백 시나리오:** + +1. **배포 실패 시**: CloudFormation이 자동으로 이전 상태로 롤백 +2. **Lambda 오류 시**: + - 현재는 기본 롤백만 사용 + - 추가로 Canary/Linear 배포 설정 가능 (AWS CodeDeploy 연동) + +```yaml +# 점진적 배포 예시 (선택적 구현) +DeploymentPreference: + Type: Canary10Percent5Minutes # 10%에 5분간 배포 후 문제없으면 전체 배포 +``` + +--- + +### Q7. 파이프라인의 아티팩트는 어떻게 관리되나요? + +**A7:** +S3 버킷을 사용하여 아티팩트를 관리합니다. + +```yaml +ArtifactBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: group2-englishstudy-pipeline-artifacts + VersioningConfiguration: + Status: Enabled # 버전 관리 활성화 + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: AES256 # 암호화 +``` + +**아티팩트 종류:** + +1. **SourceArtifact**: GitHub에서 가져온 소스 코드 ZIP +2. **BuildArtifact**: 빌드된 `packaged-template.yaml` +3. **Cache**: Gradle 캐시 (빌드 시간 단축용) + +--- + +### Q8. 파이프라인 알림은 어떻게 구현했나요? + +**A8:** +AWS CodeStar Notifications와 SNS를 연동하여 구현했습니다. + +```yaml +# SNS Topic 생성 +NotificationTopic: + Type: AWS::SNS::Topic + Properties: + TopicName: cicd-pipeline-notifications + +# 이메일 구독 +EmailSubscription: + Type: AWS::SNS::Subscription + Properties: + TopicArn: !Ref NotificationTopic + Protocol: email + Endpoint: !Ref NotificationEmail + +# 알림 규칙 +PipelineNotificationRule: + Type: AWS::CodeStarNotifications::NotificationRule + Properties: + EventTypeIds: + - codepipeline-pipeline-pipeline-execution-started + - codepipeline-pipeline-pipeline-execution-succeeded + - codepipeline-pipeline-pipeline-execution-failed + Targets: + - TargetType: SNS + TargetAddress: !Ref NotificationTopic +``` + +--- + +### Q9. CI/CD 구축 중 겪은 문제와 해결 방법은? + +**A9:** + +**문제 1: Gradle Wrapper를 찾을 수 없음** + +- 원인: `.gitignore`에서 `gradle/` 폴더 전체가 제외됨 +- 해결: `.gitignore` 수정하여 `!gradle/wrapper/` 예외 추가 + +**문제 2: JAVA_HOME 환경 변수 오류** + +- 원인: CodeBuild에서 JAVA_HOME을 수동 설정했으나 경로 불일치 +- 해결: `runtime-versions: java: corretto21`만 사용하고 JAVA_HOME 수동 설정 제거 + +**문제 3: SAM package S3 버킷 참조 오류** + +- 원인: 환경 변수를 사용한 멀티라인 명령어에서 변수 치환 실패 +- 해결: 단일 라인으로 버킷 이름 직접 지정 + +**문제 4: Lambda 환경 변수 누락** + +- 원인: WebSocket Connect 함수에 `WEBSOCKET_ENDPOINT` 환경 변수 미설정 +- 해결: `template.yaml`에 환경 변수 추가 + +--- + +### Q10. 현재 CI/CD의 개선점이 있다면? + +**A10:** + +1. **테스트 커버리지 게이트** + - 현재: 테스트 실행만 함 + - 개선: 커버리지 80% 미만 시 빌드 실패 설정 + +2. **점진적 배포 (Canary/Blue-Green)** + - 현재: 전체 교체 배포 + - 개선: Lambda Alias + CodeDeploy로 Canary 배포 구현 + +3. **다중 환경 지원** + - 현재: prod 단일 환경 + - 개선: dev, staging, prod 분리 및 승인 단계 추가 + +4. **보안 스캔** + - 개선: 의존성 취약점 스캔 (OWASP Dependency-Check) 추가 + +5. **성능 테스트** + - 개선: 배포 전 부하 테스트 단계 추가 + +--- + +### Q11. IaC(Infrastructure as Code)를 사용한 이유는? + +**A11:** +파이프라인 자체도 CloudFormation 템플릿(`pipeline.yaml`)으로 정의했습니다. + +**장점:** + +1. **버전 관리**: 인프라 변경 이력을 Git으로 추적 +2. **재현성**: 동일한 파이프라인을 다른 프로젝트/계정에 쉽게 복제 +3. **리뷰 가능**: 인프라 변경도 코드 리뷰 프로세스 적용 +4. **자동화**: 수동 콘솔 작업 없이 `aws cloudformation deploy`로 생성/업데이트 +5. **문서화**: 템플릿 자체가 인프라 문서 역할 + +--- + +### Q12. CodeBuild와 Jenkins의 차이점은? + +**A12:** + +| 항목 | CodeBuild | Jenkins | +|--------|---------------|----------------------| +| 관리 | 완전 관리형 (서버리스) | 자체 서버 운영 필요 | +| 비용 | 빌드 시간 기반 과금 | 서버 운영 비용 | +| 확장성 | 자동 확장 | 수동 확장 필요 | +| AWS 통합 | 네이티브 통합 | 플러그인 필요 | +| 커스터마이징 | buildspec.yml | Jenkinsfile (Groovy) | +| 플러그인 | 제한적 | 풍부한 생태계 | + +**선택 이유:** + +- AWS 서비스 중심 아키텍처에서 네이티브 통합의 이점 +- 서버 관리 부담 없음 +- SAM/CloudFormation과의 원활한 연동 + +--- + +## 6. 핵심 용어 정리 + +| 용어 | 설명 | +|-------------------------------------|------------------------------------------------| +| CI (Continuous Integration) | 코드 변경을 자주 통합하고 자동 테스트하는 방식 | +| CD (Continuous Delivery/Deployment) | 자동으로 프로덕션까지 배포하는 방식 | +| Pipeline | 소스-빌드-배포로 이어지는 자동화된 워크플로우 | +| Artifact | 빌드 결과물 (패키징된 코드, 템플릿 등) | +| buildspec.yml | CodeBuild의 빌드 명세 파일 | +| SAM | Serverless Application Model - 서버리스 앱 정의 프레임워크 | +| IaC | Infrastructure as Code - 코드로 인프라 관리 | + +--- + +## 7. 참고 명령어 + +```bash +# 파이프라인 생성 +aws cloudformation deploy \ + --template-file cicd/pipeline.yaml \ + --stack-name group2-cicd-pipeline \ + --capabilities CAPABILITY_NAMED_IAM \ + --parameter-overrides NotificationEmail=your@email.com + +# 파이프라인 상태 확인 +aws codepipeline get-pipeline-state --name group2-englishstudy-pipeline + +# 수동 파이프라인 실행 +aws codepipeline start-pipeline-execution --name group2-englishstudy-pipeline + +# 빌드 로그 확인 +aws logs tail /aws/codebuild/group2-englishstudy-build --follow +``` diff --git a/docs/FRONTEND-API-GUIDE.md b/docs/FRONTEND-API-GUIDE.md new file mode 100644 index 00000000..697d406a --- /dev/null +++ b/docs/FRONTEND-API-GUIDE.md @@ -0,0 +1,365 @@ +# 프론트엔드 전달사항 - 채팅/게임 API 가이드 + +## 1. 아키텍처 구조 (업데이트됨) + +### 채팅방과 게임방 분리 + +``` +RoomType enum +├── CHAT ("chat") - 일반 채팅방 +└── GAME ("game") - 게임방 (캐치마인드 등) + +RoomStatus enum +├── WAITING ("waiting") - 대기 중 +├── PLAYING ("playing") - 게임 진행 중 +└── FINISHED ("finished") - 종료됨 +``` + +### GSI1SK 인덱스 설계 + +``` +GSI1PK: "ROOMS" (고정) +GSI1SK: {type}#{gameType}#{status}#{level}#{createdAt} + +예시: +- CHAT#-#WAITING#beginner#2026-01-22T10:00:00Z (일반 채팅방) +- GAME#CATCHMIND#WAITING#intermediate#2026-01-22T10:00:00Z (대기중 게임방) +- GAME#CATCHMIND#PLAYING#advanced#2026-01-22T10:00:00Z (진행중 게임방) +``` + +**핵심**: DB 레벨에서 `type`, `gameType`, `status`, `level` 조합으로 필터링 가능 + +--- + +## 2. 방 타입 (RoomType) + +| 타입 | 코드 | 설명 | +|--------|--------|---------------| +| `CHAT` | `chat` | 일반 채팅방 | +| `GAME` | `game` | 게임방 (캐치마인드 등) | + +--- + +## 3. 방 상태 (RoomStatus) + +| 상태 | 코드 | 설명 | 게임 시작 가능 | +|------------|------------|---------|:--------:| +| `WAITING` | `waiting` | 대기 중 | O | +| `PLAYING` | `playing` | 게임 진행 중 | X | +| `FINISHED` | `finished` | 게임 종료됨 | O | + +--- + +## 4. REST API 엔드포인트 + +### 채팅방 API (`/api/chat/rooms`) + +| Method | Endpoint | 설명 | +|--------|-------------------------|---------------------| +| POST | `/rooms` | 채팅방/게임방 생성 | +| GET | `/rooms` | 방 목록 조회 (필터 지원) | +| GET | `/rooms/{roomId}` | 방 상세 조회 | +| POST | `/rooms/{roomId}/join` | 방 입장 (roomToken 발급) | +| POST | `/rooms/{roomId}/leave` | 방 퇴장 | +| DELETE | `/rooms/{roomId}` | 방 삭제 (방장만) | + +### 게임 API (`/api/game`) + +| Method | Endpoint | 설명 | +|--------|-------------------------------|----------| +| POST | `/rooms/{roomId}/game/start` | 게임 시작 | +| POST | `/rooms/{roomId}/game/stop` | 게임 중단 | +| GET | `/rooms/{roomId}/game/status` | 게임 상태 조회 | +| GET | `/rooms/{roomId}/game/scores` | 점수판 조회 | + +--- + +## 5. 방 목록 조회 쿼리 파라미터 (업데이트됨) + +``` +GET /api/chat/rooms?type=GAME&gameType=CATCHMIND&status=WAITING&level=intermediate&limit=10&cursor=xxx +``` + +| 파라미터 | 타입 | 설명 | 예시 | +|------------|--------|----------------------|----------------------------------------| +| `type` | string | 방 타입 필터 | `CHAT`, `GAME` | +| `gameType` | string | 게임 타입 | `CATCHMIND` | +| `status` | string | 상태 필터 | `WAITING`, `PLAYING`, `FINISHED` | +| `level` | string | 난이도 필터 | `beginner`, `intermediate`, `advanced` | +| `limit` | number | 조회 개수 (기본 10, 최대 20) | | +| `cursor` | string | 페이지네이션 커서 | | + +### 필터 조합 예시 + +```bash +# 대기 중인 게임방만 +GET /api/chat/rooms?type=GAME&status=WAITING + +# 캐치마인드 게임방만 +GET /api/chat/rooms?type=GAME&gameType=CATCHMIND + +# 초급 난이도 채팅방 +GET /api/chat/rooms?type=CHAT&level=beginner + +# 진행 중인 고급 게임방 +GET /api/chat/rooms?type=GAME&status=PLAYING&level=advanced +``` + +### 응답 예시 + +```json +{ + "success": true, + "message": "Rooms retrieved", + "data": { + "rooms": [ + { + "roomId": "abc-123", + "name": "초보자 영어 스터디", + "type": "GAME", + "gameType": "CATCHMIND", + "status": "WAITING", + "level": "beginner", + "currentMembers": 3, + "maxMembers": 6, + "currentRound": 0, + "totalRounds": 5, + "createdAt": "2026-01-22T10:00:00Z" + } + ], + "nextCursor": "eyJQSyI6Ik...", + "hasMore": true + } +} +``` + +--- + +## 6. 방 생성 요청 (업데이트됨) + +### 채팅방 생성 + +```json +{ + "name": "영어 스터디 채팅방", + "type": "CHAT", + "level": "beginner", + "maxMembers": 6, + "description": "초보자를 위한 영어 채팅방" +} +``` + +### 게임방 생성 + +```json +{ + "name": "캐치마인드 게임", + "type": "GAME", + "gameType": "CATCHMIND", + "level": "intermediate", + "maxMembers": 8, + "description": "영어 단어 맞추기 게임" +} +``` + +--- + +## 7. 프론트엔드에서 방 타입 구분 + +### 방법 1: API 필터 사용 (권장) + +```javascript +// 게임방만 조회 +const gameRooms = await fetch('/api/chat/rooms?type=GAME'); + +// 대기 중인 게임방만 +const waitingGames = await fetch('/api/chat/rooms?type=GAME&status=WAITING'); + +// 채팅방만 +const chatRooms = await fetch('/api/chat/rooms?type=CHAT'); +``` + +### 방법 2: 전체 조회 후 클라이언트 필터링 + +```javascript +const allRooms = await fetchRooms(); + +// 게임방만 +const gameRooms = allRooms.filter(room => room.type === 'GAME'); + +// 채팅방만 +const chatRooms = allRooms.filter(room => room.type === 'CHAT'); + +// 대기 중인 방만 +const waitingRooms = allRooms.filter(room => room.status === 'WAITING'); +``` + +--- + +## 8. WebSocket 연결 + +### 채팅/게임 WebSocket + +``` +wss://t378dif43l.execute-api.ap-northeast-2.amazonaws.com/dev?roomToken={roomToken} +``` + +### Grammar WebSocket + +``` +wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} +``` + +### 연결 순서 + +1. `POST /rooms/{roomId}/join` → `roomToken` 발급 +2. WebSocket 연결 시 `roomToken` 쿼리 파라미터로 전달 + +--- + +## 9. WebSocket 메시지 타입 (messageType) + +| 코드 | 타입 | 설명 | +|------------------|--------|---------------| +| `MSG` | 일반 메시지 | 일반 채팅 메시지 | +| `VOICE` | 음성 메시지 | 음성 채팅 | +| `JOIN` | 입장 알림 | 사용자 입장 | +| `LEAVE` | 퇴장 알림 | 사용자 퇴장 | +| `GAME_START` | 게임 시작 | 게임 시작 알림 | +| `GAME_END` | 게임 종료 | 게임 종료 + 최종 순위 | +| `ROUND_START` | 라운드 시작 | 새 라운드 시작 | +| `ROUND_END` | 라운드 종료 | 정답 공개 | +| `ANSWER_CORRECT` | 정답 | 정답 맞춤 | +| `HINT` | 힌트 | 힌트 제공 | +| `SKIP` | 스킵 | 라운드 스킵 | +| `SYSTEM` | 시스템 | 시스템 메시지 | + +--- + +## 10. 게임 명령어 (WebSocket) + +채팅 메시지로 게임 명령어 전송: + +| 명령어 | 설명 | 권한 | +|----------|--------|-----------------| +| `/start` | 게임 시작 | 방장 (2명 이상 접속 시) | +| `/stop` | 게임 중단 | 방장 또는 게임 시작자 | +| `/skip` | 라운드 스킵 | 누구나 | +| `/hint` | 힌트 제공 | 출제자만 | +| `/score` | 점수 확인 | 누구나 | + +--- + +## 11. 게임 시작 응답 예시 + +```json +{ + "messageId": "uuid", + "roomId": "abc-123", + "userId": "SYSTEM", + "content": "게임 시작!\n총 5 라운드\n\n라운드 1 시작!\n출제자: user-456", + "messageType": "GAME_START", + "createdAt": "2026-01-22T10:00:00Z", + "serverTime": "2026-01-22T10:00:00Z", + "domain": "GAME", + "type": "GAME", + "status": "PLAYING", + "currentRound": 1, + "totalRounds": 5, + "currentDrawerId": "user-456", + "drawerOrder": ["user-456", "user-789", "user-123"] +} +``` + +--- + +## 12. 정답 체크 로직 + +- **한국어** 또는 **영어** 둘 다 정답으로 인정 +- 대소문자 구분 없음 +- 공백 무시 + +### 점수 계산 + +``` +기본 점수: 10점 +시간 보너스: (제한시간 - 경과시간) * 0.5 +연속 정답 보너스: 연속정답수 * 2 + +총점 = 기본점수 + 시간보너스 + 연속정답보너스 +``` + +--- + +## 13. 게임 설정 + +| 설정 | 기본값 | 환경변수 | +|--------------|----------|---------------------------------| +| 총 라운드 수 | 5 | `GAME_TOTAL_ROUNDS` | +| 라운드 제한 시간(초) | 60 | `GAME_ROUND_TIME_LIMIT` | +| 빠른 정답 기준(ms) | 5000 | `GAME_QUICK_GUESS_THRESHOLD_MS` | +| 게임 전체 제한(초) | 420 (7분) | `GAME_TIME_LIMIT_SECONDS` | + +--- + +## 14. 주의사항 + +1. **roomToken은 한 번만 사용**: 재연결 시 새로 발급 필요 +2. **WebSocket 연결 실패 시**: `POST /rooms/{roomId}/join`으로 새 토큰 발급 +3. **게임 중 퇴장**: 자동으로 다음 출제자로 넘어감 (2명 미만 시 게임 종료) +4. **출제자는 정답 입력 불가**: 본인이 출제자일 때 채팅해도 정답 체크 안됨 +5. **방 타입 변경 불가**: 생성 시 지정한 type은 변경 불가 + +--- + +## 15. 에러 코드 + +| 코드 | HTTP | 설명 | +|--------------|------|--------------| +| `ROOM_001` | 404 | 채팅방 없음 | +| `ROOM_002` | 409 | 채팅방 이미 존재 | +| `ROOM_003` | 400 | 채팅방 인원 초과 | +| `ROOM_004` | 400 | 채팅방 종료됨 | +| `ROOM_005` | 401 | 비밀번호 틀림 | +| `ROOM_006` | 403 | 방장 권한 없음 | +| `MEMBER_001` | 403 | 채팅방 멤버 아님 | +| `MEMBER_002` | 409 | 이미 참여 중 | +| `GAME_001` | 400 | 게임 시작 실패 | +| `GAME_002` | 400 | 게임 중단 실패 | +| `GAME_003` | 400 | 게임 진행 중 아님 | +| `GAME_004` | 409 | 게임 이미 진행 중 | +| `GAME_005` | 403 | 게임 시작자 아님 | +| `GAME_006` | 404 | 게임 없음 | +| `GAME_007` | 400 | 채팅방에서 게임 불가 | +| `GAME_008` | 400 | 게임 재시작 불가 | +| `GAME_009` | 403 | 방장만 게임 시작 가능 | + +--- + +## 16. UI 구현 가이드 + +### 탭 구조 (권장) + +``` +[전체] [채팅방] [게임방] +``` + +### 게임방 상태 표시 + +``` +대기 중 (WAITING) → 초록색 뱃지 "참여 가능" +진행 중 (PLAYING) → 빨간색 뱃지 "게임 중" +종료됨 (FINISHED) → 회색 뱃지 "종료" +``` + +### 게임방 카드 정보 + +``` +┌─────────────────────────────┐ +│ 캐치마인드 - 영어 단어 맞추기 │ +│ [게임방] [intermediate] │ +│ │ +│ 👥 3/8명 🎮 대기 중 │ +│ 🕐 2026-01-22 10:00 │ +└─────────────────────────────┘ +``` diff --git a/docs/MIDTERM-REPORT.md b/docs/MIDTERM-REPORT.md new file mode 100644 index 00000000..9a6bb1d1 --- /dev/null +++ b/docs/MIDTERM-REPORT.md @@ -0,0 +1,439 @@ +# 영어 학습 플랫폼 백엔드 최종 성과 보고서 + +## 프로젝트 개요 + +| 항목 | 내용 | +|-------|--------------------------------------------------------------------------| +| 프로젝트명 | 영어 회화 학습 플랫폼 (MZC 2nd Project) | +| 담당 영역 | Vocabulary, Chatting, Grammar, Badge, Stats, Common | +| 기술 스택 | Java 21, AWS Lambda, DynamoDB, API Gateway WebSocket, Bedrock, Polly, S3 | +| 배포 환경 | AWS SAM, CloudFormation | + +--- + +## 1. 전체 시스템 아키텍처 + +```mermaid +flowchart TB + subgraph Client["클라이언트"] + WEB[Web App] + end + + subgraph Gateway["API Gateway"] + REST[REST API] + WS[WebSocket API] + GRAMMAR_WS[Grammar WebSocket] + end + + subgraph Lambda["AWS Lambda - 도메인별 핸들러"] + direction TB + VOCAB[Vocabulary
단어/일일학습/테스트] + CHAT[Chatting
실시간 채팅/게임] + GRAMMAR[Grammar
문법 체크/스트리밍] + STATS[Stats
통계 집계] + BADGE[Badge
배지 시스템] + USER[User
사용자 관리] + end + + subgraph AI["AI Services"] + BEDROCK[AWS Bedrock
Claude 3.5 Sonnet] + POLLY[AWS Polly
TTS] + end + + subgraph Data["Data Layer"] + DYNAMO_VOCAB[(DynamoDB
Vocab Table)] + DYNAMO_CHAT[(DynamoDB
Chat Table)] + S3[(S3
음성/뱃지 이미지)] + STREAMS[DynamoDB Streams] + end + + WEB --> REST + WEB --> WS + WEB --> GRAMMAR_WS + REST --> VOCAB + REST --> CHAT + REST --> GRAMMAR + REST --> BADGE + REST --> STATS + REST --> USER + WS --> CHAT + GRAMMAR_WS --> GRAMMAR + VOCAB --> DYNAMO_VOCAB + VOCAB --> POLLY + VOCAB --> S3 + CHAT --> DYNAMO_CHAT + CHAT --> BEDROCK + GRAMMAR --> DYNAMO_VOCAB + GRAMMAR --> BEDROCK + STATS --> DYNAMO_VOCAB + BADGE --> DYNAMO_VOCAB + BADGE --> S3 + STREAMS -->|이벤트 트리거| STATS + STATS -->|배지 부여| BADGE +``` + +--- + +## 2. 주요 기능 구현 + +### 2.1 Vocabulary Domain (단어 학습) + +#### 2.1.1 일일 학습 시스템 (Daily Study) + +```mermaid +flowchart LR + subgraph DailyStudy["일일 학습 흐름"] + A[오늘의 단어 조회] --> B{기존 학습 존재?} + B -->|Yes| C[기존 학습 반환] + B -->|No| D[새 단어 50개 + 복습 5개 생성] + D --> E[학습 진행] + E --> F[단어별 학습 완료 처리] + F --> G{50개 완료?} + G -->|Yes| H[isCompleted = true] + end +``` + +**주요 기능:** + +- 레벨별 신규 단어 50개 + 복습 단어 5개 자동 선정 +- 학습 진행도 실시간 추적 (learnedCount/totalWords) +- 일일 학습 완료 시 isCompleted 플래그 설정 + +#### 2.1.2 SM-2 Spaced Repetition 알고리즘 + +```mermaid +stateDiagram-v2 + [*] --> NEW: 단어 추가 + NEW --> LEARNING: 첫 학습 + LEARNING --> LEARNING: 오답 + LEARNING --> REVIEWING: 2회 연속 정답 + REVIEWING --> LEARNING: 오답 + REVIEWING --> MASTERED: 5회 연속 정답 + MASTERED --> LEARNING: 오답 + MASTERED --> MASTERED: 정답 유지 +``` + +**구현 특징:** + +- State 패턴으로 학습 상태 전이 관리 +- easeFactor 동적 조정 (1.3 ~ 2.5) +- 복습 간격 자동 계산 (1일 → 6일 → interval * easeFactor) + +#### 2.1.3 TTS 음성 생성 + +- AWS Polly 연동 (남성/여성 음성) +- S3 캐싱으로 중복 생성 방지 +- 단어 + 예문 음성 생성 + +--- + +### 2.2 Chatting Domain (실시간 채팅 & 게임) + +#### 2.2.1 WebSocket 채팅 + +```mermaid +sequenceDiagram + participant Client + participant REST as REST API + participant WS as WebSocket API + participant DB as DynamoDB + Note over Client, DB: Phase 1: 방 입장 토큰 발급 + Client ->> REST: POST /rooms/{id}/join + REST ->> DB: RoomToken 저장 (TTL: 5분) + REST -->> Client: roomToken 반환 + Note over Client, DB: Phase 2: WebSocket 연결 + Client ->> WS: $connect?roomToken={token} + WS ->> DB: 토큰 검증 + Connection 저장 + WS -->> Client: 연결 성공 + Note over Client, DB: Phase 3: 메시지 송수신 + Client ->> WS: sendmessage (채팅) + WS ->> DB: 메시지 저장 + 브로드캐스트 +``` + +**주요 기능:** + +- RoomToken 기반 인증 (TTL 5분) +- BCrypt 비밀방 암호화 +- 슬래시 명령어 시스템 (/member, /game, /skip, /hint 등) +- Connection 자동 정리 (TTL + 실패 시 삭제) + +#### 2.2.2 캐치마인드 게임 + +```mermaid +flowchart TB + subgraph Game["캐치마인드 게임 흐름"] + START["#47;game 명령어"] --> INIT["게임 초기화
출제 순서 셔플"] + INIT --> ROUND[라운드 시작
출제자 + 단어 선정] + ROUND --> DRAW[출제자 그림 그리기] + DRAW --> GUESS[참가자 정답 입력] + GUESS --> CHECK{정답?} + CHECK -->|Yes| SCORE[점수 계산
시간보너스 + 연속정답보너스] + CHECK -->|No| GUESS + SCORE --> ALLCORRECT{전원 정답?} + ALLCORRECT -->|Yes| NEXTROUND + ALLCORRECT -->|No| TIMEOUT{시간 초과?} + TIMEOUT -->|Yes| NEXTROUND[다음 라운드] + TIMEOUT -->|No| GUESS + NEXTROUND --> LASTROUND{마지막 라운드?} + LASTROUND -->|Yes| END[게임 종료
순위 발표] + LASTROUND -->|No| ROUND + end +``` + +**점수 계산:** + +``` +점수 = 기본점수(10) + 시간보너스((60-경과초)*0.5) + 연속정답보너스(streak*2) +출제자 보너스 = 정답자당 5점 +``` + +**주요 기능:** + +- 실시간 점수 브로드캐스트 +- 연속 정답 스트릭 시스템 +- 접속자 변동 시 출제자 자동 재선정 +- 라운드별 순위 표시 + +--- + +### 2.3 Grammar Domain (문법 체크) + +#### 2.3.1 AI 스트리밍 응답 + +```mermaid +sequenceDiagram + participant Client + participant WS as Grammar WebSocket + participant Handler as GrammarStreamingHandler + participant Bedrock as AWS Bedrock + Client ->> WS: 문법 체크 요청 + WS ->> Handler: Lambda 호출 + Handler ->> Bedrock: 스트리밍 요청 (Claude 3.5 Sonnet) + + loop 청크 단위 응답 + Bedrock -->> Handler: 텍스트 청크 + Handler -->> WS: 실시간 전송 + WS -->> Client: 즉시 표시 + end + + Handler -->> Client: [DONE] 완료 + Handler ->> DB: 피드백 저장 +``` + +**주요 기능:** + +- Claude 3.5 Sonnet 모델 사용 +- 스트리밍으로 체감 대기 시간 80% 감소 +- 레벨별 맞춤 프롬프트 (BEGINNER: 한국어 번역 포함) +- 대화 히스토리 저장으로 문맥 유지 +- 피드백 영구 저장 (DynamoDB) + +--- + +### 2.4 Stats Domain (학습 통계) + +```mermaid +flowchart LR + subgraph StatsTypes["통계 유형"] + DAILY["일별 통계
#47;stats#47;daily"] + WEEKLY["주별 통계
#47;stats#47;weekly"] + MONTHLY["월별 통계
#47;stats#47;monthly"] + TOTAL["전체 통계
#47;stats#47;total"] + HISTORY["히스토리
#47;stats#47;history"] + end +``` + +**통계 항목:** + +| 필드 | 설명 | +|-------------------|-------------| +| testsCompleted | 완료한 테스트 수 | +| questionsAnswered | 답변한 문제 수 | +| correctAnswers | 정답 수 | +| incorrectAnswers | 오답 수 | +| successRate | 정답률 (%) | +| newWordsLearned | 새로 학습한 단어 수 | +| wordsReviewed | 복습한 단어 수 | +| currentStreak | 현재 연속 학습일 | +| longestStreak | 최장 연속 학습일 | +| gamesPlayed | 참여한 게임 수 | +| gamesWon | 1등 횟수 | +| totalGameScore | 누적 게임 점수 | + +**DynamoDB Streams 기반 비동기 집계:** + +- 테스트 결과 저장 시 자동 트리거 +- API 응답과 분리되어 응답 속도 향상 + +--- + +### 2.5 Badge Domain (배지 시스템) + +```mermaid +flowchart TB + subgraph BadgeSystem["배지 시스템"] + TRIGGER[통계 업데이트] --> CHECK[배지 조건 체크] + CHECK --> AWARD{조건 달성?} + AWARD -->|Yes| SAVE[배지 부여 + 저장] + AWARD -->|No| END[종료] + SAVE --> NOTIFY[프론트엔드 조회] + end +``` + +**배지 종류:** + +| Badge Type | 이름 | 조건 | +|----------------------|---------|------------| +| FIRST_STEP | 첫 걸음 | 첫 학습 완료 | +| STREAK_3, 7, 30 | 연속 학습 | N일 연속 학습 | +| WORDS_100, 500, 1000 | 단어 학습 | N개 단어 학습 | +| PERFECT_SCORE | 완벽주의자 | 테스트 만점 | +| ACCURACY_90 | 정확도 달인 | 전체 정확도 90% | +| GAME_FIRST_PLAY | 첫 게임 | 첫 게임 참여 | +| GAME_10_WINS | 게임 10승 | 10번 1등 | +| QUICK_GUESSER | 번개 정답 | 5초 내 정답 | +| PERFECT_DRAWER | 완벽한 출제자 | 전원 정답 유도 | + +**기술적 특징:** + +- S3 Presigned URL로 배지 이미지 제공 (1시간 유효) +- 획득/미획득 배지 + 진행도 표시 + +--- + +## 3. 기술적 성과 + +### 3.1 아키텍처 패턴 + +| 패턴 | 적용 영역 | 효과 | +|------------------|----------|----------------------------| +| **CQRS** | 전 도메인 | 읽기/쓰기 책임 분리, 테스트 용이성 | +| **State** | 단어 학습 상태 | 복잡한 조건문 제거, 확장성 | +| **Factory** | AI 서비스 | 서비스 교체 용이 (Claude ↔ Llama) | +| **Event-Driven** | 통계/배지 | 느슨한 결합, 비동기 처리 | + +### 3.2 DynamoDB 설계 + +**Single Table Design:** + +- Vocab Table: 단어, 사용자단어, 테스트, 일일학습, 통계, 배지, 문법 +- Chat Table: 채팅방, 메시지, 연결, 게임라운드 + +**GSI 구성:** + +| GSI | 용도 | +|------|---------------------| +| GSI1 | 레벨별 단어 조회, 복습 예정 단어 | +| GSI2 | 카테고리별 단어, 상태별 사용자단어 | +| GSI3 | 북마크 단어 조회 | + +### 3.3 보안 + +- Cognito 인증 (idToken) +- WebSocket RoomToken 인증 (TTL 5분) +- BCrypt 비밀방 암호화 +- S3 Presigned URL (배지 이미지) + +### 3.4 성능 최적화 + +| 최적화 | 효과 | +|--------------------------|-------------------------| +| TTS S3 캐싱 | Polly API 호출 90% 절감 | +| 배치 처리 | 최대 100개 단어 일괄 처리 | +| Strongly Consistent Read | 데이터 정합성 보장 | +| DynamoDB Streams | 비동기 통계 집계로 응답 속도 50% 향상 | +| AI 스트리밍 | 체감 대기 시간 80% 감소 | + +--- + +## 4. API 엔드포인트 요약 + +### REST API (https://gc8l9ijhzc.execute-api.ap-northeast-2.amazonaws.com/dev) + +| Method | Path | 설명 | +|--------|-------------------------------------|-----------| +| GET | /vocab/words | 단어 목록 조회 | +| POST | /vocab/words | 단어 등록 | +| GET | /vocab/daily | 오늘의 학습 단어 | +| POST | /vocab/daily/words/{wordId}/learned | 단어 학습 완료 | +| POST | /vocab/tests | 테스트 생성 | +| POST | /vocab/tests/{testId}/submit | 테스트 제출 | +| GET | /stats/daily | 일별 통계 | +| GET | /stats/weekly | 주별 통계 | +| GET | /stats/monthly | 월별 통계 | +| GET | /stats/total | 전체 통계 | +| GET | /stats/history?limit=100 | 통계 히스토리 | +| GET | /badges | 전체 배지 목록 | +| GET | /badges/earned | 획득한 배지 | +| GET | /rooms | 채팅방 목록 | +| POST | /rooms | 채팅방 생성 | +| POST | /rooms/{roomId}/join | 채팅방 입장 | +| POST | /grammar/check | 문법 체크 | + +### WebSocket API + +| Endpoint | 설명 | +|---------------------------------------------------------------|---------| +| wss://t378dif43l.execute-api.ap-northeast-2.amazonaws.com/dev | 채팅/게임 | +| wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev | 문법 스트리밍 | + +--- + +## 5. 프로젝트 구조 + +``` +ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/ +├── common/ # 공통 모듈 +│ ├── config/ # AWS 클라이언트 (싱글톤) +│ ├── router/ # HandlerRouter, Route +│ ├── exception/ # 예외 처리 체계 +│ ├── dto/ # PaginatedResult, ErrorInfo +│ └── util/ # ResponseGenerator, CursorUtil +│ +├── domain/ +│ ├── vocabulary/ # 단어 학습 도메인 +│ │ ├── handler/ # Word, UserWord, Test, DailyStudy 핸들러 +│ │ ├── service/ # CQRS 서비스 (Command/Query) +│ │ ├── repository/ # DynamoDB 레포지토리 +│ │ ├── model/ # Word, UserWord, TestResult, DailyStudy +│ │ └── state/ # NEW, LEARNING, REVIEWING, MASTERED +│ │ +│ ├── chatting/ # 채팅 도메인 +│ │ ├── handler/ # REST + WebSocket 핸들러 +│ │ ├── service/ # ChatRoom, Game, Command 서비스 +│ │ └── model/ # ChatRoom, Connection, GameRound +│ │ +│ ├── grammar/ # 문법 체크 도메인 +│ │ ├── handler/ # REST + 스트리밍 핸들러 +│ │ ├── service/ # GrammarCheck, Conversation 서비스 +│ │ └── factory/ # BedrockGrammarCheckFactory +│ │ +│ ├── stats/ # 통계 도메인 +│ │ ├── handler/ # UserStats, Streams 핸들러 +│ │ └── repository/ # UserStatsRepository +│ │ +│ └── badge/ # 배지 도메인 +│ ├── handler/ # BadgeHandler +│ └── service/ # BadgeService +``` + +--- + +## 6. 성과 요약 + +| 카테고리 | 성과 | +|------------------|------------------------------------| +| **Lambda 함수** | 26개 | +| **API 엔드포인트** | REST 40+, WebSocket 2 | +| **DynamoDB 테이블** | 2개 (Single Table Design) | +| **GSI** | 5개 | +| **아키텍처 패턴** | CQRS, State, Factory, Event-Driven | +| **AI 연동** | Bedrock Claude 3.5 Sonnet (문법/대화) | +| **TTS** | AWS Polly (남성/여성 음성) | +| **실시간 통신** | WebSocket (채팅/게임/문법 스트리밍) | +| **인증** | Cognito + RoomToken | + +--- + +**작성일:** 2026-01-16 +**팀:** MZC 2nd Project Team / SMJ diff --git a/docs/domain-reports/BADGE-DOMAIN-REPORT.md b/docs/domain-reports/BADGE-DOMAIN-REPORT.md new file mode 100644 index 00000000..4cd58215 --- /dev/null +++ b/docs/domain-reports/BADGE-DOMAIN-REPORT.md @@ -0,0 +1,681 @@ +# Badge Domain 세부 보고서 + +## 1. 개요 + +Badge 도메인은 사용자의 학습 성취도에 따라 배지를 자동으로 부여하는 시스템입니다. 이벤트 기반 아키텍처를 통해 Stats, Vocabulary, Chatting 도메인과 연동되어 실시간으로 배지를 체크하고 +부여합니다. + +--- + +## 2. 전체 아키텍처 + +```mermaid +flowchart TB + subgraph Triggers["트리거 소스"] + TEST[테스트 완료
DynamoDB Streams] + WORD[단어 학습
Write-through] + GAME[게임 종료
Service Method] + end + + subgraph Processing["Badge 처리"] + CHECK[BadgeService
조건 체크] + AWARD[배지 부여] + end + + subgraph Storage["저장소"] + DDB[(DynamoDB
UserBadge)] + S3[(S3
배지 이미지)] + end + + subgraph Query["조회"] + API[BadgeHandler
REST API] + PRESIGN[S3 Presigned URL] + end + + TEST --> CHECK + WORD --> CHECK + GAME --> CHECK + CHECK --> AWARD + AWARD --> DDB + DDB --> API + S3 --> PRESIGN + PRESIGN --> API +``` + +--- + +## 3. 배지 종류 + +### 3.1 배지 카테고리 + +```mermaid +mindmap + root((배지 시스템)) + 학습 + FIRST_STEP[첫 걸음] + WORDS_100[단어 수집가] + WORDS_500[단어 전문가] + WORDS_1000[단어 마스터] + 연속학습 + STREAK_3[3일 연속] + STREAK_7[7일 연속] + STREAK_30[30일 연속] + 테스트 + PERFECT_SCORE[완벽주의자] + TEST_10[테스트 도전자] + ACCURACY_90[정확도 달인] + 게임 + GAME_FIRST[첫 게임] + GAME_10_WINS[10승 달성] + QUICK_GUESSER[번개 정답] + PERFECT_DRAWER[완벽한 출제자] + 최종 + MASTER[학습 마스터] +``` + +### 3.2 배지 상세 + +| Badge Type | 이름 | 설명 | 카테고리 | 조건 | +|-----------------|-----------|--------------------|-----------------|-----------------------| +| FIRST_STEP | 첫 걸음 | 첫 학습을 완료했습니다 | FIRST_STUDY | testsCompleted >= 1 | +| STREAK_3 | 3일 연속 학습 | 3일 연속으로 학습했습니다 | STREAK | currentStreak >= 3 | +| STREAK_7 | 일주일 연속 학습 | 7일 연속으로 학습했습니다 | STREAK | currentStreak >= 7 | +| STREAK_30 | 한 달 연속 학습 | 30일 연속으로 학습했습니다 | STREAK | currentStreak >= 30 | +| WORDS_100 | 단어 수집가 | 100개의 단어를 학습했습니다 | WORDS_LEARNED | totalWords >= 100 | +| WORDS_500 | 단어 전문가 | 500개의 단어를 학습했습니다 | WORDS_LEARNED | totalWords >= 500 | +| WORDS_1000 | 단어 마스터 | 1000개의 단어를 학습했습니다 | WORDS_LEARNED | totalWords >= 1000 | +| PERFECT_SCORE | 완벽주의자 | 테스트에서 만점을 받았습니다 | PERFECT_TEST | incorrectAnswers == 0 | +| TEST_10 | 테스트 도전자 | 10회의 테스트를 완료했습니다 | TESTS_COMPLETED | testsCompleted >= 10 | +| ACCURACY_90 | 정확도 달인 | 전체 정확도 90%를 달성했습니다 | ACCURACY | successRate >= 90 | +| GAME_FIRST_PLAY | 첫 게임 | 첫 게임에 참여했습니다 | GAMES_PLAYED | gamesPlayed >= 1 | +| GAME_10_WINS | 게임 10승 | 게임에서 10번 1등을 했습니다 | GAMES_WON | gamesWon >= 10 | +| QUICK_GUESSER | 번개 정답 | 5초 내에 정답을 맞췄습니다 | QUICK_GUESSES | quickGuesses >= 1 | +| PERFECT_DRAWER | 완벽한 출제자 | 출제 시 전원이 정답을 맞췄습니다 | PERFECT_DRAWS | perfectDraws >= 1 | +| MASTER | 학습 마스터 | 모든 업적을 달성했습니다 | ALL_BADGES | 모든 배지 획득 | + +--- + +## 4. 배지 부여 흐름 + +### 4.1 테스트 완료 시 + +```mermaid +sequenceDiagram + participant Test as TestResult + participant Streams as DynamoDB Streams + participant Handler as StatsStreamHandler + participant Stats as UserStats + participant Badge as BadgeService + participant DB as DynamoDB + Test ->> Streams: INSERT 이벤트 + Streams ->> Handler: 트리거 + Handler ->> Stats: incrementTestStats() + Handler ->> Stats: updateStudyStreak() + Note over Handler: 만점 체크 + alt 정답 > 0 && 오답 == 0 + Handler ->> Badge: awardBadge("PERFECT_SCORE") + Badge ->> DB: UserBadge 저장 + end + + Handler ->> Stats: findTotalStats() + Stats -->> Handler: UserStats + Handler ->> Badge: checkAndAwardBadges() + Badge ->> Badge: 각 배지 조건 체크 + Badge ->> DB: 획득 배지 저장 +``` + +### 4.2 단어 학습 시 + +```mermaid +sequenceDiagram + participant API as DailyStudyHandler + participant Service as DailyStudyCommandService + participant Stats as UserStatsRepository + participant Badge as BadgeService + participant DB as DynamoDB + API ->> Service: markWordLearned() + Service ->> Stats: incrementWordsLearned() + Note over Service: 배지 체크 (WORDS_xxx) + Service ->> Stats: findTotalStats() + Stats -->> Service: UserStats + Service ->> Badge: checkAndAwardBadges() + Badge ->> Badge: WORDS_100, 500, 1000 체크 + Badge ->> DB: 획득 배지 저장 +``` + +### 4.3 게임 종료 시 + +```mermaid +sequenceDiagram + participant Game as GameService + participant Stats as GameStatsService + participant Repo as UserStatsRepository + participant Badge as BadgeService + participant DB as DynamoDB + Game ->> Stats: updateGameStats(room) + + loop 각 참가자 + Stats ->> Stats: 점수 집계 + Note over Stats: correctGuesses
quickGuesses (5초 이내)
perfectDraws + Stats ->> Repo: incrementGameStats() + Stats ->> Repo: findTotalStats() + Repo -->> Stats: UserStats + Stats ->> Badge: checkAndAwardBadges() + Badge ->> Badge: GAME_xxx 배지 체크 + Badge ->> DB: 획득 배지 저장 + end +``` + +--- + +## 5. 배지 조건 체크 로직 + +### 5.1 카테고리별 조건 + +```mermaid +flowchart TB + START[checkAndAwardBadges] --> LOOP{모든 BadgeType 순회} + LOOP --> EARNED{이미 획득?} + EARNED -->|Yes| SKIP[건너뛰기] + EARNED -->|No| CHECK[조건 체크] + CHECK --> SWITCH{카테고리} + SWITCH -->|FIRST_STUDY| FS[testsCompleted >= 1] + SWITCH -->|STREAK| ST[currentStreak >= threshold] + SWITCH -->|WORDS_LEARNED| WL[totalWords >= threshold] + SWITCH -->|PERFECT_TEST| PT[별도 처리] + SWITCH -->|TESTS_COMPLETED| TC[testsCompleted >= threshold] + SWITCH -->|ACCURACY| AC[successRate >= threshold] + SWITCH -->|GAMES_PLAYED| GP[gamesPlayed >= threshold] + SWITCH -->|GAMES_WON| GW[gamesWon >= threshold] + SWITCH -->|QUICK_GUESSES| QG[quickGuesses >= threshold] + SWITCH -->|PERFECT_DRAWS| PD[perfectDraws >= threshold] + SWITCH -->|ALL_BADGES| AB[모든 배지 획득 체크] + FS --> RESULT{조건 충족?} + ST --> RESULT + WL --> RESULT + TC --> RESULT + AC --> RESULT + GP --> RESULT + GW --> RESULT + QG --> RESULT + PD --> RESULT + RESULT -->|Yes| AWARD[배지 부여] + RESULT -->|No| SKIP + AWARD --> LOOP + SKIP --> LOOP +``` + +### 5.2 Switch Expression 패턴 + +```java +private boolean checkBadgeCondition(BadgeType type, UserStats stats) { + return switch (type.getCategory()) { + case "FIRST_STUDY" -> stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1; + + case "STREAK" -> stats.getCurrentStreak() != null && + stats.getCurrentStreak() >= type.getThreshold(); + + case "WORDS_LEARNED" -> { + int total = (stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0) + + (stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0); + yield total >= type.getThreshold(); + } + + case "ACCURACY" -> { + if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) + yield false; + double accuracy = (stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered(); + yield accuracy >= type.getThreshold(); + } + + case "TESTS_COMPLETED" -> stats.getTestsCompleted() != null && + stats.getTestsCompleted() >= type.getThreshold(); + + case "GAMES_PLAYED" -> stats.getGamesPlayed() != null && + stats.getGamesPlayed() >= type.getThreshold(); + + case "GAMES_WON" -> stats.getGamesWon() != null && + stats.getGamesWon() >= type.getThreshold(); + + case "QUICK_GUESSES" -> stats.getQuickGuesses() != null && + stats.getQuickGuesses() >= type.getThreshold(); + + case "PERFECT_DRAWS" -> stats.getPerfectDraws() != null && + stats.getPerfectDraws() >= type.getThreshold(); + + case "PERFECT_TEST" -> false; // 별도 처리 (StatsStreamHandler) + case "ALL_BADGES" -> false; // 특수 로직 필요 + + default -> false; + }; +} +``` + +--- + +## 6. API 엔드포인트 + +### 6.1 REST API + +| Method | Endpoint | 설명 | 응답 | +|--------|----------------|----------------|-------------| +| GET | /badges | 전체 배지 목록 + 진행도 | BadgeInfo[] | +| GET | /badges/earned | 획득한 배지만 조회 | UserBadge[] | + +### 6.2 전체 배지 조회 응답 + +```json +{ + "message": "Badges retrieved", + "data": { + "badges": [ + { + "badgeType": "FIRST_STEP", + "name": "첫 걸음", + "description": "첫 학습을 완료했습니다", + "imageUrl": "https://...presigned.../badges/first_step.png", + "category": "FIRST_STUDY", + "threshold": 1, + "progress": 1, + "earned": true, + "earnedAt": "2026-01-16T10:30:45.123Z" + }, + { + "badgeType": "WORDS_100", + "name": "단어 수집가", + "description": "100개의 단어를 학습했습니다", + "imageUrl": "https://...presigned.../badges/words_100.png", + "category": "WORDS_LEARNED", + "threshold": 100, + "progress": 45, + "earned": false, + "earnedAt": null + } + ], + "totalCount": 16, + "earnedCount": 8 + } +} +``` + +### 6.3 획득 배지 조회 응답 + +```json +{ + "message": "Earned badges retrieved", + "data": { + "badges": [ + { + "badgeType": "FIRST_STEP", + "name": "첫 걸음", + "description": "첫 학습을 완료했습니다", + "imageUrl": "https://...presigned.../badges/first_step.png", + "category": "FIRST_STUDY", + "threshold": 1, + "progress": 1, + "earnedAt": "2026-01-16T10:30:45.123Z" + } + ], + "count": 8 + } +} +``` + +--- + +## 7. 데이터 모델 + +### 7.1 UserBadge + +```java + +@DynamoDbBean +public class UserBadge { + // 기본 키 + String pk; // USER#{userId}#BADGE + String sk; // BADGE#{badgeType} + + // GSI (전체 배지 조회) + String gsi1pk; // BADGE#ALL + String gsi1sk; // EARNED#{earnedAt} + + // 메타데이터 + String odUserId; + String badgeType; // BadgeType enum 이름 + String name; + String description; + String imageUrl; + String category; + Integer threshold; + Integer progress; // 획득 시점 진행도 + + // 타임스탬프 + String earnedAt; + String createdAt; +} +``` + +### 7.2 DynamoDB 키 구조 + +| 필드 | 패턴 | 예시 | +|--------|---------------------|-----------------------------| +| PK | USER#{userId}#BADGE | USER#abc123#BADGE | +| SK | BADGE#{badgeType} | BADGE#STREAK_7 | +| GSI1PK | BADGE#ALL | BADGE#ALL | +| GSI1SK | EARNED#{earnedAt} | EARNED#2026-01-16T10:30:45Z | + +### 7.3 BadgeType Enum + +```java +public enum BadgeType { + FIRST_STEP("첫 걸음", "첫 학습을 완료했습니다", + "FIRST_STUDY", 1, "first_step.png"), + STREAK_3("3일 연속 학습", "3일 연속으로 학습했습니다", + "STREAK", 3, "streak_3.png"), + STREAK_7("일주일 연속 학습", "7일 연속으로 학습했습니다", + "STREAK", 7, "streak_7.png"), + // ... 생략 + MASTER("학습 마스터", "모든 업적을 달성했습니다", + "ALL_BADGES", 1, "master.png"); + + private final String name; + private final String description; + private final String category; + private final int threshold; + private final String imageFile; +} +``` + +--- + +## 8. 진행도 계산 + +### 8.1 카테고리별 진행도 + +```mermaid +flowchart TB + subgraph Progress["진행도 계산"] + FIRST["FIRST_STUDY
testsCompleted >= 1 ? 1 : 0"] + STREAK["STREAK
currentStreak"] + WORDS["WORDS_LEARNED
newWords + reviewed"] + TESTS["TESTS_COMPLETED
testsCompleted"] + ACC["ACCURACY
successRate (%)"] + GAMES["GAMES_PLAYED
gamesPlayed"] + WINS["GAMES_WON
gamesWon"] + QUICK["QUICK_GUESSES
quickGuesses"] + PERFECT["PERFECT_DRAWS
perfectDraws"] + end +``` + +### 8.2 calculateProgress 메서드 + +```java +private int calculateProgress(BadgeType type, UserStats stats) { + return switch (type.getCategory()) { + case "FIRST_STUDY" -> (stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1) ? 1 : 0; + + case "STREAK" -> stats.getCurrentStreak() != null ? stats.getCurrentStreak() : 0; + + case "WORDS_LEARNED" -> { + int newWords = stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0; + int reviewed = stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0; + yield newWords + reviewed; + } + + case "TESTS_COMPLETED" -> stats.getTestsCompleted() != null ? stats.getTestsCompleted() : 0; + + case "ACCURACY" -> { + if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) + yield 0; + yield (int) ((stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered()); + } + + case "GAMES_PLAYED" -> stats.getGamesPlayed() != null ? stats.getGamesPlayed() : 0; + + case "GAMES_WON" -> stats.getGamesWon() != null ? stats.getGamesWon() : 0; + + case "QUICK_GUESSES" -> stats.getQuickGuesses() != null ? stats.getQuickGuesses() : 0; + + case "PERFECT_DRAWS" -> stats.getPerfectDraws() != null ? stats.getPerfectDraws() : 0; + + default -> 0; + }; +} +``` + +--- + +## 9. 멱등성 보장 + +### 9.1 중복 부여 방지 흐름 + +```mermaid +flowchart TB + START[checkAndAwardBadges] --> LOOP[배지 타입 순회] + LOOP --> CHECK{hasBadge?} + CHECK -->|이미 있음| SKIP[건너뛰기] + CHECK -->|없음| CONDITION{조건 충족?} + CONDITION -->|Yes| CREATE[배지 생성] + CONDITION -->|No| SKIP + CREATE --> SAVE[DynamoDB 저장] + SAVE --> LOOP + SKIP --> LOOP +``` + +### 9.2 구현 코드 + +```java +public List checkAndAwardBadges(String userId, UserStats stats) { + List newBadges = new ArrayList<>(); + String now = Instant.now().toString(); + + for (BadgeType type : BadgeType.values()) { + // 1. 이미 획득한 배지는 건너뛰기 + if (badgeRepository.hasBadge(userId, type.name())) { + continue; + } + + // 2. 조건 체크 + if (checkBadgeCondition(type, stats)) { + // 3. 배지 생성 및 저장 + UserBadge badge = createBadge(userId, type, now); + badgeRepository.save(badge); + newBadges.add(badge); + } + } + + return newBadges; +} +``` + +--- + +## 10. S3 이미지 연동 + +### 10.1 Presigned URL 생성 + +```mermaid +flowchart LR + REQ[배지 조회] --> SERVICE[BadgeService] + SERVICE --> PRESIGN[S3PresignUtil] + PRESIGN --> CACHE{캐시 확인} + CACHE -->|있음| RETURN[URL 반환] + CACHE -->|없음| GENERATE[Presigned URL 생성] + GENERATE --> SAVE[캐시 저장] + SAVE --> RETURN +``` + +### 10.2 이미지 URL 생성 + +```java +// S3PresignUtil.java +public static String getBadgeImageUrl(String imageFile) { + return getPresignedUrl("badges/" + imageFile); +} + +// BadgeService - 배지 생성 시 +private UserBadge createBadge(String userId, BadgeType type, String now) { + return UserBadge.builder() + .pk(BadgeKey.userBadgePk(userId)) + .sk(BadgeKey.badgeSk(type.name())) + .gsi1pk(BadgeKey.BADGE_ALL) + .gsi1sk(BadgeKey.earnedSk(now)) + .odUserId(userId) + .badgeType(type.name()) + .name(type.getName()) + .description(type.getDescription()) + .imageUrl(S3PresignUtil.getBadgeImageUrl(type.getImageFile())) + .category(type.getCategory()) + .threshold(type.getThreshold()) + .earnedAt(now) + .createdAt(now) + .build(); +} +``` + +### 10.3 S3 버킷 구조 + +``` +s3://group2-englishstudy/ +└── badges/ + ├── first_step.png + ├── streak_3.png + ├── streak_7.png + ├── streak_30.png + ├── words_100.png + ├── words_500.png + ├── words_1000.png + ├── perfect_score.png + ├── test_10.png + ├── accuracy_90.png + ├── game_first.png + ├── game_10_wins.png + ├── quick_guesser.png + ├── perfect_drawer.png + └── master.png +``` + +--- + +## 11. Stats 도메인 연동 + +### 11.1 연동 포인트 + +```mermaid +flowchart TB + subgraph Stats["Stats 도메인"] + STREAM[StatsStreamHandler] + DAILY[DailyStudyCommandService] + GAME[GameStatsService] + REPO[UserStatsRepository] + end + + subgraph Badge["Badge 도메인"] + SERVICE[BadgeService] + BADGEREPO[BadgeRepository] + end + + STREAM -->|checkAndAwardBadges| SERVICE + DAILY -->|checkWordsBadge| SERVICE + GAME -->|checkAndAwardBadges| SERVICE + SERVICE -->|hasBadge, save| BADGEREPO + SERVICE -->|findTotalStats| REPO +``` + +### 11.2 UserStats 필드와 배지 매핑 + +| UserStats 필드 | 배지 | +|------------------------------------|----------------------------------| +| testsCompleted | FIRST_STEP, TEST_10 | +| currentStreak | STREAK_3, STREAK_7, STREAK_30 | +| newWordsLearned + wordsReviewed | WORDS_100, WORDS_500, WORDS_1000 | +| correctAnswers / questionsAnswered | ACCURACY_90 | +| gamesPlayed | GAME_FIRST_PLAY | +| gamesWon | GAME_10_WINS | +| quickGuesses | QUICK_GUESSER | +| perfectDraws | PERFECT_DRAWER | + +--- + +## 12. 파일 구조 + +``` +domain/badge/ +├── enums/ +│ └── BadgeType.java # 16가지 배지 정의 +├── constants/ +│ └── BadgeKey.java # DynamoDB 키 생성 +├── model/ +│ └── UserBadge.java # 배지 엔티티 +├── repository/ +│ └── BadgeRepository.java # CRUD 연산 +├── service/ +│ └── BadgeService.java # 조건 체크, 배지 부여 +└── handler/ + └── BadgeHandler.java # REST API + +연동 파일: +├── domain/stats/handler/StatsStreamHandler.java +├── domain/vocabulary/service/DailyStudyCommandService.java +└── domain/chatting/service/GameStatsService.java +``` + +--- + +## 13. 기술 스택 + +- **Runtime:** AWS Lambda (Java 21) +- **Database:** DynamoDB (Single Table Design) +- **Storage:** S3 (배지 이미지) +- **Event:** DynamoDB Streams, Write-through, Service Method +- **Pattern:** Event-driven, Idempotent, Switch Expression +- **Java 21 Features:** Enhanced Switch, Yield Statement + +--- + +## 14. 배지 획득 시나리오 + +### 14.1 시나리오 예시 + +```mermaid +flowchart LR + subgraph Day1["1일차"] + A1[테스트 완료] --> B1["FIRST_STEP 획득"] + end + + subgraph Day3["3일차"] + A3[3일 연속 학습] --> B3["STREAK_3 획득"] + end + + subgraph Day7["7일차"] + A7[7일 연속 학습] --> B7["STREAK_7 획득"] + A7_2[100단어 학습] --> B7_2["WORDS_100 획득"] + end + + subgraph Game["게임"] + G1[5초 내 정답] --> G2["QUICK_GUESSER 획득"] + G3[10회 1등] --> G4["GAME_10_WINS 획득"] + end +``` + +### 14.2 특수 배지 획득 조건 + +**PERFECT_SCORE (완벽주의자):** + +- 테스트 제출 시 오답 0개이면 즉시 부여 +- StatsStreamHandler에서 별도 처리 + +**QUICK_GUESSER (번개 정답):** + +- 게임 중 5초(5000ms) 이내 정답 시 +- GameStatsService에서 quickGuesses 카운트 + +**PERFECT_DRAWER (완벽한 출제자):** + +- 출제 시 모든 참가자가 정답을 맞춘 경우 +- 라운드 종료 시 endReason == "ALL_CORRECT"이면 카운트 + +**MASTER (학습 마스터):** + +- 다른 모든 배지를 획득한 경우 +- 특수 로직으로 모든 배지 보유 여부 확인 diff --git a/docs/domain-reports/CHATTING-DOMAIN-REPORT.md b/docs/domain-reports/CHATTING-DOMAIN-REPORT.md new file mode 100644 index 00000000..c27eb552 --- /dev/null +++ b/docs/domain-reports/CHATTING-DOMAIN-REPORT.md @@ -0,0 +1,434 @@ +# Chatting Domain 세부 보고서 + +## 1. 개요 + +Chatting 도메인은 실시간 채팅과 캐치마인드 게임 기능을 제공하는 WebSocket 기반 시스템입니다. AWS API Gateway WebSocket과 Lambda를 활용하여 실시간 양방향 통신을 구현했습니다. + +--- + +## 2. 전체 아키텍처 + +```mermaid +flowchart TB + subgraph Client["클라이언트"] + APP[Mobile/Web App] + end + + subgraph Gateway["API Gateway"] + REST[REST API] + WS[WebSocket API] + end + + subgraph Lambda["Lambda Handlers"] + direction TB + ROOM[ChatRoomHandler] + MSG[ChatMessageHandler] + GAME[GameHandler] + VOICE[ChatVoiceHandler] + CONNECT[WebSocketConnectHandler] + DISCONNECT[WebSocketDisconnectHandler] + MESSAGE[WebSocketMessageHandler] + end + + subgraph Storage["데이터 저장소"] + DDB[(DynamoDB)] + S3[(S3 - 음성 캐시)] + end + + APP --> REST + APP <--> WS + REST --> ROOM + REST --> MSG + REST --> GAME + REST --> VOICE + WS --> CONNECT + WS --> DISCONNECT + WS --> MESSAGE + ROOM --> DDB + MSG --> DDB + GAME --> DDB + MESSAGE --> DDB + VOICE --> S3 +``` + +--- + +## 3. 채팅방 시스템 + +### 3.1 채팅방 입장 흐름 + +```mermaid +sequenceDiagram + participant Client + participant REST as REST API + participant WS as WebSocket API + participant DB as DynamoDB + Note over Client, DB: Phase 1 - 방 입장 및 토큰 발급 + Client ->> REST: POST /rooms/{roomId}/join + REST ->> DB: 비밀번호 검증 (비밀방인 경우) + REST ->> DB: RoomToken 저장 (TTL 5분) + REST -->> Client: roomToken 반환 + Note over Client, DB: Phase 2 - WebSocket 연결 + Client ->> WS: $connect?roomToken={token} + WS ->> DB: 토큰 검증 + WS ->> DB: Connection 저장 (TTL 10분) + WS -->> Client: 연결 성공 + Note over Client, DB: Phase 3 - 실시간 메시지 + Client ->> WS: sendMessage (채팅) + WS ->> DB: 메시지 저장 + WS -->> Client: 브로드캐스트 (같은 방 전체) +``` + +### 3.2 REST API 엔드포인트 + +| Method | Endpoint | 설명 | 인증 | +|--------|-------------------------------|---------------------------|----| +| POST | /chat/rooms | 채팅방 생성 | O | +| GET | /chat/rooms | 채팅방 목록 (level, joined 필터) | O | +| GET | /chat/rooms/{roomId} | 채팅방 상세 | O | +| POST | /chat/rooms/{roomId}/join | 채팅방 입장 (토큰 발급) | O | +| POST | /chat/rooms/{roomId}/leave | 채팅방 퇴장 | O | +| DELETE | /chat/rooms/{roomId} | 채팅방 삭제 (방장만) | O | +| GET | /chat/rooms/{roomId}/messages | 메시지 히스토리 | O | + +### 3.3 WebSocket 이벤트 + +| Route | 설명 | Payload | +|-------------|------------|------------------------------------------| +| $connect | 연결 (토큰 검증) | ?roomToken={token} | +| $disconnect | 연결 해제 | - | +| sendMessage | 메시지 전송 | { roomId, userId, content, messageType } | + +--- + +## 4. 캐치마인드 게임 시스템 + +### 4.1 게임 흐름 + +```mermaid +flowchart TB + subgraph GameFlow["캐치마인드 게임 흐름"] + START["/game 명령어"] --> INIT["게임 초기화
출제자 순서 셔플"] + INIT --> ROUND["라운드 시작
출제자 + 단어 선정"] + ROUND --> DRAW["출제자 그림 그리기
(DRAWING 메시지)"] + DRAW --> GUESS["참가자 정답 입력"] + GUESS --> CHECK{정답?} + CHECK -->|Yes| SCORE["점수 계산
시간보너스 + 연속보너스"] + CHECK -->|No| GUESS + SCORE --> ALLCORRECT{전원 정답?} + ALLCORRECT -->|Yes| NEXTROUND + ALLCORRECT -->|No| TIMEOUT{시간 초과?} + TIMEOUT -->|Yes| NEXTROUND["다음 라운드"] + TIMEOUT -->|No| GUESS + NEXTROUND --> LASTROUND{마지막 라운드?} + LASTROUND -->|Yes| END["게임 종료
순위 발표"] + LASTROUND -->|No| ROUND + end +``` + +### 4.2 게임 API + +| Method | Endpoint | 설명 | +|--------|----------------------------------|-------------| +| POST | /chat/rooms/{roomId}/game/start | 게임 시작 (방장만) | +| POST | /chat/rooms/{roomId}/game/stop | 게임 중지 | +| GET | /chat/rooms/{roomId}/game/status | 게임 상태 조회 | +| GET | /chat/rooms/{roomId}/game/scores | 점수판 조회 | + +### 4.3 슬래시 명령어 + +| 명령어 | 설명 | 사용 가능 | +|---------|----------------|--------| +| /start | 게임 시작 | 방장 | +| /stop | 게임 중지 | 방장/시작자 | +| /score | 점수판 보기 | 전체 | +| /member | 접속자 수 | 전체 | +| /hint | 힌트 제공 (첫글자○○○) | 출제자 | +| /skip | 라운드 스킵 | 출제자 | +| /help | 명령어 도움말 | 전체 | + +### 4.4 점수 계산 공식 + +``` +점수 = 기본점수(10) + 시간보너스 + 연속보너스 + 출제자보너스 + +- 시간보너스: (60 - 경과초) × 0.5 +- 연속보너스: streak × 2 +- 출제자보너스: 정답자당 5점 +``` + +**예시:** + +- 30초에 정답 + 연속 3회: 10 + 15 + 6 = 31점 +- 출제자가 3명 맞출 경우: 5 × 3 = 15점 + +### 4.5 게임 상태 + +```mermaid +stateDiagram-v2 + [*] --> NONE: 대기 + NONE --> PLAYING: /start 명령어 + PLAYING --> ROUND_END: 시간초과/전원정답 + ROUND_END --> PLAYING: 다음 라운드 + ROUND_END --> FINISHED: 마지막 라운드 + PLAYING --> FINISHED: /stop 명령어 + FINISHED --> [*]: 게임 종료 +``` + +--- + +## 5. WebSocket 메시지 타입 + +### 5.1 채팅 메시지 + +| Type | 설명 | 저장 | +|-------------|-------|----| +| TEXT | 일반 채팅 | O | +| IMAGE | 이미지 | O | +| VOICE | 음성 | O | +| AI_RESPONSE | AI 응답 | O | + +### 5.2 게임 메시지 + +| Type | 설명 | 저장 | +|----------------|--------------|----| +| DRAWING | 그림 데이터 (실시간) | X | +| DRAWING_CLEAR | 그림 지우기 | X | +| GUESS | 오답 추측 | X | +| CORRECT_ANSWER | 정답 알림 | X | +| SCORE_UPDATE | 점수 갱신 | X | +| GAME_START | 게임 시작 | X | +| ROUND_START | 라운드 시작 | X | +| ROUND_END | 라운드 종료 | X | +| GAME_END | 게임 종료 | X | +| HINT | 힌트 | X | + +### 5.3 실시간 점수 업데이트 메시지 + +```json +{ + "messageType": "SCORE_UPDATE", + "roomId": "uuid", + "scorerId": "user123", + "scoreGained": 25, + "ranking": [ + { + "rank": 1, + "userId": "user123", + "score": 85, + "change": 25 + }, + { + "rank": 2, + "userId": "user456", + "score": 60, + "change": 0 + } + ], + "currentRound": 3, + "totalRounds": 5 +} +``` + +--- + +## 6. 데이터 모델 + +### 6.1 ChatRoom + +```java + +@DynamoDbBean +public class ChatRoom { + // 기본 정보 + String roomId, name, description; + String level; // beginner, intermediate, advanced + Integer currentMembers, maxMembers; + Boolean isPrivate; + String password; // BCrypt 암호화 + String createdBy; // 방장 + List memberIds; + + // 게임 상태 + String gameStatus; // NONE, PLAYING, ROUND_END, FINISHED + Integer currentRound, totalRounds; + String currentDrawerId, currentWord; + Long roundStartTime; + Integer roundTimeLimit; // 60초 + List drawerOrder; + Map scores; + Map streaks; + List correctGuessers; + Boolean hintUsed; +} +``` + +**DynamoDB Keys:** + +- PK: `ROOM#{roomId}` | SK: `METADATA` +- GSI1: `ROOMS` | `{level}#{createdAt}` (레벨별 최신순) + +### 6.2 Connection + +```java + +@DynamoDbBean +public class Connection { + String connectionId; // API Gateway 연결 ID + String userId; + String roomId; + Long ttl; // 10분 (자동 삭제) +} +``` + +**DynamoDB Keys:** + +- PK: `CONN#{connectionId}` | SK: `METADATA` +- GSI1: `ROOM#{roomId}` | `CONN#{connectionId}` (방별 연결) +- GSI2: `USER#{userId}` | `CONN#{connectionId}` (사용자별 연결) + +### 6.3 GameRound + +```java + +@DynamoDbBean +public class GameRound { + Integer roundNumber; + String drawerId, word, wordEnglish; + List correctGuessers; + Map guessTimes; // 정답까지 걸린 시간 + Map roundScores; + Long startTime, endTime; + String endReason; // TIME_UP, ALL_CORRECT, SKIP + Long ttl; // 7일 +} +``` + +### 6.4 RoomToken + +```java + +@DynamoDbBean +public class RoomToken { + String token; // UUID + String roomId; + String userId; + Long ttl; // 5분 +} +``` + +--- + +## 7. 서비스 레이어 + +### 7.1 CQRS 패턴 + +| Service | 역할 | +|------------------------|----------------------| +| ChatRoomCommandService | 채팅방 생성, 입장, 퇴장, 삭제 | +| ChatRoomQueryService | 채팅방 조회, 목록 | +| GameService | 게임 시작, 정답 체크, 라운드 종료 | +| GameStatsService | 게임 종료 후 통계, 배지 처리 | +| CommandService | 슬래시 명령어 처리 | +| RoomTokenService | 토큰 발급 및 검증 | + +### 7.2 게임 정답 체크 로직 + +```mermaid +flowchart TB + INPUT[정답 입력] --> NORMALIZE["정규화
(소문자, 공백제거)"] + NORMALIZE --> VALIDATE{유효성 검사} + VALIDATE -->|게임 미진행| REJECT1[거부: 게임 없음] + VALIDATE -->|출제자 본인| REJECT2[거부: 출제자] + VALIDATE -->|이미 정답| REJECT3[거부: 중복] + VALIDATE -->|통과| COMPARE{정답 비교} + COMPARE -->|일치| CORRECT["정답 처리
점수 계산"] + COMPARE -->|불일치| WRONG["오답 처리
GUESS 메시지 전송"] + CORRECT --> BROADCAST["브로드캐스트
CORRECT_ANSWER + SCORE_UPDATE"] + WRONG --> GUESSBROADCAST["브로드캐스트
GUESS 메시지"] + BROADCAST --> ALLCHECK{전원 정답?} + ALLCHECK -->|Yes| ROUNDEND[라운드 자동 종료] + ALLCHECK -->|No| CONTINUE[게임 계속] +``` + +--- + +## 8. 브로드캐스트 시스템 + +### 8.1 WebSocketBroadcaster + +```java +public class WebSocketBroadcaster { + public List broadcast( + List connections, + String payload + ) { + // 1. 같은 방 모든 연결에 메시지 전송 + // 2. 실패한 연결 ID 반환 (Stale 정리용) + } +} +``` + +### 8.2 브로드캐스트 유형 + +| 유형 | 대상 | 예시 | +|--------|--------|-----------| +| 전체 | 방 전체 | 채팅, 정답 알림 | +| 본인 제외 | 발신자 제외 | 그림 데이터 | +| 출제자 전용 | 출제자만 | 단어 정보 | + +--- + +## 9. 파일 구조 + +``` +domain/chatting/ +├── handler/ +│ ├── ChatRoomHandler.java +│ ├── ChatMessageHandler.java +│ ├── ChatVoiceHandler.java +│ ├── GameHandler.java +│ └── websocket/ +│ ├── WebSocketConnectHandler.java +│ ├── WebSocketDisconnectHandler.java +│ └── WebSocketMessageHandler.java +├── service/ +│ ├── ChatRoomCommandService.java +│ ├── ChatRoomQueryService.java +│ ├── ChatMessageService.java +│ ├── GameService.java +│ ├── GameStatsService.java +│ ├── CommandService.java +│ └── RoomTokenService.java +├── repository/ +│ ├── ChatRoomRepository.java +│ ├── ChatMessageRepository.java +│ ├── ConnectionRepository.java +│ ├── GameRoundRepository.java +│ └── RoomTokenRepository.java +├── model/ +│ ├── ChatRoom.java +│ ├── ChatMessage.java +│ ├── Connection.java +│ ├── GameRound.java +│ └── RoomToken.java +├── dto/ +│ ├── request/ +│ └── response/ +│ └── ScoreUpdateMessage.java +└── enums/ + ├── GameStatus.java + └── MessageType.java +``` + +--- + +## 10. 기술 스택 + +- **Runtime:** AWS Lambda (Java 21) +- **API:** API Gateway REST + WebSocket +- **Database:** DynamoDB (Single Table Design) +- **Auth:** Cognito + RoomToken +- **Encryption:** BCrypt (비밀방 암호) +- **TTS:** AWS Polly + S3 캐시 +- **Pattern:** CQRS, Repository, Factory diff --git a/docs/domain-reports/COMMON-MODULE-REPORT.md b/docs/domain-reports/COMMON-MODULE-REPORT.md new file mode 100644 index 00000000..aefe6d08 --- /dev/null +++ b/docs/domain-reports/COMMON-MODULE-REPORT.md @@ -0,0 +1,1228 @@ +# Common Module 세부 보고서 + +## 1. 개요 + +Common 모듈은 모든 도메인에서 공유하는 유틸리티, 설정, 예외 처리, 라우팅 등을 제공하는 핵심 인프라 모듈입니다. Java 21의 최신 기능(Records, Sealed Interface, Pattern +Matching)을 적극 활용하여 타입 안전성과 코드 간결성을 확보했습니다. + +--- + +## 2. 전체 패키지 구조 + +```mermaid +flowchart TB +subgraph Common["common/"] +CONFIG[config/] +CONST[constants/] +DTO[dto/] +ENUM[enums/] +EXCEPTION[exception/] +ROUTER[router/] +SERVICE[service/] +UTIL[util/] +VALIDATION[validation/] +end + +subgraph ConfigFiles["config/"] +AC[AwsClients.java] +WSC[WebSocketConfig.java] +RTC[RoomTokenConfig.java] +SC[StudyConfig.java] +end + +subgraph DtoFiles["dto/"] +AR[ApiResponse.java] +EI[ErrorInfo.java] +PR[PaginatedResult.java] +end + +subgraph ExceptionFiles["exception/"] +SE[ServerlessException.java] +EC[ErrorCode.java] +CEC[CommonErrorCode.java] +CE[CommonException.java] +end + +subgraph RouterFiles["router/"] +HR[HandlerRouter.java] +RT[Route.java] +AH[AuthenticatedHandler.java] +end + +CONFIG --> ConfigFiles +DTO --> DtoFiles +EXCEPTION --> ExceptionFiles +ROUTER --> RouterFiles +``` + +--- + +## 3. Handler 라우팅 시스템 + +### 3.1 HandlerRouter 아키텍처 + +```mermaid +flowchart TB + subgraph Request["요청 처리 흐름"] + REQ[APIGatewayProxyRequestEvent] --> ROUTER[HandlerRouter] + ROUTER --> MATCH{라우트 매칭} + MATCH -->|매칭 성공| VALIDATE[파라미터 검증] + MATCH -->|매칭 실패| NF404[404 Not Found] + VALIDATE --> EXECUTE[핸들러 실행] + EXECUTE --> RESPONSE[APIGatewayProxyResponseEvent] + end + + subgraph ErrorHandling["예외 처리"] + EXECUTE -->|ServerlessException| ERR1[ErrorCode 기반 응답] + EXECUTE -->|IllegalArgumentException| ERR2[400 Bad Request] + EXECUTE -->|IllegalStateException| ERR3[409 Conflict] + EXECUTE -->|SecurityException| ERR4[403 Forbidden] + EXECUTE -->|기타 예외| ERR5[500 Internal Error] + end +``` + +### 3.2 Route 정의 (Java 21 Record) + +```java +// Route.java - Java 21 Record 활용 +public record Route( + String method, // HTTP 메서드 + String pathPattern, // 경로 패턴 (e.g., "/rooms/{roomId}") + Function handler, + List requiredPathParams, // 필수 경로 파라미터 + List requiredQueryParams // 필수 쿼리 파라미터 + ) { + // 경로 파라미터 자동 추출: {roomId} → roomId + private static final Pattern PATH_PARAM_PATTERN = + Pattern.compile("\\{([^}]+)}"); +} +``` + +### 3.3 Route 팩토리 메서드 + +```mermaid +flowchart LR + subgraph BasicRoutes["기본 라우트"] + GET["Route.get()"] + POST["Route.post()"] + PUT["Route.put()"] + DELETE["Route.delete()"] + PATCH["Route.patch()"] + end + + subgraph AuthRoutes["인증 라우트"] + GETAUTH["Route.getAuth()"] + POSTAUTH["Route.postAuth()"] + PUTAUTH["Route.putAuth()"] + DELETEAUTH["Route.deleteAuth()"] + PATCHAUTH["Route.patchAuth()"] + end + + BasicRoutes -->|" + Cognito 인증 "| AuthRoutes +``` + +### 3.4 사용 예시 + +```java +// Handler에서 라우터 초기화 +private HandlerRouter initRouter() { + return new HandlerRouter().addRoutes( + // 인증 필요 라우트 (Cognito userId 자동 추출) + Route.postAuth("/grammar/check", this::checkGrammar), + Route.getAuth("/grammar/sessions/{sessionId}", this::getSessionDetail), + Route.deleteAuth("/grammar/sessions/{sessionId}", this::deleteSession), + + // 쿼리 파라미터 검증 + Route.getAuth("/rooms", this::getRooms) + .requireQueryParams("level") + ); +} + +// Lambda 핸들러 메서드 +@Override +public APIGatewayProxyResponseEvent handleRequest( + APIGatewayProxyRequestEvent request, Context context) { + return router.route(request); +} +``` + +### 3.5 AuthenticatedHandler 인터페이스 + +```java +// 함수형 인터페이스 - Cognito 인증 요청 처리 +@FunctionalInterface +public interface AuthenticatedHandler { + APIGatewayProxyResponseEvent handle( + APIGatewayProxyRequestEvent request, + String userId // Cognito sub claim에서 자동 추출 + ); +} + +// 사용 예시 - 람다 표현식으로 간결하게 +Route. + +postAuth("/rooms",(request, userId) ->{ +CreateRoomRequest dto = parseBody(request, CreateRoomRequest.class); +ChatRoom room = roomService.createRoom(userId, dto); + return ResponseGenerator. + +created("Room created",room); +}); +``` + +--- + +## 4. 예외 처리 시스템 + +### 4.1 ErrorCode 계층 구조 (Sealed Interface) + +```mermaid +flowchart TB + subgraph SealedHierarchy["Java 21 Sealed Interface 계층"] + EC[/"ErrorCode
(sealed interface)"/] + EC -->|permits| CEC["CommonErrorCode
(enum)"] + EC -->|permits| DEC[/"DomainErrorCode
(non-sealed interface)"/] + DEC --> VEC["VocabularyErrorCode"] + DEC --> CHEC["ChattingErrorCode"] + DEC --> GEC["GrammarErrorCode"] + DEC --> SEC["StatsErrorCode"] + DEC --> BEC["BadgeErrorCode"] + end +``` + +### 4.2 CommonErrorCode 정의 + +```java +public enum CommonErrorCode implements ErrorCode { + // 인증/인가 (AUTH_xxx) + UNAUTHORIZED("AUTH_001", "인증이 필요합니다", 401), + FORBIDDEN("AUTH_002", "접근 권한이 없습니다", 403), + INVALID_TOKEN("AUTH_003", "유효하지 않은 토큰입니다", 401), + TOKEN_EXPIRED("AUTH_004", "토큰이 만료되었습니다", 401), + + // 검증 (VALIDATION_xxx) + INVALID_INPUT("VALIDATION_001", "잘못된 입력입니다", 400), + REQUIRED_FIELD_MISSING("VALIDATION_002", "필수 필드가 누락되었습니다", 400), + INVALID_FORMAT("VALIDATION_003", "형식이 올바르지 않습니다", 400), + VALUE_OUT_OF_RANGE("VALIDATION_004", "값이 허용 범위를 벗어났습니다", 400), + + // 리소스 (RESOURCE_xxx) + RESOURCE_NOT_FOUND("RESOURCE_001", "리소스를 찾을 수 없습니다", 404), + RESOURCE_ALREADY_EXISTS("RESOURCE_002", "이미 존재하는 리소스입니다", 409), + METHOD_NOT_ALLOWED("RESOURCE_003", "허용되지 않는 메서드입니다", 405), + + // 시스템 (SYSTEM_xxx) + INTERNAL_SERVER_ERROR("SYSTEM_001", "내부 서버 오류가 발생했습니다", 500), + DATABASE_ERROR("SYSTEM_002", "데이터베이스 오류가 발생했습니다", 500), + EXTERNAL_API_ERROR("SYSTEM_003", "외부 API 호출 오류가 발생했습니다", 502), + SERVICE_UNAVAILABLE("SYSTEM_004", "서비스를 일시적으로 사용할 수 없습니다", 503); + + private final String code; + private final String message; + private final int statusCode; +} +``` + +### 4.3 예외 생성 팩토리 패턴 + +```mermaid +flowchart LR + subgraph FactoryMethods["CommonException 팩토리 메서드"] + AUTH["인증 오류"] + VALID["검증 오류"] + RES["리소스 오류"] + SYS["시스템 오류"] + end + + AUTH --> UNAUTH["unauthorized()"] + AUTH --> FORBID["forbidden()"] + AUTH --> TOKEN["invalidToken()"] + VALID --> INPUT["invalidInput(msg)"] + VALID --> MISS["requiredFieldMissing(field)"] + VALID --> FMT["invalidFormat(field)"] + RES --> NF["notFound(resource, id)"] + RES --> EXIST["alreadyExists(resource)"] + SYS --> INTERN["internalError(cause)"] + SYS --> DB["databaseError(cause)"] + SYS --> EXT["externalApiError(api, cause)"] +``` + +### 4.4 예외 사용 예시 + +```java +// 가독성 높은 예외 생성 +throw CommonException.notFound("User","user123"); +// → "User (ID: user123)를 찾을 수 없습니다", 404 + +throw CommonException. + +invalidInput("Email format is invalid"); +// → 400 INVALID_INPUT with custom message + +throw CommonException. + +alreadyExists("ChatRoom","room456"); +// → "ChatRoom (ID: room456)가 이미 존재합니다", 409 + +// 상세 컨텍스트 추가 (메서드 체이닝) +throw CommonException. + +internalError(cause) + . + +addDetail("operation","database_query") + . + +addDetail("table","users"); +``` + +--- + +## 5. AWS 클라이언트 관리 + +### 5.1 Singleton 패턴 (Cold Start 최적화) + +```mermaid +flowchart TB + subgraph ColdStart["Lambda Cold Start 최적화"] + INIT["Lambda 컨테이너 초기화
(1회)"] + STATIC["static final 클라이언트 생성"] + REUSE["요청마다 재사용"] + end + + INIT --> STATIC + STATIC --> REUSE + REUSE -->|" 다음 요청 "| REUSE +``` + +### 5.2 AwsClients.java 구조 + +```java +public final class AwsClients { + // DynamoDB (Enhanced Client 포함) + private static final DynamoDbClient DYNAMO_DB_CLIENT = + DynamoDbClient.builder().build(); + private static final DynamoDbEnhancedClient DYNAMO_DB_ENHANCED_CLIENT = + DynamoDbEnhancedClient.builder() + .dynamoDbClient(DYNAMO_DB_CLIENT) + .build(); + + // S3 (Presigner 포함) + private static final S3Client S3_CLIENT = S3Client.builder().build(); + private static final S3Presigner S3_PRESIGNER = S3Presigner.builder().build(); + + // AI/ML 서비스 + private static final PollyClient POLLY_CLIENT = PollyClient.builder().build(); + private static final BedrockRuntimeClient BEDROCK_CLIENT = + BedrockRuntimeClient.builder().build(); + private static final BedrockRuntimeAsyncClient BEDROCK_ASYNC_CLIENT = + BedrockRuntimeAsyncClient.builder().build(); + private static final ComprehendClient COMPREHEND_CLIENT = + ComprehendClient.builder().build(); + + // SNS + private static final SnsClient SNS_CLIENT = SnsClient.builder().build(); + + // 팩토리 메서드 + public static DynamoDbClient dynamoDb() { + return DYNAMO_DB_CLIENT; + } + + public static DynamoDbEnhancedClient dynamoDbEnhanced() { + return DYNAMO_DB_ENHANCED_CLIENT; + } + + public static S3Client s3() { + return S3_CLIENT; + } + + public static S3Presigner s3Presigner() { + return S3_PRESIGNER; + } + + public static PollyClient polly() { + return POLLY_CLIENT; + } + + public static BedrockRuntimeClient bedrock() { + return BEDROCK_CLIENT; + } + + public static BedrockRuntimeAsyncClient bedrockAsync() { + return BEDROCK_ASYNC_CLIENT; + } + + public static ComprehendClient comprehend() { + return COMPREHEND_CLIENT; + } + + public static SnsClient sns() { + return SNS_CLIENT; + } +} +``` + +### 5.3 사용 예시 + +```java +// Service에서 사용 +public class PollyService { + public VoiceSynthesisResult synthesizeSpeech(String id, String text, String voice) { + SynthesizeSpeechRequest request = SynthesizeSpeechRequest.builder() + .text(text) + .voiceId(VoiceId.MATTHEW) + .engine("neural") + .outputFormat(OutputFormat.MP3) + .build(); + + // Singleton 클라이언트 사용 + InputStream audioStream = AwsClients.polly().synthesizeSpeech(request); + AwsClients.s3().putObject(putRequest, RequestBody.fromInputStream(audioStream, -1)); + + return new VoiceSynthesisResult(s3Key, presignedUrl, false); + } +} +``` + +--- + +## 6. DTO 패턴 (Java 21 Records) + +### 6.1 ApiResponse (제네릭 응답 래퍼) + +```java +// 불변 데이터 클래스 - Java 21 Record +public record ApiResponse( + boolean isSuccess, + String message, + T data, + String error + ) { + // 성공 응답 팩토리 + public static ApiResponse ok(String message, T data) { + return new ApiResponse<>(true, message, data, null); + } + + public static ApiResponse ok(T data) { + return new ApiResponse<>(true, null, data, null); + } + + // 실패 응답 팩토리 + public static ApiResponse fail(String errorMessage) { + return new ApiResponse<>(false, null, null, errorMessage); + } +} +``` + +**JSON 응답 예시:** + +```json +{ + "isSuccess": true, + "message": "Grammar checked successfully", + "data": { + "correctedSentence": "I am a student", + "score": 85, + "errors": [ + ... + ] + }, + "error": null +} +``` + +### 6.2 ErrorInfo (RFC 7807 준수) + +```java +// Problem Details for HTTP APIs (RFC 7807) +public record ErrorInfo( + String code, // e.g., "VOCABULARY.WORD_001" + String message, // e.g., "단어를 찾을 수 없습니다" + int status, // e.g., 404 + Map details // Optional context + ) { + public static ErrorInfo from(ErrorCode errorCode) { ...} + + public static ErrorInfo from(ServerlessException ex) { ...} + + public boolean isClientError() { + return status >= 400 && status < 500; + } + + public boolean isServerError() { + return status >= 500 && status < 600; + } +} +``` + +**JSON 에러 응답 예시:** + +```json +{ + "code": "VOCABULARY.WORD_001", + "message": "단어를 찾을 수 없습니다", + "status": 404, + "details": { + "wordId": "abc-123", + "userId": "user456" + } +} +``` + +### 6.3 PaginatedResult (커서 페이지네이션) + +```java +public record PaginatedResult( + List items, + String nextCursor // Base64 인코딩된 DynamoDB lastEvaluatedKey +) { + public boolean hasMore() { + return nextCursor != null; + } +} +``` + +--- + +## 7. 페이지네이션 유틸리티 + +### 7.1 CursorUtil 동작 흐름 + +```mermaid +sequenceDiagram + participant Client + participant Handler + participant CursorUtil + participant DynamoDB + Note over Client, DynamoDB: 첫 페이지 요청 + Client ->> Handler: GET /items?limit=10 + Handler ->> CursorUtil: decode(null) → null + Handler ->> DynamoDB: Query (exclusiveStartKey=null) + DynamoDB -->> Handler: items + lastEvaluatedKey + Handler ->> CursorUtil: encode(lastEvaluatedKey) + CursorUtil -->> Handler: "dXNlcklkPXVzZXIxMjM..." + Handler -->> Client: {"items": [...], "nextCursor": "dXNlcklkPXVzZXIxMjM..."} + Note over Client, DynamoDB: 다음 페이지 요청 + Client ->> Handler: GET /items?cursor=dXNlcklkPXVzZXIxMjM... + Handler ->> CursorUtil: decode("dXNlcklkPXVzZXIxMjM...") + CursorUtil -->> Handler: {"userId": "user123", ...} + Handler ->> DynamoDB: Query (exclusiveStartKey={...}) + DynamoDB -->> Handler: items + lastEvaluatedKey +``` + +### 7.2 CursorUtil 구현 + +```java +public class CursorUtil { + // DynamoDB lastEvaluatedKey → Base64 문자열 + public static String encode(Map lastEvaluatedKey) { + if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { + return null; + } + + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : lastEvaluatedKey.entrySet()) { + if (sb.length() > 0) sb.append("|"); + sb.append(entry.getKey()).append("=").append(entry.getValue().s()); + } + + return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); + } + + // Base64 문자열 → DynamoDB exclusiveStartKey + public static Map decode(String cursor) { + if (cursor == null || cursor.isEmpty()) { + return null; + } + + String decoded = new String(Base64.getUrlDecoder().decode(cursor)); + Map startKey = new HashMap<>(); + + for (String pair : decoded.split("\\|")) { + String[] kv = pair.split("=", 2); + if (kv.length == 2) { + startKey.put(kv[0], AttributeValue.builder().s(kv[1]).build()); + } + } + + return startKey; + } +} +``` + +--- + +## 8. 인증 유틸리티 + +### 8.1 Cognito 인증 흐름 + +```mermaid +flowchart TB + subgraph CognitoAuth["Cognito 인증 흐름"] + REQ[요청] --> AUTH[API Gateway Authorizer] + AUTH --> CLAIMS[JWT Claims 추출] + CLAIMS --> INJECT["requestContext.authorizer.claims"] + end + + subgraph CognitoUtil["CognitoUtil 추출"] + INJECT --> EXTRACT[extractUserId] + EXTRACT --> SUB["claims.sub → userId"] + end +``` + +### 8.2 CognitoUtil.java + +```java +public class CognitoUtil { + // 기본 userId 추출 (sub claim) + public static String extractUserId(APIGatewayProxyRequestEvent request) { + Map authorizer = request.getRequestContext().getAuthorizer(); + if (authorizer == null) return null; + + Map claims = (Map) authorizer.get("claims"); + return claims != null ? claims.get("sub") : null; + } + + // 선택적 claim 추출 + public static Optional extractEmail(APIGatewayProxyRequestEvent request) { + return extractClaim(request, "email"); + } + + public static Optional extractNickname(APIGatewayProxyRequestEvent request) { + return extractClaim(request, "custom:nickname"); + } + + public static Optional extractClaim( + APIGatewayProxyRequestEvent request, String claimName) { + // ... claim 추출 로직 + } + + // 사용자 접근 권한 검증 + public static boolean validateUserAccess( + APIGatewayProxyRequestEvent request, String pathUserId) { + String tokenUserId = extractUserId(request); + return tokenUserId != null && tokenUserId.equals(pathUserId); + } +} +``` + +### 8.3 JwtUtil.java (WebSocket용) + +```java +// WebSocket 연결 시 직접 JWT 파싱 (Authorizer 미사용) +public final class JwtUtil { + public static Optional extractUserId(String token) { + // Bearer 제거 + if (token.startsWith("Bearer ")) { + token = token.substring(7); + } + + // JWT payload 추출 (헤더.페이로드.시그니처) + String[] parts = token.split("\\."); + if (parts.length != 3) return Optional.empty(); + + // Base64 URL 디코딩 + String payload = new String(Base64.getUrlDecoder().decode(parts[1])); + Map claims = gson.fromJson(payload, Map.class); + + return Optional.ofNullable((String) claims.get("sub")); + } + + public static boolean isExpired(String token) { + // exp claim 확인 + } +} +``` + +--- + +## 9. HTTP 응답 생성 + +### 9.1 ResponseGenerator.java + +```java +public class ResponseGenerator { + private static final Gson GSON = new GsonBuilder() + .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") + .create(); + + private static final Map CORS_HEADERS = Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers", "Content-Type,Authorization" + ); + + // 성공 응답 + public static APIGatewayProxyResponseEvent ok(String message, T data) { + return buildResponse(200, ApiResponse.ok(message, data)); + } + + public static APIGatewayProxyResponseEvent created(String message, T data) { + return buildResponse(201, ApiResponse.ok(message, data)); + } + + public static APIGatewayProxyResponseEvent noContent() { + return buildResponse(204, null); + } + + // 에러 응답 + public static APIGatewayProxyResponseEvent fail(ErrorCode errorCode) { + return buildResponse(errorCode.getStatusCode(), ErrorInfo.from(errorCode)); + } + + public static APIGatewayProxyResponseEvent badRequest(String message) { + return fail(CommonErrorCode.INVALID_INPUT, message); + } + + public static APIGatewayProxyResponseEvent notFound(String message) { + return fail(CommonErrorCode.RESOURCE_NOT_FOUND, message); + } + + // ... 기타 편의 메서드 + + private static APIGatewayProxyResponseEvent buildResponse(int statusCode, Object body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(new HashMap<>(CORS_HEADERS)) + .withBody(body != null ? GSON.toJson(body) : null); + } + + public static Gson gson() { + return GSON; + } +} +``` + +--- + +## 10. Bean Validation + +### 10.1 BeanValidator 패턴 + +```mermaid +flowchart TB + REQ[요청 수신] --> PARSE[JSON 파싱 → DTO] +PARSE --> VALIDATE[BeanValidator.validateAndExecute] +VALIDATE --> CHECK{검증 통과?} +CHECK -->|Yes|HANDLER[핸들러 로직 실행] +CHECK -->|No|ERR400[400 Bad Request] +HANDLER --> RESPONSE[정상 응답] +``` + +### 10.2 BeanValidator.java + +```java +public final class BeanValidator { + private static final Validator VALIDATOR; + + static { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + VALIDATOR = factory.getValidator(); + } + + // 검증 + 실행 통합 패턴 + public static APIGatewayProxyResponseEvent validateAndExecute( + T object, + Function handler) { + + Optional error = validate(object); + if (error.isPresent()) { + return ResponseGenerator.badRequest(error.get()); + } + + return handler.apply(object); + } + + public static Optional validate(T object) { + Set> violations = VALIDATOR.validate(object); + if (violations.isEmpty()) { + return Optional.empty(); + } + + String message = violations.stream() + .map(ConstraintViolation::getMessage) + .collect(Collectors.joining(", ")); + + return Optional.of(message); + } +} +``` + +### 10.3 DTO 검증 예시 + +```java +// 요청 DTO +public class CreateRoomRequest { + @NotEmpty(message = "방 이름은 필수입니다") + private String roomName; + + @NotNull(message = "난이도는 필수입니다") + private String difficulty; + + @Min(value = 2, message = "최소 2명 이상이어야 합니다") + @Max(value = 10, message = "최대 10명까지 가능합니다") + private int maxMembers; +} + +// Handler에서 사용 +private APIGatewayProxyResponseEvent createRoom( + APIGatewayProxyRequestEvent request, String userId) { + + CreateRoomRequest req = ResponseGenerator.gson() + .fromJson(request.getBody(), CreateRoomRequest.class); + + return BeanValidator.validateAndExecute(req, dto -> { + // 검증 통과 시에만 실행됨 + ChatRoom room = roomService.createRoom(userId, dto); + return ResponseGenerator.created("방이 생성되었습니다", room); + }); +} +``` + +--- + +## 11. WebSocket 유틸리티 + +### 11.1 브로드캐스트 흐름 + +```mermaid +sequenceDiagram + participant Service + participant Broadcaster as WebSocketBroadcaster + participant APIGW as API Gateway + participant Clients as WebSocket Clients + Service ->> Broadcaster: broadcast(connections, message) + + loop 각 연결에 전송 + Broadcaster ->> APIGW: postToConnection(connectionId, data) + alt 성공 + APIGW -->> Clients: 메시지 전달 + else 연결 끊김 (410 Gone) + APIGW -->> Broadcaster: GoneException + Broadcaster ->> Broadcaster: failedIds에 추가 + end + end + + Broadcaster -->> Service: failedConnectionIds 반환 + Service ->> Service: Stale 연결 정리 +``` + +### 11.2 WebSocketBroadcaster.java + +```java +public class WebSocketBroadcaster { + private final ApiGatewayManagementApiClient apiClient; + + public WebSocketBroadcaster() { + String endpoint = WebSocketConfig.websocketEndpoint(); + this.apiClient = ApiGatewayManagementApiClient.builder() + .endpointOverride(URI.create(endpoint)) + .build(); + } + + // 단일 연결에 전송 + public boolean sendToConnection(String connectionId, String message) { + try { + apiClient.postToConnection(PostToConnectionRequest.builder() + .connectionId(connectionId) + .data(SdkBytes.fromUtf8String(message)) + .build()); + return true; + } catch (GoneException e) { + // 연결이 이미 끊김 + return false; + } + } + + // 다수 연결에 브로드캐스트 + public List broadcast(List connections, String message) { + List failedIds = new ArrayList<>(); + + for (Connection conn : connections) { + if (!sendToConnection(conn.getConnectionId(), message)) { + failedIds.add(conn.getConnectionId()); + } + } + + return failedIds; // 실패한 연결 ID 반환 (정리용) + } +} +``` + +### 11.3 WebSocket 응답 유틸리티 + +```java +public final class WebSocketResponseUtil { + public static Map ok(String message) { + return response(200, message); + } + + public static Map unauthorized(String message) { + return response(401, message); + } + + public static Map badRequest(String message) { + return response(400, message); + } + + private static Map response(int statusCode, String body) { + return Map.of( + "statusCode", statusCode, + "body", body + ); + } +} +``` + +--- + +## 12. S3 Presigned URL + +### 12.1 S3PresignUtil.java + +```java +public class S3PresignUtil { + private static final Duration DEFAULT_DURATION = Duration.ofHours(24); + private static final String BUCKET_NAME = System.getenv("S3_BUCKET_NAME"); + + // 내부 캐시 (Java 21 Record) + private record CachedUrl(String url, long expiresAt) { + boolean isExpired() { + // 1시간 버퍼 두고 만료 체크 + return System.currentTimeMillis() > (expiresAt - 3600_000); + } + } + + private static final Map URL_CACHE = new ConcurrentHashMap<>(); + + public static String getPresignedUrl(String key) { + return getPresignedUrl(key, DEFAULT_DURATION); + } + + public static String getPresignedUrl(String key, Duration duration) { + CachedUrl cached = URL_CACHE.get(key); + if (cached != null && !cached.isExpired()) { + return cached.url(); + } + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(duration) + .getObjectRequest(r -> r.bucket(BUCKET_NAME).key(key)) + .build(); + + String url = AwsClients.s3Presigner() + .presignGetObject(presignRequest) + .url() + .toString(); + + URL_CACHE.put(key, new CachedUrl(url, + System.currentTimeMillis() + duration.toMillis())); + + return url; + } + + // 배지 이미지 URL 생성 편의 메서드 + public static String getBadgeImageUrl(String imageFile) { + return getPresignedUrl("badges/" + imageFile); + } +} +``` + +--- + +## 13. AWS 서비스 래퍼 + +### 13.1 PollyService (TTS + S3 캐시) + +```mermaid +flowchart TB + REQ[음성 합성 요청] --> CHECK{S3 캐시 확인} + CHECK -->|캐시 있음| PRESIGN[Presigned URL 생성] + CHECK -->|캐시 없음| SYNTH[Polly 음성 합성] + SYNTH --> SAVE[S3 저장] + SAVE --> PRESIGN + PRESIGN --> RETURN[URL 반환] +``` + +```java +public class PollyService { + public VoiceSynthesisResult synthesizeSpeech(String id, String text, String voice) { + String s3Key = generateS3Key(id, voice); + + // 캐시 확인 + if (existsInS3(s3Key)) { + return new VoiceSynthesisResult(s3Key, getPresignedUrl(s3Key), true); + } + + // Polly 음성 합성 + VoiceId voiceId = "MALE".equalsIgnoreCase(voice) ? VoiceId.MATTHEW : VoiceId.JOANNA; + + SynthesizeSpeechRequest request = SynthesizeSpeechRequest.builder() + .text(text) + .voiceId(voiceId) + .engine("neural") // Neural 음성 (고품질) + .outputFormat(OutputFormat.MP3) + .build(); + + InputStream audioStream = AwsClients.polly().synthesizeSpeech(request); + + // S3 저장 + AwsClients.s3().putObject( + PutObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .contentType("audio/mpeg") + .build(), + RequestBody.fromInputStream(audioStream, -1) + ); + + return new VoiceSynthesisResult(s3Key, getPresignedUrl(s3Key), false); + } + + public String generateS3Key(String id, String voice) { + String suffix = "MALE".equalsIgnoreCase(voice) ? "male" : "female"; + return s3KeyPrefix + id + "_" + suffix + ".mp3"; + } +} +``` + +### 13.2 ComprehendService (NLP 분석) + +```java +public class ComprehendService { + public ComprehendAnalysis analyze(String text) { + // 감정 분석 + DetectSentimentResponse sentiment = AwsClients.comprehend() + .detectSentiment(DetectSentimentRequest.builder() + .text(text) + .languageCode("en") + .build()); + + // 구문 분석 (품사 태깅) + DetectSyntaxResponse syntax = AwsClients.comprehend() + .detectSyntax(DetectSyntaxRequest.builder() + .text(text) + .languageCode("en") + .build()); + + // 핵심 구문 추출 + DetectKeyPhrasesResponse keyPhrases = AwsClients.comprehend() + .detectKeyPhrases(DetectKeyPhrasesRequest.builder() + .text(text) + .languageCode("en") + .build()); + + // 문장 복잡도 계산 + String complexity = calculateComplexity(syntax.syntaxTokens()); + + return ComprehendAnalysis.builder() + .sentiment(sentiment.sentimentAsString()) + .syntax(mapTokens(syntax.syntaxTokens())) + .keyPhrases(mapKeyPhrases(keyPhrases.keyPhrases())) + .complexity(complexity) + .build(); + } + + private String calculateComplexity(List tokens) { + Set uniquePOS = tokens.stream() + .map(t -> t.partOfSpeech().tagAsString()) + .collect(Collectors.toSet()); + + if (uniquePOS.size() <= 3 && tokens.size() <= 5) return "BEGINNER"; + if (uniquePOS.size() <= 5 && tokens.size() <= 10) return "INTERMEDIATE"; + return "ADVANCED"; + } +} +``` + +--- + +## 14. 설정 클래스 + +### 14.1 StudyConfig (학습 알고리즘 상수) + +```java +public final class StudyConfig { + // SM-2 알고리즘 상수 + public static final int INITIAL_INTERVAL_DAYS = 1; + public static final double DEFAULT_EASE_FACTOR = 2.5; + public static final double MIN_EASE_FACTOR = 1.3; + public static final int INITIAL_REPETITIONS = 0; + + // 테스트 설정 + public static final int DEFAULT_WORD_COUNT = 20; + public static final int DAILY_TEST_WORD_COUNT = 10; + + // 복습 주기 (일) + public static final int[] REVIEW_INTERVALS = {1, 3, 7, 14, 30}; + + // 상태 기본값 + public static final String DEFAULT_WORD_STATUS = "NEW"; + public static final String DEFAULT_DIFFICULTY = "NORMAL"; + + // 오류 제한 + public static final int MAX_WRONG_COUNT = 3; +} +``` + +### 14.2 DynamoDbKey (키 패턴 상수) + +```java +public final class DynamoDbKey { + // 기본 키 + public static final String PK = "PK"; + public static final String SK = "SK"; + + // GSI 키 + public static final String GSI1_PK = "GSI1PK"; + public static final String GSI1_SK = "GSI1SK"; + public static final String GSI2_PK = "GSI2PK"; + public static final String GSI2_SK = "GSI2SK"; + + // GSI 이름 + public static final String GSI1 = "GSI1"; + public static final String GSI2 = "GSI2"; + + // 공통 접두사 + public static final String USER = "USER#"; + public static final String METADATA = "METADATA"; + + // 헬퍼 메서드 + public static String userPk(String userId) { + return USER + userId; // "USER#user-123" + } +} +``` + +--- + +## 15. Java 21 기능 활용 + +### 15.1 Records 활용 + +| 클래스 | 용도 | +|-----------------|----------------| +| ApiResponse | 제네릭 API 응답 래퍼 | +| ErrorInfo | RFC 7807 에러 응답 | +| PaginatedResult | 페이지네이션 결과 | +| Route | HTTP 라우트 정의 | +| RouteEntry | 라우터 내부 매칭 | +| CachedUrl | S3 URL 캐시 | + +### 15.2 Sealed Interface 활용 + +```mermaid +flowchart TB + subgraph SealedPattern["Sealed Interface 패턴"] + EC[/"sealed interface ErrorCode
permits CommonErrorCode, DomainErrorCode"/] + CEC["final enum CommonErrorCode
implements ErrorCode"] + DEC[/"non-sealed interface DomainErrorCode
extends ErrorCode"/] + EC --> CEC + EC --> DEC + end +``` + +### 15.3 Pattern Matching 활용 + +```java +// instanceof 패턴 매칭 +String code = errorCode instanceof DomainErrorCode domainCode + ? domainCode.getFullCode() // "VOCABULARY.WORD_001" + : errorCode.getCode(); // "AUTH_001" + +// switch 표현식 (Enhanced) +return switch(type. + +getCategory()){ + case"FIRST_STUDY"->stats. + +getTestsCompleted() >=1; + case"STREAK"->stats. + +getCurrentStreak() >=type. + +getThreshold(); + case"ACCURACY"->{ +double accuracy = (double) stats.getCorrectAnswers() / stats.getQuestionsAnswered() * 100; +yield accuracy >=type. + +getThreshold(); + } +default ->false; + }; +``` + +--- + +## 16. 디자인 패턴 요약 + +| 패턴 | 적용 위치 | 목적 | +|----------------------|------------------------|-------------------| +| **Singleton** | AwsClients | AWS SDK 클라이언트 재사용 | +| **Factory Method** | Route, CommonException | 객체 생성 캡슐화 | +| **Strategy** | AuthenticatedHandler | 요청 처리 전략 분리 | +| **Router** | HandlerRouter | HTTP 요청 라우팅 | +| **Builder** | ComprehendAnalysis | 복잡한 객체 생성 | +| **Template Method** | BeanValidator | 검증-실행 흐름 템플릿 | +| **Sealed Interface** | ErrorCode 계층 | 구현 제한 | +| **Data Class** | Records | 불변 데이터 전송 | + +--- + +## 17. 파일 구조 + +``` +common/ +├── config/ +│ ├── AwsClients.java # AWS SDK 클라이언트 싱글톤 +│ ├── WebSocketConfig.java # WebSocket 설정 +│ ├── RoomTokenConfig.java # 방 토큰 TTL 설정 +│ └── StudyConfig.java # 학습 알고리즘 상수 +├── constants/ +│ └── DynamoDbKey.java # DynamoDB 키 패턴 +├── dto/ +│ ├── ApiResponse.java # 제네릭 응답 래퍼 (Record) +│ ├── ErrorInfo.java # RFC 7807 에러 (Record) +│ └── PaginatedResult.java # 페이지네이션 (Record) +├── enums/ +│ ├── Difficulty.java # EASY, NORMAL, HARD +│ └── StudyLevel.java # BEGINNER, INTERMEDIATE, ADVANCED +├── exception/ +│ ├── ServerlessException.java # 기본 예외 클래스 +│ ├── ErrorCode.java # Sealed Interface +│ ├── CommonErrorCode.java # 공통 에러 코드 +│ ├── DomainErrorCode.java # 도메인 에러 인터페이스 +│ └── CommonException.java # 예외 팩토리 +├── router/ +│ ├── HandlerRouter.java # HTTP 라우터 +│ ├── Route.java # 라우트 정의 (Record) +│ └── AuthenticatedHandler.java # 인증 핸들러 인터페이스 +├── service/ +│ ├── PollyService.java # TTS + S3 캐시 +│ └── ComprehendService.java # NLP 분석 +├── util/ +│ ├── ResponseGenerator.java # HTTP 응답 빌더 +│ ├── CursorUtil.java # 커서 페이지네이션 +│ ├── CognitoUtil.java # Cognito 인증 추출 +│ ├── JwtUtil.java # JWT 직접 파싱 +│ ├── WebSocketBroadcaster.java # WebSocket 브로드캐스트 +│ ├── WebSocketEventUtil.java # WebSocket 이벤트 추출 +│ ├── WebSocketResponseUtil.java # WebSocket 응답 빌더 +│ └── S3PresignUtil.java # Presigned URL 생성 +└── validation/ + └── BeanValidator.java # Bean Validation 유틸 +``` + +--- + +## 18. 기술 스택 + +- **Runtime:** AWS Lambda (Java 21) +- **Build:** Gradle +- **AWS SDK:** AWS SDK for Java v2 +- **Validation:** Jakarta Bean Validation +- **JSON:** Gson +- **Pattern:** Singleton, Factory, Strategy, Router, Builder, Sealed Interface +- **Java 21 Features:** Records, Sealed Interface, Pattern Matching, Enhanced Switch diff --git a/docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md b/docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md new file mode 100644 index 00000000..5015a011 --- /dev/null +++ b/docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md @@ -0,0 +1,465 @@ +# Grammar Domain 세부 보고서 + +## 1. 개요 + +Grammar 도메인은 AWS Bedrock(Claude 3 Haiku)을 활용한 AI 기반 영어 문법 체크 시스템입니다. REST API와 WebSocket 스트리밍을 통해 실시간 문법 교정 및 대화형 학습을 +제공합니다. + +--- + +## 2. 전체 아키텍처 + +```mermaid +flowchart TB + subgraph Client["클라이언트"] + APP[Mobile/Web App] + end + + subgraph Gateway["API Gateway"] + REST[REST API] + WS[Grammar WebSocket] + end + + subgraph Lambda["Lambda Handlers"] + HANDLER[GrammarHandler] + CONNECT[StreamingConnectHandler] + DISCONNECT[StreamingDisconnectHandler] + STREAM[StreamingHandler] + end + + subgraph AI["AWS AI 서비스"] + BEDROCK[Bedrock
Claude 3 Haiku] + COMPREHEND[Comprehend
언어 분석] + end + + subgraph Storage["저장소"] + DDB[(DynamoDB)] + end + + APP --> REST + APP <--> WS + REST --> HANDLER + WS --> CONNECT + WS --> DISCONNECT + WS --> STREAM + HANDLER --> BEDROCK + HANDLER --> COMPREHEND + STREAM --> BEDROCK + HANDLER --> DDB + STREAM --> DDB +``` + +--- + +## 3. 문법 체크 흐름 + +### 3.1 동기식 문법 체크 + +```mermaid +sequenceDiagram + participant Client + participant Handler as GrammarHandler + participant Service as GrammarCheckService + participant Bedrock as AWS Bedrock + participant DB as DynamoDB + Client ->> Handler: POST /grammar/check + Handler ->> Service: checkGrammar(sentence, level) + Service ->> Bedrock: Claude API 호출 + Bedrock -->> Service: JSON 응답 + Service -->> Handler: GrammarCheckResponse + Handler -->> Client: 문법 교정 결과 +``` + +### 3.2 스트리밍 대화 + +```mermaid +sequenceDiagram + participant Client + participant WS as WebSocket + participant Handler as StreamingHandler + participant Service as ConversationService + participant Bedrock as AWS Bedrock + Client ->> WS: $connect?token={jwt} + WS -->> Client: 연결 성공 + Client ->> WS: 메시지 전송 + WS ->> Handler: $default 라우트 + Handler ->> Service: chatStreaming() + Service -->> Client: StartEvent (sessionId) + + loop 토큰 단위 스트리밍 + Bedrock -->> Service: 텍스트 청크 + Service -->> Client: TokenEvent + end + + Service -->> Client: CompleteEvent (전체 응답) +``` + +--- + +## 4. API 엔드포인트 + +### 4.1 REST API + +| Method | Endpoint | 설명 | +|--------|-------------------------------|---------------| +| POST | /grammar/check | 문법 체크 (단일 문장) | +| POST | /grammar/conversation | 대화형 문법 학습 | +| GET | /grammar/sessions | 대화 세션 목록 | +| GET | /grammar/sessions/{sessionId} | 세션 상세 | +| DELETE | /grammar/sessions/{sessionId} | 세션 삭제 | + +### 4.2 WebSocket API + +| Route | 설명 | +|-------------|-------------| +| $connect | JWT 토큰으로 연결 | +| $disconnect | 연결 해제 | +| $default | 스트리밍 메시지 처리 | + +--- + +## 5. 레벨별 문법 체크 + +### 5.1 학습 레벨 + +| 레벨 | 설명 | 피드백 스타일 | +|--------------|----|--------------------| +| BEGINNER | 초급 | 한국어 번역 + 쉬운 설명 | +| INTERMEDIATE | 중급 | 영어 위주 설명 | +| ADVANCED | 고급 | 상세한 문법 규칙 + 스타일 제안 | + +### 5.2 오류 유형 + +```mermaid +mindmap + root((문법 오류)) + 시제 + VERB_TENSE + 동사 시제 오류 + 일치 + SUBJECT_VERB_AGREEMENT + 주어-동사 일치 + 품사 + ARTICLE + 관사 오류 + PREPOSITION + 전치사 오류 + PRONOUN + 대명사 오류 + 구조 + WORD_ORDER + 어순 오류 + SENTENCE_STRUCTURE + 문장 구조 + 기타 + SPELLING + 철자 + PUNCTUATION + 구두점 + WORD_CHOICE + 어휘 선택 +``` + +--- + +## 6. 응답 포맷 + +### 6.1 문법 체크 응답 + +```json +{ + "originalSentence": "I goed to school yesterday", + "correctedSentence": "I went to school yesterday", + "score": 70, + "isCorrect": false, + "errors": [ + { + "type": "VERB_TENSE", + "original": "goed", + "corrected": "went", + "explanation": "'go'의 과거형은 'went'입니다 (불규칙 동사)", + "startIndex": 2, + "endIndex": 6 + } + ], + "feedback": "과거 시제를 잘 사용하려고 노력했네요! 불규칙 동사를 조금 더 연습해보세요." +} +``` + +### 6.2 대화 응답 + +```json +{ + "sessionId": "uuid", + "grammarCheck": { + /* 위와 동일 */ + }, + "aiResponse": "Great job! Your sentence structure is correct. Let's practice more complex sentences.", + "conversationTip": "Try using 'had gone' for past perfect tense." +} +``` + +### 6.3 스트리밍 이벤트 + +```json +// StartEvent +{ + "type": "start", + "sessionId": "uuid" +} + +// TokenEvent (실시간) +{ + "type": "token", + "token": "Great " +} +{ + "type": "token", + "token": "job!" +} + +// CompleteEvent (완료) +{ + "type": "complete", + "sessionId": "uuid", + "grammarCheck": { + ... + }, + "aiResponse": "...", + "conversationTip": "..." +} + +// ErrorEvent (오류 시) +{ + "type": "error", + "message": "..." +} +``` + +--- + +## 7. AWS Bedrock 통합 + +### 7.1 Claude 3 Haiku 설정 + +```java +public class BedrockGrammarCheckFactory { + private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; + private static final int MAX_TOKENS = 2048; + private static final String API_VERSION = "bedrock-2023-05-31"; +} +``` + +### 7.2 프롬프트 구조 + +**시스템 프롬프트 (초급):** + +``` +You are a friendly English grammar tutor for Korean speakers. +- Use simple English with Korean translations +- Be encouraging and supportive +- Explain grammar rules clearly +``` + +**사용자 프롬프트:** + +``` +Please check the grammar of this sentence: "{sentence}" + +Return JSON: +{ + "correctedSentence": "...", + "score": 0-100, + "isCorrect": boolean, + "errors": [...], + "feedback": "..." +} +``` + +### 7.3 스트리밍 응답 파싱 + +``` +[RESPONSE] +AI의 자연스러운 대화 응답 +[/RESPONSE] + +[GRAMMAR] +{ JSON 형식의 문법 체크 결과 } +[/GRAMMAR] + +[TIP] +학습 팁 +[/TIP] +``` + +--- + +## 8. 데이터 모델 + +### 8.1 GrammarSession + +```java + +@DynamoDbBean +public class GrammarSession { + String sessionId; + String userId; + String level; // BEGINNER, INTERMEDIATE, ADVANCED + String topic; // "Conversation Practice" + Integer messageCount; + String lastMessage; // 마지막 메시지 (100자 제한) + String createdAt; + String updatedAt; + Long ttl; // 30일 +} +``` + +**DynamoDB Keys:** + +- PK: `GSESSION#{userId}` | SK: `SESSION#{sessionId}` +- GSI1: `GSESSION#ALL` | `UPDATED#{timestamp}` (최신순 정렬) + +### 8.2 GrammarMessage + +```java + +@DynamoDbBean +public class GrammarMessage { + String messageId; + String sessionId; + String userId; + String role; // USER, ASSISTANT + String content; // 원본 메시지 + String correctedContent; // 교정된 메시지 (USER만) + String errorsJson; // 오류 목록 JSON + Integer grammarScore; + String feedback; + Boolean isCorrect; + Long ttl; // 30일 +} +``` + +**DynamoDB Keys:** + +- PK: `GSESSION#{userId}` | SK: `MSG#{timestamp}#{messageId}` +- GSI1: `GSESSION#{sessionId}` | `MSG#{timestamp}` + +### 8.3 GrammarConnection (WebSocket) + +```java + +@DynamoDbBean +public class GrammarConnection { + String connectionId; // API Gateway 연결 ID + String userId; // JWT에서 추출 + String connectedAt; + Long ttl; // 연결 타임아웃 +} +``` + +--- + +## 9. AWS Comprehend 분석 (선택적) + +```mermaid +flowchart LR + INPUT[입력 문장] --> SENTIMENT[감정 분석] + INPUT --> SYNTAX[구문 분석] + INPUT --> KEYPHRASE[핵심 구문] + INPUT --> LANGUAGE[언어 감지] + SENTIMENT --> OUTPUT[분석 결과] + SYNTAX --> OUTPUT + KEYPHRASE --> OUTPUT + LANGUAGE --> OUTPUT +``` + +**분석 항목:** + +- 감정: POSITIVE, NEGATIVE, NEUTRAL, MIXED +- 품사 태깅: NOUN, VERB, ADJ 등 +- 핵심 구문 추출 +- 문장 복잡도 추정 + +--- + +## 10. 서비스 레이어 + +### 10.1 서비스 구성 + +| Service | 역할 | +|----------------------------|----------------| +| GrammarCheckService | 단일 문장 문법 체크 | +| GrammarConversationService | 대화형 학습 + 스트리밍 | +| GrammarSessionQueryService | 세션 조회, 삭제 | +| BedrockGrammarCheckFactory | Bedrock API 호출 | + +### 10.2 대화 히스토리 관리 + +```java +// 최근 10개 메시지만 컨텍스트로 유지 +private static final int MAX_HISTORY_MESSAGES = 10; + +// 대화 히스토리 빌드 +String buildConversationHistory(String sessionId) { + // 최근 메시지 조회 + // USER: 내용 / ASSISTANT: 내용 형식으로 포맷 +} +``` + +--- + +## 11. 파일 구조 + +``` +domain/grammar/ +├── handler/ +│ ├── GrammarHandler.java +│ └── websocket/ +│ ├── GrammarStreamingConnectHandler.java +│ ├── GrammarStreamingDisconnectHandler.java +│ └── GrammarStreamingHandler.java +├── service/ +│ ├── GrammarCheckService.java +│ ├── GrammarConversationService.java +│ └── GrammarSessionQueryService.java +├── factory/ +│ ├── GrammarCheckFactory.java (interface) +│ └── BedrockGrammarCheckFactory.java +├── repository/ +│ ├── GrammarSessionRepository.java +│ └── GrammarConnectionRepository.java +├── model/ +│ ├── GrammarSession.java +│ ├── GrammarMessage.java +│ └── GrammarConnection.java +├── dto/ +│ ├── request/ +│ │ ├── GrammarCheckRequest.java +│ │ └── ConversationRequest.java +│ └── response/ +│ ├── GrammarCheckResponse.java +│ ├── ConversationResponse.java +│ ├── GrammarError.java +│ └── ComprehendAnalysis.java +├── streaming/ +│ ├── StreamingCallback.java +│ ├── StreamingEvent.java (sealed interface) +│ └── StreamingRequest.java +├── enums/ +│ ├── GrammarLevel.java +│ └── GrammarErrorType.java +└── constants/ + └── GrammarKey.java +``` + +--- + +## 12. 기술 스택 + +- **Runtime:** AWS Lambda (Java 21) +- **API:** API Gateway REST + WebSocket +- **AI:** AWS Bedrock (Claude 3 Haiku) +- **NLP:** AWS Comprehend (선택적) +- **Database:** DynamoDB +- **Auth:** JWT (Cognito) +- **Pattern:** Factory, Callback, Sealed Interface (Java 17+) diff --git a/docs/domain-reports/STATS-DOMAIN-REPORT.md b/docs/domain-reports/STATS-DOMAIN-REPORT.md new file mode 100644 index 00000000..3ca3d3ff --- /dev/null +++ b/docs/domain-reports/STATS-DOMAIN-REPORT.md @@ -0,0 +1,379 @@ +# Stats Domain 세부 보고서 + +## 1. 개요 + +Stats 도메인은 사용자의 학습 활동을 추적하고 통계를 집계하는 시스템입니다. DynamoDB Streams와 EventBridge를 활용한 이벤트 기반 아키텍처로 실시간 통계 업데이트를 제공합니다. + +--- + +## 2. 전체 아키텍처 + +```mermaid +flowchart TB + subgraph Triggers["트리거"] + TEST[테스트 완료] + DAILY[일일 학습] + GAME[게임 종료] + SCHEDULE[스케줄러
매일 자정] + end + + subgraph Processing["처리"] + STREAM[StatsStreamHandler
DynamoDB Streams] + SERVICE[StatsService
Write-through] + SCHEDULED[ScheduledStatsHandler
EventBridge] + end + + subgraph Storage["저장소"] + DDB[(DynamoDB
UserStats)] + end + + subgraph Query["조회"] + API[UserStatsHandler
REST API] + end + + TEST --> STREAM + DAILY --> SERVICE + GAME --> SERVICE + SCHEDULE --> SCHEDULED + STREAM --> DDB + SERVICE --> DDB + SCHEDULED --> DDB + DDB --> API +``` + +--- + +## 3. 통계 집계 방식 + +### 3.1 집계 레벨 + +```mermaid +flowchart LR + subgraph Levels["통계 집계 레벨"] + DAILY["일별
DAILY#2026-01-16"] + WEEKLY["주별
WEEKLY#2026-W03"] + MONTHLY["월별
MONTHLY#2026-01"] + TOTAL["전체
TOTAL"] + end + + EVENT[이벤트 발생] --> DAILY + EVENT --> WEEKLY + EVENT --> MONTHLY + EVENT --> TOTAL +``` + +### 3.2 Atomic Counter 패턴 + +```java +// 모든 레벨에 동시 업데이트 (원자적) +UpdateExpression: +SET correctAnswers = if_not_exists(correctAnswers, 0) + :correct, +incorrectAnswers = + +if_not_exists(incorrectAnswers, 0) +:incorrect, +testsCompleted = + +if_not_exists(testsCompleted, 0) +1, +updatedAt =:now +``` + +--- + +## 4. 이벤트 기반 통계 업데이트 + +### 4.1 DynamoDB Streams 처리 + +```mermaid +sequenceDiagram + participant Test as TestResult 저장 + participant Stream as DynamoDB Streams + participant Handler as StatsStreamHandler + participant DB as UserStats + Test ->> Stream: INSERT 이벤트 + Stream ->> Handler: 트리거 + Handler ->> Handler: PK/SK 패턴 확인
(TEST#userId, RESULT#timestamp) + Handler ->> DB: incrementTestStats() + Handler ->> DB: updateStudyStreak() + Handler ->> Handler: checkAndAwardBadges() +``` + +### 4.2 Write-through 패턴 + +```mermaid +sequenceDiagram + participant API as DailyStudyHandler + participant Service as StatsService + participant DB as UserStats + Note over API, DB: 단어 학습 완료 시 + API ->> Service: recordWordsLearned() + Service ->> DB: incrementWordsLearned()
(DAILY, WEEKLY, MONTHLY, TOTAL) + Service ->> DB: updateStudyStreak() +``` + +--- + +## 5. API 엔드포인트 + +### 5.1 통계 조회 API + +| Method | Endpoint | 설명 | 파라미터 | +|--------|----------------|---------|------------------| +| GET | /stats/daily | 일별 통계 | ?date=YYYY-MM-DD | +| GET | /stats/weekly | 주별 통계 | ?week=YYYY-Www | +| GET | /stats/monthly | 월별 통계 | ?month=YYYY-MM | +| GET | /stats/total | 전체 통계 | - | +| GET | /stats/history | 일별 히스토리 | ?cursor, ?limit | + +### 5.2 응답 예시 + +```json +{ + "periodType": "DAILY", + "period": "2026-01-16", + "testsCompleted": 3, + "questionsAnswered": 45, + "correctAnswers": 38, + "incorrectAnswers": 7, + "successRate": 84.44, + "newWordsLearned": 50, + "wordsReviewed": 5 +} +``` + +**전체 통계 추가 필드:** + +```json +{ + "currentStreak": 7, + "longestStreak": 14, + "lastStudyDate": "2026-01-16", + "gamesPlayed": 10, + "gamesWon": 3, + "totalGameScore": 450 +} +``` + +--- + +## 6. 연속 학습 (Streak) 시스템 + +### 6.1 스트릭 계산 로직 + +```mermaid +flowchart TB + START[학습 활동 발생] --> CHECK{lastStudyDate
확인} + CHECK -->|null| NEW["currentStreak = 1
longestStreak = 1"] + CHECK -->|오늘| SAME[변경 없음
이미 오늘 학습] + CHECK -->|어제| INCREMENT["currentStreak++
longestStreak = max()"] + CHECK -->|2일+ 전| RESET["currentStreak = 1
longestStreak 유지"] + NEW --> UPDATE[DB 업데이트] + INCREMENT --> UPDATE + RESET --> UPDATE +``` + +### 6.2 스트릭 리셋 (스케줄러) + +```java +// EventBridge: 매일 자정 실행 +@Scheduled +public void resetStreaks() { + String yesterday = LocalDate.now().minusDays(1).toString(); + // lastStudyDate != yesterday인 사용자의 스트릭 리셋 + // 비용 최적화로 클라이언트 측 계산 권장 +} +``` + +--- + +## 7. 데이터 모델 + +### 7.1 UserStats + +```java + +@DynamoDbBean +public class UserStats { + // 키 + String pk; // USER#{userId}#STATS + String sk; // DAILY#{date} | WEEKLY#{week} | MONTHLY#{month} | TOTAL + + // 메타데이터 + String userId; + String periodType; // DAILY, WEEKLY, MONTHLY, TOTAL + String period; // 2026-01-16, 2026-W03, 2026-01, TOTAL + + // 테스트 통계 + Integer testsCompleted; + Integer questionsAnswered; + Integer correctAnswers; + Integer incorrectAnswers; + Double successRate; + + // 학습 통계 + Integer newWordsLearned; + Integer wordsReviewed; + Integer wordsMastered; + + // 스트릭 (TOTAL만) + Integer currentStreak; + Integer longestStreak; + String lastStudyDate; + + // 게임 통계 (TOTAL만) + Integer gamesPlayed; + Integer gamesWon; + Integer correctGuesses; + Integer totalGameScore; + Integer quickGuesses; // 5초 이내 정답 + Integer perfectDraws; // 전원 정답 유도 + + // 타임스탬프 + String createdAt; + String updatedAt; +} +``` + +### 7.2 DynamoDB 키 구조 + +| 필드 | 패턴 | 예시 | +|---------|------------------------|-------------------| +| PK | USER#{userId}#STATS | USER#abc123#STATS | +| SK (일별) | DAILY#{date} | DAILY#2026-01-16 | +| SK (주별) | WEEKLY#{year}-W{week} | WEEKLY#2026-W03 | +| SK (월별) | MONTHLY#{year}-{month} | MONTHLY#2026-01 | +| SK (전체) | TOTAL | TOTAL | + +--- + +## 8. 통계 메트릭 + +### 8.1 테스트 메트릭 + +| 메트릭 | 설명 | 업데이트 시점 | +|-------------------|----------|---------| +| testsCompleted | 완료 테스트 수 | 테스트 제출 | +| questionsAnswered | 총 문제 수 | 테스트 제출 | +| correctAnswers | 정답 수 | 테스트 제출 | +| incorrectAnswers | 오답 수 | 테스트 제출 | +| successRate | 정답률 (%) | 조회 시 계산 | + +### 8.2 학습 메트릭 + +| 메트릭 | 설명 | 업데이트 시점 | +|-----------------|----------|---------| +| newWordsLearned | 신규 학습 단어 | 일일학습 완료 | +| wordsReviewed | 복습 단어 | 일일학습 완료 | +| wordsMastered | 마스터 단어 | 상태 변경 시 | + +### 8.3 게임 메트릭 + +| 메트릭 | 설명 | 업데이트 시점 | +|----------------|----------|---------| +| gamesPlayed | 참여 게임 수 | 게임 종료 | +| gamesWon | 1등 횟수 | 게임 종료 | +| correctGuesses | 정답 횟수 | 게임 종료 | +| totalGameScore | 누적 점수 | 게임 종료 | +| quickGuesses | 5초 내 정답 | 게임 종료 | +| perfectDraws | 전원 정답 유도 | 게임 종료 | + +--- + +## 9. 히스토리 조회 + +### 9.1 페이지네이션 + +```mermaid +flowchart LR + REQUEST["GET /stats/history
?limit=7&cursor=..."] + QUERY["Query
PK = USER#id#STATS
SK begins_with DAILY#
scanIndexForward = false"] + ENRICH["DailyStudy 조회
isCompleted 추가"] + RESPONSE["PaginatedResult
items, nextCursor, hasMore"] + REQUEST --> QUERY --> ENRICH --> RESPONSE +``` + +### 9.2 응답 구조 + +```json +{ + "history": [ + { + "period": "2026-01-16", + "testsCompleted": 2, + "questionsAnswered": 30, + "correctAnswers": 25, + "incorrectAnswers": 5, + "successRate": 83.33, + "newWordsLearned": 50, + "wordsReviewed": 5, + "isCompleted": true + } + ], + "nextCursor": "base64encoded...", + "hasMore": true +} +``` + +--- + +## 10. 배지 연동 + +### 10.1 자동 배지 체크 + +```mermaid +flowchart TB + STREAM[StatsStreamHandler] --> CHECK[배지 조건 체크] + CHECK --> PERFECT{만점 테스트?} + PERFECT -->|Yes| BADGE1[PERFECT_SCORE 배지] + CHECK --> STATS[전체 통계 조회] + STATS --> BADGESERVICE[BadgeService.checkAndAwardBadges] + BADGESERVICE --> AWARD[조건 충족 배지 부여] +``` + +### 10.2 배지 조건 예시 + +| 배지 | 조건 | 통계 필드 | +|--------------|----------|----------------------| +| STREAK_7 | 7일 연속 학습 | currentStreak >= 7 | +| ACCURACY_90 | 정확도 90% | successRate >= 90 | +| TEST_10 | 10회 테스트 | testsCompleted >= 10 | +| GAME_10_WINS | 10번 1등 | gamesWon >= 10 | + +--- + +## 11. 파일 구조 + +``` +domain/stats/ +├── handler/ +│ ├── UserStatsHandler.java (REST API) +│ ├── StatsStreamHandler.java (DynamoDB Streams) +│ └── ScheduledStatsHandler.java (EventBridge) +├── service/ +│ └── StatsService.java +├── repository/ +│ └── UserStatsRepository.java +├── model/ +│ └── UserStats.java +└── constants/ + └── StatsKey.java +``` + +--- + +## 12. 성능 최적화 + +| 최적화 | 기법 | 효과 | +|--------------------------|------------------|-------------------| +| 원자적 업데이트 | UpdateExpression | Race condition 방지 | +| 비동기 처리 | DynamoDB Streams | API 응답 속도 향상 | +| Cursor 페이지네이션 | lastEvaluatedKey | 대용량 히스토리 처리 | +| Strongly Consistent Read | 히스토리 조회 | 데이터 정합성 | + +--- + +## 13. 기술 스택 + +- **Runtime:** AWS Lambda (Java 21) +- **Database:** DynamoDB (Single Table Design) +- **Event:** DynamoDB Streams, EventBridge +- **Pattern:** Atomic Counter, Write-through, Event-driven diff --git a/docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md b/docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md new file mode 100644 index 00000000..7ee2c90e --- /dev/null +++ b/docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md @@ -0,0 +1,504 @@ +# Vocabulary Domain 세부 보고서 + +## 1. 개요 + +Vocabulary 도메인은 AWS Lambda와 DynamoDB를 기반으로 한 영어 단어 학습 시스템입니다. SM-2 Spaced Repetition 알고리즘과 CQRS 패턴을 적용하여 과학적이고 효율적인 단어 +암기를 지원합니다. + +--- + +## 2. 전체 아키텍처 + +```mermaid +flowchart TB + subgraph Client["클라이언트"] + APP[Mobile/Web App] + end + + subgraph Gateway["API Gateway"] + REST[REST API
HTTP] + end + + subgraph Lambda["Lambda Handlers"] + direction TB + WORD[WordHandler] + USERWORD[UserWordHandler] + DAILY[DailyStudyHandler] + TEST[TestHandler] + GROUP[WordGroupHandler] + VOICE[VoiceHandler] + STATS[StatisticsHandler
SQS Consumer] + end + + subgraph Services["서비스 레이어 (CQRS)"] + direction TB + CMD[Command Services
쓰기 작업] + QUERY[Query Services
읽기 작업] + end + + subgraph External["외부 서비스"] + POLLY[AWS Polly
TTS] + SNS[AWS SNS] + SQS[AWS SQS] + S3[(S3
음성 캐시)] + end + + subgraph Storage["데이터 저장소"] + DDB[(DynamoDB)] + end + + APP --> REST + REST --> WORD & USERWORD & DAILY & TEST & GROUP & VOICE + WORD & USERWORD & DAILY & TEST & GROUP --> CMD & QUERY + CMD & QUERY --> DDB + VOICE --> POLLY --> S3 + TEST --> SNS --> SQS --> STATS + STATS --> DDB +``` + +--- + +## 3. 일일 학습 시스템 + +### 3.1 일일 학습 흐름 + +```mermaid +flowchart TB + subgraph DailyStudyFlow["일일 학습 흐름"] + START[GET /vocab/daily] --> CHECK{기존 학습
존재?} + CHECK -->|Yes| RETURN[기존 학습 반환] + CHECK -->|No| CREATE[새 학습 생성] + CREATE --> REVIEW["복습 단어 5개 선정
(nextReviewAt <= today)"] + REVIEW --> NEW["신규 단어 50개 선정
(미학습 + 해당 레벨)"] + NEW --> SAVE[DailyStudy 저장] + SAVE --> RETURN + RETURN --> LEARN[학습 진행] + LEARN --> MARK["POST .../learned
단어별 학습 완료"] + MARK --> PROGRESS{50개 완료?} + PROGRESS -->|No| LEARN + PROGRESS -->|Yes| COMPLETE["isCompleted = true
배지 체크"] + end +``` + +### 3.2 Daily Study API + +| Method | Endpoint | 설명 | +|--------|-------------------------------------|-----------------| +| GET | /vocab/daily | 오늘의 학습 단어 조회/생성 | +| POST | /vocab/daily/words/{wordId}/learned | 단어 학습 완료 처리 | + +### 3.3 응답 예시 + +```json +{ + "userId": "user123", + "date": "2026-01-16", + "newWordIds": [ + "word1", + "word2", + ... + ], + "reviewWordIds": [ + "word51", + "word52", + ... + ], + "learnedWordIds": [], + "totalWords": 55, + "learnedCount": 0, + "isCompleted": false, + "progress": { + "percentage": 0, + "learned": 0, + "total": 55 + } +} +``` + +--- + +## 4. SM-2 Spaced Repetition 알고리즘 + +### 4.1 학습 상태 전이 + +```mermaid +stateDiagram-v2 + [*] --> NEW: 단어 추가 + NEW --> LEARNING: 첫 학습 + LEARNING --> LEARNING: 오답 + LEARNING --> REVIEWING: 2회 연속 정답 + REVIEWING --> LEARNING: 오답 + REVIEWING --> MASTERED: 5회 연속 정답 + MASTERED --> LEARNING: 오답 + MASTERED --> MASTERED: 정답 유지 +``` + +### 4.2 상태별 로직 + +| 상태 | 조건 | 정답 시 | 오답 시 | +|---------------|-------------|-----------------------------|---------------------------| +| **NEW** | 신규 단어 | LEARNING, rep=1, interval=1 | LEARNING, easeFactor-=0.2 | +| **LEARNING** | rep < 2 | rep++, interval 계산 | rep=0, interval=1 | +| **REVIEWING** | 2 ≤ rep < 5 | rep++, interval 증가 | rep=0, LEARNING | +| **MASTERED** | rep ≥ 5 | interval 증가, 유지 | rep=0, REVIEWING | + +### 4.3 복습 간격 계산 + +```mermaid +flowchart LR + REP1["rep = 1
interval = 1일"] + REP2["rep = 2
interval = 6일"] + REP3["rep >= 3
interval = interval × easeFactor"] + REP1 --> REP2 --> REP3 +``` + +**핵심 변수:** + +- `repetitions`: 연속 정답 횟수 (0~∞) +- `interval`: 복습 간격 (일 단위) +- `easeFactor`: 난이도 계수 (1.3~2.5, 기본 2.5) +- `nextReviewAt`: 다음 복습 예정일 + +--- + +## 5. 테스트 시스템 + +### 5.1 테스트 흐름 + +```mermaid +sequenceDiagram + participant Client + participant Handler as TestHandler + participant Service as TestCommandService + participant DB as DynamoDB + participant SNS as AWS SNS + Client ->> Handler: POST /vocab/tests/start + Handler ->> Service: startTest(userId, testType) + Service ->> DB: 오늘의 학습 단어 조회 + Service ->> Service: 4지선다 문제 생성 + Service -->> Client: 문제 목록 반환 + Note over Client: 사용자 답변 입력 + Client ->> Handler: POST /vocab/tests/submit + Handler ->> Service: submitTest(answers) + Service ->> DB: 결과 저장 + Service ->> SNS: 결과 발행 (비동기) + Service -->> Client: 테스트 결과 + Note over SNS, DB: 비동기 통계 처리 + SNS ->> DB: 통계 업데이트 +``` + +### 5.2 문제 생성 알고리즘 + +```mermaid +flowchart TB + START[문제 생성 시작] --> WORDS[일일 학습 단어 로드] + WORDS --> GROUP[레벨별 그룹화] + GROUP --> LOOP[각 단어마다] + LOOP --> CORRECT["정답 = 해당 단어의
한국어 뜻"] + CORRECT --> DIST["오답 3개 선정
(동일 레벨 단어)"] + DIST --> SHUFFLE[4개 보기 셔플] + SHUFFLE --> NEXT{다음 단어?} + NEXT -->|Yes| LOOP + NEXT -->|No| RETURN[문제 목록 반환] +``` + +### 5.3 Test API + +| Method | Endpoint | 설명 | +|--------|-------------------------------|------------| +| POST | /vocab/tests/start | 테스트 시작 | +| POST | /vocab/tests/submit | 테스트 제출 | +| GET | /vocab/tests/results | 테스트 결과 목록 | +| GET | /vocab/tests/results/{testId} | 테스트 상세 결과 | +| GET | /vocab/tests/tested-words | 최근 테스트된 단어 | + +--- + +## 6. 단어 관리 시스템 + +### 6.1 Word API + +| Method | Endpoint | 설명 | +|--------|------------------------|----------------------------| +| GET | /vocab/words | 단어 목록 (level, category 필터) | +| POST | /vocab/words | 단어 등록 | +| GET | /vocab/words/{wordId} | 단어 상세 | +| PUT | /vocab/words/{wordId} | 단어 수정 | +| DELETE | /vocab/words/{wordId} | 단어 삭제 | +| GET | /vocab/words/search | 키워드 검색 | +| POST | /vocab/words/batch | 배치 등록 (최대 100개) | +| POST | /vocab/words/batch/get | 배치 조회 | + +### 6.2 User Word API + +| Method | Endpoint | 설명 | +|--------|-----------------------------------|-------------| +| GET | /vocab/user-words | 사용자 단어 목록 | +| GET | /vocab/user-words/{wordId} | 사용자 단어 상세 | +| PUT | /vocab/user-words/{wordId} | 정답/오답 기록 | +| PATCH | /vocab/user-words/{wordId}/tag | 북마크, 난이도 설정 | +| PATCH | /vocab/user-words/{wordId}/status | 상태 수동 변경 | +| GET | /vocab/wrong-answers | 오답 단어 목록 | + +### 6.3 Word Group API + +| Method | Endpoint | 설명 | +|--------|----------------------------------------|--------| +| POST | /vocab/groups | 단어장 생성 | +| GET | /vocab/groups | 단어장 목록 | +| GET | /vocab/groups/{groupId} | 단어장 상세 | +| PUT | /vocab/groups/{groupId} | 단어장 수정 | +| DELETE | /vocab/groups/{groupId} | 단어장 삭제 | +| POST | /vocab/groups/{groupId}/words/{wordId} | 단어 추가 | +| DELETE | /vocab/groups/{groupId}/words/{wordId} | 단어 제거 | + +--- + +## 7. TTS 음성 합성 + +### 7.1 음성 생성 흐름 + +```mermaid +flowchart TB + REQUEST["POST /vocab/synthesize
{wordId, voice, type}"] + CHECK{S3 캐시
존재?} + REQUEST --> CHECK + CHECK -->|Yes| PRESIGN[Presigned URL 생성] + CHECK -->|No| POLLY[AWS Polly 호출] + POLLY --> SAVE[S3 저장] + SAVE --> PRESIGN + PRESIGN --> RESPONSE[URL 반환] +``` + +### 7.2 Voice API + +```json +// Request +{ + "wordId": "uuid", + "voice": "MALE", + // MALE | FEMALE + "type": "WORD" + // WORD | EXAMPLE +} + +// Response +{ + "url": "https://s3...presigned-url", + "expiresIn": 3600 +} +``` + +--- + +## 8. 데이터 모델 + +### 8.1 Word + +```java + +@DynamoDbBean +public class Word { + String wordId; // UUID + String english; // 영어 단어 + String korean; // 한국어 뜻 + String example; // 예문 + String level; // BEGINNER | INTERMEDIATE | ADVANCED + String category; // DAILY | BUSINESS | ACADEMIC | TRAVEL | TECHNOLOGY + String maleVoiceKey; // S3 음성 키 + String femaleVoiceKey; + String maleExampleVoiceKey; + String femaleExampleVoiceKey; +} +``` + +**DynamoDB Keys:** + +| Key | 패턴 | 용도 | +|--------|---------------------|----------| +| PK | WORD#{wordId} | 기본 조회 | +| SK | METADATA | - | +| GSI1PK | LEVEL#{level} | 레벨별 조회 | +| GSI2PK | CATEGORY#{category} | 카테고리별 조회 | + +### 8.2 UserWord + +```java + +@DynamoDbBean +public class UserWord { + String userId; + String wordId; + String status; // NEW | LEARNING | REVIEWING | MASTERED + + // SM-2 알고리즘 필드 + Integer interval; // 복습 간격 (일) + Double easeFactor; // 난이도 계수 (1.3~2.5) + Integer repetitions; // 연속 정답 횟수 + String nextReviewAt; // 다음 복습일 (YYYY-MM-DD) + + // 통계 + Integer correctCount; // 누적 정답 + Integer incorrectCount; // 누적 오답 + + // 사용자 설정 + Boolean bookmarked; // 북마크 + Boolean favorite; // 즐겨찾기 + String difficulty; // EASY | NORMAL | HARD +} +``` + +**DynamoDB Keys:** + +| Key | 패턴 | 용도 | +|--------|--------------------------|--------------| +| PK | USER#{userId} | 기본 조회 | +| SK | WORD#{wordId} | - | +| GSI1PK | USER#{userId}#REVIEW | 복습 예정 단어 | +| GSI1SK | DATE#{nextReviewAt} | - | +| GSI2PK | USER#{userId}#STATUS | 상태별 조회 | +| GSI2SK | STATUS#{status} | - | +| GSI3PK | USER#{userId}#BOOKMARKED | 북마크 (Sparse) | + +### 8.3 DailyStudy + +```java + +@DynamoDbBean +public class DailyStudy { + String userId; + String date; // YYYY-MM-DD + List newWordIds; // 신규 단어 50개 + List reviewWordIds; // 복습 단어 5개 + List learnedWordIds; // 학습 완료 단어 + Integer totalWords; // 총 단어 수 (55) + Integer learnedCount; // 학습 완료 수 + Boolean isCompleted; // 완료 여부 +} +``` + +### 8.4 TestResult + +```java + +@DynamoDbBean +public class TestResult { + String testId; + String userId; + String testType; // DAILY | WEEKLY | CUSTOM + Integer totalQuestions; + Integer correctAnswers; + Integer incorrectAnswers; + Double successRate; + List testedWordIds; + List incorrectWordIds; + String startedAt; + String completedAt; +} +``` + +--- + +## 9. 서비스 아키텍처 (CQRS) + +### 9.1 Command Services (쓰기) + +```mermaid +flowchart TB + subgraph Commands["Command Services"] + WC[WordCommandService
단어 생성/수정/삭제] + UC[UserWordCommandService
학습 상태 업데이트] + DC[DailyStudyCommandService
일일 학습 관리] + TC[TestCommandService
테스트 생성/제출] + GC[WordGroupCommandService
단어장 관리] + end +``` + +### 9.2 Query Services (읽기) + +```mermaid +flowchart TB + subgraph Queries["Query Services"] + WQ[WordQueryService
단어 조회/검색] + UQ[UserWordQueryService
학습 현황 조회] + DQ[DailyStudyQueryService
일일 학습 조회] + TQ[TestQueryService
테스트 결과 조회] + end +``` + +--- + +## 10. 성능 최적화 + +| 최적화 | 기법 | 효과 | +|---------------------|------------------------|-----------------| +| N+1 방지 | BatchGetItem (100개 단위) | DB 호출 90% 감소 | +| TTS 캐싱 | S3 + Presigned URL | Polly 호출 90% 절감 | +| 페이지네이션 | Cursor 기반 (Base64) | 대용량 데이터 처리 | +| Sparse Index | GSI3 (북마크 전용) | 인덱스 크기 최소화 | +| 비동기 통계 | SNS/SQS | API 응답 속도 향상 | +| Strongly Consistent | DailyStudy 조회 | 데이터 정합성 | + +--- + +## 11. 파일 구조 + +``` +domain/vocabulary/ +├── handler/ +│ ├── WordHandler.java +│ ├── UserWordHandler.java +│ ├── DailyStudyHandler.java +│ ├── TestHandler.java +│ ├── WordGroupHandler.java +│ ├── VoiceHandler.java +│ ├── StatsHandler.java +│ └── StatisticsHandler.java (SQS) +├── service/ +│ ├── WordCommandService.java +│ ├── WordQueryService.java +│ ├── UserWordCommandService.java +│ ├── UserWordQueryService.java +│ ├── TestCommandService.java +│ ├── TestQueryService.java +│ ├── DailyStudyCommandService.java +│ ├── DailyStudyQueryService.java +│ ├── WordGroupCommandService.java +│ ├── StatsService.java +│ └── StatisticsService.java +├── repository/ +│ ├── WordRepository.java +│ ├── UserWordRepository.java +│ ├── DailyStudyRepository.java +│ ├── TestResultRepository.java +│ └── WordGroupRepository.java +├── model/ +│ ├── Word.java +│ ├── UserWord.java +│ ├── DailyStudy.java +│ ├── TestResult.java +│ └── WordGroup.java +├── state/ +│ ├── WordState.java (interface) +│ ├── NewState.java +│ ├── LearningState.java +│ ├── ReviewingState.java +│ ├── MasteredState.java +│ ├── SpacedRepetitionContext.java +│ └── WordStateFactory.java +└── enums/ + ├── WordStatus.java + ├── WordCategory.java + └── TestType.java +``` + +--- + +## 12. 기술 스택 + +- **Runtime:** AWS Lambda (Java 21) +- **Database:** DynamoDB (Single Table Design) +- **TTS:** AWS Polly (남성/여성 음성) +- **Storage:** S3 (음성 캐시) +- **Messaging:** SNS/SQS (비동기 통계) +- **Pattern:** CQRS, State, Repository, Factory diff --git a/docs/troubleshooting/NEWS-API-TROUBLESHOOTING.md b/docs/troubleshooting/NEWS-API-TROUBLESHOOTING.md deleted file mode 100644 index 4b6e3ba7..00000000 --- a/docs/troubleshooting/NEWS-API-TROUBLESHOOTING.md +++ /dev/null @@ -1,226 +0,0 @@ -# News API 트러블슈팅 가이드 - -## 개요 -2026-01-23 뉴스 기능 프론트엔드 연동 과정에서 발생한 이슈들과 해결 방법을 정리합니다. - ---- - -## 1. GET /news/{articleId} 응답이 기사가 아닌 읽기 기록 반환 - -### 증상 -```javascript -// 예상 응답 -{ articleId: "e644d491", title: "...", summary: "...", ... } - -// 실제 응답 -{ pk: "USER#64983d3c-...#NEWS", sk: "READ#e644d491", articleId: "e644d491" } -``` - -### 원인 -`NewsArticleRepository.findById()`가 테이블 전체를 스캔하면서 `articleId`만 필터링했습니다. -뉴스 테이블에는 기사(`ARTICLE#`)와 사용자 기록(`READ#`, `BOOKMARK#`)이 함께 저장되어 있어서, -`UserNewsRecord`가 먼저 매칭되어 반환되었습니다. - -### 해결 -`findById`에서 SK가 `ARTICLE#`로 시작하는 것만 필터링하도록 수정: - -```java -// Before -Expression filterExpression = Expression.builder() - .expression("articleId = :articleId") - .putExpressionValue(":articleId", AttributeValue.builder().s(articleId).build()) - .build(); - -// After -Expression filterExpression = Expression.builder() - .expression("articleId = :articleId AND begins_with(SK, :skPrefix)") - .putExpressionValue(":articleId", AttributeValue.builder().s(articleId).build()) - .putExpressionValue(":skPrefix", AttributeValue.builder().s("ARTICLE#").build()) - .build(); -``` - -### 파일 -- `NewsArticleRepository.java` - `findById()` 메서드 - ---- - -## 2. 기사에 category 필드 누락 - -### 증상 -```json -{ - "articleId": "2b4e42f9", - "title": "...", - "category": null // 누락 -} -``` - -### 원인 -`NewsAnalysisService`에서 Bedrock AI 분석 시 category 분류 로직이 없었습니다. - -### 해결 -1. Bedrock 프롬프트에 category 분류 요청 추가 -2. `AnalysisResult` 레코드에 category 필드 추가 -3. 파싱 및 저장 로직 추가 - -```java -// 프롬프트에 추가 -"category": "WORLD", -... -For category, choose EXACTLY ONE from: WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE -``` - -### 파일 -- `NewsAnalysisService.java` - `generateSummaryAndQuiz()`, `parseAnalysisResult()`, `AnalysisResult` 레코드 - -### 주의 -기존 기사에는 category가 없으므로, **기사 삭제 후 재수집** 필요 - ---- - -## 3. /stats/dashboard CORS 에러 - -### 증상 -``` -Access to fetch at '.../stats/dashboard' has been blocked by CORS policy: -Response to preflight request doesn't pass access control check -``` - -### 원인 -새로 추가한 `/stats/dashboard` 엔드포인트가 `template.yaml`에 정의되지 않았습니다. - -### 해결 -`template.yaml`의 `UserStatsFunction` Events에 엔드포인트 추가: - -```yaml -Events: - GetDashboardStats: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /stats/dashboard - Method: GET - Auth: - Authorizer: CognitoAuthorizer -``` - -### 파일 -- `template.yaml` - UserStatsFunction Events - ---- - -## 4. 북마크 API가 기사 정보 없이 반환 - -### 증상 -```json -// GET /news/bookmarks 응답 -{ - "bookmarks": [ - { "pk": "USER#...", "sk": "BOOKMARK#...", "articleId": "..." } - ] -} -``` - -### 원인 -`NewsLearningService.getUserBookmarks()`가 북마크 레코드만 반환하고 기사 정보를 조회하지 않았습니다. - -### 해결 -북마크 레코드에서 articleId로 기사 정보를 조회하여 함께 반환: - -```java -public List> getUserBookmarks(String userId, int limit) { - List bookmarks = userNewsRepository.getUserBookmarks(userId, limit); - List> result = new ArrayList<>(); - - for (UserNewsRecord bookmark : bookmarks) { - Optional articleOpt = articleRepository.findById(bookmark.getArticleId()); - if (articleOpt.isPresent()) { - NewsArticle article = articleOpt.get(); - Map bookmarkWithArticle = new HashMap<>(); - bookmarkWithArticle.put("articleId", article.getArticleId()); - bookmarkWithArticle.put("title", article.getTitle()); - bookmarkWithArticle.put("summary", article.getSummary()); - // ... 기타 필드 - result.add(bookmarkWithArticle); - } - } - return result; -} -``` - -### 파일 -- `NewsLearningService.java` - `getUserBookmarks()` -- `NewsHandler.java` - `getBookmarks()` - ---- - -## 5. POST /news/{articleId}/words 500 에러 - -### 증상 -``` -java.lang.NullPointerException: Cannot invoke "JsonElement.getAsString()" -because the return value of "JsonObject.get(String)" is null -at NewsHandler.collectWord(NewsHandler.java:416) -``` - -### 원인 -요청 body에 `word` 필드가 없거나 null일 때 검증 없이 바로 접근했습니다. - -### 해결 -null 체크 추가 및 `INVALID_REQUEST` 에러 코드 정의: - -```java -JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); -if (body == null || !body.has("word") || body.get("word").isJsonNull()) { - return ResponseGenerator.fail(NewsErrorCode.INVALID_REQUEST); -} -``` - -### 파일 -- `NewsHandler.java` - `collectWord()` -- `NewsErrorCode.java` - `INVALID_REQUEST` 추가 - ---- - -## 6. DAILY 통계에 뉴스 관련 필드 누락 - -### 증상 -- TOTAL 통계: `newsRead: 5` ✅ -- DAILY 통계: `newsRead` 필드 없음 ❌ - -### 원인 -`incrementNewsReadStats()` 등의 메서드가 TOTAL 통계만 업데이트하고 DAILY 통계는 업데이트하지 않았습니다. - -### 해결 -각 뉴스 통계 업데이트 메서드에서 DAILY 통계도 함께 업데이트: - -```java -// TOTAL 업데이트 후 DAILY도 업데이트 -Map dailyKey = new HashMap<>(); -dailyKey.put("PK", AttributeValue.builder().s(pk).build()); -dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); -// ... DAILY 업데이트 로직 -``` - -### 파일 -- `UserStatsRepository.java` - `incrementNewsReadStats()`, `incrementNewsQuizStats()`, `incrementNewsWordStats()` - ---- - -## 체크리스트 - -새로운 API 엔드포인트 추가 시: -- [ ] Handler에 라우트 추가 -- [ ] `template.yaml`에 Events 추가 -- [ ] CORS 설정 확인 - -DynamoDB 단일 테이블 설계 주의: -- [ ] 쿼리 시 PK/SK 패턴 명확히 구분 -- [ ] Scan 사용 시 적절한 필터 표현식 사용 - -통계 업데이트 시: -- [ ] TOTAL과 DAILY 모두 업데이트 - -API 요청 처리 시: -- [ ] 요청 body null 체크 -- [ ] 필수 필드 존재 여부 검증 From 9a3fe6f6d0583551cc7d6a42645f2054d6ce7df7 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 12:34:19 +0900 Subject: [PATCH 88/99] feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords --- .../domain/news/handler/NewsHandler.java | 59 +++++++++++++++++-- .../domain/news/model/KeywordInfo.java | 1 + .../news/repository/UserNewsRepository.java | 13 ++++ .../news/service/NewsAnalysisService.java | 13 ++-- .../news/service/NewsLearningService.java | 23 +++++++- 5 files changed, 99 insertions(+), 10 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java index 86a30590..30d0d3e7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -111,7 +111,7 @@ private APIGatewayProxyResponseEvent getNewsList(APIGatewayProxyRequestEvent req result = queryService.getTodayNews(limit, cursor); } - return buildPaginatedResponse(result); + return buildPaginatedResponse(result, getUserId(request)); } /** @@ -126,7 +126,7 @@ private APIGatewayProxyResponseEvent getTodayNews(APIGatewayProxyRequestEvent re int limit = parseLimit(params.get("limit")); PaginatedResult result = queryService.getTodayNews(limit, cursor); - return buildPaginatedResponse(result); + return buildPaginatedResponse(result, getUserId(request)); } /** @@ -143,7 +143,7 @@ private APIGatewayProxyResponseEvent getRecommendedNews(APIGatewayProxyRequestEv int limit = parseLimit(params.get("limit")); PaginatedResult result = queryService.getRecommendedNews(userLevel, limit, cursor); - return buildPaginatedResponse(result); + return buildPaginatedResponse(result, getUserId(request)); } /** @@ -158,15 +158,64 @@ private APIGatewayProxyResponseEvent getNewsDetail(APIGatewayProxyRequestEvent r return ResponseGenerator.fail(NewsErrorCode.ARTICLE_NOT_FOUND); } - return ResponseGenerator.ok("뉴스 조회 성공", article.get()); + // 로그인한 사용자의 경우 북마크/읽기 상태 추가 + String userId = getUserId(request); + Map response = new HashMap<>(); + response.put("article", article.get()); + + if (userId != null) { + response.put("isBookmarked", learningService.isBookmarked(userId, articleId)); + response.put("isRead", learningService.hasRead(userId, articleId)); + } else { + response.put("isBookmarked", false); + response.put("isRead", false); + } + + return ResponseGenerator.ok("뉴스 조회 성공", response); } /** * 페이지네이션 응답 생성 */ private APIGatewayProxyResponseEvent buildPaginatedResponse(PaginatedResult result) { + return buildPaginatedResponse(result, null); + } + + /** + * 페이지네이션 응답 생성 (북마크 상태 포함) + */ + private APIGatewayProxyResponseEvent buildPaginatedResponse(PaginatedResult result, String userId) { + List> articlesWithStatus = new java.util.ArrayList<>(); + java.util.Set bookmarkedIds = java.util.Collections.emptySet(); + + // 로그인한 사용자의 경우 북마크 상태 조회 + if (userId != null && !result.items().isEmpty()) { + List articleIds = result.items().stream() + .map(NewsArticle::getArticleId) + .toList(); + bookmarkedIds = learningService.getBookmarkedArticleIds(userId, articleIds); + } + + for (NewsArticle article : result.items()) { + Map articleWithStatus = new HashMap<>(); + articleWithStatus.put("articleId", article.getArticleId()); + articleWithStatus.put("title", article.getTitle()); + articleWithStatus.put("summary", article.getSummary()); + articleWithStatus.put("source", article.getSource()); + articleWithStatus.put("publishedAt", article.getPublishedAt()); + articleWithStatus.put("keywords", article.getKeywords()); + articleWithStatus.put("highlightWords", article.getHighlightWords()); + articleWithStatus.put("category", article.getCategory()); + articleWithStatus.put("level", article.getLevel()); + articleWithStatus.put("cefrLevel", article.getCefrLevel()); + articleWithStatus.put("imageUrl", article.getImageUrl()); + articleWithStatus.put("readCount", article.getReadCount()); + articleWithStatus.put("isBookmarked", bookmarkedIds.contains(article.getArticleId())); + articlesWithStatus.add(articleWithStatus); + } + Map response = new HashMap<>(); - response.put("articles", result.items()); + response.put("articles", articlesWithStatus); response.put("nextCursor", result.nextCursor()); response.put("hasMore", result.hasMore()); response.put("count", result.items().size()); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java index cd5b4b44..c1e00f56 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java @@ -19,6 +19,7 @@ public class KeywordInfo { private String word; // 영어 단어 private String meaning; // 영어 뜻 (간단한 정의) + private String meaningKo; // 한국어 뜻 private String example; // 기사에서 발췌한 예문 private String level; // 단어 난이도 (BEGINNER, INTERMEDIATE, ADVANCED) private Integer position; // 기사 내 위치 (문장 번호 또는 단어 인덱스) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java index 25f8e651..a5fa2b67 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java @@ -145,6 +145,19 @@ public List getUserBookmarks(String userId, int limit) { return results.subList(0, Math.min(results.size(), limit)); } + /** + * 여러 기사의 북마크 여부 확인 (배치) + */ + public Set getBookmarkedArticleIds(String userId, List articleIds) { + Set bookmarkedIds = new HashSet<>(); + for (String articleId : articleIds) { + if (isBookmarked(userId, articleId)) { + bookmarkedIds.add(articleId); + } + } + return bookmarkedIds; + } + /** * 사용자 뉴스 통계 조회 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java index 83a4fc30..bf8bf449 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java @@ -185,14 +185,14 @@ private List extractKeywords(String content) { */ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) { String systemPrompt = """ - You are an English learning assistant. Analyze the news article and create learning materials. + You are an English learning assistant for Korean learners. Analyze the news article and create learning materials. Respond in this exact JSON format: { "summary": "3-line summary in English (each line separated by newline)", "keywords": [ - {"word": "economy", "meaning": "the system of trade and industry", "example": "The economy is growing steadily."}, - {"word": "policy", "meaning": "a plan of action adopted by government", "example": "The new policy affects all citizens."} + {"word": "economy", "meaning": "the system of trade and industry", "meaningKo": "경제", "example": "The economy is growing steadily."}, + {"word": "policy", "meaning": "a plan of action adopted by government", "meaningKo": "정책", "example": "The new policy affects all citizens."} ], "highlightWords": ["word1", "word2", "word3"], "category": "WORLD", @@ -225,7 +225,11 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) } IMPORTANT: - - keywords: Extract 5-8 important vocabulary words from the article. Include word, meaning (simple definition), and example sentence from the article. + - keywords: Extract 5-8 important vocabulary words from the article. Include: + - word: the English word + - meaning: simple English definition + - meaningKo: Korean translation of the word (한국어 뜻) + - example: example sentence from the article - highlightWords: 3-5 difficult words that learners should pay attention to (just the words, no definitions). - category: Choose EXACTLY ONE from: WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE - Create exactly 3 quiz questions. @@ -294,6 +298,7 @@ private AnalysisResult parseAnalysisResult(String response) { keywords.add(KeywordInfo.builder() .word(k.has("word") ? k.get("word").getAsString() : "") .meaning(k.has("meaning") ? k.get("meaning").getAsString() : "") + .meaningKo(k.has("meaningKo") ? k.get("meaningKo").getAsString() : "") .example(k.has("example") ? k.get("example").getAsString() : "") .build()); }); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java index c46e4fc6..208339ee 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java @@ -17,6 +17,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; /** * 뉴스 학습 부가 기능 서비스 @@ -63,6 +64,12 @@ public List markAsRead(String userId, String articleId) { return new ArrayList<>(); } + // 이미 읽은 기사인지 확인 (중복 조회수 증가 방지) + if (userNewsRepository.hasRead(userId, articleId)) { + logger.debug("이미 읽은 기사: userId={}, articleId={}", userId, articleId); + return new ArrayList<>(); + } + NewsArticle a = article.get(); userNewsRepository.saveReadRecord( userId, @@ -72,7 +79,7 @@ public List markAsRead(String userId, String articleId) { a.getCategory() ); - // 조회수 증가 + // 조회수 증가 (새로운 읽기만) String date = extractDateFromPk(a.getPk()); if (date != null) { articleRepository.incrementReadCount(date, articleId); @@ -135,6 +142,20 @@ public boolean isBookmarked(String userId, String articleId) { return userNewsRepository.isBookmarked(userId, articleId); } + /** + * 읽기 여부 확인 + */ + public boolean hasRead(String userId, String articleId) { + return userNewsRepository.hasRead(userId, articleId); + } + + /** + * 여러 기사의 북마크 여부 확인 (배치) + */ + public Set getBookmarkedArticleIds(String userId, List articleIds) { + return userNewsRepository.getBookmarkedArticleIds(userId, articleIds); + } + /** * 사용자 북마크 목록 조회 (기사 정보 포함) */ From a67d15fe0d40dabf2e61ad903edff519888a2c1d Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Fri, 23 Jan 2026 14:27:13 +0900 Subject: [PATCH 89/99] =?UTF-8?q?fix=20:=20sessionId=20NullPointerExceptio?= =?UTF-8?q?n=20=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95=20=20(#496)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feat : handleChat 메서드 JsonNull 체크 푸가 * refactor : session_id가 null 체크 추가 * feat : template 환경변수 리펙토링 --------- Co-authored-by: DDING JOO --- ServerlessFunction/gradlew | 28 +- ServerlessFunction/gradlew.bat | 25 +- .../{websocket => }/SpeakingHandler.java | 9 +- .../speaking/service/SpeakingService.java | 19 +- .../user/dto/response/ProfileResponse.java | 2 +- .../domain/user/handler/UserHandler.java | 15 +- .../serverless/domain/user/model/User.java | 11 +- .../domain/user/service/UserService.java | 59 +- ServerlessFunction/template.yaml | 553 +++++------------- 9 files changed, 258 insertions(+), 463 deletions(-) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/{websocket => }/SpeakingHandler.java (97%) diff --git a/ServerlessFunction/gradlew b/ServerlessFunction/gradlew index adff685a..fcb6fca1 100755 --- a/ServerlessFunction/gradlew +++ b/ServerlessFunction/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015 the original authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,8 +15,6 @@ # See the License for the specific language governing permissions and # limitations under the License. # -# SPDX-License-Identifier: Apache-2.0 -# ############################################################################## # @@ -57,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -85,8 +83,7 @@ done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -114,6 +111,7 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -146,7 +144,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 + # shellcheck disable=SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac @@ -154,7 +152,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 + # shellcheck disable=SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -171,6 +169,7 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -202,15 +201,16 @@ fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ "$@" # Stop when "xargs" is not available. diff --git a/ServerlessFunction/gradlew.bat b/ServerlessFunction/gradlew.bat index c4bdd3ab..93e3f59f 100644 --- a/ServerlessFunction/gradlew.bat +++ b/ServerlessFunction/gradlew.bat @@ -13,8 +13,6 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @@ -45,11 +43,11 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. goto fail @@ -59,21 +57,22 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. goto fail :execute @rem Setup the command line +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell 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) {} + } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ProfileResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ProfileResponse.java index bdc1ced7..7f17bd4a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ProfileResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ProfileResponse.java @@ -29,7 +29,7 @@ public static ProfileResponse from(User user) { .email(user.getEmail()) .nickname(user.getNickname()) .level(user.getLevel()) - .profileUrl(user.getProfileUrl()) + .profileUrl(user.getProfileUrlForResponse()) .createdAt(user.getCreatedAt()) .updatedAt(user.getUpdatedAt()) .build(); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java index b4fc9aea..9ad8618f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java @@ -59,9 +59,20 @@ private APIGatewayProxyResponseEvent getMyProfile( APIGatewayProxyRequestEvent request, String userId // cognitoSub ) { - User user = userService.getProfile(userId, request); - ProfileResponse response = ProfileResponse.from(user); + + // profileUrl을 Presigned URL로 변환 + String presignedUrl = userService.getPresignedProfileUrl(user.getProfileUrl()); + + ProfileResponse response = ProfileResponse.builder() + .userId(user.getCognitoSub()) + .email(user.getEmail()) + .nickname(user.getNickname()) + .level(user.getLevel()) + .profileUrl(presignedUrl) // Presigned URL 사용 + .createdAt(user.getCreatedAt()) + .updatedAt(user.getUpdatedAt()) + .build(); return ResponseGenerator.ok(user.getNickname() + " 환영합니다!", response); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java index 344d77e4..60dc6be5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java @@ -27,11 +27,13 @@ public class User { private String nickname; private String level; private String profileUrl; + private String profileUrlForResponse; private String createdAt; private String updatedAt; private String lastLoginAt; private Long ttl; - + + /** * 신규 사용자 생성 * - Lazy Registration 적용: 최초 프로필 조회 시 DynamoDB에 저장 @@ -114,7 +116,12 @@ public void updateProfileUrl(String newProfileUrl) { this.profileUrl = newProfileUrl; this.updatedAt = Instant.now().toString(); } - + + @DynamoDbIgnore + public String getProfileUrlForResponse() { + return profileUrlForResponse != null ? profileUrlForResponse : profileUrl; + } + public void updateLastLoginAt() { this.lastLoginAt = Instant.now().toString(); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java index 2421f118..6783c42b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java @@ -7,8 +7,10 @@ import com.mzc.secondproject.serverless.domain.user.repository.UserRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; @@ -52,18 +54,53 @@ public UserService(UserRepository userRepository) { * @return User 객체 */ public User getProfile(String userId, APIGatewayProxyRequestEvent request) { - - return userRepository.findByCognitoSub(userId) - .map(user -> { - // 정상 DB에서 조회 완료 - user.updateLastLoginAt(); - userRepository.update(user); - return user; + + User user = userRepository.findByCognitoSub(userId) + .map(u -> { + u.updateLastLoginAt(); + userRepository.update(u); + return u; }) - .orElseGet(() -> { - // PostConfirmation 실패 대비 fallback - return createUserFromRequest(userId, request); - }); + .orElseGet(() -> createUserFromRequest(userId, request)); + + // 프로필 URL을 Presigned URL로 변환 + String presignedProfileUrl = getPresignedProfileUrl(user.getProfileUrl()); + user.setProfileUrlForResponse(presignedProfileUrl); // 응답용으로만 설정 + + return user; + } + + public String getPresignedProfileUrl(String s3Url) { + if (s3Url == null || s3Url.isEmpty()) { + return generateGetPresignedUrl("profile/default.png"); + } + String key = extractKeyFromS3Url(s3Url); + return generateGetPresignedUrl(key); + } + + private String generateGetPresignedUrl(String imageKey) { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(BUCKET_NAME) + .key(imageKey) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofHours(24)) + .getObjectRequest(getObjectRequest) + .build(); + + return s3Presigner.presignGetObject(presignRequest).url().toString(); + } + + + private String extractKeyFromS3Url(String s3Url) { + // https://group2-englishstudy.s3.amazonaws.com/profile/user123/img.png + // → profile/user123/img.png + String prefix = String.format("https://%s.s3.amazonaws.com/", BUCKET_NAME); + if (s3Url.startsWith(prefix)) { + return s3Url.substring(prefix.length()); + } + return s3Url; } /** 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 d3029a2badac036bf0b6dfe196c3b2be18bc1204 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 14:46:19 +0900 Subject: [PATCH 90/99] feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords --- .../domain/news/service/NewsWordService.java | 58 +- ServerlessFunction/template.yaml | 553 +++++++++++++----- 2 files changed, 457 insertions(+), 154 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java index 6881c7ec..7da961ce 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java @@ -59,7 +59,7 @@ public NewsWordService(NewsWordRepository newsWordRepository, } /** - * 단어 수집 + * 단어 수집 (자동으로 Word 테이블 + UserWord에 추가) * @return 수집 결과 (단어 정보 + 새로 획득한 배지) */ public WordCollectResult collectWord(String userId, String articleId, String word, String context) { @@ -73,12 +73,51 @@ public WordCollectResult collectWord(String userId, String articleId, String wor // 기사 조회 Optional articleOpt = articleRepository.findById(articleId); String articleTitle = articleOpt.map(NewsArticle::getTitle).orElse(""); + String articleLevel = articleOpt.map(NewsArticle::getLevel).orElse("INTERMEDIATE"); + + // 기사 키워드에서 단어 정보 추출 + String meaningKo = ""; + String meaningEn = ""; + String example = ""; + if (articleOpt.isPresent() && articleOpt.get().getKeywords() != null) { + for (var keyword : articleOpt.get().getKeywords()) { + if (keyword.getWord() != null && keyword.getWord().equalsIgnoreCase(word)) { + meaningKo = keyword.getMeaningKo() != null ? keyword.getMeaningKo() : ""; + meaningEn = keyword.getMeaning() != null ? keyword.getMeaning() : ""; + example = keyword.getExample() != null ? keyword.getExample() : ""; + break; + } + } + } // 단어 정보 조회 (Word 테이블에서) String wordId = word.toLowerCase().trim(); Optional wordOpt = wordRepository.findById(wordId); - String meaning = wordOpt.map(Word::getKorean).orElse(""); - String pronunciation = ""; + String meaning = meaningKo; + + // Word 테이블에 없으면 자동 생성 + if (wordOpt.isEmpty() && !meaningKo.isEmpty()) { + String now = Instant.now().toString(); + Word newWord = Word.builder() + .pk("WORD#" + wordId) + .sk("METADATA") + .gsi1pk("LEVEL#" + articleLevel) + .gsi1sk("WORD#" + wordId) + .gsi2pk("CATEGORY#NEWS") + .gsi2sk("WORD#" + wordId) + .wordId(wordId) + .english(word) + .korean(meaningKo) + .example(example) + .level(articleLevel) + .category("NEWS") + .createdAt(now) + .build(); + wordRepository.save(newWord); + logger.info("Word 테이블에 단어 자동 추가: wordId={}, korean={}", wordId, meaningKo); + } else if (wordOpt.isPresent()) { + meaning = wordOpt.get().getKorean(); + } String now = Instant.now().toString(); @@ -90,17 +129,26 @@ public WordCollectResult collectWord(String userId, String articleId, String wor .userId(userId) .word(word) .meaning(meaning) - .pronunciation(pronunciation) + .pronunciation("") .context(context) .articleId(articleId) .articleTitle(articleTitle) .collectedAt(now) - .syncedToVocab(false) + .syncedToVocab(true) // 자동 연동됨 + .vocabUserWordId(wordId) .build(); newsWordRepository.save(wordCollect); logger.info("단어 수집 완료: userId={}, word={}, articleId={}", userId, word, articleId); + // UserWord에 자동 추가 (NEW 상태로) + try { + userWordCommandService.updateWordStatus(userId, wordId, "NEW"); + logger.info("UserWord에 자동 추가: userId={}, wordId={}", userId, wordId); + } catch (Exception e) { + logger.warn("UserWord 추가 실패 (이미 존재할 수 있음): userId={}, wordId={}, error={}", userId, wordId, e.getMessage()); + } + // 통계 업데이트 및 배지 체크 List newBadges = new ArrayList<>(); try { diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 0f64417e..97fe03e6 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -2,6 +2,25 @@ 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 @@ -16,11 +35,13 @@ Globals: CHAT_TABLE_NAME: !Ref ChatTable VOCAB_TABLE_NAME: !Ref VocabTable OPIC_TABLE_NAME: !Ref OPIcTable - BUCKET_NAME: group2-englishstudy - CHAT_BUCKET_NAME: group2-englishstudy - VOCAB_BUCKET_NAME: group2-englishstudy - PROFILE_BUCKET_NAME: group2-englishstudy - OPIC_BUCKET_NAME: group2-englishstudy + 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}" 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" @@ -29,65 +50,10 @@ Globals: Resources: ############################################# - # Cognito User Pool + # Cognito - Using Existing User Pool + # (Cognito resources are managed in group2-englishstudy-chatting stack) ############################################# - 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 @@ -100,7 +66,7 @@ Resources: Timeout: 10 Environment: Variables: - DEFAULT_PROFILE_URL: https://group2-englishstudy.s3.amazonaws.com/profile/default.png + DEFAULT_PROFILE_URL: !Sub "https://${AWS::StackName}.s3.amazonaws.com/profile/default.png" # 회원가입 시점에 사용자 모든 정보가 DB에 저장 Lambda 함수 PostConfirmationFunction: @@ -118,18 +84,6 @@ 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) ############################################# @@ -137,11 +91,11 @@ Resources: MainApi: Type: AWS::Serverless::Api Properties: - Name: group2-englishstudy-api - StageName: dev + Name: !Sub "${AWS::StackName}-api" + StageName: !Ref Environment Cors: - AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" - AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" + AllowMethods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" + AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Requested-With,Accept'" AllowOrigin: "'*'" AllowCredentials: false GatewayResponses: @@ -151,7 +105,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,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" ResponseTemplates: application/json: '{"message": "Unauthorized", "statusCode": 401}' ACCESS_DENIED: @@ -160,7 +114,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,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" ResponseTemplates: application/json: '{"message": "Access Denied", "statusCode": 403}' DEFAULT_4XX: @@ -168,27 +122,28 @@ 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,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,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,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,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,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" ResponseTemplates: application/json: '{"message": "Token expired", "statusCode": 401}' Auth: DefaultAuthorizer: CognitoAuthorizer + AddDefaultAuthorizerToCorsPreflight: false Authorizers: CognitoAuthorizer: - UserPoolArn: !GetAtt CognitoUserPool.Arn + UserPoolArn: !Sub "arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/${ExistingCognitoUserPoolId}" Identity: Header: Authorization @@ -199,7 +154,7 @@ Resources: WebSocketApi: Type: AWS::ApiGatewayV2::Api Properties: - Name: group2-englishstudy-websocket + Name: !Sub "${AWS::StackName}-websocket" ProtocolType: WEBSOCKET RouteSelectionExpression: "$request.body.action" @@ -207,7 +162,7 @@ Resources: Type: AWS::ApiGatewayV2::Stage Properties: ApiId: !Ref WebSocketApi - StageName: dev + StageName: !Ref Environment AutoDeploy: true # WebSocket Connect Route @@ -265,7 +220,7 @@ Resources: WebSocketConnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-ws-connect + FunctionName: !Sub "${AWS::StackName}-ws-connect" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.websocket.WebSocketConnectHandler::handleRequest Description: Handle WebSocket $connect @@ -274,7 +229,7 @@ Resources: Environment: Variables: WEBSOCKET_CONNECTION_TTL_SECONDS: "600" - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -295,7 +250,7 @@ Resources: WebSocketDisconnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-ws-disconnect + FunctionName: !Sub "${AWS::StackName}-ws-disconnect" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.websocket.WebSocketDisconnectHandler::handleRequest Description: Handle WebSocket $disconnect @@ -303,7 +258,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -324,7 +279,7 @@ Resources: WebSocketMessageFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-ws-message + FunctionName: !Sub "${AWS::StackName}-ws-message" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.websocket.WebSocketMessageHandler::handleRequest Description: Handle WebSocket sendMessage @@ -332,7 +287,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" GAME_AUTO_CLOSE_LAMBDA_ARN: !GetAtt GameAutoCloseFunction.Arn SCHEDULER_ROLE_ARN: !GetAtt GameSchedulerRole.Arn Policies: @@ -352,7 +307,7 @@ Resources: - scheduler:CreateSchedule - scheduler:DeleteSchedule - scheduler:GetSchedule - Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/game-auto-close/*" + Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/${AWS::StackName}-game-auto-close/*" - Statement: - Effect: Allow Action: @@ -384,7 +339,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref UserTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" Events: GetMyProfile: Type: Api @@ -412,7 +367,7 @@ Resources: ChatRoomFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-chat-room-handler + FunctionName: !Sub "${AWS::StackName}-chat-room-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatRoomHandler::handleRequest Description: Handle chat room CRUD operations @@ -420,7 +375,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -484,7 +439,7 @@ Resources: GameFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-game-handler + FunctionName: !Sub "${AWS::StackName}-game-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.GameHandler::handleRequest Description: Handle catch-mind game operations @@ -492,7 +447,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" GAME_AUTO_CLOSE_LAMBDA_ARN: !GetAtt GameAutoCloseFunction.Arn SCHEDULER_ROLE_ARN: !GetAtt GameSchedulerRole.Arn Policies: @@ -512,7 +467,7 @@ Resources: - scheduler:CreateSchedule - scheduler:DeleteSchedule - scheduler:GetSchedule - Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/game-auto-close/*" + Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/${AWS::StackName}-game-auto-close/*" - Statement: - Effect: Allow Action: @@ -556,7 +511,7 @@ Resources: GameAutoCloseFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-game-auto-close + FunctionName: !Sub "${AWS::StackName}-game-auto-close" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.GameAutoCloseHandler::handleRequest Description: Auto-close game after 7 minutes @@ -566,7 +521,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -602,12 +557,12 @@ Resources: GameScheduleGroup: Type: AWS::Scheduler::ScheduleGroup Properties: - Name: game-auto-close + Name: !Sub "${AWS::StackName}-game-auto-close" ChatMessageFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-chat-message-handler + FunctionName: !Sub "${AWS::StackName}-chat-message-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatMessageHandler::handleRequest Description: Handle chat messages @@ -617,7 +572,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref ChatTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" - Statement: - Effect: Allow Action: @@ -659,7 +614,7 @@ Resources: ChatVoiceFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-chat-voice-handler + FunctionName: !Sub "${AWS::StackName}-chat-voice-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatVoiceHandler::handleRequest Description: Convert text to speech using Polly @@ -669,7 +624,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref ChatTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" - Statement: - Effect: Allow Action: @@ -693,7 +648,7 @@ Resources: WordFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-word-handler + FunctionName: !Sub "${AWS::StackName}-vocab-word-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.WordHandler::handleRequest Description: Handle word CRUD operations @@ -771,7 +726,7 @@ Resources: UserWordFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-userword-handler + FunctionName: !Sub "${AWS::StackName}-vocab-userword-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.UserWordHandler::handleRequest Description: Handle user word learning status @@ -833,7 +788,7 @@ Resources: WordGroupFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-wordgroup-handler + FunctionName: !Sub "${AWS::StackName}-vocab-wordgroup-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.WordGroupHandler::handleRequest Description: Handle user custom word groups @@ -903,7 +858,7 @@ Resources: DailyStudyFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-daily-handler + FunctionName: !Sub "${AWS::StackName}-vocab-daily-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.DailyStudyHandler::handleRequest Description: Handle daily study word assignment @@ -933,7 +888,7 @@ Resources: TestFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-test-handler + FunctionName: !Sub "${AWS::StackName}-vocab-test-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.TestHandler::handleRequest Description: Handle vocabulary tests @@ -992,7 +947,7 @@ Resources: StatsFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-stats-handler + FunctionName: !Sub "${AWS::StackName}-vocab-stats-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.StatsHandler::handleRequest Description: Handle user learning statistics @@ -1030,7 +985,7 @@ Resources: VocabVoiceFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-voice-handler + FunctionName: !Sub "${AWS::StackName}-vocab-voice-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.VoiceHandler::handleRequest Description: Convert word to speech using Polly @@ -1040,7 +995,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref VocabTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" - Statement: - Effect: Allow Action: @@ -1065,7 +1020,7 @@ Resources: StatsStreamFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-stats-stream-handler + FunctionName: !Sub "${AWS::StackName}-stats-stream-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.stats.handler.StatsStreamHandler::handleRequest Description: Process DynamoDB Streams for stats aggregation @@ -1090,7 +1045,7 @@ Resources: UserStatsFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-user-stats-handler + FunctionName: !Sub "${AWS::StackName}-user-stats-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.stats.handler.UserStatsHandler::handleRequest Description: Handle user learning statistics API @@ -1100,6 +1055,14 @@ 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: @@ -1145,7 +1108,7 @@ Resources: BadgeFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-badge-handler + FunctionName: !Sub "${AWS::StackName}-badge-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.badge.handler.BadgeHandler::handleRequest Description: Handle user badges and achievements @@ -1155,7 +1118,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref VocabTable - S3ReadPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" Events: GetAllBadges: Type: Api @@ -1181,7 +1144,7 @@ Resources: GrammarFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-grammar-handler + FunctionName: !Sub "${AWS::StackName}-grammar-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.GrammarHandler::handleRequest Description: Handle grammar check using Bedrock AI @@ -1260,7 +1223,7 @@ Resources: GrammarWebSocketApi: Type: AWS::ApiGatewayV2::Api Properties: - Name: group2-englishstudy-grammar-websocket + Name: !Sub "${AWS::StackName}-grammar-websocket" ProtocolType: WEBSOCKET RouteSelectionExpression: "$request.body.action" @@ -1268,7 +1231,7 @@ Resources: Type: AWS::ApiGatewayV2::Stage Properties: ApiId: !Ref GrammarWebSocketApi - StageName: dev + StageName: !Ref Environment AutoDeploy: true # Grammar WebSocket Connect Route @@ -1323,13 +1286,13 @@ Resources: GrammarStreamingConnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-grammar-ws-connect + FunctionName: !Sub "${AWS::StackName}-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/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1350,13 +1313,13 @@ Resources: GrammarStreamingDisconnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-grammar-ws-disconnect + FunctionName: !Sub "${AWS::StackName}-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/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1377,7 +1340,7 @@ Resources: GrammarStreamingFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-grammar-ws-streaming + FunctionName: !Sub "${AWS::StackName}-grammar-ws-streaming" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingHandler::handleRequest Description: Handle Grammar streaming with Bedrock @@ -1385,7 +1348,7 @@ Resources: MemorySize: 1024 Environment: Variables: - GRAMMAR_WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + GRAMMAR_WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1413,7 +1376,7 @@ Resources: ScheduledStatsFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-scheduled-stats + FunctionName: !Sub "${AWS::StackName}-scheduled-stats" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.stats.handler.ScheduledStatsHandler::handleRequest Description: Daily scheduled job for word learning stats aggregation @@ -1429,7 +1392,7 @@ Resources: Type: Schedule Properties: Schedule: cron(0 15 * * ? *) # UTC 15:00 = KST 00:00 (자정) - Name: daily-stats-aggregation + Name: !Sub "${AWS::StackName}-daily-stats-aggregation" Description: Daily word learning stats aggregation Enabled: true @@ -1440,7 +1403,7 @@ 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 Description: Handle Speaking AI conversation (REST API) @@ -1450,12 +1413,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: !Ref ContentBucket - Statement: - Effect: Allow Action: @@ -1496,7 +1460,7 @@ Resources: OPIcSessionFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-opic-session-handler + FunctionName: !Sub "${AWS::StackName}-opic-session-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.opic.handler.OPIcSessionHandler::handleRequest Description: Handle OPIc speaking practice sessions @@ -1511,7 +1475,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref OPIcTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" - Statement: - Effect: Allow Action: @@ -1604,7 +1568,7 @@ Resources: DeletionPolicy: Retain # UpdateReplacePolicy: Retain Properties: - TableName: group2-englishstudy-user + TableName: !Sub "${AWS::StackName}-user" BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: PK @@ -1649,7 +1613,7 @@ Resources: ChatTable: Type: AWS::DynamoDB::Table Properties: - TableName: group2-englishstudy-chat + TableName: !Sub "${AWS::StackName}-chat" BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: PK @@ -1693,7 +1657,7 @@ Resources: VocabTable: Type: AWS::DynamoDB::Table Properties: - TableName: group2-englishstudy-vocab + TableName: !Sub "${AWS::StackName}-vocab" BillingMode: PAY_PER_REQUEST StreamSpecification: StreamViewType: NEW_IMAGE @@ -1751,7 +1715,255 @@ Resources: OPIcTable: Type: AWS::DynamoDB::Table Properties: - TableName: group2-englishstudy-opic + 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" BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: PK @@ -1762,6 +1974,10 @@ Resources: AttributeType: S - AttributeName: GSI1SK AttributeType: S + - AttributeName: GSI2PK + AttributeType: S + - AttributeName: GSI2SK + AttributeType: S KeySchema: - AttributeName: PK KeyType: HASH @@ -1776,10 +1992,45 @@ 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 ############################################# @@ -1788,20 +2039,20 @@ Resources: TestResultTopic: Type: AWS::SNS::Topic Properties: - TopicName: group2-englishstudy-test-result-topic + TopicName: !Sub "${AWS::StackName}-test-result-topic" # SQS Dead Letter Queue - 실패한 메시지 보관 StatisticsDeadLetterQueue: Type: AWS::SQS::Queue Properties: - QueueName: group2-englishstudy-statistics-dlq + QueueName: !Sub "${AWS::StackName}-statistics-dlq" MessageRetentionPeriod: 1209600 # 14일 # SQS Queue - 통계 처리용 StatisticsQueue: Type: AWS::SQS::Queue Properties: - QueueName: group2-englishstudy-statistics-queue + QueueName: !Sub "${AWS::StackName}-statistics-queue" VisibilityTimeout: 60 RedrivePolicy: deadLetterTargetArn: !GetAtt StatisticsDeadLetterQueue.Arn @@ -1837,7 +2088,7 @@ Resources: StatisticsProcessorFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-statistics-processor + FunctionName: !Sub "${AWS::StackName}-statistics-processor" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.StatisticsHandler::handleRequest Description: Process test results and update user word statistics @@ -1863,15 +2114,15 @@ Resources: Outputs: ApiUrl: Description: Unified API Gateway endpoint URL - Value: !Sub 'https://${MainApi}.execute-api.${AWS::Region}.amazonaws.com/dev/' + Value: !Sub 'https://${MainApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}/' WebSocketUrl: Description: WebSocket API Gateway endpoint URL - Value: !Sub 'wss://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev' + Value: !Sub 'wss://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}' GrammarWebSocketUrl: Description: Grammar Streaming WebSocket API endpoint URL - Value: !Sub 'wss://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev' + Value: !Sub 'wss://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}' ChatTableName: Description: Chat DynamoDB Table Name @@ -1883,16 +2134,20 @@ Outputs: BucketName: Description: S3 Bucket Name - Value: group2-englishstudy + Value: !Ref ContentBucket CognitoUserPoolId: Description: Cognito User Pool ID - Value: !Ref CognitoUserPool + Value: !Ref ExistingCognitoUserPoolId CognitoUserPoolClientId: Description: Cognito User Pool Client ID - Value: !Ref CognitoUserPoolClient + Value: !Ref ExistingCognitoClientId OPIcTableName: Description: OPIc DynamoDB Table Name Value: !Ref OPIcTable + + SpeakingTableName: + Description: Speaking DynamoDB Table Name + Value: !Ref SpeakingTable From 63669c76b6f63608eb640dc33c68b098f50f45d6 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 14:53:44 +0900 Subject: [PATCH 91/99] feat: add category filtering to UserWord API with enhanced query logic - Introduce `category` filtering to `getUserWords` API - Update WordCategory enums to include "news" category - Apply category filter after enrichment with word info - Adjust query limits for bookmarked and incorrect words queries --- .../domain/vocabulary/enums/WordCategory.java | 3 +- .../vocabulary/handler/UserWordHandler.java | 9 +++--- .../service/UserWordQueryService.java | 29 ++++++++++++++----- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordCategory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordCategory.java index 9a65b41a..aafe1eac 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordCategory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordCategory.java @@ -7,7 +7,8 @@ public enum WordCategory { BUSINESS("business", "비즈니스"), ACADEMIC("academic", "학술"), TRAVEL("travel", "여행"), - TECHNOLOGY("technology", "기술"); + TECHNOLOGY("technology", "기술"), + NEWS("news", "뉴스"); private final String code; private final String displayName; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java index 83047fa4..8cb93c80 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java @@ -66,18 +66,19 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent request, String userId) { Map queryParams = request.getQueryStringParameters(); - + String status = queryParams != null ? queryParams.get("status") : null; String cursor = queryParams != null ? queryParams.get("cursor") : null; String bookmarked = queryParams != null ? queryParams.get("bookmarked") : null; String incorrectOnly = queryParams != null ? queryParams.get("incorrectOnly") : null; - + String category = queryParams != null ? queryParams.get("category") : null; + int limit = 20; if (queryParams != null && queryParams.get("limit") != null) { limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); } - - UserWordQueryService.UserWordsResult result = queryService.getUserWords(userId, status, bookmarked, incorrectOnly, limit, cursor); + + UserWordQueryService.UserWordsResult result = queryService.getUserWords(userId, status, bookmarked, incorrectOnly, category, limit, cursor); Map response = new HashMap<>(); response.put("userWords", result.userWords()); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java index bf6920e5..6b862566 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java @@ -37,21 +37,34 @@ public UserWordQueryService(UserWordRepository userWordRepository, WordRepositor } public UserWordsResult getUserWords(String userId, String status, String bookmarked, - String incorrectOnly, int limit, String cursor) { + String incorrectOnly, String category, int limit, String cursor) { PaginatedResult userWordPage; - + if ("true".equalsIgnoreCase(bookmarked)) { - userWordPage = userWordRepository.findBookmarkedWords(userId, limit, cursor); + userWordPage = userWordRepository.findBookmarkedWords(userId, limit * 3, cursor); } else if ("true".equalsIgnoreCase(incorrectOnly)) { - userWordPage = userWordRepository.findIncorrectWords(userId, limit, cursor); + userWordPage = userWordRepository.findIncorrectWords(userId, limit * 3, cursor); } else if (status != null && !status.isEmpty()) { - userWordPage = userWordRepository.findByUserIdAndStatus(userId, status, limit, cursor); + userWordPage = userWordRepository.findByUserIdAndStatus(userId, status, limit * 3, cursor); } else { - userWordPage = userWordRepository.findByUserIdWithPagination(userId, limit, cursor); + userWordPage = userWordRepository.findByUserIdWithPagination(userId, limit * 3, cursor); } - + List> enrichedUserWords = enrichWithWordInfo(userWordPage.items()); - + + // 카테고리 필터링 (Word 테이블 조인 후 필터) + if (category != null && !category.isEmpty()) { + String upperCategory = category.toUpperCase(); + enrichedUserWords = enrichedUserWords.stream() + .filter(w -> upperCategory.equals(w.get("category"))) + .limit(limit) + .collect(Collectors.toList()); + } else { + enrichedUserWords = enrichedUserWords.stream() + .limit(limit) + .collect(Collectors.toList()); + } + return new UserWordsResult(enrichedUserWords, userWordPage.nextCursor(), userWordPage.hasMore()); } From 36cee08379dbab3457e2ba5270474c925ef328dc Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 15:10:30 +0900 Subject: [PATCH 92/99] feat: add default value for ExistingCognitoClientId in template.yaml - Set default to an empty string to ensure compatibility with templates using this parameter without explicitly specifying a value. --- ServerlessFunction/template.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 97fe03e6..34296f1a 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -19,6 +19,7 @@ Parameters: ExistingCognitoClientId: Type: String + Default: "" Description: Existing Cognito User Pool Client ID Globals: From 2c72aaa80138c2f83692227e7aed5b4a5da18c1b Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 15:24:12 +0900 Subject: [PATCH 93/99] =?UTF-8?q?fix:=20SpeakingHandler=20getStringOrNull?= =?UTF-8?q?=20=EC=BB=B4=ED=8C=8C=EC=9D=BC=20=EC=97=90=EB=9F=AC=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 --- .../serverless/domain/speaking/handler/SpeakingHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c4a04370..c4166ed8 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 @@ -135,7 +135,7 @@ private APIGatewayProxyResponseEvent handleReset(String userId, String body) { } JsonObject request = JsonParser.parseString(body).getAsJsonObject(); - String sessionId = getStringOrNull(request, "sessionId"); + String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; if (sessionId == null || sessionId.isEmpty()) { return response(400, Map.of("error", "sessionId is required")); From f8644dd44cc237c061fa6927c06ce030726ccd1a Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 15:46:22 +0900 Subject: [PATCH 94/99] =?UTF-8?q?fix:=20Bedrock=20=ED=82=A4=EC=9B=8C?= =?UTF-8?q?=EB=93=9C(meaningKo=20=ED=8F=AC=ED=95=A8)=EB=A5=BC=20article?= =?UTF-8?q?=EC=97=90=20=EC=A0=80=EC=9E=A5=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../news/service/NewsAnalysisService.java | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java index f13712f0..87da58f1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java @@ -58,11 +58,7 @@ public NewsArticle analyzeArticle(NewsArticle article) { article.setCefrLevel(cefrLevel); article.setLevel(mapCefrToLevel(cefrLevel)); - // 2. 핵심 단어 추출 (Comprehend) - List keywords = extractKeywords(content); - article.setKeywords(keywords); - - // 3. 3줄 요약 + 퀴즈 생성 (Bedrock - 한 번에 처리) + // 2. 3줄 요약 + 키워드 + 퀴즈 생성 (Bedrock - 한 번에 처리) AnalysisResult result = generateSummaryAndQuiz(content, cefrLevel); if (result.summary() != null) { article.setSummary(result.summary()); @@ -70,6 +66,15 @@ public NewsArticle analyzeArticle(NewsArticle article) { article.setQuiz(result.quiz()); article.setHighlightWords(result.highlightWords()); + // Bedrock 키워드 사용 (meaningKo 포함) + if (result.keywords() != null && !result.keywords().isEmpty()) { + article.setKeywords(result.keywords()); + } else { + // fallback: Comprehend로 키워드 추출 + List keywords = extractKeywords(content); + article.setKeywords(keywords); + } + // 4. GSI 키 설정 article.setGsi1pk("LEVEL#" + article.getLevel()); article.setGsi1sk(article.getPublishedAt()); @@ -233,7 +238,7 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) return parseAnalysisResult(response); } catch (Exception e) { logger.error("요약/퀴즈 생성 실패", e); - return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>()); + return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>(), new ArrayList<>()); } } @@ -319,7 +324,7 @@ private AnalysisResult parseAnalysisResult(String response) { }); } - return new AnalysisResult(summary, highlightWords, quiz); + return new AnalysisResult(summary, keywords, highlightWords, quiz); } private String extractJson(String response) { @@ -341,6 +346,7 @@ private String truncate(String text, int maxLength) { */ private record AnalysisResult( String summary, + List keywords, List highlightWords, List quiz ) {} From fa533d3335ac53ad4afdf671f0ea7efe23685054 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 15:46:22 +0900 Subject: [PATCH 95/99] =?UTF-8?q?fix:=20Bedrock=20=ED=82=A4=EC=9B=8C?= =?UTF-8?q?=EB=93=9C(meaningKo=20=ED=8F=AC=ED=95=A8)=EB=A5=BC=20article?= =?UTF-8?q?=EC=97=90=20=EC=A0=80=EC=9E=A5=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../news/service/NewsAnalysisService.java | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java index af23fc5b..5af2b554 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java @@ -58,11 +58,7 @@ public NewsArticle analyzeArticle(NewsArticle article) { article.setCefrLevel(cefrLevel); article.setLevel(mapCefrToLevel(cefrLevel)); - // 2. 핵심 단어 추출 (Comprehend) - List keywords = extractKeywords(content); - article.setKeywords(keywords); - - // 3. 3줄 요약 + 퀴즈 생성 (Bedrock - 한 번에 처리) + // 2. 3줄 요약 + 키워드 + 퀴즈 생성 (Bedrock - 한 번에 처리) AnalysisResult result = generateSummaryAndQuiz(content, cefrLevel); if (result.summary() != null) { article.setSummary(result.summary()); @@ -70,6 +66,15 @@ public NewsArticle analyzeArticle(NewsArticle article) { article.setQuiz(result.quiz()); article.setHighlightWords(result.highlightWords()); + // Bedrock 키워드 사용 (meaningKo 포함) + if (result.keywords() != null && !result.keywords().isEmpty()) { + article.setKeywords(result.keywords()); + } else { + // fallback: Comprehend로 키워드 추출 + List keywords = extractKeywords(content); + article.setKeywords(keywords); + } + // 4. GSI 키 설정 article.setGsi1pk("LEVEL#" + article.getLevel()); article.setGsi1sk(article.getPublishedAt()); @@ -222,7 +227,7 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) return parseAnalysisResult(response); } catch (Exception e) { logger.error("요약/퀴즈 생성 실패", e); - return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>()); + return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>(), new ArrayList<>()); } } @@ -293,7 +298,7 @@ private AnalysisResult parseAnalysisResult(String response) { }); } - return new AnalysisResult(summary, highlightWords, quiz); + return new AnalysisResult(summary, keywords, highlightWords, quiz); } private String extractJson(String response) { @@ -315,6 +320,7 @@ private String truncate(String text, int maxLength) { */ private record AnalysisResult( String summary, + List keywords, List highlightWords, List quiz ) {} From 75acb9a22d3e0a3a44a2b98ea0383de4be4c0a22 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 16:05:18 +0900 Subject: [PATCH 96/99] feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint --- .../domain/news/handler/NewsHandler.java | 2 +- .../news/service/NewsLearningService.java | 69 +++- .../stats/handler/UserStatsHandler.java | 158 +++++++-- .../domain/stats/model/UserStats.java | 26 +- .../stats/repository/UserStatsRepository.java | 314 +++++++++++++++--- ServerlessFunction/template.yaml | 8 + 6 files changed, 475 insertions(+), 102 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java index 4ffd1bab..44f9a43c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -280,7 +280,7 @@ private APIGatewayProxyResponseEvent getBookmarks(APIGatewayProxyRequestEvent re if (params == null) params = new HashMap<>(); int limit = parseLimit(params.get("limit")); - List bookmarks = learningService.getUserBookmarks(userId, limit); + List> bookmarks = learningService.getUserBookmarks(userId, limit); Map response = new HashMap<>(); response.put("bookmarks", bookmarks); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java index f69355db..02930b1d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java @@ -2,17 +2,18 @@ import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.service.PollyService; +import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; +import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; import com.mzc.secondproject.serverless.domain.news.repository.UserNewsRepository; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; +import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; +import java.util.*; /** * 뉴스 학습 부가 기능 서비스 @@ -25,35 +26,44 @@ public class NewsLearningService { private final NewsArticleRepository articleRepository; private final UserNewsRepository userNewsRepository; private final PollyService pollyService; + private final UserStatsRepository userStatsRepository; + private final BadgeService badgeService; public NewsLearningService() { this.articleRepository = new NewsArticleRepository(); this.userNewsRepository = new UserNewsRepository(); this.pollyService = new PollyService(BUCKET_NAME, "news/audio/"); + this.userStatsRepository = new UserStatsRepository(); + this.badgeService = new BadgeService(); } public NewsLearningService(NewsArticleRepository articleRepository, UserNewsRepository userNewsRepository, - PollyService pollyService) { + PollyService pollyService, + UserStatsRepository userStatsRepository, + BadgeService badgeService) { this.articleRepository = articleRepository; this.userNewsRepository = userNewsRepository; this.pollyService = pollyService; + this.userStatsRepository = userStatsRepository; + this.badgeService = badgeService; } /** * 뉴스 읽기 완료 기록 + * @return 새로 획득한 배지 목록 */ - public void markAsRead(String userId, String articleId) { + public List markAsRead(String userId, String articleId) { Optional article = articleRepository.findById(articleId); if (article.isEmpty()) { logger.warn("기사를 찾을 수 없음: {}", articleId); - return; + return new ArrayList<>(); } // 이미 읽은 기사인지 확인 (중복 조회수 증가 방지) if (userNewsRepository.hasRead(userId, articleId)) { logger.debug("이미 읽은 기사: userId={}, articleId={}", userId, articleId); - return; + return new ArrayList<>(); } NewsArticle a = article.get(); @@ -72,6 +82,23 @@ public void markAsRead(String userId, String articleId) { } logger.info("읽기 완료 기록: userId={}, articleId={}", userId, articleId); + + // 통계 업데이트 및 배지 체크 + List newBadges = new ArrayList<>(); + try { + UserStats updatedStats = userStatsRepository.incrementNewsReadStats(userId); + if (updatedStats != null) { + newBadges = badgeService.checkAndAwardBadges(userId, updatedStats); + if (!newBadges.isEmpty()) { + logger.info("새 배지 획득: userId={}, badges={}", userId, + newBadges.stream().map(UserBadge::getBadgeType).toList()); + } + } + } catch (Exception e) { + logger.error("통계/배지 업데이트 실패: userId={}, error={}", userId, e.getMessage()); + } + + return newBadges; } /** @@ -128,8 +155,30 @@ public Set getBookmarkedArticleIds(String userId, List articleId /** * 사용자 북마크 목록 조회 (기사 정보 포함) */ - public List getUserBookmarks(String userId, int limit) { - return userNewsRepository.getUserBookmarks(userId, limit); + public List> getUserBookmarks(String userId, int limit) { + List bookmarks = userNewsRepository.getUserBookmarks(userId, limit); + List> result = new ArrayList<>(); + + for (UserNewsRecord bookmark : bookmarks) { + Optional articleOpt = articleRepository.findById(bookmark.getArticleId()); + if (articleOpt.isPresent()) { + NewsArticle article = articleOpt.get(); + Map bookmarkWithArticle = new HashMap<>(); + bookmarkWithArticle.put("articleId", article.getArticleId()); + bookmarkWithArticle.put("title", article.getTitle()); + bookmarkWithArticle.put("summary", article.getSummary()); + bookmarkWithArticle.put("source", article.getSource()); + bookmarkWithArticle.put("publishedAt", article.getPublishedAt()); + bookmarkWithArticle.put("keywords", article.getKeywords()); + bookmarkWithArticle.put("highlightWords", article.getHighlightWords()); + bookmarkWithArticle.put("category", article.getCategory()); + bookmarkWithArticle.put("level", article.getLevel()); + bookmarkWithArticle.put("imageUrl", article.getImageUrl()); + bookmarkWithArticle.put("bookmarkedAt", bookmark.getCreatedAt()); + result.add(bookmarkWithArticle); + } + } + return result; } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java index 637151be..66edddce 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java @@ -24,20 +24,20 @@ * 사용자 학습 통계 API Handler */ public class UserStatsHandler implements RequestHandler { - + private static final Logger logger = LoggerFactory.getLogger(UserStatsHandler.class); - + private final UserStatsRepository statsRepository; private final DailyStudyRepository dailyStudyRepository; private final HandlerRouter router; - + /** * 기본 생성자 (Lambda에서 사용) */ public UserStatsHandler() { this(new UserStatsRepository(), new DailyStudyRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -46,9 +46,10 @@ public UserStatsHandler(UserStatsRepository statsRepository, DailyStudyRepositor this.dailyStudyRepository = dailyStudyRepository; this.router = initRouter(); } - + private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( + Route.getAuth("/stats/dashboard", this::getDashboardStats), Route.getAuth("/stats/daily", this::getDailyStats), Route.getAuth("/stats/weekly", this::getWeeklyStats), Route.getAuth("/stats/monthly", this::getMonthlyStats), @@ -56,13 +57,94 @@ private HandlerRouter initRouter() { Route.getAuth("/stats/history", this::getStatsHistory) ); } - + @Override public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); return router.route(request); } - + + /** + * 대시보드용 통합 통계 조회 (프론트엔드 요청 형식) + * GET /stats/dashboard + */ + private APIGatewayProxyResponseEvent getDashboardStats(APIGatewayProxyRequestEvent request, String userId) { + String today = LocalDate.now().toString(); + + // 오늘 통계 조회 + Optional dailyStats = statsRepository.findDailyStats(userId, today); + // 전체 통계 조회 + Optional totalStats = statsRepository.findTotalStats(userId); + // 최근 7일 히스토리 조회 + PaginatedResult weekHistory = statsRepository.findRecentDailyStats(userId, 7, null); + // 오늘 학습 목표 조회 + Optional dailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); + + Map response = new HashMap<>(); + + // today 섹션 + Map todaySection = new HashMap<>(); + if (dailyStats.isPresent()) { + UserStats ds = dailyStats.get(); + todaySection.put("wordsLearned", ds.getNewWordsLearned() != null ? ds.getNewWordsLearned() : 0); + todaySection.put("newsRead", ds.getNewsRead() != null ? ds.getNewsRead() : 0); + todaySection.put("quizzesTaken", (ds.getTestsCompleted() != null ? ds.getTestsCompleted() : 0) + + (ds.getNewsQuizCompleted() != null ? ds.getNewsQuizCompleted() : 0)); + } else { + todaySection.put("wordsLearned", 0); + todaySection.put("newsRead", 0); + todaySection.put("quizzesTaken", 0); + } + todaySection.put("wordsTotal", dailyStudy.map(ds -> ds.getTotalWords() != null ? ds.getTotalWords() : 25).orElse(25)); + response.put("today", todaySection); + + // overall 섹션 + Map overallSection = new HashMap<>(); + if (totalStats.isPresent()) { + UserStats ts = totalStats.get(); + overallSection.put("totalWordsLearned", ts.getNewWordsLearned() != null ? ts.getNewWordsLearned() : 0); + overallSection.put("totalNewsRead", ts.getNewsRead() != null ? ts.getNewsRead() : 0); + overallSection.put("totalQuizzes", (ts.getTestsCompleted() != null ? ts.getTestsCompleted() : 0) + + (ts.getNewsQuizCompleted() != null ? ts.getNewsQuizCompleted() : 0)); + overallSection.put("averageAccuracy", calculateSuccessRate(ts)); + overallSection.put("currentStreak", ts.getCurrentStreak() != null ? ts.getCurrentStreak() : 0); + overallSection.put("longestStreak", ts.getLongestStreak() != null ? ts.getLongestStreak() : 0); + overallSection.put("lastStudyDate", ts.getLastStudyDate()); + } else { + overallSection.put("totalWordsLearned", 0); + overallSection.put("totalNewsRead", 0); + overallSection.put("totalQuizzes", 0); + overallSection.put("averageAccuracy", 0.0); + overallSection.put("currentStreak", 0); + overallSection.put("longestStreak", 0); + overallSection.put("lastStudyDate", null); + } + // totalStudyDays 계산 (최근 히스토리에서 실제 학습한 날 수) + overallSection.put("totalStudyDays", weekHistory.items().size()); + response.put("overall", overallSection); + + // weeklyProgress 섹션 + List> weeklyProgress = weekHistory.items().stream() + .map(stats -> { + Map dayStats = new HashMap<>(); + dayStats.put("date", stats.getPeriod()); + dayStats.put("wordsLearned", stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0); + dayStats.put("newsRead", stats.getNewsRead() != null ? stats.getNewsRead() : 0); + return dayStats; + }) + .collect(Collectors.toList()); + response.put("weeklyProgress", weeklyProgress); + + // levelDistribution (현재 미구현 - 향후 추가 가능) + Map levelDistribution = new HashMap<>(); + levelDistribution.put("beginner", 0); + levelDistribution.put("intermediate", 0); + levelDistribution.put("advanced", 0); + response.put("levelDistribution", levelDistribution); + + return ResponseGenerator.ok("학습 통계 조회 성공", response); + } + /** * 오늘의 통계 조회 */ @@ -70,12 +152,12 @@ private APIGatewayProxyResponseEvent getDailyStats(APIGatewayProxyRequestEvent r Map queryParams = request.getQueryStringParameters(); String date = queryParams != null && queryParams.get("date") != null ? queryParams.get("date") : LocalDate.now().toString(); - + Optional stats = statsRepository.findDailyStats(userId, date); - + return ResponseGenerator.ok("Daily stats retrieved", buildStatsResponse(stats, "DAILY", date)); } - + /** * 이번 주 통계 조회 */ @@ -83,12 +165,12 @@ private APIGatewayProxyResponseEvent getWeeklyStats(APIGatewayProxyRequestEvent Map queryParams = request.getQueryStringParameters(); String yearWeek = queryParams != null && queryParams.get("week") != null ? queryParams.get("week") : getCurrentYearWeek(); - + Optional stats = statsRepository.findWeeklyStats(userId, yearWeek); - + return ResponseGenerator.ok("Weekly stats retrieved", buildStatsResponse(stats, "WEEKLY", yearWeek)); } - + /** * 이번 달 통계 조회 */ @@ -96,20 +178,20 @@ private APIGatewayProxyResponseEvent getMonthlyStats(APIGatewayProxyRequestEvent Map queryParams = request.getQueryStringParameters(); String yearMonth = queryParams != null && queryParams.get("month") != null ? queryParams.get("month") : getCurrentYearMonth(); - + Optional stats = statsRepository.findMonthlyStats(userId, yearMonth); - + return ResponseGenerator.ok("Monthly stats retrieved", buildStatsResponse(stats, "MONTHLY", yearMonth)); } - + /** * 전체 통계 조회 */ private APIGatewayProxyResponseEvent getTotalStats(APIGatewayProxyRequestEvent request, String userId) { Optional stats = statsRepository.findTotalStats(userId); - + Map response = buildStatsResponse(stats, "TOTAL", "ALL"); - + // 전체 통계에는 streak 정보 추가 if (stats.isPresent()) { UserStats s = stats.get(); @@ -121,24 +203,24 @@ private APIGatewayProxyResponseEvent getTotalStats(APIGatewayProxyRequestEvent r response.put("longestStreak", 0); response.put("lastStudyDate", null); } - + return ResponseGenerator.ok("Total stats retrieved", response); } - + /** * 최근 일별 통계 히스토리 조회 */ private APIGatewayProxyResponseEvent getStatsHistory(APIGatewayProxyRequestEvent request, String userId) { Map queryParams = request.getQueryStringParameters(); String cursor = queryParams != null ? queryParams.get("cursor") : null; - + int limit = 7; // 기본 7일 if (queryParams != null && queryParams.get("limit") != null) { limit = Math.min(Integer.parseInt(queryParams.get("limit")), 100); } - + PaginatedResult result = statsRepository.findRecentDailyStats(userId, limit, cursor); - + // 각 날짜별 isCompleted 정보 조회 및 응답 구성 List> historyWithCompletion = result.items().stream() .map(stats -> { @@ -151,28 +233,28 @@ private APIGatewayProxyResponseEvent getStatsHistory(APIGatewayProxyRequestEvent item.put("successRate", calculateSuccessRate(stats)); item.put("newWordsLearned", stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0); item.put("wordsReviewed", stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0); - + // DailyStudy에서 isCompleted 조회 Optional dailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, stats.getPeriod()); item.put("isCompleted", dailyStudy.map(ds -> ds.getIsCompleted() != null && ds.getIsCompleted()).orElse(false)); - + return item; }) .collect(Collectors.toList()); - + Map response = new HashMap<>(); response.put("history", historyWithCompletion); response.put("nextCursor", result.nextCursor()); response.put("hasMore", result.hasMore()); - + return ResponseGenerator.ok("Stats history retrieved", response); } - + private Map buildStatsResponse(Optional stats, String periodType, String period) { Map response = new HashMap<>(); response.put("periodType", periodType); response.put("period", period); - + if (stats.isPresent()) { UserStats s = stats.get(); response.put("testsCompleted", s.getTestsCompleted() != null ? s.getTestsCompleted() : 0); @@ -182,6 +264,11 @@ private Map buildStatsResponse(Optional stats, String response.put("successRate", calculateSuccessRate(s)); response.put("newWordsLearned", s.getNewWordsLearned() != null ? s.getNewWordsLearned() : 0); response.put("wordsReviewed", s.getWordsReviewed() != null ? s.getWordsReviewed() : 0); + // 뉴스 관련 통계 + response.put("newsRead", s.getNewsRead() != null ? s.getNewsRead() : 0); + response.put("newsQuizCompleted", s.getNewsQuizCompleted() != null ? s.getNewsQuizCompleted() : 0); + response.put("newsQuizPerfect", s.getNewsQuizPerfect() != null ? s.getNewsQuizPerfect() : 0); + response.put("newsWordsCollected", s.getNewsWordsCollected() != null ? s.getNewsWordsCollected() : 0); } else { response.put("testsCompleted", 0); response.put("questionsAnswered", 0); @@ -190,17 +277,22 @@ private Map buildStatsResponse(Optional stats, String response.put("successRate", 0.0); response.put("newWordsLearned", 0); response.put("wordsReviewed", 0); + // 뉴스 관련 통계 + response.put("newsRead", 0); + response.put("newsQuizCompleted", 0); + response.put("newsQuizPerfect", 0); + response.put("newsWordsCollected", 0); } - + return response; } - + private double calculateSuccessRate(UserStats stats) { int correct = stats.getCorrectAnswers() != null ? stats.getCorrectAnswers() : 0; int total = stats.getQuestionsAnswered() != null ? stats.getQuestionsAnswered() : 0; return total > 0 ? (correct * 100.0 / total) : 0.0; } - + private String getCurrentYearWeek() { LocalDate now = LocalDate.now(); WeekFields weekFields = WeekFields.of(Locale.getDefault()); @@ -208,7 +300,7 @@ private String getCurrentYearWeek() { int year = now.get(weekFields.weekBasedYear()); return String.format("%d-W%02d", year, week); } - + private String getCurrentYearMonth() { LocalDate now = LocalDate.now(); return String.format("%d-%02d", now.getYear(), now.getMonthValue()); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java index cc25634c..3c268897 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java @@ -24,31 +24,31 @@ @AllArgsConstructor @DynamoDbBean public class UserStats { - + private String pk; // USER#{userId}#STATS private String sk; // DAILY#{date} / WEEKLY#{year}-W{week} / MONTHLY#{year}-{month} / TOTAL - + private String userId; private String periodType; // DAILY, WEEKLY, MONTHLY, TOTAL private String period; // 2026-01-13, 2026-W02, 2026-01, TOTAL - + // 테스트 통계 private Integer testsCompleted; // 완료한 테스트 수 private Integer questionsAnswered; // 답변한 문제 수 private Integer correctAnswers; // 정답 수 private Integer incorrectAnswers; // 오답 수 private Double successRate; // 정답률 - + // 학습 통계 private Integer newWordsLearned; // 새로 학습한 단어 수 private Integer wordsReviewed; // 복습한 단어 수 private Integer wordsMastered; // 마스터한 단어 수 - + // Streak (연속 학습) private Integer currentStreak; // 현재 연속 학습일 private Integer longestStreak; // 최장 연속 학습일 private String lastStudyDate; // 마지막 학습일 - + // 게임 통계 private Integer gamesPlayed; // 참여한 게임 수 private Integer gamesWon; // 1등 횟수 @@ -56,17 +56,25 @@ public class UserStats { private Integer totalGameScore; // 누적 게임 점수 private Integer quickGuesses; // 5초 내 정답 횟수 private Integer perfectDraws; // 전원 정답 유도 횟수 - + + // 뉴스 통계 + private Integer newsRead; // 읽은 뉴스 수 + private Integer newsQuizCompleted; // 완료한 뉴스 퀴즈 수 + private Integer newsQuizPerfect; // 뉴스 퀴즈 만점 횟수 + private Integer newsWordsCollected; // 뉴스에서 수집한 단어 수 + private Integer newsStreak; // 뉴스 연속 읽기 일수 + private String lastNewsReadDate; // 마지막 뉴스 읽은 날짜 + // 메타데이터 private String createdAt; private String updatedAt; - + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { return pk; } - + @DynamoDbSortKey @DynamoDbAttribute("SK") public String getSk() { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java index b3ad20d8..89e9b385 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java @@ -28,26 +28,26 @@ * Atomic Counter 패턴을 사용하여 Scan 없이 통계 업데이트 */ public class UserStatsRepository { - + private static final Logger logger = LoggerFactory.getLogger(UserStatsRepository.class); private static final String TABLE_NAME = EnvConfig.getRequired("VOCAB_TABLE_NAME"); - + private final DynamoDbTable table; - + /** * 기본 생성자 (Lambda에서 사용) */ public UserStatsRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ public UserStatsRepository(DynamoDbEnhancedClient enhancedClient) { this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(UserStats.class)); } - + /** * 특정 기간의 통계 조회 */ @@ -56,39 +56,39 @@ public Optional findByUserIdAndPeriod(String userId, String sk) { .partitionValue(StatsKey.userStatsPk(userId)) .sortValue(sk) .build(); - + UserStats stats = table.getItem(key); return Optional.ofNullable(stats); } - + /** * 일별 통계 조회 */ public Optional findDailyStats(String userId, String date) { return findByUserIdAndPeriod(userId, StatsKey.statsDailySk(date)); } - + /** * 주별 통계 조회 */ public Optional findWeeklyStats(String userId, String yearWeek) { return findByUserIdAndPeriod(userId, StatsKey.statsWeeklySk(yearWeek)); } - + /** * 월별 통계 조회 */ public Optional findMonthlyStats(String userId, String yearMonth) { return findByUserIdAndPeriod(userId, StatsKey.statsMonthlySk(yearMonth)); } - + /** * 전체 통계 조회 */ public Optional findTotalStats(String userId) { return findByUserIdAndPeriod(userId, StatsKey.statsTotalSk()); } - + /** * 최근 N일 일별 통계 조회 */ @@ -98,25 +98,25 @@ public PaginatedResult findRecentDailyStats(String userId, int limit, .partitionValue(StatsKey.userStatsPk(userId)) .sortValue(StatsKey.STATS_DAILY) .build()); - + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) // 최신순 .limit(limit); - + if (cursor != null && !cursor.isEmpty()) { Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } } - + Page page = table.query(requestBuilder.build()).iterator().next(); String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - + return new PaginatedResult<>(page.items(), nextCursor); } - + /** * 테스트 결과 통계 Atomic 업데이트 * 일/주/월/전체 통계를 한 번에 업데이트 @@ -125,31 +125,31 @@ public void incrementTestStats(String userId, int correctAnswers, int incorrectA String today = LocalDate.now().toString(); String yearWeek = getYearWeek(); String yearMonth = getYearMonth(); - + List sortKeys = List.of( StatsKey.statsDailySk(today), StatsKey.statsWeeklySk(yearWeek), StatsKey.statsMonthlySk(yearMonth), StatsKey.statsTotalSk() ); - + String pk = StatsKey.userStatsPk(userId); String now = Instant.now().toString(); int totalQuestions = correctAnswers + incorrectAnswers; - + for (String sk : sortKeys) { updateTestStats(pk, sk, correctAnswers, incorrectAnswers, totalQuestions, now); } - + logger.info("Incremented test stats: userId={}, correct={}, incorrect={}", userId, correctAnswers, incorrectAnswers); } - + private void updateTestStats(String pk, String sk, int correct, int incorrect, int total, String now) { Map key = new HashMap<>(); key.put("PK", AttributeValue.builder().s(pk).build()); key.put("SK", AttributeValue.builder().s(sk).build()); - + Map values = new HashMap<>(); values.put(":correct", AttributeValue.builder().n(String.valueOf(correct)).build()); values.put(":incorrect", AttributeValue.builder().n(String.valueOf(incorrect)).build()); @@ -157,7 +157,7 @@ private void updateTestStats(String pk, String sk, int correct, int incorrect, i values.put(":one", AttributeValue.builder().n("1").build()); values.put(":zero", AttributeValue.builder().n("0").build()); values.put(":now", AttributeValue.builder().s(now).build()); - + String updateExpression = "SET " + "correctAnswers = if_not_exists(correctAnswers, :zero) + :correct, " + "incorrectAnswers = if_not_exists(incorrectAnswers, :zero) + :incorrect, " + @@ -165,17 +165,17 @@ private void updateTestStats(String pk, String sk, int correct, int incorrect, i "testsCompleted = if_not_exists(testsCompleted, :zero) + :one, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now)"; - + UpdateItemRequest request = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression(updateExpression) .expressionAttributeValues(values) .build(); - + AwsClients.dynamoDb().updateItem(request); } - + /** * 학습 완료 단어 수 Atomic 업데이트 */ @@ -183,52 +183,52 @@ public void incrementWordsLearned(String userId, int newWords, int reviewedWords String today = LocalDate.now().toString(); String yearWeek = getYearWeek(); String yearMonth = getYearMonth(); - + List sortKeys = List.of( StatsKey.statsDailySk(today), StatsKey.statsWeeklySk(yearWeek), StatsKey.statsMonthlySk(yearMonth), StatsKey.statsTotalSk() ); - + String pk = StatsKey.userStatsPk(userId); String now = Instant.now().toString(); - + for (String sk : sortKeys) { updateWordsLearned(pk, sk, newWords, reviewedWords, now); } - + logger.info("Incremented words learned: userId={}, new={}, reviewed={}", userId, newWords, reviewedWords); } - + private void updateWordsLearned(String pk, String sk, int newWords, int reviewedWords, String now) { Map key = new HashMap<>(); key.put("PK", AttributeValue.builder().s(pk).build()); key.put("SK", AttributeValue.builder().s(sk).build()); - + Map values = new HashMap<>(); values.put(":new", AttributeValue.builder().n(String.valueOf(newWords)).build()); values.put(":reviewed", AttributeValue.builder().n(String.valueOf(reviewedWords)).build()); values.put(":zero", AttributeValue.builder().n("0").build()); values.put(":now", AttributeValue.builder().s(now).build()); - + String updateExpression = "SET " + "newWordsLearned = if_not_exists(newWordsLearned, :zero) + :new, " + "wordsReviewed = if_not_exists(wordsReviewed, :zero) + :reviewed, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now)"; - + UpdateItemRequest request = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression(updateExpression) .expressionAttributeValues(values) .build(); - + AwsClients.dynamoDb().updateItem(request); } - + /** * Streak(연속 학습일) 업데이트 */ @@ -236,35 +236,35 @@ public void updateStreak(String userId, int currentStreak, int longestStreak, St String pk = StatsKey.userStatsPk(userId); String sk = StatsKey.statsTotalSk(); String now = Instant.now().toString(); - + Map key = new HashMap<>(); key.put("PK", AttributeValue.builder().s(pk).build()); key.put("SK", AttributeValue.builder().s(sk).build()); - + Map values = new HashMap<>(); values.put(":current", AttributeValue.builder().n(String.valueOf(currentStreak)).build()); values.put(":longest", AttributeValue.builder().n(String.valueOf(longestStreak)).build()); values.put(":lastDate", AttributeValue.builder().s(lastStudyDate).build()); values.put(":now", AttributeValue.builder().s(now).build()); - + String updateExpression = "SET " + "currentStreak = :current, " + "longestStreak = :longest, " + "lastStudyDate = :lastDate, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now)"; - + UpdateItemRequest request = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression(updateExpression) .expressionAttributeValues(values) .build(); - + AwsClients.dynamoDb().updateItem(request); logger.info("Updated streak: userId={}, current={}, longest={}", userId, currentStreak, longestStreak); } - + /** * 게임 통계 Atomic 업데이트 */ @@ -273,11 +273,11 @@ public void incrementGameStats(String userId, int gamesPlayed, int gamesWon, String pk = StatsKey.userStatsPk(userId); String sk = StatsKey.statsTotalSk(); String now = Instant.now().toString(); - + Map key = new HashMap<>(); key.put("PK", AttributeValue.builder().s(pk).build()); key.put("SK", AttributeValue.builder().s(sk).build()); - + Map values = new HashMap<>(); values.put(":gamesPlayed", AttributeValue.builder().n(String.valueOf(gamesPlayed)).build()); values.put(":gamesWon", AttributeValue.builder().n(String.valueOf(gamesWon)).build()); @@ -287,7 +287,7 @@ public void incrementGameStats(String userId, int gamesPlayed, int gamesWon, values.put(":perfectDraws", AttributeValue.builder().n(String.valueOf(perfectDraws)).build()); values.put(":zero", AttributeValue.builder().n("0").build()); values.put(":now", AttributeValue.builder().s(now).build()); - + String updateExpression = "SET " + "gamesPlayed = if_not_exists(gamesPlayed, :zero) + :gamesPlayed, " + "gamesWon = if_not_exists(gamesWon, :zero) + :gamesWon, " + @@ -297,19 +297,235 @@ public void incrementGameStats(String userId, int gamesPlayed, int gamesWon, "perfectDraws = if_not_exists(perfectDraws, :zero) + :perfectDraws, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now)"; - + UpdateItemRequest request = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression(updateExpression) .expressionAttributeValues(values) .build(); - + AwsClients.dynamoDb().updateItem(request); logger.info("Incremented game stats: userId={}, gamesPlayed={}, gamesWon={}, correctGuesses={}", userId, gamesPlayed, gamesWon, correctGuesses); } - + + /** + * 뉴스 읽기 통계 Atomic 업데이트 (TOTAL + DAILY) + */ + public UserStats incrementNewsReadStats(String userId) { + String today = LocalDate.now().toString(); + String pk = StatsKey.userStatsPk(userId); + String now = Instant.now().toString(); + + // 먼저 현재 통계 조회 (streak 계산용) + UserStats currentStats = findTotalStats(userId).orElse(null); + String lastNewsReadDate = currentStats != null ? currentStats.getLastNewsReadDate() : null; + + // 연속 읽기 계산 + int currentStreak = 1; + if (lastNewsReadDate != null) { + LocalDate lastDate = LocalDate.parse(lastNewsReadDate); + LocalDate todayDate = LocalDate.now(); + if (lastDate.equals(todayDate.minusDays(1))) { + // 어제 읽었으면 streak 증가 + currentStreak = (currentStats.getNewsStreak() != null ? currentStats.getNewsStreak() : 0) + 1; + } else if (lastDate.equals(todayDate)) { + // 오늘 이미 읽었으면 streak 유지 + currentStreak = currentStats.getNewsStreak() != null ? currentStats.getNewsStreak() : 1; + } + // 그 외의 경우는 streak 1로 초기화 + } + + Map values = new HashMap<>(); + values.put(":one", AttributeValue.builder().n("1").build()); + values.put(":zero", AttributeValue.builder().n("0").build()); + values.put(":streak", AttributeValue.builder().n(String.valueOf(currentStreak)).build()); + values.put(":today", AttributeValue.builder().s(today).build()); + values.put(":now", AttributeValue.builder().s(now).build()); + + // 1. TOTAL 통계 업데이트 + Map totalKey = new HashMap<>(); + totalKey.put("PK", AttributeValue.builder().s(pk).build()); + totalKey.put("SK", AttributeValue.builder().s(StatsKey.statsTotalSk()).build()); + + String totalUpdateExpression = "SET " + + "newsRead = if_not_exists(newsRead, :zero) + :one, " + + "newsStreak = :streak, " + + "lastNewsReadDate = :today, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now)"; + + UpdateItemRequest totalRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(totalKey) + .updateExpression(totalUpdateExpression) + .expressionAttributeValues(values) + .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) + .build(); + + AwsClients.dynamoDb().updateItem(totalRequest); + + // 2. DAILY 통계 업데이트 + Map dailyKey = new HashMap<>(); + dailyKey.put("PK", AttributeValue.builder().s(pk).build()); + dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); + + Map dailyValues = new HashMap<>(); + dailyValues.put(":one", AttributeValue.builder().n("1").build()); + dailyValues.put(":zero", AttributeValue.builder().n("0").build()); + dailyValues.put(":now", AttributeValue.builder().s(now).build()); + dailyValues.put(":today", AttributeValue.builder().s(today).build()); + + String dailyUpdateExpression = "SET " + + "newsRead = if_not_exists(newsRead, :zero) + :one, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now), " + + "period = if_not_exists(period, :today)"; + + UpdateItemRequest dailyRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(dailyKey) + .updateExpression(dailyUpdateExpression) + .expressionAttributeValues(dailyValues) + .build(); + + AwsClients.dynamoDb().updateItem(dailyRequest); + logger.info("Incremented news read stats (TOTAL + DAILY): userId={}, streak={}", userId, currentStreak); + + return findTotalStats(userId).orElse(null); + } + + /** + * 뉴스 퀴즈 통계 Atomic 업데이트 (TOTAL + DAILY) + */ + public UserStats incrementNewsQuizStats(String userId, boolean isPerfect) { + String today = LocalDate.now().toString(); + String pk = StatsKey.userStatsPk(userId); + String now = Instant.now().toString(); + + Map values = new HashMap<>(); + values.put(":one", AttributeValue.builder().n("1").build()); + values.put(":zero", AttributeValue.builder().n("0").build()); + values.put(":perfect", AttributeValue.builder().n(isPerfect ? "1" : "0").build()); + values.put(":now", AttributeValue.builder().s(now).build()); + + // 1. TOTAL 통계 업데이트 + Map totalKey = new HashMap<>(); + totalKey.put("PK", AttributeValue.builder().s(pk).build()); + totalKey.put("SK", AttributeValue.builder().s(StatsKey.statsTotalSk()).build()); + + String totalUpdateExpression = "SET " + + "newsQuizCompleted = if_not_exists(newsQuizCompleted, :zero) + :one, " + + "newsQuizPerfect = if_not_exists(newsQuizPerfect, :zero) + :perfect, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now)"; + + UpdateItemRequest totalRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(totalKey) + .updateExpression(totalUpdateExpression) + .expressionAttributeValues(values) + .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) + .build(); + + AwsClients.dynamoDb().updateItem(totalRequest); + + // 2. DAILY 통계 업데이트 + Map dailyKey = new HashMap<>(); + dailyKey.put("PK", AttributeValue.builder().s(pk).build()); + dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); + + Map dailyValues = new HashMap<>(); + dailyValues.put(":one", AttributeValue.builder().n("1").build()); + dailyValues.put(":zero", AttributeValue.builder().n("0").build()); + dailyValues.put(":perfect", AttributeValue.builder().n(isPerfect ? "1" : "0").build()); + dailyValues.put(":now", AttributeValue.builder().s(now).build()); + dailyValues.put(":today", AttributeValue.builder().s(today).build()); + + String dailyUpdateExpression = "SET " + + "newsQuizCompleted = if_not_exists(newsQuizCompleted, :zero) + :one, " + + "newsQuizPerfect = if_not_exists(newsQuizPerfect, :zero) + :perfect, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now), " + + "period = if_not_exists(period, :today)"; + + UpdateItemRequest dailyRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(dailyKey) + .updateExpression(dailyUpdateExpression) + .expressionAttributeValues(dailyValues) + .build(); + + AwsClients.dynamoDb().updateItem(dailyRequest); + logger.info("Incremented news quiz stats (TOTAL + DAILY): userId={}, isPerfect={}", userId, isPerfect); + + return findTotalStats(userId).orElse(null); + } + + /** + * 뉴스 단어 수집 통계 Atomic 업데이트 (TOTAL + DAILY) + */ + public UserStats incrementNewsWordStats(String userId, int wordCount) { + String today = LocalDate.now().toString(); + String pk = StatsKey.userStatsPk(userId); + String now = Instant.now().toString(); + + Map values = new HashMap<>(); + values.put(":count", AttributeValue.builder().n(String.valueOf(wordCount)).build()); + values.put(":zero", AttributeValue.builder().n("0").build()); + values.put(":now", AttributeValue.builder().s(now).build()); + + // 1. TOTAL 통계 업데이트 + Map totalKey = new HashMap<>(); + totalKey.put("PK", AttributeValue.builder().s(pk).build()); + totalKey.put("SK", AttributeValue.builder().s(StatsKey.statsTotalSk()).build()); + + String totalUpdateExpression = "SET " + + "newsWordsCollected = if_not_exists(newsWordsCollected, :zero) + :count, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now)"; + + UpdateItemRequest totalRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(totalKey) + .updateExpression(totalUpdateExpression) + .expressionAttributeValues(values) + .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) + .build(); + + AwsClients.dynamoDb().updateItem(totalRequest); + + // 2. DAILY 통계 업데이트 + Map dailyKey = new HashMap<>(); + dailyKey.put("PK", AttributeValue.builder().s(pk).build()); + dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); + + Map dailyValues = new HashMap<>(); + dailyValues.put(":count", AttributeValue.builder().n(String.valueOf(wordCount)).build()); + dailyValues.put(":zero", AttributeValue.builder().n("0").build()); + dailyValues.put(":now", AttributeValue.builder().s(now).build()); + dailyValues.put(":today", AttributeValue.builder().s(today).build()); + + String dailyUpdateExpression = "SET " + + "newsWordsCollected = if_not_exists(newsWordsCollected, :zero) + :count, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now), " + + "period = if_not_exists(period, :today)"; + + UpdateItemRequest dailyRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(dailyKey) + .updateExpression(dailyUpdateExpression) + .expressionAttributeValues(dailyValues) + .build(); + + AwsClients.dynamoDb().updateItem(dailyRequest); + logger.info("Incremented news word stats (TOTAL + DAILY): userId={}, wordCount={}", userId, wordCount); + + return findTotalStats(userId).orElse(null); + } + /** * 현재 연도-주차 반환 (예: 2026-W02) */ @@ -320,7 +536,7 @@ private String getYearWeek() { int year = now.get(weekFields.weekBasedYear()); return String.format("%d-W%02d", year, week); } - + /** * 현재 연도-월 반환 (예: 2026-01) */ diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index bb4adbd6..3c0134d7 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1088,6 +1088,14 @@ Resources: Method: GET Auth: Authorizer: CognitoAuthorizer + GetDashboard: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /stats/dashboard + Method: GET + Auth: + Authorizer: CognitoAuthorizer GetStatsHistory: Type: Api Properties: From 11c9eed6bc588bf64dfeb905ebff46c8a9be6581 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 16:11:39 +0900 Subject: [PATCH 97/99] feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint --- .../docs/NEWS_API_FRONTEND_CHANGES.md | 304 ++++ .../docs/VOCABULARY_NEWS_INTEGRATION.md | 308 +++++ docs/CATCHMIND_ARCHITECTURE_SOLUTION.md | 521 ------- docs/CICD-IMPLEMENTATION-QNA.md | 421 ------ docs/FRONTEND-API-GUIDE.md | 365 ----- docs/MIDTERM-REPORT.md | 439 ------ docs/domain-reports/BADGE-DOMAIN-REPORT.md | 681 --------- docs/domain-reports/CHATTING-DOMAIN-REPORT.md | 434 ------ docs/domain-reports/COMMON-MODULE-REPORT.md | 1228 ----------------- docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md | 465 ------- docs/domain-reports/STATS-DOMAIN-REPORT.md | 379 ----- .../VOCABULARY-DOMAIN-REPORT.md | 504 ------- 12 files changed, 612 insertions(+), 5437 deletions(-) create mode 100644 ServerlessFunction/docs/NEWS_API_FRONTEND_CHANGES.md create mode 100644 ServerlessFunction/docs/VOCABULARY_NEWS_INTEGRATION.md delete mode 100644 docs/CATCHMIND_ARCHITECTURE_SOLUTION.md delete mode 100644 docs/CICD-IMPLEMENTATION-QNA.md delete mode 100644 docs/FRONTEND-API-GUIDE.md delete mode 100644 docs/MIDTERM-REPORT.md delete mode 100644 docs/domain-reports/BADGE-DOMAIN-REPORT.md delete mode 100644 docs/domain-reports/CHATTING-DOMAIN-REPORT.md delete mode 100644 docs/domain-reports/COMMON-MODULE-REPORT.md delete mode 100644 docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md delete mode 100644 docs/domain-reports/STATS-DOMAIN-REPORT.md delete mode 100644 docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md diff --git a/ServerlessFunction/docs/NEWS_API_FRONTEND_CHANGES.md b/ServerlessFunction/docs/NEWS_API_FRONTEND_CHANGES.md new file mode 100644 index 00000000..2d63691a --- /dev/null +++ b/ServerlessFunction/docs/NEWS_API_FRONTEND_CHANGES.md @@ -0,0 +1,304 @@ +# News API 프론트엔드 변경사항 + +> 마지막 업데이트: 2025-01-23 + +## 목차 +1. [기사 목록 조회 API 변경](#1-기사-목록-조회-api-변경) +2. [기사 상세 조회 API 변경](#2-기사-상세-조회-api-변경) +3. [키워드 필드 추가](#3-키워드-필드-추가) +4. [인증 필수 엔드포인트](#4-인증-필수-엔드포인트) +5. [API 응답 예시](#5-api-응답-예시) + +--- + +## 1. 기사 목록 조회 API 변경 + +### 영향받는 엔드포인트 +- `GET /news` - 뉴스 목록 조회 +- `GET /news/today` - 오늘의 뉴스 조회 +- `GET /news/recommended` - 추천 뉴스 조회 + +### 변경사항 +각 기사 객체에 `isBookmarked` 필드가 추가되었습니다. + +| 필드 | 타입 | 설명 | +|------|------|------| +| `isBookmarked` | boolean | 현재 사용자가 해당 기사를 북마크했는지 여부 | + +### 주의사항 +- **로그인한 사용자**: 실제 북마크 상태 반환 +- **비로그인 사용자**: 모든 기사에 `false` 반환 + +### 기존 응답 (변경 전) +```json +{ + "articles": [ + { + "articleId": "abc123", + "title": "...", + "summary": "...", + "category": "TECH", + "level": "INTERMEDIATE" + } + ] +} +``` + +### 새 응답 (변경 후) +```json +{ + "articles": [ + { + "articleId": "abc123", + "title": "...", + "summary": "...", + "category": "TECH", + "level": "INTERMEDIATE", + "cefrLevel": "B1", + "isBookmarked": true + } + ] +} +``` + +--- + +## 2. 기사 상세 조회 API 변경 + +### 영향받는 엔드포인트 +- `GET /news/{articleId}` - 기사 상세 조회 + +### 변경사항 +응답에 `isBookmarked`와 `isRead` 필드가 추가되었습니다. + +| 필드 | 타입 | 설명 | +|------|------|------| +| `isBookmarked` | boolean | 현재 사용자가 해당 기사를 북마크했는지 여부 | +| `isRead` | boolean | 현재 사용자가 해당 기사를 읽었는지 여부 | + +### 새 응답 형식 +```json +{ + "success": true, + "message": "뉴스 조회 성공", + "data": { + "article": { + "articleId": "abc123", + "title": "Tech Giants Report Strong Quarterly Earnings", + "summary": "Major technology companies...", + "category": "TECH", + "level": "INTERMEDIATE", + "cefrLevel": "B1", + "keywords": [...], + "highlightWords": ["earnings", "revenue", "growth"], + "quiz": [...] + }, + "isBookmarked": true, + "isRead": false + } +} +``` + +--- + +## 3. 키워드 필드 추가 + +### 변경사항 +`keywords` 배열의 각 키워드 객체에 `meaningKo` (한국어 뜻) 필드가 추가되었습니다. + +### 키워드 객체 구조 + +| 필드 | 타입 | 설명 | +|------|------|------| +| `word` | string | 영어 단어 | +| `meaning` | string | 영어 정의 (간단한 설명) | +| `meaningKo` | string | **[신규]** 한국어 뜻 | +| `example` | string | 기사에서 발췌한 예문 | + +### 키워드 예시 +```json +{ + "keywords": [ + { + "word": "economy", + "meaning": "the system of trade and industry", + "meaningKo": "경제", + "example": "The economy is growing steadily." + }, + { + "word": "revenue", + "meaning": "income, especially of a company", + "meaningKo": "수익", + "example": "The company reported record revenue." + } + ] +} +``` + +### 프론트엔드 활용 +- 단어장 기능에서 한국어 뜻 표시 +- 학습 카드에 영어/한국어 뜻 모두 표시 가능 + +--- + +## 4. 인증 필수 엔드포인트 + +다음 엔드포인트들은 Cognito 인증 토큰이 필요합니다. + +### 인증 필수 (Authorization 헤더 필요) +| 메서드 | 엔드포인트 | 설명 | +|--------|------------|------| +| GET | `/news/stats` | 뉴스 학습 통계 조회 | +| GET | `/news/bookmarks` | 북마크 목록 조회 | +| GET | `/news/words` | 수집 단어 목록 조회 | +| GET | `/news/quiz/history` | 퀴즈 기록 조회 | +| POST | `/news/{articleId}/read` | 읽기 완료 기록 | +| POST | `/news/{articleId}/bookmark` | 북마크 토글 | +| GET | `/news/{articleId}/quiz` | 퀴즈 조회 | +| POST | `/news/{articleId}/quiz` | 퀴즈 제출 | +| POST | `/news/{articleId}/words` | 단어 수집 | +| DELETE | `/news/{articleId}/words/{word}` | 단어 삭제 | +| POST | `/news/words/{word}/sync` | 단어 Vocabulary 연동 | + +### 인증 선택 (토큰 있으면 개인화된 응답) +| 메서드 | 엔드포인트 | 설명 | +|--------|------------|------| +| GET | `/news` | 뉴스 목록 (북마크 상태 포함) | +| GET | `/news/today` | 오늘의 뉴스 (북마크 상태 포함) | +| GET | `/news/recommended` | 추천 뉴스 (북마크 상태 포함) | +| GET | `/news/{articleId}` | 기사 상세 (북마크/읽기 상태 포함) | + +### 요청 헤더 예시 +``` +Authorization: Bearer eyJraWQiOiJ... +``` + +--- + +## 5. API 응답 예시 + +### 기사 목록 조회 (GET /news) +```json +{ + "success": true, + "message": "뉴스 목록 조회 성공", + "data": { + "articles": [ + { + "articleId": "news_20250123_001", + "title": "Global Tech Summit Addresses AI Regulation", + "summary": "World leaders gathered to discuss...", + "source": "Reuters", + "publishedAt": "2025-01-23T09:00:00Z", + "category": "TECH", + "level": "INTERMEDIATE", + "cefrLevel": "B1", + "imageUrl": "https://...", + "readCount": 150, + "keywords": [ + { + "word": "regulation", + "meaning": "official rules made by a government", + "meaningKo": "규제", + "example": "New AI regulation will take effect next year." + } + ], + "highlightWords": ["regulation", "summit", "artificial intelligence"], + "isBookmarked": false + } + ], + "nextCursor": "eyJwayI6Ik5FV1MjMjAyNS0wMS0yMyIsInNrIjoiQVJUSUNMRSMxMjM0NSJ9", + "hasMore": true, + "count": 10 + } +} +``` + +### 기사 상세 조회 (GET /news/{articleId}) +```json +{ + "success": true, + "message": "뉴스 조회 성공", + "data": { + "article": { + "articleId": "news_20250123_001", + "title": "Global Tech Summit Addresses AI Regulation", + "summary": "World leaders gathered to discuss the future of artificial intelligence...", + "source": "Reuters", + "publishedAt": "2025-01-23T09:00:00Z", + "category": "TECH", + "level": "INTERMEDIATE", + "cefrLevel": "B1", + "imageUrl": "https://...", + "readCount": 151, + "keywords": [ + { + "word": "regulation", + "meaning": "official rules made by a government", + "meaningKo": "규제", + "example": "New AI regulation will take effect next year." + }, + { + "word": "summit", + "meaning": "an important meeting between leaders", + "meaningKo": "정상회담", + "example": "The summit brought together leaders from 50 countries." + } + ], + "highlightWords": ["regulation", "summit", "artificial intelligence"], + "quiz": [ + { + "questionId": "q1", + "type": "COMPREHENSION", + "question": "What is the main topic of this article?", + "options": ["AI regulation", "Climate change", "Economic policy", "Healthcare"], + "points": 20 + }, + { + "questionId": "q2", + "type": "WORD_MATCH", + "question": "What does 'regulation' mean in this context?", + "options": ["Official rules", "Technology", "Meeting", "Country"], + "points": 15 + }, + { + "questionId": "q3", + "type": "FILL_BLANK", + "question": "World leaders gathered at the _____ to discuss AI.", + "options": ["summit", "office", "factory", "school"], + "points": 30 + } + ] + }, + "isBookmarked": true, + "isRead": false + } +} +``` + +--- + +## 프론트엔드 체크리스트 + +### 기사 목록 화면 +- [ ] 각 기사 카드에 북마크 아이콘 표시 (`isBookmarked` 활용) +- [ ] 북마크된 기사는 다른 색상/아이콘으로 구분 + +### 기사 상세 화면 +- [ ] 북마크 버튼 상태 초기화 (`isBookmarked` 활용) +- [ ] 읽기 완료 표시 (`isRead` 활용) +- [ ] 키워드 목록에 한국어 뜻 표시 (`meaningKo` 활용) + +### 단어장/학습 카드 +- [ ] 한국어 뜻 표시 기능 추가 +- [ ] 영어/한국어 토글 기능 (선택사항) + +### 인증 +- [ ] 필수 인증 엔드포인트에 토큰 전송 확인 +- [ ] 401 에러 처리 (로그인 페이지로 리다이렉트) + +--- + +## 질문 및 문의 + +백엔드 관련 문의사항이 있으면 연락주세요. diff --git a/ServerlessFunction/docs/VOCABULARY_NEWS_INTEGRATION.md b/ServerlessFunction/docs/VOCABULARY_NEWS_INTEGRATION.md new file mode 100644 index 00000000..f1ff6c62 --- /dev/null +++ b/ServerlessFunction/docs/VOCABULARY_NEWS_INTEGRATION.md @@ -0,0 +1,308 @@ +# 단어장 - 뉴스 연동 기능 프론트엔드 가이드 + +> 마지막 업데이트: 2025-01-23 + +## 목차 +1. [뉴스 단어 수집 흐름](#1-뉴스-단어-수집-흐름) +2. [API 엔드포인트](#2-api-엔드포인트) +3. [카테고리 필터링](#3-카테고리-필터링) +4. [응답 예시](#4-응답-예시) +5. [프론트엔드 구현 가이드](#5-프론트엔드-구현-가이드) + +--- + +## 1. 뉴스 단어 수집 흐름 + +### 자동 연동 프로세스 + +``` +사용자가 뉴스 기사에서 "단어 가져오기" 클릭 + ↓ + POST /news/{articleId}/words + ↓ +┌─────────────────────────────────────────┐ +│ 1. 기사 키워드에서 한국어 뜻 추출 │ +│ 2. Word 테이블에 자동 저장 (NEWS 카테고리) │ +│ 3. UserWord에 자동 추가 (NEW 상태) │ +│ 4. NewsWordCollect 기록 저장 │ +└─────────────────────────────────────────┘ + ↓ + 단어장(/user-words)에서 바로 확인 가능! +``` + +### 핵심 포인트 +- **별도의 "연동" 버튼 불필요**: 단어 수집 시 자동으로 단어장에 추가됨 +- **카테고리 자동 설정**: 뉴스에서 수집한 단어는 `NEWS` 카테고리로 저장 +- **한국어 뜻 자동 포함**: 기사 AI 분석 결과에서 `meaningKo` 추출 + +--- + +## 2. API 엔드포인트 + +### 뉴스 단어 수집 API + +#### 단어 수집 (단어 가져오기) +```http +POST /news/{articleId}/words +Authorization: Bearer {token} +Content-Type: application/json + +{ + "word": "economy", + "context": "The economy is growing rapidly" // 선택사항 +} +``` + +**응답:** +```json +{ + "success": true, + "message": "단어 수집 성공", + "data": { + "wordCollect": { + "word": "economy", + "meaning": "경제", + "articleId": "abc123", + "articleTitle": "Global Economic Outlook", + "collectedAt": "2025-01-23T12:00:00Z", + "syncedToVocab": true, + "vocabUserWordId": "economy" + }, + "newBadges": [] + } +} +``` + +#### 뉴스에서 수집한 단어 목록 +```http +GET /news/words?limit=20 +Authorization: Bearer {token} +``` + +**응답:** +```json +{ + "success": true, + "data": { + "words": [ + { + "word": "economy", + "meaning": "경제", + "articleId": "abc123", + "articleTitle": "Global Economic Outlook", + "context": "The economy is growing", + "collectedAt": "2025-01-23T12:00:00Z", + "syncedToVocab": true + } + ], + "stats": { + "totalCollected": 15, + "syncedToVocab": 15 + }, + "count": 1 + } +} +``` + +--- + +### 단어장 API (카테고리 필터 추가됨) + +#### 내 단어장 조회 +```http +GET /user-words?category=NEWS&limit=20 +Authorization: Bearer {token} +``` + +**쿼리 파라미터:** + +| 파라미터 | 타입 | 설명 | 예시 | +|----------|------|------|------| +| `category` | string | 카테고리 필터 **(신규)** | `NEWS`, `DAILY`, `BUSINESS` | +| `status` | string | 학습 상태 필터 | `NEW`, `LEARNING`, `REVIEWING`, `MASTERED` | +| `bookmarked` | boolean | 북마크 필터 | `true` | +| `incorrectOnly` | boolean | 오답만 | `true` | +| `limit` | number | 조회 개수 (최대 50) | `20` | +| `cursor` | string | 페이지네이션 커서 | `eyJ...` | + +--- + +## 3. 카테고리 필터링 + +### 사용 가능한 카테고리 + +| 카테고리 | 코드 | 설명 | +|----------|------|------| +| 일상 | `DAILY` | 일상 생활 단어 | +| 비즈니스 | `BUSINESS` | 비즈니스/업무 단어 | +| 학술 | `ACADEMIC` | 학술/전문 단어 | +| 여행 | `TRAVEL` | 여행 관련 단어 | +| 기술 | `TECHNOLOGY` | IT/기술 단어 | +| **뉴스** | `NEWS` | **뉴스에서 수집한 단어 (신규)** | + +### 필터 조합 예시 + +``` +# 뉴스에서 수집한 모든 단어 +GET /user-words?category=NEWS + +# 뉴스 단어 중 학습 중인 것만 +GET /user-words?category=NEWS&status=LEARNING + +# 뉴스 단어 중 북마크한 것만 +GET /user-words?category=NEWS&bookmarked=true + +# 뉴스 단어 중 틀린 것만 +GET /user-words?category=NEWS&incorrectOnly=true + +# 모든 카테고리의 북마크 단어 +GET /user-words?bookmarked=true +``` + +--- + +## 4. 응답 예시 + +### 단어장 조회 응답 (GET /user-words?category=NEWS) + +```json +{ + "success": true, + "message": "User words retrieved", + "data": { + "userWords": [ + { + "wordId": "economy", + "userId": "user-123", + "status": "NEW", + "correctCount": 0, + "incorrectCount": 0, + "bookmarked": false, + "favorite": false, + "difficulty": null, + "nextReviewAt": null, + "lastReviewedAt": null, + "repetitions": 0, + "interval": 0, + "english": "economy", + "korean": "경제", + "level": "INTERMEDIATE", + "category": "NEWS", + "example": "The economy is growing steadily.", + "maleVoiceKey": null, + "femaleVoiceKey": null + }, + { + "wordId": "regulation", + "userId": "user-123", + "status": "LEARNING", + "correctCount": 2, + "incorrectCount": 1, + "bookmarked": true, + "favorite": false, + "difficulty": "HARD", + "english": "regulation", + "korean": "규제", + "level": "ADVANCED", + "category": "NEWS", + "example": "New regulation will take effect." + } + ], + "nextCursor": "eyJwayI6IlVTRVIjdXNlci0xMjMiLCJzayI6IldPUkQjcmVndWxhdGlvbiJ9", + "hasMore": true + } +} +``` + +--- + +## 5. 프론트엔드 구현 가이드 + +### 단어장 UI 변경사항 + +#### 1. 카테고리 탭/필터 추가 +``` +[전체] [일상] [비즈니스] [학술] [여행] [기술] [뉴스] + ↑ 신규 +``` + +#### 2. 뉴스 단어 표시 +- 뉴스에서 수집한 단어는 `category: "NEWS"` 표시 +- 출처 표시 가능 (NewsWordCollect의 articleTitle 활용) + +#### 3. 단어 수집 후 UI 업데이트 +```javascript +// 단어 수집 API 호출 +const response = await fetch(`/news/${articleId}/words`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ word: selectedWord }) +}); + +const result = await response.json(); + +if (result.success) { + // syncedToVocab: true 이므로 단어장에 자동 추가됨 + showToast('단어가 단어장에 추가되었습니다!'); + + // 새 배지 획득 시 알림 + if (result.data.newBadges?.length > 0) { + showBadgeNotification(result.data.newBadges); + } +} +``` + +### 체크리스트 + +#### 단어장 페이지 +- [ ] 카테고리 필터 UI 추가 (탭 또는 드롭다운) +- [ ] `NEWS` 카테고리 옵션 추가 +- [ ] API 호출 시 `category` 파라미터 전달 +- [ ] 카테고리별 단어 개수 표시 (선택사항) + +#### 뉴스 상세 페이지 +- [ ] "단어 가져오기" 버튼 동작 확인 +- [ ] 수집 성공 시 토스트 메시지 +- [ ] 이미 수집된 단어 표시 (비활성화 또는 체크 아이콘) + +#### 뉴스 키워드 표시 +- [ ] `keywords` 배열의 `meaningKo` 필드 표시 +- [ ] 각 키워드 클릭 시 수집 가능하도록 UI 구성 + +--- + +## 데이터 흐름 다이어그램 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 뉴스 기사 상세 │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Keywords: │ │ +│ │ [economy: 경제] [regulation: 규제] [summit: 정상회담] │ │ +│ │ ↓ 클릭 │ │ +│ │ "단어 가져오기" → POST /news/{id}/words │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + ↓ + 자동으로 단어장에 추가 + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ 단어장 │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 카테고리: [전체] [일상] [비즈니스] ... [뉴스✓] │ │ +│ │ │ │ +│ │ economy 경제 NEW 뉴스 │ │ +│ │ regulation 규제 LEARNING 뉴스 ⭐ │ │ +│ │ summit 정상회담 NEW 뉴스 │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 질문 및 문의 + +백엔드 관련 문의사항이 있으면 연락주세요. diff --git a/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md b/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md deleted file mode 100644 index e4c22aa4..00000000 --- a/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md +++ /dev/null @@ -1,521 +0,0 @@ -# 채팅방 / 캐치마인드 게임 분리 - 종합 솔루션 - -## 1. 현재 문제점 분석 - -### 1.1 백엔드 현황 - -``` -ChatRoom.java (현재 - 혼합 모델) -├── 채팅 필드 -│ ├── roomId, name, description -│ ├── memberIds, currentMembers -│ └── lastMessageAt -│ -└── 게임 필드 (여기에 섞여있음) - ├── gameStatus, gameStartedBy - ├── currentRound, totalRounds - ├── currentDrawerId, currentWord - ├── roundStartTime, roundTimeLimit ← serverTime 없음! - ├── scores, streaks - └── correctGuessers -``` - -**문제점:** - -1. `roundStartTime`만 전송, `serverTime` 누락 → 클라이언트 타이머 동기화 불가 -2. 게임 세션이 채팅방에 종속 → 게임 상태 독립 관리 불가 -3. 재접속 시 게임 상태 복구 어려움 -4. 게임 종료 후 상태 정리 복잡 - -### 1.2 WebSocket 메시지 현황 - -```java -// WebSocketMessageHandler.java - 현재 구조 -handleRequest() { - switch (messageType) { - case "DRAWING", "DRAWING_CLEAR" -> handleDrawingMessage() // 게임 - default -> handleRegularMessage() { - // 1. 슬래시 명령어 처리 (/start, /stop, /score...) - // 2. 게임 중 정답 체크 - // 3. 일반 채팅 메시지 - } - } -} -``` - -**문제점:** - -- 채팅/게임 구분 없이 모든 메시지가 동일 핸들러에서 처리 -- 메시지에 `domain` 필드 없음 - ---- - -## 2. 최적 솔루션 - -### 2.1 아키텍처 개요 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ WebSocket (단일 엔드포인트 유지) │ -│ │ -│ ┌──────────────────────┐ ┌────────────────────────────────┐ │ -│ │ domain: "chat" │ │ domain: "game" │ │ -│ │ │ │ │ │ -│ │ • TEXT │ │ • GAME_START / GAME_END │ │ -│ │ • USER_JOIN │ │ • ROUND_START / ROUND_END │ │ -│ │ • USER_LEAVE │ │ • DRAWING / DRAWING_CLEAR │ │ -│ │ • SYSTEM │ │ • GUESS / CORRECT_ANSWER │ │ -│ │ │ │ • SCORE_UPDATE / HINT │ │ -│ └──────────────────────┘ └────────────────────────────────┘ │ -│ │ -│ GameSession (별도 모델) │ -│ ├── gameSessionId │ -│ ├── roomId (연결용) │ -│ ├── status, currentRound │ -│ ├── roundStartTime + serverTime ← 핵심! │ -│ └── scores, players │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 2.2 핵심 변경사항 - -| 구분 | 현재 | 변경 후 | -|-----|----------------------|---------------------------------| -| 모델 | `ChatRoom`에 게임 필드 포함 | `ChatRoom` + `GameSession` 분리 | -| 타이머 | `roundStartTime`만 전송 | `roundStartTime` + `serverTime` | -| 메시지 | `messageType`만 존재 | `domain` + `messageType` | -| API | 채팅방 API만 존재 | 게임 세션 API 추가 | - ---- - -## 3. 백엔드 변경사항 - -### 3.1 Phase 1: 타이머 버그 수정 (즉시) - -**변경 파일:** `WebSocketMessageHandler.java` - -```java -// GAME_START 메시지에 serverTime 추가 -private void broadcastGameStart(...) { - Map message = new HashMap<>(); - // ... 기존 코드 ... - - message.put("roundStartTime", gameResult.room().getRoundStartTime()); - message.put("serverTime", System.currentTimeMillis()); // 추가! - message.put("roundDuration", gameResult.room().getRoundTimeLimit()); // 명확한 이름 - - // ... -} - -// ROUND_END → ROUND_START 메시지에도 동일하게 추가 -private void broadcastRoundEnd(...) { - // ... - messageData.put("roundStartTime", room.getRoundStartTime()); - messageData.put("serverTime", System.currentTimeMillis()); // 추가! - messageData.put("roundDuration", room.getRoundTimeLimit()); - // ... -} -``` - -**예상 작업량:** 30분 - -### 3.2 Phase 2: 메시지 구조 개선 (1일) - -**변경 파일:** `WebSocketMessageHandler.java`, 모든 브로드캐스트 메서드 - -```java -// 모든 메시지에 domain 필드 추가 -private Map createMessage(String domain, String messageType, Object data) { - Map message = new HashMap<>(); - message.put("domain", domain); // "chat" 또는 "game" - message.put("messageType", messageType); - message.put("data", data); - message.put("timestamp", System.currentTimeMillis()); - return message; -} - -// 채팅 메시지 -createMessage("chat", "TEXT", chatData); -createMessage("chat", "USER_JOIN", joinData); - -// 게임 메시지 -createMessage("game", "GAME_START", gameStartData); -createMessage("game", "ROUND_START", roundStartData); -createMessage("game", "DRAWING", drawingData); -``` - -### 3.3 Phase 3: 게임 세션 분리 (1주) - -#### 3.3.1 새 모델: GameSession.java - -```java -@DynamoDbBean -public class GameSession { - private String pk; // GAME#{gameSessionId} - private String sk; // METADATA - private String gsi1pk; // ROOM#{roomId} - private String gsi1sk; // GAME#{createdAt} - - // 게임 식별 - private String gameSessionId; - private String roomId; // 연결된 채팅방 - private String gameType; // "catchmind" - - // 게임 상태 - private String status; // WAITING, PLAYING, FINISHED - private String startedBy; - private Long startedAt; - private Long endedAt; - - // 라운드 정보 - private Integer currentRound; - private Integer totalRounds; - private String currentDrawerId; - private String currentWordId; - private String currentWord; - private Long roundStartTime; - private Integer roundDuration; - - // 점수 - private Map scores; - private Map streaks; - private List players; - private List drawerOrder; - - // 자동 종료 - private Long gameEndScheduledAt; - private String scheduleRuleArn; - - // TTL - private Long ttl; -} -``` - -#### 3.3.2 ChatRoom에서 게임 필드 제거 - -```java -@DynamoDbBean -public class ChatRoom { - // 채팅 필드만 유지 - private String roomId; - private String name; - private String description; - private String level; - private Integer currentMembers; - private Integer maxMembers; - private Boolean isPrivate; - private String password; - private String createdBy; - private String createdAt; - private String lastMessageAt; - private List memberIds; - - // 게임 연결 (참조만) - private String activeGameSessionId; // 현재 진행중인 게임 세션 ID - - // 게임 필드 모두 제거! - // - gameStatus, gameStartedBy, currentRound... 전부 GameSession으로 이동 -} -``` - -#### 3.3.3 게임 세션 API - -``` -# 게임 세션 생성 -POST /api/chat/rooms/{roomId}/games -Request: -{ - "gameType": "catchmind", - "settings": { - "totalRounds": 5, - "roundDuration": 60 - } -} - -Response: -{ - "gameSessionId": "game-abc123", - "roomId": "room-xyz", - "status": "WAITING", - "createdAt": "2024-01-20T10:00:00Z" -} - -# 게임 상태 조회 (재접속 시 필수!) -GET /api/games/{gameSessionId} - -Response: -{ - "gameSessionId": "game-abc123", - "roomId": "room-xyz", - "status": "PLAYING", - "currentRound": 2, - "totalRounds": 5, - "currentDrawerId": "user123", - "roundStartTime": 1705744800000, - "serverTime": 1705744830000, // 핵심! - "roundDuration": 60, - "scores": { - "user1": 150, - "user2": 120 - }, - "players": ["user1", "user2", "user3"] -} - -# 게임 시작 (기존 /start 명령어 대체) -POST /api/games/{gameSessionId}/start - -# 게임 종료 -POST /api/games/{gameSessionId}/stop -``` - ---- - -## 4. 프론트엔드 변경사항 - -### 4.1 Phase 1: 타이머 버그 수정 (즉시) - -```javascript -// useTimer.js - 독립적인 타이머 훅 -export function useTimer(roundStartTime, roundDuration, serverTime) { - const [remainingTime, setRemainingTime] = useState(roundDuration); - - useEffect(() => { - if (!roundStartTime || !roundDuration) return; - - // 서버-클라이언트 시간 차이 보정 - const timeOffset = serverTime ? (Date.now() - serverTime) : 0; - - const interval = setInterval(() => { - const adjustedNow = Date.now() - timeOffset; - const elapsed = Math.floor((adjustedNow - roundStartTime) / 1000); - const remaining = Math.max(0, roundDuration - elapsed); - setRemainingTime(remaining); - - if (remaining <= 0) { - clearInterval(interval); - } - }, 100); - - return () => clearInterval(interval); - }, [roundStartTime, roundDuration, serverTime]); - - return remainingTime; -} -``` - -### 4.2 Phase 2: 메시지 핸들러 분리 - -```javascript -// WebSocket 메시지 핸들러 -onMessage(event) { - const message = JSON.parse(event.data); - - switch (message.domain) { - case 'chat': - this.handleChatMessage(message); - break; - case 'game': - this.handleGameMessage(message); - break; - } -} - -handleChatMessage(message) { - switch (message.messageType) { - case 'TEXT': // 채팅 메시지 - case 'USER_JOIN': - case 'USER_LEAVE': - case 'SYSTEM': - } -} - -handleGameMessage(message) { - switch (message.messageType) { - case 'GAME_START': - case 'ROUND_START': - case 'DRAWING': - case 'CORRECT_ANSWER': - case 'SCORE_UPDATE': - } -} -``` - -### 4.3 Phase 3: 훅 분리 - -``` -src/domains/ -├── chat/ -│ ├── hooks/ -│ │ └── useChatWebSocket.js # 채팅만 처리 -│ └── components/ -│ ├── ChatMessages.jsx -│ └── ChatInput.jsx -│ -├── catchmind/ -│ ├── hooks/ -│ │ ├── useGameWebSocket.js # 게임만 처리 -│ │ ├── useGameState.js -│ │ └── useTimer.js -│ └── components/ -│ ├── DrawingCanvas.jsx -│ ├── ScoreBoard.jsx -│ └── Timer.jsx -│ -└── freetalk/ - └── pages/ - └── FreeTalkPage.jsx # chat + catchmind 조합 -``` - ---- - -## 5. 메시지 스펙 (최종) - -### 5.1 공통 메시지 구조 - -```json -{ - "domain": "chat" | "game", - "messageType": "...", - "data": { ... }, - "timestamp": 1705744800000 -} -``` - -### 5.2 채팅 메시지 - -| Type | 방향 | data 필드 | -|--------------|-----|-----------------------------------------------| -| `TEXT` | 양방향 | `messageId`, `userId`, `content`, `createdAt` | -| `USER_JOIN` | S→C | `userId`, `memberCount` | -| `USER_LEAVE` | S→C | `userId`, `memberCount` | -| `SYSTEM` | S→C | `content` | - -### 5.3 게임 메시지 - -| Type | 방향 | data 필드 | -|------------------|-----|---------------------------------------------------------------------------------------------------------------| -| `GAME_START` | S→C | `gameSessionId`, `totalRounds`, `currentDrawerId`, `roundStartTime`, `serverTime`, `roundDuration`, `players` | -| `GAME_END` | S→C | `gameSessionId`, `reason`, `finalScores`, `winner` | -| `ROUND_START` | S→C | `currentRound`, `currentDrawerId`, `roundStartTime`, `serverTime`, `roundDuration`, `currentWord`(출제자만) | -| `ROUND_END` | S→C | `currentRound`, `answer`, `scores` | -| `DRAWING` | 양방향 | `drawingData` | -| `DRAWING_CLEAR` | 양방향 | - | -| `GUESS` | C→S | `content` | -| `CORRECT_ANSWER` | S→C | `userId`, `score`, `elapsedTime` | -| `SCORE_UPDATE` | S→C | `scores`, `currentRound`, `totalRounds` | -| `HINT` | S→C | `hint` | - -### 5.4 ROUND_START 상세 (핵심!) - -```json -{ - "domain": "game", - "messageType": "ROUND_START", - "data": { - "gameSessionId": "game-abc123", - "currentRound": 2, - "totalRounds": 5, - "currentDrawerId": "user123", - "roundStartTime": 1705744800000, - "serverTime": 1705744800500, - "roundDuration": 60, - "currentWord": { - "wordId": "word-1", - "word": "apple" - } - }, - "timestamp": 1705744800500 -} -``` - -**중요:** `currentWord`는 출제자에게만 전송! - ---- - -## 6. 구현 일정 - -``` -Week 1: 긴급 버그 수정 -├── [BE] serverTime 필드 추가 (0.5일) -├── [FE] useTimer 훅 수정 (0.5일) -├── [BE] 메시지에 domain 필드 추가 (1일) -└── [FE] 메시지 핸들러 domain 분기 (0.5일) - -Week 2: 게임 세션 분리 (BE) -├── [BE] GameSession 모델 생성 -├── [BE] GameSessionRepository 구현 -├── [BE] GameService 리팩토링 -└── [BE] 게임 세션 API 구현 - -Week 3: 프론트엔드 리팩토링 -├── [FE] useChatWebSocket 분리 -├── [FE] useGameWebSocket 신규 -├── [FE] 컴포넌트 분리 -└── [FE/BE] 통합 테스트 - -Week 4: 안정화 및 추가 기능 -├── [BE] 게임 자동 종료 (7분) - Issue #417 -├── [BE] 재접속 시 게임 상태 복구 -└── [FE/BE] E2E 테스트 -``` - ---- - -## 7. 기대 효과 - -| 항목 | 현재 | 개선 후 | -|---------|-------------|------------------| -| 타이머 정확도 | 클라이언트 시계 의존 | 서버 시간 기준 동기화 | -| 재접속 | 게임 상태 유실 | 완전 복구 가능 | -| 테스트 | 채팅/게임 분리 불가 | 독립 테스트 가능 | -| 확장성 | 새 게임 추가 어려움 | gameType으로 확장 용이 | -| 유지보수 | 책임 혼재 | 명확한 책임 분리 | - ---- - -## 8. 즉시 적용 (백엔드 변경 전 프론트엔드 임시 조치) - -```javascript -// 백엔드 변경 전까지 프론트엔드에서 적용 가능한 임시 코드 - -onRoundStart: (data) => { - const roundData = data.data || data; - const now = Date.now(); - - // serverTime이 없으면 클라이언트 시간 사용 (임시) - const serverTime = roundData.serverTime || now; - let roundStartTime = roundData.roundStartTime || now; - - // roundStartTime이 미래 시간이면 현재로 보정 - if (roundStartTime > now + 1000) { - console.warn('Invalid roundStartTime, using current time'); - roundStartTime = now; - } - - setGameState((prev) => ({ - ...prev, - currentRound: roundData.currentRound, - currentDrawerId: roundData.currentDrawerId, - roundStartTime: roundStartTime, - serverTime: serverTime, - roundDuration: roundData.roundDuration || roundData.roundTimeLimit || 60, - })); -} -``` - ---- - -## 9. 결론 - -**우선순위:** - -1. **즉시 (이번 주)**: `serverTime` 추가 + `domain` 필드 추가 -2. **단기 (2주)**: GameSession 모델 분리 + API 구현 -3. **중기 (3-4주)**: FE/BE 완전 분리 + 자동 종료 + 재접속 복구 - -**핵심 원칙:** - -- 단일 WebSocket 엔드포인트 유지 (비용/복잡도) -- `domain` 필드로 채팅/게임 구분 -- `serverTime`으로 정확한 타이머 동기화 -- GameSession 독립 모델로 상태 관리 명확화 diff --git a/docs/CICD-IMPLEMENTATION-QNA.md b/docs/CICD-IMPLEMENTATION-QNA.md deleted file mode 100644 index e00c5a11..00000000 --- a/docs/CICD-IMPLEMENTATION-QNA.md +++ /dev/null @@ -1,421 +0,0 @@ -# CI/CD 파이프라인 구현 설명 및 면접 Q&A - -## 1. CI/CD 아키텍처 개요 - -``` -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ GitHub │───▶│ CodePipeline│───▶│ CodeBuild │───▶│CloudFormation│ -│ (Source) │ │ (Pipeline) │ │ (Build) │ │ (Deploy) │ -└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ - │ │ │ │ - │ ▼ ▼ ▼ - │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ - │ │ SNS │ │ S3 │ │ Lambda │ - │ │(Notification)│ │ (Artifacts) │ │ Functions │ - │ └─────────────┘ └─────────────┘ └─────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ prod 브랜치 Push/Merge │ -└─────────────────────────────────────────────────────────────────────┘ -``` - -## 2. 구성 요소 상세 설명 - -### 2.1 Source Stage (GitHub) - -- **트리거**: prod 브랜치에 Push 또는 PR Merge 시 자동 실행 -- **연결 방식**: AWS CodeConnections (구 CodeStar Connections) -- **아티팩트**: 소스 코드를 ZIP으로 압축하여 다음 스테이지로 전달 - -### 2.2 Build Stage (CodeBuild) - -- **런타임**: Amazon Linux 2, Java Corretto 21 -- **빌드 단계**: - 1. **Install**: SAM CLI 설치 - 2. **Pre-build**: Gradle 테스트 실행 (`./gradlew clean test`) - 3. **Build**: SAM build & package - 4. **Post-build**: 완료 로그 -- **캐싱**: Gradle 캐시를 S3에 저장하여 빌드 시간 단축 -- **리포트**: JUnit 테스트 결과, JaCoCo 코드 커버리지 리포트 - -### 2.3 Deploy Stage (CloudFormation) - -- **배포 방식**: CloudFormation CREATE_UPDATE -- **템플릿**: SAM으로 패키징된 `packaged-template.yaml` -- **기능**: CAPABILITY_IAM, CAPABILITY_AUTO_EXPAND - -### 2.4 Notification (SNS) - -- **이벤트**: 파이프라인 시작, 성공, 실패 시 이메일 알림 -- **구현**: CodeStar Notifications + SNS Topic - -## 3. 주요 파일 구조 - -``` -BE_Repository/ -├── cicd/ -│ └── pipeline.yaml # CloudFormation 파이프라인 템플릿 -├── ServerlessFunction/ -│ ├── buildspec.yml # CodeBuild 빌드 명세 -│ ├── samconfig.toml # SAM 배포 설정 -│ └── template.yaml # SAM 애플리케이션 템플릿 -``` - -## 4. IAM 역할 구성 - -| 역할 | 목적 | 주요 권한 | -|--------------------|---------------------|----------------------------------------| -| PipelineRole | CodePipeline 서비스 역할 | S3, CodeBuild, CloudFormation, SNS | -| CodeBuildRole | CodeBuild 서비스 역할 | S3, CloudWatch Logs, CodeBuild Reports | -| CloudFormationRole | 리소스 배포 역할 | AdministratorAccess (SAM 리소스 생성용) | - ---- - -## 5. 면접 예상 질문 및 답변 - -### Q1. CI/CD 파이프라인을 구축한 이유는 무엇인가요? - -**A1:** -수동 배포의 문제점을 해결하기 위해 CI/CD를 도입했습니다. - -1. **일관성**: 수동 배포 시 발생할 수 있는 휴먼 에러 방지 -2. **자동화**: 코드 푸시만으로 테스트-빌드-배포가 자동 실행 -3. **품질 보장**: 테스트 실패 시 배포가 중단되어 결함 있는 코드가 프로덕션에 배포되는 것을 방지 -4. **추적성**: 모든 배포 이력이 CodePipeline에 기록되어 문제 발생 시 원인 추적 용이 -5. **속도**: 반복적인 배포 작업 시간을 단축하여 개발 생산성 향상 - ---- - -### Q2. GitHub과 AWS CodePipeline을 어떻게 연동했나요? - -**A2:** -AWS CodeConnections(구 CodeStar Connections)를 사용하여 연동했습니다. - -```yaml -# pipeline.yaml의 Source Stage 설정 -- Name: Source - Actions: - - Name: GitHub - ActionTypeId: - Category: Source - Owner: AWS - Provider: CodeStarSourceConnection - Version: '1' - Configuration: - ConnectionArn: !Ref GitHubConnectionArn - FullRepositoryId: "Language-Study-Prooject/BE_Repository" - BranchName: "prod" - DetectChanges: true -``` - -**연동 과정:** - -1. AWS Console에서 CodeConnections 생성 -2. GitHub OAuth 앱 승인 -3. Connection ARN을 파이프라인에 설정 -4. `DetectChanges: true`로 설정하여 자동 트리거 활성화 - ---- - -### Q3. CodeBuild의 buildspec.yml에서 각 phase의 역할은 무엇인가요? - -**A3:** - -```yaml -phases: - install: # 빌드 환경 설정 - runtime-versions: - java: corretto21 - commands: - - pip3 install aws-sam-cli - - pre_build: # 테스트 실행 (품질 게이트) - commands: - - cd ServerlessFunction - - ./gradlew clean test - - build: # 실제 빌드 및 패키징 - commands: - - sam build - - sam package --s3-bucket ... --output-template-file packaged-template.yaml - - post_build: # 후처리 (로깅, 정리) - commands: - - echo "Build completed" -``` - -- **install**: 빌드에 필요한 런타임과 도구 설치 -- **pre_build**: 테스트 실행 - 실패 시 빌드 중단 (품질 게이트 역할) -- **build**: SAM 애플리케이션 빌드 및 S3에 패키징 -- **post_build**: 완료 로그 기록, 정리 작업 - ---- - -### Q4. 테스트가 실패하면 배포가 어떻게 되나요? - -**A4:** -테스트 실패 시 배포가 자동으로 중단됩니다. - -**작동 원리:** - -1. `pre_build` 단계에서 `./gradlew clean test` 실행 -2. 테스트 실패 시 Gradle이 exit code 1 반환 -3. CodeBuild가 비정상 종료로 판단하여 빌드 실패 처리 -4. CodePipeline의 Build Stage가 실패 상태가 됨 -5. Deploy Stage로 진행되지 않음 -6. SNS를 통해 실패 알림 이메일 발송 - -``` -Pipeline Flow: -Source ──▶ Build (테스트 실패) ──✗ Deploy - │ - ▼ - SNS 알림 발송 -``` - ---- - -### Q5. SAM과 CloudFormation의 관계는 무엇인가요? - -**A5:** -SAM(Serverless Application Model)은 CloudFormation의 확장입니다. - -**관계:** - -- SAM 템플릿은 CloudFormation 템플릿의 상위 집합 -- `sam build`/`sam package` 실행 시 SAM 템플릿이 표준 CloudFormation 템플릿으로 변환 -- 변환된 템플릿(`packaged-template.yaml`)을 CloudFormation이 배포 - -**SAM의 장점:** - -1. 간결한 문법: `AWS::Serverless::Function`으로 Lambda + API Gateway + IAM 역할 한번에 정의 -2. 로컬 테스트: `sam local invoke`로 Lambda 로컬 실행 가능 -3. 자동 패키징: 코드를 S3에 업로드하고 참조 자동 생성 - -```yaml -# SAM 템플릿 (간결) -Type: AWS::Serverless::Function -Properties: - Handler: handler.main - Runtime: java21 - Events: - Api: - Type: Api - Properties: - Path: /hello - Method: get - -# 변환된 CloudFormation (복잡) -# Lambda Function + API Gateway + IAM Role + Permission 등 여러 리소스로 확장 -``` - ---- - -### Q6. 배포 중 롤백은 어떻게 처리되나요? - -**A6:** -CloudFormation의 기본 롤백 기능을 활용합니다. - -**설정:** - -```yaml -# samconfig.toml -disable_rollback = false # 롤백 활성화 -``` - -**롤백 시나리오:** - -1. **배포 실패 시**: CloudFormation이 자동으로 이전 상태로 롤백 -2. **Lambda 오류 시**: - - 현재는 기본 롤백만 사용 - - 추가로 Canary/Linear 배포 설정 가능 (AWS CodeDeploy 연동) - -```yaml -# 점진적 배포 예시 (선택적 구현) -DeploymentPreference: - Type: Canary10Percent5Minutes # 10%에 5분간 배포 후 문제없으면 전체 배포 -``` - ---- - -### Q7. 파이프라인의 아티팩트는 어떻게 관리되나요? - -**A7:** -S3 버킷을 사용하여 아티팩트를 관리합니다. - -```yaml -ArtifactBucket: - Type: AWS::S3::Bucket - Properties: - BucketName: group2-englishstudy-pipeline-artifacts - VersioningConfiguration: - Status: Enabled # 버전 관리 활성화 - BucketEncryption: - ServerSideEncryptionConfiguration: - - ServerSideEncryptionByDefault: - SSEAlgorithm: AES256 # 암호화 -``` - -**아티팩트 종류:** - -1. **SourceArtifact**: GitHub에서 가져온 소스 코드 ZIP -2. **BuildArtifact**: 빌드된 `packaged-template.yaml` -3. **Cache**: Gradle 캐시 (빌드 시간 단축용) - ---- - -### Q8. 파이프라인 알림은 어떻게 구현했나요? - -**A8:** -AWS CodeStar Notifications와 SNS를 연동하여 구현했습니다. - -```yaml -# SNS Topic 생성 -NotificationTopic: - Type: AWS::SNS::Topic - Properties: - TopicName: cicd-pipeline-notifications - -# 이메일 구독 -EmailSubscription: - Type: AWS::SNS::Subscription - Properties: - TopicArn: !Ref NotificationTopic - Protocol: email - Endpoint: !Ref NotificationEmail - -# 알림 규칙 -PipelineNotificationRule: - Type: AWS::CodeStarNotifications::NotificationRule - Properties: - EventTypeIds: - - codepipeline-pipeline-pipeline-execution-started - - codepipeline-pipeline-pipeline-execution-succeeded - - codepipeline-pipeline-pipeline-execution-failed - Targets: - - TargetType: SNS - TargetAddress: !Ref NotificationTopic -``` - ---- - -### Q9. CI/CD 구축 중 겪은 문제와 해결 방법은? - -**A9:** - -**문제 1: Gradle Wrapper를 찾을 수 없음** - -- 원인: `.gitignore`에서 `gradle/` 폴더 전체가 제외됨 -- 해결: `.gitignore` 수정하여 `!gradle/wrapper/` 예외 추가 - -**문제 2: JAVA_HOME 환경 변수 오류** - -- 원인: CodeBuild에서 JAVA_HOME을 수동 설정했으나 경로 불일치 -- 해결: `runtime-versions: java: corretto21`만 사용하고 JAVA_HOME 수동 설정 제거 - -**문제 3: SAM package S3 버킷 참조 오류** - -- 원인: 환경 변수를 사용한 멀티라인 명령어에서 변수 치환 실패 -- 해결: 단일 라인으로 버킷 이름 직접 지정 - -**문제 4: Lambda 환경 변수 누락** - -- 원인: WebSocket Connect 함수에 `WEBSOCKET_ENDPOINT` 환경 변수 미설정 -- 해결: `template.yaml`에 환경 변수 추가 - ---- - -### Q10. 현재 CI/CD의 개선점이 있다면? - -**A10:** - -1. **테스트 커버리지 게이트** - - 현재: 테스트 실행만 함 - - 개선: 커버리지 80% 미만 시 빌드 실패 설정 - -2. **점진적 배포 (Canary/Blue-Green)** - - 현재: 전체 교체 배포 - - 개선: Lambda Alias + CodeDeploy로 Canary 배포 구현 - -3. **다중 환경 지원** - - 현재: prod 단일 환경 - - 개선: dev, staging, prod 분리 및 승인 단계 추가 - -4. **보안 스캔** - - 개선: 의존성 취약점 스캔 (OWASP Dependency-Check) 추가 - -5. **성능 테스트** - - 개선: 배포 전 부하 테스트 단계 추가 - ---- - -### Q11. IaC(Infrastructure as Code)를 사용한 이유는? - -**A11:** -파이프라인 자체도 CloudFormation 템플릿(`pipeline.yaml`)으로 정의했습니다. - -**장점:** - -1. **버전 관리**: 인프라 변경 이력을 Git으로 추적 -2. **재현성**: 동일한 파이프라인을 다른 프로젝트/계정에 쉽게 복제 -3. **리뷰 가능**: 인프라 변경도 코드 리뷰 프로세스 적용 -4. **자동화**: 수동 콘솔 작업 없이 `aws cloudformation deploy`로 생성/업데이트 -5. **문서화**: 템플릿 자체가 인프라 문서 역할 - ---- - -### Q12. CodeBuild와 Jenkins의 차이점은? - -**A12:** - -| 항목 | CodeBuild | Jenkins | -|--------|---------------|----------------------| -| 관리 | 완전 관리형 (서버리스) | 자체 서버 운영 필요 | -| 비용 | 빌드 시간 기반 과금 | 서버 운영 비용 | -| 확장성 | 자동 확장 | 수동 확장 필요 | -| AWS 통합 | 네이티브 통합 | 플러그인 필요 | -| 커스터마이징 | buildspec.yml | Jenkinsfile (Groovy) | -| 플러그인 | 제한적 | 풍부한 생태계 | - -**선택 이유:** - -- AWS 서비스 중심 아키텍처에서 네이티브 통합의 이점 -- 서버 관리 부담 없음 -- SAM/CloudFormation과의 원활한 연동 - ---- - -## 6. 핵심 용어 정리 - -| 용어 | 설명 | -|-------------------------------------|------------------------------------------------| -| CI (Continuous Integration) | 코드 변경을 자주 통합하고 자동 테스트하는 방식 | -| CD (Continuous Delivery/Deployment) | 자동으로 프로덕션까지 배포하는 방식 | -| Pipeline | 소스-빌드-배포로 이어지는 자동화된 워크플로우 | -| Artifact | 빌드 결과물 (패키징된 코드, 템플릿 등) | -| buildspec.yml | CodeBuild의 빌드 명세 파일 | -| SAM | Serverless Application Model - 서버리스 앱 정의 프레임워크 | -| IaC | Infrastructure as Code - 코드로 인프라 관리 | - ---- - -## 7. 참고 명령어 - -```bash -# 파이프라인 생성 -aws cloudformation deploy \ - --template-file cicd/pipeline.yaml \ - --stack-name group2-cicd-pipeline \ - --capabilities CAPABILITY_NAMED_IAM \ - --parameter-overrides NotificationEmail=your@email.com - -# 파이프라인 상태 확인 -aws codepipeline get-pipeline-state --name group2-englishstudy-pipeline - -# 수동 파이프라인 실행 -aws codepipeline start-pipeline-execution --name group2-englishstudy-pipeline - -# 빌드 로그 확인 -aws logs tail /aws/codebuild/group2-englishstudy-build --follow -``` diff --git a/docs/FRONTEND-API-GUIDE.md b/docs/FRONTEND-API-GUIDE.md deleted file mode 100644 index 697d406a..00000000 --- a/docs/FRONTEND-API-GUIDE.md +++ /dev/null @@ -1,365 +0,0 @@ -# 프론트엔드 전달사항 - 채팅/게임 API 가이드 - -## 1. 아키텍처 구조 (업데이트됨) - -### 채팅방과 게임방 분리 - -``` -RoomType enum -├── CHAT ("chat") - 일반 채팅방 -└── GAME ("game") - 게임방 (캐치마인드 등) - -RoomStatus enum -├── WAITING ("waiting") - 대기 중 -├── PLAYING ("playing") - 게임 진행 중 -└── FINISHED ("finished") - 종료됨 -``` - -### GSI1SK 인덱스 설계 - -``` -GSI1PK: "ROOMS" (고정) -GSI1SK: {type}#{gameType}#{status}#{level}#{createdAt} - -예시: -- CHAT#-#WAITING#beginner#2026-01-22T10:00:00Z (일반 채팅방) -- GAME#CATCHMIND#WAITING#intermediate#2026-01-22T10:00:00Z (대기중 게임방) -- GAME#CATCHMIND#PLAYING#advanced#2026-01-22T10:00:00Z (진행중 게임방) -``` - -**핵심**: DB 레벨에서 `type`, `gameType`, `status`, `level` 조합으로 필터링 가능 - ---- - -## 2. 방 타입 (RoomType) - -| 타입 | 코드 | 설명 | -|--------|--------|---------------| -| `CHAT` | `chat` | 일반 채팅방 | -| `GAME` | `game` | 게임방 (캐치마인드 등) | - ---- - -## 3. 방 상태 (RoomStatus) - -| 상태 | 코드 | 설명 | 게임 시작 가능 | -|------------|------------|---------|:--------:| -| `WAITING` | `waiting` | 대기 중 | O | -| `PLAYING` | `playing` | 게임 진행 중 | X | -| `FINISHED` | `finished` | 게임 종료됨 | O | - ---- - -## 4. REST API 엔드포인트 - -### 채팅방 API (`/api/chat/rooms`) - -| Method | Endpoint | 설명 | -|--------|-------------------------|---------------------| -| POST | `/rooms` | 채팅방/게임방 생성 | -| GET | `/rooms` | 방 목록 조회 (필터 지원) | -| GET | `/rooms/{roomId}` | 방 상세 조회 | -| POST | `/rooms/{roomId}/join` | 방 입장 (roomToken 발급) | -| POST | `/rooms/{roomId}/leave` | 방 퇴장 | -| DELETE | `/rooms/{roomId}` | 방 삭제 (방장만) | - -### 게임 API (`/api/game`) - -| Method | Endpoint | 설명 | -|--------|-------------------------------|----------| -| POST | `/rooms/{roomId}/game/start` | 게임 시작 | -| POST | `/rooms/{roomId}/game/stop` | 게임 중단 | -| GET | `/rooms/{roomId}/game/status` | 게임 상태 조회 | -| GET | `/rooms/{roomId}/game/scores` | 점수판 조회 | - ---- - -## 5. 방 목록 조회 쿼리 파라미터 (업데이트됨) - -``` -GET /api/chat/rooms?type=GAME&gameType=CATCHMIND&status=WAITING&level=intermediate&limit=10&cursor=xxx -``` - -| 파라미터 | 타입 | 설명 | 예시 | -|------------|--------|----------------------|----------------------------------------| -| `type` | string | 방 타입 필터 | `CHAT`, `GAME` | -| `gameType` | string | 게임 타입 | `CATCHMIND` | -| `status` | string | 상태 필터 | `WAITING`, `PLAYING`, `FINISHED` | -| `level` | string | 난이도 필터 | `beginner`, `intermediate`, `advanced` | -| `limit` | number | 조회 개수 (기본 10, 최대 20) | | -| `cursor` | string | 페이지네이션 커서 | | - -### 필터 조합 예시 - -```bash -# 대기 중인 게임방만 -GET /api/chat/rooms?type=GAME&status=WAITING - -# 캐치마인드 게임방만 -GET /api/chat/rooms?type=GAME&gameType=CATCHMIND - -# 초급 난이도 채팅방 -GET /api/chat/rooms?type=CHAT&level=beginner - -# 진행 중인 고급 게임방 -GET /api/chat/rooms?type=GAME&status=PLAYING&level=advanced -``` - -### 응답 예시 - -```json -{ - "success": true, - "message": "Rooms retrieved", - "data": { - "rooms": [ - { - "roomId": "abc-123", - "name": "초보자 영어 스터디", - "type": "GAME", - "gameType": "CATCHMIND", - "status": "WAITING", - "level": "beginner", - "currentMembers": 3, - "maxMembers": 6, - "currentRound": 0, - "totalRounds": 5, - "createdAt": "2026-01-22T10:00:00Z" - } - ], - "nextCursor": "eyJQSyI6Ik...", - "hasMore": true - } -} -``` - ---- - -## 6. 방 생성 요청 (업데이트됨) - -### 채팅방 생성 - -```json -{ - "name": "영어 스터디 채팅방", - "type": "CHAT", - "level": "beginner", - "maxMembers": 6, - "description": "초보자를 위한 영어 채팅방" -} -``` - -### 게임방 생성 - -```json -{ - "name": "캐치마인드 게임", - "type": "GAME", - "gameType": "CATCHMIND", - "level": "intermediate", - "maxMembers": 8, - "description": "영어 단어 맞추기 게임" -} -``` - ---- - -## 7. 프론트엔드에서 방 타입 구분 - -### 방법 1: API 필터 사용 (권장) - -```javascript -// 게임방만 조회 -const gameRooms = await fetch('/api/chat/rooms?type=GAME'); - -// 대기 중인 게임방만 -const waitingGames = await fetch('/api/chat/rooms?type=GAME&status=WAITING'); - -// 채팅방만 -const chatRooms = await fetch('/api/chat/rooms?type=CHAT'); -``` - -### 방법 2: 전체 조회 후 클라이언트 필터링 - -```javascript -const allRooms = await fetchRooms(); - -// 게임방만 -const gameRooms = allRooms.filter(room => room.type === 'GAME'); - -// 채팅방만 -const chatRooms = allRooms.filter(room => room.type === 'CHAT'); - -// 대기 중인 방만 -const waitingRooms = allRooms.filter(room => room.status === 'WAITING'); -``` - ---- - -## 8. WebSocket 연결 - -### 채팅/게임 WebSocket - -``` -wss://t378dif43l.execute-api.ap-northeast-2.amazonaws.com/dev?roomToken={roomToken} -``` - -### Grammar WebSocket - -``` -wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} -``` - -### 연결 순서 - -1. `POST /rooms/{roomId}/join` → `roomToken` 발급 -2. WebSocket 연결 시 `roomToken` 쿼리 파라미터로 전달 - ---- - -## 9. WebSocket 메시지 타입 (messageType) - -| 코드 | 타입 | 설명 | -|------------------|--------|---------------| -| `MSG` | 일반 메시지 | 일반 채팅 메시지 | -| `VOICE` | 음성 메시지 | 음성 채팅 | -| `JOIN` | 입장 알림 | 사용자 입장 | -| `LEAVE` | 퇴장 알림 | 사용자 퇴장 | -| `GAME_START` | 게임 시작 | 게임 시작 알림 | -| `GAME_END` | 게임 종료 | 게임 종료 + 최종 순위 | -| `ROUND_START` | 라운드 시작 | 새 라운드 시작 | -| `ROUND_END` | 라운드 종료 | 정답 공개 | -| `ANSWER_CORRECT` | 정답 | 정답 맞춤 | -| `HINT` | 힌트 | 힌트 제공 | -| `SKIP` | 스킵 | 라운드 스킵 | -| `SYSTEM` | 시스템 | 시스템 메시지 | - ---- - -## 10. 게임 명령어 (WebSocket) - -채팅 메시지로 게임 명령어 전송: - -| 명령어 | 설명 | 권한 | -|----------|--------|-----------------| -| `/start` | 게임 시작 | 방장 (2명 이상 접속 시) | -| `/stop` | 게임 중단 | 방장 또는 게임 시작자 | -| `/skip` | 라운드 스킵 | 누구나 | -| `/hint` | 힌트 제공 | 출제자만 | -| `/score` | 점수 확인 | 누구나 | - ---- - -## 11. 게임 시작 응답 예시 - -```json -{ - "messageId": "uuid", - "roomId": "abc-123", - "userId": "SYSTEM", - "content": "게임 시작!\n총 5 라운드\n\n라운드 1 시작!\n출제자: user-456", - "messageType": "GAME_START", - "createdAt": "2026-01-22T10:00:00Z", - "serverTime": "2026-01-22T10:00:00Z", - "domain": "GAME", - "type": "GAME", - "status": "PLAYING", - "currentRound": 1, - "totalRounds": 5, - "currentDrawerId": "user-456", - "drawerOrder": ["user-456", "user-789", "user-123"] -} -``` - ---- - -## 12. 정답 체크 로직 - -- **한국어** 또는 **영어** 둘 다 정답으로 인정 -- 대소문자 구분 없음 -- 공백 무시 - -### 점수 계산 - -``` -기본 점수: 10점 -시간 보너스: (제한시간 - 경과시간) * 0.5 -연속 정답 보너스: 연속정답수 * 2 - -총점 = 기본점수 + 시간보너스 + 연속정답보너스 -``` - ---- - -## 13. 게임 설정 - -| 설정 | 기본값 | 환경변수 | -|--------------|----------|---------------------------------| -| 총 라운드 수 | 5 | `GAME_TOTAL_ROUNDS` | -| 라운드 제한 시간(초) | 60 | `GAME_ROUND_TIME_LIMIT` | -| 빠른 정답 기준(ms) | 5000 | `GAME_QUICK_GUESS_THRESHOLD_MS` | -| 게임 전체 제한(초) | 420 (7분) | `GAME_TIME_LIMIT_SECONDS` | - ---- - -## 14. 주의사항 - -1. **roomToken은 한 번만 사용**: 재연결 시 새로 발급 필요 -2. **WebSocket 연결 실패 시**: `POST /rooms/{roomId}/join`으로 새 토큰 발급 -3. **게임 중 퇴장**: 자동으로 다음 출제자로 넘어감 (2명 미만 시 게임 종료) -4. **출제자는 정답 입력 불가**: 본인이 출제자일 때 채팅해도 정답 체크 안됨 -5. **방 타입 변경 불가**: 생성 시 지정한 type은 변경 불가 - ---- - -## 15. 에러 코드 - -| 코드 | HTTP | 설명 | -|--------------|------|--------------| -| `ROOM_001` | 404 | 채팅방 없음 | -| `ROOM_002` | 409 | 채팅방 이미 존재 | -| `ROOM_003` | 400 | 채팅방 인원 초과 | -| `ROOM_004` | 400 | 채팅방 종료됨 | -| `ROOM_005` | 401 | 비밀번호 틀림 | -| `ROOM_006` | 403 | 방장 권한 없음 | -| `MEMBER_001` | 403 | 채팅방 멤버 아님 | -| `MEMBER_002` | 409 | 이미 참여 중 | -| `GAME_001` | 400 | 게임 시작 실패 | -| `GAME_002` | 400 | 게임 중단 실패 | -| `GAME_003` | 400 | 게임 진행 중 아님 | -| `GAME_004` | 409 | 게임 이미 진행 중 | -| `GAME_005` | 403 | 게임 시작자 아님 | -| `GAME_006` | 404 | 게임 없음 | -| `GAME_007` | 400 | 채팅방에서 게임 불가 | -| `GAME_008` | 400 | 게임 재시작 불가 | -| `GAME_009` | 403 | 방장만 게임 시작 가능 | - ---- - -## 16. UI 구현 가이드 - -### 탭 구조 (권장) - -``` -[전체] [채팅방] [게임방] -``` - -### 게임방 상태 표시 - -``` -대기 중 (WAITING) → 초록색 뱃지 "참여 가능" -진행 중 (PLAYING) → 빨간색 뱃지 "게임 중" -종료됨 (FINISHED) → 회색 뱃지 "종료" -``` - -### 게임방 카드 정보 - -``` -┌─────────────────────────────┐ -│ 캐치마인드 - 영어 단어 맞추기 │ -│ [게임방] [intermediate] │ -│ │ -│ 👥 3/8명 🎮 대기 중 │ -│ 🕐 2026-01-22 10:00 │ -└─────────────────────────────┘ -``` diff --git a/docs/MIDTERM-REPORT.md b/docs/MIDTERM-REPORT.md deleted file mode 100644 index 9a6bb1d1..00000000 --- a/docs/MIDTERM-REPORT.md +++ /dev/null @@ -1,439 +0,0 @@ -# 영어 학습 플랫폼 백엔드 최종 성과 보고서 - -## 프로젝트 개요 - -| 항목 | 내용 | -|-------|--------------------------------------------------------------------------| -| 프로젝트명 | 영어 회화 학습 플랫폼 (MZC 2nd Project) | -| 담당 영역 | Vocabulary, Chatting, Grammar, Badge, Stats, Common | -| 기술 스택 | Java 21, AWS Lambda, DynamoDB, API Gateway WebSocket, Bedrock, Polly, S3 | -| 배포 환경 | AWS SAM, CloudFormation | - ---- - -## 1. 전체 시스템 아키텍처 - -```mermaid -flowchart TB - subgraph Client["클라이언트"] - WEB[Web App] - end - - subgraph Gateway["API Gateway"] - REST[REST API] - WS[WebSocket API] - GRAMMAR_WS[Grammar WebSocket] - end - - subgraph Lambda["AWS Lambda - 도메인별 핸들러"] - direction TB - VOCAB[Vocabulary
단어/일일학습/테스트] - CHAT[Chatting
실시간 채팅/게임] - GRAMMAR[Grammar
문법 체크/스트리밍] - STATS[Stats
통계 집계] - BADGE[Badge
배지 시스템] - USER[User
사용자 관리] - end - - subgraph AI["AI Services"] - BEDROCK[AWS Bedrock
Claude 3.5 Sonnet] - POLLY[AWS Polly
TTS] - end - - subgraph Data["Data Layer"] - DYNAMO_VOCAB[(DynamoDB
Vocab Table)] - DYNAMO_CHAT[(DynamoDB
Chat Table)] - S3[(S3
음성/뱃지 이미지)] - STREAMS[DynamoDB Streams] - end - - WEB --> REST - WEB --> WS - WEB --> GRAMMAR_WS - REST --> VOCAB - REST --> CHAT - REST --> GRAMMAR - REST --> BADGE - REST --> STATS - REST --> USER - WS --> CHAT - GRAMMAR_WS --> GRAMMAR - VOCAB --> DYNAMO_VOCAB - VOCAB --> POLLY - VOCAB --> S3 - CHAT --> DYNAMO_CHAT - CHAT --> BEDROCK - GRAMMAR --> DYNAMO_VOCAB - GRAMMAR --> BEDROCK - STATS --> DYNAMO_VOCAB - BADGE --> DYNAMO_VOCAB - BADGE --> S3 - STREAMS -->|이벤트 트리거| STATS - STATS -->|배지 부여| BADGE -``` - ---- - -## 2. 주요 기능 구현 - -### 2.1 Vocabulary Domain (단어 학습) - -#### 2.1.1 일일 학습 시스템 (Daily Study) - -```mermaid -flowchart LR - subgraph DailyStudy["일일 학습 흐름"] - A[오늘의 단어 조회] --> B{기존 학습 존재?} - B -->|Yes| C[기존 학습 반환] - B -->|No| D[새 단어 50개 + 복습 5개 생성] - D --> E[학습 진행] - E --> F[단어별 학습 완료 처리] - F --> G{50개 완료?} - G -->|Yes| H[isCompleted = true] - end -``` - -**주요 기능:** - -- 레벨별 신규 단어 50개 + 복습 단어 5개 자동 선정 -- 학습 진행도 실시간 추적 (learnedCount/totalWords) -- 일일 학습 완료 시 isCompleted 플래그 설정 - -#### 2.1.2 SM-2 Spaced Repetition 알고리즘 - -```mermaid -stateDiagram-v2 - [*] --> NEW: 단어 추가 - NEW --> LEARNING: 첫 학습 - LEARNING --> LEARNING: 오답 - LEARNING --> REVIEWING: 2회 연속 정답 - REVIEWING --> LEARNING: 오답 - REVIEWING --> MASTERED: 5회 연속 정답 - MASTERED --> LEARNING: 오답 - MASTERED --> MASTERED: 정답 유지 -``` - -**구현 특징:** - -- State 패턴으로 학습 상태 전이 관리 -- easeFactor 동적 조정 (1.3 ~ 2.5) -- 복습 간격 자동 계산 (1일 → 6일 → interval * easeFactor) - -#### 2.1.3 TTS 음성 생성 - -- AWS Polly 연동 (남성/여성 음성) -- S3 캐싱으로 중복 생성 방지 -- 단어 + 예문 음성 생성 - ---- - -### 2.2 Chatting Domain (실시간 채팅 & 게임) - -#### 2.2.1 WebSocket 채팅 - -```mermaid -sequenceDiagram - participant Client - participant REST as REST API - participant WS as WebSocket API - participant DB as DynamoDB - Note over Client, DB: Phase 1: 방 입장 토큰 발급 - Client ->> REST: POST /rooms/{id}/join - REST ->> DB: RoomToken 저장 (TTL: 5분) - REST -->> Client: roomToken 반환 - Note over Client, DB: Phase 2: WebSocket 연결 - Client ->> WS: $connect?roomToken={token} - WS ->> DB: 토큰 검증 + Connection 저장 - WS -->> Client: 연결 성공 - Note over Client, DB: Phase 3: 메시지 송수신 - Client ->> WS: sendmessage (채팅) - WS ->> DB: 메시지 저장 + 브로드캐스트 -``` - -**주요 기능:** - -- RoomToken 기반 인증 (TTL 5분) -- BCrypt 비밀방 암호화 -- 슬래시 명령어 시스템 (/member, /game, /skip, /hint 등) -- Connection 자동 정리 (TTL + 실패 시 삭제) - -#### 2.2.2 캐치마인드 게임 - -```mermaid -flowchart TB - subgraph Game["캐치마인드 게임 흐름"] - START["#47;game 명령어"] --> INIT["게임 초기화
출제 순서 셔플"] - INIT --> ROUND[라운드 시작
출제자 + 단어 선정] - ROUND --> DRAW[출제자 그림 그리기] - DRAW --> GUESS[참가자 정답 입력] - GUESS --> CHECK{정답?} - CHECK -->|Yes| SCORE[점수 계산
시간보너스 + 연속정답보너스] - CHECK -->|No| GUESS - SCORE --> ALLCORRECT{전원 정답?} - ALLCORRECT -->|Yes| NEXTROUND - ALLCORRECT -->|No| TIMEOUT{시간 초과?} - TIMEOUT -->|Yes| NEXTROUND[다음 라운드] - TIMEOUT -->|No| GUESS - NEXTROUND --> LASTROUND{마지막 라운드?} - LASTROUND -->|Yes| END[게임 종료
순위 발표] - LASTROUND -->|No| ROUND - end -``` - -**점수 계산:** - -``` -점수 = 기본점수(10) + 시간보너스((60-경과초)*0.5) + 연속정답보너스(streak*2) -출제자 보너스 = 정답자당 5점 -``` - -**주요 기능:** - -- 실시간 점수 브로드캐스트 -- 연속 정답 스트릭 시스템 -- 접속자 변동 시 출제자 자동 재선정 -- 라운드별 순위 표시 - ---- - -### 2.3 Grammar Domain (문법 체크) - -#### 2.3.1 AI 스트리밍 응답 - -```mermaid -sequenceDiagram - participant Client - participant WS as Grammar WebSocket - participant Handler as GrammarStreamingHandler - participant Bedrock as AWS Bedrock - Client ->> WS: 문법 체크 요청 - WS ->> Handler: Lambda 호출 - Handler ->> Bedrock: 스트리밍 요청 (Claude 3.5 Sonnet) - - loop 청크 단위 응답 - Bedrock -->> Handler: 텍스트 청크 - Handler -->> WS: 실시간 전송 - WS -->> Client: 즉시 표시 - end - - Handler -->> Client: [DONE] 완료 - Handler ->> DB: 피드백 저장 -``` - -**주요 기능:** - -- Claude 3.5 Sonnet 모델 사용 -- 스트리밍으로 체감 대기 시간 80% 감소 -- 레벨별 맞춤 프롬프트 (BEGINNER: 한국어 번역 포함) -- 대화 히스토리 저장으로 문맥 유지 -- 피드백 영구 저장 (DynamoDB) - ---- - -### 2.4 Stats Domain (학습 통계) - -```mermaid -flowchart LR - subgraph StatsTypes["통계 유형"] - DAILY["일별 통계
#47;stats#47;daily"] - WEEKLY["주별 통계
#47;stats#47;weekly"] - MONTHLY["월별 통계
#47;stats#47;monthly"] - TOTAL["전체 통계
#47;stats#47;total"] - HISTORY["히스토리
#47;stats#47;history"] - end -``` - -**통계 항목:** - -| 필드 | 설명 | -|-------------------|-------------| -| testsCompleted | 완료한 테스트 수 | -| questionsAnswered | 답변한 문제 수 | -| correctAnswers | 정답 수 | -| incorrectAnswers | 오답 수 | -| successRate | 정답률 (%) | -| newWordsLearned | 새로 학습한 단어 수 | -| wordsReviewed | 복습한 단어 수 | -| currentStreak | 현재 연속 학습일 | -| longestStreak | 최장 연속 학습일 | -| gamesPlayed | 참여한 게임 수 | -| gamesWon | 1등 횟수 | -| totalGameScore | 누적 게임 점수 | - -**DynamoDB Streams 기반 비동기 집계:** - -- 테스트 결과 저장 시 자동 트리거 -- API 응답과 분리되어 응답 속도 향상 - ---- - -### 2.5 Badge Domain (배지 시스템) - -```mermaid -flowchart TB - subgraph BadgeSystem["배지 시스템"] - TRIGGER[통계 업데이트] --> CHECK[배지 조건 체크] - CHECK --> AWARD{조건 달성?} - AWARD -->|Yes| SAVE[배지 부여 + 저장] - AWARD -->|No| END[종료] - SAVE --> NOTIFY[프론트엔드 조회] - end -``` - -**배지 종류:** - -| Badge Type | 이름 | 조건 | -|----------------------|---------|------------| -| FIRST_STEP | 첫 걸음 | 첫 학습 완료 | -| STREAK_3, 7, 30 | 연속 학습 | N일 연속 학습 | -| WORDS_100, 500, 1000 | 단어 학습 | N개 단어 학습 | -| PERFECT_SCORE | 완벽주의자 | 테스트 만점 | -| ACCURACY_90 | 정확도 달인 | 전체 정확도 90% | -| GAME_FIRST_PLAY | 첫 게임 | 첫 게임 참여 | -| GAME_10_WINS | 게임 10승 | 10번 1등 | -| QUICK_GUESSER | 번개 정답 | 5초 내 정답 | -| PERFECT_DRAWER | 완벽한 출제자 | 전원 정답 유도 | - -**기술적 특징:** - -- S3 Presigned URL로 배지 이미지 제공 (1시간 유효) -- 획득/미획득 배지 + 진행도 표시 - ---- - -## 3. 기술적 성과 - -### 3.1 아키텍처 패턴 - -| 패턴 | 적용 영역 | 효과 | -|------------------|----------|----------------------------| -| **CQRS** | 전 도메인 | 읽기/쓰기 책임 분리, 테스트 용이성 | -| **State** | 단어 학습 상태 | 복잡한 조건문 제거, 확장성 | -| **Factory** | AI 서비스 | 서비스 교체 용이 (Claude ↔ Llama) | -| **Event-Driven** | 통계/배지 | 느슨한 결합, 비동기 처리 | - -### 3.2 DynamoDB 설계 - -**Single Table Design:** - -- Vocab Table: 단어, 사용자단어, 테스트, 일일학습, 통계, 배지, 문법 -- Chat Table: 채팅방, 메시지, 연결, 게임라운드 - -**GSI 구성:** - -| GSI | 용도 | -|------|---------------------| -| GSI1 | 레벨별 단어 조회, 복습 예정 단어 | -| GSI2 | 카테고리별 단어, 상태별 사용자단어 | -| GSI3 | 북마크 단어 조회 | - -### 3.3 보안 - -- Cognito 인증 (idToken) -- WebSocket RoomToken 인증 (TTL 5분) -- BCrypt 비밀방 암호화 -- S3 Presigned URL (배지 이미지) - -### 3.4 성능 최적화 - -| 최적화 | 효과 | -|--------------------------|-------------------------| -| TTS S3 캐싱 | Polly API 호출 90% 절감 | -| 배치 처리 | 최대 100개 단어 일괄 처리 | -| Strongly Consistent Read | 데이터 정합성 보장 | -| DynamoDB Streams | 비동기 통계 집계로 응답 속도 50% 향상 | -| AI 스트리밍 | 체감 대기 시간 80% 감소 | - ---- - -## 4. API 엔드포인트 요약 - -### REST API (https://gc8l9ijhzc.execute-api.ap-northeast-2.amazonaws.com/dev) - -| Method | Path | 설명 | -|--------|-------------------------------------|-----------| -| GET | /vocab/words | 단어 목록 조회 | -| POST | /vocab/words | 단어 등록 | -| GET | /vocab/daily | 오늘의 학습 단어 | -| POST | /vocab/daily/words/{wordId}/learned | 단어 학습 완료 | -| POST | /vocab/tests | 테스트 생성 | -| POST | /vocab/tests/{testId}/submit | 테스트 제출 | -| GET | /stats/daily | 일별 통계 | -| GET | /stats/weekly | 주별 통계 | -| GET | /stats/monthly | 월별 통계 | -| GET | /stats/total | 전체 통계 | -| GET | /stats/history?limit=100 | 통계 히스토리 | -| GET | /badges | 전체 배지 목록 | -| GET | /badges/earned | 획득한 배지 | -| GET | /rooms | 채팅방 목록 | -| POST | /rooms | 채팅방 생성 | -| POST | /rooms/{roomId}/join | 채팅방 입장 | -| POST | /grammar/check | 문법 체크 | - -### WebSocket API - -| Endpoint | 설명 | -|---------------------------------------------------------------|---------| -| wss://t378dif43l.execute-api.ap-northeast-2.amazonaws.com/dev | 채팅/게임 | -| wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev | 문법 스트리밍 | - ---- - -## 5. 프로젝트 구조 - -``` -ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/ -├── common/ # 공통 모듈 -│ ├── config/ # AWS 클라이언트 (싱글톤) -│ ├── router/ # HandlerRouter, Route -│ ├── exception/ # 예외 처리 체계 -│ ├── dto/ # PaginatedResult, ErrorInfo -│ └── util/ # ResponseGenerator, CursorUtil -│ -├── domain/ -│ ├── vocabulary/ # 단어 학습 도메인 -│ │ ├── handler/ # Word, UserWord, Test, DailyStudy 핸들러 -│ │ ├── service/ # CQRS 서비스 (Command/Query) -│ │ ├── repository/ # DynamoDB 레포지토리 -│ │ ├── model/ # Word, UserWord, TestResult, DailyStudy -│ │ └── state/ # NEW, LEARNING, REVIEWING, MASTERED -│ │ -│ ├── chatting/ # 채팅 도메인 -│ │ ├── handler/ # REST + WebSocket 핸들러 -│ │ ├── service/ # ChatRoom, Game, Command 서비스 -│ │ └── model/ # ChatRoom, Connection, GameRound -│ │ -│ ├── grammar/ # 문법 체크 도메인 -│ │ ├── handler/ # REST + 스트리밍 핸들러 -│ │ ├── service/ # GrammarCheck, Conversation 서비스 -│ │ └── factory/ # BedrockGrammarCheckFactory -│ │ -│ ├── stats/ # 통계 도메인 -│ │ ├── handler/ # UserStats, Streams 핸들러 -│ │ └── repository/ # UserStatsRepository -│ │ -│ └── badge/ # 배지 도메인 -│ ├── handler/ # BadgeHandler -│ └── service/ # BadgeService -``` - ---- - -## 6. 성과 요약 - -| 카테고리 | 성과 | -|------------------|------------------------------------| -| **Lambda 함수** | 26개 | -| **API 엔드포인트** | REST 40+, WebSocket 2 | -| **DynamoDB 테이블** | 2개 (Single Table Design) | -| **GSI** | 5개 | -| **아키텍처 패턴** | CQRS, State, Factory, Event-Driven | -| **AI 연동** | Bedrock Claude 3.5 Sonnet (문법/대화) | -| **TTS** | AWS Polly (남성/여성 음성) | -| **실시간 통신** | WebSocket (채팅/게임/문법 스트리밍) | -| **인증** | Cognito + RoomToken | - ---- - -**작성일:** 2026-01-16 -**팀:** MZC 2nd Project Team / SMJ diff --git a/docs/domain-reports/BADGE-DOMAIN-REPORT.md b/docs/domain-reports/BADGE-DOMAIN-REPORT.md deleted file mode 100644 index 4cd58215..00000000 --- a/docs/domain-reports/BADGE-DOMAIN-REPORT.md +++ /dev/null @@ -1,681 +0,0 @@ -# Badge Domain 세부 보고서 - -## 1. 개요 - -Badge 도메인은 사용자의 학습 성취도에 따라 배지를 자동으로 부여하는 시스템입니다. 이벤트 기반 아키텍처를 통해 Stats, Vocabulary, Chatting 도메인과 연동되어 실시간으로 배지를 체크하고 -부여합니다. - ---- - -## 2. 전체 아키텍처 - -```mermaid -flowchart TB - subgraph Triggers["트리거 소스"] - TEST[테스트 완료
DynamoDB Streams] - WORD[단어 학습
Write-through] - GAME[게임 종료
Service Method] - end - - subgraph Processing["Badge 처리"] - CHECK[BadgeService
조건 체크] - AWARD[배지 부여] - end - - subgraph Storage["저장소"] - DDB[(DynamoDB
UserBadge)] - S3[(S3
배지 이미지)] - end - - subgraph Query["조회"] - API[BadgeHandler
REST API] - PRESIGN[S3 Presigned URL] - end - - TEST --> CHECK - WORD --> CHECK - GAME --> CHECK - CHECK --> AWARD - AWARD --> DDB - DDB --> API - S3 --> PRESIGN - PRESIGN --> API -``` - ---- - -## 3. 배지 종류 - -### 3.1 배지 카테고리 - -```mermaid -mindmap - root((배지 시스템)) - 학습 - FIRST_STEP[첫 걸음] - WORDS_100[단어 수집가] - WORDS_500[단어 전문가] - WORDS_1000[단어 마스터] - 연속학습 - STREAK_3[3일 연속] - STREAK_7[7일 연속] - STREAK_30[30일 연속] - 테스트 - PERFECT_SCORE[완벽주의자] - TEST_10[테스트 도전자] - ACCURACY_90[정확도 달인] - 게임 - GAME_FIRST[첫 게임] - GAME_10_WINS[10승 달성] - QUICK_GUESSER[번개 정답] - PERFECT_DRAWER[완벽한 출제자] - 최종 - MASTER[학습 마스터] -``` - -### 3.2 배지 상세 - -| Badge Type | 이름 | 설명 | 카테고리 | 조건 | -|-----------------|-----------|--------------------|-----------------|-----------------------| -| FIRST_STEP | 첫 걸음 | 첫 학습을 완료했습니다 | FIRST_STUDY | testsCompleted >= 1 | -| STREAK_3 | 3일 연속 학습 | 3일 연속으로 학습했습니다 | STREAK | currentStreak >= 3 | -| STREAK_7 | 일주일 연속 학습 | 7일 연속으로 학습했습니다 | STREAK | currentStreak >= 7 | -| STREAK_30 | 한 달 연속 학습 | 30일 연속으로 학습했습니다 | STREAK | currentStreak >= 30 | -| WORDS_100 | 단어 수집가 | 100개의 단어를 학습했습니다 | WORDS_LEARNED | totalWords >= 100 | -| WORDS_500 | 단어 전문가 | 500개의 단어를 학습했습니다 | WORDS_LEARNED | totalWords >= 500 | -| WORDS_1000 | 단어 마스터 | 1000개의 단어를 학습했습니다 | WORDS_LEARNED | totalWords >= 1000 | -| PERFECT_SCORE | 완벽주의자 | 테스트에서 만점을 받았습니다 | PERFECT_TEST | incorrectAnswers == 0 | -| TEST_10 | 테스트 도전자 | 10회의 테스트를 완료했습니다 | TESTS_COMPLETED | testsCompleted >= 10 | -| ACCURACY_90 | 정확도 달인 | 전체 정확도 90%를 달성했습니다 | ACCURACY | successRate >= 90 | -| GAME_FIRST_PLAY | 첫 게임 | 첫 게임에 참여했습니다 | GAMES_PLAYED | gamesPlayed >= 1 | -| GAME_10_WINS | 게임 10승 | 게임에서 10번 1등을 했습니다 | GAMES_WON | gamesWon >= 10 | -| QUICK_GUESSER | 번개 정답 | 5초 내에 정답을 맞췄습니다 | QUICK_GUESSES | quickGuesses >= 1 | -| PERFECT_DRAWER | 완벽한 출제자 | 출제 시 전원이 정답을 맞췄습니다 | PERFECT_DRAWS | perfectDraws >= 1 | -| MASTER | 학습 마스터 | 모든 업적을 달성했습니다 | ALL_BADGES | 모든 배지 획득 | - ---- - -## 4. 배지 부여 흐름 - -### 4.1 테스트 완료 시 - -```mermaid -sequenceDiagram - participant Test as TestResult - participant Streams as DynamoDB Streams - participant Handler as StatsStreamHandler - participant Stats as UserStats - participant Badge as BadgeService - participant DB as DynamoDB - Test ->> Streams: INSERT 이벤트 - Streams ->> Handler: 트리거 - Handler ->> Stats: incrementTestStats() - Handler ->> Stats: updateStudyStreak() - Note over Handler: 만점 체크 - alt 정답 > 0 && 오답 == 0 - Handler ->> Badge: awardBadge("PERFECT_SCORE") - Badge ->> DB: UserBadge 저장 - end - - Handler ->> Stats: findTotalStats() - Stats -->> Handler: UserStats - Handler ->> Badge: checkAndAwardBadges() - Badge ->> Badge: 각 배지 조건 체크 - Badge ->> DB: 획득 배지 저장 -``` - -### 4.2 단어 학습 시 - -```mermaid -sequenceDiagram - participant API as DailyStudyHandler - participant Service as DailyStudyCommandService - participant Stats as UserStatsRepository - participant Badge as BadgeService - participant DB as DynamoDB - API ->> Service: markWordLearned() - Service ->> Stats: incrementWordsLearned() - Note over Service: 배지 체크 (WORDS_xxx) - Service ->> Stats: findTotalStats() - Stats -->> Service: UserStats - Service ->> Badge: checkAndAwardBadges() - Badge ->> Badge: WORDS_100, 500, 1000 체크 - Badge ->> DB: 획득 배지 저장 -``` - -### 4.3 게임 종료 시 - -```mermaid -sequenceDiagram - participant Game as GameService - participant Stats as GameStatsService - participant Repo as UserStatsRepository - participant Badge as BadgeService - participant DB as DynamoDB - Game ->> Stats: updateGameStats(room) - - loop 각 참가자 - Stats ->> Stats: 점수 집계 - Note over Stats: correctGuesses
quickGuesses (5초 이내)
perfectDraws - Stats ->> Repo: incrementGameStats() - Stats ->> Repo: findTotalStats() - Repo -->> Stats: UserStats - Stats ->> Badge: checkAndAwardBadges() - Badge ->> Badge: GAME_xxx 배지 체크 - Badge ->> DB: 획득 배지 저장 - end -``` - ---- - -## 5. 배지 조건 체크 로직 - -### 5.1 카테고리별 조건 - -```mermaid -flowchart TB - START[checkAndAwardBadges] --> LOOP{모든 BadgeType 순회} - LOOP --> EARNED{이미 획득?} - EARNED -->|Yes| SKIP[건너뛰기] - EARNED -->|No| CHECK[조건 체크] - CHECK --> SWITCH{카테고리} - SWITCH -->|FIRST_STUDY| FS[testsCompleted >= 1] - SWITCH -->|STREAK| ST[currentStreak >= threshold] - SWITCH -->|WORDS_LEARNED| WL[totalWords >= threshold] - SWITCH -->|PERFECT_TEST| PT[별도 처리] - SWITCH -->|TESTS_COMPLETED| TC[testsCompleted >= threshold] - SWITCH -->|ACCURACY| AC[successRate >= threshold] - SWITCH -->|GAMES_PLAYED| GP[gamesPlayed >= threshold] - SWITCH -->|GAMES_WON| GW[gamesWon >= threshold] - SWITCH -->|QUICK_GUESSES| QG[quickGuesses >= threshold] - SWITCH -->|PERFECT_DRAWS| PD[perfectDraws >= threshold] - SWITCH -->|ALL_BADGES| AB[모든 배지 획득 체크] - FS --> RESULT{조건 충족?} - ST --> RESULT - WL --> RESULT - TC --> RESULT - AC --> RESULT - GP --> RESULT - GW --> RESULT - QG --> RESULT - PD --> RESULT - RESULT -->|Yes| AWARD[배지 부여] - RESULT -->|No| SKIP - AWARD --> LOOP - SKIP --> LOOP -``` - -### 5.2 Switch Expression 패턴 - -```java -private boolean checkBadgeCondition(BadgeType type, UserStats stats) { - return switch (type.getCategory()) { - case "FIRST_STUDY" -> stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1; - - case "STREAK" -> stats.getCurrentStreak() != null && - stats.getCurrentStreak() >= type.getThreshold(); - - case "WORDS_LEARNED" -> { - int total = (stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0) - + (stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0); - yield total >= type.getThreshold(); - } - - case "ACCURACY" -> { - if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) - yield false; - double accuracy = (stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered(); - yield accuracy >= type.getThreshold(); - } - - case "TESTS_COMPLETED" -> stats.getTestsCompleted() != null && - stats.getTestsCompleted() >= type.getThreshold(); - - case "GAMES_PLAYED" -> stats.getGamesPlayed() != null && - stats.getGamesPlayed() >= type.getThreshold(); - - case "GAMES_WON" -> stats.getGamesWon() != null && - stats.getGamesWon() >= type.getThreshold(); - - case "QUICK_GUESSES" -> stats.getQuickGuesses() != null && - stats.getQuickGuesses() >= type.getThreshold(); - - case "PERFECT_DRAWS" -> stats.getPerfectDraws() != null && - stats.getPerfectDraws() >= type.getThreshold(); - - case "PERFECT_TEST" -> false; // 별도 처리 (StatsStreamHandler) - case "ALL_BADGES" -> false; // 특수 로직 필요 - - default -> false; - }; -} -``` - ---- - -## 6. API 엔드포인트 - -### 6.1 REST API - -| Method | Endpoint | 설명 | 응답 | -|--------|----------------|----------------|-------------| -| GET | /badges | 전체 배지 목록 + 진행도 | BadgeInfo[] | -| GET | /badges/earned | 획득한 배지만 조회 | UserBadge[] | - -### 6.2 전체 배지 조회 응답 - -```json -{ - "message": "Badges retrieved", - "data": { - "badges": [ - { - "badgeType": "FIRST_STEP", - "name": "첫 걸음", - "description": "첫 학습을 완료했습니다", - "imageUrl": "https://...presigned.../badges/first_step.png", - "category": "FIRST_STUDY", - "threshold": 1, - "progress": 1, - "earned": true, - "earnedAt": "2026-01-16T10:30:45.123Z" - }, - { - "badgeType": "WORDS_100", - "name": "단어 수집가", - "description": "100개의 단어를 학습했습니다", - "imageUrl": "https://...presigned.../badges/words_100.png", - "category": "WORDS_LEARNED", - "threshold": 100, - "progress": 45, - "earned": false, - "earnedAt": null - } - ], - "totalCount": 16, - "earnedCount": 8 - } -} -``` - -### 6.3 획득 배지 조회 응답 - -```json -{ - "message": "Earned badges retrieved", - "data": { - "badges": [ - { - "badgeType": "FIRST_STEP", - "name": "첫 걸음", - "description": "첫 학습을 완료했습니다", - "imageUrl": "https://...presigned.../badges/first_step.png", - "category": "FIRST_STUDY", - "threshold": 1, - "progress": 1, - "earnedAt": "2026-01-16T10:30:45.123Z" - } - ], - "count": 8 - } -} -``` - ---- - -## 7. 데이터 모델 - -### 7.1 UserBadge - -```java - -@DynamoDbBean -public class UserBadge { - // 기본 키 - String pk; // USER#{userId}#BADGE - String sk; // BADGE#{badgeType} - - // GSI (전체 배지 조회) - String gsi1pk; // BADGE#ALL - String gsi1sk; // EARNED#{earnedAt} - - // 메타데이터 - String odUserId; - String badgeType; // BadgeType enum 이름 - String name; - String description; - String imageUrl; - String category; - Integer threshold; - Integer progress; // 획득 시점 진행도 - - // 타임스탬프 - String earnedAt; - String createdAt; -} -``` - -### 7.2 DynamoDB 키 구조 - -| 필드 | 패턴 | 예시 | -|--------|---------------------|-----------------------------| -| PK | USER#{userId}#BADGE | USER#abc123#BADGE | -| SK | BADGE#{badgeType} | BADGE#STREAK_7 | -| GSI1PK | BADGE#ALL | BADGE#ALL | -| GSI1SK | EARNED#{earnedAt} | EARNED#2026-01-16T10:30:45Z | - -### 7.3 BadgeType Enum - -```java -public enum BadgeType { - FIRST_STEP("첫 걸음", "첫 학습을 완료했습니다", - "FIRST_STUDY", 1, "first_step.png"), - STREAK_3("3일 연속 학습", "3일 연속으로 학습했습니다", - "STREAK", 3, "streak_3.png"), - STREAK_7("일주일 연속 학습", "7일 연속으로 학습했습니다", - "STREAK", 7, "streak_7.png"), - // ... 생략 - MASTER("학습 마스터", "모든 업적을 달성했습니다", - "ALL_BADGES", 1, "master.png"); - - private final String name; - private final String description; - private final String category; - private final int threshold; - private final String imageFile; -} -``` - ---- - -## 8. 진행도 계산 - -### 8.1 카테고리별 진행도 - -```mermaid -flowchart TB - subgraph Progress["진행도 계산"] - FIRST["FIRST_STUDY
testsCompleted >= 1 ? 1 : 0"] - STREAK["STREAK
currentStreak"] - WORDS["WORDS_LEARNED
newWords + reviewed"] - TESTS["TESTS_COMPLETED
testsCompleted"] - ACC["ACCURACY
successRate (%)"] - GAMES["GAMES_PLAYED
gamesPlayed"] - WINS["GAMES_WON
gamesWon"] - QUICK["QUICK_GUESSES
quickGuesses"] - PERFECT["PERFECT_DRAWS
perfectDraws"] - end -``` - -### 8.2 calculateProgress 메서드 - -```java -private int calculateProgress(BadgeType type, UserStats stats) { - return switch (type.getCategory()) { - case "FIRST_STUDY" -> (stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1) ? 1 : 0; - - case "STREAK" -> stats.getCurrentStreak() != null ? stats.getCurrentStreak() : 0; - - case "WORDS_LEARNED" -> { - int newWords = stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0; - int reviewed = stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0; - yield newWords + reviewed; - } - - case "TESTS_COMPLETED" -> stats.getTestsCompleted() != null ? stats.getTestsCompleted() : 0; - - case "ACCURACY" -> { - if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) - yield 0; - yield (int) ((stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered()); - } - - case "GAMES_PLAYED" -> stats.getGamesPlayed() != null ? stats.getGamesPlayed() : 0; - - case "GAMES_WON" -> stats.getGamesWon() != null ? stats.getGamesWon() : 0; - - case "QUICK_GUESSES" -> stats.getQuickGuesses() != null ? stats.getQuickGuesses() : 0; - - case "PERFECT_DRAWS" -> stats.getPerfectDraws() != null ? stats.getPerfectDraws() : 0; - - default -> 0; - }; -} -``` - ---- - -## 9. 멱등성 보장 - -### 9.1 중복 부여 방지 흐름 - -```mermaid -flowchart TB - START[checkAndAwardBadges] --> LOOP[배지 타입 순회] - LOOP --> CHECK{hasBadge?} - CHECK -->|이미 있음| SKIP[건너뛰기] - CHECK -->|없음| CONDITION{조건 충족?} - CONDITION -->|Yes| CREATE[배지 생성] - CONDITION -->|No| SKIP - CREATE --> SAVE[DynamoDB 저장] - SAVE --> LOOP - SKIP --> LOOP -``` - -### 9.2 구현 코드 - -```java -public List checkAndAwardBadges(String userId, UserStats stats) { - List newBadges = new ArrayList<>(); - String now = Instant.now().toString(); - - for (BadgeType type : BadgeType.values()) { - // 1. 이미 획득한 배지는 건너뛰기 - if (badgeRepository.hasBadge(userId, type.name())) { - continue; - } - - // 2. 조건 체크 - if (checkBadgeCondition(type, stats)) { - // 3. 배지 생성 및 저장 - UserBadge badge = createBadge(userId, type, now); - badgeRepository.save(badge); - newBadges.add(badge); - } - } - - return newBadges; -} -``` - ---- - -## 10. S3 이미지 연동 - -### 10.1 Presigned URL 생성 - -```mermaid -flowchart LR - REQ[배지 조회] --> SERVICE[BadgeService] - SERVICE --> PRESIGN[S3PresignUtil] - PRESIGN --> CACHE{캐시 확인} - CACHE -->|있음| RETURN[URL 반환] - CACHE -->|없음| GENERATE[Presigned URL 생성] - GENERATE --> SAVE[캐시 저장] - SAVE --> RETURN -``` - -### 10.2 이미지 URL 생성 - -```java -// S3PresignUtil.java -public static String getBadgeImageUrl(String imageFile) { - return getPresignedUrl("badges/" + imageFile); -} - -// BadgeService - 배지 생성 시 -private UserBadge createBadge(String userId, BadgeType type, String now) { - return UserBadge.builder() - .pk(BadgeKey.userBadgePk(userId)) - .sk(BadgeKey.badgeSk(type.name())) - .gsi1pk(BadgeKey.BADGE_ALL) - .gsi1sk(BadgeKey.earnedSk(now)) - .odUserId(userId) - .badgeType(type.name()) - .name(type.getName()) - .description(type.getDescription()) - .imageUrl(S3PresignUtil.getBadgeImageUrl(type.getImageFile())) - .category(type.getCategory()) - .threshold(type.getThreshold()) - .earnedAt(now) - .createdAt(now) - .build(); -} -``` - -### 10.3 S3 버킷 구조 - -``` -s3://group2-englishstudy/ -└── badges/ - ├── first_step.png - ├── streak_3.png - ├── streak_7.png - ├── streak_30.png - ├── words_100.png - ├── words_500.png - ├── words_1000.png - ├── perfect_score.png - ├── test_10.png - ├── accuracy_90.png - ├── game_first.png - ├── game_10_wins.png - ├── quick_guesser.png - ├── perfect_drawer.png - └── master.png -``` - ---- - -## 11. Stats 도메인 연동 - -### 11.1 연동 포인트 - -```mermaid -flowchart TB - subgraph Stats["Stats 도메인"] - STREAM[StatsStreamHandler] - DAILY[DailyStudyCommandService] - GAME[GameStatsService] - REPO[UserStatsRepository] - end - - subgraph Badge["Badge 도메인"] - SERVICE[BadgeService] - BADGEREPO[BadgeRepository] - end - - STREAM -->|checkAndAwardBadges| SERVICE - DAILY -->|checkWordsBadge| SERVICE - GAME -->|checkAndAwardBadges| SERVICE - SERVICE -->|hasBadge, save| BADGEREPO - SERVICE -->|findTotalStats| REPO -``` - -### 11.2 UserStats 필드와 배지 매핑 - -| UserStats 필드 | 배지 | -|------------------------------------|----------------------------------| -| testsCompleted | FIRST_STEP, TEST_10 | -| currentStreak | STREAK_3, STREAK_7, STREAK_30 | -| newWordsLearned + wordsReviewed | WORDS_100, WORDS_500, WORDS_1000 | -| correctAnswers / questionsAnswered | ACCURACY_90 | -| gamesPlayed | GAME_FIRST_PLAY | -| gamesWon | GAME_10_WINS | -| quickGuesses | QUICK_GUESSER | -| perfectDraws | PERFECT_DRAWER | - ---- - -## 12. 파일 구조 - -``` -domain/badge/ -├── enums/ -│ └── BadgeType.java # 16가지 배지 정의 -├── constants/ -│ └── BadgeKey.java # DynamoDB 키 생성 -├── model/ -│ └── UserBadge.java # 배지 엔티티 -├── repository/ -│ └── BadgeRepository.java # CRUD 연산 -├── service/ -│ └── BadgeService.java # 조건 체크, 배지 부여 -└── handler/ - └── BadgeHandler.java # REST API - -연동 파일: -├── domain/stats/handler/StatsStreamHandler.java -├── domain/vocabulary/service/DailyStudyCommandService.java -└── domain/chatting/service/GameStatsService.java -``` - ---- - -## 13. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **Database:** DynamoDB (Single Table Design) -- **Storage:** S3 (배지 이미지) -- **Event:** DynamoDB Streams, Write-through, Service Method -- **Pattern:** Event-driven, Idempotent, Switch Expression -- **Java 21 Features:** Enhanced Switch, Yield Statement - ---- - -## 14. 배지 획득 시나리오 - -### 14.1 시나리오 예시 - -```mermaid -flowchart LR - subgraph Day1["1일차"] - A1[테스트 완료] --> B1["FIRST_STEP 획득"] - end - - subgraph Day3["3일차"] - A3[3일 연속 학습] --> B3["STREAK_3 획득"] - end - - subgraph Day7["7일차"] - A7[7일 연속 학습] --> B7["STREAK_7 획득"] - A7_2[100단어 학습] --> B7_2["WORDS_100 획득"] - end - - subgraph Game["게임"] - G1[5초 내 정답] --> G2["QUICK_GUESSER 획득"] - G3[10회 1등] --> G4["GAME_10_WINS 획득"] - end -``` - -### 14.2 특수 배지 획득 조건 - -**PERFECT_SCORE (완벽주의자):** - -- 테스트 제출 시 오답 0개이면 즉시 부여 -- StatsStreamHandler에서 별도 처리 - -**QUICK_GUESSER (번개 정답):** - -- 게임 중 5초(5000ms) 이내 정답 시 -- GameStatsService에서 quickGuesses 카운트 - -**PERFECT_DRAWER (완벽한 출제자):** - -- 출제 시 모든 참가자가 정답을 맞춘 경우 -- 라운드 종료 시 endReason == "ALL_CORRECT"이면 카운트 - -**MASTER (학습 마스터):** - -- 다른 모든 배지를 획득한 경우 -- 특수 로직으로 모든 배지 보유 여부 확인 diff --git a/docs/domain-reports/CHATTING-DOMAIN-REPORT.md b/docs/domain-reports/CHATTING-DOMAIN-REPORT.md deleted file mode 100644 index c27eb552..00000000 --- a/docs/domain-reports/CHATTING-DOMAIN-REPORT.md +++ /dev/null @@ -1,434 +0,0 @@ -# Chatting Domain 세부 보고서 - -## 1. 개요 - -Chatting 도메인은 실시간 채팅과 캐치마인드 게임 기능을 제공하는 WebSocket 기반 시스템입니다. AWS API Gateway WebSocket과 Lambda를 활용하여 실시간 양방향 통신을 구현했습니다. - ---- - -## 2. 전체 아키텍처 - -```mermaid -flowchart TB - subgraph Client["클라이언트"] - APP[Mobile/Web App] - end - - subgraph Gateway["API Gateway"] - REST[REST API] - WS[WebSocket API] - end - - subgraph Lambda["Lambda Handlers"] - direction TB - ROOM[ChatRoomHandler] - MSG[ChatMessageHandler] - GAME[GameHandler] - VOICE[ChatVoiceHandler] - CONNECT[WebSocketConnectHandler] - DISCONNECT[WebSocketDisconnectHandler] - MESSAGE[WebSocketMessageHandler] - end - - subgraph Storage["데이터 저장소"] - DDB[(DynamoDB)] - S3[(S3 - 음성 캐시)] - end - - APP --> REST - APP <--> WS - REST --> ROOM - REST --> MSG - REST --> GAME - REST --> VOICE - WS --> CONNECT - WS --> DISCONNECT - WS --> MESSAGE - ROOM --> DDB - MSG --> DDB - GAME --> DDB - MESSAGE --> DDB - VOICE --> S3 -``` - ---- - -## 3. 채팅방 시스템 - -### 3.1 채팅방 입장 흐름 - -```mermaid -sequenceDiagram - participant Client - participant REST as REST API - participant WS as WebSocket API - participant DB as DynamoDB - Note over Client, DB: Phase 1 - 방 입장 및 토큰 발급 - Client ->> REST: POST /rooms/{roomId}/join - REST ->> DB: 비밀번호 검증 (비밀방인 경우) - REST ->> DB: RoomToken 저장 (TTL 5분) - REST -->> Client: roomToken 반환 - Note over Client, DB: Phase 2 - WebSocket 연결 - Client ->> WS: $connect?roomToken={token} - WS ->> DB: 토큰 검증 - WS ->> DB: Connection 저장 (TTL 10분) - WS -->> Client: 연결 성공 - Note over Client, DB: Phase 3 - 실시간 메시지 - Client ->> WS: sendMessage (채팅) - WS ->> DB: 메시지 저장 - WS -->> Client: 브로드캐스트 (같은 방 전체) -``` - -### 3.2 REST API 엔드포인트 - -| Method | Endpoint | 설명 | 인증 | -|--------|-------------------------------|---------------------------|----| -| POST | /chat/rooms | 채팅방 생성 | O | -| GET | /chat/rooms | 채팅방 목록 (level, joined 필터) | O | -| GET | /chat/rooms/{roomId} | 채팅방 상세 | O | -| POST | /chat/rooms/{roomId}/join | 채팅방 입장 (토큰 발급) | O | -| POST | /chat/rooms/{roomId}/leave | 채팅방 퇴장 | O | -| DELETE | /chat/rooms/{roomId} | 채팅방 삭제 (방장만) | O | -| GET | /chat/rooms/{roomId}/messages | 메시지 히스토리 | O | - -### 3.3 WebSocket 이벤트 - -| Route | 설명 | Payload | -|-------------|------------|------------------------------------------| -| $connect | 연결 (토큰 검증) | ?roomToken={token} | -| $disconnect | 연결 해제 | - | -| sendMessage | 메시지 전송 | { roomId, userId, content, messageType } | - ---- - -## 4. 캐치마인드 게임 시스템 - -### 4.1 게임 흐름 - -```mermaid -flowchart TB - subgraph GameFlow["캐치마인드 게임 흐름"] - START["/game 명령어"] --> INIT["게임 초기화
출제자 순서 셔플"] - INIT --> ROUND["라운드 시작
출제자 + 단어 선정"] - ROUND --> DRAW["출제자 그림 그리기
(DRAWING 메시지)"] - DRAW --> GUESS["참가자 정답 입력"] - GUESS --> CHECK{정답?} - CHECK -->|Yes| SCORE["점수 계산
시간보너스 + 연속보너스"] - CHECK -->|No| GUESS - SCORE --> ALLCORRECT{전원 정답?} - ALLCORRECT -->|Yes| NEXTROUND - ALLCORRECT -->|No| TIMEOUT{시간 초과?} - TIMEOUT -->|Yes| NEXTROUND["다음 라운드"] - TIMEOUT -->|No| GUESS - NEXTROUND --> LASTROUND{마지막 라운드?} - LASTROUND -->|Yes| END["게임 종료
순위 발표"] - LASTROUND -->|No| ROUND - end -``` - -### 4.2 게임 API - -| Method | Endpoint | 설명 | -|--------|----------------------------------|-------------| -| POST | /chat/rooms/{roomId}/game/start | 게임 시작 (방장만) | -| POST | /chat/rooms/{roomId}/game/stop | 게임 중지 | -| GET | /chat/rooms/{roomId}/game/status | 게임 상태 조회 | -| GET | /chat/rooms/{roomId}/game/scores | 점수판 조회 | - -### 4.3 슬래시 명령어 - -| 명령어 | 설명 | 사용 가능 | -|---------|----------------|--------| -| /start | 게임 시작 | 방장 | -| /stop | 게임 중지 | 방장/시작자 | -| /score | 점수판 보기 | 전체 | -| /member | 접속자 수 | 전체 | -| /hint | 힌트 제공 (첫글자○○○) | 출제자 | -| /skip | 라운드 스킵 | 출제자 | -| /help | 명령어 도움말 | 전체 | - -### 4.4 점수 계산 공식 - -``` -점수 = 기본점수(10) + 시간보너스 + 연속보너스 + 출제자보너스 - -- 시간보너스: (60 - 경과초) × 0.5 -- 연속보너스: streak × 2 -- 출제자보너스: 정답자당 5점 -``` - -**예시:** - -- 30초에 정답 + 연속 3회: 10 + 15 + 6 = 31점 -- 출제자가 3명 맞출 경우: 5 × 3 = 15점 - -### 4.5 게임 상태 - -```mermaid -stateDiagram-v2 - [*] --> NONE: 대기 - NONE --> PLAYING: /start 명령어 - PLAYING --> ROUND_END: 시간초과/전원정답 - ROUND_END --> PLAYING: 다음 라운드 - ROUND_END --> FINISHED: 마지막 라운드 - PLAYING --> FINISHED: /stop 명령어 - FINISHED --> [*]: 게임 종료 -``` - ---- - -## 5. WebSocket 메시지 타입 - -### 5.1 채팅 메시지 - -| Type | 설명 | 저장 | -|-------------|-------|----| -| TEXT | 일반 채팅 | O | -| IMAGE | 이미지 | O | -| VOICE | 음성 | O | -| AI_RESPONSE | AI 응답 | O | - -### 5.2 게임 메시지 - -| Type | 설명 | 저장 | -|----------------|--------------|----| -| DRAWING | 그림 데이터 (실시간) | X | -| DRAWING_CLEAR | 그림 지우기 | X | -| GUESS | 오답 추측 | X | -| CORRECT_ANSWER | 정답 알림 | X | -| SCORE_UPDATE | 점수 갱신 | X | -| GAME_START | 게임 시작 | X | -| ROUND_START | 라운드 시작 | X | -| ROUND_END | 라운드 종료 | X | -| GAME_END | 게임 종료 | X | -| HINT | 힌트 | X | - -### 5.3 실시간 점수 업데이트 메시지 - -```json -{ - "messageType": "SCORE_UPDATE", - "roomId": "uuid", - "scorerId": "user123", - "scoreGained": 25, - "ranking": [ - { - "rank": 1, - "userId": "user123", - "score": 85, - "change": 25 - }, - { - "rank": 2, - "userId": "user456", - "score": 60, - "change": 0 - } - ], - "currentRound": 3, - "totalRounds": 5 -} -``` - ---- - -## 6. 데이터 모델 - -### 6.1 ChatRoom - -```java - -@DynamoDbBean -public class ChatRoom { - // 기본 정보 - String roomId, name, description; - String level; // beginner, intermediate, advanced - Integer currentMembers, maxMembers; - Boolean isPrivate; - String password; // BCrypt 암호화 - String createdBy; // 방장 - List memberIds; - - // 게임 상태 - String gameStatus; // NONE, PLAYING, ROUND_END, FINISHED - Integer currentRound, totalRounds; - String currentDrawerId, currentWord; - Long roundStartTime; - Integer roundTimeLimit; // 60초 - List drawerOrder; - Map scores; - Map streaks; - List correctGuessers; - Boolean hintUsed; -} -``` - -**DynamoDB Keys:** - -- PK: `ROOM#{roomId}` | SK: `METADATA` -- GSI1: `ROOMS` | `{level}#{createdAt}` (레벨별 최신순) - -### 6.2 Connection - -```java - -@DynamoDbBean -public class Connection { - String connectionId; // API Gateway 연결 ID - String userId; - String roomId; - Long ttl; // 10분 (자동 삭제) -} -``` - -**DynamoDB Keys:** - -- PK: `CONN#{connectionId}` | SK: `METADATA` -- GSI1: `ROOM#{roomId}` | `CONN#{connectionId}` (방별 연결) -- GSI2: `USER#{userId}` | `CONN#{connectionId}` (사용자별 연결) - -### 6.3 GameRound - -```java - -@DynamoDbBean -public class GameRound { - Integer roundNumber; - String drawerId, word, wordEnglish; - List correctGuessers; - Map guessTimes; // 정답까지 걸린 시간 - Map roundScores; - Long startTime, endTime; - String endReason; // TIME_UP, ALL_CORRECT, SKIP - Long ttl; // 7일 -} -``` - -### 6.4 RoomToken - -```java - -@DynamoDbBean -public class RoomToken { - String token; // UUID - String roomId; - String userId; - Long ttl; // 5분 -} -``` - ---- - -## 7. 서비스 레이어 - -### 7.1 CQRS 패턴 - -| Service | 역할 | -|------------------------|----------------------| -| ChatRoomCommandService | 채팅방 생성, 입장, 퇴장, 삭제 | -| ChatRoomQueryService | 채팅방 조회, 목록 | -| GameService | 게임 시작, 정답 체크, 라운드 종료 | -| GameStatsService | 게임 종료 후 통계, 배지 처리 | -| CommandService | 슬래시 명령어 처리 | -| RoomTokenService | 토큰 발급 및 검증 | - -### 7.2 게임 정답 체크 로직 - -```mermaid -flowchart TB - INPUT[정답 입력] --> NORMALIZE["정규화
(소문자, 공백제거)"] - NORMALIZE --> VALIDATE{유효성 검사} - VALIDATE -->|게임 미진행| REJECT1[거부: 게임 없음] - VALIDATE -->|출제자 본인| REJECT2[거부: 출제자] - VALIDATE -->|이미 정답| REJECT3[거부: 중복] - VALIDATE -->|통과| COMPARE{정답 비교} - COMPARE -->|일치| CORRECT["정답 처리
점수 계산"] - COMPARE -->|불일치| WRONG["오답 처리
GUESS 메시지 전송"] - CORRECT --> BROADCAST["브로드캐스트
CORRECT_ANSWER + SCORE_UPDATE"] - WRONG --> GUESSBROADCAST["브로드캐스트
GUESS 메시지"] - BROADCAST --> ALLCHECK{전원 정답?} - ALLCHECK -->|Yes| ROUNDEND[라운드 자동 종료] - ALLCHECK -->|No| CONTINUE[게임 계속] -``` - ---- - -## 8. 브로드캐스트 시스템 - -### 8.1 WebSocketBroadcaster - -```java -public class WebSocketBroadcaster { - public List broadcast( - List connections, - String payload - ) { - // 1. 같은 방 모든 연결에 메시지 전송 - // 2. 실패한 연결 ID 반환 (Stale 정리용) - } -} -``` - -### 8.2 브로드캐스트 유형 - -| 유형 | 대상 | 예시 | -|--------|--------|-----------| -| 전체 | 방 전체 | 채팅, 정답 알림 | -| 본인 제외 | 발신자 제외 | 그림 데이터 | -| 출제자 전용 | 출제자만 | 단어 정보 | - ---- - -## 9. 파일 구조 - -``` -domain/chatting/ -├── handler/ -│ ├── ChatRoomHandler.java -│ ├── ChatMessageHandler.java -│ ├── ChatVoiceHandler.java -│ ├── GameHandler.java -│ └── websocket/ -│ ├── WebSocketConnectHandler.java -│ ├── WebSocketDisconnectHandler.java -│ └── WebSocketMessageHandler.java -├── service/ -│ ├── ChatRoomCommandService.java -│ ├── ChatRoomQueryService.java -│ ├── ChatMessageService.java -│ ├── GameService.java -│ ├── GameStatsService.java -│ ├── CommandService.java -│ └── RoomTokenService.java -├── repository/ -│ ├── ChatRoomRepository.java -│ ├── ChatMessageRepository.java -│ ├── ConnectionRepository.java -│ ├── GameRoundRepository.java -│ └── RoomTokenRepository.java -├── model/ -│ ├── ChatRoom.java -│ ├── ChatMessage.java -│ ├── Connection.java -│ ├── GameRound.java -│ └── RoomToken.java -├── dto/ -│ ├── request/ -│ └── response/ -│ └── ScoreUpdateMessage.java -└── enums/ - ├── GameStatus.java - └── MessageType.java -``` - ---- - -## 10. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **API:** API Gateway REST + WebSocket -- **Database:** DynamoDB (Single Table Design) -- **Auth:** Cognito + RoomToken -- **Encryption:** BCrypt (비밀방 암호) -- **TTS:** AWS Polly + S3 캐시 -- **Pattern:** CQRS, Repository, Factory diff --git a/docs/domain-reports/COMMON-MODULE-REPORT.md b/docs/domain-reports/COMMON-MODULE-REPORT.md deleted file mode 100644 index aefe6d08..00000000 --- a/docs/domain-reports/COMMON-MODULE-REPORT.md +++ /dev/null @@ -1,1228 +0,0 @@ -# Common Module 세부 보고서 - -## 1. 개요 - -Common 모듈은 모든 도메인에서 공유하는 유틸리티, 설정, 예외 처리, 라우팅 등을 제공하는 핵심 인프라 모듈입니다. Java 21의 최신 기능(Records, Sealed Interface, Pattern -Matching)을 적극 활용하여 타입 안전성과 코드 간결성을 확보했습니다. - ---- - -## 2. 전체 패키지 구조 - -```mermaid -flowchart TB -subgraph Common["common/"] -CONFIG[config/] -CONST[constants/] -DTO[dto/] -ENUM[enums/] -EXCEPTION[exception/] -ROUTER[router/] -SERVICE[service/] -UTIL[util/] -VALIDATION[validation/] -end - -subgraph ConfigFiles["config/"] -AC[AwsClients.java] -WSC[WebSocketConfig.java] -RTC[RoomTokenConfig.java] -SC[StudyConfig.java] -end - -subgraph DtoFiles["dto/"] -AR[ApiResponse.java] -EI[ErrorInfo.java] -PR[PaginatedResult.java] -end - -subgraph ExceptionFiles["exception/"] -SE[ServerlessException.java] -EC[ErrorCode.java] -CEC[CommonErrorCode.java] -CE[CommonException.java] -end - -subgraph RouterFiles["router/"] -HR[HandlerRouter.java] -RT[Route.java] -AH[AuthenticatedHandler.java] -end - -CONFIG --> ConfigFiles -DTO --> DtoFiles -EXCEPTION --> ExceptionFiles -ROUTER --> RouterFiles -``` - ---- - -## 3. Handler 라우팅 시스템 - -### 3.1 HandlerRouter 아키텍처 - -```mermaid -flowchart TB - subgraph Request["요청 처리 흐름"] - REQ[APIGatewayProxyRequestEvent] --> ROUTER[HandlerRouter] - ROUTER --> MATCH{라우트 매칭} - MATCH -->|매칭 성공| VALIDATE[파라미터 검증] - MATCH -->|매칭 실패| NF404[404 Not Found] - VALIDATE --> EXECUTE[핸들러 실행] - EXECUTE --> RESPONSE[APIGatewayProxyResponseEvent] - end - - subgraph ErrorHandling["예외 처리"] - EXECUTE -->|ServerlessException| ERR1[ErrorCode 기반 응답] - EXECUTE -->|IllegalArgumentException| ERR2[400 Bad Request] - EXECUTE -->|IllegalStateException| ERR3[409 Conflict] - EXECUTE -->|SecurityException| ERR4[403 Forbidden] - EXECUTE -->|기타 예외| ERR5[500 Internal Error] - end -``` - -### 3.2 Route 정의 (Java 21 Record) - -```java -// Route.java - Java 21 Record 활용 -public record Route( - String method, // HTTP 메서드 - String pathPattern, // 경로 패턴 (e.g., "/rooms/{roomId}") - Function handler, - List requiredPathParams, // 필수 경로 파라미터 - List requiredQueryParams // 필수 쿼리 파라미터 - ) { - // 경로 파라미터 자동 추출: {roomId} → roomId - private static final Pattern PATH_PARAM_PATTERN = - Pattern.compile("\\{([^}]+)}"); -} -``` - -### 3.3 Route 팩토리 메서드 - -```mermaid -flowchart LR - subgraph BasicRoutes["기본 라우트"] - GET["Route.get()"] - POST["Route.post()"] - PUT["Route.put()"] - DELETE["Route.delete()"] - PATCH["Route.patch()"] - end - - subgraph AuthRoutes["인증 라우트"] - GETAUTH["Route.getAuth()"] - POSTAUTH["Route.postAuth()"] - PUTAUTH["Route.putAuth()"] - DELETEAUTH["Route.deleteAuth()"] - PATCHAUTH["Route.patchAuth()"] - end - - BasicRoutes -->|" + Cognito 인증 "| AuthRoutes -``` - -### 3.4 사용 예시 - -```java -// Handler에서 라우터 초기화 -private HandlerRouter initRouter() { - return new HandlerRouter().addRoutes( - // 인증 필요 라우트 (Cognito userId 자동 추출) - Route.postAuth("/grammar/check", this::checkGrammar), - Route.getAuth("/grammar/sessions/{sessionId}", this::getSessionDetail), - Route.deleteAuth("/grammar/sessions/{sessionId}", this::deleteSession), - - // 쿼리 파라미터 검증 - Route.getAuth("/rooms", this::getRooms) - .requireQueryParams("level") - ); -} - -// Lambda 핸들러 메서드 -@Override -public APIGatewayProxyResponseEvent handleRequest( - APIGatewayProxyRequestEvent request, Context context) { - return router.route(request); -} -``` - -### 3.5 AuthenticatedHandler 인터페이스 - -```java -// 함수형 인터페이스 - Cognito 인증 요청 처리 -@FunctionalInterface -public interface AuthenticatedHandler { - APIGatewayProxyResponseEvent handle( - APIGatewayProxyRequestEvent request, - String userId // Cognito sub claim에서 자동 추출 - ); -} - -// 사용 예시 - 람다 표현식으로 간결하게 -Route. - -postAuth("/rooms",(request, userId) ->{ -CreateRoomRequest dto = parseBody(request, CreateRoomRequest.class); -ChatRoom room = roomService.createRoom(userId, dto); - return ResponseGenerator. - -created("Room created",room); -}); -``` - ---- - -## 4. 예외 처리 시스템 - -### 4.1 ErrorCode 계층 구조 (Sealed Interface) - -```mermaid -flowchart TB - subgraph SealedHierarchy["Java 21 Sealed Interface 계층"] - EC[/"ErrorCode
(sealed interface)"/] - EC -->|permits| CEC["CommonErrorCode
(enum)"] - EC -->|permits| DEC[/"DomainErrorCode
(non-sealed interface)"/] - DEC --> VEC["VocabularyErrorCode"] - DEC --> CHEC["ChattingErrorCode"] - DEC --> GEC["GrammarErrorCode"] - DEC --> SEC["StatsErrorCode"] - DEC --> BEC["BadgeErrorCode"] - end -``` - -### 4.2 CommonErrorCode 정의 - -```java -public enum CommonErrorCode implements ErrorCode { - // 인증/인가 (AUTH_xxx) - UNAUTHORIZED("AUTH_001", "인증이 필요합니다", 401), - FORBIDDEN("AUTH_002", "접근 권한이 없습니다", 403), - INVALID_TOKEN("AUTH_003", "유효하지 않은 토큰입니다", 401), - TOKEN_EXPIRED("AUTH_004", "토큰이 만료되었습니다", 401), - - // 검증 (VALIDATION_xxx) - INVALID_INPUT("VALIDATION_001", "잘못된 입력입니다", 400), - REQUIRED_FIELD_MISSING("VALIDATION_002", "필수 필드가 누락되었습니다", 400), - INVALID_FORMAT("VALIDATION_003", "형식이 올바르지 않습니다", 400), - VALUE_OUT_OF_RANGE("VALIDATION_004", "값이 허용 범위를 벗어났습니다", 400), - - // 리소스 (RESOURCE_xxx) - RESOURCE_NOT_FOUND("RESOURCE_001", "리소스를 찾을 수 없습니다", 404), - RESOURCE_ALREADY_EXISTS("RESOURCE_002", "이미 존재하는 리소스입니다", 409), - METHOD_NOT_ALLOWED("RESOURCE_003", "허용되지 않는 메서드입니다", 405), - - // 시스템 (SYSTEM_xxx) - INTERNAL_SERVER_ERROR("SYSTEM_001", "내부 서버 오류가 발생했습니다", 500), - DATABASE_ERROR("SYSTEM_002", "데이터베이스 오류가 발생했습니다", 500), - EXTERNAL_API_ERROR("SYSTEM_003", "외부 API 호출 오류가 발생했습니다", 502), - SERVICE_UNAVAILABLE("SYSTEM_004", "서비스를 일시적으로 사용할 수 없습니다", 503); - - private final String code; - private final String message; - private final int statusCode; -} -``` - -### 4.3 예외 생성 팩토리 패턴 - -```mermaid -flowchart LR - subgraph FactoryMethods["CommonException 팩토리 메서드"] - AUTH["인증 오류"] - VALID["검증 오류"] - RES["리소스 오류"] - SYS["시스템 오류"] - end - - AUTH --> UNAUTH["unauthorized()"] - AUTH --> FORBID["forbidden()"] - AUTH --> TOKEN["invalidToken()"] - VALID --> INPUT["invalidInput(msg)"] - VALID --> MISS["requiredFieldMissing(field)"] - VALID --> FMT["invalidFormat(field)"] - RES --> NF["notFound(resource, id)"] - RES --> EXIST["alreadyExists(resource)"] - SYS --> INTERN["internalError(cause)"] - SYS --> DB["databaseError(cause)"] - SYS --> EXT["externalApiError(api, cause)"] -``` - -### 4.4 예외 사용 예시 - -```java -// 가독성 높은 예외 생성 -throw CommonException.notFound("User","user123"); -// → "User (ID: user123)를 찾을 수 없습니다", 404 - -throw CommonException. - -invalidInput("Email format is invalid"); -// → 400 INVALID_INPUT with custom message - -throw CommonException. - -alreadyExists("ChatRoom","room456"); -// → "ChatRoom (ID: room456)가 이미 존재합니다", 409 - -// 상세 컨텍스트 추가 (메서드 체이닝) -throw CommonException. - -internalError(cause) - . - -addDetail("operation","database_query") - . - -addDetail("table","users"); -``` - ---- - -## 5. AWS 클라이언트 관리 - -### 5.1 Singleton 패턴 (Cold Start 최적화) - -```mermaid -flowchart TB - subgraph ColdStart["Lambda Cold Start 최적화"] - INIT["Lambda 컨테이너 초기화
(1회)"] - STATIC["static final 클라이언트 생성"] - REUSE["요청마다 재사용"] - end - - INIT --> STATIC - STATIC --> REUSE - REUSE -->|" 다음 요청 "| REUSE -``` - -### 5.2 AwsClients.java 구조 - -```java -public final class AwsClients { - // DynamoDB (Enhanced Client 포함) - private static final DynamoDbClient DYNAMO_DB_CLIENT = - DynamoDbClient.builder().build(); - private static final DynamoDbEnhancedClient DYNAMO_DB_ENHANCED_CLIENT = - DynamoDbEnhancedClient.builder() - .dynamoDbClient(DYNAMO_DB_CLIENT) - .build(); - - // S3 (Presigner 포함) - private static final S3Client S3_CLIENT = S3Client.builder().build(); - private static final S3Presigner S3_PRESIGNER = S3Presigner.builder().build(); - - // AI/ML 서비스 - private static final PollyClient POLLY_CLIENT = PollyClient.builder().build(); - private static final BedrockRuntimeClient BEDROCK_CLIENT = - BedrockRuntimeClient.builder().build(); - private static final BedrockRuntimeAsyncClient BEDROCK_ASYNC_CLIENT = - BedrockRuntimeAsyncClient.builder().build(); - private static final ComprehendClient COMPREHEND_CLIENT = - ComprehendClient.builder().build(); - - // SNS - private static final SnsClient SNS_CLIENT = SnsClient.builder().build(); - - // 팩토리 메서드 - public static DynamoDbClient dynamoDb() { - return DYNAMO_DB_CLIENT; - } - - public static DynamoDbEnhancedClient dynamoDbEnhanced() { - return DYNAMO_DB_ENHANCED_CLIENT; - } - - public static S3Client s3() { - return S3_CLIENT; - } - - public static S3Presigner s3Presigner() { - return S3_PRESIGNER; - } - - public static PollyClient polly() { - return POLLY_CLIENT; - } - - public static BedrockRuntimeClient bedrock() { - return BEDROCK_CLIENT; - } - - public static BedrockRuntimeAsyncClient bedrockAsync() { - return BEDROCK_ASYNC_CLIENT; - } - - public static ComprehendClient comprehend() { - return COMPREHEND_CLIENT; - } - - public static SnsClient sns() { - return SNS_CLIENT; - } -} -``` - -### 5.3 사용 예시 - -```java -// Service에서 사용 -public class PollyService { - public VoiceSynthesisResult synthesizeSpeech(String id, String text, String voice) { - SynthesizeSpeechRequest request = SynthesizeSpeechRequest.builder() - .text(text) - .voiceId(VoiceId.MATTHEW) - .engine("neural") - .outputFormat(OutputFormat.MP3) - .build(); - - // Singleton 클라이언트 사용 - InputStream audioStream = AwsClients.polly().synthesizeSpeech(request); - AwsClients.s3().putObject(putRequest, RequestBody.fromInputStream(audioStream, -1)); - - return new VoiceSynthesisResult(s3Key, presignedUrl, false); - } -} -``` - ---- - -## 6. DTO 패턴 (Java 21 Records) - -### 6.1 ApiResponse (제네릭 응답 래퍼) - -```java -// 불변 데이터 클래스 - Java 21 Record -public record ApiResponse( - boolean isSuccess, - String message, - T data, - String error - ) { - // 성공 응답 팩토리 - public static ApiResponse ok(String message, T data) { - return new ApiResponse<>(true, message, data, null); - } - - public static ApiResponse ok(T data) { - return new ApiResponse<>(true, null, data, null); - } - - // 실패 응답 팩토리 - public static ApiResponse fail(String errorMessage) { - return new ApiResponse<>(false, null, null, errorMessage); - } -} -``` - -**JSON 응답 예시:** - -```json -{ - "isSuccess": true, - "message": "Grammar checked successfully", - "data": { - "correctedSentence": "I am a student", - "score": 85, - "errors": [ - ... - ] - }, - "error": null -} -``` - -### 6.2 ErrorInfo (RFC 7807 준수) - -```java -// Problem Details for HTTP APIs (RFC 7807) -public record ErrorInfo( - String code, // e.g., "VOCABULARY.WORD_001" - String message, // e.g., "단어를 찾을 수 없습니다" - int status, // e.g., 404 - Map details // Optional context - ) { - public static ErrorInfo from(ErrorCode errorCode) { ...} - - public static ErrorInfo from(ServerlessException ex) { ...} - - public boolean isClientError() { - return status >= 400 && status < 500; - } - - public boolean isServerError() { - return status >= 500 && status < 600; - } -} -``` - -**JSON 에러 응답 예시:** - -```json -{ - "code": "VOCABULARY.WORD_001", - "message": "단어를 찾을 수 없습니다", - "status": 404, - "details": { - "wordId": "abc-123", - "userId": "user456" - } -} -``` - -### 6.3 PaginatedResult (커서 페이지네이션) - -```java -public record PaginatedResult( - List items, - String nextCursor // Base64 인코딩된 DynamoDB lastEvaluatedKey -) { - public boolean hasMore() { - return nextCursor != null; - } -} -``` - ---- - -## 7. 페이지네이션 유틸리티 - -### 7.1 CursorUtil 동작 흐름 - -```mermaid -sequenceDiagram - participant Client - participant Handler - participant CursorUtil - participant DynamoDB - Note over Client, DynamoDB: 첫 페이지 요청 - Client ->> Handler: GET /items?limit=10 - Handler ->> CursorUtil: decode(null) → null - Handler ->> DynamoDB: Query (exclusiveStartKey=null) - DynamoDB -->> Handler: items + lastEvaluatedKey - Handler ->> CursorUtil: encode(lastEvaluatedKey) - CursorUtil -->> Handler: "dXNlcklkPXVzZXIxMjM..." - Handler -->> Client: {"items": [...], "nextCursor": "dXNlcklkPXVzZXIxMjM..."} - Note over Client, DynamoDB: 다음 페이지 요청 - Client ->> Handler: GET /items?cursor=dXNlcklkPXVzZXIxMjM... - Handler ->> CursorUtil: decode("dXNlcklkPXVzZXIxMjM...") - CursorUtil -->> Handler: {"userId": "user123", ...} - Handler ->> DynamoDB: Query (exclusiveStartKey={...}) - DynamoDB -->> Handler: items + lastEvaluatedKey -``` - -### 7.2 CursorUtil 구현 - -```java -public class CursorUtil { - // DynamoDB lastEvaluatedKey → Base64 문자열 - public static String encode(Map lastEvaluatedKey) { - if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { - return null; - } - - StringBuilder sb = new StringBuilder(); - for (Map.Entry entry : lastEvaluatedKey.entrySet()) { - if (sb.length() > 0) sb.append("|"); - sb.append(entry.getKey()).append("=").append(entry.getValue().s()); - } - - return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); - } - - // Base64 문자열 → DynamoDB exclusiveStartKey - public static Map decode(String cursor) { - if (cursor == null || cursor.isEmpty()) { - return null; - } - - String decoded = new String(Base64.getUrlDecoder().decode(cursor)); - Map startKey = new HashMap<>(); - - for (String pair : decoded.split("\\|")) { - String[] kv = pair.split("=", 2); - if (kv.length == 2) { - startKey.put(kv[0], AttributeValue.builder().s(kv[1]).build()); - } - } - - return startKey; - } -} -``` - ---- - -## 8. 인증 유틸리티 - -### 8.1 Cognito 인증 흐름 - -```mermaid -flowchart TB - subgraph CognitoAuth["Cognito 인증 흐름"] - REQ[요청] --> AUTH[API Gateway Authorizer] - AUTH --> CLAIMS[JWT Claims 추출] - CLAIMS --> INJECT["requestContext.authorizer.claims"] - end - - subgraph CognitoUtil["CognitoUtil 추출"] - INJECT --> EXTRACT[extractUserId] - EXTRACT --> SUB["claims.sub → userId"] - end -``` - -### 8.2 CognitoUtil.java - -```java -public class CognitoUtil { - // 기본 userId 추출 (sub claim) - public static String extractUserId(APIGatewayProxyRequestEvent request) { - Map authorizer = request.getRequestContext().getAuthorizer(); - if (authorizer == null) return null; - - Map claims = (Map) authorizer.get("claims"); - return claims != null ? claims.get("sub") : null; - } - - // 선택적 claim 추출 - public static Optional extractEmail(APIGatewayProxyRequestEvent request) { - return extractClaim(request, "email"); - } - - public static Optional extractNickname(APIGatewayProxyRequestEvent request) { - return extractClaim(request, "custom:nickname"); - } - - public static Optional extractClaim( - APIGatewayProxyRequestEvent request, String claimName) { - // ... claim 추출 로직 - } - - // 사용자 접근 권한 검증 - public static boolean validateUserAccess( - APIGatewayProxyRequestEvent request, String pathUserId) { - String tokenUserId = extractUserId(request); - return tokenUserId != null && tokenUserId.equals(pathUserId); - } -} -``` - -### 8.3 JwtUtil.java (WebSocket용) - -```java -// WebSocket 연결 시 직접 JWT 파싱 (Authorizer 미사용) -public final class JwtUtil { - public static Optional extractUserId(String token) { - // Bearer 제거 - if (token.startsWith("Bearer ")) { - token = token.substring(7); - } - - // JWT payload 추출 (헤더.페이로드.시그니처) - String[] parts = token.split("\\."); - if (parts.length != 3) return Optional.empty(); - - // Base64 URL 디코딩 - String payload = new String(Base64.getUrlDecoder().decode(parts[1])); - Map claims = gson.fromJson(payload, Map.class); - - return Optional.ofNullable((String) claims.get("sub")); - } - - public static boolean isExpired(String token) { - // exp claim 확인 - } -} -``` - ---- - -## 9. HTTP 응답 생성 - -### 9.1 ResponseGenerator.java - -```java -public class ResponseGenerator { - private static final Gson GSON = new GsonBuilder() - .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") - .create(); - - private static final Map CORS_HEADERS = Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", - "Access-Control-Allow-Headers", "Content-Type,Authorization" - ); - - // 성공 응답 - public static APIGatewayProxyResponseEvent ok(String message, T data) { - return buildResponse(200, ApiResponse.ok(message, data)); - } - - public static APIGatewayProxyResponseEvent created(String message, T data) { - return buildResponse(201, ApiResponse.ok(message, data)); - } - - public static APIGatewayProxyResponseEvent noContent() { - return buildResponse(204, null); - } - - // 에러 응답 - public static APIGatewayProxyResponseEvent fail(ErrorCode errorCode) { - return buildResponse(errorCode.getStatusCode(), ErrorInfo.from(errorCode)); - } - - public static APIGatewayProxyResponseEvent badRequest(String message) { - return fail(CommonErrorCode.INVALID_INPUT, message); - } - - public static APIGatewayProxyResponseEvent notFound(String message) { - return fail(CommonErrorCode.RESOURCE_NOT_FOUND, message); - } - - // ... 기타 편의 메서드 - - private static APIGatewayProxyResponseEvent buildResponse(int statusCode, Object body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(new HashMap<>(CORS_HEADERS)) - .withBody(body != null ? GSON.toJson(body) : null); - } - - public static Gson gson() { - return GSON; - } -} -``` - ---- - -## 10. Bean Validation - -### 10.1 BeanValidator 패턴 - -```mermaid -flowchart TB - REQ[요청 수신] --> PARSE[JSON 파싱 → DTO] -PARSE --> VALIDATE[BeanValidator.validateAndExecute] -VALIDATE --> CHECK{검증 통과?} -CHECK -->|Yes|HANDLER[핸들러 로직 실행] -CHECK -->|No|ERR400[400 Bad Request] -HANDLER --> RESPONSE[정상 응답] -``` - -### 10.2 BeanValidator.java - -```java -public final class BeanValidator { - private static final Validator VALIDATOR; - - static { - ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); - VALIDATOR = factory.getValidator(); - } - - // 검증 + 실행 통합 패턴 - public static APIGatewayProxyResponseEvent validateAndExecute( - T object, - Function handler) { - - Optional error = validate(object); - if (error.isPresent()) { - return ResponseGenerator.badRequest(error.get()); - } - - return handler.apply(object); - } - - public static Optional validate(T object) { - Set> violations = VALIDATOR.validate(object); - if (violations.isEmpty()) { - return Optional.empty(); - } - - String message = violations.stream() - .map(ConstraintViolation::getMessage) - .collect(Collectors.joining(", ")); - - return Optional.of(message); - } -} -``` - -### 10.3 DTO 검증 예시 - -```java -// 요청 DTO -public class CreateRoomRequest { - @NotEmpty(message = "방 이름은 필수입니다") - private String roomName; - - @NotNull(message = "난이도는 필수입니다") - private String difficulty; - - @Min(value = 2, message = "최소 2명 이상이어야 합니다") - @Max(value = 10, message = "최대 10명까지 가능합니다") - private int maxMembers; -} - -// Handler에서 사용 -private APIGatewayProxyResponseEvent createRoom( - APIGatewayProxyRequestEvent request, String userId) { - - CreateRoomRequest req = ResponseGenerator.gson() - .fromJson(request.getBody(), CreateRoomRequest.class); - - return BeanValidator.validateAndExecute(req, dto -> { - // 검증 통과 시에만 실행됨 - ChatRoom room = roomService.createRoom(userId, dto); - return ResponseGenerator.created("방이 생성되었습니다", room); - }); -} -``` - ---- - -## 11. WebSocket 유틸리티 - -### 11.1 브로드캐스트 흐름 - -```mermaid -sequenceDiagram - participant Service - participant Broadcaster as WebSocketBroadcaster - participant APIGW as API Gateway - participant Clients as WebSocket Clients - Service ->> Broadcaster: broadcast(connections, message) - - loop 각 연결에 전송 - Broadcaster ->> APIGW: postToConnection(connectionId, data) - alt 성공 - APIGW -->> Clients: 메시지 전달 - else 연결 끊김 (410 Gone) - APIGW -->> Broadcaster: GoneException - Broadcaster ->> Broadcaster: failedIds에 추가 - end - end - - Broadcaster -->> Service: failedConnectionIds 반환 - Service ->> Service: Stale 연결 정리 -``` - -### 11.2 WebSocketBroadcaster.java - -```java -public class WebSocketBroadcaster { - private final ApiGatewayManagementApiClient apiClient; - - public WebSocketBroadcaster() { - String endpoint = WebSocketConfig.websocketEndpoint(); - this.apiClient = ApiGatewayManagementApiClient.builder() - .endpointOverride(URI.create(endpoint)) - .build(); - } - - // 단일 연결에 전송 - public boolean sendToConnection(String connectionId, String message) { - try { - apiClient.postToConnection(PostToConnectionRequest.builder() - .connectionId(connectionId) - .data(SdkBytes.fromUtf8String(message)) - .build()); - return true; - } catch (GoneException e) { - // 연결이 이미 끊김 - return false; - } - } - - // 다수 연결에 브로드캐스트 - public List broadcast(List connections, String message) { - List failedIds = new ArrayList<>(); - - for (Connection conn : connections) { - if (!sendToConnection(conn.getConnectionId(), message)) { - failedIds.add(conn.getConnectionId()); - } - } - - return failedIds; // 실패한 연결 ID 반환 (정리용) - } -} -``` - -### 11.3 WebSocket 응답 유틸리티 - -```java -public final class WebSocketResponseUtil { - public static Map ok(String message) { - return response(200, message); - } - - public static Map unauthorized(String message) { - return response(401, message); - } - - public static Map badRequest(String message) { - return response(400, message); - } - - private static Map response(int statusCode, String body) { - return Map.of( - "statusCode", statusCode, - "body", body - ); - } -} -``` - ---- - -## 12. S3 Presigned URL - -### 12.1 S3PresignUtil.java - -```java -public class S3PresignUtil { - private static final Duration DEFAULT_DURATION = Duration.ofHours(24); - private static final String BUCKET_NAME = System.getenv("S3_BUCKET_NAME"); - - // 내부 캐시 (Java 21 Record) - private record CachedUrl(String url, long expiresAt) { - boolean isExpired() { - // 1시간 버퍼 두고 만료 체크 - return System.currentTimeMillis() > (expiresAt - 3600_000); - } - } - - private static final Map URL_CACHE = new ConcurrentHashMap<>(); - - public static String getPresignedUrl(String key) { - return getPresignedUrl(key, DEFAULT_DURATION); - } - - public static String getPresignedUrl(String key, Duration duration) { - CachedUrl cached = URL_CACHE.get(key); - if (cached != null && !cached.isExpired()) { - return cached.url(); - } - - GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() - .signatureDuration(duration) - .getObjectRequest(r -> r.bucket(BUCKET_NAME).key(key)) - .build(); - - String url = AwsClients.s3Presigner() - .presignGetObject(presignRequest) - .url() - .toString(); - - URL_CACHE.put(key, new CachedUrl(url, - System.currentTimeMillis() + duration.toMillis())); - - return url; - } - - // 배지 이미지 URL 생성 편의 메서드 - public static String getBadgeImageUrl(String imageFile) { - return getPresignedUrl("badges/" + imageFile); - } -} -``` - ---- - -## 13. AWS 서비스 래퍼 - -### 13.1 PollyService (TTS + S3 캐시) - -```mermaid -flowchart TB - REQ[음성 합성 요청] --> CHECK{S3 캐시 확인} - CHECK -->|캐시 있음| PRESIGN[Presigned URL 생성] - CHECK -->|캐시 없음| SYNTH[Polly 음성 합성] - SYNTH --> SAVE[S3 저장] - SAVE --> PRESIGN - PRESIGN --> RETURN[URL 반환] -``` - -```java -public class PollyService { - public VoiceSynthesisResult synthesizeSpeech(String id, String text, String voice) { - String s3Key = generateS3Key(id, voice); - - // 캐시 확인 - if (existsInS3(s3Key)) { - return new VoiceSynthesisResult(s3Key, getPresignedUrl(s3Key), true); - } - - // Polly 음성 합성 - VoiceId voiceId = "MALE".equalsIgnoreCase(voice) ? VoiceId.MATTHEW : VoiceId.JOANNA; - - SynthesizeSpeechRequest request = SynthesizeSpeechRequest.builder() - .text(text) - .voiceId(voiceId) - .engine("neural") // Neural 음성 (고품질) - .outputFormat(OutputFormat.MP3) - .build(); - - InputStream audioStream = AwsClients.polly().synthesizeSpeech(request); - - // S3 저장 - AwsClients.s3().putObject( - PutObjectRequest.builder() - .bucket(bucketName) - .key(s3Key) - .contentType("audio/mpeg") - .build(), - RequestBody.fromInputStream(audioStream, -1) - ); - - return new VoiceSynthesisResult(s3Key, getPresignedUrl(s3Key), false); - } - - public String generateS3Key(String id, String voice) { - String suffix = "MALE".equalsIgnoreCase(voice) ? "male" : "female"; - return s3KeyPrefix + id + "_" + suffix + ".mp3"; - } -} -``` - -### 13.2 ComprehendService (NLP 분석) - -```java -public class ComprehendService { - public ComprehendAnalysis analyze(String text) { - // 감정 분석 - DetectSentimentResponse sentiment = AwsClients.comprehend() - .detectSentiment(DetectSentimentRequest.builder() - .text(text) - .languageCode("en") - .build()); - - // 구문 분석 (품사 태깅) - DetectSyntaxResponse syntax = AwsClients.comprehend() - .detectSyntax(DetectSyntaxRequest.builder() - .text(text) - .languageCode("en") - .build()); - - // 핵심 구문 추출 - DetectKeyPhrasesResponse keyPhrases = AwsClients.comprehend() - .detectKeyPhrases(DetectKeyPhrasesRequest.builder() - .text(text) - .languageCode("en") - .build()); - - // 문장 복잡도 계산 - String complexity = calculateComplexity(syntax.syntaxTokens()); - - return ComprehendAnalysis.builder() - .sentiment(sentiment.sentimentAsString()) - .syntax(mapTokens(syntax.syntaxTokens())) - .keyPhrases(mapKeyPhrases(keyPhrases.keyPhrases())) - .complexity(complexity) - .build(); - } - - private String calculateComplexity(List tokens) { - Set uniquePOS = tokens.stream() - .map(t -> t.partOfSpeech().tagAsString()) - .collect(Collectors.toSet()); - - if (uniquePOS.size() <= 3 && tokens.size() <= 5) return "BEGINNER"; - if (uniquePOS.size() <= 5 && tokens.size() <= 10) return "INTERMEDIATE"; - return "ADVANCED"; - } -} -``` - ---- - -## 14. 설정 클래스 - -### 14.1 StudyConfig (학습 알고리즘 상수) - -```java -public final class StudyConfig { - // SM-2 알고리즘 상수 - public static final int INITIAL_INTERVAL_DAYS = 1; - public static final double DEFAULT_EASE_FACTOR = 2.5; - public static final double MIN_EASE_FACTOR = 1.3; - public static final int INITIAL_REPETITIONS = 0; - - // 테스트 설정 - public static final int DEFAULT_WORD_COUNT = 20; - public static final int DAILY_TEST_WORD_COUNT = 10; - - // 복습 주기 (일) - public static final int[] REVIEW_INTERVALS = {1, 3, 7, 14, 30}; - - // 상태 기본값 - public static final String DEFAULT_WORD_STATUS = "NEW"; - public static final String DEFAULT_DIFFICULTY = "NORMAL"; - - // 오류 제한 - public static final int MAX_WRONG_COUNT = 3; -} -``` - -### 14.2 DynamoDbKey (키 패턴 상수) - -```java -public final class DynamoDbKey { - // 기본 키 - public static final String PK = "PK"; - public static final String SK = "SK"; - - // GSI 키 - public static final String GSI1_PK = "GSI1PK"; - public static final String GSI1_SK = "GSI1SK"; - public static final String GSI2_PK = "GSI2PK"; - public static final String GSI2_SK = "GSI2SK"; - - // GSI 이름 - public static final String GSI1 = "GSI1"; - public static final String GSI2 = "GSI2"; - - // 공통 접두사 - public static final String USER = "USER#"; - public static final String METADATA = "METADATA"; - - // 헬퍼 메서드 - public static String userPk(String userId) { - return USER + userId; // "USER#user-123" - } -} -``` - ---- - -## 15. Java 21 기능 활용 - -### 15.1 Records 활용 - -| 클래스 | 용도 | -|-----------------|----------------| -| ApiResponse | 제네릭 API 응답 래퍼 | -| ErrorInfo | RFC 7807 에러 응답 | -| PaginatedResult | 페이지네이션 결과 | -| Route | HTTP 라우트 정의 | -| RouteEntry | 라우터 내부 매칭 | -| CachedUrl | S3 URL 캐시 | - -### 15.2 Sealed Interface 활용 - -```mermaid -flowchart TB - subgraph SealedPattern["Sealed Interface 패턴"] - EC[/"sealed interface ErrorCode
permits CommonErrorCode, DomainErrorCode"/] - CEC["final enum CommonErrorCode
implements ErrorCode"] - DEC[/"non-sealed interface DomainErrorCode
extends ErrorCode"/] - EC --> CEC - EC --> DEC - end -``` - -### 15.3 Pattern Matching 활용 - -```java -// instanceof 패턴 매칭 -String code = errorCode instanceof DomainErrorCode domainCode - ? domainCode.getFullCode() // "VOCABULARY.WORD_001" - : errorCode.getCode(); // "AUTH_001" - -// switch 표현식 (Enhanced) -return switch(type. - -getCategory()){ - case"FIRST_STUDY"->stats. - -getTestsCompleted() >=1; - case"STREAK"->stats. - -getCurrentStreak() >=type. - -getThreshold(); - case"ACCURACY"->{ -double accuracy = (double) stats.getCorrectAnswers() / stats.getQuestionsAnswered() * 100; -yield accuracy >=type. - -getThreshold(); - } -default ->false; - }; -``` - ---- - -## 16. 디자인 패턴 요약 - -| 패턴 | 적용 위치 | 목적 | -|----------------------|------------------------|-------------------| -| **Singleton** | AwsClients | AWS SDK 클라이언트 재사용 | -| **Factory Method** | Route, CommonException | 객체 생성 캡슐화 | -| **Strategy** | AuthenticatedHandler | 요청 처리 전략 분리 | -| **Router** | HandlerRouter | HTTP 요청 라우팅 | -| **Builder** | ComprehendAnalysis | 복잡한 객체 생성 | -| **Template Method** | BeanValidator | 검증-실행 흐름 템플릿 | -| **Sealed Interface** | ErrorCode 계층 | 구현 제한 | -| **Data Class** | Records | 불변 데이터 전송 | - ---- - -## 17. 파일 구조 - -``` -common/ -├── config/ -│ ├── AwsClients.java # AWS SDK 클라이언트 싱글톤 -│ ├── WebSocketConfig.java # WebSocket 설정 -│ ├── RoomTokenConfig.java # 방 토큰 TTL 설정 -│ └── StudyConfig.java # 학습 알고리즘 상수 -├── constants/ -│ └── DynamoDbKey.java # DynamoDB 키 패턴 -├── dto/ -│ ├── ApiResponse.java # 제네릭 응답 래퍼 (Record) -│ ├── ErrorInfo.java # RFC 7807 에러 (Record) -│ └── PaginatedResult.java # 페이지네이션 (Record) -├── enums/ -│ ├── Difficulty.java # EASY, NORMAL, HARD -│ └── StudyLevel.java # BEGINNER, INTERMEDIATE, ADVANCED -├── exception/ -│ ├── ServerlessException.java # 기본 예외 클래스 -│ ├── ErrorCode.java # Sealed Interface -│ ├── CommonErrorCode.java # 공통 에러 코드 -│ ├── DomainErrorCode.java # 도메인 에러 인터페이스 -│ └── CommonException.java # 예외 팩토리 -├── router/ -│ ├── HandlerRouter.java # HTTP 라우터 -│ ├── Route.java # 라우트 정의 (Record) -│ └── AuthenticatedHandler.java # 인증 핸들러 인터페이스 -├── service/ -│ ├── PollyService.java # TTS + S3 캐시 -│ └── ComprehendService.java # NLP 분석 -├── util/ -│ ├── ResponseGenerator.java # HTTP 응답 빌더 -│ ├── CursorUtil.java # 커서 페이지네이션 -│ ├── CognitoUtil.java # Cognito 인증 추출 -│ ├── JwtUtil.java # JWT 직접 파싱 -│ ├── WebSocketBroadcaster.java # WebSocket 브로드캐스트 -│ ├── WebSocketEventUtil.java # WebSocket 이벤트 추출 -│ ├── WebSocketResponseUtil.java # WebSocket 응답 빌더 -│ └── S3PresignUtil.java # Presigned URL 생성 -└── validation/ - └── BeanValidator.java # Bean Validation 유틸 -``` - ---- - -## 18. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **Build:** Gradle -- **AWS SDK:** AWS SDK for Java v2 -- **Validation:** Jakarta Bean Validation -- **JSON:** Gson -- **Pattern:** Singleton, Factory, Strategy, Router, Builder, Sealed Interface -- **Java 21 Features:** Records, Sealed Interface, Pattern Matching, Enhanced Switch diff --git a/docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md b/docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md deleted file mode 100644 index 5015a011..00000000 --- a/docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md +++ /dev/null @@ -1,465 +0,0 @@ -# Grammar Domain 세부 보고서 - -## 1. 개요 - -Grammar 도메인은 AWS Bedrock(Claude 3 Haiku)을 활용한 AI 기반 영어 문법 체크 시스템입니다. REST API와 WebSocket 스트리밍을 통해 실시간 문법 교정 및 대화형 학습을 -제공합니다. - ---- - -## 2. 전체 아키텍처 - -```mermaid -flowchart TB - subgraph Client["클라이언트"] - APP[Mobile/Web App] - end - - subgraph Gateway["API Gateway"] - REST[REST API] - WS[Grammar WebSocket] - end - - subgraph Lambda["Lambda Handlers"] - HANDLER[GrammarHandler] - CONNECT[StreamingConnectHandler] - DISCONNECT[StreamingDisconnectHandler] - STREAM[StreamingHandler] - end - - subgraph AI["AWS AI 서비스"] - BEDROCK[Bedrock
Claude 3 Haiku] - COMPREHEND[Comprehend
언어 분석] - end - - subgraph Storage["저장소"] - DDB[(DynamoDB)] - end - - APP --> REST - APP <--> WS - REST --> HANDLER - WS --> CONNECT - WS --> DISCONNECT - WS --> STREAM - HANDLER --> BEDROCK - HANDLER --> COMPREHEND - STREAM --> BEDROCK - HANDLER --> DDB - STREAM --> DDB -``` - ---- - -## 3. 문법 체크 흐름 - -### 3.1 동기식 문법 체크 - -```mermaid -sequenceDiagram - participant Client - participant Handler as GrammarHandler - participant Service as GrammarCheckService - participant Bedrock as AWS Bedrock - participant DB as DynamoDB - Client ->> Handler: POST /grammar/check - Handler ->> Service: checkGrammar(sentence, level) - Service ->> Bedrock: Claude API 호출 - Bedrock -->> Service: JSON 응답 - Service -->> Handler: GrammarCheckResponse - Handler -->> Client: 문법 교정 결과 -``` - -### 3.2 스트리밍 대화 - -```mermaid -sequenceDiagram - participant Client - participant WS as WebSocket - participant Handler as StreamingHandler - participant Service as ConversationService - participant Bedrock as AWS Bedrock - Client ->> WS: $connect?token={jwt} - WS -->> Client: 연결 성공 - Client ->> WS: 메시지 전송 - WS ->> Handler: $default 라우트 - Handler ->> Service: chatStreaming() - Service -->> Client: StartEvent (sessionId) - - loop 토큰 단위 스트리밍 - Bedrock -->> Service: 텍스트 청크 - Service -->> Client: TokenEvent - end - - Service -->> Client: CompleteEvent (전체 응답) -``` - ---- - -## 4. API 엔드포인트 - -### 4.1 REST API - -| Method | Endpoint | 설명 | -|--------|-------------------------------|---------------| -| POST | /grammar/check | 문법 체크 (단일 문장) | -| POST | /grammar/conversation | 대화형 문법 학습 | -| GET | /grammar/sessions | 대화 세션 목록 | -| GET | /grammar/sessions/{sessionId} | 세션 상세 | -| DELETE | /grammar/sessions/{sessionId} | 세션 삭제 | - -### 4.2 WebSocket API - -| Route | 설명 | -|-------------|-------------| -| $connect | JWT 토큰으로 연결 | -| $disconnect | 연결 해제 | -| $default | 스트리밍 메시지 처리 | - ---- - -## 5. 레벨별 문법 체크 - -### 5.1 학습 레벨 - -| 레벨 | 설명 | 피드백 스타일 | -|--------------|----|--------------------| -| BEGINNER | 초급 | 한국어 번역 + 쉬운 설명 | -| INTERMEDIATE | 중급 | 영어 위주 설명 | -| ADVANCED | 고급 | 상세한 문법 규칙 + 스타일 제안 | - -### 5.2 오류 유형 - -```mermaid -mindmap - root((문법 오류)) - 시제 - VERB_TENSE - 동사 시제 오류 - 일치 - SUBJECT_VERB_AGREEMENT - 주어-동사 일치 - 품사 - ARTICLE - 관사 오류 - PREPOSITION - 전치사 오류 - PRONOUN - 대명사 오류 - 구조 - WORD_ORDER - 어순 오류 - SENTENCE_STRUCTURE - 문장 구조 - 기타 - SPELLING - 철자 - PUNCTUATION - 구두점 - WORD_CHOICE - 어휘 선택 -``` - ---- - -## 6. 응답 포맷 - -### 6.1 문법 체크 응답 - -```json -{ - "originalSentence": "I goed to school yesterday", - "correctedSentence": "I went to school yesterday", - "score": 70, - "isCorrect": false, - "errors": [ - { - "type": "VERB_TENSE", - "original": "goed", - "corrected": "went", - "explanation": "'go'의 과거형은 'went'입니다 (불규칙 동사)", - "startIndex": 2, - "endIndex": 6 - } - ], - "feedback": "과거 시제를 잘 사용하려고 노력했네요! 불규칙 동사를 조금 더 연습해보세요." -} -``` - -### 6.2 대화 응답 - -```json -{ - "sessionId": "uuid", - "grammarCheck": { - /* 위와 동일 */ - }, - "aiResponse": "Great job! Your sentence structure is correct. Let's practice more complex sentences.", - "conversationTip": "Try using 'had gone' for past perfect tense." -} -``` - -### 6.3 스트리밍 이벤트 - -```json -// StartEvent -{ - "type": "start", - "sessionId": "uuid" -} - -// TokenEvent (실시간) -{ - "type": "token", - "token": "Great " -} -{ - "type": "token", - "token": "job!" -} - -// CompleteEvent (완료) -{ - "type": "complete", - "sessionId": "uuid", - "grammarCheck": { - ... - }, - "aiResponse": "...", - "conversationTip": "..." -} - -// ErrorEvent (오류 시) -{ - "type": "error", - "message": "..." -} -``` - ---- - -## 7. AWS Bedrock 통합 - -### 7.1 Claude 3 Haiku 설정 - -```java -public class BedrockGrammarCheckFactory { - private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; - private static final int MAX_TOKENS = 2048; - private static final String API_VERSION = "bedrock-2023-05-31"; -} -``` - -### 7.2 프롬프트 구조 - -**시스템 프롬프트 (초급):** - -``` -You are a friendly English grammar tutor for Korean speakers. -- Use simple English with Korean translations -- Be encouraging and supportive -- Explain grammar rules clearly -``` - -**사용자 프롬프트:** - -``` -Please check the grammar of this sentence: "{sentence}" - -Return JSON: -{ - "correctedSentence": "...", - "score": 0-100, - "isCorrect": boolean, - "errors": [...], - "feedback": "..." -} -``` - -### 7.3 스트리밍 응답 파싱 - -``` -[RESPONSE] -AI의 자연스러운 대화 응답 -[/RESPONSE] - -[GRAMMAR] -{ JSON 형식의 문법 체크 결과 } -[/GRAMMAR] - -[TIP] -학습 팁 -[/TIP] -``` - ---- - -## 8. 데이터 모델 - -### 8.1 GrammarSession - -```java - -@DynamoDbBean -public class GrammarSession { - String sessionId; - String userId; - String level; // BEGINNER, INTERMEDIATE, ADVANCED - String topic; // "Conversation Practice" - Integer messageCount; - String lastMessage; // 마지막 메시지 (100자 제한) - String createdAt; - String updatedAt; - Long ttl; // 30일 -} -``` - -**DynamoDB Keys:** - -- PK: `GSESSION#{userId}` | SK: `SESSION#{sessionId}` -- GSI1: `GSESSION#ALL` | `UPDATED#{timestamp}` (최신순 정렬) - -### 8.2 GrammarMessage - -```java - -@DynamoDbBean -public class GrammarMessage { - String messageId; - String sessionId; - String userId; - String role; // USER, ASSISTANT - String content; // 원본 메시지 - String correctedContent; // 교정된 메시지 (USER만) - String errorsJson; // 오류 목록 JSON - Integer grammarScore; - String feedback; - Boolean isCorrect; - Long ttl; // 30일 -} -``` - -**DynamoDB Keys:** - -- PK: `GSESSION#{userId}` | SK: `MSG#{timestamp}#{messageId}` -- GSI1: `GSESSION#{sessionId}` | `MSG#{timestamp}` - -### 8.3 GrammarConnection (WebSocket) - -```java - -@DynamoDbBean -public class GrammarConnection { - String connectionId; // API Gateway 연결 ID - String userId; // JWT에서 추출 - String connectedAt; - Long ttl; // 연결 타임아웃 -} -``` - ---- - -## 9. AWS Comprehend 분석 (선택적) - -```mermaid -flowchart LR - INPUT[입력 문장] --> SENTIMENT[감정 분석] - INPUT --> SYNTAX[구문 분석] - INPUT --> KEYPHRASE[핵심 구문] - INPUT --> LANGUAGE[언어 감지] - SENTIMENT --> OUTPUT[분석 결과] - SYNTAX --> OUTPUT - KEYPHRASE --> OUTPUT - LANGUAGE --> OUTPUT -``` - -**분석 항목:** - -- 감정: POSITIVE, NEGATIVE, NEUTRAL, MIXED -- 품사 태깅: NOUN, VERB, ADJ 등 -- 핵심 구문 추출 -- 문장 복잡도 추정 - ---- - -## 10. 서비스 레이어 - -### 10.1 서비스 구성 - -| Service | 역할 | -|----------------------------|----------------| -| GrammarCheckService | 단일 문장 문법 체크 | -| GrammarConversationService | 대화형 학습 + 스트리밍 | -| GrammarSessionQueryService | 세션 조회, 삭제 | -| BedrockGrammarCheckFactory | Bedrock API 호출 | - -### 10.2 대화 히스토리 관리 - -```java -// 최근 10개 메시지만 컨텍스트로 유지 -private static final int MAX_HISTORY_MESSAGES = 10; - -// 대화 히스토리 빌드 -String buildConversationHistory(String sessionId) { - // 최근 메시지 조회 - // USER: 내용 / ASSISTANT: 내용 형식으로 포맷 -} -``` - ---- - -## 11. 파일 구조 - -``` -domain/grammar/ -├── handler/ -│ ├── GrammarHandler.java -│ └── websocket/ -│ ├── GrammarStreamingConnectHandler.java -│ ├── GrammarStreamingDisconnectHandler.java -│ └── GrammarStreamingHandler.java -├── service/ -│ ├── GrammarCheckService.java -│ ├── GrammarConversationService.java -│ └── GrammarSessionQueryService.java -├── factory/ -│ ├── GrammarCheckFactory.java (interface) -│ └── BedrockGrammarCheckFactory.java -├── repository/ -│ ├── GrammarSessionRepository.java -│ └── GrammarConnectionRepository.java -├── model/ -│ ├── GrammarSession.java -│ ├── GrammarMessage.java -│ └── GrammarConnection.java -├── dto/ -│ ├── request/ -│ │ ├── GrammarCheckRequest.java -│ │ └── ConversationRequest.java -│ └── response/ -│ ├── GrammarCheckResponse.java -│ ├── ConversationResponse.java -│ ├── GrammarError.java -│ └── ComprehendAnalysis.java -├── streaming/ -│ ├── StreamingCallback.java -│ ├── StreamingEvent.java (sealed interface) -│ └── StreamingRequest.java -├── enums/ -│ ├── GrammarLevel.java -│ └── GrammarErrorType.java -└── constants/ - └── GrammarKey.java -``` - ---- - -## 12. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **API:** API Gateway REST + WebSocket -- **AI:** AWS Bedrock (Claude 3 Haiku) -- **NLP:** AWS Comprehend (선택적) -- **Database:** DynamoDB -- **Auth:** JWT (Cognito) -- **Pattern:** Factory, Callback, Sealed Interface (Java 17+) diff --git a/docs/domain-reports/STATS-DOMAIN-REPORT.md b/docs/domain-reports/STATS-DOMAIN-REPORT.md deleted file mode 100644 index 3ca3d3ff..00000000 --- a/docs/domain-reports/STATS-DOMAIN-REPORT.md +++ /dev/null @@ -1,379 +0,0 @@ -# Stats Domain 세부 보고서 - -## 1. 개요 - -Stats 도메인은 사용자의 학습 활동을 추적하고 통계를 집계하는 시스템입니다. DynamoDB Streams와 EventBridge를 활용한 이벤트 기반 아키텍처로 실시간 통계 업데이트를 제공합니다. - ---- - -## 2. 전체 아키텍처 - -```mermaid -flowchart TB - subgraph Triggers["트리거"] - TEST[테스트 완료] - DAILY[일일 학습] - GAME[게임 종료] - SCHEDULE[스케줄러
매일 자정] - end - - subgraph Processing["처리"] - STREAM[StatsStreamHandler
DynamoDB Streams] - SERVICE[StatsService
Write-through] - SCHEDULED[ScheduledStatsHandler
EventBridge] - end - - subgraph Storage["저장소"] - DDB[(DynamoDB
UserStats)] - end - - subgraph Query["조회"] - API[UserStatsHandler
REST API] - end - - TEST --> STREAM - DAILY --> SERVICE - GAME --> SERVICE - SCHEDULE --> SCHEDULED - STREAM --> DDB - SERVICE --> DDB - SCHEDULED --> DDB - DDB --> API -``` - ---- - -## 3. 통계 집계 방식 - -### 3.1 집계 레벨 - -```mermaid -flowchart LR - subgraph Levels["통계 집계 레벨"] - DAILY["일별
DAILY#2026-01-16"] - WEEKLY["주별
WEEKLY#2026-W03"] - MONTHLY["월별
MONTHLY#2026-01"] - TOTAL["전체
TOTAL"] - end - - EVENT[이벤트 발생] --> DAILY - EVENT --> WEEKLY - EVENT --> MONTHLY - EVENT --> TOTAL -``` - -### 3.2 Atomic Counter 패턴 - -```java -// 모든 레벨에 동시 업데이트 (원자적) -UpdateExpression: -SET correctAnswers = if_not_exists(correctAnswers, 0) + :correct, -incorrectAnswers = - -if_not_exists(incorrectAnswers, 0) +:incorrect, -testsCompleted = - -if_not_exists(testsCompleted, 0) +1, -updatedAt =:now -``` - ---- - -## 4. 이벤트 기반 통계 업데이트 - -### 4.1 DynamoDB Streams 처리 - -```mermaid -sequenceDiagram - participant Test as TestResult 저장 - participant Stream as DynamoDB Streams - participant Handler as StatsStreamHandler - participant DB as UserStats - Test ->> Stream: INSERT 이벤트 - Stream ->> Handler: 트리거 - Handler ->> Handler: PK/SK 패턴 확인
(TEST#userId, RESULT#timestamp) - Handler ->> DB: incrementTestStats() - Handler ->> DB: updateStudyStreak() - Handler ->> Handler: checkAndAwardBadges() -``` - -### 4.2 Write-through 패턴 - -```mermaid -sequenceDiagram - participant API as DailyStudyHandler - participant Service as StatsService - participant DB as UserStats - Note over API, DB: 단어 학습 완료 시 - API ->> Service: recordWordsLearned() - Service ->> DB: incrementWordsLearned()
(DAILY, WEEKLY, MONTHLY, TOTAL) - Service ->> DB: updateStudyStreak() -``` - ---- - -## 5. API 엔드포인트 - -### 5.1 통계 조회 API - -| Method | Endpoint | 설명 | 파라미터 | -|--------|----------------|---------|------------------| -| GET | /stats/daily | 일별 통계 | ?date=YYYY-MM-DD | -| GET | /stats/weekly | 주별 통계 | ?week=YYYY-Www | -| GET | /stats/monthly | 월별 통계 | ?month=YYYY-MM | -| GET | /stats/total | 전체 통계 | - | -| GET | /stats/history | 일별 히스토리 | ?cursor, ?limit | - -### 5.2 응답 예시 - -```json -{ - "periodType": "DAILY", - "period": "2026-01-16", - "testsCompleted": 3, - "questionsAnswered": 45, - "correctAnswers": 38, - "incorrectAnswers": 7, - "successRate": 84.44, - "newWordsLearned": 50, - "wordsReviewed": 5 -} -``` - -**전체 통계 추가 필드:** - -```json -{ - "currentStreak": 7, - "longestStreak": 14, - "lastStudyDate": "2026-01-16", - "gamesPlayed": 10, - "gamesWon": 3, - "totalGameScore": 450 -} -``` - ---- - -## 6. 연속 학습 (Streak) 시스템 - -### 6.1 스트릭 계산 로직 - -```mermaid -flowchart TB - START[학습 활동 발생] --> CHECK{lastStudyDate
확인} - CHECK -->|null| NEW["currentStreak = 1
longestStreak = 1"] - CHECK -->|오늘| SAME[변경 없음
이미 오늘 학습] - CHECK -->|어제| INCREMENT["currentStreak++
longestStreak = max()"] - CHECK -->|2일+ 전| RESET["currentStreak = 1
longestStreak 유지"] - NEW --> UPDATE[DB 업데이트] - INCREMENT --> UPDATE - RESET --> UPDATE -``` - -### 6.2 스트릭 리셋 (스케줄러) - -```java -// EventBridge: 매일 자정 실행 -@Scheduled -public void resetStreaks() { - String yesterday = LocalDate.now().minusDays(1).toString(); - // lastStudyDate != yesterday인 사용자의 스트릭 리셋 - // 비용 최적화로 클라이언트 측 계산 권장 -} -``` - ---- - -## 7. 데이터 모델 - -### 7.1 UserStats - -```java - -@DynamoDbBean -public class UserStats { - // 키 - String pk; // USER#{userId}#STATS - String sk; // DAILY#{date} | WEEKLY#{week} | MONTHLY#{month} | TOTAL - - // 메타데이터 - String userId; - String periodType; // DAILY, WEEKLY, MONTHLY, TOTAL - String period; // 2026-01-16, 2026-W03, 2026-01, TOTAL - - // 테스트 통계 - Integer testsCompleted; - Integer questionsAnswered; - Integer correctAnswers; - Integer incorrectAnswers; - Double successRate; - - // 학습 통계 - Integer newWordsLearned; - Integer wordsReviewed; - Integer wordsMastered; - - // 스트릭 (TOTAL만) - Integer currentStreak; - Integer longestStreak; - String lastStudyDate; - - // 게임 통계 (TOTAL만) - Integer gamesPlayed; - Integer gamesWon; - Integer correctGuesses; - Integer totalGameScore; - Integer quickGuesses; // 5초 이내 정답 - Integer perfectDraws; // 전원 정답 유도 - - // 타임스탬프 - String createdAt; - String updatedAt; -} -``` - -### 7.2 DynamoDB 키 구조 - -| 필드 | 패턴 | 예시 | -|---------|------------------------|-------------------| -| PK | USER#{userId}#STATS | USER#abc123#STATS | -| SK (일별) | DAILY#{date} | DAILY#2026-01-16 | -| SK (주별) | WEEKLY#{year}-W{week} | WEEKLY#2026-W03 | -| SK (월별) | MONTHLY#{year}-{month} | MONTHLY#2026-01 | -| SK (전체) | TOTAL | TOTAL | - ---- - -## 8. 통계 메트릭 - -### 8.1 테스트 메트릭 - -| 메트릭 | 설명 | 업데이트 시점 | -|-------------------|----------|---------| -| testsCompleted | 완료 테스트 수 | 테스트 제출 | -| questionsAnswered | 총 문제 수 | 테스트 제출 | -| correctAnswers | 정답 수 | 테스트 제출 | -| incorrectAnswers | 오답 수 | 테스트 제출 | -| successRate | 정답률 (%) | 조회 시 계산 | - -### 8.2 학습 메트릭 - -| 메트릭 | 설명 | 업데이트 시점 | -|-----------------|----------|---------| -| newWordsLearned | 신규 학습 단어 | 일일학습 완료 | -| wordsReviewed | 복습 단어 | 일일학습 완료 | -| wordsMastered | 마스터 단어 | 상태 변경 시 | - -### 8.3 게임 메트릭 - -| 메트릭 | 설명 | 업데이트 시점 | -|----------------|----------|---------| -| gamesPlayed | 참여 게임 수 | 게임 종료 | -| gamesWon | 1등 횟수 | 게임 종료 | -| correctGuesses | 정답 횟수 | 게임 종료 | -| totalGameScore | 누적 점수 | 게임 종료 | -| quickGuesses | 5초 내 정답 | 게임 종료 | -| perfectDraws | 전원 정답 유도 | 게임 종료 | - ---- - -## 9. 히스토리 조회 - -### 9.1 페이지네이션 - -```mermaid -flowchart LR - REQUEST["GET /stats/history
?limit=7&cursor=..."] - QUERY["Query
PK = USER#id#STATS
SK begins_with DAILY#
scanIndexForward = false"] - ENRICH["DailyStudy 조회
isCompleted 추가"] - RESPONSE["PaginatedResult
items, nextCursor, hasMore"] - REQUEST --> QUERY --> ENRICH --> RESPONSE -``` - -### 9.2 응답 구조 - -```json -{ - "history": [ - { - "period": "2026-01-16", - "testsCompleted": 2, - "questionsAnswered": 30, - "correctAnswers": 25, - "incorrectAnswers": 5, - "successRate": 83.33, - "newWordsLearned": 50, - "wordsReviewed": 5, - "isCompleted": true - } - ], - "nextCursor": "base64encoded...", - "hasMore": true -} -``` - ---- - -## 10. 배지 연동 - -### 10.1 자동 배지 체크 - -```mermaid -flowchart TB - STREAM[StatsStreamHandler] --> CHECK[배지 조건 체크] - CHECK --> PERFECT{만점 테스트?} - PERFECT -->|Yes| BADGE1[PERFECT_SCORE 배지] - CHECK --> STATS[전체 통계 조회] - STATS --> BADGESERVICE[BadgeService.checkAndAwardBadges] - BADGESERVICE --> AWARD[조건 충족 배지 부여] -``` - -### 10.2 배지 조건 예시 - -| 배지 | 조건 | 통계 필드 | -|--------------|----------|----------------------| -| STREAK_7 | 7일 연속 학습 | currentStreak >= 7 | -| ACCURACY_90 | 정확도 90% | successRate >= 90 | -| TEST_10 | 10회 테스트 | testsCompleted >= 10 | -| GAME_10_WINS | 10번 1등 | gamesWon >= 10 | - ---- - -## 11. 파일 구조 - -``` -domain/stats/ -├── handler/ -│ ├── UserStatsHandler.java (REST API) -│ ├── StatsStreamHandler.java (DynamoDB Streams) -│ └── ScheduledStatsHandler.java (EventBridge) -├── service/ -│ └── StatsService.java -├── repository/ -│ └── UserStatsRepository.java -├── model/ -│ └── UserStats.java -└── constants/ - └── StatsKey.java -``` - ---- - -## 12. 성능 최적화 - -| 최적화 | 기법 | 효과 | -|--------------------------|------------------|-------------------| -| 원자적 업데이트 | UpdateExpression | Race condition 방지 | -| 비동기 처리 | DynamoDB Streams | API 응답 속도 향상 | -| Cursor 페이지네이션 | lastEvaluatedKey | 대용량 히스토리 처리 | -| Strongly Consistent Read | 히스토리 조회 | 데이터 정합성 | - ---- - -## 13. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **Database:** DynamoDB (Single Table Design) -- **Event:** DynamoDB Streams, EventBridge -- **Pattern:** Atomic Counter, Write-through, Event-driven diff --git a/docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md b/docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md deleted file mode 100644 index 7ee2c90e..00000000 --- a/docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md +++ /dev/null @@ -1,504 +0,0 @@ -# Vocabulary Domain 세부 보고서 - -## 1. 개요 - -Vocabulary 도메인은 AWS Lambda와 DynamoDB를 기반으로 한 영어 단어 학습 시스템입니다. SM-2 Spaced Repetition 알고리즘과 CQRS 패턴을 적용하여 과학적이고 효율적인 단어 -암기를 지원합니다. - ---- - -## 2. 전체 아키텍처 - -```mermaid -flowchart TB - subgraph Client["클라이언트"] - APP[Mobile/Web App] - end - - subgraph Gateway["API Gateway"] - REST[REST API
HTTP] - end - - subgraph Lambda["Lambda Handlers"] - direction TB - WORD[WordHandler] - USERWORD[UserWordHandler] - DAILY[DailyStudyHandler] - TEST[TestHandler] - GROUP[WordGroupHandler] - VOICE[VoiceHandler] - STATS[StatisticsHandler
SQS Consumer] - end - - subgraph Services["서비스 레이어 (CQRS)"] - direction TB - CMD[Command Services
쓰기 작업] - QUERY[Query Services
읽기 작업] - end - - subgraph External["외부 서비스"] - POLLY[AWS Polly
TTS] - SNS[AWS SNS] - SQS[AWS SQS] - S3[(S3
음성 캐시)] - end - - subgraph Storage["데이터 저장소"] - DDB[(DynamoDB)] - end - - APP --> REST - REST --> WORD & USERWORD & DAILY & TEST & GROUP & VOICE - WORD & USERWORD & DAILY & TEST & GROUP --> CMD & QUERY - CMD & QUERY --> DDB - VOICE --> POLLY --> S3 - TEST --> SNS --> SQS --> STATS - STATS --> DDB -``` - ---- - -## 3. 일일 학습 시스템 - -### 3.1 일일 학습 흐름 - -```mermaid -flowchart TB - subgraph DailyStudyFlow["일일 학습 흐름"] - START[GET /vocab/daily] --> CHECK{기존 학습
존재?} - CHECK -->|Yes| RETURN[기존 학습 반환] - CHECK -->|No| CREATE[새 학습 생성] - CREATE --> REVIEW["복습 단어 5개 선정
(nextReviewAt <= today)"] - REVIEW --> NEW["신규 단어 50개 선정
(미학습 + 해당 레벨)"] - NEW --> SAVE[DailyStudy 저장] - SAVE --> RETURN - RETURN --> LEARN[학습 진행] - LEARN --> MARK["POST .../learned
단어별 학습 완료"] - MARK --> PROGRESS{50개 완료?} - PROGRESS -->|No| LEARN - PROGRESS -->|Yes| COMPLETE["isCompleted = true
배지 체크"] - end -``` - -### 3.2 Daily Study API - -| Method | Endpoint | 설명 | -|--------|-------------------------------------|-----------------| -| GET | /vocab/daily | 오늘의 학습 단어 조회/생성 | -| POST | /vocab/daily/words/{wordId}/learned | 단어 학습 완료 처리 | - -### 3.3 응답 예시 - -```json -{ - "userId": "user123", - "date": "2026-01-16", - "newWordIds": [ - "word1", - "word2", - ... - ], - "reviewWordIds": [ - "word51", - "word52", - ... - ], - "learnedWordIds": [], - "totalWords": 55, - "learnedCount": 0, - "isCompleted": false, - "progress": { - "percentage": 0, - "learned": 0, - "total": 55 - } -} -``` - ---- - -## 4. SM-2 Spaced Repetition 알고리즘 - -### 4.1 학습 상태 전이 - -```mermaid -stateDiagram-v2 - [*] --> NEW: 단어 추가 - NEW --> LEARNING: 첫 학습 - LEARNING --> LEARNING: 오답 - LEARNING --> REVIEWING: 2회 연속 정답 - REVIEWING --> LEARNING: 오답 - REVIEWING --> MASTERED: 5회 연속 정답 - MASTERED --> LEARNING: 오답 - MASTERED --> MASTERED: 정답 유지 -``` - -### 4.2 상태별 로직 - -| 상태 | 조건 | 정답 시 | 오답 시 | -|---------------|-------------|-----------------------------|---------------------------| -| **NEW** | 신규 단어 | LEARNING, rep=1, interval=1 | LEARNING, easeFactor-=0.2 | -| **LEARNING** | rep < 2 | rep++, interval 계산 | rep=0, interval=1 | -| **REVIEWING** | 2 ≤ rep < 5 | rep++, interval 증가 | rep=0, LEARNING | -| **MASTERED** | rep ≥ 5 | interval 증가, 유지 | rep=0, REVIEWING | - -### 4.3 복습 간격 계산 - -```mermaid -flowchart LR - REP1["rep = 1
interval = 1일"] - REP2["rep = 2
interval = 6일"] - REP3["rep >= 3
interval = interval × easeFactor"] - REP1 --> REP2 --> REP3 -``` - -**핵심 변수:** - -- `repetitions`: 연속 정답 횟수 (0~∞) -- `interval`: 복습 간격 (일 단위) -- `easeFactor`: 난이도 계수 (1.3~2.5, 기본 2.5) -- `nextReviewAt`: 다음 복습 예정일 - ---- - -## 5. 테스트 시스템 - -### 5.1 테스트 흐름 - -```mermaid -sequenceDiagram - participant Client - participant Handler as TestHandler - participant Service as TestCommandService - participant DB as DynamoDB - participant SNS as AWS SNS - Client ->> Handler: POST /vocab/tests/start - Handler ->> Service: startTest(userId, testType) - Service ->> DB: 오늘의 학습 단어 조회 - Service ->> Service: 4지선다 문제 생성 - Service -->> Client: 문제 목록 반환 - Note over Client: 사용자 답변 입력 - Client ->> Handler: POST /vocab/tests/submit - Handler ->> Service: submitTest(answers) - Service ->> DB: 결과 저장 - Service ->> SNS: 결과 발행 (비동기) - Service -->> Client: 테스트 결과 - Note over SNS, DB: 비동기 통계 처리 - SNS ->> DB: 통계 업데이트 -``` - -### 5.2 문제 생성 알고리즘 - -```mermaid -flowchart TB - START[문제 생성 시작] --> WORDS[일일 학습 단어 로드] - WORDS --> GROUP[레벨별 그룹화] - GROUP --> LOOP[각 단어마다] - LOOP --> CORRECT["정답 = 해당 단어의
한국어 뜻"] - CORRECT --> DIST["오답 3개 선정
(동일 레벨 단어)"] - DIST --> SHUFFLE[4개 보기 셔플] - SHUFFLE --> NEXT{다음 단어?} - NEXT -->|Yes| LOOP - NEXT -->|No| RETURN[문제 목록 반환] -``` - -### 5.3 Test API - -| Method | Endpoint | 설명 | -|--------|-------------------------------|------------| -| POST | /vocab/tests/start | 테스트 시작 | -| POST | /vocab/tests/submit | 테스트 제출 | -| GET | /vocab/tests/results | 테스트 결과 목록 | -| GET | /vocab/tests/results/{testId} | 테스트 상세 결과 | -| GET | /vocab/tests/tested-words | 최근 테스트된 단어 | - ---- - -## 6. 단어 관리 시스템 - -### 6.1 Word API - -| Method | Endpoint | 설명 | -|--------|------------------------|----------------------------| -| GET | /vocab/words | 단어 목록 (level, category 필터) | -| POST | /vocab/words | 단어 등록 | -| GET | /vocab/words/{wordId} | 단어 상세 | -| PUT | /vocab/words/{wordId} | 단어 수정 | -| DELETE | /vocab/words/{wordId} | 단어 삭제 | -| GET | /vocab/words/search | 키워드 검색 | -| POST | /vocab/words/batch | 배치 등록 (최대 100개) | -| POST | /vocab/words/batch/get | 배치 조회 | - -### 6.2 User Word API - -| Method | Endpoint | 설명 | -|--------|-----------------------------------|-------------| -| GET | /vocab/user-words | 사용자 단어 목록 | -| GET | /vocab/user-words/{wordId} | 사용자 단어 상세 | -| PUT | /vocab/user-words/{wordId} | 정답/오답 기록 | -| PATCH | /vocab/user-words/{wordId}/tag | 북마크, 난이도 설정 | -| PATCH | /vocab/user-words/{wordId}/status | 상태 수동 변경 | -| GET | /vocab/wrong-answers | 오답 단어 목록 | - -### 6.3 Word Group API - -| Method | Endpoint | 설명 | -|--------|----------------------------------------|--------| -| POST | /vocab/groups | 단어장 생성 | -| GET | /vocab/groups | 단어장 목록 | -| GET | /vocab/groups/{groupId} | 단어장 상세 | -| PUT | /vocab/groups/{groupId} | 단어장 수정 | -| DELETE | /vocab/groups/{groupId} | 단어장 삭제 | -| POST | /vocab/groups/{groupId}/words/{wordId} | 단어 추가 | -| DELETE | /vocab/groups/{groupId}/words/{wordId} | 단어 제거 | - ---- - -## 7. TTS 음성 합성 - -### 7.1 음성 생성 흐름 - -```mermaid -flowchart TB - REQUEST["POST /vocab/synthesize
{wordId, voice, type}"] - CHECK{S3 캐시
존재?} - REQUEST --> CHECK - CHECK -->|Yes| PRESIGN[Presigned URL 생성] - CHECK -->|No| POLLY[AWS Polly 호출] - POLLY --> SAVE[S3 저장] - SAVE --> PRESIGN - PRESIGN --> RESPONSE[URL 반환] -``` - -### 7.2 Voice API - -```json -// Request -{ - "wordId": "uuid", - "voice": "MALE", - // MALE | FEMALE - "type": "WORD" - // WORD | EXAMPLE -} - -// Response -{ - "url": "https://s3...presigned-url", - "expiresIn": 3600 -} -``` - ---- - -## 8. 데이터 모델 - -### 8.1 Word - -```java - -@DynamoDbBean -public class Word { - String wordId; // UUID - String english; // 영어 단어 - String korean; // 한국어 뜻 - String example; // 예문 - String level; // BEGINNER | INTERMEDIATE | ADVANCED - String category; // DAILY | BUSINESS | ACADEMIC | TRAVEL | TECHNOLOGY - String maleVoiceKey; // S3 음성 키 - String femaleVoiceKey; - String maleExampleVoiceKey; - String femaleExampleVoiceKey; -} -``` - -**DynamoDB Keys:** - -| Key | 패턴 | 용도 | -|--------|---------------------|----------| -| PK | WORD#{wordId} | 기본 조회 | -| SK | METADATA | - | -| GSI1PK | LEVEL#{level} | 레벨별 조회 | -| GSI2PK | CATEGORY#{category} | 카테고리별 조회 | - -### 8.2 UserWord - -```java - -@DynamoDbBean -public class UserWord { - String userId; - String wordId; - String status; // NEW | LEARNING | REVIEWING | MASTERED - - // SM-2 알고리즘 필드 - Integer interval; // 복습 간격 (일) - Double easeFactor; // 난이도 계수 (1.3~2.5) - Integer repetitions; // 연속 정답 횟수 - String nextReviewAt; // 다음 복습일 (YYYY-MM-DD) - - // 통계 - Integer correctCount; // 누적 정답 - Integer incorrectCount; // 누적 오답 - - // 사용자 설정 - Boolean bookmarked; // 북마크 - Boolean favorite; // 즐겨찾기 - String difficulty; // EASY | NORMAL | HARD -} -``` - -**DynamoDB Keys:** - -| Key | 패턴 | 용도 | -|--------|--------------------------|--------------| -| PK | USER#{userId} | 기본 조회 | -| SK | WORD#{wordId} | - | -| GSI1PK | USER#{userId}#REVIEW | 복습 예정 단어 | -| GSI1SK | DATE#{nextReviewAt} | - | -| GSI2PK | USER#{userId}#STATUS | 상태별 조회 | -| GSI2SK | STATUS#{status} | - | -| GSI3PK | USER#{userId}#BOOKMARKED | 북마크 (Sparse) | - -### 8.3 DailyStudy - -```java - -@DynamoDbBean -public class DailyStudy { - String userId; - String date; // YYYY-MM-DD - List newWordIds; // 신규 단어 50개 - List reviewWordIds; // 복습 단어 5개 - List learnedWordIds; // 학습 완료 단어 - Integer totalWords; // 총 단어 수 (55) - Integer learnedCount; // 학습 완료 수 - Boolean isCompleted; // 완료 여부 -} -``` - -### 8.4 TestResult - -```java - -@DynamoDbBean -public class TestResult { - String testId; - String userId; - String testType; // DAILY | WEEKLY | CUSTOM - Integer totalQuestions; - Integer correctAnswers; - Integer incorrectAnswers; - Double successRate; - List testedWordIds; - List incorrectWordIds; - String startedAt; - String completedAt; -} -``` - ---- - -## 9. 서비스 아키텍처 (CQRS) - -### 9.1 Command Services (쓰기) - -```mermaid -flowchart TB - subgraph Commands["Command Services"] - WC[WordCommandService
단어 생성/수정/삭제] - UC[UserWordCommandService
학습 상태 업데이트] - DC[DailyStudyCommandService
일일 학습 관리] - TC[TestCommandService
테스트 생성/제출] - GC[WordGroupCommandService
단어장 관리] - end -``` - -### 9.2 Query Services (읽기) - -```mermaid -flowchart TB - subgraph Queries["Query Services"] - WQ[WordQueryService
단어 조회/검색] - UQ[UserWordQueryService
학습 현황 조회] - DQ[DailyStudyQueryService
일일 학습 조회] - TQ[TestQueryService
테스트 결과 조회] - end -``` - ---- - -## 10. 성능 최적화 - -| 최적화 | 기법 | 효과 | -|---------------------|------------------------|-----------------| -| N+1 방지 | BatchGetItem (100개 단위) | DB 호출 90% 감소 | -| TTS 캐싱 | S3 + Presigned URL | Polly 호출 90% 절감 | -| 페이지네이션 | Cursor 기반 (Base64) | 대용량 데이터 처리 | -| Sparse Index | GSI3 (북마크 전용) | 인덱스 크기 최소화 | -| 비동기 통계 | SNS/SQS | API 응답 속도 향상 | -| Strongly Consistent | DailyStudy 조회 | 데이터 정합성 | - ---- - -## 11. 파일 구조 - -``` -domain/vocabulary/ -├── handler/ -│ ├── WordHandler.java -│ ├── UserWordHandler.java -│ ├── DailyStudyHandler.java -│ ├── TestHandler.java -│ ├── WordGroupHandler.java -│ ├── VoiceHandler.java -│ ├── StatsHandler.java -│ └── StatisticsHandler.java (SQS) -├── service/ -│ ├── WordCommandService.java -│ ├── WordQueryService.java -│ ├── UserWordCommandService.java -│ ├── UserWordQueryService.java -│ ├── TestCommandService.java -│ ├── TestQueryService.java -│ ├── DailyStudyCommandService.java -│ ├── DailyStudyQueryService.java -│ ├── WordGroupCommandService.java -│ ├── StatsService.java -│ └── StatisticsService.java -├── repository/ -│ ├── WordRepository.java -│ ├── UserWordRepository.java -│ ├── DailyStudyRepository.java -│ ├── TestResultRepository.java -│ └── WordGroupRepository.java -├── model/ -│ ├── Word.java -│ ├── UserWord.java -│ ├── DailyStudy.java -│ ├── TestResult.java -│ └── WordGroup.java -├── state/ -│ ├── WordState.java (interface) -│ ├── NewState.java -│ ├── LearningState.java -│ ├── ReviewingState.java -│ ├── MasteredState.java -│ ├── SpacedRepetitionContext.java -│ └── WordStateFactory.java -└── enums/ - ├── WordStatus.java - ├── WordCategory.java - └── TestType.java -``` - ---- - -## 12. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **Database:** DynamoDB (Single Table Design) -- **TTS:** AWS Polly (남성/여성 음성) -- **Storage:** S3 (음성 캐시) -- **Messaging:** SNS/SQS (비동기 통계) -- **Pattern:** CQRS, State, Repository, Factory From 0ca9df0ac0ff7240d850892859042c00fe4d3270 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 16:11:48 +0900 Subject: [PATCH 98/99] feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint --- .../docs/CATCHMIND_FRONTEND_GUIDE.md | 761 ------------------ .../docs/NEWS_API_FRONTEND_CHANGES.md | 304 ------- .../docs/VOCABULARY_NEWS_INTEGRATION.md | 308 ------- 3 files changed, 1373 deletions(-) delete mode 100644 ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md delete mode 100644 ServerlessFunction/docs/NEWS_API_FRONTEND_CHANGES.md delete mode 100644 ServerlessFunction/docs/VOCABULARY_NEWS_INTEGRATION.md diff --git a/ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md b/ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md deleted file mode 100644 index 4dd03385..00000000 --- a/ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md +++ /dev/null @@ -1,761 +0,0 @@ -# Catchmind 게임 프론트엔드 연동 가이드 - -## 목차 - -1. [개요](#개요) -2. [아키텍처](#아키텍처) -3. [WebSocket 연결](#websocket-연결) -4. [메시지 구조](#메시지-구조) -5. [게임 흐름](#게임-흐름) -6. [REST API](#rest-api) -7. [타이머 동기화](#타이머-동기화) -8. [게임 자동 종료](#게임-자동-종료) -9. [재접속 처리](#재접속-처리) -10. [에러 처리](#에러-처리) - ---- - -## 개요 - -Catchmind는 실시간 그림 맞추기 게임입니다. WebSocket을 통한 실시간 통신과 REST API를 통한 게임 세션 관리를 지원합니다. - -### 주요 특징 - -- **실시간 통신**: WebSocket 기반 양방향 통신 -- **도메인 분리**: `chat` / `game` 도메인으로 메시지 라우팅 -- **타이머 동기화**: `serverTime` 필드를 통한 클라이언트-서버 시간 동기화 -- **자동 종료**: 게임 시작 7분 후 자동 종료 -- **재접속 지원**: 게임 세션 API를 통한 상태 복원 - ---- - -## 아키텍처 - -``` -┌─────────────┐ WebSocket ┌──────────────────┐ -│ Frontend │◄──────────────────►│ API Gateway WS │ -│ (React) │ └────────┬─────────┘ -│ │ │ -│ │ REST API ┌───────▼─────────┐ -│ │◄───────────────────►│ API Gateway │ -└─────────────┘ │ REST │ - └────────┬────────┘ - │ - ┌─────────────┼─────────────┐ - │ │ │ - ┌─────▼────┐ ┌─────▼────┐ ┌─────▼────┐ - │ WS Msg │ │ Game │ │ Game │ - │ Handler │ │ Handler │ │ Session │ - └──────────┘ └──────────┘ │ Handler │ - └──────────┘ -``` - ---- - -## WebSocket 연결 - -### 연결 URL - -``` -wss://{api-id}.execute-api.{region}.amazonaws.com/dev?roomToken={token} -``` - -### 연결 절차 - -1. REST API로 방 토큰 발급 (`POST /chat/rooms/{roomId}/join`) -2. 토큰으로 WebSocket 연결 -3. 연결 성공 시 자동으로 방에 입장 - -### 연결 예시 (TypeScript) - -```typescript -const connectWebSocket = (roomToken: string): WebSocket => { - const ws = new WebSocket( - `wss://xxx.execute-api.ap-northeast-2.amazonaws.com/dev?roomToken=${roomToken}` - ); - - ws.onopen = () => console.log('WebSocket connected'); - ws.onmessage = (event) => handleMessage(JSON.parse(event.data)); - ws.onerror = (error) => console.error('WebSocket error:', error); - ws.onclose = () => console.log('WebSocket closed'); - - return ws; -}; -``` - ---- - -## 메시지 구조 - -### 공통 메시지 포맷 - -모든 WebSocket 메시지는 다음 필드를 포함합니다: - -```typescript -interface BaseMessage { - domain: 'chat' | 'game'; // 도메인 구분 - messageType: string; // 메시지 타입 - messageId: string; // 고유 메시지 ID - roomId: string; // 방 ID - userId: string; // 발신자 ID (시스템: "SYSTEM") - content?: string; // 메시지 내용 - createdAt: string; // ISO 8601 형식 시간 - timestamp: number; // Unix timestamp (ms) -} -``` - -### 도메인 구분 - -| 도메인 | 설명 | 메시지 타입 | -|--------|--------|-------------------------------------------------------------------------------------------| -| `chat` | 채팅 메시지 | text, image, voice, ai_response | -| `game` | 게임 메시지 | game_start, game_end, round_start, round_end, drawing, correct_answer, score_update, hint | - -### 메시지 라우팅 예시 - -```typescript -const handleMessage = (message: BaseMessage) => { - if (message.domain === 'chat') { - handleChatMessage(message); - } else if (message.domain === 'game') { - handleGameMessage(message); - } -}; -``` - ---- - -## 게임 흐름 - -### 게임 상태 (GameStatus) - -```typescript -type GameStatus = 'NONE' | 'WAITING' | 'PLAYING' | 'ROUND_END' | 'FINISHED'; -``` - -### 전체 흐름 - -``` -[대기] ─── /game 시작 ───► [게임 시작] ─► [라운드 1] ─► [라운드 종료] - │ │ - │ ◄───────────────────┘ - │ (반복) - ▼ - [게임 종료] - │ - ┌────┴────┐ - │ │ - 수동 종료 자동 종료 - (7분 경과) -``` - -### 1. 게임 시작 (game_start) - -**수신 메시지:** - -```json -{ - "domain": "game", - "messageType": "game_start", - "messageId": "uuid", - "roomId": "room-123", - "userId": "SYSTEM", - "content": "🎮 게임 시작!\n총 5 라운드\n\n라운드 1 시작!\n출제자: user-1", - "createdAt": "2024-01-20T10:00:00Z", - "timestamp": 1705746000000, - "serverTime": 1705746000000, - "gameStatus": "PLAYING", - "currentRound": 1, - "totalRounds": 5, - "currentDrawerId": "user-1", - "drawerOrder": ["user-1", "user-2", "user-3"], - "roundStartTime": 1705746000000, - "roundDuration": 60 -} -``` - -**프론트엔드 처리:** - -```typescript -const handleGameStart = (message: GameStartMessage) => { - setGameStatus('PLAYING'); - setCurrentRound(message.currentRound); - setTotalRounds(message.totalRounds); - setCurrentDrawer(message.currentDrawerId); - setDrawerOrder(message.drawerOrder); - - // 타이머 동기화 - startTimer(message.roundStartTime, message.roundDuration, message.serverTime); - - // 현재 사용자가 출제자인지 확인 - setIsDrawer(message.currentDrawerId === currentUserId); -}; -``` - -### 2. 그림 데이터 전송/수신 (drawing) - -**전송 (출제자만):** - -```typescript -const sendDrawing = (drawingData: DrawingData) => { - ws.send(JSON.stringify({ - action: 'sendMessage', - messageType: 'drawing', - content: JSON.stringify(drawingData) - })); -}; -``` - -**수신 메시지:** - -```json -{ - "domain": "game", - "messageType": "drawing", - "messageId": "uuid", - "roomId": "room-123", - "userId": "user-1", - "content": "{\"type\":\"path\",\"points\":[...],\"color\":\"#000\",\"width\":3}", - "timestamp": 1705746010000 -} -``` - -### 3. 정답 체크 - -**채팅 메시지로 자동 체크됩니다:** - -```typescript -const sendAnswer = (answer: string) => { - ws.send(JSON.stringify({ - action: 'sendMessage', - messageType: 'text', - content: answer - })); -}; -``` - -### 4. 정답 알림 (correct_answer) - -**수신 메시지:** - -```json -{ - "domain": "game", - "messageType": "correct_answer", - "roomId": "room-123", - "userId": "user-2", - "content": "🎉 user-2님이 정답을 맞혔습니다! (+35점)", - "timestamp": 1705746030000, - "serverTime": 1705746030000, - "score": 35, - "elapsedTime": 30000, - "allCorrect": false, - "scores": { - "user-1": 5, - "user-2": 35 - } -} -``` - -### 5. 점수 업데이트 (score_update) - -**수신 메시지:** - -```json -{ - "domain": "game", - "messageType": "score_update", - "roomId": "room-123", - "timestamp": 1705746030000, - "scores": { - "user-1": 15, - "user-2": 35, - "user-3": 20 - }, - "lastScorer": "user-2", - "lastScore": 35 -} -``` - -### 6. 라운드 종료 (round_end) - -**수신 메시지:** - -```json -{ - "domain": "game", - "messageType": "round_end", - "roomId": "room-123", - "content": "라운드 1 종료! 정답: 사과\n\n라운드 2 시작! 출제자: user-2", - "timestamp": 1705746060000, - "serverTime": 1705746060000, - "data": { - "answer": "사과", - "currentRound": 1, - "totalRounds": 5, - "nextRound": 2, - "nextDrawer": "user-2", - "nextWord": { - "wordId": "word-123", - "korean": "바나나" - }, - "roundStartTime": 1705746060000, - "roundDuration": 60, - "ranking": [ - { "rank": 1, "userId": "user-2", "score": 35 }, - { "rank": 2, "userId": "user-3", "score": 20 }, - { "rank": 3, "userId": "user-1", "score": 15 } - ] - } -} -``` - -**프론트엔드 처리:** - -```typescript -const handleRoundEnd = (message: RoundEndMessage) => { - const { data } = message; - - // 정답 표시 - showAnswer(data.answer); - - // 순위 표시 - showRanking(data.ranking); - - // 다음 라운드 준비 - if (data.nextRound) { - setCurrentRound(data.nextRound); - setCurrentDrawer(data.nextDrawer); - setIsDrawer(data.nextDrawer === currentUserId); - - // 출제자에게만 단어 표시 - if (data.nextDrawer === currentUserId && data.nextWord) { - setCurrentWord(data.nextWord.korean); - } - - // 타이머 재시작 - startTimer(data.roundStartTime, data.roundDuration, message.serverTime); - - // 캔버스 초기화 - clearCanvas(); - } -}; -``` - -### 7. 게임 종료 (game_end) - -**수신 메시지:** - -```json -{ - "domain": "game", - "messageType": "game_end", - "roomId": "room-123", - "content": "🎮 게임 종료!\n\n📊 최종 순위:\n 🥇 user-2: 120점\n 🥈 user-3: 95점\n 🥉 user-1: 80점", - "timestamp": 1705746300000, - "reason": "COMPLETED" -} -``` - -**종료 사유 (reason):** -| 값 | 설명 | -|----|------| -| `COMPLETED` | 모든 라운드 완료 | -| `STOPPED` | 수동 종료 | -| `TIME_EXPIRED` | 7분 시간 초과 | -| `NOT_ENOUGH_PLAYERS` | 인원 부족 | - ---- - -## REST API - -### 게임 시작 - -```http -POST /chat/rooms/{roomId}/game/start -Authorization: Bearer {accessToken} -``` - -**Response:** - -```json -{ - "success": true, - "message": "Game started", - "data": { - "gameSessionId": "session-123", - "roomId": "room-123", - "status": "PLAYING", - "currentRound": 1, - "totalRounds": 5, - "currentDrawerId": "user-1", - "roundStartTime": 1705746000000, - "serverTime": 1705746000000, - "roundDuration": 60, - "drawerOrder": ["user-1", "user-2", "user-3"], - "currentWord": { - "wordId": "word-1", - "word": "사과" - } - } -} -``` - -> **Note:** `currentWord`는 출제자에게만 포함됩니다. - -### 게임 종료 - -```http -POST /chat/rooms/{roomId}/game/stop -Authorization: Bearer {accessToken} -``` - -### 게임 상태 조회 - -```http -GET /chat/rooms/{roomId}/game/status -Authorization: Bearer {accessToken} -``` - -### 게임 세션 조회 (재접속용) - -```http -GET /games/{gameSessionId} -Authorization: Bearer {accessToken} -``` - -**Response:** - -```json -{ - "success": true, - "message": "Game session retrieved", - "data": { - "gameSessionId": "session-123", - "roomId": "room-123", - "gameType": "catchmind", - "status": "PLAYING", - "currentRound": 3, - "totalRounds": 5, - "currentDrawerId": "user-2", - "roundStartTime": 1705746180000, - "serverTime": 1705746200000, - "roundDuration": 60, - "scores": { - "user-1": 45, - "user-2": 60, - "user-3": 30 - }, - "players": ["user-1", "user-2", "user-3"], - "drawerOrder": ["user-1", "user-2", "user-3"], - "hintUsed": false, - "currentWord": { - "wordId": "word-5", - "word": "바나나" - } - } -} -``` - -> **Note:** `currentWord`는 출제자에게만 포함됩니다. - ---- - -## 타이머 동기화 - -### 문제 - -클라이언트와 서버 시간 차이로 인한 타이머 불일치 - -### 해결책 - -`serverTime` 필드를 사용하여 서버 시간 기준 타이머 계산 - -### 구현 예시 - -```typescript -interface TimerSync { - roundStartTime: number; // 라운드 시작 시간 (서버 기준) - roundDuration: number; // 라운드 지속 시간 (초) - serverTime: number; // 메시지 발송 시점의 서버 시간 -} - -const startTimer = ( - roundStartTime: number, - roundDuration: number, - serverTime: number -) => { - // 서버에서 이미 경과한 시간 계산 - const elapsedOnServer = serverTime - roundStartTime; - - // 남은 시간 계산 (밀리초) - const remainingTime = (roundDuration * 1000) - elapsedOnServer; - - // 음수 방지 - const safeRemainingTime = Math.max(0, remainingTime); - - setRemainingTime(safeRemainingTime); - - // 타이머 시작 - const interval = setInterval(() => { - setRemainingTime((prev) => { - if (prev <= 1000) { - clearInterval(interval); - return 0; - } - return prev - 1000; - }); - }, 1000); - - return () => clearInterval(interval); -}; -``` - -### React Hook 예시 - -```typescript -const useGameTimer = (timerSync: TimerSync | null) => { - const [remainingSeconds, setRemainingSeconds] = useState(0); - - useEffect(() => { - if (!timerSync) return; - - const { roundStartTime, roundDuration, serverTime } = timerSync; - const elapsed = (serverTime - roundStartTime) / 1000; - const remaining = Math.max(0, roundDuration - elapsed); - - setRemainingSeconds(Math.ceil(remaining)); - - const interval = setInterval(() => { - setRemainingSeconds((prev) => Math.max(0, prev - 1)); - }, 1000); - - return () => clearInterval(interval); - }, [timerSync]); - - return remainingSeconds; -}; -``` - ---- - -## 게임 자동 종료 - -### 개요 - -게임 시작 후 7분(420초)이 경과하면 자동으로 종료됩니다. - -### 자동 종료 메시지 - -```json -{ - "domain": "game", - "messageType": "game_end", - "roomId": "room-123", - "userId": "SYSTEM", - "content": "⏰ 시간 초과! 🎮 게임 종료!\n\n📊 최종 순위:\n 🥇 user-2: 120점\n 🥈 user-1: 95점", - "timestamp": 1705746420000, - "reason": "TIME_EXPIRED" -} -``` - -### 프론트엔드 처리 - -```typescript -const handleGameEnd = (message: GameEndMessage) => { - setGameStatus('FINISHED'); - - // 종료 사유에 따른 UI 처리 - if (message.reason === 'TIME_EXPIRED') { - showNotification('시간 초과로 게임이 종료되었습니다.'); - } else if (message.reason === 'STOPPED') { - showNotification('게임이 수동으로 종료되었습니다.'); - } - - // 최종 결과 표시 - showFinalResults(message.content); - - // 캔버스 초기화 - clearCanvas(); -}; -``` - ---- - -## 재접속 처리 - -### 시나리오 - -사용자가 게임 중 연결이 끊어졌다가 다시 접속하는 경우 - -### 처리 절차 - -1. WebSocket 재연결 -2. 게임 세션 API로 현재 상태 조회 -3. UI 상태 복원 -4. 타이머 동기화 - -### 구현 예시 - -```typescript -const handleReconnect = async (roomId: string, gameSessionId: string) => { - // 1. WebSocket 재연결 - const roomToken = await getRoomToken(roomId); - connectWebSocket(roomToken); - - // 2. 게임 세션 조회 - const session = await fetchGameSession(gameSessionId); - - if (session.status === 'PLAYING') { - // 3. UI 상태 복원 - setGameStatus('PLAYING'); - setCurrentRound(session.currentRound); - setScores(session.scores); - setCurrentDrawer(session.currentDrawerId); - setIsDrawer(session.currentDrawerId === currentUserId); - - // 출제자인 경우 단어 설정 - if (session.currentWord) { - setCurrentWord(session.currentWord.word); - } - - // 4. 타이머 동기화 - startTimer( - session.roundStartTime, - session.roundDuration, - session.serverTime - ); - } else if (session.status === 'FINISHED') { - setGameStatus('FINISHED'); - } -}; -``` - ---- - -## 에러 처리 - -### WebSocket 에러 코드 - -| 코드 | 설명 | 처리 방법 | -|------|--------|--------------| -| 1000 | 정상 종료 | - | -| 1001 | 서버 종료 | 재연결 시도 | -| 1006 | 비정상 종료 | 재연결 시도 | -| 4001 | 인증 실패 | 토큰 재발급 후 재연결 | -| 4003 | 권한 없음 | 에러 표시 | - -### REST API 에러 코드 - -| 코드 | 설명 | -|------------|-----------------------| -| `GAME_001` | 게임 시작 실패 | -| `GAME_002` | 게임 중단 실패 | -| `GAME_003` | 진행 중인 게임 없음 | -| `GAME_004` | 이미 게임 진행 중 | -| `GAME_005` | 권한 없음 (게임 시작자만 중단 가능) | -| `GAME_006` | 게임 세션을 찾을 수 없음 | - -### 에러 처리 예시 - -```typescript -const handleError = (error: ApiError) => { - switch (error.code) { - case 'GAME_001': - showNotification('게임을 시작할 수 없습니다. 최소 2명이 필요합니다.'); - break; - case 'GAME_004': - showNotification('이미 게임이 진행 중입니다.'); - break; - case 'GAME_006': - // 게임 세션 만료 - 목록으로 이동 - navigateToRoomList(); - break; - default: - showNotification('오류가 발생했습니다.'); - } -}; -``` - ---- - -## 전체 상태 관리 예시 (React) - -```typescript -interface GameState { - status: GameStatus; - currentRound: number; - totalRounds: number; - currentDrawerId: string | null; - currentWord: string | null; - scores: Record; - isDrawer: boolean; - remainingTime: number; - drawerOrder: string[]; -} - -const initialGameState: GameState = { - status: 'NONE', - currentRound: 0, - totalRounds: 0, - currentDrawerId: null, - currentWord: null, - scores: {}, - isDrawer: false, - remainingTime: 0, - drawerOrder: [], -}; - -const gameReducer = (state: GameState, action: GameAction): GameState => { - switch (action.type) { - case 'GAME_START': - return { - ...state, - status: 'PLAYING', - currentRound: action.payload.currentRound, - totalRounds: action.payload.totalRounds, - currentDrawerId: action.payload.currentDrawerId, - drawerOrder: action.payload.drawerOrder, - isDrawer: action.payload.currentDrawerId === action.payload.currentUserId, - scores: {}, - }; - - case 'ROUND_END': - return { - ...state, - currentRound: action.payload.nextRound, - currentDrawerId: action.payload.nextDrawer, - currentWord: action.payload.isDrawer ? action.payload.nextWord : null, - isDrawer: action.payload.isDrawer, - }; - - case 'SCORE_UPDATE': - return { - ...state, - scores: action.payload.scores, - }; - - case 'GAME_END': - return { - ...initialGameState, - status: 'FINISHED', - scores: state.scores, - }; - - case 'RESET': - return initialGameState; - - default: - return state; - } -}; -``` - ---- - -## 버전 이력 - -| 버전 | 날짜 | 변경 내용 | -|-------|------------|---------------------| -| 1.0.0 | 2024-01-20 | 초기 문서 작성 | -| 1.1.0 | 2024-01-20 | 게임 자동 종료 (7분) 기능 추가 | diff --git a/ServerlessFunction/docs/NEWS_API_FRONTEND_CHANGES.md b/ServerlessFunction/docs/NEWS_API_FRONTEND_CHANGES.md deleted file mode 100644 index 2d63691a..00000000 --- a/ServerlessFunction/docs/NEWS_API_FRONTEND_CHANGES.md +++ /dev/null @@ -1,304 +0,0 @@ -# News API 프론트엔드 변경사항 - -> 마지막 업데이트: 2025-01-23 - -## 목차 -1. [기사 목록 조회 API 변경](#1-기사-목록-조회-api-변경) -2. [기사 상세 조회 API 변경](#2-기사-상세-조회-api-변경) -3. [키워드 필드 추가](#3-키워드-필드-추가) -4. [인증 필수 엔드포인트](#4-인증-필수-엔드포인트) -5. [API 응답 예시](#5-api-응답-예시) - ---- - -## 1. 기사 목록 조회 API 변경 - -### 영향받는 엔드포인트 -- `GET /news` - 뉴스 목록 조회 -- `GET /news/today` - 오늘의 뉴스 조회 -- `GET /news/recommended` - 추천 뉴스 조회 - -### 변경사항 -각 기사 객체에 `isBookmarked` 필드가 추가되었습니다. - -| 필드 | 타입 | 설명 | -|------|------|------| -| `isBookmarked` | boolean | 현재 사용자가 해당 기사를 북마크했는지 여부 | - -### 주의사항 -- **로그인한 사용자**: 실제 북마크 상태 반환 -- **비로그인 사용자**: 모든 기사에 `false` 반환 - -### 기존 응답 (변경 전) -```json -{ - "articles": [ - { - "articleId": "abc123", - "title": "...", - "summary": "...", - "category": "TECH", - "level": "INTERMEDIATE" - } - ] -} -``` - -### 새 응답 (변경 후) -```json -{ - "articles": [ - { - "articleId": "abc123", - "title": "...", - "summary": "...", - "category": "TECH", - "level": "INTERMEDIATE", - "cefrLevel": "B1", - "isBookmarked": true - } - ] -} -``` - ---- - -## 2. 기사 상세 조회 API 변경 - -### 영향받는 엔드포인트 -- `GET /news/{articleId}` - 기사 상세 조회 - -### 변경사항 -응답에 `isBookmarked`와 `isRead` 필드가 추가되었습니다. - -| 필드 | 타입 | 설명 | -|------|------|------| -| `isBookmarked` | boolean | 현재 사용자가 해당 기사를 북마크했는지 여부 | -| `isRead` | boolean | 현재 사용자가 해당 기사를 읽었는지 여부 | - -### 새 응답 형식 -```json -{ - "success": true, - "message": "뉴스 조회 성공", - "data": { - "article": { - "articleId": "abc123", - "title": "Tech Giants Report Strong Quarterly Earnings", - "summary": "Major technology companies...", - "category": "TECH", - "level": "INTERMEDIATE", - "cefrLevel": "B1", - "keywords": [...], - "highlightWords": ["earnings", "revenue", "growth"], - "quiz": [...] - }, - "isBookmarked": true, - "isRead": false - } -} -``` - ---- - -## 3. 키워드 필드 추가 - -### 변경사항 -`keywords` 배열의 각 키워드 객체에 `meaningKo` (한국어 뜻) 필드가 추가되었습니다. - -### 키워드 객체 구조 - -| 필드 | 타입 | 설명 | -|------|------|------| -| `word` | string | 영어 단어 | -| `meaning` | string | 영어 정의 (간단한 설명) | -| `meaningKo` | string | **[신규]** 한국어 뜻 | -| `example` | string | 기사에서 발췌한 예문 | - -### 키워드 예시 -```json -{ - "keywords": [ - { - "word": "economy", - "meaning": "the system of trade and industry", - "meaningKo": "경제", - "example": "The economy is growing steadily." - }, - { - "word": "revenue", - "meaning": "income, especially of a company", - "meaningKo": "수익", - "example": "The company reported record revenue." - } - ] -} -``` - -### 프론트엔드 활용 -- 단어장 기능에서 한국어 뜻 표시 -- 학습 카드에 영어/한국어 뜻 모두 표시 가능 - ---- - -## 4. 인증 필수 엔드포인트 - -다음 엔드포인트들은 Cognito 인증 토큰이 필요합니다. - -### 인증 필수 (Authorization 헤더 필요) -| 메서드 | 엔드포인트 | 설명 | -|--------|------------|------| -| GET | `/news/stats` | 뉴스 학습 통계 조회 | -| GET | `/news/bookmarks` | 북마크 목록 조회 | -| GET | `/news/words` | 수집 단어 목록 조회 | -| GET | `/news/quiz/history` | 퀴즈 기록 조회 | -| POST | `/news/{articleId}/read` | 읽기 완료 기록 | -| POST | `/news/{articleId}/bookmark` | 북마크 토글 | -| GET | `/news/{articleId}/quiz` | 퀴즈 조회 | -| POST | `/news/{articleId}/quiz` | 퀴즈 제출 | -| POST | `/news/{articleId}/words` | 단어 수집 | -| DELETE | `/news/{articleId}/words/{word}` | 단어 삭제 | -| POST | `/news/words/{word}/sync` | 단어 Vocabulary 연동 | - -### 인증 선택 (토큰 있으면 개인화된 응답) -| 메서드 | 엔드포인트 | 설명 | -|--------|------------|------| -| GET | `/news` | 뉴스 목록 (북마크 상태 포함) | -| GET | `/news/today` | 오늘의 뉴스 (북마크 상태 포함) | -| GET | `/news/recommended` | 추천 뉴스 (북마크 상태 포함) | -| GET | `/news/{articleId}` | 기사 상세 (북마크/읽기 상태 포함) | - -### 요청 헤더 예시 -``` -Authorization: Bearer eyJraWQiOiJ... -``` - ---- - -## 5. API 응답 예시 - -### 기사 목록 조회 (GET /news) -```json -{ - "success": true, - "message": "뉴스 목록 조회 성공", - "data": { - "articles": [ - { - "articleId": "news_20250123_001", - "title": "Global Tech Summit Addresses AI Regulation", - "summary": "World leaders gathered to discuss...", - "source": "Reuters", - "publishedAt": "2025-01-23T09:00:00Z", - "category": "TECH", - "level": "INTERMEDIATE", - "cefrLevel": "B1", - "imageUrl": "https://...", - "readCount": 150, - "keywords": [ - { - "word": "regulation", - "meaning": "official rules made by a government", - "meaningKo": "규제", - "example": "New AI regulation will take effect next year." - } - ], - "highlightWords": ["regulation", "summit", "artificial intelligence"], - "isBookmarked": false - } - ], - "nextCursor": "eyJwayI6Ik5FV1MjMjAyNS0wMS0yMyIsInNrIjoiQVJUSUNMRSMxMjM0NSJ9", - "hasMore": true, - "count": 10 - } -} -``` - -### 기사 상세 조회 (GET /news/{articleId}) -```json -{ - "success": true, - "message": "뉴스 조회 성공", - "data": { - "article": { - "articleId": "news_20250123_001", - "title": "Global Tech Summit Addresses AI Regulation", - "summary": "World leaders gathered to discuss the future of artificial intelligence...", - "source": "Reuters", - "publishedAt": "2025-01-23T09:00:00Z", - "category": "TECH", - "level": "INTERMEDIATE", - "cefrLevel": "B1", - "imageUrl": "https://...", - "readCount": 151, - "keywords": [ - { - "word": "regulation", - "meaning": "official rules made by a government", - "meaningKo": "규제", - "example": "New AI regulation will take effect next year." - }, - { - "word": "summit", - "meaning": "an important meeting between leaders", - "meaningKo": "정상회담", - "example": "The summit brought together leaders from 50 countries." - } - ], - "highlightWords": ["regulation", "summit", "artificial intelligence"], - "quiz": [ - { - "questionId": "q1", - "type": "COMPREHENSION", - "question": "What is the main topic of this article?", - "options": ["AI regulation", "Climate change", "Economic policy", "Healthcare"], - "points": 20 - }, - { - "questionId": "q2", - "type": "WORD_MATCH", - "question": "What does 'regulation' mean in this context?", - "options": ["Official rules", "Technology", "Meeting", "Country"], - "points": 15 - }, - { - "questionId": "q3", - "type": "FILL_BLANK", - "question": "World leaders gathered at the _____ to discuss AI.", - "options": ["summit", "office", "factory", "school"], - "points": 30 - } - ] - }, - "isBookmarked": true, - "isRead": false - } -} -``` - ---- - -## 프론트엔드 체크리스트 - -### 기사 목록 화면 -- [ ] 각 기사 카드에 북마크 아이콘 표시 (`isBookmarked` 활용) -- [ ] 북마크된 기사는 다른 색상/아이콘으로 구분 - -### 기사 상세 화면 -- [ ] 북마크 버튼 상태 초기화 (`isBookmarked` 활용) -- [ ] 읽기 완료 표시 (`isRead` 활용) -- [ ] 키워드 목록에 한국어 뜻 표시 (`meaningKo` 활용) - -### 단어장/학습 카드 -- [ ] 한국어 뜻 표시 기능 추가 -- [ ] 영어/한국어 토글 기능 (선택사항) - -### 인증 -- [ ] 필수 인증 엔드포인트에 토큰 전송 확인 -- [ ] 401 에러 처리 (로그인 페이지로 리다이렉트) - ---- - -## 질문 및 문의 - -백엔드 관련 문의사항이 있으면 연락주세요. diff --git a/ServerlessFunction/docs/VOCABULARY_NEWS_INTEGRATION.md b/ServerlessFunction/docs/VOCABULARY_NEWS_INTEGRATION.md deleted file mode 100644 index f1ff6c62..00000000 --- a/ServerlessFunction/docs/VOCABULARY_NEWS_INTEGRATION.md +++ /dev/null @@ -1,308 +0,0 @@ -# 단어장 - 뉴스 연동 기능 프론트엔드 가이드 - -> 마지막 업데이트: 2025-01-23 - -## 목차 -1. [뉴스 단어 수집 흐름](#1-뉴스-단어-수집-흐름) -2. [API 엔드포인트](#2-api-엔드포인트) -3. [카테고리 필터링](#3-카테고리-필터링) -4. [응답 예시](#4-응답-예시) -5. [프론트엔드 구현 가이드](#5-프론트엔드-구현-가이드) - ---- - -## 1. 뉴스 단어 수집 흐름 - -### 자동 연동 프로세스 - -``` -사용자가 뉴스 기사에서 "단어 가져오기" 클릭 - ↓ - POST /news/{articleId}/words - ↓ -┌─────────────────────────────────────────┐ -│ 1. 기사 키워드에서 한국어 뜻 추출 │ -│ 2. Word 테이블에 자동 저장 (NEWS 카테고리) │ -│ 3. UserWord에 자동 추가 (NEW 상태) │ -│ 4. NewsWordCollect 기록 저장 │ -└─────────────────────────────────────────┘ - ↓ - 단어장(/user-words)에서 바로 확인 가능! -``` - -### 핵심 포인트 -- **별도의 "연동" 버튼 불필요**: 단어 수집 시 자동으로 단어장에 추가됨 -- **카테고리 자동 설정**: 뉴스에서 수집한 단어는 `NEWS` 카테고리로 저장 -- **한국어 뜻 자동 포함**: 기사 AI 분석 결과에서 `meaningKo` 추출 - ---- - -## 2. API 엔드포인트 - -### 뉴스 단어 수집 API - -#### 단어 수집 (단어 가져오기) -```http -POST /news/{articleId}/words -Authorization: Bearer {token} -Content-Type: application/json - -{ - "word": "economy", - "context": "The economy is growing rapidly" // 선택사항 -} -``` - -**응답:** -```json -{ - "success": true, - "message": "단어 수집 성공", - "data": { - "wordCollect": { - "word": "economy", - "meaning": "경제", - "articleId": "abc123", - "articleTitle": "Global Economic Outlook", - "collectedAt": "2025-01-23T12:00:00Z", - "syncedToVocab": true, - "vocabUserWordId": "economy" - }, - "newBadges": [] - } -} -``` - -#### 뉴스에서 수집한 단어 목록 -```http -GET /news/words?limit=20 -Authorization: Bearer {token} -``` - -**응답:** -```json -{ - "success": true, - "data": { - "words": [ - { - "word": "economy", - "meaning": "경제", - "articleId": "abc123", - "articleTitle": "Global Economic Outlook", - "context": "The economy is growing", - "collectedAt": "2025-01-23T12:00:00Z", - "syncedToVocab": true - } - ], - "stats": { - "totalCollected": 15, - "syncedToVocab": 15 - }, - "count": 1 - } -} -``` - ---- - -### 단어장 API (카테고리 필터 추가됨) - -#### 내 단어장 조회 -```http -GET /user-words?category=NEWS&limit=20 -Authorization: Bearer {token} -``` - -**쿼리 파라미터:** - -| 파라미터 | 타입 | 설명 | 예시 | -|----------|------|------|------| -| `category` | string | 카테고리 필터 **(신규)** | `NEWS`, `DAILY`, `BUSINESS` | -| `status` | string | 학습 상태 필터 | `NEW`, `LEARNING`, `REVIEWING`, `MASTERED` | -| `bookmarked` | boolean | 북마크 필터 | `true` | -| `incorrectOnly` | boolean | 오답만 | `true` | -| `limit` | number | 조회 개수 (최대 50) | `20` | -| `cursor` | string | 페이지네이션 커서 | `eyJ...` | - ---- - -## 3. 카테고리 필터링 - -### 사용 가능한 카테고리 - -| 카테고리 | 코드 | 설명 | -|----------|------|------| -| 일상 | `DAILY` | 일상 생활 단어 | -| 비즈니스 | `BUSINESS` | 비즈니스/업무 단어 | -| 학술 | `ACADEMIC` | 학술/전문 단어 | -| 여행 | `TRAVEL` | 여행 관련 단어 | -| 기술 | `TECHNOLOGY` | IT/기술 단어 | -| **뉴스** | `NEWS` | **뉴스에서 수집한 단어 (신규)** | - -### 필터 조합 예시 - -``` -# 뉴스에서 수집한 모든 단어 -GET /user-words?category=NEWS - -# 뉴스 단어 중 학습 중인 것만 -GET /user-words?category=NEWS&status=LEARNING - -# 뉴스 단어 중 북마크한 것만 -GET /user-words?category=NEWS&bookmarked=true - -# 뉴스 단어 중 틀린 것만 -GET /user-words?category=NEWS&incorrectOnly=true - -# 모든 카테고리의 북마크 단어 -GET /user-words?bookmarked=true -``` - ---- - -## 4. 응답 예시 - -### 단어장 조회 응답 (GET /user-words?category=NEWS) - -```json -{ - "success": true, - "message": "User words retrieved", - "data": { - "userWords": [ - { - "wordId": "economy", - "userId": "user-123", - "status": "NEW", - "correctCount": 0, - "incorrectCount": 0, - "bookmarked": false, - "favorite": false, - "difficulty": null, - "nextReviewAt": null, - "lastReviewedAt": null, - "repetitions": 0, - "interval": 0, - "english": "economy", - "korean": "경제", - "level": "INTERMEDIATE", - "category": "NEWS", - "example": "The economy is growing steadily.", - "maleVoiceKey": null, - "femaleVoiceKey": null - }, - { - "wordId": "regulation", - "userId": "user-123", - "status": "LEARNING", - "correctCount": 2, - "incorrectCount": 1, - "bookmarked": true, - "favorite": false, - "difficulty": "HARD", - "english": "regulation", - "korean": "규제", - "level": "ADVANCED", - "category": "NEWS", - "example": "New regulation will take effect." - } - ], - "nextCursor": "eyJwayI6IlVTRVIjdXNlci0xMjMiLCJzayI6IldPUkQjcmVndWxhdGlvbiJ9", - "hasMore": true - } -} -``` - ---- - -## 5. 프론트엔드 구현 가이드 - -### 단어장 UI 변경사항 - -#### 1. 카테고리 탭/필터 추가 -``` -[전체] [일상] [비즈니스] [학술] [여행] [기술] [뉴스] - ↑ 신규 -``` - -#### 2. 뉴스 단어 표시 -- 뉴스에서 수집한 단어는 `category: "NEWS"` 표시 -- 출처 표시 가능 (NewsWordCollect의 articleTitle 활용) - -#### 3. 단어 수집 후 UI 업데이트 -```javascript -// 단어 수집 API 호출 -const response = await fetch(`/news/${articleId}/words`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ word: selectedWord }) -}); - -const result = await response.json(); - -if (result.success) { - // syncedToVocab: true 이므로 단어장에 자동 추가됨 - showToast('단어가 단어장에 추가되었습니다!'); - - // 새 배지 획득 시 알림 - if (result.data.newBadges?.length > 0) { - showBadgeNotification(result.data.newBadges); - } -} -``` - -### 체크리스트 - -#### 단어장 페이지 -- [ ] 카테고리 필터 UI 추가 (탭 또는 드롭다운) -- [ ] `NEWS` 카테고리 옵션 추가 -- [ ] API 호출 시 `category` 파라미터 전달 -- [ ] 카테고리별 단어 개수 표시 (선택사항) - -#### 뉴스 상세 페이지 -- [ ] "단어 가져오기" 버튼 동작 확인 -- [ ] 수집 성공 시 토스트 메시지 -- [ ] 이미 수집된 단어 표시 (비활성화 또는 체크 아이콘) - -#### 뉴스 키워드 표시 -- [ ] `keywords` 배열의 `meaningKo` 필드 표시 -- [ ] 각 키워드 클릭 시 수집 가능하도록 UI 구성 - ---- - -## 데이터 흐름 다이어그램 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 뉴스 기사 상세 │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ Keywords: │ │ -│ │ [economy: 경제] [regulation: 규제] [summit: 정상회담] │ │ -│ │ ↓ 클릭 │ │ -│ │ "단어 가져오기" → POST /news/{id}/words │ │ -│ └─────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ - ↓ - 자동으로 단어장에 추가 - ↓ -┌─────────────────────────────────────────────────────────────────┐ -│ 단어장 │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ 카테고리: [전체] [일상] [비즈니스] ... [뉴스✓] │ │ -│ │ │ │ -│ │ economy 경제 NEW 뉴스 │ │ -│ │ regulation 규제 LEARNING 뉴스 ⭐ │ │ -│ │ summit 정상회담 NEW 뉴스 │ │ -│ └─────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 질문 및 문의 - -백엔드 관련 문의사항이 있으면 연락주세요. From b5d62e82ca8a0215c86e3902bbc1d4ea2c623fb1 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 16:16:04 +0900 Subject: [PATCH 99/99] refactor: format code with consistent indentation and spacing - Apply consistent formatting to improve code readability across multiple files - Adjust indentation, spacing, and alignment in model classes, DTOs, and repository methods --- .../domain/badge/enums/BadgeType.java | 14 +- .../domain/news/constants/NewsKey.java | 36 +- .../domain/news/dto/RawNewsArticle.java | 6 +- .../domain/news/enums/NewsCategory.java | 14 +- .../domain/news/enums/QuizType.java | 14 +- .../domain/news/exception/NewsErrorCode.java | 32 +- .../news/handler/NewsCollectionHandler.java | 20 +- .../domain/news/handler/NewsHandler.java | 187 +++--- .../domain/news/model/KeywordInfo.java | 2 +- .../domain/news/model/NewsArticle.java | 22 +- .../domain/news/model/NewsQuizResult.java | 12 +- .../domain/news/model/NewsWordCollect.java | 12 +- .../domain/news/model/QuizAnswerResult.java | 2 +- .../domain/news/model/QuizQuestion.java | 2 +- .../domain/news/model/UserNewsRecord.java | 12 +- .../repository/NewsArticleRepository.java | 97 +-- .../news/repository/NewsQuizRepository.java | 46 +- .../news/repository/NewsWordRepository.java | 38 +- .../news/repository/UserNewsRepository.java | 73 +-- .../news/service/NewsAnalysisService.java | 103 ++-- .../news/service/NewsCollectorService.java | 38 +- .../news/service/NewsDuplicateChecker.java | 34 +- .../news/service/NewsLearningService.java | 61 +- .../domain/news/service/NewsQueryService.java | 26 +- .../domain/news/service/NewsQuizService.java | 74 +-- .../domain/news/service/NewsWordService.java | 68 +-- .../domain/news/service/RssFeedParser.java | 50 +- .../speaking/dto/request/ResetRequest.java | 10 +- .../speaking/dto/request/SpeakingRequest.java | 50 +- .../dto/response/SpeakingResponse.java | 13 +- .../speaking/handler/SpeakingHandler.java | 268 ++++----- .../speaking/model/SpeakingSession.java | 156 ++--- .../repository/SpeakingSessionRepository.java | 114 ++-- .../speaking/service/SpeakingService.java | 557 +++++++++--------- .../stats/handler/UserStatsHandler.java | 82 +-- .../domain/stats/model/UserStats.java | 20 +- .../stats/repository/UserStatsRepository.java | 174 +++--- .../user/handler/PostConfirmationHandler.java | 11 +- .../domain/user/handler/PreSignUpHandler.java | 14 +- .../domain/user/handler/UserHandler.java | 4 +- .../serverless/domain/user/model/User.java | 8 +- .../domain/user/service/UserService.java | 31 +- .../vocabulary/handler/UserWordHandler.java | 6 +- .../service/UserWordQueryService.java | 8 +- 44 files changed, 1317 insertions(+), 1304 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java index f9d32794..bc7e102f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java @@ -32,21 +32,14 @@ public enum BadgeType { // 특별 MASTER("학습 마스터", "모든 업적을 달성했습니다", "master.png", "ALL_BADGES", 1); - + private static final String BUCKET_NAME = System.getenv("BUCKET_NAME"); private static final String BASE_URL = getBaseUrl(); - - private static String getBaseUrl() { - String bucket = BUCKET_NAME != null ? BUCKET_NAME : "group2-englishstudy"; - return String.format("https://%s.s3.ap-northeast-2.amazonaws.com/badges/", bucket); - } - private final String name; private final String description; private final String imageFile; private final String category; private final int threshold; - BadgeType(String name, String description, String imageFile, String category, int threshold) { this.name = name; this.description = description; @@ -55,6 +48,11 @@ private static String getBaseUrl() { this.threshold = threshold; } + private static String getBaseUrl() { + String bucket = BUCKET_NAME != null ? BUCKET_NAME : "group2-englishstudy"; + return String.format("https://%s.s3.ap-northeast-2.amazonaws.com/badges/", bucket); + } + public static BadgeType fromString(String value) { if (value == null) return null; try { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java index 4c44a58f..eb1425d8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java @@ -6,7 +6,7 @@ * 뉴스 도메인 DynamoDB 키 상수 및 빌더 */ public final class NewsKey { - + // Entity Prefixes public static final String NEWS = "NEWS#"; public static final String ARTICLE = "ARTICLE#"; @@ -18,108 +18,108 @@ public final class NewsKey { public static final String BOOKMARK = "BOOKMARK#"; public static final String COMMENT = "COMMENT#"; public static final String STATS = "STATS"; - + // User Suffixes public static final String SUFFIX_NEWS = "#NEWS"; public static final String SUFFIX_NEWS_WORDS = "#NEWS_WORDS"; public static final String SUFFIX_NEWS_COMMENTS = "#NEWS_COMMENTS"; - + private NewsKey() { } - + // === Key Builders === - + /** * 뉴스 기사 PK: NEWS#{date} */ public static String newsPk(String date) { return NEWS + date; } - + /** * 뉴스 기사 SK: ARTICLE#{articleId} */ public static String articleSk(String articleId) { return ARTICLE + articleId; } - + /** * 레벨별 조회 GSI1 PK: LEVEL#{level} */ public static String levelPk(String level) { return LEVEL + level; } - + /** * 카테고리별 조회 GSI2 PK: CATEGORY#{category} */ public static String categoryPk(String category) { return CATEGORY + category; } - + /** * 사용자 뉴스 활동 PK: USER#{userId}#NEWS */ public static String userNewsPk(String userId) { return DynamoDbKey.USER + userId + SUFFIX_NEWS; } - + /** * 읽기 기록 SK: READ#{articleId} */ public static String readSk(String articleId) { return READ + articleId; } - + /** * 퀴즈 결과 SK: QUIZ#{articleId} */ public static String quizSk(String articleId) { return QUIZ + articleId; } - + /** * 단어 수집 SK: WORD#{word}#{articleId} */ public static String wordSk(String word, String articleId) { return WORD + word + "#" + articleId; } - + /** * 북마크 SK: BOOKMARK#{articleId} */ public static String bookmarkSk(String articleId) { return BOOKMARK + articleId; } - + /** * 사용자 수집 단어 GSI1 PK: USER#{userId}#NEWS_WORDS */ public static String userNewsWordsPk(String userId) { return DynamoDbKey.USER + userId + SUFFIX_NEWS_WORDS; } - + /** * 댓글 PK: NEWS_COMMENT#{articleId} */ public static String commentPk(String articleId) { return "NEWS_COMMENT#" + articleId; } - + /** * 댓글 SK: COMMENT#{commentId} */ public static String commentSk(String commentId) { return COMMENT + commentId; } - + /** * 사용자 댓글 GSI1 PK: USER#{userId}#NEWS_COMMENTS */ public static String userNewsCommentsPk(String userId) { return DynamoDbKey.USER + userId + SUFFIX_NEWS_COMMENTS; } - + /** * 사용자 뉴스 통계 GSI1 PK: USER_NEWS_STAT#{userId} */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/dto/RawNewsArticle.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/dto/RawNewsArticle.java index c9902559..4be72fac 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/dto/RawNewsArticle.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/dto/RawNewsArticle.java @@ -14,7 +14,7 @@ @NoArgsConstructor @AllArgsConstructor public class RawNewsArticle { - + private String title; private String description; private String url; @@ -22,7 +22,7 @@ public class RawNewsArticle { private String source; private String publishedAt; private String content; - + /** * URL 기반 고유 식별자 생성 */ @@ -32,7 +32,7 @@ public String generateId() { } return String.valueOf(url.hashCode()); } - + /** * 유효한 기사인지 검증 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/NewsCategory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/NewsCategory.java index 7f88078f..3f5a8bc5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/NewsCategory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/NewsCategory.java @@ -13,21 +13,21 @@ public enum NewsCategory { WORLD("world", "세계"), CULTURE("culture", "문화"), SCIENCE("science", "과학"); - + private final String code; private final String displayName; - + NewsCategory(String code, String displayName) { this.code = code; this.displayName = displayName; } - + public static boolean isValid(String value) { if (value == null) return false; return Arrays.stream(values()) .anyMatch(cat -> cat.name().equalsIgnoreCase(value) || cat.code.equalsIgnoreCase(value)); } - + public static NewsCategory fromString(String value) { if (value == null) { throw new IllegalArgumentException("NewsCategory value cannot be null"); @@ -37,18 +37,18 @@ public static NewsCategory fromString(String value) { .findFirst() .orElseThrow(() -> new IllegalArgumentException("Unknown NewsCategory: " + value)); } - + public static NewsCategory fromStringOrDefault(String value, NewsCategory defaultValue) { if (value == null || !isValid(value)) { return defaultValue; } return fromString(value); } - + public String getCode() { return code; } - + public String getDisplayName() { return displayName; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/QuizType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/QuizType.java index 7b95a466..20da38ae 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/QuizType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/QuizType.java @@ -9,23 +9,23 @@ public enum QuizType { COMPREHENSION("comprehension", "독해 질문", 20), WORD_MATCH("word_match", "단어-뜻 매칭", 15), FILL_BLANK("fill_blank", "빈칸 채우기", 30); - + private final String code; private final String displayName; private final int defaultPoints; - + QuizType(String code, String displayName, int defaultPoints) { this.code = code; this.displayName = displayName; this.defaultPoints = defaultPoints; } - + public static boolean isValid(String value) { if (value == null) return false; return Arrays.stream(values()) .anyMatch(type -> type.name().equalsIgnoreCase(value) || type.code.equalsIgnoreCase(value)); } - + public static QuizType fromString(String value) { if (value == null) { throw new IllegalArgumentException("QuizType value cannot be null"); @@ -35,15 +35,15 @@ public static QuizType fromString(String value) { .findFirst() .orElseThrow(() -> new IllegalArgumentException("Unknown QuizType: " + value)); } - + public String getCode() { return code; } - + public String getDisplayName() { return displayName; } - + public int getDefaultPoints() { return defaultPoints; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java index 58197f0e..ef2c05cb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java @@ -7,72 +7,72 @@ * 뉴스 기사, 퀴즈, 단어 수집, 댓글 관련 에러 코드를 정의합니다. */ public enum NewsErrorCode implements DomainErrorCode { - + // 인증 관련 에러 UNAUTHORIZED("AUTH_001", "인증이 필요합니다", 401), - + // 뉴스 기사 관련 에러 ARTICLE_NOT_FOUND("ARTICLE_001", "뉴스 기사를 찾을 수 없습니다", 404), INVALID_ARTICLE_DATA("ARTICLE_002", "뉴스 기사 데이터가 유효하지 않습니다", 400), ARTICLE_ALREADY_EXISTS("ARTICLE_003", "이미 존재하는 뉴스 기사입니다", 409), - + // 카테고리/레벨 관련 에러 INVALID_CATEGORY("CATEGORY_001", "유효하지 않은 카테고리입니다", 400), INVALID_LEVEL("LEVEL_001", "유효하지 않은 레벨입니다", 400), - + // 읽기 기록 관련 에러 READ_RECORD_NOT_FOUND("READ_001", "읽기 기록을 찾을 수 없습니다", 404), ALREADY_READ("READ_002", "이미 읽은 기사입니다", 409), - + // 퀴즈 관련 에러 QUIZ_NOT_FOUND("QUIZ_001", "퀴즈를 찾을 수 없습니다", 404), QUIZ_ALREADY_SUBMITTED("QUIZ_002", "이미 제출한 퀴즈입니다", 409), INVALID_QUIZ_ANSWER("QUIZ_003", "유효하지 않은 퀴즈 답변입니다", 400), - + // 단어 수집 관련 에러 WORD_ALREADY_COLLECTED("WORD_001", "이미 수집한 단어입니다", 409), WORD_NOT_COLLECTED("WORD_002", "수집한 단어를 찾을 수 없습니다", 404), - + // 북마크 관련 에러 BOOKMARK_NOT_FOUND("BOOKMARK_001", "북마크를 찾을 수 없습니다", 404), ALREADY_BOOKMARKED("BOOKMARK_002", "이미 북마크한 기사입니다", 409), BOOKMARK_LIMIT_EXCEEDED("BOOKMARK_003", "북마크 한도를 초과했습니다", 400), - + // 댓글 관련 에러 COMMENT_NOT_FOUND("COMMENT_001", "댓글을 찾을 수 없습니다", 404), COMMENT_NOT_OWNER("COMMENT_002", "댓글 작성자만 수정/삭제할 수 있습니다", 403), INVALID_COMMENT_DATA("COMMENT_003", "유효하지 않은 댓글 데이터입니다", 400), - + // 통계 관련 에러 STATS_NOT_FOUND("STATS_001", "통계 정보를 찾을 수 없습니다", 404); - + private static final String DOMAIN = "NEWS"; - + private final String code; private final String message; private final int statusCode; - + NewsErrorCode(String code, String message, int statusCode) { this.code = code; this.message = message; this.statusCode = statusCode; } - + @Override public String getDomain() { return DOMAIN; } - + @Override public String getCode() { return code; } - + @Override public String getMessage() { return message; } - + @Override public int getStatusCode() { return statusCode; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java index 4d17463f..246617c3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java @@ -14,29 +14,29 @@ * EventBridge 스케줄러에 의해 매일 18시에 트리거 */ public class NewsCollectionHandler implements RequestHandler> { - + private static final Logger logger = LoggerFactory.getLogger(NewsCollectionHandler.class); - + private final NewsCollectorService collectorService; - + public NewsCollectionHandler() { this.collectorService = new NewsCollectorService(); } - + public NewsCollectionHandler(NewsCollectorService collectorService) { this.collectorService = collectorService; } - + @Override public Map handleRequest(ScheduledEvent event, Context context) { logger.info("뉴스 수집 Lambda 시작 - requestId: {}", context.getAwsRequestId()); - + try { NewsCollectorService.CollectionResult result = collectorService.collectNews(); - + logger.info("뉴스 수집 완료 - 수집: {}, 저장: {}, 소요: {}ms", result.collectedCount(), result.savedCount(), result.elapsedMs()); - + return Map.of( "statusCode", 200, "message", "News collection completed", @@ -44,10 +44,10 @@ public Map handleRequest(ScheduledEvent event, Context context) "savedCount", result.savedCount(), "elapsedMs", result.elapsedMs() ); - + } catch (Exception e) { logger.error("뉴스 수집 실패", e); - + return Map.of( "statusCode", 500, "message", "News collection failed: " + e.getMessage() diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java index 44f9a43c..f806e2f2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -4,6 +4,9 @@ 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.JsonArray; +import com.google.gson.JsonObject; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.router.HandlerRouter; import com.mzc.secondproject.serverless.common.router.Route; @@ -13,14 +16,10 @@ import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; import com.mzc.secondproject.serverless.domain.news.model.NewsQuizResult; import com.mzc.secondproject.serverless.domain.news.model.NewsWordCollect; -import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; import com.mzc.secondproject.serverless.domain.news.service.NewsLearningService; import com.mzc.secondproject.serverless.domain.news.service.NewsQueryService; import com.mzc.secondproject.serverless.domain.news.service.NewsQuizService; import com.mzc.secondproject.serverless.domain.news.service.NewsWordService; -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,31 +32,31 @@ * 뉴스 학습 API 핸들러 */ public class NewsHandler implements RequestHandler { - + private static final Logger logger = LoggerFactory.getLogger(NewsHandler.class); private static final int DEFAULT_LIMIT = 10; private static final int MAX_LIMIT = 50; private static final Gson gson = new Gson(); - + private final NewsQueryService queryService; private final NewsLearningService learningService; private final NewsQuizService quizService; private final NewsWordService wordService; private final HandlerRouter router; - + public NewsHandler() { this(new NewsQueryService(), new NewsLearningService(), new NewsQuizService(), new NewsWordService()); } - + public NewsHandler(NewsQueryService queryService, NewsLearningService learningService, - NewsQuizService quizService, NewsWordService wordService) { + NewsQuizService quizService, NewsWordService wordService) { this.queryService = queryService; this.learningService = learningService; this.quizService = quizService; this.wordService = wordService; this.router = initRouter(); } - + private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( Route.get("/news/today", this::getTodayNews), @@ -79,13 +78,13 @@ private HandlerRouter initRouter() { Route.get("/news", this::getNewsList) ); } - + @Override public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { logger.info("News API 요청: {} {}", request.getHttpMethod(), request.getPath()); return router.route(request); } - + /** * 뉴스 목록 조회 (필터링 지원) * GET /news?level=INTERMEDIATE&category=TECH&limit=10&cursor=xxx @@ -93,14 +92,14 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re private APIGatewayProxyResponseEvent getNewsList(APIGatewayProxyRequestEvent request) { Map params = request.getQueryStringParameters(); if (params == null) params = new HashMap<>(); - + String level = params.get("level"); String category = params.get("category"); String cursor = params.get("cursor"); int limit = parseLimit(params.get("limit")); - + PaginatedResult result; - + if (level != null && category != null) { result = queryService.getNewsByLevelAndCategory(level.toUpperCase(), category.toUpperCase(), limit, cursor); } else if (level != null) { @@ -110,10 +109,10 @@ private APIGatewayProxyResponseEvent getNewsList(APIGatewayProxyRequestEvent req } else { result = queryService.getTodayNews(limit, cursor); } - + return buildPaginatedResponse(result, getUserId(request)); } - + /** * 오늘의 뉴스 조회 * GET /news/today?limit=10&cursor=xxx @@ -121,14 +120,14 @@ private APIGatewayProxyResponseEvent getNewsList(APIGatewayProxyRequestEvent req private APIGatewayProxyResponseEvent getTodayNews(APIGatewayProxyRequestEvent request) { Map params = request.getQueryStringParameters(); if (params == null) params = new HashMap<>(); - + String cursor = params.get("cursor"); int limit = parseLimit(params.get("limit")); - + PaginatedResult result = queryService.getTodayNews(limit, cursor); return buildPaginatedResponse(result, getUserId(request)); } - + /** * 내 레벨 맞춤 뉴스 추천 * GET /news/recommended?limit=10&cursor=xxx @@ -136,33 +135,33 @@ private APIGatewayProxyResponseEvent getTodayNews(APIGatewayProxyRequestEvent re private APIGatewayProxyResponseEvent getRecommendedNews(APIGatewayProxyRequestEvent request) { Map params = request.getQueryStringParameters(); if (params == null) params = new HashMap<>(); - + // 사용자 레벨 조회 (Cognito 토큰에서) String userLevel = getUserLevel(request); String cursor = params.get("cursor"); int limit = parseLimit(params.get("limit")); - + PaginatedResult result = queryService.getRecommendedNews(userLevel, limit, cursor); return buildPaginatedResponse(result, getUserId(request)); } - + /** * 뉴스 상세 조회 * GET /news/{articleId} */ private APIGatewayProxyResponseEvent getNewsDetail(APIGatewayProxyRequestEvent request) { String articleId = request.getPathParameters().get("articleId"); - + Optional article = queryService.getArticle(articleId); if (article.isEmpty()) { return ResponseGenerator.fail(NewsErrorCode.ARTICLE_NOT_FOUND); } - + // 로그인한 사용자의 경우 북마크/읽기 상태 추가 String userId = getUserId(request); Map response = new HashMap<>(); response.put("article", article.get()); - + if (userId != null) { response.put("isBookmarked", learningService.isBookmarked(userId, articleId)); response.put("isRead", learningService.hasRead(userId, articleId)); @@ -170,24 +169,24 @@ private APIGatewayProxyResponseEvent getNewsDetail(APIGatewayProxyRequestEvent r response.put("isBookmarked", false); response.put("isRead", false); } - + return ResponseGenerator.ok("뉴스 조회 성공", response); } - + /** * 페이지네이션 응답 생성 */ private APIGatewayProxyResponseEvent buildPaginatedResponse(PaginatedResult result) { return buildPaginatedResponse(result, null); } - + /** * 페이지네이션 응답 생성 (북마크 상태 포함) */ private APIGatewayProxyResponseEvent buildPaginatedResponse(PaginatedResult result, String userId) { List> articlesWithStatus = new java.util.ArrayList<>(); java.util.Set bookmarkedIds = java.util.Collections.emptySet(); - + // 로그인한 사용자의 경우 북마크 상태 조회 if (userId != null && !result.items().isEmpty()) { List articleIds = result.items().stream() @@ -195,7 +194,7 @@ private APIGatewayProxyResponseEvent buildPaginatedResponse(PaginatedResult articleWithStatus = new HashMap<>(); articleWithStatus.put("articleId", article.getArticleId()); @@ -213,16 +212,16 @@ private APIGatewayProxyResponseEvent buildPaginatedResponse(PaginatedResult response = new HashMap<>(); response.put("articles", articlesWithStatus); response.put("nextCursor", result.nextCursor()); response.put("hasMore", result.hasMore()); response.put("count", result.items().size()); - + return ResponseGenerator.ok("뉴스 목록 조회 성공", response); } - + /** * limit 파싱 */ @@ -235,7 +234,7 @@ private int parseLimit(String limitStr) { return DEFAULT_LIMIT; } } - + /** * 사용자 레벨 조회 */ @@ -243,7 +242,7 @@ private String getUserLevel(APIGatewayProxyRequestEvent request) { return CognitoUtil.extractClaim(request, "custom:level") .orElse("INTERMEDIATE"); } - + /** * 사용자 ID 추출 */ @@ -251,7 +250,7 @@ private String getUserId(APIGatewayProxyRequestEvent request) { return CognitoUtil.extractClaim(request, "sub") .orElse(null); } - + /** * 뉴스 학습 통계 조회 * GET /news/stats @@ -261,11 +260,11 @@ private APIGatewayProxyResponseEvent getNewsStats(APIGatewayProxyRequestEvent re if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + Map stats = learningService.getUserStats(userId); return ResponseGenerator.ok("뉴스 학습 통계 조회 성공", stats); } - + /** * 북마크 목록 조회 * GET /news/bookmarks?limit=10 @@ -275,20 +274,20 @@ private APIGatewayProxyResponseEvent getBookmarks(APIGatewayProxyRequestEvent re if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + Map params = request.getQueryStringParameters(); if (params == null) params = new HashMap<>(); - + int limit = parseLimit(params.get("limit")); List> bookmarks = learningService.getUserBookmarks(userId, limit); - + Map response = new HashMap<>(); response.put("bookmarks", bookmarks); response.put("count", bookmarks.size()); - + return ResponseGenerator.ok("북마크 목록 조회 성공", response); } - + /** * 뉴스 읽기 완료 기록 * POST /news/{articleId}/read @@ -298,13 +297,13 @@ private APIGatewayProxyResponseEvent markAsRead(APIGatewayProxyRequestEvent requ if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + String articleId = request.getPathParameters().get("articleId"); learningService.markAsRead(userId, articleId); - + return ResponseGenerator.ok("읽기 완료 기록 성공", Map.of("articleId", articleId)); } - + /** * 북마크 토글 * POST /news/{articleId}/bookmark @@ -314,34 +313,34 @@ private APIGatewayProxyResponseEvent toggleBookmark(APIGatewayProxyRequestEvent if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + String articleId = request.getPathParameters().get("articleId"); boolean isBookmarked = learningService.toggleBookmark(userId, articleId); - + return ResponseGenerator.ok( isBookmarked ? "북마크 추가 성공" : "북마크 해제 성공", Map.of("articleId", articleId, "bookmarked", isBookmarked) ); } - + /** * 뉴스 TTS 오디오 URL 조회 * GET /news/{articleId}/audio?voice=Joanna */ private APIGatewayProxyResponseEvent getAudio(APIGatewayProxyRequestEvent request) { String articleId = request.getPathParameters().get("articleId"); - + Map params = request.getQueryStringParameters(); String voice = (params != null) ? params.getOrDefault("voice", "Joanna") : "Joanna"; - + String audioUrl = learningService.getAudioUrl(articleId, voice); if (audioUrl == null) { return ResponseGenerator.fail(NewsErrorCode.ARTICLE_NOT_FOUND); } - + return ResponseGenerator.ok("TTS 오디오 URL 조회 성공", Map.of("audioUrl", audioUrl)); } - + /** * 퀴즈 조회 * GET /news/{articleId}/quiz @@ -351,17 +350,17 @@ private APIGatewayProxyResponseEvent getQuiz(APIGatewayProxyRequestEvent request if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + String articleId = request.getPathParameters().get("articleId"); Optional quizData = quizService.getQuiz(articleId, userId); - + if (quizData.isEmpty()) { return ResponseGenerator.fail(NewsErrorCode.QUIZ_NOT_FOUND); } - + return ResponseGenerator.ok("퀴즈 조회 성공", quizData.get()); } - + /** * 퀴즈 제출 * POST /news/{articleId}/quiz @@ -371,14 +370,14 @@ private APIGatewayProxyResponseEvent submitQuiz(APIGatewayProxyRequestEvent requ if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + String articleId = request.getPathParameters().get("articleId"); - + // 요청 바디 파싱 JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); JsonArray answersArray = body.getAsJsonArray("answers"); Integer timeTaken = body.has("timeTaken") ? body.get("timeTaken").getAsInt() : null; - + List answers = new java.util.ArrayList<>(); if (answersArray != null) { answersArray.forEach(e -> { @@ -389,16 +388,16 @@ private APIGatewayProxyResponseEvent submitQuiz(APIGatewayProxyRequestEvent requ )); }); } - + NewsQuizService.QuizSubmitResult result = quizService.submitQuiz(userId, articleId, answers, timeTaken); - + if (result == null) { return ResponseGenerator.fail(NewsErrorCode.QUIZ_ALREADY_SUBMITTED); } - + return ResponseGenerator.ok("퀴즈 제출 성공", result); } - + /** * 퀴즈 기록 조회 * GET /news/quiz/history?limit=10 @@ -408,22 +407,22 @@ private APIGatewayProxyResponseEvent getQuizHistory(APIGatewayProxyRequestEvent if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + Map params = request.getQueryStringParameters(); if (params == null) params = new HashMap<>(); - + int limit = parseLimit(params.get("limit")); List history = quizService.getUserQuizHistory(userId, limit); Map quizStats = quizService.getUserQuizStats(userId); - + Map response = new HashMap<>(); response.put("history", history); response.put("stats", quizStats); response.put("count", history.size()); - + return ResponseGenerator.ok("퀴즈 기록 조회 성공", response); } - + /** * 수집 단어 목록 조회 * GET /news/words?limit=10 @@ -433,22 +432,22 @@ private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent re if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + Map params = request.getQueryStringParameters(); if (params == null) params = new HashMap<>(); - + int limit = parseLimit(params.get("limit")); List words = wordService.getUserWords(userId, limit); Map stats = wordService.getUserWordStats(userId); - + Map response = new HashMap<>(); response.put("words", words); response.put("stats", stats); response.put("count", words.size()); - + return ResponseGenerator.ok("수집 단어 목록 조회 성공", response); } - + /** * 단어 수집 * POST /news/{articleId}/words @@ -458,22 +457,22 @@ private APIGatewayProxyResponseEvent collectWord(APIGatewayProxyRequestEvent req if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + String articleId = request.getPathParameters().get("articleId"); - + JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); String word = body.get("word").getAsString(); String context = body.has("context") ? body.get("context").getAsString() : ""; - + NewsWordCollect collected = wordService.collectWord(userId, articleId, word, context); - + if (collected == null) { return ResponseGenerator.fail(NewsErrorCode.WORD_ALREADY_COLLECTED); } - + return ResponseGenerator.ok("단어 수집 성공", collected); } - + /** * 단어 삭제 * DELETE /news/{articleId}/words/{word} @@ -483,31 +482,31 @@ private APIGatewayProxyResponseEvent deleteWord(APIGatewayProxyRequestEvent requ if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + String articleId = request.getPathParameters().get("articleId"); String word = request.getPathParameters().get("word"); - + wordService.deleteWord(userId, word, articleId); - + return ResponseGenerator.ok("단어 삭제 성공", Map.of("word", word)); } - + /** * 단어 상세 정보 조회 * GET /news/{articleId}/words/{word} */ private APIGatewayProxyResponseEvent getWordDetail(APIGatewayProxyRequestEvent request) { String word = request.getPathParameters().get("word"); - + Optional detail = wordService.getWordDetail(word); - + if (detail.isEmpty()) { return ResponseGenerator.fail(NewsErrorCode.WORD_NOT_COLLECTED); } - + return ResponseGenerator.ok("단어 상세 조회 성공", detail.get()); } - + /** * 단어 Vocabulary 연동 * POST /news/words/{word}/sync @@ -517,18 +516,18 @@ private APIGatewayProxyResponseEvent syncWordToVocab(APIGatewayProxyRequestEvent if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + String word = request.getPathParameters().get("word"); - + JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); String articleId = body.get("articleId").getAsString(); - + boolean synced = wordService.syncToVocabulary(userId, word, articleId); - + if (!synced) { return ResponseGenerator.fail(NewsErrorCode.WORD_NOT_COLLECTED); } - + return ResponseGenerator.ok("Vocabulary 연동 성공", Map.of("word", word, "synced", true)); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java index 948328ff..c4ad3708 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java @@ -16,7 +16,7 @@ @AllArgsConstructor @DynamoDbBean public class KeywordInfo { - + private String word; // 영어 단어 private String meaning; // 영어 뜻 (간단한 정의) private String meaningKo; // 한국어 뜻 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsArticle.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsArticle.java index 13fd8a19..3f0537f3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsArticle.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsArticle.java @@ -21,14 +21,14 @@ @AllArgsConstructor @DynamoDbBean public class NewsArticle { - + private String pk; // NEWS#{date} private String sk; // ARTICLE#{articleId} private String gsi1pk; // LEVEL#{level} private String gsi1sk; // {publishedAt} private String gsi2pk; // CATEGORY#{category} private String gsi2sk; // {publishedAt} - + // 기본 정보 private String articleId; private String title; @@ -36,54 +36,54 @@ public class NewsArticle { private String originalUrl; // 원문 링크 private String source; // BBC, VOA, NPR, NewsAPI private String imageUrl; // 썸네일 이미지 - + // 분류 private String category; // TECH, BUSINESS, SPORTS 등 private String level; // BEGINNER, INTERMEDIATE, ADVANCED private String cefrLevel; // A1, A2, B1, B2, C1, C2 (원본 CEFR 레벨) - + // AI 분석 결과 private List keywords; // 핵심 단어 정보 private List highlightWords; // 사용자 레벨 대비 어려운 단어 private List quiz; // 퀴즈 문제 (5개) - + // 메타데이터 private String publishedAt; // 원본 발행일 private String collectedAt; // 수집일 private Long readCount; // 조회수 private Long commentCount; // 댓글수 private Long ttl; - + @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; } - + @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") @DynamoDbAttribute("GSI2PK") public String getGsi2pk() { return gsi2pk; } - + @DynamoDbSecondarySortKey(indexNames = "GSI2") @DynamoDbAttribute("GSI2SK") public String getGsi2sk() { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java index 23b47f62..c2aaaae9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java @@ -19,12 +19,12 @@ @AllArgsConstructor @DynamoDbBean public class NewsQuizResult { - + private String pk; // USER#{userId}#NEWS private String sk; // QUIZ#{articleId} private String gsi1pk; // USER_NEWS_STAT#{userId} private String gsi1sk; // {date}#QUIZ - + private String userId; private String articleId; private String articleTitle; @@ -36,25 +36,25 @@ public class NewsQuizResult { private Integer timeTaken; // 소요 시간 (초) private String submittedAt; private Long ttl; - + @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() { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java index 59f4fa93..227e90e3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java @@ -18,12 +18,12 @@ @AllArgsConstructor @DynamoDbBean public class NewsWordCollect { - + private String pk; // USER#{userId}#NEWS private String sk; // WORD#{word}#{articleId} private String gsi1pk; // USER#{userId}#NEWS_WORDS private String gsi1sk; // {collectedAt} - + private String userId; private String word; private String meaning; @@ -35,25 +35,25 @@ public class NewsWordCollect { private Boolean syncedToVocab; // Vocabulary 연동 여부 private String vocabUserWordId; // 연동된 UserWord ID private Long ttl; - + @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() { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java index 3dee95b6..7340216f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java @@ -15,7 +15,7 @@ @AllArgsConstructor @DynamoDbBean public class QuizAnswerResult { - + private String questionId; private String type; // COMPREHENSION, WORD_MATCH, FILL_BLANK private String userAnswer; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizQuestion.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizQuestion.java index 6657ef33..1f6dab4f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizQuestion.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizQuestion.java @@ -17,7 +17,7 @@ @AllArgsConstructor @DynamoDbBean public class QuizQuestion { - + private String questionId; // 문제 ID (q1, q2, ...) private String type; // COMPREHENSION, WORD_MATCH, FILL_BLANK private String question; // 문제 내용 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java index eedfa8c5..b0622e00 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java @@ -17,12 +17,12 @@ @AllArgsConstructor @DynamoDbBean public class UserNewsRecord { - + private String pk; // USER_NEWS#{userId} private String sk; // READ#{articleId} 또는 BOOKMARK#{articleId} private String gsi1pk; // USER_NEWS_STAT#{userId} private String gsi1sk; // {date}#{type} - + private String userId; private String articleId; private String type; // READ, BOOKMARK @@ -31,25 +31,25 @@ public class UserNewsRecord { private String articleCategory; private String createdAt; private Long ttl; - + @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() { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java index 28ca35cc..4f4ec3ae 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java @@ -9,7 +9,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.enhanced.dynamodb.*; -import software.amazon.awssdk.enhanced.dynamodb.model.*; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.ScanEnhancedRequest; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; @@ -22,20 +25,20 @@ * 뉴스 기사 Repository */ public class NewsArticleRepository { - + private static final Logger logger = LoggerFactory.getLogger(NewsArticleRepository.class); private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); - + private final DynamoDbEnhancedClient enhancedClient; private final DynamoDbTable table; - + /** * 기본 생성자 (Lambda에서 사용) */ public NewsArticleRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -43,7 +46,7 @@ public NewsArticleRepository(DynamoDbEnhancedClient enhancedClient) { this.enhancedClient = enhancedClient; this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(NewsArticle.class)); } - + /** * 뉴스 기사 저장 */ @@ -52,7 +55,7 @@ public NewsArticle save(NewsArticle article) { table.putItem(article); return article; } - + /** * 뉴스 기사 조회 (날짜 + 기사ID) */ @@ -61,11 +64,11 @@ public Optional findByDateAndId(String date, String articleId) { .partitionValue(NewsKey.newsPk(date)) .sortValue(NewsKey.articleSk(articleId)) .build(); - + NewsArticle article = table.getItem(key); return Optional.ofNullable(article); } - + /** * 뉴스 기사 조회 (기사ID만으로 - GSI 활용 또는 Scan) * 참고: 실제로는 articleId로 date를 알 수 있도록 설계하거나 GSI 추가 필요 @@ -75,12 +78,12 @@ public Optional findById(String articleId) { .expression("articleId = :articleId") .putExpressionValue(":articleId", AttributeValue.builder().s(articleId).build()) .build(); - + ScanEnhancedRequest request = ScanEnhancedRequest.builder() .filterExpression(filterExpression) .limit(1) .build(); - + for (Page page : table.scan(request)) { List items = page.items(); if (!items.isEmpty()) { @@ -89,7 +92,7 @@ public Optional findById(String articleId) { } return Optional.empty(); } - + /** * 뉴스 기사 삭제 */ @@ -98,88 +101,88 @@ public void delete(String date, String articleId) { .partitionValue(NewsKey.newsPk(date)) .sortValue(NewsKey.articleSk(articleId)) .build(); - + table.deleteItem(key); logger.info("Deleted news article: {}", articleId); } - + /** * 날짜별 뉴스 기사 조회 (페이지네이션) */ public PaginatedResult findByDate(String date, int limit, String cursor) { QueryConditional queryConditional = QueryConditional .keyEqualTo(Key.builder().partitionValue(NewsKey.newsPk(date)).build()); - + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) // 최신순 (SK 역순) .limit(limit); - + if (cursor != null && !cursor.isEmpty()) { Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } } - + Page page = table.query(requestBuilder.build()).iterator().next(); String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - + return new PaginatedResult<>(page.items(), nextCursor); } - + /** * 레벨별 뉴스 기사 조회 (GSI1 - 최신순) */ public PaginatedResult findByLevel(String level, int limit, String cursor) { QueryConditional queryConditional = QueryConditional .keyEqualTo(Key.builder().partitionValue(NewsKey.levelPk(level.toUpperCase())).build()); - + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) // 최신순 .limit(limit); - + if (cursor != null && !cursor.isEmpty()) { Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } } - + DynamoDbIndex gsi1 = table.index("GSI1"); Page page = gsi1.query(requestBuilder.build()).iterator().next(); String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - + return new PaginatedResult<>(page.items(), nextCursor); } - + /** * 카테고리별 뉴스 기사 조회 (GSI2 - 최신순) */ public PaginatedResult findByCategory(String category, int limit, String cursor) { QueryConditional queryConditional = QueryConditional .keyEqualTo(Key.builder().partitionValue(NewsKey.categoryPk(category.toUpperCase())).build()); - + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) // 최신순 .limit(limit); - + if (cursor != null && !cursor.isEmpty()) { Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } } - + DynamoDbIndex gsi2 = table.index("GSI2"); Page page = gsi2.query(requestBuilder.build()).iterator().next(); String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - + return new PaginatedResult<>(page.items(), nextCursor); } - + /** * 레벨 + 카테고리 필터 조회 (GSI1 쿼리 후 필터) */ @@ -188,27 +191,27 @@ public PaginatedResult findByLevelAndCategory(String level, String .expression("category = :category") .putExpressionValue(":category", AttributeValue.builder().s(category.toUpperCase()).build()) .build(); - + QueryConditional queryConditional = QueryConditional .keyEqualTo(Key.builder().partitionValue(NewsKey.levelPk(level.toUpperCase())).build()); - + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .filterExpression(filterExpression) .scanIndexForward(false) .limit(limit * 2); // 필터 적용되므로 넉넉히 - + if (cursor != null && !cursor.isEmpty()) { Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } } - + DynamoDbIndex gsi1 = table.index("GSI1"); List results = new ArrayList<>(); Map lastKey = null; - + for (Page page : gsi1.query(requestBuilder.build())) { for (NewsArticle article : page.items()) { results.add(article); @@ -217,11 +220,11 @@ public PaginatedResult findByLevelAndCategory(String level, String lastKey = page.lastEvaluatedKey(); if (results.size() >= limit) break; } - + String nextCursor = results.size() >= limit ? CursorUtil.encode(lastKey) : null; return new PaginatedResult<>(results.subList(0, Math.min(results.size(), limit)), nextCursor); } - + /** * 조회수 증가 (Atomic Update) */ @@ -230,23 +233,23 @@ public void incrementReadCount(String date, String articleId) { "PK", AttributeValue.builder().s(NewsKey.newsPk(date)).build(), "SK", AttributeValue.builder().s(NewsKey.articleSk(articleId)).build() ); - + Map values = Map.of( ":zero", AttributeValue.builder().n("0").build(), ":inc", AttributeValue.builder().n("1").build() ); - + UpdateItemRequest request = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression("SET readCount = if_not_exists(readCount, :zero) + :inc") .expressionAttributeValues(values) .build(); - + AwsClients.dynamoDb().updateItem(request); logger.debug("Incremented read count for article: {}", articleId); } - + /** * 댓글수 증가 (Atomic Update) */ @@ -255,22 +258,22 @@ public void incrementCommentCount(String date, String articleId) { "PK", AttributeValue.builder().s(NewsKey.newsPk(date)).build(), "SK", AttributeValue.builder().s(NewsKey.articleSk(articleId)).build() ); - + Map values = Map.of( ":zero", AttributeValue.builder().n("0").build(), ":inc", AttributeValue.builder().n("1").build() ); - + UpdateItemRequest request = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression("SET commentCount = if_not_exists(commentCount, :zero) + :inc") .expressionAttributeValues(values) .build(); - + AwsClients.dynamoDb().updateItem(request); } - + /** * 댓글수 감소 (Atomic Update) */ @@ -279,19 +282,19 @@ public void decrementCommentCount(String date, String articleId) { "PK", AttributeValue.builder().s(NewsKey.newsPk(date)).build(), "SK", AttributeValue.builder().s(NewsKey.articleSk(articleId)).build() ); - + Map values = Map.of( ":one", AttributeValue.builder().n("1").build(), ":dec", AttributeValue.builder().n("1").build() ); - + UpdateItemRequest request = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression("SET commentCount = if_not_exists(commentCount, :one) - :dec") .expressionAttributeValues(values) .build(); - + AwsClients.dynamoDb().updateItem(request); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java index b2786f99..d772ee5a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java @@ -6,8 +6,13 @@ import com.mzc.secondproject.serverless.domain.news.model.NewsQuizResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.*; -import software.amazon.awssdk.enhanced.dynamodb.model.*; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; import java.util.ArrayList; import java.util.List; @@ -17,20 +22,20 @@ * 뉴스 퀴즈 결과 Repository */ public class NewsQuizRepository { - + private static final Logger logger = LoggerFactory.getLogger(NewsQuizRepository.class); private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); - + private final DynamoDbTable table; - + public NewsQuizRepository() { this(AwsClients.dynamoDbEnhanced()); } - + public NewsQuizRepository(DynamoDbEnhancedClient enhancedClient) { this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(NewsQuizResult.class)); } - + /** * 퀴즈 결과 저장 */ @@ -39,7 +44,7 @@ public void save(NewsQuizResult result) { logger.debug("퀴즈 결과 저장: userId={}, articleId={}, score={}", result.getUserId(), result.getArticleId(), result.getScore()); } - + /** * 퀴즈 결과 조회 */ @@ -48,18 +53,18 @@ public Optional findByUserAndArticle(String userId, String artic .partitionValue(NewsKey.userNewsPk(userId)) .sortValue(NewsKey.quizSk(articleId)) .build(); - + NewsQuizResult result = table.getItem(key); return Optional.ofNullable(result); } - + /** * 퀴즈 제출 여부 확인 */ public boolean hasSubmitted(String userId, String articleId) { return findByUserAndArticle(userId, articleId).isPresent(); } - + /** * 사용자 퀴즈 결과 목록 조회 */ @@ -70,22 +75,22 @@ public List getUserQuizResults(String userId, int limit) { .sortValue("QUIZ#") .build() ); - + QueryEnhancedRequest request = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) .limit(limit) .build(); - + List results = new ArrayList<>(); for (Page page : table.query(request)) { results.addAll(page.items()); if (results.size() >= limit) break; } - + return results.subList(0, Math.min(results.size(), limit)); } - + /** * 사용자 퀴즈 통계 조회 */ @@ -96,11 +101,11 @@ public QuizStats getUserQuizStats(String userId) { .sortValue("QUIZ#") .build() ); - + int totalQuizzes = 0; int totalScore = 0; int perfectScores = 0; - + for (Page page : table.query(queryConditional)) { for (NewsQuizResult result : page.items()) { totalQuizzes++; @@ -110,11 +115,11 @@ public QuizStats getUserQuizStats(String userId) { } } } - + int avgScore = totalQuizzes > 0 ? totalScore / totalQuizzes : 0; return new QuizStats(totalQuizzes, avgScore, perfectScores); } - + /** * 퀴즈 통계 레코드 */ @@ -122,5 +127,6 @@ public record QuizStats( int totalQuizzes, int avgScore, int perfectScores - ) {} + ) { + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsWordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsWordRepository.java index 5dfebc80..be899b98 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsWordRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsWordRepository.java @@ -7,7 +7,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.enhanced.dynamodb.*; -import software.amazon.awssdk.enhanced.dynamodb.model.*; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; import java.util.ArrayList; import java.util.List; @@ -17,22 +19,22 @@ * 뉴스 단어 수집 Repository */ public class NewsWordRepository { - + private static final Logger logger = LoggerFactory.getLogger(NewsWordRepository.class); private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); - + private final DynamoDbTable table; private final DynamoDbIndex gsi1Index; - + public NewsWordRepository() { this(AwsClients.dynamoDbEnhanced()); } - + public NewsWordRepository(DynamoDbEnhancedClient enhancedClient) { this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(NewsWordCollect.class)); this.gsi1Index = table.index("GSI1"); } - + /** * 단어 수집 저장 */ @@ -40,7 +42,7 @@ public void save(NewsWordCollect wordCollect) { table.putItem(wordCollect); logger.debug("단어 수집 저장: userId={}, word={}", wordCollect.getUserId(), wordCollect.getWord()); } - + /** * 단어 수집 조회 */ @@ -49,18 +51,18 @@ public Optional findByUserWordArticle(String userId, String wor .partitionValue(NewsKey.userNewsPk(userId)) .sortValue(NewsKey.wordSk(word, articleId)) .build(); - + NewsWordCollect result = table.getItem(key); return Optional.ofNullable(result); } - + /** * 이미 수집했는지 확인 */ public boolean hasCollected(String userId, String word, String articleId) { return findByUserWordArticle(userId, word, articleId).isPresent(); } - + /** * 단어 수집 삭제 */ @@ -69,11 +71,11 @@ public void delete(String userId, String word, String articleId) { .partitionValue(NewsKey.userNewsPk(userId)) .sortValue(NewsKey.wordSk(word, articleId)) .build(); - + table.deleteItem(key); logger.debug("단어 수집 삭제: userId={}, word={}", userId, word); } - + /** * 사용자 수집 단어 목록 조회 (최신순) */ @@ -83,22 +85,22 @@ public List getUserWords(String userId, int limit) { .partitionValue(NewsKey.userNewsWordsPk(userId)) .build() ); - + QueryEnhancedRequest request = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) .limit(limit) .build(); - + List results = new ArrayList<>(); for (Page page : gsi1Index.query(request)) { results.addAll(page.items()); if (results.size() >= limit) break; } - + return results.subList(0, Math.min(results.size(), limit)); } - + /** * 사용자 수집 단어 수 조회 */ @@ -109,14 +111,14 @@ public int countUserWords(String userId) { .sortValue("WORD#") .build() ); - + int count = 0; for (Page page : table.query(queryConditional)) { count += page.items().size(); } return count; } - + /** * Vocabulary 연동 상태 업데이트 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java index a5fa2b67..febc8895 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java @@ -6,9 +6,13 @@ import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.*; -import software.amazon.awssdk.enhanced.dynamodb.model.*; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; import java.time.Instant; import java.time.LocalDate; @@ -18,27 +22,27 @@ * 사용자 뉴스 학습 기록 Repository */ public class UserNewsRepository { - + private static final Logger logger = LoggerFactory.getLogger(UserNewsRepository.class); private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); - + private final DynamoDbTable table; - + public UserNewsRepository() { this(AwsClients.dynamoDbEnhanced()); } - + public UserNewsRepository(DynamoDbEnhancedClient enhancedClient) { this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(UserNewsRecord.class)); } - + /** * 읽기 기록 저장 */ public void saveReadRecord(String userId, String articleId, String title, String level, String category) { String now = Instant.now().toString(); String today = LocalDate.now().toString(); - + UserNewsRecord record = UserNewsRecord.builder() .pk(NewsKey.userNewsPk(userId)) .sk(NewsKey.readSk(articleId)) @@ -52,18 +56,18 @@ public void saveReadRecord(String userId, String articleId, String title, String .articleCategory(category) .createdAt(now) .build(); - + table.putItem(record); logger.debug("읽기 기록 저장: userId={}, articleId={}", userId, articleId); } - + /** * 북마크 저장 */ public void saveBookmark(String userId, String articleId, String title, String level, String category) { String now = Instant.now().toString(); String today = LocalDate.now().toString(); - + UserNewsRecord record = UserNewsRecord.builder() .pk(NewsKey.userNewsPk(userId)) .sk(NewsKey.bookmarkSk(articleId)) @@ -77,11 +81,11 @@ public void saveBookmark(String userId, String articleId, String title, String l .articleCategory(category) .createdAt(now) .build(); - + table.putItem(record); logger.debug("북마크 저장: userId={}, articleId={}", userId, articleId); } - + /** * 북마크 삭제 */ @@ -90,11 +94,11 @@ public void deleteBookmark(String userId, String articleId) { .partitionValue(NewsKey.userNewsPk(userId)) .sortValue(NewsKey.bookmarkSk(articleId)) .build(); - + table.deleteItem(key); logger.debug("북마크 삭제: userId={}, articleId={}", userId, articleId); } - + /** * 북마크 여부 확인 */ @@ -103,10 +107,10 @@ public boolean isBookmarked(String userId, String articleId) { .partitionValue(NewsKey.userNewsPk(userId)) .sortValue(NewsKey.bookmarkSk(articleId)) .build(); - + return table.getItem(key) != null; } - + /** * 읽기 기록 여부 확인 */ @@ -115,10 +119,10 @@ public boolean hasRead(String userId, String articleId) { .partitionValue(NewsKey.userNewsPk(userId)) .sortValue(NewsKey.readSk(articleId)) .build(); - + return table.getItem(key) != null; } - + /** * 사용자 북마크 목록 조회 */ @@ -129,22 +133,22 @@ public List getUserBookmarks(String userId, int limit) { .sortValue("BOOKMARK#") .build() ); - + QueryEnhancedRequest request = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) .limit(limit) .build(); - + List results = new ArrayList<>(); for (Page page : table.query(request)) { results.addAll(page.items()); if (results.size() >= limit) break; } - + return results.subList(0, Math.min(results.size(), limit)); } - + /** * 여러 기사의 북마크 여부 확인 (배치) */ @@ -157,7 +161,7 @@ public Set getBookmarkedArticleIds(String userId, List articleId } return bookmarkedIds; } - + /** * 사용자 뉴스 통계 조회 */ @@ -165,20 +169,20 @@ public NewsStats getUserStats(String userId) { QueryConditional queryConditional = QueryConditional.keyEqualTo( Key.builder().partitionValue(NewsKey.userNewsPk(userId)).build() ); - + int totalRead = 0; int thisWeekRead = 0; int totalBookmarks = 0; Map byLevel = new HashMap<>(); Map byCategory = new HashMap<>(); - + LocalDate weekAgo = LocalDate.now().minusDays(7); - + for (Page page : table.query(queryConditional)) { for (UserNewsRecord record : page.items()) { if ("READ".equals(record.getType())) { totalRead++; - + // 이번 주 읽은 것 if (record.getCreatedAt() != null) { LocalDate readDate = Instant.parse(record.getCreatedAt()) @@ -187,13 +191,13 @@ public NewsStats getUserStats(String userId) { thisWeekRead++; } } - + // 레벨별 통계 String level = record.getArticleLevel(); if (level != null) { byLevel.merge(level, 1, Integer::sum); } - + // 카테고리별 통계 String category = record.getArticleCategory(); if (category != null) { @@ -204,10 +208,10 @@ public NewsStats getUserStats(String userId) { } } } - + return new NewsStats(totalRead, thisWeekRead, totalBookmarks, byLevel, byCategory); } - + /** * 뉴스 통계 레코드 */ @@ -217,5 +221,6 @@ public record NewsStats( int totalBookmarks, Map byLevel, Map byCategory - ) {} + ) { + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java index 87da58f1..4badd15f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java @@ -13,11 +13,13 @@ import software.amazon.awssdk.core.SdkBytes; import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelRequest; import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelResponse; -import software.amazon.awssdk.services.comprehend.model.*; +import software.amazon.awssdk.services.comprehend.model.DetectKeyPhrasesRequest; +import software.amazon.awssdk.services.comprehend.model.DetectKeyPhrasesResponse; +import software.amazon.awssdk.services.comprehend.model.KeyPhrase; +import software.amazon.awssdk.services.comprehend.model.LanguageCode; import java.util.ArrayList; import java.util.List; -import java.util.stream.Collectors; /** * 뉴스 AI 분석 서비스 @@ -27,37 +29,37 @@ * - 퀴즈 생성 (Bedrock) */ public class NewsAnalysisService { - + private static final Logger logger = LoggerFactory.getLogger(NewsAnalysisService.class); private static final Gson gson = new Gson(); private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; - + private final NewsArticleRepository articleRepository; - + public NewsAnalysisService() { this.articleRepository = new NewsArticleRepository(); } - + public NewsAnalysisService(NewsArticleRepository articleRepository) { this.articleRepository = articleRepository; } - + /** * 뉴스 기사 전체 분석 */ public NewsArticle analyzeArticle(NewsArticle article) { logger.info("뉴스 분석 시작: {}", article.getArticleId()); long startTime = System.currentTimeMillis(); - + String content = article.getTitle() + ". " + (article.getSummary() != null ? article.getSummary() : ""); - + try { // 1. CEFR 난이도 분석 String cefrLevel = analyzeDifficulty(content); article.setCefrLevel(cefrLevel); article.setLevel(mapCefrToLevel(cefrLevel)); - + // 2. 3줄 요약 + 키워드 + 퀴즈 생성 (Bedrock - 한 번에 처리) AnalysisResult result = generateSummaryAndQuiz(content, cefrLevel); if (result.summary() != null) { @@ -65,7 +67,7 @@ public NewsArticle analyzeArticle(NewsArticle article) { } article.setQuiz(result.quiz()); article.setHighlightWords(result.highlightWords()); - + // Bedrock 키워드 사용 (meaningKo 포함) if (result.keywords() != null && !result.keywords().isEmpty()) { article.setKeywords(result.keywords()); @@ -74,7 +76,7 @@ public NewsArticle analyzeArticle(NewsArticle article) { List keywords = extractKeywords(content); article.setKeywords(keywords); } - + // 4. GSI 키 설정 article.setGsi1pk("LEVEL#" + article.getLevel()); article.setGsi1sk(article.getPublishedAt()); @@ -82,13 +84,13 @@ public NewsArticle analyzeArticle(NewsArticle article) { article.setGsi2pk("CATEGORY#" + article.getCategory()); article.setGsi2sk(article.getPublishedAt()); } - + // 5. 저장 articleRepository.save(article); - + long elapsed = System.currentTimeMillis() - startTime; logger.info("뉴스 분석 완료: {} ({}ms)", article.getArticleId(), elapsed); - + } catch (Exception e) { logger.error("뉴스 분석 실패: {}", article.getArticleId(), e); // 분석 실패해도 기본값으로 저장 @@ -96,10 +98,10 @@ public NewsArticle analyzeArticle(NewsArticle article) { article.setCefrLevel("B1"); articleRepository.save(article); } - + return article; } - + /** * CEFR 난이도 분석 (Bedrock) */ @@ -107,31 +109,31 @@ private String analyzeDifficulty(String content) { String systemPrompt = """ You are an English language expert. Analyze the text and determine its CEFR level. Consider vocabulary complexity, sentence structure, and topic familiarity. - + Respond with ONLY the CEFR level code: A1, A2, B1, B2, C1, or C2 No explanation, just the level code. """; - + String userPrompt = "Determine the CEFR level of this text:\n\n" + truncate(content, 1000); - + String response = invokeBedrock(systemPrompt, userPrompt); String level = response.trim().toUpperCase(); - + // 유효한 레벨인지 확인 if (List.of("A1", "A2", "B1", "B2", "C1", "C2").contains(level)) { return level; } - + // 레벨 추출 시도 for (String validLevel : List.of("C2", "C1", "B2", "B1", "A2", "A1")) { if (response.toUpperCase().contains(validLevel)) { return validLevel; } } - + return "B1"; // 기본값 } - + /** * CEFR을 3단계 레벨로 매핑 */ @@ -143,7 +145,7 @@ private String mapCefrToLevel(String cefrLevel) { default -> "INTERMEDIATE"; }; } - + /** * 핵심 단어 추출 (Comprehend) */ @@ -155,10 +157,10 @@ private List extractKeywords(String content) { .languageCode(LanguageCode.EN) .build() ); - + List keywords = new ArrayList<>(); List phrases = response.keyPhrases(); - + for (int i = 0; i < Math.min(phrases.size(), 10); i++) { KeyPhrase phrase = phrases.get(i); if (phrase.score() > 0.8) { @@ -168,22 +170,22 @@ private List extractKeywords(String content) { .build()); } } - + return keywords; - + } catch (Exception e) { logger.error("키워드 추출 실패", e); return new ArrayList<>(); } } - + /** * 요약 + 퀴즈 생성 (Bedrock) */ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) { String systemPrompt = """ You are an English learning assistant for Korean learners. Analyze the news article and create learning materials. - + Respond in this exact JSON format: { "summary": "3-line summary in English (each line separated by newline)", @@ -219,7 +221,7 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) } ] } - + IMPORTANT: - keywords: Extract 5-8 important vocabulary words from the article. Include: - word: the English word @@ -230,9 +232,9 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) - category: Choose EXACTLY ONE from: WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE - Create exactly 3 quiz questions. - Adjust difficulty based on CEFR level: """ + cefrLevel; - + String userPrompt = "Create learning materials for this article:\n\n" + truncate(content, 1500); - + try { String response = invokeBedrock(systemPrompt, userPrompt); return parseAnalysisResult(response); @@ -241,7 +243,7 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>(), new ArrayList<>()); } } - + /** * Bedrock API 호출 */ @@ -250,42 +252,42 @@ private String invokeBedrock(String systemPrompt, String userPrompt) { requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); requestBody.addProperty("max_tokens", 2000); requestBody.addProperty("system", systemPrompt); - + JsonArray messages = new JsonArray(); JsonObject userMessage = new JsonObject(); userMessage.addProperty("role", "user"); userMessage.addProperty("content", userPrompt); messages.add(userMessage); requestBody.add("messages", messages); - + InvokeModelRequest request = InvokeModelRequest.builder() .modelId(MODEL_ID) .contentType("application/json") .accept("application/json") .body(SdkBytes.fromUtf8String(gson.toJson(requestBody))) .build(); - + InvokeModelResponse response = AwsClients.bedrock().invokeModel(request); JsonObject jsonResponse = gson.fromJson(response.body().asUtf8String(), JsonObject.class); - + JsonArray contentArray = jsonResponse.getAsJsonArray("content"); if (contentArray != null && !contentArray.isEmpty()) { return contentArray.get(0).getAsJsonObject().get("text").getAsString(); } - + throw new RuntimeException("Empty response from Bedrock"); } - + /** * 분석 결과 파싱 */ private AnalysisResult parseAnalysisResult(String response) { String jsonStr = extractJson(response); JsonObject json = gson.fromJson(jsonStr, JsonObject.class); - + String summary = json.has("summary") ? json.get("summary").getAsString() : null; String category = json.has("category") ? json.get("category").getAsString().toUpperCase() : "WORLD"; - + // keywords 파싱 List keywords = new ArrayList<>(); if (json.has("keywords")) { @@ -299,12 +301,12 @@ private AnalysisResult parseAnalysisResult(String response) { .build()); }); } - + List highlightWords = new ArrayList<>(); if (json.has("highlightWords")) { json.getAsJsonArray("highlightWords").forEach(e -> highlightWords.add(e.getAsString())); } - + List quiz = new ArrayList<>(); if (json.has("quiz")) { json.getAsJsonArray("quiz").forEach(e -> { @@ -323,10 +325,10 @@ private AnalysisResult parseAnalysisResult(String response) { .build()); }); } - + return new AnalysisResult(summary, keywords, highlightWords, quiz); } - + private String extractJson(String response) { int start = response.indexOf('{'); int end = response.lastIndexOf('}'); @@ -335,12 +337,12 @@ private String extractJson(String response) { } return response; } - + private String truncate(String text, int maxLength) { if (text == null) return ""; return text.length() > maxLength ? text.substring(0, maxLength) : text; } - + /** * 분석 결과 레코드 */ @@ -349,5 +351,6 @@ private record AnalysisResult( List keywords, List highlightWords, List quiz - ) {} + ) { + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java index ecac47df..1709fc42 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java @@ -18,41 +18,41 @@ * RSS 피드에서 뉴스를 수집하고 저장 (BBC, VOA, NPR) */ public class NewsCollectorService { - + private static final Logger logger = LoggerFactory.getLogger(NewsCollectorService.class); - + private static final int RSS_LIMIT_PER_SOURCE = 7; private static final long TTL_DAYS = 30; - + private final RssFeedParser rssFeedParser; private final NewsDuplicateChecker duplicateChecker; private final NewsArticleRepository articleRepository; private final NewsAnalysisService analysisService; - + public NewsCollectorService() { this.rssFeedParser = new RssFeedParser(); this.duplicateChecker = new NewsDuplicateChecker(); this.articleRepository = new NewsArticleRepository(); this.analysisService = new NewsAnalysisService(); } - + public NewsCollectorService(RssFeedParser rssFeedParser, - NewsDuplicateChecker duplicateChecker, - NewsArticleRepository articleRepository, - NewsAnalysisService analysisService) { + NewsDuplicateChecker duplicateChecker, + NewsArticleRepository articleRepository, + NewsAnalysisService analysisService) { this.rssFeedParser = rssFeedParser; this.duplicateChecker = duplicateChecker; this.articleRepository = articleRepository; this.analysisService = analysisService; } - + /** * 뉴스 수집 실행 */ public CollectionResult collectNews() { logger.info("뉴스 수집 시작"); long startTime = System.currentTimeMillis(); - + List rssArticles; try { rssArticles = rssFeedParser.fetchAllFeeds(RSS_LIMIT_PER_SOURCE); @@ -61,16 +61,16 @@ public CollectionResult collectNews() { logger.error("RSS 수집 실패", e); return new CollectionResult(0, 0, System.currentTimeMillis() - startTime); } - + List uniqueArticles = duplicateChecker.filterDuplicates(rssArticles); logger.info("중복 제거 후 {}개 기사", uniqueArticles.size()); - + int savedCount = 0; int analyzedCount = 0; for (RawNewsArticle rawArticle : uniqueArticles) { try { NewsArticle article = convertToNewsArticle(rawArticle); - + // AI 분석 수행 (난이도, 요약, 키워드, 퀴즈) analysisService.analyzeArticle(article); analyzedCount++; @@ -79,13 +79,13 @@ public CollectionResult collectNews() { logger.error("기사 처리 실패: {}", rawArticle.getTitle(), e); } } - + long elapsed = System.currentTimeMillis() - startTime; logger.info("뉴스 수집/분석 완료 - 저장: {}, 분석: {}, 소요시간: {}ms", savedCount, analyzedCount, elapsed); - + return new CollectionResult(rssArticles.size(), savedCount, elapsed); } - + /** * RawNewsArticle을 NewsArticle로 변환 * AI 분석은 별도 Story에서 처리 @@ -94,12 +94,12 @@ private NewsArticle convertToNewsArticle(RawNewsArticle raw) { String today = LocalDate.now().toString(); String articleId = UUID.randomUUID().toString().substring(0, 8); String now = Instant.now().toString(); - + long ttlEpoch = Instant.now() .atOffset(ZoneOffset.UTC) .plusDays(TTL_DAYS) .toEpochSecond(); - + return NewsArticle.builder() .pk(NewsKey.newsPk(today)) .sk(NewsKey.articleSk(articleId)) @@ -116,7 +116,7 @@ private NewsArticle convertToNewsArticle(RawNewsArticle raw) { .ttl(ttlEpoch) .build(); } - + /** * 수집 결과 레코드 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsDuplicateChecker.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsDuplicateChecker.java index d4eedd82..b939c7bc 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsDuplicateChecker.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsDuplicateChecker.java @@ -11,21 +11,17 @@ import software.amazon.awssdk.services.dynamodb.model.QueryResponse; import java.time.LocalDate; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; +import java.util.*; /** * 뉴스 중복 검사 서비스 * URL 기반으로 중복 뉴스 필터링 */ public class NewsDuplicateChecker { - + private static final Logger logger = LoggerFactory.getLogger(NewsDuplicateChecker.class); private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); - + /** * 중복 뉴스 필터링 */ @@ -33,38 +29,38 @@ public List filterDuplicates(List articles) { if (articles.isEmpty()) { return articles; } - + Set existingUrls = getExistingUrls(); Set seenUrls = new HashSet<>(); List uniqueArticles = new ArrayList<>(); - + for (RawNewsArticle article : articles) { String url = article.getUrl(); if (url == null) { continue; } - + if (!existingUrls.contains(url) && !seenUrls.contains(url)) { uniqueArticles.add(article); seenUrls.add(url); } } - + int duplicateCount = articles.size() - uniqueArticles.size(); if (duplicateCount > 0) { logger.info("{}개 중복 기사 필터링됨", duplicateCount); } - + return uniqueArticles; } - + /** * 오늘 날짜의 기존 뉴스 URL 조회 */ private Set getExistingUrls() { Set urls = new HashSet<>(); String today = LocalDate.now().toString(); - + try { QueryRequest request = QueryRequest.builder() .tableName(TABLE_NAME) @@ -74,21 +70,21 @@ private Set getExistingUrls() { )) .projectionExpression("originalUrl") .build(); - + QueryResponse response = AwsClients.dynamoDb().query(request); - + for (Map item : response.items()) { if (item.containsKey("originalUrl")) { urls.add(item.get("originalUrl").s()); } } - + logger.debug("기존 뉴스 {}개 URL 로드됨", urls.size()); - + } catch (Exception e) { logger.error("기존 뉴스 URL 조회 실패", e); } - + return urls; } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java index 02930b1d..dfc5b799 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java @@ -19,16 +19,16 @@ * 뉴스 학습 부가 기능 서비스 */ public class NewsLearningService { - + private static final Logger logger = LoggerFactory.getLogger(NewsLearningService.class); private static final String BUCKET_NAME = EnvConfig.getOrDefault("NEWS_BUCKET_NAME", "group2-englishstudy"); - + private final NewsArticleRepository articleRepository; private final UserNewsRepository userNewsRepository; private final PollyService pollyService; private final UserStatsRepository userStatsRepository; private final BadgeService badgeService; - + public NewsLearningService() { this.articleRepository = new NewsArticleRepository(); this.userNewsRepository = new UserNewsRepository(); @@ -36,21 +36,22 @@ public NewsLearningService() { this.userStatsRepository = new UserStatsRepository(); this.badgeService = new BadgeService(); } - + public NewsLearningService(NewsArticleRepository articleRepository, - UserNewsRepository userNewsRepository, - PollyService pollyService, - UserStatsRepository userStatsRepository, - BadgeService badgeService) { + UserNewsRepository userNewsRepository, + PollyService pollyService, + UserStatsRepository userStatsRepository, + BadgeService badgeService) { this.articleRepository = articleRepository; this.userNewsRepository = userNewsRepository; this.pollyService = pollyService; this.userStatsRepository = userStatsRepository; this.badgeService = badgeService; } - + /** * 뉴스 읽기 완료 기록 + * * @return 새로 획득한 배지 목록 */ public List markAsRead(String userId, String articleId) { @@ -59,13 +60,13 @@ public List markAsRead(String userId, String articleId) { logger.warn("기사를 찾을 수 없음: {}", articleId); return new ArrayList<>(); } - + // 이미 읽은 기사인지 확인 (중복 조회수 증가 방지) if (userNewsRepository.hasRead(userId, articleId)) { logger.debug("이미 읽은 기사: userId={}, articleId={}", userId, articleId); return new ArrayList<>(); } - + NewsArticle a = article.get(); userNewsRepository.saveReadRecord( userId, @@ -74,15 +75,15 @@ public List markAsRead(String userId, String articleId) { a.getLevel(), a.getCategory() ); - + // 조회수 증가 (새로운 읽기만) String date = extractDateFromPk(a.getPk()); if (date != null) { articleRepository.incrementReadCount(date, articleId); } - + logger.info("읽기 완료 기록: userId={}, articleId={}", userId, articleId); - + // 통계 업데이트 및 배지 체크 List newBadges = new ArrayList<>(); try { @@ -97,16 +98,16 @@ public List markAsRead(String userId, String articleId) { } catch (Exception e) { logger.error("통계/배지 업데이트 실패: userId={}, error={}", userId, e.getMessage()); } - + return newBadges; } - + /** * 북마크 토글 */ public boolean toggleBookmark(String userId, String articleId) { boolean isBookmarked = userNewsRepository.isBookmarked(userId, articleId); - + if (isBookmarked) { userNewsRepository.deleteBookmark(userId, articleId); logger.info("북마크 해제: userId={}, articleId={}", userId, articleId); @@ -117,7 +118,7 @@ public boolean toggleBookmark(String userId, String articleId) { logger.warn("기사를 찾을 수 없음: {}", articleId); return false; } - + NewsArticle a = article.get(); userNewsRepository.saveBookmark( userId, @@ -130,35 +131,35 @@ public boolean toggleBookmark(String userId, String articleId) { return true; } } - + /** * 북마크 여부 확인 */ public boolean isBookmarked(String userId, String articleId) { return userNewsRepository.isBookmarked(userId, articleId); } - + /** * 읽기 여부 확인 */ public boolean hasRead(String userId, String articleId) { return userNewsRepository.hasRead(userId, articleId); } - + /** * 여러 기사의 북마크 여부 확인 (배치) */ public Set getBookmarkedArticleIds(String userId, List articleIds) { return userNewsRepository.getBookmarkedArticleIds(userId, articleIds); } - + /** * 사용자 북마크 목록 조회 (기사 정보 포함) */ public List> getUserBookmarks(String userId, int limit) { List bookmarks = userNewsRepository.getUserBookmarks(userId, limit); List> result = new ArrayList<>(); - + for (UserNewsRecord bookmark : bookmarks) { Optional articleOpt = articleRepository.findById(bookmark.getArticleId()); if (articleOpt.isPresent()) { @@ -180,7 +181,7 @@ public List> getUserBookmarks(String userId, int limit) { } return result; } - + /** * 뉴스 TTS 오디오 URL 생성 */ @@ -190,25 +191,25 @@ public String getAudioUrl(String articleId, String voice) { logger.warn("기사를 찾을 수 없음: {}", articleId); return null; } - + NewsArticle a = article.get(); String text = a.getTitle() + ". " + (a.getSummary() != null ? a.getSummary() : ""); - + // 텍스트가 너무 길면 제한 if (text.length() > 3000) { text = text.substring(0, 3000); } - + PollyService.VoiceSynthesisResult result = pollyService.synthesizeSpeech(articleId, text, voice); return result.getAudioUrl(); } - + /** * 사용자 뉴스 학습 통계 조회 */ public Map getUserStats(String userId) { UserNewsRepository.NewsStats stats = userNewsRepository.getUserStats(userId); - + return Map.of( "totalRead", stats.totalRead(), "thisWeekRead", stats.thisWeekRead(), @@ -217,7 +218,7 @@ public Map getUserStats(String userId) { "byCategory", stats.byCategory() ); } - + /** * PK에서 날짜 추출 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java index 5a3930ed..7f25e408 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java @@ -13,26 +13,26 @@ * 뉴스 조회 서비스 */ public class NewsQueryService { - + private static final Logger logger = LoggerFactory.getLogger(NewsQueryService.class); - + private final NewsArticleRepository articleRepository; - + public NewsQueryService() { this.articleRepository = new NewsArticleRepository(); } - + public NewsQueryService(NewsArticleRepository articleRepository) { this.articleRepository = articleRepository; } - + /** * 뉴스 상세 조회 */ public Optional getArticle(String articleId) { logger.debug("뉴스 상세 조회: {}", articleId); Optional article = articleRepository.findById(articleId); - + // 조회수 증가 article.ifPresent(a -> { String date = extractDateFromPk(a.getPk()); @@ -40,10 +40,10 @@ public Optional getArticle(String articleId) { articleRepository.incrementReadCount(date, articleId); } }); - + return article; } - + /** * 오늘의 뉴스 목록 조회 */ @@ -52,7 +52,7 @@ public PaginatedResult getTodayNews(int limit, String cursor) { logger.debug("오늘의 뉴스 조회: date={}, limit={}", today, limit); return articleRepository.findByDate(today, limit, cursor); } - + /** * 레벨별 뉴스 조회 */ @@ -60,7 +60,7 @@ public PaginatedResult getNewsByLevel(String level, int limit, Stri logger.debug("레벨별 뉴스 조회: level={}, limit={}", level, limit); return articleRepository.findByLevel(level, limit, cursor); } - + /** * 카테고리별 뉴스 조회 */ @@ -68,7 +68,7 @@ public PaginatedResult getNewsByCategory(String category, int limit logger.debug("카테고리별 뉴스 조회: category={}, limit={}", category, limit); return articleRepository.findByCategory(category, limit, cursor); } - + /** * 레벨 + 카테고리 복합 필터 조회 */ @@ -76,7 +76,7 @@ public PaginatedResult getNewsByLevelAndCategory(String level, Stri logger.debug("레벨+카테고리 뉴스 조회: level={}, category={}, limit={}", level, category, limit); return articleRepository.findByLevelAndCategory(level, category, limit, cursor); } - + /** * 사용자 레벨 맞춤 뉴스 추천 */ @@ -85,7 +85,7 @@ public PaginatedResult getRecommendedNews(String userLevel, int lim // 사용자 레벨에 맞는 뉴스 조회 return articleRepository.findByLevel(userLevel, limit, cursor); } - + /** * PK에서 날짜 추출 (NEWS#2024-01-15 → 2024-01-15) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java index bb22fc90..31c768c1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java @@ -1,7 +1,10 @@ package com.mzc.secondproject.serverless.domain.news.service; import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; -import com.mzc.secondproject.serverless.domain.news.model.*; +import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import com.mzc.secondproject.serverless.domain.news.model.NewsQuizResult; +import com.mzc.secondproject.serverless.domain.news.model.QuizAnswerResult; +import com.mzc.secondproject.serverless.domain.news.model.QuizQuestion; import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; import com.mzc.secondproject.serverless.domain.news.repository.NewsQuizRepository; import org.slf4j.Logger; @@ -15,22 +18,22 @@ * 뉴스 퀴즈 서비스 */ public class NewsQuizService { - + private static final Logger logger = LoggerFactory.getLogger(NewsQuizService.class); - + private final NewsArticleRepository articleRepository; private final NewsQuizRepository quizRepository; - + public NewsQuizService() { this.articleRepository = new NewsArticleRepository(); this.quizRepository = new NewsQuizRepository(); } - + public NewsQuizService(NewsArticleRepository articleRepository, NewsQuizRepository quizRepository) { this.articleRepository = articleRepository; this.quizRepository = quizRepository; } - + /** * 퀴즈 조회 */ @@ -40,18 +43,18 @@ public Optional getQuiz(String articleId, String userId) { logger.warn("기사를 찾을 수 없음: {}", articleId); return Optional.empty(); } - + NewsArticle article = articleOpt.get(); List questions = article.getQuiz(); - + if (questions == null || questions.isEmpty()) { logger.warn("퀴즈가 없는 기사: {}", articleId); return Optional.empty(); } - + // 이미 제출했는지 확인 boolean submitted = quizRepository.hasSubmitted(userId, articleId); - + // 정답 제거한 퀴즈 반환 List questionViews = questions.stream() .map(q -> QuizQuestionView.builder() @@ -62,7 +65,7 @@ public Optional getQuiz(String articleId, String userId) { .points(q.getPoints()) .build()) .toList(); - + return Optional.of(QuizData.builder() .articleId(articleId) .articleTitle(article.getTitle()) @@ -72,7 +75,7 @@ public Optional getQuiz(String articleId, String userId) { .submitted(submitted) .build()); } - + /** * 퀴즈 제출 및 채점 */ @@ -82,42 +85,42 @@ public QuizSubmitResult submitQuiz(String userId, String articleId, List articleOpt = articleRepository.findById(articleId); if (articleOpt.isEmpty()) { logger.warn("기사를 찾을 수 없음: {}", articleId); return null; } - + NewsArticle article = articleOpt.get(); List questions = article.getQuiz(); - + if (questions == null || questions.isEmpty()) { logger.warn("퀴즈가 없는 기사: {}", articleId); return null; } - + // 정답 맵 생성 Map questionMap = new HashMap<>(); for (QuizQuestion q : questions) { questionMap.put(q.getQuestionId(), q); } - + // 채점 List answerResults = new ArrayList<>(); int earnedPoints = 0; int totalPoints = 0; - + for (QuizAnswer answer : answers) { QuizQuestion question = questionMap.get(answer.questionId()); if (question == null) continue; - + boolean correct = question.getCorrectAnswer().equalsIgnoreCase(answer.answer()); int points = correct ? question.getPoints() : 0; earnedPoints += points; totalPoints += question.getPoints(); - + answerResults.add(QuizAnswerResult.builder() .questionId(answer.questionId()) .type(question.getType()) @@ -127,14 +130,14 @@ public QuizSubmitResult submitQuiz(String userId, String articleId, List 0 ? (earnedPoints * 100) / totalPoints : 0; - + // 결과 저장 String now = Instant.now().toString(); String today = LocalDate.now().toString(); - + NewsQuizResult result = NewsQuizResult.builder() .pk(NewsKey.userNewsPk(userId)) .sk(NewsKey.quizSk(articleId)) @@ -151,13 +154,13 @@ public QuizSubmitResult submitQuiz(String userId, String articleId, List getQuizResult(String userId, String articleId) { return quizRepository.findByUserAndArticle(userId, articleId); } - + /** * 사용자 퀴즈 기록 목록 조회 */ public List getUserQuizHistory(String userId, int limit) { return quizRepository.getUserQuizResults(userId, limit); } - + /** * 사용자 퀴즈 통계 조회 */ @@ -192,7 +195,7 @@ public Map getUserQuizStats(String userId) { "perfectScores", stats.perfectScores() ); } - + /** * 피드백 생성 */ @@ -209,7 +212,7 @@ private String generateFeedback(int score, List results) { return "Don't give up! Focus on vocabulary and main ideas."; } } - + /** * 퀴즈 데이터 (정답 제외) */ @@ -225,7 +228,7 @@ public static class QuizData { private int totalPoints; private boolean submitted; } - + /** * 퀴즈 문제 뷰 (정답 제외) */ @@ -240,12 +243,13 @@ public static class QuizQuestionView { private List options; private int points; } - + /** * 사용자 답변 */ - public record QuizAnswer(String questionId, String answer) {} - + public record QuizAnswer(String questionId, String answer) { + } + /** * 퀴즈 제출 결과 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java index edf8901b..b38ce3ca 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java @@ -20,31 +20,31 @@ * 뉴스 단어 수집 서비스 */ public class NewsWordService { - + private static final Logger logger = LoggerFactory.getLogger(NewsWordService.class); - + private final NewsWordRepository newsWordRepository; private final NewsArticleRepository articleRepository; private final WordRepository wordRepository; private final UserWordCommandService userWordCommandService; - + public NewsWordService() { this.newsWordRepository = new NewsWordRepository(); this.articleRepository = new NewsArticleRepository(); this.wordRepository = new WordRepository(); this.userWordCommandService = new UserWordCommandService(); } - + public NewsWordService(NewsWordRepository newsWordRepository, - NewsArticleRepository articleRepository, - WordRepository wordRepository, - UserWordCommandService userWordCommandService) { + NewsArticleRepository articleRepository, + WordRepository wordRepository, + UserWordCommandService userWordCommandService) { this.newsWordRepository = newsWordRepository; this.articleRepository = articleRepository; this.wordRepository = wordRepository; this.userWordCommandService = userWordCommandService; } - + /** * 단어 수집 (자동으로 Word 테이블 + UserWord에 추가) */ @@ -54,12 +54,12 @@ public NewsWordCollect collectWord(String userId, String articleId, String word, logger.warn("이미 수집한 단어: userId={}, word={}", userId, word); return newsWordRepository.findByUserWordArticle(userId, word, articleId).orElse(null); } - + // 기사 조회 Optional articleOpt = articleRepository.findById(articleId); String articleTitle = articleOpt.map(NewsArticle::getTitle).orElse(""); String articleLevel = articleOpt.map(NewsArticle::getLevel).orElse("INTERMEDIATE"); - + // 기사 키워드에서 단어 정보 추출 String meaningKo = ""; String meaningEn = ""; @@ -74,12 +74,12 @@ public NewsWordCollect collectWord(String userId, String articleId, String word, } } } - + // 단어 정보 조회 (Word 테이블에서) String wordId = word.toLowerCase().trim(); Optional wordOpt = wordRepository.findById(wordId); String meaning = meaningKo; - + // Word 테이블에 없으면 자동 생성 if (wordOpt.isEmpty() && !meaningKo.isEmpty()) { String now = Instant.now().toString(); @@ -103,9 +103,9 @@ public NewsWordCollect collectWord(String userId, String articleId, String word, } else if (wordOpt.isPresent()) { meaning = wordOpt.get().getKorean(); } - + String now = Instant.now().toString(); - + NewsWordCollect wordCollect = NewsWordCollect.builder() .pk(NewsKey.userNewsPk(userId)) .sk(NewsKey.wordSk(word, articleId)) @@ -122,10 +122,10 @@ public NewsWordCollect collectWord(String userId, String articleId, String word, .syncedToVocab(true) // 자동 연동됨 .vocabUserWordId(wordId) .build(); - + newsWordRepository.save(wordCollect); logger.info("단어 수집 완료: userId={}, word={}, articleId={}", userId, word, articleId); - + // UserWord에 자동 추가 (NEW 상태로) try { userWordCommandService.updateWordStatus(userId, wordId, "NEW"); @@ -133,10 +133,10 @@ public NewsWordCollect collectWord(String userId, String articleId, String word, } catch (Exception e) { logger.warn("UserWord 추가 실패 (이미 존재할 수 있음): userId={}, wordId={}, error={}", userId, wordId, e.getMessage()); } - + return wordCollect; } - + /** * 수집한 단어 삭제 */ @@ -144,32 +144,32 @@ public void deleteWord(String userId, String word, String articleId) { newsWordRepository.delete(userId, word, articleId); logger.info("단어 삭제: userId={}, word={}", userId, word); } - + /** * 사용자 수집 단어 목록 조회 */ public List getUserWords(String userId, int limit) { return newsWordRepository.getUserWords(userId, limit); } - + /** * 사용자 수집 단어 수 조회 */ public int countUserWords(String userId) { return newsWordRepository.countUserWords(userId); } - + /** * 단어 상세 정보 조회 */ public Optional getWordDetail(String word) { String wordId = word.toLowerCase().trim(); Optional wordOpt = wordRepository.findById(wordId); - + if (wordOpt.isEmpty()) { return Optional.empty(); } - + Word w = wordOpt.get(); return Optional.of(WordDetail.builder() .word(w.getEnglish()) @@ -179,7 +179,7 @@ public Optional getWordDetail(String word) { .level(w.getLevel()) .build()); } - + /** * Vocabulary 도메인으로 단어 연동 */ @@ -189,34 +189,34 @@ public boolean syncToVocabulary(String userId, String word, String articleId) { logger.warn("수집한 단어를 찾을 수 없음: userId={}, word={}", userId, word); return false; } - + NewsWordCollect wordCollect = wordOpt.get(); - + // 이미 연동됐는지 확인 if (Boolean.TRUE.equals(wordCollect.getSyncedToVocab())) { logger.info("이미 Vocabulary에 연동됨: userId={}, word={}", userId, word); return true; } - + // Word 테이블에서 단어 조회 String wordId = word.toLowerCase().trim(); Optional vocabWord = wordRepository.findById(wordId); - + if (vocabWord.isEmpty()) { logger.warn("Vocabulary에 없는 단어: {}", word); return false; } - + // UserWord 생성 (NEW 상태로) userWordCommandService.updateWordStatus(userId, wordId, "NEW"); - + // 연동 상태 업데이트 newsWordRepository.updateSyncStatus(userId, word, articleId, wordId); - + logger.info("Vocabulary 연동 완료: userId={}, word={}", userId, word); return true; } - + /** * 사용자 단어 수집 통계 */ @@ -226,14 +226,14 @@ public Map getUserWordStats(String userId) { long syncedCount = recentWords.stream() .filter(w -> Boolean.TRUE.equals(w.getSyncedToVocab())) .count(); - + return Map.of( "totalCollected", totalWords, "recentWords", recentWords, "syncedToVocab", syncedCount ); } - + /** * 단어 상세 정보 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/RssFeedParser.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/RssFeedParser.java index ca2c98b8..bc7facd1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/RssFeedParser.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/RssFeedParser.java @@ -24,34 +24,34 @@ * BBC, VOA, NPR 등의 RSS 피드에서 뉴스 수집 */ public class RssFeedParser { - + private static final Logger logger = LoggerFactory.getLogger(RssFeedParser.class); - + private static final Map RSS_FEEDS = Map.of( "BBC", "https://feeds.bbci.co.uk/news/world/rss.xml", "VOA", "https://www.voanews.com/api/ziqpoe-mqm", "NPR", "https://feeds.npr.org/1001/rss.xml" ); - + private final HttpClient httpClient; - + public RssFeedParser() { this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .followRedirects(HttpClient.Redirect.NORMAL) .build(); } - + /** * 모든 RSS 피드에서 뉴스 수집 */ public List fetchAllFeeds(int maxPerSource) { List allArticles = new ArrayList<>(); - + for (Map.Entry entry : RSS_FEEDS.entrySet()) { String source = entry.getKey(); String feedUrl = entry.getValue(); - + try { List articles = fetchFeed(feedUrl, source, maxPerSource); allArticles.addAll(articles); @@ -60,16 +60,16 @@ public List fetchAllFeeds(int maxPerSource) { logger.error("{} RSS 피드 수집 실패: {}", source, e.getMessage()); } } - + return allArticles; } - + /** * 특정 RSS 피드에서 뉴스 수집 */ public List fetchFeed(String feedUrl, String source, int maxItems) { List articles = new ArrayList<>(); - + try { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(feedUrl)) @@ -77,22 +77,22 @@ public List fetchFeed(String feedUrl, String source, int maxItem .timeout(Duration.ofSeconds(30)) .GET() .build(); - + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); - + if (response.statusCode() != 200) { logger.error("RSS 피드 요청 실패 - url: {}, status: {}", feedUrl, response.statusCode()); return articles; } - + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); DocumentBuilder builder = factory.newDocumentBuilder(); Document document = builder.parse(response.body()); - + NodeList items = document.getElementsByTagName("item"); int count = Math.min(items.getLength(), maxItems); - + for (int i = 0; i < count; i++) { Element item = (Element) items.item(i); RawNewsArticle article = parseRssItem(item, source); @@ -100,14 +100,14 @@ public List fetchFeed(String feedUrl, String source, int maxItem articles.add(article); } } - + } catch (Exception e) { logger.error("RSS 피드 파싱 중 오류 발생 - url: {}", feedUrl, e); } - + return articles; } - + /** * RSS item 요소를 RawNewsArticle로 변환 */ @@ -121,7 +121,7 @@ private RawNewsArticle parseRssItem(Element item, String source) { .publishedAt(parsePublishedDate(getElementText(item, "pubDate"))) .build(); } - + /** * 요소에서 텍스트 추출 */ @@ -132,7 +132,7 @@ private String getElementText(Element parent, String tagName) { } return null; } - + /** * 이미지 URL 추출 (media:content, enclosure 등) */ @@ -142,7 +142,7 @@ private String extractImageUrl(Element item) { Element media = (Element) mediaContent.item(0); return media.getAttribute("url"); } - + NodeList enclosure = item.getElementsByTagName("enclosure"); if (enclosure.getLength() > 0) { Element enc = (Element) enclosure.item(0); @@ -151,16 +151,16 @@ private String extractImageUrl(Element item) { return enc.getAttribute("url"); } } - + NodeList mediaThumbnail = item.getElementsByTagName("media:thumbnail"); if (mediaThumbnail.getLength() > 0) { Element thumbnail = (Element) mediaThumbnail.item(0); return thumbnail.getAttribute("url"); } - + return null; } - + /** * RSS pubDate를 ISO 8601 형식으로 변환 */ @@ -170,7 +170,7 @@ private String parsePublishedDate(String pubDate) { } return pubDate; } - + /** * HTML 태그 제거 */ 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 index 8f600c66..3152beaf 100644 --- 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 @@ -4,9 +4,9 @@ * 대화 초기화 요청 DTO */ public record ResetRequest( - String sessionId + String sessionId ) { - public boolean isValid() { - return sessionId != null && !sessionId.isEmpty(); - } -} \ No newline at end of file + public boolean isValid() { + return sessionId != null && !sessionId.isEmpty(); + } +} 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 index 58ad78c1..f75ec1bb 100644 --- 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 @@ -4,29 +4,29 @@ * Speaking API 요청 DTO */ public record SpeakingRequest( - String sessionId, // 세션 ID (첫 요청 시 null) - String audio, // 음성 데이터 (base64) - String text, // 텍스트 입력 - String level // 레벨 (BEGINNER, INTERMEDIATE, ADVANCED) + 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 + /** + * 기본값 적용된 레벨 반환 + */ + 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(); + } +} 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 index 49d714dd..21cec696 100644 --- 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 @@ -4,9 +4,10 @@ * 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 + String sessionId, // 세션 ID (다음 요청에 사용) + String userTranscript, // 사용자가 말한 내용 (STT 결과) + String aiText, // AI 응답 텍스트 + String aiAudioUrl, // AI 응답 음성 URL (Polly) + double confidence // STT 신뢰도 +) { +} 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..ed6fbda0 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,142 @@ /** * 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 { + // 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)); + } + + } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingSession.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingSession.java index 07956b2f..a0712d4b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingSession.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingSession.java @@ -16,81 +16,81 @@ @AllArgsConstructor @DynamoDbBean public class SpeakingSession { - - // DynamoDB Key Prefixes - 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 = "SESSION#"; - - private String pk; // SPEAKING_SESSION#{sessionId} - private String sk; // METADATA - private String gsi1pk; // SPEAKING_USER#{userId} - private String gsi1sk; // SESSION#{sessionId} - - private String sessionId; - private String userId; - private String createdAt; - private String updatedAt; - private Long ttl; // 자동 삭제용 (24시간) - - // Speaking 전용 필드 - private String conversationHistory; // 대화 히스토리 (JSON) - private String targetLevel; // 목표 레벨 (BEGINNER, INTERMEDIATE, ADVANCED) - - /** - * 세션 생성 팩토리 메서드 - */ - public static SpeakingSession create(String sessionId, String userId, String level) { - String now = java.time.Instant.now().toString(); - // 24시간 후 자동 삭제 - long ttl = java.time.Instant.now().plusSeconds(86400).getEpochSecond(); - - return SpeakingSession.builder() - .pk(PK_PREFIX + sessionId) - .sk(SK_METADATA) - .gsi1pk(GSI1PK_PREFIX + userId) - .gsi1sk(GSI1SK_PREFIX + sessionId) - .sessionId(sessionId) - .userId(userId) - .createdAt(now) - .updatedAt(now) - .ttl(ttl) - .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() { - 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 + + // DynamoDB Key Prefixes + 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 = "SESSION#"; + + private String pk; // SPEAKING_SESSION#{sessionId} + private String sk; // METADATA + private String gsi1pk; // SPEAKING_USER#{userId} + private String gsi1sk; // SESSION#{sessionId} + + private String sessionId; + private String userId; + private String createdAt; + private String updatedAt; + private Long ttl; // 자동 삭제용 (24시간) + + // Speaking 전용 필드 + private String conversationHistory; // 대화 히스토리 (JSON) + private String targetLevel; // 목표 레벨 (BEGINNER, INTERMEDIATE, ADVANCED) + + /** + * 세션 생성 팩토리 메서드 + */ + public static SpeakingSession create(String sessionId, String userId, String level) { + String now = java.time.Instant.now().toString(); + // 24시간 후 자동 삭제 + long ttl = java.time.Instant.now().plusSeconds(86400).getEpochSecond(); + + return SpeakingSession.builder() + .pk(PK_PREFIX + sessionId) + .sk(SK_METADATA) + .gsi1pk(GSI1PK_PREFIX + userId) + .gsi1sk(GSI1SK_PREFIX + sessionId) + .sessionId(sessionId) + .userId(userId) + .createdAt(now) + .updatedAt(now) + .ttl(ttl) + .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() { + 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; + } +} 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..aa7acb63 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 @@ -15,60 +15,60 @@ * 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 + + 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); + } +} 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 180e14dc..fb94ce14 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 @@ -8,14 +8,12 @@ import com.mzc.secondproject.serverless.domain.speaking.dto.response.SpeakingResponse; 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; import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelRequest; import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelResponse; - import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -25,253 +23,253 @@ * 음성 입력 → 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 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(); - this.pollyService = new PollyService( - EnvConfig.getRequired("BUCKET_NAME"), - "speaking/voice/" - ); - this.sessionRepository = new SpeakingSessionRepository(); - } - - /** - * 음성 입력 처리 (전체 플로우) - */ - public SpeakingResponse processVoiceInput(String sessionId, String userId, String audioBase64, String level) { - logger.info("Processing voice input for sessionId: {}", sessionId); - - // 세션 조회 또는 생성 - SpeakingSession session = getOrCreateSession(sessionId, userId, level); - - String targetLevel = session.getTargetLevel(); - - // STT: 음성 → 텍스트 (Transcribe Proxy 사용) - logger.info("Step 1: Transcribing audio..."); - TranscribeProxyService.TranscribeResult sttResult = transcribeService.transcribe( - audioBase64, - session.getSessionId(), - "en-US" - ); - String userText = sttResult.transcript(); - logger.info("Transcription complete: {} (confidence: {})", userText, sttResult.confidence()); - - // 대화 히스토리 로드 - List history = parseHistory(session.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())); - } - session.setConversationHistory(toJson(history)); - sessionRepository.update(session); - - // TTS: 텍스트 → 음성 (Polly 사용) - logger.info("Step 3: Synthesizing speech..."); - String audioId = session.getSessionId() + "_" + System.currentTimeMillis(); - PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( - audioId, - aiResponse, - "FEMALE" - ); - logger.info("Speech synthesis complete: cached={}", ttsResult.isCached()); - - return new SpeakingResponse( - session.getSessionId(), - userText, - aiResponse, - ttsResult.getAudioUrl(), - sttResult.confidence() - ); - } - - /** - * 텍스트 입력 처리 (음성 없이 텍스트만) - */ - public SpeakingResponse processTextInput(String sessionId, String userId, String userText, String level){ - logger.info("Processing text input for sessionId: {}", sessionId); - - // 세션 조회 또는 생성 - SpeakingSession session = getOrCreateSession(sessionId, userId, level); - - // 대화 히스토리 로드 - List history = parseHistory(session.getConversationHistory()); - - // AI 응답 생성 - String aiResponse = generateAiResponse(userText, history, session.getTargetLevel()); - - // 히스토리 업데이트 - 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())); - } - session.setConversationHistory(toJson(history)); - sessionRepository.update(session); - - // TTS 생성 - String audioId = session.getSessionId() + "_" + System.currentTimeMillis(); - PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( - audioId, aiResponse, "FEMALE" - ); - - return new SpeakingResponse( - session.getSessionId(), - userText, - aiResponse, - ttsResult.getAudioUrl(), - 1.0 - ); - } - - /** - * 레벨 변경 - */ - public void updateLevel(String sessionId, String level) { - SpeakingSession session = sessionRepository.findBySessionId(sessionId) - .orElseThrow(() -> new RuntimeException("session not found: " + sessionId)); - - session.setTargetLevel(level.toUpperCase()); - sessionRepository.update(session); - logger.info("Level updated for sessionId {}: {}", sessionId, level); - } - - /** - * 대화 히스토리 초기화 - */ - public void resetConversation(String sessionId) { - SpeakingSession session = sessionRepository.findBySessionId(sessionId) - .orElseThrow(() -> new RuntimeException("session not found: " + sessionId)); - - session.setConversationHistory("[]"); - sessionRepository.update(session); - logger.info("Conversation reset for sessionId: {}", sessionId); - } - - - /** - * 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" -> """ + + 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 SpeakingSessionRepository sessionRepository; + + public SpeakingService() { + this.transcribeService = new TranscribeProxyService(); + this.pollyService = new PollyService( + EnvConfig.getRequired("BUCKET_NAME"), + "speaking/voice/" + ); + this.sessionRepository = new SpeakingSessionRepository(); + } + + /** + * 세션 생성 또는 조회 + */ + 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 SpeakingResponse processVoiceInput(String sessionId, String userId, String audioBase64, String level) { + logger.info("Processing voice input for sessionId: {}", sessionId); + + // 세션 조회 또는 생성 + SpeakingSession session = getOrCreateSession(sessionId, userId, level); + + String targetLevel = session.getTargetLevel(); + + // STT: 음성 → 텍스트 (Transcribe Proxy 사용) + logger.info("Step 1: Transcribing audio..."); + TranscribeProxyService.TranscribeResult sttResult = transcribeService.transcribe( + audioBase64, + session.getSessionId(), + "en-US" + ); + String userText = sttResult.transcript(); + logger.info("Transcription complete: {} (confidence: {})", userText, sttResult.confidence()); + + // 대화 히스토리 로드 + List history = parseHistory(session.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())); + } + session.setConversationHistory(toJson(history)); + sessionRepository.update(session); + + // TTS: 텍스트 → 음성 (Polly 사용) + logger.info("Step 3: Synthesizing speech..."); + String audioId = session.getSessionId() + "_" + System.currentTimeMillis(); + PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( + audioId, + aiResponse, + "FEMALE" + ); + logger.info("Speech synthesis complete: cached={}", ttsResult.isCached()); + + return new SpeakingResponse( + session.getSessionId(), + userText, + aiResponse, + ttsResult.getAudioUrl(), + sttResult.confidence() + ); + } + + /** + * 텍스트 입력 처리 (음성 없이 텍스트만) + */ + public SpeakingResponse processTextInput(String sessionId, String userId, String userText, String level) { + logger.info("Processing text input for sessionId: {}", sessionId); + + // 세션 조회 또는 생성 + SpeakingSession session = getOrCreateSession(sessionId, userId, level); + + // 대화 히스토리 로드 + List history = parseHistory(session.getConversationHistory()); + + // AI 응답 생성 + String aiResponse = generateAiResponse(userText, history, session.getTargetLevel()); + + // 히스토리 업데이트 + 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())); + } + session.setConversationHistory(toJson(history)); + sessionRepository.update(session); + + // TTS 생성 + String audioId = session.getSessionId() + "_" + System.currentTimeMillis(); + PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( + audioId, aiResponse, "FEMALE" + ); + + return new SpeakingResponse( + session.getSessionId(), + userText, + aiResponse, + ttsResult.getAudioUrl(), + 1.0 + ); + } + + /** + * 레벨 변경 + */ + public void updateLevel(String sessionId, String level) { + SpeakingSession session = sessionRepository.findBySessionId(sessionId) + .orElseThrow(() -> new RuntimeException("session not found: " + sessionId)); + + session.setTargetLevel(level.toUpperCase()); + sessionRepository.update(session); + logger.info("Level updated for sessionId {}: {}", sessionId, level); + } + + /** + * 대화 히스토리 초기화 + */ + public void resetConversation(String sessionId) { + SpeakingSession session = sessionRepository.findBySessionId(sessionId) + .orElseThrow(() -> new RuntimeException("session not found: " + sessionId)); + + session.setConversationHistory("[]"); + sessionRepository.update(session); + logger.info("Conversation reset for sessionId: {}", sessionId); + } + + + /** + * 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" -> """ + 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 -> """ + default -> """ - Use moderate vocabulary appropriate for intermediate learners - Mix simple and compound sentences - Introduce useful expressions gradually - Balance challenge with accessibility """; - }; - - return String.format(""" + }; + + 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 @@ -280,59 +278,60 @@ private String buildSystemPrompt(String targetLevel) { - 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(); - } - - /** - * 대화 메시지 (히스토리용) - */ - private record Message(String role, String content) {} - + } + + /** + * 히스토리 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(); + } + + /** + * 대화 메시지 (히스토리용) + */ + private record Message(String role, String content) { + } + } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java index 66edddce..40fd94d7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java @@ -24,20 +24,20 @@ * 사용자 학습 통계 API Handler */ public class UserStatsHandler implements RequestHandler { - + private static final Logger logger = LoggerFactory.getLogger(UserStatsHandler.class); - + private final UserStatsRepository statsRepository; private final DailyStudyRepository dailyStudyRepository; private final HandlerRouter router; - + /** * 기본 생성자 (Lambda에서 사용) */ public UserStatsHandler() { this(new UserStatsRepository(), new DailyStudyRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -46,7 +46,7 @@ public UserStatsHandler(UserStatsRepository statsRepository, DailyStudyRepositor this.dailyStudyRepository = dailyStudyRepository; this.router = initRouter(); } - + private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( Route.getAuth("/stats/dashboard", this::getDashboardStats), @@ -57,20 +57,20 @@ private HandlerRouter initRouter() { Route.getAuth("/stats/history", this::getStatsHistory) ); } - + @Override public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); return router.route(request); } - + /** * 대시보드용 통합 통계 조회 (프론트엔드 요청 형식) * GET /stats/dashboard */ private APIGatewayProxyResponseEvent getDashboardStats(APIGatewayProxyRequestEvent request, String userId) { String today = LocalDate.now().toString(); - + // 오늘 통계 조회 Optional dailyStats = statsRepository.findDailyStats(userId, today); // 전체 통계 조회 @@ -79,9 +79,9 @@ private APIGatewayProxyResponseEvent getDashboardStats(APIGatewayProxyRequestEve PaginatedResult weekHistory = statsRepository.findRecentDailyStats(userId, 7, null); // 오늘 학습 목표 조회 Optional dailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); - + Map response = new HashMap<>(); - + // today 섹션 Map todaySection = new HashMap<>(); if (dailyStats.isPresent()) { @@ -97,7 +97,7 @@ private APIGatewayProxyResponseEvent getDashboardStats(APIGatewayProxyRequestEve } todaySection.put("wordsTotal", dailyStudy.map(ds -> ds.getTotalWords() != null ? ds.getTotalWords() : 25).orElse(25)); response.put("today", todaySection); - + // overall 섹션 Map overallSection = new HashMap<>(); if (totalStats.isPresent()) { @@ -122,7 +122,7 @@ private APIGatewayProxyResponseEvent getDashboardStats(APIGatewayProxyRequestEve // totalStudyDays 계산 (최근 히스토리에서 실제 학습한 날 수) overallSection.put("totalStudyDays", weekHistory.items().size()); response.put("overall", overallSection); - + // weeklyProgress 섹션 List> weeklyProgress = weekHistory.items().stream() .map(stats -> { @@ -134,17 +134,17 @@ private APIGatewayProxyResponseEvent getDashboardStats(APIGatewayProxyRequestEve }) .collect(Collectors.toList()); response.put("weeklyProgress", weeklyProgress); - + // levelDistribution (현재 미구현 - 향후 추가 가능) Map levelDistribution = new HashMap<>(); levelDistribution.put("beginner", 0); levelDistribution.put("intermediate", 0); levelDistribution.put("advanced", 0); response.put("levelDistribution", levelDistribution); - + return ResponseGenerator.ok("학습 통계 조회 성공", response); } - + /** * 오늘의 통계 조회 */ @@ -152,12 +152,12 @@ private APIGatewayProxyResponseEvent getDailyStats(APIGatewayProxyRequestEvent r Map queryParams = request.getQueryStringParameters(); String date = queryParams != null && queryParams.get("date") != null ? queryParams.get("date") : LocalDate.now().toString(); - + Optional stats = statsRepository.findDailyStats(userId, date); - + return ResponseGenerator.ok("Daily stats retrieved", buildStatsResponse(stats, "DAILY", date)); } - + /** * 이번 주 통계 조회 */ @@ -165,12 +165,12 @@ private APIGatewayProxyResponseEvent getWeeklyStats(APIGatewayProxyRequestEvent Map queryParams = request.getQueryStringParameters(); String yearWeek = queryParams != null && queryParams.get("week") != null ? queryParams.get("week") : getCurrentYearWeek(); - + Optional stats = statsRepository.findWeeklyStats(userId, yearWeek); - + return ResponseGenerator.ok("Weekly stats retrieved", buildStatsResponse(stats, "WEEKLY", yearWeek)); } - + /** * 이번 달 통계 조회 */ @@ -178,20 +178,20 @@ private APIGatewayProxyResponseEvent getMonthlyStats(APIGatewayProxyRequestEvent Map queryParams = request.getQueryStringParameters(); String yearMonth = queryParams != null && queryParams.get("month") != null ? queryParams.get("month") : getCurrentYearMonth(); - + Optional stats = statsRepository.findMonthlyStats(userId, yearMonth); - + return ResponseGenerator.ok("Monthly stats retrieved", buildStatsResponse(stats, "MONTHLY", yearMonth)); } - + /** * 전체 통계 조회 */ private APIGatewayProxyResponseEvent getTotalStats(APIGatewayProxyRequestEvent request, String userId) { Optional stats = statsRepository.findTotalStats(userId); - + Map response = buildStatsResponse(stats, "TOTAL", "ALL"); - + // 전체 통계에는 streak 정보 추가 if (stats.isPresent()) { UserStats s = stats.get(); @@ -203,24 +203,24 @@ private APIGatewayProxyResponseEvent getTotalStats(APIGatewayProxyRequestEvent r response.put("longestStreak", 0); response.put("lastStudyDate", null); } - + return ResponseGenerator.ok("Total stats retrieved", response); } - + /** * 최근 일별 통계 히스토리 조회 */ private APIGatewayProxyResponseEvent getStatsHistory(APIGatewayProxyRequestEvent request, String userId) { Map queryParams = request.getQueryStringParameters(); String cursor = queryParams != null ? queryParams.get("cursor") : null; - + int limit = 7; // 기본 7일 if (queryParams != null && queryParams.get("limit") != null) { limit = Math.min(Integer.parseInt(queryParams.get("limit")), 100); } - + PaginatedResult result = statsRepository.findRecentDailyStats(userId, limit, cursor); - + // 각 날짜별 isCompleted 정보 조회 및 응답 구성 List> historyWithCompletion = result.items().stream() .map(stats -> { @@ -233,28 +233,28 @@ private APIGatewayProxyResponseEvent getStatsHistory(APIGatewayProxyRequestEvent item.put("successRate", calculateSuccessRate(stats)); item.put("newWordsLearned", stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0); item.put("wordsReviewed", stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0); - + // DailyStudy에서 isCompleted 조회 Optional dailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, stats.getPeriod()); item.put("isCompleted", dailyStudy.map(ds -> ds.getIsCompleted() != null && ds.getIsCompleted()).orElse(false)); - + return item; }) .collect(Collectors.toList()); - + Map response = new HashMap<>(); response.put("history", historyWithCompletion); response.put("nextCursor", result.nextCursor()); response.put("hasMore", result.hasMore()); - + return ResponseGenerator.ok("Stats history retrieved", response); } - + private Map buildStatsResponse(Optional stats, String periodType, String period) { Map response = new HashMap<>(); response.put("periodType", periodType); response.put("period", period); - + if (stats.isPresent()) { UserStats s = stats.get(); response.put("testsCompleted", s.getTestsCompleted() != null ? s.getTestsCompleted() : 0); @@ -283,16 +283,16 @@ private Map buildStatsResponse(Optional stats, String response.put("newsQuizPerfect", 0); response.put("newsWordsCollected", 0); } - + return response; } - + private double calculateSuccessRate(UserStats stats) { int correct = stats.getCorrectAnswers() != null ? stats.getCorrectAnswers() : 0; int total = stats.getQuestionsAnswered() != null ? stats.getQuestionsAnswered() : 0; return total > 0 ? (correct * 100.0 / total) : 0.0; } - + private String getCurrentYearWeek() { LocalDate now = LocalDate.now(); WeekFields weekFields = WeekFields.of(Locale.getDefault()); @@ -300,7 +300,7 @@ private String getCurrentYearWeek() { int year = now.get(weekFields.weekBasedYear()); return String.format("%d-W%02d", year, week); } - + private String getCurrentYearMonth() { LocalDate now = LocalDate.now(); return String.format("%d-%02d", now.getYear(), now.getMonthValue()); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java index 3c268897..1955d429 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java @@ -24,31 +24,31 @@ @AllArgsConstructor @DynamoDbBean public class UserStats { - + private String pk; // USER#{userId}#STATS private String sk; // DAILY#{date} / WEEKLY#{year}-W{week} / MONTHLY#{year}-{month} / TOTAL - + private String userId; private String periodType; // DAILY, WEEKLY, MONTHLY, TOTAL private String period; // 2026-01-13, 2026-W02, 2026-01, TOTAL - + // 테스트 통계 private Integer testsCompleted; // 완료한 테스트 수 private Integer questionsAnswered; // 답변한 문제 수 private Integer correctAnswers; // 정답 수 private Integer incorrectAnswers; // 오답 수 private Double successRate; // 정답률 - + // 학습 통계 private Integer newWordsLearned; // 새로 학습한 단어 수 private Integer wordsReviewed; // 복습한 단어 수 private Integer wordsMastered; // 마스터한 단어 수 - + // Streak (연속 학습) private Integer currentStreak; // 현재 연속 학습일 private Integer longestStreak; // 최장 연속 학습일 private String lastStudyDate; // 마지막 학습일 - + // 게임 통계 private Integer gamesPlayed; // 참여한 게임 수 private Integer gamesWon; // 1등 횟수 @@ -56,7 +56,7 @@ public class UserStats { private Integer totalGameScore; // 누적 게임 점수 private Integer quickGuesses; // 5초 내 정답 횟수 private Integer perfectDraws; // 전원 정답 유도 횟수 - + // 뉴스 통계 private Integer newsRead; // 읽은 뉴스 수 private Integer newsQuizCompleted; // 완료한 뉴스 퀴즈 수 @@ -64,17 +64,17 @@ public class UserStats { private Integer newsWordsCollected; // 뉴스에서 수집한 단어 수 private Integer newsStreak; // 뉴스 연속 읽기 일수 private String lastNewsReadDate; // 마지막 뉴스 읽은 날짜 - + // 메타데이터 private String createdAt; private String updatedAt; - + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { return pk; } - + @DynamoDbSortKey @DynamoDbAttribute("SK") public String getSk() { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java index 89e9b385..86b90969 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java @@ -28,26 +28,26 @@ * Atomic Counter 패턴을 사용하여 Scan 없이 통계 업데이트 */ public class UserStatsRepository { - + private static final Logger logger = LoggerFactory.getLogger(UserStatsRepository.class); private static final String TABLE_NAME = EnvConfig.getRequired("VOCAB_TABLE_NAME"); - + private final DynamoDbTable table; - + /** * 기본 생성자 (Lambda에서 사용) */ public UserStatsRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ public UserStatsRepository(DynamoDbEnhancedClient enhancedClient) { this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(UserStats.class)); } - + /** * 특정 기간의 통계 조회 */ @@ -56,39 +56,39 @@ public Optional findByUserIdAndPeriod(String userId, String sk) { .partitionValue(StatsKey.userStatsPk(userId)) .sortValue(sk) .build(); - + UserStats stats = table.getItem(key); return Optional.ofNullable(stats); } - + /** * 일별 통계 조회 */ public Optional findDailyStats(String userId, String date) { return findByUserIdAndPeriod(userId, StatsKey.statsDailySk(date)); } - + /** * 주별 통계 조회 */ public Optional findWeeklyStats(String userId, String yearWeek) { return findByUserIdAndPeriod(userId, StatsKey.statsWeeklySk(yearWeek)); } - + /** * 월별 통계 조회 */ public Optional findMonthlyStats(String userId, String yearMonth) { return findByUserIdAndPeriod(userId, StatsKey.statsMonthlySk(yearMonth)); } - + /** * 전체 통계 조회 */ public Optional findTotalStats(String userId) { return findByUserIdAndPeriod(userId, StatsKey.statsTotalSk()); } - + /** * 최근 N일 일별 통계 조회 */ @@ -98,25 +98,25 @@ public PaginatedResult findRecentDailyStats(String userId, int limit, .partitionValue(StatsKey.userStatsPk(userId)) .sortValue(StatsKey.STATS_DAILY) .build()); - + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) // 최신순 .limit(limit); - + if (cursor != null && !cursor.isEmpty()) { Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } } - + Page page = table.query(requestBuilder.build()).iterator().next(); String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - + return new PaginatedResult<>(page.items(), nextCursor); } - + /** * 테스트 결과 통계 Atomic 업데이트 * 일/주/월/전체 통계를 한 번에 업데이트 @@ -125,31 +125,31 @@ public void incrementTestStats(String userId, int correctAnswers, int incorrectA String today = LocalDate.now().toString(); String yearWeek = getYearWeek(); String yearMonth = getYearMonth(); - + List sortKeys = List.of( StatsKey.statsDailySk(today), StatsKey.statsWeeklySk(yearWeek), StatsKey.statsMonthlySk(yearMonth), StatsKey.statsTotalSk() ); - + String pk = StatsKey.userStatsPk(userId); String now = Instant.now().toString(); int totalQuestions = correctAnswers + incorrectAnswers; - + for (String sk : sortKeys) { updateTestStats(pk, sk, correctAnswers, incorrectAnswers, totalQuestions, now); } - + logger.info("Incremented test stats: userId={}, correct={}, incorrect={}", userId, correctAnswers, incorrectAnswers); } - + private void updateTestStats(String pk, String sk, int correct, int incorrect, int total, String now) { Map key = new HashMap<>(); key.put("PK", AttributeValue.builder().s(pk).build()); key.put("SK", AttributeValue.builder().s(sk).build()); - + Map values = new HashMap<>(); values.put(":correct", AttributeValue.builder().n(String.valueOf(correct)).build()); values.put(":incorrect", AttributeValue.builder().n(String.valueOf(incorrect)).build()); @@ -157,7 +157,7 @@ private void updateTestStats(String pk, String sk, int correct, int incorrect, i values.put(":one", AttributeValue.builder().n("1").build()); values.put(":zero", AttributeValue.builder().n("0").build()); values.put(":now", AttributeValue.builder().s(now).build()); - + String updateExpression = "SET " + "correctAnswers = if_not_exists(correctAnswers, :zero) + :correct, " + "incorrectAnswers = if_not_exists(incorrectAnswers, :zero) + :incorrect, " + @@ -165,17 +165,17 @@ private void updateTestStats(String pk, String sk, int correct, int incorrect, i "testsCompleted = if_not_exists(testsCompleted, :zero) + :one, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now)"; - + UpdateItemRequest request = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression(updateExpression) .expressionAttributeValues(values) .build(); - + AwsClients.dynamoDb().updateItem(request); } - + /** * 학습 완료 단어 수 Atomic 업데이트 */ @@ -183,52 +183,52 @@ public void incrementWordsLearned(String userId, int newWords, int reviewedWords String today = LocalDate.now().toString(); String yearWeek = getYearWeek(); String yearMonth = getYearMonth(); - + List sortKeys = List.of( StatsKey.statsDailySk(today), StatsKey.statsWeeklySk(yearWeek), StatsKey.statsMonthlySk(yearMonth), StatsKey.statsTotalSk() ); - + String pk = StatsKey.userStatsPk(userId); String now = Instant.now().toString(); - + for (String sk : sortKeys) { updateWordsLearned(pk, sk, newWords, reviewedWords, now); } - + logger.info("Incremented words learned: userId={}, new={}, reviewed={}", userId, newWords, reviewedWords); } - + private void updateWordsLearned(String pk, String sk, int newWords, int reviewedWords, String now) { Map key = new HashMap<>(); key.put("PK", AttributeValue.builder().s(pk).build()); key.put("SK", AttributeValue.builder().s(sk).build()); - + Map values = new HashMap<>(); values.put(":new", AttributeValue.builder().n(String.valueOf(newWords)).build()); values.put(":reviewed", AttributeValue.builder().n(String.valueOf(reviewedWords)).build()); values.put(":zero", AttributeValue.builder().n("0").build()); values.put(":now", AttributeValue.builder().s(now).build()); - + String updateExpression = "SET " + "newWordsLearned = if_not_exists(newWordsLearned, :zero) + :new, " + "wordsReviewed = if_not_exists(wordsReviewed, :zero) + :reviewed, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now)"; - + UpdateItemRequest request = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression(updateExpression) .expressionAttributeValues(values) .build(); - + AwsClients.dynamoDb().updateItem(request); } - + /** * Streak(연속 학습일) 업데이트 */ @@ -236,35 +236,35 @@ public void updateStreak(String userId, int currentStreak, int longestStreak, St String pk = StatsKey.userStatsPk(userId); String sk = StatsKey.statsTotalSk(); String now = Instant.now().toString(); - + Map key = new HashMap<>(); key.put("PK", AttributeValue.builder().s(pk).build()); key.put("SK", AttributeValue.builder().s(sk).build()); - + Map values = new HashMap<>(); values.put(":current", AttributeValue.builder().n(String.valueOf(currentStreak)).build()); values.put(":longest", AttributeValue.builder().n(String.valueOf(longestStreak)).build()); values.put(":lastDate", AttributeValue.builder().s(lastStudyDate).build()); values.put(":now", AttributeValue.builder().s(now).build()); - + String updateExpression = "SET " + "currentStreak = :current, " + "longestStreak = :longest, " + "lastStudyDate = :lastDate, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now)"; - + UpdateItemRequest request = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression(updateExpression) .expressionAttributeValues(values) .build(); - + AwsClients.dynamoDb().updateItem(request); logger.info("Updated streak: userId={}, current={}, longest={}", userId, currentStreak, longestStreak); } - + /** * 게임 통계 Atomic 업데이트 */ @@ -273,11 +273,11 @@ public void incrementGameStats(String userId, int gamesPlayed, int gamesWon, String pk = StatsKey.userStatsPk(userId); String sk = StatsKey.statsTotalSk(); String now = Instant.now().toString(); - + Map key = new HashMap<>(); key.put("PK", AttributeValue.builder().s(pk).build()); key.put("SK", AttributeValue.builder().s(sk).build()); - + Map values = new HashMap<>(); values.put(":gamesPlayed", AttributeValue.builder().n(String.valueOf(gamesPlayed)).build()); values.put(":gamesWon", AttributeValue.builder().n(String.valueOf(gamesWon)).build()); @@ -287,7 +287,7 @@ public void incrementGameStats(String userId, int gamesPlayed, int gamesWon, values.put(":perfectDraws", AttributeValue.builder().n(String.valueOf(perfectDraws)).build()); values.put(":zero", AttributeValue.builder().n("0").build()); values.put(":now", AttributeValue.builder().s(now).build()); - + String updateExpression = "SET " + "gamesPlayed = if_not_exists(gamesPlayed, :zero) + :gamesPlayed, " + "gamesWon = if_not_exists(gamesWon, :zero) + :gamesWon, " + @@ -297,19 +297,19 @@ public void incrementGameStats(String userId, int gamesPlayed, int gamesWon, "perfectDraws = if_not_exists(perfectDraws, :zero) + :perfectDraws, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now)"; - + UpdateItemRequest request = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression(updateExpression) .expressionAttributeValues(values) .build(); - + AwsClients.dynamoDb().updateItem(request); logger.info("Incremented game stats: userId={}, gamesPlayed={}, gamesWon={}, correctGuesses={}", userId, gamesPlayed, gamesWon, correctGuesses); } - + /** * 뉴스 읽기 통계 Atomic 업데이트 (TOTAL + DAILY) */ @@ -317,11 +317,11 @@ public UserStats incrementNewsReadStats(String userId) { String today = LocalDate.now().toString(); String pk = StatsKey.userStatsPk(userId); String now = Instant.now().toString(); - + // 먼저 현재 통계 조회 (streak 계산용) UserStats currentStats = findTotalStats(userId).orElse(null); String lastNewsReadDate = currentStats != null ? currentStats.getLastNewsReadDate() : null; - + // 연속 읽기 계산 int currentStreak = 1; if (lastNewsReadDate != null) { @@ -336,26 +336,26 @@ public UserStats incrementNewsReadStats(String userId) { } // 그 외의 경우는 streak 1로 초기화 } - + Map values = new HashMap<>(); values.put(":one", AttributeValue.builder().n("1").build()); values.put(":zero", AttributeValue.builder().n("0").build()); values.put(":streak", AttributeValue.builder().n(String.valueOf(currentStreak)).build()); values.put(":today", AttributeValue.builder().s(today).build()); values.put(":now", AttributeValue.builder().s(now).build()); - + // 1. TOTAL 통계 업데이트 Map totalKey = new HashMap<>(); totalKey.put("PK", AttributeValue.builder().s(pk).build()); totalKey.put("SK", AttributeValue.builder().s(StatsKey.statsTotalSk()).build()); - + String totalUpdateExpression = "SET " + "newsRead = if_not_exists(newsRead, :zero) + :one, " + "newsStreak = :streak, " + "lastNewsReadDate = :today, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now)"; - + UpdateItemRequest totalRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(totalKey) @@ -363,39 +363,39 @@ public UserStats incrementNewsReadStats(String userId) { .expressionAttributeValues(values) .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) .build(); - + AwsClients.dynamoDb().updateItem(totalRequest); - + // 2. DAILY 통계 업데이트 Map dailyKey = new HashMap<>(); dailyKey.put("PK", AttributeValue.builder().s(pk).build()); dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); - + Map dailyValues = new HashMap<>(); dailyValues.put(":one", AttributeValue.builder().n("1").build()); dailyValues.put(":zero", AttributeValue.builder().n("0").build()); dailyValues.put(":now", AttributeValue.builder().s(now).build()); dailyValues.put(":today", AttributeValue.builder().s(today).build()); - + String dailyUpdateExpression = "SET " + "newsRead = if_not_exists(newsRead, :zero) + :one, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now), " + "period = if_not_exists(period, :today)"; - + UpdateItemRequest dailyRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(dailyKey) .updateExpression(dailyUpdateExpression) .expressionAttributeValues(dailyValues) .build(); - + AwsClients.dynamoDb().updateItem(dailyRequest); logger.info("Incremented news read stats (TOTAL + DAILY): userId={}, streak={}", userId, currentStreak); - + return findTotalStats(userId).orElse(null); } - + /** * 뉴스 퀴즈 통계 Atomic 업데이트 (TOTAL + DAILY) */ @@ -403,24 +403,24 @@ public UserStats incrementNewsQuizStats(String userId, boolean isPerfect) { String today = LocalDate.now().toString(); String pk = StatsKey.userStatsPk(userId); String now = Instant.now().toString(); - + Map values = new HashMap<>(); values.put(":one", AttributeValue.builder().n("1").build()); values.put(":zero", AttributeValue.builder().n("0").build()); values.put(":perfect", AttributeValue.builder().n(isPerfect ? "1" : "0").build()); values.put(":now", AttributeValue.builder().s(now).build()); - + // 1. TOTAL 통계 업데이트 Map totalKey = new HashMap<>(); totalKey.put("PK", AttributeValue.builder().s(pk).build()); totalKey.put("SK", AttributeValue.builder().s(StatsKey.statsTotalSk()).build()); - + String totalUpdateExpression = "SET " + "newsQuizCompleted = if_not_exists(newsQuizCompleted, :zero) + :one, " + "newsQuizPerfect = if_not_exists(newsQuizPerfect, :zero) + :perfect, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now)"; - + UpdateItemRequest totalRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(totalKey) @@ -428,41 +428,41 @@ public UserStats incrementNewsQuizStats(String userId, boolean isPerfect) { .expressionAttributeValues(values) .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) .build(); - + AwsClients.dynamoDb().updateItem(totalRequest); - + // 2. DAILY 통계 업데이트 Map dailyKey = new HashMap<>(); dailyKey.put("PK", AttributeValue.builder().s(pk).build()); dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); - + Map dailyValues = new HashMap<>(); dailyValues.put(":one", AttributeValue.builder().n("1").build()); dailyValues.put(":zero", AttributeValue.builder().n("0").build()); dailyValues.put(":perfect", AttributeValue.builder().n(isPerfect ? "1" : "0").build()); dailyValues.put(":now", AttributeValue.builder().s(now).build()); dailyValues.put(":today", AttributeValue.builder().s(today).build()); - + String dailyUpdateExpression = "SET " + "newsQuizCompleted = if_not_exists(newsQuizCompleted, :zero) + :one, " + "newsQuizPerfect = if_not_exists(newsQuizPerfect, :zero) + :perfect, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now), " + "period = if_not_exists(period, :today)"; - + UpdateItemRequest dailyRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(dailyKey) .updateExpression(dailyUpdateExpression) .expressionAttributeValues(dailyValues) .build(); - + AwsClients.dynamoDb().updateItem(dailyRequest); logger.info("Incremented news quiz stats (TOTAL + DAILY): userId={}, isPerfect={}", userId, isPerfect); - + return findTotalStats(userId).orElse(null); } - + /** * 뉴스 단어 수집 통계 Atomic 업데이트 (TOTAL + DAILY) */ @@ -470,22 +470,22 @@ public UserStats incrementNewsWordStats(String userId, int wordCount) { String today = LocalDate.now().toString(); String pk = StatsKey.userStatsPk(userId); String now = Instant.now().toString(); - + Map values = new HashMap<>(); values.put(":count", AttributeValue.builder().n(String.valueOf(wordCount)).build()); values.put(":zero", AttributeValue.builder().n("0").build()); values.put(":now", AttributeValue.builder().s(now).build()); - + // 1. TOTAL 통계 업데이트 Map totalKey = new HashMap<>(); totalKey.put("PK", AttributeValue.builder().s(pk).build()); totalKey.put("SK", AttributeValue.builder().s(StatsKey.statsTotalSk()).build()); - + String totalUpdateExpression = "SET " + "newsWordsCollected = if_not_exists(newsWordsCollected, :zero) + :count, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now)"; - + UpdateItemRequest totalRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(totalKey) @@ -493,39 +493,39 @@ public UserStats incrementNewsWordStats(String userId, int wordCount) { .expressionAttributeValues(values) .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) .build(); - + AwsClients.dynamoDb().updateItem(totalRequest); - + // 2. DAILY 통계 업데이트 Map dailyKey = new HashMap<>(); dailyKey.put("PK", AttributeValue.builder().s(pk).build()); dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); - + Map dailyValues = new HashMap<>(); dailyValues.put(":count", AttributeValue.builder().n(String.valueOf(wordCount)).build()); dailyValues.put(":zero", AttributeValue.builder().n("0").build()); dailyValues.put(":now", AttributeValue.builder().s(now).build()); dailyValues.put(":today", AttributeValue.builder().s(today).build()); - + String dailyUpdateExpression = "SET " + "newsWordsCollected = if_not_exists(newsWordsCollected, :zero) + :count, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now), " + "period = if_not_exists(period, :today)"; - + UpdateItemRequest dailyRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(dailyKey) .updateExpression(dailyUpdateExpression) .expressionAttributeValues(dailyValues) .build(); - + AwsClients.dynamoDb().updateItem(dailyRequest); logger.info("Incremented news word stats (TOTAL + DAILY): userId={}, wordCount={}", userId, wordCount); - + return findTotalStats(userId).orElse(null); } - + /** * 현재 연도-주차 반환 (예: 2026-W02) */ @@ -536,7 +536,7 @@ private String getYearWeek() { int year = now.get(weekFields.weekBasedYear()); return String.format("%d-W%02d", year, week); } - + /** * 현재 연도-월 반환 (예: 2026-01) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java index 74a601db..c43d41e5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java @@ -21,18 +21,17 @@ public class PostConfirmationHandler implements RequestHandler, Map private static final Logger logger = LoggerFactory.getLogger(PreSignUpHandler.class); private static final String BUCKET_NAME = System.getenv("BUCKET_NAME"); private static final String DEFAULT_PROFILE_URL = getDefaultProfileUrl(); - + private static String getDefaultProfileUrl() { String envUrl = System.getenv("DEFAULT_PROFILE_URL"); if (envUrl != null && !envUrl.isEmpty()) { @@ -22,30 +22,30 @@ private static String getDefaultProfileUrl() { String bucket = BUCKET_NAME != null ? BUCKET_NAME : "group2-englishstudy"; return String.format("https://%s.s3.amazonaws.com/profile/default.png", bucket); } - + @Override public Map handleRequest(Map input, Context context) { - + try { @SuppressWarnings("unchecked") Map request = (Map) input.get("request"); - + @SuppressWarnings("unchecked") Map userAttributes = (Map) request.get("userAttributes"); - + String nickname = userAttributes.get("nickname"); if (nickname == null || nickname.trim().isEmpty()) { String defaultNickname = UUID.randomUUID().toString().substring(0, 6).toUpperCase() + "님"; userAttributes.put("nickname", defaultNickname); logger.info("nickname 기본값: {}", defaultNickname); } - + String level = userAttributes.get("custom:level"); if (level == null || level.trim().isEmpty()) { userAttributes.put("custom:level", "BEGINNER"); logger.info("level 선택 기본값: BEGINNER"); } - + String profileUrl = userAttributes.get("custom:profileUrl"); if (profileUrl == null || profileUrl.trim().isEmpty()) { userAttributes.put("custom:profileUrl", DEFAULT_PROFILE_URL); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java index 9ad8618f..c2f7b41c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java @@ -60,10 +60,10 @@ private APIGatewayProxyResponseEvent getMyProfile( String userId // cognitoSub ) { User user = userService.getProfile(userId, request); - + // profileUrl을 Presigned URL로 변환 String presignedUrl = userService.getPresignedProfileUrl(user.getProfileUrl()); - + ProfileResponse response = ProfileResponse.builder() .userId(user.getCognitoSub()) .email(user.getEmail()) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java index 60dc6be5..218c7eb9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java @@ -32,8 +32,8 @@ public class User { private String updatedAt; private String lastLoginAt; private Long ttl; - - + + /** * 신규 사용자 생성 * - Lazy Registration 적용: 최초 프로필 조회 시 DynamoDB에 저장 @@ -116,12 +116,12 @@ public void updateProfileUrl(String newProfileUrl) { this.profileUrl = newProfileUrl; this.updatedAt = Instant.now().toString(); } - + @DynamoDbIgnore public String getProfileUrlForResponse() { return profileUrlForResponse != null ? profileUrlForResponse : profileUrl; } - + public void updateLastLoginAt() { this.lastLoginAt = Instant.now().toString(); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java index 6783c42b..4f635106 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java @@ -25,26 +25,23 @@ public class UserService { private static final Logger logger = LoggerFactory.getLogger(UserService.class); private static final String BUCKET_NAME = System.getenv("PROFILE_BUCKET_NAME"); private static final String DEFAULT_PROFILE_URL = getDefaultProfileUrl(); - - private static String getDefaultProfileUrl() { - String bucket = BUCKET_NAME != null ? BUCKET_NAME : "group2-englishstudy"; - return String.format("https://%s.s3.amazonaws.com/profile/default.png", bucket); - } private static final List VALID_LEVELS = Arrays.asList("BEGINNER", "INTERMEDIATE", "ADVANCED"); - private static final List VALID_IMAGE_TYPES = Arrays.asList("image/jpeg", "image/png", "image/gif", "image/webp"); private static final int NICKNAME_MIN_LENGTH = 2; private static final int NICKNAME_MAX_LENGTH = 20; - private final UserRepository userRepository; private final S3Presigner s3Presigner; - public UserService(UserRepository userRepository) { this.userRepository = userRepository; // AwsClients 싱글톤 사용 - Cold Start 최적화 this.s3Presigner = AwsClients.s3Presigner(); } + private static String getDefaultProfileUrl() { + String bucket = BUCKET_NAME != null ? BUCKET_NAME : "group2-englishstudy"; + return String.format("https://%s.s3.amazonaws.com/profile/default.png", bucket); + } + /** * 프로필 조회 * DynamoDB에 없으면 request에서 claims 추출 → fallback 저장 @@ -54,7 +51,7 @@ public UserService(UserRepository userRepository) { * @return User 객체 */ public User getProfile(String userId, APIGatewayProxyRequestEvent request) { - + User user = userRepository.findByCognitoSub(userId) .map(u -> { u.updateLastLoginAt(); @@ -62,14 +59,14 @@ public User getProfile(String userId, APIGatewayProxyRequestEvent request) { return u; }) .orElseGet(() -> createUserFromRequest(userId, request)); - + // 프로필 URL을 Presigned URL로 변환 String presignedProfileUrl = getPresignedProfileUrl(user.getProfileUrl()); user.setProfileUrlForResponse(presignedProfileUrl); // 응답용으로만 설정 - + return user; } - + public String getPresignedProfileUrl(String s3Url) { if (s3Url == null || s3Url.isEmpty()) { return generateGetPresignedUrl("profile/default.png"); @@ -77,22 +74,22 @@ public String getPresignedProfileUrl(String s3Url) { String key = extractKeyFromS3Url(s3Url); return generateGetPresignedUrl(key); } - + private String generateGetPresignedUrl(String imageKey) { GetObjectRequest getObjectRequest = GetObjectRequest.builder() .bucket(BUCKET_NAME) .key(imageKey) .build(); - + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() .signatureDuration(Duration.ofHours(24)) .getObjectRequest(getObjectRequest) .build(); - + return s3Presigner.presignGetObject(presignRequest).url().toString(); } - - + + private String extractKeyFromS3Url(String s3Url) { // https://group2-englishstudy.s3.amazonaws.com/profile/user123/img.png // → profile/user123/img.png diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java index 8cb93c80..84aca50a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java @@ -66,18 +66,18 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent request, String userId) { Map queryParams = request.getQueryStringParameters(); - + String status = queryParams != null ? queryParams.get("status") : null; String cursor = queryParams != null ? queryParams.get("cursor") : null; String bookmarked = queryParams != null ? queryParams.get("bookmarked") : null; String incorrectOnly = queryParams != null ? queryParams.get("incorrectOnly") : null; String category = queryParams != null ? queryParams.get("category") : null; - + int limit = 20; if (queryParams != null && queryParams.get("limit") != null) { limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); } - + UserWordQueryService.UserWordsResult result = queryService.getUserWords(userId, status, bookmarked, incorrectOnly, category, limit, cursor); Map response = new HashMap<>(); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java index 6b862566..a606d5f7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java @@ -39,7 +39,7 @@ public UserWordQueryService(UserWordRepository userWordRepository, WordRepositor public UserWordsResult getUserWords(String userId, String status, String bookmarked, String incorrectOnly, String category, int limit, String cursor) { PaginatedResult userWordPage; - + if ("true".equalsIgnoreCase(bookmarked)) { userWordPage = userWordRepository.findBookmarkedWords(userId, limit * 3, cursor); } else if ("true".equalsIgnoreCase(incorrectOnly)) { @@ -49,9 +49,9 @@ public UserWordsResult getUserWords(String userId, String status, String bookmar } else { userWordPage = userWordRepository.findByUserIdWithPagination(userId, limit * 3, cursor); } - + List> enrichedUserWords = enrichWithWordInfo(userWordPage.items()); - + // 카테고리 필터링 (Word 테이블 조인 후 필터) if (category != null && !category.isEmpty()) { String upperCategory = category.toUpperCase(); @@ -64,7 +64,7 @@ public UserWordsResult getUserWords(String userId, String status, String bookmar .limit(limit) .collect(Collectors.toList()); } - + return new UserWordsResult(enrichedUserWords, userWordPage.nextCursor(), userWordPage.hasMore()); }