From 143c0ff6449662f21cbc3b4080e8539f71e6218d Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:53:04 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat=20:=20speaking=20REST=20API=20?= =?UTF-8?q?=EB=9E=8C=EB=8B=A4=20=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=20(#491)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * fix : 오타 수정 * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: add CORS headers to API Gateway error responses (#479) API Gateway의 인증 실패 등 에러 응답에 CORS 헤더가 누락되어 CloudFront를 통한 프론트엔드 요청이 차단되는 문제 수정 - UNAUTHORIZED (401) 응답에 CORS 헤더 추가 - ACCESS_DENIED (403) 응답에 CORS 헤더 추가 - DEFAULT_4XX/5XX 응답에 CORS 헤더 추가 - EXPIRED_TOKEN 응답에 CORS 헤더 추가 * feat : speaking rest API 람다 함수 추가 --------- Co-authored-by: ddingjoo --- .../speaking/service/SpeakingService.java | 2 +- ServerlessFunction/template.yaml | 56 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java index 010c4afe..3dffac92 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java @@ -342,4 +342,4 @@ public record SpeakingResponse( String aiAudioUrl, // AI 응답 음성 URL (Polly) double confidence // STT 신뢰도comp ) {} -} \ No newline at end of file +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index ba797ea6..fdd9351f 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1388,6 +1388,62 @@ Resources: Description: Daily word learning stats aggregation Enabled: true + ############################################# + # Speaking REST API (AI와 대화하기) + ############################################# + + SpeakingFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-speaking-handler + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.speaking.handler.SpeakingHandler::handleRequest + Description: Handle Speaking AI conversation (REST API) + Timeout: 120 + MemorySize: 1024 + SnapStart: + ApplyOn: PublishedVersions + Environment: + Variables: + TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ChatTable + - S3CrudPolicy: + BucketName: group2-englishstudy + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + Resource: "*" + - Statement: + - Effect: Allow + Action: + - polly:SynthesizeSpeech + Resource: "*" + - Statement: + - Effect: Allow + Action: + - ssm:GetParameter + Resource: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/opic/*" + Events: + SpeakingChat: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /api/speaking/chat + Method: POST + Auth: + Authorizer: CognitoAuthorizer + SpeakingReset: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /api/speaking/reset + Method: POST + Auth: + Authorizer: CognitoAuthorizer + ############################################# # OPIc Lambda Functions ############################################# From f82e649d69f71d5f8c5a641ba785fbd419ccc5a0 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 09:19:22 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(news):=20=EB=89=B4=EC=8A=A4=20?= =?UTF-8?q?=ED=95=99=EC=8A=B5=20=EB=B0=B0=EC=A7=80=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=EA=B5=AC=ED=98=84=20(#473)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 14개 뉴스 관련 배지 추가 (읽기, 퀴즈, 단어수집, 연속학습, 마스터) - UserStats에 뉴스 통계 필드 추가 - 6개 뉴스 배지 Strategy 클래스 생성 - 뉴스 읽기/퀴즈/단어수집 시 통계 업데이트 및 배지 체크 연동 --- .../domain/badge/enums/BadgeType.java | 26 +++- .../BadgeConditionStrategyFactory.java | 7 + .../badge/strategy/NewsMasterStrategy.java | 45 ++++++ .../strategy/NewsQuizPerfectStrategy.java | 25 ++++ .../badge/strategy/NewsQuizStrategy.java | 25 ++++ .../badge/strategy/NewsReadStrategy.java | 25 ++++ .../badge/strategy/NewsStreakStrategy.java | 25 ++++ .../badge/strategy/NewsWordStrategy.java | 25 ++++ .../domain/news/handler/NewsHandler.java | 12 +- .../news/service/NewsLearningService.java | 37 ++++- .../domain/news/service/NewsQuizService.java | 31 +++- .../domain/news/service/NewsWordService.java | 43 +++++- .../domain/stats/model/UserStats.java | 10 +- .../stats/repository/UserStatsRepository.java | 134 ++++++++++++++++++ 14 files changed, 457 insertions(+), 13 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsMasterStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizPerfectStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsReadStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsStreakStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsWordStrategy.java 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) */