From fff1ab5d61d1f829e37817960354714721a22a86 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 14:10:24 +0900 Subject: [PATCH] =?UTF-8?q?feat(news):=20=EB=8B=A8=EC=96=B4=20=EC=88=98?= =?UTF-8?q?=EC=A7=91=20&=20Vocabulary=20=EC=97=B0=EB=8F=99=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#472)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 단어 수집 API (POST /news/{articleId}/words) - 수집 단어 목록 API (GET /news/words) - 단어 상세 조회 API (GET /news/{articleId}/words/{word}) - 단어 삭제 API (DELETE /news/{articleId}/words/{word}) - Vocabulary 연동 API (POST /news/words/{word}/sync) - NewsWordCollect 모델 추가 - NewsWordRepository 추가 - NewsWordService 추가 --- .../domain/news/handler/NewsHandler.java | 122 ++++++++++- .../domain/news/model/NewsWordCollect.java | 62 ++++++ .../news/repository/NewsWordRepository.java | 133 ++++++++++++ .../domain/news/service/NewsWordService.java | 203 ++++++++++++++++++ ServerlessFunction/template.yaml | 32 +++ 5 files changed, 550 insertions(+), 2 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsWordRepository.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java index ac23fd5d..180bb7cb 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 @@ -12,10 +12,12 @@ import com.mzc.secondproject.serverless.domain.news.exception.NewsErrorCode; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; import com.mzc.secondproject.serverless.domain.news.model.NewsQuizResult; +import com.mzc.secondproject.serverless.domain.news.model.NewsWordCollect; import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; import com.mzc.secondproject.serverless.domain.news.service.NewsLearningService; import com.mzc.secondproject.serverless.domain.news.service.NewsQueryService; import com.mzc.secondproject.serverless.domain.news.service.NewsQuizService; +import com.mzc.secondproject.serverless.domain.news.service.NewsWordService; import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonObject; @@ -40,16 +42,19 @@ public class NewsHandler implements RequestHandler params = request.getQueryStringParameters(); + if (params == null) params = new HashMap<>(); + + int limit = parseLimit(params.get("limit")); + List words = wordService.getUserWords(userId, limit); + Map stats = wordService.getUserWordStats(userId); + + Map response = new HashMap<>(); + response.put("words", words); + response.put("stats", stats); + response.put("count", words.size()); + + return ResponseGenerator.ok("수집 단어 목록 조회 성공", response); + } + + /** + * 단어 수집 + * POST /news/{articleId}/words + */ + private APIGatewayProxyResponseEvent collectWord(APIGatewayProxyRequestEvent request) { + String userId = getUserId(request); + if (userId == null) { + return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); + } + + String articleId = request.getPathParameters().get("articleId"); + + JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); + String word = body.get("word").getAsString(); + String context = body.has("context") ? body.get("context").getAsString() : ""; + + NewsWordCollect collected = wordService.collectWord(userId, articleId, word, context); + + if (collected == null) { + return ResponseGenerator.fail(NewsErrorCode.WORD_ALREADY_COLLECTED); + } + + return ResponseGenerator.ok("단어 수집 성공", collected); + } + + /** + * 단어 삭제 + * DELETE /news/{articleId}/words/{word} + */ + private APIGatewayProxyResponseEvent deleteWord(APIGatewayProxyRequestEvent request) { + String userId = getUserId(request); + if (userId == null) { + return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); + } + + String articleId = request.getPathParameters().get("articleId"); + String word = request.getPathParameters().get("word"); + + wordService.deleteWord(userId, word, articleId); + + return ResponseGenerator.ok("단어 삭제 성공", Map.of("word", word)); + } + + /** + * 단어 상세 정보 조회 + * GET /news/{articleId}/words/{word} + */ + private APIGatewayProxyResponseEvent getWordDetail(APIGatewayProxyRequestEvent request) { + String word = request.getPathParameters().get("word"); + + Optional detail = wordService.getWordDetail(word); + + if (detail.isEmpty()) { + return ResponseGenerator.fail(NewsErrorCode.WORD_NOT_COLLECTED); + } + + return ResponseGenerator.ok("단어 상세 조회 성공", detail.get()); + } + + /** + * 단어 Vocabulary 연동 + * POST /news/words/{word}/sync + */ + private APIGatewayProxyResponseEvent syncWordToVocab(APIGatewayProxyRequestEvent request) { + String userId = getUserId(request); + if (userId == null) { + return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); + } + + String word = request.getPathParameters().get("word"); + + JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); + String articleId = body.get("articleId").getAsString(); + + boolean synced = wordService.syncToVocabulary(userId, word, articleId); + + if (!synced) { + return ResponseGenerator.fail(NewsErrorCode.WORD_NOT_COLLECTED); + } + + return ResponseGenerator.ok("Vocabulary 연동 성공", Map.of("word", word, "synced", true)); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java new file mode 100644 index 00000000..59f4fa93 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java @@ -0,0 +1,62 @@ +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#{userId}#NEWS + * SK: WORD#{word}#{articleId} + * GSI1: USER#{userId}#NEWS_WORDS / {collectedAt} + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class NewsWordCollect { + + private String pk; // USER#{userId}#NEWS + private String sk; // WORD#{word}#{articleId} + private String gsi1pk; // USER#{userId}#NEWS_WORDS + private String gsi1sk; // {collectedAt} + + private String userId; + private String word; + private String meaning; + private String pronunciation; + private String context; // 문맥 문장 + private String articleId; + private String articleTitle; + private String collectedAt; + private Boolean syncedToVocab; // Vocabulary 연동 여부 + private String vocabUserWordId; // 연동된 UserWord ID + 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/NewsWordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsWordRepository.java new file mode 100644 index 00000000..5dfebc80 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsWordRepository.java @@ -0,0 +1,133 @@ +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.NewsWordCollect; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.*; +import software.amazon.awssdk.enhanced.dynamodb.model.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * 뉴스 단어 수집 Repository + */ +public class NewsWordRepository { + + private static final Logger logger = LoggerFactory.getLogger(NewsWordRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); + + private final DynamoDbTable table; + private final DynamoDbIndex gsi1Index; + + public NewsWordRepository() { + this(AwsClients.dynamoDbEnhanced()); + } + + public NewsWordRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(NewsWordCollect.class)); + this.gsi1Index = table.index("GSI1"); + } + + /** + * 단어 수집 저장 + */ + public void save(NewsWordCollect wordCollect) { + table.putItem(wordCollect); + logger.debug("단어 수집 저장: userId={}, word={}", wordCollect.getUserId(), wordCollect.getWord()); + } + + /** + * 단어 수집 조회 + */ + public Optional findByUserWordArticle(String userId, String word, String articleId) { + Key key = Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue(NewsKey.wordSk(word, articleId)) + .build(); + + NewsWordCollect result = table.getItem(key); + return Optional.ofNullable(result); + } + + /** + * 이미 수집했는지 확인 + */ + public boolean hasCollected(String userId, String word, String articleId) { + return findByUserWordArticle(userId, word, articleId).isPresent(); + } + + /** + * 단어 수집 삭제 + */ + public void delete(String userId, String word, String articleId) { + Key key = Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue(NewsKey.wordSk(word, articleId)) + .build(); + + table.deleteItem(key); + logger.debug("단어 수집 삭제: userId={}, word={}", userId, word); + } + + /** + * 사용자 수집 단어 목록 조회 (최신순) + */ + public List getUserWords(String userId, int limit) { + QueryConditional queryConditional = QueryConditional.keyEqualTo( + Key.builder() + .partitionValue(NewsKey.userNewsWordsPk(userId)) + .build() + ); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) + .limit(limit) + .build(); + + List results = new ArrayList<>(); + for (Page page : gsi1Index.query(request)) { + results.addAll(page.items()); + if (results.size() >= limit) break; + } + + return results.subList(0, Math.min(results.size(), limit)); + } + + /** + * 사용자 수집 단어 수 조회 + */ + public int countUserWords(String userId) { + QueryConditional queryConditional = QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue("WORD#") + .build() + ); + + int count = 0; + for (Page page : table.query(queryConditional)) { + count += page.items().size(); + } + return count; + } + + /** + * Vocabulary 연동 상태 업데이트 + */ + public void updateSyncStatus(String userId, String word, String articleId, String vocabUserWordId) { + Optional wordOpt = findByUserWordArticle(userId, word, articleId); + if (wordOpt.isPresent()) { + NewsWordCollect wordCollect = wordOpt.get(); + wordCollect.setSyncedToVocab(true); + wordCollect.setVocabUserWordId(vocabUserWordId); + table.putItem(wordCollect); + logger.debug("Vocabulary 연동 완료: userId={}, word={}", userId, word); + } + } +} 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 new file mode 100644 index 00000000..6c3c23ec --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java @@ -0,0 +1,203 @@ +package com.mzc.secondproject.serverless.domain.news.service; + +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; +import com.mzc.secondproject.serverless.domain.news.model.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.vocabulary.model.Word; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.service.UserWordCommandService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 뉴스 단어 수집 서비스 + */ +public class NewsWordService { + + private static final Logger logger = LoggerFactory.getLogger(NewsWordService.class); + + private final NewsWordRepository newsWordRepository; + private final NewsArticleRepository articleRepository; + private final WordRepository wordRepository; + private final UserWordCommandService userWordCommandService; + + public NewsWordService() { + this.newsWordRepository = new NewsWordRepository(); + this.articleRepository = new NewsArticleRepository(); + this.wordRepository = new WordRepository(); + this.userWordCommandService = new UserWordCommandService(); + } + + public NewsWordService(NewsWordRepository newsWordRepository, + NewsArticleRepository articleRepository, + WordRepository wordRepository, + UserWordCommandService userWordCommandService) { + this.newsWordRepository = newsWordRepository; + this.articleRepository = articleRepository; + this.wordRepository = wordRepository; + this.userWordCommandService = userWordCommandService; + } + + /** + * 단어 수집 + */ + public NewsWordCollect 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); + } + + // 기사 조회 + Optional articleOpt = articleRepository.findById(articleId); + String articleTitle = articleOpt.map(NewsArticle::getTitle).orElse(""); + + // 단어 정보 조회 (Word 테이블에서) + String wordId = word.toLowerCase().trim(); + Optional wordOpt = wordRepository.findById(wordId); + String meaning = wordOpt.map(Word::getKorean).orElse(""); + String pronunciation = ""; + + String now = Instant.now().toString(); + + NewsWordCollect wordCollect = NewsWordCollect.builder() + .pk(NewsKey.userNewsPk(userId)) + .sk(NewsKey.wordSk(word, articleId)) + .gsi1pk(NewsKey.userNewsWordsPk(userId)) + .gsi1sk(now) + .userId(userId) + .word(word) + .meaning(meaning) + .pronunciation(pronunciation) + .context(context) + .articleId(articleId) + .articleTitle(articleTitle) + .collectedAt(now) + .syncedToVocab(false) + .build(); + + newsWordRepository.save(wordCollect); + logger.info("단어 수집 완료: userId={}, word={}, articleId={}", userId, word, articleId); + + return wordCollect; + } + + /** + * 수집한 단어 삭제 + */ + public void deleteWord(String userId, String word, String articleId) { + newsWordRepository.delete(userId, word, articleId); + logger.info("단어 삭제: userId={}, word={}", userId, word); + } + + /** + * 사용자 수집 단어 목록 조회 + */ + public List getUserWords(String userId, int limit) { + return newsWordRepository.getUserWords(userId, limit); + } + + /** + * 사용자 수집 단어 수 조회 + */ + public int countUserWords(String userId) { + return newsWordRepository.countUserWords(userId); + } + + /** + * 단어 상세 정보 조회 + */ + public Optional getWordDetail(String word) { + String wordId = word.toLowerCase().trim(); + Optional wordOpt = wordRepository.findById(wordId); + + if (wordOpt.isEmpty()) { + return Optional.empty(); + } + + Word w = wordOpt.get(); + return Optional.of(WordDetail.builder() + .word(w.getEnglish()) + .meaning(w.getKorean()) + .pronunciation("") + .example(w.getExample()) + .level(w.getLevel()) + .build()); + } + + /** + * Vocabulary 도메인으로 단어 연동 + */ + public boolean syncToVocabulary(String userId, String word, String articleId) { + Optional wordOpt = newsWordRepository.findByUserWordArticle(userId, word, articleId); + if (wordOpt.isEmpty()) { + logger.warn("수집한 단어를 찾을 수 없음: userId={}, word={}", userId, word); + return false; + } + + NewsWordCollect wordCollect = wordOpt.get(); + + // 이미 연동됐는지 확인 + if (Boolean.TRUE.equals(wordCollect.getSyncedToVocab())) { + logger.info("이미 Vocabulary에 연동됨: userId={}, word={}", userId, word); + return true; + } + + // Word 테이블에서 단어 조회 + String wordId = word.toLowerCase().trim(); + Optional vocabWord = wordRepository.findById(wordId); + + if (vocabWord.isEmpty()) { + logger.warn("Vocabulary에 없는 단어: {}", word); + return false; + } + + // UserWord 생성 (NEW 상태로) + userWordCommandService.updateWordStatus(userId, wordId, "NEW"); + + // 연동 상태 업데이트 + newsWordRepository.updateSyncStatus(userId, word, articleId, wordId); + + logger.info("Vocabulary 연동 완료: userId={}, word={}", userId, word); + return true; + } + + /** + * 사용자 단어 수집 통계 + */ + public Map getUserWordStats(String userId) { + int totalWords = newsWordRepository.countUserWords(userId); + List recentWords = newsWordRepository.getUserWords(userId, 5); + long syncedCount = recentWords.stream() + .filter(w -> Boolean.TRUE.equals(w.getSyncedToVocab())) + .count(); + + return Map.of( + "totalCollected", totalWords, + "recentWords", recentWords, + "syncedToVocab", syncedCount + ); + } + + /** + * 단어 상세 정보 + */ + @lombok.Data + @lombok.Builder + @lombok.NoArgsConstructor + @lombok.AllArgsConstructor + public static class WordDetail { + private String word; + private String meaning; + private String pronunciation; + private String example; + private String level; + } +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index f701a42f..15fd4280 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1762,6 +1762,8 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref NewsTable + - DynamoDBCrudPolicy: + TableName: !Ref VocabularyTable - S3CrudPolicy: BucketName: !Ref ContentBucket - Statement: @@ -1800,6 +1802,36 @@ Resources: RestApiId: !Ref MainApi Path: /news/bookmarks Method: GET + GetUserWords: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/words + Method: GET + SyncWordToVocab: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/words/{word}/sync + Method: POST + CollectWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/words + Method: POST + DeleteWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/words/{word} + Method: DELETE + GetWordDetail: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/words/{word} + Method: GET GetQuizHistory: Type: Api Properties: