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/52] =?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/52] =?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/52] =?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/52] =?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/52] =?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/52] =?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/52] =?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/52] =?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/52] =?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/52] =?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/52] =?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/52] =?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/52] =?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/52] =?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/52] =?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/52] =?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/52] =?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/52] =?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/52] =?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/52] 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/52] 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/52] =?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/52] =?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/52] 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/52] =?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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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 8296952b74f648ea6550ed5dcf7e8f567b442621 Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 10:50:53 +0900 Subject: [PATCH 35/52] =?UTF-8?q?feat=20:=20WebSocket=20Connect,=20Disconn?= =?UTF-8?q?ect=20=ED=95=B8=EB=93=A4=EB=9F=AC=20&=20SpeakingConnecion=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websocket/SpeakingConnectHandler.java | 88 +++++++++++++++++++ .../websocket/SpeakingDisconnectHandler.java | 44 ++++++++++ .../speaking/model/SpeakingConnection.java | 84 ++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java new file mode 100644 index 00000000..535a3fc1 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java @@ -0,0 +1,88 @@ +package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.mzc.secondproject.serverless.common.config.WebSocketConfig; +import com.mzc.secondproject.serverless.common.util.JwtUtil; +import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; +import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingConnection; +import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.Optional; + +/** + * Speaking WebSocket $connect 핸들러 + * JWT 토큰 검증 후 연결 정보를 DynamoDB에 저장 + * + * 연결 방법: + * wss://{api-id}.execute-api.{region}.amazonaws.com/{stage}?token={jwt} + */ +public class SpeakingConnectHandler implements RequestHandler, Map> { + + private static final Logger logger = LoggerFactory.getLogger(SpeakingConnectHandler.class); + + private final SpeakingConnectionRepository connectionRepository; + + public SpeakingConnectHandler() { + this.connectionRepository = new SpeakingConnectionRepository(); + } + + @Override + public Map handleRequest(Map event, Context context) { + logger.info("Speaking WebSocket connect event"); + + try { + String connectionId = WebSocketEventUtil.extractConnectionId(event); + Map queryParams = WebSocketEventUtil.extractQueryStringParameters(event); + + // JWT 토큰 검증 + String token = queryParams.get("token"); + + if (token == null || token.isEmpty()) { + logger.warn("Missing token parameter"); + return WebSocketEventUtil.unauthorized("token is required"); + } + + // 토큰 유효성 검사 + if (!JwtUtil.isValid(token)) { + logger.warn("Invalid or expired token"); + return WebSocketEventUtil.unauthorized("Invalid or expired token"); + } + + // userId 추출 + Optional userIdOpt = JwtUtil.extractUserId(token); + if (userIdOpt.isEmpty()) { + logger.warn("Failed to extract userId from token"); + return WebSocketEventUtil.unauthorized("Invalid token"); + } + + String userId = userIdOpt.get(); + + // 연결 정보 저장 + SpeakingConnection connection = SpeakingConnection.create( + connectionId, + userId, + WebSocketConfig.connectionTtlSeconds() + ); + + // 레벨 파라미터가 있으면 설정 + String level = queryParams.get("level"); + if (level != null && !level.isEmpty()) { + connection.setTargetLevel(level.toUpperCase()); + } + + connectionRepository.save(connection); + + logger.info("Speaking connection established: connectionId={}, userId={}, level={}", + connectionId, userId, connection.getTargetLevel()); + return WebSocketEventUtil.ok("Connected"); + + } catch (Exception e) { + logger.error("Error handling connect: {}", e.getMessage(), e); + return WebSocketEventUtil.serverError("Internal server error"); + } + } +} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java new file mode 100644 index 00000000..4d82a9d1 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java @@ -0,0 +1,44 @@ +package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; +import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; + +/** + * Speaking WebSocket $disconnect 핸들러 + * 연결 해제 시 DynamoDB에서 연결 정보 삭제 + */ +public class SpeakingDisconnectHandler implements RequestHandler, Map> { + + private static final Logger logger = LoggerFactory.getLogger(SpeakingDisconnectHandler.class); + + private final SpeakingConnectionRepository connectionRepository; + + public SpeakingDisconnectHandler() { + this.connectionRepository = new SpeakingConnectionRepository(); + } + + @Override + public Map handleRequest(Map event, Context context) { + logger.info("Speaking WebSocket disconnect event"); + + try { + String connectionId = WebSocketEventUtil.extractConnectionId(event); + + // 연결 정보 삭제 + connectionRepository.delete(connectionId); + + logger.info("Speaking connection closed: connectionId={}", connectionId); + return WebSocketEventUtil.ok("Disconnected"); + + } catch (Exception e) { + logger.error("Error handling disconnect: {}", e.getMessage(), e); + return WebSocketEventUtil.serverError("Internal server error"); + } + } +} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java new file mode 100644 index 00000000..6ea0c185 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java @@ -0,0 +1,84 @@ +package com.mzc.secondproject.serverless.domain.speaking.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +/** + * Speaking WebSocket 연결 정보 + * connectionId ↔ userId 매핑 + 대화 히스토리 저장 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class SpeakingConnection { + + // DynamoDB Key Prefixes + public static final String PK_PREFIX = "SPEAKING_CONN#"; + public static final String SK_METADATA = "METADATA"; + public static final String GSI1PK_PREFIX = "SPEAKING_USER#"; + public static final String GSI1SK_PREFIX = "CONN#"; + + private String pk; // SPEAKING_CONN#{connectionId} + private String sk; // METADATA + private String gsi1pk; // SPEAKING_USER#{userId} + private String gsi1sk; // CONN#{connectionId} + + private String connectionId; + private String userId; + private String connectedAt; + private Long ttl; // 자동 삭제용 + + // Speaking 전용 필드 + private String conversationHistory; // 대화 히스토리 (JSON) + private String targetLevel; // 목표 레벨 (BEGINNER, INTERMEDIATE, ADVANCED) + + /** + * 연결 정보 생성 팩토리 메서드 + */ + public static SpeakingConnection create(String connectionId, String userId, long ttlSeconds) { + String now = java.time.Instant.now().toString(); + long ttl = java.time.Instant.now().plusSeconds(ttlSeconds).getEpochSecond(); + + return SpeakingConnection.builder() + .pk(PK_PREFIX + connectionId) + .sk(SK_METADATA) + .gsi1pk(GSI1PK_PREFIX + userId) + .gsi1sk(GSI1SK_PREFIX + connectionId) + .connectionId(connectionId) + .userId(userId) + .connectedAt(now) + .ttl(ttl) + .conversationHistory("[]") // 빈 배열로 초기화 + .targetLevel("INTERMEDIATE") // 기본값 + .build(); + } + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } +} \ No newline at end of file From fc6f9e6d8a298ae7d9b1f7154e45303bce592b2f Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 10:51:44 +0900 Subject: [PATCH 36/52] =?UTF-8?q?feat=20:=20WebSocket=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EC=B2=98=EB=A6=AC=20handler,=20service=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websocket/SpeakingMessageHandler.java | 217 ++++++++++++ .../speaking/service/SpeakingService.java | 317 ++++++++++++++++++ 2 files changed, 534 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java new file mode 100644 index 00000000..89ec0a44 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java @@ -0,0 +1,217 @@ +package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; +import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; +import com.mzc.secondproject.serverless.domain.speaking.service.SpeakingService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.services.apigatewaymanagementapi.ApiGatewayManagementApiClient; +import software.amazon.awssdk.services.apigatewaymanagementapi.model.PostToConnectionRequest; + +import java.net.URI; +import java.util.Map; + +/** + * Speaking WebSocket 메시지 핸들러 + * + * 지원하는 action: + * - speak: 음성 입력 처리 (audio base64) + * - text: 텍스트 입력 처리 + * - setLevel: 레벨 변경 + * - reset: 대화 히스토리 초기화 + */ +public class SpeakingMessageHandler implements RequestHandler, Map> { + + private static final Logger logger = LoggerFactory.getLogger(SpeakingMessageHandler.class); + private static final Gson gson = new GsonBuilder().create(); + + private final SpeakingService speakingService; + private final SpeakingConnectionRepository connectionRepository; + + public SpeakingMessageHandler() { + this.speakingService = new SpeakingService(); + this.connectionRepository = new SpeakingConnectionRepository(); + } + + @Override + public Map handleRequest(Map event, Context context) { + logger.info("Speaking message event received"); + + String connectionId = null; + String endpoint = null; + + try { + connectionId = WebSocketEventUtil.extractConnectionId(event); + endpoint = WebSocketEventUtil.extractWebSocketEndpoint(event); + + // 연결 정보 확인 + if (connectionRepository.findByConnectionId(connectionId).isEmpty()) { + logger.warn("Connection not found: {}", connectionId); + return sendError(connectionId, endpoint, "Unauthorized - please reconnect"); + } + + // 요청 바디 파싱 + String body = (String) event.get("body"); + if (body == null || body.isEmpty()) { + return sendError(connectionId, endpoint, "Message body is required"); + } + + JsonObject request = JsonParser.parseString(body).getAsJsonObject(); + String action = request.has("action") ? request.get("action").getAsString() : "speak"; + + logger.info("Processing action: {} for connectionId: {}", action, connectionId); + + // 액션별 처리 + switch (action) { + case "speak" -> handleSpeak(connectionId, endpoint, request); + case "text" -> handleText(connectionId, endpoint, request); + case "setLevel" -> handleSetLevel(connectionId, endpoint, request); + case "reset" -> handleReset(connectionId, endpoint); + default -> sendError(connectionId, endpoint, "Unknown action: " + action); + } + + return WebSocketEventUtil.ok("Processed"); + + } catch (Exception e) { + logger.error("Error processing message: {}", e.getMessage(), e); + if (connectionId != null && endpoint != null) { + sendError(connectionId, endpoint, "Processing error: " + e.getMessage()); + } + return WebSocketEventUtil.serverError("Internal server error"); + } + } + + /** + * 음성 입력 처리 + */ + private void handleSpeak(String connectionId, String endpoint, JsonObject request) { + // 시작 이벤트 전송 + sendToConnection(connectionId, endpoint, Map.of( + "type", "start", + "message", "Processing your voice..." + )); + + // 음성 데이터 추출 + String audioBase64 = request.has("audio") ? request.get("audio").getAsString() : null; + if (audioBase64 == null || audioBase64.isEmpty()) { + sendError(connectionId, endpoint, "audio data is required for speak action"); + return; + } + + // 음성 처리 + SpeakingService.SpeakingResponse response = speakingService.processVoiceInput( + connectionId, audioBase64 + ); + + // 결과 전송 + sendToConnection(connectionId, endpoint, Map.of( + "type", "complete", + "userTranscript", response.userTranscript(), + "aiText", response.aiText(), + "aiAudioUrl", response.aiAudioUrl(), + "confidence", response.confidence() + )); + } + + /** + * 텍스트 입력 처리 + */ + private void handleText(String connectionId, String endpoint, JsonObject request) { + String text = request.has("text") ? request.get("text").getAsString() : null; + if (text == null || text.trim().isEmpty()) { + sendError(connectionId, endpoint, "text is required for text action"); + return; + } + + // 시작 이벤트 전송 + sendToConnection(connectionId, endpoint, Map.of( + "type", "start", + "message", "Processing your message..." + )); + + // 텍스트 처리 + SpeakingService.SpeakingResponse response = speakingService.processTextInput( + connectionId, text.trim() + ); + + // 결과 전송 + sendToConnection(connectionId, endpoint, Map.of( + "type", "complete", + "userTranscript", response.userTranscript(), + "aiText", response.aiText(), + "aiAudioUrl", response.aiAudioUrl(), + "confidence", response.confidence() + )); + } + + /** + * 레벨 변경 처리 + */ + private void handleSetLevel(String connectionId, String endpoint, JsonObject request) { + String level = request.has("level") ? request.get("level").getAsString() : null; + if (level == null || level.isEmpty()) { + sendError(connectionId, endpoint, "level is required"); + return; + } + + speakingService.updateLevel(connectionId, level); + + sendToConnection(connectionId, endpoint, Map.of( + "type", "levelChanged", + "level", level.toUpperCase() + )); + } + + /** + * 대화 초기화 처리 + */ + private void handleReset(String connectionId, String endpoint) { + speakingService.resetConversation(connectionId); + + sendToConnection(connectionId, endpoint, Map.of( + "type", "reset", + "message", "Conversation has been reset. Let's start fresh!" + )); + } + + /** + * WebSocket으로 메시지 전송 + */ + private void sendToConnection(String connectionId, String endpoint, Map data) { + try { + ApiGatewayManagementApiClient apiClient = ApiGatewayManagementApiClient.builder() + .endpointOverride(URI.create(endpoint)) + .build(); + + String message = gson.toJson(data); + + apiClient.postToConnection(PostToConnectionRequest.builder() + .connectionId(connectionId) + .data(SdkBytes.fromUtf8String(message)) + .build()); + + logger.debug("Message sent to {}: {}", connectionId, data.get("type")); + + } catch (Exception e) { + logger.error("Failed to send message to {}: {}", connectionId, e.getMessage()); + } + } + + /** + * 에러 메시지 전송 + */ + private Map sendError(String connectionId, String endpoint, String errorMessage) { + sendToConnection(connectionId, endpoint, Map.of( + "type", "error", + "message", errorMessage + )); + return WebSocketEventUtil.ok("Error sent"); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java new file mode 100644 index 00000000..3462bbfb --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java @@ -0,0 +1,317 @@ +package com.mzc.secondproject.serverless.domain.speaking.service; + +import com.google.gson.*; +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.common.service.PollyService; +import com.mzc.secondproject.serverless.domain.opic.service.TranscribeProxyService; +import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingConnection; +import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelRequest; +import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelResponse; + + +import java.util.ArrayList; +import java.util.List; + +/** + * AI와 대화하기 서비스 + * 음성 입력 → STT → Bedrock → TTS → 음성 출력 + */ +public class SpeakingService { + + private static final Logger logger = LoggerFactory.getLogger(SpeakingService.class); + private static final Gson gson = new GsonBuilder().create(); + + private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; + private static final int MAX_TOKENS = 500; + private static final int MAX_HISTORY_SIZE = 10; // 최근 10턴만 유지 + + private final TranscribeProxyService transcribeService; + private final PollyService pollyService; + private final SpeakingConnectionRepository connectionRepository; + + public SpeakingService() { + this.transcribeService = new TranscribeProxyService(); + this.pollyService = new PollyService( + EnvConfig.getRequired("BUCKET_NAME"), + "speaking/voice/" + ); + this.connectionRepository = new SpeakingConnectionRepository(); + } + + /** + * 음성 입력 처리 (전체 플로우) + */ + public SpeakingResponse processVoiceInput(String connectionId, String audioBase64) { + logger.info("Processing voice input for connectionId: {}", connectionId); + + // 연결 정보 조회 + SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) + .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + + String targetLevel = connection.getTargetLevel(); + + // STT: 음성 → 텍스트 (Transcribe Proxy 사용) + logger.info("Step 1: Transcribing audio..."); + TranscribeProxyService.TranscribeResult sttResult = transcribeService.transcribe( + audioBase64, + connectionId, + "en-US" + ); + String userText = sttResult.transcript(); + logger.info("Transcription complete: {} (confidence: {})", userText, sttResult.confidence()); + + // 대화 히스토리 로드 + List history = parseHistory(connection.getConversationHistory()); + + // Bedrock: AI 응답 생성 + logger.info("Step 2: Generating AI response..."); + String aiResponse = generateAiResponse(userText, history, targetLevel); + logger.info("AI response generated: {}", aiResponse); + + // 히스토리 업데이트 (최근 N턴만 유지) + history.add(new Message("user", userText)); + history.add(new Message("assistant", aiResponse)); + if (history.size() > MAX_HISTORY_SIZE * 2) { + history = new ArrayList<>(history.subList(history.size() - MAX_HISTORY_SIZE * 2, history.size())); + } + connection.setConversationHistory(toJson(history)); + connectionRepository.update(connection); + + // TTS: 텍스트 → 음성 (Polly 사용) + logger.info("Step 3: Synthesizing speech..."); + String audioId = connectionId + "_" + System.currentTimeMillis(); + PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( + audioId, + aiResponse, + "FEMALE" + ); + logger.info("Speech synthesis complete: cached={}", ttsResult.isCached()); + + return new SpeakingResponse( + userText, + aiResponse, + ttsResult.getAudioUrl(), + sttResult.confidence() + ); + } + + /** + * 텍스트 입력 처리 (음성 없이 텍스트만) + */ + public SpeakingResponse processTextInput(String connectionId, String userText) { + logger.info("Processing text input for connectionId: {}", connectionId); + + // 연결 정보 조회 + SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) + .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + + String targetLevel = connection.getTargetLevel(); + + // 대화 히스토리 로드 + List history = parseHistory(connection.getConversationHistory()); + + // AI 응답 생성 + String aiResponse = generateAiResponse(userText, history, targetLevel); + + // 히스토리 업데이트 + history.add(new Message("user", userText)); + history.add(new Message("assistant", aiResponse)); + if (history.size() > MAX_HISTORY_SIZE * 2) { + history = new ArrayList<>(history.subList(history.size() - MAX_HISTORY_SIZE * 2, history.size())); + } + connection.setConversationHistory(toJson(history)); + connectionRepository.update(connection); + + // TTS 생성 + String audioId = connectionId + "_" + System.currentTimeMillis(); + PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( + audioId, aiResponse, "FEMALE" + ); + + return new SpeakingResponse(userText, aiResponse, ttsResult.getAudioUrl(), 1.0); + } + + /** + * 레벨 변경 + */ + public void updateLevel(String connectionId, String level) { + SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) + .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + + connection.setTargetLevel(level.toUpperCase()); + connectionRepository.update(connection); + logger.info("Level updated for connectionId {}: {}", connectionId, level); + } + + /** + * 대화 히스토리 초기화 + */ + public void resetConversation(String connectionId) { + SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) + .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + + connection.setConversationHistory("[]"); + connectionRepository.update(connection); + logger.info("Conversation reset for connectionId: {}", connectionId); + } + + + /** + * Bedrock Claude 호출하여 AI 응답 생성 + */ + private String generateAiResponse(String userText, List history, String targetLevel) { + String systemPrompt = buildSystemPrompt(targetLevel); + + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); + requestBody.addProperty("max_tokens", MAX_TOKENS); + requestBody.addProperty("system", systemPrompt); + + // 메시지 배열 구성 + JsonArray messages = new JsonArray(); + + // 기존 히스토리 추가 + for (Message msg : history) { + JsonObject m = new JsonObject(); + m.addProperty("role", msg.role()); + m.addProperty("content", msg.content()); + messages.add(m); + } + + // 현재 사용자 입력 추가 + JsonObject userMsg = new JsonObject(); + userMsg.addProperty("role", "user"); + userMsg.addProperty("content", userText); + messages.add(userMsg); + + requestBody.add("messages", messages); + + // Bedrock 호출 + InvokeModelResponse response = AwsClients.bedrock().invokeModel( + InvokeModelRequest.builder() + .modelId(MODEL_ID) + .contentType("application/json") + .body(SdkBytes.fromUtf8String(requestBody.toString())) + .build() + ); + + // 응답 파싱 + JsonObject result = JsonParser.parseString( + response.body().asUtf8String() + ).getAsJsonObject(); + + return result.getAsJsonArray("content") + .get(0).getAsJsonObject() + .get("text").getAsString(); + } + + /** + * 레벨별 시스템 프롬프트 생성 + */ + private String buildSystemPrompt(String targetLevel) { + String levelGuidance = switch (targetLevel.toUpperCase()) { + case "BEGINNER" -> """ + - Use simple vocabulary and short sentences + - Speak slowly and clearly + - Use basic grammar structures + - Provide Korean translations for difficult words in parentheses + """; + case "ADVANCED" -> """ + - Use sophisticated vocabulary and complex sentences + - Include idiomatic expressions and phrasal verbs + - Discuss abstract concepts naturally + - Challenge the user with nuanced topics + """; + default -> """ + - Use moderate vocabulary appropriate for intermediate learners + - Mix simple and compound sentences + - Introduce useful expressions gradually + - Balance challenge with accessibility + """; + }; + + return String.format(""" + You are a friendly English conversation partner for Korean learners. + Your name is "Amy" and you're an American English teacher living in Seoul. + + ## Target Level: %s + + ## Level-Specific Guidelines: + %s + + ## General Guidelines: + - Keep responses conversational (2-4 sentences) + - Be warm, encouraging, and supportive + - If the user makes grammar mistakes, gently correct them naturally in your response + - Ask follow-up questions to keep the conversation going + - Respond in English only (except for occasional Korean translations for difficult words) + - Match the conversation topic to the user's interests + - Use natural filler words occasionally (well, you know, actually) + + ## Correction Style: + Instead of: "You said 'I go to store.' It should be 'I went to the store.'" + Do this: "Oh, so you went to the store? That's nice! What did you buy?" + + Remember: Your goal is to make the user feel comfortable practicing English! + """, targetLevel, levelGuidance); + } + + /** + * 히스토리 JSON 파싱 + */ + private List parseHistory(String historyJson) { + List history = new ArrayList<>(); + + if (historyJson == null || historyJson.isEmpty() || historyJson.equals("[]")) { + return history; + } + + try { + JsonArray array = JsonParser.parseString(historyJson).getAsJsonArray(); + for (JsonElement el : array) { + JsonObject obj = el.getAsJsonObject(); + history.add(new Message( + obj.get("role").getAsString(), + obj.get("content").getAsString() + )); + } + } catch (Exception e) { + logger.warn("Failed to parse history, starting fresh: {}", e.getMessage()); + } + + return history; + } + + /** + * 히스토리 JSON 변환 + */ + private String toJson(List history) { + JsonArray array = new JsonArray(); + for (Message msg : history) { + JsonObject obj = new JsonObject(); + obj.addProperty("role", msg.role()); + obj.addProperty("content", msg.content()); + array.add(obj); + } + return array.toString(); + } + + // ==================== Inner Classes ==================== + + private record Message(String role, String content) {} + + /** + * Speaking 응답 DTO + */ + public record SpeakingResponse( + String userTranscript, // 사용자가 말한 내용 (STT 결과) + String aiText, // AI 응답 텍스트 + String aiAudioUrl, // AI 응답 음성 URL (Polly) + double confidence // STT 신뢰도comp + ) {} +} \ No newline at end of file From f39bdf971a2d22e05b0b8918f7f08b59730cdd3b Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 10:52:33 +0900 Subject: [PATCH 37/52] =?UTF-8?q?feat=20:=20WebSocket=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=20Repository=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SpeakingConnectionRepository.java | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java new file mode 100644 index 00000000..cbef094a --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java @@ -0,0 +1,73 @@ +현package com.mzc.secondproject.serverless.domain.speaking.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingConnection; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; + +import java.util.Optional; + +/** + * Speaking WebSocket 연결 정보 Repository + */ +public class SpeakingConnectionRepository { + + private static final Logger logger = LoggerFactory.getLogger(SpeakingConnectionRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); + + private final DynamoDbTable table; + + public SpeakingConnectionRepository() { + this.table = AwsClients.dynamoDbEnhanced().table( + TABLE_NAME, + TableSchema.fromBean(SpeakingConnection.class) + ); + } + + /** + * 연결 정보 저장 + */ + public void save(SpeakingConnection connection) { + table.putItem(connection); + logger.debug("Speaking connection saved: connectionId={}, userId={}", + connection.getConnectionId(), connection.getUserId()); + } + + /** + * connectionId로 연결 정보 조회 + */ + public Optional findByConnectionId(String connectionId) { + Key key = Key.builder() + .partitionValue(SpeakingConnection.PK_PREFIX + connectionId) + .sortValue(SpeakingConnection.SK_METADATA) + .build(); + + SpeakingConnection connection = table.getItem(key); + return Optional.ofNullable(connection); + } + + /** + * 연결 정보 업데이트 (대화 히스토리 등) + */ + public void update(SpeakingConnection connection) { + table.putItem(connection); + logger.debug("Speaking connection updated: connectionId={}", connection.getConnectionId()); + } + + /** + * 연결 정보 삭제 + */ + public void delete(String connectionId) { + Key key = Key.builder() + .partitionValue(SpeakingConnection.PK_PREFIX + connectionId) + .sortValue(SpeakingConnection.SK_METADATA) + .build(); + + table.deleteItem(key); + logger.info("Speaking connection deleted: connectionId={}", connectionId); + } +} \ No newline at end of file From 674f87c2e321176c1648139aa2f8e8e3b1574f5f Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 10:54:47 +0900 Subject: [PATCH 38/52] 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 39/52] 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 40/52] 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 41/52] =?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 42/52] 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 ccaa03411d6841bf934a29ad97b5fdebda5bc307 Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 11:18:04 +0900 Subject: [PATCH 43/52] =?UTF-8?q?fix=20:=20=EC=98=A4=ED=83=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../speaking/repository/SpeakingConnectionRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java index cbef094a..14b468d1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java @@ -1,4 +1,4 @@ -현package com.mzc.secondproject.serverless.domain.speaking.repository; +package com.mzc.secondproject.serverless.domain.speaking.repository; import com.mzc.secondproject.serverless.common.config.AwsClients; import com.mzc.secondproject.serverless.common.config.EnvConfig; From c201a07902d8c28c5a8367a030d7a2a922589731 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 11:24:43 +0900 Subject: [PATCH 44/52] 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 45/52] 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 46/52] 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 6810f4138bffdc0a0a4314e790ef819b321f474c Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 12:36:40 +0900 Subject: [PATCH 47/52] =?UTF-8?q?refactor=20:=20websocket=20->=20rest=20ap?= =?UTF-8?q?i=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websocket/SpeakingDisconnectHandler.java | 44 ---- .../websocket/SpeakingMessageHandler.java | 217 ------------------ .../SpeakingConnectionRepository.java | 73 ------ 3 files changed, 334 deletions(-) delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java deleted file mode 100644 index 4d82a9d1..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; -import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Map; - -/** - * Speaking WebSocket $disconnect 핸들러 - * 연결 해제 시 DynamoDB에서 연결 정보 삭제 - */ -public class SpeakingDisconnectHandler implements RequestHandler, Map> { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingDisconnectHandler.class); - - private final SpeakingConnectionRepository connectionRepository; - - public SpeakingDisconnectHandler() { - this.connectionRepository = new SpeakingConnectionRepository(); - } - - @Override - public Map handleRequest(Map event, Context context) { - logger.info("Speaking WebSocket disconnect event"); - - try { - String connectionId = WebSocketEventUtil.extractConnectionId(event); - - // 연결 정보 삭제 - connectionRepository.delete(connectionId); - - logger.info("Speaking connection closed: connectionId={}", connectionId); - return WebSocketEventUtil.ok("Disconnected"); - - } catch (Exception e) { - logger.error("Error handling disconnect: {}", e.getMessage(), e); - return WebSocketEventUtil.serverError("Internal server error"); - } - } -} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java deleted file mode 100644 index 89ec0a44..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java +++ /dev/null @@ -1,217 +0,0 @@ -package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; -import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; -import com.mzc.secondproject.serverless.domain.speaking.service.SpeakingService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.core.SdkBytes; -import software.amazon.awssdk.services.apigatewaymanagementapi.ApiGatewayManagementApiClient; -import software.amazon.awssdk.services.apigatewaymanagementapi.model.PostToConnectionRequest; - -import java.net.URI; -import java.util.Map; - -/** - * Speaking WebSocket 메시지 핸들러 - * - * 지원하는 action: - * - speak: 음성 입력 처리 (audio base64) - * - text: 텍스트 입력 처리 - * - setLevel: 레벨 변경 - * - reset: 대화 히스토리 초기화 - */ -public class SpeakingMessageHandler implements RequestHandler, Map> { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingMessageHandler.class); - private static final Gson gson = new GsonBuilder().create(); - - private final SpeakingService speakingService; - private final SpeakingConnectionRepository connectionRepository; - - public SpeakingMessageHandler() { - this.speakingService = new SpeakingService(); - this.connectionRepository = new SpeakingConnectionRepository(); - } - - @Override - public Map handleRequest(Map event, Context context) { - logger.info("Speaking message event received"); - - String connectionId = null; - String endpoint = null; - - try { - connectionId = WebSocketEventUtil.extractConnectionId(event); - endpoint = WebSocketEventUtil.extractWebSocketEndpoint(event); - - // 연결 정보 확인 - if (connectionRepository.findByConnectionId(connectionId).isEmpty()) { - logger.warn("Connection not found: {}", connectionId); - return sendError(connectionId, endpoint, "Unauthorized - please reconnect"); - } - - // 요청 바디 파싱 - String body = (String) event.get("body"); - if (body == null || body.isEmpty()) { - return sendError(connectionId, endpoint, "Message body is required"); - } - - JsonObject request = JsonParser.parseString(body).getAsJsonObject(); - String action = request.has("action") ? request.get("action").getAsString() : "speak"; - - logger.info("Processing action: {} for connectionId: {}", action, connectionId); - - // 액션별 처리 - switch (action) { - case "speak" -> handleSpeak(connectionId, endpoint, request); - case "text" -> handleText(connectionId, endpoint, request); - case "setLevel" -> handleSetLevel(connectionId, endpoint, request); - case "reset" -> handleReset(connectionId, endpoint); - default -> sendError(connectionId, endpoint, "Unknown action: " + action); - } - - return WebSocketEventUtil.ok("Processed"); - - } catch (Exception e) { - logger.error("Error processing message: {}", e.getMessage(), e); - if (connectionId != null && endpoint != null) { - sendError(connectionId, endpoint, "Processing error: " + e.getMessage()); - } - return WebSocketEventUtil.serverError("Internal server error"); - } - } - - /** - * 음성 입력 처리 - */ - private void handleSpeak(String connectionId, String endpoint, JsonObject request) { - // 시작 이벤트 전송 - sendToConnection(connectionId, endpoint, Map.of( - "type", "start", - "message", "Processing your voice..." - )); - - // 음성 데이터 추출 - String audioBase64 = request.has("audio") ? request.get("audio").getAsString() : null; - if (audioBase64 == null || audioBase64.isEmpty()) { - sendError(connectionId, endpoint, "audio data is required for speak action"); - return; - } - - // 음성 처리 - SpeakingService.SpeakingResponse response = speakingService.processVoiceInput( - connectionId, audioBase64 - ); - - // 결과 전송 - sendToConnection(connectionId, endpoint, Map.of( - "type", "complete", - "userTranscript", response.userTranscript(), - "aiText", response.aiText(), - "aiAudioUrl", response.aiAudioUrl(), - "confidence", response.confidence() - )); - } - - /** - * 텍스트 입력 처리 - */ - private void handleText(String connectionId, String endpoint, JsonObject request) { - String text = request.has("text") ? request.get("text").getAsString() : null; - if (text == null || text.trim().isEmpty()) { - sendError(connectionId, endpoint, "text is required for text action"); - return; - } - - // 시작 이벤트 전송 - sendToConnection(connectionId, endpoint, Map.of( - "type", "start", - "message", "Processing your message..." - )); - - // 텍스트 처리 - SpeakingService.SpeakingResponse response = speakingService.processTextInput( - connectionId, text.trim() - ); - - // 결과 전송 - sendToConnection(connectionId, endpoint, Map.of( - "type", "complete", - "userTranscript", response.userTranscript(), - "aiText", response.aiText(), - "aiAudioUrl", response.aiAudioUrl(), - "confidence", response.confidence() - )); - } - - /** - * 레벨 변경 처리 - */ - private void handleSetLevel(String connectionId, String endpoint, JsonObject request) { - String level = request.has("level") ? request.get("level").getAsString() : null; - if (level == null || level.isEmpty()) { - sendError(connectionId, endpoint, "level is required"); - return; - } - - speakingService.updateLevel(connectionId, level); - - sendToConnection(connectionId, endpoint, Map.of( - "type", "levelChanged", - "level", level.toUpperCase() - )); - } - - /** - * 대화 초기화 처리 - */ - private void handleReset(String connectionId, String endpoint) { - speakingService.resetConversation(connectionId); - - sendToConnection(connectionId, endpoint, Map.of( - "type", "reset", - "message", "Conversation has been reset. Let's start fresh!" - )); - } - - /** - * WebSocket으로 메시지 전송 - */ - private void sendToConnection(String connectionId, String endpoint, Map data) { - try { - ApiGatewayManagementApiClient apiClient = ApiGatewayManagementApiClient.builder() - .endpointOverride(URI.create(endpoint)) - .build(); - - String message = gson.toJson(data); - - apiClient.postToConnection(PostToConnectionRequest.builder() - .connectionId(connectionId) - .data(SdkBytes.fromUtf8String(message)) - .build()); - - logger.debug("Message sent to {}: {}", connectionId, data.get("type")); - - } catch (Exception e) { - logger.error("Failed to send message to {}: {}", connectionId, e.getMessage()); - } - } - - /** - * 에러 메시지 전송 - */ - private Map sendError(String connectionId, String endpoint, String errorMessage) { - sendToConnection(connectionId, endpoint, Map.of( - "type", "error", - "message", errorMessage - )); - return WebSocketEventUtil.ok("Error sent"); - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java deleted file mode 100644 index 14b468d1..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.mzc.secondproject.serverless.domain.speaking.repository; - -import com.mzc.secondproject.serverless.common.config.AwsClients; -import com.mzc.secondproject.serverless.common.config.EnvConfig; -import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingConnection; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; -import software.amazon.awssdk.enhanced.dynamodb.Key; -import software.amazon.awssdk.enhanced.dynamodb.TableSchema; - -import java.util.Optional; - -/** - * Speaking WebSocket 연결 정보 Repository - */ -public class SpeakingConnectionRepository { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingConnectionRepository.class); - private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); - - private final DynamoDbTable table; - - public SpeakingConnectionRepository() { - this.table = AwsClients.dynamoDbEnhanced().table( - TABLE_NAME, - TableSchema.fromBean(SpeakingConnection.class) - ); - } - - /** - * 연결 정보 저장 - */ - public void save(SpeakingConnection connection) { - table.putItem(connection); - logger.debug("Speaking connection saved: connectionId={}, userId={}", - connection.getConnectionId(), connection.getUserId()); - } - - /** - * connectionId로 연결 정보 조회 - */ - public Optional findByConnectionId(String connectionId) { - Key key = Key.builder() - .partitionValue(SpeakingConnection.PK_PREFIX + connectionId) - .sortValue(SpeakingConnection.SK_METADATA) - .build(); - - SpeakingConnection connection = table.getItem(key); - return Optional.ofNullable(connection); - } - - /** - * 연결 정보 업데이트 (대화 히스토리 등) - */ - public void update(SpeakingConnection connection) { - table.putItem(connection); - logger.debug("Speaking connection updated: connectionId={}", connection.getConnectionId()); - } - - /** - * 연결 정보 삭제 - */ - public void delete(String connectionId) { - Key key = Key.builder() - .partitionValue(SpeakingConnection.PK_PREFIX + connectionId) - .sortValue(SpeakingConnection.SK_METADATA) - .build(); - - table.deleteItem(key); - logger.info("Speaking connection deleted: connectionId={}", connectionId); - } -} \ No newline at end of file From 76045eb86ebf27c8b2411311f9aacc5c42f3f496 Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 12:37:28 +0900 Subject: [PATCH 48/52] =?UTF-8?q?feat=20:=20speaking=20handler=20REST?= =?UTF-8?q?=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/websocket/SpeakingHandler.java | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java new file mode 100644 index 00000000..69375925 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java @@ -0,0 +1,157 @@ +package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.mzc.secondproject.serverless.common.util.JwtUtil; +import com.mzc.secondproject.serverless.domain.speaking.service.SpeakingService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.Optional; + +/** + * Speaking API 핸들러 + * + * POST /api/speaking/chat - 대화 (음성 또는 텍스트) + * POST /api/speaking/reset - 대화 초기화 + */ +public class SpeakingHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(SpeakingHandler.class); + private static final Gson gson = new GsonBuilder().create(); + + private static final Map CORS_HEADERS = Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Headers", "Content-Type,Authorization", + "Access-Control-Allow-Methods", "POST,OPTIONS" + ); + + private final SpeakingService speakingService; + + public SpeakingHandler() { + this.speakingService = new SpeakingService(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent event, Context context) { + logger.info("Speaking API request received"); + + // OPTIONS 요청 처리 (CORS preflight) + if ("OPTIONS".equalsIgnoreCase(event.getHttpMethod())) { + return response(200, Map.of("message", "OK")); + } + + try { + // JWT 토큰 검증 + String authHeader = event.getHeaders().get("Authorization"); + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + return response(401, Map.of("error", "Authorization header is required")); + } + + String token = authHeader.substring(7); + if (!JwtUtil.isValid(token)) { + return response(401, Map.of("error", "Invalid or expired token")); + } + + Optional userIdOpt = JwtUtil.extractUserId(token); + if (userIdOpt.isEmpty()) { + return response(401, Map.of("error", "Invalid token")); + } + + String userId = userIdOpt.get(); + String path = event.getPath(); + String body = event.getBody(); + + logger.info("Processing request: path={}, userId={}", path, userId); + + // 라우팅 + if (path.endsWith("/chat")) { + return handleChat(userId, body); + } else if (path.endsWith("/reset")) { + return handleReset(userId, body); + } else { + return response(404, Map.of("error", "Not found")); + } + + } catch (Exception e) { + logger.error("Error processing request: {}", e.getMessage(), e); + return response(500, Map.of("error", "Internal server error: " + e.getMessage())); + } + } + + /** + * 대화 처리 (음성 또는 텍스트) + */ + private APIGatewayProxyResponseEvent handleChat(String userId, String body) { + if (body == null || body.isEmpty()) { + return response(400, Map.of("error", "Request body is required")); + } + + JsonObject request = JsonParser.parseString(body).getAsJsonObject(); + + String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; + String level = request.has("level") ? request.get("level").getAsString() : "INTERMEDIATE"; + String audio = request.has("audio") ? request.get("audio").getAsString() : null; + String text = request.has("text") ? request.get("text").getAsString() : null; + + SpeakingService.SpeakingResponse result; + + if (audio != null && !audio.isEmpty()) { + // 음성 입력 처리 + logger.info("Processing voice input"); + result = speakingService.processVoiceInput(sessionId, userId, audio, level); + } else if (text != null && !text.trim().isEmpty()) { + // 텍스트 입력 처리 + logger.info("Processing text input: {}", text); + result = speakingService.processTextInput(sessionId, userId, text.trim(), level); + } else { + return response(400, Map.of("error", "Either 'audio' or 'text' is required")); + } + + return response(200, Map.of( + "sessionId", result.sessionId(), + "userTranscript", result.userTranscript(), + "aiText", result.aiText(), + "aiAudioUrl", result.aiAudioUrl(), + "confidence", result.confidence() + )); + } + + /** + * 대화 초기화 + */ + private APIGatewayProxyResponseEvent handleReset(String userId, String body) { + if (body == null || body.isEmpty()) { + return response(400, Map.of("error", "Request body is required")); + } + + JsonObject request = JsonParser.parseString(body).getAsJsonObject(); + String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; + + if (sessionId == null || sessionId.isEmpty()) { + return response(400, Map.of("error", "sessionId is required")); + } + + speakingService.resetConversation(sessionId); + + return response(200, Map.of( + "message", "Conversation reset successfully", + "sessionId", sessionId + )); + } + + private APIGatewayProxyResponseEvent response(int statusCode, Map body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(CORS_HEADERS) + .withBody(gson.toJson(body)); + } +} From 2c7094a0099a1c9cb3a918cfeec5d4d59544819f Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 12:37:44 +0900 Subject: [PATCH 49/52] =?UTF-8?q?feat=20:=20speaking=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20dto=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../speaking/dto/request/ResetRequest.java | 12 +++++++ .../speaking/dto/request/SpeakingRequest.java | 32 +++++++++++++++++++ .../dto/response/SpeakingResponse.java | 12 +++++++ 3 files changed, 56 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/ResetRequest.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/SpeakingRequest.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/response/SpeakingResponse.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/ResetRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/ResetRequest.java new file mode 100644 index 00000000..8f600c66 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/ResetRequest.java @@ -0,0 +1,12 @@ +package com.mzc.secondproject.serverless.domain.speaking.dto.request; + +/** + * 대화 초기화 요청 DTO + */ +public record ResetRequest( + String sessionId +) { + public boolean isValid() { + return sessionId != null && !sessionId.isEmpty(); + } +} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/SpeakingRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/SpeakingRequest.java new file mode 100644 index 00000000..58ad78c1 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/SpeakingRequest.java @@ -0,0 +1,32 @@ +package com.mzc.secondproject.serverless.domain.speaking.dto.request; + +/** + * Speaking API 요청 DTO + */ +public record SpeakingRequest( + String sessionId, // 세션 ID (첫 요청 시 null) + String audio, // 음성 데이터 (base64) + String text, // 텍스트 입력 + String level // 레벨 (BEGINNER, INTERMEDIATE, ADVANCED) +) { + /** + * 기본값 적용된 레벨 반환 + */ + public String getLevelOrDefault() { + return level != null && !level.isEmpty() ? level : "INTERMEDIATE"; + } + + /** + * 음성 입력인지 확인 + */ + public boolean hasAudio() { + return audio != null && !audio.isEmpty(); + } + + /** + * 텍스트 입력인지 확인 + */ + public boolean hasText() { + return text != null && !text.trim().isEmpty(); + } +} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/response/SpeakingResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/response/SpeakingResponse.java new file mode 100644 index 00000000..49d714dd --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/response/SpeakingResponse.java @@ -0,0 +1,12 @@ +package com.mzc.secondproject.serverless.domain.speaking.dto.response; + +/** + * Speaking API 응답 DTO + */ +public record SpeakingResponse( + String sessionId, // 세션 ID (다음 요청에 사용) + String userTranscript, // 사용자가 말한 내용 (STT 결과) + String aiText, // AI 응답 텍스트 + String aiAudioUrl, // AI 응답 음성 URL (Polly) + double confidence // STT 신뢰도 +) {} \ No newline at end of file From 5ad8fd63fac51eaa0892d06933639836670d485e Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 12:38:45 +0900 Subject: [PATCH 50/52] =?UTF-8?q?refacotor=20:=20=EA=B8=B0=EC=A1=B4=20serv?= =?UTF-8?q?ice=20=EC=BD=94=EB=93=9C=20=EB=A1=9C=EC=A7=81=20=EC=9E=AC?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20=EB=B0=8F=20repository=20=EB=A6=AC?= =?UTF-8?q?=ED=8E=99=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websocket/SpeakingConnectHandler.java | 88 -------------- ...ngConnection.java => SpeakingSession.java} | 48 +++++--- .../repository/SpeakingSessionRepository.java | 74 ++++++++++++ .../speaking/service/SpeakingService.java | 108 +++++++++++------- 4 files changed, 172 insertions(+), 146 deletions(-) delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/{SpeakingConnection.java => SpeakingSession.java} (56%) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java deleted file mode 100644 index 535a3fc1..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.mzc.secondproject.serverless.common.config.WebSocketConfig; -import com.mzc.secondproject.serverless.common.util.JwtUtil; -import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; -import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingConnection; -import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Map; -import java.util.Optional; - -/** - * Speaking WebSocket $connect 핸들러 - * JWT 토큰 검증 후 연결 정보를 DynamoDB에 저장 - * - * 연결 방법: - * wss://{api-id}.execute-api.{region}.amazonaws.com/{stage}?token={jwt} - */ -public class SpeakingConnectHandler implements RequestHandler, Map> { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingConnectHandler.class); - - private final SpeakingConnectionRepository connectionRepository; - - public SpeakingConnectHandler() { - this.connectionRepository = new SpeakingConnectionRepository(); - } - - @Override - public Map handleRequest(Map event, Context context) { - logger.info("Speaking WebSocket connect event"); - - try { - String connectionId = WebSocketEventUtil.extractConnectionId(event); - Map queryParams = WebSocketEventUtil.extractQueryStringParameters(event); - - // JWT 토큰 검증 - String token = queryParams.get("token"); - - if (token == null || token.isEmpty()) { - logger.warn("Missing token parameter"); - return WebSocketEventUtil.unauthorized("token is required"); - } - - // 토큰 유효성 검사 - if (!JwtUtil.isValid(token)) { - logger.warn("Invalid or expired token"); - return WebSocketEventUtil.unauthorized("Invalid or expired token"); - } - - // userId 추출 - Optional userIdOpt = JwtUtil.extractUserId(token); - if (userIdOpt.isEmpty()) { - logger.warn("Failed to extract userId from token"); - return WebSocketEventUtil.unauthorized("Invalid token"); - } - - String userId = userIdOpt.get(); - - // 연결 정보 저장 - SpeakingConnection connection = SpeakingConnection.create( - connectionId, - userId, - WebSocketConfig.connectionTtlSeconds() - ); - - // 레벨 파라미터가 있으면 설정 - String level = queryParams.get("level"); - if (level != null && !level.isEmpty()) { - connection.setTargetLevel(level.toUpperCase()); - } - - connectionRepository.save(connection); - - logger.info("Speaking connection established: connectionId={}, userId={}, level={}", - connectionId, userId, connection.getTargetLevel()); - return WebSocketEventUtil.ok("Connected"); - - } catch (Exception e) { - logger.error("Error handling connect: {}", e.getMessage(), e); - return WebSocketEventUtil.serverError("Internal server error"); - } - } -} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingSession.java similarity index 56% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingSession.java index 6ea0c185..07956b2f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingSession.java @@ -15,49 +15,61 @@ @NoArgsConstructor @AllArgsConstructor @DynamoDbBean -public class SpeakingConnection { +public class SpeakingSession { // DynamoDB Key Prefixes - public static final String PK_PREFIX = "SPEAKING_CONN#"; + public static final String PK_PREFIX = "SPEAKING_SESSION#"; public static final String SK_METADATA = "METADATA"; public static final String GSI1PK_PREFIX = "SPEAKING_USER#"; - public static final String GSI1SK_PREFIX = "CONN#"; + public static final String GSI1SK_PREFIX = "SESSION#"; - private String pk; // SPEAKING_CONN#{connectionId} + private String pk; // SPEAKING_SESSION#{sessionId} private String sk; // METADATA private String gsi1pk; // SPEAKING_USER#{userId} - private String gsi1sk; // CONN#{connectionId} + private String gsi1sk; // SESSION#{sessionId} - private String connectionId; + private String sessionId; private String userId; - private String connectedAt; - private Long ttl; // 자동 삭제용 + private String createdAt; + private String updatedAt; + private Long ttl; // 자동 삭제용 (24시간) // Speaking 전용 필드 private String conversationHistory; // 대화 히스토리 (JSON) private String targetLevel; // 목표 레벨 (BEGINNER, INTERMEDIATE, ADVANCED) /** - * 연결 정보 생성 팩토리 메서드 + * 세션 생성 팩토리 메서드 */ - public static SpeakingConnection create(String connectionId, String userId, long ttlSeconds) { + public static SpeakingSession create(String sessionId, String userId, String level) { String now = java.time.Instant.now().toString(); - long ttl = java.time.Instant.now().plusSeconds(ttlSeconds).getEpochSecond(); + // 24시간 후 자동 삭제 + long ttl = java.time.Instant.now().plusSeconds(86400).getEpochSecond(); - return SpeakingConnection.builder() - .pk(PK_PREFIX + connectionId) + return SpeakingSession.builder() + .pk(PK_PREFIX + sessionId) .sk(SK_METADATA) .gsi1pk(GSI1PK_PREFIX + userId) - .gsi1sk(GSI1SK_PREFIX + connectionId) - .connectionId(connectionId) + .gsi1sk(GSI1SK_PREFIX + sessionId) + .sessionId(sessionId) .userId(userId) - .connectedAt(now) + .createdAt(now) + .updatedAt(now) .ttl(ttl) - .conversationHistory("[]") // 빈 배열로 초기화 - .targetLevel("INTERMEDIATE") // 기본값 + .conversationHistory("[]") + .targetLevel(level != null ? level.toUpperCase() : "INTERMEDIATE") .build(); } + /** + * 업데이트 시간 갱신 + */ + public void touch() { + this.updatedAt = java.time.Instant.now().toString(); + // TTL 연장 (24시간) + this.ttl = java.time.Instant.now().plusSeconds(86400).getEpochSecond(); + } + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java new file mode 100644 index 00000000..fed1cd66 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java @@ -0,0 +1,74 @@ +package com.mzc.secondproject.serverless.domain.speaking.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; + +import java.util.Optional; + +/** + * Speaking WebSocket 연결 정보 Repository + */ +public class SpeakingSessionRepository { + + private static final Logger logger = LoggerFactory.getLogger(SpeakingSessionRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); + + private final DynamoDbTable table; + + public SpeakingSessionRepository() { + this.table = AwsClients.dynamoDbEnhanced().table( + TABLE_NAME, + TableSchema.fromBean(SpeakingSession.class) + ); + } + + /** + * 연결 정보 저장 + */ + public void save(SpeakingSession session) { + table.putItem(session); + logger.debug("Speaking session saved: sessionId={}, userId={}", + session.getSessionId(), session.getUserId()); + } + + /** + * sessionId로 연결 정보 조회 + */ + public Optional findBySessionId(String sessionId) { + Key key = Key.builder() + .partitionValue(SpeakingSession.PK_PREFIX + sessionId) + .sortValue(SpeakingSession.SK_METADATA) + .build(); + + SpeakingSession session = table.getItem(key); + return Optional.ofNullable(session); + } + + /** + * 연결 정보 업데이트 (대화 히스토리 등) + */ + public void update(SpeakingSession session) { + session.touch(); // 업데이트 시간 및 TTL 갱신 + table.putItem(session); + logger.debug("Speaking session updated: sessionId={}", session.getSessionId()); + } + + /** + * 연결 정보 삭제 + */ + public void delete(String sessionId) { + Key key = Key.builder() + .partitionValue(SpeakingSession.PK_PREFIX + sessionId) + .sortValue(SpeakingSession.SK_METADATA) + .build(); + + table.deleteItem(key); + logger.info("Speaking session deleted: sessionId={}", sessionId); + } +} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java index 3462bbfb..010c4afe 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java @@ -5,8 +5,9 @@ import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.service.PollyService; import com.mzc.secondproject.serverless.domain.opic.service.TranscribeProxyService; -import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingConnection; -import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; +import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingSession; +import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingSessionRepository; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.core.SdkBytes; @@ -16,6 +17,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.UUID; /** * AI와 대화하기 서비스 @@ -32,7 +34,29 @@ public class SpeakingService { private final TranscribeProxyService transcribeService; private final PollyService pollyService; - private final SpeakingConnectionRepository connectionRepository; + private final SpeakingSessionRepository sessionRepository; + + /** + * 세션 생성 또는 조회 + */ + public SpeakingSession getOrCreateSession(String sessionId, String userId, String level) { + if (sessionId != null && !sessionId.isEmpty()) { + return sessionRepository.findBySessionId(sessionId) + .orElseGet(() -> createNewSession(userId, level)); + } + return createNewSession(userId, level); + } + + /** + * 새 세션 생성 + */ + private SpeakingSession createNewSession(String userId, String level) { + String newSessionId = UUID.randomUUID().toString(); + SpeakingSession session = SpeakingSession.create(newSessionId, userId, level); + sessionRepository.save(session); + logger.info("New speaking session created: sessionId={}, userId={}", newSessionId, userId); + return session; + } public SpeakingService() { this.transcribeService = new TranscribeProxyService(); @@ -40,33 +64,32 @@ public SpeakingService() { EnvConfig.getRequired("BUCKET_NAME"), "speaking/voice/" ); - this.connectionRepository = new SpeakingConnectionRepository(); + this.sessionRepository = new SpeakingSessionRepository(); } /** * 음성 입력 처리 (전체 플로우) */ - public SpeakingResponse processVoiceInput(String connectionId, String audioBase64) { - logger.info("Processing voice input for connectionId: {}", connectionId); + public SpeakingResponse processVoiceInput(String sessionId, String userId, String audioBase64, String level) { + logger.info("Processing voice input for sessionId: {}", sessionId); - // 연결 정보 조회 - SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) - .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + // 세션 조회 또는 생성 + SpeakingSession session = getOrCreateSession(sessionId, userId, level); - String targetLevel = connection.getTargetLevel(); + String targetLevel = session.getTargetLevel(); // STT: 음성 → 텍스트 (Transcribe Proxy 사용) logger.info("Step 1: Transcribing audio..."); TranscribeProxyService.TranscribeResult sttResult = transcribeService.transcribe( audioBase64, - connectionId, + sessionId, "en-US" ); String userText = sttResult.transcript(); logger.info("Transcription complete: {} (confidence: {})", userText, sttResult.confidence()); // 대화 히스토리 로드 - List history = parseHistory(connection.getConversationHistory()); + List history = parseHistory(session.getConversationHistory()); // Bedrock: AI 응답 생성 logger.info("Step 2: Generating AI response..."); @@ -79,12 +102,12 @@ public SpeakingResponse processVoiceInput(String connectionId, String audioBase6 if (history.size() > MAX_HISTORY_SIZE * 2) { history = new ArrayList<>(history.subList(history.size() - MAX_HISTORY_SIZE * 2, history.size())); } - connection.setConversationHistory(toJson(history)); - connectionRepository.update(connection); + session.setConversationHistory(toJson(history)); + sessionRepository.update(session); // TTS: 텍스트 → 음성 (Polly 사용) logger.info("Step 3: Synthesizing speech..."); - String audioId = connectionId + "_" + System.currentTimeMillis(); + String audioId = session.getSessionId() + "_" + System.currentTimeMillis(); PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( audioId, aiResponse, @@ -93,6 +116,7 @@ public SpeakingResponse processVoiceInput(String connectionId, String audioBase6 logger.info("Speech synthesis complete: cached={}", ttsResult.isCached()); return new SpeakingResponse( + session.getSessionId(), userText, aiResponse, ttsResult.getAudioUrl(), @@ -103,20 +127,17 @@ public SpeakingResponse processVoiceInput(String connectionId, String audioBase6 /** * 텍스트 입력 처리 (음성 없이 텍스트만) */ - public SpeakingResponse processTextInput(String connectionId, String userText) { - logger.info("Processing text input for connectionId: {}", connectionId); - - // 연결 정보 조회 - SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) - .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + public SpeakingResponse processTextInput(String sessionId, String userId, String userText, String level){ + logger.info("Processing text input for sessionId: {}", sessionId); - String targetLevel = connection.getTargetLevel(); + // 세션 조회 또는 생성 + SpeakingSession session = getOrCreateSession(sessionId, userId, level); // 대화 히스토리 로드 - List history = parseHistory(connection.getConversationHistory()); + List history = parseHistory(session.getConversationHistory()); // AI 응답 생성 - String aiResponse = generateAiResponse(userText, history, targetLevel); + String aiResponse = generateAiResponse(userText, history, session.getTargetLevel()); // 히스토리 업데이트 history.add(new Message("user", userText)); @@ -124,40 +145,46 @@ public SpeakingResponse processTextInput(String connectionId, String userText) { if (history.size() > MAX_HISTORY_SIZE * 2) { history = new ArrayList<>(history.subList(history.size() - MAX_HISTORY_SIZE * 2, history.size())); } - connection.setConversationHistory(toJson(history)); - connectionRepository.update(connection); + session.setConversationHistory(toJson(history)); + sessionRepository.update(session); // TTS 생성 - String audioId = connectionId + "_" + System.currentTimeMillis(); + String audioId = session.getSessionId() + "_" + System.currentTimeMillis(); PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( audioId, aiResponse, "FEMALE" ); - return new SpeakingResponse(userText, aiResponse, ttsResult.getAudioUrl(), 1.0); + return new SpeakingResponse( + session.getSessionId(), + userText, + aiResponse, + ttsResult.getAudioUrl(), + 1.0 + ); } /** * 레벨 변경 */ - public void updateLevel(String connectionId, String level) { - SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) - .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + public void updateLevel(String sessionId, String level) { + SpeakingSession session = sessionRepository.findBySessionId(sessionId) + .orElseThrow(() -> new RuntimeException("session not found: " + sessionId)); - connection.setTargetLevel(level.toUpperCase()); - connectionRepository.update(connection); - logger.info("Level updated for connectionId {}: {}", connectionId, level); + session.setTargetLevel(level.toUpperCase()); + sessionRepository.update(session); + logger.info("Level updated for sessionId {}: {}", sessionId, level); } /** * 대화 히스토리 초기화 */ - public void resetConversation(String connectionId) { - SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) - .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + public void resetConversation(String sessionId) { + SpeakingSession session = sessionRepository.findBySessionId(sessionId) + .orElseThrow(() -> new RuntimeException("session not found: " + sessionId)); - connection.setConversationHistory("[]"); - connectionRepository.update(connection); - logger.info("Conversation reset for connectionId: {}", connectionId); + session.setConversationHistory("[]"); + sessionRepository.update(session); + logger.info("Conversation reset for sessionId: {}", sessionId); } @@ -309,6 +336,7 @@ private record Message(String role, String content) {} * Speaking 응답 DTO */ public record SpeakingResponse( + String sessionId, // 세션 ID (다음 요청에 사용) String userTranscript, // 사용자가 말한 내용 (STT 결과) String aiText, // AI 응답 텍스트 String aiAudioUrl, // AI 응답 음성 URL (Polly) From c1ca2c0a0da07c1de784f771f47b2734018d8e95 Mon Sep 17 00:00:00 2001 From: DDING JOO Date: Thu, 22 Jan 2026 12:39:43 +0900 Subject: [PATCH 51/52] 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 6c4cc89cf3234f7fa3c2de871f17e652c924daf3 Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 15:52:31 +0900 Subject: [PATCH 52/52] =?UTF-8?q?feat=20:=20speaking=20rest=20API=20?= =?UTF-8?q?=EB=9E=8C=EB=8B=A4=20=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ServerlessFunction/template.yaml | 56 ++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 488fc843..0f64417e 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1433,6 +1433,62 @@ Resources: Description: Daily word learning stats aggregation Enabled: true + ############################################# + # Speaking REST API (AI와 대화하기) + ############################################# + + SpeakingFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-speaking-handler + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.speaking.handler.SpeakingHandler::handleRequest + Description: Handle Speaking AI conversation (REST API) + Timeout: 120 + MemorySize: 1024 + SnapStart: + ApplyOn: PublishedVersions + Environment: + Variables: + TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ChatTable + - S3CrudPolicy: + BucketName: group2-englishstudy + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + Resource: "*" + - Statement: + - Effect: Allow + Action: + - polly:SynthesizeSpeech + Resource: "*" + - Statement: + - Effect: Allow + Action: + - ssm:GetParameter + Resource: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/opic/*" + Events: + SpeakingChat: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /api/speaking/chat + Method: POST + Auth: + Authorizer: CognitoAuthorizer + SpeakingReset: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /api/speaking/reset + Method: POST + Auth: + Authorizer: CognitoAuthorizer + ############################################# # OPIc Lambda Functions #############################################