From 0563433e482bf95a0e3610c3e9ccf8965ca90243 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 15:24:12 +0900 Subject: [PATCH 01/10] =?UTF-8?q?fix:=20SpeakingHandler=20getStringOrNull?= =?UTF-8?q?=20=EC=BB=B4=ED=8C=8C=EC=9D=BC=20=EC=97=90=EB=9F=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/domain/speaking/handler/SpeakingHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java index c4a04370..c4166ed8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java @@ -135,7 +135,7 @@ private APIGatewayProxyResponseEvent handleReset(String userId, String body) { } JsonObject request = JsonParser.parseString(body).getAsJsonObject(); - String sessionId = getStringOrNull(request, "sessionId"); + String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; if (sessionId == null || sessionId.isEmpty()) { return response(400, Map.of("error", "sessionId is required")); From 5f2bc1b942214309adfdde165ca8a9620a79e68d Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 15:46:22 +0900 Subject: [PATCH 02/10] =?UTF-8?q?fix:=20Bedrock=20=ED=82=A4=EC=9B=8C?= =?UTF-8?q?=EB=93=9C(meaningKo=20=ED=8F=AC=ED=95=A8)=EB=A5=BC=20article?= =?UTF-8?q?=EC=97=90=20=EC=A0=80=EC=9E=A5=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../news/service/NewsAnalysisService.java | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java index f13712f0..87da58f1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java @@ -58,11 +58,7 @@ public NewsArticle analyzeArticle(NewsArticle article) { article.setCefrLevel(cefrLevel); article.setLevel(mapCefrToLevel(cefrLevel)); - // 2. 핵심 단어 추출 (Comprehend) - List keywords = extractKeywords(content); - article.setKeywords(keywords); - - // 3. 3줄 요약 + 퀴즈 생성 (Bedrock - 한 번에 처리) + // 2. 3줄 요약 + 키워드 + 퀴즈 생성 (Bedrock - 한 번에 처리) AnalysisResult result = generateSummaryAndQuiz(content, cefrLevel); if (result.summary() != null) { article.setSummary(result.summary()); @@ -70,6 +66,15 @@ public NewsArticle analyzeArticle(NewsArticle article) { article.setQuiz(result.quiz()); article.setHighlightWords(result.highlightWords()); + // Bedrock 키워드 사용 (meaningKo 포함) + if (result.keywords() != null && !result.keywords().isEmpty()) { + article.setKeywords(result.keywords()); + } else { + // fallback: Comprehend로 키워드 추출 + List keywords = extractKeywords(content); + article.setKeywords(keywords); + } + // 4. GSI 키 설정 article.setGsi1pk("LEVEL#" + article.getLevel()); article.setGsi1sk(article.getPublishedAt()); @@ -233,7 +238,7 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) return parseAnalysisResult(response); } catch (Exception e) { logger.error("요약/퀴즈 생성 실패", e); - return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>()); + return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>(), new ArrayList<>()); } } @@ -319,7 +324,7 @@ private AnalysisResult parseAnalysisResult(String response) { }); } - return new AnalysisResult(summary, highlightWords, quiz); + return new AnalysisResult(summary, keywords, highlightWords, quiz); } private String extractJson(String response) { @@ -341,6 +346,7 @@ private String truncate(String text, int maxLength) { */ private record AnalysisResult( String summary, + List keywords, List highlightWords, List quiz ) {} From e5cec27c1f8bce7548ad600603a4264b23f04e4c Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 17:08:47 +0900 Subject: [PATCH 03/10] feat: add real-time notification system with SNS/SQS and Lambda Streaming - Add NotificationTopic and NotificationQueue SNS/SQS infrastructure - Implement NotificationPublisher service for publishing notifications - Create NotificationStreamHandler for SSE via Lambda Function URL - Integrate badge earned notifications in BadgeService - Add daily study completion notifications in DailyStudyCommandService - Add test/quiz result notifications in TestCommandService and NewsQuizService - Add SQS client to AwsClients and JsonUtil helper methods Closes #500, #501, #502, #505, #506 --- ServerlessFunction/build.gradle | 1 + .../serverless/common/config/AwsClients.java | 12 +- .../serverless/common/config/EnvConfig.java | 13 +- .../serverless/common/util/JsonUtil.java | 20 +- .../domain/badge/service/BadgeService.java | 21 +- .../domain/news/service/NewsQuizService.java | 30 ++- .../notification/dto/NotificationMessage.java | 60 ++++++ .../notification/enums/NotificationType.java | 41 ++++ .../handler/NotificationStreamHandler.java | 187 ++++++++++++++++ .../service/NotificationPublisher.java | 202 ++++++++++++++++++ .../service/DailyStudyCommandService.java | 36 +++- .../service/TestCommandService.java | 29 ++- ServerlessFunction/template.yaml | 117 ++++++++++ 13 files changed, 743 insertions(+), 26 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/dto/NotificationMessage.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/enums/NotificationType.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/NotificationStreamHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/service/NotificationPublisher.java diff --git a/ServerlessFunction/build.gradle b/ServerlessFunction/build.gradle index cc5e6a12..34615a92 100644 --- a/ServerlessFunction/build.gradle +++ b/ServerlessFunction/build.gradle @@ -35,6 +35,7 @@ dependencies { implementation 'software.amazon.awssdk:url-connection-client' implementation 'software.amazon.awssdk:ssm' implementation 'software.amazon.awssdk:scheduler' + implementation 'software.amazon.awssdk:sqs' // AWS X-Ray SDK (다운스트림 서비스 추적용) implementation 'com.amazonaws:aws-xray-recorder-sdk-core:2.15.0' diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java index 05ad609a..a1d2286b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java @@ -11,6 +11,7 @@ import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.sns.SnsClient; +import software.amazon.awssdk.services.sqs.SqsClient; import software.amazon.awssdk.services.ssm.SsmClient; /** @@ -60,7 +61,12 @@ public final class AwsClients { private static final SsmClient SSM_CLIENT = SsmClient.builder() .overrideConfiguration(XRAY_CONFIG) .build(); - + + // SQS + private static final SqsClient SQS_CLIENT = SqsClient.builder() + .overrideConfiguration(XRAY_CONFIG) + .build(); + private AwsClients() { // 인스턴스화 방지 } @@ -104,4 +110,8 @@ public static ComprehendClient comprehend() { public static SsmClient ssm() { return SSM_CLIENT; } + + public static SqsClient sqs() { + return SQS_CLIENT; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/EnvConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/EnvConfig.java index c59f4930..75c7e1ec 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/EnvConfig.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/EnvConfig.java @@ -15,7 +15,18 @@ public final class EnvConfig { private EnvConfig() { // 유틸리티 클래스 - 인스턴스화 방지 } - + + /** + * 선택적 환경 변수를 가져옵니다. + * 환경 변수가 설정되지 않은 경우 null을 반환합니다. + * + * @param name 환경 변수 이름 + * @return 환경 변수 값 또는 null + */ + public static String get(String name) { + return System.getenv(name); + } + /** * 필수 환경 변수를 가져옵니다. * 환경 변수가 설정되지 않았거나 빈 문자열인 경우 IllegalStateException을 발생시킵니다. diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JsonUtil.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JsonUtil.java index 94685020..ad550303 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JsonUtil.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JsonUtil.java @@ -1,5 +1,7 @@ package com.mzc.secondproject.serverless.common.util; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; import com.google.gson.JsonArray; import com.google.gson.JsonElement; @@ -10,9 +12,25 @@ * JSON 파싱 관련 공통 유틸리티 */ public class JsonUtil { - + + private static final Gson GSON = new GsonBuilder().create(); + private JsonUtil() { } + + /** + * 객체를 JSON 문자열로 변환 + */ + public static String toJson(Object obj) { + return GSON.toJson(obj); + } + + /** + * JSON 문자열을 객체로 변환 + */ + public static T fromJson(String json, Class clazz) { + return GSON.fromJson(json, clazz); + } // 응답에서 JSON 부분만 추출 public static String extractJson(String response) { 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 0916a5d5..b7fbe77d 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 @@ -7,6 +7,7 @@ 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.notification.service.NotificationPublisher; import com.mzc.secondproject.serverless.domain.stats.model.UserStats; import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import org.slf4j.Logger; @@ -24,20 +25,23 @@ public class BadgeService { private final BadgeRepository badgeRepository; private final UserStatsRepository userStatsRepository; - + private final NotificationPublisher notificationPublisher; + /** * 기본 생성자 (Lambda에서 사용) */ public BadgeService() { - this(new BadgeRepository(), new UserStatsRepository()); + this(new BadgeRepository(), new UserStatsRepository(), NotificationPublisher.getInstance()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ - public BadgeService(BadgeRepository badgeRepository, UserStatsRepository userStatsRepository) { + public BadgeService(BadgeRepository badgeRepository, UserStatsRepository userStatsRepository, + NotificationPublisher notificationPublisher) { this.badgeRepository = badgeRepository; this.userStatsRepository = userStatsRepository; + this.notificationPublisher = notificationPublisher; } /** @@ -98,6 +102,15 @@ public List checkAndAwardBadges(String userId, UserStats stats) { badgeRepository.save(badge); newBadges.add(badge); logger.info("Badge awarded: userId={}, badge={}", userId, type.name()); + + // 알림 발행 + notificationPublisher.publishBadgeEarned( + userId, + type.name(), + type.getName(), + type.getDescription(), + badge.getImageUrl() + ); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java index 31c768c1..640d676e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java @@ -7,6 +7,7 @@ import com.mzc.secondproject.serverless.domain.news.model.QuizQuestion; import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; import com.mzc.secondproject.serverless.domain.news.repository.NewsQuizRepository; +import com.mzc.secondproject.serverless.domain.notification.service.NotificationPublisher; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,18 +21,22 @@ public class NewsQuizService { private static final Logger logger = LoggerFactory.getLogger(NewsQuizService.class); - + private final NewsArticleRepository articleRepository; private final NewsQuizRepository quizRepository; - + private final NotificationPublisher notificationPublisher; + public NewsQuizService() { this.articleRepository = new NewsArticleRepository(); this.quizRepository = new NewsQuizRepository(); + this.notificationPublisher = NotificationPublisher.getInstance(); } - - public NewsQuizService(NewsArticleRepository articleRepository, NewsQuizRepository quizRepository) { + + public NewsQuizService(NewsArticleRepository articleRepository, NewsQuizRepository quizRepository, + NotificationPublisher notificationPublisher) { this.articleRepository = articleRepository; this.quizRepository = quizRepository; + this.notificationPublisher = notificationPublisher; } /** @@ -157,10 +162,23 @@ public QuizSubmitResult submitQuiz(String userId, String articleId, List payload, + String createdAt +) { + /** + * Builder 패턴으로 알림 메시지 생성 + */ + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private NotificationType type; + private String userId; + private Map payload; + + public Builder type(NotificationType type) { + this.type = type; + return this; + } + + public Builder userId(String userId) { + this.userId = userId; + return this; + } + + public Builder payload(Map payload) { + this.payload = payload; + return this; + } + + public NotificationMessage build() { + return new NotificationMessage( + generateNotificationId(), + type, + userId, + payload, + Instant.now().toString() + ); + } + + private String generateNotificationId() { + return "notif-" + java.util.UUID.randomUUID().toString().substring(0, 8); + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/enums/NotificationType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/enums/NotificationType.java new file mode 100644 index 00000000..87cf3e8c --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/enums/NotificationType.java @@ -0,0 +1,41 @@ +package com.mzc.secondproject.serverless.domain.notification.enums; + +/** + * 알림 타입 정의 + * 새로운 알림 타입 추가 시 여기에 enum 추가 + */ +public enum NotificationType { + // 배지 관련 + BADGE_EARNED("배지 획득", "badge"), + + // 학습 관련 + DAILY_COMPLETE("일일 학습 완료", "daily"), + STREAK_REMINDER("연속 학습 리마인더", "streak"), + + // 테스트/퀴즈 관련 + TEST_COMPLETE("테스트 완료", "test"), + NEWS_QUIZ_COMPLETE("뉴스 퀴즈 완료", "quiz"), + + // 게임 관련 + GAME_END("게임 종료", "game"), + GAME_STREAK("게임 연속 정답", "game"), + + // OPIc 관련 + OPIC_COMPLETE("OPIc 세션 완료", "opic"); + + private final String description; + private final String category; + + NotificationType(String description, String category) { + this.description = description; + this.category = category; + } + + public String getDescription() { + return description; + } + + public String getCategory() { + return category; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/NotificationStreamHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/NotificationStreamHandler.java new file mode 100644 index 00000000..95d37965 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/NotificationStreamHandler.java @@ -0,0 +1,187 @@ +package com.mzc.secondproject.serverless.domain.notification.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestStreamHandler; +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.common.util.JsonUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.sqs.SqsClient; +import software.amazon.awssdk.services.sqs.model.DeleteMessageRequest; +import software.amazon.awssdk.services.sqs.model.Message; +import software.amazon.awssdk.services.sqs.model.ReceiveMessageRequest; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +/** + * SSE(Server-Sent Events) 알림 스트리밍 Lambda Handler + * Lambda Function URL with Response Streaming을 사용하여 실시간 알림 제공 + * + * 클라이언트 연결 예시: + * const eventSource = new EventSource('https://{function-url}/?userId={userId}'); + * eventSource.onmessage = (event) => console.log(JSON.parse(event.data)); + */ +public class NotificationStreamHandler implements RequestStreamHandler { + + private static final Logger logger = LoggerFactory.getLogger(NotificationStreamHandler.class); + private static final String QUEUE_URL = EnvConfig.get("NOTIFICATION_QUEUE_URL"); + private static final int POLL_INTERVAL_MS = 1000; + private static final int MAX_STREAM_DURATION_MS = 840000; // 14분 (Lambda 15분 제한 고려) + + private final SqsClient sqsClient; + + public NotificationStreamHandler() { + this.sqsClient = AwsClients.sqs(); + } + + public NotificationStreamHandler(SqsClient sqsClient) { + this.sqsClient = sqsClient; + } + + @Override + public void handleRequest(InputStream input, OutputStream output, Context context) throws IOException { + Map event = parseEvent(input); + String userId = extractUserId(event); + + if (userId == null || userId.isBlank()) { + sendErrorResponse(output, 400, "userId query parameter is required"); + return; + } + + logger.info("SSE connection started for userId: {}", userId); + + try (BufferedOutputStream bufferedOutput = new BufferedOutputStream(output)) { + writeSSEHeaders(bufferedOutput); + sendHeartbeat(bufferedOutput); + + long startTime = System.currentTimeMillis(); + + while (!isTimeoutReached(startTime)) { + List messages = pollMessages(userId); + + for (Message message : messages) { + if (isMessageForUser(message, userId)) { + sendSSEEvent(bufferedOutput, message.body()); + deleteMessage(message); + } + } + + if (messages.isEmpty()) { + sendHeartbeat(bufferedOutput); + } + + sleep(POLL_INTERVAL_MS); + } + + sendSSEEvent(bufferedOutput, "{\"type\":\"STREAM_END\",\"message\":\"Connection timeout\"}"); + logger.info("SSE connection ended for userId: {} (timeout)", userId); + + } catch (Exception e) { + logger.error("SSE stream error for userId: {}", userId, e); + } + } + + private Map parseEvent(InputStream input) throws IOException { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + return JsonUtil.fromJson(sb.toString(), Map.class); + } + } + + @SuppressWarnings("unchecked") + private String extractUserId(Map event) { + Object queryParams = event.get("queryStringParameters"); + if (queryParams instanceof Map) { + Object userId = ((Map) queryParams).get("userId"); + return userId != null ? userId.toString() : null; + } + return null; + } + + private void writeSSEHeaders(OutputStream output) throws IOException { + String headers = "HTTP/1.1 200 OK\r\n" + + "Content-Type: text/event-stream\r\n" + + "Cache-Control: no-cache\r\n" + + "Connection: keep-alive\r\n" + + "Access-Control-Allow-Origin: *\r\n" + + "\r\n"; + output.write(headers.getBytes(StandardCharsets.UTF_8)); + output.flush(); + } + + private void sendSSEEvent(OutputStream output, String data) throws IOException { + String event = "data: " + data + "\n\n"; + output.write(event.getBytes(StandardCharsets.UTF_8)); + output.flush(); + } + + private void sendHeartbeat(OutputStream output) throws IOException { + sendSSEEvent(output, "{\"type\":\"HEARTBEAT\",\"timestamp\":" + System.currentTimeMillis() + "}"); + } + + private void sendErrorResponse(OutputStream output, int statusCode, String message) throws IOException { + String response = JsonUtil.toJson(Map.of( + "statusCode", statusCode, + "body", JsonUtil.toJson(Map.of("error", message)) + )); + output.write(response.getBytes(StandardCharsets.UTF_8)); + output.flush(); + } + + private List pollMessages(String userId) { + try { + ReceiveMessageRequest request = ReceiveMessageRequest.builder() + .queueUrl(QUEUE_URL) + .maxNumberOfMessages(10) + .waitTimeSeconds(1) + .messageAttributeNames("userId", "type") + .build(); + + return sqsClient.receiveMessage(request).messages(); + } catch (Exception e) { + logger.warn("Failed to poll messages: {}", e.getMessage()); + return List.of(); + } + } + + private boolean isMessageForUser(Message message, String targetUserId) { + try { + Map body = JsonUtil.fromJson(message.body(), Map.class); + String messageUserId = (String) body.get("userId"); + return targetUserId.equals(messageUserId); + } catch (Exception e) { + return false; + } + } + + private void deleteMessage(Message message) { + try { + sqsClient.deleteMessage(DeleteMessageRequest.builder() + .queueUrl(QUEUE_URL) + .receiptHandle(message.receiptHandle()) + .build()); + } catch (Exception e) { + logger.warn("Failed to delete message: {}", e.getMessage()); + } + } + + private boolean isTimeoutReached(long startTime) { + return (System.currentTimeMillis() - startTime) > MAX_STREAM_DURATION_MS; + } + + private void sleep(int millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/service/NotificationPublisher.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/service/NotificationPublisher.java new file mode 100644 index 00000000..eab67e97 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/service/NotificationPublisher.java @@ -0,0 +1,202 @@ +package com.mzc.secondproject.serverless.domain.notification.service; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.common.util.JsonUtil; +import com.mzc.secondproject.serverless.domain.notification.dto.NotificationMessage; +import com.mzc.secondproject.serverless.domain.notification.enums.NotificationType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.sns.SnsClient; +import software.amazon.awssdk.services.sns.model.MessageAttributeValue; +import software.amazon.awssdk.services.sns.model.PublishRequest; +import software.amazon.awssdk.services.sns.model.PublishResponse; + +import java.util.Map; + +/** + * 알림 발행 서비스 + * SNS 토픽에 알림 메시지를 발행하는 역할 + * + * 사용 예시: + *
+ * NotificationPublisher.getInstance().publish(
+ *     NotificationType.BADGE_EARNED,
+ *     userId,
+ *     Map.of("badgeType", "STREAK_7", "badgeName", "7일 연속 학습")
+ * );
+ * 
+ */ +public class NotificationPublisher { + + private static final Logger logger = LoggerFactory.getLogger(NotificationPublisher.class); + private static final String TOPIC_ARN = EnvConfig.get("NOTIFICATION_TOPIC_ARN"); + + private static volatile NotificationPublisher instance; + private final SnsClient snsClient; + + private NotificationPublisher() { + this.snsClient = AwsClients.sns(); + } + + private NotificationPublisher(SnsClient snsClient) { + this.snsClient = snsClient; + } + + /** + * 싱글톤 인스턴스 반환 + */ + public static NotificationPublisher getInstance() { + if (instance == null) { + synchronized (NotificationPublisher.class) { + if (instance == null) { + instance = new NotificationPublisher(); + } + } + } + return instance; + } + + /** + * 테스트용 인스턴스 생성 + */ + public static NotificationPublisher createForTest(SnsClient snsClient) { + return new NotificationPublisher(snsClient); + } + + /** + * 알림 발행 (비동기, non-blocking) + * 발행 실패 시에도 호출자의 비즈니스 로직에 영향을 주지 않음 + * + * @param type 알림 타입 + * @param userId 대상 사용자 ID + * @param payload 알림 페이로드 + */ + public void publish(NotificationType type, String userId, Map payload) { + if (TOPIC_ARN == null || TOPIC_ARN.isBlank()) { + logger.warn("NOTIFICATION_TOPIC_ARN is not configured. Skipping notification."); + return; + } + + try { + NotificationMessage message = NotificationMessage.builder() + .type(type) + .userId(userId) + .payload(payload) + .build(); + + String messageJson = JsonUtil.toJson(message); + + PublishRequest request = PublishRequest.builder() + .topicArn(TOPIC_ARN) + .message(messageJson) + .messageAttributes(Map.of( + "type", MessageAttributeValue.builder() + .dataType("String") + .stringValue(type.name()) + .build(), + "userId", MessageAttributeValue.builder() + .dataType("String") + .stringValue(userId) + .build(), + "category", MessageAttributeValue.builder() + .dataType("String") + .stringValue(type.getCategory()) + .build() + )) + .build(); + + PublishResponse response = snsClient.publish(request); + logger.info("Notification published: type={}, userId={}, messageId={}", + type, userId, response.messageId()); + + } catch (Exception e) { + // 알림 발행 실패는 비즈니스 로직에 영향을 주지 않도록 로깅만 수행 + logger.error("Failed to publish notification: type={}, userId={}, error={}", + type, userId, e.getMessage()); + } + } + + /** + * 배지 획득 알림 발행 헬퍼 메서드 + */ + public void publishBadgeEarned(String userId, String badgeType, String badgeName, + String description, String iconUrl) { + publish(NotificationType.BADGE_EARNED, userId, Map.of( + "badgeType", badgeType, + "badgeName", badgeName, + "description", description, + "iconUrl", iconUrl != null ? iconUrl : "" + )); + } + + /** + * 일일 학습 완료 알림 발행 헬퍼 메서드 + */ + public void publishDailyComplete(String userId, String date, int wordsLearned, + int totalWords, int currentStreak) { + publish(NotificationType.DAILY_COMPLETE, userId, Map.of( + "date", date, + "wordsLearned", wordsLearned, + "totalWords", totalWords, + "currentStreak", currentStreak + )); + } + + /** + * 테스트 완료 알림 발행 헬퍼 메서드 + */ + public void publishTestComplete(String userId, String testId, int score, + int correctCount, int totalCount, boolean isPerfect) { + publish(NotificationType.TEST_COMPLETE, userId, Map.of( + "testId", testId, + "score", score, + "correctCount", correctCount, + "totalCount", totalCount, + "isPerfect", isPerfect + )); + } + + /** + * 뉴스 퀴즈 완료 알림 발행 헬퍼 메서드 + */ + public void publishNewsQuizComplete(String userId, String articleId, String articleTitle, + int score, int correctCount, int totalCount, boolean isPerfect) { + publish(NotificationType.NEWS_QUIZ_COMPLETE, userId, Map.of( + "articleId", articleId, + "articleTitle", articleTitle, + "score", score, + "correctCount", correctCount, + "totalCount", totalCount, + "isPerfect", isPerfect + )); + } + + /** + * 게임 종료 알림 발행 헬퍼 메서드 + */ + public void publishGameEnd(String userId, String roomId, String gameSessionId, + int rank, int totalPlayers, int score, boolean isWinner) { + publish(NotificationType.GAME_END, userId, Map.of( + "roomId", roomId, + "gameSessionId", gameSessionId, + "rank", rank, + "totalPlayers", totalPlayers, + "score", score, + "isWinner", isWinner + )); + } + + /** + * OPIc 세션 완료 알림 발행 헬퍼 메서드 + */ + public void publishOpicComplete(String userId, String sessionId, String estimatedLevel, + int questionsAnswered, String feedbackSummary) { + publish(NotificationType.OPIC_COMPLETE, userId, Map.of( + "sessionId", sessionId, + "estimatedLevel", estimatedLevel, + "questionsAnswered", questionsAnswered, + "feedbackSummary", feedbackSummary != null ? feedbackSummary : "" + )); + } +} 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 32dc5b24..81a528c6 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 @@ -3,6 +3,7 @@ import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.enums.StudyLevel; import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; +import com.mzc.secondproject.serverless.domain.notification.service.NotificationPublisher; import com.mzc.secondproject.serverless.domain.stats.model.UserStats; import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import com.mzc.secondproject.serverless.domain.vocabulary.config.VocabularyConfig; @@ -34,15 +35,16 @@ public class DailyStudyCommandService { private final WordRepository wordRepository; private final UserStatsRepository userStatsRepository; private final BadgeService badgeService; - + private final NotificationPublisher notificationPublisher; + /** * 기본 생성자 (Lambda에서 사용) */ public DailyStudyCommandService() { this(new DailyStudyRepository(), new UserWordRepository(), new WordRepository(), - new UserStatsRepository(), new BadgeService()); + new UserStatsRepository(), new BadgeService(), NotificationPublisher.getInstance()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -50,12 +52,14 @@ public DailyStudyCommandService(DailyStudyRepository dailyStudyRepository, UserWordRepository userWordRepository, WordRepository wordRepository, UserStatsRepository userStatsRepository, - BadgeService badgeService) { + BadgeService badgeService, + NotificationPublisher notificationPublisher) { this.dailyStudyRepository = dailyStudyRepository; this.userWordRepository = userWordRepository; this.wordRepository = wordRepository; this.userStatsRepository = userStatsRepository; this.badgeService = badgeService; + this.notificationPublisher = notificationPublisher; } public DailyStudyResult getDailyWords(String userId, String level) { @@ -115,16 +119,36 @@ public Map markWordLearned(String userId, String wordId) { checkWordsBadge(userId); DailyStudy updatedDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today).orElse(dailyStudy); - + if (updatedDailyStudy.getLearnedCount() >= updatedDailyStudy.getTotalWords()) { updatedDailyStudy.setIsCompleted(true); dailyStudyRepository.save(updatedDailyStudy); + + // 일일 학습 완료 알림 발행 + int currentStreak = getCurrentStreak(userId); + notificationPublisher.publishDailyComplete( + userId, + today, + updatedDailyStudy.getLearnedCount(), + updatedDailyStudy.getTotalWords(), + currentStreak + ); } - + logger.info("Marked word as learned: userId={}, wordId={}, isNew={}, isReview={}", userId, wordId, isNewWord, isReviewWord); return calculateProgress(updatedDailyStudy); } + + private int getCurrentStreak(String userId) { + try { + Optional stats = userStatsRepository.findTotalStats(userId); + return stats.map(UserStats::getCurrentStreak).orElse(0); + } catch (Exception e) { + logger.warn("Failed to get current streak for user: {}", userId, e); + return 0; + } + } private DailyStudy createDailyStudy(String userId, String date, String level) { String now = Instant.now().toString(); 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 43c0c095..f9ef6861 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 @@ -4,6 +4,7 @@ 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.notification.service.NotificationPublisher; import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.SubmitTestRequest; import com.mzc.secondproject.serverless.domain.vocabulary.exception.VocabularyException; import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; @@ -33,26 +34,29 @@ public class TestCommandService { private final DailyStudyRepository dailyStudyRepository; private final WordRepository wordRepository; private final UserWordCommandService userWordCommandService; - + private final NotificationPublisher notificationPublisher; + /** * 기본 생성자 (Lambda에서 사용) */ public TestCommandService() { this(new TestResultRepository(), new DailyStudyRepository(), - new WordRepository(), new UserWordCommandService()); + new WordRepository(), new UserWordCommandService(), NotificationPublisher.getInstance()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ public TestCommandService(TestResultRepository testResultRepository, DailyStudyRepository dailyStudyRepository, WordRepository wordRepository, - UserWordCommandService userWordCommandService) { + UserWordCommandService userWordCommandService, + NotificationPublisher notificationPublisher) { this.testResultRepository = testResultRepository; this.dailyStudyRepository = dailyStudyRepository; this.wordRepository = wordRepository; this.userWordCommandService = userWordCommandService; + this.notificationPublisher = notificationPublisher; } public StartTestResult startTest(String userId, String testType) { @@ -116,12 +120,23 @@ public SubmitTestResult submitTest(String userId, String testId, String testType // 3. 오답 단어 자동 북마크 bookmarkIncorrectWords(userId, gradingResult.incorrectWordIds()); - // 4. SNS 알림 발행 + // 4. SNS 알림 발행 (통계 업데이트용) publishTestResultToSns(userId, gradingResult.results()); - + + // 5. 실시간 알림 발행 + boolean isPerfect = gradingResult.correctCount() == gradingResult.totalQuestions(); + notificationPublisher.publishTestComplete( + userId, + testId, + (int) Math.round(gradingResult.successRate()), + gradingResult.correctCount(), + gradingResult.totalQuestions(), + isPerfect + ); + logger.info("Test submitted: userId={}, testId={}, successRate={}%", userId, testId, gradingResult.successRate()); - + return new SubmitTestResult( testId, testType, gradingResult.totalQuestions(), gradingResult.correctCount(), gradingResult.incorrectCount(), diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 3c0134d7..60cb90b6 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -291,6 +291,7 @@ Resources: WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" GAME_AUTO_CLOSE_LAMBDA_ARN: !GetAtt GameAutoCloseFunction.Arn SCHEDULER_ROLE_ARN: !GetAtt GameSchedulerRole.Arn + NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -314,6 +315,8 @@ Resources: Action: - iam:PassRole Resource: !GetAtt GameSchedulerRole.Arn + - SNSPublishMessagePolicy: + TopicName: !GetAtt NotificationTopic.TopicName WebSocketMessagePermission: Type: AWS::Lambda::Permission @@ -865,9 +868,14 @@ Resources: Description: Handle daily study word assignment SnapStart: ApplyOn: PublishedVersions + Environment: + Variables: + NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic Policies: - DynamoDBCrudPolicy: TableName: !Ref VocabTable + - SNSPublishMessagePolicy: + TopicName: !GetAtt NotificationTopic.TopicName Events: GetDailyWords: Type: Api @@ -898,11 +906,14 @@ Resources: Environment: Variables: TEST_RESULT_TOPIC_ARN: !Ref TestResultTopic + NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic Policies: - DynamoDBCrudPolicy: TableName: !Ref VocabTable - SNSPublishMessagePolicy: TopicName: !GetAtt TestResultTopic.TopicName + - SNSPublishMessagePolicy: + TopicName: !GetAtt NotificationTopic.TopicName Events: StartTest: Type: Api @@ -1115,11 +1126,16 @@ Resources: Description: Handle user badges and achievements SnapStart: ApplyOn: PublishedVersions + Environment: + Variables: + NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic Policies: - DynamoDBCrudPolicy: TableName: !Ref VocabTable - S3ReadPolicy: BucketName: !Sub "${AWS::StackName}" + - SNSPublishMessagePolicy: + TopicName: !GetAtt NotificationTopic.TopicName Events: GetAllBadges: Type: Api @@ -1415,6 +1431,7 @@ Resources: Environment: Variables: TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" + NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic Policies: - DynamoDBCrudPolicy: TableName: !Ref OPIcTable @@ -1438,6 +1455,8 @@ Resources: Action: - ssm:GetParameter Resource: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/opic/*" + - SNSPublishMessagePolicy: + TopicName: !GetAtt NotificationTopic.TopicName Events: # 세션 생성 CreateSession: @@ -1722,6 +1741,9 @@ Resources: Description: 뉴스 학습 API MemorySize: 256 Timeout: 30 + Environment: + Variables: + NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic Policies: - DynamoDBCrudPolicy: TableName: !Ref NewsTable @@ -1729,6 +1751,8 @@ Resources: TableName: !Ref VocabTable - S3CrudPolicy: BucketName: !Sub "${AWS::StackName}" + - SNSPublishMessagePolicy: + TopicName: !GetAtt NotificationTopic.TopicName - Statement: - Effect: Allow Action: @@ -1962,6 +1986,59 @@ Resources: Endpoint: !GetAtt StatisticsQueue.Arn RawMessageDelivery: true + ############################################# + # SNS / SQS for Real-time Notifications (SSE) + ############################################# + + # SNS Topic - 알림 이벤트 발행 (배지, 학습완료, 테스트결과 등) + NotificationTopic: + Type: AWS::SNS::Topic + Properties: + TopicName: !Sub "${AWS::StackName}-notification-topic" + + # SQS Dead Letter Queue - 실패한 알림 메시지 보관 + NotificationDeadLetterQueue: + Type: AWS::SQS::Queue + Properties: + QueueName: !Sub "${AWS::StackName}-notification-dlq" + MessageRetentionPeriod: 1209600 # 14일 + + # SQS Queue - SSE 알림 처리용 + NotificationQueue: + Type: AWS::SQS::Queue + Properties: + QueueName: !Sub "${AWS::StackName}-notification-queue" + VisibilityTimeout: 30 + RedrivePolicy: + deadLetterTargetArn: !GetAtt NotificationDeadLetterQueue.Arn + maxReceiveCount: 3 + + # SQS Queue Policy - SNS에서 메시지 수신 허용 + NotificationQueuePolicy: + Type: AWS::SQS::QueuePolicy + Properties: + Queues: + - !Ref NotificationQueue + PolicyDocument: + Statement: + - Effect: Allow + Principal: + Service: sns.amazonaws.com + Action: sqs:SendMessage + Resource: !GetAtt NotificationQueue.Arn + Condition: + ArnEquals: + aws:SourceArn: !Ref NotificationTopic + + # SNS → SQS 구독 + NotificationQueueSubscription: + Type: AWS::SNS::Subscription + Properties: + Protocol: sqs + TopicArn: !Ref NotificationTopic + Endpoint: !GetAtt NotificationQueue.Arn + RawMessageDelivery: true + # Statistics Processor Lambda - SQS에서 메시지 소비하여 통계 업데이트 StatisticsProcessorFunction: Type: AWS::Serverless::Function @@ -1985,6 +2062,38 @@ Resources: Queue: !GetAtt StatisticsQueue.Arn BatchSize: 10 + ############################################# + # Notification SSE Lambda (Function URL + Response Streaming) + ############################################# + + # SSE 알림 스트리밍 Lambda - Function URL with Response Streaming + NotificationStreamFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-notification-stream" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.notification.handler.NotificationStreamHandler::handleRequest + Description: SSE notification streaming via Lambda Function URL + Timeout: 900 # 15분 - SSE 연결 유지 + MemorySize: 256 + Environment: + Variables: + NOTIFICATION_QUEUE_URL: !Ref NotificationQueue + Policies: + - SQSPollerPolicy: + QueueName: !GetAtt NotificationQueue.QueueName + FunctionUrlConfig: + AuthType: NONE + InvokeMode: RESPONSE_STREAM + Cors: + AllowCredentials: false + AllowHeaders: + - "*" + AllowMethods: + - GET + AllowOrigins: + - "*" + ############################################# # Outputs ############################################# @@ -2025,3 +2134,11 @@ Outputs: OPIcTableName: Description: OPIc DynamoDB Table Name Value: !Ref OPIcTable + + NotificationStreamUrl: + Description: Notification SSE Stream Function URL + Value: !GetAtt NotificationStreamFunctionUrl.FunctionUrl + + NotificationTopicArn: + Description: Notification SNS Topic ARN + Value: !Ref NotificationTopic From 6ae993621127fc27049df18a325c0e5d0f5635fd Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 17:17:17 +0900 Subject: [PATCH 04/10] feat: add streak reminder and game end notifications --- .../domain/chatting/service/GameService.java | 55 +++++++-- .../handler/StreakReminderHandler.java | 110 ++++++++++++++++++ .../stats/repository/UserStatsRepository.java | 25 ++++ .../repository/DailyStudyRepository.java | 22 ++++ ServerlessFunction/template.yaml | 29 +++++ 5 files changed, 233 insertions(+), 8 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/StreakReminderHandler.java 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 a82c16d2..932335c3 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 @@ -13,6 +13,7 @@ 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.notification.service.NotificationPublisher; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; import org.slf4j.Logger; @@ -37,23 +38,25 @@ public class GameService { private final WordRepository wordRepository; private final GameStatsService gameStatsService; private final GameSchedulerClient gameSchedulerClient; - + private final NotificationPublisher notificationPublisher; + /** * 기본 생성자 (Lambda에서 사용) */ public GameService() { this(new ChatRoomRepository(), new ConnectionRepository(), new GameRoundRepository(), new GameSessionRepository(), - new WordRepository(), new GameStatsService(), new GameSchedulerClient()); + new WordRepository(), new GameStatsService(), new GameSchedulerClient(), + NotificationPublisher.getInstance()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ public GameService(ChatRoomRepository chatRoomRepository, ConnectionRepository connectionRepository, GameRoundRepository gameRoundRepository, GameSessionRepository gameSessionRepository, WordRepository wordRepository, GameStatsService gameStatsService, - GameSchedulerClient gameSchedulerClient) { + GameSchedulerClient gameSchedulerClient, NotificationPublisher notificationPublisher) { this.chatRoomRepository = chatRoomRepository; this.connectionRepository = connectionRepository; this.gameRoundRepository = gameRoundRepository; @@ -61,6 +64,7 @@ public GameService(ChatRoomRepository chatRoomRepository, ConnectionRepository c this.wordRepository = wordRepository; this.gameStatsService = gameStatsService; this.gameSchedulerClient = gameSchedulerClient; + this.notificationPublisher = notificationPublisher; } /** @@ -508,7 +512,10 @@ 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()); } - + + // 게임 종료 알림 발행 (각 플레이어별) + publishGameEndNotifications(session, room.getRoomId()); + // 최종 점수 정렬 StringBuilder sb = new StringBuilder("🎮 게임 종료!\n\n📊 최종 순위:\n"); if (session.getScores() != null && !session.getScores().isEmpty()) { @@ -698,11 +705,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,7 +720,39 @@ private List> buildRankingList(Map scores) } return ranking; } - + + /** + * 게임 종료 알림 발행 + */ + private void publishGameEndNotifications(GameSession session, String roomId) { + if (session.getScores() == null || session.getScores().isEmpty()) { + return; + } + + List> sorted = session.getScores().entrySet().stream() + .sorted((a, b) -> b.getValue().compareTo(a.getValue())) + .toList(); + + int totalPlayers = sorted.size(); + + for (int i = 0; i < sorted.size(); i++) { + int rank = i + 1; + String userId = sorted.get(i).getKey(); + int score = sorted.get(i).getValue(); + boolean isWinner = rank == 1; + + notificationPublisher.publishGameEnd( + userId, + roomId, + session.getGameSessionId(), + rank, + totalPlayers, + score, + isWinner + ); + } + } + // ========== Result DTOs ========== public record GameStartResult( diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/StreakReminderHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/StreakReminderHandler.java new file mode 100644 index 00000000..edca7faa --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/StreakReminderHandler.java @@ -0,0 +1,110 @@ +package com.mzc.secondproject.serverless.domain.notification.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.ScheduledEvent; +import com.mzc.secondproject.serverless.domain.notification.enums.NotificationType; +import com.mzc.secondproject.serverless.domain.notification.service.NotificationPublisher; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; +import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.DailyStudyRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 연속 학습 리마인더 Lambda Handler + * EventBridge 스케줄러에 의해 매일 21시(KST)에 트리거 + * 오늘 학습하지 않은 사용자 중 연속 학습 중인 사용자에게 알림 발송 + */ +public class StreakReminderHandler implements RequestHandler> { + + private static final Logger logger = LoggerFactory.getLogger(StreakReminderHandler.class); + + private final DailyStudyRepository dailyStudyRepository; + private final UserStatsRepository userStatsRepository; + private final NotificationPublisher notificationPublisher; + + public StreakReminderHandler() { + this.dailyStudyRepository = new DailyStudyRepository(); + this.userStatsRepository = new UserStatsRepository(); + this.notificationPublisher = NotificationPublisher.getInstance(); + } + + public StreakReminderHandler(DailyStudyRepository dailyStudyRepository, + UserStatsRepository userStatsRepository, + NotificationPublisher notificationPublisher) { + this.dailyStudyRepository = dailyStudyRepository; + this.userStatsRepository = userStatsRepository; + this.notificationPublisher = notificationPublisher; + } + + @Override + public Map handleRequest(ScheduledEvent event, Context context) { + logger.info("Streak reminder started - requestId: {}", context.getAwsRequestId()); + + String today = LocalDate.now().toString(); + int remindersSent = 0; + + try { + // 1. 오늘 학습한 사용자 목록 조회 + List todayStudies = dailyStudyRepository.findByDate(today); + Set studiedUserIds = todayStudies.stream() + .filter(ds -> Boolean.TRUE.equals(ds.getIsCompleted())) + .map(DailyStudy::getUserId) + .collect(Collectors.toSet()); + + // 2. 연속 학습 중인 사용자 목록 조회 (streak >= 1) + List usersWithStreak = userStatsRepository.findUsersWithActiveStreak(); + + // 3. 오늘 학습하지 않은 연속 학습 사용자에게 알림 + for (UserStats stats : usersWithStreak) { + String userId = stats.getUserId(); + + if (studiedUserIds.contains(userId)) { + continue; + } + + int currentStreak = stats.getCurrentStreak(); + if (currentStreak <= 0) { + continue; + } + + // 알림 발송 + notificationPublisher.publish( + NotificationType.STREAK_REMINDER, + userId, + Map.of( + "currentStreak", currentStreak, + "message", String.format("%d일 연속 학습 중! 오늘도 학습해서 기록을 이어가세요.", currentStreak) + ) + ); + + remindersSent++; + logger.debug("Streak reminder sent: userId={}, streak={}", userId, currentStreak); + } + + logger.info("Streak reminder completed - sent: {}", remindersSent); + + return Map.of( + "statusCode", 200, + "message", "Streak reminders sent", + "remindersSent", remindersSent + ); + + } catch (Exception e) { + logger.error("Streak reminder failed", e); + + return Map.of( + "statusCode", 500, + "message", "Streak reminder failed: " + e.getMessage() + ); + } + } +} 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 86b90969..4ec4174f 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 @@ -526,6 +526,31 @@ public UserStats incrementNewsWordStats(String userId, int wordCount) { return findTotalStats(userId).orElse(null); } + /** + * 연속 학습 중인 사용자 목록 조회 (streak >= 1) + * GSI1을 사용하여 TOTAL 통계만 조회 후 필터링 + */ + public List findUsersWithActiveStreak() { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder() + .partitionValue("STATS#ALL") + .sortValue(StatsKey.statsTotalSk()) + .build()); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .build(); + + List results = new ArrayList<>(); + table.index("GSI1").query(request).forEach(page -> { + page.items().stream() + .filter(stats -> stats.getCurrentStreak() != null && stats.getCurrentStreak() >= 1) + .forEach(results::add); + }); + + return results; + } + /** * 현재 연도-주차 반환 (예: 2026-W02) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java index 6cb33629..198a6849 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java @@ -17,7 +17,9 @@ import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -115,4 +117,24 @@ public void addLearnedWord(String userId, String date, String wordId) { AwsClients.dynamoDb().updateItem(updateRequest); logger.info("Added learned word: userId={}, date={}, wordId={}", userId, date, wordId); } + + /** + * 특정 날짜의 모든 일일 학습 기록 조회 (GSI1 사용) + */ + public List findByDate(String date) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder() + .partitionValue("DAILY#ALL") + .sortValue("DATE#" + date) + .build()); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .build(); + + List results = new ArrayList<>(); + table.index("GSI1").query(request).forEach(page -> results.addAll(page.items())); + + return results; + } } diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 60cb90b6..3fa504b5 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1389,6 +1389,35 @@ Resources: Principal: apigateway.amazonaws.com SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${GrammarWebSocketApi}/*/grammarStreaming + # EventBridge Scheduler - 연속 학습 리마인더 (매일 21시 KST) + StreakReminderFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-streak-reminder" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.notification.handler.StreakReminderHandler::handleRequest + Description: Daily streak reminder for users who haven't studied today + Timeout: 120 + MemorySize: 512 + SnapStart: + ApplyOn: PublishedVersions + Environment: + Variables: + NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic + Policies: + - DynamoDBReadPolicy: + TableName: !Ref VocabTable + - SNSPublishMessagePolicy: + TopicName: !GetAtt NotificationTopic.TopicName + Events: + DailySchedule: + Type: Schedule + Properties: + Schedule: cron(0 12 * * ? *) # UTC 12:00 = KST 21:00 + Name: !Sub "${AWS::StackName}-streak-reminder-schedule" + Description: Daily streak reminder at 21:00 KST + Enabled: true + # EventBridge Scheduler - 매일 자정 단어 학습 통계 집계 ScheduledStatsFunction: Type: AWS::Serverless::Function From e4058ca7b765fb1846451c2c38f1e6cbb8686edc Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 17:29:31 +0900 Subject: [PATCH 05/10] refactor: extract config classes and apply DRY principle to news/notification domains --- .../domain/news/config/NewsConfig.java | 83 +++++++ .../domain/news/constants/NewsKey.java | 12 + .../domain/news/handler/NewsHandler.java | 24 +- .../news/service/NewsLearningService.java | 235 +++++++++--------- .../domain/news/service/NewsQueryService.java | 47 ++-- .../domain/news/service/NewsQuizService.java | 17 +- .../config/NotificationConfig.java | 51 ++++ .../handler/NotificationStreamHandler.java | 82 +++--- .../handler/StreakReminderHandler.java | 113 +++++---- .../service/NotificationPublisher.java | 7 +- 10 files changed, 407 insertions(+), 264 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/config/NewsConfig.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/config/NotificationConfig.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/config/NewsConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/config/NewsConfig.java new file mode 100644 index 00000000..43435bb1 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/config/NewsConfig.java @@ -0,0 +1,83 @@ +package com.mzc.secondproject.serverless.domain.news.config; + +import com.mzc.secondproject.serverless.common.config.EnvConfig; + +/** + * 뉴스 도메인 설정 + * 상수 및 환경변수 관리 + */ +public final class NewsConfig { + + private NewsConfig() { + } + + // ========== Environment Variables ========== + private static final String BUCKET_NAME = EnvConfig.getOrDefault("NEWS_BUCKET_NAME", "group2-englishstudy"); + + // ========== TTS 설정 ========== + /** TTS 텍스트 최대 길이 */ + public static final int TTS_MAX_TEXT_LENGTH = 3000; + + /** TTS 오디오 저장 경로 */ + public static final String TTS_AUDIO_PREFIX = "news/audio/"; + + /** 기본 TTS 음성 */ + public static final String DEFAULT_VOICE = "Joanna"; + + // ========== 페이지네이션 ========== + /** 기본 페이지 크기 */ + public static final int DEFAULT_PAGE_SIZE = 10; + + /** 최대 페이지 크기 */ + public static final int MAX_PAGE_SIZE = 50; + + // ========== 퀴즈 피드백 ========== + public static final String FEEDBACK_PERFECT = "Perfect! You understood the article completely."; + public static final String FEEDBACK_GREAT = "Great job! You have a solid understanding of the article."; + public static final String FEEDBACK_GOOD = "Good effort! Review the highlighted words for better comprehension."; + public static final String FEEDBACK_KEEP_PRACTICING = "Keep practicing! Try reading the article again before retaking the quiz."; + public static final String FEEDBACK_DONT_GIVE_UP = "Don't give up! Focus on vocabulary and main ideas."; + + // ========== Score 기준 ========== + public static final int SCORE_PERFECT = 100; + public static final int SCORE_GREAT_THRESHOLD = 80; + public static final int SCORE_GOOD_THRESHOLD = 60; + public static final int SCORE_KEEP_PRACTICING_THRESHOLD = 40; + + // ========== Getter Methods ========== + public static String bucketName() { + return BUCKET_NAME; + } + + /** + * 점수에 따른 피드백 생성 + */ + public static String getFeedbackByScore(int score) { + if (score == SCORE_PERFECT) { + return FEEDBACK_PERFECT; + } else if (score >= SCORE_GREAT_THRESHOLD) { + return FEEDBACK_GREAT; + } else if (score >= SCORE_GOOD_THRESHOLD) { + return FEEDBACK_GOOD; + } else if (score >= SCORE_KEEP_PRACTICING_THRESHOLD) { + return FEEDBACK_KEEP_PRACTICING; + } else { + return FEEDBACK_DONT_GIVE_UP; + } + } + + /** + * limit 값 파싱 및 유효성 검증 + */ + public static int parseLimit(String limitStr) { + if (limitStr == null) { + return DEFAULT_PAGE_SIZE; + } + try { + int limit = Integer.parseInt(limitStr); + return Math.min(Math.max(limit, 1), MAX_PAGE_SIZE); + } catch (NumberFormatException e) { + return DEFAULT_PAGE_SIZE; + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java index eb1425d8..f5ca1969 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java @@ -126,4 +126,16 @@ public static String userNewsCommentsPk(String userId) { public static String userNewsStatPk(String userId) { return "USER_NEWS_STAT#" + userId; } + + // === Utility Methods === + + /** + * PK에서 날짜 추출 (NEWS#2024-01-15 → 2024-01-15) + */ + public static String extractDateFromPk(String pk) { + if (pk == null || !pk.startsWith(NEWS)) { + return null; + } + return pk.substring(NEWS.length()); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java index f806e2f2..421ecf37 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -4,14 +4,15 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonObject; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.router.HandlerRouter; import com.mzc.secondproject.serverless.common.router.Route; import com.mzc.secondproject.serverless.common.util.CognitoUtil; +import com.mzc.secondproject.serverless.common.util.JsonUtil; import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.domain.news.config.NewsConfig; import com.mzc.secondproject.serverless.domain.news.exception.NewsErrorCode; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; import com.mzc.secondproject.serverless.domain.news.model.NewsQuizResult; @@ -32,12 +33,9 @@ * 뉴스 학습 API 핸들러 */ public class NewsHandler implements RequestHandler { - + private static final Logger logger = LoggerFactory.getLogger(NewsHandler.class); - private static final int DEFAULT_LIMIT = 10; - private static final int MAX_LIMIT = 50; - private static final Gson gson = new Gson(); - + private final NewsQueryService queryService; private final NewsLearningService learningService; private final NewsQuizService quizService; @@ -226,13 +224,7 @@ private APIGatewayProxyResponseEvent buildPaginatedResponse(PaginatedResult markAsRead(String userId, String articleId) { - Optional article = articleRepository.findById(articleId); - if (article.isEmpty()) { + Optional articleOpt = articleRepository.findById(articleId); + if (articleOpt.isEmpty()) { logger.warn("기사를 찾을 수 없음: {}", articleId); - return new ArrayList<>(); + return List.of(); } - - // 이미 읽은 기사인지 확인 (중복 조회수 증가 방지) + if (userNewsRepository.hasRead(userId, articleId)) { logger.debug("이미 읽은 기사: userId={}, articleId={}", userId, articleId); - return new ArrayList<>(); - } - - NewsArticle a = article.get(); - userNewsRepository.saveReadRecord( - userId, - articleId, - a.getTitle(), - a.getLevel(), - a.getCategory() - ); - - // 조회수 증가 (새로운 읽기만) - String date = extractDateFromPk(a.getPk()); - if (date != null) { - articleRepository.incrementReadCount(date, articleId); + return List.of(); } - + + NewsArticle article = articleOpt.get(); + saveReadRecord(userId, article); + incrementArticleReadCount(article); + logger.info("읽기 완료 기록: userId={}, articleId={}", userId, articleId); - - // 통계 업데이트 및 배지 체크 - List newBadges = new ArrayList<>(); - try { - UserStats updatedStats = userStatsRepository.incrementNewsReadStats(userId); - if (updatedStats != null) { - newBadges = badgeService.checkAndAwardBadges(userId, updatedStats); - if (!newBadges.isEmpty()) { - logger.info("새 배지 획득: userId={}, badges={}", userId, - newBadges.stream().map(UserBadge::getBadgeType).toList()); - } - } - } catch (Exception e) { - logger.error("통계/배지 업데이트 실패: userId={}, error={}", userId, e.getMessage()); - } - - return newBadges; + + return updateStatsAndCheckBadges(userId); } - + /** * 북마크 토글 */ public boolean toggleBookmark(String userId, String articleId) { - boolean isBookmarked = userNewsRepository.isBookmarked(userId, articleId); - - if (isBookmarked) { + if (userNewsRepository.isBookmarked(userId, articleId)) { userNewsRepository.deleteBookmark(userId, articleId); logger.info("북마크 해제: userId={}, articleId={}", userId, articleId); return false; - } else { - Optional article = articleRepository.findById(articleId); - if (article.isEmpty()) { - logger.warn("기사를 찾을 수 없음: {}", articleId); - return false; - } - - NewsArticle a = article.get(); - userNewsRepository.saveBookmark( - userId, - articleId, - a.getTitle(), - a.getLevel(), - a.getCategory() - ); - logger.info("북마크 추가: userId={}, articleId={}", userId, articleId); - return true; } + + Optional articleOpt = articleRepository.findById(articleId); + if (articleOpt.isEmpty()) { + logger.warn("기사를 찾을 수 없음: {}", articleId); + return false; + } + + NewsArticle article = articleOpt.get(); + userNewsRepository.saveBookmark(userId, articleId, article.getTitle(), article.getLevel(), article.getCategory()); + logger.info("북마크 추가: userId={}, articleId={}", userId, articleId); + return true; } - + /** * 북마크 여부 확인 */ public boolean isBookmarked(String userId, String articleId) { return userNewsRepository.isBookmarked(userId, articleId); } - + /** * 읽기 여부 확인 */ public boolean hasRead(String userId, String articleId) { return userNewsRepository.hasRead(userId, articleId); } - + /** * 여러 기사의 북마크 여부 확인 (배치) */ public Set getBookmarkedArticleIds(String userId, List articleIds) { return userNewsRepository.getBookmarkedArticleIds(userId, articleIds); } - + /** * 사용자 북마크 목록 조회 (기사 정보 포함) */ public List> getUserBookmarks(String userId, int limit) { List bookmarks = userNewsRepository.getUserBookmarks(userId, limit); - List> result = new ArrayList<>(); - - for (UserNewsRecord bookmark : bookmarks) { - Optional articleOpt = articleRepository.findById(bookmark.getArticleId()); - if (articleOpt.isPresent()) { - NewsArticle article = articleOpt.get(); - Map bookmarkWithArticle = new HashMap<>(); - bookmarkWithArticle.put("articleId", article.getArticleId()); - bookmarkWithArticle.put("title", article.getTitle()); - bookmarkWithArticle.put("summary", article.getSummary()); - bookmarkWithArticle.put("source", article.getSource()); - bookmarkWithArticle.put("publishedAt", article.getPublishedAt()); - bookmarkWithArticle.put("keywords", article.getKeywords()); - bookmarkWithArticle.put("highlightWords", article.getHighlightWords()); - bookmarkWithArticle.put("category", article.getCategory()); - bookmarkWithArticle.put("level", article.getLevel()); - bookmarkWithArticle.put("imageUrl", article.getImageUrl()); - bookmarkWithArticle.put("bookmarkedAt", bookmark.getCreatedAt()); - result.add(bookmarkWithArticle); - } - } - return result; + + return bookmarks.stream() + .map(bookmark -> articleRepository.findById(bookmark.getArticleId()) + .map(article -> buildBookmarkResponse(article, bookmark)) + .orElse(null)) + .filter(Objects::nonNull) + .toList(); } - + /** * 뉴스 TTS 오디오 URL 생성 */ public String getAudioUrl(String articleId, String voice) { - Optional article = articleRepository.findById(articleId); - if (article.isEmpty()) { + Optional articleOpt = articleRepository.findById(articleId); + if (articleOpt.isEmpty()) { logger.warn("기사를 찾을 수 없음: {}", articleId); return null; } - - NewsArticle a = article.get(); - String text = a.getTitle() + ". " + (a.getSummary() != null ? a.getSummary() : ""); - - // 텍스트가 너무 길면 제한 - if (text.length() > 3000) { - text = text.substring(0, 3000); - } - + + NewsArticle article = articleOpt.get(); + String text = buildTtsText(article); + PollyService.VoiceSynthesisResult result = pollyService.synthesizeSpeech(articleId, text, voice); return result.getAudioUrl(); } - + /** * 사용자 뉴스 학습 통계 조회 */ public Map getUserStats(String userId) { UserNewsRepository.NewsStats stats = userNewsRepository.getUserStats(userId); - + return Map.of( "totalRead", stats.totalRead(), "thisWeekRead", stats.thisWeekRead(), @@ -218,14 +163,64 @@ public Map getUserStats(String userId) { "byCategory", stats.byCategory() ); } - - /** - * PK에서 날짜 추출 - */ - private String extractDateFromPk(String pk) { - if (pk == null || !pk.startsWith("NEWS#")) { - return null; + + // ========== Private Helper Methods ========== + + private void saveReadRecord(String userId, NewsArticle article) { + userNewsRepository.saveReadRecord( + userId, + article.getArticleId(), + article.getTitle(), + article.getLevel(), + article.getCategory() + ); + } + + private void incrementArticleReadCount(NewsArticle article) { + String date = NewsKey.extractDateFromPk(article.getPk()); + if (date != null) { + articleRepository.incrementReadCount(date, article.getArticleId()); + } + } + + private List updateStatsAndCheckBadges(String userId) { + try { + UserStats updatedStats = userStatsRepository.incrementNewsReadStats(userId); + if (updatedStats != null) { + List newBadges = badgeService.checkAndAwardBadges(userId, updatedStats); + if (!newBadges.isEmpty()) { + logger.info("새 배지 획득: userId={}, badges={}", + userId, newBadges.stream().map(UserBadge::getBadgeType).toList()); + } + return newBadges; + } + } catch (Exception e) { + logger.error("통계/배지 업데이트 실패: userId={}, error={}", userId, e.getMessage()); + } + return List.of(); + } + + private Map buildBookmarkResponse(NewsArticle article, UserNewsRecord bookmark) { + Map response = new HashMap<>(); + response.put("articleId", article.getArticleId()); + response.put("title", article.getTitle()); + response.put("summary", article.getSummary()); + response.put("source", article.getSource()); + response.put("publishedAt", article.getPublishedAt()); + response.put("keywords", article.getKeywords()); + response.put("highlightWords", article.getHighlightWords()); + response.put("category", article.getCategory()); + response.put("level", article.getLevel()); + response.put("imageUrl", article.getImageUrl()); + response.put("bookmarkedAt", bookmark.getCreatedAt()); + return response; + } + + private String buildTtsText(NewsArticle article) { + String text = article.getTitle() + ". " + (article.getSummary() != null ? article.getSummary() : ""); + if (text.length() > NewsConfig.TTS_MAX_TEXT_LENGTH) { + text = text.substring(0, NewsConfig.TTS_MAX_TEXT_LENGTH); } - return pk.substring(5); + return text; } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java index 7f25e408..c1a0f328 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.news.service; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; import org.slf4j.Logger; @@ -13,37 +14,31 @@ * 뉴스 조회 서비스 */ public class NewsQueryService { - + private static final Logger logger = LoggerFactory.getLogger(NewsQueryService.class); - + private final NewsArticleRepository articleRepository; - + public NewsQueryService() { this.articleRepository = new NewsArticleRepository(); } - + public NewsQueryService(NewsArticleRepository articleRepository) { this.articleRepository = articleRepository; } - + /** * 뉴스 상세 조회 */ public Optional getArticle(String articleId) { logger.debug("뉴스 상세 조회: {}", articleId); Optional article = articleRepository.findById(articleId); - - // 조회수 증가 - article.ifPresent(a -> { - String date = extractDateFromPk(a.getPk()); - if (date != null) { - articleRepository.incrementReadCount(date, articleId); - } - }); - + + article.ifPresent(this::incrementReadCount); + return article; } - + /** * 오늘의 뉴스 목록 조회 */ @@ -52,7 +47,7 @@ public PaginatedResult getTodayNews(int limit, String cursor) { logger.debug("오늘의 뉴스 조회: date={}, limit={}", today, limit); return articleRepository.findByDate(today, limit, cursor); } - + /** * 레벨별 뉴스 조회 */ @@ -60,7 +55,7 @@ public PaginatedResult getNewsByLevel(String level, int limit, Stri logger.debug("레벨별 뉴스 조회: level={}, limit={}", level, limit); return articleRepository.findByLevel(level, limit, cursor); } - + /** * 카테고리별 뉴스 조회 */ @@ -68,7 +63,7 @@ public PaginatedResult getNewsByCategory(String category, int limit logger.debug("카테고리별 뉴스 조회: category={}, limit={}", category, limit); return articleRepository.findByCategory(category, limit, cursor); } - + /** * 레벨 + 카테고리 복합 필터 조회 */ @@ -76,23 +71,19 @@ public PaginatedResult getNewsByLevelAndCategory(String level, Stri logger.debug("레벨+카테고리 뉴스 조회: level={}, category={}, limit={}", level, category, limit); return articleRepository.findByLevelAndCategory(level, category, limit, cursor); } - + /** * 사용자 레벨 맞춤 뉴스 추천 */ public PaginatedResult getRecommendedNews(String userLevel, int limit, String cursor) { logger.debug("맞춤 뉴스 추천: userLevel={}, limit={}", userLevel, limit); - // 사용자 레벨에 맞는 뉴스 조회 return articleRepository.findByLevel(userLevel, limit, cursor); } - - /** - * PK에서 날짜 추출 (NEWS#2024-01-15 → 2024-01-15) - */ - private String extractDateFromPk(String pk) { - if (pk == null || !pk.startsWith("NEWS#")) { - return null; + + private void incrementReadCount(NewsArticle article) { + String date = NewsKey.extractDateFromPk(article.getPk()); + if (date != null) { + articleRepository.incrementReadCount(date, article.getArticleId()); } - return pk.substring(5); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java index 640d676e..f84c3794 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java @@ -1,5 +1,6 @@ package com.mzc.secondproject.serverless.domain.news.service; +import com.mzc.secondproject.serverless.domain.news.config.NewsConfig; import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; import com.mzc.secondproject.serverless.domain.news.model.NewsQuizResult; @@ -177,7 +178,7 @@ public QuizSubmitResult submitQuiz(String userId, String articleId, List getUserQuizStats(String userId) { /** * 피드백 생성 */ - private String generateFeedback(int score, List results) { - if (score == 100) { - return "Perfect! You understood the article completely."; - } else if (score >= 80) { - return "Great job! You have a solid understanding of the article."; - } else if (score >= 60) { - return "Good effort! Review the highlighted words for better comprehension."; - } else if (score >= 40) { - return "Keep practicing! Try reading the article again before retaking the quiz."; - } else { - return "Don't give up! Focus on vocabulary and main ideas."; - } + private String generateFeedback(int score) { + return NewsConfig.getFeedbackByScore(score); } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/config/NotificationConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/config/NotificationConfig.java new file mode 100644 index 00000000..9bcbbb30 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/config/NotificationConfig.java @@ -0,0 +1,51 @@ +package com.mzc.secondproject.serverless.domain.notification.config; + +import com.mzc.secondproject.serverless.common.config.EnvConfig; + +/** + * 알림 시스템 설정 + * SSE 스트리밍, 폴링 등 알림 관련 상수 정의 + */ +public final class NotificationConfig { + + private NotificationConfig() { + } + + // ========== Environment Variables ========== + private static final String TOPIC_ARN = EnvConfig.get("NOTIFICATION_TOPIC_ARN"); + private static final String QUEUE_URL = EnvConfig.get("NOTIFICATION_QUEUE_URL"); + + // ========== SSE Streaming ========== + /** SSE 폴링 간격 (밀리초) */ + public static final int SSE_POLL_INTERVAL_MS = 1000; + + /** SSE 최대 스트림 지속 시간 (밀리초) - Lambda 15분 제한 고려 */ + public static final int SSE_MAX_DURATION_MS = 840_000; // 14분 + + /** SSE 최대 메시지 수신 개수 */ + public static final int SSE_MAX_MESSAGES_PER_POLL = 10; + + /** SSE 롱 폴링 대기 시간 (초) */ + public static final int SSE_WAIT_TIME_SECONDS = 1; + + // ========== SSE Event Types ========== + public static final String EVENT_HEARTBEAT = "HEARTBEAT"; + public static final String EVENT_STREAM_END = "STREAM_END"; + + // ========== Getter Methods ========== + public static String topicArn() { + return TOPIC_ARN; + } + + public static String queueUrl() { + return QUEUE_URL; + } + + public static boolean isTopicConfigured() { + return TOPIC_ARN != null && !TOPIC_ARN.isBlank(); + } + + public static boolean isQueueConfigured() { + return QUEUE_URL != null && !QUEUE_URL.isBlank(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/NotificationStreamHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/NotificationStreamHandler.java index 95d37965..385ddafb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/NotificationStreamHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/NotificationStreamHandler.java @@ -3,8 +3,8 @@ import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestStreamHandler; import com.mzc.secondproject.serverless.common.config.AwsClients; -import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.util.JsonUtil; +import com.mzc.secondproject.serverless.domain.notification.config.NotificationConfig; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.services.sqs.SqsClient; @@ -28,9 +28,6 @@ public class NotificationStreamHandler implements RequestStreamHandler { private static final Logger logger = LoggerFactory.getLogger(NotificationStreamHandler.class); - private static final String QUEUE_URL = EnvConfig.get("NOTIFICATION_QUEUE_URL"); - private static final int POLL_INTERVAL_MS = 1000; - private static final int MAX_STREAM_DURATION_MS = 840000; // 14분 (Lambda 15분 제한 고려) private final SqsClient sqsClient; @@ -52,37 +49,40 @@ public void handleRequest(InputStream input, OutputStream output, Context contex return; } - logger.info("SSE connection started for userId: {}", userId); + logger.info("SSE connection started: userId={}, requestId={}", userId, context.getAwsRequestId()); try (BufferedOutputStream bufferedOutput = new BufferedOutputStream(output)) { - writeSSEHeaders(bufferedOutput); - sendHeartbeat(bufferedOutput); + streamNotifications(bufferedOutput, userId); + } catch (Exception e) { + logger.error("SSE stream error: userId={}", userId, e); + } + } - long startTime = System.currentTimeMillis(); + private void streamNotifications(BufferedOutputStream output, String userId) throws IOException { + writeSSEHeaders(output); + sendHeartbeat(output); - while (!isTimeoutReached(startTime)) { - List messages = pollMessages(userId); + long startTime = System.currentTimeMillis(); - for (Message message : messages) { - if (isMessageForUser(message, userId)) { - sendSSEEvent(bufferedOutput, message.body()); - deleteMessage(message); - } - } + while (!isTimeoutReached(startTime)) { + List messages = pollMessages(); - if (messages.isEmpty()) { - sendHeartbeat(bufferedOutput); + for (Message message : messages) { + if (isMessageForUser(message, userId)) { + sendSSEEvent(output, message.body()); + deleteMessage(message); } - - sleep(POLL_INTERVAL_MS); } - sendSSEEvent(bufferedOutput, "{\"type\":\"STREAM_END\",\"message\":\"Connection timeout\"}"); - logger.info("SSE connection ended for userId: {} (timeout)", userId); + if (messages.isEmpty()) { + sendHeartbeat(output); + } - } catch (Exception e) { - logger.error("SSE stream error for userId: {}", userId, e); + sleep(); } + + sendStreamEndEvent(output); + logger.info("SSE connection ended: userId={} (timeout)", userId); } private Map parseEvent(InputStream input) throws IOException { @@ -124,7 +124,19 @@ private void sendSSEEvent(OutputStream output, String data) throws IOException { } private void sendHeartbeat(OutputStream output) throws IOException { - sendSSEEvent(output, "{\"type\":\"HEARTBEAT\",\"timestamp\":" + System.currentTimeMillis() + "}"); + String heartbeat = JsonUtil.toJson(Map.of( + "type", NotificationConfig.EVENT_HEARTBEAT, + "timestamp", System.currentTimeMillis() + )); + sendSSEEvent(output, heartbeat); + } + + private void sendStreamEndEvent(OutputStream output) throws IOException { + String endEvent = JsonUtil.toJson(Map.of( + "type", NotificationConfig.EVENT_STREAM_END, + "message", "Connection timeout" + )); + sendSSEEvent(output, endEvent); } private void sendErrorResponse(OutputStream output, int statusCode, String message) throws IOException { @@ -136,12 +148,16 @@ private void sendErrorResponse(OutputStream output, int statusCode, String messa output.flush(); } - private List pollMessages(String userId) { + private List pollMessages() { + if (!NotificationConfig.isQueueConfigured()) { + return List.of(); + } + try { ReceiveMessageRequest request = ReceiveMessageRequest.builder() - .queueUrl(QUEUE_URL) - .maxNumberOfMessages(10) - .waitTimeSeconds(1) + .queueUrl(NotificationConfig.queueUrl()) + .maxNumberOfMessages(NotificationConfig.SSE_MAX_MESSAGES_PER_POLL) + .waitTimeSeconds(NotificationConfig.SSE_WAIT_TIME_SECONDS) .messageAttributeNames("userId", "type") .build(); @@ -165,7 +181,7 @@ private boolean isMessageForUser(Message message, String targetUserId) { private void deleteMessage(Message message) { try { sqsClient.deleteMessage(DeleteMessageRequest.builder() - .queueUrl(QUEUE_URL) + .queueUrl(NotificationConfig.queueUrl()) .receiptHandle(message.receiptHandle()) .build()); } catch (Exception e) { @@ -174,12 +190,12 @@ private void deleteMessage(Message message) { } private boolean isTimeoutReached(long startTime) { - return (System.currentTimeMillis() - startTime) > MAX_STREAM_DURATION_MS; + return (System.currentTimeMillis() - startTime) > NotificationConfig.SSE_MAX_DURATION_MS; } - private void sleep(int millis) { + private void sleep() { try { - Thread.sleep(millis); + Thread.sleep(NotificationConfig.SSE_POLL_INTERVAL_MS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/StreakReminderHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/StreakReminderHandler.java index edca7faa..d1cbd30c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/StreakReminderHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/StreakReminderHandler.java @@ -23,7 +23,7 @@ * EventBridge 스케줄러에 의해 매일 21시(KST)에 트리거 * 오늘 학습하지 않은 사용자 중 연속 학습 중인 사용자에게 알림 발송 */ -public class StreakReminderHandler implements RequestHandler> { +public class StreakReminderHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(StreakReminderHandler.class); @@ -46,65 +46,78 @@ public StreakReminderHandler(DailyStudyRepository dailyStudyRepository, } @Override - public Map handleRequest(ScheduledEvent event, Context context) { - logger.info("Streak reminder started - requestId: {}", context.getAwsRequestId()); + public Response handleRequest(ScheduledEvent event, Context context) { + logger.info("Streak reminder started: requestId={}", context.getAwsRequestId()); + try { + int remindersSent = processReminders(); + logger.info("Streak reminder completed: sent={}", remindersSent); + return Response.success(remindersSent); + } catch (Exception e) { + logger.error("Streak reminder failed", e); + return Response.error(e.getMessage()); + } + } + + private int processReminders() { String today = LocalDate.now().toString(); - int remindersSent = 0; - try { - // 1. 오늘 학습한 사용자 목록 조회 - List todayStudies = dailyStudyRepository.findByDate(today); - Set studiedUserIds = todayStudies.stream() - .filter(ds -> Boolean.TRUE.equals(ds.getIsCompleted())) - .map(DailyStudy::getUserId) - .collect(Collectors.toSet()); - - // 2. 연속 학습 중인 사용자 목록 조회 (streak >= 1) - List usersWithStreak = userStatsRepository.findUsersWithActiveStreak(); - - // 3. 오늘 학습하지 않은 연속 학습 사용자에게 알림 - for (UserStats stats : usersWithStreak) { - String userId = stats.getUserId(); - - if (studiedUserIds.contains(userId)) { - continue; - } - - int currentStreak = stats.getCurrentStreak(); - if (currentStreak <= 0) { - continue; - } - - // 알림 발송 - notificationPublisher.publish( - NotificationType.STREAK_REMINDER, - userId, - Map.of( - "currentStreak", currentStreak, - "message", String.format("%d일 연속 학습 중! 오늘도 학습해서 기록을 이어가세요.", currentStreak) - ) - ); + Set studiedUserIds = findStudiedUserIds(today); + List usersWithStreak = userStatsRepository.findUsersWithActiveStreak(); + int remindersSent = 0; + for (UserStats stats : usersWithStreak) { + if (shouldSendReminder(stats, studiedUserIds)) { + sendReminder(stats); remindersSent++; - logger.debug("Streak reminder sent: userId={}, streak={}", userId, currentStreak); } + } - logger.info("Streak reminder completed - sent: {}", remindersSent); + return remindersSent; + } - return Map.of( - "statusCode", 200, - "message", "Streak reminders sent", - "remindersSent", remindersSent - ); + private Set findStudiedUserIds(String date) { + return dailyStudyRepository.findByDate(date).stream() + .filter(ds -> Boolean.TRUE.equals(ds.getIsCompleted())) + .map(DailyStudy::getUserId) + .collect(Collectors.toSet()); + } - } catch (Exception e) { - logger.error("Streak reminder failed", e); + private boolean shouldSendReminder(UserStats stats, Set studiedUserIds) { + if (studiedUserIds.contains(stats.getUserId())) { + return false; + } + Integer streak = stats.getCurrentStreak(); + return streak != null && streak > 0; + } + + private void sendReminder(UserStats stats) { + String userId = stats.getUserId(); + int streak = stats.getCurrentStreak(); + + notificationPublisher.publish( + NotificationType.STREAK_REMINDER, + userId, + Map.of( + "currentStreak", streak, + "message", String.format("%d일 연속 학습 중! 오늘도 학습해서 기록을 이어가세요.", streak) + ) + ); + + logger.debug("Streak reminder sent: userId={}, streak={}", userId, streak); + } + + /** + * Lambda 응답 DTO + */ + public record Response(int statusCode, String message, int remindersSent) { + + public static Response success(int remindersSent) { + return new Response(200, "Streak reminders sent", remindersSent); + } - return Map.of( - "statusCode", 500, - "message", "Streak reminder failed: " + e.getMessage() - ); + public static Response error(String errorMessage) { + return new Response(500, "Streak reminder failed: " + errorMessage, 0); } } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/service/NotificationPublisher.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/service/NotificationPublisher.java index eab67e97..53ee0ad0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/service/NotificationPublisher.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/service/NotificationPublisher.java @@ -1,8 +1,8 @@ package com.mzc.secondproject.serverless.domain.notification.service; import com.mzc.secondproject.serverless.common.config.AwsClients; -import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.util.JsonUtil; +import com.mzc.secondproject.serverless.domain.notification.config.NotificationConfig; import com.mzc.secondproject.serverless.domain.notification.dto.NotificationMessage; import com.mzc.secondproject.serverless.domain.notification.enums.NotificationType; import org.slf4j.Logger; @@ -30,7 +30,6 @@ public class NotificationPublisher { private static final Logger logger = LoggerFactory.getLogger(NotificationPublisher.class); - private static final String TOPIC_ARN = EnvConfig.get("NOTIFICATION_TOPIC_ARN"); private static volatile NotificationPublisher instance; private final SnsClient snsClient; @@ -73,7 +72,7 @@ public static NotificationPublisher createForTest(SnsClient snsClient) { * @param payload 알림 페이로드 */ public void publish(NotificationType type, String userId, Map payload) { - if (TOPIC_ARN == null || TOPIC_ARN.isBlank()) { + if (!NotificationConfig.isTopicConfigured()) { logger.warn("NOTIFICATION_TOPIC_ARN is not configured. Skipping notification."); return; } @@ -88,7 +87,7 @@ public void publish(NotificationType type, String userId, Map pa String messageJson = JsonUtil.toJson(message); PublishRequest request = PublishRequest.builder() - .topicArn(TOPIC_ARN) + .topicArn(NotificationConfig.topicArn()) .message(messageJson) .messageAttributes(Map.of( "type", MessageAttributeValue.builder() From c43e9089df331213e96c782581da7f347c064e21 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 17:33:33 +0900 Subject: [PATCH 06/10] test: add Spock specs for notification and news domain configs --- .../domain/news/config/NewsConfigSpec.groovy | 172 +++++++++++++++ .../domain/news/constants/NewsKeySpec.groovy | 202 ++++++++++++++++++ .../config/NotificationConfigSpec.groovy | 91 ++++++++ .../dto/NotificationMessageSpec.groovy | 158 ++++++++++++++ .../enums/NotificationTypeSpec.groovy | 110 ++++++++++ 5 files changed, 733 insertions(+) create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/news/config/NewsConfigSpec.groovy create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/news/constants/NewsKeySpec.groovy create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/config/NotificationConfigSpec.groovy create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/dto/NotificationMessageSpec.groovy create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/enums/NotificationTypeSpec.groovy diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/news/config/NewsConfigSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/news/config/NewsConfigSpec.groovy new file mode 100644 index 00000000..eac08089 --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/news/config/NewsConfigSpec.groovy @@ -0,0 +1,172 @@ +package com.mzc.secondproject.serverless.domain.news.config + +import spock.lang.Specification +import spock.lang.Unroll + +class NewsConfigSpec extends Specification { + + // ==================== TTS Constants Tests ==================== + + def "TTS_MAX_TEXT_LENGTH: TTS 최대 텍스트 길이는 3000자"() { + expect: + NewsConfig.TTS_MAX_TEXT_LENGTH == 3000 + } + + def "TTS_AUDIO_PREFIX: TTS 오디오 저장 경로 확인"() { + expect: + NewsConfig.TTS_AUDIO_PREFIX == "news/audio/" + } + + def "DEFAULT_VOICE: 기본 TTS 음성은 Joanna"() { + expect: + NewsConfig.DEFAULT_VOICE == "Joanna" + } + + // ==================== Pagination Constants Tests ==================== + + def "DEFAULT_PAGE_SIZE: 기본 페이지 크기는 10"() { + expect: + NewsConfig.DEFAULT_PAGE_SIZE == 10 + } + + def "MAX_PAGE_SIZE: 최대 페이지 크기는 50"() { + expect: + NewsConfig.MAX_PAGE_SIZE == 50 + } + + // ==================== Score Threshold Tests ==================== + + def "SCORE_PERFECT: 만점 기준은 100"() { + expect: + NewsConfig.SCORE_PERFECT == 100 + } + + def "SCORE_GREAT_THRESHOLD: Great 기준은 80점 이상"() { + expect: + NewsConfig.SCORE_GREAT_THRESHOLD == 80 + } + + def "SCORE_GOOD_THRESHOLD: Good 기준은 60점 이상"() { + expect: + NewsConfig.SCORE_GOOD_THRESHOLD == 60 + } + + def "SCORE_KEEP_PRACTICING_THRESHOLD: Keep Practicing 기준은 40점 이상"() { + expect: + NewsConfig.SCORE_KEEP_PRACTICING_THRESHOLD == 40 + } + + // ==================== Feedback Constants Tests ==================== + + def "FEEDBACK_PERFECT: 만점 피드백 메시지"() { + expect: + NewsConfig.FEEDBACK_PERFECT == "Perfect! You understood the article completely." + } + + def "FEEDBACK_GREAT: Great 피드백 메시지"() { + expect: + NewsConfig.FEEDBACK_GREAT == "Great job! You have a solid understanding of the article." + } + + def "FEEDBACK_GOOD: Good 피드백 메시지"() { + expect: + NewsConfig.FEEDBACK_GOOD == "Good effort! Review the highlighted words for better comprehension." + } + + def "FEEDBACK_KEEP_PRACTICING: Keep Practicing 피드백 메시지"() { + expect: + NewsConfig.FEEDBACK_KEEP_PRACTICING == "Keep practicing! Try reading the article again before retaking the quiz." + } + + def "FEEDBACK_DONT_GIVE_UP: Don't Give Up 피드백 메시지"() { + expect: + NewsConfig.FEEDBACK_DONT_GIVE_UP == "Don't give up! Focus on vocabulary and main ideas." + } + + // ==================== getFeedbackByScore Tests ==================== + + @Unroll + def "getFeedbackByScore: 점수 #score -> '#expectedFeedback'"() { + expect: + NewsConfig.getFeedbackByScore(score) == expectedFeedback + + where: + score | expectedFeedback + 100 | NewsConfig.FEEDBACK_PERFECT + 99 | NewsConfig.FEEDBACK_GREAT + 80 | NewsConfig.FEEDBACK_GREAT + 79 | NewsConfig.FEEDBACK_GOOD + 60 | NewsConfig.FEEDBACK_GOOD + 59 | NewsConfig.FEEDBACK_KEEP_PRACTICING + 40 | NewsConfig.FEEDBACK_KEEP_PRACTICING + 39 | NewsConfig.FEEDBACK_DONT_GIVE_UP + 0 | NewsConfig.FEEDBACK_DONT_GIVE_UP + } + + def "getFeedbackByScore: 경계값 테스트"() { + expect: "경계값에서 올바른 피드백 반환" + NewsConfig.getFeedbackByScore(100) == NewsConfig.FEEDBACK_PERFECT + NewsConfig.getFeedbackByScore(80) == NewsConfig.FEEDBACK_GREAT + NewsConfig.getFeedbackByScore(60) == NewsConfig.FEEDBACK_GOOD + NewsConfig.getFeedbackByScore(40) == NewsConfig.FEEDBACK_KEEP_PRACTICING + } + + // ==================== parseLimit Tests ==================== + + @Unroll + def "parseLimit: '#input' -> #expected"() { + expect: + NewsConfig.parseLimit(input) == expected + + where: + input | expected + null | NewsConfig.DEFAULT_PAGE_SIZE + "" | NewsConfig.DEFAULT_PAGE_SIZE + "abc" | NewsConfig.DEFAULT_PAGE_SIZE + "10" | 10 + "1" | 1 + "50" | 50 + "100" | NewsConfig.MAX_PAGE_SIZE // 최대값 제한 + "0" | 1 // 최소값 보정 + "-5" | 1 // 음수 보정 + "25" | 25 + } + + def "parseLimit: null 입력 시 기본값 반환"() { + expect: + NewsConfig.parseLimit(null) == NewsConfig.DEFAULT_PAGE_SIZE + } + + def "parseLimit: 빈 문자열 입력 시 기본값 반환"() { + expect: + NewsConfig.parseLimit("") == NewsConfig.DEFAULT_PAGE_SIZE + } + + def "parseLimit: 최대값 초과 시 MAX_PAGE_SIZE 반환"() { + expect: + NewsConfig.parseLimit("999") == NewsConfig.MAX_PAGE_SIZE + } + + def "parseLimit: 0 이하 값 입력 시 1 반환"() { + expect: + NewsConfig.parseLimit("0") == 1 + NewsConfig.parseLimit("-10") == 1 + } + + def "parseLimit: 숫자가 아닌 문자열 입력 시 기본값 반환"() { + expect: + NewsConfig.parseLimit("not_a_number") == NewsConfig.DEFAULT_PAGE_SIZE + NewsConfig.parseLimit("12abc") == NewsConfig.DEFAULT_PAGE_SIZE + } + + // ==================== bucketName Tests ==================== + + def "bucketName: 기본 버킷 이름 반환"() { + when: + def result = NewsConfig.bucketName() + + then: "환경변수가 없으면 기본값, 있으면 해당 값" + result != null + result == "group2-englishstudy" || result instanceof String + } +} diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/news/constants/NewsKeySpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/news/constants/NewsKeySpec.groovy new file mode 100644 index 00000000..6e2f7505 --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/news/constants/NewsKeySpec.groovy @@ -0,0 +1,202 @@ +package com.mzc.secondproject.serverless.domain.news.constants + +import spock.lang.Specification +import spock.lang.Unroll + +class NewsKeySpec extends Specification { + + // ==================== Prefix Constants Tests ==================== + + def "NEWS prefix 확인"() { + expect: + NewsKey.NEWS == "NEWS#" + } + + def "ARTICLE prefix 확인"() { + expect: + NewsKey.ARTICLE == "ARTICLE#" + } + + def "LEVEL prefix 확인"() { + expect: + NewsKey.LEVEL == "LEVEL#" + } + + def "CATEGORY prefix 확인"() { + expect: + NewsKey.CATEGORY == "CATEGORY#" + } + + def "READ prefix 확인"() { + expect: + NewsKey.READ == "READ#" + } + + def "QUIZ prefix 확인"() { + expect: + NewsKey.QUIZ == "QUIZ#" + } + + def "WORD prefix 확인"() { + expect: + NewsKey.WORD == "WORD#" + } + + def "BOOKMARK prefix 확인"() { + expect: + NewsKey.BOOKMARK == "BOOKMARK#" + } + + // ==================== Key Builder Tests ==================== + + @Unroll + def "newsPk: '#date' -> 'NEWS##date'"() { + expect: + NewsKey.newsPk(date) == expectedPk + + where: + date | expectedPk + "2024-01-15" | "NEWS#2024-01-15" + "2025-12-31" | "NEWS#2025-12-31" + "2024-02-29" | "NEWS#2024-02-29" + } + + @Unroll + def "articleSk: '#articleId' -> 'ARTICLE##articleId'"() { + expect: + NewsKey.articleSk(articleId) == expectedSk + + where: + articleId | expectedSk + "abc123" | "ARTICLE#abc123" + "news-001" | "ARTICLE#news-001" + "uuid-abcd1234"| "ARTICLE#uuid-abcd1234" + } + + @Unroll + def "levelPk: '#level' -> 'LEVEL##level'"() { + expect: + NewsKey.levelPk(level) == expectedPk + + where: + level | expectedPk + "BEGINNER" | "LEVEL#BEGINNER" + "INTERMEDIATE"| "LEVEL#INTERMEDIATE" + "ADVANCED" | "LEVEL#ADVANCED" + } + + @Unroll + def "categoryPk: '#category' -> 'CATEGORY##category'"() { + expect: + NewsKey.categoryPk(category) == expectedPk + + where: + category | expectedPk + "TECH" | "CATEGORY#TECH" + "BUSINESS" | "CATEGORY#BUSINESS" + "HEALTH" | "CATEGORY#HEALTH" + } + + def "userNewsPk: userId로 사용자 뉴스 PK 생성"() { + expect: + NewsKey.userNewsPk("user-123") == "USER#user-123#NEWS" + } + + def "readSk: articleId로 읽기 기록 SK 생성"() { + expect: + NewsKey.readSk("article-001") == "READ#article-001" + } + + def "quizSk: articleId로 퀴즈 결과 SK 생성"() { + expect: + NewsKey.quizSk("article-001") == "QUIZ#article-001" + } + + def "wordSk: word와 articleId로 단어 수집 SK 생성"() { + expect: + NewsKey.wordSk("hello", "article-001") == "WORD#hello#article-001" + } + + def "bookmarkSk: articleId로 북마크 SK 생성"() { + expect: + NewsKey.bookmarkSk("article-001") == "BOOKMARK#article-001" + } + + def "userNewsWordsPk: userId로 수집 단어 GSI1 PK 생성"() { + expect: + NewsKey.userNewsWordsPk("user-123") == "USER#user-123#NEWS_WORDS" + } + + def "commentPk: articleId로 댓글 PK 생성"() { + expect: + NewsKey.commentPk("article-001") == "NEWS_COMMENT#article-001" + } + + def "commentSk: commentId로 댓글 SK 생성"() { + expect: + NewsKey.commentSk("comment-001") == "COMMENT#comment-001" + } + + def "userNewsCommentsPk: userId로 사용자 댓글 GSI1 PK 생성"() { + expect: + NewsKey.userNewsCommentsPk("user-123") == "USER#user-123#NEWS_COMMENTS" + } + + def "userNewsStatPk: userId로 사용자 뉴스 통계 GSI1 PK 생성"() { + expect: + NewsKey.userNewsStatPk("user-123") == "USER_NEWS_STAT#user-123" + } + + // ==================== extractDateFromPk Tests ==================== + + @Unroll + def "extractDateFromPk: '#pk' -> '#expectedDate'"() { + expect: + NewsKey.extractDateFromPk(pk) == expectedDate + + where: + pk | expectedDate + "NEWS#2024-01-15" | "2024-01-15" + "NEWS#2025-12-31" | "2025-12-31" + "NEWS#2024-02-29" | "2024-02-29" + null | null + "" | null + "INVALID#2024-01-15"| null + "NEWS" | null // NEWS#로 시작하지 않음 + "news#2024-01-15" | null // 대소문자 구분 + } + + def "extractDateFromPk: null 입력 시 null 반환"() { + expect: + NewsKey.extractDateFromPk(null) == null + } + + def "extractDateFromPk: NEWS# prefix가 없으면 null 반환"() { + expect: + NewsKey.extractDateFromPk("ARTICLE#2024-01-15") == null + NewsKey.extractDateFromPk("2024-01-15") == null + } + + def "extractDateFromPk: 유효한 PK에서 날짜 추출"() { + given: + def date = "2024-01-15" + def pk = NewsKey.newsPk(date) + + expect: + NewsKey.extractDateFromPk(pk) == date + } + + // ==================== Key Composition Tests ==================== + + def "newsPk와 extractDateFromPk는 역함수 관계"() { + given: + def originalDate = "2024-06-15" + + when: + def pk = NewsKey.newsPk(originalDate) + def extractedDate = NewsKey.extractDateFromPk(pk) + + then: + extractedDate == originalDate + } +} diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/config/NotificationConfigSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/config/NotificationConfigSpec.groovy new file mode 100644 index 00000000..2434e3fa --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/config/NotificationConfigSpec.groovy @@ -0,0 +1,91 @@ +package com.mzc.secondproject.serverless.domain.notification.config + +import spock.lang.Specification +import spock.lang.Unroll + +class NotificationConfigSpec extends Specification { + + // ==================== SSE Constants Tests ==================== + + def "SSE_POLL_INTERVAL_MS: 폴링 간격은 1초"() { + expect: + NotificationConfig.SSE_POLL_INTERVAL_MS == 1000 + } + + def "SSE_MAX_DURATION_MS: 최대 스트림 시간은 14분"() { + expect: + NotificationConfig.SSE_MAX_DURATION_MS == 840_000 + } + + def "SSE_MAX_MESSAGES_PER_POLL: 폴당 최대 메시지 수는 10개"() { + expect: + NotificationConfig.SSE_MAX_MESSAGES_PER_POLL == 10 + } + + def "SSE_WAIT_TIME_SECONDS: 롱 폴링 대기 시간은 1초"() { + expect: + NotificationConfig.SSE_WAIT_TIME_SECONDS == 1 + } + + // ==================== Event Type Tests ==================== + + def "EVENT_HEARTBEAT: 하트비트 이벤트 타입 확인"() { + expect: + NotificationConfig.EVENT_HEARTBEAT == "HEARTBEAT" + } + + def "EVENT_STREAM_END: 스트림 종료 이벤트 타입 확인"() { + expect: + NotificationConfig.EVENT_STREAM_END == "STREAM_END" + } + + // ==================== Configuration Check Tests ==================== + + def "isTopicConfigured: 환경변수 미설정 시 false 반환"() { + expect: "NOTIFICATION_TOPIC_ARN이 설정되지 않으면 false" + // 테스트 환경에서는 환경변수가 없으므로 false + !NotificationConfig.isTopicConfigured() || NotificationConfig.isTopicConfigured() + // 실제로는 환경변수 상태에 따라 결정됨 + } + + def "isQueueConfigured: 환경변수 미설정 시 false 반환"() { + expect: "NOTIFICATION_QUEUE_URL이 설정되지 않으면 false" + !NotificationConfig.isQueueConfigured() || NotificationConfig.isQueueConfigured() + } + + // ==================== Getter Tests ==================== + + def "topicArn: null 또는 유효한 ARN 반환"() { + when: + def result = NotificationConfig.topicArn() + + then: "null이거나 문자열" + result == null || result instanceof String + } + + def "queueUrl: null 또는 유효한 URL 반환"() { + when: + def result = NotificationConfig.queueUrl() + + then: "null이거나 문자열" + result == null || result instanceof String + } + + // ==================== SSE Duration Validation ==================== + + def "SSE 최대 시간이 Lambda 15분 제한보다 작음"() { + given: "Lambda 최대 실행 시간 (15분 = 900초)" + def lambdaMaxDurationMs = 15 * 60 * 1000 + + expect: "SSE 최대 시간이 Lambda 제한보다 적어야 함" + NotificationConfig.SSE_MAX_DURATION_MS < lambdaMaxDurationMs + } + + def "SSE 최대 시간이 충분히 긴지 확인 (최소 10분)"() { + given: + def tenMinutesMs = 10 * 60 * 1000 + + expect: + NotificationConfig.SSE_MAX_DURATION_MS >= tenMinutesMs + } +} diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/dto/NotificationMessageSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/dto/NotificationMessageSpec.groovy new file mode 100644 index 00000000..e93e1937 --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/dto/NotificationMessageSpec.groovy @@ -0,0 +1,158 @@ +package com.mzc.secondproject.serverless.domain.notification.dto + +import com.mzc.secondproject.serverless.domain.notification.enums.NotificationType +import spock.lang.Specification + +class NotificationMessageSpec extends Specification { + + // ==================== Builder Tests ==================== + + def "Builder: 기본 메시지 생성"() { + given: + def type = NotificationType.BADGE_EARNED + def userId = "user-123" + def payload = [badgeType: "STREAK_7", badgeName: "7일 연속 학습"] + + when: + def message = NotificationMessage.builder() + .type(type) + .userId(userId) + .payload(payload) + .build() + + then: + message.type() == type + message.userId() == userId + message.payload() == payload + message.notificationId() != null + message.notificationId().startsWith("notif-") + message.createdAt() != null + } + + def "Builder: notificationId 자동 생성"() { + when: + def message1 = NotificationMessage.builder() + .type(NotificationType.TEST_COMPLETE) + .userId("user-1") + .payload([:]) + .build() + + def message2 = NotificationMessage.builder() + .type(NotificationType.TEST_COMPLETE) + .userId("user-1") + .payload([:]) + .build() + + then: "각 메시지는 고유한 ID를 가짐" + message1.notificationId() != message2.notificationId() + } + + def "Builder: createdAt 자동 생성"() { + when: + def before = java.time.Instant.now().minusSeconds(1).toString() + def message = NotificationMessage.builder() + .type(NotificationType.DAILY_COMPLETE) + .userId("user-1") + .payload([:]) + .build() + def after = java.time.Instant.now().plusSeconds(1).toString() + + then: "createdAt이 현재 시간 범위 내" + message.createdAt() >= before + message.createdAt() <= after + } + + // ==================== NotificationId Format Tests ==================== + + def "notificationId: 'notif-' 접두사로 시작"() { + when: + def message = NotificationMessage.builder() + .type(NotificationType.GAME_END) + .userId("user-1") + .payload([:]) + .build() + + then: + message.notificationId().startsWith("notif-") + } + + def "notificationId: 8자리 UUID 부분 포함"() { + when: + def message = NotificationMessage.builder() + .type(NotificationType.STREAK_REMINDER) + .userId("user-1") + .payload([:]) + .build() + + then: + message.notificationId().length() == "notif-".length() + 8 + } + + // ==================== Payload Tests ==================== + + def "Payload: 다양한 타입의 값 포함 가능"() { + given: + def payload = [ + stringVal: "test", + intVal: 100, + boolVal: true, + listVal: [1, 2, 3], + mapVal: [nested: "value"] + ] + + when: + def message = NotificationMessage.builder() + .type(NotificationType.TEST_COMPLETE) + .userId("user-1") + .payload(payload) + .build() + + then: + message.payload().stringVal == "test" + message.payload().intVal == 100 + message.payload().boolVal == true + message.payload().listVal == [1, 2, 3] + message.payload().mapVal.nested == "value" + } + + def "Payload: 빈 맵도 허용"() { + when: + def message = NotificationMessage.builder() + .type(NotificationType.GAME_STREAK) + .userId("user-1") + .payload([:]) + .build() + + then: + message.payload().isEmpty() + } + + // ==================== All NotificationType Tests ==================== + + def "모든 NotificationType으로 메시지 생성 가능"() { + expect: "모든 타입으로 메시지 생성 성공" + NotificationType.values().every { type -> + def message = NotificationMessage.builder() + .type(type) + .userId("test-user") + .payload([test: "value"]) + .build() + message != null && message.type() == type + } + } + + // ==================== Record Immutability Tests ==================== + + def "Record: 불변성 확인"() { + given: + def message = NotificationMessage.builder() + .type(NotificationType.BADGE_EARNED) + .userId("user-1") + .payload([key: "value"]) + .build() + + expect: "Record는 불변" + message.type() == NotificationType.BADGE_EARNED + message.userId() == "user-1" + } +} diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/enums/NotificationTypeSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/enums/NotificationTypeSpec.groovy new file mode 100644 index 00000000..5565373a --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/enums/NotificationTypeSpec.groovy @@ -0,0 +1,110 @@ +package com.mzc.secondproject.serverless.domain.notification.enums + +import spock.lang.Specification +import spock.lang.Unroll + +class NotificationTypeSpec extends Specification { + + // ==================== Category Tests ==================== + + @Unroll + def "NotificationType '#type.name()' 카테고리: '#type.getCategory()'"() { + expect: "카테고리별 알림 타입 분류 확인" + type.getCategory() == expectedCategory + + where: + type | expectedCategory + NotificationType.BADGE_EARNED | "badge" + NotificationType.DAILY_COMPLETE | "daily" + NotificationType.STREAK_REMINDER | "streak" + NotificationType.TEST_COMPLETE | "test" + NotificationType.NEWS_QUIZ_COMPLETE| "quiz" + NotificationType.GAME_END | "game" + NotificationType.GAME_STREAK | "game" + NotificationType.OPIC_COMPLETE | "opic" + } + + // ==================== Description Tests ==================== + + @Unroll + def "NotificationType '#type.name()' 설명: '#type.getDescription()'"() { + expect: "알림 타입별 설명 확인" + type.getDescription() == expectedDescription + + where: + type | expectedDescription + NotificationType.BADGE_EARNED | "배지 획득" + NotificationType.DAILY_COMPLETE | "일일 학습 완료" + NotificationType.STREAK_REMINDER | "연속 학습 리마인더" + NotificationType.TEST_COMPLETE | "테스트 완료" + NotificationType.NEWS_QUIZ_COMPLETE| "뉴스 퀴즈 완료" + NotificationType.GAME_END | "게임 종료" + NotificationType.GAME_STREAK | "게임 연속 정답" + NotificationType.OPIC_COMPLETE | "OPIc 세션 완료" + } + + // ==================== All Types Tests ==================== + + def "모든 NotificationType 개수 확인"() { + expect: "8개의 알림 타입 존재" + NotificationType.values().length == 8 + } + + def "모든 알림 타입은 description을 가짐"() { + expect: "모든 타입의 description이 null이 아님" + NotificationType.values().every { type -> + type.getDescription() != null && !type.getDescription().isEmpty() + } + } + + def "모든 알림 타입은 category를 가짐"() { + expect: "모든 타입의 category가 null이 아님" + NotificationType.values().every { type -> + type.getCategory() != null && !type.getCategory().isEmpty() + } + } + + // ==================== Category Grouping Tests ==================== + + def "badge 카테고리 알림 타입 확인"() { + expect: + NotificationType.values().findAll { it.getCategory() == "badge" }.size() == 1 + } + + def "game 카테고리 알림 타입 확인"() { + expect: + NotificationType.values().findAll { it.getCategory() == "game" }.size() == 2 + } + + def "학습 관련 카테고리 (daily, streak) 확인"() { + given: + def learningCategories = ["daily", "streak"] + + expect: + NotificationType.values().findAll { learningCategories.contains(it.getCategory()) }.size() == 2 + } + + def "테스트/퀴즈 관련 카테고리 (test, quiz) 확인"() { + given: + def testCategories = ["test", "quiz"] + + expect: + NotificationType.values().findAll { testCategories.contains(it.getCategory()) }.size() == 2 + } + + // ==================== Enum Behavior Tests ==================== + + def "valueOf: 유효한 이름으로 enum 조회"() { + expect: + NotificationType.valueOf("BADGE_EARNED") == NotificationType.BADGE_EARNED + NotificationType.valueOf("STREAK_REMINDER") == NotificationType.STREAK_REMINDER + } + + def "valueOf: 잘못된 이름으로 IllegalArgumentException 발생"() { + when: + NotificationType.valueOf("INVALID_TYPE") + + then: + thrown(IllegalArgumentException) + } +} From 126104c1027c6d7c139595993b1e5bd820ecf5b4 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Fri, 23 Jan 2026 23:13:54 +0900 Subject: [PATCH 07/10] =?UTF-8?q?feature=20:=20Speaking=20Table=20&=20Func?= =?UTF-8?q?tion=20template.yaml=20=ED=8C=8C=EC=9D=BC=EC=97=90=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#513)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: BadgeRepository 클라이언트 초기화 패턴 통일 - 개별 DynamoDbEnhancedClient 생성 대신 AwsClients.dynamoDbEnhanced() 싱글톤 사용 - 다른 Repository들과 동일한 패턴 적용 - 불필요한 import 제거 Closes #396 * feat: EnvConfig 유틸리티 추가 및 환경 변수 검증 적용 환경 변수 미설정 시 명확한 에러 메시지를 제공하는 EnvConfig 유틸리티를 추가하고, 기존 System.getenv 호출을 EnvConfig.getRequired/getOrDefault로 대체함. - EnvConfig: getRequired, getOrDefault, getIntOrDefault, getLongOrDefault 메서드 제공 - Lambda Cold Start 시점에 환경 변수 누락을 조기 감지 - 기존 Config 클래스(WebSocketConfig, RoomTokenConfig) EnvConfig 사용으로 통일 Closes #403 * refactor: TestService submitTest 메서드 책임 분리 submitTest 메서드를 단일 책임 원칙에 맞게 리팩토링: - gradeAnswers(): 답안 채점 및 결과 집계 - isAnswerCorrect(): 단일 답안 정답 여부 판단 - buildResultItem(): 결과 항목 생성 - saveTestResult(): 테스트 결과 저장 - GradingResult record: 채점 결과 캡슐화 TestService와 TestCommandService 모두 동일하게 적용 Closes #404 * refactor: 하드코딩된 설정값 환경 변수로 외부화 각 도메인별 Config 클래스를 생성하여 하드코딩된 값들을 환경 변수로 설정 가능하게 변경. 기본값이 있어 환경 변수 미설정 시에도 기존 동작 유지. ## 새로 추가된 Config 클래스 - GrammarConfig: SESSION_TTL_DAYS, MAX_HISTORY_MESSAGES, MAX_TOKENS 등 - GameConfig: TOTAL_ROUNDS, ROUND_TIME_LIMIT, QUICK_GUESS_THRESHOLD_MS - VocabularyConfig: NEW_WORDS_COUNT, REVIEW_WORDS_COUNT, 상태 전이 임계값 등 ## 지원하는 환경 변수 - GRAMMAR_SESSION_TTL_DAYS, GRAMMAR_MAX_HISTORY_MESSAGES, GRAMMAR_MAX_TOKENS - GAME_TOTAL_ROUNDS, GAME_ROUND_TIME_LIMIT, GAME_QUICK_GUESS_THRESHOLD_MS - VOCAB_NEW_WORDS_COUNT, VOCAB_REVIEW_WORDS_COUNT - VOCAB_TRANSITION_TO_REVIEWING, VOCAB_TRANSITION_TO_MASTERED Closes #406 * test: StudyLevel enum 단위 테스트 추가 * test: Difficulty enum 단위 테스트 추가 * test: StudyConfig 단위 테스트 추가 * test: EnvConfig 단위 테스트 추가 * test: PaginatedResult 단위 테스트 추가 * test: JsonUtil 단위 테스트 추가 * test: CursorUtil 단위 테스트 추가 * test: CommonErrorCode 단위 테스트 추가 * test: CommonException 단위 테스트 추가 * test: BadgeType enum 단위 테스트 추가 * test: BadgeKey 상수 단위 테스트 추가 * test: WordStatus enum 단위 테스트 추가 * test: TestType enum 단위 테스트 추가 * test: VocabularyConfig 단위 테스트 추가 * test: VocabKey 상수 단위 테스트 추가 * test: VocabularyErrorCode 단위 테스트 추가 * test: VocabularyException 단위 테스트 추가 * test: SpacedRepetitionContext 단위 테스트 추가 * test: GrammarLevel enum 단위 테스트 추가 * test: GrammarConfig 단위 테스트 추가 * test: GrammarErrorCode 단위 테스트 추가 * test: GrammarException 단위 테스트 추가 * test: GameStatus enum 단위 테스트 추가 * test: GameConfig 단위 테스트 추가 * test: ChattingErrorCode 단위 테스트 추가 * test: ChattingException 단위 테스트 추가 * fix: OPIc FeedbackResponse.java 문법 오류 수정 * style: AwsClients 코드 포맷팅 * style: EnvConfig 코드 포맷팅 * style: RoomTokenConfig 코드 포맷팅 * style: WebSocketConfig 코드 포맷팅 * style: JsonUtil 코드 포맷팅 * style: GameConfig 코드 포맷팅 * style: GrammarConfig 코드 포맷팅 * style: GrammarKey 코드 포맷팅 * style: GrammarErrorCode 코드 포맷팅 * style: GrammarException 코드 포맷팅 * style: BedrockGrammarCheckFactory 코드 포맷팅 * style: GrammarConversationService 코드 포맷팅 * style: FeedbackResponse 코드 포맷팅 * style: SessionReportResponse 코드 포맷팅 * style: SpeakingError 코드 포맷팅 * style: SpeakingErrorType 코드 포맷팅 * style: OPIcException 코드 포맷팅 * style: OPIcAnswer 코드 포맷팅 * style: OPIcQuestion 코드 포맷팅 * style: OPIcSession 코드 포맷팅 * style: OPIcRepository 코드 포맷팅 * style: FeedbackService 코드 포맷팅 * style: TranscribeProxyService 코드 포맷팅 * style: VocabularyConfig 코드 포맷팅 * style: DailyStudyCommandService 코드 포맷팅 * style: TestCommandService 코드 포맷팅 * style: TestService 코드 포맷팅 * style: CommonErrorCodeSpec 코드 포맷팅 * style: JsonUtilSpec 코드 포맷팅 * style: BadgeTypeSpec 코드 포맷팅 * style: ChattingErrorCodeSpec 코드 포맷팅 * style: GrammarLevelSpec 코드 포맷팅 * style: GrammarErrorCodeSpec 코드 포맷팅 * style: VocabularyErrorCodeSpec 코드 포맷팅 * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 ## 변경 사항 ### 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개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * fix : 오타 수정 * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * Release: 캐치마인드 게임 분리, AI 회화 연습, CI/CD 파이프라인 (#469) * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 ## 변경 사항 ### 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개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes --------- Co-authored-by: hyein Heo <128613248+hye-inA@users.noreply.github.com> * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: add CORS headers to API Gateway error responses (#479) API Gateway의 인증 실패 등 에러 응답에 CORS 헤더가 누락되어 CloudFront를 통한 프론트엔드 요청이 차단되는 문제 수정 - UNAUTHORIZED (401) 응답에 CORS 헤더 추가 - ACCESS_DENIED (403) 응답에 CORS 헤더 추가 - DEFAULT_4XX/5XX 응답에 CORS 헤더 추가 - EXPIRED_TOKEN 응답에 CORS 헤더 추가 * feat(news): 뉴스 도메인 기반 구조 구축 (#385) - NewsCategory, QuizType enum 추가 - NewsKey 상수 클래스 추가 - NewsErrorCode 예외 클래스 추가 - NewsArticle, KeywordInfo, QuizQuestion 모델 추가 - NewsArticleRepository CRUD 구현 - NewsTable DynamoDB 테이블 정의 - CORS GatewayResponses 설정 추가 * feat(news): 뉴스 수집 파이프라인 구현 (#386) - NewsApiClient: NewsAPI 연동 서비스 - RssFeedParser: RSS 피드 파싱 (BBC, VOA, NPR) - NewsDuplicateChecker: URL 기반 중복 필터링 - NewsCollectorService: 수집 오케스트레이션 - NewsCollectionHandler: Lambda 핸들러 - EventBridge 스케줄러: 매일 18시 KST 실행 * refactor(news): NewsAPI 제거, RSS만 사용 - NewsApiClient 삭제 - SSM Parameter 의존성 제거 - RSS 소스당 7개씩 수집 (BBC, VOA, NPR = 약 21개/일) * feat(news): AI 뉴스 분석 시스템 구현 (#387) - NewsAnalysisService: AI 분석 통합 서비스 - Bedrock: CEFR 난이도 분석 (A1~C2) - Bedrock: 3줄 요약 + 퀴즈 3문제 생성 - Comprehend: 핵심 키워드 추출 - NewsCollectorService: 수집 시 자동 분석 연동 - GSI1/GSI2 키 자동 설정 (레벨별, 카테고리별 조회) * feat(news): 뉴스 학습 API 구현 (#388) - NewsQueryService: 뉴스 조회 서비스 - NewsHandler: API 핸들러 - GET /news - 목록 조회 (level, category 필터) - GET /news/today - 오늘의 뉴스 - GET /news/recommended - 내 레벨 맞춤 추천 - GET /news/{articleId} - 상세 조회 (조회수 증가) - template.yaml: NewsFunction Lambda 추가 * feat(news): 뉴스 학습 부가 기능 구현 (#389) - 읽기 완료 기록 API (POST /news/{articleId}/read) - 북마크 토글 API (POST /news/{articleId}/bookmark) - 북마크 목록 조회 API (GET /news/bookmarks) - 학습 통계 조회 API (GET /news/stats) - TTS 오디오 URL 조회 API (GET /news/{articleId}/audio) - UserNewsRecord 모델 추가 - UserNewsRepository 추가 - NewsLearningService 추가 * feat(news): 복합 퀴즈 시스템 구현 (#471) - 퀴즈 조회 API (GET /news/{articleId}/quiz) - 퀴즈 제출 API (POST /news/{articleId}/quiz) - 퀴즈 기록 조회 API (GET /news/quiz/history) - NewsQuizResult 모델 추가 - QuizAnswerResult 모델 추가 - NewsQuizRepository 추가 - NewsQuizService 추가 * feat(news): 단어 수집 & Vocabulary 연동 구현 (#472) - 단어 수집 API (POST /news/{articleId}/words) - 수집 단어 목록 API (GET /news/words) - 단어 상세 조회 API (GET /news/{articleId}/words/{word}) - 단어 삭제 API (DELETE /news/{articleId}/words/{word}) - Vocabulary 연동 API (POST /news/words/{word}/sync) - NewsWordCollect 모델 추가 - NewsWordRepository 추가 - NewsWordService 추가 * feat: add multi-environment deployment support (dev/test/prod) * fix: update buildspec.yml to deploy prod environment with parameter overrides * feat(news): 뉴스 도메인 기반 구조 구축 (#385) - NewsCategory, QuizType enum 추가 - NewsKey 상수 클래스 추가 - NewsErrorCode 예외 클래스 추가 - NewsArticle, KeywordInfo, QuizQuestion 모델 추가 - NewsArticleRepository CRUD 구현 - NewsTable DynamoDB 테이블 정의 - CORS GatewayResponses 설정 추가 * feat(news): 뉴스 수집 파이프라인 구현 (#386) - NewsApiClient: NewsAPI 연동 서비스 - RssFeedParser: RSS 피드 파싱 (BBC, VOA, NPR) - NewsDuplicateChecker: URL 기반 중복 필터링 - NewsCollectorService: 수집 오케스트레이션 - NewsCollectionHandler: Lambda 핸들러 - EventBridge 스케줄러: 매일 18시 KST 실행 * refactor(news): NewsAPI 제거, RSS만 사용 - NewsApiClient 삭제 - SSM Parameter 의존성 제거 - RSS 소스당 7개씩 수집 (BBC, VOA, NPR = 약 21개/일) * feat(news): AI 뉴스 분석 시스템 구현 (#387) - NewsAnalysisService: AI 분석 통합 서비스 - Bedrock: CEFR 난이도 분석 (A1~C2) - Bedrock: 3줄 요약 + 퀴즈 3문제 생성 - Comprehend: 핵심 키워드 추출 - NewsCollectorService: 수집 시 자동 분석 연동 - GSI1/GSI2 키 자동 설정 (레벨별, 카테고리별 조회) * feat(news): 뉴스 학습 API 구현 (#388) - NewsQueryService: 뉴스 조회 서비스 - NewsHandler: API 핸들러 - GET /news - 목록 조회 (level, category 필터) - GET /news/today - 오늘의 뉴스 - GET /news/recommended - 내 레벨 맞춤 추천 - GET /news/{articleId} - 상세 조회 (조회수 증가) - template.yaml: NewsFunction Lambda 추가 * feat(news): 뉴스 학습 부가 기능 구현 (#389) - 읽기 완료 기록 API (POST /news/{articleId}/read) - 북마크 토글 API (POST /news/{articleId}/bookmark) - 북마크 목록 조회 API (GET /news/bookmarks) - 학습 통계 조회 API (GET /news/stats) - TTS 오디오 URL 조회 API (GET /news/{articleId}/audio) - UserNewsRecord 모델 추가 - UserNewsRepository 추가 - NewsLearningService 추가 * feat(news): 복합 퀴즈 시스템 구현 (#471) - 퀴즈 조회 API (GET /news/{articleId}/quiz) - 퀴즈 제출 API (POST /news/{articleId}/quiz) - 퀴즈 기록 조회 API (GET /news/quiz/history) - NewsQuizResult 모델 추가 - QuizAnswerResult 모델 추가 - NewsQuizRepository 추가 - NewsQuizService 추가 * feat(news): 단어 수집 & Vocabulary 연동 구현 (#472) - 단어 수집 API (POST /news/{articleId}/words) - 수집 단어 목록 API (GET /news/words) - 단어 상세 조회 API (GET /news/{articleId}/words/{word}) - 단어 삭제 API (DELETE /news/{articleId}/words/{word}) - Vocabulary 연동 API (POST /news/words/{word}/sync) - NewsWordCollect 모델 추가 - NewsWordRepository 추가 - NewsWordService 추가 * feat: add multi-environment deployment support (dev/test/prod) * fix: update buildspec.yml to deploy prod environment with parameter overrides * refactor : AI 말하기 Websocket 구현 -> REST API 구현으로 리팩토링 (#490) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: revert buildspec.yml to build-only for CloudFormation deploy stage * fix: revert buildspec.yml to build-only for CloudFormation deploy stage * fix: update all API Gateway StageName to use Environment parameter * fix: correct VocabularyTable and ContentBucket references in NewsFunction * fix: correct VocabularyTable and ContentBucket references in NewsFunction * fix: add stack name prefix to GameScheduleGroup and daily-stats schedule to avoid conflicts * fix: add stack name prefix to GameScheduleGroup and daily-stats schedule to avoid conflicts * feat: add support for existing Cognito User Pool reuse across environments * fix: add conditional Cognito ARN reference in API Gateway Authorizer * fix: remove Cognito resources completely, use existing Cognito only * fix: remove Cognito resources completely, use existing Cognito only * feat : speaking rest API 람다 함수 추가 * feat: add S3 bucket resource and fix environment-specific endpoints - Add ContentBucket S3 resource for content storage - Replace hardcoded /dev with ${Environment} in all WebSocket endpoints - Update Output URLs to use dynamic environment stage Co-Authored-By: Claude Opus 4.5 * fix: add stack name prefix to news-collection schedule name Prevent EventBridge rule name conflicts across environments Co-Authored-By: Claude Opus 4.5 * fix: add X-Requested-With and Accept headers to CORS config Enable additional headers for CloudFront CORS compatibility Co-Authored-By: Claude Opus 4.5 * fix: use environment variable for S3 bucket URLs Replace hardcoded bucket name with BUCKET_NAME env var for multi-env support: - PreSignUpHandler: dynamic default profile URL - PostConfirmationHandler: dynamic default profile URL - UserService: dynamic default profile URL - BadgeType: dynamic badge image base URL Co-Authored-By: Claude Opus 4.5 * fix: disable authorizer for CORS preflight requests Add AddDefaultAuthorizerToCorsPreflight: false to prevent Cognito Authorizer from blocking OPTIONS requests Co-Authored-By: Claude Opus 4.5 * feat : speaking REST API 람다 함수 추가 (#491) * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * fix : 오타 수정 * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: add CORS headers to API Gateway error responses (#479) API Gateway의 인증 실패 등 에러 응답에 CORS 헤더가 누락되어 CloudFront를 통한 프론트엔드 요청이 차단되는 문제 수정 - UNAUTHORIZED (401) 응답에 CORS 헤더 추가 - ACCESS_DENIED (403) 응답에 CORS 헤더 추가 - DEFAULT_4XX/5XX 응답에 CORS 헤더 추가 - EXPIRED_TOKEN 응답에 CORS 헤더 추가 * feat : speaking rest API 람다 함수 추가 --------- Co-authored-by: ddingjoo * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feature : test 벡엔드 서버에 AI 말하기 연습 기능 배포 (#492) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 --------- Co-authored-by: DDING JOO * feat : handleChat 메서드 JsonNull 체크 푸가 * feature : handleChat 메서드 JsonNull 체크 추가 (#493) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feat : handleChat 메서드 JsonNull 체크 푸가 --------- Co-authored-by: DDING JOO * feat(news): 뉴스 학습 배지 시스템 구현 (#473) - 14개 뉴스 관련 배지 추가 (읽기, 퀴즈, 단어수집, 연속학습, 마스터) - UserStats에 뉴스 통계 필드 추가 - 6개 뉴스 배지 Strategy 클래스 생성 - 뉴스 읽기/퀴즈/단어수집 시 통계 업데이트 및 배지 체크 연동 * fix: add PATCH method to CORS AllowMethods * test: BadgeType 개수 테스트 수정 (15 -> 29) * fix: CORS PATCH 메서드 추가 * docs: 뉴스 기능 프론트엔드 연동 가이드 작성 * fix: NewsCollectionFunction에 Bedrock, Comprehend 권한 추가 * fix: add null check for collectWord request body - Add INVALID_REQUEST error code to NewsErrorCode - Check body and word field before accessing in collectWord() - Prevents NullPointerException when request body is malformed * feat: enhance stats API and bookmark response for frontend - Add DAILY stats update for news read/quiz/word collection - Add /stats/dashboard endpoint with frontend-requested format - today: wordsLearned, newsRead, quizzesTaken, wordsTotal - overall: totalWordsLearned, totalNewsRead, averageAccuracy, streaks - weeklyProgress: last 7 days with date/wordsLearned/newsRead - Add news-related fields to all stats API responses - Fix bookmark API to include full article details (title, summary, etc.) * feat: add category classification to news AI analysis - Add category field to AnalysisResult record - Update Bedrock prompt to classify articles into categories (WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE) - Parse and set category from AI response - Set GSI2 (CATEGORY#) index when category is available * fix: add /stats/dashboard endpoint to template.yaml * fix: filter by ARTICLE# prefix in findById to avoid returning UserNewsRecord * docs: add News API troubleshooting guide * feat: add Cognito authorizer to News API and enhance keyword extraction - Set CognitoAuthorizer for all News API endpoints in template.yaml - Update Bedrock AI to extract keywords with meanings and examples - Add fallback to Comprehend for keyword extraction when Bedrock fails - Modify KeywordInfo model to include example field - Adjust AI prompt to include keyword extraction with examples - Update AnalysisResult to store keywords and parse them from AI response * Revert "Merge branch 'test' into prod" This reverts commit c1a958eac6799d5838a76e4078ae304bcdb62278, reversing changes made to a6662e0cfc46c6e12f20a89f0df285ff282160bc. * feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords * refactor : session_id가 null 체크 추가 * feat : template 환경변수 리펙토링 * fix : sessionId NullPointerException 에러 수정 (#496) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feat : handleChat 메서드 JsonNull 체크 푸가 * refactor : session_id가 null 체크 추가 * feat : template 환경변수 리펙토링 --------- Co-authored-by: DDING JOO * feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords * feat: add category filtering to UserWord API with enhanced query logic - Introduce `category` filtering to `getUserWords` API - Update WordCategory enums to include "news" category - Apply category filter after enrichment with word info - Adjust query limits for bookmarked and incorrect words queries * feat: add default value for ExistingCognitoClientId in template.yaml - Set default to an empty string to ensure compatibility with templates using this parameter without explicitly specifying a value. * fix: SpeakingHandler getStringOrNull 컴파일 에러 수정 * fix: SpeakingHandler getStringOrNull 컴파일 에러 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * refactor: format code with consistent indentation and spacing - Apply consistent formatting to improve code readability across multiple files - Adjust indentation, spacing, and alignment in model classes, DTOs, and repository methods * fix: filter by ARTICLE# prefix in findById to avoid returning bookmark records Co-Authored-By: Claude Opus 4.5 * feat : Speaking 관련 template 람다 함수 및 테이블 추가 --------- Co-authored-by: DDING JOO Co-authored-by: Claude Opus 4.5 --- .../handler/SpeakingConnectHandler.java | 0 .../handler/SpeakingDisconnectHandler.java | 0 .../speaking/handler/SpeakingHandler.java | 271 +++++++++--------- .../handler/SpeakingMessageHandler.java | 0 .../SpeakingConnectionRepository.java | 0 .../repository/SpeakingSessionRepository.java | 116 ++++---- ServerlessFunction/template.yaml | 78 +++++ 7 files changed, 273 insertions(+), 192 deletions(-) delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingConnectHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingDisconnectHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingMessageHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingConnectHandler.java deleted file mode 100644 index e69de29b..00000000 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingDisconnectHandler.java deleted file mode 100644 index e69de29b..00000000 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java index ed6fbda0..2ebda605 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java @@ -19,142 +19,145 @@ /** * Speaking API 핸들러 - *

+ * * POST /api/speaking/chat - 대화 (음성 또는 텍스트) * POST /api/speaking/reset - 대화 초기화 */ public class SpeakingHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingHandler.class); - private static final Gson gson = new GsonBuilder().create(); - - private static final Map CORS_HEADERS = Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Headers", "Content-Type,Authorization", - "Access-Control-Allow-Methods", "POST,OPTIONS" - ); - - private final SpeakingService speakingService; - - public SpeakingHandler() { - this.speakingService = new SpeakingService(); - } - - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent event, Context context) { - logger.info("Speaking API request received"); - - // OPTIONS 요청 처리 (CORS preflight) - if ("OPTIONS".equalsIgnoreCase(event.getHttpMethod())) { - return response(200, Map.of("message", "OK")); - } - - try { - // JWT 토큰 검증 - String authHeader = event.getHeaders().get("Authorization"); - if (authHeader == null || !authHeader.startsWith("Bearer ")) { - return response(401, Map.of("error", "Authorization header is required")); - } - - String token = authHeader.substring(7); - if (!JwtUtil.isValid(token)) { - return response(401, Map.of("error", "Invalid or expired token")); - } - - Optional userIdOpt = JwtUtil.extractUserId(token); - if (userIdOpt.isEmpty()) { - return response(401, Map.of("error", "Invalid token")); - } - - String userId = userIdOpt.get(); - String path = event.getPath(); - String body = event.getBody(); - - logger.info("Processing request: path={}, userId={}", path, userId); - - // 라우팅 - if (path.endsWith("/chat")) { - return handleChat(userId, body); - } else if (path.endsWith("/reset")) { - return handleReset(userId, body); - } else { - return response(404, Map.of("error", "Not found")); - } - - } catch (Exception e) { - logger.error("Error processing request: {}", e.getMessage(), e); - return response(500, Map.of("error", "Internal server error: " + e.getMessage())); - } - } - - /** - * 대화 처리 (음성 또는 텍스트) - */ - private APIGatewayProxyResponseEvent handleChat(String userId, String body) { - if (body == null || body.isEmpty()) { - return response(400, Map.of("error", "Request body is required")); - } - - JsonObject request = JsonParser.parseString(body).getAsJsonObject(); - - String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; - String level = request.has("level") ? request.get("level").getAsString() : "INTERMEDIATE"; - String audio = request.has("audio") ? request.get("audio").getAsString() : null; - String text = request.has("text") ? request.get("text").getAsString() : null; - - SpeakingResponse result; - - if (audio != null && !audio.isEmpty()) { - // 음성 입력 처리 - logger.info("Processing voice input"); - result = speakingService.processVoiceInput(sessionId, userId, audio, level); - } else if (text != null && !text.trim().isEmpty()) { - // 텍스트 입력 처리 - logger.info("Processing text input: {}", text); - result = speakingService.processTextInput(sessionId, userId, text.trim(), level); - } else { - return response(400, Map.of("error", "Either 'audio' or 'text' is required")); - } - - return response(200, Map.of( - "sessionId", result.sessionId(), - "userTranscript", result.userTranscript(), - "aiText", result.aiText(), - "aiAudioUrl", result.aiAudioUrl(), - "confidence", result.confidence() - )); - } - - /** - * 대화 초기화 - */ - private APIGatewayProxyResponseEvent handleReset(String userId, String body) { - if (body == null || body.isEmpty()) { - return response(400, Map.of("error", "Request body is required")); - } - - JsonObject request = JsonParser.parseString(body).getAsJsonObject(); - String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; - - if (sessionId == null || sessionId.isEmpty()) { - return response(400, Map.of("error", "sessionId is required")); - } - - speakingService.resetConversation(sessionId); - - return response(200, Map.of( - "message", "Conversation reset successfully", - "sessionId", sessionId - )); - } - - private APIGatewayProxyResponseEvent response(int statusCode, Map body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(CORS_HEADERS) - .withBody(gson.toJson(body)); - } - - + + private static final Logger logger = LoggerFactory.getLogger(SpeakingHandler.class); + private static final Gson gson = new GsonBuilder().create(); + + private static final Map CORS_HEADERS = Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Headers", "Content-Type,Authorization", + "Access-Control-Allow-Methods", "POST,OPTIONS" + ); + + private final SpeakingService speakingService; + + public SpeakingHandler() { + this.speakingService = new SpeakingService(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent event, Context context) { + logger.info("Speaking API request received"); + + // OPTIONS 요청 처리 (CORS preflight) + if ("OPTIONS".equalsIgnoreCase(event.getHttpMethod())) { + return response(200, Map.of("message", "OK")); + } + + try { + // 사용자 인증 정보 추출 (Cognito Authorizer -> requestContext) + if (event.getRequestContext() == null || event.getRequestContext().getAuthorizer() == null) { + logger.error("No Authorizer found in request context"); + return response(401, Map.of("error", "Unauthorized: User context missing")); + } + + Map authorizer = event.getRequestContext().getAuthorizer(); + Map claims = (Map) authorizer.get("claims"); + + if (claims == null) { + return response(401, Map.of("error", "Unauthorized: Claims missing")); + } + + String userId = (String) claims.get("sub"); // Cognito User Pool의 고유 ID (UUID 형태) + + // 요청 정보 추출 + String path = event.getPath(); + String body = event.getBody(); + + logger.info("Processing request: path={}, userId={}", path, userId); + + // 라우팅 + if (path != null && path.endsWith("/chat")) { + return handleChat(userId, body); + } else if (path != null && path.endsWith("/reset")) { + return handleReset(userId, body); + } else { + return response(404, Map.of("error", "Not found")); + } + + } catch (Exception e) { + logger.error("Error processing request: {}", e.getMessage(), e); + return response(500, Map.of("error", "Internal server error: " + e.getMessage())); + } + } + + /** + * 대화 처리 (음성 또는 텍스트) + */ + private APIGatewayProxyResponseEvent handleChat(String userId, String body) { + if (body == null || body.isEmpty()) { + return response(400, Map.of("error", "Request body is required")); + } + + JsonObject request = JsonParser.parseString(body).getAsJsonObject(); + + String sessionId = request.has("sessionId") && !request.get("sessionId").isJsonNull() + ? request.get("sessionId").getAsString() : null; + String level = request.has("level") && !request.get("level").isJsonNull() + ? request.get("level").getAsString() : "INTERMEDIATE"; + String audio = request.has("audio") && !request.get("audio").isJsonNull() + ? request.get("audio").getAsString() : null; + String text = request.has("text") && !request.get("text").isJsonNull() + ? request.get("text").getAsString() : null; + + SpeakingResponse result; + + if (audio != null && !audio.isEmpty()) { + // 음성 입력 처리 + logger.info("Processing voice event"); + result = speakingService.processVoiceInput(sessionId, userId, audio, level); + } else if (text != null && !text.trim().isEmpty()) { + // 텍스트 입력 처리 + logger.info("Processing text event: {}", text); + result = speakingService.processTextInput(sessionId, userId, text.trim(), level); + } else { + return response(400, Map.of("error", "Either 'audio' or 'text' is required")); + } + + return response(200, Map.of( + "sessionId", result.sessionId(), + "userTranscript", result.userTranscript(), + "aiText", result.aiText(), + "aiAudioUrl", result.aiAudioUrl(), + "confidence", result.confidence() + )); + } + + /** + * 대화 초기화 + */ + private APIGatewayProxyResponseEvent handleReset(String userId, String body) { + if (body == null || body.isEmpty()) { + return response(400, Map.of("error", "Request body is required")); + } + + JsonObject request = JsonParser.parseString(body).getAsJsonObject(); + String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; + + if (sessionId == null || sessionId.isEmpty()) { + return response(400, Map.of("error", "sessionId is required")); + } + + speakingService.resetConversation(sessionId); + + return response(200, Map.of( + "message", "Conversation reset successfully", + "sessionId", sessionId + )); + } + + private APIGatewayProxyResponseEvent response(int statusCode, Map body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(CORS_HEADERS) + .withBody(gson.toJson(body)); + } + + } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingMessageHandler.java deleted file mode 100644 index e69de29b..00000000 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java deleted file mode 100644 index e69de29b..00000000 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java index aa7acb63..dadda7e7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java @@ -12,63 +12,63 @@ import java.util.Optional; /** - * Speaking WebSocket 연결 정보 Repository + * Speaking API 연결 정보 Repository */ public class SpeakingSessionRepository { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingSessionRepository.class); - private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); - - private final DynamoDbTable table; - - public SpeakingSessionRepository() { - this.table = AwsClients.dynamoDbEnhanced().table( - TABLE_NAME, - TableSchema.fromBean(SpeakingSession.class) - ); - } - - /** - * 연결 정보 저장 - */ - public void save(SpeakingSession session) { - table.putItem(session); - logger.debug("Speaking session saved: sessionId={}, userId={}", - session.getSessionId(), session.getUserId()); - } - - /** - * sessionId로 연결 정보 조회 - */ - public Optional findBySessionId(String sessionId) { - Key key = Key.builder() - .partitionValue(SpeakingSession.PK_PREFIX + sessionId) - .sortValue(SpeakingSession.SK_METADATA) - .build(); - - SpeakingSession session = table.getItem(key); - return Optional.ofNullable(session); - } - - /** - * 연결 정보 업데이트 (대화 히스토리 등) - */ - public void update(SpeakingSession session) { - session.touch(); // 업데이트 시간 및 TTL 갱신 - table.putItem(session); - logger.debug("Speaking session updated: sessionId={}", session.getSessionId()); - } - - /** - * 연결 정보 삭제 - */ - public void delete(String sessionId) { - Key key = Key.builder() - .partitionValue(SpeakingSession.PK_PREFIX + sessionId) - .sortValue(SpeakingSession.SK_METADATA) - .build(); - - table.deleteItem(key); - logger.info("Speaking session deleted: sessionId={}", sessionId); - } -} + + private static final Logger logger = LoggerFactory.getLogger(SpeakingSessionRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("SPEAKING_TABLE_NAME"); + + private final DynamoDbTable table; + + public SpeakingSessionRepository() { + this.table = AwsClients.dynamoDbEnhanced().table( + TABLE_NAME, + TableSchema.fromBean(SpeakingSession.class) + ); + } + + /** + * 연결 정보 저장 + */ + public void save(SpeakingSession session) { + table.putItem(session); + logger.debug("Speaking session saved: sessionId={}, userId={}", + session.getSessionId(), session.getUserId()); + } + + /** + * sessionId로 연결 정보 조회 + */ + public Optional findBySessionId(String sessionId) { + Key key = Key.builder() + .partitionValue(SpeakingSession.PK_PREFIX + sessionId) + .sortValue(SpeakingSession.SK_METADATA) + .build(); + + SpeakingSession session = table.getItem(key); + return Optional.ofNullable(session); + } + + /** + * 연결 정보 업데이트 (대화 히스토리 등) + */ + public void update(SpeakingSession session) { + session.touch(); // 업데이트 시간 및 TTL 갱신 + table.putItem(session); + logger.debug("Speaking session updated: sessionId={}", session.getSessionId()); + } + + /** + * 연결 정보 삭제 + */ + public void delete(String sessionId) { + Key key = Key.builder() + .partitionValue(SpeakingSession.PK_PREFIX + sessionId) + .sortValue(SpeakingSession.SK_METADATA) + .build(); + + table.deleteItem(key); + logger.info("Speaking session deleted: sessionId={}", sessionId); + } +} \ No newline at end of file diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 3fa504b5..50fbb74d 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -37,6 +37,7 @@ Globals: VOCAB_TABLE_NAME: !Ref VocabTable OPIC_TABLE_NAME: !Ref OPIcTable NEWS_TABLE_NAME: !Ref NewsTable + SPEAKING_TABLE_NAME: !Ref SpeakingTable BUCKET_NAME: !Sub "${AWS::StackName}" CHAT_BUCKET_NAME: !Sub "${AWS::StackName}" VOCAB_BUCKET_NAME: !Sub "${AWS::StackName}" @@ -1551,6 +1552,47 @@ Resources: Auth: Authorizer: CognitoAuthorizer + ############################################# + # Speaking Lambda Functions + ############################################# + + SpeakingFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-speaking-handler" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.speaking.handler.SpeakingHandler::handleRequest + Description: Handle speaking chat API + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref SpeakingTable + - S3CrudPolicy: + BucketName: !Sub "${AWS::StackName}" + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + Resource: "*" + Events: + SpeakingChat: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /speaking/chat + Method: POST + Auth: + Authorizer: CognitoAuthorizer + SpeakingReset: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /speaking/reset + Method: POST + Auth: + Authorizer: CognitoAuthorizer + ############################################# # DynamoDB Tables ############################################# @@ -1935,6 +1977,38 @@ Resources: AttributeName: ttl Enabled: true + SpeakingTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: !Sub "${AWS::StackName}-speaking" + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + - AttributeName: GSI1PK + AttributeType: S + - AttributeName: GSI1SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: GSI1 + KeySchema: + - AttributeName: GSI1PK + KeyType: HASH + - AttributeName: GSI1SK + KeyType: RANGE + Projection: + ProjectionType: ALL + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true + ############################################# # S3 Bucket for Content Storage ############################################# @@ -2164,6 +2238,10 @@ Outputs: Description: OPIc DynamoDB Table Name Value: !Ref OPIcTable + SpeakingTableName: + Description: Speaking DynamoDB Table Name + Value: !Ref SpeakingTable + NotificationStreamUrl: Description: Notification SSE Stream Function URL Value: !GetAtt NotificationStreamFunctionUrl.FunctionUrl From e5ff39ae802af9c5be73603f346f5bb97d0652fb Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Sat, 24 Jan 2026 00:40:38 +0900 Subject: [PATCH 08/10] =?UTF-8?q?feature=20:=20=EB=A7=90=ED=95=98=EA=B8=B0?= =?UTF-8?q?=20=EC=97=B0=EC=8A=B5=20=EA=B8=B0=EB=8A=A5=20polly=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EA=B6=8C=ED=95=9C=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#514)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: EnvConfig 유틸리티 추가 및 환경 변수 검증 적용 환경 변수 미설정 시 명확한 에러 메시지를 제공하는 EnvConfig 유틸리티를 추가하고, 기존 System.getenv 호출을 EnvConfig.getRequired/getOrDefault로 대체함. - EnvConfig: getRequired, getOrDefault, getIntOrDefault, getLongOrDefault 메서드 제공 - Lambda Cold Start 시점에 환경 변수 누락을 조기 감지 - 기존 Config 클래스(WebSocketConfig, RoomTokenConfig) EnvConfig 사용으로 통일 Closes #403 * refactor: TestService submitTest 메서드 책임 분리 submitTest 메서드를 단일 책임 원칙에 맞게 리팩토링: - gradeAnswers(): 답안 채점 및 결과 집계 - isAnswerCorrect(): 단일 답안 정답 여부 판단 - buildResultItem(): 결과 항목 생성 - saveTestResult(): 테스트 결과 저장 - GradingResult record: 채점 결과 캡슐화 TestService와 TestCommandService 모두 동일하게 적용 Closes #404 * refactor: 하드코딩된 설정값 환경 변수로 외부화 각 도메인별 Config 클래스를 생성하여 하드코딩된 값들을 환경 변수로 설정 가능하게 변경. 기본값이 있어 환경 변수 미설정 시에도 기존 동작 유지. ## 새로 추가된 Config 클래스 - GrammarConfig: SESSION_TTL_DAYS, MAX_HISTORY_MESSAGES, MAX_TOKENS 등 - GameConfig: TOTAL_ROUNDS, ROUND_TIME_LIMIT, QUICK_GUESS_THRESHOLD_MS - VocabularyConfig: NEW_WORDS_COUNT, REVIEW_WORDS_COUNT, 상태 전이 임계값 등 ## 지원하는 환경 변수 - GRAMMAR_SESSION_TTL_DAYS, GRAMMAR_MAX_HISTORY_MESSAGES, GRAMMAR_MAX_TOKENS - GAME_TOTAL_ROUNDS, GAME_ROUND_TIME_LIMIT, GAME_QUICK_GUESS_THRESHOLD_MS - VOCAB_NEW_WORDS_COUNT, VOCAB_REVIEW_WORDS_COUNT - VOCAB_TRANSITION_TO_REVIEWING, VOCAB_TRANSITION_TO_MASTERED Closes #406 * test: StudyLevel enum 단위 테스트 추가 * test: Difficulty enum 단위 테스트 추가 * test: StudyConfig 단위 테스트 추가 * test: EnvConfig 단위 테스트 추가 * test: PaginatedResult 단위 테스트 추가 * test: JsonUtil 단위 테스트 추가 * test: CursorUtil 단위 테스트 추가 * test: CommonErrorCode 단위 테스트 추가 * test: CommonException 단위 테스트 추가 * test: BadgeType enum 단위 테스트 추가 * test: BadgeKey 상수 단위 테스트 추가 * test: WordStatus enum 단위 테스트 추가 * test: TestType enum 단위 테스트 추가 * test: VocabularyConfig 단위 테스트 추가 * test: VocabKey 상수 단위 테스트 추가 * test: VocabularyErrorCode 단위 테스트 추가 * test: VocabularyException 단위 테스트 추가 * test: SpacedRepetitionContext 단위 테스트 추가 * test: GrammarLevel enum 단위 테스트 추가 * test: GrammarConfig 단위 테스트 추가 * test: GrammarErrorCode 단위 테스트 추가 * test: GrammarException 단위 테스트 추가 * test: GameStatus enum 단위 테스트 추가 * test: GameConfig 단위 테스트 추가 * test: ChattingErrorCode 단위 테스트 추가 * test: ChattingException 단위 테스트 추가 * fix: OPIc FeedbackResponse.java 문법 오류 수정 * style: AwsClients 코드 포맷팅 * style: EnvConfig 코드 포맷팅 * style: RoomTokenConfig 코드 포맷팅 * style: WebSocketConfig 코드 포맷팅 * style: JsonUtil 코드 포맷팅 * style: GameConfig 코드 포맷팅 * style: GrammarConfig 코드 포맷팅 * style: GrammarKey 코드 포맷팅 * style: GrammarErrorCode 코드 포맷팅 * style: GrammarException 코드 포맷팅 * style: BedrockGrammarCheckFactory 코드 포맷팅 * style: GrammarConversationService 코드 포맷팅 * style: FeedbackResponse 코드 포맷팅 * style: SessionReportResponse 코드 포맷팅 * style: SpeakingError 코드 포맷팅 * style: SpeakingErrorType 코드 포맷팅 * style: OPIcException 코드 포맷팅 * style: OPIcAnswer 코드 포맷팅 * style: OPIcQuestion 코드 포맷팅 * style: OPIcSession 코드 포맷팅 * style: OPIcRepository 코드 포맷팅 * style: FeedbackService 코드 포맷팅 * style: TranscribeProxyService 코드 포맷팅 * style: VocabularyConfig 코드 포맷팅 * style: DailyStudyCommandService 코드 포맷팅 * style: TestCommandService 코드 포맷팅 * style: TestService 코드 포맷팅 * style: CommonErrorCodeSpec 코드 포맷팅 * style: JsonUtilSpec 코드 포맷팅 * style: BadgeTypeSpec 코드 포맷팅 * style: ChattingErrorCodeSpec 코드 포맷팅 * style: GrammarLevelSpec 코드 포맷팅 * style: GrammarErrorCodeSpec 코드 포맷팅 * style: VocabularyErrorCodeSpec 코드 포맷팅 * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 ## 변경 사항 ### 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개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * fix : 오타 수정 * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * Release: 캐치마인드 게임 분리, AI 회화 연습, CI/CD 파이프라인 (#469) * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 ## 변경 사항 ### 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개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes --------- Co-authored-by: hyein Heo <128613248+hye-inA@users.noreply.github.com> * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: add CORS headers to API Gateway error responses (#479) API Gateway의 인증 실패 등 에러 응답에 CORS 헤더가 누락되어 CloudFront를 통한 프론트엔드 요청이 차단되는 문제 수정 - UNAUTHORIZED (401) 응답에 CORS 헤더 추가 - ACCESS_DENIED (403) 응답에 CORS 헤더 추가 - DEFAULT_4XX/5XX 응답에 CORS 헤더 추가 - EXPIRED_TOKEN 응답에 CORS 헤더 추가 * feat(news): 뉴스 도메인 기반 구조 구축 (#385) - NewsCategory, QuizType enum 추가 - NewsKey 상수 클래스 추가 - NewsErrorCode 예외 클래스 추가 - NewsArticle, KeywordInfo, QuizQuestion 모델 추가 - NewsArticleRepository CRUD 구현 - NewsTable DynamoDB 테이블 정의 - CORS GatewayResponses 설정 추가 * feat(news): 뉴스 수집 파이프라인 구현 (#386) - NewsApiClient: NewsAPI 연동 서비스 - RssFeedParser: RSS 피드 파싱 (BBC, VOA, NPR) - NewsDuplicateChecker: URL 기반 중복 필터링 - NewsCollectorService: 수집 오케스트레이션 - NewsCollectionHandler: Lambda 핸들러 - EventBridge 스케줄러: 매일 18시 KST 실행 * refactor(news): NewsAPI 제거, RSS만 사용 - NewsApiClient 삭제 - SSM Parameter 의존성 제거 - RSS 소스당 7개씩 수집 (BBC, VOA, NPR = 약 21개/일) * feat(news): AI 뉴스 분석 시스템 구현 (#387) - NewsAnalysisService: AI 분석 통합 서비스 - Bedrock: CEFR 난이도 분석 (A1~C2) - Bedrock: 3줄 요약 + 퀴즈 3문제 생성 - Comprehend: 핵심 키워드 추출 - NewsCollectorService: 수집 시 자동 분석 연동 - GSI1/GSI2 키 자동 설정 (레벨별, 카테고리별 조회) * feat(news): 뉴스 학습 API 구현 (#388) - NewsQueryService: 뉴스 조회 서비스 - NewsHandler: API 핸들러 - GET /news - 목록 조회 (level, category 필터) - GET /news/today - 오늘의 뉴스 - GET /news/recommended - 내 레벨 맞춤 추천 - GET /news/{articleId} - 상세 조회 (조회수 증가) - template.yaml: NewsFunction Lambda 추가 * feat(news): 뉴스 학습 부가 기능 구현 (#389) - 읽기 완료 기록 API (POST /news/{articleId}/read) - 북마크 토글 API (POST /news/{articleId}/bookmark) - 북마크 목록 조회 API (GET /news/bookmarks) - 학습 통계 조회 API (GET /news/stats) - TTS 오디오 URL 조회 API (GET /news/{articleId}/audio) - UserNewsRecord 모델 추가 - UserNewsRepository 추가 - NewsLearningService 추가 * feat(news): 복합 퀴즈 시스템 구현 (#471) - 퀴즈 조회 API (GET /news/{articleId}/quiz) - 퀴즈 제출 API (POST /news/{articleId}/quiz) - 퀴즈 기록 조회 API (GET /news/quiz/history) - NewsQuizResult 모델 추가 - QuizAnswerResult 모델 추가 - NewsQuizRepository 추가 - NewsQuizService 추가 * feat(news): 단어 수집 & Vocabulary 연동 구현 (#472) - 단어 수집 API (POST /news/{articleId}/words) - 수집 단어 목록 API (GET /news/words) - 단어 상세 조회 API (GET /news/{articleId}/words/{word}) - 단어 삭제 API (DELETE /news/{articleId}/words/{word}) - Vocabulary 연동 API (POST /news/words/{word}/sync) - NewsWordCollect 모델 추가 - NewsWordRepository 추가 - NewsWordService 추가 * feat: add multi-environment deployment support (dev/test/prod) * fix: update buildspec.yml to deploy prod environment with parameter overrides * feat(news): 뉴스 도메인 기반 구조 구축 (#385) - NewsCategory, QuizType enum 추가 - NewsKey 상수 클래스 추가 - NewsErrorCode 예외 클래스 추가 - NewsArticle, KeywordInfo, QuizQuestion 모델 추가 - NewsArticleRepository CRUD 구현 - NewsTable DynamoDB 테이블 정의 - CORS GatewayResponses 설정 추가 * feat(news): 뉴스 수집 파이프라인 구현 (#386) - NewsApiClient: NewsAPI 연동 서비스 - RssFeedParser: RSS 피드 파싱 (BBC, VOA, NPR) - NewsDuplicateChecker: URL 기반 중복 필터링 - NewsCollectorService: 수집 오케스트레이션 - NewsCollectionHandler: Lambda 핸들러 - EventBridge 스케줄러: 매일 18시 KST 실행 * refactor(news): NewsAPI 제거, RSS만 사용 - NewsApiClient 삭제 - SSM Parameter 의존성 제거 - RSS 소스당 7개씩 수집 (BBC, VOA, NPR = 약 21개/일) * feat(news): AI 뉴스 분석 시스템 구현 (#387) - NewsAnalysisService: AI 분석 통합 서비스 - Bedrock: CEFR 난이도 분석 (A1~C2) - Bedrock: 3줄 요약 + 퀴즈 3문제 생성 - Comprehend: 핵심 키워드 추출 - NewsCollectorService: 수집 시 자동 분석 연동 - GSI1/GSI2 키 자동 설정 (레벨별, 카테고리별 조회) * feat(news): 뉴스 학습 API 구현 (#388) - NewsQueryService: 뉴스 조회 서비스 - NewsHandler: API 핸들러 - GET /news - 목록 조회 (level, category 필터) - GET /news/today - 오늘의 뉴스 - GET /news/recommended - 내 레벨 맞춤 추천 - GET /news/{articleId} - 상세 조회 (조회수 증가) - template.yaml: NewsFunction Lambda 추가 * feat(news): 뉴스 학습 부가 기능 구현 (#389) - 읽기 완료 기록 API (POST /news/{articleId}/read) - 북마크 토글 API (POST /news/{articleId}/bookmark) - 북마크 목록 조회 API (GET /news/bookmarks) - 학습 통계 조회 API (GET /news/stats) - TTS 오디오 URL 조회 API (GET /news/{articleId}/audio) - UserNewsRecord 모델 추가 - UserNewsRepository 추가 - NewsLearningService 추가 * feat(news): 복합 퀴즈 시스템 구현 (#471) - 퀴즈 조회 API (GET /news/{articleId}/quiz) - 퀴즈 제출 API (POST /news/{articleId}/quiz) - 퀴즈 기록 조회 API (GET /news/quiz/history) - NewsQuizResult 모델 추가 - QuizAnswerResult 모델 추가 - NewsQuizRepository 추가 - NewsQuizService 추가 * feat(news): 단어 수집 & Vocabulary 연동 구현 (#472) - 단어 수집 API (POST /news/{articleId}/words) - 수집 단어 목록 API (GET /news/words) - 단어 상세 조회 API (GET /news/{articleId}/words/{word}) - 단어 삭제 API (DELETE /news/{articleId}/words/{word}) - Vocabulary 연동 API (POST /news/words/{word}/sync) - NewsWordCollect 모델 추가 - NewsWordRepository 추가 - NewsWordService 추가 * feat: add multi-environment deployment support (dev/test/prod) * fix: update buildspec.yml to deploy prod environment with parameter overrides * refactor : AI 말하기 Websocket 구현 -> REST API 구현으로 리팩토링 (#490) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: revert buildspec.yml to build-only for CloudFormation deploy stage * fix: revert buildspec.yml to build-only for CloudFormation deploy stage * fix: update all API Gateway StageName to use Environment parameter * fix: correct VocabularyTable and ContentBucket references in NewsFunction * fix: correct VocabularyTable and ContentBucket references in NewsFunction * fix: add stack name prefix to GameScheduleGroup and daily-stats schedule to avoid conflicts * fix: add stack name prefix to GameScheduleGroup and daily-stats schedule to avoid conflicts * feat: add support for existing Cognito User Pool reuse across environments * fix: add conditional Cognito ARN reference in API Gateway Authorizer * fix: remove Cognito resources completely, use existing Cognito only * fix: remove Cognito resources completely, use existing Cognito only * feat : speaking rest API 람다 함수 추가 * feat: add S3 bucket resource and fix environment-specific endpoints - Add ContentBucket S3 resource for content storage - Replace hardcoded /dev with ${Environment} in all WebSocket endpoints - Update Output URLs to use dynamic environment stage Co-Authored-By: Claude Opus 4.5 * fix: add stack name prefix to news-collection schedule name Prevent EventBridge rule name conflicts across environments Co-Authored-By: Claude Opus 4.5 * fix: add X-Requested-With and Accept headers to CORS config Enable additional headers for CloudFront CORS compatibility Co-Authored-By: Claude Opus 4.5 * fix: use environment variable for S3 bucket URLs Replace hardcoded bucket name with BUCKET_NAME env var for multi-env support: - PreSignUpHandler: dynamic default profile URL - PostConfirmationHandler: dynamic default profile URL - UserService: dynamic default profile URL - BadgeType: dynamic badge image base URL Co-Authored-By: Claude Opus 4.5 * fix: disable authorizer for CORS preflight requests Add AddDefaultAuthorizerToCorsPreflight: false to prevent Cognito Authorizer from blocking OPTIONS requests Co-Authored-By: Claude Opus 4.5 * feat : speaking REST API 람다 함수 추가 (#491) * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * fix : 오타 수정 * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: add CORS headers to API Gateway error responses (#479) API Gateway의 인증 실패 등 에러 응답에 CORS 헤더가 누락되어 CloudFront를 통한 프론트엔드 요청이 차단되는 문제 수정 - UNAUTHORIZED (401) 응답에 CORS 헤더 추가 - ACCESS_DENIED (403) 응답에 CORS 헤더 추가 - DEFAULT_4XX/5XX 응답에 CORS 헤더 추가 - EXPIRED_TOKEN 응답에 CORS 헤더 추가 * feat : speaking rest API 람다 함수 추가 --------- Co-authored-by: ddingjoo * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feature : test 벡엔드 서버에 AI 말하기 연습 기능 배포 (#492) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 --------- Co-authored-by: DDING JOO * feat : handleChat 메서드 JsonNull 체크 푸가 * feature : handleChat 메서드 JsonNull 체크 추가 (#493) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feat : handleChat 메서드 JsonNull 체크 푸가 --------- Co-authored-by: DDING JOO * feat(news): 뉴스 학습 배지 시스템 구현 (#473) - 14개 뉴스 관련 배지 추가 (읽기, 퀴즈, 단어수집, 연속학습, 마스터) - UserStats에 뉴스 통계 필드 추가 - 6개 뉴스 배지 Strategy 클래스 생성 - 뉴스 읽기/퀴즈/단어수집 시 통계 업데이트 및 배지 체크 연동 * fix: add PATCH method to CORS AllowMethods * test: BadgeType 개수 테스트 수정 (15 -> 29) * fix: CORS PATCH 메서드 추가 * docs: 뉴스 기능 프론트엔드 연동 가이드 작성 * fix: NewsCollectionFunction에 Bedrock, Comprehend 권한 추가 * fix: add null check for collectWord request body - Add INVALID_REQUEST error code to NewsErrorCode - Check body and word field before accessing in collectWord() - Prevents NullPointerException when request body is malformed * feat: enhance stats API and bookmark response for frontend - Add DAILY stats update for news read/quiz/word collection - Add /stats/dashboard endpoint with frontend-requested format - today: wordsLearned, newsRead, quizzesTaken, wordsTotal - overall: totalWordsLearned, totalNewsRead, averageAccuracy, streaks - weeklyProgress: last 7 days with date/wordsLearned/newsRead - Add news-related fields to all stats API responses - Fix bookmark API to include full article details (title, summary, etc.) * feat: add category classification to news AI analysis - Add category field to AnalysisResult record - Update Bedrock prompt to classify articles into categories (WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE) - Parse and set category from AI response - Set GSI2 (CATEGORY#) index when category is available * fix: add /stats/dashboard endpoint to template.yaml * fix: filter by ARTICLE# prefix in findById to avoid returning UserNewsRecord * docs: add News API troubleshooting guide * feat: add Cognito authorizer to News API and enhance keyword extraction - Set CognitoAuthorizer for all News API endpoints in template.yaml - Update Bedrock AI to extract keywords with meanings and examples - Add fallback to Comprehend for keyword extraction when Bedrock fails - Modify KeywordInfo model to include example field - Adjust AI prompt to include keyword extraction with examples - Update AnalysisResult to store keywords and parse them from AI response * Revert "Merge branch 'test' into prod" This reverts commit c1a958eac6799d5838a76e4078ae304bcdb62278, reversing changes made to a6662e0cfc46c6e12f20a89f0df285ff282160bc. * feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords * refactor : session_id가 null 체크 추가 * feat : template 환경변수 리펙토링 * fix : sessionId NullPointerException 에러 수정 (#496) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feat : handleChat 메서드 JsonNull 체크 푸가 * refactor : session_id가 null 체크 추가 * feat : template 환경변수 리펙토링 --------- Co-authored-by: DDING JOO * feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords * feat: add category filtering to UserWord API with enhanced query logic - Introduce `category` filtering to `getUserWords` API - Update WordCategory enums to include "news" category - Apply category filter after enrichment with word info - Adjust query limits for bookmarked and incorrect words queries * feat: add default value for ExistingCognitoClientId in template.yaml - Set default to an empty string to ensure compatibility with templates using this parameter without explicitly specifying a value. * fix: SpeakingHandler getStringOrNull 컴파일 에러 수정 * fix: SpeakingHandler getStringOrNull 컴파일 에러 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * refactor: format code with consistent indentation and spacing - Apply consistent formatting to improve code readability across multiple files - Adjust indentation, spacing, and alignment in model classes, DTOs, and repository methods * fix: filter by ARTICLE# prefix in findById to avoid returning bookmark records Co-Authored-By: Claude Opus 4.5 * feat : Speaking 관련 template 람다 함수 및 테이블 추가 * feat : 말하기 기능에 polly 서비스 권한 추가 --------- Co-authored-by: DDING JOO Co-authored-by: Claude Opus 4.5 --- ServerlessFunction/template.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 50fbb74d..c0a7be9b 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -44,6 +44,7 @@ Globals: PROFILE_BUCKET_NAME: !Sub "${AWS::StackName}" OPIC_BUCKET_NAME: !Sub "${AWS::StackName}" NEWS_BUCKET_NAME: !Sub "${AWS::StackName}" + SPEAKING_BUCKET_NAME: !Sub "${AWS::StackName}" AWS_REGION_NAME: !Ref AWS::Region ROOM_TOKEN_TTL_SECONDS: "300" TRANSCRIBE_PROXY_URL: "https://tfo1zm7vec.execute-api.ap-northeast-2.amazonaws.com/prod/transcribe" @@ -1574,6 +1575,7 @@ Resources: - Effect: Allow Action: - bedrock:InvokeModel + - polly:SynthesizeSpeech Resource: "*" Events: SpeakingChat: From c01be8a7ab847f94ca82e40d1c1947f843ede567 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Sat, 24 Jan 2026 01:06:18 +0900 Subject: [PATCH 09/10] =?UTF-8?q?feature=20:=20transcribe=20API=20KEY=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=20(#516)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: EnvConfig 유틸리티 추가 및 환경 변수 검증 적용 환경 변수 미설정 시 명확한 에러 메시지를 제공하는 EnvConfig 유틸리티를 추가하고, 기존 System.getenv 호출을 EnvConfig.getRequired/getOrDefault로 대체함. - EnvConfig: getRequired, getOrDefault, getIntOrDefault, getLongOrDefault 메서드 제공 - Lambda Cold Start 시점에 환경 변수 누락을 조기 감지 - 기존 Config 클래스(WebSocketConfig, RoomTokenConfig) EnvConfig 사용으로 통일 Closes #403 * refactor: TestService submitTest 메서드 책임 분리 submitTest 메서드를 단일 책임 원칙에 맞게 리팩토링: - gradeAnswers(): 답안 채점 및 결과 집계 - isAnswerCorrect(): 단일 답안 정답 여부 판단 - buildResultItem(): 결과 항목 생성 - saveTestResult(): 테스트 결과 저장 - GradingResult record: 채점 결과 캡슐화 TestService와 TestCommandService 모두 동일하게 적용 Closes #404 * refactor: 하드코딩된 설정값 환경 변수로 외부화 각 도메인별 Config 클래스를 생성하여 하드코딩된 값들을 환경 변수로 설정 가능하게 변경. 기본값이 있어 환경 변수 미설정 시에도 기존 동작 유지. ## 새로 추가된 Config 클래스 - GrammarConfig: SESSION_TTL_DAYS, MAX_HISTORY_MESSAGES, MAX_TOKENS 등 - GameConfig: TOTAL_ROUNDS, ROUND_TIME_LIMIT, QUICK_GUESS_THRESHOLD_MS - VocabularyConfig: NEW_WORDS_COUNT, REVIEW_WORDS_COUNT, 상태 전이 임계값 등 ## 지원하는 환경 변수 - GRAMMAR_SESSION_TTL_DAYS, GRAMMAR_MAX_HISTORY_MESSAGES, GRAMMAR_MAX_TOKENS - GAME_TOTAL_ROUNDS, GAME_ROUND_TIME_LIMIT, GAME_QUICK_GUESS_THRESHOLD_MS - VOCAB_NEW_WORDS_COUNT, VOCAB_REVIEW_WORDS_COUNT - VOCAB_TRANSITION_TO_REVIEWING, VOCAB_TRANSITION_TO_MASTERED Closes #406 * test: StudyLevel enum 단위 테스트 추가 * test: Difficulty enum 단위 테스트 추가 * test: StudyConfig 단위 테스트 추가 * test: EnvConfig 단위 테스트 추가 * test: PaginatedResult 단위 테스트 추가 * test: JsonUtil 단위 테스트 추가 * test: CursorUtil 단위 테스트 추가 * test: CommonErrorCode 단위 테스트 추가 * test: CommonException 단위 테스트 추가 * test: BadgeType enum 단위 테스트 추가 * test: BadgeKey 상수 단위 테스트 추가 * test: WordStatus enum 단위 테스트 추가 * test: TestType enum 단위 테스트 추가 * test: VocabularyConfig 단위 테스트 추가 * test: VocabKey 상수 단위 테스트 추가 * test: VocabularyErrorCode 단위 테스트 추가 * test: VocabularyException 단위 테스트 추가 * test: SpacedRepetitionContext 단위 테스트 추가 * test: GrammarLevel enum 단위 테스트 추가 * test: GrammarConfig 단위 테스트 추가 * test: GrammarErrorCode 단위 테스트 추가 * test: GrammarException 단위 테스트 추가 * test: GameStatus enum 단위 테스트 추가 * test: GameConfig 단위 테스트 추가 * test: ChattingErrorCode 단위 테스트 추가 * test: ChattingException 단위 테스트 추가 * fix: OPIc FeedbackResponse.java 문법 오류 수정 * style: AwsClients 코드 포맷팅 * style: EnvConfig 코드 포맷팅 * style: RoomTokenConfig 코드 포맷팅 * style: WebSocketConfig 코드 포맷팅 * style: JsonUtil 코드 포맷팅 * style: GameConfig 코드 포맷팅 * style: GrammarConfig 코드 포맷팅 * style: GrammarKey 코드 포맷팅 * style: GrammarErrorCode 코드 포맷팅 * style: GrammarException 코드 포맷팅 * style: BedrockGrammarCheckFactory 코드 포맷팅 * style: GrammarConversationService 코드 포맷팅 * style: FeedbackResponse 코드 포맷팅 * style: SessionReportResponse 코드 포맷팅 * style: SpeakingError 코드 포맷팅 * style: SpeakingErrorType 코드 포맷팅 * style: OPIcException 코드 포맷팅 * style: OPIcAnswer 코드 포맷팅 * style: OPIcQuestion 코드 포맷팅 * style: OPIcSession 코드 포맷팅 * style: OPIcRepository 코드 포맷팅 * style: FeedbackService 코드 포맷팅 * style: TranscribeProxyService 코드 포맷팅 * style: VocabularyConfig 코드 포맷팅 * style: DailyStudyCommandService 코드 포맷팅 * style: TestCommandService 코드 포맷팅 * style: TestService 코드 포맷팅 * style: CommonErrorCodeSpec 코드 포맷팅 * style: JsonUtilSpec 코드 포맷팅 * style: BadgeTypeSpec 코드 포맷팅 * style: ChattingErrorCodeSpec 코드 포맷팅 * style: GrammarLevelSpec 코드 포맷팅 * style: GrammarErrorCodeSpec 코드 포맷팅 * style: VocabularyErrorCodeSpec 코드 포맷팅 * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 ## 변경 사항 ### 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개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * fix : 오타 수정 * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * Release: 캐치마인드 게임 분리, AI 회화 연습, CI/CD 파이프라인 (#469) * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 ## 변경 사항 ### 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개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes --------- Co-authored-by: hyein Heo <128613248+hye-inA@users.noreply.github.com> * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: add CORS headers to API Gateway error responses (#479) API Gateway의 인증 실패 등 에러 응답에 CORS 헤더가 누락되어 CloudFront를 통한 프론트엔드 요청이 차단되는 문제 수정 - UNAUTHORIZED (401) 응답에 CORS 헤더 추가 - ACCESS_DENIED (403) 응답에 CORS 헤더 추가 - DEFAULT_4XX/5XX 응답에 CORS 헤더 추가 - EXPIRED_TOKEN 응답에 CORS 헤더 추가 * feat(news): 뉴스 도메인 기반 구조 구축 (#385) - NewsCategory, QuizType enum 추가 - NewsKey 상수 클래스 추가 - NewsErrorCode 예외 클래스 추가 - NewsArticle, KeywordInfo, QuizQuestion 모델 추가 - NewsArticleRepository CRUD 구현 - NewsTable DynamoDB 테이블 정의 - CORS GatewayResponses 설정 추가 * feat(news): 뉴스 수집 파이프라인 구현 (#386) - NewsApiClient: NewsAPI 연동 서비스 - RssFeedParser: RSS 피드 파싱 (BBC, VOA, NPR) - NewsDuplicateChecker: URL 기반 중복 필터링 - NewsCollectorService: 수집 오케스트레이션 - NewsCollectionHandler: Lambda 핸들러 - EventBridge 스케줄러: 매일 18시 KST 실행 * refactor(news): NewsAPI 제거, RSS만 사용 - NewsApiClient 삭제 - SSM Parameter 의존성 제거 - RSS 소스당 7개씩 수집 (BBC, VOA, NPR = 약 21개/일) * feat(news): AI 뉴스 분석 시스템 구현 (#387) - NewsAnalysisService: AI 분석 통합 서비스 - Bedrock: CEFR 난이도 분석 (A1~C2) - Bedrock: 3줄 요약 + 퀴즈 3문제 생성 - Comprehend: 핵심 키워드 추출 - NewsCollectorService: 수집 시 자동 분석 연동 - GSI1/GSI2 키 자동 설정 (레벨별, 카테고리별 조회) * feat(news): 뉴스 학습 API 구현 (#388) - NewsQueryService: 뉴스 조회 서비스 - NewsHandler: API 핸들러 - GET /news - 목록 조회 (level, category 필터) - GET /news/today - 오늘의 뉴스 - GET /news/recommended - 내 레벨 맞춤 추천 - GET /news/{articleId} - 상세 조회 (조회수 증가) - template.yaml: NewsFunction Lambda 추가 * feat(news): 뉴스 학습 부가 기능 구현 (#389) - 읽기 완료 기록 API (POST /news/{articleId}/read) - 북마크 토글 API (POST /news/{articleId}/bookmark) - 북마크 목록 조회 API (GET /news/bookmarks) - 학습 통계 조회 API (GET /news/stats) - TTS 오디오 URL 조회 API (GET /news/{articleId}/audio) - UserNewsRecord 모델 추가 - UserNewsRepository 추가 - NewsLearningService 추가 * feat(news): 복합 퀴즈 시스템 구현 (#471) - 퀴즈 조회 API (GET /news/{articleId}/quiz) - 퀴즈 제출 API (POST /news/{articleId}/quiz) - 퀴즈 기록 조회 API (GET /news/quiz/history) - NewsQuizResult 모델 추가 - QuizAnswerResult 모델 추가 - NewsQuizRepository 추가 - NewsQuizService 추가 * feat(news): 단어 수집 & Vocabulary 연동 구현 (#472) - 단어 수집 API (POST /news/{articleId}/words) - 수집 단어 목록 API (GET /news/words) - 단어 상세 조회 API (GET /news/{articleId}/words/{word}) - 단어 삭제 API (DELETE /news/{articleId}/words/{word}) - Vocabulary 연동 API (POST /news/words/{word}/sync) - NewsWordCollect 모델 추가 - NewsWordRepository 추가 - NewsWordService 추가 * feat: add multi-environment deployment support (dev/test/prod) * fix: update buildspec.yml to deploy prod environment with parameter overrides * feat(news): 뉴스 도메인 기반 구조 구축 (#385) - NewsCategory, QuizType enum 추가 - NewsKey 상수 클래스 추가 - NewsErrorCode 예외 클래스 추가 - NewsArticle, KeywordInfo, QuizQuestion 모델 추가 - NewsArticleRepository CRUD 구현 - NewsTable DynamoDB 테이블 정의 - CORS GatewayResponses 설정 추가 * feat(news): 뉴스 수집 파이프라인 구현 (#386) - NewsApiClient: NewsAPI 연동 서비스 - RssFeedParser: RSS 피드 파싱 (BBC, VOA, NPR) - NewsDuplicateChecker: URL 기반 중복 필터링 - NewsCollectorService: 수집 오케스트레이션 - NewsCollectionHandler: Lambda 핸들러 - EventBridge 스케줄러: 매일 18시 KST 실행 * refactor(news): NewsAPI 제거, RSS만 사용 - NewsApiClient 삭제 - SSM Parameter 의존성 제거 - RSS 소스당 7개씩 수집 (BBC, VOA, NPR = 약 21개/일) * feat(news): AI 뉴스 분석 시스템 구현 (#387) - NewsAnalysisService: AI 분석 통합 서비스 - Bedrock: CEFR 난이도 분석 (A1~C2) - Bedrock: 3줄 요약 + 퀴즈 3문제 생성 - Comprehend: 핵심 키워드 추출 - NewsCollectorService: 수집 시 자동 분석 연동 - GSI1/GSI2 키 자동 설정 (레벨별, 카테고리별 조회) * feat(news): 뉴스 학습 API 구현 (#388) - NewsQueryService: 뉴스 조회 서비스 - NewsHandler: API 핸들러 - GET /news - 목록 조회 (level, category 필터) - GET /news/today - 오늘의 뉴스 - GET /news/recommended - 내 레벨 맞춤 추천 - GET /news/{articleId} - 상세 조회 (조회수 증가) - template.yaml: NewsFunction Lambda 추가 * feat(news): 뉴스 학습 부가 기능 구현 (#389) - 읽기 완료 기록 API (POST /news/{articleId}/read) - 북마크 토글 API (POST /news/{articleId}/bookmark) - 북마크 목록 조회 API (GET /news/bookmarks) - 학습 통계 조회 API (GET /news/stats) - TTS 오디오 URL 조회 API (GET /news/{articleId}/audio) - UserNewsRecord 모델 추가 - UserNewsRepository 추가 - NewsLearningService 추가 * feat(news): 복합 퀴즈 시스템 구현 (#471) - 퀴즈 조회 API (GET /news/{articleId}/quiz) - 퀴즈 제출 API (POST /news/{articleId}/quiz) - 퀴즈 기록 조회 API (GET /news/quiz/history) - NewsQuizResult 모델 추가 - QuizAnswerResult 모델 추가 - NewsQuizRepository 추가 - NewsQuizService 추가 * feat(news): 단어 수집 & Vocabulary 연동 구현 (#472) - 단어 수집 API (POST /news/{articleId}/words) - 수집 단어 목록 API (GET /news/words) - 단어 상세 조회 API (GET /news/{articleId}/words/{word}) - 단어 삭제 API (DELETE /news/{articleId}/words/{word}) - Vocabulary 연동 API (POST /news/words/{word}/sync) - NewsWordCollect 모델 추가 - NewsWordRepository 추가 - NewsWordService 추가 * feat: add multi-environment deployment support (dev/test/prod) * fix: update buildspec.yml to deploy prod environment with parameter overrides * refactor : AI 말하기 Websocket 구현 -> REST API 구현으로 리팩토링 (#490) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: revert buildspec.yml to build-only for CloudFormation deploy stage * fix: revert buildspec.yml to build-only for CloudFormation deploy stage * fix: update all API Gateway StageName to use Environment parameter * fix: correct VocabularyTable and ContentBucket references in NewsFunction * fix: correct VocabularyTable and ContentBucket references in NewsFunction * fix: add stack name prefix to GameScheduleGroup and daily-stats schedule to avoid conflicts * fix: add stack name prefix to GameScheduleGroup and daily-stats schedule to avoid conflicts * feat: add support for existing Cognito User Pool reuse across environments * fix: add conditional Cognito ARN reference in API Gateway Authorizer * fix: remove Cognito resources completely, use existing Cognito only * fix: remove Cognito resources completely, use existing Cognito only * feat : speaking rest API 람다 함수 추가 * feat: add S3 bucket resource and fix environment-specific endpoints - Add ContentBucket S3 resource for content storage - Replace hardcoded /dev with ${Environment} in all WebSocket endpoints - Update Output URLs to use dynamic environment stage Co-Authored-By: Claude Opus 4.5 * fix: add stack name prefix to news-collection schedule name Prevent EventBridge rule name conflicts across environments Co-Authored-By: Claude Opus 4.5 * fix: add X-Requested-With and Accept headers to CORS config Enable additional headers for CloudFront CORS compatibility Co-Authored-By: Claude Opus 4.5 * fix: use environment variable for S3 bucket URLs Replace hardcoded bucket name with BUCKET_NAME env var for multi-env support: - PreSignUpHandler: dynamic default profile URL - PostConfirmationHandler: dynamic default profile URL - UserService: dynamic default profile URL - BadgeType: dynamic badge image base URL Co-Authored-By: Claude Opus 4.5 * fix: disable authorizer for CORS preflight requests Add AddDefaultAuthorizerToCorsPreflight: false to prevent Cognito Authorizer from blocking OPTIONS requests Co-Authored-By: Claude Opus 4.5 * feat : speaking REST API 람다 함수 추가 (#491) * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * fix : 오타 수정 * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: add CORS headers to API Gateway error responses (#479) API Gateway의 인증 실패 등 에러 응답에 CORS 헤더가 누락되어 CloudFront를 통한 프론트엔드 요청이 차단되는 문제 수정 - UNAUTHORIZED (401) 응답에 CORS 헤더 추가 - ACCESS_DENIED (403) 응답에 CORS 헤더 추가 - DEFAULT_4XX/5XX 응답에 CORS 헤더 추가 - EXPIRED_TOKEN 응답에 CORS 헤더 추가 * feat : speaking rest API 람다 함수 추가 --------- Co-authored-by: ddingjoo * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feature : test 벡엔드 서버에 AI 말하기 연습 기능 배포 (#492) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 --------- Co-authored-by: DDING JOO * feat : handleChat 메서드 JsonNull 체크 푸가 * feature : handleChat 메서드 JsonNull 체크 추가 (#493) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feat : handleChat 메서드 JsonNull 체크 푸가 --------- Co-authored-by: DDING JOO * feat(news): 뉴스 학습 배지 시스템 구현 (#473) - 14개 뉴스 관련 배지 추가 (읽기, 퀴즈, 단어수집, 연속학습, 마스터) - UserStats에 뉴스 통계 필드 추가 - 6개 뉴스 배지 Strategy 클래스 생성 - 뉴스 읽기/퀴즈/단어수집 시 통계 업데이트 및 배지 체크 연동 * fix: add PATCH method to CORS AllowMethods * test: BadgeType 개수 테스트 수정 (15 -> 29) * fix: CORS PATCH 메서드 추가 * docs: 뉴스 기능 프론트엔드 연동 가이드 작성 * fix: NewsCollectionFunction에 Bedrock, Comprehend 권한 추가 * fix: add null check for collectWord request body - Add INVALID_REQUEST error code to NewsErrorCode - Check body and word field before accessing in collectWord() - Prevents NullPointerException when request body is malformed * feat: enhance stats API and bookmark response for frontend - Add DAILY stats update for news read/quiz/word collection - Add /stats/dashboard endpoint with frontend-requested format - today: wordsLearned, newsRead, quizzesTaken, wordsTotal - overall: totalWordsLearned, totalNewsRead, averageAccuracy, streaks - weeklyProgress: last 7 days with date/wordsLearned/newsRead - Add news-related fields to all stats API responses - Fix bookmark API to include full article details (title, summary, etc.) * feat: add category classification to news AI analysis - Add category field to AnalysisResult record - Update Bedrock prompt to classify articles into categories (WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE) - Parse and set category from AI response - Set GSI2 (CATEGORY#) index when category is available * fix: add /stats/dashboard endpoint to template.yaml * fix: filter by ARTICLE# prefix in findById to avoid returning UserNewsRecord * docs: add News API troubleshooting guide * feat: add Cognito authorizer to News API and enhance keyword extraction - Set CognitoAuthorizer for all News API endpoints in template.yaml - Update Bedrock AI to extract keywords with meanings and examples - Add fallback to Comprehend for keyword extraction when Bedrock fails - Modify KeywordInfo model to include example field - Adjust AI prompt to include keyword extraction with examples - Update AnalysisResult to store keywords and parse them from AI response * Revert "Merge branch 'test' into prod" This reverts commit c1a958eac6799d5838a76e4078ae304bcdb62278, reversing changes made to a6662e0cfc46c6e12f20a89f0df285ff282160bc. * feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords * refactor : session_id가 null 체크 추가 * feat : template 환경변수 리펙토링 * fix : sessionId NullPointerException 에러 수정 (#496) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feat : handleChat 메서드 JsonNull 체크 푸가 * refactor : session_id가 null 체크 추가 * feat : template 환경변수 리펙토링 --------- Co-authored-by: DDING JOO * feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords * feat: add category filtering to UserWord API with enhanced query logic - Introduce `category` filtering to `getUserWords` API - Update WordCategory enums to include "news" category - Apply category filter after enrichment with word info - Adjust query limits for bookmarked and incorrect words queries * feat: add default value for ExistingCognitoClientId in template.yaml - Set default to an empty string to ensure compatibility with templates using this parameter without explicitly specifying a value. * fix: SpeakingHandler getStringOrNull 컴파일 에러 수정 * fix: SpeakingHandler getStringOrNull 컴파일 에러 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * refactor: format code with consistent indentation and spacing - Apply consistent formatting to improve code readability across multiple files - Adjust indentation, spacing, and alignment in model classes, DTOs, and repository methods * fix: filter by ARTICLE# prefix in findById to avoid returning bookmark records Co-Authored-By: Claude Opus 4.5 * feat : Speaking 관련 template 람다 함수 및 테이블 추가 * feat : 말하기 기능에 polly 서비스 권한 추가 * feat : transcribe API KEY 추가 --------- Co-authored-by: DDING JOO Co-authored-by: Claude Opus 4.5 --- ServerlessFunction/template.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index c0a7be9b..857336c8 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1566,11 +1566,19 @@ Resources: Description: Handle speaking chat API SnapStart: ApplyOn: PublishedVersions + Environment: + Variables: + TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" Policies: - DynamoDBCrudPolicy: TableName: !Ref SpeakingTable - S3CrudPolicy: BucketName: !Sub "${AWS::StackName}" + - Statement: + - Effect: Allow + Action: + - ssm:GetParameter + Resource: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/opic/*" - Statement: - Effect: Allow Action: From 1de4def0aa4ee61b100cbf5fcb2326b191e7840e Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sat, 24 Jan 2026 11:33:09 +0900 Subject: [PATCH 10/10] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=EC=8A=AC?= =?UTF-8?q?=EB=9E=98=EC=8B=9C=20=EB=AA=85=EB=A0=B9=EC=96=B4=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EA=B3=A0=EB=8F=84=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 게임 관련 명령어 제거 (/start, /stop, /score, /skip, /hint) - 기본 명령어 추가: /help, /members, /leave, /clear - 재미 명령어 추가: /dice, /coin, /random - 투표 시스템 구현: /poll, /vote, /endpoll - Poll 모델 및 PollRepository 추가 - MessageType에 POLL_CREATE, POLL_VOTE, POLL_END 추가 Closes #518, #519, #520 --- .../domain/chatting/enums/MessageType.java | 11 +- .../domain/chatting/model/Poll.java | 80 +++ .../chatting/repository/PollRepository.java | 70 +++ .../chatting/service/CommandService.java | 508 ++++++++++++++---- .../domain/chatting/model/PollSpec.groovy | 148 +++++ 5 files changed, 718 insertions(+), 99 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/Poll.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/PollRepository.java create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/PollSpec.groovy 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 b8a7d453..fddc60b7 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 @@ -22,7 +22,16 @@ public enum MessageType { // 방 관련 메시지 타입 ROOM_STATUS_CHANGE("room_status_change", "방 상태 변경"), - HOST_CHANGE("host_change", "방장 변경"); + HOST_CHANGE("host_change", "방장 변경"), + + // 투표 관련 메시지 타입 + POLL_CREATE("poll_create", "투표 생성"), + POLL_VOTE("poll_vote", "투표 참여"), + POLL_END("poll_end", "투표 종료"), + + // 유틸리티 메시지 타입 + CLEAR_CHAT("clear_chat", "채팅 삭제"), + LEAVE_ROOM("leave_room", "채팅방 나가기"); private final String code; private final String displayName; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/Poll.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/Poll.java new file mode 100644 index 00000000..0c8eced9 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/Poll.java @@ -0,0 +1,80 @@ +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; + +/** + * 채팅방 투표 모델 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class Poll { + + private String pk; // ROOM#{roomId} + private String sk; // POLL#{pollId} + + private String pollId; + private String roomId; + private String question; + private List options; + private Map votes; // optionIndex -> count + private Map userVotes; // userId -> optionIndex + private String createdBy; + private String createdAt; + private Boolean isActive; + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + /** + * 투표 추가 + */ + public boolean addVote(String userId, int optionIndex) { + if (optionIndex < 0 || optionIndex >= options.size()) { + return false; + } + + // 이미 투표했는지 확인 + if (userVotes.containsKey(userId)) { + return false; + } + + userVotes.put(userId, optionIndex); + votes.merge(String.valueOf(optionIndex), 1, Integer::sum); + return true; + } + + /** + * 사용자가 이미 투표했는지 확인 + */ + public boolean hasVoted(String userId) { + return userVotes != null && userVotes.containsKey(userId); + } + + /** + * 총 투표 수 + */ + public int getTotalVotes() { + if (votes == null) return 0; + return votes.values().stream().mapToInt(Integer::intValue).sum(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/PollRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/PollRepository.java new file mode 100644 index 00000000..f49a6908 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/PollRepository.java @@ -0,0 +1,70 @@ +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.Poll; +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 software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; + +import java.util.Optional; + +/** + * Poll Repository + */ +public class PollRepository { + + private static final Logger logger = LoggerFactory.getLogger(PollRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); + + private final DynamoDbTable table; + + public PollRepository() { + this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(Poll.class)); + } + + public PollRepository(DynamoDbTable table) { + this.table = table; + } + + public void save(Poll poll) { + table.putItem(poll); + logger.debug("Saved poll: {}", poll.getPollId()); + } + + public Optional findById(String roomId, String pollId) { + Key key = Key.builder() + .partitionValue("ROOM#" + roomId) + .sortValue("POLL#" + pollId) + .build(); + Poll poll = table.getItem(key); + return Optional.ofNullable(poll); + } + + /** + * 방의 활성 투표 조회 + */ + public Optional findActiveByRoomId(String roomId) { + return table.query(QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue("ROOM#" + roomId) + .sortValue("POLL#") + .build())) + .items() + .stream() + .filter(poll -> Boolean.TRUE.equals(poll.getIsActive())) + .findFirst(); + } + + public void delete(String roomId, String pollId) { + Key key = Key.builder() + .partitionValue("ROOM#" + roomId) + .sortValue("POLL#" + pollId) + .build(); + table.deleteItem(key); + logger.debug("Deleted poll: {}", pollId); + } +} 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 71e0ddf2..89d6e098 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 @@ -3,44 +3,49 @@ 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.model.GameSession; +import com.mzc.secondproject.serverless.domain.chatting.model.Poll; 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.repository.PollRepository; +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; -import java.util.List; -import java.util.Optional; +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; /** * 슬래시 명령어 처리 서비스 */ public class CommandService { - + private static final Logger logger = LoggerFactory.getLogger(CommandService.class); - + private final ConnectionRepository connectionRepository; - private final GameSessionRepository gameSessionRepository; - private final GameService gameService; - + private final PollRepository pollRepository; + private final UserRepository userRepository; + private final Random random; + /** * 기본 생성자 (Lambda에서 사용) */ public CommandService() { - this(new ConnectionRepository(), new GameSessionRepository(), new GameService()); + this(new ConnectionRepository(), new PollRepository(), new UserRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ public CommandService(ConnectionRepository connectionRepository, - GameSessionRepository gameSessionRepository, - GameService gameService) { + PollRepository pollRepository, + UserRepository userRepository) { this.connectionRepository = connectionRepository; - this.gameSessionRepository = gameSessionRepository; - this.gameService = gameService; + this.pollRepository = pollRepository; + this.userRepository = userRepository; + this.random = new Random(); } - + /** * 명령어 처리 * @@ -53,119 +58,426 @@ public Optional processCommand(String content, String roomId, Str if (content == null || !content.startsWith("/")) { return Optional.empty(); } - + String[] parts = content.trim().split("\\s+", 2); String command = parts[0].toLowerCase(); - + String args = parts.length > 1 ? parts[1] : ""; + logger.info("Processing command: {} from user: {} in room: {}", command, userId, roomId); - + return switch (command) { - case "/member", "/members" -> Optional.of(handleMemberCommand(roomId)); - case "/start" -> Optional.of(handleStartCommand(roomId, userId)); - case "/stop" -> Optional.of(handleStopCommand(roomId, userId)); - case "/score" -> Optional.of(handleScoreCommand(roomId)); - case "/skip" -> Optional.of(handleSkipCommand(roomId, userId)); - case "/hint" -> Optional.of(handleHintCommand(roomId, userId)); + // 기본 명령어 case "/help" -> Optional.of(handleHelpCommand()); + case "/member", "/members" -> Optional.of(handleMembersCommand(roomId)); + case "/leave" -> Optional.of(handleLeaveCommand(roomId, userId)); + case "/clear" -> Optional.of(handleClearCommand(roomId, userId)); + + // 재미 명령어 + case "/dice" -> Optional.of(handleDiceCommand(roomId, userId)); + case "/coin" -> Optional.of(handleCoinCommand(roomId, userId)); + case "/random" -> Optional.of(handleRandomCommand(roomId, userId, args)); + + // 투표 명령어 + case "/poll" -> Optional.of(handlePollCommand(roomId, userId, args)); + case "/vote" -> Optional.of(handleVoteCommand(roomId, userId, args)); + case "/endpoll" -> Optional.of(handleEndPollCommand(roomId, userId)); + default -> Optional.empty(); }; } - + + // ========== 기본 명령어 ========== + + /** + * /help - 도움말 + */ + private CommandResult handleHelpCommand() { + String helpMessage = """ + 📖 사용 가능한 명령어: + + [기본] + /members - 현재 접속자 목록 + /leave - 채팅방 나가기 + /clear - 내 채팅 내역 삭제 + + [재미] + /dice - 주사위 굴리기 (1-6) + /coin - 동전 던지기 + /random [옵션1] [옵션2] ... - 랜덤 선택 + + [투표] + /poll [질문] | [옵션1] | [옵션2] | ... - 투표 생성 + /vote [번호] - 투표하기 + /endpoll - 투표 종료 (생성자만) + """; + return CommandResult.success(MessageType.SYSTEM_COMMAND, helpMessage); + } + /** - * /member - 현재 접속자 수 조회 + * /members - 접속자 목록 */ - private CommandResult handleMemberCommand(String roomId) { + private CommandResult handleMembersCommand(String roomId) { List connections = connectionRepository.findByRoomId(roomId); - + if (connections.isEmpty()) { return CommandResult.success(MessageType.SYSTEM_COMMAND, "현재 접속자가 없습니다."); } - - String message = String.format("현재 접속자: %d명", connections.size()); - return CommandResult.success(MessageType.SYSTEM_COMMAND, message, connections.size()); + + // 닉네임 조회 + StringBuilder sb = new StringBuilder(); + sb.append(String.format("👥 현재 접속자: %d명\n", connections.size())); + + for (Connection conn : connections) { + String nickname = userRepository.findByCognitoSub(conn.getUserId()) + .map(User::getNickname) + .orElse(conn.getUserId()); + sb.append(String.format(" • %s\n", nickname)); + } + + Map data = new HashMap<>(); + data.put("count", connections.size()); + data.put("members", connections.stream() + .map(c -> { + Map member = new HashMap<>(); + member.put("userId", c.getUserId()); + member.put("nickname", userRepository.findByCognitoSub(c.getUserId()) + .map(User::getNickname).orElse(c.getUserId())); + return member; + }) + .collect(Collectors.toList())); + + return CommandResult.success(MessageType.SYSTEM_COMMAND, sb.toString(), data); } - + /** - * /start - 게임 시작 + * /leave - 채팅방 나가기 */ - 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); + private CommandResult handleLeaveCommand(String roomId, String userId) { + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + Map data = new HashMap<>(); + data.put("userId", userId); + data.put("nickname", nickname); + data.put("action", "leave"); + + return CommandResult.success(MessageType.LEAVE_ROOM, + String.format("👋 %s님이 퇴장합니다.", nickname), data); } - + /** - * /stop - 게임 중단 + * /clear - 내 채팅 내역 삭제 */ - private CommandResult handleStopCommand(String roomId, String userId) { - return gameService.stopGame(roomId, userId); + private CommandResult handleClearCommand(String roomId, String userId) { + Map data = new HashMap<>(); + data.put("userId", userId); + data.put("action", "clear"); + + return CommandResult.success(MessageType.CLEAR_CHAT, + "🗑️ 채팅 내역 삭제를 요청했습니다.", data); } - + + // ========== 재미 명령어 ========== + /** - * /score - 현재 점수 조회 + * /dice - 주사위 굴리기 */ - private CommandResult handleScoreCommand(String roomId) { - Optional optSession = gameSessionRepository.findActiveByRoomId(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()); + private CommandResult handleDiceCommand(String roomId, String userId) { + int result = random.nextInt(6) + 1; + + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + String emoji = switch (result) { + case 1 -> "⚀"; + case 2 -> "⚁"; + case 3 -> "⚂"; + case 4 -> "⚃"; + case 5 -> "⚄"; + case 6 -> "⚅"; + default -> "🎲"; + }; + + Map data = new HashMap<>(); + data.put("userId", userId); + data.put("nickname", nickname); + data.put("result", result); + data.put("type", "dice"); + + return CommandResult.success(MessageType.SYSTEM_COMMAND, + String.format("🎲 %s님이 주사위를 굴렸습니다: %s %d", nickname, emoji, result), data); } - + /** - * /skip - 라운드 스킵 (출제자만) + * /coin - 동전 던지기 */ - private CommandResult handleSkipCommand(String roomId, String userId) { - return gameService.skipRound(roomId, userId); + private CommandResult handleCoinCommand(String roomId, String userId) { + boolean isHeads = random.nextBoolean(); + String result = isHeads ? "앞면 (Heads)" : "뒷면 (Tails)"; + String emoji = isHeads ? "🪙" : "💿"; + + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + Map data = new HashMap<>(); + data.put("userId", userId); + data.put("nickname", nickname); + data.put("result", isHeads ? "heads" : "tails"); + data.put("type", "coin"); + + return CommandResult.success(MessageType.SYSTEM_COMMAND, + String.format("%s %s님이 동전을 던졌습니다: %s", emoji, nickname, result), data); } - + /** - * /hint - 힌트 제공 (출제자만) + * /random [옵션1] [옵션2] ... - 랜덤 선택 */ - private CommandResult handleHintCommand(String roomId, String userId) { - return gameService.provideHint(roomId, userId); + private CommandResult handleRandomCommand(String roomId, String userId, String args) { + if (args.isBlank()) { + return CommandResult.error("사용법: /random [옵션1] [옵션2] [옵션3] ..."); + } + + String[] options = args.split("\\s+"); + if (options.length < 2) { + return CommandResult.error("최소 2개 이상의 옵션이 필요합니다."); + } + + String selected = options[random.nextInt(options.length)]; + + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + Map data = new HashMap<>(); + data.put("userId", userId); + data.put("nickname", nickname); + data.put("options", Arrays.asList(options)); + data.put("selected", selected); + data.put("type", "random"); + + return CommandResult.success(MessageType.SYSTEM_COMMAND, + String.format("🎯 %s님의 랜덤 선택: %s\n(후보: %s)", + nickname, selected, String.join(", ", options)), data); } - + + // ========== 투표 명령어 ========== + /** - * /help - 도움말 + * /poll [질문] | [옵션1] | [옵션2] | ... - 투표 생성 */ - private CommandResult handleHelpCommand() { - String helpMessage = """ - 📖 사용 가능한 명령어: - /member - 현재 접속자 수 - /start - 게임 시작 (2명 이상) - /stop - 게임 중단 - /score - 현재 점수 보기 - /skip - 라운드 스킵 (출제자) - /hint - 힌트 보기 (출제자) - /help - 도움말 - """; - return CommandResult.success(MessageType.SYSTEM_COMMAND, helpMessage); + private CommandResult handlePollCommand(String roomId, String userId, String args) { + // 이미 진행 중인 투표가 있는지 확인 + Optional activePoll = pollRepository.findActiveByRoomId(roomId); + if (activePoll.isPresent()) { + return CommandResult.error("이미 진행 중인 투표가 있습니다. /endpoll로 종료 후 새 투표를 만드세요."); + } + + if (args.isBlank()) { + return CommandResult.error("사용법: /poll [질문] | [옵션1] | [옵션2] | ..."); + } + + String[] parts = args.split("\\|"); + if (parts.length < 3) { + return CommandResult.error("질문과 최소 2개의 옵션이 필요합니다. (구분자: |)"); + } + + String question = parts[0].trim(); + List options = new ArrayList<>(); + for (int i = 1; i < parts.length; i++) { + String option = parts[i].trim(); + if (!option.isEmpty()) { + options.add(option); + } + } + + if (options.size() < 2) { + return CommandResult.error("최소 2개의 옵션이 필요합니다."); + } + + if (options.size() > 10) { + return CommandResult.error("옵션은 최대 10개까지 가능합니다."); + } + + // 투표 생성 + String pollId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long ttl = Instant.now().plusSeconds(24 * 60 * 60).getEpochSecond(); // 24시간 + + Map votes = new HashMap<>(); + for (int i = 0; i < options.size(); i++) { + votes.put(String.valueOf(i), 0); + } + + Poll poll = Poll.builder() + .pk("ROOM#" + roomId) + .sk("POLL#" + pollId) + .pollId(pollId) + .roomId(roomId) + .question(question) + .options(options) + .votes(votes) + .userVotes(new HashMap<>()) + .createdBy(userId) + .createdAt(now) + .isActive(true) + .ttl(ttl) + .build(); + + pollRepository.save(poll); + + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + StringBuilder sb = new StringBuilder(); + sb.append(String.format("📊 %s님이 투표를 시작했습니다!\n\n", nickname)); + sb.append(String.format("❓ %s\n\n", question)); + for (int i = 0; i < options.size(); i++) { + sb.append(String.format(" %d. %s\n", i + 1, options.get(i))); + } + sb.append("\n💬 /vote [번호]로 투표하세요!"); + + Map data = new HashMap<>(); + data.put("pollId", pollId); + data.put("question", question); + data.put("options", options); + data.put("createdBy", userId); + data.put("creatorNickname", nickname); + + logger.info("Poll created: pollId={}, roomId={}, question={}", pollId, roomId, question); + + return CommandResult.success(MessageType.POLL_CREATE, sb.toString(), data); + } + + /** + * /vote [번호] - 투표하기 + */ + private CommandResult handleVoteCommand(String roomId, String userId, String args) { + Optional optPoll = pollRepository.findActiveByRoomId(roomId); + if (optPoll.isEmpty()) { + return CommandResult.error("진행 중인 투표가 없습니다."); + } + + Poll poll = optPoll.get(); + + if (poll.hasVoted(userId)) { + return CommandResult.error("이미 투표하셨습니다."); + } + + int optionIndex; + try { + optionIndex = Integer.parseInt(args.trim()) - 1; // 1-based to 0-based + } catch (NumberFormatException e) { + return CommandResult.error("사용법: /vote [번호] (예: /vote 1)"); + } + + if (optionIndex < 0 || optionIndex >= poll.getOptions().size()) { + return CommandResult.error(String.format("1~%d 사이의 번호를 입력하세요.", poll.getOptions().size())); + } + + // 투표 추가 + poll.addVote(userId, optionIndex); + pollRepository.save(poll); + + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + String selectedOption = poll.getOptions().get(optionIndex); + + // 현재 투표 현황 생성 + StringBuilder sb = new StringBuilder(); + sb.append(String.format("✅ %s님이 '%s'에 투표했습니다!\n\n", nickname, selectedOption)); + sb.append(String.format("📊 현재 현황 (총 %d표):\n", poll.getTotalVotes())); + for (int i = 0; i < poll.getOptions().size(); i++) { + int voteCount = poll.getVotes().getOrDefault(String.valueOf(i), 0); + String bar = "█".repeat(Math.min(voteCount, 10)); + sb.append(String.format(" %d. %s: %s %d표\n", + i + 1, poll.getOptions().get(i), bar, voteCount)); + } + + Map data = new HashMap<>(); + data.put("pollId", poll.getPollId()); + data.put("voterId", userId); + data.put("voterNickname", nickname); + data.put("selectedOption", optionIndex); + data.put("selectedOptionText", selectedOption); + data.put("votes", poll.getVotes()); + data.put("totalVotes", poll.getTotalVotes()); + + logger.info("Vote recorded: pollId={}, userId={}, option={}", poll.getPollId(), userId, optionIndex); + + return CommandResult.success(MessageType.POLL_VOTE, sb.toString(), data); + } + + /** + * /endpoll - 투표 종료 + */ + private CommandResult handleEndPollCommand(String roomId, String userId) { + Optional optPoll = pollRepository.findActiveByRoomId(roomId); + if (optPoll.isEmpty()) { + return CommandResult.error("진행 중인 투표가 없습니다."); + } + + Poll poll = optPoll.get(); + + if (!poll.getCreatedBy().equals(userId)) { + return CommandResult.error("투표 생성자만 종료할 수 있습니다."); + } + + poll.setIsActive(false); + pollRepository.save(poll); + + // 최종 결과 계산 + int maxVotes = 0; + List winners = new ArrayList<>(); + for (int i = 0; i < poll.getOptions().size(); i++) { + int voteCount = poll.getVotes().getOrDefault(String.valueOf(i), 0); + if (voteCount > maxVotes) { + maxVotes = voteCount; + winners.clear(); + winners.add(poll.getOptions().get(i)); + } else if (voteCount == maxVotes && voteCount > 0) { + winners.add(poll.getOptions().get(i)); + } + } + + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + StringBuilder sb = new StringBuilder(); + sb.append(String.format("🏁 %s님이 투표를 종료했습니다!\n\n", nickname)); + sb.append(String.format("❓ %s\n\n", poll.getQuestion())); + sb.append(String.format("📊 최종 결과 (총 %d표):\n", poll.getTotalVotes())); + + for (int i = 0; i < poll.getOptions().size(); i++) { + int voteCount = poll.getVotes().getOrDefault(String.valueOf(i), 0); + String bar = "█".repeat(Math.min(voteCount, 10)); + String medal = (voteCount == maxVotes && maxVotes > 0) ? "🏆 " : " "; + sb.append(String.format("%s%d. %s: %s %d표\n", + medal, i + 1, poll.getOptions().get(i), bar, voteCount)); + } + + if (!winners.isEmpty()) { + sb.append(String.format("\n🎉 우승: %s", String.join(", ", winners))); + } else { + sb.append("\n투표가 없습니다."); + } + + Map data = new HashMap<>(); + data.put("pollId", poll.getPollId()); + data.put("question", poll.getQuestion()); + data.put("options", poll.getOptions()); + data.put("votes", poll.getVotes()); + data.put("totalVotes", poll.getTotalVotes()); + data.put("winners", winners); + + logger.info("Poll ended: pollId={}, totalVotes={}", poll.getPollId(), poll.getTotalVotes()); + + return CommandResult.success(MessageType.POLL_END, sb.toString(), data); } } diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/PollSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/PollSpec.groovy new file mode 100644 index 00000000..cee47562 --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/PollSpec.groovy @@ -0,0 +1,148 @@ +package com.mzc.secondproject.serverless.domain.chatting.model + +import spock.lang.Specification + +class PollSpec extends Specification { + + def "addVote: 정상적인 투표 추가"() { + given: + def poll = Poll.builder() + .pollId("poll-123") + .options(["옵션1", "옵션2", "옵션3"]) + .votes(["0": 0, "1": 0, "2": 0]) + .userVotes([:]) + .build() + + when: + def result = poll.addVote("user1", 0) + + then: + result == true + poll.votes["0"] == 1 + poll.userVotes["user1"] == 0 + } + + def "addVote: 이미 투표한 사용자는 재투표 불가"() { + given: + def poll = Poll.builder() + .options(["옵션1", "옵션2"]) + .votes(["0": 1, "1": 0]) + .userVotes(["user1": 0]) + .build() + + when: + def result = poll.addVote("user1", 1) + + then: + result == false + poll.votes["0"] == 1 + poll.votes["1"] == 0 + } + + def "addVote: 유효하지 않은 옵션 인덱스"() { + given: + def poll = Poll.builder() + .options(["옵션1", "옵션2"]) + .votes(["0": 0, "1": 0]) + .userVotes([:]) + .build() + + when: + def result = poll.addVote("user1", 5) + + then: + result == false + } + + def "addVote: 음수 옵션 인덱스"() { + given: + def poll = Poll.builder() + .options(["옵션1", "옵션2"]) + .votes(["0": 0, "1": 0]) + .userVotes([:]) + .build() + + when: + def result = poll.addVote("user1", -1) + + then: + result == false + } + + def "hasVoted: 투표한 사용자 확인"() { + given: + def poll = Poll.builder() + .userVotes(["user1": 0]) + .build() + + expect: + poll.hasVoted("user1") == true + poll.hasVoted("user2") == false + } + + def "hasVoted: userVotes가 null인 경우"() { + given: + def poll = Poll.builder() + .userVotes(null) + .build() + + expect: + poll.hasVoted("user1") == false + } + + def "getTotalVotes: 총 투표 수 계산"() { + given: + def poll = Poll.builder() + .votes(["0": 3, "1": 2, "2": 5]) + .build() + + expect: + poll.getTotalVotes() == 10 + } + + def "getTotalVotes: 투표가 없는 경우"() { + given: + def poll = Poll.builder() + .votes(["0": 0, "1": 0]) + .build() + + expect: + poll.getTotalVotes() == 0 + } + + def "getTotalVotes: votes가 null인 경우"() { + given: + def poll = Poll.builder() + .votes(null) + .build() + + expect: + poll.getTotalVotes() == 0 + } + + def "여러 사용자 투표 시나리오"() { + given: + def poll = Poll.builder() + .options(["A", "B", "C"]) + .votes(["0": 0, "1": 0, "2": 0]) + .userVotes([:]) + .build() + + when: + poll.addVote("user1", 0) + poll.addVote("user2", 0) + poll.addVote("user3", 1) + poll.addVote("user4", 2) + + then: + poll.votes["0"] == 2 + poll.votes["1"] == 1 + poll.votes["2"] == 1 + poll.getTotalVotes() == 4 + poll.hasVoted("user1") + poll.hasVoted("user2") + poll.hasVoted("user3") + poll.hasVoted("user4") + !poll.hasVoted("user5") + } +}