From c70c703dddae6bbaf0af40ee2f9516a5dd06608c Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 13:51:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(news):=20=EB=89=B4=EC=8A=A4=20=ED=95=99?= =?UTF-8?q?=EC=8A=B5=20=EB=B6=80=EA=B0=80=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#389)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 읽기 완료 기록 API (POST /news/{articleId}/read) - 북마크 토글 API (POST /news/{articleId}/bookmark) - 북마크 목록 조회 API (GET /news/bookmarks) - 학습 통계 조회 API (GET /news/stats) - TTS 오디오 URL 조회 API (GET /news/{articleId}/audio) - UserNewsRecord 모델 추가 - UserNewsRepository 추가 - NewsLearningService 추가 --- .../domain/news/constants/NewsKey.java | 7 + .../domain/news/exception/NewsErrorCode.java | 3 + .../domain/news/handler/NewsHandler.java | 112 +++++++++- .../domain/news/model/UserNewsRecord.java | 58 +++++ .../news/repository/UserNewsRepository.java | 208 ++++++++++++++++++ .../news/service/NewsLearningService.java | 160 ++++++++++++++ ServerlessFunction/template.yaml | 37 ++++ 7 files changed, 583 insertions(+), 2 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java index e5ba97b8..4c44a58f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java @@ -119,4 +119,11 @@ public static String commentSk(String commentId) { public static String userNewsCommentsPk(String userId) { return DynamoDbKey.USER + userId + SUFFIX_NEWS_COMMENTS; } + + /** + * 사용자 뉴스 통계 GSI1 PK: USER_NEWS_STAT#{userId} + */ + public static String userNewsStatPk(String userId) { + return "USER_NEWS_STAT#" + userId; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java index fa898252..58197f0e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java @@ -8,6 +8,9 @@ */ public enum NewsErrorCode implements DomainErrorCode { + // 인증 관련 에러 + UNAUTHORIZED("AUTH_001", "인증이 필요합니다", 401), + // 뉴스 기사 관련 에러 ARTICLE_NOT_FOUND("ARTICLE_001", "뉴스 기사를 찾을 수 없습니다", 404), INVALID_ARTICLE_DATA("ARTICLE_002", "뉴스 기사 데이터가 유효하지 않습니다", 400), 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 3f53332b..8e42762d 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,11 +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.UserNewsRecord; +import com.mzc.secondproject.serverless.domain.news.service.NewsLearningService; import com.mzc.secondproject.serverless.domain.news.service.NewsQueryService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -29,14 +32,16 @@ public class NewsHandler implements RequestHandler stats = learningService.getUserStats(userId); + return ResponseGenerator.ok("뉴스 학습 통계 조회 성공", stats); + } + + /** + * 북마크 목록 조회 + * GET /news/bookmarks?limit=10 + */ + private APIGatewayProxyResponseEvent getBookmarks(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 bookmarks = learningService.getUserBookmarks(userId, limit); + + Map response = new HashMap<>(); + response.put("bookmarks", bookmarks); + response.put("count", bookmarks.size()); + + return ResponseGenerator.ok("북마크 목록 조회 성공", response); + } + + /** + * 뉴스 읽기 완료 기록 + * POST /news/{articleId}/read + */ + private APIGatewayProxyResponseEvent markAsRead(APIGatewayProxyRequestEvent request) { + String userId = getUserId(request); + if (userId == null) { + return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); + } + + String articleId = request.getPathParameters().get("articleId"); + learningService.markAsRead(userId, articleId); + + return ResponseGenerator.ok("읽기 완료 기록 성공", Map.of("articleId", articleId)); + } + + /** + * 북마크 토글 + * POST /news/{articleId}/bookmark + */ + private APIGatewayProxyResponseEvent toggleBookmark(APIGatewayProxyRequestEvent request) { + String userId = getUserId(request); + if (userId == null) { + return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); + } + + String articleId = request.getPathParameters().get("articleId"); + boolean isBookmarked = learningService.toggleBookmark(userId, articleId); + + return ResponseGenerator.ok( + isBookmarked ? "북마크 추가 성공" : "북마크 해제 성공", + Map.of("articleId", articleId, "bookmarked", isBookmarked) + ); + } + + /** + * 뉴스 TTS 오디오 URL 조회 + * GET /news/{articleId}/audio?voice=Joanna + */ + private APIGatewayProxyResponseEvent getAudio(APIGatewayProxyRequestEvent request) { + String articleId = request.getPathParameters().get("articleId"); + + Map params = request.getQueryStringParameters(); + String voice = (params != null) ? params.getOrDefault("voice", "Joanna") : "Joanna"; + + String audioUrl = learningService.getAudioUrl(articleId, voice); + if (audioUrl == null) { + return ResponseGenerator.fail(NewsErrorCode.ARTICLE_NOT_FOUND); + } + + return ResponseGenerator.ok("TTS 오디오 URL 조회 성공", Map.of("audioUrl", audioUrl)); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java new file mode 100644 index 00000000..eedfa8c5 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java @@ -0,0 +1,58 @@ +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.*; + +/** + * 사용자 뉴스 학습 기록 + * PK: USER_NEWS#{userId} + * SK: READ#{articleId} 또는 BOOKMARK#{articleId} + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class UserNewsRecord { + + private String pk; // USER_NEWS#{userId} + private String sk; // READ#{articleId} 또는 BOOKMARK#{articleId} + private String gsi1pk; // USER_NEWS_STAT#{userId} + private String gsi1sk; // {date}#{type} + + private String userId; + private String articleId; + private String type; // READ, BOOKMARK + private String articleTitle; + private String articleLevel; + private String articleCategory; + private String createdAt; + 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/repository/UserNewsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java new file mode 100644 index 00000000..25f8e651 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java @@ -0,0 +1,208 @@ +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.UserNewsRecord; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.*; +import software.amazon.awssdk.enhanced.dynamodb.model.*; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.*; + +/** + * 사용자 뉴스 학습 기록 Repository + */ +public class UserNewsRepository { + + private static final Logger logger = LoggerFactory.getLogger(UserNewsRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); + + private final DynamoDbTable table; + + public UserNewsRepository() { + this(AwsClients.dynamoDbEnhanced()); + } + + public UserNewsRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(UserNewsRecord.class)); + } + + /** + * 읽기 기록 저장 + */ + public void saveReadRecord(String userId, String articleId, String title, String level, String category) { + String now = Instant.now().toString(); + String today = LocalDate.now().toString(); + + UserNewsRecord record = UserNewsRecord.builder() + .pk(NewsKey.userNewsPk(userId)) + .sk(NewsKey.readSk(articleId)) + .gsi1pk(NewsKey.userNewsStatPk(userId)) + .gsi1sk(today + "#READ") + .userId(userId) + .articleId(articleId) + .type("READ") + .articleTitle(title) + .articleLevel(level) + .articleCategory(category) + .createdAt(now) + .build(); + + table.putItem(record); + logger.debug("읽기 기록 저장: userId={}, articleId={}", userId, articleId); + } + + /** + * 북마크 저장 + */ + public void saveBookmark(String userId, String articleId, String title, String level, String category) { + String now = Instant.now().toString(); + String today = LocalDate.now().toString(); + + UserNewsRecord record = UserNewsRecord.builder() + .pk(NewsKey.userNewsPk(userId)) + .sk(NewsKey.bookmarkSk(articleId)) + .gsi1pk(NewsKey.userNewsStatPk(userId)) + .gsi1sk(today + "#BOOKMARK") + .userId(userId) + .articleId(articleId) + .type("BOOKMARK") + .articleTitle(title) + .articleLevel(level) + .articleCategory(category) + .createdAt(now) + .build(); + + table.putItem(record); + logger.debug("북마크 저장: userId={}, articleId={}", userId, articleId); + } + + /** + * 북마크 삭제 + */ + public void deleteBookmark(String userId, String articleId) { + Key key = Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue(NewsKey.bookmarkSk(articleId)) + .build(); + + table.deleteItem(key); + logger.debug("북마크 삭제: userId={}, articleId={}", userId, articleId); + } + + /** + * 북마크 여부 확인 + */ + public boolean isBookmarked(String userId, String articleId) { + Key key = Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue(NewsKey.bookmarkSk(articleId)) + .build(); + + return table.getItem(key) != null; + } + + /** + * 읽기 기록 여부 확인 + */ + public boolean hasRead(String userId, String articleId) { + Key key = Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue(NewsKey.readSk(articleId)) + .build(); + + return table.getItem(key) != null; + } + + /** + * 사용자 북마크 목록 조회 + */ + public List getUserBookmarks(String userId, int limit) { + QueryConditional queryConditional = QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue("BOOKMARK#") + .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 NewsStats getUserStats(String userId) { + QueryConditional queryConditional = QueryConditional.keyEqualTo( + Key.builder().partitionValue(NewsKey.userNewsPk(userId)).build() + ); + + int totalRead = 0; + int thisWeekRead = 0; + int totalBookmarks = 0; + Map byLevel = new HashMap<>(); + Map byCategory = new HashMap<>(); + + LocalDate weekAgo = LocalDate.now().minusDays(7); + + for (Page page : table.query(queryConditional)) { + for (UserNewsRecord record : page.items()) { + if ("READ".equals(record.getType())) { + totalRead++; + + // 이번 주 읽은 것 + if (record.getCreatedAt() != null) { + LocalDate readDate = Instant.parse(record.getCreatedAt()) + .atZone(java.time.ZoneId.systemDefault()).toLocalDate(); + if (readDate.isAfter(weekAgo)) { + thisWeekRead++; + } + } + + // 레벨별 통계 + String level = record.getArticleLevel(); + if (level != null) { + byLevel.merge(level, 1, Integer::sum); + } + + // 카테고리별 통계 + String category = record.getArticleCategory(); + if (category != null) { + byCategory.merge(category, 1, Integer::sum); + } + } else if ("BOOKMARK".equals(record.getType())) { + totalBookmarks++; + } + } + } + + return new NewsStats(totalRead, thisWeekRead, totalBookmarks, byLevel, byCategory); + } + + /** + * 뉴스 통계 레코드 + */ + public record NewsStats( + int totalRead, + int thisWeekRead, + int totalBookmarks, + Map byLevel, + Map byCategory + ) {} +} 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 new file mode 100644 index 00000000..8eba8522 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java @@ -0,0 +1,160 @@ +package com.mzc.secondproject.serverless.domain.news.service; + +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.common.service.PollyService; +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 org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 뉴스 학습 부가 기능 서비스 + */ +public class NewsLearningService { + + private static final Logger logger = LoggerFactory.getLogger(NewsLearningService.class); + private static final String BUCKET_NAME = EnvConfig.getOrDefault("NEWS_BUCKET_NAME", "group2-englishstudy"); + + private final NewsArticleRepository articleRepository; + private final UserNewsRepository userNewsRepository; + private final PollyService pollyService; + + public NewsLearningService() { + this.articleRepository = new NewsArticleRepository(); + this.userNewsRepository = new UserNewsRepository(); + this.pollyService = new PollyService(BUCKET_NAME, "news/audio/"); + } + + public NewsLearningService(NewsArticleRepository articleRepository, + UserNewsRepository userNewsRepository, + PollyService pollyService) { + this.articleRepository = articleRepository; + this.userNewsRepository = userNewsRepository; + this.pollyService = pollyService; + } + + /** + * 뉴스 읽기 완료 기록 + */ + public void markAsRead(String userId, String articleId) { + Optional article = articleRepository.findById(articleId); + if (article.isEmpty()) { + logger.warn("기사를 찾을 수 없음: {}", articleId); + return; + } + + NewsArticle a = article.get(); + userNewsRepository.saveReadRecord( + userId, + articleId, + a.getTitle(), + a.getLevel(), + a.getCategory() + ); + + // 조회수 증가 + String date = extractDateFromPk(a.getPk()); + if (date != null) { + articleRepository.incrementReadCount(date, articleId); + } + + logger.info("읽기 완료 기록: userId={}, articleId={}", userId, articleId); + } + + /** + * 북마크 토글 + */ + public boolean toggleBookmark(String userId, String articleId) { + boolean isBookmarked = userNewsRepository.isBookmarked(userId, articleId); + + if (isBookmarked) { + userNewsRepository.deleteBookmark(userId, articleId); + logger.info("북마크 해제: userId={}, articleId={}", userId, articleId); + return false; + } else { + Optional article = articleRepository.findById(articleId); + if (article.isEmpty()) { + logger.warn("기사를 찾을 수 없음: {}", articleId); + return false; + } + + NewsArticle a = article.get(); + userNewsRepository.saveBookmark( + userId, + articleId, + a.getTitle(), + a.getLevel(), + a.getCategory() + ); + logger.info("북마크 추가: userId={}, articleId={}", userId, articleId); + return true; + } + } + + /** + * 북마크 여부 확인 + */ + public boolean isBookmarked(String userId, String articleId) { + return userNewsRepository.isBookmarked(userId, articleId); + } + + /** + * 사용자 북마크 목록 조회 + */ + public List getUserBookmarks(String userId, int limit) { + return userNewsRepository.getUserBookmarks(userId, limit); + } + + /** + * 뉴스 TTS 오디오 URL 생성 + */ + public String getAudioUrl(String articleId, String voice) { + Optional article = articleRepository.findById(articleId); + if (article.isEmpty()) { + logger.warn("기사를 찾을 수 없음: {}", articleId); + return null; + } + + NewsArticle a = article.get(); + String text = a.getTitle() + ". " + (a.getSummary() != null ? a.getSummary() : ""); + + // 텍스트가 너무 길면 제한 + if (text.length() > 3000) { + text = text.substring(0, 3000); + } + + PollyService.VoiceSynthesisResult result = pollyService.synthesizeSpeech(articleId, text, voice); + return result.getAudioUrl(); + } + + /** + * 사용자 뉴스 학습 통계 조회 + */ + public Map getUserStats(String userId) { + UserNewsRepository.NewsStats stats = userNewsRepository.getUserStats(userId); + + return Map.of( + "totalRead", stats.totalRead(), + "thisWeekRead", stats.thisWeekRead(), + "totalBookmarks", stats.totalBookmarks(), + "byLevel", stats.byLevel(), + "byCategory", stats.byCategory() + ); + } + + /** + * PK에서 날짜 추출 + */ + private String extractDateFromPk(String pk) { + if (pk == null || !pk.startsWith("NEWS#")) { + return null; + } + return pk.substring(5); + } +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 8288972b..8bc1de01 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1762,6 +1762,13 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref NewsTable + - S3CrudPolicy: + BucketName: !Ref ContentBucket + - Statement: + - Effect: Allow + Action: + - polly:SynthesizeSpeech + Resource: "*" Events: GetNewsList: Type: Api @@ -1781,6 +1788,36 @@ Resources: RestApiId: !Ref MainApi Path: /news/recommended Method: GET + GetNewsStats: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/stats + Method: GET + GetBookmarks: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/bookmarks + Method: GET + MarkAsRead: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/read + Method: POST + ToggleBookmark: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/bookmark + Method: POST + GetAudio: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/audio + Method: GET GetNewsDetail: Type: Api Properties: