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
@@ -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<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {

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

String cursor = params.get("cursor");
int limit = parseLimit(params.get("limit"));

PaginatedResult<NewsArticle> result = queryService.getTodayNews(limit, cursor);
return buildPaginatedResponse(result);
}

/**
* 내 레벨 맞춤 뉴스 추천
* GET /news/recommended?limit=10&cursor=xxx
*/
private APIGatewayProxyResponseEvent getRecommendedNews(APIGatewayProxyRequestEvent request) {
Map<String, String> 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<NewsArticle> result = queryService.getRecommendedNews(userLevel, limit, cursor);
return buildPaginatedResponse(result);
}

/**
* 뉴스 상세 조회
* GET /news/{articleId}
*/
private APIGatewayProxyResponseEvent getNewsDetail(APIGatewayProxyRequestEvent request) {
String articleId = request.getPathParameters().get("articleId");

Optional<NewsArticle> article = queryService.getArticle(articleId);
if (article.isEmpty()) {
return ResponseGenerator.fail(NewsErrorCode.ARTICLE_NOT_FOUND);
}

return ResponseGenerator.ok("뉴스 조회 성공", article.get());
}

/**
* 페이지네이션 응답 생성
*/
private APIGatewayProxyResponseEvent buildPaginatedResponse(PaginatedResult<NewsArticle> result) {
Map<String, Object> 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");
}
}
Original file line number Diff line number Diff line change
@@ -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<NewsArticle> getArticle(String articleId) {
logger.debug("뉴스 상세 조회: {}", articleId);
Optional<NewsArticle> article = articleRepository.findById(articleId);

// 조회수 증가
article.ifPresent(a -> {
String date = extractDateFromPk(a.getPk());
if (date != null) {
articleRepository.incrementReadCount(date, articleId);
}
});

return article;
}

/**
* 오늘의 뉴스 목록 조회
*/
public PaginatedResult<NewsArticle> getTodayNews(int limit, String cursor) {
String today = LocalDate.now().toString();
logger.debug("오늘의 뉴스 조회: date={}, limit={}", today, limit);
return articleRepository.findByDate(today, limit, cursor);
}

/**
* 레벨별 뉴스 조회
*/
public PaginatedResult<NewsArticle> getNewsByLevel(String level, int limit, String cursor) {
logger.debug("레벨별 뉴스 조회: level={}, limit={}", level, limit);
return articleRepository.findByLevel(level, limit, cursor);
}

/**
* 카테고리별 뉴스 조회
*/
public PaginatedResult<NewsArticle> getNewsByCategory(String category, int limit, String cursor) {
logger.debug("카테고리별 뉴스 조회: category={}, limit={}", category, limit);
return articleRepository.findByCategory(category, limit, cursor);
}

/**
* 레벨 + 카테고리 복합 필터 조회
*/
public PaginatedResult<NewsArticle> 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<NewsArticle> 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);
}
}
38 changes: 38 additions & 0 deletions ServerlessFunction/template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down