diff --git a/src/main/java/grep/neogulcoder/domain/alram/controller/AlarmController.java b/src/main/java/grep/neogulcoder/domain/alram/controller/AlarmController.java index 5f32b620..21c7b8fd 100644 --- a/src/main/java/grep/neogulcoder/domain/alram/controller/AlarmController.java +++ b/src/main/java/grep/neogulcoder/domain/alram/controller/AlarmController.java @@ -41,7 +41,7 @@ public ApiResponse respondToInvite(@AuthenticationPrincipal Principal prin if (accepted) { alarmService.acceptInvite(principal.getUserId(), alarmId); } else { - alarmService.rejectInvite(principal.getUserId()); + alarmService.rejectInvite(principal.getUserId(), alarmId); } return ApiResponse.success(accepted ? "스터디 초대를 수락했습니다." : "스터디 초대를 거절했습니다."); diff --git a/src/main/java/grep/neogulcoder/domain/alram/exception/code/AlarmErrorCode.java b/src/main/java/grep/neogulcoder/domain/alram/exception/code/AlarmErrorCode.java index df837efc..a1f35319 100644 --- a/src/main/java/grep/neogulcoder/domain/alram/exception/code/AlarmErrorCode.java +++ b/src/main/java/grep/neogulcoder/domain/alram/exception/code/AlarmErrorCode.java @@ -7,7 +7,8 @@ @Getter public enum AlarmErrorCode implements ErrorCode { - ALARM_NOT_FOUND("A001",HttpStatus.NOT_FOUND,"알람을 찾을 수 없습니다."); + ALARM_NOT_FOUND("A001",HttpStatus.NOT_FOUND,"알람을 찾을 수 없습니다."), + ALREADY_CHECKED("A002",HttpStatus.BAD_REQUEST,"이미 읽은 알림입니다."); private final String code; private final HttpStatus status; diff --git a/src/main/java/grep/neogulcoder/domain/alram/repository/AlarmRepository.java b/src/main/java/grep/neogulcoder/domain/alram/repository/AlarmRepository.java index 95e3196c..fc44cc7a 100644 --- a/src/main/java/grep/neogulcoder/domain/alram/repository/AlarmRepository.java +++ b/src/main/java/grep/neogulcoder/domain/alram/repository/AlarmRepository.java @@ -2,10 +2,13 @@ import grep.neogulcoder.domain.alram.entity.Alarm; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +@Repository public interface AlarmRepository extends JpaRepository { List findAllByReceiverUserIdAndCheckedFalse(Long receiverUserId); List findAllByReceiverUserId(Long receiverUserId); - + Optional findByReceiverUserIdAndId(Long targetUserId, Long alarmId); } diff --git a/src/main/java/grep/neogulcoder/domain/alram/service/AlarmService.java b/src/main/java/grep/neogulcoder/domain/alram/service/AlarmService.java index daab579d..87b9b80b 100644 --- a/src/main/java/grep/neogulcoder/domain/alram/service/AlarmService.java +++ b/src/main/java/grep/neogulcoder/domain/alram/service/AlarmService.java @@ -18,6 +18,7 @@ import grep.neogulcoder.domain.study.repository.StudyMemberQueryRepository; import grep.neogulcoder.domain.study.repository.StudyMemberRepository; import grep.neogulcoder.domain.study.repository.StudyRepository; +import grep.neogulcoder.domain.study.service.StudyManagementServiceFacade; import grep.neogulcoder.domain.studyapplication.StudyApplication; import grep.neogulcoder.domain.studyapplication.event.ApplicationEvent; import grep.neogulcoder.domain.studyapplication.event.ApplicationStatusChangedEvent; @@ -37,6 +38,7 @@ import java.util.List; +import static grep.neogulcoder.domain.alram.exception.code.AlarmErrorCode.ALREADY_CHECKED; import static grep.neogulcoder.domain.recruitment.RecruitmentErrorCode.NOT_FOUND; import static grep.neogulcoder.domain.study.exception.code.StudyErrorCode.STUDY_LEADER_NOT_FOUND; import static grep.neogulcoder.domain.study.exception.code.StudyErrorCode.STUDY_NOT_FOUND; @@ -56,6 +58,7 @@ public class AlarmService { private final StudyMemberRepository studyMemberRepository; private final RecruitmentPostRepository recruitmentPostRepository; private final ApplicationRepository applicationRepository; + private final StudyManagementServiceFacade studyManagementServiceFacade; @Transactional public void saveAlarm(Long receiverId, AlarmType alarmType, DomainType domainType, @@ -113,18 +116,28 @@ public void handleStudyInviteEvent(StudyInviteEvent event) { @Transactional public void acceptInvite(Long targetUserId, Long alarmId) { + if(isChecked(targetUserId, alarmId)){ + throw new BusinessException(ALREADY_CHECKED); + } + validateParticipantStudyLimit(targetUserId); - Alarm alarm = findValidAlarm(alarmId); + Alarm alarm = findValidAlarm(targetUserId, alarmId); Long studyId = alarm.getDomainId(); Study study = findValidStudy(studyId); studyMemberRepository.save(StudyMember.createMember(study, targetUserId)); + studyManagementServiceFacade.increaseMemberCount(study, targetUserId); alarm.checkAlarm(); } @Transactional - public void rejectInvite(Long alarmId) { - Alarm alarm = findValidAlarm(alarmId); + public void rejectInvite(Long targetUserId,Long alarmId) { + + if(isChecked(targetUserId, alarmId)){ + throw new BusinessException(ALREADY_CHECKED); + } + + Alarm alarm = findValidAlarm(targetUserId, alarmId); alarm.checkAlarm(); } @@ -248,9 +261,8 @@ public void handleStudyPostCommentEvent(StudyPostCommentEvent event) { ); } - private Alarm findValidAlarm(Long alarmId) { - return alarmRepository.findById(alarmId) - .orElseThrow(() -> new NotFoundException(AlarmErrorCode.ALARM_NOT_FOUND)); + private Alarm findValidAlarm(Long targetUserId, Long alarmId) { + return alarmRepository.findByReceiverUserIdAndId(targetUserId, alarmId).orElseThrow(() -> new NotFoundException(AlarmErrorCode.ALARM_NOT_FOUND)); } private Study findValidStudy(Long studyId) { @@ -264,4 +276,8 @@ private void validateParticipantStudyLimit(Long userId) { throw new BusinessException(APPLICATION_PARTICIPANT_LIMIT_EXCEEDED); } } + + private boolean isChecked(Long targetUserId, Long alarmId) { + return findValidAlarm(targetUserId, alarmId).isChecked(); + } } diff --git a/src/main/java/grep/neogulcoder/domain/recruitment/post/repository/RecruitmentPostRepository.java b/src/main/java/grep/neogulcoder/domain/recruitment/post/repository/RecruitmentPostRepository.java index 6ec0ed4f..46c6b67e 100644 --- a/src/main/java/grep/neogulcoder/domain/recruitment/post/repository/RecruitmentPostRepository.java +++ b/src/main/java/grep/neogulcoder/domain/recruitment/post/repository/RecruitmentPostRepository.java @@ -18,4 +18,6 @@ public interface RecruitmentPostRepository extends JpaRepository findBySubjectContainingIgnoreCase(String subject, Pageable pageable); + + Optional findByStudyIdAndActivatedTrue(Long studyId); } diff --git a/src/main/java/grep/neogulcoder/domain/study/Study.java b/src/main/java/grep/neogulcoder/domain/study/Study.java index 520ff6dc..0036862c 100644 --- a/src/main/java/grep/neogulcoder/domain/study/Study.java +++ b/src/main/java/grep/neogulcoder/domain/study/Study.java @@ -84,8 +84,8 @@ public void update(String name, Category category, int capacity, StudyType study this.imageUrl = imageUrl; } - public boolean isStarted() { - return this.startDate.isBefore(LocalDateTime.now()); + public boolean isStarted(LocalDateTime currentDateTime) { + return this.startDate.isBefore(currentDateTime); } public void delete() { diff --git a/src/main/java/grep/neogulcoder/domain/study/StudyMember.java b/src/main/java/grep/neogulcoder/domain/study/StudyMember.java index 20d5bb37..0cc6dba2 100644 --- a/src/main/java/grep/neogulcoder/domain/study/StudyMember.java +++ b/src/main/java/grep/neogulcoder/domain/study/StudyMember.java @@ -72,10 +72,6 @@ public void changeRoleMember() { this.role = StudyMemberRole.MEMBER; } - public boolean isParticipated() { - return this.participated; - } - public void participate() { this.participated = true; } diff --git a/src/main/java/grep/neogulcoder/domain/study/controller/StudyController.java b/src/main/java/grep/neogulcoder/domain/study/controller/StudyController.java index ae70f087..ad70bed0 100644 --- a/src/main/java/grep/neogulcoder/domain/study/controller/StudyController.java +++ b/src/main/java/grep/neogulcoder/domain/study/controller/StudyController.java @@ -11,6 +11,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -26,70 +27,70 @@ public class StudyController implements StudySpecification { private final StudyService studyService; @GetMapping - public ApiResponse getMyStudies(@PageableDefault(size = 12) Pageable pageable, - @RequestParam(required = false) Boolean finished, - @AuthenticationPrincipal Principal userDetails) { + public ResponseEntity> getMyStudies(@PageableDefault(size = 12) Pageable pageable, + @RequestParam(required = false) Boolean finished, + @AuthenticationPrincipal Principal userDetails) { StudyItemPagingResponse myStudies = studyService.getMyStudiesPaging(pageable, userDetails.getUserId(), finished); - return ApiResponse.success(myStudies); + return ResponseEntity.ok(ApiResponse.success(myStudies)); } @GetMapping("/main") - public ApiResponse> getMyUnfinishedStudies(@AuthenticationPrincipal Principal userDetails) { + public ResponseEntity>> getMyUnfinishedStudies(@AuthenticationPrincipal Principal userDetails) { List unfinishedStudies = studyService.getMyUnfinishedStudies(userDetails.getUserId()); - return ApiResponse.success(unfinishedStudies); + return ResponseEntity.ok(ApiResponse.success(unfinishedStudies)); } @GetMapping("/{studyId}/header") - public ApiResponse getStudyHeader(@PathVariable("studyId") Long studyId) { - return ApiResponse.success(studyService.getStudyHeader(studyId)); + public ResponseEntity> getStudyHeader(@PathVariable("studyId") Long studyId) { + return ResponseEntity.ok(ApiResponse.success(studyService.getStudyHeader(studyId))); } @GetMapping("/{studyId}") - public ApiResponse getStudy(@PathVariable("studyId") Long studyId) { - return ApiResponse.success(studyService.getStudy(studyId)); + public ResponseEntity> getStudy(@PathVariable("studyId") Long studyId) { + return ResponseEntity.ok(ApiResponse.success(studyService.getStudy(studyId))); } @GetMapping("/me/images") - public ApiResponse> getStudyImages(@AuthenticationPrincipal Principal userDetails) { + public ResponseEntity>> getStudyImages(@AuthenticationPrincipal Principal userDetails) { Long userId = userDetails.getUserId(); - return ApiResponse.success(studyService.getStudyImages(userId)); + return ResponseEntity.ok(ApiResponse.success(studyService.getStudyImages(userId))); } @GetMapping("/{studyId}/info") - public ApiResponse getStudyInfo(@PathVariable("studyId") Long studyId, + public ResponseEntity> getStudyInfo(@PathVariable("studyId") Long studyId, @AuthenticationPrincipal Principal userDetails) { Long userId = userDetails.getUserId(); - return ApiResponse.success(studyService.getMyStudyContent(studyId, userId)); + return ResponseEntity.ok(ApiResponse.success(studyService.getMyStudyContent(studyId, userId))); } @GetMapping("/{studyId}/me") - public ApiResponse getMyStudyMemberInfo(@PathVariable("studyId") Long studyId, + public ResponseEntity> getMyStudyMemberInfo(@PathVariable("studyId") Long studyId, @AuthenticationPrincipal Principal userDetails) { Long userId = userDetails.getUserId(); - return ApiResponse.success(studyService.getMyStudyMemberInfo(studyId, userId)); + return ResponseEntity.ok(ApiResponse.success(studyService.getMyStudyMemberInfo(studyId, userId))); } @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ApiResponse createStudy(@RequestPart("request") @Valid StudyCreateRequest request, + public ResponseEntity> createStudy(@RequestPart("request") @Valid StudyCreateRequest request, @RequestPart(value = "image", required = false) MultipartFile image, @AuthenticationPrincipal Principal userDetails) throws IOException { Long id = studyService.createStudy(request, userDetails.getUserId(), image); - return ApiResponse.success(id); + return ResponseEntity.ok(ApiResponse.success(id)); } @PutMapping(value = "/{studyId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ApiResponse updateStudy(@PathVariable("studyId") Long studyId, + public ResponseEntity> updateStudy(@PathVariable("studyId") Long studyId, @RequestPart @Valid StudyUpdateRequest request, @RequestPart(value = "image", required = false) MultipartFile image, @AuthenticationPrincipal Principal userDetails) throws IOException { studyService.updateStudy(studyId, request, userDetails.getUserId(), image); - return ApiResponse.noContent(); + return ResponseEntity.ok(ApiResponse.noContent()); } @DeleteMapping("/{studyId}") - public ApiResponse deleteStudy(@PathVariable("studyId") Long studyId, + public ResponseEntity> deleteStudy(@PathVariable("studyId") Long studyId, @AuthenticationPrincipal Principal userDetails) { studyService.deleteStudy(studyId, userDetails.getUserId()); - return ApiResponse.noContent(); + return ResponseEntity.ok(ApiResponse.noContent()); } } diff --git a/src/main/java/grep/neogulcoder/domain/study/controller/StudyManagementController.java b/src/main/java/grep/neogulcoder/domain/study/controller/StudyManagementController.java index 266b4173..29585fb7 100644 --- a/src/main/java/grep/neogulcoder/domain/study/controller/StudyManagementController.java +++ b/src/main/java/grep/neogulcoder/domain/study/controller/StudyManagementController.java @@ -9,6 +9,7 @@ import grep.neogulcoder.global.response.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -22,51 +23,50 @@ public class StudyManagementController implements StudyManagementSpecification { private final StudyManagementService studyManagementService; @GetMapping("/extension") - public ApiResponse getStudyExtension(@PathVariable("studyId") Long studyId) { + public ResponseEntity> getStudyExtension(@PathVariable("studyId") Long studyId) { StudyExtensionResponse studyExtension = studyManagementService.getStudyExtension(studyId); - return ApiResponse.success(studyExtension); + return ResponseEntity.ok(ApiResponse.success(studyExtension)); } @GetMapping("/extension/participations") - public ApiResponse> getExtendParticipations(@PathVariable("studyId") Long studyId) { + public ResponseEntity>> getExtendParticipations(@PathVariable("studyId") Long studyId) { List extendParticipations = studyManagementService.getExtendParticipations(studyId); - return ApiResponse.success(extendParticipations); + return ResponseEntity.ok(ApiResponse.success(extendParticipations)); } @DeleteMapping("/me") - public ApiResponse leaveStudy(@PathVariable("studyId") Long studyId, + public ResponseEntity> leaveStudy(@PathVariable("studyId") Long studyId, @AuthenticationPrincipal Principal userDetails) { studyManagementService.leaveStudy(studyId, userDetails.getUserId()); - return ApiResponse.noContent(); + return ResponseEntity.ok(ApiResponse.noContent()); } @PostMapping("/delegate") - public ApiResponse delegateLeader(@PathVariable("studyId") Long studyId, + public ResponseEntity> delegateLeader(@PathVariable("studyId") Long studyId, @RequestBody DelegateLeaderRequest request, @AuthenticationPrincipal Principal userDetails) { studyManagementService.delegateLeader(studyId, userDetails.getUserId(), request.getNewLeaderId()); - return ApiResponse.noContent(); + return ResponseEntity.ok(ApiResponse.noContent()); } @PostMapping("/extension") - public ApiResponse extendStudy(@PathVariable("studyId") Long studyId, + public ResponseEntity> extendStudy(@PathVariable("studyId") Long studyId, @RequestBody @Valid ExtendStudyRequest request, @AuthenticationPrincipal Principal userDetails) { Long extendStudyId = studyManagementService.extendStudy(studyId, request, userDetails.getUserId()); - return ApiResponse.success(extendStudyId); + return ResponseEntity.ok(ApiResponse.success(extendStudyId)); } @PostMapping("/extension/participations") - public ApiResponse registerExtensionParticipation(@PathVariable("studyId") Long studyId, + public ResponseEntity> registerExtensionParticipation(@PathVariable("studyId") Long studyId, @AuthenticationPrincipal Principal userDetails) { studyManagementService.registerExtensionParticipation(studyId, userDetails.getUserId()); - return ApiResponse.noContent(); + return ResponseEntity.ok(ApiResponse.noContent()); } @PostMapping("/invite/user") - public ApiResponse inviteUser(@PathVariable("studyId") Long studyId, @AuthenticationPrincipal Principal userDetails, String targetUserNickname) { + public ResponseEntity> inviteUser(@PathVariable("studyId") Long studyId, @AuthenticationPrincipal Principal userDetails, String targetUserNickname) { studyManagementService.inviteTargetUser(studyId, userDetails.getUserId(), targetUserNickname); - return ApiResponse.noContent(); + return ResponseEntity.ok(ApiResponse.noContent()); } - } diff --git a/src/main/java/grep/neogulcoder/domain/study/controller/StudyManagementSpecification.java b/src/main/java/grep/neogulcoder/domain/study/controller/StudyManagementSpecification.java index b808d6d1..81641113 100644 --- a/src/main/java/grep/neogulcoder/domain/study/controller/StudyManagementSpecification.java +++ b/src/main/java/grep/neogulcoder/domain/study/controller/StudyManagementSpecification.java @@ -8,6 +8,7 @@ import grep.neogulcoder.global.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; import java.util.List; @@ -15,20 +16,23 @@ public interface StudyManagementSpecification { @Operation(summary = "스터디 연장 여부 조회", description = "스터디 연장 여부를 조회합니다.") - ApiResponse getStudyExtension(Long studyId); + ResponseEntity> getStudyExtension(Long studyId); @Operation(summary = "연장 스터디 참여 멤버 목록 조회", description = "연장 스터디에 참여하는 멤버 목록을 조회합니다.") - ApiResponse> getExtendParticipations(Long studyId); + ResponseEntity>> getExtendParticipations(Long studyId); @Operation(summary = "스터디 탈퇴", description = "스터디를 탈퇴합니다.") - ApiResponse leaveStudy(Long studyId, Principal userDetails); + ResponseEntity> leaveStudy(Long studyId, Principal userDetails); @Operation(summary = "스터디장 위임", description = "스터디원에게 스터디장을 위임합니다.") - ApiResponse delegateLeader(Long studyId, DelegateLeaderRequest request, Principal userDetails); + ResponseEntity> delegateLeader(Long studyId, DelegateLeaderRequest request, Principal userDetails); @Operation(summary = "스터디 연장", description = "스터디장이 스터디를 연장합니다.") - ApiResponse extendStudy(Long studyId, ExtendStudyRequest request, Principal userDetails); + ResponseEntity> extendStudy(Long studyId, ExtendStudyRequest request, Principal userDetails); @Operation(summary = "연장 스터디 참여", description = "스터디원이 연장된 스터디에 참여합니다.") - ApiResponse registerExtensionParticipation(Long studyId, Principal userDetails); + ResponseEntity> registerExtensionParticipation(Long studyId, Principal userDetails); + + @Operation(summary = "스터디 초대", description = "스터디장이 스터디에 초대합니다.") + ResponseEntity> inviteUser(Long studyId, Principal userDetails, String targetUserNickname); } diff --git a/src/main/java/grep/neogulcoder/domain/study/controller/StudySpecification.java b/src/main/java/grep/neogulcoder/domain/study/controller/StudySpecification.java index d1b29f52..bfc91b90 100644 --- a/src/main/java/grep/neogulcoder/domain/study/controller/StudySpecification.java +++ b/src/main/java/grep/neogulcoder/domain/study/controller/StudySpecification.java @@ -8,6 +8,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; @@ -17,32 +18,32 @@ public interface StudySpecification { @Operation(summary = "스터디 목록 조회", description = "가입한 스터디 목록을 조회합니다.") - ApiResponse getMyStudies(Pageable pageable, Boolean finished, Principal userDetails); + ResponseEntity> getMyStudies(Pageable pageable, Boolean finished, Principal userDetails); @Operation(summary = "종료되지않은 내 스터디 목록 조회", description = "종료되지않은 내 스터디 목록을 조회합니다.") - ApiResponse> getMyUnfinishedStudies(Principal userDetails); + ResponseEntity>> getMyUnfinishedStudies(Principal userDetails); @Operation(summary = "스터디 헤더 조회", description = "스터디 헤더 정보를 조회합니다.") - ApiResponse getStudyHeader(Long studyId); + ResponseEntity> getStudyHeader(Long studyId); @Operation(summary = "스터디 조회", description = "스터디를 조회합니다.") - ApiResponse getStudy(Long studyId); + ResponseEntity> getStudy(Long studyId); @Operation(summary = "스터디 대표 이미지 조회", description = "참여중인 스터디의 대표 이미지 목록을 조회합니다.") - ApiResponse> getStudyImages(Principal userDetails); + ResponseEntity>> getStudyImages(Principal userDetails); @Operation(summary = "스터디 정보 조회", description = "스터디장이 스터디 정보를 조회합니다.") - ApiResponse getStudyInfo(Long studyId, Principal userDetails); + ResponseEntity> getStudyInfo(Long studyId, Principal userDetails); @Operation(summary = "스터디 내 정보 조회", description = "스터디에서 사용자의 정보를 조회합니다.") - ApiResponse getMyStudyMemberInfo(Long studyId, Principal userDetails); + ResponseEntity> getMyStudyMemberInfo(Long studyId, Principal userDetails); @Operation(summary = "스터디 생성", description = "새로운 스터디를 생성합니다.") - ApiResponse createStudy(StudyCreateRequest request, MultipartFile image, Principal userDetails) throws IOException; + ResponseEntity> createStudy(StudyCreateRequest request, MultipartFile image, Principal userDetails) throws IOException; @Operation(summary = "스터디 수정", description = "스터디를 수정합니다.") - ApiResponse updateStudy(Long studyId, StudyUpdateRequest request, MultipartFile image, Principal userDetails) throws IOException; + ResponseEntity> updateStudy(Long studyId, StudyUpdateRequest request, MultipartFile image, Principal userDetails) throws IOException; @Operation(summary = "스터디 삭제", description = "스터디를 삭제합니다.") - ApiResponse deleteStudy(Long studyId, Principal userDetails); + ResponseEntity> deleteStudy(Long studyId, Principal userDetails); } diff --git a/src/main/java/grep/neogulcoder/domain/study/controller/dto/request/ExtendStudyRequest.java b/src/main/java/grep/neogulcoder/domain/study/controller/dto/request/ExtendStudyRequest.java index 4eda9daf..5809e548 100644 --- a/src/main/java/grep/neogulcoder/domain/study/controller/dto/request/ExtendStudyRequest.java +++ b/src/main/java/grep/neogulcoder/domain/study/controller/dto/request/ExtendStudyRequest.java @@ -22,8 +22,9 @@ private ExtendStudyRequest(LocalDateTime newEndDate) { this.newEndDate = newEndDate; } - public Study toEntity(Study study) { + public Study toEntity(Study study, Long userId) { return Study.builder() + .userId(userId) .originStudyId(study.getId()) .name(study.getName()) .category(study.getCategory()) diff --git a/src/main/java/grep/neogulcoder/domain/study/controller/dto/request/StudyCreateRequest.java b/src/main/java/grep/neogulcoder/domain/study/controller/dto/request/StudyCreateRequest.java index efa36cba..1b4485b0 100644 --- a/src/main/java/grep/neogulcoder/domain/study/controller/dto/request/StudyCreateRequest.java +++ b/src/main/java/grep/neogulcoder/domain/study/controller/dto/request/StudyCreateRequest.java @@ -4,6 +4,7 @@ import grep.neogulcoder.domain.study.enums.Category; import grep.neogulcoder.domain.study.enums.StudyType; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -45,6 +46,12 @@ public class StudyCreateRequest { @Schema(description = "스터디 소개", example = "자바 스터디입니다.") private String introduction; + @AssertTrue(message = "스터디 타입이 OFFLINE이나 HYBRID인 스터디는 지역 입력이 필수입니다.") + public boolean isLocationValid() { + return studyType != StudyType.OFFLINE && studyType != StudyType.HYBRID + || (location != null && !location.isBlank()); + } + private StudyCreateRequest() {} @Builder diff --git a/src/main/java/grep/neogulcoder/domain/study/controller/dto/request/StudyUpdateRequest.java b/src/main/java/grep/neogulcoder/domain/study/controller/dto/request/StudyUpdateRequest.java index 0a259f8d..cc244d59 100644 --- a/src/main/java/grep/neogulcoder/domain/study/controller/dto/request/StudyUpdateRequest.java +++ b/src/main/java/grep/neogulcoder/domain/study/controller/dto/request/StudyUpdateRequest.java @@ -4,6 +4,7 @@ import grep.neogulcoder.domain.study.enums.Category; import grep.neogulcoder.domain.study.enums.StudyType; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.Builder; @@ -38,6 +39,12 @@ public class StudyUpdateRequest { @Schema(description = "스터디 소개", example = "자바 스터디입니다.") private String introduction; + @AssertTrue(message = "스터디 타입이 OFFLINE이나 HYBRID인 스터디는 지역 입력이 필수입니다.") + public boolean isLocationValid() { + return studyType != StudyType.OFFLINE && studyType != StudyType.HYBRID + || (location != null && !location.isBlank()); + } + private StudyUpdateRequest() {} @Builder diff --git a/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/ExtendParticipationResponse.java b/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/ExtendParticipationResponse.java index 59dc8662..67ff629e 100644 --- a/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/ExtendParticipationResponse.java +++ b/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/ExtendParticipationResponse.java @@ -10,16 +10,16 @@ public class ExtendParticipationResponse { @Schema(description = "유저 Id", example = "1") - private Long userId; + private final Long userId; @Schema(description = "닉네임", example = "너굴") - private String nickname; + private final String nickname; @Schema(description = "역할", example = "LEADER") - private StudyMemberRole role; + private final StudyMemberRole role; @Schema(description = "참여 여부", example = "true") - private boolean participated; + private final boolean participated; @Builder public ExtendParticipationResponse(Long userId, String nickname, StudyMemberRole role, boolean participated) { diff --git a/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyExtensionResponse.java b/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyExtensionResponse.java index 74537d22..d9718dca 100644 --- a/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyExtensionResponse.java +++ b/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyExtensionResponse.java @@ -11,13 +11,13 @@ public class StudyExtensionResponse { @Schema(description = "스터디 Id", example = "1") - private Long studyId; + private final Long studyId; @Schema(description = "연장 여부", example = "true") - private boolean extended; + private final boolean extended; @Schema(description = "연장 스터디 참여 여부 목록") - private List members; + private final List members; @Builder private StudyExtensionResponse(Long studyId, boolean extended, List members) { diff --git a/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyHeaderResponse.java b/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyHeaderResponse.java index aacc828c..424f378b 100644 --- a/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyHeaderResponse.java +++ b/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyHeaderResponse.java @@ -15,35 +15,35 @@ public class StudyHeaderResponse { @Schema(description = "스터디 이름", example = "자바 스터디") - private String name; + private final String name; @Schema(description = "스터디 소개", example = "자바 스터디") - private String introduction; + private final String introduction; @Schema(description = "대표 이미지", example = "http://localhost:8083/image.jpg") - private String imageUrl; + private final String imageUrl; @Schema(description = "타입", example = "ONLINE") - private StudyType studyType; + private final StudyType studyType; @Schema(description = "지역", example = "서울") - private String location; + private final String location; @Schema(description = "카테고리", example = "IT") - private Category category; + private final Category category; @Schema(description = "인원수", example = "6") - private int capacity; + private final int capacity; @Schema(description = "시작일", example = "2025-07-15") - private LocalDateTime startDate; + private final LocalDateTime startDate; @NotNull @Schema(description = "종료일", example = "2025-07-28") - private LocalDateTime endDate; + private final LocalDateTime endDate; @Schema(description = "스터디 멤버 목록") - private List members; + private final List members; @Builder private StudyHeaderResponse(String name, String introduction, String imageUrl, StudyType studyType, String location, Category category, diff --git a/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyImageResponse.java b/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyImageResponse.java index 11e43e8d..4e851dba 100644 --- a/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyImageResponse.java +++ b/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyImageResponse.java @@ -9,13 +9,13 @@ public class StudyImageResponse { @Schema(description = "스터디 번호", example = "3") - private Long studyId; + private final Long studyId; @Schema(description = "스터디 이름", example = "자바 스터디") - private String name; + private final String name; @Schema(description = "대표 이미지", example = "http://localhost:8083/image.jpg") - private String imageUrl; + private final String imageUrl; @Builder private StudyImageResponse(Long studyId, String name, String imageUrl) { diff --git a/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyInfoResponse.java b/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyInfoResponse.java index 76643a5b..4993b1d4 100644 --- a/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyInfoResponse.java +++ b/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyInfoResponse.java @@ -15,35 +15,35 @@ public class StudyInfoResponse { @Schema(description = "대표 이미지", example = "http://localhost:8083/image.jpg") - private String imageUrl; + private final String imageUrl; @Schema(description = "스터디 이름", example = "자바 스터디") - private String name; + private final String name; @Schema(description = "카테고리", example = "IT") - private Category category; + private final Category category; @Schema(description = "인원수", example = "6") - private int capacity; + private final int capacity; @Schema(description = "타입", example = "ONLINE") - private StudyType studyType; + private final StudyType studyType; @Schema(description = "지역", example = "서울") - private String location; + private final String location; @Schema(description = "시작일", example = "2025-07-15") - private LocalDateTime startDate; + private final LocalDateTime startDate; @NotNull @Schema(description = "종료일", example = "2025-07-28") - private LocalDateTime endDate; + private final LocalDateTime endDate; @Schema(description = "스터디 소개", example = "자바 스터디입니다.") - private String introduction; + private final String introduction; @Schema(description = "스터디 멤버 목록") - private List members; + private final List members; @Builder private StudyInfoResponse(String imageUrl, String name, Category category, int capacity, StudyType studyType, String location, diff --git a/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyItemPagingResponse.java b/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyItemPagingResponse.java index 0562ad62..d15bcda3 100644 --- a/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyItemPagingResponse.java +++ b/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyItemPagingResponse.java @@ -27,16 +27,16 @@ public class StudyItemPagingResponse { "\"finished\": false" + "}]" ) - private List studies; + private final List studies; @Schema(description = "총 페이지 수", example = "2") - private int totalPage; + private final int totalPage; @Schema(description = "총 요소 개수", example = "10") - private int totalElementCount; + private final int totalElementCount; @Schema(example = "false", description = "다음 페이지 여부") - private boolean hasNext; + private final boolean hasNext; @Builder private StudyItemPagingResponse(Page page) { diff --git a/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyItemResponse.java b/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyItemResponse.java index 1a9519a9..7ece04d4 100644 --- a/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyItemResponse.java +++ b/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyItemResponse.java @@ -12,40 +12,40 @@ public class StudyItemResponse { @Schema(description = "스터디 번호", example = "3") - private Long studyId; + private final Long studyId; @Schema(description = "스터디 이름", example = "자바 스터디") - private String name; + private final String name; @Schema(description = "스터디장 닉네임", example = "너굴") - private String leaderNickname; + private final String leaderNickname; @Schema(description = "정원", example = "4") - private int capacity; + private final int capacity; @Schema(description = "인원수", example = "3") - private int currentCount; + private final int currentCount; @Schema(description = "시작일", example = "2025-07-15") - private LocalDateTime startDate; + private final LocalDateTime startDate; @Schema(description = "종료일", example = "2025-07-28") - private LocalDateTime endDate; + private final LocalDateTime endDate; @Schema(description = "대표 이미지", example = "http://localhost:8083/image.jpg") - private String imageUrl; + private final String imageUrl; @Schema(description = "스터디 소개", example = "자바 스터디입니다.") - private String introduction; + private final String introduction; @Schema(description = "카테고리", example = "IT") - private Category category; + private final Category category; @Schema(description = "타입", example = "ONLINE") - private StudyType studyType; + private final StudyType studyType; @Schema(description = "종료 여부", example = "false") - private boolean finished; + private final boolean finished; @QueryProjection public StudyItemResponse(Long studyId, String name, String leaderNickname, int capacity, int currentCount, LocalDateTime startDate, diff --git a/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyMemberInfoResponse.java b/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyMemberInfoResponse.java index fb70a102..ccf7d542 100644 --- a/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyMemberInfoResponse.java +++ b/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyMemberInfoResponse.java @@ -11,16 +11,16 @@ public class StudyMemberInfoResponse { @Schema(description = "유저 Id", example = "1") - private Long userId; + private final Long userId; @Schema(description = "스터디 Id", example = "1") - private Long studyId; + private final Long studyId; @Schema(description = "스터디 멤버 역할", example = "LEADER") - private StudyMemberRole role; + private final StudyMemberRole role; @Schema(description = "닉네임", example = "너굴") - private String nickname; + private final String nickname; @Builder private StudyMemberInfoResponse(Long userId, Long studyId, StudyMemberRole role, String nickname) { diff --git a/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyMemberResponse.java b/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyMemberResponse.java index 189d4546..e89f62f3 100644 --- a/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyMemberResponse.java +++ b/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyMemberResponse.java @@ -11,16 +11,16 @@ public class StudyMemberResponse { @Schema(description = "유저 Id", example = "1") - private Long userId; + private final Long userId; @Schema(description = "스터디원 닉네임", example = "너굴") - private String nickname; + private final String nickname; @Schema(description = "스터디원 프로필 사진", example = "http://localhost:8083/image.jpg") - private String profileImageUrl; + private final String profileImageUrl; @Schema(description = "스터디 멤버 역할", example = "LEADER") - private StudyMemberRole role; + private final StudyMemberRole role; @Builder public StudyMemberResponse(Long userId, String nickname, String profileImageUrl, StudyMemberRole role) { diff --git a/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyMyContentResponse.java b/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyMyContentResponse.java deleted file mode 100644 index 4e7b5dce..00000000 --- a/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyMyContentResponse.java +++ /dev/null @@ -1,14 +0,0 @@ -package grep.neogulcoder.domain.study.controller.dto.response; - -import grep.neogulcoder.domain.studypost.controller.dto.StudyPostListResponse; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Getter; - -import java.util.List; - -@Getter -public class StudyMyContentResponse { - - @Schema(description = "게시글 리스트") - private List studyPosts; -} diff --git a/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyResponse.java b/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyResponse.java index 26f1972a..730a4647 100644 --- a/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyResponse.java +++ b/src/main/java/grep/neogulcoder/domain/study/controller/dto/response/StudyResponse.java @@ -14,28 +14,28 @@ public class StudyResponse { @Schema(description = "스터디 진행 일수", example = "45") - private int progressDays; + private final int progressDays; @Schema(description = "스터디 총 기간", example = "320") - private int totalDays; + private final int totalDays; @Schema(description = "정원", example = "4") - private int capacity; + private final int capacity; @Schema(description = "인원수", example = "3") - private int currentCount; + private final int currentCount; @Schema(description = "스터디 총 게시물 수", example = "10") - private int totalPostCount; + private final int totalPostCount; @Schema(description = "팀 달력") - private List teamCalendars; + private final List teamCalendars; @Schema(description = "스터디 공지글 2개") - private List noticePosts; + private final List noticePosts; @Schema(description = "스터디 자유글 3개") - private List freePosts; + private final List freePosts; @Builder private StudyResponse(int progressDays, int totalDays, int capacity, int currentCount, int totalPostCount, diff --git a/src/main/java/grep/neogulcoder/domain/study/enums/StudyType.java b/src/main/java/grep/neogulcoder/domain/study/enums/StudyType.java index 7e4e4050..31ad252b 100644 --- a/src/main/java/grep/neogulcoder/domain/study/enums/StudyType.java +++ b/src/main/java/grep/neogulcoder/domain/study/enums/StudyType.java @@ -1,10 +1,5 @@ package grep.neogulcoder.domain.study.enums; -import grep.neogulcoder.domain.recruitment.RecruitmentErrorCode; -import grep.neogulcoder.global.exception.business.NotFoundException; - -import java.util.Arrays; - public enum StudyType { ONLINE("온라인"), OFFLINE("오프라인"), HYBRID("병행"); @@ -14,13 +9,6 @@ public enum StudyType { this.description = description; } - public static StudyType fromDescription(String description) { - return Arrays.stream(values()) - .filter(study -> study.equalsDescription(description)) - .findFirst() - .orElseThrow(() -> new NotFoundException(RecruitmentErrorCode.BAD_REQUEST_STUDY_TYPE)); - } - private boolean equalsDescription(String description) { return this.description.equals(description); } diff --git a/src/main/java/grep/neogulcoder/domain/study/exception/code/StudyErrorCode.java b/src/main/java/grep/neogulcoder/domain/study/exception/code/StudyErrorCode.java index 94f9a8f3..cfb5954c 100644 --- a/src/main/java/grep/neogulcoder/domain/study/exception/code/StudyErrorCode.java +++ b/src/main/java/grep/neogulcoder/domain/study/exception/code/StudyErrorCode.java @@ -15,16 +15,17 @@ public enum StudyErrorCode implements ErrorCode { STUDY_CREATE_LIMIT_EXCEEDED("S005", HttpStatus.BAD_REQUEST, "종료되지 않은 스터디는 최대 10개까지만 생성할 수 있습니다."), STUDY_ALREADY_STARTED("S006", HttpStatus.BAD_REQUEST, "이미 시작된 스터디의 시작일은 변경할 수 없습니다."), STUDY_DELETE_NOT_ALLOWED("S007", HttpStatus.BAD_REQUEST, "스터디 멤버가 1명일 때만 삭제할 수 있습니다."), - STUDY_LOCATION_REQUIRED("S008", HttpStatus.BAD_REQUEST, "스터디 타입이 OFFLINE이나 HYBRID인 스터디는 지역 입력이 필수입니다."), - STUDY_EXTENSION_NOT_AVAILABLE("S009", HttpStatus.BAD_REQUEST, "스터디 연장은 스터디 종료일 7일 전부터 가능합니다."), - END_DATE_BEFORE_ORIGIN_STUDY("S010", HttpStatus.BAD_REQUEST, "연장 스터디 종료일은 기존 스터디 종료일 이후여야 합니다."), - ALREADY_EXTENDED_STUDY("S011", HttpStatus.BAD_REQUEST, "이미 연장된 스터디입니다."), - ALREADY_REGISTERED_PARTICIPATION("S012", HttpStatus.BAD_REQUEST, "연장 스터디 참여는 한 번만 등록할 수 있습니다."), + STUDY_EXTENSION_NOT_AVAILABLE("S008", HttpStatus.BAD_REQUEST, "스터디 연장은 스터디 종료일 7일 전부터 가능합니다."), + END_DATE_BEFORE_ORIGIN_STUDY("S009", HttpStatus.BAD_REQUEST, "연장 스터디 종료일은 기존 스터디 종료일 이후여야 합니다."), + ALREADY_EXTENDED_STUDY("S010", HttpStatus.BAD_REQUEST, "이미 연장된 스터디입니다."), + ALREADY_REGISTERED_PARTICIPATION("S011", HttpStatus.BAD_REQUEST, "연장 스터디 참여는 한 번만 등록할 수 있습니다."), - NOT_STUDY_LEADER("S013", HttpStatus.FORBIDDEN, "스터디장만 접근이 가능합니다."), - LEADER_CANNOT_LEAVE_STUDY("S014", HttpStatus.BAD_REQUEST, "스터디장은 스터디를 탈퇴할 수 없습니다."), - LEADER_CANNOT_DELEGATE_TO_SELF("S015", HttpStatus.BAD_REQUEST, "자기 자신에게는 스터디장 위임이 불가능합니다."); + NOT_STUDY_LEADER("S012", HttpStatus.FORBIDDEN, "스터디장만 접근이 가능합니다."), + LEADER_CANNOT_LEAVE_STUDY("S013", HttpStatus.BAD_REQUEST, "스터디장은 스터디를 탈퇴할 수 없습니다."), + LEADER_CANNOT_DELEGATE_TO_SELF("S014", HttpStatus.BAD_REQUEST, "자기 자신에게는 스터디장 위임이 불가능합니다."), + + STUDY_MEMBER_COUNT_UPDATE_FAILED("S015", HttpStatus.CONFLICT, "스터디 인원 수 업데이트에 실패했습니다. 잠시 후 다시 시도해주세요."); private final String code; private final HttpStatus status; diff --git a/src/main/java/grep/neogulcoder/domain/study/provider/StudyExtendMessageProvider.java b/src/main/java/grep/neogulcoder/domain/study/provider/StudyExtendMessageProvider.java index 3bc4511d..e4cab7e4 100644 --- a/src/main/java/grep/neogulcoder/domain/study/provider/StudyExtendMessageProvider.java +++ b/src/main/java/grep/neogulcoder/domain/study/provider/StudyExtendMessageProvider.java @@ -9,7 +9,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import static grep.neogulcoder.domain.study.exception.code.StudyErrorCode.*; +import static grep.neogulcoder.domain.study.exception.code.StudyErrorCode.STUDY_NOT_FOUND; @Component @RequiredArgsConstructor diff --git a/src/main/java/grep/neogulcoder/domain/study/provider/StudyExtensionReminderMessageProvider.java b/src/main/java/grep/neogulcoder/domain/study/provider/StudyExtensionReminderMessageProvider.java index 66546021..6830daa8 100644 --- a/src/main/java/grep/neogulcoder/domain/study/provider/StudyExtensionReminderMessageProvider.java +++ b/src/main/java/grep/neogulcoder/domain/study/provider/StudyExtensionReminderMessageProvider.java @@ -9,7 +9,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import static grep.neogulcoder.domain.study.exception.code.StudyErrorCode.*; +import static grep.neogulcoder.domain.study.exception.code.StudyErrorCode.STUDY_NOT_FOUND; @Component @RequiredArgsConstructor diff --git a/src/main/java/grep/neogulcoder/domain/study/repository/StudyMemberQueryRepository.java b/src/main/java/grep/neogulcoder/domain/study/repository/StudyMemberQueryRepository.java index 9a8547ae..32d6b523 100644 --- a/src/main/java/grep/neogulcoder/domain/study/repository/StudyMemberQueryRepository.java +++ b/src/main/java/grep/neogulcoder/domain/study/repository/StudyMemberQueryRepository.java @@ -17,11 +17,9 @@ @Repository public class StudyMemberQueryRepository { - private final EntityManager em; private final JPAQueryFactory queryFactory; public StudyMemberQueryRepository(EntityManager em) { - this.em = em; this.queryFactory = new JPAQueryFactory(em); } diff --git a/src/main/java/grep/neogulcoder/domain/study/repository/StudyMemberRepository.java b/src/main/java/grep/neogulcoder/domain/study/repository/StudyMemberRepository.java index 2f30eae2..7d47d351 100644 --- a/src/main/java/grep/neogulcoder/domain/study/repository/StudyMemberRepository.java +++ b/src/main/java/grep/neogulcoder/domain/study/repository/StudyMemberRepository.java @@ -13,8 +13,6 @@ import java.util.Optional; public interface StudyMemberRepository extends JpaRepository { - List findByStudyId(long studyId); - List findAllByStudyIdAndActivatedTrue(Long studyId); @Query("select m.study from StudyMember m where m.userId = :userId and m.study.activated = true") @@ -27,8 +25,6 @@ public interface StudyMemberRepository extends JpaRepository boolean existsByStudyIdAndUserIdAndActivatedTrue(Long studyId, Long userId); - boolean existsByStudyIdAndUserIdAndRoleAndActivatedTrue(Long studyId, Long id, StudyMemberRole role); - @Modifying(clearAutomatically = true) @Query("update StudyMember m set m.activated = false where m.study.id = :studyId") void deactivateByStudyId(@Param("studyId") Long studyId); diff --git a/src/main/java/grep/neogulcoder/domain/study/repository/StudyQueryRepository.java b/src/main/java/grep/neogulcoder/domain/study/repository/StudyQueryRepository.java index f5e8e731..8024f37e 100644 --- a/src/main/java/grep/neogulcoder/domain/study/repository/StudyQueryRepository.java +++ b/src/main/java/grep/neogulcoder/domain/study/repository/StudyQueryRepository.java @@ -3,7 +3,6 @@ import com.querydsl.core.BooleanBuilder; import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; -import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; import grep.neogulcoder.domain.study.QStudyMember; import grep.neogulcoder.domain.study.Study; diff --git a/src/main/java/grep/neogulcoder/domain/study/scheduler/StudyScheduler.java b/src/main/java/grep/neogulcoder/domain/study/scheduler/StudyScheduler.java index 1dcdceac..2c776dc4 100644 --- a/src/main/java/grep/neogulcoder/domain/study/scheduler/StudyScheduler.java +++ b/src/main/java/grep/neogulcoder/domain/study/scheduler/StudyScheduler.java @@ -1,13 +1,10 @@ package grep.neogulcoder.domain.study.scheduler; -import grep.neogulcoder.domain.study.Study; import grep.neogulcoder.domain.study.service.StudySchedulerService; import lombok.RequiredArgsConstructor; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -import java.util.List; - @Component @RequiredArgsConstructor public class StudyScheduler { diff --git a/src/main/java/grep/neogulcoder/domain/study/service/StudyManagementService.java b/src/main/java/grep/neogulcoder/domain/study/service/StudyManagementService.java index 3cb5467b..5b6e00ce 100644 --- a/src/main/java/grep/neogulcoder/domain/study/service/StudyManagementService.java +++ b/src/main/java/grep/neogulcoder/domain/study/service/StudyManagementService.java @@ -1,5 +1,7 @@ package grep.neogulcoder.domain.study.service; +import grep.neogulcoder.domain.recruitment.RecruitmentPostStatus; +import grep.neogulcoder.domain.recruitment.post.repository.RecruitmentPostRepository; import grep.neogulcoder.domain.study.Study; import grep.neogulcoder.domain.study.StudyMember; import grep.neogulcoder.domain.study.controller.dto.request.ExtendStudyRequest; @@ -15,6 +17,11 @@ import grep.neogulcoder.domain.users.repository.UserRepository; import grep.neogulcoder.global.exception.business.BusinessException; import grep.neogulcoder.global.exception.business.NotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; @@ -22,12 +29,6 @@ import java.util.Random; import java.util.stream.Collectors; -import lombok.RequiredArgsConstructor; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import static grep.neogulcoder.domain.study.enums.StudyMemberRole.*; import static grep.neogulcoder.domain.study.exception.code.StudyErrorCode.*; @Transactional(readOnly = true) @@ -41,30 +42,30 @@ public class StudyManagementService { private final UserRepository userRepository; private final ApplicationEventPublisher eventPublisher; private final StudyManagementServiceFacade studyManagementServiceFacade; + private final RecruitmentPostRepository recruitmentPostRepository; public StudyExtensionResponse getStudyExtension(Long studyId) { - Study study = findValidStudy(studyId); + Study study = getStudyById(studyId); - List members = studyMemberQueryRepository.findExtendParticipation( - studyId); + List members = studyMemberQueryRepository.findExtendParticipation(studyId); return StudyExtensionResponse.from(study, members); } public List getExtendParticipations(Long studyId) { - Study study = findValidStudy(studyId); + getStudyById(studyId); return studyMemberQueryRepository.findExtendParticipation(studyId); } @Transactional public void leaveStudy(Long studyId, Long userId) { - Study study = findValidStudy(studyId); - - StudyMember studyMember = findValidStudyMember(studyId, userId); + Study study = getStudyById(studyId); + StudyMember studyMember = getStudyMemberById(studyId, userId); if (isLastMember(study)) { study.delete(); studyMember.delete(); + completeRecruitmentPostIfExists(studyId); return; } @@ -82,10 +83,10 @@ public void delegateLeader(Long studyId, Long userId, Long newLeaderId) { throw new BusinessException(LEADER_CANNOT_DELEGATE_TO_SELF); } - StudyMember currentLeader = findValidStudyMember(studyId, userId); - isLeader(currentLeader); + StudyMember currentLeader = getStudyMemberById(studyId, userId); + validateLeader(currentLeader); - StudyMember newLeader = findValidStudyMember(studyId, newLeaderId); + StudyMember newLeader = getStudyMemberById(studyId, newLeaderId); currentLeader.changeRoleMember(); newLeader.changeRoleLeader(); @@ -94,22 +95,13 @@ public void delegateLeader(Long studyId, Long userId, Long newLeaderId) { @Transactional public void deleteUserFromStudies(Long userId) { List myStudyMembers = studyMemberQueryRepository.findActivatedStudyMembersWithStudy(userId); - - List studyIds = myStudyMembers.stream() - .map(studyMember -> studyMember.getStudy().getId()) - .distinct() - .toList(); - - List allActivatedMembers = studyMemberQueryRepository.findActivatedMembersByStudyIds(studyIds); - - Map> activatedMemberMap = allActivatedMembers.stream() - .collect(Collectors.groupingBy(studyMember -> studyMember.getStudy().getId())); + Map> activatedMemberMap = getActivatedMemberMap(myStudyMembers); for (StudyMember myMember : myStudyMembers) { Study study = myMember.getStudy(); List activatedMembers = activatedMemberMap.getOrDefault(study.getId(), List.of()); - if (activatedMembers.size() == 1) { + if (isLastActivatedMember(activatedMembers)) { study.delete(); myMember.delete(); continue; @@ -126,23 +118,19 @@ public void deleteUserFromStudies(Long userId) { @Transactional public Long extendStudy(Long studyId, ExtendStudyRequest request, Long userId) { - Study originStudy = findValidStudy(studyId); + Study originStudy = getStudyById(studyId); - StudyMember leader = findValidStudyMember(studyId, userId); - isLeader(leader); + StudyMember leader = getStudyMemberById(studyId, userId); + validateLeader(leader); validateStudyExtendable(originStudy, request.getNewEndDate()); - Study extendedStudy = request.toEntity(originStudy); + Study extendedStudy = request.toEntity(originStudy, userId); studyRepository.save(extendedStudy); originStudy.extend(); leader.participate(); - StudyMember extendedLeader = StudyMember.builder() - .study(extendedStudy) - .userId(userId) - .role(LEADER) - .build(); + StudyMember extendedLeader = StudyMember.createLeader(extendedStudy, userId); studyMemberRepository.save(extendedLeader); eventPublisher.publishEvent(new StudyExtendEvent(originStudy.getId())); @@ -152,8 +140,8 @@ public Long extendStudy(Long studyId, ExtendStudyRequest request, Long userId) { @Transactional public void registerExtensionParticipation(Long studyId, Long userId) { - Study originStudy = findValidStudy(studyId); - StudyMember studyMember = findValidStudyMember(studyId, userId); + Study study = getStudyById(studyId); + StudyMember studyMember = getStudyMemberById(studyId, userId); if (studyMember.isParticipated()) { throw new BusinessException(ALREADY_REGISTERED_PARTICIPATION); @@ -164,17 +152,14 @@ public void registerExtensionParticipation(Long studyId, Long userId) { Study extendedStudy = studyRepository.findByOriginStudyIdAndActivatedTrue(studyId) .orElseThrow(() -> new BusinessException(EXTENDED_STUDY_NOT_FOUND)); - StudyMember extendMember = StudyMember.builder() - .study(extendedStudy) - .userId(userId) - .role(MEMBER) - .build(); + StudyMember extendMember = StudyMember.createMember(extendedStudy, userId); studyMemberRepository.save(extendMember); + studyManagementServiceFacade.increaseMemberCount(study, userId); } @Transactional public void inviteTargetUser(Long studyId, Long userId, String targetUserNickname) { - StudyMember studyMember = findValidStudyMember(studyId, userId); + StudyMember studyMember = getStudyMemberById(studyId, userId); studyMember.isLeader(); User targetUser = userRepository.findByNickname(targetUserNickname) @@ -184,12 +169,12 @@ public void inviteTargetUser(Long studyId, Long userId, String targetUserNicknam eventPublisher.publishEvent(new StudyInviteEvent(studyId, userId, targetUser.getId())); } - private Study findValidStudy(Long studyId) { + private Study getStudyById(Long studyId) { return studyRepository.findById(studyId) .orElseThrow(() -> new NotFoundException(STUDY_NOT_FOUND)); } - private StudyMember findValidStudyMember(Long studyId, Long userId) { + private StudyMember getStudyMemberById(Long studyId, Long userId) { return studyMemberRepository.findByStudyIdAndUserId(studyId, userId) .orElseThrow(() -> new NotFoundException(STUDY_MEMBER_NOT_FOUND)); } @@ -200,9 +185,13 @@ private boolean isLastMember(Study study) { return activatedMemberCount == 1; } - private void randomDelegateLeader(Long studyId, StudyMember currentLeader) { + private void completeRecruitmentPostIfExists(Long studyId) { + recruitmentPostRepository.findByStudyIdAndActivatedTrue(studyId) + .ifPresent(recruitmentPost -> recruitmentPost.updateStatus(RecruitmentPostStatus.COMPLETE)); + } - isLeader(currentLeader); + private void randomDelegateLeader(Long studyId, StudyMember currentLeader) { + validateLeader(currentLeader); List studyMembers = studyMemberRepository.findAvailableNewLeaders(studyId); @@ -216,12 +205,28 @@ private void randomDelegateLeader(Long studyId, StudyMember currentLeader) { newLeader.changeRoleLeader(); } - private void isLeader(StudyMember studyMember) { + private void validateLeader(StudyMember studyMember) { if (!studyMember.isLeader()) { throw new BusinessException(NOT_STUDY_LEADER); } } + private Map> getActivatedMemberMap(List myStudyMembers) { + List studyIds = myStudyMembers.stream() + .map(sm -> sm.getStudy().getId()) + .distinct() + .toList(); + + List allActivatedMembers = studyMemberQueryRepository.findActivatedMembersByStudyIds(studyIds); + + return allActivatedMembers.stream() + .collect(Collectors.groupingBy(studyMember -> studyMember.getStudy().getId())); + } + + private static boolean isLastActivatedMember(List activatedMembers) { + return activatedMembers.size() == 1; + } + private void validateStudyExtendable(Study study, LocalDateTime endDate) { if (study.alreadyExtended()) { throw new BusinessException(ALREADY_EXTENDED_STUDY); diff --git a/src/main/java/grep/neogulcoder/domain/study/service/StudyManagementServiceFacade.java b/src/main/java/grep/neogulcoder/domain/study/service/StudyManagementServiceFacade.java index 555f6c60..c2ed3c75 100644 --- a/src/main/java/grep/neogulcoder/domain/study/service/StudyManagementServiceFacade.java +++ b/src/main/java/grep/neogulcoder/domain/study/service/StudyManagementServiceFacade.java @@ -1,11 +1,15 @@ package grep.neogulcoder.domain.study.service; import grep.neogulcoder.domain.study.Study; +import grep.neogulcoder.global.exception.business.BusinessException; import jakarta.persistence.OptimisticLockException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static grep.neogulcoder.domain.study.exception.code.StudyErrorCode.STUDY_MEMBER_COUNT_UPDATE_FAILED; @Slf4j @Service @@ -14,6 +18,30 @@ public class StudyManagementServiceFacade { private static final int MAX_RETRY = 3; + @Transactional + public void increaseMemberCount(Study study, Long userId) { + int retry = 0; + while (retry < MAX_RETRY) { + try { + study.increaseMemberCount(); + return; + } catch (OptimisticLockException | ObjectOptimisticLockingFailureException e) { + retry++; + + try { + Thread.sleep(30); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + break; + } + } + } + + log.warn("스터디 currentCount 증가 실패 (studyId={}, userId={})", study.getId(), userId); + throw new BusinessException(STUDY_MEMBER_COUNT_UPDATE_FAILED); + } + + @Transactional public void decreaseMemberCount(Study study, Long userId) { int retry = 0; while (retry < MAX_RETRY) { @@ -33,5 +61,6 @@ public void decreaseMemberCount(Study study, Long userId) { } log.warn("스터디 currentCount 감소 실패 (studyId={}, userId={})", study.getId(), userId); + throw new BusinessException(STUDY_MEMBER_COUNT_UPDATE_FAILED); } } diff --git a/src/main/java/grep/neogulcoder/domain/study/service/StudyService.java b/src/main/java/grep/neogulcoder/domain/study/service/StudyService.java index 08577199..779283c8 100644 --- a/src/main/java/grep/neogulcoder/domain/study/service/StudyService.java +++ b/src/main/java/grep/neogulcoder/domain/study/service/StudyService.java @@ -11,7 +11,6 @@ import grep.neogulcoder.domain.study.controller.dto.request.StudyUpdateRequest; import grep.neogulcoder.domain.study.controller.dto.response.*; import grep.neogulcoder.domain.study.enums.StudyMemberRole; -import grep.neogulcoder.domain.study.enums.StudyType; import grep.neogulcoder.domain.study.repository.StudyMemberQueryRepository; import grep.neogulcoder.domain.study.repository.StudyMemberRepository; import grep.neogulcoder.domain.study.repository.StudyQueryRepository; @@ -36,6 +35,7 @@ import java.io.IOException; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Optional; @@ -71,14 +71,14 @@ public List getMyUnfinishedStudies(Long userId) { } public StudyHeaderResponse getStudyHeader(Long studyId) { - Study study = findValidStudy(studyId); + Study study = getStudyById(studyId); List members = studyQueryRepository.findStudyMembers(studyId); return StudyHeaderResponse.from(study, members); } public StudyResponse getStudy(Long studyId) { - Study study = findValidStudy(studyId); + Study study = getStudyById(studyId); int progressDays = (int) ChronoUnit.DAYS.between(study.getStartDate().toLocalDate(), LocalDate.now()) + 1; int totalDays = (int) ChronoUnit.DAYS.between(study.getStartDate().toLocalDate(), study.getEndDate().toLocalDate()) + 1; @@ -102,7 +102,7 @@ public List getStudyImages(Long userId) { } public StudyInfoResponse getMyStudyContent(Long studyId, Long userId) { - Study study = findValidStudy(studyId); + Study study = getStudyById(studyId); validateStudyMember(studyId, userId); validateStudyLeader(studyId, userId); @@ -113,7 +113,7 @@ public StudyInfoResponse getMyStudyContent(Long studyId, Long userId) { } public StudyMemberInfoResponse getMyStudyMemberInfo(Long studyId, Long userId) { - StudyMember studyMember = findValidStudyMember(studyId, userId); + StudyMember studyMember = getStudyMemberById(studyId, userId); User user = userRepository.findById(userId) .orElseThrow(() -> new NotFoundException(USER_NOT_FOUND)); @@ -124,7 +124,6 @@ public StudyMemberInfoResponse getMyStudyMemberInfo(Long studyId, Long userId) { @Transactional public Long createStudy(StudyCreateRequest request, Long userId, MultipartFile image) throws IOException { validateStudyCreateLimit(userId); - validateLocation(request.getStudyType(), request.getLocation()); String imageUrl = createImageUrl(userId, image); @@ -141,9 +140,8 @@ public Long createStudy(StudyCreateRequest request, Long userId, MultipartFile i @Transactional public void updateStudy(Long studyId, StudyUpdateRequest request, Long userId, MultipartFile image) throws IOException { - Study study = findValidStudy(studyId); + Study study = getStudyById(studyId); - validateLocation(request.getStudyType(), request.getLocation()); validateStudyMember(studyId, userId); validateStudyLeader(studyId, userId); validateStudyStartDate(request, study); @@ -164,7 +162,7 @@ public void updateStudy(Long studyId, StudyUpdateRequest request, Long userId, M @Transactional public void deleteStudy(Long studyId, Long userId) { - Study study = findValidStudy(studyId); + getStudyById(studyId); validateStudyMember(studyId, userId); validateStudyLeader(studyId, userId); @@ -175,12 +173,12 @@ public void deleteStudy(Long studyId, Long userId) { recruitmentPostRepository.deactivateByStudyId(studyId); } - private Study findValidStudy(Long studyId) { + private Study getStudyById(Long studyId) { return studyRepository.findById(studyId) .orElseThrow(() -> new NotFoundException(STUDY_NOT_FOUND)); } - private StudyMember findValidStudyMember(Long studyId, Long userId) { + private StudyMember getStudyMemberById(Long studyId, Long userId) { return Optional.ofNullable(studyMemberQueryRepository.findByStudyIdAndUserId(studyId, userId)) .orElseThrow(() -> new NotFoundException(STUDY_MEMBER_NOT_FOUND)); } @@ -192,12 +190,6 @@ private void validateStudyCreateLimit(Long userId) { } } - private static void validateLocation(StudyType studyType, String location) { - if ((studyType == StudyType.OFFLINE || studyType == StudyType.HYBRID) && (location == null || location.isBlank())) { - throw new BusinessException(STUDY_LOCATION_REQUIRED); - } - } - private List getCurrentMonthTeamCalendars(Long studyId) { LocalDate now = LocalDate.now(); int currentYear = now.getYear(); @@ -220,7 +212,7 @@ private void validateStudyLeader(Long studyId, Long userId) { } private static void validateStudyStartDate(StudyUpdateRequest request, Study study) { - if (study.isStarted() && !study.getStartDate().equals(request.getStartDate())) { + if (study.isStarted(LocalDateTime.now()) && !study.getStartDate().equals(request.getStartDate())) { throw new BusinessException(STUDY_ALREADY_STARTED); } } @@ -251,8 +243,4 @@ private String updateImageUrl(Long userId, MultipartFile image, String originalI private boolean isImgExists(MultipartFile image) { return image != null && !image.isEmpty(); } - - private int getActiveUnfinishedStudiesCount(Long userId) { - return studyMemberQueryRepository.countActiveUnfinishedStudies(userId); - } } diff --git a/src/main/java/grep/neogulcoder/domain/timevote/TimeVoteStat.java b/src/main/java/grep/neogulcoder/domain/timevote/TimeVoteStat.java index 45097ae2..7ea41eee 100644 --- a/src/main/java/grep/neogulcoder/domain/timevote/TimeVoteStat.java +++ b/src/main/java/grep/neogulcoder/domain/timevote/TimeVoteStat.java @@ -34,18 +34,13 @@ public class TimeVoteStat extends BaseEntity { @Column(nullable = false) private Long voteCount; - @Version - @Column(nullable = false) - private Long version; - protected TimeVoteStat() {} @Builder - public TimeVoteStat(TimeVotePeriod period, LocalDateTime timeSlot, Long voteCount, Long version) { + public TimeVoteStat(TimeVotePeriod period, LocalDateTime timeSlot, Long voteCount) { this.period = period; this.timeSlot = timeSlot; this.voteCount = voteCount; - this.version = version; } public static TimeVoteStat of(TimeVotePeriod period, LocalDateTime timeSlot, Long voteCount) { @@ -53,12 +48,11 @@ public static TimeVoteStat of(TimeVotePeriod period, LocalDateTime timeSlot, Lon .period(period) .timeSlot(timeSlot) .voteCount(voteCount) - .version(0L) .build(); } public void addVotes(Long countToAdd) { - log.debug("addVotes: 이전 voteCount={}, 추가 count={}, 이전 version={}", this.voteCount, countToAdd, this.version); + log.debug("addVotes: 이전 voteCount={}, 추가 count={}, 이전 version={}", this.voteCount, countToAdd); this.voteCount += countToAdd; } } diff --git a/src/main/java/grep/neogulcoder/domain/timevote/exception/code/TimeVoteErrorCode.java b/src/main/java/grep/neogulcoder/domain/timevote/exception/code/TimeVoteErrorCode.java index 4a122519..35a269ae 100644 --- a/src/main/java/grep/neogulcoder/domain/timevote/exception/code/TimeVoteErrorCode.java +++ b/src/main/java/grep/neogulcoder/domain/timevote/exception/code/TimeVoteErrorCode.java @@ -17,6 +17,7 @@ public enum TimeVoteErrorCode implements ErrorCode { INVALID_TIME_VOTE_PERIOD("TVP_003", HttpStatus.BAD_REQUEST, "모일 일정 조율 기간은 최대 7일까지 설정할 수 있습니다."), TIME_VOTE_PERIOD_START_DATE_IN_PAST("TVP_004", HttpStatus.BAD_REQUEST, "투표 시작일은 현재 시각보다 이전일 수 없습니다."), TIME_VOTE_INVALID_DATE_RANGE("TVP_005", HttpStatus.BAD_REQUEST, "종료일은 시작일보다 이후여야 합니다."), + TIME_VOTE_DELETE_FAILED("TVP_006", HttpStatus.INTERNAL_SERVER_ERROR, "기존 투표 데이터 삭제 중 오류가 발생했습니다."), // Time Vote, Time Vote Stats (e.g. 투표 기간 관련) TIME_VOTE_OUT_OF_RANGE("TVAS_001", HttpStatus.BAD_REQUEST, "선택한 시간이 투표 기간을 벗어났습니다."), diff --git a/src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVotePeriodRepository.java b/src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVotePeriodRepository.java index 44e6c1e5..bf66ae2c 100644 --- a/src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVotePeriodRepository.java +++ b/src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVotePeriodRepository.java @@ -3,12 +3,17 @@ import grep.neogulcoder.domain.timevote.TimeVotePeriod; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface TimeVotePeriodRepository extends JpaRepository { - void deleteAllByStudyId(Long studyId); + @Modifying + @Query("UPDATE TimeVotePeriod p SET p.activated = false WHERE p.studyId = :studyId") + void deactivateAllByStudyId(@Param("studyId") Long studyId); - boolean existsByStudyId(Long studyId); + boolean existsByStudyIdAndActivatedTrue(Long studyId); - Optional findTopByStudyIdOrderByStartDateDesc(Long studyId); + Optional findTopByStudyIdAndActivatedTrueOrderByStartDateDesc(Long studyId); } diff --git a/src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVoteQueryRepository.java b/src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVoteQueryRepository.java index 8fe1e292..5213e8b8 100644 --- a/src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVoteQueryRepository.java +++ b/src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVoteQueryRepository.java @@ -1,9 +1,7 @@ package grep.neogulcoder.domain.timevote.repository; -import com.querydsl.core.types.dsl.BooleanPath; -import com.querydsl.core.types.dsl.Expressions; -import com.querydsl.core.types.dsl.NumberPath; -import com.querydsl.core.types.dsl.StringPath; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; import grep.neogulcoder.domain.study.QStudyMember; import grep.neogulcoder.domain.timevote.dto.response.TimeVoteSubmissionStatusResponse; @@ -24,45 +22,44 @@ public TimeVoteQueryRepository(EntityManager em) { this.queryFactory = new JPAQueryFactory(em); } - public List findSubmissionStatuses(Long studyId, Long periodId) { + public List findSubmissionStatuses(Long studyId, + Long periodId) { QStudyMember studyMember = QStudyMember.studyMember; QTimeVote timeVote = QTimeVote.timeVote; QUser user = QUser.user; - // select절에서 alias를 지정해 Tuple에서 이름 기반으로 값을 꺼낼 수 있도록 Path 객체 생성 - NumberPath aliasStudyMemberId = Expressions.numberPath(Long.class, "aliasStudyMemberId"); - StringPath aliasNickname = Expressions.stringPath("aliasNickname"); - StringPath aliasProfileImageUrl = Expressions.stringPath("aliasProfileImageUrl"); - BooleanPath aliasIsSubmitted = Expressions.booleanPath("aliasIsSubmitted"); + BooleanExpression existsVoteSubquery = JPAExpressions + .selectOne() + .from(timeVote) + .where( + timeVote.studyMemberId.eq(studyMember.id) + .and(timeVote.period.periodId.eq(periodId)) + .and(timeVote.activated.isTrue()) + ) + .exists(); + List results = queryFactory .select( - studyMember.id.as(aliasStudyMemberId), - user.nickname.as(aliasNickname), - user.profileImageUrl.as(aliasProfileImageUrl), - timeVote.voteId.count().gt(0).as(aliasIsSubmitted) + studyMember.id, + user.nickname, + user.profileImageUrl, + existsVoteSubquery ) .from(studyMember) - .leftJoin(user).on(studyMember.userId.eq(user.id)) - .leftJoin(timeVote).on( - timeVote.period.periodId.eq(periodId) - .and(timeVote.studyMemberId.eq(studyMember.id)) - ) + .join(user).on(studyMember.userId.eq(user.id)) .where( studyMember.study.id.eq(studyId), studyMember.activated.isTrue() ) - // 중복 방지 (id, 닉네임, 프로필 기준으로 그룹핑) - .groupBy(studyMember.id, user.nickname, user.profileImageUrl) .fetch(); - // Tuple 결과를 DTO 로 변환 (alias 기반으로 값 추출) return results.stream() .map(tuple -> TimeVoteSubmissionStatusResponse.builder() - .studyMemberId(tuple.get(aliasStudyMemberId)) - .nickname(tuple.get(aliasNickname)) - .profileImageUrl(tuple.get(aliasProfileImageUrl)) - .isSubmitted(tuple.get(aliasIsSubmitted)) + .studyMemberId(tuple.get(studyMember.id)) + .nickname(tuple.get(user.nickname)) + .profileImageUrl(tuple.get(user.profileImageUrl)) + .isSubmitted(tuple.get(existsVoteSubquery)) .build()) .collect(Collectors.toList()); } diff --git a/src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVoteRepository.java b/src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVoteRepository.java index 16ce126a..9b6b583e 100644 --- a/src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVoteRepository.java +++ b/src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVoteRepository.java @@ -4,14 +4,21 @@ import grep.neogulcoder.domain.timevote.TimeVotePeriod; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface TimeVoteRepository extends JpaRepository { - void deleteAllByPeriod_StudyId(Long studyId); + @Modifying(clearAutomatically = true) + @Query("UPDATE TimeVote v SET v.activated = false WHERE v.period.studyId = :studyId") + void deactivateAllByPeriod_StudyId(@Param("studyId") Long studyId); - List findByPeriodAndStudyMemberId(TimeVotePeriod period, Long userId); + List findByPeriodAndStudyMemberIdAndActivatedTrue(TimeVotePeriod period, Long studyMemberId); - void deleteAllByPeriodAndStudyMemberId(TimeVotePeriod period, Long studyMemberId); + @Modifying(clearAutomatically = true) + @Query("UPDATE TimeVote v SET v.activated = false WHERE v.period = :period AND v.studyMemberId = :memberId") + void deactivateByPeriodAndStudyMember(@Param("period") TimeVotePeriod period, @Param("memberId") Long memberId); - boolean existsByPeriodAndStudyMemberId(TimeVotePeriod period, Long studyMemberId); + boolean existsByPeriodAndStudyMemberIdAndActivatedTrue(TimeVotePeriod period, Long studyMemberId); } diff --git a/src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVoteStatQueryRepository.java b/src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVoteStatQueryRepository.java index 770e3569..17491ba2 100644 --- a/src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVoteStatQueryRepository.java +++ b/src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVoteStatQueryRepository.java @@ -5,13 +5,13 @@ import grep.neogulcoder.domain.timevote.TimeVotePeriod; import grep.neogulcoder.domain.timevote.TimeVoteStat; import grep.neogulcoder.domain.timevote.QTimeVote; -import grep.neogulcoder.domain.timevote.QTimeVoteStat; import jakarta.persistence.EntityManager; -import java.time.LocalDateTime; import java.util.List; import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Repository; +@Slf4j @Repository public class TimeVoteStatQueryRepository { @@ -29,29 +29,19 @@ public List countStatsByPeriod(TimeVotePeriod period) { List result = queryFactory .select(timeVote.timeSlot, timeVote.count()) .from(timeVote) - .where(timeVote.period.eq(period)) + .where( + timeVote.period.eq(period), + timeVote.activated.isTrue() + ) .groupBy(timeVote.timeSlot) .fetch(); + for (Tuple tuple : result) { + log.info(">>> 통계 디버깅: timeSlot={}, count={}", tuple.get(timeVote.timeSlot), tuple.get(timeVote.count())); + } + return result.stream() .map(tuple -> TimeVoteStat.of(period, tuple.get(timeVote.timeSlot), tuple.get(timeVote.count()))) .collect(Collectors.toList()); } - - public void incrementOrInsert(TimeVotePeriod period, LocalDateTime slot, Long countToAdd) { - QTimeVoteStat stat = QTimeVoteStat.timeVoteStat; - - TimeVoteStat existing = queryFactory - .selectFrom(stat) - .where(stat.period.eq(period), stat.timeSlot.eq(slot)) - .fetchOne(); - - if (existing != null) { - existing.addVotes(countToAdd); - } else { - TimeVoteStat newStat = TimeVoteStat.of(period, slot, countToAdd); - em.persist(newStat); - em.flush(); - } - } } diff --git a/src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVoteStatRepository.java b/src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVoteStatRepository.java index 5e0a862d..d8f8e00a 100644 --- a/src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVoteStatRepository.java +++ b/src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVoteStatRepository.java @@ -2,17 +2,57 @@ import grep.neogulcoder.domain.timevote.TimeVotePeriod; import grep.neogulcoder.domain.timevote.TimeVoteStat; +import java.time.LocalDateTime; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; public interface TimeVoteStatRepository extends JpaRepository { - void deleteAllByPeriod_StudyId(Long studyId); + @Modifying + @Query("UPDATE TimeVoteStat s SET s.activated = false WHERE s.period.studyId = :studyId") + void deactivateAllByPeriod_StudyId(@Param("studyId") Long studyId); - void deleteByPeriod(TimeVotePeriod period); - @Query("SELECT s FROM TimeVoteStat s WHERE s.period.periodId = :periodId") + @Modifying + @Query("UPDATE TimeVoteStat s SET s.activated = false WHERE s.period = :period") + void softDeleteByPeriod(@Param("period") TimeVotePeriod period); + + @Query("SELECT s FROM TimeVoteStat s WHERE s.period.periodId = :periodId AND s.activated = true") List findAllByPeriodId(@Param("periodId") Long periodId); + + @Modifying(clearAutomatically = true) + @Query(value = """ + + INSERT INTO time_vote_stat (period_id, time_slot, vote_count, activated, created_date, modified_date) + VALUES (:periodId, :timeSlot, :count, true, now(), now()) + ON CONFLICT (period_id, time_slot) + DO UPDATE SET + vote_count = time_vote_stat.vote_count + EXCLUDED.vote_count, + modified_date = now(), + activated = true + """, nativeQuery = true) + void upsertVoteStat( + @Param("periodId") Long periodId, + @Param("timeSlot") LocalDateTime timeSlot, + @Param("count") Long count + ); + + @Modifying(clearAutomatically = true) + @Query(value = """ + INSERT INTO time_vote_stat (period_id, time_slot, vote_count, activated, created_date, modified_date) + VALUES (:periodId, :timeSlot, :voteCount, true, now(), now()) + ON CONFLICT (period_id, time_slot) + DO UPDATE SET + vote_count = EXCLUDED.vote_count, + modified_date = now(), + activated = true + """, nativeQuery = true) + void bulkUpsertStat( + @Param("periodId") Long periodId, + @Param("timeSlot") LocalDateTime timeSlot, + @Param("voteCount") Long voteCount + ); } diff --git a/src/main/java/grep/neogulcoder/domain/timevote/service/TimeVoteMapper.java b/src/main/java/grep/neogulcoder/domain/timevote/service/TimeVoteMapper.java index 03f3f357..0ddd18f0 100644 --- a/src/main/java/grep/neogulcoder/domain/timevote/service/TimeVoteMapper.java +++ b/src/main/java/grep/neogulcoder/domain/timevote/service/TimeVoteMapper.java @@ -42,4 +42,14 @@ public List toEntities(TimeVoteUpdateRequest request, TimeVotePeriod p .build()) .collect(Collectors.toList()); } + + public List toEntities(List timeSlots, TimeVotePeriod period, Long studyMemberId) { + return timeSlots.stream() + .map(slot -> TimeVote.builder() + .period(period) + .studyMemberId(studyMemberId) + .timeSlot(slot) + .build()) + .collect(Collectors.toList()); + } } diff --git a/src/main/java/grep/neogulcoder/domain/timevote/service/period/TimeVotePeriodService.java b/src/main/java/grep/neogulcoder/domain/timevote/service/period/TimeVotePeriodService.java index b3a1335d..64aa028f 100644 --- a/src/main/java/grep/neogulcoder/domain/timevote/service/period/TimeVotePeriodService.java +++ b/src/main/java/grep/neogulcoder/domain/timevote/service/period/TimeVotePeriodService.java @@ -1,5 +1,7 @@ package grep.neogulcoder.domain.timevote.service.period; +import static grep.neogulcoder.domain.timevote.exception.code.TimeVoteErrorCode.*; + import grep.neogulcoder.domain.timevote.dto.request.TimeVotePeriodCreateRequest; import grep.neogulcoder.domain.timevote.dto.response.TimeVotePeriodResponse; import grep.neogulcoder.domain.timevote.TimeVotePeriod; @@ -8,13 +10,16 @@ import grep.neogulcoder.domain.timevote.repository.TimeVoteRepository; import grep.neogulcoder.domain.timevote.repository.TimeVoteStatRepository; import grep.neogulcoder.domain.timevote.service.TimeVoteMapper; +import grep.neogulcoder.global.exception.business.BusinessException; import java.time.LocalDateTime; import java.time.LocalTime; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @Transactional @RequiredArgsConstructor @@ -28,30 +33,51 @@ public class TimeVotePeriodService { private final ApplicationEventPublisher eventPublisher; public TimeVotePeriodResponse createTimeVotePeriodAndReturn(TimeVotePeriodCreateRequest request, Long studyId, Long userId) { + log.info("[TimeVotePeriod] 투표 기간 생성 시작 - studyId={}, userId={}, 요청={}", studyId, userId, request); + // 입력값 검증 (스터디 존재, 멤버 활성화 여부, 리더 여부, 날짜 유효성 등) timeVotePeriodValidator.validatePeriodCreateRequestAndReturnMember(request, studyId, userId); - LocalDateTime adjustedEndDate = adjustEndDate(request.getEndDate()); - if (timeVotePeriodRepository.existsByStudyId(studyId)) { deleteAllTimeVoteDate(studyId); } + try { + if (timeVotePeriodRepository.existsByStudyIdAndActivatedTrue(studyId)) { + log.info("[TimeVotePeriod] 기존 투표 기간 존재, 전체 투표 데이터 삭제 진행"); + deleteAllTimeVoteDate(studyId); + } + } catch (Exception e) { + log.error("[TimeVotePeriod] 기존 투표 삭제 중 오류 발생 - studyId={}, error={}", studyId, e.getMessage(), e); + throw new BusinessException(TIME_VOTE_DELETE_FAILED); + } TimeVotePeriod savedPeriod = timeVotePeriodRepository.save(timeVoteMapper.toEntity(request, studyId, adjustedEndDate)); + log.info("[TimeVotePeriod] 투표 기간 저장 완료 - periodId={}", savedPeriod.getPeriodId()); // 알림 메시지를 위한 스터디 유효성 검증 (존재 확인) timeVotePeriodValidator.getValidStudy(studyId); // 리더 자신을 제외한 나머지 멤버들에게 투표 요청 알림 저장 eventPublisher.publishEvent(new TimeVotePeriodCreatedEvent(studyId, userId)); + log.info("[TimeVotePeriod] 투표 요청 알림 이벤트 발행 완료 - studyId={}, userId={}", studyId, userId); return TimeVotePeriodResponse.from(savedPeriod); } // 해당 스터디 ID로 등록된 모든 투표 관련 데이터 삭제 public void deleteAllTimeVoteDate(Long studyId) { + long start = System.currentTimeMillis(); + log.info("[TimeVotePeriod] 전체 투표 데이터 삭제 시작 - studyId={}", studyId); + timeVotePeriodValidator.getValidStudy(studyId); - timeVoteRepository.deleteAllByPeriod_StudyId(studyId); - timeVoteStatRepository.deleteAllByPeriod_StudyId(studyId); - timeVotePeriodRepository.deleteAllByStudyId(studyId); + timeVoteRepository.deactivateAllByPeriod_StudyId(studyId); + log.info("[TimeVotePeriod] 투표 soft delete 완료"); + + timeVoteStatRepository.deactivateAllByPeriod_StudyId(studyId); + log.info("[TimeVotePeriod] 통계 soft delete 완료"); + + timeVotePeriodRepository.deactivateAllByStudyId(studyId); + log.info("[TimeVotePeriod] 투표 기간 hard delete 완료"); + + log.info("[TimeVotePeriod] 전체 삭제 완료 - {}ms 소요", System.currentTimeMillis() - start); } // 투표 기간의 종료일을 '해당 일의 23:59:59'로 보정 diff --git a/src/main/java/grep/neogulcoder/domain/timevote/service/stat/TimeVoteStatService.java b/src/main/java/grep/neogulcoder/domain/timevote/service/stat/TimeVoteStatService.java index 0b4a0aac..071abb7e 100644 --- a/src/main/java/grep/neogulcoder/domain/timevote/service/stat/TimeVoteStatService.java +++ b/src/main/java/grep/neogulcoder/domain/timevote/service/stat/TimeVoteStatService.java @@ -9,15 +9,15 @@ import grep.neogulcoder.domain.timevote.repository.TimeVoteStatQueryRepository; import grep.neogulcoder.domain.timevote.repository.TimeVoteStatRepository; import grep.neogulcoder.global.exception.business.BusinessException; -import jakarta.persistence.OptimisticLockException; +import jakarta.persistence.PersistenceException; import java.time.LocalDateTime; -import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataAccessException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -33,73 +33,34 @@ public class TimeVoteStatService { @Transactional(readOnly = true) public TimeVoteStatResponse getStats(Long studyId, Long userId) { + log.info("[통계 조회] 시작 - studyId={}, userId={}", studyId, userId); + TimeVoteContext context = timeVoteStatValidator.getValidatedContext(studyId, userId); List stats = timeVoteStatRepository.findAllByPeriodId(context.period().getPeriodId()); + log.info("[통계 조회] 조회된 통계 개수 = {}", stats.size()); timeVoteStatValidator.validateStatTimeSlotsWithinPeriod(context.period(), stats); return TimeVoteStatResponse.from(context.period(), stats); } - public void incrementStats(Long periodId, List timeSlots) { - log.info("[TimeVoteStatService] 투표 통계 계산 시작: periodId={}, timeSlots={}", periodId, timeSlots); - TimeVotePeriod period = timeVoteStatValidator.getValidTimeVotePeriodByPeriodId(periodId); - - Map countMap = timeSlots.stream() - .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); - - List failedSlots = new ArrayList<>(); - - countMap.forEach((slot, count) -> { - boolean success = false; - int retry = 0; - - while (!success && retry < 3) { - try { - timeVoteStatQueryRepository.incrementOrInsert(period, slot, count); - success = true; - } catch (OptimisticLockException e) { - retry++; - // 현재 version 로그는 트랜잭션 커밋 시점에 확인 필요 - log.warn("[TimeVoteStatService] 낙관적 락 충돌 발생: periodId={}, slot={}, count={}, 재시도 {}/3, 원인={}", - period.getPeriodId(), slot, count, retry, e.getMessage()); // 통계 동시성 충돌 시 재시도 (최대 3회) - try { - Thread.sleep(10L); - } catch (InterruptedException e2) { - Thread.currentThread().interrupt(); - throw new BusinessException(TIME_VOTE_THREAD_INTERRUPTED); - } - } - } - - if (!success) { - failedSlots.add(slot); // 실패한 slot 따로 저장 - } - }); - - if (!failedSlots.isEmpty()) { - log.error("[TimeVoteStatService] 통계 반영 실패: periodId={}, 실패 slot 목록={}", periodId, failedSlots); - throw new BusinessException(TIME_VOTE_STAT_CONFLICT); - } - } - public void recalculateStats(Long periodId) { log.info("[TimeVoteStatService] 투표 통계 재계산 시작: periodId={}", periodId); - try { - synchronized (("recalc-lock:" + periodId).intern()) { - TimeVotePeriod period = timeVoteStatValidator.getValidTimeVotePeriodByPeriodId(periodId); - - timeVoteStatRepository.deleteByPeriod(period); + TimeVotePeriod period = timeVoteStatValidator.getValidTimeVotePeriodByPeriodId(periodId); - List stats = timeVoteStatQueryRepository.countStatsByPeriod(period); + timeVoteStatRepository.softDeleteByPeriod(period); - timeVoteStatRepository.saveAll(stats); + List stats = timeVoteStatQueryRepository.countStatsByPeriod(period); - log.info("[TimeVoteStatService] 통계 재계산 완료: 저장된 slot 수 = {}", stats.size()); + try { + for (TimeVoteStat stat : stats) { + timeVoteStatRepository.bulkUpsertStat(stat.getPeriod().getPeriodId(), stat.getTimeSlot(), stat.getVoteCount()); } - } catch (Exception e) { - log.error("[TimeVoteStatService] 통계 재계산 실패: periodId={}, 원인={}", periodId, e.getMessage(), e); + log.info("[TimeVoteStatService] 투표 통계 재계산 완료: periodId={}, 총 {}개의 슬롯", periodId, stats.size()); + } catch (DataAccessException | PersistenceException e) { + log.error("[TimeVoteStatService] 통계 upsert 실패: periodId={}, 원인={}", periodId, e.getMessage(), + e); throw new BusinessException(TIME_VOTE_STAT_FATAL); } } diff --git a/src/main/java/grep/neogulcoder/domain/timevote/service/stat/TimeVoteStatValidator.java b/src/main/java/grep/neogulcoder/domain/timevote/service/stat/TimeVoteStatValidator.java index c4d090ea..d43dbe99 100644 --- a/src/main/java/grep/neogulcoder/domain/timevote/service/stat/TimeVoteStatValidator.java +++ b/src/main/java/grep/neogulcoder/domain/timevote/service/stat/TimeVoteStatValidator.java @@ -53,7 +53,7 @@ private StudyMember getValidStudyMember(Long studyId, Long userId) { } private TimeVotePeriod getValidTimeVotePeriodByStudyId(Long studyId) { - return timeVotePeriodRepository.findTopByStudyIdOrderByStartDateDesc(studyId) + return timeVotePeriodRepository.findTopByStudyIdAndActivatedTrueOrderByStartDateDesc(studyId) .orElseThrow(() -> new BusinessException(TIME_VOTE_PERIOD_NOT_FOUND)); } } diff --git a/src/main/java/grep/neogulcoder/domain/timevote/service/vote/TimeVoteCleanupScheduler.java b/src/main/java/grep/neogulcoder/domain/timevote/service/vote/TimeVoteCleanupScheduler.java new file mode 100644 index 00000000..157e9c68 --- /dev/null +++ b/src/main/java/grep/neogulcoder/domain/timevote/service/vote/TimeVoteCleanupScheduler.java @@ -0,0 +1,70 @@ +package grep.neogulcoder.domain.timevote.service.vote; + +import grep.neogulcoder.domain.timevote.service.stat.TimeVoteStatService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@EnableScheduling +@RequiredArgsConstructor +public class TimeVoteCleanupScheduler { + + private final JdbcTemplate jdbcTemplate; + private final TimeVoteStatService timeVoteStatService; + + // 매일 새벽 3시: soft delete 된 투표 실제 삭제 + @Scheduled(cron = "0 0 3 * * ?") + public void hardDeleteDeactivatedVotes() { + log.info("[Scheduler] Soft 삭제된 데이터 정리 시작"); + + try { + // 1. 통계 재계산 대상 조회 + List periodIds = jdbcTemplate.queryForList( + "SELECT DISTINCT period_id FROM time_vote WHERE activated = false", Long.class + ); + + // // 2. 통계 재계산 + for (Long periodId : periodIds) { + try { + timeVoteStatService.recalculateStats(periodId); + } catch (Exception e) { + log.error("[Scheduler] 통계 재계산 실패 - periodId={}, error={}", periodId, e.getMessage(), e); + } + } + + log.info("[Scheduler] 통계 재계산 완료 - 총 {}건", periodIds.size()); + + } catch (Exception e) { + log.error("[Scheduler] 통계 재계산 단계 실패: {}", e.getMessage(), e); + } + // 3. 실제 삭제 + try { + int deletedVotes = jdbcTemplate.update("DELETE FROM time_vote WHERE activated = false"); + log.info("[Scheduler] Soft 삭제된 투표 {}건 실제 삭제 완료", deletedVotes); + } catch (Exception e) { + log.error("[Scheduler] 투표 삭제 실패: {}", e.getMessage(), e); + } + + try { + int deletedStats = jdbcTemplate.update("DELETE FROM time_vote_stat WHERE activated = false"); + log.info("[Scheduler] Soft 삭제된 통계 {}건 실제 삭제 완료", deletedStats); + } catch (Exception e) { + log.error("[Scheduler] 통계 삭제 실패: {}", e.getMessage(), e); + } + + try { + int deletedPeriods = jdbcTemplate.update("DELETE FROM time_vote_period WHERE activated = false"); + log.info("[Scheduler] Soft 삭제된 투표 기간 {}건 실제 삭제 완료", deletedPeriods); + } catch (Exception e) { + log.error("[Scheduler] 투표 기간 삭제 실패: {}", e.getMessage(), e); + } + + log.info("[Scheduler] Soft 삭제된 데이터 정리 작업 종료"); + } +} diff --git a/src/main/java/grep/neogulcoder/domain/timevote/service/vote/TimeVoteService.java b/src/main/java/grep/neogulcoder/domain/timevote/service/vote/TimeVoteService.java index e65e1ed3..343ff40f 100644 --- a/src/main/java/grep/neogulcoder/domain/timevote/service/vote/TimeVoteService.java +++ b/src/main/java/grep/neogulcoder/domain/timevote/service/vote/TimeVoteService.java @@ -10,12 +10,16 @@ import grep.neogulcoder.domain.timevote.repository.TimeVoteQueryRepository; import grep.neogulcoder.domain.timevote.service.TimeVoteMapper; import grep.neogulcoder.domain.timevote.service.stat.TimeVoteStatService; +import jakarta.persistence.EntityManager; +import java.time.LocalDateTime; import java.util.List; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service +@Slf4j @Transactional @RequiredArgsConstructor public class TimeVoteService { @@ -25,64 +29,80 @@ public class TimeVoteService { private final TimeVoteStatService timeVoteStatService; private final TimeVoteMapper timeVoteMapper; private final TimeVoteValidator timeVoteValidator; + private final EntityManager em; @Transactional(readOnly = true) public TimeVoteResponse getMyVotes(Long studyId, Long userId) { + log.info("[TimeVote] 사용자 투표 조회 시작 - studyId={}, userId={}", studyId, userId); // 투표 전 필요한 모든 유효성 검증 및 도메인 정보 캡슐화 TimeVoteContext context = timeVoteValidator.getContext(studyId, userId); - List votes = timeVoteRepository.findByPeriodAndStudyMemberId(context.period(), + + List votes = timeVoteRepository.findByPeriodAndStudyMemberIdAndActivatedTrue(context.period(), context.studyMember().getId()); + log.info("[TimeVote] 사용자 ID={}의 투표 {}건 조회 완료", context.studyMember().getId(), votes.size()); return TimeVoteResponse.from(context.studyMember().getId(), votes); } public TimeVoteResponse submitVotes(TimeVoteCreateRequest request, Long studyId, Long userId) { - // 투표 전 필요한 모든 유효성 검증 및 도메인 정보 캡슐화 TimeVoteContext context = timeVoteValidator.getSubmitContext(studyId, userId, request.getTimeSlots()); - - List votes = timeVoteMapper.toEntities(request, context.period(), context.studyMember().getId()); - timeVoteRepository.saveAll(votes); - - // 통계 반영 - timeVoteStatService.incrementStats(context.period().getPeriodId(), request.getTimeSlots()); - - List saved = timeVoteRepository.findByPeriodAndStudyMemberId(context.period(), - context.studyMember().getId()); - - return TimeVoteResponse.from(context.studyMember().getId(), saved); + return executeVoteSubmission(context, request.getTimeSlots()); } public TimeVoteResponse updateVotes(TimeVoteUpdateRequest request, Long studyId, Long userId) { - // 투표 전 필요한 모든 유효성 검증 및 도메인 정보 캡슐화 TimeVoteContext context = timeVoteValidator.getUpdateContext(studyId, userId, request.getTimeSlots()); - - timeVoteRepository.deleteAllByPeriodAndStudyMemberId(context.period(), context.studyMember().getId()); - - List newVotes = timeVoteMapper.toEntities(request, context.period(), context.studyMember().getId()); - timeVoteRepository.saveAll(newVotes); - - // 통계 재반영 - timeVoteStatService.recalculateStats(context.period().getPeriodId()); - - List saved = timeVoteRepository.findByPeriodAndStudyMemberId(context.period(), - context.studyMember().getId()); - - return TimeVoteResponse.from(context.studyMember().getId(), saved); + return executeVoteSubmission(context, request.getTimeSlots()); } public void deleteAllVotes(Long studyId, Long userId) { // 투표 삭제 전 필요한 모든 유효성 검증 및 도메인 정보 캡슐화 TimeVoteContext context = timeVoteValidator.getContext(studyId, userId); - timeVoteRepository.deleteAllByPeriodAndStudyMemberId(context.period(), context.studyMember().getId()); + long start = System.currentTimeMillis(); + log.info("[TimeVote] 전체 투표 삭제 시작 - studyId={}, userId={}", studyId, userId); + + timeVoteRepository.deactivateByPeriodAndStudyMember(context.period(), context.studyMember().getId()); + log.info("[TimeVote] 삭제 완료 - {}ms 소요", System.currentTimeMillis() - start); + + start = System.currentTimeMillis(); + log.info("[TimeVote] 통계 재계산 시작 - periodId={}", context.period().getPeriodId()); timeVoteStatService.recalculateStats(context.period().getPeriodId()); + log.info("[TimeVote] 통계 재계산 완료 - {}ms 소요", System.currentTimeMillis() - start); } public List getSubmissionStatusList(Long studyId, Long userId) { + log.info("[TimeVote] 제출 현황 조회 시작 - studyId={}, userId={}", studyId, userId); // 사용자 제출 확인 전 필요한 모든 유효성 검증 및 도메인 정보 캡슐화 TimeVoteContext context = timeVoteValidator.getContext(studyId, userId); - return timeVoteQueryRepository.findSubmissionStatuses(studyId, context.period().getPeriodId()); + List result = + timeVoteQueryRepository.findSubmissionStatuses(studyId, context.period().getPeriodId()); + + log.info("[TimeVote] 제출 현황 조회 완료 - 총 {}명", result.size()); + return result; + } + + private TimeVoteResponse executeVoteSubmission(TimeVoteContext context, List timeSlots) { + long totalStart = System.currentTimeMillis(); + + timeVoteRepository.deactivateByPeriodAndStudyMember(context.period(), context.studyMember().getId()); + em.flush(); + log.info("[TimeVote] 기존 투표 soft delete 완료"); + + List newVotes = timeVoteMapper.toEntities(timeSlots, context.period(), context.studyMember().getId()); + timeVoteRepository.saveAll(newVotes); + log.info("[TimeVote] 새 투표 저장 완료 - {}건", newVotes.size()); + + timeVoteStatService.recalculateStats(context.period().getPeriodId()); + log.info("[TimeVote] 통계 재계산 완료"); + + List saved = timeVoteRepository.findByPeriodAndStudyMemberIdAndActivatedTrue( + context.period(), context.studyMember().getId()); + + log.info("[TimeVote] 저장된 투표 재조회 완료 - {}건", saved.size()); + log.info("[TimeVote] 전체 완료 - 총 {}ms", System.currentTimeMillis() - totalStart); + + return TimeVoteResponse.from(context.studyMember().getId(), saved); } } diff --git a/src/main/java/grep/neogulcoder/domain/timevote/service/vote/TimeVoteValidator.java b/src/main/java/grep/neogulcoder/domain/timevote/service/vote/TimeVoteValidator.java index d1bbcc5e..957ccb08 100644 --- a/src/main/java/grep/neogulcoder/domain/timevote/service/vote/TimeVoteValidator.java +++ b/src/main/java/grep/neogulcoder/domain/timevote/service/vote/TimeVoteValidator.java @@ -84,7 +84,7 @@ private StudyMember getValidStudyMember(Long studyId, Long userId) { } private TimeVotePeriod getValidTimeVotePeriod(Long studyId) { - return timeVotePeriodRepository.findTopByStudyIdOrderByStartDateDesc(studyId) + return timeVotePeriodRepository.findTopByStudyIdAndActivatedTrueOrderByStartDateDesc(studyId) .orElseThrow(() -> new BusinessException(TIME_VOTE_PERIOD_NOT_FOUND)); } @@ -117,7 +117,7 @@ private void validateNotExpired(TimeVotePeriod period) { } private void validateNotAlreadySubmitted(TimeVotePeriod period, Long studyMemberId) { - boolean alreadySubmitted = timeVoteRepository.existsByPeriodAndStudyMemberId(period, + boolean alreadySubmitted = timeVoteRepository.existsByPeriodAndStudyMemberIdAndActivatedTrue(period, studyMemberId); if (alreadySubmitted) { throw new BusinessException(TIME_VOTE_ALREADY_SUBMITTED); @@ -125,7 +125,7 @@ private void validateNotAlreadySubmitted(TimeVotePeriod period, Long studyMember } private void validateAlreadySubmitted(TimeVotePeriod period, Long studyMemberId) { - boolean alreadySubmitted = timeVoteRepository.existsByPeriodAndStudyMemberId(period, + boolean alreadySubmitted = timeVoteRepository.existsByPeriodAndStudyMemberIdAndActivatedTrue(period, studyMemberId); if (!alreadySubmitted) { throw new BusinessException(TIME_VOTE_NOT_FOUND); diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index 270c6707..544555d7 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -110,16 +110,16 @@ INSERT INTO time_vote (period_id, study_member_id, time_slot, activated) VALUES INSERT INTO time_vote (period_id, study_member_id, time_slot, activated) VALUES (1, 8, '2025-07-29 20:00:00', TRUE); -- [ time_vote_stat ] -INSERT INTO time_vote_stat (period_id, time_slot, vote_count, activated, version) VALUES (1, '2025-07-25 10:00:00', 2, TRUE, 0); -INSERT INTO time_vote_stat (period_id, time_slot, vote_count, activated, version) VALUES (1, '2025-07-25 11:00:00', 2, TRUE, 0); -INSERT INTO time_vote_stat (period_id, time_slot, vote_count, activated, version) VALUES (1, '2025-07-26 14:00:00', 3, TRUE, 0); -INSERT INTO time_vote_stat (period_id, time_slot, vote_count, activated, version) VALUES (1, '2025-07-26 15:00:00', 3, TRUE, 0); -INSERT INTO time_vote_stat (period_id, time_slot, vote_count, activated, version) VALUES (1, '2025-07-27 09:00:00', 1, TRUE, 0); -INSERT INTO time_vote_stat (period_id, time_slot, vote_count, activated, version) VALUES (1, '2025-07-27 13:00:00', 1, TRUE, 0); -INSERT INTO time_vote_stat (period_id, time_slot, vote_count, activated, version) VALUES (1, '2025-07-28 13:00:00', 3, TRUE, 0); -INSERT INTO time_vote_stat (period_id, time_slot, vote_count, activated, version) VALUES (1, '2025-07-28 14:00:00', 3, TRUE, 0); -INSERT INTO time_vote_stat (period_id, time_slot, vote_count, activated, version) VALUES (1, '2025-07-29 19:00:00', 2, TRUE, 0); -INSERT INTO time_vote_stat (period_id, time_slot, vote_count, activated, version) VALUES (1, '2025-07-29 20:00:00', 1, TRUE, 0); +INSERT INTO time_vote_stat (period_id, time_slot, vote_count, activated) VALUES (1, '2025-07-25 10:00:00', 2, TRUE); +INSERT INTO time_vote_stat (period_id, time_slot, vote_count, activated) VALUES (1, '2025-07-25 11:00:00', 2, TRUE); +INSERT INTO time_vote_stat (period_id, time_slot, vote_count, activated) VALUES (1, '2025-07-26 14:00:00', 3, TRUE); +INSERT INTO time_vote_stat (period_id, time_slot, vote_count, activated) VALUES (1, '2025-07-26 15:00:00', 3, TRUE); +INSERT INTO time_vote_stat (period_id, time_slot, vote_count, activated) VALUES (1, '2025-07-27 09:00:00', 1, TRUE); +INSERT INTO time_vote_stat (period_id, time_slot, vote_count, activated) VALUES (1, '2025-07-27 13:00:00', 1, TRUE); +INSERT INTO time_vote_stat (period_id, time_slot, vote_count, activated) VALUES (1, '2025-07-28 13:00:00', 3, TRUE); +INSERT INTO time_vote_stat (period_id, time_slot, vote_count, activated) VALUES (1, '2025-07-28 14:00:00', 3, TRUE); +INSERT INTO time_vote_stat (period_id, time_slot, vote_count, activated) VALUES (1, '2025-07-29 19:00:00', 2, TRUE); +INSERT INTO time_vote_stat (period_id, time_slot, vote_count, activated) VALUES (1, '2025-07-29 20:00:00', 1, TRUE); -- [ alarm ] -- 1. 스터디 초대 알림 diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql new file mode 100644 index 00000000..c6e861ab --- /dev/null +++ b/src/main/resources/schema.sql @@ -0,0 +1,8 @@ +-- 시간 투표 통계 테이블에서 기간(period_id) + 시간 슬롯(time_slot) 조합의 중복을 방지하고 +-- upsert 시 ON CONFLICT 조건을 성능 저하 없이 처리하기 위한 복합 유니크 인덱스 +CREATE UNIQUE INDEX uq_time_vote_stat_period_slot ON time_vote_stat (period_id, time_slot); + +-- 시간 투표 테이블에서 기간 및 사용자 기준으로 빠른 조인을 위해 인덱스 추가 +-- 사용자 기준 삭제 성능 향상을 위해 추가 +-- getSubmissionStatusList() 쿼리의 성능 최적화 목적 +CREATE INDEX idx_time_vote_period_member ON time_vote (period_id, study_member_id);