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 new file mode 100644 index 00000000..3f53332b --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -0,0 +1,166 @@ +package com.mzc.secondproject.serverless.domain.news.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.router.HandlerRouter; +import com.mzc.secondproject.serverless.common.router.Route; +import com.mzc.secondproject.serverless.common.util.CognitoUtil; +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.service.NewsQueryService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * 뉴스 학습 API 핸들러 + */ +public class NewsHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(NewsHandler.class); + private static final int DEFAULT_LIMIT = 10; + private static final int MAX_LIMIT = 50; + + private final NewsQueryService queryService; + private final HandlerRouter router; + + public NewsHandler() { + this(new NewsQueryService()); + } + + public NewsHandler(NewsQueryService queryService) { + this.queryService = queryService; + 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/{articleId}", this::getNewsDetail), + Route.get("/news", this::getNewsList) + ); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("News API 요청: {} {}", request.getHttpMethod(), request.getPath()); + return router.route(request); + } + + /** + * 뉴스 목록 조회 (필터링 지원) + * GET /news?level=INTERMEDIATE&category=TECH&limit=10&cursor=xxx + */ + private APIGatewayProxyResponseEvent getNewsList(APIGatewayProxyRequestEvent request) { + Map params = request.getQueryStringParameters(); + if (params == null) params = new HashMap<>(); + + String level = params.get("level"); + String category = params.get("category"); + String cursor = params.get("cursor"); + int limit = parseLimit(params.get("limit")); + + PaginatedResult result; + + if (level != null && category != null) { + result = queryService.getNewsByLevelAndCategory(level.toUpperCase(), category.toUpperCase(), limit, cursor); + } else if (level != null) { + result = queryService.getNewsByLevel(level.toUpperCase(), limit, cursor); + } else if (category != null) { + result = queryService.getNewsByCategory(category.toUpperCase(), limit, cursor); + } else { + result = queryService.getTodayNews(limit, cursor); + } + + return buildPaginatedResponse(result); + } + + /** + * 오늘의 뉴스 조회 + * GET /news/today?limit=10&cursor=xxx + */ + private APIGatewayProxyResponseEvent getTodayNews(APIGatewayProxyRequestEvent request) { + Map params = request.getQueryStringParameters(); + if (params == null) params = new HashMap<>(); + + String cursor = params.get("cursor"); + int limit = parseLimit(params.get("limit")); + + PaginatedResult result = queryService.getTodayNews(limit, cursor); + return buildPaginatedResponse(result); + } + + /** + * 내 레벨 맞춤 뉴스 추천 + * GET /news/recommended?limit=10&cursor=xxx + */ + private APIGatewayProxyResponseEvent getRecommendedNews(APIGatewayProxyRequestEvent request) { + Map params = request.getQueryStringParameters(); + if (params == null) params = new HashMap<>(); + + // 사용자 레벨 조회 (Cognito 토큰에서) + String userLevel = getUserLevel(request); + String cursor = params.get("cursor"); + int limit = parseLimit(params.get("limit")); + + PaginatedResult result = queryService.getRecommendedNews(userLevel, limit, cursor); + return buildPaginatedResponse(result); + } + + /** + * 뉴스 상세 조회 + * GET /news/{articleId} + */ + private APIGatewayProxyResponseEvent getNewsDetail(APIGatewayProxyRequestEvent request) { + String articleId = request.getPathParameters().get("articleId"); + + Optional article = queryService.getArticle(articleId); + if (article.isEmpty()) { + return ResponseGenerator.fail(NewsErrorCode.ARTICLE_NOT_FOUND); + } + + return ResponseGenerator.ok("뉴스 조회 성공", article.get()); + } + + /** + * 페이지네이션 응답 생성 + */ + private APIGatewayProxyResponseEvent buildPaginatedResponse(PaginatedResult result) { + Map response = new HashMap<>(); + response.put("articles", result.items()); + response.put("nextCursor", result.nextCursor()); + response.put("hasMore", result.hasMore()); + response.put("count", result.items().size()); + + return ResponseGenerator.ok("뉴스 목록 조회 성공", response); + } + + /** + * limit 파싱 + */ + private int parseLimit(String limitStr) { + if (limitStr == null) return DEFAULT_LIMIT; + try { + int limit = Integer.parseInt(limitStr); + return Math.min(Math.max(limit, 1), MAX_LIMIT); + } catch (NumberFormatException e) { + return DEFAULT_LIMIT; + } + } + + /** + * 사용자 레벨 조회 + */ + private String getUserLevel(APIGatewayProxyRequestEvent request) { + return CognitoUtil.extractClaim(request, "custom:level") + .orElse("INTERMEDIATE"); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java new file mode 100644 index 00000000..5a3930ed --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java @@ -0,0 +1,98 @@ +package com.mzc.secondproject.serverless.domain.news.service; + +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.LocalDate; +import java.util.Optional; + +/** + * 뉴스 조회 서비스 + */ +public class NewsQueryService { + + private static final Logger logger = LoggerFactory.getLogger(NewsQueryService.class); + + private final NewsArticleRepository articleRepository; + + public NewsQueryService() { + this.articleRepository = new NewsArticleRepository(); + } + + public NewsQueryService(NewsArticleRepository articleRepository) { + this.articleRepository = articleRepository; + } + + /** + * 뉴스 상세 조회 + */ + public Optional getArticle(String articleId) { + logger.debug("뉴스 상세 조회: {}", articleId); + Optional article = articleRepository.findById(articleId); + + // 조회수 증가 + article.ifPresent(a -> { + String date = extractDateFromPk(a.getPk()); + if (date != null) { + articleRepository.incrementReadCount(date, articleId); + } + }); + + return article; + } + + /** + * 오늘의 뉴스 목록 조회 + */ + public PaginatedResult getTodayNews(int limit, String cursor) { + String today = LocalDate.now().toString(); + logger.debug("오늘의 뉴스 조회: date={}, limit={}", today, limit); + return articleRepository.findByDate(today, limit, cursor); + } + + /** + * 레벨별 뉴스 조회 + */ + public PaginatedResult getNewsByLevel(String level, int limit, String cursor) { + logger.debug("레벨별 뉴스 조회: level={}, limit={}", level, limit); + return articleRepository.findByLevel(level, limit, cursor); + } + + /** + * 카테고리별 뉴스 조회 + */ + public PaginatedResult getNewsByCategory(String category, int limit, String cursor) { + logger.debug("카테고리별 뉴스 조회: category={}, limit={}", category, limit); + return articleRepository.findByCategory(category, limit, cursor); + } + + /** + * 레벨 + 카테고리 복합 필터 조회 + */ + public PaginatedResult getNewsByLevelAndCategory(String level, String category, int limit, String cursor) { + logger.debug("레벨+카테고리 뉴스 조회: level={}, category={}, limit={}", level, category, limit); + return articleRepository.findByLevelAndCategory(level, category, limit, cursor); + } + + /** + * 사용자 레벨 맞춤 뉴스 추천 + */ + public PaginatedResult getRecommendedNews(String userLevel, int limit, String cursor) { + logger.debug("맞춤 뉴스 추천: userLevel={}, limit={}", userLevel, limit); + // 사용자 레벨에 맞는 뉴스 조회 + return articleRepository.findByLevel(userLevel, limit, cursor); + } + + /** + * PK에서 날짜 추출 (NEWS#2024-01-15 → 2024-01-15) + */ + 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 552cb8e9..8288972b 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1750,6 +1750,44 @@ Resources: Description: 매일 18시 KST (09:00 UTC)에 뉴스 수집 Enabled: true + NewsFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-news" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.news.handler.NewsHandler::handleRequest + Description: 뉴스 학습 API + MemorySize: 256 + Timeout: 30 + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref NewsTable + Events: + GetNewsList: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news + Method: GET + GetTodayNews: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/today + Method: GET + GetRecommendedNews: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/recommended + Method: GET + GetNewsDetail: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId} + Method: GET + NewsTable: Type: AWS::DynamoDB::Table Properties: