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 @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -29,21 +32,28 @@ public class NewsHandler implements RequestHandler<APIGatewayProxyRequestEvent,
private static final int MAX_LIMIT = 50;

private final NewsQueryService queryService;
private final NewsLearningService learningService;
private final HandlerRouter router;

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

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

private HandlerRouter initRouter() {
return new HandlerRouter().addRoutes(
Route.get("/news/today", this::getTodayNews),
Route.get("/news/recommended", this::getRecommendedNews),
Route.get("/news/stats", this::getNewsStats),
Route.get("/news/bookmarks", this::getBookmarks),
Route.post("/news/{articleId}/read", this::markAsRead),
Route.post("/news/{articleId}/bookmark", this::toggleBookmark),
Route.get("/news/{articleId}/audio", this::getAudio),
Route.get("/news/{articleId}", this::getNewsDetail),
Route.get("/news", this::getNewsList)
);
Expand Down Expand Up @@ -163,4 +173,102 @@ private String getUserLevel(APIGatewayProxyRequestEvent request) {
return CognitoUtil.extractClaim(request, "custom:level")
.orElse("INTERMEDIATE");
}

/**
* 사용자 ID 추출
*/
private String getUserId(APIGatewayProxyRequestEvent request) {
return CognitoUtil.extractClaim(request, "sub")
.orElse(null);
}

/**
* 뉴스 학습 통계 조회
* GET /news/stats
*/
private APIGatewayProxyResponseEvent getNewsStats(APIGatewayProxyRequestEvent request) {
String userId = getUserId(request);
if (userId == null) {
return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED);
}

Map<String, Object> 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<String, String> params = request.getQueryStringParameters();
if (params == null) params = new HashMap<>();

int limit = parseLimit(params.get("limit"));
List<UserNewsRecord> bookmarks = learningService.getUserBookmarks(userId, limit);

Map<String, Object> 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<String, String> 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));
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading