From 4e5005d11c2a6ff308cd0991130089b3a383a3b0 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 7 Jan 2026 12:29:21 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8B=A8=EC=96=B4=20=EC=95=94=EA=B8=B0?= =?UTF-8?q?=20=EC=84=9C=EB=B9=84=EC=8A=A4=20(vocabulary)=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DynamoDB Single Table Design (Word, UserWord, DailyStudy, TestResult) - 6개 Lambda Handler (Word, UserWord, DailyStudy, Test, Stats, Voice) - Spaced Repetition 알고리즘 적용 - Polly TTS 음성 캐싱 (S3 + Pre-signed URL) - 일일 학습 55개 단어 (50개 신규 + 5개 복습) - 시험 기능 및 통계 대시보드 --- vocabulary/VocabFunction/build.gradle | 60 ++++ vocabulary/VocabFunction/gradlew | 234 +++++++++++++ vocabulary/VocabFunction/gradlew.bat | 89 +++++ .../vocabulary/dto/ApiResponse.java | 33 ++ .../vocabulary/handler/DailyStudyHandler.java | 243 ++++++++++++++ .../vocabulary/handler/StatsHandler.java | 179 ++++++++++ .../vocabulary/handler/TestHandler.java | 240 +++++++++++++ .../vocabulary/handler/UserWordHandler.java | 223 +++++++++++++ .../vocabulary/handler/VoiceHandler.java | 123 +++++++ .../vocabulary/handler/WordHandler.java | 234 +++++++++++++ .../vocabulary/model/DailyStudy.java | 74 +++++ .../vocabulary/model/TestResult.java | 74 +++++ .../serverless/vocabulary/model/UserWord.java | 88 +++++ .../serverless/vocabulary/model/Word.java | 83 +++++ .../repository/DailyStudyRepository.java | 161 +++++++++ .../repository/TestResultRepository.java | 137 ++++++++ .../repository/UserWordRepository.java | 193 +++++++++++ .../vocabulary/repository/WordRepository.java | 170 ++++++++++ .../vocabulary/service/PollyService.java | 160 +++++++++ .../src/main/resources/log4j2.xml | 17 + vocabulary/template.yaml | 314 ++++++++++++++++++ 21 files changed, 3129 insertions(+) create mode 100644 vocabulary/VocabFunction/build.gradle create mode 100755 vocabulary/VocabFunction/gradlew create mode 100644 vocabulary/VocabFunction/gradlew.bat create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/dto/ApiResponse.java create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatsHandler.java create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/VoiceHandler.java create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/WordHandler.java create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/DailyStudy.java create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/TestResult.java create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/UserWord.java create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/Word.java create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/DailyStudyRepository.java create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/TestResultRepository.java create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/UserWordRepository.java create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/WordRepository.java create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/service/PollyService.java create mode 100644 vocabulary/VocabFunction/src/main/resources/log4j2.xml create mode 100644 vocabulary/template.yaml diff --git a/vocabulary/VocabFunction/build.gradle b/vocabulary/VocabFunction/build.gradle new file mode 100644 index 00000000..2dcb4f2b --- /dev/null +++ b/vocabulary/VocabFunction/build.gradle @@ -0,0 +1,60 @@ +plugins { + id 'java' +} + +group = 'com.mzc.secondproject.serverless' +version = '1.0.0' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +dependencies { + // AWS Lambda Core + implementation 'com.amazonaws:aws-lambda-java-core:1.2.3' + implementation 'com.amazonaws:aws-lambda-java-events:3.11.4' + + // AWS SDK v2 + implementation platform('software.amazon.awssdk:bom:2.24.0') + implementation 'software.amazon.awssdk:dynamodb' + implementation 'software.amazon.awssdk:dynamodb-enhanced' + implementation 'software.amazon.awssdk:polly' + implementation 'software.amazon.awssdk:s3' + + // JSON Processing + implementation 'com.google.code.gson:gson:2.10.1' + + // Logging + implementation 'com.amazonaws:aws-lambda-java-log4j2:1.6.0' + implementation 'org.apache.logging.log4j:log4j-api:2.22.1' + implementation 'org.apache.logging.log4j:log4j-core:2.22.1' + implementation 'org.apache.logging.log4j:log4j-slf4j2-impl:2.22.1' + + // Lombok + compileOnly 'org.projectlombok:lombok:1.18.30' + annotationProcessor 'org.projectlombok:lombok:1.18.30' + + // Testing + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.1' + testImplementation 'org.mockito:mockito-core:5.8.0' +} + +test { + useJUnitPlatform() +} + +task buildZip(type: Zip) { + from compileJava + from processResources + into('lib') { + from configurations.runtimeClasspath + } +} + +build.dependsOn buildZip diff --git a/vocabulary/VocabFunction/gradlew b/vocabulary/VocabFunction/gradlew new file mode 100755 index 00000000..1b6c7873 --- /dev/null +++ b/vocabulary/VocabFunction/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# 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. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/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/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# 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"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# 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" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# 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" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/vocabulary/VocabFunction/gradlew.bat b/vocabulary/VocabFunction/gradlew.bat new file mode 100644 index 00000000..107acd32 --- /dev/null +++ b/vocabulary/VocabFunction/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +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 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +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%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/dto/ApiResponse.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/dto/ApiResponse.java new file mode 100644 index 00000000..acd76c92 --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/dto/ApiResponse.java @@ -0,0 +1,33 @@ +package com.mzc.secondproject.serverless.vocabulary.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ApiResponse { + + private boolean success; + private String message; + private T data; + private String error; + + public static ApiResponse success(String message, T data) { + return ApiResponse.builder() + .success(true) + .message(message) + .data(data) + .build(); + } + + public static ApiResponse error(String errorMessage) { + return ApiResponse.builder() + .success(false) + .error(errorMessage) + .build(); + } +} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java new file mode 100644 index 00000000..aeabd3cd --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java @@ -0,0 +1,243 @@ +package com.mzc.secondproject.serverless.vocabulary.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.mzc.secondproject.serverless.vocabulary.dto.ApiResponse; +import com.mzc.secondproject.serverless.vocabulary.model.DailyStudy; +import com.mzc.secondproject.serverless.vocabulary.model.UserWord; +import com.mzc.secondproject.serverless.vocabulary.model.Word; +import com.mzc.secondproject.serverless.vocabulary.repository.DailyStudyRepository; +import com.mzc.secondproject.serverless.vocabulary.repository.UserWordRepository; +import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +public class DailyStudyHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(DailyStudyHandler.class); + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + private static final int NEW_WORDS_COUNT = 50; + private static final int REVIEW_WORDS_COUNT = 5; + + private final DailyStudyRepository dailyStudyRepository; + private final UserWordRepository userWordRepository; + private final WordRepository wordRepository; + + public DailyStudyHandler() { + this.dailyStudyRepository = new DailyStudyRepository(); + this.userWordRepository = new UserWordRepository(); + this.wordRepository = new WordRepository(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + String httpMethod = request.getHttpMethod(); + String path = request.getPath(); + + logger.info("Received request: {} {}", httpMethod, path); + + try { + // GET /vocab/daily/{userId} - 오늘의 학습 단어 + if ("GET".equals(httpMethod) && !path.contains("/learned")) { + return getDailyWords(request); + } + + // POST /vocab/daily/{userId}/words/{wordId}/learned - 학습 완료 + if ("POST".equals(httpMethod) && path.endsWith("/learned")) { + return markWordLearned(request); + } + + return createResponse(404, ApiResponse.error("Not found")); + + } catch (Exception e) { + logger.error("Error handling request", e); + return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); + } + } + + private APIGatewayProxyResponseEvent getDailyWords(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + + if (userId == null) { + return createResponse(400, ApiResponse.error("userId is required")); + } + + String today = LocalDate.now().toString(); + + // 오늘의 학습 데이터 조회 + Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); + + DailyStudy dailyStudy; + if (optDailyStudy.isPresent()) { + dailyStudy = optDailyStudy.get(); + } else { + // 새로운 일일 학습 생성 + dailyStudy = createDailyStudy(userId, today); + } + + // 단어 상세 정보 조회 + List newWords = getWordDetails(dailyStudy.getNewWordIds()); + List reviewWords = getWordDetails(dailyStudy.getReviewWordIds()); + + Map result = new HashMap<>(); + result.put("dailyStudy", dailyStudy); + result.put("newWords", newWords); + result.put("reviewWords", reviewWords); + result.put("progress", calculateProgress(dailyStudy)); + + return createResponse(200, ApiResponse.success("Daily words retrieved", result)); + } + + private DailyStudy createDailyStudy(String userId, String date) { + String now = Instant.now().toString(); + + // 복습 대상 단어 조회 (5개) + UserWordRepository.UserWordPage reviewPage = userWordRepository.findReviewDueWords(userId, date, REVIEW_WORDS_COUNT, null); + List reviewWordIds = reviewPage.getUserWords().stream() + .map(UserWord::getWordId) + .collect(Collectors.toList()); + + // 신규 단어 조회 (50개) - 아직 학습하지 않은 단어 + List newWordIds = getNewWordsForUser(userId, NEW_WORDS_COUNT); + + DailyStudy dailyStudy = DailyStudy.builder() + .pk("DAILY#" + userId) + .sk("DATE#" + date) + .gsi1pk("DAILY#ALL") + .gsi1sk("DATE#" + date) + .userId(userId) + .date(date) + .newWordIds(newWordIds) + .reviewWordIds(reviewWordIds) + .learnedWordIds(new ArrayList<>()) + .totalWords(newWordIds.size() + reviewWordIds.size()) + .learnedCount(0) + .isCompleted(false) + .createdAt(now) + .updatedAt(now) + .build(); + + dailyStudyRepository.save(dailyStudy); + logger.info("Created daily study for user: {}, date: {}", userId, date); + + return dailyStudy; + } + + private List getNewWordsForUser(String userId, int count) { + // 사용자가 학습한 단어 목록 + UserWordRepository.UserWordPage userWordPage = userWordRepository.findByUserIdWithPagination(userId, 1000, null); + List learnedWordIds = userWordPage.getUserWords().stream() + .map(UserWord::getWordId) + .collect(Collectors.toList()); + + // 전체 단어에서 학습하지 않은 단어 선택 + List newWordIds = new ArrayList<>(); + String[] levels = {"BEGINNER", "INTERMEDIATE", "ADVANCED"}; + + for (String level : levels) { + if (newWordIds.size() >= count) break; + + WordRepository.WordPage wordPage = wordRepository.findByLevelWithPagination(level, count * 2, null); + for (Word word : wordPage.getWords()) { + if (!learnedWordIds.contains(word.getWordId()) && !newWordIds.contains(word.getWordId())) { + newWordIds.add(word.getWordId()); + if (newWordIds.size() >= count) break; + } + } + } + + return newWordIds; + } + + private List getWordDetails(List wordIds) { + if (wordIds == null || wordIds.isEmpty()) { + return new ArrayList<>(); + } + + List words = new ArrayList<>(); + for (String wordId : wordIds) { + wordRepository.findById(wordId).ifPresent(words::add); + } + return words; + } + + private Map calculateProgress(DailyStudy dailyStudy) { + Map progress = new HashMap<>(); + int total = dailyStudy.getTotalWords(); + int learned = dailyStudy.getLearnedCount(); + + progress.put("total", total); + progress.put("learned", learned); + progress.put("remaining", total - learned); + progress.put("percentage", total > 0 ? (learned * 100.0 / total) : 0); + progress.put("isCompleted", dailyStudy.getIsCompleted()); + + return progress; + } + + private APIGatewayProxyResponseEvent markWordLearned(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + String wordId = pathParams != null ? pathParams.get("wordId") : null; + + if (userId == null || wordId == null) { + return createResponse(400, ApiResponse.error("userId and wordId are required")); + } + + String today = LocalDate.now().toString(); + + Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); + if (optDailyStudy.isEmpty()) { + return createResponse(404, ApiResponse.error("Daily study not found")); + } + + DailyStudy dailyStudy = optDailyStudy.get(); + + // 이미 학습 완료된 단어인지 확인 + if (dailyStudy.getLearnedWordIds() != null && dailyStudy.getLearnedWordIds().contains(wordId)) { + return createResponse(200, ApiResponse.success("Already marked as learned", dailyStudy)); + } + + // 학습 완료 처리 + dailyStudyRepository.addLearnedWord(userId, today, wordId); + + // 업데이트된 데이터 조회 + DailyStudy updatedDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today).orElse(dailyStudy); + + // 완료 여부 확인 + if (updatedDailyStudy.getLearnedCount() >= updatedDailyStudy.getTotalWords()) { + updatedDailyStudy.setIsCompleted(true); + dailyStudyRepository.save(updatedDailyStudy); + } + + logger.info("Marked word as learned: userId={}, wordId={}", userId, wordId); + return createResponse(200, ApiResponse.success("Word marked as learned", calculateProgress(updatedDailyStudy))); + } + + private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers", "Content-Type,Authorization" + )) + .withBody(gson.toJson(body)); + } +} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatsHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatsHandler.java new file mode 100644 index 00000000..2e680e22 --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatsHandler.java @@ -0,0 +1,179 @@ +package com.mzc.secondproject.serverless.vocabulary.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.mzc.secondproject.serverless.vocabulary.dto.ApiResponse; +import com.mzc.secondproject.serverless.vocabulary.model.DailyStudy; +import com.mzc.secondproject.serverless.vocabulary.model.TestResult; +import com.mzc.secondproject.serverless.vocabulary.model.UserWord; +import com.mzc.secondproject.serverless.vocabulary.repository.DailyStudyRepository; +import com.mzc.secondproject.serverless.vocabulary.repository.TestResultRepository; +import com.mzc.secondproject.serverless.vocabulary.repository.UserWordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class StatsHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(StatsHandler.class); + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + private final UserWordRepository userWordRepository; + private final DailyStudyRepository dailyStudyRepository; + private final TestResultRepository testResultRepository; + + public StatsHandler() { + this.userWordRepository = new UserWordRepository(); + this.dailyStudyRepository = new DailyStudyRepository(); + this.testResultRepository = new TestResultRepository(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + String httpMethod = request.getHttpMethod(); + String path = request.getPath(); + + logger.info("Received request: {} {}", httpMethod, path); + + try { + // GET /vocab/stats/{userId} - 전체 통계 + if ("GET".equals(httpMethod) && !path.endsWith("/daily")) { + return getOverallStats(request); + } + + // GET /vocab/stats/{userId}/daily - 일별 통계 + if ("GET".equals(httpMethod) && path.endsWith("/daily")) { + return getDailyStats(request); + } + + return createResponse(404, ApiResponse.error("Not found")); + + } catch (Exception e) { + logger.error("Error handling request", e); + return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); + } + } + + private APIGatewayProxyResponseEvent getOverallStats(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + + if (userId == null) { + return createResponse(400, ApiResponse.error("userId is required")); + } + + // 단어 학습 상태별 통계 + Map wordStatusCounts = new HashMap<>(); + wordStatusCounts.put("NEW", 0); + wordStatusCounts.put("LEARNING", 0); + wordStatusCounts.put("REVIEWING", 0); + wordStatusCounts.put("MASTERED", 0); + + int totalCorrect = 0; + int totalIncorrect = 0; + + // 사용자 단어 통계 조회 + String cursor = null; + do { + UserWordRepository.UserWordPage page = userWordRepository.findByUserIdWithPagination(userId, 100, cursor); + for (UserWord userWord : page.getUserWords()) { + String status = userWord.getStatus(); + wordStatusCounts.merge(status, 1, Integer::sum); + totalCorrect += userWord.getCorrectCount() != null ? userWord.getCorrectCount() : 0; + totalIncorrect += userWord.getIncorrectCount() != null ? userWord.getIncorrectCount() : 0; + } + cursor = page.getNextCursor(); + } while (cursor != null); + + int totalWords = wordStatusCounts.values().stream().mapToInt(Integer::intValue).sum(); + + // 시험 통계 + TestResultRepository.TestResultPage testPage = testResultRepository.findByUserIdWithPagination(userId, 100, null); + List testResults = testPage.getTestResults(); + + double avgSuccessRate = testResults.stream() + .mapToDouble(TestResult::getSuccessRate) + .average() + .orElse(0.0); + + // 학습 일수 + DailyStudyRepository.DailyStudyPage dailyPage = dailyStudyRepository.findByUserIdWithPagination(userId, 365, null); + int studyDays = dailyPage.getDailyStudies().size(); + int completedDays = (int) dailyPage.getDailyStudies().stream() + .filter(d -> Boolean.TRUE.equals(d.getIsCompleted())) + .count(); + + Map stats = new HashMap<>(); + stats.put("totalWords", totalWords); + stats.put("wordStatusCounts", wordStatusCounts); + stats.put("totalCorrect", totalCorrect); + stats.put("totalIncorrect", totalIncorrect); + stats.put("accuracy", totalCorrect + totalIncorrect > 0 + ? (totalCorrect * 100.0 / (totalCorrect + totalIncorrect)) : 0); + stats.put("testCount", testResults.size()); + stats.put("avgSuccessRate", avgSuccessRate); + stats.put("studyDays", studyDays); + stats.put("completedDays", completedDays); + stats.put("completionRate", studyDays > 0 ? (completedDays * 100.0 / studyDays) : 0); + + return createResponse(200, ApiResponse.success("Stats retrieved", stats)); + } + + private APIGatewayProxyResponseEvent getDailyStats(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + Map queryParams = request.getQueryStringParameters(); + + String userId = pathParams != null ? pathParams.get("userId") : null; + String cursor = queryParams != null ? queryParams.get("cursor") : null; + + if (userId == null) { + return createResponse(400, ApiResponse.error("userId is required")); + } + + int limit = 30; // 최근 30일 + if (queryParams != null && queryParams.get("limit") != null) { + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 90); + } + + DailyStudyRepository.DailyStudyPage dailyPage = dailyStudyRepository.findByUserIdWithPagination(userId, limit, cursor); + + List> dailyStats = dailyPage.getDailyStudies().stream() + .map(daily -> { + Map stat = new HashMap<>(); + stat.put("date", daily.getDate()); + stat.put("totalWords", daily.getTotalWords()); + stat.put("learnedCount", daily.getLearnedCount()); + stat.put("isCompleted", daily.getIsCompleted()); + stat.put("progress", daily.getTotalWords() > 0 + ? (daily.getLearnedCount() * 100.0 / daily.getTotalWords()) : 0); + return stat; + }) + .toList(); + + Map result = new HashMap<>(); + result.put("dailyStats", dailyStats); + result.put("nextCursor", dailyPage.getNextCursor()); + result.put("hasMore", dailyPage.hasMore()); + + return createResponse(200, ApiResponse.success("Daily stats retrieved", result)); + } + + private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers", "Content-Type,Authorization" + )) + .withBody(gson.toJson(body)); + } +} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java new file mode 100644 index 00000000..ad70efab --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java @@ -0,0 +1,240 @@ +package com.mzc.secondproject.serverless.vocabulary.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.mzc.secondproject.serverless.vocabulary.dto.ApiResponse; +import com.mzc.secondproject.serverless.vocabulary.model.DailyStudy; +import com.mzc.secondproject.serverless.vocabulary.model.TestResult; +import com.mzc.secondproject.serverless.vocabulary.model.Word; +import com.mzc.secondproject.serverless.vocabulary.repository.DailyStudyRepository; +import com.mzc.secondproject.serverless.vocabulary.repository.TestResultRepository; +import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +public class TestHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(TestHandler.class); + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + private final TestResultRepository testResultRepository; + private final DailyStudyRepository dailyStudyRepository; + private final WordRepository wordRepository; + + public TestHandler() { + this.testResultRepository = new TestResultRepository(); + this.dailyStudyRepository = new DailyStudyRepository(); + this.wordRepository = new WordRepository(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + String httpMethod = request.getHttpMethod(); + String path = request.getPath(); + + logger.info("Received request: {} {}", httpMethod, path); + + try { + // POST /vocab/test/{userId}/start - 시험 시작 + if ("POST".equals(httpMethod) && path.endsWith("/start")) { + return startTest(request); + } + + // POST /vocab/test/{userId}/submit - 답안 제출 + if ("POST".equals(httpMethod) && path.endsWith("/submit")) { + return submitAnswer(request); + } + + // GET /vocab/test/{userId}/results - 시험 결과 조회 + if ("GET".equals(httpMethod) && path.endsWith("/results")) { + return getTestResults(request); + } + + return createResponse(404, ApiResponse.error("Not found")); + + } catch (Exception e) { + logger.error("Error handling request", e); + return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); + } + } + + private APIGatewayProxyResponseEvent startTest(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + + if (userId == null) { + return createResponse(400, ApiResponse.error("userId is required")); + } + + String body = request.getBody(); + Map requestBody = gson.fromJson(body, Map.class); + String testType = (String) requestBody.getOrDefault("testType", "DAILY"); + + String today = LocalDate.now().toString(); + + // 오늘 학습한 단어 기반 시험 + Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); + if (optDailyStudy.isEmpty()) { + return createResponse(404, ApiResponse.error("No daily study found for today")); + } + + DailyStudy dailyStudy = optDailyStudy.get(); + List allWordIds = new ArrayList<>(); + if (dailyStudy.getNewWordIds() != null) allWordIds.addAll(dailyStudy.getNewWordIds()); + if (dailyStudy.getReviewWordIds() != null) allWordIds.addAll(dailyStudy.getReviewWordIds()); + + if (allWordIds.isEmpty()) { + return createResponse(400, ApiResponse.error("No words to test")); + } + + // 시험 문제 생성 + List> questions = new ArrayList<>(); + for (String wordId : allWordIds) { + Optional optWord = wordRepository.findById(wordId); + if (optWord.isPresent()) { + Word word = optWord.get(); + Map question = new HashMap<>(); + question.put("wordId", word.getWordId()); + question.put("english", word.getEnglish()); + question.put("example", word.getExample()); + questions.add(question); + } + } + + String testId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + Map result = new HashMap<>(); + result.put("testId", testId); + result.put("testType", testType); + result.put("questions", questions); + result.put("totalQuestions", questions.size()); + result.put("startedAt", now); + + logger.info("Started test: userId={}, testId={}, questions={}", userId, testId, questions.size()); + return createResponse(200, ApiResponse.success("Test started", result)); + } + + @SuppressWarnings("unchecked") + private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + + if (userId == null) { + return createResponse(400, ApiResponse.error("userId is required")); + } + + String body = request.getBody(); + Map requestBody = gson.fromJson(body, Map.class); + + String testId = (String) requestBody.get("testId"); + String testType = (String) requestBody.getOrDefault("testType", "DAILY"); + List> answers = (List>) requestBody.get("answers"); + + if (testId == null || answers == null) { + return createResponse(400, ApiResponse.error("testId and answers are required")); + } + + String now = Instant.now().toString(); + String today = LocalDate.now().toString(); + + int correctCount = 0; + int incorrectCount = 0; + List incorrectWordIds = new ArrayList<>(); + + for (Map answer : answers) { + String wordId = (String) answer.get("wordId"); + String userAnswer = (String) answer.get("answer"); + + Optional optWord = wordRepository.findById(wordId); + if (optWord.isPresent()) { + Word word = optWord.get(); + // 대소문자 무시, 공백 제거 후 비교 + boolean isCorrect = word.getKorean().trim().equalsIgnoreCase(userAnswer.trim()); + + if (isCorrect) { + correctCount++; + } else { + incorrectCount++; + incorrectWordIds.add(wordId); + } + } + } + + int totalQuestions = answers.size(); + double successRate = totalQuestions > 0 ? (correctCount * 100.0 / totalQuestions) : 0; + + TestResult testResult = TestResult.builder() + .pk("TEST#" + userId) + .sk("RESULT#" + now) + .gsi1pk("TEST#ALL") + .gsi1sk("DATE#" + today) + .testId(testId) + .userId(userId) + .testType(testType) + .totalQuestions(totalQuestions) + .correctAnswers(correctCount) + .incorrectAnswers(incorrectCount) + .successRate(successRate) + .incorrectWordIds(incorrectWordIds) + .startedAt((String) requestBody.get("startedAt")) + .completedAt(now) + .build(); + + testResultRepository.save(testResult); + + logger.info("Test submitted: userId={}, testId={}, successRate={}%", userId, testId, successRate); + return createResponse(200, ApiResponse.success("Test submitted", testResult)); + } + + private APIGatewayProxyResponseEvent getTestResults(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + Map queryParams = request.getQueryStringParameters(); + + String userId = pathParams != null ? pathParams.get("userId") : null; + String cursor = queryParams != null ? queryParams.get("cursor") : null; + + if (userId == null) { + return createResponse(400, ApiResponse.error("userId is required")); + } + + int limit = 10; + if (queryParams != null && queryParams.get("limit") != null) { + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); + } + + TestResultRepository.TestResultPage resultPage = testResultRepository.findByUserIdWithPagination(userId, limit, cursor); + + Map result = new HashMap<>(); + result.put("testResults", resultPage.getTestResults()); + result.put("nextCursor", resultPage.getNextCursor()); + result.put("hasMore", resultPage.hasMore()); + + return createResponse(200, ApiResponse.success("Test results retrieved", result)); + } + + private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers", "Content-Type,Authorization" + )) + .withBody(gson.toJson(body)); + } +} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java new file mode 100644 index 00000000..c4af3cc5 --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java @@ -0,0 +1,223 @@ +package com.mzc.secondproject.serverless.vocabulary.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.mzc.secondproject.serverless.vocabulary.dto.ApiResponse; +import com.mzc.secondproject.serverless.vocabulary.model.UserWord; +import com.mzc.secondproject.serverless.vocabulary.repository.UserWordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +public class UserWordHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(UserWordHandler.class); + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + private final UserWordRepository userWordRepository; + + public UserWordHandler() { + this.userWordRepository = new UserWordRepository(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + String httpMethod = request.getHttpMethod(); + String path = request.getPath(); + + logger.info("Received request: {} {}", httpMethod, path); + + try { + // GET /vocab/users/{userId}/words - 사용자 단어 목록 + if ("GET".equals(httpMethod) && path.endsWith("/words")) { + return getUserWords(request); + } + + // GET /vocab/users/{userId}/words/{wordId} - 사용자 단어 상세 + if ("GET".equals(httpMethod) && path.contains("/words/")) { + return getUserWord(request); + } + + // PUT /vocab/users/{userId}/words/{wordId} - 학습 상태 업데이트 + if ("PUT".equals(httpMethod) && path.contains("/words/")) { + return updateUserWord(request); + } + + return createResponse(404, ApiResponse.error("Not found")); + + } catch (Exception e) { + logger.error("Error handling request", e); + return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); + } + } + + private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + Map queryParams = request.getQueryStringParameters(); + + String userId = pathParams != null ? pathParams.get("userId") : null; + String status = queryParams != null ? queryParams.get("status") : null; + String cursor = queryParams != null ? queryParams.get("cursor") : null; + + if (userId == null) { + return createResponse(400, ApiResponse.error("userId is required")); + } + + int limit = 20; + if (queryParams != null && queryParams.get("limit") != null) { + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); + } + + UserWordRepository.UserWordPage userWordPage; + if (status != null && !status.isEmpty()) { + userWordPage = userWordRepository.findByUserIdAndStatus(userId, status, limit, cursor); + } else { + userWordPage = userWordRepository.findByUserIdWithPagination(userId, limit, cursor); + } + + Map result = new HashMap<>(); + result.put("userWords", userWordPage.getUserWords()); + result.put("nextCursor", userWordPage.getNextCursor()); + result.put("hasMore", userWordPage.hasMore()); + + return createResponse(200, ApiResponse.success("User words retrieved", result)); + } + + private APIGatewayProxyResponseEvent getUserWord(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + String wordId = pathParams != null ? pathParams.get("wordId") : null; + + if (userId == null || wordId == null) { + return createResponse(400, ApiResponse.error("userId and wordId are required")); + } + + Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); + if (optUserWord.isEmpty()) { + return createResponse(404, ApiResponse.error("UserWord not found")); + } + + return createResponse(200, ApiResponse.success("UserWord retrieved", optUserWord.get())); + } + + private APIGatewayProxyResponseEvent updateUserWord(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + String wordId = pathParams != null ? pathParams.get("wordId") : null; + + if (userId == null || wordId == null) { + return createResponse(400, ApiResponse.error("userId and wordId are required")); + } + + String body = request.getBody(); + Map requestBody = gson.fromJson(body, Map.class); + + // 정답/오답 여부 + Boolean isCorrect = (Boolean) requestBody.get("isCorrect"); + if (isCorrect == null) { + return createResponse(400, ApiResponse.error("isCorrect is required")); + } + + Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); + UserWord userWord; + String now = Instant.now().toString(); + + if (optUserWord.isEmpty()) { + // 새로운 UserWord 생성 + userWord = UserWord.builder() + .pk("USER#" + userId) + .sk("WORD#" + wordId) + .gsi1pk("USER#" + userId + "#REVIEW") + .gsi2pk("USER#" + userId + "#STATUS") + .userId(userId) + .wordId(wordId) + .status("NEW") + .interval(1) + .easeFactor(2.5) + .repetitions(0) + .correctCount(0) + .incorrectCount(0) + .createdAt(now) + .build(); + } else { + userWord = optUserWord.get(); + } + + // Spaced Repetition 알고리즘 적용 + applySpacedRepetition(userWord, isCorrect); + userWord.setUpdatedAt(now); + userWord.setLastReviewedAt(now); + + // GSI 업데이트 + userWord.setGsi1sk("DATE#" + userWord.getNextReviewAt()); + userWord.setGsi2sk("STATUS#" + userWord.getStatus()); + + userWordRepository.save(userWord); + + logger.info("Updated user word: userId={}, wordId={}, isCorrect={}", userId, wordId, isCorrect); + return createResponse(200, ApiResponse.success("UserWord updated", userWord)); + } + + /** + * SM-2 Spaced Repetition 알고리즘 적용 + */ + private void applySpacedRepetition(UserWord userWord, boolean isCorrect) { + if (isCorrect) { + userWord.setCorrectCount(userWord.getCorrectCount() + 1); + userWord.setRepetitions(userWord.getRepetitions() + 1); + + // 간격 계산 + if (userWord.getRepetitions() == 1) { + userWord.setInterval(1); + } else if (userWord.getRepetitions() == 2) { + userWord.setInterval(6); + } else { + int newInterval = (int) Math.round(userWord.getInterval() * userWord.getEaseFactor()); + userWord.setInterval(newInterval); + } + + // 상태 업데이트 + if (userWord.getRepetitions() >= 5) { + userWord.setStatus("MASTERED"); + } else if (userWord.getRepetitions() >= 2) { + userWord.setStatus("REVIEWING"); + } else { + userWord.setStatus("LEARNING"); + } + } else { + userWord.setIncorrectCount(userWord.getIncorrectCount() + 1); + userWord.setRepetitions(0); + userWord.setInterval(1); + userWord.setStatus("LEARNING"); + + // easeFactor 감소 (최소 1.3) + double newEaseFactor = userWord.getEaseFactor() - 0.2; + userWord.setEaseFactor(Math.max(1.3, newEaseFactor)); + } + + // 다음 복습일 계산 + LocalDate nextReview = LocalDate.now().plusDays(userWord.getInterval()); + userWord.setNextReviewAt(nextReview.toString()); + } + + private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers", "Content-Type,Authorization" + )) + .withBody(gson.toJson(body)); + } +} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/VoiceHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/VoiceHandler.java new file mode 100644 index 00000000..c49d4fef --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/VoiceHandler.java @@ -0,0 +1,123 @@ +package com.mzc.secondproject.serverless.vocabulary.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.mzc.secondproject.serverless.vocabulary.dto.ApiResponse; +import com.mzc.secondproject.serverless.vocabulary.model.Word; +import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; +import com.mzc.secondproject.serverless.vocabulary.service.PollyService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +public class VoiceHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(VoiceHandler.class); + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + private final WordRepository wordRepository; + private final PollyService pollyService; + + public VoiceHandler() { + this.wordRepository = new WordRepository(); + this.pollyService = new PollyService(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + String httpMethod = request.getHttpMethod(); + String path = request.getPath(); + + logger.info("Received request: {} {}", httpMethod, path); + + try { + // POST /vocab/voice/synthesize - 음성 합성 + if ("POST".equals(httpMethod) && path.endsWith("/synthesize")) { + return synthesizeSpeech(request); + } + + return createResponse(404, ApiResponse.error("Not found")); + + } catch (Exception e) { + logger.error("Error handling request", e); + return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); + } + } + + private APIGatewayProxyResponseEvent synthesizeSpeech(APIGatewayProxyRequestEvent request) { + String body = request.getBody(); + Map requestBody = gson.fromJson(body, Map.class); + + String wordId = (String) requestBody.get("wordId"); + String voice = (String) requestBody.getOrDefault("voice", "FEMALE"); + + if (wordId == null || wordId.isEmpty()) { + return createResponse(400, ApiResponse.error("wordId is required")); + } + + // 단어 조회 + Optional optWord = wordRepository.findById(wordId); + if (optWord.isEmpty()) { + return createResponse(404, ApiResponse.error("Word not found")); + } + + Word word = optWord.get(); + boolean isMale = "MALE".equalsIgnoreCase(voice); + + // 캐시 확인: DynamoDB에 저장된 S3 키 확인 + String cachedKey = isMale ? word.getMaleVoiceKey() : word.getFemaleVoiceKey(); + String audioUrl; + boolean cached = false; + + if (cachedKey != null && !cachedKey.isEmpty()) { + // DB에 캐시 키가 있으면 Pre-signed URL 생성 + audioUrl = pollyService.getPresignedUrl(cachedKey); + cached = true; + logger.info("Cache hit from DB: wordId={}, voice={}", wordId, voice); + } else { + // 캐시 미스: Polly 변환 후 S3 저장 + PollyService.VoiceSynthesisResult result = pollyService.synthesizeSpeechForWord( + wordId, word.getEnglish(), voice); + + audioUrl = result.getAudioUrl(); + cached = result.isCached(); + + // DynamoDB에 S3 키 저장 + if (isMale) { + word.setMaleVoiceKey(result.getS3Key()); + } else { + word.setFemaleVoiceKey(result.getS3Key()); + } + wordRepository.save(word); + logger.info("Saved voice cache to DB: wordId={}, voice={}", wordId, voice); + } + + Map responseData = new HashMap<>(); + responseData.put("wordId", wordId); + responseData.put("english", word.getEnglish()); + responseData.put("voice", voice); + responseData.put("audioUrl", audioUrl); + responseData.put("cached", cached); + + return createResponse(200, ApiResponse.success("Speech synthesized", responseData)); + } + + private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers", "Content-Type,Authorization" + )) + .withBody(gson.toJson(body)); + } +} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/WordHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/WordHandler.java new file mode 100644 index 00000000..2d591f32 --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/WordHandler.java @@ -0,0 +1,234 @@ +package com.mzc.secondproject.serverless.vocabulary.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.mzc.secondproject.serverless.vocabulary.dto.ApiResponse; +import com.mzc.secondproject.serverless.vocabulary.model.Word; +import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +public class WordHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(WordHandler.class); + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + private final WordRepository wordRepository; + + public WordHandler() { + this.wordRepository = new WordRepository(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + String httpMethod = request.getHttpMethod(); + String path = request.getPath(); + + logger.info("Received request: {} {}", httpMethod, path); + + try { + // POST /vocab/words - 단어 생성 + if ("POST".equals(httpMethod) && path.endsWith("/words")) { + return createWord(request); + } + + // GET /vocab/words - 단어 목록 조회 + if ("GET".equals(httpMethod) && path.endsWith("/words")) { + return getWords(request); + } + + // GET /vocab/words/{wordId} - 단어 상세 조회 + if ("GET".equals(httpMethod) && path.contains("/words/")) { + return getWord(request); + } + + // PUT /vocab/words/{wordId} - 단어 수정 + if ("PUT".equals(httpMethod) && path.contains("/words/")) { + return updateWord(request); + } + + // DELETE /vocab/words/{wordId} - 단어 삭제 + if ("DELETE".equals(httpMethod) && path.contains("/words/")) { + return deleteWord(request); + } + + return createResponse(404, ApiResponse.error("Not found")); + + } catch (Exception e) { + logger.error("Error handling request", e); + return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); + } + } + + private APIGatewayProxyResponseEvent createWord(APIGatewayProxyRequestEvent request) { + String body = request.getBody(); + Map requestBody = gson.fromJson(body, Map.class); + + String english = (String) requestBody.get("english"); + String korean = (String) requestBody.get("korean"); + String example = (String) requestBody.get("example"); + String level = (String) requestBody.getOrDefault("level", "BEGINNER"); + String category = (String) requestBody.getOrDefault("category", "DAILY"); + + if (english == null || english.isEmpty()) { + return createResponse(400, ApiResponse.error("english is required")); + } + if (korean == null || korean.isEmpty()) { + return createResponse(400, ApiResponse.error("korean is required")); + } + + String wordId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + Word word = Word.builder() + .pk("WORD#" + wordId) + .sk("METADATA") + .gsi1pk("LEVEL#" + level) + .gsi1sk("WORD#" + wordId) + .gsi2pk("CATEGORY#" + category) + .gsi2sk("WORD#" + wordId) + .wordId(wordId) + .english(english) + .korean(korean) + .example(example) + .level(level) + .category(category) + .createdAt(now) + .build(); + + wordRepository.save(word); + + logger.info("Created word: {}", wordId); + return createResponse(201, ApiResponse.success("Word created", word)); + } + + private APIGatewayProxyResponseEvent getWords(APIGatewayProxyRequestEvent request) { + Map queryParams = request.getQueryStringParameters(); + + String level = queryParams != null ? queryParams.get("level") : null; + String category = queryParams != null ? queryParams.get("category") : null; + String cursor = queryParams != null ? queryParams.get("cursor") : null; + + int limit = 20; + if (queryParams != null && queryParams.get("limit") != null) { + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); + } + + WordRepository.WordPage wordPage; + if (level != null && !level.isEmpty()) { + wordPage = wordRepository.findByLevelWithPagination(level, limit, cursor); + } else if (category != null && !category.isEmpty()) { + wordPage = wordRepository.findByCategoryWithPagination(category, limit, cursor); + } else { + // 기본: BEGINNER 레벨 + wordPage = wordRepository.findByLevelWithPagination("BEGINNER", limit, cursor); + } + + Map result = new HashMap<>(); + result.put("words", wordPage.getWords()); + result.put("nextCursor", wordPage.getNextCursor()); + result.put("hasMore", wordPage.hasMore()); + + return createResponse(200, ApiResponse.success("Words retrieved", result)); + } + + private APIGatewayProxyResponseEvent getWord(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String wordId = pathParams != null ? pathParams.get("wordId") : null; + + if (wordId == null) { + return createResponse(400, ApiResponse.error("wordId is required")); + } + + Optional optWord = wordRepository.findById(wordId); + if (optWord.isEmpty()) { + return createResponse(404, ApiResponse.error("Word not found")); + } + + return createResponse(200, ApiResponse.success("Word retrieved", optWord.get())); + } + + private APIGatewayProxyResponseEvent updateWord(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String wordId = pathParams != null ? pathParams.get("wordId") : null; + + if (wordId == null) { + return createResponse(400, ApiResponse.error("wordId is required")); + } + + Optional optWord = wordRepository.findById(wordId); + if (optWord.isEmpty()) { + return createResponse(404, ApiResponse.error("Word not found")); + } + + Word word = optWord.get(); + String body = request.getBody(); + Map requestBody = gson.fromJson(body, Map.class); + + if (requestBody.containsKey("english")) { + word.setEnglish((String) requestBody.get("english")); + } + if (requestBody.containsKey("korean")) { + word.setKorean((String) requestBody.get("korean")); + } + if (requestBody.containsKey("example")) { + word.setExample((String) requestBody.get("example")); + } + if (requestBody.containsKey("level")) { + String newLevel = (String) requestBody.get("level"); + word.setLevel(newLevel); + word.setGsi1pk("LEVEL#" + newLevel); + } + if (requestBody.containsKey("category")) { + String newCategory = (String) requestBody.get("category"); + word.setCategory(newCategory); + word.setGsi2pk("CATEGORY#" + newCategory); + } + + wordRepository.save(word); + + logger.info("Updated word: {}", wordId); + return createResponse(200, ApiResponse.success("Word updated", word)); + } + + private APIGatewayProxyResponseEvent deleteWord(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String wordId = pathParams != null ? pathParams.get("wordId") : null; + + if (wordId == null) { + return createResponse(400, ApiResponse.error("wordId is required")); + } + + Optional optWord = wordRepository.findById(wordId); + if (optWord.isEmpty()) { + return createResponse(404, ApiResponse.error("Word not found")); + } + + wordRepository.delete(wordId); + + logger.info("Deleted word: {}", wordId); + return createResponse(200, ApiResponse.success("Word deleted", null)); + } + + private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers", "Content-Type,Authorization" + )) + .withBody(gson.toJson(body)); + } +} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/DailyStudy.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/DailyStudy.java new file mode 100644 index 00000000..c6c51520 --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/DailyStudy.java @@ -0,0 +1,74 @@ +package com.mzc.secondproject.serverless.vocabulary.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; + +import java.util.List; + +/** + * 일일 학습 정보 + * PK: DAILY#{userId} + * SK: DATE#{date} + * GSI1: DAILY#ALL / DATE#{date} - 전체 일일 학습 조회 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class DailyStudy { + + private String pk; // DAILY#{userId} + private String sk; // DATE#{date} + private String gsi1pk; // DAILY#ALL + private String gsi1sk; // DATE#{date} + + private String userId; + private String date; // yyyy-MM-dd + + // 학습 단어 목록 (55개: 50개 신규 + 5개 복습) + private List newWordIds; // 신규 단어 ID 목록 (50개) + private List reviewWordIds; // 복습 단어 ID 목록 (5개) + private List learnedWordIds; // 학습 완료 단어 ID 목록 + + // 진행 상태 + private Integer totalWords; // 총 단어 수 (55) + private Integer learnedCount; // 학습 완료 수 + private Boolean isCompleted; // 일일 학습 완료 여부 + + private String createdAt; + private String updatedAt; + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } +} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/TestResult.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/TestResult.java new file mode 100644 index 00000000..1b0da164 --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/TestResult.java @@ -0,0 +1,74 @@ +package com.mzc.secondproject.serverless.vocabulary.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; + +import java.util.List; + +/** + * 시험 결과 + * PK: TEST#{userId} + * SK: RESULT#{timestamp} + * GSI1: TEST#ALL / DATE#{date} - 전체 시험 결과 조회 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class TestResult { + + private String pk; // TEST#{userId} + private String sk; // RESULT#{timestamp} + private String gsi1pk; // TEST#ALL + private String gsi1sk; // DATE#{date} + + private String testId; + private String userId; + private String testType; // DAILY, WEEKLY, CUSTOM + + // 시험 결과 + private Integer totalQuestions; + private Integer correctAnswers; + private Integer incorrectAnswers; + private Double successRate; // 성공률 (%) + + // 오답 단어 목록 + private List incorrectWordIds; + + private String startedAt; + private String completedAt; + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } +} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/UserWord.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/UserWord.java new file mode 100644 index 00000000..0abb5e19 --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/UserWord.java @@ -0,0 +1,88 @@ +package com.mzc.secondproject.serverless.vocabulary.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; + +/** + * 사용자별 단어 학습 상태 (Spaced Repetition) + * PK: USER#{userId} + * SK: WORD#{wordId} + * GSI1: USER#{userId}#REVIEW / DATE#{nextReviewAt} - 복습 예정 조회 + * GSI2: USER#{userId}#STATUS / STATUS#{status} - 상태별 조회 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class UserWord { + + private String pk; // USER#{userId} + private String sk; // WORD#{wordId} + private String gsi1pk; // USER#{userId}#REVIEW + private String gsi1sk; // DATE#{nextReviewAt} + private String gsi2pk; // USER#{userId}#STATUS + private String gsi2sk; // STATUS#{status} + + private String userId; + private String wordId; + private String status; // NEW, LEARNING, REVIEWING, MASTERED + + // Spaced Repetition 알고리즘 필드 + private Integer interval; // 복습 간격 (일) + private Double easeFactor; // 난이도 계수 (2.5 기본) + private Integer repetitions; // 연속 정답 횟수 + private String nextReviewAt; // 다음 복습 예정일 + private String lastReviewedAt; // 마지막 복습일 + + // 학습 통계 + private Integer correctCount; // 정답 횟수 + private Integer incorrectCount; // 오답 횟수 + private String createdAt; + private String updatedAt; + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2PK") + public String getGsi2pk() { + return gsi2pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2SK") + public String getGsi2sk() { + return gsi2sk; + } +} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/Word.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/Word.java new file mode 100644 index 00000000..ce116685 --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/Word.java @@ -0,0 +1,83 @@ +package com.mzc.secondproject.serverless.vocabulary.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; + +/** + * 단어 정보 모델 + * PK: WORD#{wordId} + * SK: METADATA + * GSI1: LEVEL#{level} / WORD#{wordId} - 난이도별 조회 + * GSI2: CATEGORY#{category} / WORD#{wordId} - 카테고리별 조회 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class Word { + + private String pk; // WORD#{wordId} + private String sk; // METADATA + private String gsi1pk; // LEVEL#{level} + private String gsi1sk; // WORD#{wordId} + private String gsi2pk; // CATEGORY#{category} + private String gsi2sk; // WORD#{wordId} + + private String wordId; + private String english; // 영어 단어 + private String korean; // 한국어 뜻 + private String example; // 예문 + private String level; // BEGINNER, INTERMEDIATE, ADVANCED + private String category; // DAILY, BUSINESS, ACADEMIC, etc. + private String createdAt; + private Long ttl; + + // 음성 캐시용 S3 키 (vocab/voice/{wordId}_{voice}.mp3) + private String maleVoiceKey; + private String femaleVoiceKey; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2PK") + public String getGsi2pk() { + return gsi2pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2SK") + public String getGsi2sk() { + return gsi2sk; + } +} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/DailyStudyRepository.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/DailyStudyRepository.java new file mode 100644 index 00000000..c255dd70 --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/DailyStudyRepository.java @@ -0,0 +1,161 @@ +package com.mzc.secondproject.serverless.vocabulary.repository; + +import com.mzc.secondproject.serverless.vocabulary.model.DailyStudy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; + +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class DailyStudyRepository { + + private static final Logger logger = LoggerFactory.getLogger(DailyStudyRepository.class); + + // Singleton 패턴으로 Cold Start 최적화 + private static final DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); + private static final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(dynamoDbClient) + .build(); + private static final String tableName = System.getenv("VOCAB_TABLE_NAME"); + + private final DynamoDbTable table; + + public DailyStudyRepository() { + this.table = enhancedClient.table(tableName, TableSchema.fromBean(DailyStudy.class)); + } + + public DailyStudy save(DailyStudy dailyStudy) { + logger.info("Saving daily study: userId={}, date={}", dailyStudy.getUserId(), dailyStudy.getDate()); + table.putItem(dailyStudy); + return dailyStudy; + } + + public Optional findByUserIdAndDate(String userId, String date) { + Key key = Key.builder() + .partitionValue("DAILY#" + userId) + .sortValue("DATE#" + date) + .build(); + + DailyStudy dailyStudy = table.getItem(key); + return Optional.ofNullable(dailyStudy); + } + + /** + * 사용자의 일일 학습 기록 조회 - 최신순, 페이지네이션 + */ + public DailyStudyPage findByUserIdWithPagination(String userId, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue("DAILY#" + userId) + .sortValue("DATE#") + .build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.query(requestBuilder.build()).iterator().next(); + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + + return new DailyStudyPage(page.items(), nextCursor); + } + + /** + * 학습 완료 단어 추가 (UpdateExpression 사용 - N+1 방지) + */ + public void addLearnedWord(String userId, String date, String wordId) { + Map key = new HashMap<>(); + key.put("PK", AttributeValue.builder().s("DAILY#" + userId).build()); + key.put("SK", AttributeValue.builder().s("DATE#" + date).build()); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":wordId", AttributeValue.builder().ss(wordId).build()); + expressionValues.put(":one", AttributeValue.builder().n("1").build()); + + UpdateItemRequest updateRequest = UpdateItemRequest.builder() + .tableName(tableName) + .key(key) + .updateExpression("ADD learnedWordIds :wordId, learnedCount :one") + .expressionAttributeValues(expressionValues) + .build(); + + dynamoDbClient.updateItem(updateRequest); + logger.info("Added learned word: userId={}, date={}, wordId={}", userId, date, wordId); + } + + private String encodeCursor(Map lastEvaluatedKey) { + if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { + return null; + } + + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : lastEvaluatedKey.entrySet()) { + if (sb.length() > 0) sb.append("|"); + sb.append(entry.getKey()).append("=").append(entry.getValue().s()); + } + + return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); + } + + private Map decodeCursor(String cursor) { + try { + String decoded = new String(Base64.getUrlDecoder().decode(cursor)); + Map result = new HashMap<>(); + + for (String pair : decoded.split("\\|")) { + String[] kv = pair.split("=", 2); + if (kv.length == 2) { + result.put(kv[0], AttributeValue.builder().s(kv[1]).build()); + } + } + + return result.isEmpty() ? null : result; + } catch (Exception e) { + logger.error("Failed to decode cursor: {}", cursor, e); + return null; + } + } + + public static class DailyStudyPage { + private final List dailyStudies; + private final String nextCursor; + + public DailyStudyPage(List dailyStudies, String nextCursor) { + this.dailyStudies = dailyStudies; + this.nextCursor = nextCursor; + } + + public List getDailyStudies() { + return dailyStudies; + } + + public String getNextCursor() { + return nextCursor; + } + + public boolean hasMore() { + return nextCursor != null; + } + } +} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/TestResultRepository.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/TestResultRepository.java new file mode 100644 index 00000000..6224f2bc --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/TestResultRepository.java @@ -0,0 +1,137 @@ +package com.mzc.secondproject.serverless.vocabulary.repository; + +import com.mzc.secondproject.serverless.vocabulary.model.TestResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class TestResultRepository { + + private static final Logger logger = LoggerFactory.getLogger(TestResultRepository.class); + + // Singleton 패턴으로 Cold Start 최적화 + private static final DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); + private static final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(dynamoDbClient) + .build(); + private static final String tableName = System.getenv("VOCAB_TABLE_NAME"); + + private final DynamoDbTable table; + + public TestResultRepository() { + this.table = enhancedClient.table(tableName, TableSchema.fromBean(TestResult.class)); + } + + public TestResult save(TestResult testResult) { + logger.info("Saving test result: userId={}, testId={}", testResult.getUserId(), testResult.getTestId()); + table.putItem(testResult); + return testResult; + } + + public Optional findByUserIdAndTestId(String userId, String timestamp) { + Key key = Key.builder() + .partitionValue("TEST#" + userId) + .sortValue("RESULT#" + timestamp) + .build(); + + TestResult testResult = table.getItem(key); + return Optional.ofNullable(testResult); + } + + /** + * 사용자의 시험 결과 조회 - 최신순, 페이지네이션 + */ + public TestResultPage findByUserIdWithPagination(String userId, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue("TEST#" + userId) + .sortValue("RESULT#") + .build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.query(requestBuilder.build()).iterator().next(); + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + + return new TestResultPage(page.items(), nextCursor); + } + + private String encodeCursor(Map lastEvaluatedKey) { + if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { + return null; + } + + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : lastEvaluatedKey.entrySet()) { + if (sb.length() > 0) sb.append("|"); + sb.append(entry.getKey()).append("=").append(entry.getValue().s()); + } + + return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); + } + + private Map decodeCursor(String cursor) { + try { + String decoded = new String(Base64.getUrlDecoder().decode(cursor)); + Map result = new HashMap<>(); + + for (String pair : decoded.split("\\|")) { + String[] kv = pair.split("=", 2); + if (kv.length == 2) { + result.put(kv[0], AttributeValue.builder().s(kv[1]).build()); + } + } + + return result.isEmpty() ? null : result; + } catch (Exception e) { + logger.error("Failed to decode cursor: {}", cursor, e); + return null; + } + } + + public static class TestResultPage { + private final List testResults; + private final String nextCursor; + + public TestResultPage(List testResults, String nextCursor) { + this.testResults = testResults; + this.nextCursor = nextCursor; + } + + public List getTestResults() { + return testResults; + } + + public String getNextCursor() { + return nextCursor; + } + + public boolean hasMore() { + return nextCursor != null; + } + } +} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/UserWordRepository.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/UserWordRepository.java new file mode 100644 index 00000000..7214b500 --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/UserWordRepository.java @@ -0,0 +1,193 @@ +package com.mzc.secondproject.serverless.vocabulary.repository; + +import com.mzc.secondproject.serverless.vocabulary.model.UserWord; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class UserWordRepository { + + private static final Logger logger = LoggerFactory.getLogger(UserWordRepository.class); + + // Singleton 패턴으로 Cold Start 최적화 + private static final DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); + private static final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(dynamoDbClient) + .build(); + private static final String tableName = System.getenv("VOCAB_TABLE_NAME"); + + private final DynamoDbTable table; + + public UserWordRepository() { + this.table = enhancedClient.table(tableName, TableSchema.fromBean(UserWord.class)); + } + + public UserWord save(UserWord userWord) { + logger.info("Saving user word: userId={}, wordId={}", userWord.getUserId(), userWord.getWordId()); + table.putItem(userWord); + return userWord; + } + + public Optional findByUserIdAndWordId(String userId, String wordId) { + Key key = Key.builder() + .partitionValue("USER#" + userId) + .sortValue("WORD#" + wordId) + .build(); + + UserWord userWord = table.getItem(key); + return Optional.ofNullable(userWord); + } + + /** + * 사용자의 모든 단어 학습 상태 조회 - 페이지네이션 + */ + public UserWordPage findByUserIdWithPagination(String userId, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue("USER#" + userId) + .sortValue("WORD#") + .build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.query(requestBuilder.build()).iterator().next(); + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + + return new UserWordPage(page.items(), nextCursor); + } + + /** + * 복습 예정 단어 조회 (오늘 이전 날짜) - 페이지네이션 + */ + public UserWordPage findReviewDueWords(String userId, String todayDate, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortLessThanOrEqualTo(Key.builder() + .partitionValue("USER#" + userId + "#REVIEW") + .sortValue("DATE#" + todayDate) + .build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + DynamoDbIndex gsi1 = table.index("GSI1"); + Page page = gsi1.query(requestBuilder.build()).iterator().next(); + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + + return new UserWordPage(page.items(), nextCursor); + } + + /** + * 상태별 단어 조회 - 페이지네이션 + */ + public UserWordPage findByUserIdAndStatus(String userId, String status, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder() + .partitionValue("USER#" + userId + "#STATUS") + .sortValue("STATUS#" + status) + .build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + DynamoDbIndex gsi2 = table.index("GSI2"); + Page page = gsi2.query(requestBuilder.build()).iterator().next(); + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + + return new UserWordPage(page.items(), nextCursor); + } + + private String encodeCursor(Map lastEvaluatedKey) { + if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { + return null; + } + + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : lastEvaluatedKey.entrySet()) { + if (sb.length() > 0) sb.append("|"); + sb.append(entry.getKey()).append("=").append(entry.getValue().s()); + } + + return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); + } + + private Map decodeCursor(String cursor) { + try { + String decoded = new String(Base64.getUrlDecoder().decode(cursor)); + Map result = new HashMap<>(); + + for (String pair : decoded.split("\\|")) { + String[] kv = pair.split("=", 2); + if (kv.length == 2) { + result.put(kv[0], AttributeValue.builder().s(kv[1]).build()); + } + } + + return result.isEmpty() ? null : result; + } catch (Exception e) { + logger.error("Failed to decode cursor: {}", cursor, e); + return null; + } + } + + public static class UserWordPage { + private final List userWords; + private final String nextCursor; + + public UserWordPage(List userWords, String nextCursor) { + this.userWords = userWords; + this.nextCursor = nextCursor; + } + + public List getUserWords() { + return userWords; + } + + public String getNextCursor() { + return nextCursor; + } + + public boolean hasMore() { + return nextCursor != null; + } + } +} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/WordRepository.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/WordRepository.java new file mode 100644 index 00000000..0acf0c01 --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/WordRepository.java @@ -0,0 +1,170 @@ +package com.mzc.secondproject.serverless.vocabulary.repository; + +import com.mzc.secondproject.serverless.vocabulary.model.Word; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class WordRepository { + + private static final Logger logger = LoggerFactory.getLogger(WordRepository.class); + + // Singleton 패턴으로 Cold Start 최적화 + private static final DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); + private static final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(dynamoDbClient) + .build(); + private static final String tableName = System.getenv("VOCAB_TABLE_NAME"); + + private final DynamoDbTable table; + + public WordRepository() { + this.table = enhancedClient.table(tableName, TableSchema.fromBean(Word.class)); + } + + public Word save(Word word) { + logger.info("Saving word to DynamoDB: {}", word.getWordId()); + table.putItem(word); + return word; + } + + public Optional findById(String wordId) { + Key key = Key.builder() + .partitionValue("WORD#" + wordId) + .sortValue("METADATA") + .build(); + + Word word = table.getItem(key); + return Optional.ofNullable(word); + } + + public void delete(String wordId) { + Key key = Key.builder() + .partitionValue("WORD#" + wordId) + .sortValue("METADATA") + .build(); + + table.deleteItem(key); + logger.info("Deleted word: {}", wordId); + } + + /** + * 난이도별 단어 조회 - 페이지네이션 + */ + public WordPage findByLevelWithPagination(String level, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder().partitionValue("LEVEL#" + level).build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + DynamoDbIndex gsi1 = table.index("GSI1"); + Page page = gsi1.query(requestBuilder.build()).iterator().next(); + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + + return new WordPage(page.items(), nextCursor); + } + + /** + * 카테고리별 단어 조회 - 페이지네이션 + */ + public WordPage findByCategoryWithPagination(String category, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder().partitionValue("CATEGORY#" + category).build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + DynamoDbIndex gsi2 = table.index("GSI2"); + Page page = gsi2.query(requestBuilder.build()).iterator().next(); + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + + return new WordPage(page.items(), nextCursor); + } + + private String encodeCursor(Map lastEvaluatedKey) { + if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { + return null; + } + + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : lastEvaluatedKey.entrySet()) { + if (sb.length() > 0) sb.append("|"); + sb.append(entry.getKey()).append("=").append(entry.getValue().s()); + } + + return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); + } + + private Map decodeCursor(String cursor) { + try { + String decoded = new String(Base64.getUrlDecoder().decode(cursor)); + Map result = new HashMap<>(); + + for (String pair : decoded.split("\\|")) { + String[] kv = pair.split("=", 2); + if (kv.length == 2) { + result.put(kv[0], AttributeValue.builder().s(kv[1]).build()); + } + } + + return result.isEmpty() ? null : result; + } catch (Exception e) { + logger.error("Failed to decode cursor: {}", cursor, e); + return null; + } + } + + public static class WordPage { + private final List words; + private final String nextCursor; + + public WordPage(List words, String nextCursor) { + this.words = words; + this.nextCursor = nextCursor; + } + + public List getWords() { + return words; + } + + public String getNextCursor() { + return nextCursor; + } + + public boolean hasMore() { + return nextCursor != null; + } + } +} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/service/PollyService.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/service/PollyService.java new file mode 100644 index 00000000..5e7cb5c1 --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/service/PollyService.java @@ -0,0 +1,160 @@ +package com.mzc.secondproject.serverless.vocabulary.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.polly.PollyClient; +import software.amazon.awssdk.services.polly.model.OutputFormat; +import software.amazon.awssdk.services.polly.model.SynthesizeSpeechRequest; +import software.amazon.awssdk.services.polly.model.VoiceId; +import software.amazon.awssdk.services.s3.S3Client; +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.PresignedGetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.time.Duration; + +public class PollyService { + + private static final Logger logger = LoggerFactory.getLogger(PollyService.class); + + // Singleton 패턴으로 Cold Start 최적화 + private static final PollyClient pollyClient = PollyClient.builder().build(); + private static final S3Client s3Client = S3Client.builder().build(); + private static final S3Presigner s3Presigner = S3Presigner.builder().build(); + private static final String bucketName = System.getenv("VOCAB_BUCKET_NAME"); + + /** + * 단어 ID 기반으로 음성 합성 (캐시 지원) + * S3에 파일이 있으면 바로 URL 반환, 없으면 Polly 변환 후 저장 + */ + public VoiceSynthesisResult synthesizeSpeechForWord(String wordId, String text, String voice) { + String s3Key = generateS3Key(wordId, voice); + + // 캐시 확인: S3에 이미 존재하는지 체크 + if (existsInS3(s3Key)) { + logger.info("Cache hit: {}", s3Key); + String presignedUrl = getPresignedUrl(s3Key); + return new VoiceSynthesisResult(s3Key, presignedUrl, true); + } + + // 캐시 미스: Polly 변환 후 S3 저장 + logger.info("Cache miss: synthesizing and saving to {}", s3Key); + synthesizeAndSave(text, voice, s3Key); + String presignedUrl = getPresignedUrl(s3Key); + return new VoiceSynthesisResult(s3Key, presignedUrl, false); + } + + /** + * S3 키로 Pre-signed URL 생성 + */ + public String getPresignedUrl(String s3Key) { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofHours(1)) + .getObjectRequest(getObjectRequest) + .build(); + + PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest); + return presignedRequest.url().toString(); + } + + /** + * S3에 파일 존재 여부 확인 + */ + public boolean existsInS3(String s3Key) { + try { + s3Client.headObject(HeadObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .build()); + return true; + } catch (NoSuchKeyException e) { + return false; + } + } + + /** + * Polly로 음성 변환 후 지정된 S3 키로 저장 + */ + private void synthesizeAndSave(String text, String voice, String s3Key) { + VoiceId voiceId = resolveVoiceId(voice); + + try { + SynthesizeSpeechRequest request = SynthesizeSpeechRequest.builder() + .text(text) + .voiceId(voiceId) + .engine("neural") + .outputFormat(OutputFormat.MP3) + .build(); + + InputStream audioStream = pollyClient.synthesizeSpeech(request); + + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] data = new byte[4096]; + int bytesRead; + while ((bytesRead = audioStream.read(data, 0, data.length)) != -1) { + buffer.write(data, 0, bytesRead); + } + byte[] audioBytes = buffer.toByteArray(); + + s3Client.putObject( + PutObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .contentType("audio/mpeg") + .build(), + RequestBody.fromBytes(audioBytes) + ); + + logger.info("Saved audio to S3: {}", s3Key); + } catch (Exception e) { + logger.error("Error synthesizing speech", e); + throw new RuntimeException("Failed to synthesize speech", e); + } + } + + /** + * 단어 ID와 음성 타입으로 S3 키 생성 + */ + public String generateS3Key(String wordId, String voice) { + String voiceSuffix = "MALE".equalsIgnoreCase(voice) ? "male" : "female"; + return "vocab/voice/" + wordId + "_" + voiceSuffix + ".mp3"; + } + + /** + * 음성 합성 결과 + */ + public static class VoiceSynthesisResult { + private final String s3Key; + private final String audioUrl; + private final boolean cached; + + public VoiceSynthesisResult(String s3Key, String audioUrl, boolean cached) { + this.s3Key = s3Key; + this.audioUrl = audioUrl; + this.cached = cached; + } + + public String getS3Key() { return s3Key; } + public String getAudioUrl() { return audioUrl; } + public boolean isCached() { return cached; } + } + + private VoiceId resolveVoiceId(String voice) { + if ("MALE".equalsIgnoreCase(voice)) { + return VoiceId.MATTHEW; // 미국 영어 남성 (Neural 지원) + } + return VoiceId.JOANNA; // 미국 영어 여성 (Neural 지원, 기본값) + } +} diff --git a/vocabulary/VocabFunction/src/main/resources/log4j2.xml b/vocabulary/VocabFunction/src/main/resources/log4j2.xml new file mode 100644 index 00000000..69a29404 --- /dev/null +++ b/vocabulary/VocabFunction/src/main/resources/log4j2.xml @@ -0,0 +1,17 @@ + + + + + + %d{yyyy-MM-dd HH:mm:ss} %X{AWSRequestId} %-5p %c{1} - %m%n + + + + + + + + + + + diff --git a/vocabulary/template.yaml b/vocabulary/template.yaml new file mode 100644 index 00000000..4970f7d8 --- /dev/null +++ b/vocabulary/template.yaml @@ -0,0 +1,314 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: Group2 English Study - Vocabulary Domain + +Globals: + Function: + Timeout: 30 + MemorySize: 512 + Runtime: java21 + Architectures: + - x86_64 + Environment: + Variables: + VOCAB_TABLE_NAME: !Ref VocabTable + VOCAB_BUCKET_NAME: group2-englishstudy + AWS_REGION_NAME: !Ref AWS::Region + +Resources: + ############################################# + # Lambda Functions + ############################################# + + # 단어 관리 함수 + WordFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-word-handler + CodeUri: VocabFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.WordHandler::handleRequest + Description: Handle word CRUD operations + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + CreateWord: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/words + Method: POST + GetWords: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/words + Method: GET + GetWord: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/words/{wordId} + Method: GET + UpdateWord: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/words/{wordId} + Method: PUT + DeleteWord: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/words/{wordId} + Method: DELETE + + # 사용자 단어 학습 상태 관리 함수 + UserWordFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-userword-handler + CodeUri: VocabFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.UserWordHandler::handleRequest + Description: Handle user word learning status + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + GetUserWords: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/users/{userId}/words + Method: GET + GetUserWord: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/users/{userId}/words/{wordId} + Method: GET + UpdateUserWord: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/users/{userId}/words/{wordId} + Method: PUT + + # 일일 학습 관리 함수 (55개 단어 할당) + DailyStudyFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-daily-handler + CodeUri: VocabFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.DailyStudyHandler::handleRequest + Description: Handle daily study word assignment + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + GetDailyWords: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/daily/{userId} + Method: GET + MarkWordLearned: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/daily/{userId}/words/{wordId}/learned + Method: POST + + # 시험 기능 함수 + TestFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-test-handler + CodeUri: VocabFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.TestHandler::handleRequest + Description: Handle vocabulary tests + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + StartTest: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/test/{userId}/start + Method: POST + SubmitAnswer: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/test/{userId}/submit + Method: POST + GetTestResult: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/test/{userId}/results + Method: GET + + # 통계 함수 + StatsFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-stats-handler + CodeUri: VocabFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.StatsHandler::handleRequest + Description: Handle user learning statistics + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + GetStats: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/stats/{userId} + Method: GET + GetDailyStats: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/stats/{userId}/daily + Method: GET + + # 음성 변환 함수 (Polly) + VoiceFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-voice-handler + CodeUri: VocabFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.VoiceHandler::handleRequest + Description: Convert word to speech using Polly + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + - S3CrudPolicy: + BucketName: group2-englishstudy + - Statement: + - Effect: Allow + Action: + - polly:SynthesizeSpeech + - polly:DescribeVoices + Resource: "*" + Events: + TextToSpeech: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/voice/synthesize + Method: POST + + ############################################# + # API Gateway + ############################################# + + VocabApi: + Type: AWS::Serverless::Api + Properties: + Name: group2-englishstudy-vocab-api + StageName: dev + Cors: + AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" + AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" + AllowOrigin: "'*'" + + ############################################# + # DynamoDB + ############################################# + + VocabTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: group2-englishstudy-vocab + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + - AttributeName: GSI1PK + AttributeType: S + - AttributeName: GSI1SK + AttributeType: S + - AttributeName: GSI2PK + AttributeType: S + - AttributeName: GSI2SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: GSI1 + KeySchema: + - AttributeName: GSI1PK + KeyType: HASH + - AttributeName: GSI1SK + KeyType: RANGE + Projection: + ProjectionType: ALL + - IndexName: GSI2 + KeySchema: + - AttributeName: GSI2PK + KeyType: HASH + - AttributeName: GSI2SK + KeyType: RANGE + Projection: + ProjectionType: ALL + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true + +############################################# +# Outputs +############################################# + +Outputs: + VocabApiUrl: + Description: API Gateway endpoint URL + Value: !Sub 'https://${VocabApi}.execute-api.${AWS::Region}.amazonaws.com/dev/' + + VocabTableName: + Description: DynamoDB Table Name + Value: !Ref VocabTable + + WordFunctionArn: + Description: Word Lambda Function ARN + Value: !GetAtt WordFunction.Arn + + UserWordFunctionArn: + Description: UserWord Lambda Function ARN + Value: !GetAtt UserWordFunction.Arn + + DailyStudyFunctionArn: + Description: DailyStudy Lambda Function ARN + Value: !GetAtt DailyStudyFunction.Arn + + TestFunctionArn: + Description: Test Lambda Function ARN + Value: !GetAtt TestFunction.Arn + + StatsFunctionArn: + Description: Stats Lambda Function ARN + Value: !GetAtt StatsFunction.Arn + + VoiceFunctionArn: + Description: Voice Lambda Function ARN + Value: !GetAtt VoiceFunction.Arn