Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -40,16 +42,19 @@ public class NewsHandler implements RequestHandler<APIGatewayProxyRequestEvent,
private final NewsQueryService queryService;
private final NewsLearningService learningService;
private final NewsQuizService quizService;
private final NewsWordService wordService;
private final HandlerRouter router;

public NewsHandler() {
this(new NewsQueryService(), new NewsLearningService(), new NewsQuizService());
this(new NewsQueryService(), new NewsLearningService(), new NewsQuizService(), new NewsWordService());
}

public NewsHandler(NewsQueryService queryService, NewsLearningService learningService, NewsQuizService quizService) {
public NewsHandler(NewsQueryService queryService, NewsLearningService learningService,
NewsQuizService quizService, NewsWordService wordService) {
this.queryService = queryService;
this.learningService = learningService;
this.quizService = quizService;
this.wordService = wordService;
this.router = initRouter();
}

Expand All @@ -59,7 +64,12 @@ private HandlerRouter initRouter() {
Route.get("/news/recommended", this::getRecommendedNews),
Route.get("/news/stats", this::getNewsStats),
Route.get("/news/bookmarks", this::getBookmarks),
Route.get("/news/words", this::getUserWords),
Route.get("/news/quiz/history", this::getQuizHistory),
Route.get("/news/{articleId}/words/{word}", this::getWordDetail),
Route.post("/news/{articleId}/words", this::collectWord),
Route.delete("/news/{articleId}/words/{word}", this::deleteWord),
Route.post("/news/words/{word}/sync", this::syncWordToVocab),
Route.get("/news/{articleId}/quiz", this::getQuiz),
Route.post("/news/{articleId}/quiz", this::submitQuiz),
Route.post("/news/{articleId}/read", this::markAsRead),
Expand Down Expand Up @@ -364,4 +374,112 @@ private APIGatewayProxyResponseEvent getQuizHistory(APIGatewayProxyRequestEvent

return ResponseGenerator.ok("퀴즈 기록 조회 성공", response);
}

/**
* 수집 단어 목록 조회
* GET /news/words?limit=10
*/
private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent request) {
String userId = getUserId(request);
if (userId == null) {
return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED);
}

Map<String, String> params = request.getQueryStringParameters();
if (params == null) params = new HashMap<>();

int limit = parseLimit(params.get("limit"));
List<NewsWordCollect> words = wordService.getUserWords(userId, limit);
Map<String, Object> stats = wordService.getUserWordStats(userId);

Map<String, Object> 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<NewsWordService.WordDetail> 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));
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<NewsWordCollect> table;
private final DynamoDbIndex<NewsWordCollect> 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<NewsWordCollect> 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<NewsWordCollect> 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<NewsWordCollect> results = new ArrayList<>();
for (Page<NewsWordCollect> 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<NewsWordCollect> page : table.query(queryConditional)) {
count += page.items().size();
}
return count;
}

/**
* Vocabulary 연동 상태 업데이트
*/
public void updateSyncStatus(String userId, String word, String articleId, String vocabUserWordId) {
Optional<NewsWordCollect> 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);
}
}
}
Loading