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
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ dependencies {

// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.redisson:redisson-spring-boot-starter:3.52.0'

// QueryDSL
implementation 'io.github.openfeign.querydsl:querydsl-core:7.0'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
import OneQ.OnSurvey.domain.participation.model.dto.AnswerInsertDto;

public interface AnswerCommand<E extends AbstractAnswer> {
Boolean upsertAnswers(AnswerInsertDto insertDto);
Boolean upsertAnswers(AnswerInsertDto insertDto, Long surveyId, Long userKey, Long memberId);
Boolean insertAnswer(AnswerInsertDto.AnswerInfo answerInfo);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public abstract class AnswerCommandService<E extends AbstractAnswer> implements
protected final AnswerRepository<E> answerRepository;
protected final ResponseRepository responseRepository;

public Boolean upsertAnswers(AnswerInsertDto insertDto) {
public Boolean upsertAnswers(AnswerInsertDto insertDto, Long surveyId, Long userKey, Long memberId) {
List<E> answerList = insertDto.getAnswerInfoList().stream()
.map(this::createAnswerFromDto)
.toList();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@
import OneQ.OnSurvey.domain.participation.model.dto.AnswerInsertDto;
import OneQ.OnSurvey.domain.participation.repository.answer.AnswerRepository;
import OneQ.OnSurvey.domain.participation.repository.response.ResponseRepository;
import OneQ.OnSurvey.domain.question.repository.question.QuestionRepository;
import OneQ.OnSurvey.domain.survey.SurveyErrorCode;
import OneQ.OnSurvey.global.common.exception.CustomException;
import OneQ.OnSurvey.global.common.exception.ErrorCode;
import OneQ.OnSurvey.global.infra.redis.RedisAgent;
import lombok.extern.slf4j.Slf4j;
import org.redisson.client.RedisException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -20,105 +25,120 @@

@Slf4j
@Service
@Transactional
public class QuestionAnswerCommandService extends AnswerCommandService<QuestionAnswer> {

private final QuestionRepository questionRepository;
@Value("${redis.survey-key-prefix.lock}")
private String surveyLockKeyPrefix;

private final RedisAgent redisAgent;

public QuestionAnswerCommandService(
AnswerRepository<QuestionAnswer> answerRepository,
ResponseRepository responseRepository,
QuestionRepository questionRepository
RedisAgent redisAgent
) {
super(answerRepository, responseRepository);
this.questionRepository = questionRepository;
this.redisAgent = redisAgent;
}

@Override
@Transactional
public QuestionAnswer createAnswerFromDto(AnswerInsertDto.AnswerInfo answerInfo) {
return QuestionAnswer.from(answerInfo);
}

@Override
public Boolean upsertAnswers(AnswerInsertDto insertDto) {
AnswerInsertDto.AnswerInfo first = insertDto.getAnswerInfoList().getFirst();
Long memberId = first.getMemberId();
public Boolean upsertAnswers(AnswerInsertDto insertDto, Long surveyId, Long userKey, Long memberId) {
log.info("[QUESTION_ANSWER:COMMAND] 문항 응답 생성 - memberId: {}", memberId);

// 새로운 응답을 questionId 기준으로 그룹화
Map<Long, Set<QuestionAnswer>> newQuestionAnswerMap = insertDto.getAnswerInfoList().stream()
.map(this::createAnswerFromDto)
.collect(Collectors.groupingBy(QuestionAnswer::getQuestionId, Collectors.toSet()));

// 새로운 응답의 questionId로부터 기존 응답 조회 및 그룹화
List<Long> questionIdList = newQuestionAnswerMap.keySet().stream().toList();
Map<Long, Set<QuestionAnswer>> existingQuestionAnswerMap =
answerRepository.getAnswerListByQuestionIdsAndMemberId(questionIdList, memberId)
.stream()
.collect(Collectors.groupingBy(QuestionAnswer::getQuestionId, Collectors.toSet()));

// 새로 저장할 응답 리스트
List<QuestionAnswer> finalAnswersToSave = new ArrayList<>();
// 삭제하지 않을 ID
Set<Long> idSetToKeep = new HashSet<>();

questionIdList.forEach(questionId -> {
// questionId에 대한 새로운 응답과 기존 응답의 content 집합 생성
Set<QuestionAnswer> newAnswerContentSet = newQuestionAnswerMap.getOrDefault(questionId, Set.of());
Set<String> newContents = newAnswerContentSet.stream()
.map(QuestionAnswer::getContent)
.map(content -> content == null ? null : content.strip())
.collect(Collectors.toSet());
Set<QuestionAnswer> existingAnswerContentSet = existingQuestionAnswerMap.getOrDefault(questionId, Set.of());
Set<String> existingContents = existingAnswerContentSet.stream()
.map(QuestionAnswer::getContent)
.collect(Collectors.toSet());

// 새로운 응답이 null을 포함한 경우(객관식) 혹은 빈 문자열인 경우(단답/장문), 해당 문항의 기존 응답은 모두 삭제 대상에 남겨둠
if (!newContents.contains(null) && !newContents.contains("")) {
// 새로운 응답 중 기존에 없는 content는 저장 대상에 추가
newAnswerContentSet.stream()
.filter(newAnswer -> !existingContents.contains(newAnswer.getContent()))
.forEach(finalAnswersToSave::add);
// 새로운 응답에 포함된 기존 응답은 삭제 대상에서 제외
existingAnswerContentSet.stream()
.filter(existingAnswer -> newContents.contains(existingAnswer.getContent()))
.map(QuestionAnswer::getAnswerId)
.forEach(idSetToKeep::add);
}
});
// 삭제할 기존 응답 ID 리스트 (초기값: 기존 응답 전체)
List<Long> finalAnswerIdsToDelete = existingQuestionAnswerMap.values().stream()
.flatMap(Collection::stream)
.map(QuestionAnswer::getAnswerId)
.collect(Collectors.toList());
finalAnswerIdsToDelete.removeAll(idSetToKeep);

if (!finalAnswersToSave.isEmpty()) {
answerRepository.saveAll(finalAnswersToSave);
}
if (!finalAnswerIdsToDelete.isEmpty()) {
answerRepository.deleteAllByIds(finalAnswerIdsToDelete);
}

Long surveyId = getSurveyIdFromQuestion(first.getId());
updateResponseAfterQuestionAnswers(surveyId, first);

return true;
}

private Long getSurveyIdFromQuestion(Long questionId) {
return questionRepository.getSurveyId(questionId);
String lockKey = surveyLockKeyPrefix + surveyId + ":" + userKey;
try {
return redisAgent.executeNewTransactionAfterLock(lockKey, 3, 5, () -> {
/*
새로운 응답의 questionId로부터 기존 응답 조회 및 그룹화
Phantom Read 방지를 위해 조회 로직도 락 내부에서 실행
*/
Map<Long, Set<QuestionAnswer>> existingQuestionAnswerMap =
answerRepository.getAnswerListByQuestionIdsAndMemberId(questionIdList, memberId)
.stream()
.collect(Collectors.groupingBy(QuestionAnswer::getQuestionId, Collectors.toSet()));

// 새로 저장할 응답 리스트
List<QuestionAnswer> finalAnswersToSave = new ArrayList<>();
// 삭제하지 않을 ID
Set<Long> idSetToKeep = new HashSet<>();

questionIdList.forEach(questionId -> {
// questionId에 대한 새로운 응답과 기존 응답의 content 집합 생성
Set<QuestionAnswer> newAnswerContentSet = newQuestionAnswerMap.getOrDefault(questionId, Set.of());
Set<String> newContents = newAnswerContentSet.stream()
.map(QuestionAnswer::getContent)
.map(content -> content == null ? null : content.strip())
.collect(Collectors.toSet());
Set<QuestionAnswer> existingAnswerContentSet = existingQuestionAnswerMap.getOrDefault(questionId, Set.of());
Set<String> existingContents = existingAnswerContentSet.stream()
.map(QuestionAnswer::getContent)
.collect(Collectors.toSet());

// 새로운 응답이 null을 포함한 경우(객관식) 혹은 빈 문자열인 경우(단답/장문), 해당 문항의 기존 응답은 모두 삭제 대상에 남겨둠
if (!newContents.contains(null) && !newContents.contains("")) {
// 새로운 응답 중 기존에 없는 content는 저장 대상에 추가
newAnswerContentSet.stream()
.filter(newAnswer -> !existingContents.contains(newAnswer.getContent()))
.forEach(finalAnswersToSave::add);
// 새로운 응답에 포함된 기존 응답은 삭제 대상에서 제외
existingAnswerContentSet.stream()
.filter(existingAnswer -> newContents.contains(existingAnswer.getContent()))
.map(QuestionAnswer::getAnswerId)
.forEach(idSetToKeep::add);
}
});
// 삭제할 기존 응답 ID 리스트 (초기값: 기존 응답 전체)
List<Long> finalAnswerIdsToDelete = existingQuestionAnswerMap.values().stream()
.flatMap(Collection::stream)
.map(QuestionAnswer::getAnswerId)
.collect(Collectors.toList());
finalAnswerIdsToDelete.removeAll(idSetToKeep);

if (!finalAnswersToSave.isEmpty()) {
answerRepository.saveAll(finalAnswersToSave);
}
if (!finalAnswerIdsToDelete.isEmpty()) {
answerRepository.deleteAllByIds(finalAnswerIdsToDelete);
}

updateResponseAfterQuestionAnswers(surveyId, memberId);

return true;
});
} catch (RedisException e) {
log.warn("[QUESTION_ANSWER:COMMAND] 문항 응답 저장 락 획득 실패 - memberId: {}, error: {}", memberId, e.getMessage());
throw new CustomException(SurveyErrorCode.SURVEY_PARTICIPATION_IN_PROCESS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("[QUESTION_ANSWER:COMMAND] 문항 응답 저장 중 오류 발생 - memberId: {}, error: {}", memberId, e.getMessage());
throw new CustomException(ErrorCode.SERVER_UNTRACKED_ERROR);
}
}

// TODO - memberId를 userKey로 변경
public void updateResponseAfterQuestionAnswers(
Long surveyId,
AnswerInsertDto.AnswerInfo answerInfo
Long surveyId, Long memberId
) {
Response response = responseRepository
.findBySurveyIdAndMemberId(surveyId, answerInfo.getMemberId())
.orElseGet(() -> Response.of(surveyId, answerInfo.getMemberId()));
.findBySurveyIdAndMemberId(surveyId, memberId)
.orElseGet(() -> Response.of(surveyId, memberId));

responseRepository.save(response);
// 완료된 응답이 잘못 업데이트 되는 것을 방지하기 위해, 응답이 완료되지 않은 경우에만 저장
if (Boolean.FALSE.equals(response.getIsResponded())) {
responseRepository.save(response);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,87 +10,95 @@
import OneQ.OnSurvey.domain.survey.repository.surveyInfo.SurveyInfoRepository;
import OneQ.OnSurvey.domain.survey.service.SurveyGlobalStatsService;
import OneQ.OnSurvey.global.common.exception.CustomException;
import OneQ.OnSurvey.global.common.exception.ErrorCode;
import OneQ.OnSurvey.global.infra.redis.RedisAgent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.client.RedisException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class ResponseCommandService implements ResponseCommand {

private final StringRedisTemplate redisTemplate;

private final ResponseRepository responseRepository;
private final SurveyRepository surveyRepository;
private final SurveyInfoRepository surveyInfoRepository;
private final SurveyGlobalStatsService surveyGlobalStatsService;
private final RedisAgent redisAgent;

@Value("${redis.survey-key-prefix.lock}")
private String surveyLockKeyPrefix;
@Value("${redis.survey-key-prefix.potential-count}")
private String potentialKey;

@Value("${redis.survey-key-prefix.completed-count}")
private String completedKey;

@Value("${redis.survey-key-prefix.due-count}")
private String dueCountKey;

@Value("${redis.survey-key-prefix.creator-userkey}")
private String creatorKey;

@Override
public Boolean createResponse(Long surveyId, Long memberId, Long userKey) {
Response response = responseRepository
.findBySurveyIdAndMemberId(surveyId, memberId)
.orElseGet(() -> Response.of(surveyId, memberId));

if (Boolean.TRUE.equals(response.getIsResponded())) {
throw new CustomException(SurveyErrorCode.SURVEY_ALREADY_PARTICIPATED);
try {
return redisAgent.executeNewTransactionAfterLock(surveyLockKeyPrefix + surveyId + ":" + userKey, 3, 5, () -> {
Response response = responseRepository
.findBySurveyIdAndMemberId(surveyId, memberId)
.orElseGet(() -> Response.of(surveyId, memberId));

if (Boolean.TRUE.equals(response.getIsResponded())) {
throw new CustomException(SurveyErrorCode.SURVEY_ALREADY_PARTICIPATED);
}

response.markResponded();
responseRepository.save(response);

surveyGlobalStatsService.addCompletedCount(1);

SurveyInfo surveyInfo = surveyInfoRepository.findBySurveyId(surveyId)
.orElseThrow(() -> new CustomException(SurveyErrorCode.SURVEY_INFO_NOT_FOUND));
int currCompleted = updateCounter(surveyId, userKey);
surveyInfo.updateCompletedCount(currCompleted);

if (currCompleted >= surveyInfo.getDueCount()) {
Survey survey = surveyRepository.getSurveyById(surveyId)
.orElseThrow(() -> new CustomException(SurveyErrorCode.SURVEY_NOT_FOUND));

survey.updateSurveyStatus(SurveyStatus.CLOSED);
redisAgent.deleteKeys(List.of(
this.dueCountKey + surveyId,
this.completedKey + surveyId,
this.potentialKey + surveyId,
this.creatorKey + surveyId
));
}

return true;
});
} catch (RedisException e) {
log.warn("[RESPONSE:COMMAND] 응답완료 처리 중 레디스 락 획득 실패 - surveyId: {}, userKey: {}. 잠시 후 다시 시도해주세요.", surveyId, userKey, e);
throw new CustomException(SurveyErrorCode.SURVEY_PARTICIPATION_IN_PROCESS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("[RESPONSE:COMMAND] 응답완료 처리 중 인터럽트 발생 - surveyId: {}, userKey: {}", surveyId, userKey, e);
throw new CustomException(ErrorCode.SERVER_UNTRACKED_ERROR);
}

response.markResponded();
responseRepository.save(response);

surveyGlobalStatsService.addCompletedCount(1);

SurveyInfo surveyInfo = surveyInfoRepository.findBySurveyId(surveyId)
.orElseThrow(() -> new CustomException(SurveyErrorCode.SURVEY_INFO_NOT_FOUND));

surveyInfo.increaseCompletedCount();
Integer currCompleted = updateCounter(surveyId, userKey);
if (currCompleted != null && currCompleted.equals(surveyInfo.getDueCount())) {
Survey survey = surveyRepository.getSurveyById(surveyId)
.orElseThrow(() -> new CustomException(SurveyErrorCode.SURVEY_NOT_FOUND));

survey.updateSurveyStatus(SurveyStatus.CLOSED);
deleteAllRedisKeys(surveyId);
}

return true;
}

private void deleteAllRedisKeys(Long surveyId) {
redisTemplate.delete(List.of(
this.dueCountKey + surveyId,
this.completedKey + surveyId,
this.potentialKey + surveyId,
this.creatorKey + surveyId
));
}

private Integer updateCounter(Long surveyId, Long userKey) {
String potentialKey = this.potentialKey + surveyId;
String completedKey = this.completedKey + surveyId;
String memberValue = String.valueOf(userKey);

private int updateCounter(Long surveyId, Long userKey) {
// 완료 인원 추가
Long currCompleted = redisTemplate.opsForValue().increment(completedKey);
Long currCompleted = redisAgent.incrementValue(this.completedKey + surveyId);
// 잠재 응답자 Sorted Set에서 제거
redisTemplate.opsForZSet().remove(potentialKey, memberValue);
return currCompleted != null ? currCompleted.intValue() : null;
redisAgent.removeFromZSet(this.potentialKey + surveyId, String.valueOf(userKey));

if (currCompleted == null) {
log.error("[RESPONSE:COMMAND] 레디스 완료 값 갱신 실패 - surveyId: {}, userKey: {}", surveyId, userKey);
throw new CustomException(ErrorCode.SERVER_UNTRACKED_ERROR);
}
return currCompleted.intValue();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public enum SurveyErrorCode implements ApiErrorCode {

SURVEY_PARTICIPATION_TEMP_EXCEEDED("SURVEY_PARTICIPATION_TEMP_EXCEEDED_409", "설문 참여 가능 인원이 일시적으로 초과되었습니다.", HttpStatus.CONFLICT),
SURVEY_PARTICIPATION_OWN_SURVEY("SURVEY_PARTICIPATION_OWN_403", "본인이 생성한 설문에는 참여할 수 없습니다.", HttpStatus.FORBIDDEN),
SURVEY_PARTICIPATION_IN_PROCESS("SURVEY_PARTICIPATION_IN_409", "제출한 설문 응답이 처리 중입니다.", HttpStatus.CONFLICT),

SURVEY_INCORRECT_STATUS("SURVEY_STATUS_400", "요청과 설문 상태가 올바르지 않습니다.", HttpStatus.BAD_REQUEST),
SURVEY_FORM_INVALID_QUESTION_TYPE("SURVEY_FORM_QUESTION_TYPE_400", "문항 타입이 올바르지 않습니다.", HttpStatus.BAD_REQUEST),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ public SuccessResponse<Boolean> createQuestionAnswer(

AnswerInsertDto answerInsertDto = request.toDto(principal.getMemberId());

return SuccessResponse.ok(questionAnswerCommand.upsertAnswers(answerInsertDto));
return SuccessResponse.ok(questionAnswerCommand.upsertAnswers(answerInsertDto, surveyId, principal.getUserKey(), principal.getMemberId()));
}

@PostMapping("surveys/{surveyId}/complete")
Expand Down
Loading
Loading