From 0563433e482bf95a0e3610c3e9ccf8965ca90243 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 15:24:12 +0900 Subject: [PATCH 01/22] =?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/22] =?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/22] 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/22] 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/22] 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/22] 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/22] =?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/22] =?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/22] =?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/22] =?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") + } +} From aac551a55929fc45bea7ee0d8e51cb7abbdaf074 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sat, 24 Jan 2026 12:10:05 +0900 Subject: [PATCH 11/22] =?UTF-8?q?feat:=20implement=20word=20chain=20(?= =?UTF-8?q?=EB=81=9D=EB=A7=90=EC=9E=87=EA=B8=B0)=20game=20with=20dictionar?= =?UTF-8?q?y=20API=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add WordChainSession model with time limit, scoring, player management - Add WordChainService with game logic (start, submit, timeout, stop) - Add DictionaryService for word validation via Free Dictionary API - Add WordChainHandler REST API endpoints (/wordchain/start, submit, etc.) - Add WordChainFunction to SAM template - Add WORDCHAIN_* message types for WebSocket broadcasts - Fix command result domain (use chat domain for chat commands) - Add unit tests for WordChainSession and DictionaryService Closes #524, #525, #526, #527, #528 --- .../domain/chatting/enums/MessageType.java | 9 + .../chatting/exception/ChattingErrorCode.java | 4 + .../chatting/handler/WordChainHandler.java | 382 ++++++++++++++++ .../websocket/WebSocketMessageHandler.java | 41 +- .../chatting/model/WordChainSession.java | 206 +++++++++ .../WordChainSessionRepository.java | 93 ++++ .../chatting/service/DictionaryService.java | 220 +++++++++ .../chatting/service/WordChainService.java | 431 ++++++++++++++++++ .../model/WordChainSessionSpec.groovy | 271 +++++++++++ .../service/DictionaryServiceSpec.groovy | 54 +++ ServerlessFunction/template.yaml | 65 +++ 11 files changed, 1767 insertions(+), 9 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/WordChainHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSession.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/WordChainSessionRepository.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/DictionaryService.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/WordChainService.java create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSessionSpec.groovy create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/service/DictionaryServiceSpec.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 fddc60b7..c0fdc428 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 @@ -29,6 +29,15 @@ public enum MessageType { POLL_VOTE("poll_vote", "투표 참여"), POLL_END("poll_end", "투표 종료"), + // 끝말잇기(Word Chain) 게임 메시지 타입 + WORDCHAIN_START("wordchain_start", "끝말잇기 시작"), + WORDCHAIN_TURN("wordchain_turn", "턴 변경"), + WORDCHAIN_CORRECT("wordchain_correct", "정답"), + WORDCHAIN_WRONG("wordchain_wrong", "오답"), + WORDCHAIN_TIMEOUT("wordchain_timeout", "시간 초과"), + WORDCHAIN_ELIMINATED("wordchain_eliminated", "탈락"), + WORDCHAIN_END("wordchain_end", "끝말잇기 종료"), + // 유틸리티 메시지 타입 CLEAR_CHAT("clear_chat", "채팅 삭제"), LEAVE_ROOM("leave_room", "채팅방 나가기"); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java index ad599b53..9e6c8faf 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java @@ -44,6 +44,10 @@ public enum ChattingErrorCode implements DomainErrorCode { GAME_NOT_ALLOWED_IN_CHAT_ROOM("GAME_007", "게임은 게임 방에서만 시작할 수 있습니다", 400), GAME_RESTART_NOT_ALLOWED("GAME_008", "게임 진행 중에는 재시작할 수 없습니다", 400), GAME_START_NOT_HOST("GAME_009", "방장만 게임을 시작할 수 있습니다", 403), + GAME_ACTION_FAILED("GAME_010", "게임 액션 처리에 실패했습니다", 400), + + // 일반 입력 에러 + INVALID_INPUT("INPUT_001", "유효하지 않은 입력입니다", 400), ; private static final String DOMAIN = "CHATTING"; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/WordChainHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/WordChainHandler.java new file mode 100644 index 00000000..654a42eb --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/WordChainHandler.java @@ -0,0 +1,382 @@ +package com.mzc.secondproject.serverless.domain.chatting.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.mzc.secondproject.serverless.common.router.HandlerRouter; +import com.mzc.secondproject.serverless.common.router.Route; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.common.util.WebSocketBroadcaster; +import com.mzc.secondproject.serverless.common.util.WebSocketMessageHelper; +import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; +import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingErrorCode; +import com.mzc.secondproject.serverless.domain.chatting.model.Connection; +import com.mzc.secondproject.serverless.domain.chatting.model.WordChainSession; +import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.WordChainSessionRepository; +import com.mzc.secondproject.serverless.domain.chatting.service.WordChainService; +import com.mzc.secondproject.serverless.domain.chatting.service.WordChainService.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.*; + +/** + * 끝말잇기(Word Chain) 게임 REST API 핸들러 + */ +public class WordChainHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(WordChainHandler.class); + private static final String DOMAIN_WORDCHAIN = "wordchain"; + + private final WordChainService wordChainService; + private final WordChainSessionRepository sessionRepository; + private final ConnectionRepository connectionRepository; + private final WebSocketBroadcaster broadcaster; + private final HandlerRouter router; + + /** + * 기본 생성자 (Lambda에서 사용) + */ + public WordChainHandler() { + this(new WordChainService(), + new WordChainSessionRepository(), + new ConnectionRepository(), + new WebSocketBroadcaster()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public WordChainHandler(WordChainService wordChainService, + WordChainSessionRepository sessionRepository, + ConnectionRepository connectionRepository, + WebSocketBroadcaster broadcaster) { + this.wordChainService = wordChainService; + this.sessionRepository = sessionRepository; + this.connectionRepository = connectionRepository; + this.broadcaster = broadcaster; + this.router = initRouter(); + } + + private HandlerRouter initRouter() { + return new HandlerRouter().addRoutes( + Route.postAuth("/rooms/{roomId}/wordchain/start", this::startGame), + Route.postAuth("/rooms/{roomId}/wordchain/submit", this::submitWord), + Route.postAuth("/rooms/{roomId}/wordchain/timeout", this::handleTimeout), + Route.postAuth("/rooms/{roomId}/wordchain/stop", this::stopGame), + Route.getAuth("/rooms/{roomId}/wordchain/status", this::getGameStatus) + ); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); + return router.route(request); + } + + /** + * POST /rooms/{roomId}/wordchain/start - 게임 시작 + */ + private APIGatewayProxyResponseEvent startGame(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + GameStartResult result = wordChainService.startGame(roomId, userId); + + if (!result.success()) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, result.error()); + } + + // WebSocket으로 게임 시작 알림 브로드캐스트 + broadcastGameStart(roomId, result); + + Map response = buildGameStatusResponse(result.session()); + return ResponseGenerator.ok("Word Chain game started", response); + } + + /** + * POST /rooms/{roomId}/wordchain/submit - 단어 제출 + */ + private APIGatewayProxyResponseEvent submitWord(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + @SuppressWarnings("unchecked") + Map body = ResponseGenerator.gson().fromJson(request.getBody(), Map.class); + String word = body.get("word"); + + if (word == null || word.isBlank()) { + return ResponseGenerator.fail(ChattingErrorCode.INVALID_INPUT, "단어를 입력해주세요."); + } + + WordSubmitResult result = wordChainService.submitWord(roomId, userId, word); + + // 결과에 따라 브로드캐스트 + broadcastWordResult(roomId, result); + + return buildSubmitResponse(result); + } + + /** + * POST /rooms/{roomId}/wordchain/timeout - 타임아웃 처리 + */ + private APIGatewayProxyResponseEvent handleTimeout(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + WordSubmitResult result = wordChainService.handleTimeout(roomId, userId); + + // 타임아웃 결과 브로드캐스트 + broadcastWordResult(roomId, result); + + return buildSubmitResponse(result); + } + + /** + * POST /rooms/{roomId}/wordchain/stop - 게임 중단 + */ + private APIGatewayProxyResponseEvent stopGame(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + WordSubmitResult result = wordChainService.stopGame(roomId, userId); + + if (result.type() == WordSubmitResult.ResultType.ERROR) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_STOP_FAILED, result.error()); + } + + // 게임 종료 브로드캐스트 + broadcastWordResult(roomId, result); + + return ResponseGenerator.ok("Game stopped", Map.of("message", "게임이 종료되었습니다.")); + } + + /** + * GET /rooms/{roomId}/wordchain/status - 게임 상태 조회 + */ + private APIGatewayProxyResponseEvent getGameStatus(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + Optional optSession = sessionRepository.findActiveByRoomId(roomId); + if (optSession.isEmpty()) { + return ResponseGenerator.ok("No active game", Map.of("gameStatus", "NONE")); + } + + Map response = buildGameStatusResponse(optSession.get()); + return ResponseGenerator.ok("Game status retrieved", response); + } + + /** + * 게임 상태 응답 빌드 + */ + private Map buildGameStatusResponse(WordChainSession session) { + Map response = new LinkedHashMap<>(); + response.put("sessionId", session.getSessionId()); + response.put("gameStatus", session.getStatus()); + response.put("currentRound", session.getCurrentRound()); + response.put("currentPlayerId", session.getCurrentPlayerId()); + response.put("currentWord", session.getCurrentWord()); + response.put("nextLetter", session.getNextLetter()); + response.put("timeLimit", session.getTimeLimit()); + response.put("turnStartTime", session.getTurnStartTime()); + response.put("serverTime", System.currentTimeMillis()); + response.put("activePlayers", session.getActivePlayers()); + response.put("eliminatedPlayers", session.getEliminatedPlayers()); + response.put("scores", session.getScores() != null ? session.getScores() : Map.of()); + response.put("usedWords", session.getUsedWords()); + return response; + } + + /** + * 단어 제출 결과 응답 빌드 + */ + private APIGatewayProxyResponseEvent buildSubmitResponse(WordSubmitResult result) { + Map response = new LinkedHashMap<>(); + response.put("resultType", result.type().name()); + + switch (result.type()) { + case CORRECT -> { + response.put("word", result.word()); + response.put("definition", result.definition()); + response.put("phonetic", result.phonetic()); + response.put("score", result.score()); + response.put("nextLetter", result.nextLetter()); + response.put("nextPlayerId", result.nextPlayerId()); + response.put("nextTimeLimit", result.nextTimeLimit()); + return ResponseGenerator.ok("Correct!", response); + } + case WRONG_LETTER, INVALID_WORD -> { + response.put("error", result.error()); + return ResponseGenerator.ok("Wrong answer", response); + } + case TIMEOUT -> { + response.put("eliminatedPlayerId", result.eliminatedPlayerId()); + response.put("eliminatedNickname", result.eliminatedNickname()); + response.put("nextPlayerId", result.nextPlayerId()); + response.put("nextTimeLimit", result.nextTimeLimit()); + return ResponseGenerator.ok("Timeout", response); + } + case GAME_END -> { + response.put("winnerId", result.winnerId()); + response.put("winnerNickname", result.winnerNickname()); + response.put("ranking", result.ranking()); + if (result.session() != null) { + response.put("usedWords", result.session().getUsedWords()); + response.put("wordDefinitions", result.session().getWordDefinitions()); + } + return ResponseGenerator.ok("Game ended", response); + } + case ERROR -> { + return ResponseGenerator.fail(ChattingErrorCode.GAME_ACTION_FAILED, result.error()); + } + default -> { + return ResponseGenerator.fail(ChattingErrorCode.GAME_ACTION_FAILED, "Unknown result type"); + } + } + } + + // ========== WebSocket Broadcast Methods ========== + + /** + * 게임 시작 브로드캐스트 + */ + private void broadcastGameStart(String roomId, GameStartResult result) { + WordChainSession session = result.session(); + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long serverTime = System.currentTimeMillis(); + + String message = String.format(""" + 🎮 끝말잇기 시작! + 시작 단어: %s + 다음 글자: '%c' + + 첫 번째 차례: %s + 제한 시간: %d초 + """, + result.starterWord(), + result.nextLetter(), + result.firstPlayerId(), + session.getTimeLimit()); + + Map payload = new LinkedHashMap<>(); + payload.put("domain", DOMAIN_WORDCHAIN); + payload.put("messageId", messageId); + payload.put("roomId", roomId); + payload.put("userId", "SYSTEM"); + payload.put("content", message); + payload.put("messageType", MessageType.WORDCHAIN_START.getCode()); + payload.put("createdAt", now); + payload.put("timestamp", serverTime); + payload.put("sessionId", session.getSessionId()); + payload.put("starterWord", result.starterWord()); + payload.put("nextLetter", result.nextLetter()); + payload.put("currentPlayerId", result.firstPlayerId()); + payload.put("timeLimit", session.getTimeLimit()); + payload.put("turnStartTime", session.getTurnStartTime()); + payload.put("serverTime", serverTime); + payload.put("players", session.getPlayers()); + payload.put("activePlayers", session.getActivePlayers()); + + broadcastToRoom(roomId, payload); + logger.info("WordChain game start broadcasted: roomId={}, starterWord={}", + roomId, result.starterWord()); + } + + /** + * 단어 제출 결과 브로드캐스트 + */ + private void broadcastWordResult(String roomId, WordSubmitResult result) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long serverTime = System.currentTimeMillis(); + + Map payload = new LinkedHashMap<>(); + payload.put("domain", DOMAIN_WORDCHAIN); + payload.put("messageId", messageId); + payload.put("roomId", roomId); + payload.put("userId", "SYSTEM"); + payload.put("createdAt", now); + payload.put("timestamp", serverTime); + payload.put("serverTime", serverTime); + payload.put("resultType", result.type().name()); + + switch (result.type()) { + case CORRECT -> { + payload.put("messageType", MessageType.WORDCHAIN_CORRECT.getCode()); + payload.put("content", String.format("✅ %s: \"%s\" (+%d점)\n뜻: %s\n다음 글자: '%c'", + result.playerNickname(), + result.word(), + result.score(), + result.definition() != null ? result.definition() : "(정의 없음)", + result.nextLetter())); + payload.put("word", result.word()); + payload.put("definition", result.definition()); + payload.put("phonetic", result.phonetic()); + payload.put("score", result.score()); + payload.put("nextLetter", result.nextLetter()); + payload.put("nextPlayerId", result.nextPlayerId()); + payload.put("nextTimeLimit", result.nextTimeLimit()); + payload.put("playerNickname", result.playerNickname()); + if (result.session() != null) { + payload.put("turnStartTime", result.session().getTurnStartTime()); + payload.put("scores", result.session().getScores()); + } + } + case WRONG_LETTER -> { + payload.put("messageType", MessageType.WORDCHAIN_WRONG.getCode()); + payload.put("content", result.error()); + payload.put("error", result.error()); + } + case INVALID_WORD -> { + payload.put("messageType", MessageType.WORDCHAIN_WRONG.getCode()); + payload.put("content", "❌ " + result.error()); + payload.put("error", result.error()); + } + case TIMEOUT -> { + payload.put("messageType", MessageType.WORDCHAIN_TIMEOUT.getCode()); + payload.put("content", String.format("⏰ %s 시간 초과! 탈락!", + result.eliminatedNickname())); + payload.put("eliminatedPlayerId", result.eliminatedPlayerId()); + payload.put("eliminatedNickname", result.eliminatedNickname()); + payload.put("nextPlayerId", result.nextPlayerId()); + payload.put("nextTimeLimit", result.nextTimeLimit()); + if (result.session() != null) { + payload.put("nextLetter", result.session().getNextLetter()); + payload.put("turnStartTime", result.session().getTurnStartTime()); + payload.put("activePlayers", result.session().getActivePlayers()); + } + } + case GAME_END -> { + payload.put("messageType", MessageType.WORDCHAIN_END.getCode()); + String winnerMsg = result.winnerId() != null + ? String.format("🏆 승자: %s!", result.winnerNickname()) + : "게임 종료!"; + payload.put("content", winnerMsg); + payload.put("winnerId", result.winnerId()); + payload.put("winnerNickname", result.winnerNickname()); + payload.put("ranking", result.ranking()); + if (result.session() != null) { + payload.put("usedWords", result.session().getUsedWords()); + payload.put("wordDefinitions", result.session().getWordDefinitions()); + payload.put("scores", result.session().getScores()); + } + } + case ERROR -> { + // 에러는 브로드캐스트하지 않음 (요청자에게만 응답) + return; + } + } + + broadcastToRoom(roomId, payload); + logger.info("WordChain result broadcasted: roomId={}, type={}", roomId, result.type()); + } + + /** + * 방에 메시지 브로드캐스트 + */ + private void broadcastToRoom(String roomId, Map payload) { + List connections = connectionRepository.findByRoomId(roomId); + String jsonPayload = ResponseGenerator.gson().toJson(payload); + broadcaster.broadcast(connections, jsonPayload); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java index f8da9d75..5515cc1b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java @@ -362,13 +362,13 @@ private Map handleRoundTimeout(MessagePayload payload) { */ private Map handleCommandResult(CommandResult result, String roomId, String userId) { List connections = connectionRepository.findByRoomId(roomId); - + // GAME_START는 특별 처리 (출제자에게만 제시어 전송 + serverTime 포함) if (result.messageType() == MessageType.GAME_START && result.data() instanceof GameService.GameStartResult gameResult) { broadcastGameStart(connections, result, gameResult, roomId); return WebSocketEventUtil.ok("Command executed"); } - + // ROUND_END는 특별 처리 (다음 출제자에게만 제시어 전송 + serverTime 포함) if (result.messageType() == MessageType.ROUND_END && result.data() instanceof Map) { @SuppressWarnings("unchecked") @@ -376,14 +376,17 @@ private Map handleCommandResult(CommandResult result, String roo broadcastRoundEnd(connections, result, data, roomId); return WebSocketEventUtil.ok("Command executed"); } - - // 일반 시스템 메시지 (게임 관련 명령어 결과) + + // 일반 시스템 메시지 String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + + // 메시지 타입에 따라 domain 결정 + String domain = determineDomain(result.messageType()); + // domain 필드 포함을 위해 Map으로 생성 Map systemMessage = new HashMap<>(); - systemMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); + systemMessage.put("domain", domain); systemMessage.put("messageId", messageId); systemMessage.put("roomId", roomId); systemMessage.put("userId", "SYSTEM"); @@ -391,14 +394,34 @@ private Map handleCommandResult(CommandResult result, String roo systemMessage.put("messageType", result.messageType().getCode()); systemMessage.put("createdAt", now); systemMessage.put("timestamp", System.currentTimeMillis()); - + + // 추가 데이터가 있으면 포함 + if (result.data() != null) { + systemMessage.put("data", result.data()); + } + String broadcastPayload = gson.toJson(systemMessage); List failedConnections = broadcaster.broadcast(connections, broadcastPayload); cleanupFailedConnections(failedConnections); - - logger.info("Command result broadcasted: type={}, roomId={}", result.messageType(), roomId); + + logger.info("Command result broadcasted: type={}, domain={}, roomId={}", result.messageType(), domain, roomId); return WebSocketEventUtil.ok("Command executed"); } + + /** + * 메시지 타입에 따라 domain 결정 + */ + private String determineDomain(MessageType messageType) { + return switch (messageType) { + // 게임 관련 메시지 + case GAME_START, GAME_END, ROUND_START, ROUND_END, DRAWING, DRAWING_CLEAR, + CORRECT_ANSWER, SCORE_UPDATE, HINT -> WebSocketMessageHelper.DOMAIN_GAME; + // 방 상태 관련 메시지 + case ROOM_STATUS_CHANGE, HOST_CHANGE -> WebSocketMessageHelper.DOMAIN_ROOM; + // 채팅 관련 메시지 (기본값) + default -> WebSocketMessageHelper.DOMAIN_CHAT; + }; + } /** * GAME_START 메시지 브로드캐스트 - 출제자에게만 제시어 포함, serverTime 추가 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSession.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSession.java new file mode 100644 index 00000000..aa4e2c8d --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSession.java @@ -0,0 +1,206 @@ +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.*; + +/** + * 끝말잇기 게임 세션 모델 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class WordChainSession { + + private String pk; // WORDCHAIN#{sessionId} + private String sk; // METADATA + private String gsi1pk; // ROOM#{roomId} + private String gsi1sk; // WORDCHAIN#{createdAt} + + private String sessionId; + private String roomId; + private String gameType; // "wordchain" + + // 게임 상태 + private String status; // WAITING, PLAYING, FINISHED + private String startedBy; + private Long startedAt; + private Long endedAt; + + // 턴 정보 + private Integer currentRound; + private String currentPlayerId; + private String currentWord; + private Character nextLetter; // 다음 사람이 시작해야 할 글자 + private Long turnStartTime; + private Integer timeLimit; // 현재 라운드 시간 제한 (초) + + // 플레이어 관리 + private List players; // 전체 플레이어 (순서대로) + private List activePlayers; // 탈락하지 않은 플레이어 + private List eliminatedPlayers; // 탈락한 플레이어 + private Map scores; + + // 게임 기록 + private List usedWords; // 사용된 단어 목록 + private Map wordDefinitions; // 단어 -> 뜻 (게임 종료 후 학습용) + + // TTL + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } + + // ========== 비즈니스 메서드 ========== + + /** + * 게임이 활성 상태인지 확인 + */ + public boolean isActive() { + return "PLAYING".equals(status); + } + + /** + * 현재 턴인지 확인 + */ + public boolean isCurrentTurn(String userId) { + return userId != null && userId.equals(currentPlayerId); + } + + /** + * 단어가 이미 사용되었는지 확인 + */ + public boolean isWordUsed(String word) { + return usedWords != null && usedWords.contains(word.toLowerCase()); + } + + /** + * 단어 추가 + */ + public void addUsedWord(String word, String definition) { + if (usedWords == null) { + usedWords = new ArrayList<>(); + } + usedWords.add(word.toLowerCase()); + + if (definition != null) { + if (wordDefinitions == null) { + wordDefinitions = new HashMap<>(); + } + wordDefinitions.put(word.toLowerCase(), definition); + } + } + + /** + * 플레이어 탈락 처리 + */ + public void eliminatePlayer(String userId) { + if (activePlayers != null) { + activePlayers.remove(userId); + } + if (eliminatedPlayers == null) { + eliminatedPlayers = new ArrayList<>(); + } + if (!eliminatedPlayers.contains(userId)) { + eliminatedPlayers.add(userId); + } + } + + /** + * 다음 플레이어 ID 반환 + */ + public String getNextPlayerId() { + if (activePlayers == null || activePlayers.isEmpty()) { + return null; + } + if (activePlayers.size() == 1) { + return activePlayers.get(0); // 마지막 1명 = 승자 + } + if (currentPlayerId == null) { + return activePlayers.get(0); + } + int currentIndex = activePlayers.indexOf(currentPlayerId); + if (currentIndex == -1) { + return activePlayers.get(0); + } + return activePlayers.get((currentIndex + 1) % activePlayers.size()); + } + + /** + * 점수 추가 + */ + public void addScore(String userId, int points) { + if (scores == null) { + scores = new HashMap<>(); + } + scores.merge(userId, points, Integer::sum); + } + + /** + * 게임 종료 조건 확인 (1명만 남음) + */ + public boolean isGameOver() { + return activePlayers == null || activePlayers.size() <= 1; + } + + /** + * 승자 반환 + */ + public String getWinner() { + if (activePlayers != null && activePlayers.size() == 1) { + return activePlayers.get(0); + } + return null; + } + + /** + * 시간 제한 계산 (라운드에 따라 점점 빨라짐) + * Round 1-2: 15초, Round 3-4: 13초, Round 5-6: 11초, Round 7-8: 9초, Round 9+: 8초 + */ + public static int calculateTimeLimit(int round) { + return Math.max(8, 15 - ((round - 1) / 2) * 2); + } + + /** + * 점수 계산 (빠른 응답 + 긴 단어 보너스) + */ + public static int calculateScore(long responseTimeMs, int wordLength, int timeLimit) { + int baseScore = 10; + + // 시간 보너스 (빠를수록 높음) + int remainingSeconds = timeLimit - (int)(responseTimeMs / 1000); + int timeBonus = Math.max(0, remainingSeconds); + + // 단어 길이 보너스 (5글자 이상부터) + int lengthBonus = Math.max(0, (wordLength - 4) * 2); + + return baseScore + timeBonus + lengthBonus; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/WordChainSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/WordChainSessionRepository.java new file mode 100644 index 00000000..ca39ddf7 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/WordChainSessionRepository.java @@ -0,0 +1,93 @@ +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.WordChainSession; +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; + +/** + * 끝말잇기 게임 세션 Repository + */ +public class WordChainSessionRepository { + + private static final Logger logger = LoggerFactory.getLogger(WordChainSessionRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); + + private final DynamoDbTable table; + + public WordChainSessionRepository() { + this.table = AwsClients.dynamoDbEnhanced() + .table(TABLE_NAME, TableSchema.fromBean(WordChainSession.class)); + } + + public WordChainSessionRepository(DynamoDbTable table) { + this.table = table; + } + + /** + * 세션 저장 + */ + public void save(WordChainSession session) { + table.putItem(session); + logger.debug("Saved WordChainSession: {}", session.getSessionId()); + } + + /** + * 세션 ID로 조회 + */ + public Optional findById(String sessionId) { + Key key = Key.builder() + .partitionValue("WORDCHAIN#" + sessionId) + .sortValue("METADATA") + .build(); + WordChainSession session = table.getItem(key); + return Optional.ofNullable(session); + } + + /** + * 방의 활성 세션 조회 + */ + public Optional findActiveByRoomId(String roomId) { + return table.query(QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue("ROOM#" + roomId) + .sortValue("WORDCHAIN#") + .build())) + .items() + .stream() + .filter(WordChainSession::isActive) + .findFirst(); + } + + /** + * 세션 삭제 + */ + public void delete(String sessionId) { + Key key = Key.builder() + .partitionValue("WORDCHAIN#" + sessionId) + .sortValue("METADATA") + .build(); + table.deleteItem(key); + logger.debug("Deleted WordChainSession: {}", sessionId); + } + + /** + * 게임 종료 처리 + */ + public void finishGame(String sessionId, long endedAt, long ttl) { + findById(sessionId).ifPresent(session -> { + session.setStatus("FINISHED"); + session.setEndedAt(endedAt); + session.setTtl(ttl); + save(session); + logger.info("Finished WordChainSession: {}", sessionId); + }); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/DictionaryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/DictionaryService.java new file mode 100644 index 00000000..84191f08 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/DictionaryService.java @@ -0,0 +1,220 @@ +package com.mzc.secondproject.serverless.domain.chatting.service; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 외부 사전 API 연동 서비스 + * Free Dictionary API (https://dictionaryapi.dev/) 사용 + */ +public class DictionaryService { + + private static final Logger logger = LoggerFactory.getLogger(DictionaryService.class); + private static final String API_BASE_URL = "https://api.dictionaryapi.dev/api/v2/entries/en/"; + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private final HttpClient httpClient; + private final Gson gson; + + // 간단한 인메모리 캐시 (Lambda 인스턴스 내에서만 유효) + private final ConcurrentHashMap cache; + + public DictionaryService() { + this.httpClient = HttpClient.newBuilder() + .connectTimeout(TIMEOUT) + .build(); + this.gson = new Gson(); + this.cache = new ConcurrentHashMap<>(); + } + + /** + * 단어 검증 및 정의 조회 + * + * @param word 검증할 단어 + * @return 검증 결과 (유효 여부 + 정의) + */ + public DictionaryResult lookupWord(String word) { + if (word == null || word.isBlank()) { + return DictionaryResult.invalid("단어가 비어있습니다."); + } + + String normalizedWord = word.trim().toLowerCase(); + + // 캐시 확인 + if (cache.containsKey(normalizedWord)) { + logger.debug("Cache hit for word: {}", normalizedWord); + return cache.get(normalizedWord); + } + + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(API_BASE_URL + normalizedWord)) + .timeout(TIMEOUT) + .GET() + .build(); + + HttpResponse response = httpClient.send(request, + HttpResponse.BodyHandlers.ofString()); + + DictionaryResult result = parseResponse(normalizedWord, response); + + // 캐시 저장 + cache.put(normalizedWord, result); + + return result; + + } catch (Exception e) { + logger.error("Dictionary API error for word '{}': {}", normalizedWord, e.getMessage()); + // API 실패 시 일단 유효한 것으로 처리 (fallback) + return DictionaryResult.validWithoutDefinition(normalizedWord); + } + } + + /** + * API 응답 파싱 + */ + private DictionaryResult parseResponse(String word, HttpResponse response) { + if (response.statusCode() == 404) { + return DictionaryResult.invalid("사전에 없는 단어입니다: " + word); + } + + if (response.statusCode() != 200) { + logger.warn("Unexpected API response: {} for word '{}'", response.statusCode(), word); + return DictionaryResult.validWithoutDefinition(word); + } + + try { + JsonArray jsonArray = gson.fromJson(response.body(), JsonArray.class); + if (jsonArray == null || jsonArray.isEmpty()) { + return DictionaryResult.invalid("사전에 없는 단어입니다: " + word); + } + + JsonObject firstEntry = jsonArray.get(0).getAsJsonObject(); + + // 발음 추출 (있으면) + String phonetic = extractPhonetic(firstEntry); + + // 첫 번째 정의 추출 + String definition = extractFirstDefinition(firstEntry); + + return DictionaryResult.valid(word, definition, phonetic); + + } catch (Exception e) { + logger.error("Failed to parse dictionary response for '{}': {}", word, e.getMessage()); + return DictionaryResult.validWithoutDefinition(word); + } + } + + /** + * 발음 기호 추출 + */ + private String extractPhonetic(JsonObject entry) { + try { + if (entry.has("phonetic")) { + return entry.get("phonetic").getAsString(); + } + if (entry.has("phonetics")) { + JsonArray phonetics = entry.getAsJsonArray("phonetics"); + for (JsonElement p : phonetics) { + JsonObject phoneticObj = p.getAsJsonObject(); + if (phoneticObj.has("text") && !phoneticObj.get("text").isJsonNull()) { + String text = phoneticObj.get("text").getAsString(); + if (!text.isBlank()) { + return text; + } + } + } + } + } catch (Exception e) { + logger.debug("Failed to extract phonetic: {}", e.getMessage()); + } + return null; + } + + /** + * 첫 번째 정의 추출 + */ + private String extractFirstDefinition(JsonObject entry) { + try { + if (!entry.has("meanings")) { + return null; + } + JsonArray meanings = entry.getAsJsonArray("meanings"); + if (meanings.isEmpty()) { + return null; + } + + JsonObject firstMeaning = meanings.get(0).getAsJsonObject(); + String partOfSpeech = firstMeaning.has("partOfSpeech") + ? firstMeaning.get("partOfSpeech").getAsString() + : ""; + + JsonArray definitions = firstMeaning.getAsJsonArray("definitions"); + if (definitions == null || definitions.isEmpty()) { + return null; + } + + String definition = definitions.get(0).getAsJsonObject() + .get("definition").getAsString(); + + return String.format("(%s) %s", partOfSpeech, definition); + + } catch (Exception e) { + logger.debug("Failed to extract definition: {}", e.getMessage()); + return null; + } + } + + /** + * 단어가 유효한지만 빠르게 확인 (정의 필요 없을 때) + */ + public boolean isValidWord(String word) { + return lookupWord(word).isValid(); + } + + // ========== Result DTO ========== + + public record DictionaryResult( + boolean valid, + String word, + String definition, + String phonetic, + String errorMessage + ) { + public static DictionaryResult valid(String word, String definition, String phonetic) { + return new DictionaryResult(true, word, definition, phonetic, null); + } + + public static DictionaryResult validWithoutDefinition(String word) { + return new DictionaryResult(true, word, null, null, null); + } + + public static DictionaryResult invalid(String errorMessage) { + return new DictionaryResult(false, null, null, null, errorMessage); + } + + public boolean isValid() { + return valid; + } + + public Optional getDefinition() { + return Optional.ofNullable(definition); + } + + public Optional getPhonetic() { + return Optional.ofNullable(phonetic); + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/WordChainService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/WordChainService.java new file mode 100644 index 00000000..c8298468 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/WordChainService.java @@ -0,0 +1,431 @@ +package com.mzc.secondproject.serverless.domain.chatting.service; + +import com.mzc.secondproject.serverless.domain.chatting.model.Connection; +import com.mzc.secondproject.serverless.domain.chatting.model.WordChainSession; +import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.WordChainSessionRepository; +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.time.Instant; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 끝말잇기 게임 서비스 + */ +public class WordChainService { + + private static final Logger logger = LoggerFactory.getLogger(WordChainService.class); + + // 게임 시작 단어 후보 (쉬운 3-5글자 단어) + private static final List STARTER_WORDS = List.of( + "apple", "house", "water", "happy", "green", "music", "paper", + "table", "chair", "phone", "smile", "dream", "light", "earth", + "ocean", "river", "cloud", "sugar", "lemon", "tiger", "eagle" + ); + + private final WordChainSessionRepository sessionRepository; + private final ConnectionRepository connectionRepository; + private final UserRepository userRepository; + private final DictionaryService dictionaryService; + private final Random random; + + public WordChainService() { + this(new WordChainSessionRepository(), + new ConnectionRepository(), + new UserRepository(), + new DictionaryService()); + } + + public WordChainService(WordChainSessionRepository sessionRepository, + ConnectionRepository connectionRepository, + UserRepository userRepository, + DictionaryService dictionaryService) { + this.sessionRepository = sessionRepository; + this.connectionRepository = connectionRepository; + this.userRepository = userRepository; + this.dictionaryService = dictionaryService; + this.random = new Random(); + } + + /** + * 게임 시작 + */ + public GameStartResult startGame(String roomId, String userId) { + // 이미 진행 중인 게임 확인 + Optional existingSession = sessionRepository.findActiveByRoomId(roomId); + if (existingSession.isPresent()) { + return GameStartResult.error("이미 진행 중인 게임이 있습니다."); + } + + // 접속자 확인 + List connections = connectionRepository.findByRoomId(roomId); + if (connections.size() < 2) { + return GameStartResult.error("최소 2명 이상 필요합니다."); + } + + // 플레이어 순서 랜덤 셔플 + List players = connections.stream() + .map(Connection::getUserId) + .collect(Collectors.toList()); + Collections.shuffle(players); + + // 시작 단어 선택 + String starterWord = STARTER_WORDS.get(random.nextInt(STARTER_WORDS.size())); + char nextLetter = starterWord.charAt(starterWord.length() - 1); + + // 세션 생성 + String sessionId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long currentTime = System.currentTimeMillis(); + int timeLimit = WordChainSession.calculateTimeLimit(1); + + WordChainSession session = WordChainSession.builder() + .pk("WORDCHAIN#" + sessionId) + .sk("METADATA") + .gsi1pk("ROOM#" + roomId) + .gsi1sk("WORDCHAIN#" + now) + .sessionId(sessionId) + .roomId(roomId) + .gameType("wordchain") + .status("PLAYING") + .startedBy(userId) + .startedAt(currentTime) + .currentRound(1) + .currentPlayerId(players.get(0)) + .currentWord(starterWord) + .nextLetter(nextLetter) + .turnStartTime(currentTime) + .timeLimit(timeLimit) + .players(players) + .activePlayers(new ArrayList<>(players)) + .eliminatedPlayers(new ArrayList<>()) + .scores(new HashMap<>()) + .usedWords(new ArrayList<>(List.of(starterWord.toLowerCase()))) + .wordDefinitions(new HashMap<>()) + .build(); + + // 시작 단어 정의 조회 + DictionaryService.DictionaryResult starterResult = dictionaryService.lookupWord(starterWord); + if (starterResult.getDefinition().isPresent()) { + session.getWordDefinitions().put(starterWord.toLowerCase(), starterResult.getDefinition().get()); + } + + sessionRepository.save(session); + + logger.info("WordChain game started: sessionId={}, roomId={}, players={}", + sessionId, roomId, players.size()); + + return GameStartResult.success(session, starterWord, nextLetter, players.get(0)); + } + + /** + * 단어 제출 + */ + public WordSubmitResult submitWord(String roomId, String userId, String word) { + Optional optSession = sessionRepository.findActiveByRoomId(roomId); + if (optSession.isEmpty()) { + return WordSubmitResult.error("진행 중인 게임이 없습니다."); + } + + WordChainSession session = optSession.get(); + + // 본인 턴인지 확인 + if (!session.isCurrentTurn(userId)) { + return WordSubmitResult.error("당신의 차례가 아닙니다."); + } + + // 시간 초과 확인 + long elapsed = System.currentTimeMillis() - session.getTurnStartTime(); + if (elapsed > session.getTimeLimit() * 1000L) { + return handleTimeout(session, userId); + } + + String normalizedWord = word.trim().toLowerCase(); + + // 첫 글자 확인 + if (normalizedWord.charAt(0) != session.getNextLetter()) { + return WordSubmitResult.wrongLetter(session.getNextLetter()); + } + + // 중복 단어 확인 + if (session.isWordUsed(normalizedWord)) { + return WordSubmitResult.error("이미 사용된 단어입니다: " + normalizedWord); + } + + // 사전 API로 유효성 검증 + DictionaryService.DictionaryResult dictResult = dictionaryService.lookupWord(normalizedWord); + if (!dictResult.isValid()) { + return WordSubmitResult.invalidWord(dictResult.errorMessage()); + } + + // 정답 처리 + int score = WordChainSession.calculateScore(elapsed, normalizedWord.length(), session.getTimeLimit()); + session.addScore(userId, score); + session.addUsedWord(normalizedWord, dictResult.getDefinition().orElse(null)); + + // 다음 턴 준비 + char nextLetter = normalizedWord.charAt(normalizedWord.length() - 1); + String nextPlayerId = session.getNextPlayerId(); + int nextRound = session.getCurrentRound() + 1; + int nextTimeLimit = WordChainSession.calculateTimeLimit(nextRound); + + session.setCurrentRound(nextRound); + session.setCurrentWord(normalizedWord); + session.setNextLetter(nextLetter); + session.setCurrentPlayerId(nextPlayerId); + session.setTurnStartTime(System.currentTimeMillis()); + session.setTimeLimit(nextTimeLimit); + + sessionRepository.save(session); + + String nickname = getNickname(userId); + + logger.info("Word accepted: sessionId={}, word={}, player={}, score={}", + session.getSessionId(), normalizedWord, userId, score); + + return WordSubmitResult.correct( + session, + normalizedWord, + dictResult.getDefinition().orElse(null), + dictResult.getPhonetic().orElse(null), + score, + nextLetter, + nextPlayerId, + nextTimeLimit, + nickname + ); + } + + /** + * 타임아웃 처리 + */ + public WordSubmitResult handleTimeout(String roomId, String userId) { + Optional optSession = sessionRepository.findActiveByRoomId(roomId); + if (optSession.isEmpty()) { + return WordSubmitResult.error("진행 중인 게임이 없습니다."); + } + return handleTimeout(optSession.get(), userId); + } + + private WordSubmitResult handleTimeout(WordChainSession session, String userId) { + // 플레이어 탈락 + session.eliminatePlayer(userId); + String nickname = getNickname(userId); + + logger.info("Player eliminated (timeout): sessionId={}, player={}", + session.getSessionId(), userId); + + // 게임 종료 확인 + if (session.isGameOver()) { + return finishGame(session, "TIMEOUT"); + } + + // 다음 턴 준비 + String nextPlayerId = session.getNextPlayerId(); + int nextRound = session.getCurrentRound() + 1; + int nextTimeLimit = WordChainSession.calculateTimeLimit(nextRound); + + session.setCurrentRound(nextRound); + session.setCurrentPlayerId(nextPlayerId); + session.setTurnStartTime(System.currentTimeMillis()); + session.setTimeLimit(nextTimeLimit); + + sessionRepository.save(session); + + return WordSubmitResult.timeout( + session, + userId, + nickname, + nextPlayerId, + nextTimeLimit + ); + } + + /** + * 게임 종료 + */ + public WordSubmitResult finishGame(WordChainSession session, String reason) { + long endTime = System.currentTimeMillis(); + long ttl = Instant.now().plusSeconds(7 * 24 * 60 * 60).getEpochSecond(); // 7일 보관 + + session.setStatus("FINISHED"); + session.setEndedAt(endTime); + session.setTtl(ttl); + sessionRepository.save(session); + + String winnerId = session.getWinner(); + String winnerNickname = winnerId != null ? getNickname(winnerId) : null; + + // 최종 순위 계산 + List ranking = buildRanking(session); + + logger.info("WordChain game finished: sessionId={}, winner={}, reason={}", + session.getSessionId(), winnerId, reason); + + return WordSubmitResult.gameEnd(session, winnerId, winnerNickname, ranking); + } + + /** + * 게임 강제 종료 + */ + public WordSubmitResult stopGame(String roomId, String userId) { + Optional optSession = sessionRepository.findActiveByRoomId(roomId); + if (optSession.isEmpty()) { + return WordSubmitResult.error("진행 중인 게임이 없습니다."); + } + + WordChainSession session = optSession.get(); + + // 게임 시작자만 종료 가능 + if (!userId.equals(session.getStartedBy())) { + return WordSubmitResult.error("게임 시작자만 종료할 수 있습니다."); + } + + return finishGame(session, "STOPPED"); + } + + /** + * 순위 계산 + */ + private List buildRanking(WordChainSession session) { + List ranking = new ArrayList<>(); + + // 점수 기준 정렬 + Map scores = session.getScores() != null + ? session.getScores() + : new HashMap<>(); + + // 활성 플레이어 (생존자) 먼저 + if (session.getActivePlayers() != null) { + for (String playerId : session.getActivePlayers()) { + ranking.add(new RankEntry( + playerId, + getNickname(playerId), + scores.getOrDefault(playerId, 0), + false + )); + } + } + + // 탈락 플레이어 (역순으로 - 나중에 탈락한 사람이 순위 높음) + if (session.getEliminatedPlayers() != null) { + List eliminated = new ArrayList<>(session.getEliminatedPlayers()); + Collections.reverse(eliminated); + for (String playerId : eliminated) { + ranking.add(new RankEntry( + playerId, + getNickname(playerId), + scores.getOrDefault(playerId, 0), + true + )); + } + } + + return ranking; + } + + /** + * 닉네임 조회 + */ + private String getNickname(String userId) { + return userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + } + + // ========== Result DTOs ========== + + public record GameStartResult( + boolean success, + String error, + WordChainSession session, + String starterWord, + Character nextLetter, + String firstPlayerId + ) { + public static GameStartResult success(WordChainSession session, String word, char letter, String playerId) { + return new GameStartResult(true, null, session, word, letter, playerId); + } + + public static GameStartResult error(String message) { + return new GameStartResult(false, message, null, null, null, null); + } + } + + public record WordSubmitResult( + ResultType type, + String error, + WordChainSession session, + // 정답 시 + String word, + String definition, + String phonetic, + int score, + Character nextLetter, + String nextPlayerId, + int nextTimeLimit, + String playerNickname, + // 타임아웃 시 + String eliminatedPlayerId, + String eliminatedNickname, + // 게임 종료 시 + String winnerId, + String winnerNickname, + List ranking + ) { + public enum ResultType { + CORRECT, WRONG_LETTER, INVALID_WORD, TIMEOUT, GAME_END, ERROR + } + + public static WordSubmitResult correct(WordChainSession session, String word, String definition, + String phonetic, int score, char nextLetter, + String nextPlayerId, int nextTimeLimit, String nickname) { + return new WordSubmitResult(ResultType.CORRECT, null, session, word, definition, phonetic, + score, nextLetter, nextPlayerId, nextTimeLimit, nickname, + null, null, null, null, null); + } + + public static WordSubmitResult wrongLetter(char expected) { + return new WordSubmitResult(ResultType.WRONG_LETTER, + String.format("'%c'로 시작하는 단어를 입력하세요.", expected), + null, null, null, null, 0, null, null, 0, null, + null, null, null, null, null); + } + + public static WordSubmitResult invalidWord(String reason) { + return new WordSubmitResult(ResultType.INVALID_WORD, reason, + null, null, null, null, 0, null, null, 0, null, + null, null, null, null, null); + } + + public static WordSubmitResult timeout(WordChainSession session, String eliminatedId, String eliminatedNick, + String nextPlayerId, int nextTimeLimit) { + return new WordSubmitResult(ResultType.TIMEOUT, null, session, null, null, null, 0, + session.getNextLetter(), nextPlayerId, nextTimeLimit, null, + eliminatedId, eliminatedNick, null, null, null); + } + + public static WordSubmitResult gameEnd(WordChainSession session, String winnerId, String winnerNick, + List ranking) { + return new WordSubmitResult(ResultType.GAME_END, null, session, null, null, null, 0, + null, null, 0, null, null, null, winnerId, winnerNick, ranking); + } + + public static WordSubmitResult error(String message) { + return new WordSubmitResult(ResultType.ERROR, message, null, null, null, null, 0, + null, null, 0, null, null, null, null, null, null); + } + } + + public record RankEntry( + String playerId, + String nickname, + int score, + boolean eliminated + ) { + } +} diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSessionSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSessionSpec.groovy new file mode 100644 index 00000000..0b87dc9b --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSessionSpec.groovy @@ -0,0 +1,271 @@ +package com.mzc.secondproject.serverless.domain.chatting.model + +import spock.lang.Specification +import spock.lang.Unroll + +class WordChainSessionSpec extends Specification { + + def "calculateTimeLimit: 라운드별 시간 제한 계산"() { + expect: + WordChainSession.calculateTimeLimit(round) == expected + + where: + round | expected + 1 | 15 + 2 | 15 + 3 | 13 + 4 | 13 + 5 | 11 + 6 | 11 + 7 | 9 + 8 | 9 + 9 | 8 + 10 | 8 + 20 | 8 + } + + def "calculateScore: 기본 점수 계산"() { + when: + def score = WordChainSession.calculateScore(responseTimeMs, wordLength, timeLimit) + + then: + score == expected + + where: + responseTimeMs | wordLength | timeLimit | expected + 0 | 4 | 15 | 25 // base(10) + time(15) + length(0) + 5000 | 4 | 15 | 20 // base(10) + time(10) + length(0) + 10000 | 4 | 15 | 15 // base(10) + time(5) + length(0) + 15000 | 4 | 15 | 10 // base(10) + time(0) + length(0) + 0 | 7 | 15 | 31 // base(10) + time(15) + length(6) + 5000 | 6 | 15 | 24 // base(10) + time(10) + length(4) + } + + def "isActive: 게임 활성 상태 확인"() { + given: + def session = WordChainSession.builder() + .status(status) + .build() + + expect: + session.isActive() == expected + + where: + status | expected + "PLAYING" | true + "FINISHED" | false + "WAITING" | false + null | false + } + + def "isCurrentTurn: 현재 턴 확인"() { + given: + def session = WordChainSession.builder() + .currentPlayerId("player1") + .build() + + expect: + session.isCurrentTurn("player1") == true + session.isCurrentTurn("player2") == false + session.isCurrentTurn(null) == false + } + + def "isWordUsed: 단어 사용 여부 확인"() { + given: + def session = WordChainSession.builder() + .usedWords(["apple", "elephant", "tiger"]) + .build() + + expect: + session.isWordUsed("apple") == true + session.isWordUsed("APPLE") == true + session.isWordUsed("banana") == false + } + + def "isWordUsed: usedWords가 null인 경우"() { + given: + def session = WordChainSession.builder() + .usedWords(null) + .build() + + expect: + session.isWordUsed("apple") == false + } + + def "addUsedWord: 단어 추가"() { + given: + def session = WordChainSession.builder() + .usedWords(new ArrayList<>()) + .wordDefinitions(new HashMap<>()) + .build() + + when: + session.addUsedWord("Apple", "(noun) A fruit") + + then: + session.usedWords.contains("apple") + session.wordDefinitions["apple"] == "(noun) A fruit" + } + + def "addUsedWord: null 리스트에서 시작"() { + given: + def session = WordChainSession.builder() + .usedWords(null) + .wordDefinitions(null) + .build() + + when: + session.addUsedWord("apple", "(noun) A fruit") + + then: + session.usedWords == ["apple"] + session.wordDefinitions["apple"] == "(noun) A fruit" + } + + def "addUsedWord: definition이 null인 경우"() { + given: + def session = WordChainSession.builder() + .usedWords(new ArrayList<>()) + .wordDefinitions(new HashMap<>()) + .build() + + when: + session.addUsedWord("apple", null) + + then: + session.usedWords.contains("apple") + !session.wordDefinitions.containsKey("apple") + } + + def "eliminatePlayer: 플레이어 탈락 처리"() { + given: + def session = WordChainSession.builder() + .activePlayers(new ArrayList<>(["player1", "player2", "player3"])) + .eliminatedPlayers(new ArrayList<>()) + .build() + + when: + session.eliminatePlayer("player2") + + then: + session.activePlayers == ["player1", "player3"] + session.eliminatedPlayers == ["player2"] + } + + def "eliminatePlayer: 이미 탈락한 플레이어는 중복 추가되지 않음"() { + given: + def session = WordChainSession.builder() + .activePlayers(new ArrayList<>(["player1"])) + .eliminatedPlayers(new ArrayList<>(["player2"])) + .build() + + when: + session.eliminatePlayer("player2") + + then: + session.eliminatedPlayers.size() == 1 + } + + def "getNextPlayerId: 다음 플레이어 반환"() { + given: + def session = WordChainSession.builder() + .activePlayers(["player1", "player2", "player3"]) + .currentPlayerId(currentPlayer) + .build() + + expect: + session.getNextPlayerId() == expected + + where: + currentPlayer | expected + "player1" | "player2" + "player2" | "player3" + "player3" | "player1" + null | "player1" + "unknown" | "player1" + } + + def "getNextPlayerId: 한 명만 남은 경우"() { + given: + def session = WordChainSession.builder() + .activePlayers(["winner"]) + .currentPlayerId("winner") + .build() + + expect: + session.getNextPlayerId() == "winner" + } + + def "getNextPlayerId: 빈 리스트인 경우"() { + given: + def session = WordChainSession.builder() + .activePlayers([]) + .build() + + expect: + session.getNextPlayerId() == null + } + + def "addScore: 점수 추가"() { + given: + def session = WordChainSession.builder() + .scores(new HashMap<>()) + .build() + + when: + session.addScore("player1", 10) + session.addScore("player1", 15) + session.addScore("player2", 20) + + then: + session.scores["player1"] == 25 + session.scores["player2"] == 20 + } + + def "addScore: scores가 null인 경우"() { + given: + def session = WordChainSession.builder() + .scores(null) + .build() + + when: + session.addScore("player1", 10) + + then: + session.scores["player1"] == 10 + } + + def "isGameOver: 게임 종료 조건 확인"() { + given: + def session = WordChainSession.builder() + .activePlayers(players) + .build() + + expect: + session.isGameOver() == expected + + where: + players | expected + null | true + [] | true + ["player1"] | true + ["player1", "player2"] | false + } + + def "getWinner: 승자 반환"() { + given: + def session = WordChainSession.builder() + .activePlayers(players) + .build() + + expect: + session.getWinner() == expected + + where: + players | expected + ["winner"] | "winner" + ["p1", "p2"] | null + [] | null + null | null + } +} diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/service/DictionaryServiceSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/service/DictionaryServiceSpec.groovy new file mode 100644 index 00000000..d32e825a --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/service/DictionaryServiceSpec.groovy @@ -0,0 +1,54 @@ +package com.mzc.secondproject.serverless.domain.chatting.service + +import spock.lang.Specification + +class DictionaryServiceSpec extends Specification { + + def "DictionaryResult.valid: 유효한 결과 생성"() { + when: + def result = DictionaryService.DictionaryResult.valid("apple", "(noun) A fruit", "/ˈæpəl/") + + then: + result.isValid() + result.word() == "apple" + result.getDefinition().isPresent() + result.getDefinition().get() == "(noun) A fruit" + result.getPhonetic().isPresent() + result.getPhonetic().get() == "/ˈæpəl/" + result.errorMessage() == null + } + + def "DictionaryResult.validWithoutDefinition: 정의 없이 유효한 결과"() { + when: + def result = DictionaryService.DictionaryResult.validWithoutDefinition("apple") + + then: + result.isValid() + result.word() == "apple" + result.getDefinition().isEmpty() + result.getPhonetic().isEmpty() + } + + def "DictionaryResult.invalid: 유효하지 않은 결과"() { + when: + def result = DictionaryService.DictionaryResult.invalid("사전에 없는 단어입니다.") + + then: + !result.isValid() + result.word() == null + result.getDefinition().isEmpty() + result.errorMessage() == "사전에 없는 단어입니다." + } + + def "DictionaryResult.getDefinition: Optional 반환"() { + expect: + DictionaryService.DictionaryResult.valid("test", "def", null).getDefinition().isPresent() + DictionaryService.DictionaryResult.valid("test", null, null).getDefinition().isEmpty() + } + + def "DictionaryResult.getPhonetic: Optional 반환"() { + expect: + DictionaryService.DictionaryResult.valid("test", null, "/test/").getPhonetic().isPresent() + DictionaryService.DictionaryResult.valid("test", null, null).getPhonetic().isEmpty() + } +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 3fa504b5..7553d2a5 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -511,6 +511,71 @@ Resources: Auth: Authorizer: CognitoAuthorizer + # 끝말잇기(Word Chain) 게임 핸들러 + WordChainFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-wordchain-handler" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.chatting.handler.WordChainHandler::handleRequest + Description: Handle word chain game operations + SnapStart: + ApplyOn: PublishedVersions + Environment: + Variables: + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ChatTable + - DynamoDBReadPolicy: + TableName: !Ref UserTable + - Statement: + - Effect: Allow + Action: + - execute-api:ManageConnections + Resource: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/*" + Events: + StartWordChain: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/wordchain/start + Method: POST + Auth: + Authorizer: CognitoAuthorizer + SubmitWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/wordchain/submit + Method: POST + Auth: + Authorizer: CognitoAuthorizer + HandleTimeout: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/wordchain/timeout + Method: POST + Auth: + Authorizer: CognitoAuthorizer + StopWordChain: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/wordchain/stop + Method: POST + Auth: + Authorizer: CognitoAuthorizer + GetWordChainStatus: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/wordchain/status + Method: GET + Auth: + Authorizer: CognitoAuthorizer + # 게임 자동 종료 Lambda (EventBridge Scheduler에 의해 호출) GameAutoCloseFunction: Type: AWS::Serverless::Function From 161b305dfd5974eca523dc3d2691870d0ec6a9da Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sat, 24 Jan 2026 12:14:19 +0900 Subject: [PATCH 12/22] fix: update ChattingErrorCodeSpec to include new error codes --- .../domain/chatting/exception/ChattingErrorCodeSpec.groovy | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy index 9895fc71..911a0ea3 100644 --- a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy @@ -44,11 +44,13 @@ class ChattingErrorCodeSpec extends Specification { ChattingErrorCode.GAME_NOT_ALLOWED_IN_CHAT_ROOM | "GAME_007" | 400 ChattingErrorCode.GAME_RESTART_NOT_ALLOWED | "GAME_008" | 400 ChattingErrorCode.GAME_START_NOT_HOST | "GAME_009" | 403 + ChattingErrorCode.GAME_ACTION_FAILED | "GAME_010" | 400 + ChattingErrorCode.INVALID_INPUT | "INPUT_001" | 400 } def "모든 에러 코드 개수 확인"() { - expect: "24개의 에러 코드 존재" - ChattingErrorCode.values().length == 24 + expect: "26개의 에러 코드 존재" + ChattingErrorCode.values().length == 26 } def "채팅방 관련 에러 코드들 (ROOM_XXX)"() { From 18406b4d0bd5ce93e7c8166311fd4b121f8629fa Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sat, 24 Jan 2026 12:30:48 +0900 Subject: [PATCH 13/22] fix: add UserTable read permission to WebSocket Lambda WebSocket Lambda needs DynamoDBReadPolicy for UserTable to look up user nicknames for chat commands (/member, /dice, /coin, /hint, etc.) --- ServerlessFunction/template.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 7553d2a5..8a7be954 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -297,6 +297,8 @@ Resources: TableName: !Ref ChatTable - DynamoDBCrudPolicy: TableName: !Ref VocabTable + - DynamoDBReadPolicy: + TableName: !Ref UserTable - Statement: - Effect: Allow Action: From c351b374f4b2e8de4b7cc234d7c896f641c48060 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sat, 24 Jan 2026 13:44:25 +0900 Subject: [PATCH 14/22] fix: add Bedrock permission to NewsCollectionFunction NewsCollectionFunction needs bedrock:InvokeModel permission to analyze news difficulty using Claude. --- ServerlessFunction/template.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 8a7be954..84ae5559 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1819,6 +1819,11 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref NewsTable + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + Resource: "*" Events: DailySchedule: Type: Schedule From 49e6c94c9ecf77bf48b7fb129759af03e456b958 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sat, 24 Jan 2026 13:54:41 +0900 Subject: [PATCH 15/22] fix: fallback to yesterday's news when today's news is empty GET /news now returns yesterday's articles if no articles exist for today. This prevents empty results before the daily 18:00 KST news collection runs. --- .../domain/news/service/NewsQueryService.java | 14 +- ...frontend-notification-integration-guide.md | 747 ++++++++++++++++++ docs/frontend-wordchain-guide.md | 365 +++++++++ 3 files changed, 1124 insertions(+), 2 deletions(-) create mode 100644 docs/frontend-notification-integration-guide.md create mode 100644 docs/frontend-wordchain-guide.md 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 c1a0f328..99f0e0ac 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 @@ -40,12 +40,22 @@ public Optional getArticle(String articleId) { } /** - * 오늘의 뉴스 목록 조회 + * 오늘의 뉴스 목록 조회 (오늘 기사 없으면 어제 기사 조회) */ public PaginatedResult getTodayNews(int limit, String cursor) { String today = LocalDate.now().toString(); logger.debug("오늘의 뉴스 조회: date={}, limit={}", today, limit); - return articleRepository.findByDate(today, limit, cursor); + + PaginatedResult result = articleRepository.findByDate(today, limit, cursor); + + // 오늘 기사가 없으면 어제 기사 조회 + if (result.items().isEmpty() && cursor == null) { + String yesterday = LocalDate.now().minusDays(1).toString(); + logger.debug("오늘 기사 없음, 어제 기사 조회: date={}", yesterday); + result = articleRepository.findByDate(yesterday, limit, cursor); + } + + return result; } /** diff --git a/docs/frontend-notification-integration-guide.md b/docs/frontend-notification-integration-guide.md new file mode 100644 index 00000000..5c647bcf --- /dev/null +++ b/docs/frontend-notification-integration-guide.md @@ -0,0 +1,747 @@ +# 프론트엔드 실시간 알림 연동 가이드 + +## 개요 + +이 문서는 백엔드 알림 시스템과 프론트엔드를 연동하기 위한 가이드입니다. +**Server-Sent Events (SSE)** 를 사용하여 실시간 알림을 수신합니다. + +--- + +## 연결 방식 + +### SSE (Server-Sent Events) 사용 + +- WebSocket과 달리 **단방향 통신** (서버 → 클라이언트) +- HTTP 기반으로 별도 프로토콜 핸들링 불필요 +- 브라우저 `EventSource` API로 간단히 구현 가능 +- 연결 끊김 시 자동 재연결 지원 + +--- + +## 연결 엔드포인트 + +``` +GET {NOTIFICATION_FUNCTION_URL}?userId={userId} +``` + +| 파라미터 | 설명 | 예시 | +|---------|------|------| +| `userId` | 로그인한 사용자 ID | `user-123` | + +> ⚠️ **NOTIFICATION_FUNCTION_URL**은 배포 환경별로 다릅니다. 환경변수로 관리하세요. + +--- + +## 기본 연결 구현 + +### JavaScript (Vanilla) + +```javascript +const connectNotifications = (userId) => { + const url = `${NOTIFICATION_FUNCTION_URL}?userId=${userId}`; + const eventSource = new EventSource(url); + + // 알림 수신 + eventSource.onmessage = (event) => { + const notification = JSON.parse(event.data); + handleNotification(notification); + }; + + // 연결 성공 + eventSource.onopen = () => { + console.log('알림 연결 성공'); + }; + + // 에러 처리 + eventSource.onerror = (error) => { + console.error('알림 연결 에러:', error); + // EventSource는 자동으로 재연결을 시도합니다 + }; + + return eventSource; +}; + +// 연결 해제 +const disconnect = (eventSource) => { + eventSource.close(); +}; +``` + +### React Hook 예시 + +```typescript +import { useEffect, useCallback, useRef } from 'react'; + +interface Notification { + notificationId: string; + type: NotificationType; + userId: string; + payload: Record; + createdAt: string; +} + +type NotificationType = + | 'BADGE_EARNED' + | 'DAILY_COMPLETE' + | 'STREAK_REMINDER' + | 'TEST_COMPLETE' + | 'NEWS_QUIZ_COMPLETE' + | 'GAME_END' + | 'GAME_STREAK' + | 'OPIC_COMPLETE'; + +export const useNotifications = ( + userId: string | null, + onNotification: (notification: Notification) => void +) => { + const eventSourceRef = useRef(null); + + const connect = useCallback(() => { + if (!userId) return; + + const url = `${process.env.NEXT_PUBLIC_NOTIFICATION_URL}?userId=${userId}`; + const eventSource = new EventSource(url); + + eventSource.onmessage = (event) => { + // Heartbeat 무시 + if (event.data === 'HEARTBEAT') return; + + try { + const notification: Notification = JSON.parse(event.data); + onNotification(notification); + } catch (e) { + console.error('알림 파싱 실패:', e); + } + }; + + eventSource.onerror = () => { + console.log('알림 연결 끊김, 재연결 시도 중...'); + }; + + eventSourceRef.current = eventSource; + }, [userId, onNotification]); + + const disconnect = useCallback(() => { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + }, []); + + useEffect(() => { + connect(); + return () => disconnect(); + }, [connect, disconnect]); + + return { disconnect, reconnect: connect }; +}; +``` + +### React 컴포넌트 사용 예시 + +```tsx +const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { user } = useAuth(); + const [notifications, setNotifications] = useState([]); + + const handleNotification = useCallback((notification: Notification) => { + setNotifications(prev => [notification, ...prev]); + + // 타입별 처리 + switch (notification.type) { + case 'BADGE_EARNED': + showBadgeToast(notification.payload); + break; + case 'DAILY_COMPLETE': + showStreakCelebration(notification.payload); + break; + case 'GAME_END': + showGameResult(notification.payload); + break; + // ... 기타 타입 + } + }, []); + + useNotifications(user?.id ?? null, handleNotification); + + return ( + + {children} + + ); +}; +``` + +--- + +## 알림 타입 및 Payload 구조 + +### 공통 응답 구조 + +```typescript +interface Notification { + notificationId: string; // "notif-xxxxxxxx" 형식 + type: NotificationType; // 알림 타입 + userId: string; // 대상 사용자 ID + payload: object; // 타입별 상세 데이터 + createdAt: string; // ISO-8601 형식 (예: "2024-01-15T09:30:00Z") +} +``` + +--- + +### 1. BADGE_EARNED (배지 획득) + +사용자가 새로운 배지를 획득했을 때 + +```typescript +interface BadgeEarnedPayload { + badgeType: string; // 배지 타입 코드 + badgeName: string; // 배지 이름 + description: string; // 배지 설명 + iconUrl: string; // 배지 아이콘 URL +} +``` + +**예시:** +```json +{ + "notificationId": "notif-a1b2c3d4", + "type": "BADGE_EARNED", + "userId": "user-123", + "payload": { + "badgeType": "STREAK_7", + "badgeName": "7일 연속 학습", + "description": "7일 연속으로 학습을 완료했습니다!", + "iconUrl": "https://cdn.example.com/badges/streak-7.png" + }, + "createdAt": "2024-01-15T09:30:00Z" +} +``` + +--- + +### 2. DAILY_COMPLETE (일일 학습 완료) + +오늘의 단어 학습을 모두 완료했을 때 + +```typescript +interface DailyCompletePayload { + date: string; // 학습 완료 날짜 (YYYY-MM-DD) + wordsLearned: number; // 오늘 학습한 단어 수 + totalWords: number; // 총 학습 단어 수 + currentStreak: number; // 현재 연속 학습 일수 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-e5f6g7h8", + "type": "DAILY_COMPLETE", + "userId": "user-123", + "payload": { + "date": "2024-01-15", + "wordsLearned": 20, + "totalWords": 150, + "currentStreak": 5 + }, + "createdAt": "2024-01-15T14:00:00Z" +} +``` + +--- + +### 3. STREAK_REMINDER (연속 학습 리마인더) + +매일 21:00 KST에 오늘 학습을 아직 하지 않은 사용자에게 발송 + +```typescript +interface StreakReminderPayload { + currentStreak: number; // 현재 연속 학습 일수 + message: string; // 리마인더 메시지 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-i9j0k1l2", + "type": "STREAK_REMINDER", + "userId": "user-123", + "payload": { + "currentStreak": 5, + "message": "오늘 학습을 완료하고 6일 연속 학습을 달성하세요!" + }, + "createdAt": "2024-01-15T12:00:00Z" +} +``` + +--- + +### 4. TEST_COMPLETE (단어 테스트 완료) + +단어 테스트를 완료했을 때 + +```typescript +interface TestCompletePayload { + testId: string; // 테스트 ID + score: number; // 점수 (0-100) + correctCount: number; // 맞힌 문제 수 + totalCount: number; // 전체 문제 수 + isPerfect: boolean; // 만점 여부 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-m3n4o5p6", + "type": "TEST_COMPLETE", + "userId": "user-123", + "payload": { + "testId": "test-abc123", + "score": 85, + "correctCount": 17, + "totalCount": 20, + "isPerfect": false + }, + "createdAt": "2024-01-15T10:30:00Z" +} +``` + +--- + +### 5. NEWS_QUIZ_COMPLETE (뉴스 퀴즈 완료) + +뉴스 기사 퀴즈를 완료했을 때 + +```typescript +interface NewsQuizCompletePayload { + articleId: string; // 뉴스 기사 ID + articleTitle: string; // 기사 제목 + score: number; // 점수 (0-100) + correctCount: number; // 맞힌 문제 수 + totalCount: number; // 전체 문제 수 + isPerfect: boolean; // 만점 여부 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-q7r8s9t0", + "type": "NEWS_QUIZ_COMPLETE", + "userId": "user-123", + "payload": { + "articleId": "article-xyz789", + "articleTitle": "Tech Giants Report Strong Q4 Earnings", + "score": 100, + "correctCount": 5, + "totalCount": 5, + "isPerfect": true + }, + "createdAt": "2024-01-15T11:00:00Z" +} +``` + +--- + +### 6. GAME_END (게임 종료) + +캐치마인드 게임이 종료되었을 때 + +```typescript +interface GameEndPayload { + roomId: string; // 게임 방 ID + gameSessionId: string; // 게임 세션 ID + rank: number; // 최종 순위 + totalPlayers: number; // 전체 플레이어 수 + score: number; // 획득 점수 + isWinner: boolean; // 1등 여부 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-u1v2w3x4", + "type": "GAME_END", + "userId": "user-123", + "payload": { + "roomId": "room-game-001", + "gameSessionId": "session-abc", + "rank": 1, + "totalPlayers": 4, + "score": 2500, + "isWinner": true + }, + "createdAt": "2024-01-15T15:30:00Z" +} +``` + +--- + +### 7. GAME_STREAK (게임 연속 정답) + +게임 중 연속 정답을 달성했을 때 + +```typescript +interface GameStreakPayload { + roomId: string; // 게임 방 ID + streakCount: number; // 연속 정답 횟수 + bonusPoints: number; // 보너스 점수 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-y5z6a7b8", + "type": "GAME_STREAK", + "userId": "user-123", + "payload": { + "roomId": "room-game-001", + "streakCount": 5, + "bonusPoints": 500 + }, + "createdAt": "2024-01-15T15:25:00Z" +} +``` + +--- + +### 8. OPIC_COMPLETE (OPIc 연습 완료) + +OPIc 스피킹 연습 세션을 완료했을 때 + +```typescript +interface OpicCompletePayload { + sessionId: string; // 세션 ID + estimatedLevel: string; // 예상 등급 (IM1, IM2, IH, AL 등) + questionsAnswered: number; // 답변한 문제 수 + feedbackSummary: string; // 피드백 요약 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-c9d0e1f2", + "type": "OPIC_COMPLETE", + "userId": "user-123", + "payload": { + "sessionId": "opic-session-456", + "estimatedLevel": "IM2", + "questionsAnswered": 15, + "feedbackSummary": "발음과 유창성이 좋습니다. 문법적 정확성을 더 연습하세요." + }, + "createdAt": "2024-01-15T16:00:00Z" +} +``` + +--- + +## 특수 이벤트 + +### HEARTBEAT (하트비트) + +서버에서 연결 유지를 위해 1초마다 전송합니다. 무시하면 됩니다. + +```javascript +eventSource.onmessage = (event) => { + if (event.data === 'HEARTBEAT') return; // 무시 + // ... +}; +``` + +### STREAM_END (스트림 종료) + +서버가 연결을 종료할 때 전송됩니다. (최대 14분 후) +`EventSource`는 자동으로 재연결을 시도합니다. + +--- + +## 연결 관리 권장사항 + +### 1. 연결 시점 + +```typescript +// 로그인 후 연결 +const handleLoginSuccess = (user: User) => { + connectNotifications(user.id); +}; + +// 페이지 로드 시 (이미 로그인된 경우) +useEffect(() => { + if (isAuthenticated && user) { + connectNotifications(user.id); + } +}, [isAuthenticated, user]); +``` + +### 2. 연결 해제 시점 + +```typescript +// 로그아웃 시 +const handleLogout = () => { + disconnectNotifications(); + // ... +}; + +// 페이지 언마운트 시 (SPA) +useEffect(() => { + return () => disconnectNotifications(); +}, []); +``` + +### 3. 재연결 처리 + +`EventSource`는 연결 끊김 시 자동 재연결을 시도합니다. +추가적인 재연결 로직이 필요한 경우: + +```typescript +const MAX_RETRY_COUNT = 5; +let retryCount = 0; + +eventSource.onerror = () => { + retryCount++; + + if (retryCount >= MAX_RETRY_COUNT) { + eventSource.close(); + showErrorMessage('알림 서버 연결에 실패했습니다. 새로고침해주세요.'); + } +}; + +eventSource.onopen = () => { + retryCount = 0; // 연결 성공 시 초기화 +}; +``` + +--- + +## UI 처리 권장사항 + +### 토스트 알림 + +```typescript +const showNotificationToast = (notification: Notification) => { + const config = getToastConfig(notification.type); + + toast({ + title: config.title, + description: formatPayload(notification.payload), + icon: config.icon, + duration: config.duration, + }); +}; + +const getToastConfig = (type: NotificationType) => { + switch (type) { + case 'BADGE_EARNED': + return { title: '🏆 배지 획득!', icon: 'trophy', duration: 5000 }; + case 'DAILY_COMPLETE': + return { title: '✅ 오늘의 학습 완료!', icon: 'check', duration: 4000 }; + case 'STREAK_REMINDER': + return { title: '⏰ 학습 리마인더', icon: 'clock', duration: 6000 }; + case 'TEST_COMPLETE': + return { title: '📝 테스트 완료', icon: 'file', duration: 3000 }; + case 'GAME_END': + return { title: '🎮 게임 종료', icon: 'gamepad', duration: 4000 }; + default: + return { title: '알림', icon: 'bell', duration: 3000 }; + } +}; +``` + +### 알림 센터 + +```typescript +const NotificationCenter: React.FC = () => { + const { notifications } = useNotificationContext(); + const [unreadCount, setUnreadCount] = useState(0); + + return ( + + + + {unreadCount > 0 && } + + + {notifications.map(notif => ( + + ))} + + + ); +}; +``` + +--- + +## TypeScript 타입 정의 (복사용) + +```typescript +// types/notification.ts + +export type NotificationType = + | 'BADGE_EARNED' + | 'DAILY_COMPLETE' + | 'STREAK_REMINDER' + | 'TEST_COMPLETE' + | 'NEWS_QUIZ_COMPLETE' + | 'GAME_END' + | 'GAME_STREAK' + | 'OPIC_COMPLETE'; + +export interface BaseNotification { + notificationId: string; + type: T; + userId: string; + payload: P; + createdAt: string; +} + +export interface BadgeEarnedPayload { + badgeType: string; + badgeName: string; + description: string; + iconUrl: string; +} + +export interface DailyCompletePayload { + date: string; + wordsLearned: number; + totalWords: number; + currentStreak: number; +} + +export interface StreakReminderPayload { + currentStreak: number; + message: string; +} + +export interface TestCompletePayload { + testId: string; + score: number; + correctCount: number; + totalCount: number; + isPerfect: boolean; +} + +export interface NewsQuizCompletePayload { + articleId: string; + articleTitle: string; + score: number; + correctCount: number; + totalCount: number; + isPerfect: boolean; +} + +export interface GameEndPayload { + roomId: string; + gameSessionId: string; + rank: number; + totalPlayers: number; + score: number; + isWinner: boolean; +} + +export interface GameStreakPayload { + roomId: string; + streakCount: number; + bonusPoints: number; +} + +export interface OpicCompletePayload { + sessionId: string; + estimatedLevel: string; + questionsAnswered: number; + feedbackSummary: string; +} + +export type Notification = + | BaseNotification<'BADGE_EARNED', BadgeEarnedPayload> + | BaseNotification<'DAILY_COMPLETE', DailyCompletePayload> + | BaseNotification<'STREAK_REMINDER', StreakReminderPayload> + | BaseNotification<'TEST_COMPLETE', TestCompletePayload> + | BaseNotification<'NEWS_QUIZ_COMPLETE', NewsQuizCompletePayload> + | BaseNotification<'GAME_END', GameEndPayload> + | BaseNotification<'GAME_STREAK', GameStreakPayload> + | BaseNotification<'OPIC_COMPLETE', OpicCompletePayload>; +``` + +--- + +## 환경 설정 + +### 환경 변수 + +| 환경 | URL | +|------|-----| +| **Test** | `https://flhf42jd6xgrh26wrqgwxmbmee0zmjnv.lambda-url.ap-northeast-2.on.aws/` | +| **Prod** | (배포 후 업데이트 예정) | + +```env +# .env.local (Next.js) +NEXT_PUBLIC_NOTIFICATION_URL=https://flhf42jd6xgrh26wrqgwxmbmee0zmjnv.lambda-url.ap-northeast-2.on.aws + +# .env (Vite) +VITE_NOTIFICATION_URL=https://flhf42jd6xgrh26wrqgwxmbmee0zmjnv.lambda-url.ap-northeast-2.on.aws +``` + +--- + +## 테스트 방법 + +### 개발 환경에서 테스트 + +1. 브라우저 개발자 도구 → Network 탭 열기 +2. EventStream 필터 선택 +3. 로그인 후 알림 연결 확인 +4. 학습 완료, 테스트 제출 등의 액션 수행 +5. 실시간으로 알림 수신 확인 + +### Mock SSE 서버 (로컬 테스트용) + +```javascript +// mock-sse-server.js +const http = require('http'); + +http.createServer((req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*', + }); + + // 테스트 알림 전송 + setInterval(() => { + const notification = { + notificationId: `notif-${Date.now()}`, + type: 'BADGE_EARNED', + userId: 'test-user', + payload: { + badgeType: 'TEST_BADGE', + badgeName: '테스트 배지', + description: '테스트용 배지입니다', + iconUrl: 'https://example.com/badge.png', + }, + createdAt: new Date().toISOString(), + }; + res.write(`data: ${JSON.stringify(notification)}\n\n`); + }, 5000); +}).listen(3001); + +console.log('Mock SSE server running on http://localhost:3001'); +``` + +--- + +## 문의 + +백엔드 알림 시스템 관련 문의: **[백엔드 담당자 이름/연락처]** \ No newline at end of file diff --git a/docs/frontend-wordchain-guide.md b/docs/frontend-wordchain-guide.md new file mode 100644 index 00000000..5296aa09 --- /dev/null +++ b/docs/frontend-wordchain-guide.md @@ -0,0 +1,365 @@ +# 영어 끝말잇기(쿵쿵따) 프론트엔드 통합 가이드 + +## 개요 +영어 끝말잇기 게임 - 이전 단어의 마지막 글자로 시작하는 단어를 제출하는 게임 + +## REST API 엔드포인트 + +### 1. 게임 시작 +``` +POST /chat/rooms/{roomId}/wordchain/start +Authorization: Bearer {token} +``` + +**Response (성공):** +```json +{ + "success": true, + "message": "Word Chain game started", + "data": { + "sessionId": "uuid", + "gameStatus": "PLAYING", + "currentRound": 1, + "currentPlayerId": "user-id", + "currentWord": "apple", + "nextLetter": "e", + "timeLimit": 15, + "turnStartTime": 1706000000000, + "serverTime": 1706000000000, + "activePlayers": ["user1", "user2", "user3"], + "eliminatedPlayers": [], + "scores": {}, + "usedWords": ["apple"] + } +} +``` + +### 2. 단어 제출 +``` +POST /chat/rooms/{roomId}/wordchain/submit +Authorization: Bearer {token} +Content-Type: application/json + +{ + "word": "elephant" +} +``` + +**Response (정답):** +```json +{ + "success": true, + "message": "Correct!", + "data": { + "resultType": "CORRECT", + "word": "elephant", + "definition": "(noun) A large mammal with a trunk", + "phonetic": "/ˈɛləfənt/", + "score": 23, + "nextLetter": "t", + "nextPlayerId": "user2", + "nextTimeLimit": 15 + } +} +``` + +**Response (오답 - 첫 글자 틀림):** +```json +{ + "success": true, + "message": "Wrong answer", + "data": { + "resultType": "WRONG_LETTER", + "error": "'e'로 시작하는 단어를 입력하세요." + } +} +``` + +**Response (오답 - 사전에 없음):** +```json +{ + "success": true, + "message": "Wrong answer", + "data": { + "resultType": "INVALID_WORD", + "error": "사전에 없는 단어입니다: xyz" + } +} +``` + +### 3. 타임아웃 처리 +``` +POST /chat/rooms/{roomId}/wordchain/timeout +Authorization: Bearer {token} +``` + +### 4. 게임 종료 (시작자만) +``` +POST /chat/rooms/{roomId}/wordchain/stop +Authorization: Bearer {token} +``` + +### 5. 게임 상태 조회 +``` +GET /chat/rooms/{roomId}/wordchain/status +Authorization: Bearer {token} +``` + +--- + +## WebSocket 메시지 + +### Domain +```javascript +domain: "wordchain" +``` + +### 메시지 타입 + +| messageType | 설명 | +|-------------|------| +| `wordchain_start` | 게임 시작 | +| `wordchain_correct` | 정답 | +| `wordchain_wrong` | 오답 | +| `wordchain_timeout` | 시간 초과 (탈락) | +| `wordchain_end` | 게임 종료 | + +--- + +## WebSocket 메시지 상세 + +### 1. 게임 시작 (wordchain_start) +```json +{ + "domain": "wordchain", + "messageType": "wordchain_start", + "messageId": "uuid", + "roomId": "room-id", + "userId": "SYSTEM", + "content": "🎮 끝말잇기 시작!\n시작 단어: apple\n다음 글자: 'e'\n\n첫 번째 차례: user1\n제한 시간: 15초", + "createdAt": "2026-01-24T12:00:00Z", + "timestamp": 1706000000000, + "sessionId": "session-uuid", + "starterWord": "apple", + "nextLetter": "e", + "currentPlayerId": "user1", + "timeLimit": 15, + "turnStartTime": 1706000000000, + "serverTime": 1706000000000, + "players": ["user1", "user2", "user3"], + "activePlayers": ["user1", "user2", "user3"] +} +``` + +### 2. 정답 (wordchain_correct) +```json +{ + "domain": "wordchain", + "messageType": "wordchain_correct", + "messageId": "uuid", + "roomId": "room-id", + "userId": "SYSTEM", + "content": "✅ 닉네임: \"elephant\" (+23점)\n뜻: (noun) A large mammal\n다음 글자: 't'", + "createdAt": "2026-01-24T12:00:05Z", + "timestamp": 1706000005000, + "serverTime": 1706000005000, + "resultType": "CORRECT", + "word": "elephant", + "definition": "(noun) A large mammal with a trunk", + "phonetic": "/ˈɛləfənt/", + "score": 23, + "nextLetter": "t", + "nextPlayerId": "user2", + "nextTimeLimit": 15, + "playerNickname": "닉네임", + "turnStartTime": 1706000005000, + "scores": { + "user1": 23 + } +} +``` + +### 3. 오답 (wordchain_wrong) +```json +{ + "domain": "wordchain", + "messageType": "wordchain_wrong", + "messageId": "uuid", + "roomId": "room-id", + "userId": "SYSTEM", + "content": "❌ 사전에 없는 단어입니다: xyz", + "resultType": "INVALID_WORD", + "error": "사전에 없는 단어입니다: xyz" +} +``` + +### 4. 시간 초과 (wordchain_timeout) +```json +{ + "domain": "wordchain", + "messageType": "wordchain_timeout", + "messageId": "uuid", + "roomId": "room-id", + "userId": "SYSTEM", + "content": "⏰ 닉네임 시간 초과! 탈락!", + "resultType": "TIMEOUT", + "eliminatedPlayerId": "user1", + "eliminatedNickname": "닉네임", + "nextPlayerId": "user2", + "nextTimeLimit": 13, + "nextLetter": "e", + "turnStartTime": 1706000015000, + "activePlayers": ["user2", "user3"] +} +``` + +### 5. 게임 종료 (wordchain_end) +```json +{ + "domain": "wordchain", + "messageType": "wordchain_end", + "messageId": "uuid", + "roomId": "room-id", + "userId": "SYSTEM", + "content": "🏆 승자: 닉네임!", + "resultType": "GAME_END", + "winnerId": "user2", + "winnerNickname": "닉네임", + "ranking": [ + { "playerId": "user2", "nickname": "닉네임2", "score": 45, "eliminated": false }, + { "playerId": "user3", "nickname": "닉네임3", "score": 30, "eliminated": true }, + { "playerId": "user1", "nickname": "닉네임1", "score": 23, "eliminated": true } + ], + "usedWords": ["apple", "elephant", "tiger", "rainbow"], + "wordDefinitions": { + "apple": "(noun) A fruit", + "elephant": "(noun) A large mammal", + "tiger": "(noun) A large cat", + "rainbow": "(noun) An arc of colors" + }, + "scores": { + "user1": 23, + "user2": 45, + "user3": 30 + } +} +``` + +--- + +## 게임 규칙 + +### 시간 제한 (라운드별 감소) +| 라운드 | 시간 제한 | +|--------|----------| +| 1-2 | 15초 | +| 3-4 | 13초 | +| 5-6 | 11초 | +| 7-8 | 9초 | +| 9+ | 8초 | + +### 점수 계산 +``` +점수 = 기본점수(10) + 시간보너스 + 길이보너스 + +시간보너스 = 남은시간(초) +길이보너스 = (단어길이 - 4) × 2 (5글자 이상부터) +``` + +**예시:** +- 15초 제한에서 5초 만에 "elephant"(8글자) 제출 +- 점수 = 10 + 10 + 8 = 28점 + +### 게임 종료 조건 +- 1명만 남으면 게임 종료 +- 시작자가 `/stop` 호출 + +--- + +## 프론트엔드 구현 가이드 + +### 1. 타이머 동기화 +```javascript +// 서버 시간과 클라이언트 시간 차이 계산 +const serverTimeDiff = message.serverTime - Date.now(); + +// 남은 시간 계산 +const elapsed = Date.now() + serverTimeDiff - message.turnStartTime; +const remaining = (message.timeLimit * 1000) - elapsed; +``` + +### 2. WebSocket 메시지 핸들러 +```javascript +socket.onmessage = (event) => { + const message = JSON.parse(event.data); + + if (message.domain !== 'wordchain') return; + + switch (message.messageType) { + case 'wordchain_start': + handleGameStart(message); + break; + case 'wordchain_correct': + handleCorrectAnswer(message); + break; + case 'wordchain_wrong': + handleWrongAnswer(message); + break; + case 'wordchain_timeout': + handleTimeout(message); + break; + case 'wordchain_end': + handleGameEnd(message); + break; + } +}; +``` + +### 3. 타임아웃 자동 전송 +```javascript +// 내 턴일 때 타이머 만료 시 자동으로 타임아웃 API 호출 +if (isMyTurn && remaining <= 0) { + fetch(`/chat/rooms/${roomId}/wordchain/timeout`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` } + }); +} +``` + +### 4. UI 구성 요소 +- 현재 단어 표시 +- 다음 시작 글자 강조 +- 타이머 (남은 시간) +- 현재 차례 플레이어 표시 +- 활성/탈락 플레이어 목록 +- 점수판 +- 사용된 단어 목록 +- 단어 입력 필드 (본인 차례일 때만 활성화) + +### 5. 게임 종료 후 학습 화면 +```javascript +// 게임 종료 시 사용된 단어와 뜻 표시 +message.usedWords.forEach(word => { + const definition = message.wordDefinitions[word]; + console.log(`${word}: ${definition}`); +}); +``` + +--- + +## 에러 코드 + +| 코드 | 메시지 | +|------|--------| +| GAME_001 | 게임 시작에 실패했습니다 | +| GAME_002 | 게임 중단에 실패했습니다 | +| GAME_010 | 게임 액션 처리에 실패했습니다 | +| INPUT_001 | 유효하지 않은 입력입니다 | + +--- + +## 참고 + +- Dictionary API: [Free Dictionary API](https://dictionaryapi.dev/) +- 최소 인원: 2명 +- 시작 단어: 서버에서 랜덤 선택 (apple, house, water 등) From f1d5aa6852f4448ff627a8856e1ec65a4017b4ce Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Sun, 25 Jan 2026 02:45:32 +0900 Subject: [PATCH 16/22] =?UTF-8?q?feature=20=20:=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=8B=89=EB=84=A4=EC=9E=84=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=9A=A9=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#534)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * fix: add stack name prefix to news-collection schedule name Prevent EventBridge rule name conflicts across environments * fix: add X-Requested-With and Accept headers to CORS config Enable additional headers for CloudFront CORS compatibility * 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 * fix: disable authorizer for CORS preflight requests Add AddDefaultAuthorizerToCorsPreflight: false to prevent Cognito Authorizer from blocking OPTIONS requests * 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 체크 추가 * 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: SpeakingHandler getStringOrNull 컴파일 에러 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * 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 * feature : Speaking Table & Function template.yaml 파일에 추가 (#513) * 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 * fix: add stack name prefix to news-collection schedule name Prevent EventBridge rule name conflicts across environments * fix: add X-Requested-With and Accept headers to CORS config Enable additional headers for CloudFront CORS compatibility * 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 * fix: disable authorizer for CORS preflight requests Add AddDefaultAuthorizerToCorsPreflight: false to prevent Cognito Authorizer from blocking OPTIONS requests * 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 * feat : Speaking 관련 template 람다 함수 및 테이블 추가 --------- Co-authored-by: DDING JOO * feature : 말하기 연습 기능 polly 서비스 권한 추가 (#514) * 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 * fix: add stack name prefix to news-collection schedule name Prevent EventBridge rule name conflicts across environments * fix: add X-Requested-With and Accept headers to CORS config Enable additional headers for CloudFront CORS compatibility * 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 * fix: disable authorizer for CORS preflight requests Add AddDefaultAuthorizerToCorsPreflight: false to prevent Cognito Authorizer from blocking OPTIONS requests * 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 * feat : Speaking 관련 template 람다 함수 및 테이블 추가 * feat : 말하기 기능에 polly 서비스 권한 추가 --------- Co-authored-by: DDING JOO * feature : transcribe API KEY 추가 (#516) * 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 * fix: add stack name prefix to news-collection schedule name Prevent EventBridge rule name conflicts across environments * fix: add X-Requested-With and Accept headers to CORS config Enable additional headers for CloudFront CORS compatibility * 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 * fix: disable authorizer for CORS preflight requests Add AddDefaultAuthorizerToCorsPreflight: false to prevent Cognito Authorizer from blocking OPTIONS requests * 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 * feat : Speaking 관련 template 람다 함수 및 테이블 추가 * feat : 말하기 기능에 polly 서비스 권한 추가 * feat : transcribe API KEY 추가 --------- Co-authored-by: DDING JOO * feat : 채팅 도메인 닉네임 조회용 메서드 추가 --------- Co-authored-by: ddingjoo --- .../websocket/WebSocketMessageHandler.java | 20 ++ .../domain/chatting/model/ChatMessage.java | 1 + .../handler/SpeakingConnectHandler.java | 0 .../handler/SpeakingDisconnectHandler.java | 0 .../speaking/handler/SpeakingHandler.java | 271 +++++++++--------- .../handler/SpeakingMessageHandler.java | 0 .../SpeakingConnectionRepository.java | 0 .../repository/SpeakingSessionRepository.java | 116 ++++---- .../domain/user/service/UserService.java | 10 + ServerlessFunction/template.yaml | 88 ++++++ 10 files changed, 314 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/chatting/handler/websocket/WebSocketMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java index 5515cc1b..0435d5cb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java @@ -19,6 +19,8 @@ import com.mzc.secondproject.serverless.domain.chatting.service.ChatMessageService; import com.mzc.secondproject.serverless.domain.chatting.service.CommandService; import com.mzc.secondproject.serverless.domain.chatting.service.GameService; +import com.mzc.secondproject.serverless.domain.user.model.User; +import com.mzc.secondproject.serverless.domain.user.service.UserService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -44,6 +46,7 @@ public class WebSocketMessageHandler implements RequestHandler handleRegularMessage(String connectionId, MessagePay // 일반 메시지 저장 및 브로드캐스트 String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); + + // 닉네임 조회 + String nickname = "Unknown"; + try { + // DB에서 유저 정보(닉네임) 가져오기 + User user = userService.getUserProfile(payload.userId); + if (user != null && user.getNickname() != null) { + nickname = user.getNickname(); + } else { + // 혹시 없으면 UUID 사용 + nickname = payload.userId; + } + } catch (Exception e) { + nickname = payload.userId; + } ChatMessage message = ChatMessage.builder() .pk("ROOM#" + payload.roomId) @@ -166,6 +185,7 @@ private Map handleRegularMessage(String connectionId, MessagePay .messageId(messageId) .roomId(payload.roomId) .userId(payload.userId) + .nickname(nickname) .content(payload.content) .messageType(messageType) .createdAt(now) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatMessage.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatMessage.java index 0cbde348..211abaf1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatMessage.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatMessage.java @@ -23,6 +23,7 @@ public class ChatMessage { private String messageId; private String roomId; private String userId; + private String nickname; private String content; private String messageType; // TEXT, IMAGE, VOICE, AI_RESPONSE private String createdAt; 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/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java index 4f635106..e302205b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java @@ -31,11 +31,16 @@ public class UserService { private static final int NICKNAME_MAX_LENGTH = 20; private final UserRepository userRepository; private final S3Presigner s3Presigner; + public UserService(UserRepository userRepository) { this.userRepository = userRepository; // AwsClients 싱글톤 사용 - Cold Start 최적화 this.s3Presigner = AwsClients.s3Presigner(); } + + public UserService() { + this(new UserRepository()); + } private static String getDefaultProfileUrl() { String bucket = BUCKET_NAME != null ? BUCKET_NAME : "group2-englishstudy"; @@ -66,6 +71,11 @@ public User getProfile(String userId, APIGatewayProxyRequestEvent request) { return user; } + + // 단순 프로필 조회 메서드 (채팅용) - DB 조회만 수행 + public User getUserProfile(String userId) { + return userRepository.findByCognitoSub(userId).orElse(null); + } public String getPresignedProfileUrl(String s3Url) { if (s3Url == null || s3Url.isEmpty()) { diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 84ae5559..225dc206 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -37,12 +37,14 @@ 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}" 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" @@ -1618,6 +1620,56 @@ 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 + 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: + - bedrock:InvokeModel + - polly:SynthesizeSpeech + 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 ############################################# @@ -2007,6 +2059,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 ############################################# @@ -2236,6 +2320,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 7cd2c699110841aa8538c5e562203b6a05a475b2 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Sun, 25 Jan 2026 02:46:50 +0900 Subject: [PATCH 17/22] =?UTF-8?q?feature=20:=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EB=A9=94=EB=89=B4=EC=97=90=EC=84=9C=20=EB=8B=89=EB=84=A4?= =?UTF-8?q?=EC=9E=84=20=EC=A1=B0=ED=9A=8C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20(#535)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: add Bedrock permission to NewsCollectionFunction NewsCollectionFunction needs bedrock:InvokeModel permission to analyze news difficulty using Claude. * fix: fallback to yesterday's news when today's news is empty GET /news now returns yesterday's articles if no articles exist for today. This prevents empty results before the daily 18:00 KST news collection runs. * feature : 채팅 도메인 닉네임 조회용 메서드 추가 (#534) * 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 체크 추가 * 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: SpeakingHandler getStringOrNull 컴파일 에러 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * 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 * feature : Speaking Table & Function template.yaml 파일에 추가 (#513) * 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 에러) - 방… --------- Co-authored-by: ddingjoo Co-authored-by: Claude Opus 4.5 --- .../websocket/WebSocketMessageHandler.java | 20 + .../domain/chatting/model/ChatMessage.java | 1 + .../domain/news/service/NewsQueryService.java | 14 +- .../domain/user/service/UserService.java | 10 + ServerlessFunction/template.yaml | 5 + ...frontend-notification-integration-guide.md | 747 ++++++++++++++++++ docs/frontend-wordchain-guide.md | 365 +++++++++ 7 files changed, 1160 insertions(+), 2 deletions(-) create mode 100644 docs/frontend-notification-integration-guide.md create mode 100644 docs/frontend-wordchain-guide.md diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java index 5515cc1b..0435d5cb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java @@ -19,6 +19,8 @@ import com.mzc.secondproject.serverless.domain.chatting.service.ChatMessageService; import com.mzc.secondproject.serverless.domain.chatting.service.CommandService; import com.mzc.secondproject.serverless.domain.chatting.service.GameService; +import com.mzc.secondproject.serverless.domain.user.model.User; +import com.mzc.secondproject.serverless.domain.user.service.UserService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -44,6 +46,7 @@ public class WebSocketMessageHandler implements RequestHandler handleRegularMessage(String connectionId, MessagePay // 일반 메시지 저장 및 브로드캐스트 String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); + + // 닉네임 조회 + String nickname = "Unknown"; + try { + // DB에서 유저 정보(닉네임) 가져오기 + User user = userService.getUserProfile(payload.userId); + if (user != null && user.getNickname() != null) { + nickname = user.getNickname(); + } else { + // 혹시 없으면 UUID 사용 + nickname = payload.userId; + } + } catch (Exception e) { + nickname = payload.userId; + } ChatMessage message = ChatMessage.builder() .pk("ROOM#" + payload.roomId) @@ -166,6 +185,7 @@ private Map handleRegularMessage(String connectionId, MessagePay .messageId(messageId) .roomId(payload.roomId) .userId(payload.userId) + .nickname(nickname) .content(payload.content) .messageType(messageType) .createdAt(now) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatMessage.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatMessage.java index 0cbde348..211abaf1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatMessage.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatMessage.java @@ -23,6 +23,7 @@ public class ChatMessage { private String messageId; private String roomId; private String userId; + private String nickname; private String content; private String messageType; // TEXT, IMAGE, VOICE, AI_RESPONSE private String createdAt; 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 c1a0f328..99f0e0ac 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 @@ -40,12 +40,22 @@ public Optional getArticle(String articleId) { } /** - * 오늘의 뉴스 목록 조회 + * 오늘의 뉴스 목록 조회 (오늘 기사 없으면 어제 기사 조회) */ public PaginatedResult getTodayNews(int limit, String cursor) { String today = LocalDate.now().toString(); logger.debug("오늘의 뉴스 조회: date={}, limit={}", today, limit); - return articleRepository.findByDate(today, limit, cursor); + + PaginatedResult result = articleRepository.findByDate(today, limit, cursor); + + // 오늘 기사가 없으면 어제 기사 조회 + if (result.items().isEmpty() && cursor == null) { + String yesterday = LocalDate.now().minusDays(1).toString(); + logger.debug("오늘 기사 없음, 어제 기사 조회: date={}", yesterday); + result = articleRepository.findByDate(yesterday, limit, cursor); + } + + return result; } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java index 4f635106..e302205b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java @@ -31,11 +31,16 @@ public class UserService { private static final int NICKNAME_MAX_LENGTH = 20; private final UserRepository userRepository; private final S3Presigner s3Presigner; + public UserService(UserRepository userRepository) { this.userRepository = userRepository; // AwsClients 싱글톤 사용 - Cold Start 최적화 this.s3Presigner = AwsClients.s3Presigner(); } + + public UserService() { + this(new UserRepository()); + } private static String getDefaultProfileUrl() { String bucket = BUCKET_NAME != null ? BUCKET_NAME : "group2-englishstudy"; @@ -66,6 +71,11 @@ public User getProfile(String userId, APIGatewayProxyRequestEvent request) { return user; } + + // 단순 프로필 조회 메서드 (채팅용) - DB 조회만 수행 + public User getUserProfile(String userId) { + return userRepository.findByCognitoSub(userId).orElse(null); + } public String getPresignedProfileUrl(String s3Url) { if (s3Url == null || s3Url.isEmpty()) { diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 0c3dea39..225dc206 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1871,6 +1871,11 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref NewsTable + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + Resource: "*" Events: DailySchedule: Type: Schedule diff --git a/docs/frontend-notification-integration-guide.md b/docs/frontend-notification-integration-guide.md new file mode 100644 index 00000000..5c647bcf --- /dev/null +++ b/docs/frontend-notification-integration-guide.md @@ -0,0 +1,747 @@ +# 프론트엔드 실시간 알림 연동 가이드 + +## 개요 + +이 문서는 백엔드 알림 시스템과 프론트엔드를 연동하기 위한 가이드입니다. +**Server-Sent Events (SSE)** 를 사용하여 실시간 알림을 수신합니다. + +--- + +## 연결 방식 + +### SSE (Server-Sent Events) 사용 + +- WebSocket과 달리 **단방향 통신** (서버 → 클라이언트) +- HTTP 기반으로 별도 프로토콜 핸들링 불필요 +- 브라우저 `EventSource` API로 간단히 구현 가능 +- 연결 끊김 시 자동 재연결 지원 + +--- + +## 연결 엔드포인트 + +``` +GET {NOTIFICATION_FUNCTION_URL}?userId={userId} +``` + +| 파라미터 | 설명 | 예시 | +|---------|------|------| +| `userId` | 로그인한 사용자 ID | `user-123` | + +> ⚠️ **NOTIFICATION_FUNCTION_URL**은 배포 환경별로 다릅니다. 환경변수로 관리하세요. + +--- + +## 기본 연결 구현 + +### JavaScript (Vanilla) + +```javascript +const connectNotifications = (userId) => { + const url = `${NOTIFICATION_FUNCTION_URL}?userId=${userId}`; + const eventSource = new EventSource(url); + + // 알림 수신 + eventSource.onmessage = (event) => { + const notification = JSON.parse(event.data); + handleNotification(notification); + }; + + // 연결 성공 + eventSource.onopen = () => { + console.log('알림 연결 성공'); + }; + + // 에러 처리 + eventSource.onerror = (error) => { + console.error('알림 연결 에러:', error); + // EventSource는 자동으로 재연결을 시도합니다 + }; + + return eventSource; +}; + +// 연결 해제 +const disconnect = (eventSource) => { + eventSource.close(); +}; +``` + +### React Hook 예시 + +```typescript +import { useEffect, useCallback, useRef } from 'react'; + +interface Notification { + notificationId: string; + type: NotificationType; + userId: string; + payload: Record; + createdAt: string; +} + +type NotificationType = + | 'BADGE_EARNED' + | 'DAILY_COMPLETE' + | 'STREAK_REMINDER' + | 'TEST_COMPLETE' + | 'NEWS_QUIZ_COMPLETE' + | 'GAME_END' + | 'GAME_STREAK' + | 'OPIC_COMPLETE'; + +export const useNotifications = ( + userId: string | null, + onNotification: (notification: Notification) => void +) => { + const eventSourceRef = useRef(null); + + const connect = useCallback(() => { + if (!userId) return; + + const url = `${process.env.NEXT_PUBLIC_NOTIFICATION_URL}?userId=${userId}`; + const eventSource = new EventSource(url); + + eventSource.onmessage = (event) => { + // Heartbeat 무시 + if (event.data === 'HEARTBEAT') return; + + try { + const notification: Notification = JSON.parse(event.data); + onNotification(notification); + } catch (e) { + console.error('알림 파싱 실패:', e); + } + }; + + eventSource.onerror = () => { + console.log('알림 연결 끊김, 재연결 시도 중...'); + }; + + eventSourceRef.current = eventSource; + }, [userId, onNotification]); + + const disconnect = useCallback(() => { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + }, []); + + useEffect(() => { + connect(); + return () => disconnect(); + }, [connect, disconnect]); + + return { disconnect, reconnect: connect }; +}; +``` + +### React 컴포넌트 사용 예시 + +```tsx +const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { user } = useAuth(); + const [notifications, setNotifications] = useState([]); + + const handleNotification = useCallback((notification: Notification) => { + setNotifications(prev => [notification, ...prev]); + + // 타입별 처리 + switch (notification.type) { + case 'BADGE_EARNED': + showBadgeToast(notification.payload); + break; + case 'DAILY_COMPLETE': + showStreakCelebration(notification.payload); + break; + case 'GAME_END': + showGameResult(notification.payload); + break; + // ... 기타 타입 + } + }, []); + + useNotifications(user?.id ?? null, handleNotification); + + return ( + + {children} + + ); +}; +``` + +--- + +## 알림 타입 및 Payload 구조 + +### 공통 응답 구조 + +```typescript +interface Notification { + notificationId: string; // "notif-xxxxxxxx" 형식 + type: NotificationType; // 알림 타입 + userId: string; // 대상 사용자 ID + payload: object; // 타입별 상세 데이터 + createdAt: string; // ISO-8601 형식 (예: "2024-01-15T09:30:00Z") +} +``` + +--- + +### 1. BADGE_EARNED (배지 획득) + +사용자가 새로운 배지를 획득했을 때 + +```typescript +interface BadgeEarnedPayload { + badgeType: string; // 배지 타입 코드 + badgeName: string; // 배지 이름 + description: string; // 배지 설명 + iconUrl: string; // 배지 아이콘 URL +} +``` + +**예시:** +```json +{ + "notificationId": "notif-a1b2c3d4", + "type": "BADGE_EARNED", + "userId": "user-123", + "payload": { + "badgeType": "STREAK_7", + "badgeName": "7일 연속 학습", + "description": "7일 연속으로 학습을 완료했습니다!", + "iconUrl": "https://cdn.example.com/badges/streak-7.png" + }, + "createdAt": "2024-01-15T09:30:00Z" +} +``` + +--- + +### 2. DAILY_COMPLETE (일일 학습 완료) + +오늘의 단어 학습을 모두 완료했을 때 + +```typescript +interface DailyCompletePayload { + date: string; // 학습 완료 날짜 (YYYY-MM-DD) + wordsLearned: number; // 오늘 학습한 단어 수 + totalWords: number; // 총 학습 단어 수 + currentStreak: number; // 현재 연속 학습 일수 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-e5f6g7h8", + "type": "DAILY_COMPLETE", + "userId": "user-123", + "payload": { + "date": "2024-01-15", + "wordsLearned": 20, + "totalWords": 150, + "currentStreak": 5 + }, + "createdAt": "2024-01-15T14:00:00Z" +} +``` + +--- + +### 3. STREAK_REMINDER (연속 학습 리마인더) + +매일 21:00 KST에 오늘 학습을 아직 하지 않은 사용자에게 발송 + +```typescript +interface StreakReminderPayload { + currentStreak: number; // 현재 연속 학습 일수 + message: string; // 리마인더 메시지 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-i9j0k1l2", + "type": "STREAK_REMINDER", + "userId": "user-123", + "payload": { + "currentStreak": 5, + "message": "오늘 학습을 완료하고 6일 연속 학습을 달성하세요!" + }, + "createdAt": "2024-01-15T12:00:00Z" +} +``` + +--- + +### 4. TEST_COMPLETE (단어 테스트 완료) + +단어 테스트를 완료했을 때 + +```typescript +interface TestCompletePayload { + testId: string; // 테스트 ID + score: number; // 점수 (0-100) + correctCount: number; // 맞힌 문제 수 + totalCount: number; // 전체 문제 수 + isPerfect: boolean; // 만점 여부 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-m3n4o5p6", + "type": "TEST_COMPLETE", + "userId": "user-123", + "payload": { + "testId": "test-abc123", + "score": 85, + "correctCount": 17, + "totalCount": 20, + "isPerfect": false + }, + "createdAt": "2024-01-15T10:30:00Z" +} +``` + +--- + +### 5. NEWS_QUIZ_COMPLETE (뉴스 퀴즈 완료) + +뉴스 기사 퀴즈를 완료했을 때 + +```typescript +interface NewsQuizCompletePayload { + articleId: string; // 뉴스 기사 ID + articleTitle: string; // 기사 제목 + score: number; // 점수 (0-100) + correctCount: number; // 맞힌 문제 수 + totalCount: number; // 전체 문제 수 + isPerfect: boolean; // 만점 여부 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-q7r8s9t0", + "type": "NEWS_QUIZ_COMPLETE", + "userId": "user-123", + "payload": { + "articleId": "article-xyz789", + "articleTitle": "Tech Giants Report Strong Q4 Earnings", + "score": 100, + "correctCount": 5, + "totalCount": 5, + "isPerfect": true + }, + "createdAt": "2024-01-15T11:00:00Z" +} +``` + +--- + +### 6. GAME_END (게임 종료) + +캐치마인드 게임이 종료되었을 때 + +```typescript +interface GameEndPayload { + roomId: string; // 게임 방 ID + gameSessionId: string; // 게임 세션 ID + rank: number; // 최종 순위 + totalPlayers: number; // 전체 플레이어 수 + score: number; // 획득 점수 + isWinner: boolean; // 1등 여부 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-u1v2w3x4", + "type": "GAME_END", + "userId": "user-123", + "payload": { + "roomId": "room-game-001", + "gameSessionId": "session-abc", + "rank": 1, + "totalPlayers": 4, + "score": 2500, + "isWinner": true + }, + "createdAt": "2024-01-15T15:30:00Z" +} +``` + +--- + +### 7. GAME_STREAK (게임 연속 정답) + +게임 중 연속 정답을 달성했을 때 + +```typescript +interface GameStreakPayload { + roomId: string; // 게임 방 ID + streakCount: number; // 연속 정답 횟수 + bonusPoints: number; // 보너스 점수 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-y5z6a7b8", + "type": "GAME_STREAK", + "userId": "user-123", + "payload": { + "roomId": "room-game-001", + "streakCount": 5, + "bonusPoints": 500 + }, + "createdAt": "2024-01-15T15:25:00Z" +} +``` + +--- + +### 8. OPIC_COMPLETE (OPIc 연습 완료) + +OPIc 스피킹 연습 세션을 완료했을 때 + +```typescript +interface OpicCompletePayload { + sessionId: string; // 세션 ID + estimatedLevel: string; // 예상 등급 (IM1, IM2, IH, AL 등) + questionsAnswered: number; // 답변한 문제 수 + feedbackSummary: string; // 피드백 요약 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-c9d0e1f2", + "type": "OPIC_COMPLETE", + "userId": "user-123", + "payload": { + "sessionId": "opic-session-456", + "estimatedLevel": "IM2", + "questionsAnswered": 15, + "feedbackSummary": "발음과 유창성이 좋습니다. 문법적 정확성을 더 연습하세요." + }, + "createdAt": "2024-01-15T16:00:00Z" +} +``` + +--- + +## 특수 이벤트 + +### HEARTBEAT (하트비트) + +서버에서 연결 유지를 위해 1초마다 전송합니다. 무시하면 됩니다. + +```javascript +eventSource.onmessage = (event) => { + if (event.data === 'HEARTBEAT') return; // 무시 + // ... +}; +``` + +### STREAM_END (스트림 종료) + +서버가 연결을 종료할 때 전송됩니다. (최대 14분 후) +`EventSource`는 자동으로 재연결을 시도합니다. + +--- + +## 연결 관리 권장사항 + +### 1. 연결 시점 + +```typescript +// 로그인 후 연결 +const handleLoginSuccess = (user: User) => { + connectNotifications(user.id); +}; + +// 페이지 로드 시 (이미 로그인된 경우) +useEffect(() => { + if (isAuthenticated && user) { + connectNotifications(user.id); + } +}, [isAuthenticated, user]); +``` + +### 2. 연결 해제 시점 + +```typescript +// 로그아웃 시 +const handleLogout = () => { + disconnectNotifications(); + // ... +}; + +// 페이지 언마운트 시 (SPA) +useEffect(() => { + return () => disconnectNotifications(); +}, []); +``` + +### 3. 재연결 처리 + +`EventSource`는 연결 끊김 시 자동 재연결을 시도합니다. +추가적인 재연결 로직이 필요한 경우: + +```typescript +const MAX_RETRY_COUNT = 5; +let retryCount = 0; + +eventSource.onerror = () => { + retryCount++; + + if (retryCount >= MAX_RETRY_COUNT) { + eventSource.close(); + showErrorMessage('알림 서버 연결에 실패했습니다. 새로고침해주세요.'); + } +}; + +eventSource.onopen = () => { + retryCount = 0; // 연결 성공 시 초기화 +}; +``` + +--- + +## UI 처리 권장사항 + +### 토스트 알림 + +```typescript +const showNotificationToast = (notification: Notification) => { + const config = getToastConfig(notification.type); + + toast({ + title: config.title, + description: formatPayload(notification.payload), + icon: config.icon, + duration: config.duration, + }); +}; + +const getToastConfig = (type: NotificationType) => { + switch (type) { + case 'BADGE_EARNED': + return { title: '🏆 배지 획득!', icon: 'trophy', duration: 5000 }; + case 'DAILY_COMPLETE': + return { title: '✅ 오늘의 학습 완료!', icon: 'check', duration: 4000 }; + case 'STREAK_REMINDER': + return { title: '⏰ 학습 리마인더', icon: 'clock', duration: 6000 }; + case 'TEST_COMPLETE': + return { title: '📝 테스트 완료', icon: 'file', duration: 3000 }; + case 'GAME_END': + return { title: '🎮 게임 종료', icon: 'gamepad', duration: 4000 }; + default: + return { title: '알림', icon: 'bell', duration: 3000 }; + } +}; +``` + +### 알림 센터 + +```typescript +const NotificationCenter: React.FC = () => { + const { notifications } = useNotificationContext(); + const [unreadCount, setUnreadCount] = useState(0); + + return ( + + + + {unreadCount > 0 && } + + + {notifications.map(notif => ( + + ))} + + + ); +}; +``` + +--- + +## TypeScript 타입 정의 (복사용) + +```typescript +// types/notification.ts + +export type NotificationType = + | 'BADGE_EARNED' + | 'DAILY_COMPLETE' + | 'STREAK_REMINDER' + | 'TEST_COMPLETE' + | 'NEWS_QUIZ_COMPLETE' + | 'GAME_END' + | 'GAME_STREAK' + | 'OPIC_COMPLETE'; + +export interface BaseNotification { + notificationId: string; + type: T; + userId: string; + payload: P; + createdAt: string; +} + +export interface BadgeEarnedPayload { + badgeType: string; + badgeName: string; + description: string; + iconUrl: string; +} + +export interface DailyCompletePayload { + date: string; + wordsLearned: number; + totalWords: number; + currentStreak: number; +} + +export interface StreakReminderPayload { + currentStreak: number; + message: string; +} + +export interface TestCompletePayload { + testId: string; + score: number; + correctCount: number; + totalCount: number; + isPerfect: boolean; +} + +export interface NewsQuizCompletePayload { + articleId: string; + articleTitle: string; + score: number; + correctCount: number; + totalCount: number; + isPerfect: boolean; +} + +export interface GameEndPayload { + roomId: string; + gameSessionId: string; + rank: number; + totalPlayers: number; + score: number; + isWinner: boolean; +} + +export interface GameStreakPayload { + roomId: string; + streakCount: number; + bonusPoints: number; +} + +export interface OpicCompletePayload { + sessionId: string; + estimatedLevel: string; + questionsAnswered: number; + feedbackSummary: string; +} + +export type Notification = + | BaseNotification<'BADGE_EARNED', BadgeEarnedPayload> + | BaseNotification<'DAILY_COMPLETE', DailyCompletePayload> + | BaseNotification<'STREAK_REMINDER', StreakReminderPayload> + | BaseNotification<'TEST_COMPLETE', TestCompletePayload> + | BaseNotification<'NEWS_QUIZ_COMPLETE', NewsQuizCompletePayload> + | BaseNotification<'GAME_END', GameEndPayload> + | BaseNotification<'GAME_STREAK', GameStreakPayload> + | BaseNotification<'OPIC_COMPLETE', OpicCompletePayload>; +``` + +--- + +## 환경 설정 + +### 환경 변수 + +| 환경 | URL | +|------|-----| +| **Test** | `https://flhf42jd6xgrh26wrqgwxmbmee0zmjnv.lambda-url.ap-northeast-2.on.aws/` | +| **Prod** | (배포 후 업데이트 예정) | + +```env +# .env.local (Next.js) +NEXT_PUBLIC_NOTIFICATION_URL=https://flhf42jd6xgrh26wrqgwxmbmee0zmjnv.lambda-url.ap-northeast-2.on.aws + +# .env (Vite) +VITE_NOTIFICATION_URL=https://flhf42jd6xgrh26wrqgwxmbmee0zmjnv.lambda-url.ap-northeast-2.on.aws +``` + +--- + +## 테스트 방법 + +### 개발 환경에서 테스트 + +1. 브라우저 개발자 도구 → Network 탭 열기 +2. EventStream 필터 선택 +3. 로그인 후 알림 연결 확인 +4. 학습 완료, 테스트 제출 등의 액션 수행 +5. 실시간으로 알림 수신 확인 + +### Mock SSE 서버 (로컬 테스트용) + +```javascript +// mock-sse-server.js +const http = require('http'); + +http.createServer((req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*', + }); + + // 테스트 알림 전송 + setInterval(() => { + const notification = { + notificationId: `notif-${Date.now()}`, + type: 'BADGE_EARNED', + userId: 'test-user', + payload: { + badgeType: 'TEST_BADGE', + badgeName: '테스트 배지', + description: '테스트용 배지입니다', + iconUrl: 'https://example.com/badge.png', + }, + createdAt: new Date().toISOString(), + }; + res.write(`data: ${JSON.stringify(notification)}\n\n`); + }, 5000); +}).listen(3001); + +console.log('Mock SSE server running on http://localhost:3001'); +``` + +--- + +## 문의 + +백엔드 알림 시스템 관련 문의: **[백엔드 담당자 이름/연락처]** \ No newline at end of file diff --git a/docs/frontend-wordchain-guide.md b/docs/frontend-wordchain-guide.md new file mode 100644 index 00000000..5296aa09 --- /dev/null +++ b/docs/frontend-wordchain-guide.md @@ -0,0 +1,365 @@ +# 영어 끝말잇기(쿵쿵따) 프론트엔드 통합 가이드 + +## 개요 +영어 끝말잇기 게임 - 이전 단어의 마지막 글자로 시작하는 단어를 제출하는 게임 + +## REST API 엔드포인트 + +### 1. 게임 시작 +``` +POST /chat/rooms/{roomId}/wordchain/start +Authorization: Bearer {token} +``` + +**Response (성공):** +```json +{ + "success": true, + "message": "Word Chain game started", + "data": { + "sessionId": "uuid", + "gameStatus": "PLAYING", + "currentRound": 1, + "currentPlayerId": "user-id", + "currentWord": "apple", + "nextLetter": "e", + "timeLimit": 15, + "turnStartTime": 1706000000000, + "serverTime": 1706000000000, + "activePlayers": ["user1", "user2", "user3"], + "eliminatedPlayers": [], + "scores": {}, + "usedWords": ["apple"] + } +} +``` + +### 2. 단어 제출 +``` +POST /chat/rooms/{roomId}/wordchain/submit +Authorization: Bearer {token} +Content-Type: application/json + +{ + "word": "elephant" +} +``` + +**Response (정답):** +```json +{ + "success": true, + "message": "Correct!", + "data": { + "resultType": "CORRECT", + "word": "elephant", + "definition": "(noun) A large mammal with a trunk", + "phonetic": "/ˈɛləfənt/", + "score": 23, + "nextLetter": "t", + "nextPlayerId": "user2", + "nextTimeLimit": 15 + } +} +``` + +**Response (오답 - 첫 글자 틀림):** +```json +{ + "success": true, + "message": "Wrong answer", + "data": { + "resultType": "WRONG_LETTER", + "error": "'e'로 시작하는 단어를 입력하세요." + } +} +``` + +**Response (오답 - 사전에 없음):** +```json +{ + "success": true, + "message": "Wrong answer", + "data": { + "resultType": "INVALID_WORD", + "error": "사전에 없는 단어입니다: xyz" + } +} +``` + +### 3. 타임아웃 처리 +``` +POST /chat/rooms/{roomId}/wordchain/timeout +Authorization: Bearer {token} +``` + +### 4. 게임 종료 (시작자만) +``` +POST /chat/rooms/{roomId}/wordchain/stop +Authorization: Bearer {token} +``` + +### 5. 게임 상태 조회 +``` +GET /chat/rooms/{roomId}/wordchain/status +Authorization: Bearer {token} +``` + +--- + +## WebSocket 메시지 + +### Domain +```javascript +domain: "wordchain" +``` + +### 메시지 타입 + +| messageType | 설명 | +|-------------|------| +| `wordchain_start` | 게임 시작 | +| `wordchain_correct` | 정답 | +| `wordchain_wrong` | 오답 | +| `wordchain_timeout` | 시간 초과 (탈락) | +| `wordchain_end` | 게임 종료 | + +--- + +## WebSocket 메시지 상세 + +### 1. 게임 시작 (wordchain_start) +```json +{ + "domain": "wordchain", + "messageType": "wordchain_start", + "messageId": "uuid", + "roomId": "room-id", + "userId": "SYSTEM", + "content": "🎮 끝말잇기 시작!\n시작 단어: apple\n다음 글자: 'e'\n\n첫 번째 차례: user1\n제한 시간: 15초", + "createdAt": "2026-01-24T12:00:00Z", + "timestamp": 1706000000000, + "sessionId": "session-uuid", + "starterWord": "apple", + "nextLetter": "e", + "currentPlayerId": "user1", + "timeLimit": 15, + "turnStartTime": 1706000000000, + "serverTime": 1706000000000, + "players": ["user1", "user2", "user3"], + "activePlayers": ["user1", "user2", "user3"] +} +``` + +### 2. 정답 (wordchain_correct) +```json +{ + "domain": "wordchain", + "messageType": "wordchain_correct", + "messageId": "uuid", + "roomId": "room-id", + "userId": "SYSTEM", + "content": "✅ 닉네임: \"elephant\" (+23점)\n뜻: (noun) A large mammal\n다음 글자: 't'", + "createdAt": "2026-01-24T12:00:05Z", + "timestamp": 1706000005000, + "serverTime": 1706000005000, + "resultType": "CORRECT", + "word": "elephant", + "definition": "(noun) A large mammal with a trunk", + "phonetic": "/ˈɛləfənt/", + "score": 23, + "nextLetter": "t", + "nextPlayerId": "user2", + "nextTimeLimit": 15, + "playerNickname": "닉네임", + "turnStartTime": 1706000005000, + "scores": { + "user1": 23 + } +} +``` + +### 3. 오답 (wordchain_wrong) +```json +{ + "domain": "wordchain", + "messageType": "wordchain_wrong", + "messageId": "uuid", + "roomId": "room-id", + "userId": "SYSTEM", + "content": "❌ 사전에 없는 단어입니다: xyz", + "resultType": "INVALID_WORD", + "error": "사전에 없는 단어입니다: xyz" +} +``` + +### 4. 시간 초과 (wordchain_timeout) +```json +{ + "domain": "wordchain", + "messageType": "wordchain_timeout", + "messageId": "uuid", + "roomId": "room-id", + "userId": "SYSTEM", + "content": "⏰ 닉네임 시간 초과! 탈락!", + "resultType": "TIMEOUT", + "eliminatedPlayerId": "user1", + "eliminatedNickname": "닉네임", + "nextPlayerId": "user2", + "nextTimeLimit": 13, + "nextLetter": "e", + "turnStartTime": 1706000015000, + "activePlayers": ["user2", "user3"] +} +``` + +### 5. 게임 종료 (wordchain_end) +```json +{ + "domain": "wordchain", + "messageType": "wordchain_end", + "messageId": "uuid", + "roomId": "room-id", + "userId": "SYSTEM", + "content": "🏆 승자: 닉네임!", + "resultType": "GAME_END", + "winnerId": "user2", + "winnerNickname": "닉네임", + "ranking": [ + { "playerId": "user2", "nickname": "닉네임2", "score": 45, "eliminated": false }, + { "playerId": "user3", "nickname": "닉네임3", "score": 30, "eliminated": true }, + { "playerId": "user1", "nickname": "닉네임1", "score": 23, "eliminated": true } + ], + "usedWords": ["apple", "elephant", "tiger", "rainbow"], + "wordDefinitions": { + "apple": "(noun) A fruit", + "elephant": "(noun) A large mammal", + "tiger": "(noun) A large cat", + "rainbow": "(noun) An arc of colors" + }, + "scores": { + "user1": 23, + "user2": 45, + "user3": 30 + } +} +``` + +--- + +## 게임 규칙 + +### 시간 제한 (라운드별 감소) +| 라운드 | 시간 제한 | +|--------|----------| +| 1-2 | 15초 | +| 3-4 | 13초 | +| 5-6 | 11초 | +| 7-8 | 9초 | +| 9+ | 8초 | + +### 점수 계산 +``` +점수 = 기본점수(10) + 시간보너스 + 길이보너스 + +시간보너스 = 남은시간(초) +길이보너스 = (단어길이 - 4) × 2 (5글자 이상부터) +``` + +**예시:** +- 15초 제한에서 5초 만에 "elephant"(8글자) 제출 +- 점수 = 10 + 10 + 8 = 28점 + +### 게임 종료 조건 +- 1명만 남으면 게임 종료 +- 시작자가 `/stop` 호출 + +--- + +## 프론트엔드 구현 가이드 + +### 1. 타이머 동기화 +```javascript +// 서버 시간과 클라이언트 시간 차이 계산 +const serverTimeDiff = message.serverTime - Date.now(); + +// 남은 시간 계산 +const elapsed = Date.now() + serverTimeDiff - message.turnStartTime; +const remaining = (message.timeLimit * 1000) - elapsed; +``` + +### 2. WebSocket 메시지 핸들러 +```javascript +socket.onmessage = (event) => { + const message = JSON.parse(event.data); + + if (message.domain !== 'wordchain') return; + + switch (message.messageType) { + case 'wordchain_start': + handleGameStart(message); + break; + case 'wordchain_correct': + handleCorrectAnswer(message); + break; + case 'wordchain_wrong': + handleWrongAnswer(message); + break; + case 'wordchain_timeout': + handleTimeout(message); + break; + case 'wordchain_end': + handleGameEnd(message); + break; + } +}; +``` + +### 3. 타임아웃 자동 전송 +```javascript +// 내 턴일 때 타이머 만료 시 자동으로 타임아웃 API 호출 +if (isMyTurn && remaining <= 0) { + fetch(`/chat/rooms/${roomId}/wordchain/timeout`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` } + }); +} +``` + +### 4. UI 구성 요소 +- 현재 단어 표시 +- 다음 시작 글자 강조 +- 타이머 (남은 시간) +- 현재 차례 플레이어 표시 +- 활성/탈락 플레이어 목록 +- 점수판 +- 사용된 단어 목록 +- 단어 입력 필드 (본인 차례일 때만 활성화) + +### 5. 게임 종료 후 학습 화면 +```javascript +// 게임 종료 시 사용된 단어와 뜻 표시 +message.usedWords.forEach(word => { + const definition = message.wordDefinitions[word]; + console.log(`${word}: ${definition}`); +}); +``` + +--- + +## 에러 코드 + +| 코드 | 메시지 | +|------|--------| +| GAME_001 | 게임 시작에 실패했습니다 | +| GAME_002 | 게임 중단에 실패했습니다 | +| GAME_010 | 게임 액션 처리에 실패했습니다 | +| INPUT_001 | 유효하지 않은 입력입니다 | + +--- + +## 참고 + +- Dictionary API: [Free Dictionary API](https://dictionaryapi.dev/) +- 최소 인원: 2명 +- 시작 단어: 서버에서 랜덤 선택 (apple, house, water 등) From 309857b3f13992d93490e02a4f430fcbbc70c4e7 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sun, 25 Jan 2026 16:29:00 +0900 Subject: [PATCH 18/22] =?UTF-8?q?fix:=20test=20=ED=99=98=EA=B2=BD=20Cognit?= =?UTF-8?q?o=20User=20Pool=EC=9D=84=20prod=EC=99=80=20=EB=8F=99=EC=9D=BC?= =?UTF-8?q?=ED=95=98=EA=B2=8C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ServerlessFunction/buildspec-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ServerlessFunction/buildspec-test.yml b/ServerlessFunction/buildspec-test.yml index b74b041c..e00db1d6 100644 --- a/ServerlessFunction/buildspec-test.yml +++ b/ServerlessFunction/buildspec-test.yml @@ -43,7 +43,7 @@ phases: --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \ --no-confirm-changeset \ --no-fail-on-empty-changeset \ - --parameter-overrides Environment=$ENVIRONMENT + --parameter-overrides Environment=$ENVIRONMENT ExistingCognitoUserPoolId=ap-northeast-2_ezDwzFCzR ExistingCognitoClientId=4ns077jcr1pkue2vvisr6qdpu5 - echo "Deployment completed on $(date)" cache: From 4341f8e95150db1a384d4a736bb680a69d5476ee Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sun, 25 Jan 2026 17:30:01 +0900 Subject: [PATCH 19/22] fix: add Cognito parameters to CI/CD buildspec and wordchain turnTimeLimit support - Add ExistingCognitoUserPoolId and ExistingCognitoClientId to buildspec-dev.yml and buildspec-prod.yml - Add turnTimeLimit field to GameSettings for wordchain game - Add baseTurnTimeLimit to WordChainSession for dynamic time calculation - Update WordChainService to read turnTimeLimit from room's gameSettings --- ServerlessFunction/buildspec-dev.yml | 5 +++- ServerlessFunction/buildspec-prod.yml | 5 +++- .../domain/chatting/model/GameSettings.java | 7 ++++-- .../chatting/model/WordChainSession.java | 22 ++++++++++++++-- .../chatting/service/WordChainService.java | 25 ++++++++++++++++--- 5 files changed, 55 insertions(+), 9 deletions(-) diff --git a/ServerlessFunction/buildspec-dev.yml b/ServerlessFunction/buildspec-dev.yml index 78a12758..10571c8d 100644 --- a/ServerlessFunction/buildspec-dev.yml +++ b/ServerlessFunction/buildspec-dev.yml @@ -43,7 +43,10 @@ phases: --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \ --no-confirm-changeset \ --no-fail-on-empty-changeset \ - --parameter-overrides Environment=$ENVIRONMENT + --parameter-overrides \ + Environment=$ENVIRONMENT \ + ExistingCognitoUserPoolId=ap-northeast-2_ezDwzFCzR \ + ExistingCognitoClientId=4ns077jcr1pkue2vvisr6qdpu5 - echo "Deployment completed on $(date)" cache: diff --git a/ServerlessFunction/buildspec-prod.yml b/ServerlessFunction/buildspec-prod.yml index 0aa83787..6b8ed3e3 100644 --- a/ServerlessFunction/buildspec-prod.yml +++ b/ServerlessFunction/buildspec-prod.yml @@ -43,7 +43,10 @@ phases: --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \ --no-confirm-changeset \ --no-fail-on-empty-changeset \ - --parameter-overrides Environment=$ENVIRONMENT + --parameter-overrides \ + Environment=$ENVIRONMENT \ + ExistingCognitoUserPoolId=ap-northeast-2_ezDwzFCzR \ + ExistingCognitoClientId=4ns077jcr1pkue2vvisr6qdpu5 - echo "Deployment completed on $(date)" cache: diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java index d97f2fcc..7c2ae723 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java @@ -14,10 +14,13 @@ public class GameSettings { @Builder.Default private Integer maxRounds = 5; - + @Builder.Default private Integer roundTimeLimit = 60; - + + @Builder.Default + private Integer turnTimeLimit = 15; // 끝말잇기용 턴 시간 제한 + @Builder.Default private Boolean autoDeleteOnEnd = false; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSession.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSession.java index aa4e2c8d..62f49483 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSession.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSession.java @@ -40,6 +40,7 @@ public class WordChainSession { private Character nextLetter; // 다음 사람이 시작해야 할 글자 private Long turnStartTime; private Integer timeLimit; // 현재 라운드 시간 제한 (초) + private Integer baseTurnTimeLimit; // 사용자 설정 기본 턴 시간 (초) // 플레이어 관리 private List players; // 전체 플레이어 (순서대로) @@ -182,10 +183,27 @@ public String getWinner() { /** * 시간 제한 계산 (라운드에 따라 점점 빨라짐) - * Round 1-2: 15초, Round 3-4: 13초, Round 5-6: 11초, Round 7-8: 9초, Round 9+: 8초 + * 기본값 15초에서 시작하여 2라운드마다 2초씩 감소, 최소 8초 */ public static int calculateTimeLimit(int round) { - return Math.max(8, 15 - ((round - 1) / 2) * 2); + return calculateTimeLimit(round, 15); + } + + /** + * 시간 제한 계산 (기본 시간 제한 기준) + * 설정된 기본 시간에서 시작하여 2라운드마다 1초씩 감소, 최소 (baseTimeLimit / 2)초 + */ + public static int calculateTimeLimit(int round, int baseTimeLimit) { + int minTimeLimit = Math.max(5, baseTimeLimit / 2); + return Math.max(minTimeLimit, baseTimeLimit - ((round - 1) / 2)); + } + + /** + * 세션의 기본 시간 제한을 기준으로 다음 라운드 시간 계산 + */ + public int getNextRoundTimeLimit(int nextRound) { + int base = baseTurnTimeLimit != null ? baseTurnTimeLimit : 15; + return calculateTimeLimit(nextRound, base); } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/WordChainService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/WordChainService.java index c8298468..eb41e151 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/WordChainService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/WordChainService.java @@ -1,7 +1,10 @@ package com.mzc.secondproject.serverless.domain.chatting.service; +import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.domain.chatting.model.Connection; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSettings; import com.mzc.secondproject.serverless.domain.chatting.model.WordChainSession; +import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; import com.mzc.secondproject.serverless.domain.chatting.repository.WordChainSessionRepository; import com.mzc.secondproject.serverless.domain.user.model.User; @@ -29,6 +32,7 @@ public class WordChainService { private final WordChainSessionRepository sessionRepository; private final ConnectionRepository connectionRepository; + private final ChatRoomRepository chatRoomRepository; private final UserRepository userRepository; private final DictionaryService dictionaryService; private final Random random; @@ -36,16 +40,19 @@ public class WordChainService { public WordChainService() { this(new WordChainSessionRepository(), new ConnectionRepository(), + new ChatRoomRepository(), new UserRepository(), new DictionaryService()); } public WordChainService(WordChainSessionRepository sessionRepository, ConnectionRepository connectionRepository, + ChatRoomRepository chatRoomRepository, UserRepository userRepository, DictionaryService dictionaryService) { this.sessionRepository = sessionRepository; this.connectionRepository = connectionRepository; + this.chatRoomRepository = chatRoomRepository; this.userRepository = userRepository; this.dictionaryService = dictionaryService; this.random = new Random(); @@ -67,6 +74,17 @@ public GameStartResult startGame(String roomId, String userId) { return GameStartResult.error("최소 2명 이상 필요합니다."); } + // 방 정보에서 gameSettings 조회 + Optional optRoom = chatRoomRepository.findById(roomId); + int baseTurnTimeLimit = 15; // 기본값 + if (optRoom.isPresent()) { + ChatRoom room = optRoom.get(); + GameSettings settings = room.getGameSettings(); + if (settings != null && settings.getTurnTimeLimit() != null) { + baseTurnTimeLimit = settings.getTurnTimeLimit(); + } + } + // 플레이어 순서 랜덤 셔플 List players = connections.stream() .map(Connection::getUserId) @@ -81,7 +99,7 @@ public GameStartResult startGame(String roomId, String userId) { String sessionId = UUID.randomUUID().toString(); String now = Instant.now().toString(); long currentTime = System.currentTimeMillis(); - int timeLimit = WordChainSession.calculateTimeLimit(1); + int timeLimit = baseTurnTimeLimit; // 사용자 설정 턴 시간 사용 WordChainSession session = WordChainSession.builder() .pk("WORDCHAIN#" + sessionId) @@ -100,6 +118,7 @@ public GameStartResult startGame(String roomId, String userId) { .nextLetter(nextLetter) .turnStartTime(currentTime) .timeLimit(timeLimit) + .baseTurnTimeLimit(baseTurnTimeLimit) .players(players) .activePlayers(new ArrayList<>(players)) .eliminatedPlayers(new ArrayList<>()) @@ -171,7 +190,7 @@ public WordSubmitResult submitWord(String roomId, String userId, String word) { char nextLetter = normalizedWord.charAt(normalizedWord.length() - 1); String nextPlayerId = session.getNextPlayerId(); int nextRound = session.getCurrentRound() + 1; - int nextTimeLimit = WordChainSession.calculateTimeLimit(nextRound); + int nextTimeLimit = session.getNextRoundTimeLimit(nextRound); session.setCurrentRound(nextRound); session.setCurrentWord(normalizedWord); @@ -227,7 +246,7 @@ private WordSubmitResult handleTimeout(WordChainSession session, String userId) // 다음 턴 준비 String nextPlayerId = session.getNextPlayerId(); int nextRound = session.getCurrentRound() + 1; - int nextTimeLimit = WordChainSession.calculateTimeLimit(nextRound); + int nextTimeLimit = session.getNextRoundTimeLimit(nextRound); session.setCurrentRound(nextRound); session.setCurrentPlayerId(nextPlayerId); From 989686fcac20a1e7314fbe88b4a9525f557724fd Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sun, 25 Jan 2026 17:39:31 +0900 Subject: [PATCH 20/22] fix: update GameSettingsTest for turnTimeLimit field --- .../domain/chatting/model/GameSettingsTest.java | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettingsTest.java b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettingsTest.java index 9d608c90..0ac520b1 100644 --- a/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettingsTest.java +++ b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettingsTest.java @@ -10,6 +10,7 @@ void testDefaultValues() { GameSettings settings = GameSettings.builder().build(); assertEquals(5, settings.getMaxRounds()); assertEquals(60, settings.getRoundTimeLimit()); + assertEquals(15, settings.getTurnTimeLimit()); assertFalse(settings.getAutoDeleteOnEnd()); } @@ -18,10 +19,12 @@ void testCustomValues() { GameSettings settings = GameSettings.builder() .maxRounds(10) .roundTimeLimit(90) + .turnTimeLimit(20) .autoDeleteOnEnd(true) .build(); assertEquals(10, settings.getMaxRounds()); assertEquals(90, settings.getRoundTimeLimit()); + assertEquals(20, settings.getTurnTimeLimit()); assertTrue(settings.getAutoDeleteOnEnd()); } @@ -30,14 +33,16 @@ void testNoArgsConstructor() { GameSettings settings = new GameSettings(); assertEquals(5, settings.getMaxRounds()); assertEquals(60, settings.getRoundTimeLimit()); + assertEquals(15, settings.getTurnTimeLimit()); assertFalse(settings.getAutoDeleteOnEnd()); } - + @Test void testAllArgsConstructor() { - GameSettings settings = new GameSettings(10, 90, true); + GameSettings settings = new GameSettings(10, 90, 20, true); assertEquals(10, settings.getMaxRounds()); assertEquals(90, settings.getRoundTimeLimit()); + assertEquals(20, settings.getTurnTimeLimit()); assertTrue(settings.getAutoDeleteOnEnd()); } @@ -46,10 +51,12 @@ void testSettersAndGetters() { GameSettings settings = new GameSettings(); settings.setMaxRounds(8); settings.setRoundTimeLimit(120); + settings.setTurnTimeLimit(25); settings.setAutoDeleteOnEnd(true); - + assertEquals(8, settings.getMaxRounds()); assertEquals(120, settings.getRoundTimeLimit()); + assertEquals(25, settings.getTurnTimeLimit()); assertTrue(settings.getAutoDeleteOnEnd()); } } From 2db7a8f52699e3db05ae30a51de395a81ccca6c7 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sun, 25 Jan 2026 17:41:41 +0900 Subject: [PATCH 21/22] fix: update WordChainSessionSpec for new time calculation logic --- .../model/WordChainSessionSpec.groovy | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSessionSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSessionSpec.groovy index 0b87dc9b..396c6421 100644 --- a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSessionSpec.groovy +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSessionSpec.groovy @@ -10,18 +10,19 @@ class WordChainSessionSpec extends Specification { WordChainSession.calculateTimeLimit(round) == expected where: + // 기본 15초에서 2라운드마다 1초씩 감소, 최소 7초 (15/2) round | expected 1 | 15 2 | 15 - 3 | 13 - 4 | 13 - 5 | 11 - 6 | 11 - 7 | 9 - 8 | 9 - 9 | 8 - 10 | 8 - 20 | 8 + 3 | 14 + 4 | 14 + 5 | 13 + 6 | 13 + 7 | 12 + 8 | 12 + 9 | 11 + 10 | 11 + 20 | 7 // 최소값 } def "calculateScore: 기본 점수 계산"() { From 9431b8938344bcfa4c7613897f62ea1e86f1113c Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sun, 25 Jan 2026 18:14:41 +0900 Subject: [PATCH 22/22] fix: revert buildspec to build+package only, let Deploy stage handle deployment - Remove sam deploy from buildspec-test.yml and buildspec-prod.yml - CodeBuild only does sam build + sam package - Deploy stage handles CloudFormation deployment with Cognito parameters --- ServerlessFunction/buildspec-prod.yml | 26 ++++++++------------------ ServerlessFunction/buildspec-test.yml | 23 ++++++++--------------- 2 files changed, 16 insertions(+), 33 deletions(-) diff --git a/ServerlessFunction/buildspec-prod.yml b/ServerlessFunction/buildspec-prod.yml index 6b8ed3e3..25d01611 100644 --- a/ServerlessFunction/buildspec-prod.yml +++ b/ServerlessFunction/buildspec-prod.yml @@ -5,7 +5,6 @@ env: SAM_CLI_TELEMETRY: 0 GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" ENVIRONMENT: prod - STACK_NAME: group2-englishstudy-prod phases: install: @@ -28,26 +27,17 @@ phases: - echo "Building SAM application for $ENVIRONMENT..." - cd $CODEBUILD_SRC_DIR/ServerlessFunction - sam build --parallel --cached - - echo "Build completed" + - echo "Packaging SAM application..." + - sam package --s3-bucket group2-englishstudy-pipeline-artifacts --s3-prefix sam-packages/$ENVIRONMENT --output-template-file packaged-template.yaml post_build: commands: - - echo "Deploying to $ENVIRONMENT environment..." - - cd $CODEBUILD_SRC_DIR/ServerlessFunction - - | - sam deploy \ - --stack-name $STACK_NAME \ - --s3-bucket group2-englishstudy-pipeline-artifacts \ - --s3-prefix sam-deploy/prod \ - --region ap-northeast-2 \ - --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \ - --no-confirm-changeset \ - --no-fail-on-empty-changeset \ - --parameter-overrides \ - Environment=$ENVIRONMENT \ - ExistingCognitoUserPoolId=ap-northeast-2_ezDwzFCzR \ - ExistingCognitoClientId=4ns077jcr1pkue2vvisr6qdpu5 - - echo "Deployment completed on $(date)" + - echo "Build completed on $(date)" + +artifacts: + files: + - packaged-template.yaml + base-directory: ServerlessFunction cache: paths: diff --git a/ServerlessFunction/buildspec-test.yml b/ServerlessFunction/buildspec-test.yml index e00db1d6..6d75e4f0 100644 --- a/ServerlessFunction/buildspec-test.yml +++ b/ServerlessFunction/buildspec-test.yml @@ -5,7 +5,6 @@ env: SAM_CLI_TELEMETRY: 0 GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" ENVIRONMENT: test - STACK_NAME: group2-englishstudy-test phases: install: @@ -28,23 +27,17 @@ phases: - echo "Building SAM application for $ENVIRONMENT..." - cd $CODEBUILD_SRC_DIR/ServerlessFunction - sam build --parallel --cached - - echo "Build completed" + - echo "Packaging SAM application..." + - sam package --s3-bucket group2-englishstudy-pipeline-artifacts --s3-prefix sam-packages/$ENVIRONMENT --output-template-file packaged-template.yaml post_build: commands: - - echo "Deploying to $ENVIRONMENT environment..." - - cd $CODEBUILD_SRC_DIR/ServerlessFunction - - | - sam deploy \ - --stack-name $STACK_NAME \ - --resolve-s3 \ - --s3-prefix $STACK_NAME \ - --region ap-northeast-2 \ - --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \ - --no-confirm-changeset \ - --no-fail-on-empty-changeset \ - --parameter-overrides Environment=$ENVIRONMENT ExistingCognitoUserPoolId=ap-northeast-2_ezDwzFCzR ExistingCognitoClientId=4ns077jcr1pkue2vvisr6qdpu5 - - echo "Deployment completed on $(date)" + - echo "Build completed on $(date)" + +artifacts: + files: + - packaged-template.yaml + base-directory: ServerlessFunction cache: paths: