From ddbdc1b912f13fb682e6774168bd41c37757af7f Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 14:01:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(news):=20=EB=B3=B5=ED=95=A9=20=ED=80=B4?= =?UTF-8?q?=EC=A6=88=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#471)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 퀴즈 조회 API (GET /news/{articleId}/quiz) - 퀴즈 제출 API (POST /news/{articleId}/quiz) - 퀴즈 기록 조회 API (GET /news/quiz/history) - NewsQuizResult 모델 추가 - QuizAnswerResult 모델 추가 - NewsQuizRepository 추가 - NewsQuizService 추가 --- .../domain/news/handler/NewsHandler.java | 97 ++++++- .../domain/news/model/NewsQuizResult.java | 63 +++++ .../domain/news/model/QuizAnswerResult.java | 25 ++ .../news/repository/NewsQuizRepository.java | 126 +++++++++ .../domain/news/service/NewsQuizService.java | 263 ++++++++++++++++++ ServerlessFunction/template.yaml | 18 ++ 6 files changed, 590 insertions(+), 2 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java index 8e42762d..ac23fd5d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -11,9 +11,14 @@ import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.news.exception.NewsErrorCode; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import com.mzc.secondproject.serverless.domain.news.model.NewsQuizResult; import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; import com.mzc.secondproject.serverless.domain.news.service.NewsLearningService; import com.mzc.secondproject.serverless.domain.news.service.NewsQueryService; +import com.mzc.secondproject.serverless.domain.news.service.NewsQuizService; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -30,18 +35,21 @@ public class NewsHandler implements RequestHandler quizData = quizService.getQuiz(articleId, userId); + + if (quizData.isEmpty()) { + return ResponseGenerator.fail(NewsErrorCode.QUIZ_NOT_FOUND); + } + + return ResponseGenerator.ok("퀴즈 조회 성공", quizData.get()); + } + + /** + * 퀴즈 제출 + * POST /news/{articleId}/quiz + */ + private APIGatewayProxyResponseEvent submitQuiz(APIGatewayProxyRequestEvent request) { + String userId = getUserId(request); + if (userId == null) { + return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); + } + + String articleId = request.getPathParameters().get("articleId"); + + // 요청 바디 파싱 + JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); + JsonArray answersArray = body.getAsJsonArray("answers"); + Integer timeTaken = body.has("timeTaken") ? body.get("timeTaken").getAsInt() : null; + + List answers = new java.util.ArrayList<>(); + if (answersArray != null) { + answersArray.forEach(e -> { + JsonObject a = e.getAsJsonObject(); + answers.add(new NewsQuizService.QuizAnswer( + a.get("questionId").getAsString(), + a.get("answer").getAsString() + )); + }); + } + + NewsQuizService.QuizSubmitResult result = quizService.submitQuiz(userId, articleId, answers, timeTaken); + + if (result == null) { + return ResponseGenerator.fail(NewsErrorCode.QUIZ_ALREADY_SUBMITTED); + } + + return ResponseGenerator.ok("퀴즈 제출 성공", result); + } + + /** + * 퀴즈 기록 조회 + * GET /news/quiz/history?limit=10 + */ + private APIGatewayProxyResponseEvent getQuizHistory(APIGatewayProxyRequestEvent request) { + String userId = getUserId(request); + if (userId == null) { + return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); + } + + Map params = request.getQueryStringParameters(); + if (params == null) params = new HashMap<>(); + + int limit = parseLimit(params.get("limit")); + List history = quizService.getUserQuizHistory(userId, limit); + Map quizStats = quizService.getUserQuizStats(userId); + + Map response = new HashMap<>(); + response.put("history", history); + response.put("stats", quizStats); + response.put("count", history.size()); + + return ResponseGenerator.ok("퀴즈 기록 조회 성공", response); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java new file mode 100644 index 00000000..23b47f62 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java @@ -0,0 +1,63 @@ +package com.mzc.secondproject.serverless.domain.news.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +import java.util.List; + +/** + * 뉴스 퀴즈 결과 + * PK: USER#{userId}#NEWS + * SK: QUIZ#{articleId} + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class NewsQuizResult { + + private String pk; // USER#{userId}#NEWS + private String sk; // QUIZ#{articleId} + private String gsi1pk; // USER_NEWS_STAT#{userId} + private String gsi1sk; // {date}#QUIZ + + private String userId; + private String articleId; + private String articleTitle; + private String articleLevel; + private int score; // 0-100 + private int totalPoints; // 총 배점 + private int earnedPoints; // 획득 점수 + private List answers; + private Integer timeTaken; // 소요 시간 (초) + private String submittedAt; + 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/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java new file mode 100644 index 00000000..3dee95b6 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.news.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; + +/** + * 퀴즈 답변 결과 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class QuizAnswerResult { + + private String questionId; + private String type; // COMPREHENSION, WORD_MATCH, FILL_BLANK + private String userAnswer; + private String correctAnswer; + private boolean correct; + private int points; // 획득 점수 (정답시 배점, 오답시 0) +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java new file mode 100644 index 00000000..b2786f99 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java @@ -0,0 +1,126 @@ +package com.mzc.secondproject.serverless.domain.news.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; +import com.mzc.secondproject.serverless.domain.news.model.NewsQuizResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.*; +import software.amazon.awssdk.enhanced.dynamodb.model.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * 뉴스 퀴즈 결과 Repository + */ +public class NewsQuizRepository { + + private static final Logger logger = LoggerFactory.getLogger(NewsQuizRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); + + private final DynamoDbTable table; + + public NewsQuizRepository() { + this(AwsClients.dynamoDbEnhanced()); + } + + public NewsQuizRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(NewsQuizResult.class)); + } + + /** + * 퀴즈 결과 저장 + */ + public void save(NewsQuizResult result) { + table.putItem(result); + logger.debug("퀴즈 결과 저장: userId={}, articleId={}, score={}", + result.getUserId(), result.getArticleId(), result.getScore()); + } + + /** + * 퀴즈 결과 조회 + */ + public Optional findByUserAndArticle(String userId, String articleId) { + Key key = Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue(NewsKey.quizSk(articleId)) + .build(); + + NewsQuizResult result = table.getItem(key); + return Optional.ofNullable(result); + } + + /** + * 퀴즈 제출 여부 확인 + */ + public boolean hasSubmitted(String userId, String articleId) { + return findByUserAndArticle(userId, articleId).isPresent(); + } + + /** + * 사용자 퀴즈 결과 목록 조회 + */ + public List getUserQuizResults(String userId, int limit) { + QueryConditional queryConditional = QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue("QUIZ#") + .build() + ); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) + .limit(limit) + .build(); + + List results = new ArrayList<>(); + for (Page page : table.query(request)) { + results.addAll(page.items()); + if (results.size() >= limit) break; + } + + return results.subList(0, Math.min(results.size(), limit)); + } + + /** + * 사용자 퀴즈 통계 조회 + */ + public QuizStats getUserQuizStats(String userId) { + QueryConditional queryConditional = QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue("QUIZ#") + .build() + ); + + int totalQuizzes = 0; + int totalScore = 0; + int perfectScores = 0; + + for (Page page : table.query(queryConditional)) { + for (NewsQuizResult result : page.items()) { + totalQuizzes++; + totalScore += result.getScore(); + if (result.getScore() == 100) { + perfectScores++; + } + } + } + + int avgScore = totalQuizzes > 0 ? totalScore / totalQuizzes : 0; + return new QuizStats(totalQuizzes, avgScore, perfectScores); + } + + /** + * 퀴즈 통계 레코드 + */ + public record QuizStats( + int totalQuizzes, + int avgScore, + int perfectScores + ) {} +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java new file mode 100644 index 00000000..bb22fc90 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java @@ -0,0 +1,263 @@ +package com.mzc.secondproject.serverless.domain.news.service; + +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; +import com.mzc.secondproject.serverless.domain.news.model.*; +import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; +import com.mzc.secondproject.serverless.domain.news.repository.NewsQuizRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.*; + +/** + * 뉴스 퀴즈 서비스 + */ +public class NewsQuizService { + + private static final Logger logger = LoggerFactory.getLogger(NewsQuizService.class); + + private final NewsArticleRepository articleRepository; + private final NewsQuizRepository quizRepository; + + public NewsQuizService() { + this.articleRepository = new NewsArticleRepository(); + this.quizRepository = new NewsQuizRepository(); + } + + public NewsQuizService(NewsArticleRepository articleRepository, NewsQuizRepository quizRepository) { + this.articleRepository = articleRepository; + this.quizRepository = quizRepository; + } + + /** + * 퀴즈 조회 + */ + public Optional getQuiz(String articleId, String userId) { + Optional articleOpt = articleRepository.findById(articleId); + if (articleOpt.isEmpty()) { + logger.warn("기사를 찾을 수 없음: {}", articleId); + return Optional.empty(); + } + + NewsArticle article = articleOpt.get(); + List questions = article.getQuiz(); + + if (questions == null || questions.isEmpty()) { + logger.warn("퀴즈가 없는 기사: {}", articleId); + return Optional.empty(); + } + + // 이미 제출했는지 확인 + boolean submitted = quizRepository.hasSubmitted(userId, articleId); + + // 정답 제거한 퀴즈 반환 + List questionViews = questions.stream() + .map(q -> QuizQuestionView.builder() + .questionId(q.getQuestionId()) + .type(q.getType()) + .question(q.getQuestion()) + .options(q.getOptions()) + .points(q.getPoints()) + .build()) + .toList(); + + return Optional.of(QuizData.builder() + .articleId(articleId) + .articleTitle(article.getTitle()) + .level(article.getLevel()) + .questions(questionViews) + .totalPoints(questions.stream().mapToInt(QuizQuestion::getPoints).sum()) + .submitted(submitted) + .build()); + } + + /** + * 퀴즈 제출 및 채점 + */ + public QuizSubmitResult submitQuiz(String userId, String articleId, List answers, Integer timeTaken) { + // 이미 제출했는지 확인 + if (quizRepository.hasSubmitted(userId, articleId)) { + logger.warn("이미 제출한 퀴즈: userId={}, articleId={}", userId, articleId); + return null; + } + + // 기사 조회 + Optional articleOpt = articleRepository.findById(articleId); + if (articleOpt.isEmpty()) { + logger.warn("기사를 찾을 수 없음: {}", articleId); + return null; + } + + NewsArticle article = articleOpt.get(); + List questions = article.getQuiz(); + + if (questions == null || questions.isEmpty()) { + logger.warn("퀴즈가 없는 기사: {}", articleId); + return null; + } + + // 정답 맵 생성 + Map questionMap = new HashMap<>(); + for (QuizQuestion q : questions) { + questionMap.put(q.getQuestionId(), q); + } + + // 채점 + List answerResults = new ArrayList<>(); + int earnedPoints = 0; + int totalPoints = 0; + + for (QuizAnswer answer : answers) { + QuizQuestion question = questionMap.get(answer.questionId()); + if (question == null) continue; + + boolean correct = question.getCorrectAnswer().equalsIgnoreCase(answer.answer()); + int points = correct ? question.getPoints() : 0; + earnedPoints += points; + totalPoints += question.getPoints(); + + answerResults.add(QuizAnswerResult.builder() + .questionId(answer.questionId()) + .type(question.getType()) + .userAnswer(answer.answer()) + .correctAnswer(question.getCorrectAnswer()) + .correct(correct) + .points(points) + .build()); + } + + // 점수 계산 (100점 만점) + int score = totalPoints > 0 ? (earnedPoints * 100) / totalPoints : 0; + + // 결과 저장 + String now = Instant.now().toString(); + String today = LocalDate.now().toString(); + + NewsQuizResult result = NewsQuizResult.builder() + .pk(NewsKey.userNewsPk(userId)) + .sk(NewsKey.quizSk(articleId)) + .gsi1pk(NewsKey.userNewsStatPk(userId)) + .gsi1sk(today + "#QUIZ") + .userId(userId) + .articleId(articleId) + .articleTitle(article.getTitle()) + .articleLevel(article.getLevel()) + .score(score) + .totalPoints(totalPoints) + .earnedPoints(earnedPoints) + .answers(answerResults) + .timeTaken(timeTaken) + .submittedAt(now) + .build(); + + quizRepository.save(result); + logger.info("퀴즈 제출 완료: userId={}, articleId={}, score={}", userId, articleId, score); + + // 피드백 생성 + String feedback = generateFeedback(score, answerResults); + + return QuizSubmitResult.builder() + .score(score) + .earnedPoints(earnedPoints) + .totalPoints(totalPoints) + .results(answerResults) + .feedback(feedback) + .build(); + } + + /** + * 사용자 퀴즈 결과 조회 + */ + public Optional getQuizResult(String userId, String articleId) { + return quizRepository.findByUserAndArticle(userId, articleId); + } + + /** + * 사용자 퀴즈 기록 목록 조회 + */ + public List getUserQuizHistory(String userId, int limit) { + return quizRepository.getUserQuizResults(userId, limit); + } + + /** + * 사용자 퀴즈 통계 조회 + */ + public Map getUserQuizStats(String userId) { + NewsQuizRepository.QuizStats stats = quizRepository.getUserQuizStats(userId); + return Map.of( + "totalQuizzes", stats.totalQuizzes(), + "avgScore", stats.avgScore(), + "perfectScores", stats.perfectScores() + ); + } + + /** + * 피드백 생성 + */ + private String generateFeedback(int score, List results) { + if (score == 100) { + return "Perfect! You understood the article completely."; + } else if (score >= 80) { + return "Great job! You have a solid understanding of the article."; + } else if (score >= 60) { + return "Good effort! Review the highlighted words for better comprehension."; + } else if (score >= 40) { + return "Keep practicing! Try reading the article again before retaking the quiz."; + } else { + return "Don't give up! Focus on vocabulary and main ideas."; + } + } + + /** + * 퀴즈 데이터 (정답 제외) + */ + @lombok.Data + @lombok.Builder + @lombok.NoArgsConstructor + @lombok.AllArgsConstructor + public static class QuizData { + private String articleId; + private String articleTitle; + private String level; + private List questions; + private int totalPoints; + private boolean submitted; + } + + /** + * 퀴즈 문제 뷰 (정답 제외) + */ + @lombok.Data + @lombok.Builder + @lombok.NoArgsConstructor + @lombok.AllArgsConstructor + public static class QuizQuestionView { + private String questionId; + private String type; + private String question; + private List options; + private int points; + } + + /** + * 사용자 답변 + */ + public record QuizAnswer(String questionId, String answer) {} + + /** + * 퀴즈 제출 결과 + */ + @lombok.Data + @lombok.Builder + @lombok.NoArgsConstructor + @lombok.AllArgsConstructor + public static class QuizSubmitResult { + private int score; + private int earnedPoints; + private int totalPoints; + private List results; + private String feedback; + } +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 8bc1de01..f701a42f 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1800,6 +1800,24 @@ Resources: RestApiId: !Ref MainApi Path: /news/bookmarks Method: GET + GetQuizHistory: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/quiz/history + Method: GET + GetQuiz: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/quiz + Method: GET + SubmitQuiz: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/quiz + Method: POST MarkAsRead: Type: Api Properties: