From 9a3fe6f6d0583551cc7d6a42645f2054d6ce7df7 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 12:34:19 +0900 Subject: [PATCH 1/5] feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords --- .../domain/news/handler/NewsHandler.java | 59 +++++++++++++++++-- .../domain/news/model/KeywordInfo.java | 1 + .../news/repository/UserNewsRepository.java | 13 ++++ .../news/service/NewsAnalysisService.java | 13 ++-- .../news/service/NewsLearningService.java | 23 +++++++- 5 files changed, 99 insertions(+), 10 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java index 86a30590..30d0d3e7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -111,7 +111,7 @@ private APIGatewayProxyResponseEvent getNewsList(APIGatewayProxyRequestEvent req result = queryService.getTodayNews(limit, cursor); } - return buildPaginatedResponse(result); + return buildPaginatedResponse(result, getUserId(request)); } /** @@ -126,7 +126,7 @@ private APIGatewayProxyResponseEvent getTodayNews(APIGatewayProxyRequestEvent re int limit = parseLimit(params.get("limit")); PaginatedResult result = queryService.getTodayNews(limit, cursor); - return buildPaginatedResponse(result); + return buildPaginatedResponse(result, getUserId(request)); } /** @@ -143,7 +143,7 @@ private APIGatewayProxyResponseEvent getRecommendedNews(APIGatewayProxyRequestEv int limit = parseLimit(params.get("limit")); PaginatedResult result = queryService.getRecommendedNews(userLevel, limit, cursor); - return buildPaginatedResponse(result); + return buildPaginatedResponse(result, getUserId(request)); } /** @@ -158,15 +158,64 @@ private APIGatewayProxyResponseEvent getNewsDetail(APIGatewayProxyRequestEvent r return ResponseGenerator.fail(NewsErrorCode.ARTICLE_NOT_FOUND); } - return ResponseGenerator.ok("뉴스 조회 성공", article.get()); + // 로그인한 사용자의 경우 북마크/읽기 상태 추가 + String userId = getUserId(request); + Map response = new HashMap<>(); + response.put("article", article.get()); + + if (userId != null) { + response.put("isBookmarked", learningService.isBookmarked(userId, articleId)); + response.put("isRead", learningService.hasRead(userId, articleId)); + } else { + response.put("isBookmarked", false); + response.put("isRead", false); + } + + return ResponseGenerator.ok("뉴스 조회 성공", response); } /** * 페이지네이션 응답 생성 */ private APIGatewayProxyResponseEvent buildPaginatedResponse(PaginatedResult result) { + return buildPaginatedResponse(result, null); + } + + /** + * 페이지네이션 응답 생성 (북마크 상태 포함) + */ + private APIGatewayProxyResponseEvent buildPaginatedResponse(PaginatedResult result, String userId) { + List> articlesWithStatus = new java.util.ArrayList<>(); + java.util.Set bookmarkedIds = java.util.Collections.emptySet(); + + // 로그인한 사용자의 경우 북마크 상태 조회 + if (userId != null && !result.items().isEmpty()) { + List articleIds = result.items().stream() + .map(NewsArticle::getArticleId) + .toList(); + bookmarkedIds = learningService.getBookmarkedArticleIds(userId, articleIds); + } + + for (NewsArticle article : result.items()) { + Map articleWithStatus = new HashMap<>(); + articleWithStatus.put("articleId", article.getArticleId()); + articleWithStatus.put("title", article.getTitle()); + articleWithStatus.put("summary", article.getSummary()); + articleWithStatus.put("source", article.getSource()); + articleWithStatus.put("publishedAt", article.getPublishedAt()); + articleWithStatus.put("keywords", article.getKeywords()); + articleWithStatus.put("highlightWords", article.getHighlightWords()); + articleWithStatus.put("category", article.getCategory()); + articleWithStatus.put("level", article.getLevel()); + articleWithStatus.put("cefrLevel", article.getCefrLevel()); + articleWithStatus.put("imageUrl", article.getImageUrl()); + articleWithStatus.put("readCount", article.getReadCount()); + articleWithStatus.put("isBookmarked", bookmarkedIds.contains(article.getArticleId())); + articlesWithStatus.add(articleWithStatus); + } + Map response = new HashMap<>(); - response.put("articles", result.items()); + response.put("articles", articlesWithStatus); response.put("nextCursor", result.nextCursor()); response.put("hasMore", result.hasMore()); response.put("count", result.items().size()); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java index cd5b4b44..c1e00f56 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java @@ -19,6 +19,7 @@ public class KeywordInfo { private String word; // 영어 단어 private String meaning; // 영어 뜻 (간단한 정의) + private String meaningKo; // 한국어 뜻 private String example; // 기사에서 발췌한 예문 private String level; // 단어 난이도 (BEGINNER, INTERMEDIATE, ADVANCED) private Integer position; // 기사 내 위치 (문장 번호 또는 단어 인덱스) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java index 25f8e651..a5fa2b67 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java @@ -145,6 +145,19 @@ public List getUserBookmarks(String userId, int limit) { return results.subList(0, Math.min(results.size(), limit)); } + /** + * 여러 기사의 북마크 여부 확인 (배치) + */ + public Set getBookmarkedArticleIds(String userId, List articleIds) { + Set bookmarkedIds = new HashSet<>(); + for (String articleId : articleIds) { + if (isBookmarked(userId, articleId)) { + bookmarkedIds.add(articleId); + } + } + return bookmarkedIds; + } + /** * 사용자 뉴스 통계 조회 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java index 83a4fc30..bf8bf449 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java @@ -185,14 +185,14 @@ private List extractKeywords(String content) { */ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) { String systemPrompt = """ - You are an English learning assistant. Analyze the news article and create learning materials. + You are an English learning assistant for Korean learners. Analyze the news article and create learning materials. Respond in this exact JSON format: { "summary": "3-line summary in English (each line separated by newline)", "keywords": [ - {"word": "economy", "meaning": "the system of trade and industry", "example": "The economy is growing steadily."}, - {"word": "policy", "meaning": "a plan of action adopted by government", "example": "The new policy affects all citizens."} + {"word": "economy", "meaning": "the system of trade and industry", "meaningKo": "경제", "example": "The economy is growing steadily."}, + {"word": "policy", "meaning": "a plan of action adopted by government", "meaningKo": "정책", "example": "The new policy affects all citizens."} ], "highlightWords": ["word1", "word2", "word3"], "category": "WORLD", @@ -225,7 +225,11 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) } IMPORTANT: - - keywords: Extract 5-8 important vocabulary words from the article. Include word, meaning (simple definition), and example sentence from the article. + - keywords: Extract 5-8 important vocabulary words from the article. Include: + - word: the English word + - meaning: simple English definition + - meaningKo: Korean translation of the word (한국어 뜻) + - example: example sentence from the article - highlightWords: 3-5 difficult words that learners should pay attention to (just the words, no definitions). - category: Choose EXACTLY ONE from: WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE - Create exactly 3 quiz questions. @@ -294,6 +298,7 @@ private AnalysisResult parseAnalysisResult(String response) { keywords.add(KeywordInfo.builder() .word(k.has("word") ? k.get("word").getAsString() : "") .meaning(k.has("meaning") ? k.get("meaning").getAsString() : "") + .meaningKo(k.has("meaningKo") ? k.get("meaningKo").getAsString() : "") .example(k.has("example") ? k.get("example").getAsString() : "") .build()); }); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java index c46e4fc6..208339ee 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java @@ -17,6 +17,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; /** * 뉴스 학습 부가 기능 서비스 @@ -63,6 +64,12 @@ public List markAsRead(String userId, String articleId) { return new ArrayList<>(); } + // 이미 읽은 기사인지 확인 (중복 조회수 증가 방지) + if (userNewsRepository.hasRead(userId, articleId)) { + logger.debug("이미 읽은 기사: userId={}, articleId={}", userId, articleId); + return new ArrayList<>(); + } + NewsArticle a = article.get(); userNewsRepository.saveReadRecord( userId, @@ -72,7 +79,7 @@ public List markAsRead(String userId, String articleId) { a.getCategory() ); - // 조회수 증가 + // 조회수 증가 (새로운 읽기만) String date = extractDateFromPk(a.getPk()); if (date != null) { articleRepository.incrementReadCount(date, articleId); @@ -135,6 +142,20 @@ public boolean isBookmarked(String userId, String articleId) { return userNewsRepository.isBookmarked(userId, articleId); } + /** + * 읽기 여부 확인 + */ + public boolean hasRead(String userId, String articleId) { + return userNewsRepository.hasRead(userId, articleId); + } + + /** + * 여러 기사의 북마크 여부 확인 (배치) + */ + public Set getBookmarkedArticleIds(String userId, List articleIds) { + return userNewsRepository.getBookmarkedArticleIds(userId, articleIds); + } + /** * 사용자 북마크 목록 조회 (기사 정보 포함) */ From a67d15fe0d40dabf2e61ad903edff519888a2c1d Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Fri, 23 Jan 2026 14:27:13 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix=20:=20sessionId=20NullPointerException?= =?UTF-8?q?=20=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95=20=20(#496)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feat : handleChat 메서드 JsonNull 체크 푸가 * refactor : session_id가 null 체크 추가 * feat : template 환경변수 리펙토링 --------- Co-authored-by: DDING JOO --- ServerlessFunction/gradlew | 28 +- ServerlessFunction/gradlew.bat | 25 +- .../{websocket => }/SpeakingHandler.java | 9 +- .../speaking/service/SpeakingService.java | 19 +- .../user/dto/response/ProfileResponse.java | 2 +- .../domain/user/handler/UserHandler.java | 15 +- .../serverless/domain/user/model/User.java | 11 +- .../domain/user/service/UserService.java | 59 +- ServerlessFunction/template.yaml | 553 +++++------------- 9 files changed, 258 insertions(+), 463 deletions(-) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/{websocket => }/SpeakingHandler.java (97%) diff --git a/ServerlessFunction/gradlew b/ServerlessFunction/gradlew index adff685a..fcb6fca1 100755 --- a/ServerlessFunction/gradlew +++ b/ServerlessFunction/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015 the original authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,8 +15,6 @@ # See the License for the specific language governing permissions and # limitations under the License. # -# SPDX-License-Identifier: Apache-2.0 -# ############################################################################## # @@ -57,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -85,8 +83,7 @@ done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -114,6 +111,7 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -146,7 +144,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 + # shellcheck disable=SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac @@ -154,7 +152,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 + # shellcheck disable=SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -171,6 +169,7 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -202,15 +201,16 @@ fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ "$@" # Stop when "xargs" is not available. diff --git a/ServerlessFunction/gradlew.bat b/ServerlessFunction/gradlew.bat index c4bdd3ab..93e3f59f 100644 --- a/ServerlessFunction/gradlew.bat +++ b/ServerlessFunction/gradlew.bat @@ -13,8 +13,6 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @@ -45,11 +43,11 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. goto fail @@ -59,21 +57,22 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. goto fail :execute @rem Setup the command line +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java similarity index 97% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java index c515f950..90e041a7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; +package com.mzc.secondproject.serverless.domain.speaking.handler; import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; @@ -9,6 +9,7 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.mzc.secondproject.serverless.common.util.JwtUtil; +import com.mzc.secondproject.serverless.domain.speaking.dto.response.SpeakingResponse; import com.mzc.secondproject.serverless.domain.speaking.service.SpeakingService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -102,7 +103,7 @@ private APIGatewayProxyResponseEvent handleChat(String userId, String body) { String audio = request.has("audio") ? request.get("audio").getAsString() : null; String text = request.has("text") ? request.get("text").getAsString() : null; - SpeakingService.SpeakingResponse result; + SpeakingResponse result; if (audio != null && !audio.isEmpty()) { // 음성 입력 처리 @@ -134,7 +135,7 @@ private APIGatewayProxyResponseEvent handleReset(String userId, String body) { } JsonObject request = JsonParser.parseString(body).getAsJsonObject(); - String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; + String sessionId = getStringOrNull(request, "sessionId"); if (sessionId == null || sessionId.isEmpty()) { return response(400, Map.of("error", "sessionId is required")); @@ -176,4 +177,6 @@ private APIGatewayProxyResponseEvent response(int statusCode, Map parseHistory(String historyJson) { return history; } + /** * 히스토리 JSON 변환 */ @@ -328,18 +330,9 @@ private String toJson(List history) { return array.toString(); } - // ==================== Inner Classes ==================== - - private record Message(String role, String content) {} - /** - * Speaking 응답 DTO + * 대화 메시지 (히스토리용) */ - public record SpeakingResponse( - String sessionId, // 세션 ID (다음 요청에 사용) - String userTranscript, // 사용자가 말한 내용 (STT 결과) - String aiText, // AI 응답 텍스트 - String aiAudioUrl, // AI 응답 음성 URL (Polly) - double confidence // STT 신뢰도comp - ) {} + private record Message(String role, String content) {} + } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ProfileResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ProfileResponse.java index bdc1ced7..7f17bd4a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ProfileResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ProfileResponse.java @@ -29,7 +29,7 @@ public static ProfileResponse from(User user) { .email(user.getEmail()) .nickname(user.getNickname()) .level(user.getLevel()) - .profileUrl(user.getProfileUrl()) + .profileUrl(user.getProfileUrlForResponse()) .createdAt(user.getCreatedAt()) .updatedAt(user.getUpdatedAt()) .build(); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java index b4fc9aea..9ad8618f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java @@ -59,9 +59,20 @@ private APIGatewayProxyResponseEvent getMyProfile( APIGatewayProxyRequestEvent request, String userId // cognitoSub ) { - User user = userService.getProfile(userId, request); - ProfileResponse response = ProfileResponse.from(user); + + // profileUrl을 Presigned URL로 변환 + String presignedUrl = userService.getPresignedProfileUrl(user.getProfileUrl()); + + ProfileResponse response = ProfileResponse.builder() + .userId(user.getCognitoSub()) + .email(user.getEmail()) + .nickname(user.getNickname()) + .level(user.getLevel()) + .profileUrl(presignedUrl) // Presigned URL 사용 + .createdAt(user.getCreatedAt()) + .updatedAt(user.getUpdatedAt()) + .build(); return ResponseGenerator.ok(user.getNickname() + " 환영합니다!", response); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java index 344d77e4..60dc6be5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java @@ -27,11 +27,13 @@ public class User { private String nickname; private String level; private String profileUrl; + private String profileUrlForResponse; private String createdAt; private String updatedAt; private String lastLoginAt; private Long ttl; - + + /** * 신규 사용자 생성 * - Lazy Registration 적용: 최초 프로필 조회 시 DynamoDB에 저장 @@ -114,7 +116,12 @@ public void updateProfileUrl(String newProfileUrl) { this.profileUrl = newProfileUrl; this.updatedAt = Instant.now().toString(); } - + + @DynamoDbIgnore + public String getProfileUrlForResponse() { + return profileUrlForResponse != null ? profileUrlForResponse : profileUrl; + } + public void updateLastLoginAt() { this.lastLoginAt = Instant.now().toString(); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java index 2421f118..6783c42b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java @@ -7,8 +7,10 @@ import com.mzc.secondproject.serverless.domain.user.repository.UserRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; @@ -52,18 +54,53 @@ public UserService(UserRepository userRepository) { * @return User 객체 */ public User getProfile(String userId, APIGatewayProxyRequestEvent request) { - - return userRepository.findByCognitoSub(userId) - .map(user -> { - // 정상 DB에서 조회 완료 - user.updateLastLoginAt(); - userRepository.update(user); - return user; + + User user = userRepository.findByCognitoSub(userId) + .map(u -> { + u.updateLastLoginAt(); + userRepository.update(u); + return u; }) - .orElseGet(() -> { - // PostConfirmation 실패 대비 fallback - return createUserFromRequest(userId, request); - }); + .orElseGet(() -> createUserFromRequest(userId, request)); + + // 프로필 URL을 Presigned URL로 변환 + String presignedProfileUrl = getPresignedProfileUrl(user.getProfileUrl()); + user.setProfileUrlForResponse(presignedProfileUrl); // 응답용으로만 설정 + + return user; + } + + public String getPresignedProfileUrl(String s3Url) { + if (s3Url == null || s3Url.isEmpty()) { + return generateGetPresignedUrl("profile/default.png"); + } + String key = extractKeyFromS3Url(s3Url); + return generateGetPresignedUrl(key); + } + + private String generateGetPresignedUrl(String imageKey) { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(BUCKET_NAME) + .key(imageKey) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofHours(24)) + .getObjectRequest(getObjectRequest) + .build(); + + return s3Presigner.presignGetObject(presignRequest).url().toString(); + } + + + private String extractKeyFromS3Url(String s3Url) { + // https://group2-englishstudy.s3.amazonaws.com/profile/user123/img.png + // → profile/user123/img.png + String prefix = String.format("https://%s.s3.amazonaws.com/", BUCKET_NAME); + if (s3Url.startsWith(prefix)) { + return s3Url.substring(prefix.length()); + } + return s3Url; } /** diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 97fe03e6..0f64417e 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -2,25 +2,6 @@ AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Description: Group2 English Study - Unified API (Chatting + Vocabulary) -Parameters: - Environment: - Type: String - Default: dev - AllowedValues: - - dev - - test - - prod - Description: Deployment environment - - ExistingCognitoUserPoolId: - Type: String - Default: "" - Description: Existing Cognito User Pool ID (leave empty to create new) - - ExistingCognitoClientId: - Type: String - Description: Existing Cognito User Pool Client ID - Globals: Function: Timeout: 30 @@ -35,13 +16,11 @@ Globals: CHAT_TABLE_NAME: !Ref ChatTable VOCAB_TABLE_NAME: !Ref VocabTable OPIC_TABLE_NAME: !Ref OPIcTable - NEWS_TABLE_NAME: !Ref NewsTable - BUCKET_NAME: !Sub "${AWS::StackName}" - CHAT_BUCKET_NAME: !Sub "${AWS::StackName}" - VOCAB_BUCKET_NAME: !Sub "${AWS::StackName}" - PROFILE_BUCKET_NAME: !Sub "${AWS::StackName}" - OPIC_BUCKET_NAME: !Sub "${AWS::StackName}" - NEWS_BUCKET_NAME: !Sub "${AWS::StackName}" + BUCKET_NAME: group2-englishstudy + CHAT_BUCKET_NAME: group2-englishstudy + VOCAB_BUCKET_NAME: group2-englishstudy + PROFILE_BUCKET_NAME: group2-englishstudy + OPIC_BUCKET_NAME: group2-englishstudy AWS_REGION_NAME: !Ref AWS::Region ROOM_TOKEN_TTL_SECONDS: "300" TRANSCRIBE_PROXY_URL: "https://tfo1zm7vec.execute-api.ap-northeast-2.amazonaws.com/prod/transcribe" @@ -50,10 +29,65 @@ Globals: Resources: ############################################# - # Cognito - Using Existing User Pool - # (Cognito resources are managed in group2-englishstudy-chatting stack) + # Cognito User Pool ############################################# + CognitoUserPool: + Type: AWS::Cognito::UserPool + DeletionPolicy: Retain + # UpdateReplacePolicy: Retain + Properties: + UserPoolName: !Sub "${AWS::StackName}-userpool" + UsernameAttributes: + - email + AutoVerifiedAttributes: + - email + Policies: + PasswordPolicy: + MinimumLength: 8 + RequireLowercase: true + RequireNumbers: true + RequireSymbols: true + RequireUppercase: false + # Cognito에 저장할 사용자 정보 정의 ≈ 회원 테이블 컬럼 + Schema: + - Name: email + AttributeDataType: String + Required: true + Mutable: true + - Name: nickname + AttributeDataType: String + Required: false + Mutable: true + - Name: level + AttributeDataType: String + Required: false + Mutable: true + - Name: profileUrl + AttributeDataType: String + Required: false + Mutable: true + LambdaConfig: + PreSignUp: !GetAtt PreSignUpFunction.Arn + PostConfirmation: !GetAtt PostConfirmationFunction.Arn + + # Cognito에게 Lambda 호출 권한 부여 + PreSignUpPermission: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: !Ref PreSignUpFunction + Principal: cognito-idp.amazonaws.com + SourceArn: !Sub arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/* + + PostConfirmationPermission: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: !GetAtt PostConfirmationFunction.Arn + Principal: cognito-idp.amazonaws.com + SourceArn: !GetAtt CognitoUserPool.Arn + # 사용자 custom 속성들 기본값 설정 Lambda 함수 PreSignUpFunction: Type: AWS::Serverless::Function @@ -66,7 +100,7 @@ Resources: Timeout: 10 Environment: Variables: - DEFAULT_PROFILE_URL: !Sub "https://${AWS::StackName}.s3.amazonaws.com/profile/default.png" + DEFAULT_PROFILE_URL: https://group2-englishstudy.s3.amazonaws.com/profile/default.png # 회원가입 시점에 사용자 모든 정보가 DB에 저장 Lambda 함수 PostConfirmationFunction: @@ -84,6 +118,18 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref UserTable + CognitoUserPoolClient: + Type: AWS::Cognito::UserPoolClient + Properties: + ClientName: !Sub "${AWS::StackName}-client" + UserPoolId: !Ref CognitoUserPool + GenerateSecret: false + ExplicitAuthFlows: + - ALLOW_USER_SRP_AUTH + - ALLOW_REFRESH_TOKEN_AUTH + - ALLOW_USER_PASSWORD_AUTH + PreventUserExistenceErrors: ENABLED + ############################################# # API Gateway (Unified) ############################################# @@ -91,11 +137,11 @@ Resources: MainApi: Type: AWS::Serverless::Api Properties: - Name: !Sub "${AWS::StackName}-api" - StageName: !Ref Environment + Name: group2-englishstudy-api + StageName: dev Cors: - AllowMethods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" - AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Requested-With,Accept'" + AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" + AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" AllowOrigin: "'*'" AllowCredentials: false GatewayResponses: @@ -105,7 +151,7 @@ Resources: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" ResponseTemplates: application/json: '{"message": "Unauthorized", "statusCode": 401}' ACCESS_DENIED: @@ -114,7 +160,7 @@ Resources: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" ResponseTemplates: application/json: '{"message": "Access Denied", "statusCode": 403}' DEFAULT_4XX: @@ -122,28 +168,27 @@ Resources: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" DEFAULT_5XX: ResponseParameters: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" EXPIRED_TOKEN: StatusCode: 401 ResponseParameters: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" ResponseTemplates: application/json: '{"message": "Token expired", "statusCode": 401}' Auth: DefaultAuthorizer: CognitoAuthorizer - AddDefaultAuthorizerToCorsPreflight: false Authorizers: CognitoAuthorizer: - UserPoolArn: !Sub "arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/${ExistingCognitoUserPoolId}" + UserPoolArn: !GetAtt CognitoUserPool.Arn Identity: Header: Authorization @@ -154,7 +199,7 @@ Resources: WebSocketApi: Type: AWS::ApiGatewayV2::Api Properties: - Name: !Sub "${AWS::StackName}-websocket" + Name: group2-englishstudy-websocket ProtocolType: WEBSOCKET RouteSelectionExpression: "$request.body.action" @@ -162,7 +207,7 @@ Resources: Type: AWS::ApiGatewayV2::Stage Properties: ApiId: !Ref WebSocketApi - StageName: !Ref Environment + StageName: dev AutoDeploy: true # WebSocket Connect Route @@ -220,7 +265,7 @@ Resources: WebSocketConnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-ws-connect" + FunctionName: group2-englishstudy-ws-connect CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.websocket.WebSocketConnectHandler::handleRequest Description: Handle WebSocket $connect @@ -229,7 +274,7 @@ Resources: Environment: Variables: WEBSOCKET_CONNECTION_TTL_SECONDS: "600" - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -250,7 +295,7 @@ Resources: WebSocketDisconnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-ws-disconnect" + FunctionName: group2-englishstudy-ws-disconnect CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.websocket.WebSocketDisconnectHandler::handleRequest Description: Handle WebSocket $disconnect @@ -258,7 +303,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -279,7 +324,7 @@ Resources: WebSocketMessageFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-ws-message" + FunctionName: group2-englishstudy-ws-message CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.websocket.WebSocketMessageHandler::handleRequest Description: Handle WebSocket sendMessage @@ -287,7 +332,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" GAME_AUTO_CLOSE_LAMBDA_ARN: !GetAtt GameAutoCloseFunction.Arn SCHEDULER_ROLE_ARN: !GetAtt GameSchedulerRole.Arn Policies: @@ -307,7 +352,7 @@ Resources: - scheduler:CreateSchedule - scheduler:DeleteSchedule - scheduler:GetSchedule - Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/${AWS::StackName}-game-auto-close/*" + Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/game-auto-close/*" - Statement: - Effect: Allow Action: @@ -339,7 +384,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref UserTable - S3CrudPolicy: - BucketName: !Sub "${AWS::StackName}" + BucketName: group2-englishstudy Events: GetMyProfile: Type: Api @@ -367,7 +412,7 @@ Resources: ChatRoomFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-chat-room-handler" + FunctionName: group2-englishstudy-chat-room-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatRoomHandler::handleRequest Description: Handle chat room CRUD operations @@ -375,7 +420,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -439,7 +484,7 @@ Resources: GameFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-game-handler" + FunctionName: group2-englishstudy-game-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.GameHandler::handleRequest Description: Handle catch-mind game operations @@ -447,7 +492,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" GAME_AUTO_CLOSE_LAMBDA_ARN: !GetAtt GameAutoCloseFunction.Arn SCHEDULER_ROLE_ARN: !GetAtt GameSchedulerRole.Arn Policies: @@ -467,7 +512,7 @@ Resources: - scheduler:CreateSchedule - scheduler:DeleteSchedule - scheduler:GetSchedule - Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/${AWS::StackName}-game-auto-close/*" + Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/game-auto-close/*" - Statement: - Effect: Allow Action: @@ -511,7 +556,7 @@ Resources: GameAutoCloseFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-game-auto-close" + FunctionName: group2-englishstudy-game-auto-close CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.GameAutoCloseHandler::handleRequest Description: Auto-close game after 7 minutes @@ -521,7 +566,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -557,12 +602,12 @@ Resources: GameScheduleGroup: Type: AWS::Scheduler::ScheduleGroup Properties: - Name: !Sub "${AWS::StackName}-game-auto-close" + Name: game-auto-close ChatMessageFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-chat-message-handler" + FunctionName: group2-englishstudy-chat-message-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatMessageHandler::handleRequest Description: Handle chat messages @@ -572,7 +617,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref ChatTable - S3CrudPolicy: - BucketName: !Sub "${AWS::StackName}" + BucketName: group2-englishstudy - Statement: - Effect: Allow Action: @@ -614,7 +659,7 @@ Resources: ChatVoiceFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-chat-voice-handler" + FunctionName: group2-englishstudy-chat-voice-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatVoiceHandler::handleRequest Description: Convert text to speech using Polly @@ -624,7 +669,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref ChatTable - S3CrudPolicy: - BucketName: !Sub "${AWS::StackName}" + BucketName: group2-englishstudy - Statement: - Effect: Allow Action: @@ -648,7 +693,7 @@ Resources: WordFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-vocab-word-handler" + FunctionName: group2-englishstudy-vocab-word-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.WordHandler::handleRequest Description: Handle word CRUD operations @@ -726,7 +771,7 @@ Resources: UserWordFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-vocab-userword-handler" + FunctionName: group2-englishstudy-vocab-userword-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.UserWordHandler::handleRequest Description: Handle user word learning status @@ -788,7 +833,7 @@ Resources: WordGroupFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-vocab-wordgroup-handler" + FunctionName: group2-englishstudy-vocab-wordgroup-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.WordGroupHandler::handleRequest Description: Handle user custom word groups @@ -858,7 +903,7 @@ Resources: DailyStudyFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-vocab-daily-handler" + FunctionName: group2-englishstudy-vocab-daily-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.DailyStudyHandler::handleRequest Description: Handle daily study word assignment @@ -888,7 +933,7 @@ Resources: TestFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-vocab-test-handler" + FunctionName: group2-englishstudy-vocab-test-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.TestHandler::handleRequest Description: Handle vocabulary tests @@ -947,7 +992,7 @@ Resources: StatsFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-vocab-stats-handler" + FunctionName: group2-englishstudy-vocab-stats-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.StatsHandler::handleRequest Description: Handle user learning statistics @@ -985,7 +1030,7 @@ Resources: VocabVoiceFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-vocab-voice-handler" + FunctionName: group2-englishstudy-vocab-voice-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.VoiceHandler::handleRequest Description: Convert word to speech using Polly @@ -995,7 +1040,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref VocabTable - S3CrudPolicy: - BucketName: !Sub "${AWS::StackName}" + BucketName: group2-englishstudy - Statement: - Effect: Allow Action: @@ -1020,7 +1065,7 @@ Resources: StatsStreamFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-stats-stream-handler" + FunctionName: group2-englishstudy-stats-stream-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.stats.handler.StatsStreamHandler::handleRequest Description: Process DynamoDB Streams for stats aggregation @@ -1045,7 +1090,7 @@ Resources: UserStatsFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-user-stats-handler" + FunctionName: group2-englishstudy-user-stats-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.stats.handler.UserStatsHandler::handleRequest Description: Handle user learning statistics API @@ -1055,14 +1100,6 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref VocabTable Events: - GetDashboardStats: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /stats/dashboard - Method: GET - Auth: - Authorizer: CognitoAuthorizer GetDailyStats: Type: Api Properties: @@ -1108,7 +1145,7 @@ Resources: BadgeFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-badge-handler" + FunctionName: group2-englishstudy-badge-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.badge.handler.BadgeHandler::handleRequest Description: Handle user badges and achievements @@ -1118,7 +1155,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref VocabTable - S3ReadPolicy: - BucketName: !Sub "${AWS::StackName}" + BucketName: group2-englishstudy Events: GetAllBadges: Type: Api @@ -1144,7 +1181,7 @@ Resources: GrammarFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-grammar-handler" + FunctionName: group2-englishstudy-grammar-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.GrammarHandler::handleRequest Description: Handle grammar check using Bedrock AI @@ -1223,7 +1260,7 @@ Resources: GrammarWebSocketApi: Type: AWS::ApiGatewayV2::Api Properties: - Name: !Sub "${AWS::StackName}-grammar-websocket" + Name: group2-englishstudy-grammar-websocket ProtocolType: WEBSOCKET RouteSelectionExpression: "$request.body.action" @@ -1231,7 +1268,7 @@ Resources: Type: AWS::ApiGatewayV2::Stage Properties: ApiId: !Ref GrammarWebSocketApi - StageName: !Ref Environment + StageName: dev AutoDeploy: true # Grammar WebSocket Connect Route @@ -1286,13 +1323,13 @@ Resources: GrammarStreamingConnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-grammar-ws-connect" + FunctionName: group2-grammar-ws-connect CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingConnectHandler::handleRequest Description: Handle Grammar WebSocket $connect with JWT auth Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" + WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1313,13 +1350,13 @@ Resources: GrammarStreamingDisconnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-grammar-ws-disconnect" + FunctionName: group2-grammar-ws-disconnect CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingDisconnectHandler::handleRequest Description: Handle Grammar WebSocket $disconnect Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" + WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1340,7 +1377,7 @@ Resources: GrammarStreamingFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-grammar-ws-streaming" + FunctionName: group2-grammar-ws-streaming CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingHandler::handleRequest Description: Handle Grammar streaming with Bedrock @@ -1348,7 +1385,7 @@ Resources: MemorySize: 1024 Environment: Variables: - GRAMMAR_WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" + GRAMMAR_WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1376,7 +1413,7 @@ Resources: ScheduledStatsFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-scheduled-stats" + FunctionName: group2-englishstudy-scheduled-stats CodeUri: . Handler: com.mzc.secondproject.serverless.domain.stats.handler.ScheduledStatsHandler::handleRequest Description: Daily scheduled job for word learning stats aggregation @@ -1392,7 +1429,7 @@ Resources: Type: Schedule Properties: Schedule: cron(0 15 * * ? *) # UTC 15:00 = KST 00:00 (자정) - Name: !Sub "${AWS::StackName}-daily-stats-aggregation" + Name: daily-stats-aggregation Description: Daily word learning stats aggregation Enabled: true @@ -1403,7 +1440,7 @@ Resources: SpeakingFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-speaking-handler" + FunctionName: group2-englishstudy-speaking-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.speaking.handler.SpeakingHandler::handleRequest Description: Handle Speaking AI conversation (REST API) @@ -1413,13 +1450,12 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - SPEAKING_TABLE_NAME: !Ref SpeakingTable TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" Policies: - DynamoDBCrudPolicy: - TableName: !Ref SpeakingTable + TableName: !Ref ChatTable - S3CrudPolicy: - BucketName: !Ref ContentBucket + BucketName: group2-englishstudy - Statement: - Effect: Allow Action: @@ -1460,7 +1496,7 @@ Resources: OPIcSessionFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-opic-session-handler" + FunctionName: group2-englishstudy-opic-session-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.opic.handler.OPIcSessionHandler::handleRequest Description: Handle OPIc speaking practice sessions @@ -1475,7 +1511,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref OPIcTable - S3CrudPolicy: - BucketName: !Sub "${AWS::StackName}" + BucketName: group2-englishstudy - Statement: - Effect: Allow Action: @@ -1568,7 +1604,7 @@ Resources: DeletionPolicy: Retain # UpdateReplacePolicy: Retain Properties: - TableName: !Sub "${AWS::StackName}-user" + TableName: group2-englishstudy-user BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: PK @@ -1613,7 +1649,7 @@ Resources: ChatTable: Type: AWS::DynamoDB::Table Properties: - TableName: !Sub "${AWS::StackName}-chat" + TableName: group2-englishstudy-chat BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: PK @@ -1657,7 +1693,7 @@ Resources: VocabTable: Type: AWS::DynamoDB::Table Properties: - TableName: !Sub "${AWS::StackName}-vocab" + TableName: group2-englishstudy-vocab BillingMode: PAY_PER_REQUEST StreamSpecification: StreamViewType: NEW_IMAGE @@ -1715,255 +1751,7 @@ Resources: OPIcTable: Type: AWS::DynamoDB::Table Properties: - TableName: !Sub "${AWS::StackName}-opic" - BillingMode: PAY_PER_REQUEST - AttributeDefinitions: - - AttributeName: PK - AttributeType: S - - AttributeName: SK - AttributeType: S - - AttributeName: GSI1PK - AttributeType: S - - AttributeName: GSI1SK - AttributeType: S - KeySchema: - - AttributeName: PK - KeyType: HASH - - AttributeName: SK - KeyType: RANGE - GlobalSecondaryIndexes: - - IndexName: GSI1 - KeySchema: - - AttributeName: GSI1PK - KeyType: HASH - - AttributeName: GSI1SK - KeyType: RANGE - Projection: - ProjectionType: ALL - TimeToLiveSpecification: - AttributeName: ttl - Enabled: true - - SpeakingTable: - Type: AWS::DynamoDB::Table - Properties: - TableName: !Sub "${AWS::StackName}-speaking" - BillingMode: PAY_PER_REQUEST - AttributeDefinitions: - - AttributeName: PK - AttributeType: S - - AttributeName: SK - AttributeType: S - - AttributeName: GSI1PK - AttributeType: S - - AttributeName: GSI1SK - AttributeType: S - KeySchema: - - AttributeName: PK - KeyType: HASH - - AttributeName: SK - KeyType: RANGE - GlobalSecondaryIndexes: - - IndexName: GSI1 - KeySchema: - - AttributeName: GSI1PK - KeyType: HASH - - AttributeName: GSI1SK - KeyType: RANGE - Projection: - ProjectionType: ALL - TimeToLiveSpecification: - AttributeName: ttl - Enabled: true - - ############################################# - # News Collection Scheduled Lambda - ############################################# - - NewsCollectionFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: !Sub "${AWS::StackName}-news-collection" - CodeUri: . - Handler: com.mzc.secondproject.serverless.domain.news.handler.NewsCollectionHandler::handleRequest - Description: 매일 18시에 영어 뉴스를 수집하는 Lambda - MemorySize: 512 - Timeout: 300 - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref NewsTable - - Statement: - - Effect: Allow - Action: - - bedrock:InvokeModel - Resource: "*" - - Statement: - - Effect: Allow - Action: - - comprehend:DetectKeyPhrases - Resource: "*" - Events: - DailySchedule: - Type: Schedule - Properties: - Schedule: cron(0 9 * * ? *) - Name: !Sub "${AWS::StackName}-news-collection-daily-schedule" - Description: 매일 18시 KST (09:00 UTC)에 뉴스 수집 - Enabled: true - - NewsFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: !Sub "${AWS::StackName}-news" - CodeUri: . - Handler: com.mzc.secondproject.serverless.domain.news.handler.NewsHandler::handleRequest - Description: 뉴스 학습 API - MemorySize: 256 - Timeout: 30 - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref NewsTable - - DynamoDBCrudPolicy: - TableName: !Ref VocabTable - - S3CrudPolicy: - BucketName: !Sub "${AWS::StackName}" - - Statement: - - Effect: Allow - Action: - - polly:SynthesizeSpeech - Resource: "*" - Events: - GetNewsList: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news - Method: GET - GetTodayNews: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/today - Method: GET - GetRecommendedNews: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/recommended - Method: GET - GetNewsStats: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/stats - Method: GET - Auth: - Authorizer: CognitoAuthorizer - GetBookmarks: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/bookmarks - Method: GET - Auth: - Authorizer: CognitoAuthorizer - GetUserWords: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/words - Method: GET - Auth: - Authorizer: CognitoAuthorizer - SyncWordToVocab: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/words/{word}/sync - Method: POST - Auth: - Authorizer: CognitoAuthorizer - CollectWord: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/{articleId}/words - Method: POST - Auth: - Authorizer: CognitoAuthorizer - DeleteWord: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/{articleId}/words/{word} - Method: DELETE - Auth: - Authorizer: CognitoAuthorizer - GetWordDetail: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/{articleId}/words/{word} - Method: GET - Auth: - Authorizer: CognitoAuthorizer - GetQuizHistory: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/quiz/history - Method: GET - Auth: - Authorizer: CognitoAuthorizer - GetQuiz: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/{articleId}/quiz - Method: GET - SubmitQuiz: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/{articleId}/quiz - Method: POST - Auth: - Authorizer: CognitoAuthorizer - MarkAsRead: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/{articleId}/read - Method: POST - Auth: - Authorizer: CognitoAuthorizer - ToggleBookmark: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/{articleId}/bookmark - Method: POST - Auth: - Authorizer: CognitoAuthorizer - GetAudio: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/{articleId}/audio - Method: GET - Auth: - Authorizer: CognitoAuthorizer - GetNewsDetail: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/{articleId} - Method: GET - - NewsTable: - Type: AWS::DynamoDB::Table - Properties: - TableName: !Sub "${AWS::StackName}-news" + TableName: group2-englishstudy-opic BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: PK @@ -1974,10 +1762,6 @@ Resources: AttributeType: S - AttributeName: GSI1SK AttributeType: S - - AttributeName: GSI2PK - AttributeType: S - - AttributeName: GSI2SK - AttributeType: S KeySchema: - AttributeName: PK KeyType: HASH @@ -1992,45 +1776,10 @@ Resources: KeyType: RANGE Projection: ProjectionType: ALL - - IndexName: GSI2 - KeySchema: - - AttributeName: GSI2PK - KeyType: HASH - - AttributeName: GSI2SK - KeyType: RANGE - Projection: - ProjectionType: ALL TimeToLiveSpecification: AttributeName: ttl Enabled: true - ############################################# - # S3 Bucket for Content Storage - ############################################# - - ContentBucket: - Type: AWS::S3::Bucket - Properties: - BucketName: !Sub "${AWS::StackName}" - CorsConfiguration: - CorsRules: - - AllowedHeaders: - - "*" - AllowedMethods: - - GET - - PUT - - POST - - DELETE - - HEAD - AllowedOrigins: - - "*" - MaxAge: 3600 - PublicAccessBlockConfiguration: - BlockPublicAcls: false - BlockPublicPolicy: false - IgnorePublicAcls: false - RestrictPublicBuckets: false - ############################################# # SNS / SQS for Async Statistics Processing ############################################# @@ -2039,20 +1788,20 @@ Resources: TestResultTopic: Type: AWS::SNS::Topic Properties: - TopicName: !Sub "${AWS::StackName}-test-result-topic" + TopicName: group2-englishstudy-test-result-topic # SQS Dead Letter Queue - 실패한 메시지 보관 StatisticsDeadLetterQueue: Type: AWS::SQS::Queue Properties: - QueueName: !Sub "${AWS::StackName}-statistics-dlq" + QueueName: group2-englishstudy-statistics-dlq MessageRetentionPeriod: 1209600 # 14일 # SQS Queue - 통계 처리용 StatisticsQueue: Type: AWS::SQS::Queue Properties: - QueueName: !Sub "${AWS::StackName}-statistics-queue" + QueueName: group2-englishstudy-statistics-queue VisibilityTimeout: 60 RedrivePolicy: deadLetterTargetArn: !GetAtt StatisticsDeadLetterQueue.Arn @@ -2088,7 +1837,7 @@ Resources: StatisticsProcessorFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-statistics-processor" + FunctionName: group2-englishstudy-statistics-processor CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.StatisticsHandler::handleRequest Description: Process test results and update user word statistics @@ -2114,15 +1863,15 @@ Resources: Outputs: ApiUrl: Description: Unified API Gateway endpoint URL - Value: !Sub 'https://${MainApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}/' + Value: !Sub 'https://${MainApi}.execute-api.${AWS::Region}.amazonaws.com/dev/' WebSocketUrl: Description: WebSocket API Gateway endpoint URL - Value: !Sub 'wss://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}' + Value: !Sub 'wss://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev' GrammarWebSocketUrl: Description: Grammar Streaming WebSocket API endpoint URL - Value: !Sub 'wss://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}' + Value: !Sub 'wss://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev' ChatTableName: Description: Chat DynamoDB Table Name @@ -2134,20 +1883,16 @@ Outputs: BucketName: Description: S3 Bucket Name - Value: !Ref ContentBucket + Value: group2-englishstudy CognitoUserPoolId: Description: Cognito User Pool ID - Value: !Ref ExistingCognitoUserPoolId + Value: !Ref CognitoUserPool CognitoUserPoolClientId: Description: Cognito User Pool Client ID - Value: !Ref ExistingCognitoClientId + Value: !Ref CognitoUserPoolClient OPIcTableName: Description: OPIc DynamoDB Table Name Value: !Ref OPIcTable - - SpeakingTableName: - Description: Speaking DynamoDB Table Name - Value: !Ref SpeakingTable From d3029a2badac036bf0b6dfe196c3b2be18bc1204 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 14:46:19 +0900 Subject: [PATCH 3/5] feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords --- .../domain/news/service/NewsWordService.java | 58 +- ServerlessFunction/template.yaml | 553 +++++++++++++----- 2 files changed, 457 insertions(+), 154 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java index 6881c7ec..7da961ce 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java @@ -59,7 +59,7 @@ public NewsWordService(NewsWordRepository newsWordRepository, } /** - * 단어 수집 + * 단어 수집 (자동으로 Word 테이블 + UserWord에 추가) * @return 수집 결과 (단어 정보 + 새로 획득한 배지) */ public WordCollectResult collectWord(String userId, String articleId, String word, String context) { @@ -73,12 +73,51 @@ public WordCollectResult collectWord(String userId, String articleId, String wor // 기사 조회 Optional articleOpt = articleRepository.findById(articleId); String articleTitle = articleOpt.map(NewsArticle::getTitle).orElse(""); + String articleLevel = articleOpt.map(NewsArticle::getLevel).orElse("INTERMEDIATE"); + + // 기사 키워드에서 단어 정보 추출 + String meaningKo = ""; + String meaningEn = ""; + String example = ""; + if (articleOpt.isPresent() && articleOpt.get().getKeywords() != null) { + for (var keyword : articleOpt.get().getKeywords()) { + if (keyword.getWord() != null && keyword.getWord().equalsIgnoreCase(word)) { + meaningKo = keyword.getMeaningKo() != null ? keyword.getMeaningKo() : ""; + meaningEn = keyword.getMeaning() != null ? keyword.getMeaning() : ""; + example = keyword.getExample() != null ? keyword.getExample() : ""; + break; + } + } + } // 단어 정보 조회 (Word 테이블에서) String wordId = word.toLowerCase().trim(); Optional wordOpt = wordRepository.findById(wordId); - String meaning = wordOpt.map(Word::getKorean).orElse(""); - String pronunciation = ""; + String meaning = meaningKo; + + // Word 테이블에 없으면 자동 생성 + if (wordOpt.isEmpty() && !meaningKo.isEmpty()) { + String now = Instant.now().toString(); + Word newWord = Word.builder() + .pk("WORD#" + wordId) + .sk("METADATA") + .gsi1pk("LEVEL#" + articleLevel) + .gsi1sk("WORD#" + wordId) + .gsi2pk("CATEGORY#NEWS") + .gsi2sk("WORD#" + wordId) + .wordId(wordId) + .english(word) + .korean(meaningKo) + .example(example) + .level(articleLevel) + .category("NEWS") + .createdAt(now) + .build(); + wordRepository.save(newWord); + logger.info("Word 테이블에 단어 자동 추가: wordId={}, korean={}", wordId, meaningKo); + } else if (wordOpt.isPresent()) { + meaning = wordOpt.get().getKorean(); + } String now = Instant.now().toString(); @@ -90,17 +129,26 @@ public WordCollectResult collectWord(String userId, String articleId, String wor .userId(userId) .word(word) .meaning(meaning) - .pronunciation(pronunciation) + .pronunciation("") .context(context) .articleId(articleId) .articleTitle(articleTitle) .collectedAt(now) - .syncedToVocab(false) + .syncedToVocab(true) // 자동 연동됨 + .vocabUserWordId(wordId) .build(); newsWordRepository.save(wordCollect); logger.info("단어 수집 완료: userId={}, word={}, articleId={}", userId, word, articleId); + // UserWord에 자동 추가 (NEW 상태로) + try { + userWordCommandService.updateWordStatus(userId, wordId, "NEW"); + logger.info("UserWord에 자동 추가: userId={}, wordId={}", userId, wordId); + } catch (Exception e) { + logger.warn("UserWord 추가 실패 (이미 존재할 수 있음): userId={}, wordId={}, error={}", userId, wordId, e.getMessage()); + } + // 통계 업데이트 및 배지 체크 List newBadges = new ArrayList<>(); try { diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 0f64417e..97fe03e6 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -2,6 +2,25 @@ AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Description: Group2 English Study - Unified API (Chatting + Vocabulary) +Parameters: + Environment: + Type: String + Default: dev + AllowedValues: + - dev + - test + - prod + Description: Deployment environment + + ExistingCognitoUserPoolId: + Type: String + Default: "" + Description: Existing Cognito User Pool ID (leave empty to create new) + + ExistingCognitoClientId: + Type: String + Description: Existing Cognito User Pool Client ID + Globals: Function: Timeout: 30 @@ -16,11 +35,13 @@ Globals: CHAT_TABLE_NAME: !Ref ChatTable VOCAB_TABLE_NAME: !Ref VocabTable OPIC_TABLE_NAME: !Ref OPIcTable - BUCKET_NAME: group2-englishstudy - CHAT_BUCKET_NAME: group2-englishstudy - VOCAB_BUCKET_NAME: group2-englishstudy - PROFILE_BUCKET_NAME: group2-englishstudy - OPIC_BUCKET_NAME: group2-englishstudy + NEWS_TABLE_NAME: !Ref NewsTable + BUCKET_NAME: !Sub "${AWS::StackName}" + CHAT_BUCKET_NAME: !Sub "${AWS::StackName}" + VOCAB_BUCKET_NAME: !Sub "${AWS::StackName}" + PROFILE_BUCKET_NAME: !Sub "${AWS::StackName}" + OPIC_BUCKET_NAME: !Sub "${AWS::StackName}" + NEWS_BUCKET_NAME: !Sub "${AWS::StackName}" AWS_REGION_NAME: !Ref AWS::Region ROOM_TOKEN_TTL_SECONDS: "300" TRANSCRIBE_PROXY_URL: "https://tfo1zm7vec.execute-api.ap-northeast-2.amazonaws.com/prod/transcribe" @@ -29,65 +50,10 @@ Globals: Resources: ############################################# - # Cognito User Pool + # Cognito - Using Existing User Pool + # (Cognito resources are managed in group2-englishstudy-chatting stack) ############################################# - CognitoUserPool: - Type: AWS::Cognito::UserPool - DeletionPolicy: Retain - # UpdateReplacePolicy: Retain - Properties: - UserPoolName: !Sub "${AWS::StackName}-userpool" - UsernameAttributes: - - email - AutoVerifiedAttributes: - - email - Policies: - PasswordPolicy: - MinimumLength: 8 - RequireLowercase: true - RequireNumbers: true - RequireSymbols: true - RequireUppercase: false - # Cognito에 저장할 사용자 정보 정의 ≈ 회원 테이블 컬럼 - Schema: - - Name: email - AttributeDataType: String - Required: true - Mutable: true - - Name: nickname - AttributeDataType: String - Required: false - Mutable: true - - Name: level - AttributeDataType: String - Required: false - Mutable: true - - Name: profileUrl - AttributeDataType: String - Required: false - Mutable: true - LambdaConfig: - PreSignUp: !GetAtt PreSignUpFunction.Arn - PostConfirmation: !GetAtt PostConfirmationFunction.Arn - - # Cognito에게 Lambda 호출 권한 부여 - PreSignUpPermission: - Type: AWS::Lambda::Permission - Properties: - Action: lambda:InvokeFunction - FunctionName: !Ref PreSignUpFunction - Principal: cognito-idp.amazonaws.com - SourceArn: !Sub arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/* - - PostConfirmationPermission: - Type: AWS::Lambda::Permission - Properties: - Action: lambda:InvokeFunction - FunctionName: !GetAtt PostConfirmationFunction.Arn - Principal: cognito-idp.amazonaws.com - SourceArn: !GetAtt CognitoUserPool.Arn - # 사용자 custom 속성들 기본값 설정 Lambda 함수 PreSignUpFunction: Type: AWS::Serverless::Function @@ -100,7 +66,7 @@ Resources: Timeout: 10 Environment: Variables: - DEFAULT_PROFILE_URL: https://group2-englishstudy.s3.amazonaws.com/profile/default.png + DEFAULT_PROFILE_URL: !Sub "https://${AWS::StackName}.s3.amazonaws.com/profile/default.png" # 회원가입 시점에 사용자 모든 정보가 DB에 저장 Lambda 함수 PostConfirmationFunction: @@ -118,18 +84,6 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref UserTable - CognitoUserPoolClient: - Type: AWS::Cognito::UserPoolClient - Properties: - ClientName: !Sub "${AWS::StackName}-client" - UserPoolId: !Ref CognitoUserPool - GenerateSecret: false - ExplicitAuthFlows: - - ALLOW_USER_SRP_AUTH - - ALLOW_REFRESH_TOKEN_AUTH - - ALLOW_USER_PASSWORD_AUTH - PreventUserExistenceErrors: ENABLED - ############################################# # API Gateway (Unified) ############################################# @@ -137,11 +91,11 @@ Resources: MainApi: Type: AWS::Serverless::Api Properties: - Name: group2-englishstudy-api - StageName: dev + Name: !Sub "${AWS::StackName}-api" + StageName: !Ref Environment Cors: - AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" - AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" + AllowMethods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" + AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Requested-With,Accept'" AllowOrigin: "'*'" AllowCredentials: false GatewayResponses: @@ -151,7 +105,7 @@ Resources: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" ResponseTemplates: application/json: '{"message": "Unauthorized", "statusCode": 401}' ACCESS_DENIED: @@ -160,7 +114,7 @@ Resources: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" ResponseTemplates: application/json: '{"message": "Access Denied", "statusCode": 403}' DEFAULT_4XX: @@ -168,27 +122,28 @@ Resources: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" DEFAULT_5XX: ResponseParameters: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" EXPIRED_TOKEN: StatusCode: 401 ResponseParameters: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" ResponseTemplates: application/json: '{"message": "Token expired", "statusCode": 401}' Auth: DefaultAuthorizer: CognitoAuthorizer + AddDefaultAuthorizerToCorsPreflight: false Authorizers: CognitoAuthorizer: - UserPoolArn: !GetAtt CognitoUserPool.Arn + UserPoolArn: !Sub "arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/${ExistingCognitoUserPoolId}" Identity: Header: Authorization @@ -199,7 +154,7 @@ Resources: WebSocketApi: Type: AWS::ApiGatewayV2::Api Properties: - Name: group2-englishstudy-websocket + Name: !Sub "${AWS::StackName}-websocket" ProtocolType: WEBSOCKET RouteSelectionExpression: "$request.body.action" @@ -207,7 +162,7 @@ Resources: Type: AWS::ApiGatewayV2::Stage Properties: ApiId: !Ref WebSocketApi - StageName: dev + StageName: !Ref Environment AutoDeploy: true # WebSocket Connect Route @@ -265,7 +220,7 @@ Resources: WebSocketConnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-ws-connect + FunctionName: !Sub "${AWS::StackName}-ws-connect" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.websocket.WebSocketConnectHandler::handleRequest Description: Handle WebSocket $connect @@ -274,7 +229,7 @@ Resources: Environment: Variables: WEBSOCKET_CONNECTION_TTL_SECONDS: "600" - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -295,7 +250,7 @@ Resources: WebSocketDisconnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-ws-disconnect + FunctionName: !Sub "${AWS::StackName}-ws-disconnect" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.websocket.WebSocketDisconnectHandler::handleRequest Description: Handle WebSocket $disconnect @@ -303,7 +258,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -324,7 +279,7 @@ Resources: WebSocketMessageFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-ws-message + FunctionName: !Sub "${AWS::StackName}-ws-message" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.websocket.WebSocketMessageHandler::handleRequest Description: Handle WebSocket sendMessage @@ -332,7 +287,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" GAME_AUTO_CLOSE_LAMBDA_ARN: !GetAtt GameAutoCloseFunction.Arn SCHEDULER_ROLE_ARN: !GetAtt GameSchedulerRole.Arn Policies: @@ -352,7 +307,7 @@ Resources: - scheduler:CreateSchedule - scheduler:DeleteSchedule - scheduler:GetSchedule - Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/game-auto-close/*" + Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/${AWS::StackName}-game-auto-close/*" - Statement: - Effect: Allow Action: @@ -384,7 +339,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref UserTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" Events: GetMyProfile: Type: Api @@ -412,7 +367,7 @@ Resources: ChatRoomFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-chat-room-handler + FunctionName: !Sub "${AWS::StackName}-chat-room-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatRoomHandler::handleRequest Description: Handle chat room CRUD operations @@ -420,7 +375,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -484,7 +439,7 @@ Resources: GameFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-game-handler + FunctionName: !Sub "${AWS::StackName}-game-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.GameHandler::handleRequest Description: Handle catch-mind game operations @@ -492,7 +447,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" GAME_AUTO_CLOSE_LAMBDA_ARN: !GetAtt GameAutoCloseFunction.Arn SCHEDULER_ROLE_ARN: !GetAtt GameSchedulerRole.Arn Policies: @@ -512,7 +467,7 @@ Resources: - scheduler:CreateSchedule - scheduler:DeleteSchedule - scheduler:GetSchedule - Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/game-auto-close/*" + Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/${AWS::StackName}-game-auto-close/*" - Statement: - Effect: Allow Action: @@ -556,7 +511,7 @@ Resources: GameAutoCloseFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-game-auto-close + FunctionName: !Sub "${AWS::StackName}-game-auto-close" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.GameAutoCloseHandler::handleRequest Description: Auto-close game after 7 minutes @@ -566,7 +521,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -602,12 +557,12 @@ Resources: GameScheduleGroup: Type: AWS::Scheduler::ScheduleGroup Properties: - Name: game-auto-close + Name: !Sub "${AWS::StackName}-game-auto-close" ChatMessageFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-chat-message-handler + FunctionName: !Sub "${AWS::StackName}-chat-message-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatMessageHandler::handleRequest Description: Handle chat messages @@ -617,7 +572,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref ChatTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" - Statement: - Effect: Allow Action: @@ -659,7 +614,7 @@ Resources: ChatVoiceFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-chat-voice-handler + FunctionName: !Sub "${AWS::StackName}-chat-voice-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatVoiceHandler::handleRequest Description: Convert text to speech using Polly @@ -669,7 +624,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref ChatTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" - Statement: - Effect: Allow Action: @@ -693,7 +648,7 @@ Resources: WordFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-word-handler + FunctionName: !Sub "${AWS::StackName}-vocab-word-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.WordHandler::handleRequest Description: Handle word CRUD operations @@ -771,7 +726,7 @@ Resources: UserWordFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-userword-handler + FunctionName: !Sub "${AWS::StackName}-vocab-userword-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.UserWordHandler::handleRequest Description: Handle user word learning status @@ -833,7 +788,7 @@ Resources: WordGroupFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-wordgroup-handler + FunctionName: !Sub "${AWS::StackName}-vocab-wordgroup-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.WordGroupHandler::handleRequest Description: Handle user custom word groups @@ -903,7 +858,7 @@ Resources: DailyStudyFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-daily-handler + FunctionName: !Sub "${AWS::StackName}-vocab-daily-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.DailyStudyHandler::handleRequest Description: Handle daily study word assignment @@ -933,7 +888,7 @@ Resources: TestFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-test-handler + FunctionName: !Sub "${AWS::StackName}-vocab-test-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.TestHandler::handleRequest Description: Handle vocabulary tests @@ -992,7 +947,7 @@ Resources: StatsFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-stats-handler + FunctionName: !Sub "${AWS::StackName}-vocab-stats-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.StatsHandler::handleRequest Description: Handle user learning statistics @@ -1030,7 +985,7 @@ Resources: VocabVoiceFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-voice-handler + FunctionName: !Sub "${AWS::StackName}-vocab-voice-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.VoiceHandler::handleRequest Description: Convert word to speech using Polly @@ -1040,7 +995,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref VocabTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" - Statement: - Effect: Allow Action: @@ -1065,7 +1020,7 @@ Resources: StatsStreamFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-stats-stream-handler + FunctionName: !Sub "${AWS::StackName}-stats-stream-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.stats.handler.StatsStreamHandler::handleRequest Description: Process DynamoDB Streams for stats aggregation @@ -1090,7 +1045,7 @@ Resources: UserStatsFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-user-stats-handler + FunctionName: !Sub "${AWS::StackName}-user-stats-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.stats.handler.UserStatsHandler::handleRequest Description: Handle user learning statistics API @@ -1100,6 +1055,14 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref VocabTable Events: + GetDashboardStats: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /stats/dashboard + Method: GET + Auth: + Authorizer: CognitoAuthorizer GetDailyStats: Type: Api Properties: @@ -1145,7 +1108,7 @@ Resources: BadgeFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-badge-handler + FunctionName: !Sub "${AWS::StackName}-badge-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.badge.handler.BadgeHandler::handleRequest Description: Handle user badges and achievements @@ -1155,7 +1118,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref VocabTable - S3ReadPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" Events: GetAllBadges: Type: Api @@ -1181,7 +1144,7 @@ Resources: GrammarFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-grammar-handler + FunctionName: !Sub "${AWS::StackName}-grammar-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.GrammarHandler::handleRequest Description: Handle grammar check using Bedrock AI @@ -1260,7 +1223,7 @@ Resources: GrammarWebSocketApi: Type: AWS::ApiGatewayV2::Api Properties: - Name: group2-englishstudy-grammar-websocket + Name: !Sub "${AWS::StackName}-grammar-websocket" ProtocolType: WEBSOCKET RouteSelectionExpression: "$request.body.action" @@ -1268,7 +1231,7 @@ Resources: Type: AWS::ApiGatewayV2::Stage Properties: ApiId: !Ref GrammarWebSocketApi - StageName: dev + StageName: !Ref Environment AutoDeploy: true # Grammar WebSocket Connect Route @@ -1323,13 +1286,13 @@ Resources: GrammarStreamingConnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-grammar-ws-connect + FunctionName: !Sub "${AWS::StackName}-grammar-ws-connect" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingConnectHandler::handleRequest Description: Handle Grammar WebSocket $connect with JWT auth Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1350,13 +1313,13 @@ Resources: GrammarStreamingDisconnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-grammar-ws-disconnect + FunctionName: !Sub "${AWS::StackName}-grammar-ws-disconnect" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingDisconnectHandler::handleRequest Description: Handle Grammar WebSocket $disconnect Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1377,7 +1340,7 @@ Resources: GrammarStreamingFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-grammar-ws-streaming + FunctionName: !Sub "${AWS::StackName}-grammar-ws-streaming" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingHandler::handleRequest Description: Handle Grammar streaming with Bedrock @@ -1385,7 +1348,7 @@ Resources: MemorySize: 1024 Environment: Variables: - GRAMMAR_WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + GRAMMAR_WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1413,7 +1376,7 @@ Resources: ScheduledStatsFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-scheduled-stats + FunctionName: !Sub "${AWS::StackName}-scheduled-stats" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.stats.handler.ScheduledStatsHandler::handleRequest Description: Daily scheduled job for word learning stats aggregation @@ -1429,7 +1392,7 @@ Resources: Type: Schedule Properties: Schedule: cron(0 15 * * ? *) # UTC 15:00 = KST 00:00 (자정) - Name: daily-stats-aggregation + Name: !Sub "${AWS::StackName}-daily-stats-aggregation" Description: Daily word learning stats aggregation Enabled: true @@ -1440,7 +1403,7 @@ Resources: SpeakingFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-speaking-handler + FunctionName: !Sub "${AWS::StackName}-speaking-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.speaking.handler.SpeakingHandler::handleRequest Description: Handle Speaking AI conversation (REST API) @@ -1450,12 +1413,13 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: + SPEAKING_TABLE_NAME: !Ref SpeakingTable TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" Policies: - DynamoDBCrudPolicy: - TableName: !Ref ChatTable + TableName: !Ref SpeakingTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Ref ContentBucket - Statement: - Effect: Allow Action: @@ -1496,7 +1460,7 @@ Resources: OPIcSessionFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-opic-session-handler + FunctionName: !Sub "${AWS::StackName}-opic-session-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.opic.handler.OPIcSessionHandler::handleRequest Description: Handle OPIc speaking practice sessions @@ -1511,7 +1475,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref OPIcTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" - Statement: - Effect: Allow Action: @@ -1604,7 +1568,7 @@ Resources: DeletionPolicy: Retain # UpdateReplacePolicy: Retain Properties: - TableName: group2-englishstudy-user + TableName: !Sub "${AWS::StackName}-user" BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: PK @@ -1649,7 +1613,7 @@ Resources: ChatTable: Type: AWS::DynamoDB::Table Properties: - TableName: group2-englishstudy-chat + TableName: !Sub "${AWS::StackName}-chat" BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: PK @@ -1693,7 +1657,7 @@ Resources: VocabTable: Type: AWS::DynamoDB::Table Properties: - TableName: group2-englishstudy-vocab + TableName: !Sub "${AWS::StackName}-vocab" BillingMode: PAY_PER_REQUEST StreamSpecification: StreamViewType: NEW_IMAGE @@ -1751,7 +1715,255 @@ Resources: OPIcTable: Type: AWS::DynamoDB::Table Properties: - TableName: group2-englishstudy-opic + TableName: !Sub "${AWS::StackName}-opic" + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + - AttributeName: GSI1PK + AttributeType: S + - AttributeName: GSI1SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: GSI1 + KeySchema: + - AttributeName: GSI1PK + KeyType: HASH + - AttributeName: GSI1SK + KeyType: RANGE + Projection: + ProjectionType: ALL + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true + + SpeakingTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: !Sub "${AWS::StackName}-speaking" + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + - AttributeName: GSI1PK + AttributeType: S + - AttributeName: GSI1SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: GSI1 + KeySchema: + - AttributeName: GSI1PK + KeyType: HASH + - AttributeName: GSI1SK + KeyType: RANGE + Projection: + ProjectionType: ALL + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true + + ############################################# + # News Collection Scheduled Lambda + ############################################# + + NewsCollectionFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-news-collection" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.news.handler.NewsCollectionHandler::handleRequest + Description: 매일 18시에 영어 뉴스를 수집하는 Lambda + MemorySize: 512 + Timeout: 300 + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref NewsTable + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + Resource: "*" + - Statement: + - Effect: Allow + Action: + - comprehend:DetectKeyPhrases + Resource: "*" + Events: + DailySchedule: + Type: Schedule + Properties: + Schedule: cron(0 9 * * ? *) + Name: !Sub "${AWS::StackName}-news-collection-daily-schedule" + Description: 매일 18시 KST (09:00 UTC)에 뉴스 수집 + Enabled: true + + NewsFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-news" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.news.handler.NewsHandler::handleRequest + Description: 뉴스 학습 API + MemorySize: 256 + Timeout: 30 + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref NewsTable + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + - S3CrudPolicy: + BucketName: !Sub "${AWS::StackName}" + - Statement: + - Effect: Allow + Action: + - polly:SynthesizeSpeech + Resource: "*" + Events: + GetNewsList: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news + Method: GET + GetTodayNews: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/today + Method: GET + GetRecommendedNews: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/recommended + Method: GET + GetNewsStats: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/stats + Method: GET + Auth: + Authorizer: CognitoAuthorizer + GetBookmarks: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/bookmarks + Method: GET + Auth: + Authorizer: CognitoAuthorizer + GetUserWords: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/words + Method: GET + Auth: + Authorizer: CognitoAuthorizer + SyncWordToVocab: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/words/{word}/sync + Method: POST + Auth: + Authorizer: CognitoAuthorizer + CollectWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/words + Method: POST + Auth: + Authorizer: CognitoAuthorizer + DeleteWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/words/{word} + Method: DELETE + Auth: + Authorizer: CognitoAuthorizer + GetWordDetail: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/words/{word} + Method: GET + Auth: + Authorizer: CognitoAuthorizer + GetQuizHistory: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/quiz/history + Method: GET + Auth: + Authorizer: CognitoAuthorizer + GetQuiz: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/quiz + Method: GET + SubmitQuiz: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/quiz + Method: POST + Auth: + Authorizer: CognitoAuthorizer + MarkAsRead: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/read + Method: POST + Auth: + Authorizer: CognitoAuthorizer + ToggleBookmark: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/bookmark + Method: POST + Auth: + Authorizer: CognitoAuthorizer + GetAudio: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/audio + Method: GET + Auth: + Authorizer: CognitoAuthorizer + GetNewsDetail: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId} + Method: GET + + NewsTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: !Sub "${AWS::StackName}-news" BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: PK @@ -1762,6 +1974,10 @@ Resources: AttributeType: S - AttributeName: GSI1SK AttributeType: S + - AttributeName: GSI2PK + AttributeType: S + - AttributeName: GSI2SK + AttributeType: S KeySchema: - AttributeName: PK KeyType: HASH @@ -1776,10 +1992,45 @@ Resources: KeyType: RANGE Projection: ProjectionType: ALL + - IndexName: GSI2 + KeySchema: + - AttributeName: GSI2PK + KeyType: HASH + - AttributeName: GSI2SK + KeyType: RANGE + Projection: + ProjectionType: ALL TimeToLiveSpecification: AttributeName: ttl Enabled: true + ############################################# + # S3 Bucket for Content Storage + ############################################# + + ContentBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: !Sub "${AWS::StackName}" + CorsConfiguration: + CorsRules: + - AllowedHeaders: + - "*" + AllowedMethods: + - GET + - PUT + - POST + - DELETE + - HEAD + AllowedOrigins: + - "*" + MaxAge: 3600 + PublicAccessBlockConfiguration: + BlockPublicAcls: false + BlockPublicPolicy: false + IgnorePublicAcls: false + RestrictPublicBuckets: false + ############################################# # SNS / SQS for Async Statistics Processing ############################################# @@ -1788,20 +2039,20 @@ Resources: TestResultTopic: Type: AWS::SNS::Topic Properties: - TopicName: group2-englishstudy-test-result-topic + TopicName: !Sub "${AWS::StackName}-test-result-topic" # SQS Dead Letter Queue - 실패한 메시지 보관 StatisticsDeadLetterQueue: Type: AWS::SQS::Queue Properties: - QueueName: group2-englishstudy-statistics-dlq + QueueName: !Sub "${AWS::StackName}-statistics-dlq" MessageRetentionPeriod: 1209600 # 14일 # SQS Queue - 통계 처리용 StatisticsQueue: Type: AWS::SQS::Queue Properties: - QueueName: group2-englishstudy-statistics-queue + QueueName: !Sub "${AWS::StackName}-statistics-queue" VisibilityTimeout: 60 RedrivePolicy: deadLetterTargetArn: !GetAtt StatisticsDeadLetterQueue.Arn @@ -1837,7 +2088,7 @@ Resources: StatisticsProcessorFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-statistics-processor + FunctionName: !Sub "${AWS::StackName}-statistics-processor" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.StatisticsHandler::handleRequest Description: Process test results and update user word statistics @@ -1863,15 +2114,15 @@ Resources: Outputs: ApiUrl: Description: Unified API Gateway endpoint URL - Value: !Sub 'https://${MainApi}.execute-api.${AWS::Region}.amazonaws.com/dev/' + Value: !Sub 'https://${MainApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}/' WebSocketUrl: Description: WebSocket API Gateway endpoint URL - Value: !Sub 'wss://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev' + Value: !Sub 'wss://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}' GrammarWebSocketUrl: Description: Grammar Streaming WebSocket API endpoint URL - Value: !Sub 'wss://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev' + Value: !Sub 'wss://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}' ChatTableName: Description: Chat DynamoDB Table Name @@ -1883,16 +2134,20 @@ Outputs: BucketName: Description: S3 Bucket Name - Value: group2-englishstudy + Value: !Ref ContentBucket CognitoUserPoolId: Description: Cognito User Pool ID - Value: !Ref CognitoUserPool + Value: !Ref ExistingCognitoUserPoolId CognitoUserPoolClientId: Description: Cognito User Pool Client ID - Value: !Ref CognitoUserPoolClient + Value: !Ref ExistingCognitoClientId OPIcTableName: Description: OPIc DynamoDB Table Name Value: !Ref OPIcTable + + SpeakingTableName: + Description: Speaking DynamoDB Table Name + Value: !Ref SpeakingTable From 63669c76b6f63608eb640dc33c68b098f50f45d6 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 14:53:44 +0900 Subject: [PATCH 4/5] feat: add category filtering to UserWord API with enhanced query logic - Introduce `category` filtering to `getUserWords` API - Update WordCategory enums to include "news" category - Apply category filter after enrichment with word info - Adjust query limits for bookmarked and incorrect words queries --- .../domain/vocabulary/enums/WordCategory.java | 3 +- .../vocabulary/handler/UserWordHandler.java | 9 +++--- .../service/UserWordQueryService.java | 29 ++++++++++++++----- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordCategory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordCategory.java index 9a65b41a..aafe1eac 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordCategory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordCategory.java @@ -7,7 +7,8 @@ public enum WordCategory { BUSINESS("business", "비즈니스"), ACADEMIC("academic", "학술"), TRAVEL("travel", "여행"), - TECHNOLOGY("technology", "기술"); + TECHNOLOGY("technology", "기술"), + NEWS("news", "뉴스"); private final String code; private final String displayName; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java index 83047fa4..8cb93c80 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java @@ -66,18 +66,19 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent request, String userId) { Map queryParams = request.getQueryStringParameters(); - + String status = queryParams != null ? queryParams.get("status") : null; String cursor = queryParams != null ? queryParams.get("cursor") : null; String bookmarked = queryParams != null ? queryParams.get("bookmarked") : null; String incorrectOnly = queryParams != null ? queryParams.get("incorrectOnly") : null; - + String category = queryParams != null ? queryParams.get("category") : null; + int limit = 20; if (queryParams != null && queryParams.get("limit") != null) { limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); } - - UserWordQueryService.UserWordsResult result = queryService.getUserWords(userId, status, bookmarked, incorrectOnly, limit, cursor); + + UserWordQueryService.UserWordsResult result = queryService.getUserWords(userId, status, bookmarked, incorrectOnly, category, limit, cursor); Map response = new HashMap<>(); response.put("userWords", result.userWords()); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java index bf6920e5..6b862566 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java @@ -37,21 +37,34 @@ public UserWordQueryService(UserWordRepository userWordRepository, WordRepositor } public UserWordsResult getUserWords(String userId, String status, String bookmarked, - String incorrectOnly, int limit, String cursor) { + String incorrectOnly, String category, int limit, String cursor) { PaginatedResult userWordPage; - + if ("true".equalsIgnoreCase(bookmarked)) { - userWordPage = userWordRepository.findBookmarkedWords(userId, limit, cursor); + userWordPage = userWordRepository.findBookmarkedWords(userId, limit * 3, cursor); } else if ("true".equalsIgnoreCase(incorrectOnly)) { - userWordPage = userWordRepository.findIncorrectWords(userId, limit, cursor); + userWordPage = userWordRepository.findIncorrectWords(userId, limit * 3, cursor); } else if (status != null && !status.isEmpty()) { - userWordPage = userWordRepository.findByUserIdAndStatus(userId, status, limit, cursor); + userWordPage = userWordRepository.findByUserIdAndStatus(userId, status, limit * 3, cursor); } else { - userWordPage = userWordRepository.findByUserIdWithPagination(userId, limit, cursor); + userWordPage = userWordRepository.findByUserIdWithPagination(userId, limit * 3, cursor); } - + List> enrichedUserWords = enrichWithWordInfo(userWordPage.items()); - + + // 카테고리 필터링 (Word 테이블 조인 후 필터) + if (category != null && !category.isEmpty()) { + String upperCategory = category.toUpperCase(); + enrichedUserWords = enrichedUserWords.stream() + .filter(w -> upperCategory.equals(w.get("category"))) + .limit(limit) + .collect(Collectors.toList()); + } else { + enrichedUserWords = enrichedUserWords.stream() + .limit(limit) + .collect(Collectors.toList()); + } + return new UserWordsResult(enrichedUserWords, userWordPage.nextCursor(), userWordPage.hasMore()); } From 36cee08379dbab3457e2ba5270474c925ef328dc Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 15:10:30 +0900 Subject: [PATCH 5/5] feat: add default value for ExistingCognitoClientId in template.yaml - Set default to an empty string to ensure compatibility with templates using this parameter without explicitly specifying a value. --- ServerlessFunction/template.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 97fe03e6..34296f1a 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -19,6 +19,7 @@ Parameters: ExistingCognitoClientId: Type: String + Default: "" Description: Existing Cognito User Pool Client ID Globals: