diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/advice/ControllerExceptionAdvice.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/advice/ControllerExceptionAdvice.java index 8f2dfe29..c4752718 100644 --- a/umbba-api/src/main/java/sopt/org/umbba/api/controller/advice/ControllerExceptionAdvice.java +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/advice/ControllerExceptionAdvice.java @@ -192,11 +192,17 @@ public ApiResponse handlerNullPointerException(final NullPointerExcep * CUSTOM_ERROR */ @ExceptionHandler(CustomException.class) - protected ResponseEntity handleCustomException(CustomException e) { + protected ResponseEntity handleCustomException(CustomException e, final HttpServletRequest request) { log.error("CustomException occured: {}", e.getMessage(), e); - return ResponseEntity.status(e.getHttpStatus()) - .body(ApiResponse.error(e.getErrorType(), e.getMessage())); + if (e.getHttpStatus() == 501) { + notificationService.sendExceptionToSlack(e, request); + return ResponseEntity.status(e.getHttpStatus()) + .body(ApiResponse.error(ErrorType.NEED_MORE_QUESTION)); + } else { + return ResponseEntity.status(e.getHttpStatus()) + .body(ApiResponse.error(e.getErrorType(), e.getMessage())); + } } } diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/QnAController.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/QnAController.java index 85c0b3b6..ee967e36 100644 --- a/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/QnAController.java +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/qna/QnAController.java @@ -70,6 +70,14 @@ public ApiResponse getSingleQna( qnAService.getSingleQna(JwtProvider.getUserFromPrincial(principal), qnaId)); } + @PatchMapping("/qna/restart") + @ResponseStatus(HttpStatus.OK) + public ApiResponse restartQna(Principal principal) { + + qnAService.restartQna(JwtProvider.getUserFromPrincial(principal)); + return ApiResponse.success(SuccessType.RESTART_QNA_SUCCESS); + } + @GetMapping("/home") @ResponseStatus(HttpStatus.OK) public ApiResponse home(Principal principal) { diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/service/qna/QnAService.java b/umbba-api/src/main/java/sopt/org/umbba/api/service/qna/QnAService.java index db4092b0..f0336af6 100644 --- a/umbba-api/src/main/java/sopt/org/umbba/api/service/qna/QnAService.java +++ b/umbba-api/src/main/java/sopt/org/umbba/api/service/qna/QnAService.java @@ -4,7 +4,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import sopt.org.umbba.api.config.sqs.producer.SqsProducer; import sopt.org.umbba.api.controller.qna.dto.request.TodayAnswerRequestDto; import sopt.org.umbba.api.controller.qna.dto.response.*; import sopt.org.umbba.api.service.notification.NotificationService; @@ -12,9 +11,7 @@ import sopt.org.umbba.common.exception.model.CustomException; import sopt.org.umbba.domain.domain.parentchild.Parentchild; import sopt.org.umbba.domain.domain.parentchild.dao.ParentchildDao; -import sopt.org.umbba.domain.domain.qna.OnboardingAnswer; -import sopt.org.umbba.domain.domain.qna.QnA; -import sopt.org.umbba.domain.domain.qna.Question; +import sopt.org.umbba.domain.domain.qna.*; import sopt.org.umbba.domain.domain.qna.repository.QnARepository; import sopt.org.umbba.domain.domain.qna.repository.QuestionRepository; import sopt.org.umbba.domain.domain.user.SocialPlatform; @@ -22,11 +19,10 @@ import sopt.org.umbba.domain.domain.user.repository.UserRepository; import javax.validation.constraints.NotNull; -import java.util.List; -import java.util.Objects; -import java.util.Optional; +import java.util.*; import java.util.stream.Collectors; +import static sopt.org.umbba.common.exception.ErrorType.NEED_MORE_QUESTION; import static sopt.org.umbba.domain.domain.qna.OnboardingAnswer.NO; import static sopt.org.umbba.domain.domain.qna.OnboardingAnswer.YES; import static sopt.org.umbba.domain.domain.qna.QuestionSection.*; @@ -163,8 +159,8 @@ public void filterFirstQuestion(Long userId) { .build(); qnARepository.save(newQnA); - parentchild.initQnA(); - parentchild.addQnA(newQnA); + parentchild.initQna(); + parentchild.setQna(newQnA); } @Transactional @@ -188,7 +184,7 @@ public void filterAllQuestion(Long userId) { .isChildAnswer(false) .build(); qnARepository.save(newQnA); - parentchild.addQnA(newQnA); + parentchild.setQna(newQnA); } } @@ -324,13 +320,66 @@ public GetMainViewResponseDto getMainInfo(Long userId) { log.info("getCount(): {}", parentchild.getCount()); if (parentchild.getCount() == 7 && (currentQnA.isParentAnswer() && currentQnA.isChildAnswer())) { - return GetMainViewResponseDto.of(currentQnA, parentchild.getCount()+1); // 유효하지 않은 8로 반환 시 엔딩이벤트 + return GetMainViewResponseDto.of(currentQnA, -1); // 유효하지 않은 -1로 반환 시 엔딩이벤트 } - return GetMainViewResponseDto.of(currentQnA, parentchild.getCount()); } + @Transactional + public void restartQna(Long userId) { + Parentchild parentchild = getParentchild(userId); + + if (parentchild.getCount() == 8) { + // 상대측이 이미 답변 이어가기를 호출했다면 실행할 필요 X + return; + } + + List qnaList = getQnAListByParentchild(parentchild); + + // 1. 메인 타입과 미사용 타입에 대해서 불러오기 + List types = Arrays.asList(MAIN, YET); + + // 2. 내가 이미 주고받은 질문 제외하기 + List doneQuestionIds = qnaList.stream() + .map(qna -> qna.getQuestion().getId()) + .collect(Collectors.toList()); + + // 5. 이 경우 아예 추가될 질문이 없으므로 예외 발생시킴 + List targetQuestions = questionRepository.findByTypeInAndIdNotIn(types, doneQuestionIds); + if (targetQuestions.isEmpty()) { + throw new CustomException(NEED_MORE_QUESTION); + } + + QuestionSection section = qnaList.get(parentchild.getCount() - 1).getQuestion().getSection(); + List differentSectionQuestions = targetQuestions.stream() + .filter(question -> !question.getSection().equals(section)) + .collect(Collectors.toList()); + + Random random = new Random(); + Question randomQuestion; + if (!differentSectionQuestions.isEmpty()) { + // 3. 최근에 주고받은 질문의 section과 다른 질문들 중에서 랜덤하게 추출 + randomQuestion = differentSectionQuestions.get(random.nextInt(differentSectionQuestions.size())); + } else { + // 4. 없다면 동일한 section의 질문 중에서라도 랜덤하게 추출 + List equalSectionQuestions = targetQuestions.stream() + .filter(question -> !question.getSection().equals(section)) + .collect(Collectors.toList()); + randomQuestion = equalSectionQuestions.get(random.nextInt(equalSectionQuestions.size())); + } + + // 새로운 질문 추가! + QnA newQnA = QnA.builder() + .question(randomQuestion) + .isParentAnswer(false) + .isChildAnswer(false) + .build(); + qnARepository.save(newQnA); + parentchild.addQna(newQnA); + parentchild.addCount(); + } + @NotNull private Parentchild getParentchild(Long userId) { Parentchild parentchild = getUserById(userId).getParentChild(); diff --git a/umbba-common/src/main/java/sopt/org/umbba/common/exception/ErrorType.java b/umbba-common/src/main/java/sopt/org/umbba/common/exception/ErrorType.java index adc0ad08..37bb4846 100644 --- a/umbba-common/src/main/java/sopt/org/umbba/common/exception/ErrorType.java +++ b/umbba-common/src/main/java/sopt/org/umbba/common/exception/ErrorType.java @@ -62,7 +62,6 @@ public enum ErrorType { PARENTCHILD_HAVE_NO_OPPONENT(HttpStatus.NOT_FOUND, "부모자식 관계에 1명만 참여하고 있습니다."), NOT_FOUND_SECTION(HttpStatus.NOT_FOUND, "해당 아이디와 일치하는 섹션이 없습니다."), - /** * About Apple (HttpStatus 고민) */ @@ -82,7 +81,6 @@ public enum ErrorType { FIREBASE_CONNECTION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "파이어베이스 서버와의 연결에 실패했습니다."), FAIL_TO_SEND_PUSH_ALARM(HttpStatus.INTERNAL_SERVER_ERROR, "푸시 알림 메세지 전송에 실패했습니다."), - // ETC INDEX_OUT_OF_BOUNDS(HttpStatus.INTERNAL_SERVER_ERROR, "인덱스 범위를 초과했습니다."), JWT_SERIALIZE(HttpStatus.INTERNAL_SERVER_ERROR, "JWT 라이브러리 직렬화에 실패했습니다."), @@ -93,7 +91,10 @@ public enum ErrorType { DATA_INTEGRITY_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "데이터 무결성 제약조건을 위반했습니다."), NULL_POINTER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "NULL 포인터를 참조했습니다."), - + /** + * 501 NOT_IMPLEMENTED + */ + NEED_MORE_QUESTION(HttpStatus.NOT_IMPLEMENTED, "남은 질문이 없습니다. 질문을 추가해주세요."), ; diff --git a/umbba-common/src/main/java/sopt/org/umbba/common/exception/SuccessType.java b/umbba-common/src/main/java/sopt/org/umbba/common/exception/SuccessType.java index 9c0b18d2..df69b310 100644 --- a/umbba-common/src/main/java/sopt/org/umbba/common/exception/SuccessType.java +++ b/umbba-common/src/main/java/sopt/org/umbba/common/exception/SuccessType.java @@ -26,6 +26,7 @@ public enum SuccessType { PUSH_ALARM_PERIODIC_SUCCESS(HttpStatus.OK, "오늘의 질문 푸시알림 활성에 성공했습니다."), REMIND_QUESTION_SUCCESS(HttpStatus.OK, "상대방에게 질문을 리마인드 하는 데 성공했습니다."), TEST_SUCCESS(HttpStatus.OK, "데모데이 테스트용 API 호출에 성공했습니다."), + RESTART_QNA_SUCCESS(HttpStatus.OK, "7일 이후 문답이 정상적으로 시작되었습니다."), /** diff --git a/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/parentchild/Parentchild.java b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/parentchild/Parentchild.java index 96743556..31a19aa2 100644 --- a/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/parentchild/Parentchild.java +++ b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/parentchild/Parentchild.java @@ -38,10 +38,8 @@ public class Parentchild extends AuditingTimeEntity { private int count; public void addCount() { - if (this.count < 7) { - this.count += 1; - log.info("Parentchild - addCount() 호출: {}", this.count); - } + this.count += 1; + log.info("Parentchild - addCount() 호출: {}", this.count); // 미답변 일수 필드 0으로 초기화 this.remindCnt = 0; } @@ -87,15 +85,19 @@ public void changeParentOnboardingAnswerList(List onboardingAn private boolean deleted = Boolean.FALSE; - public void initQnA() { + public void initQna() { qnaList = new ArrayList<>(); } - public void addQnA(QnA qnA) { + public void setQna(QnA qnA) { if (qnaList.size() >= 7) { throw new CustomException(ErrorType.ALREADY_QNA_LIST_FULL); } qnaList.add(qnA); } + public void addQna(QnA qnA) { + qnaList.add(qnA); + } + } diff --git a/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/qna/repository/QuestionRepository.java b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/qna/repository/QuestionRepository.java index 1a9d7586..07afe0b8 100644 --- a/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/qna/repository/QuestionRepository.java +++ b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/qna/repository/QuestionRepository.java @@ -17,6 +17,8 @@ public interface QuestionRepository extends Repository { List findByType(QuestionType type); + List findByTypeInAndIdNotIn(List types, List doneQuestionIds); + default List findBySectionAndTypeRandom(QuestionSection section, QuestionType type, int size) { List matchingQuestions = findBySectionAndType(section, type); List selectedQuestions = new ArrayList<>(); diff --git a/umbba-notification/src/main/java/sopt/org/umbba/notification/service/fcm/FCMController.java b/umbba-notification/src/main/java/sopt/org/umbba/notification/service/fcm/FCMController.java index 5d65b1a9..1fb4345a 100644 --- a/umbba-notification/src/main/java/sopt/org/umbba/notification/service/fcm/FCMController.java +++ b/umbba-notification/src/main/java/sopt/org/umbba/notification/service/fcm/FCMController.java @@ -6,7 +6,7 @@ import sopt.org.umbba.common.exception.SuccessType; import sopt.org.umbba.common.exception.dto.ApiResponse; import sopt.org.umbba.common.sqs.dto.FCMPushRequestDto; -import sopt.org.umbba.notification.service.fcm.FCMService; +import sopt.org.umbba.notification.config.ScheduleConfig; import sopt.org.umbba.notification.service.scheduler.FCMScheduler; import java.io.IOException; @@ -28,6 +28,7 @@ public class FCMController { @PostMapping("/qna") @ResponseStatus(HttpStatus.OK) public ApiResponse sendTopicScheduledTest() { + ScheduleConfig.resetScheduler(); return ApiResponse.success(SuccessType.PUSH_ALARM_PERIODIC_SUCCESS, fcmScheduler.pushTodayQna()); } diff --git a/umbba-notification/src/main/java/sopt/org/umbba/notification/service/fcm/FCMService.java b/umbba-notification/src/main/java/sopt/org/umbba/notification/service/fcm/FCMService.java index 52586d4b..f1a0966b 100644 --- a/umbba-notification/src/main/java/sopt/org/umbba/notification/service/fcm/FCMService.java +++ b/umbba-notification/src/main/java/sopt/org/umbba/notification/service/fcm/FCMService.java @@ -21,11 +21,15 @@ import org.springframework.transaction.support.DefaultTransactionDefinition; import sopt.org.umbba.common.exception.ErrorType; import sopt.org.umbba.common.exception.model.CustomException; -import sopt.org.umbba.common.sqs.dto.PushMessage; import sopt.org.umbba.domain.domain.parentchild.Parentchild; import sopt.org.umbba.domain.domain.parentchild.dao.ParentchildDao; import sopt.org.umbba.domain.domain.parentchild.repository.ParentchildRepository; import sopt.org.umbba.domain.domain.qna.QnA; +import sopt.org.umbba.domain.domain.qna.Question; +import sopt.org.umbba.domain.domain.qna.QuestionSection; +import sopt.org.umbba.domain.domain.qna.QuestionType; +import sopt.org.umbba.domain.domain.qna.repository.QnARepository; +import sopt.org.umbba.domain.domain.qna.repository.QuestionRepository; import sopt.org.umbba.domain.domain.user.SocialPlatform; import sopt.org.umbba.domain.domain.user.User; import sopt.org.umbba.domain.domain.user.repository.UserRepository; @@ -38,7 +42,13 @@ import java.io.IOException; import java.util.Arrays; import java.util.List; +import java.util.Random; import java.util.concurrent.ScheduledFuture; +import java.util.stream.Collectors; + +import static sopt.org.umbba.common.exception.ErrorType.NEED_MORE_QUESTION; +import static sopt.org.umbba.domain.domain.qna.QuestionType.MAIN; +import static sopt.org.umbba.domain.domain.qna.QuestionType.YET; /** * 서버에서 파이어베이스로 전송이 잘 이루어지는지 테스트하기 위한 컨트롤러 @@ -59,6 +69,8 @@ public class FCMService { private final UserRepository userRepository; private final ParentchildRepository parentchildRepository; + private final QnARepository qnARepository; + private final QuestionRepository questionRepository; private final ParentchildDao parentchildDao; private final ObjectMapper objectMapper; private final TaskScheduler taskScheduler; @@ -184,7 +196,6 @@ public void schedulePushAlarm(String cronExpression, Long parentchildId) { scheduledFuture = taskScheduler.schedule(() -> { - try { Thread.sleep(1000); } catch (InterruptedException e) { @@ -247,7 +258,12 @@ public void schedulePushAlarm(String cronExpression, Long parentchildId) { } // 부모와 자식 모두 답변한 경우 - else if (currentQnA.isParentAnswer() && currentQnA.isChildAnswer() && parentchild.getCount() < 7) { + else if (currentQnA.isParentAnswer() && currentQnA.isChildAnswer() && parentchild.getCount() != 7) { + + // 8일 이후 (7일 + 엔딩페이지 API 통신으로 추가된 1일) 에는 스케줄링을 돌며 QnA 직접 추가 + if (parentchild.getCount() >= 8) { + appendQna(parentchild); + } log.info("둘 다 답변함 다음 질문으로 ㄱ {}", parentchild.getCount()); parentchild.addCount(); // 오늘의 질문 UP & 리마인드 카운트 초기화 @@ -269,8 +285,6 @@ else if (currentQnA.isParentAnswer() && currentQnA.isChildAnswer() && parentchil todayQnA.getQuestion().getTopic()), parentchild.getId()); } } - - } transactionManager.commit(transactionStatus); } catch (PessimisticLockingFailureException | PessimisticLockException e) { @@ -294,7 +308,60 @@ public static void clearScheduledTasks() { log.info("ScheduledFuture: {}", scheduledFuture); } + private void appendQna(Parentchild parentchild) { + List qnaList = getQnAListByParentchild(parentchild); + + // 1. 메인 타입과 미사용 타입에 대해서 불러오기 + List types = Arrays.asList(MAIN, YET); + + // 2. 내가 이미 주고받은 질문 제외하기 + List doneQuestionIds = qnaList.stream() + .map(qna -> qna.getQuestion().getId()) + .collect(Collectors.toList()); + + // 5. 이 경우 아예 추가될 질문이 없으므로 예외 발생시킴 + List targetQuestions = questionRepository.findByTypeInAndIdNotIn(types, doneQuestionIds); + if (targetQuestions.isEmpty()) { + // 충실한 유저가 추가될 수 있는 질문을 모두 수행했을 경우, 기획 측에서 알 수 있도록 500 에러로 처리 + throw new CustomException(NEED_MORE_QUESTION); + } + QuestionSection section = qnaList.get(parentchild.getCount() - 1).getQuestion().getSection(); + List differentSectionQuestions = targetQuestions.stream() + .filter(question -> !question.getSection().equals(section)) + .collect(Collectors.toList()); + + Random random = new Random(); + Question randomQuestion; + if (!differentSectionQuestions.isEmpty()) { + // 3. 최근에 주고받은 질문의 section과 다른 질문들 중에서 랜덤하게 추출 + randomQuestion = differentSectionQuestions.get(random.nextInt(differentSectionQuestions.size())); + } else { + // 4. 없다면 동일한 section의 질문 중에서라도 랜덤하게 추출 + List equalSectionQuestions = targetQuestions.stream() + .filter(question -> !question.getSection().equals(section)) + .collect(Collectors.toList()); + randomQuestion = equalSectionQuestions.get(random.nextInt(equalSectionQuestions.size())); + } + + QnA newQnA = QnA.builder() + .question(randomQuestion) + .isParentAnswer(false) + .isChildAnswer(false) + .build(); + + qnARepository.save(newQnA); + parentchild.addQna(newQnA); + } + + private List getQnAListByParentchild(Parentchild parentchild) { + List qnaList = parentchild.getQnaList(); + if (qnaList == null || qnaList.isEmpty()) { + throw new CustomException(ErrorType.PARENTCHILD_HAVE_NO_QNALIST); + } + + return qnaList; + } /** * 사용 안하는 함수들