diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java index f9d32794..76e857ae 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java @@ -31,7 +31,31 @@ public enum BadgeType { PERFECT_DRAWER("완벽한 출제자", "출제 시 전원이 정답을 맞췄습니다", "perfect_drawer.png", "PERFECT_DRAWS", 1), // 특별 - MASTER("학습 마스터", "모든 업적을 달성했습니다", "master.png", "ALL_BADGES", 1); + MASTER("학습 마스터", "모든 업적을 달성했습니다", "master.png", "ALL_BADGES", 1), + + // 뉴스 - 읽기 + NEWS_FIRST_READ("뉴스 첫 발걸음", "첫 번째 뉴스 읽기 완료", "news_first_read.png", "NEWS_READ", 1), + NEWS_READ_10("뉴스 탐험가", "뉴스 10개 읽기 완료", "news_read_10.png", "NEWS_READ", 10), + NEWS_READ_50("뉴스 애호가", "뉴스 50개 읽기 완료", "news_read_50.png", "NEWS_READ", 50), + NEWS_READ_100("뉴스 전문가", "뉴스 100개 읽기 완료", "news_read_100.png", "NEWS_READ", 100), + + // 뉴스 - 퀴즈 + NEWS_QUIZ_FIRST("퀴즈 도전", "첫 뉴스 퀴즈 완료", "news_quiz_first.png", "NEWS_QUIZ", 1), + NEWS_QUIZ_PERFECT("완벽한 이해", "뉴스 퀴즈에서 만점 달성", "news_quiz_perfect.png", "NEWS_QUIZ_PERFECT", 1), + NEWS_QUIZ_10("퀴즈 탐험가", "뉴스 퀴즈 10회 완료", "news_quiz_10.png", "NEWS_QUIZ", 10), + NEWS_QUIZ_50("퀴즈 마스터", "뉴스 퀴즈 50회 완료", "news_quiz_50.png", "NEWS_QUIZ", 50), + + // 뉴스 - 단어 수집 + NEWS_WORD_10("단어 수집가", "뉴스에서 단어 10개 수집", "news_word_10.png", "NEWS_WORD", 10), + NEWS_WORD_50("단어 사냥꾼", "뉴스에서 단어 50개 수집", "news_word_50.png", "NEWS_WORD", 50), + NEWS_WORD_100("단어 전문가", "뉴스에서 단어 100개 수집", "news_word_100.png", "NEWS_WORD", 100), + + // 뉴스 - 연속 학습 + NEWS_STREAK_7("일주일 뉴스 습관", "7일 연속 뉴스 읽기", "news_streak_7.png", "NEWS_STREAK", 7), + NEWS_STREAK_30("한 달 뉴스 습관", "30일 연속 뉴스 읽기", "news_streak_30.png", "NEWS_STREAK", 30), + + // 뉴스 - 종합 + NEWS_MASTER("뉴스 마스터", "읽기100+퀴즈50+단어100 달성", "news_master.png", "NEWS_MASTER", 1); private static final String BUCKET_NAME = System.getenv("BUCKET_NAME"); private static final String BASE_URL = getBaseUrl(); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java index 01f6ed33..ecfb1e63 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java @@ -22,6 +22,13 @@ public class BadgeConditionStrategyFactory { register(new GamesWonStrategy()); register(new QuickGuessesStrategy()); register(new PerfectDrawsStrategy()); + // 뉴스 관련 전략 + register(new NewsReadStrategy()); + register(new NewsQuizStrategy()); + register(new NewsQuizPerfectStrategy()); + register(new NewsWordStrategy()); + register(new NewsStreakStrategy()); + register(new NewsMasterStrategy()); // 별도 로직이 필요한 카테고리 register(new NoOpStrategy("PERFECT_TEST")); register(new NoOpStrategy("ALL_BADGES")); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsMasterStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsMasterStrategy.java new file mode 100644 index 00000000..43fee824 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsMasterStrategy.java @@ -0,0 +1,45 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 뉴스 마스터 뱃지 조건 전략 + * 읽기 100개 + 퀴즈 50회 + 단어 100개 달성 시 획득 + */ +public class NewsMasterStrategy implements BadgeConditionStrategy { + + private static final int NEWS_READ_REQUIRED = 100; + private static final int NEWS_QUIZ_REQUIRED = 50; + private static final int NEWS_WORD_REQUIRED = 100; + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + int newsRead = stats.getNewsRead() != null ? stats.getNewsRead() : 0; + int newsQuiz = stats.getNewsQuizCompleted() != null ? stats.getNewsQuizCompleted() : 0; + int newsWord = stats.getNewsWordsCollected() != null ? stats.getNewsWordsCollected() : 0; + + return newsRead >= NEWS_READ_REQUIRED + && newsQuiz >= NEWS_QUIZ_REQUIRED + && newsWord >= NEWS_WORD_REQUIRED; + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + int newsRead = stats.getNewsRead() != null ? stats.getNewsRead() : 0; + int newsQuiz = stats.getNewsQuizCompleted() != null ? stats.getNewsQuizCompleted() : 0; + int newsWord = stats.getNewsWordsCollected() != null ? stats.getNewsWordsCollected() : 0; + + // 3가지 조건의 평균 진행률 (각각 100%, 100%, 100% 기준) + int readProgress = Math.min(newsRead * 100 / NEWS_READ_REQUIRED, 100); + int quizProgress = Math.min(newsQuiz * 100 / NEWS_QUIZ_REQUIRED, 100); + int wordProgress = Math.min(newsWord * 100 / NEWS_WORD_REQUIRED, 100); + + return (readProgress + quizProgress + wordProgress) / 3; + } + + @Override + public String getCategory() { + return "NEWS_MASTER"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizPerfectStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizPerfectStrategy.java new file mode 100644 index 00000000..d9790b27 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizPerfectStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 뉴스 퀴즈 만점 뱃지 조건 전략 + */ +public class NewsQuizPerfectStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getNewsQuizPerfect() != null && stats.getNewsQuizPerfect() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getNewsQuizPerfect() != null ? stats.getNewsQuizPerfect() : 0; + } + + @Override + public String getCategory() { + return "NEWS_QUIZ_PERFECT"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizStrategy.java new file mode 100644 index 00000000..4ce390d8 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 뉴스 퀴즈 완료 뱃지 조건 전략 + */ +public class NewsQuizStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getNewsQuizCompleted() != null && stats.getNewsQuizCompleted() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getNewsQuizCompleted() != null ? stats.getNewsQuizCompleted() : 0; + } + + @Override + public String getCategory() { + return "NEWS_QUIZ"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsReadStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsReadStrategy.java new file mode 100644 index 00000000..3e5cee34 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsReadStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 뉴스 읽기 뱃지 조건 전략 + */ +public class NewsReadStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getNewsRead() != null && stats.getNewsRead() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getNewsRead() != null ? stats.getNewsRead() : 0; + } + + @Override + public String getCategory() { + return "NEWS_READ"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsStreakStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsStreakStrategy.java new file mode 100644 index 00000000..cb5f58d0 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsStreakStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 뉴스 연속 읽기 뱃지 조건 전략 + */ +public class NewsStreakStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getNewsStreak() != null && stats.getNewsStreak() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getNewsStreak() != null ? stats.getNewsStreak() : 0; + } + + @Override + public String getCategory() { + return "NEWS_STREAK"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsWordStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsWordStrategy.java new file mode 100644 index 00000000..70c6c1a7 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsWordStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 뉴스 단어 수집 뱃지 조건 전략 + */ +public class NewsWordStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getNewsWordsCollected() != null && stats.getNewsWordsCollected() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getNewsWordsCollected() != null ? stats.getNewsWordsCollected() : 0; + } + + @Override + public String getCategory() { + return "NEWS_WORD"; + } +} 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 180bb7cb..a427051d 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 @@ -416,13 +416,19 @@ private APIGatewayProxyResponseEvent collectWord(APIGatewayProxyRequestEvent req String word = body.get("word").getAsString(); String context = body.has("context") ? body.get("context").getAsString() : ""; - NewsWordCollect collected = wordService.collectWord(userId, articleId, word, context); + NewsWordService.WordCollectResult result = wordService.collectWord(userId, articleId, word, context); - if (collected == null) { + if (result == null || result.wordCollect() == null) { return ResponseGenerator.fail(NewsErrorCode.WORD_ALREADY_COLLECTED); } - return ResponseGenerator.ok("단어 수집 성공", collected); + Map responseData = new java.util.HashMap<>(); + responseData.put("wordCollect", result.wordCollect()); + if (result.newBadges() != null && !result.newBadges().isEmpty()) { + responseData.put("newBadges", result.newBadges()); + } + + return ResponseGenerator.ok("단어 수집 성공", responseData); } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java index 8eba8522..3e13f2c3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java @@ -2,13 +2,18 @@ import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.service.PollyService; +import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; +import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; import com.mzc.secondproject.serverless.domain.news.repository.UserNewsRepository; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; +import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; @@ -24,29 +29,38 @@ public class NewsLearningService { private final NewsArticleRepository articleRepository; private final UserNewsRepository userNewsRepository; private final PollyService pollyService; + private final UserStatsRepository userStatsRepository; + private final BadgeService badgeService; public NewsLearningService() { this.articleRepository = new NewsArticleRepository(); this.userNewsRepository = new UserNewsRepository(); this.pollyService = new PollyService(BUCKET_NAME, "news/audio/"); + this.userStatsRepository = new UserStatsRepository(); + this.badgeService = new BadgeService(); } public NewsLearningService(NewsArticleRepository articleRepository, UserNewsRepository userNewsRepository, - PollyService pollyService) { + PollyService pollyService, + UserStatsRepository userStatsRepository, + BadgeService badgeService) { this.articleRepository = articleRepository; this.userNewsRepository = userNewsRepository; this.pollyService = pollyService; + this.userStatsRepository = userStatsRepository; + this.badgeService = badgeService; } /** * 뉴스 읽기 완료 기록 + * @return 새로 획득한 배지 목록 */ - public void markAsRead(String userId, String articleId) { + public List markAsRead(String userId, String articleId) { Optional article = articleRepository.findById(articleId); if (article.isEmpty()) { logger.warn("기사를 찾을 수 없음: {}", articleId); - return; + return new ArrayList<>(); } NewsArticle a = article.get(); @@ -65,6 +79,23 @@ public void markAsRead(String userId, String articleId) { } logger.info("읽기 완료 기록: userId={}, articleId={}", userId, articleId); + + // 통계 업데이트 및 배지 체크 + List newBadges = new ArrayList<>(); + try { + UserStats updatedStats = userStatsRepository.incrementNewsReadStats(userId); + if (updatedStats != null) { + newBadges = badgeService.checkAndAwardBadges(userId, updatedStats); + if (!newBadges.isEmpty()) { + logger.info("새 배지 획득: userId={}, badges={}", userId, + newBadges.stream().map(UserBadge::getBadgeType).toList()); + } + } + } catch (Exception e) { + logger.error("통계/배지 업데이트 실패: userId={}, error={}", userId, e.getMessage()); + } + + return newBadges; } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java index bb22fc90..da86d430 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java @@ -1,9 +1,13 @@ package com.mzc.secondproject.serverless.domain.news.service; +import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; +import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; 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 com.mzc.secondproject.serverless.domain.stats.model.UserStats; +import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,15 +24,22 @@ public class NewsQuizService { private final NewsArticleRepository articleRepository; private final NewsQuizRepository quizRepository; + private final UserStatsRepository userStatsRepository; + private final BadgeService badgeService; public NewsQuizService() { this.articleRepository = new NewsArticleRepository(); this.quizRepository = new NewsQuizRepository(); + this.userStatsRepository = new UserStatsRepository(); + this.badgeService = new BadgeService(); } - public NewsQuizService(NewsArticleRepository articleRepository, NewsQuizRepository quizRepository) { + public NewsQuizService(NewsArticleRepository articleRepository, NewsQuizRepository quizRepository, + UserStatsRepository userStatsRepository, BadgeService badgeService) { this.articleRepository = articleRepository; this.quizRepository = quizRepository; + this.userStatsRepository = userStatsRepository; + this.badgeService = badgeService; } /** @@ -158,12 +169,29 @@ public QuizSubmitResult submitQuiz(String userId, String articleId, List newBadges = new ArrayList<>(); + try { + boolean isPerfect = score == 100; + UserStats updatedStats = userStatsRepository.incrementNewsQuizStats(userId, isPerfect); + if (updatedStats != null) { + newBadges = badgeService.checkAndAwardBadges(userId, updatedStats); + if (!newBadges.isEmpty()) { + logger.info("새 배지 획득: userId={}, badges={}", userId, + newBadges.stream().map(UserBadge::getBadgeType).toList()); + } + } + } catch (Exception e) { + logger.error("통계/배지 업데이트 실패: userId={}, error={}", userId, e.getMessage()); + } + return QuizSubmitResult.builder() .score(score) .earnedPoints(earnedPoints) .totalPoints(totalPoints) .results(answerResults) .feedback(feedback) + .newBadges(newBadges) .build(); } @@ -259,5 +287,6 @@ public static class QuizSubmitResult { private int totalPoints; private List results; private String feedback; + private List newBadges; } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java index 6c3c23ec..6881c7ec 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java @@ -1,10 +1,14 @@ package com.mzc.secondproject.serverless.domain.news.service; +import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; +import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; import com.mzc.secondproject.serverless.domain.news.model.NewsWordCollect; import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; import com.mzc.secondproject.serverless.domain.news.repository.NewsWordRepository; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; +import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; import com.mzc.secondproject.serverless.domain.vocabulary.service.UserWordCommandService; @@ -12,6 +16,7 @@ import org.slf4j.LoggerFactory; import java.time.Instant; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; @@ -27,32 +32,42 @@ public class NewsWordService { private final NewsArticleRepository articleRepository; private final WordRepository wordRepository; private final UserWordCommandService userWordCommandService; + private final UserStatsRepository userStatsRepository; + private final BadgeService badgeService; public NewsWordService() { this.newsWordRepository = new NewsWordRepository(); this.articleRepository = new NewsArticleRepository(); this.wordRepository = new WordRepository(); this.userWordCommandService = new UserWordCommandService(); + this.userStatsRepository = new UserStatsRepository(); + this.badgeService = new BadgeService(); } public NewsWordService(NewsWordRepository newsWordRepository, NewsArticleRepository articleRepository, WordRepository wordRepository, - UserWordCommandService userWordCommandService) { + UserWordCommandService userWordCommandService, + UserStatsRepository userStatsRepository, + BadgeService badgeService) { this.newsWordRepository = newsWordRepository; this.articleRepository = articleRepository; this.wordRepository = wordRepository; this.userWordCommandService = userWordCommandService; + this.userStatsRepository = userStatsRepository; + this.badgeService = badgeService; } /** * 단어 수집 + * @return 수집 결과 (단어 정보 + 새로 획득한 배지) */ - public NewsWordCollect collectWord(String userId, String articleId, String word, String context) { + public WordCollectResult collectWord(String userId, String articleId, String word, String context) { // 이미 수집했는지 확인 if (newsWordRepository.hasCollected(userId, word, articleId)) { logger.warn("이미 수집한 단어: userId={}, word={}", userId, word); - return newsWordRepository.findByUserWordArticle(userId, word, articleId).orElse(null); + NewsWordCollect existing = newsWordRepository.findByUserWordArticle(userId, word, articleId).orElse(null); + return new WordCollectResult(existing, new ArrayList<>()); } // 기사 조회 @@ -86,9 +101,29 @@ public NewsWordCollect collectWord(String userId, String articleId, String word, newsWordRepository.save(wordCollect); logger.info("단어 수집 완료: userId={}, word={}, articleId={}", userId, word, articleId); - return wordCollect; + // 통계 업데이트 및 배지 체크 + List newBadges = new ArrayList<>(); + try { + UserStats updatedStats = userStatsRepository.incrementNewsWordStats(userId, 1); + if (updatedStats != null) { + newBadges = badgeService.checkAndAwardBadges(userId, updatedStats); + if (!newBadges.isEmpty()) { + logger.info("새 배지 획득: userId={}, badges={}", userId, + newBadges.stream().map(UserBadge::getBadgeType).toList()); + } + } + } catch (Exception e) { + logger.error("통계/배지 업데이트 실패: userId={}, error={}", userId, e.getMessage()); + } + + return new WordCollectResult(wordCollect, newBadges); } + /** + * 단어 수집 결과 + */ + public record WordCollectResult(NewsWordCollect wordCollect, List newBadges) {} + /** * 수집한 단어 삭제 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java index cc25634c..4905a9f2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java @@ -56,7 +56,15 @@ public class UserStats { private Integer totalGameScore; // 누적 게임 점수 private Integer quickGuesses; // 5초 내 정답 횟수 private Integer perfectDraws; // 전원 정답 유도 횟수 - + + // 뉴스 통계 + private Integer newsRead; // 읽은 뉴스 수 + private Integer newsQuizCompleted; // 완료한 뉴스 퀴즈 수 + private Integer newsQuizPerfect; // 뉴스 퀴즈 만점 횟수 + private Integer newsWordsCollected; // 뉴스에서 수집한 단어 수 + private Integer newsStreak; // 뉴스 연속 읽기 일수 + private String lastNewsReadDate; // 마지막 뉴스 읽은 날짜 + // 메타데이터 private String createdAt; private String updatedAt; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java index b3ad20d8..2c49d4c7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java @@ -310,6 +310,140 @@ public void incrementGameStats(String userId, int gamesPlayed, int gamesWon, userId, gamesPlayed, gamesWon, correctGuesses); } + /** + * 뉴스 읽기 통계 Atomic 업데이트 + */ + public UserStats incrementNewsReadStats(String userId) { + String today = LocalDate.now().toString(); + String pk = StatsKey.userStatsPk(userId); + String sk = StatsKey.statsTotalSk(); + String now = Instant.now().toString(); + + // 먼저 현재 통계 조회 (streak 계산용) + UserStats currentStats = findTotalStats(userId).orElse(null); + String lastNewsReadDate = currentStats != null ? currentStats.getLastNewsReadDate() : null; + + // 연속 읽기 계산 + int currentStreak = 1; + if (lastNewsReadDate != null) { + LocalDate lastDate = LocalDate.parse(lastNewsReadDate); + LocalDate todayDate = LocalDate.now(); + if (lastDate.equals(todayDate.minusDays(1))) { + // 어제 읽었으면 streak 증가 + currentStreak = (currentStats.getNewsStreak() != null ? currentStats.getNewsStreak() : 0) + 1; + } else if (lastDate.equals(todayDate)) { + // 오늘 이미 읽었으면 streak 유지 + currentStreak = currentStats.getNewsStreak() != null ? currentStats.getNewsStreak() : 1; + } + // 그 외의 경우는 streak 1로 초기화 + } + + Map key = new HashMap<>(); + key.put("PK", AttributeValue.builder().s(pk).build()); + key.put("SK", AttributeValue.builder().s(sk).build()); + + Map values = new HashMap<>(); + values.put(":one", AttributeValue.builder().n("1").build()); + values.put(":zero", AttributeValue.builder().n("0").build()); + values.put(":streak", AttributeValue.builder().n(String.valueOf(currentStreak)).build()); + values.put(":today", AttributeValue.builder().s(today).build()); + values.put(":now", AttributeValue.builder().s(now).build()); + + String updateExpression = "SET " + + "newsRead = if_not_exists(newsRead, :zero) + :one, " + + "newsStreak = :streak, " + + "lastNewsReadDate = :today, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now)"; + + UpdateItemRequest request = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression(updateExpression) + .expressionAttributeValues(values) + .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) + .build(); + + AwsClients.dynamoDb().updateItem(request); + logger.info("Incremented news read stats: userId={}, streak={}", userId, currentStreak); + + return findTotalStats(userId).orElse(null); + } + + /** + * 뉴스 퀴즈 통계 Atomic 업데이트 + */ + public UserStats incrementNewsQuizStats(String userId, boolean isPerfect) { + String pk = StatsKey.userStatsPk(userId); + String sk = StatsKey.statsTotalSk(); + String now = Instant.now().toString(); + + Map key = new HashMap<>(); + key.put("PK", AttributeValue.builder().s(pk).build()); + key.put("SK", AttributeValue.builder().s(sk).build()); + + Map values = new HashMap<>(); + values.put(":one", AttributeValue.builder().n("1").build()); + values.put(":zero", AttributeValue.builder().n("0").build()); + values.put(":perfect", AttributeValue.builder().n(isPerfect ? "1" : "0").build()); + values.put(":now", AttributeValue.builder().s(now).build()); + + String updateExpression = "SET " + + "newsQuizCompleted = if_not_exists(newsQuizCompleted, :zero) + :one, " + + "newsQuizPerfect = if_not_exists(newsQuizPerfect, :zero) + :perfect, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now)"; + + UpdateItemRequest request = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression(updateExpression) + .expressionAttributeValues(values) + .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) + .build(); + + AwsClients.dynamoDb().updateItem(request); + logger.info("Incremented news quiz stats: userId={}, isPerfect={}", userId, isPerfect); + + return findTotalStats(userId).orElse(null); + } + + /** + * 뉴스 단어 수집 통계 Atomic 업데이트 + */ + public UserStats incrementNewsWordStats(String userId, int wordCount) { + String pk = StatsKey.userStatsPk(userId); + String sk = StatsKey.statsTotalSk(); + String now = Instant.now().toString(); + + Map key = new HashMap<>(); + key.put("PK", AttributeValue.builder().s(pk).build()); + key.put("SK", AttributeValue.builder().s(sk).build()); + + Map values = new HashMap<>(); + values.put(":count", AttributeValue.builder().n(String.valueOf(wordCount)).build()); + values.put(":zero", AttributeValue.builder().n("0").build()); + values.put(":now", AttributeValue.builder().s(now).build()); + + String updateExpression = "SET " + + "newsWordsCollected = if_not_exists(newsWordsCollected, :zero) + :count, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now)"; + + UpdateItemRequest request = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression(updateExpression) + .expressionAttributeValues(values) + .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) + .build(); + + AwsClients.dynamoDb().updateItem(request); + logger.info("Incremented news word stats: userId={}, wordCount={}", userId, wordCount); + + return findTotalStats(userId).orElse(null); + } + /** * 현재 연도-주차 반환 (예: 2026-W02) */