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,54 @@
package com.devpick.domain.content.controller;

import com.devpick.domain.content.dto.QuizHistoryListResponse;
import com.devpick.domain.content.dto.QuizResultResponse;
import com.devpick.domain.content.service.AiQuizService;
import com.devpick.global.common.response.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.UUID;

@Tag(name = "Quiz History", description = "퀴즈 이력 조회")
@RestController
@RequiredArgsConstructor
public class QuizHistoryController {

private final AiQuizService aiQuizService;

@Operation(summary = "퀴즈 이력 목록", description = "contentId+level 기준 최신 attempt 중 미완료(점수 < 전체)인 것만 페이징 반환합니다.")
@ApiResponses({

Check warning on line 29 in src/main/java/com/devpick/domain/content/controller/QuizHistoryController.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the 'ApiResponses' wrapper from this annotation group

See more on https://sonarcloud.io/project/issues?id=Devpick-Org_devpick-backend&issues=AZ3AJEDDXImmEA23dyz4&open=AZ3AJEDDXImmEA23dyz4&pullRequest=135
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증 필요")
})
@GetMapping("/users/me/quiz-history")
public ApiResponse<QuizHistoryListResponse> getQuizHistory(
@AuthenticationPrincipal UUID userId,
@Parameter(description = "정렬 순서", example = "newest") @RequestParam(defaultValue = "newest") String sort,
@Parameter(description = "페이지 번호 (0부터 시작)", example = "0") @RequestParam(defaultValue = "0") int page,
@Parameter(description = "페이지 크기", example = "10") @RequestParam(defaultValue = "10") int size) {
return ApiResponse.ok(aiQuizService.getQuizHistory(userId, sort, PageRequest.of(page, size)));
}

@Operation(summary = "퀴즈 결과 상세", description = "특정 퀴즈 시도의 문제 목록과 내 답안을 반환합니다.")
@ApiResponses({

Check warning on line 43 in src/main/java/com/devpick/domain/content/controller/QuizHistoryController.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the 'ApiResponses' wrapper from this annotation group

See more on https://sonarcloud.io/project/issues?id=Devpick-Org_devpick-backend&issues=AZ3AJEDDXImmEA23dyz5&open=AZ3AJEDDXImmEA23dyz5&pullRequest=135
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "타인의 이력 접근"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "이력 없음")
})
@GetMapping("/quiz-history/{attemptId}")
public ApiResponse<QuizResultResponse> getQuizResult(
@AuthenticationPrincipal UUID userId,
@Parameter(description = "퀴즈 시도 ID (UUID)", required = true) @PathVariable UUID attemptId) {
return ApiResponse.ok(aiQuizService.getQuizResult(userId, attemptId));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.devpick.domain.content.dto;

import com.devpick.domain.content.entity.QuizAttempt;
import com.devpick.domain.content.service.AiSummaryService;

import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Map;
import java.util.UUID;

public record QuizHistoryItemResponse(
UUID attemptId,
UUID contentId,
String contentTitle,
String thumbnail,
String preview,
String level,
int score,
int totalQuestions,
boolean passed,
Instant attemptedAt
) {
public static QuizHistoryItemResponse of(QuizAttempt attempt, Map<String, String> previewMap) {
UUID contentId = attempt.getContent().getId();
String key = contentId + "|" + attempt.getLevel();
String rawPreview = previewMap.get(key);
String preview = (rawPreview != null && !rawPreview.isBlank()) ? rawPreview : null;

return new QuizHistoryItemResponse(
attempt.getId(),
contentId,
attempt.getContent().getTitle(),
attempt.getContent().getThumbnailUrl(),
preview,
AiSummaryService.fromAiServerLevel(attempt.getLevel()),
attempt.getScore(),
attempt.getTotalQuestions(),
attempt.isPassed(),
attempt.getCreatedAt().toInstant(ZoneOffset.UTC)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.devpick.domain.content.dto;

import java.util.List;

public record QuizHistoryListResponse(
List<QuizHistoryItemResponse> content,
int page,
int size,
long totalElements,
int totalPages
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.devpick.domain.content.dto;

import com.devpick.domain.content.document.AiQuizDocument;

import java.util.List;
import java.util.UUID;

public record QuizResultResponse(
UUID attemptId,
UUID contentId,
int score,
int totalQuestions,
boolean passed,
int pointsEarned,
QuizData quiz,
List<MyAnswer> myAnswers
) {
public record QuizData(
List<AiQuizDocument.Question> questions,
int passingCount
) {}

public record MyAnswer(
String questionId,
String selectedOptionId,
String answerText,
boolean isCorrect
) {}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
package com.devpick.domain.content.dto;

import java.util.List;

public record QuizSubmitRequest(
String level,
int score,
int totalQuestions,
boolean passed
) {}
boolean passed,
List<AnswerItem> answers
) {
public record AnswerItem(
String questionId,
String selectedOptionId,
String answerText,
boolean isCorrect
) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.devpick.domain.content.entity;

import com.devpick.global.entity.BaseCreatedEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "quiz_attempt_answers", indexes = {
@Index(name = "idx_quiz_attempt_answers_attempt_id", columnList = "attempt_id")
})
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
@AllArgsConstructor
public class QuizAttemptAnswer extends BaseCreatedEntity {

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "attempt_id", nullable = false)
private QuizAttempt attempt;

@Column(name = "question_id", length = 100, nullable = false)
private String questionId;

@Column(name = "selected_option_id", length = 100)
private String selectedOptionId;

@Column(name = "answer_text", columnDefinition = "TEXT")
private String answerText;

@Column(name = "is_correct", nullable = false)
private boolean correct;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,26 @@
import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable;
import software.amazon.awssdk.enhanced.dynamodb.Key;
import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
import software.amazon.awssdk.enhanced.dynamodb.model.BatchGetItemEnhancedRequest;
import software.amazon.awssdk.enhanced.dynamodb.model.ReadBatch;

import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;

@Repository
public class AiQuizRepository {

private static final String TABLE_NAME = "ai_quizzes";

private final DynamoDbEnhancedClient enhancedClient;
private final DynamoDbTable<AiQuizDocument> table;

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

Expand All @@ -40,4 +49,35 @@ public void deleteByContentIdAndLevel(String contentId, String level) {
.build();
table.deleteItem(key);
}

/**
* (contentId, level) 쌍 목록에 대해 첫 번째 문제 텍스트를 배치 조회한다.
* @param keys [contentId, level] 배열의 리스트
* @return "contentId|level" → 첫 번째 문제 텍스트
*/
public Map<String, String> batchFindFirstQuestions(List<String[]> keys) {
if (keys.isEmpty()) return Map.of();

ReadBatch.Builder<AiQuizDocument> batchBuilder = ReadBatch.builder(AiQuizDocument.class)
.mappedTableResource(table);

Set<String> seen = new HashSet<>();
for (String[] key : keys) {
if (seen.add(key[0] + "|" + key[1])) {
batchBuilder.addGetItem(Key.builder()
.partitionValue(key[0]).sortValue(key[1]).build());
}
}

Map<String, String> result = new HashMap<>();
enhancedClient.batchGetItem(
BatchGetItemEnhancedRequest.builder().readBatches(batchBuilder.build()).build()
).resultsForTable(table).forEach(doc -> {
if (doc.getQuestions() != null && !doc.getQuestions().isEmpty()) {
result.put(doc.getContentId() + "|" + doc.getLevel(),
doc.getQuestions().getFirst().getQuestion());
}
});
return result;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.devpick.domain.content.repository;

import com.devpick.domain.content.entity.QuizAttemptAnswer;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;
import java.util.UUID;

public interface QuizAttemptAnswerRepository extends JpaRepository<QuizAttemptAnswer, UUID> {

List<QuizAttemptAnswer> findByAttempt_Id(UUID attemptId);
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,30 @@
package com.devpick.domain.content.repository;

import com.devpick.domain.content.entity.QuizAttempt;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.Optional;
import java.util.UUID;

public interface QuizAttemptRepository extends JpaRepository<QuizAttempt, UUID> {

Optional<QuizAttempt> findTopByUser_IdAndContent_IdOrderByCreatedAtDesc(UUID userId, UUID contentId);

@Query(value = "SELECT qa FROM QuizAttempt qa JOIN FETCH qa.content c JOIN FETCH c.source " +
"WHERE qa.user.id = :userId " +
"AND qa.createdAt = (SELECT MAX(qa2.createdAt) FROM QuizAttempt qa2 " +
"WHERE qa2.user.id = :userId AND qa2.content.id = qa.content.id AND qa2.level = qa.level) " +
"AND qa.score < qa.totalQuestions",
countQuery = "SELECT COUNT(qa) FROM QuizAttempt qa " +
"WHERE qa.user.id = :userId " +
"AND qa.createdAt = (SELECT MAX(qa2.createdAt) FROM QuizAttempt qa2 " +
"WHERE qa2.user.id = :userId AND qa2.content.id = qa.content.id AND qa2.level = qa.level) " +
"AND qa.score < qa.totalQuestions")
Page<QuizAttempt> findHistoryByUserId(@Param("userId") UUID userId, Pageable pageable);

Optional<QuizAttempt> findByIdAndUser_Id(UUID id, UUID userId);
}
Loading
Loading