diff --git a/src/main/java/com/devkor/ifive/nadab/domain/question/api/QuestionControllerV2.java b/src/main/java/com/devkor/ifive/nadab/domain/question/api/QuestionControllerV2.java new file mode 100644 index 00000000..eeee26f8 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/question/api/QuestionControllerV2.java @@ -0,0 +1,108 @@ +package com.devkor.ifive.nadab.domain.question.api; + +import com.devkor.ifive.nadab.domain.question.api.dto.response.DailyQuestionResponseV2; +import com.devkor.ifive.nadab.domain.question.application.QuestionCommandServiceV2; +import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; +import com.devkor.ifive.nadab.global.core.response.ApiResponseEntity; +import com.devkor.ifive.nadab.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "질문 API V2", description = "오늘의 질문 관련 API V2") +@RestController +@RequestMapping("/api/v2/question") +@RequiredArgsConstructor +public class QuestionControllerV2 { + + private final QuestionCommandServiceV2 questionCommandService; + + @GetMapping + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "오늘의 질문 조회", + description = "오늘의 질문을 조회합니다.", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse( + responseCode = "200", + description = "성공", + content = @Content(schema = @Schema(implementation = DailyQuestionResponseV2.class), mediaType = "application/json") + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패 (JWT 토큰 관련)", + content = @Content + ), + @ApiResponse( + responseCode = "404", + description = """ + - ErrorCode: USER_NOT_FOUND - 사용자를 찾을 수 없습니다. + - ErrorCode: USER_INTEREST_NOT_FOUND - 관심 주제를 찾을 수 없습니다. + - ErrorCode: QUESTION_NOT_FOUND_FOR_CONDITION - 조건에 맞는 질문을 찾을 수 없습니다. + """, + content = @Content + ) + } + ) + public ResponseEntity> getDailyQuestion( + @AuthenticationPrincipal UserPrincipal principal + ) { + DailyQuestionResponseV2 response = questionCommandService.getOrCreateTodayQuestion(principal.getId()); + return ApiResponseEntity.ok(response); + } + + @PostMapping("/reroll") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "새로운 질문 받기", + description = "오늘의 질문을 새로 받습니다. 하루에 5번까지 가능합니다.", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse( + responseCode = "200", + description = "성공", + content = @Content(schema = @Schema(implementation = DailyQuestionResponseV2.class), mediaType = "application/json") + ), + @ApiResponse( + responseCode = "401", + description = "사용자 인증 실패", + content = @Content + ), + @ApiResponse( + responseCode = "404", + description = """ + - ErrorCode: DAILY_QUESTION_NOT_FOUND - 오늘의 질문이 아직 생성되지 않았습니다. + - ErrorCode: USER_INTEREST_NOT_FOUND - 유저의 관심 주제를 찾을 수 없습니다. + - ErrorCode: QUESTION_NO_ALTERNATIVE - 리롤 가능한 질문이 없습니다. + """, + content = @Content + ), + @ApiResponse( + responseCode = "409", + description = """ + - ErrorCode: QUESTION_REROLL_LIMIT_EXCEEDED - 오늘의 질문은 하루에 5번까지만 새로 받을 수 있습니다. + - ErrorCode: QUESTION_ALREADY_ANSWERED - 오늘의 질문에 이미 답변을 작성함 + """, + content = @Content + ) + } + ) + public ResponseEntity> rerollDailyQuestion( + @AuthenticationPrincipal UserPrincipal principal + ) { + DailyQuestionResponseV2 response = questionCommandService.rerollTodayQuestion(principal.getId()); + return ApiResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/question/api/dto/response/DailyQuestionResponseV2.java b/src/main/java/com/devkor/ifive/nadab/domain/question/api/dto/response/DailyQuestionResponseV2.java new file mode 100644 index 00000000..524bd218 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/question/api/dto/response/DailyQuestionResponseV2.java @@ -0,0 +1,31 @@ +package com.devkor.ifive.nadab.domain.question.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "오늘의 질문 응답") +public record DailyQuestionResponseV2( + @Schema(description = "질문 ID") + Long questionId, + + @Schema(description = "관심 주제 코드", example = "PREFERENCE") + String interestCode, + + @Schema(description = "질문 텍스트", example = "요즘 자주 찾는 색깔은 무엇인가요?") + String questionText, + + @Schema(description = "공감 가이드 텍스트", example = "색깔 하나로 기분이 달라질 때가 있어요.") + String empathyGuide, + + @Schema(description = "힌트 가이드 텍스트", example = "지금 입은 옷이나 주변 소품을 보세요.") + String hintGuide, + + @Schema(description = "도입 질문 가이드 텍스트", example = "그 색을 보면 어떤 기분이 드나요?") + String leadingQuestionGuide, + + @Schema(description = "사용자가 오늘의 질문에 답변했는지 여부") + boolean answered, + + @Schema(description = "사용자의 오늘의 질문을 새로 받기 남은 횟수") + int rerollRemainingCount +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/question/application/QuestionCommandServiceV2.java b/src/main/java/com/devkor/ifive/nadab/domain/question/application/QuestionCommandServiceV2.java new file mode 100644 index 00000000..c2385c8f --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/question/application/QuestionCommandServiceV2.java @@ -0,0 +1,128 @@ +package com.devkor.ifive.nadab.domain.question.application; + +import com.devkor.ifive.nadab.domain.dailyreport.core.repository.AnswerEntryRepository; +import com.devkor.ifive.nadab.domain.question.api.dto.response.DailyQuestionResponseV2; +import com.devkor.ifive.nadab.domain.question.application.helper.DailyQuestionSelector; +import com.devkor.ifive.nadab.domain.question.application.helper.QuestionLevelPolicy; +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestion; +import com.devkor.ifive.nadab.domain.question.core.entity.UserDailyQuestion; +import com.devkor.ifive.nadab.domain.question.core.repository.UserDailyQuestionRepository; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.core.repository.UserInterestRepository; +import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.ConflictException; +import com.devkor.ifive.nadab.global.exception.NotFoundException; +import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +@Service +@RequiredArgsConstructor +@Transactional +public class QuestionCommandServiceV2 { + + private final UserRepository userRepository; + private final UserDailyQuestionRepository userDailyQuestionRepository; + private final UserInterestRepository userInterestRepository; + private final AnswerEntryRepository answerEntryRepository; + + private final QuestionLevelPolicy questionLevelPolicy; + private final DailyQuestionSelector dailyQuestionSelector; + + public DailyQuestionResponseV2 getOrCreateTodayQuestion(Long userId) { + + User user = userRepository.findById(userId) + .orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND)); + + LocalDate today = TodayDateTimeProvider.getTodayDate(); + UserDailyQuestion udq = userDailyQuestionRepository.findByUserIdAndDate(userId, today) + .orElseGet(() -> this.createTodayQuestion(userId, today)); + + DailyQuestion question = udq.getDailyQuestion(); + + boolean answered = answerEntryRepository.existsActiveAnswer(userId, question.getId()); + + return new DailyQuestionResponseV2( + question.getId(), + question.getInterest().getCode().toString(), + question.getQuestionText(), + question.getEmpathyGuide(), + question.getHintGuide(), + question.getLeadingQuestionGuide(), + answered, + udq.getRerollLeft() + ); + } + + public UserDailyQuestion createTodayQuestion(Long userId, LocalDate todayKst) { + // 동시성: 여러 요청이 동시에 들어오면 UNIQUE(user_id, date)로 한 번만 성공해야 함 + // -> insert 시도 후 unique 위반이면 다시 조회해서 반환 + try { + User user = userRepository.findById(userId) + .orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND)); + + Long userInterestId = userInterestRepository.findInterestIdByUserId(userId) + .orElseThrow(() -> new NotFoundException(ErrorCode.USER_INTEREST_NOT_FOUND)); + + boolean isFirstQuestion = !(userDailyQuestionRepository.existsByUserId(userId)); + + Integer levelOnly = questionLevelPolicy.levelOnlyForFirstTime(isFirstQuestion); + + DailyQuestion picked = dailyQuestionSelector.pickFirst(user.getId(), userInterestId, levelOnly); + + UserDailyQuestion udq = UserDailyQuestion.create(user, todayKst, picked); + return userDailyQuestionRepository.save(udq); + + } catch (DataIntegrityViolationException e) { + // 이미 생성됨(경합 상황) + return userDailyQuestionRepository.findByUserIdAndDate(userId, todayKst) + .orElseThrow(() -> e); + } + } + + public DailyQuestionResponseV2 rerollTodayQuestion(Long userId) { + LocalDate today = TodayDateTimeProvider.getTodayDate(); + + UserDailyQuestion udq = userDailyQuestionRepository.findByUserIdAndDate(userId, today) + .orElseThrow(() -> new NotFoundException(ErrorCode.DAILY_QUESTION_NOT_FOUND)); + + if (udq.getRerollLeft() <= 0) { + throw new ConflictException(ErrorCode.QUESTION_REROLL_LIMIT_EXCEEDED); + } + + User user = udq.getUser(); + + boolean alreadyAnswered = answerEntryRepository.existsByUserAndDate(user, today); + if (alreadyAnswered) { + throw new ConflictException(ErrorCode.QUESTION_ALREADY_ANSWERED); + } + + Long userInterestId = userInterestRepository.findInterestIdByUserId(userId) + .orElseThrow(() -> new NotFoundException(ErrorCode.USER_INTEREST_NOT_FOUND)); + + DailyQuestion newQ = dailyQuestionSelector.pickReroll( + user.getId(), + userInterestId, + udq.getDailyQuestion().getId(), + null + ); + + udq.rerollTo(newQ); + + return new DailyQuestionResponseV2( + newQ.getId(), + newQ.getInterest().getCode().toString(), + newQ.getQuestionText(), + newQ.getEmpathyGuide(), + newQ.getHintGuide(), + newQ.getLeadingQuestionGuide(), + false, + udq.getRerollLeft() + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/question/application/helper/QuestionLevelPolicy.java b/src/main/java/com/devkor/ifive/nadab/domain/question/application/helper/QuestionLevelPolicy.java index 098fe9d6..e68867a2 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/question/application/helper/QuestionLevelPolicy.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/question/application/helper/QuestionLevelPolicy.java @@ -22,4 +22,8 @@ public Integer levelOnlyFor(User user, OffsetDateTime now) { boolean isNewbie = registeredAt.isAfter(now.minusDays(NEWBIE_DAYS)); return isNewbie ? NEWBIE_LEVEL_ONLY : null; } + + public Integer levelOnlyForFirstTime(boolean isFirstQuestion) { + return isFirstQuestion ? NEWBIE_LEVEL_ONLY : null; + } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/question/core/entity/UserDailyQuestion.java b/src/main/java/com/devkor/ifive/nadab/domain/question/core/entity/UserDailyQuestion.java index c130083c..80405611 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/question/core/entity/UserDailyQuestion.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/question/core/entity/UserDailyQuestion.java @@ -38,12 +38,16 @@ public class UserDailyQuestion extends AuditableEntity { @Column(name = "reroll_used", nullable = false) private boolean rerollUsed = false; + @Column(name = "reroll_left", nullable = false) + private int rerollLeft = 5; + public static UserDailyQuestion create(User user, LocalDate date, DailyQuestion dailyQuestion) { UserDailyQuestion udq = new UserDailyQuestion(); udq.user = user; udq.date = date; udq.dailyQuestion = dailyQuestion; udq.rerollUsed = false; + udq.rerollLeft = 5; return udq; } @@ -51,5 +55,6 @@ public static UserDailyQuestion create(User user, LocalDate date, DailyQuestion public void rerollTo(DailyQuestion newQuestion) { this.dailyQuestion = newQuestion; this.rerollUsed = true; + this.rerollLeft = Math.max(0, this.rerollLeft - 1); } } \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/question/core/repository/UserDailyQuestionRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/question/core/repository/UserDailyQuestionRepository.java index c3a4ce5d..1dde401b 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/question/core/repository/UserDailyQuestionRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/question/core/repository/UserDailyQuestionRepository.java @@ -9,4 +9,6 @@ public interface UserDailyQuestionRepository extends JpaRepository { Optional findByUserIdAndDate(Long userId, LocalDate date); + + boolean existsByUserId(Long userId); } diff --git a/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java b/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java index bb38165e..a2c6053a 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java +++ b/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java @@ -104,7 +104,7 @@ public enum ErrorCode { DAILY_QUESTION_NOT_FOUND(HttpStatus.NOT_FOUND, "오늘의 질문이 아직 생성되지 않았습니다"), // 409 Conflict - QUESTION_REROLL_LIMIT_EXCEEDED(HttpStatus.CONFLICT, "오늘의 질문은 하루에 한 번만 새로 받을 수 있습니다"), + QUESTION_REROLL_LIMIT_EXCEEDED(HttpStatus.CONFLICT, "오늘의 질문은 변경 가능 횟수가 모두 소진되었습니다"), QUESTION_ALREADY_ANSWERED(HttpStatus.CONFLICT, "오늘의 질문에 이미 답변을 작성한 후에는 질문을 새로 받을 수 없습니다"), // ==================== WALLET (지갑) ==================== diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 8b806859..e89709a2 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -20,6 +20,7 @@ springdoc: enabled: true swagger-ui: path: /swagger-ui.html + tags-sorter: alpha oauth: naver: diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index ed280ab7..0bf513ff 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -21,6 +21,7 @@ springdoc: enabled: true swagger-ui: path: /swagger-ui.html + tags-sorter: alpha oauth: naver: diff --git a/src/main/resources/db/migration/V20260503_1953__IS_add_reroll_left_to_user_daily_questions.sql b/src/main/resources/db/migration/V20260503_1953__IS_add_reroll_left_to_user_daily_questions.sql new file mode 100644 index 00000000..7ce4ef99 --- /dev/null +++ b/src/main/resources/db/migration/V20260503_1953__IS_add_reroll_left_to_user_daily_questions.sql @@ -0,0 +1,12 @@ +ALTER TABLE user_daily_questions +ADD COLUMN reroll_left INT; + +UPDATE user_daily_questions +SET reroll_left = 5 +WHERE reroll_left IS NULL; + +ALTER TABLE user_daily_questions +ALTER COLUMN reroll_left SET DEFAULT 5; + +ALTER TABLE user_daily_questions +ALTER COLUMN reroll_left SET NOT NULL; diff --git a/src/test/java/com/devkor/ifive/nadab/domain/question/core/entity/UserDailyQuestionTest.java b/src/test/java/com/devkor/ifive/nadab/domain/question/core/entity/UserDailyQuestionTest.java new file mode 100644 index 00000000..adde0853 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/question/core/entity/UserDailyQuestionTest.java @@ -0,0 +1,37 @@ +package com.devkor.ifive.nadab.domain.question.core.entity; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +class UserDailyQuestionTest { + + @Test + void create_초기_리롤_횟수는_5다() { + // given + LocalDate date = LocalDate.now(); + DailyQuestion dailyQuestion = new DailyQuestion(); + + // when + UserDailyQuestion userDailyQuestion = UserDailyQuestion.create(null, date, dailyQuestion); + + // then + assertThat(userDailyQuestion.getRerollLeft()).isEqualTo(5); + assertThat(userDailyQuestion.isRerollUsed()).isFalse(); + } + + @Test + void rerollTo_호출시_rerollUsed는_true가_된다() { + // given + LocalDate date = LocalDate.now(); + UserDailyQuestion userDailyQuestion = UserDailyQuestion.create(null, date, new DailyQuestion()); + + // when + userDailyQuestion.rerollTo(new DailyQuestion()); + + // then + assertThat(userDailyQuestion.isRerollUsed()).isTrue(); + } +}