Skip to content
Merged
1 change: 1 addition & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ dependencies {
testImplementation 'org.springframework.security:spring-security-test'

/* ETC */
implementation 'org.apache.commons:commons-lang3:3.12.0'
annotationProcessor 'org.projectlombok:lombok'
compileOnly 'org.projectlombok:lombok'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,26 @@
import io.f1.backend.domain.quiz.app.QuizService;
import io.f1.backend.domain.quiz.dto.QuizCreateRequest;
import io.f1.backend.domain.quiz.dto.QuizCreateResponse;
import io.f1.backend.domain.quiz.dto.QuizListPageResponse;
import io.f1.backend.domain.quiz.dto.QuizUpdateRequest;

import jakarta.validation.Valid;

import lombok.RequiredArgsConstructor;

import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
Expand All @@ -35,4 +45,37 @@ public ResponseEntity<QuizCreateResponse> saveQuiz(

return ResponseEntity.status(HttpStatus.CREATED).body(response);
}

@DeleteMapping("/{quizId}")
public ResponseEntity<Void> deleteQuiz(@PathVariable Long quizId) {

quizService.deleteQuiz(quizId);
return ResponseEntity.noContent().build();
}

@PutMapping("/{quizId}")
public ResponseEntity<Void> updateQuiz(
@PathVariable Long quizId,
@RequestPart(required = false) MultipartFile thumbnailFile,
@RequestPart QuizUpdateRequest request)
throws IOException {

quizService.updateQuiz(quizId, thumbnailFile, request);

return ResponseEntity.noContent().build();
}

@GetMapping
public ResponseEntity<QuizListPageResponse> getQuizzes(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String title,
@RequestParam(required = false) String creator) {

Pageable pageable =
PageRequest.of(page - 1, size, Sort.by(Sort.Direction.DESC, "createdAt"));
QuizListPageResponse quizzes = quizService.getQuizzes(title, creator, pageable);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이부분은 지금 퀴즈리스트에 대한 정렬이 적용이 된건가요?.?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

퀴즈 리스트에 대한 정렬은 적용되지 않았습니다 !
따로 정한 기준은 없었던 것 같은데,,, 제가 기억이 안 나는 걸까요..? 혹시 어떤 정렬을 해야 할까요?

Copy link
Collaborator

@sehee123 sehee123 Jul 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[L4-변경제안]
최신순, 인기순 정렬을 변경하는 기능은 정의하지 않았지만, 디폴트 정렬은 있어야 사용자에게 일관적인 리스트를 보여줄 수 있다고 생각합니다!(명시하지않아서 디폴트 정렬로 나오는 것 같지만, 안정적으로 명시하는게 좋다는 의견입니다) 현재 1번부터 나오는 asc라면 desc로 정렬하면(최신순) 어떨까하는 의견입니다. 🤔

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오..! 그렇군요. 페이징을 처음해봐서 정렬까지 같이 처리해주는 기능이 있는지 몰랐는데 디폴트로 정렬기준이 있다면 아주 유용하고 좋을 것 같습니다. 의견 반영해서 커밋 다시 올렸습니다. 좋은 의견 감사합니다 !!


return ResponseEntity.ok().body(quizzes);
}
}
101 changes: 101 additions & 0 deletions backend/src/main/java/io/f1/backend/domain/quiz/app/QuizService.java
Original file line number Diff line number Diff line change
@@ -1,20 +1,31 @@
package io.f1.backend.domain.quiz.app;

import static io.f1.backend.domain.quiz.mapper.QuizMapper.pageQuizToPageQuizListResponse;
import static io.f1.backend.domain.quiz.mapper.QuizMapper.quizCreateRequestToQuiz;
import static io.f1.backend.domain.quiz.mapper.QuizMapper.quizToQuizCreateResponse;
import static io.f1.backend.domain.quiz.mapper.QuizMapper.toQuizListPageResponse;

import static java.nio.file.Files.deleteIfExists;

import io.f1.backend.domain.question.app.QuestionService;
import io.f1.backend.domain.question.dto.QuestionRequest;
import io.f1.backend.domain.quiz.dao.QuizRepository;
import io.f1.backend.domain.quiz.dto.QuizCreateRequest;
import io.f1.backend.domain.quiz.dto.QuizCreateResponse;
import io.f1.backend.domain.quiz.dto.QuizListPageResponse;
import io.f1.backend.domain.quiz.dto.QuizListResponse;
import io.f1.backend.domain.quiz.dto.QuizUpdateRequest;
import io.f1.backend.domain.quiz.entity.Quiz;
import io.f1.backend.domain.user.dao.UserRepository;
import io.f1.backend.domain.user.entity.User;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
Expand All @@ -23,8 +34,10 @@
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.UUID;

@Slf4j
@Service
@RequiredArgsConstructor
public class QuizService {
Expand All @@ -35,6 +48,8 @@ public class QuizService {
@Value("${file.default-thumbnail-url}")
private String defaultThumbnailPath;

private final String DEFAULT = "default";

// TODO : 시큐리티 구현 이후 삭제해도 되는 의존성 주입
private final UserRepository userRepository;
private final QuestionService questionService;
Expand Down Expand Up @@ -92,6 +107,92 @@ private String getExtension(String filename) {
return filename.substring(filename.lastIndexOf(".") + 1);
}

@Transactional
public void deleteQuiz(Long quizId) {

Quiz quiz =
quizRepository
.findById(quizId)
.orElseThrow(() -> new NoSuchElementException("존재하지 않는 퀴즈입니다."));

// TODO : util 메서드에서 사용자 ID 꺼내쓰는 식으로 수정하기
if (1L != quiz.getCreator().getId()) {
throw new RuntimeException("권한이 없습니다.");
}

deleteThumbnailFile(quiz.getThumbnailUrl());
quizRepository.deleteById(quizId);
}

@Transactional
public void updateQuiz(Long quizId, MultipartFile thumbnailFile, QuizUpdateRequest request)
throws IOException {

Quiz quiz =
quizRepository
.findById(quizId)
.orElseThrow(() -> new NoSuchElementException("존재하지 않는 퀴즈입니다."));

if (request.title() != null) {
quiz.changeTitle(request.title());
}

if (request.description() != null) {
quiz.changeDescription(request.description());
}

if (thumbnailFile != null && !thumbnailFile.isEmpty()) {
validateImageFile(thumbnailFile);
String newThumbnailPath = convertToThumbnailPath(thumbnailFile);

deleteThumbnailFile(quiz.getThumbnailUrl());
quiz.changeThumbnailUrl(newThumbnailPath);
}
}

private void deleteThumbnailFile(String oldFilename) {
if (oldFilename.contains(DEFAULT)) {
return;
}

// oldFilename : /images/thumbnail/123asd.jpg
// filename : 123asd.jpg
String filename = oldFilename.substring(oldFilename.lastIndexOf("/") + 1);
Path filePath = Paths.get(uploadPath, filename).toAbsolutePath();

try {
boolean deleted = deleteIfExists(filePath);
if (deleted) {
log.info("기존 썸네일 삭제 완료 : {}", filePath);
} else {
log.info("기존 썸네일 존재 X : {}", filePath);
}
} catch (IOException e) {
log.error("기존 썸네일 삭제 중 오류 : {}", filePath);
throw new RuntimeException(e);
}
}

@Transactional(readOnly = true)
public QuizListPageResponse getQuizzes(String title, String creator, Pageable pageable) {

Page<Quiz> quizzes;

// 검색어가 있을 때
if (StringUtils.isBlank(title)) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[L5-참고의견]
StringUtils의 사용은 Apache Commons를 주로 사용하는 것 같습니다. (멘토링 코드 리뷰 때 알게 됐습니다)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

헉 import가 잘못됐네요 ! 감사합니다 ! 혹시 멘토링 때 그 차이점이 뭐라고 하셨나요?

quizzes = quizRepository.findQuizzesByTitleContaining(title, pageable);
} else if (StringUtils.isBlank(creator)) {
quizzes = quizRepository.findQuizzesByCreator_NicknameContaining(creator, pageable);
} else { // 검색어가 없을 때 혹은 빈 문자열일 때
quizzes = quizRepository.findAll(pageable);
}

Page<QuizListResponse> quizListResponses = pageQuizToPageQuizListResponse(quizzes);

return toQuizListPageResponse(quizListResponses);
}

@Transactional(readOnly = true)
public Quiz getQuizById(Long quizId) {
return quizRepository
.findById(quizId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

import io.f1.backend.domain.quiz.entity.Quiz;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface QuizRepository extends JpaRepository<Quiz, Long> {}
public interface QuizRepository extends JpaRepository<Quiz, Long> {

Page<Quiz> findQuizzesByTitleContaining(String title, Pageable pageable);

Page<Quiz> findQuizzesByCreator_NicknameContaining(String creator, Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package io.f1.backend.domain.quiz.dto;

import java.util.List;

public record QuizListPageResponse(
int totalPages, int currentPage, long totalElements, List<QuizListResponse> quiz) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package io.f1.backend.domain.quiz.dto;

public record QuizListResponse(
Long quizId,
String title,
String description,
String creatorNickname,
int numberOfQuestion,
String thumbnailUrl) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package io.f1.backend.domain.quiz.dto;

public record QuizUpdateRequest(String title, String description) {}
12 changes: 12 additions & 0 deletions backend/src/main/java/io/f1/backend/domain/quiz/entity/Quiz.java
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,16 @@ public Quiz(
public void addQuestion(Question question) {
this.questions.add(question);
}

public void changeTitle(String title) {
this.title = title;
}

public void changeDescription(String description) {
this.description = description;
}

public void changeThumbnailUrl(String thumbnailUrl) {
this.thumbnailUrl = thumbnailUrl;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@

import io.f1.backend.domain.quiz.dto.QuizCreateRequest;
import io.f1.backend.domain.quiz.dto.QuizCreateResponse;
import io.f1.backend.domain.quiz.dto.QuizListPageResponse;
import io.f1.backend.domain.quiz.dto.QuizListResponse;
import io.f1.backend.domain.quiz.entity.Quiz;
import io.f1.backend.domain.user.entity.User;

import org.springframework.data.domain.Page;

public class QuizMapper {

// TODO : 이후 파라미터에서 user 삭제하기
Expand All @@ -30,4 +34,26 @@ public static QuizCreateResponse quizToQuizCreateResponse(Quiz quiz) {
quiz.getThumbnailUrl(),
quiz.getCreator().getId());
}

public static QuizListResponse quizToQuizListResponse(Quiz quiz) {
return new QuizListResponse(
quiz.getId(),
quiz.getTitle(),
quiz.getDescription(),
quiz.getCreator().getNickname(),
quiz.getQuestions().size(),
quiz.getThumbnailUrl());
}

public static QuizListPageResponse toQuizListPageResponse(Page<QuizListResponse> quizzes) {
return new QuizListPageResponse(
quizzes.getTotalPages(),
quizzes.getNumber() + 1,
quizzes.getTotalElements(),
quizzes.getContent());
}

public static Page<QuizListResponse> pageQuizToPageQuizListResponse(Page<Quiz> quizzes) {
return quizzes.map(QuizMapper::quizToQuizListResponse);
}
}