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
4 changes: 4 additions & 0 deletions commitlint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ module.exports = {
"auth",
"user",
"ai",
"report",
"friend",
"notify",
"infra",
Expand Down Expand Up @@ -104,6 +105,9 @@ module.exports = {
ai: {
description: '🤖 AI 처리 (예: 벡터 검색, OpenAI API, 모델 응답 로직)'
},
report: {
description: '📔 리포트 도메인 (예: 리포트 생성, 조회, 관리)'
},
friend: {
description: '👥 친구 도메인 (예: 친구 신청, 수락, 목록 관리)'
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package com.devkor.ifive.nadab.domain.question.api;

import com.devkor.ifive.nadab.domain.question.api.dto.response.DailyQuestionResponse;
import com.devkor.ifive.nadab.domain.question.application.QuestionCommandService;
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", description = "오늘의 질문 관련 API")
@RestController
@RequestMapping("${api_prefix}/question")
@RequiredArgsConstructor
public class QuestionController {

private final QuestionCommandService questionCommandService;

@GetMapping
@PreAuthorize("isAuthenticated()")
@Operation(
summary = "오늘의 질문 조회",
description = "오늘의 질문을 조회합니다.",
security = @SecurityRequirement(name = "bearerAuth"),
responses = {
@ApiResponse(
responseCode = "200",
description = "성공",
content = @Content(schema = @Schema(implementation = DailyQuestionResponse.class), mediaType = "application/json")
),
@ApiResponse(
responseCode = "401",
description = "사용자 인증 실패",
content = @Content
)
}
)
public ResponseEntity<ApiResponseDto<DailyQuestionResponse>> getDailyQuestion(
@AuthenticationPrincipal UserPrincipal principal
) {
DailyQuestionResponse response = questionCommandService.getOrCreateTodayQuestion(principal.getId());
return ApiResponseEntity.ok(response);
}

@PostMapping("/reroll")
@PreAuthorize("isAuthenticated()")
@Operation(
summary = "새로운 질문 받기",
description = "오늘의 질문을 새로 받습니다. 하루에 한 번만 가능합니다.",
security = @SecurityRequirement(name = "bearerAuth"),
responses = {
@ApiResponse(
responseCode = "200",
description = "성공",
content = @Content(schema = @Schema(implementation = DailyQuestionResponse.class), mediaType = "application/json")
),
@ApiResponse(
responseCode = "401",
description = "사용자 인증 실패",
content = @Content
),
@ApiResponse(
responseCode = "409",
description = "오늘의 질문은 하루에 한 번만 새로 받을 수 있습니다.",
content = @Content
)
}
)
public ResponseEntity<ApiResponseDto<DailyQuestionResponse>> rerollDailyQuestion(
@AuthenticationPrincipal UserPrincipal principal
) {
DailyQuestionResponse response = questionCommandService.rerollTodayQuestion(principal.getId());
return ApiResponseEntity.ok(response);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.devkor.ifive.nadab.domain.question.api.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;

@Schema(description = "오늘의 질문 응답")
public record DailyQuestionResponse(
@Schema(description = "질문 ID")
Long questionId,

@Schema(description = "질문 텍스트")
String questionText,

@Schema(description = "공감 가이드 텍스트")
String empathyGuide,

@Schema(description = "힌트 가이드 텍스트")
String hintGuide,

@Schema(description = "도입 질문 가이드 텍스트")
String leadingQuestionGuide,

@Schema(description = "사용자가 오늘의 질문에 답변했는지 여부")
boolean answered,

@Schema(description = "사용자가 새로운 질문 받기를 했는지 여부")
boolean rerollUsed
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package com.devkor.ifive.nadab.domain.question.application;

import com.devkor.ifive.nadab.domain.question.api.dto.response.DailyQuestionResponse;
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.application.helper.WeightedInterestPicker;
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.report.core.repository.AnswerEntryRepository;
import com.devkor.ifive.nadab.domain.user.core.entity.User;
import com.devkor.ifive.nadab.domain.user.core.repository.InterestRepository;
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.exception.BadRequestException;
import com.devkor.ifive.nadab.global.exception.ConflictException;
import com.devkor.ifive.nadab.global.exception.NotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.*;
import java.util.List;

@Service
@RequiredArgsConstructor
@Transactional
public class QuestionCommandService {

private static final ZoneId KST = ZoneId.of("Asia/Seoul");

private final UserRepository userRepository;
private final UserDailyQuestionRepository userDailyQuestionRepository;
private final UserInterestRepository userInterestRepository;
private final InterestRepository interestRepository;
private final AnswerEntryRepository answerEntryRepository;

private final QuestionLevelPolicy questionLevelPolicy;
private final WeightedInterestPicker weightedInterestPicker;
private final DailyQuestionSelector dailyQuestionSelector;

public DailyQuestionResponse getOrCreateTodayQuestion(Long userId) {
LocalDate today = LocalDate.now(KST);

User user = userRepository.findById(userId)
.orElseThrow(() -> new NotFoundException("사용자를 찾을 수 없습니다. id: " + userId));

UserDailyQuestion udq = userDailyQuestionRepository.findByUserIdAndDate(userId, today)
.orElseGet(() -> this.createTodayQuestion(userId, today));

DailyQuestion question = udq.getDailyQuestion();

boolean answered = answerEntryRepository.existsActiveAnswer(userId, question.getId());

return new DailyQuestionResponse(
question.getId(),
question.getQuestionText(),
question.getEmpathyGuide(),
question.getHintGuide(),
question.getLeadingQuestionGuide(),
answered,
udq.isRerollUsed()
);
}

/**
* 오늘 첫 질문 생성:
* - UserInterest의 interest_id 질문들 중 랜덤 1개
* - 가입 2주 미만은 level = 1만
* - 이미 답변한 질문은 제외
*/
public UserDailyQuestion createTodayQuestion(Long userId, LocalDate todayKst) {
// 동시성: 여러 요청이 동시에 들어오면 UNIQUE(user_id, date)로 한 번만 성공해야 함
// -> insert 시도 후 unique 위반이면 다시 조회해서 반환
try {
User user = userRepository.findById(userId)
.orElseThrow(() -> new NotFoundException("사용자를 찾을 수 없습니다. id: " + userId));

Long userInterestId = userInterestRepository.findInterestIdByUserId(userId)
.orElseThrow(() -> new NotFoundException("사용자의 관심 주제를 찾을 수 없습니다. id: " + userId));

Integer levelOnly = questionLevelPolicy.levelOnlyFor(user, OffsetDateTime.now());

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);
}
}

/**
* 리롤:
* - reroll_used = false일 때만 허용
* - 관심사 가중치 랜덤: 내 interest 50%, 나머지 interest는 동등 분배
* - 선택된 interest 내에서 랜덤 질문 (단, 현재 질문 제외)
* - 가입 2주 미만이면 level = 1만
* - 이미 답변한 질문은 제외
*/
public DailyQuestionResponse rerollTodayQuestion(Long userId) {
LocalDate today = LocalDate.now(KST);

UserDailyQuestion udq = userDailyQuestionRepository.findByUserIdAndDate(userId, today)
.orElseThrow(() -> new ConflictException("오늘의 첫 질문이 아직 생성되지 않았습니다."));

if (udq.isRerollUsed()) {
throw new BadRequestException("오늘의 질문은 하루에 한 번만 새로 받을 수 있습니다.");
}

User user = udq.getUser();

Long userInterestId = userInterestRepository.findInterestIdByUserId(userId)
.orElseThrow(() -> new NotFoundException("유저 관심 주제가 없습니다. id: " + userId));

List<Long> allInterestIds = interestRepository.findAllIds();
Long selectedInterestId = weightedInterestPicker.pickForReroll(userInterestId, allInterestIds);

Integer levelOnly = questionLevelPolicy.levelOnlyFor(user, OffsetDateTime.now());

DailyQuestion newQ = dailyQuestionSelector.pickReroll(
user.getId(),
selectedInterestId,
udq.getDailyQuestion().getId(),
levelOnly
);

udq.rerollTo(newQ);

return new DailyQuestionResponse(
newQ.getId(),
newQ.getQuestionText(),
newQ.getEmpathyGuide(),
newQ.getHintGuide(),
newQ.getLeadingQuestionGuide(),
false,
true
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.devkor.ifive.nadab.domain.question.application.helper;

import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestion;
import com.devkor.ifive.nadab.domain.question.core.repository.DailyQuestionRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Component;


@Component
@RequiredArgsConstructor
public class DailyQuestionSelector {

private final DailyQuestionRepository dailyQuestionRepository;

public DailyQuestion pickFirst(Long userId, Long interestId, Integer levelOnly) {
return dailyQuestionRepository.findRandomByInterestExcludingAnswered(userId, interestId, levelOnly, PageRequest.of(0, 1))
.stream()
.findFirst()
.orElseThrow(() -> new IllegalStateException("조건에 맞는 질문이 없습니다."));
}

public DailyQuestion pickReroll(Long userId, Long interestId, Long excludeQuestionId, Integer levelOnly) {
return dailyQuestionRepository.findRandomByInterestExcludingAnsweredAndExcludingId(userId, interestId, excludeQuestionId, levelOnly, PageRequest.of(0, 1))
.stream()
.findFirst()
.orElseThrow(() -> new IllegalStateException("리롤 가능한 질문이 없습니다."));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.devkor.ifive.nadab.domain.question.application.helper;

import com.devkor.ifive.nadab.domain.user.core.entity.User;
import org.springframework.stereotype.Component;

import java.time.OffsetDateTime;

/**
* 질문 작성 시, 사용자 레벨에 따른 정책을 적용하는 헬퍼 클래스
* - 신규 사용자(가입 후 14일 이내)는 레벨 1 질문만 작성 가능
*/
@Component
public class QuestionLevelPolicy {

private static final int NEWBIE_DAYS = 14;
private static final int NEWBIE_LEVEL_ONLY = 1;

public Integer levelOnlyFor(User user, OffsetDateTime now) {
OffsetDateTime registeredAt = user.getRegisteredAt();
if (registeredAt == null) return null;

boolean isNewbie = registeredAt.isAfter(now.minusDays(NEWBIE_DAYS));
return isNewbie ? NEWBIE_LEVEL_ONLY : null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.devkor.ifive.nadab.domain.question.application.helper;

import org.springframework.stereotype.Component;

import java.util.List;
import java.util.concurrent.ThreadLocalRandom;

/**
* 가중치 기반으로 관심사를 선택하는 헬퍼 클래스
* - 사용자의 기존 관심사에 더 높은 가중치(0.5)를 부여하여 재선택
* - 나머지 관심사들은 균등하게 나머지 가중치(0.5)를 분배
*/
@Component
public class WeightedInterestPicker {

public Long pickForReroll(Long userInterestId, List<Long> allInterestIds) {
if (allInterestIds == null || allInterestIds.isEmpty()) {
throw new IllegalStateException("interest 목록이 비어있습니다.");
}
if (allInterestIds.size() == 1) {
return allInterestIds.get(0);
}

double userWeight = 0.5d;
double othersWeightEach = 0.5d / (allInterestIds.size() - 1);

double r = ThreadLocalRandom.current().nextDouble(0.0, 1.0);
double acc = 0.0;

for (Long id : allInterestIds) {
double w = id.equals(userInterestId) ? userWeight : othersWeightEach;
acc += w;
if (r <= acc) return id;
}
return allInterestIds.get(allInterestIds.size() - 1); // float 오차 방지
}
}
Loading