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,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<ApiResponseDto<DailyQuestionResponseV2>> 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<ApiResponseDto<DailyQuestionResponseV2>> rerollDailyQuestion(
@AuthenticationPrincipal UserPrincipal principal
) {
DailyQuestionResponseV2 response = questionCommandService.rerollTodayQuestion(principal.getId());
return ApiResponseEntity.ok(response);
}
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,23 @@ 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;
}

// 리롤 처리
public void rerollTo(DailyQuestion newQuestion) {
this.dailyQuestion = newQuestion;
this.rerollUsed = true;
this.rerollLeft = Math.max(0, this.rerollLeft - 1);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@
public interface UserDailyQuestionRepository extends JpaRepository<UserDailyQuestion, Long> {

Optional<UserDailyQuestion> findByUserIdAndDate(Long userId, LocalDate date);

boolean existsByUserId(Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 (지갑) ====================
Expand Down
1 change: 1 addition & 0 deletions src/main/resources/application-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ springdoc:
enabled: true
swagger-ui:
path: /swagger-ui.html
tags-sorter: alpha

oauth:
naver:
Expand Down
1 change: 1 addition & 0 deletions src/main/resources/application-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ springdoc:
enabled: true
swagger-ui:
path: /swagger-ui.html
tags-sorter: alpha

oauth:
naver:
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading