diff --git a/src/main/java/com/ripple/BE/user/controller/AttendanceController.java b/src/main/java/com/ripple/BE/user/controller/AttendanceController.java index 24e4f59..718edf3 100644 --- a/src/main/java/com/ripple/BE/user/controller/AttendanceController.java +++ b/src/main/java/com/ripple/BE/user/controller/AttendanceController.java @@ -35,7 +35,7 @@ public ResponseEntity> currentStreak( @Operation( summary = "오늘의 퀘스트 완료 여부 조회", - description = "오늘의 퀘스트 완료 여부를 조회합니다. 퍼센트로 반환됩니다. (0~100)") + description = "오늘의 퀘스트 완료 여부를 조회합니다. 퍼센트로 반환됩니다 (0~100). 매일 0시 1분 0초에 자동으로 초기화됩니다.") @GetMapping("/today-quest") public ResponseEntity> todayQuest( @AuthenticationPrincipal CustomUserDetails customUserDetails) { @@ -45,7 +45,9 @@ public ResponseEntity> todayQuest( .body(ApiResponse.from(QuestResponse.toQuestResponse(todayQuest))); } - @Operation(summary = "요일별 출석 현황 조회", description = "요일별 출석 현황을 조회합니다. 매주 자동으로 초기화됩니다.") + @Operation( + summary = "요일별 출석 현황 조회", + description = "요일별 출석 현황을 조회합니다. 매주 월요일 0시 0분 0초에 자동으로 초기화됩니다.") @GetMapping("/weekly-attendance") public ResponseEntity> weeklyAttendance( @AuthenticationPrincipal CustomUserDetails customUserDetails) { diff --git a/src/main/java/com/ripple/BE/user/controller/UserController.java b/src/main/java/com/ripple/BE/user/controller/UserController.java index 371c66b..0f9fc51 100644 --- a/src/main/java/com/ripple/BE/user/controller/UserController.java +++ b/src/main/java/com/ripple/BE/user/controller/UserController.java @@ -21,9 +21,12 @@ import com.ripple.BE.user.domain.CustomUserDetails; import com.ripple.BE.user.domain.type.Level; import com.ripple.BE.user.dto.ProgressDTO; +import com.ripple.BE.user.dto.UserGoalDTO; import com.ripple.BE.user.dto.UserInfoDTO; import com.ripple.BE.user.dto.request.UpdateUserProfileRequest; +import com.ripple.BE.user.dto.request.UserGoalRequest; import com.ripple.BE.user.dto.response.ProgressResponse; +import com.ripple.BE.user.dto.response.UserGoalResponse; import com.ripple.BE.user.dto.response.UserInfoResponse; import com.ripple.BE.user.service.MyPageService; import com.ripple.BE.user.service.UserProgressService; @@ -241,4 +244,25 @@ public ResponseEntity> getUserInfo( return ResponseEntity.status(HttpStatus.OK) .body(ApiResponse.from(UserInfoResponse.toUserInfoResponse(userInfo))); } + + @Operation(summary = "사용자 퀘스트 목표 조회", description = "로그인한 유저의 퀘스트 목표를 조회합니다.") + @GetMapping("/goal") + public ResponseEntity> getUserGoal( + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + UserGoalDTO userGoal = userService.getUserGoal(customUserDetails.getId()); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.from(UserGoalResponse.toUserGoalResponse(userGoal))); + } + + @Operation(summary = "사용자 퀘스트 목표 수정", description = "로그인한 유저의 퀘스트 목표를 수정합니다. 목표는 1 이상이어야 합니다.") + @PostMapping("/goal") + public ResponseEntity> updateUserGoal( + @Valid @RequestBody UserGoalRequest userGoalRequest, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + + userService.updateUserGoal(userGoalRequest, customUserDetails.getId()); + + return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.EMPTY_RESPONSE); + } } diff --git a/src/main/java/com/ripple/BE/user/domain/Quest.java b/src/main/java/com/ripple/BE/user/domain/Quest.java index f2eb1d9..e176f5e 100644 --- a/src/main/java/com/ripple/BE/user/domain/Quest.java +++ b/src/main/java/com/ripple/BE/user/domain/Quest.java @@ -33,27 +33,27 @@ public class Quest extends BaseEntity { @JoinColumn(name = "user_id", nullable = false) private User user; - private boolean quizCompleted; + private int quizCompletedCount; - private boolean conceptCompleted; + private int conceptCompletedCount; - private long articleCompletedCount; + private int articleCompletedCount; private LocalDate lastUpdatedDate; // 퀘스트 완료 날짜 public void resetQuests() { - this.quizCompleted = false; - this.conceptCompleted = false; + this.quizCompletedCount = 0; + this.conceptCompletedCount = 0; this.articleCompletedCount = 0; this.lastUpdatedDate = LocalDate.now(); } - public void updateQuizCompleted() { - this.quizCompleted = true; + public void updateQuizCompletedCount() { + this.quizCompletedCount++; } - public void updateConceptCompleted() { - this.conceptCompleted = true; + public void updateConceptCompletedCount() { + this.conceptCompletedCount++; } public void updateArticleCompletedCount() { diff --git a/src/main/java/com/ripple/BE/user/domain/UserGoal.java b/src/main/java/com/ripple/BE/user/domain/UserGoal.java new file mode 100644 index 0000000..ccc964d --- /dev/null +++ b/src/main/java/com/ripple/BE/user/domain/UserGoal.java @@ -0,0 +1,47 @@ +package com.ripple.BE.user.domain; + +import com.ripple.BE.global.entity.BaseEntity; +import com.ripple.BE.user.dto.UserGoalDTO; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Table(name = "user_goals") +@Getter +@Builder +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class UserGoal extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_goal_id") + private Long id; + + @OneToOne + @JoinColumn(name = "user_id", nullable = false) + private User user; + + private int quizGoal; + + private int conceptGoal; + + private int articleGoal; + + public void updateQuizGoal(UserGoalDTO userGoalDTO) { + this.quizGoal = userGoalDTO.quizGoal(); + this.conceptGoal = userGoalDTO.conceptGoal(); + this.articleGoal = userGoalDTO.articleGoal(); + } +} diff --git a/src/main/java/com/ripple/BE/user/dto/QuestDTO.java b/src/main/java/com/ripple/BE/user/dto/QuestDTO.java index 7b89037..4da969f 100644 --- a/src/main/java/com/ripple/BE/user/dto/QuestDTO.java +++ b/src/main/java/com/ripple/BE/user/dto/QuestDTO.java @@ -1,9 +1,9 @@ package com.ripple.BE.user.dto; -public record QuestDTO(Long conceptProgress, Long quizProgress, Long articleProgress) { +public record QuestDTO(int conceptProgress, int quizProgress, int articleProgress) { public static QuestDTO toQuestDTO( - final Long conceptProgress, final Long quizProgress, final Long articleProgress) { + final int conceptProgress, final int quizProgress, final int articleProgress) { return new QuestDTO(conceptProgress, quizProgress, articleProgress); } } diff --git a/src/main/java/com/ripple/BE/user/dto/UserGoalDTO.java b/src/main/java/com/ripple/BE/user/dto/UserGoalDTO.java new file mode 100644 index 0000000..94f6436 --- /dev/null +++ b/src/main/java/com/ripple/BE/user/dto/UserGoalDTO.java @@ -0,0 +1,11 @@ +package com.ripple.BE.user.dto; + +import com.ripple.BE.user.dto.request.UserGoalRequest; + +public record UserGoalDTO(int conceptGoal, int quizGoal, int articleGoal) { + + public static UserGoalDTO toUserGoalDTO(UserGoalRequest userGoalRequest) { + return new UserGoalDTO( + userGoalRequest.conceptGoal(), userGoalRequest.quizGoal(), userGoalRequest.articleGoal()); + } +} diff --git a/src/main/java/com/ripple/BE/user/dto/request/UserGoalRequest.java b/src/main/java/com/ripple/BE/user/dto/request/UserGoalRequest.java new file mode 100644 index 0000000..7486cbf --- /dev/null +++ b/src/main/java/com/ripple/BE/user/dto/request/UserGoalRequest.java @@ -0,0 +1,8 @@ +package com.ripple.BE.user.dto.request; + +import jakarta.validation.constraints.Min; + +public record UserGoalRequest( + @Min(value = 1, message = "1 이상의 값을 입력해주세요.") int conceptGoal, + @Min(value = 1, message = "1 이상의 값을 입력해주세요.") int quizGoal, + @Min(value = 1, message = "1 이상의 값을 입력해주세요.") int articleGoal) {} diff --git a/src/main/java/com/ripple/BE/user/dto/response/QuestResponse.java b/src/main/java/com/ripple/BE/user/dto/response/QuestResponse.java index b7fb786..dae0052 100644 --- a/src/main/java/com/ripple/BE/user/dto/response/QuestResponse.java +++ b/src/main/java/com/ripple/BE/user/dto/response/QuestResponse.java @@ -2,7 +2,7 @@ import com.ripple.BE.user.dto.QuestDTO; -public record QuestResponse(Long conceptProgress, Long quizProgress, Long articleProgress) { +public record QuestResponse(int conceptProgress, int quizProgress, int articleProgress) { public static QuestResponse toQuestResponse(QuestDTO questDTO) { return new QuestResponse( diff --git a/src/main/java/com/ripple/BE/user/dto/response/UserGoalResponse.java b/src/main/java/com/ripple/BE/user/dto/response/UserGoalResponse.java new file mode 100644 index 0000000..863aae6 --- /dev/null +++ b/src/main/java/com/ripple/BE/user/dto/response/UserGoalResponse.java @@ -0,0 +1,10 @@ +package com.ripple.BE.user.dto.response; + +import com.ripple.BE.user.dto.UserGoalDTO; + +public record UserGoalResponse(int conceptGoal, int quizGoal, int articleGoal) { + public static UserGoalResponse toUserGoalResponse(UserGoalDTO userGoalDTO) { + return new UserGoalResponse( + userGoalDTO.conceptGoal(), userGoalDTO.quizGoal(), userGoalDTO.articleGoal()); + } +} diff --git a/src/main/java/com/ripple/BE/user/exception/errorcode/UserErrorCode.java b/src/main/java/com/ripple/BE/user/exception/errorcode/UserErrorCode.java index daaefcf..e326d87 100644 --- a/src/main/java/com/ripple/BE/user/exception/errorcode/UserErrorCode.java +++ b/src/main/java/com/ripple/BE/user/exception/errorcode/UserErrorCode.java @@ -14,6 +14,7 @@ public enum UserErrorCode implements ErrorCode { INVALID_QUEST_TYPE(HttpStatus.BAD_REQUEST, "Invalid Quest Type"), INVALID_EMAIL(HttpStatus.BAD_REQUEST, "Already exist Email"), QUEST_NOT_FOUND(HttpStatus.NOT_FOUND, "Quest not found"), + USER_GOAL_NOT_FOUND(HttpStatus.NOT_FOUND, "User Goal not found"), ATTENDANCE_NOT_FOUND(HttpStatus.NOT_FOUND, "Attendance not found"); private final HttpStatus httpStatus; diff --git a/src/main/java/com/ripple/BE/user/repository/UserGoalRepository.java b/src/main/java/com/ripple/BE/user/repository/UserGoalRepository.java new file mode 100644 index 0000000..3a76221 --- /dev/null +++ b/src/main/java/com/ripple/BE/user/repository/UserGoalRepository.java @@ -0,0 +1,11 @@ +package com.ripple.BE.user.repository; + +import com.ripple.BE.user.domain.UserGoal; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserGoalRepository extends JpaRepository { + Optional findByUserId(Long userId); +} diff --git a/src/main/java/com/ripple/BE/user/service/AttendanceScheduler.java b/src/main/java/com/ripple/BE/user/service/AttendanceScheduler.java index 4725f35..09082c6 100644 --- a/src/main/java/com/ripple/BE/user/service/AttendanceScheduler.java +++ b/src/main/java/com/ripple/BE/user/service/AttendanceScheduler.java @@ -1,11 +1,5 @@ package com.ripple.BE.user.service; -import com.ripple.BE.user.domain.AttendanceLog; -import com.ripple.BE.user.domain.Quest; -import com.ripple.BE.user.repository.AttendanceLogRepository; -import com.ripple.BE.user.repository.AttendanceRepository; -import com.ripple.BE.user.repository.QuestRepository; -import java.time.LocalDate; import lombok.RequiredArgsConstructor; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -15,36 +9,23 @@ @RequiredArgsConstructor public class AttendanceScheduler { - private final AttendanceLogRepository attendanceLogRepository; - private final QuestRepository questRepository; - private final AttendanceRepository attendanceRepository; + private final AttendanceService attendanceService; @Scheduled(cron = "0 0 0 * * MON") // 매주 월요일 0시 0분 0초에 실행 - public void resetWeeklyAttendance() { - attendanceLogRepository.deleteAll(); - } - - @Scheduled(cron = "0 0 0 * * ?") // 매일 0시 0분 0초에 실행 @Transactional - public void resetAllQuests() { - questRepository.findAll().forEach(Quest::resetQuests); + public void resetWeeklyAttendanceLog() { + attendanceService.resetWeeklyAttendanceLog(); } - @Scheduled(cron = "0 0 0 * * ?") // 매일 0시 0분 0초에 실행 + @Scheduled(cron = "30 0 0 * * ?") // 매일 0시 0분 30초에 실행 @Transactional public void recordAttendanceLog() { - LocalDate today = LocalDate.now(); + attendanceService.recordAttendanceLog(); + } - attendanceRepository - .findAll() - .forEach( - attendance -> { - attendanceLogRepository.save( - AttendanceLog.builder() - .attendance(attendance) - .date(today) - .isAttended(false) - .build()); - }); + @Scheduled(cron = "0 1 0 * * ?") // 매일 0시 1분 0초에 실행 + @Transactional + public void resetAllQuests() { + attendanceService.resetAllQuests(); } } diff --git a/src/main/java/com/ripple/BE/user/service/AttendanceService.java b/src/main/java/com/ripple/BE/user/service/AttendanceService.java index 7151e8d..ef145b5 100644 --- a/src/main/java/com/ripple/BE/user/service/AttendanceService.java +++ b/src/main/java/com/ripple/BE/user/service/AttendanceService.java @@ -6,12 +6,14 @@ import com.ripple.BE.user.domain.AttendanceLog; import com.ripple.BE.user.domain.Quest; import com.ripple.BE.user.domain.User; +import com.ripple.BE.user.domain.UserGoal; import com.ripple.BE.user.dto.AttendanceDTO; import com.ripple.BE.user.dto.QuestDTO; import com.ripple.BE.user.exception.UserException; import com.ripple.BE.user.repository.AttendanceLogRepository; import com.ripple.BE.user.repository.AttendanceRepository; import com.ripple.BE.user.repository.QuestRepository; +import com.ripple.BE.user.repository.UserGoalRepository; import java.time.LocalDate; import java.util.ArrayList; import java.util.Collections; @@ -30,43 +32,58 @@ public class AttendanceService { private final QuestRepository questRepository; private final AttendanceRepository attendanceRepository; private final AttendanceLogRepository attendanceLogRepository; + private final UserGoalRepository userGoalRepository; + @Transactional public Long getCurrentStreak(Long id) { Attendance attendance = attendanceRepository .findByUserId(id) .orElseThrow(() -> new UserException(ATTENDANCE_NOT_FOUND)); + if (attendance.getLastAttendedDate() != null + && attendance.getLastAttendedDate().isBefore(LocalDate.now().minusDays(1))) { + attendance.updateCurrentStreak(1L); + } + return attendance.getCurrentStreak(); } public QuestDTO getTodayQuest(Long id) { Quest quest = questRepository.findByUserId(id).orElseThrow(() -> new UserException(QUEST_NOT_FOUND)); + UserGoal userGoal = + userGoalRepository + .findByUserId(id) + .orElseThrow(() -> new UserException(USER_GOAL_NOT_FOUND)); return QuestDTO.toQuestDTO( - quest.isConceptCompleted() ? 100L : 0L, - quest.isQuizCompleted() ? 100L : 0L, - quest.getArticleCompletedCount() / 3 * 100); + Math.min(100, quest.getConceptCompletedCount() * 100 / userGoal.getConceptGoal()), + Math.min(100, quest.getQuizCompletedCount() * 100 / userGoal.getQuizGoal()), + Math.min(100, quest.getArticleCompletedCount() * 100 / userGoal.getArticleGoal())); } @Transactional public void completeQuest(Long userId, String questType) { Quest quest = questRepository.findByUserId(userId).orElseThrow(() -> new UserException(QUEST_NOT_FOUND)); + UserGoal userGoal = + userGoalRepository + .findByUserId(userId) + .orElseThrow(() -> new UserException(USER_GOAL_NOT_FOUND)); // 퀘스트 타입에 따라 완료 처리 switch (questType.toUpperCase()) { - case "QUIZ" -> quest.updateQuizCompleted(); - case "CONCEPT" -> quest.updateConceptCompleted(); + case "QUIZ" -> quest.updateQuizCompletedCount(); + case "CONCEPT" -> quest.updateConceptCompletedCount(); case "ARTICLE" -> quest.updateArticleCompletedCount(); default -> throw new UserException(INVALID_QUEST_TYPE); } - // 퀘스트 3개 완료 시 출석 완료 처리 - if (quest.getArticleCompletedCount() >= 3 - && quest.isConceptCompleted() - && quest.isQuizCompleted()) { + // 퀘스트 완료 시 출석 처리 + if (quest.getArticleCompletedCount() >= userGoal.getArticleGoal() + && quest.getConceptCompletedCount() >= userGoal.getConceptGoal() + && quest.getQuizCompletedCount() >= userGoal.getQuizGoal()) { completeAttendance(userId, LocalDate.now()); } } @@ -108,6 +125,8 @@ public void createAttendance(User user) { questRepository.save(Quest.builder().user(user).lastUpdatedDate(today).build()); attendanceLogRepository.save( AttendanceLog.builder().date(today).isAttended(false).attendance(attendance).build()); + userGoalRepository.save( + UserGoal.builder().user(user).quizGoal(1).articleGoal(3).conceptGoal(1).build()); } @Transactional @@ -136,4 +155,29 @@ public AttendanceDTO getWeeklyAttendance(Long userId) { .sunday(weeklyAttendance.get(6)) .build(); } + + @Transactional + public void resetWeeklyAttendanceLog() { + attendanceLogRepository.deleteAll(); + } + + @Transactional + public void resetAllQuests() { + questRepository.findAll().forEach(Quest::resetQuests); + } + + @Transactional + public void recordAttendanceLog() { + attendanceRepository + .findAll() + .forEach( + attendance -> { + attendanceLogRepository.save( + AttendanceLog.builder() + .attendance(attendance) + .date(LocalDate.now()) + .isAttended(false) + .build()); + }); + } } diff --git a/src/main/java/com/ripple/BE/user/service/UserService.java b/src/main/java/com/ripple/BE/user/service/UserService.java index 4c5092a..71db373 100644 --- a/src/main/java/com/ripple/BE/user/service/UserService.java +++ b/src/main/java/com/ripple/BE/user/service/UserService.java @@ -8,11 +8,15 @@ import com.ripple.BE.image.repository.ImageRepository; import com.ripple.BE.learning.domain.quiz.FailQuiz; import com.ripple.BE.user.domain.User; +import com.ripple.BE.user.domain.UserGoal; import com.ripple.BE.user.domain.type.Level; import com.ripple.BE.user.domain.type.LoginType; +import com.ripple.BE.user.dto.UserGoalDTO; import com.ripple.BE.user.dto.UserInfoDTO; import com.ripple.BE.user.dto.request.UpdateUserProfileRequest; +import com.ripple.BE.user.dto.request.UserGoalRequest; import com.ripple.BE.user.exception.UserException; +import com.ripple.BE.user.repository.UserGoalRepository; import com.ripple.BE.user.repository.UserRepository; import java.util.Date; import java.util.List; @@ -33,6 +37,7 @@ public class UserService { private final ImageRepository imageRepository; private final AttendanceService attendanceService; + private final UserGoalRepository userGoalRepository; private final PasswordEncoder passwordEncoder; @Transactional @@ -151,4 +156,24 @@ public UserInfoDTO getUserInfo(final long userId) { .quizCorrectRate(quizCorrectRate) .build(); } + + public UserGoalDTO getUserGoal(final long userId) { + UserGoal userGoal = + userGoalRepository + .findByUserId(userId) + .orElseThrow(() -> new UserException(USER_GOAL_NOT_FOUND)); + + return new UserGoalDTO( + userGoal.getConceptGoal(), userGoal.getQuizGoal(), userGoal.getArticleGoal()); + } + + @Transactional + public void updateUserGoal(final UserGoalRequest userGoalRequest, final long userId) { + UserGoal userGoal = + userGoalRepository + .findByUserId(userId) + .orElseThrow(() -> new UserException(USER_GOAL_NOT_FOUND)); + + userGoal.updateQuizGoal(UserGoalDTO.toUserGoalDTO(userGoalRequest)); + } }