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 @@ -11,9 +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.NewsQuizResult;
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.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Expand All @@ -30,18 +35,21 @@ public class NewsHandler implements RequestHandler<APIGatewayProxyRequestEvent,
private static final Logger logger = LoggerFactory.getLogger(NewsHandler.class);
private static final int DEFAULT_LIMIT = 10;
private static final int MAX_LIMIT = 50;
private static final Gson gson = new Gson();

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

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

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

Expand All @@ -51,6 +59,9 @@ private HandlerRouter initRouter() {
Route.get("/news/recommended", this::getRecommendedNews),
Route.get("/news/stats", this::getNewsStats),
Route.get("/news/bookmarks", this::getBookmarks),
Route.get("/news/quiz/history", this::getQuizHistory),
Route.get("/news/{articleId}/quiz", this::getQuiz),
Route.post("/news/{articleId}/quiz", this::submitQuiz),
Route.post("/news/{articleId}/read", this::markAsRead),
Route.post("/news/{articleId}/bookmark", this::toggleBookmark),
Route.get("/news/{articleId}/audio", this::getAudio),
Expand Down Expand Up @@ -271,4 +282,86 @@ private APIGatewayProxyResponseEvent getAudio(APIGatewayProxyRequestEvent reques

return ResponseGenerator.ok("TTS 오디오 URL 조회 성공", Map.of("audioUrl", audioUrl));
}

/**
* 퀴즈 조회
* GET /news/{articleId}/quiz
*/
private APIGatewayProxyResponseEvent getQuiz(APIGatewayProxyRequestEvent request) {
String userId = getUserId(request);
if (userId == null) {
return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED);
}

String articleId = request.getPathParameters().get("articleId");
Optional<NewsQuizService.QuizData> quizData = quizService.getQuiz(articleId, userId);

if (quizData.isEmpty()) {
return ResponseGenerator.fail(NewsErrorCode.QUIZ_NOT_FOUND);
}

return ResponseGenerator.ok("퀴즈 조회 성공", quizData.get());
}

/**
* 퀴즈 제출
* POST /news/{articleId}/quiz
*/
private APIGatewayProxyResponseEvent submitQuiz(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);
JsonArray answersArray = body.getAsJsonArray("answers");
Integer timeTaken = body.has("timeTaken") ? body.get("timeTaken").getAsInt() : null;

List<NewsQuizService.QuizAnswer> answers = new java.util.ArrayList<>();
if (answersArray != null) {
answersArray.forEach(e -> {
JsonObject a = e.getAsJsonObject();
answers.add(new NewsQuizService.QuizAnswer(
a.get("questionId").getAsString(),
a.get("answer").getAsString()
));
});
}

NewsQuizService.QuizSubmitResult result = quizService.submitQuiz(userId, articleId, answers, timeTaken);

if (result == null) {
return ResponseGenerator.fail(NewsErrorCode.QUIZ_ALREADY_SUBMITTED);
}

return ResponseGenerator.ok("퀴즈 제출 성공", result);
}

/**
* 퀴즈 기록 조회
* GET /news/quiz/history?limit=10
*/
private APIGatewayProxyResponseEvent getQuizHistory(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<NewsQuizResult> history = quizService.getUserQuizHistory(userId, limit);
Map<String, Object> quizStats = quizService.getUserQuizStats(userId);

Map<String, Object> response = new HashMap<>();
response.put("history", history);
response.put("stats", quizStats);
response.put("count", history.size());

return ResponseGenerator.ok("퀴즈 기록 조회 성공", response);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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.*;

import java.util.List;

/**
* 뉴스 퀴즈 결과
* PK: USER#{userId}#NEWS
* SK: QUIZ#{articleId}
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@DynamoDbBean
public class NewsQuizResult {

private String pk; // USER#{userId}#NEWS
private String sk; // QUIZ#{articleId}
private String gsi1pk; // USER_NEWS_STAT#{userId}
private String gsi1sk; // {date}#QUIZ

private String userId;
private String articleId;
private String articleTitle;
private String articleLevel;
private int score; // 0-100
private int totalPoints; // 총 배점
private int earnedPoints; // 획득 점수
private List<QuizAnswerResult> answers;
private Integer timeTaken; // 소요 시간 (초)
private String submittedAt;
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,25 @@
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.DynamoDbBean;

/**
* 퀴즈 답변 결과
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@DynamoDbBean
public class QuizAnswerResult {

private String questionId;
private String type; // COMPREHENSION, WORD_MATCH, FILL_BLANK
private String userAnswer;
private String correctAnswer;
private boolean correct;
private int points; // 획득 점수 (정답시 배점, 오답시 0)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
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.NewsQuizResult;
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 NewsQuizRepository {

private static final Logger logger = LoggerFactory.getLogger(NewsQuizRepository.class);
private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME");

private final DynamoDbTable<NewsQuizResult> table;

public NewsQuizRepository() {
this(AwsClients.dynamoDbEnhanced());
}

public NewsQuizRepository(DynamoDbEnhancedClient enhancedClient) {
this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(NewsQuizResult.class));
}

/**
* 퀴즈 결과 저장
*/
public void save(NewsQuizResult result) {
table.putItem(result);
logger.debug("퀴즈 결과 저장: userId={}, articleId={}, score={}",
result.getUserId(), result.getArticleId(), result.getScore());
}

/**
* 퀴즈 결과 조회
*/
public Optional<NewsQuizResult> findByUserAndArticle(String userId, String articleId) {
Key key = Key.builder()
.partitionValue(NewsKey.userNewsPk(userId))
.sortValue(NewsKey.quizSk(articleId))
.build();

NewsQuizResult result = table.getItem(key);
return Optional.ofNullable(result);
}

/**
* 퀴즈 제출 여부 확인
*/
public boolean hasSubmitted(String userId, String articleId) {
return findByUserAndArticle(userId, articleId).isPresent();
}

/**
* 사용자 퀴즈 결과 목록 조회
*/
public List<NewsQuizResult> getUserQuizResults(String userId, int limit) {
QueryConditional queryConditional = QueryConditional.sortBeginsWith(
Key.builder()
.partitionValue(NewsKey.userNewsPk(userId))
.sortValue("QUIZ#")
.build()
);

QueryEnhancedRequest request = QueryEnhancedRequest.builder()
.queryConditional(queryConditional)
.scanIndexForward(false)
.limit(limit)
.build();

List<NewsQuizResult> results = new ArrayList<>();
for (Page<NewsQuizResult> page : table.query(request)) {
results.addAll(page.items());
if (results.size() >= limit) break;
}

return results.subList(0, Math.min(results.size(), limit));
}

/**
* 사용자 퀴즈 통계 조회
*/
public QuizStats getUserQuizStats(String userId) {
QueryConditional queryConditional = QueryConditional.sortBeginsWith(
Key.builder()
.partitionValue(NewsKey.userNewsPk(userId))
.sortValue("QUIZ#")
.build()
);

int totalQuizzes = 0;
int totalScore = 0;
int perfectScores = 0;

for (Page<NewsQuizResult> page : table.query(queryConditional)) {
for (NewsQuizResult result : page.items()) {
totalQuizzes++;
totalScore += result.getScore();
if (result.getScore() == 100) {
perfectScores++;
}
}
}

int avgScore = totalQuizzes > 0 ? totalScore / totalQuizzes : 0;
return new QuizStats(totalQuizzes, avgScore, perfectScores);
}

/**
* 퀴즈 통계 레코드
*/
public record QuizStats(
int totalQuizzes,
int avgScore,
int perfectScores
) {}
}
Loading