From 87a6adc576654f474fe6bce9d00c19d6209a6ab9 Mon Sep 17 00:00:00 2001 From: pia01190 Date: Wed, 23 Jul 2025 21:45:12 +0900 Subject: [PATCH 01/21] =?UTF-8?q?[EA3-168]=20feature:=20=EB=AA=A8=EC=A7=91?= =?UTF-8?q?=EA=B8=80=20=EC=8B=A0=EC=B2=AD=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/RecruitmentPostController.java | 8 ---- .../RecruitmentPostSpecification.java | 29 ------------ .../RecruitmentApplicationPagingInfo.java | 36 --------------- .../controller/ApplicationController.java | 8 ++++ .../controller/ApplicationSpecification.java | 4 ++ .../ReceivedApplicationPagingResponse.java | 45 +++++++++++++++++++ .../response/ReceivedApplicationResponse.java | 35 +++++++++++++++ .../ApplicationQueryRepository.java | 31 +++++++++++++ .../service/ApplicationService.java | 26 +++++++++-- 9 files changed, 145 insertions(+), 77 deletions(-) delete mode 100644 src/main/java/grep/neogulcoder/domain/recruitment/post/controller/dto/response/RecruitmentApplicationPagingInfo.java create mode 100644 src/main/java/grep/neogulcoder/domain/studyapplication/controller/dto/response/ReceivedApplicationPagingResponse.java create mode 100644 src/main/java/grep/neogulcoder/domain/studyapplication/controller/dto/response/ReceivedApplicationResponse.java diff --git a/src/main/java/grep/neogulcoder/domain/recruitment/post/controller/RecruitmentPostController.java b/src/main/java/grep/neogulcoder/domain/recruitment/post/controller/RecruitmentPostController.java index e36054e3..3e04629a 100644 --- a/src/main/java/grep/neogulcoder/domain/recruitment/post/controller/RecruitmentPostController.java +++ b/src/main/java/grep/neogulcoder/domain/recruitment/post/controller/RecruitmentPostController.java @@ -2,7 +2,6 @@ import grep.neogulcoder.domain.recruitment.post.controller.dto.request.RecruitmentPostStatusUpdateRequest; import grep.neogulcoder.domain.recruitment.post.controller.dto.request.RecruitmentPostUpdateRequest; -import grep.neogulcoder.domain.recruitment.post.controller.dto.response.RecruitmentApplicationPagingInfo; import grep.neogulcoder.domain.recruitment.post.controller.dto.response.RecruitmentPostInfo; import grep.neogulcoder.domain.recruitment.post.controller.dto.response.RecruitmentPostPagingInfo; import grep.neogulcoder.domain.recruitment.post.service.RecruitmentPostService; @@ -76,11 +75,4 @@ public ApiResponse changeStatus(@PathVariable("recruitment-post-id") long long postId = recruitmentPostService.updateStatus(request.toServiceRequest(), recruitmentPostId, userDetails.getUserId()); return ApiResponse.success(postId); } - - @GetMapping("{recruitment-post-id}/applications") - public ApiResponse getApplications(@PageableDefault(size = 5) Pageable pageable, - @PathVariable("recruitment-post-id") long recruitmentPostId) { - return ApiResponse.success(new RecruitmentApplicationPagingInfo()); - } - } diff --git a/src/main/java/grep/neogulcoder/domain/recruitment/post/controller/RecruitmentPostSpecification.java b/src/main/java/grep/neogulcoder/domain/recruitment/post/controller/RecruitmentPostSpecification.java index c479b207..e6a2dd93 100644 --- a/src/main/java/grep/neogulcoder/domain/recruitment/post/controller/RecruitmentPostSpecification.java +++ b/src/main/java/grep/neogulcoder/domain/recruitment/post/controller/RecruitmentPostSpecification.java @@ -2,7 +2,6 @@ import grep.neogulcoder.domain.recruitment.post.controller.dto.request.RecruitmentPostStatusUpdateRequest; import grep.neogulcoder.domain.recruitment.post.controller.dto.request.RecruitmentPostUpdateRequest; -import grep.neogulcoder.domain.recruitment.post.controller.dto.response.RecruitmentApplicationPagingInfo; import grep.neogulcoder.domain.recruitment.post.controller.dto.response.RecruitmentPostInfo; import grep.neogulcoder.domain.recruitment.post.controller.dto.response.RecruitmentPostPagingInfo; import grep.neogulcoder.domain.study.enums.Category; @@ -144,32 +143,4 @@ ApiResponse getMyPostPagingInfo(Pageable pageable, Ca """ ) ApiResponse getPagingInfo(Pageable pageable, Category category, StudyType studyType, String keyword); - - @Operation( - summary = "스터디 신청한 회원 목록 페이징 조회", - description = """ - 특정 모집글에 신청한 회원 목록을 페이징하여 조회합니다. - - ✅ 요청 형식: - `GET /recruitment-posts/{id}/applications?page=0&size=5` - - ✅ 응답 예시: - ```json - { - "applicationInfos": [ - { - "nickname": "테스터", - "buddyEnergy": 30, - "createdDate": "2025-07-13T15:30:00", - "applicationReason": "자바를 더 공부 하고싶어요!" - } - ], - "totalPage": 3, - "totalElementCount": 20 - } - ``` - """ - ) - ApiResponse getApplications(Pageable pageable, long recruitmentPostId); - } diff --git a/src/main/java/grep/neogulcoder/domain/recruitment/post/controller/dto/response/RecruitmentApplicationPagingInfo.java b/src/main/java/grep/neogulcoder/domain/recruitment/post/controller/dto/response/RecruitmentApplicationPagingInfo.java deleted file mode 100644 index 066ce80d..00000000 --- a/src/main/java/grep/neogulcoder/domain/recruitment/post/controller/dto/response/RecruitmentApplicationPagingInfo.java +++ /dev/null @@ -1,36 +0,0 @@ -package grep.neogulcoder.domain.recruitment.post.controller.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Getter; - -import java.time.LocalDateTime; -import java.util.List; - -@Getter -public class RecruitmentApplicationPagingInfo { - - @Schema(example = "[ {nickname: 테스터, buddyEnergy: 30, createdDate: 2025-07-13, applicationReason: 자바를 더 공부 하고싶어요!} ]") - private List applicationInfos; - - @Schema(example = "3", description = "총 페이지 수") - private int totalPage; - - @Schema(example = "20", description = "총 요소 개수") - private int totalElementCount; - - @Getter - static class ApplicationInfo{ - - @Schema(description = "신청자 닉네임", example = "너굴") - private String nickname; - - @Schema(description = "신청자 버디에너지", example = "30") - private int buddyEnergy; - - @Schema(description = "신청 날짜", example = "2025-07-10T15:30:00") - private LocalDateTime createdDate; - - @Schema(description = "스터디 신청 지원 동기", example = "자바를 더 공부하고싶어 지원하였습니다.") - private String applicationReason; - } -} diff --git a/src/main/java/grep/neogulcoder/domain/studyapplication/controller/ApplicationController.java b/src/main/java/grep/neogulcoder/domain/studyapplication/controller/ApplicationController.java index 122b4659..89d14376 100644 --- a/src/main/java/grep/neogulcoder/domain/studyapplication/controller/ApplicationController.java +++ b/src/main/java/grep/neogulcoder/domain/studyapplication/controller/ApplicationController.java @@ -3,6 +3,7 @@ import grep.neogulcoder.domain.studyapplication.ApplicationStatus; import grep.neogulcoder.domain.studyapplication.controller.dto.request.ApplicationCreateRequest; import grep.neogulcoder.domain.studyapplication.controller.dto.response.MyApplicationPagingResponse; +import grep.neogulcoder.domain.studyapplication.controller.dto.response.ReceivedApplicationPagingResponse; import grep.neogulcoder.domain.studyapplication.service.ApplicationService; import grep.neogulcoder.global.auth.Principal; import grep.neogulcoder.global.response.ApiResponse; @@ -21,6 +22,13 @@ public class ApplicationController implements ApplicationSpecification { private final ApplicationService applicationService; @GetMapping("/{recruitment-post-id}/applications") + public ApiResponse getReceivedApplications(@PathVariable("recruitment-post-id") Long recruitmentPostId, + @PageableDefault(size = 12) Pageable pageable, + @AuthenticationPrincipal Principal userDetails) { + return ApiResponse.success(applicationService.getReceivedApplicationsPaging(recruitmentPostId, pageable, userDetails.getUserId())); + } + + @GetMapping("/applications") public ApiResponse getMyStudyApplications(@PathVariable("recruitment-post-id") Long recruitmentPostId, @PageableDefault(size = 12) Pageable pageable, @RequestParam(required = false) ApplicationStatus status, diff --git a/src/main/java/grep/neogulcoder/domain/studyapplication/controller/ApplicationSpecification.java b/src/main/java/grep/neogulcoder/domain/studyapplication/controller/ApplicationSpecification.java index 9115befb..f6c4a9a9 100644 --- a/src/main/java/grep/neogulcoder/domain/studyapplication/controller/ApplicationSpecification.java +++ b/src/main/java/grep/neogulcoder/domain/studyapplication/controller/ApplicationSpecification.java @@ -3,6 +3,7 @@ import grep.neogulcoder.domain.studyapplication.ApplicationStatus; import grep.neogulcoder.domain.studyapplication.controller.dto.request.ApplicationCreateRequest; import grep.neogulcoder.domain.studyapplication.controller.dto.response.MyApplicationPagingResponse; +import grep.neogulcoder.domain.studyapplication.controller.dto.response.ReceivedApplicationPagingResponse; import grep.neogulcoder.global.auth.Principal; import grep.neogulcoder.global.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; @@ -12,6 +13,9 @@ @Tag(name = "StudyApplication", description = "스터디 신청 API") public interface ApplicationSpecification { + @Operation(summary = "모집글 신청 목록 조회", description = "스터디장(모집글 작성자)이 신청 목록을 조회합니다.") + public ApiResponse getReceivedApplications(Long recruitmentPostId, Pageable pageable, Principal userDetails); + @Operation(summary = "내 스터디 신청 목록 조회", description = "내가 신청한 스터디의 목록을 조회합니다.") ApiResponse getMyStudyApplications(Long recruitmentPostId, Pageable pageable, ApplicationStatus status, Principal userDetails); diff --git a/src/main/java/grep/neogulcoder/domain/studyapplication/controller/dto/response/ReceivedApplicationPagingResponse.java b/src/main/java/grep/neogulcoder/domain/studyapplication/controller/dto/response/ReceivedApplicationPagingResponse.java new file mode 100644 index 00000000..f78a1fb4 --- /dev/null +++ b/src/main/java/grep/neogulcoder/domain/studyapplication/controller/dto/response/ReceivedApplicationPagingResponse.java @@ -0,0 +1,45 @@ +package grep.neogulcoder.domain.studyapplication.controller.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.domain.Page; + +import java.util.List; + +@Getter +public class ReceivedApplicationPagingResponse { + + @Schema( + description = "내 모집글에 지원한 신청 목록", + example = "[{" + + "\"applicationId\": 1," + + "\"nickname\": \"너굴\"," + + "\"buddyEnergy\": 30," + + "\"createdDate\": \"2025-07-10T15:30:00\"," + + "\"applicationReason\": \"자바를 더 공부하고 싶어 지원합니다.\"" + + "}]" + ) + private List receivedApplications; + + @Schema(description = "총 페이지 수", example = "2") + private int totalPage; + + @Schema(description = "총 요소 개수", example = "10") + private int totalElementCount; + + @Schema(description = "다음 페이지 여부", example = "false") + private boolean hasNext; + + @Builder + private ReceivedApplicationPagingResponse(Page page) { + this.receivedApplications = page.getContent(); + this.totalPage = page.getTotalPages(); + this.totalElementCount = (int) page.getTotalElements(); + this.hasNext = page.hasNext(); + } + + public static ReceivedApplicationPagingResponse of(Page page) { + return new ReceivedApplicationPagingResponse(page); + } +} diff --git a/src/main/java/grep/neogulcoder/domain/studyapplication/controller/dto/response/ReceivedApplicationResponse.java b/src/main/java/grep/neogulcoder/domain/studyapplication/controller/dto/response/ReceivedApplicationResponse.java new file mode 100644 index 00000000..96abd3ec --- /dev/null +++ b/src/main/java/grep/neogulcoder/domain/studyapplication/controller/dto/response/ReceivedApplicationResponse.java @@ -0,0 +1,35 @@ +package grep.neogulcoder.domain.studyapplication.controller.dto.response; + +import com.querydsl.core.annotations.QueryProjection; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class ReceivedApplicationResponse { + + @Schema(description = "신청 번호", example = "1") + private Long applicationId; + + @Schema(description = "신청자 닉네임", example = "너굴") + private String nickname; + + @Schema(description = "신청자 버디에너지", example = "30") + private int buddyEnergy; + + @Schema(description = "신청 날짜", example = "2025-07-10T15:30:00") + private LocalDateTime createdDate; + + @Schema(description = "스터디 신청 지원 동기", example = "자바를 더 공부하고 싶어 지원합니다.") + private String applicationReason; + + @QueryProjection + public ReceivedApplicationResponse(Long applicationId, String nickname, int buddyEnergy, LocalDateTime createdDate, String applicationReason) { + this.applicationId = applicationId; + this.nickname = nickname; + this.buddyEnergy = buddyEnergy; + this.createdDate = createdDate; + this.applicationReason = applicationReason; + } +} diff --git a/src/main/java/grep/neogulcoder/domain/studyapplication/repository/ApplicationQueryRepository.java b/src/main/java/grep/neogulcoder/domain/studyapplication/repository/ApplicationQueryRepository.java index 177ed982..14525a37 100644 --- a/src/main/java/grep/neogulcoder/domain/studyapplication/repository/ApplicationQueryRepository.java +++ b/src/main/java/grep/neogulcoder/domain/studyapplication/repository/ApplicationQueryRepository.java @@ -6,6 +6,8 @@ import grep.neogulcoder.domain.studyapplication.ApplicationStatus; import grep.neogulcoder.domain.studyapplication.controller.dto.response.MyApplicationResponse; import grep.neogulcoder.domain.studyapplication.controller.dto.response.QMyApplicationResponse; +import grep.neogulcoder.domain.studyapplication.controller.dto.response.QReceivedApplicationResponse; +import grep.neogulcoder.domain.studyapplication.controller.dto.response.ReceivedApplicationResponse; import jakarta.persistence.EntityManager; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; @@ -21,6 +23,7 @@ import static grep.neogulcoder.domain.study.enums.StudyMemberRole.LEADER; import static grep.neogulcoder.domain.studyapplication.QStudyApplication.studyApplication; import static grep.neogulcoder.domain.users.entity.QUser.user; +import static grep.neogulcoder.domain.buddy.entity.QBuddyEnergy.buddyEnergy; @Repository public class ApplicationQueryRepository { @@ -31,6 +34,34 @@ public ApplicationQueryRepository(EntityManager em) { this.queryFactory = new JPAQueryFactory(em); } + public Page findReceivedApplicationsPaging(Long recruitmentPostId, Pageable pageable) { + List receivedApplications = queryFactory + .select(new QReceivedApplicationResponse( + studyApplication.id, + user.nickname, + buddyEnergy.level, + studyApplication.createdDate, + studyApplication.applicationReason + )) + .from(studyApplication) + .join(user).on(studyApplication.userId.eq(user.id)) + .join(buddyEnergy).on(user.id.eq(buddyEnergy.userId)) + .where(studyApplication.recruitmentPostId.eq(recruitmentPostId)) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = queryFactory + .select(studyApplication.count()) + .from(studyApplication) + .join(user).on(studyApplication.userId.eq(user.id)) + .join(buddyEnergy).on(user.id.eq(buddyEnergy.userId)) + .where(studyApplication.recruitmentPostId.eq(recruitmentPostId)) + .fetchOne(); + + return new PageImpl<>(receivedApplications, pageable, total == null ? 0 : total); + } + public Page findMyStudyApplicationsPaging(Pageable pageable, Long userId, ApplicationStatus status) { List applications = queryFactory .select(new QMyApplicationResponse( diff --git a/src/main/java/grep/neogulcoder/domain/studyapplication/service/ApplicationService.java b/src/main/java/grep/neogulcoder/domain/studyapplication/service/ApplicationService.java index 0a219e9d..df6439b3 100644 --- a/src/main/java/grep/neogulcoder/domain/studyapplication/service/ApplicationService.java +++ b/src/main/java/grep/neogulcoder/domain/studyapplication/service/ApplicationService.java @@ -12,6 +12,8 @@ import grep.neogulcoder.domain.studyapplication.controller.dto.request.ApplicationCreateRequest; import grep.neogulcoder.domain.studyapplication.controller.dto.response.MyApplicationPagingResponse; import grep.neogulcoder.domain.studyapplication.controller.dto.response.MyApplicationResponse; +import grep.neogulcoder.domain.studyapplication.controller.dto.response.ReceivedApplicationPagingResponse; +import grep.neogulcoder.domain.studyapplication.controller.dto.response.ReceivedApplicationResponse; import grep.neogulcoder.domain.studyapplication.repository.ApplicationQueryRepository; import grep.neogulcoder.domain.studyapplication.repository.ApplicationRepository; import grep.neogulcoder.global.exception.business.BusinessException; @@ -23,6 +25,7 @@ import org.springframework.transaction.annotation.Transactional; import static grep.neogulcoder.domain.recruitment.RecruitmentErrorCode.NOT_FOUND; +import static grep.neogulcoder.domain.recruitment.RecruitmentErrorCode.NOT_OWNER; import static grep.neogulcoder.domain.study.exception.code.StudyErrorCode.STUDY_NOT_FOUND; import static grep.neogulcoder.domain.studyapplication.exception.code.ApplicationErrorCode.*; @@ -37,6 +40,15 @@ public class ApplicationService { private final StudyMemberRepository studyMemberRepository; private final StudyRepository studyRepository; + public ReceivedApplicationPagingResponse getReceivedApplicationsPaging(Long recruitmentPostId, Pageable pageable, Long userId) { + RecruitmentPost recruitmentPost = findValidRecruitmentPost(recruitmentPostId); + + validateOwner(userId, recruitmentPost); + + Page page = applicationQueryRepository.findReceivedApplicationsPaging(recruitmentPostId, pageable); + return ReceivedApplicationPagingResponse.of(page); + } + public MyApplicationPagingResponse getMyStudyApplicationsPaging(Pageable pageable, Long userId, ApplicationStatus status) { Page page = applicationQueryRepository.findMyStudyApplicationsPaging(pageable, userId, status); return MyApplicationPagingResponse.of(page); @@ -44,7 +56,7 @@ public MyApplicationPagingResponse getMyStudyApplicationsPaging(Pageable pageabl @Transactional public Long createApplication(Long recruitmentPostId, ApplicationCreateRequest request, Long userId) { - RecruitmentPost recruitmentPost = findValidRecruimentPost(recruitmentPostId); + RecruitmentPost recruitmentPost = findValidRecruitmentPost(recruitmentPostId); validateNotLeaderApply(recruitmentPost, userId); validateNotAlreadyApplied(recruitmentPostId, userId); @@ -58,7 +70,7 @@ public Long createApplication(Long recruitmentPostId, ApplicationCreateRequest r @Transactional public void approveApplication(Long applicationId, Long userId) { StudyApplication application = findValidApplication(applicationId); - RecruitmentPost post = findValidRecruimentPost(application.getRecruitmentPostId()); + RecruitmentPost post = findValidRecruitmentPost(application.getRecruitmentPostId()); Study study = findValidStudy(post); validateOnlyLeaderCanApprove(study, userId); @@ -74,7 +86,7 @@ public void approveApplication(Long applicationId, Long userId) { @Transactional public void rejectApplication(Long applicationId, Long userId) { StudyApplication application = findValidApplication(applicationId); - RecruitmentPost post = findValidRecruimentPost(application.getRecruitmentPostId()); + RecruitmentPost post = findValidRecruitmentPost(application.getRecruitmentPostId()); Study study = findValidStudy(post); validateOnlyLeaderCanReject(study, userId); @@ -95,12 +107,18 @@ private StudyApplication findValidApplication(Long applicationId) { return application; } - private RecruitmentPost findValidRecruimentPost(Long recruitmentPostId) { + private RecruitmentPost findValidRecruitmentPost(Long recruitmentPostId) { RecruitmentPost post = recruitmentPostRepository.findByIdAndActivatedTrue(recruitmentPostId) .orElseThrow(() -> new NotFoundException(NOT_FOUND)); return post; } + private static void validateOwner(Long userId, RecruitmentPost recruitmentPost) { + if (recruitmentPost.isNotOwnedBy(userId)) { + throw new BusinessException(NOT_OWNER); + } + } + private void validateNotLeaderApply(RecruitmentPost recruitmentPost, Long userId) { boolean isLeader = studyMemberRepository.existsByStudyIdAndUserIdAndRole(recruitmentPost.getStudyId(), userId, StudyMemberRole.LEADER); if (isLeader) { From b9f642ba387cf56d4d1845087de7953003e29527 Mon Sep 17 00:00:00 2001 From: pia01190 Date: Wed, 23 Jul 2025 21:46:25 +0900 Subject: [PATCH 02/21] =?UTF-8?q?[EA3-168]=20refactor:=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98=20=EA=B0=9C?= =?UTF-8?q?=EC=88=98=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../studyapplication/controller/ApplicationController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/grep/neogulcoder/domain/studyapplication/controller/ApplicationController.java b/src/main/java/grep/neogulcoder/domain/studyapplication/controller/ApplicationController.java index 89d14376..c6369407 100644 --- a/src/main/java/grep/neogulcoder/domain/studyapplication/controller/ApplicationController.java +++ b/src/main/java/grep/neogulcoder/domain/studyapplication/controller/ApplicationController.java @@ -23,7 +23,7 @@ public class ApplicationController implements ApplicationSpecification { @GetMapping("/{recruitment-post-id}/applications") public ApiResponse getReceivedApplications(@PathVariable("recruitment-post-id") Long recruitmentPostId, - @PageableDefault(size = 12) Pageable pageable, + @PageableDefault(size = 5) Pageable pageable, @AuthenticationPrincipal Principal userDetails) { return ApiResponse.success(applicationService.getReceivedApplicationsPaging(recruitmentPostId, pageable, userDetails.getUserId())); } From ab2971b2cf6e1aef465ae34723ed2928320e87cb Mon Sep 17 00:00:00 2001 From: pia01190 Date: Wed, 23 Jul 2025 21:52:01 +0900 Subject: [PATCH 03/21] =?UTF-8?q?[EA3-168]=20refactor:=20getMyStudyApplica?= =?UTF-8?q?tions=20=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../studyapplication/controller/ApplicationController.java | 3 +-- .../studyapplication/controller/ApplicationSpecification.java | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/grep/neogulcoder/domain/studyapplication/controller/ApplicationController.java b/src/main/java/grep/neogulcoder/domain/studyapplication/controller/ApplicationController.java index c6369407..a0f30793 100644 --- a/src/main/java/grep/neogulcoder/domain/studyapplication/controller/ApplicationController.java +++ b/src/main/java/grep/neogulcoder/domain/studyapplication/controller/ApplicationController.java @@ -29,8 +29,7 @@ public ApiResponse getReceivedApplications(@P } @GetMapping("/applications") - public ApiResponse getMyStudyApplications(@PathVariable("recruitment-post-id") Long recruitmentPostId, - @PageableDefault(size = 12) Pageable pageable, + public ApiResponse getMyStudyApplications(@PageableDefault(size = 12) Pageable pageable, @RequestParam(required = false) ApplicationStatus status, @AuthenticationPrincipal Principal userDetails) { return ApiResponse.success(applicationService.getMyStudyApplicationsPaging(pageable, userDetails.getUserId(), status)); diff --git a/src/main/java/grep/neogulcoder/domain/studyapplication/controller/ApplicationSpecification.java b/src/main/java/grep/neogulcoder/domain/studyapplication/controller/ApplicationSpecification.java index f6c4a9a9..06b8d1f7 100644 --- a/src/main/java/grep/neogulcoder/domain/studyapplication/controller/ApplicationSpecification.java +++ b/src/main/java/grep/neogulcoder/domain/studyapplication/controller/ApplicationSpecification.java @@ -17,7 +17,7 @@ public interface ApplicationSpecification { public ApiResponse getReceivedApplications(Long recruitmentPostId, Pageable pageable, Principal userDetails); @Operation(summary = "내 스터디 신청 목록 조회", description = "내가 신청한 스터디의 목록을 조회합니다.") - ApiResponse getMyStudyApplications(Long recruitmentPostId, Pageable pageable, ApplicationStatus status, Principal userDetails); + ApiResponse getMyStudyApplications(Pageable pageable, ApplicationStatus status, Principal userDetails); @Operation(summary = "스터디 신청 생성", description = "스터디를 신청합니다.") ApiResponse createApplication(Long recruitmentPostId, ApplicationCreateRequest request, Principal userDetails); From 5007f21753757a2afd67af7da019b9872461d71d Mon Sep 17 00:00:00 2001 From: pia01190 Date: Wed, 23 Jul 2025 22:28:34 +0900 Subject: [PATCH 04/21] =?UTF-8?q?[EA3-168]=20feature:=20=EC=8B=A0=EC=B2=AD?= =?UTF-8?q?=EC=84=9C=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20isRead=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../studyapplication/repository/ApplicationRepository.java | 7 +++++++ .../studyapplication/service/ApplicationService.java | 2 ++ 2 files changed, 9 insertions(+) diff --git a/src/main/java/grep/neogulcoder/domain/studyapplication/repository/ApplicationRepository.java b/src/main/java/grep/neogulcoder/domain/studyapplication/repository/ApplicationRepository.java index fa0490f9..80be1ca6 100644 --- a/src/main/java/grep/neogulcoder/domain/studyapplication/repository/ApplicationRepository.java +++ b/src/main/java/grep/neogulcoder/domain/studyapplication/repository/ApplicationRepository.java @@ -2,6 +2,9 @@ import grep.neogulcoder.domain.studyapplication.StudyApplication; 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; import java.util.List; import java.util.Optional; @@ -12,4 +15,8 @@ public interface ApplicationRepository extends JpaRepository findByIdAndActivatedTrue(Long applicationId); + + @Modifying(clearAutomatically = true) + @Query("update StudyApplication sa set sa.isRead = true where sa.recruitmentPostId = :recruitmentPostId and sa.isRead = false") + void markAllAsReadByRecruitmentPostId(@Param("recruitmentPostId") Long recruitmentPostId); } diff --git a/src/main/java/grep/neogulcoder/domain/studyapplication/service/ApplicationService.java b/src/main/java/grep/neogulcoder/domain/studyapplication/service/ApplicationService.java index df6439b3..d6bbdf77 100644 --- a/src/main/java/grep/neogulcoder/domain/studyapplication/service/ApplicationService.java +++ b/src/main/java/grep/neogulcoder/domain/studyapplication/service/ApplicationService.java @@ -40,10 +40,12 @@ public class ApplicationService { private final StudyMemberRepository studyMemberRepository; private final StudyRepository studyRepository; + @Transactional public ReceivedApplicationPagingResponse getReceivedApplicationsPaging(Long recruitmentPostId, Pageable pageable, Long userId) { RecruitmentPost recruitmentPost = findValidRecruitmentPost(recruitmentPostId); validateOwner(userId, recruitmentPost); + applicationRepository.markAllAsReadByRecruitmentPostId(recruitmentPostId); Page page = applicationQueryRepository.findReceivedApplicationsPaging(recruitmentPostId, pageable); return ReceivedApplicationPagingResponse.of(page); From 5af3398d8766f8bfc57535e83878019a4619e201 Mon Sep 17 00:00:00 2001 From: pia01190 Date: Thu, 24 Jul 2025 00:01:39 +0900 Subject: [PATCH 05/21] =?UTF-8?q?[EA3-161]=20refactor:=20=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=EB=94=94=20=EC=83=9D=EC=84=B1,=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20request=20imageUrl=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/StudyCreateRequest.java | 28 ++++++++----------- .../dto/request/StudyUpdateRequest.java | 8 ++---- 2 files changed, 14 insertions(+), 22 deletions(-) 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 0b3f018c..4d961223 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 @@ -35,7 +35,7 @@ public class StudyCreateRequest { private String location; @NotNull - @Schema(description = "시작일", example = "2025-07-15") + @Schema(description = "시작일", example = "2025-07-25T10:00:00") private LocalDateTime startDate; @NotNull @@ -45,14 +45,11 @@ public class StudyCreateRequest { @Schema(description = "스터디 소개", example = "자바 스터디입니다.") private String introduction; - @Schema(description = "대표 이미지", example = "http://localhost:8083/image.jpg") - private String imageUrl; - private StudyCreateRequest() {} @Builder private StudyCreateRequest(String name, Category category, int capacity, StudyType studyType, String location, - LocalDateTime startDate, LocalDateTime endDate, String introduction, String imageUrl) { + LocalDateTime startDate, LocalDateTime endDate, String introduction) { this.name = name; this.category = category; this.capacity = capacity; @@ -61,20 +58,19 @@ private StudyCreateRequest(String name, Category category, int capacity, StudyTy this.startDate = startDate; this.endDate = endDate; this.introduction = introduction; - this.imageUrl = imageUrl; } public Study toEntity(String imageUrl) { return Study.builder() - .name(this.name) - .category(this.category) - .capacity(this.capacity) - .studyType(this.studyType) - .location(this.location) - .startDate(this.startDate) - .endDate(this.endDate) - .introduction(this.introduction) - .imageUrl(imageUrl) - .build(); + .name(this.name) + .category(this.category) + .capacity(this.capacity) + .studyType(this.studyType) + .location(this.location) + .startDate(this.startDate) + .endDate(this.endDate) + .introduction(this.introduction) + .imageUrl(imageUrl) + .build(); } } 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 01145143..0a259f8d 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 @@ -38,14 +38,11 @@ public class StudyUpdateRequest { @Schema(description = "스터디 소개", example = "자바 스터디입니다.") private String introduction; - @Schema(description = "대표 이미지", example = "http://localhost:8083/image.jpg") - private String imageUrl; - private StudyUpdateRequest() {} @Builder - private StudyUpdateRequest(String name, Category category, int capacity, StudyType studyType, String location, - LocalDateTime startDate, String introduction, String imageUrl) { + private StudyUpdateRequest(String name, Category category, int capacity, StudyType studyType, + String location, LocalDateTime startDate, String introduction) { this.name = name; this.category = category; this.capacity = capacity; @@ -53,7 +50,6 @@ private StudyUpdateRequest(String name, Category category, int capacity, StudyTy this.location = location; this.startDate = startDate; this.introduction = introduction; - this.imageUrl = imageUrl; } public Study toEntity(String imageUrl) { From 4f0cdb84be6f738f2d5ec6d64d5361295583379d Mon Sep 17 00:00:00 2001 From: pia01190 Date: Thu, 24 Jul 2025 00:04:52 +0900 Subject: [PATCH 06/21] =?UTF-8?q?[EA3-161]=20refactor:=20=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=EB=94=94=20=EC=88=98=EC=A0=95=20mediatype=20=EC=A7=80?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../neogulcoder/domain/study/controller/StudyController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e968d2be..99145d49 100644 --- a/src/main/java/grep/neogulcoder/domain/study/controller/StudyController.java +++ b/src/main/java/grep/neogulcoder/domain/study/controller/StudyController.java @@ -77,7 +77,7 @@ public ApiResponse createStudy(@RequestPart("request") @Valid StudyCreateR return ApiResponse.success(id); } - @PutMapping("/{studyId}") + @PutMapping(value = "/{studyId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ApiResponse updateStudy(@PathVariable("studyId") Long studyId, @RequestPart @Valid StudyUpdateRequest request, @RequestPart(value = "image", required = false) MultipartFile image, From 64cbb0a19939478b77f36d5a3c66cb8550620231 Mon Sep 17 00:00:00 2001 From: pia01190 Date: Thu, 24 Jul 2025 01:21:45 +0900 Subject: [PATCH 07/21] =?UTF-8?q?[EA3-167]=20feature:=20=EC=8A=A4=ED=84=B0?= =?UTF-8?q?=EB=94=94=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/controller/StudyController.java | 2 +- .../dto/response/StudyResponse.java | 46 +++++++++++++---- .../domain/study/service/StudyService.java | 50 +++++++++++++++---- .../controller/dto/response/FreePostInfo.java | 33 ++++++++++++ .../repository/StudyPostQueryRepository.java | 23 ++++++++- .../repository/StudyPostRepository.java | 3 +- 6 files changed, 134 insertions(+), 23 deletions(-) create mode 100644 src/main/java/grep/neogulcoder/domain/studypost/controller/dto/response/FreePostInfo.java 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 e968d2be..ef30286b 100644 --- a/src/main/java/grep/neogulcoder/domain/study/controller/StudyController.java +++ b/src/main/java/grep/neogulcoder/domain/study/controller/StudyController.java @@ -46,7 +46,7 @@ public ApiResponse getStudyHeader(@PathVariable("studyId") @GetMapping("/{studyId}") public ApiResponse getStudy(@PathVariable("studyId") Long studyId) { - return ApiResponse.success(new StudyResponse()); + return ApiResponse.success(studyService.getStudy(studyId)); } @GetMapping("/me/images") 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 0199d6d2..94803c35 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 @@ -1,9 +1,11 @@ package grep.neogulcoder.domain.study.controller.dto.response; -import grep.neogulcoder.domain.attendance.controller.dto.response.AttendanceResponse; import grep.neogulcoder.domain.calender.controller.dto.response.TeamCalendarResponse; -import grep.neogulcoder.domain.studypost.controller.dto.StudyPostListResponse; +import grep.neogulcoder.domain.study.Study; +import grep.neogulcoder.domain.studypost.controller.dto.response.FreePostInfo; +import grep.neogulcoder.domain.studypost.controller.dto.response.NoticePostInfo; import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; import lombok.Getter; import java.util.List; @@ -26,15 +28,39 @@ public class StudyResponse { @Schema(description = "스터디 총 게시물 수", example = "10") private int totalPostCount; - @Schema(description = "출석 정보") - private List attendances; + @Schema(description = "팀 달력") + private List teamCalendars; - @Schema(description = "출석률", example = "60") - private int attendanceRate; + @Schema(description = "스터디 공지글 2개") + private List noticePosts; - @Schema(description = "팀 달력") - private List teamCalenders; + @Schema(description = "스터디 자유글 3개") + private List freePosts; + + @Builder + private StudyResponse(int progressDays, int totalDays, int capacity, int currentCount, int totalPostCount, + List teamCalendars, List noticePosts, List freePosts) { + this.progressDays = progressDays; + this.totalDays = totalDays; + this.capacity = capacity; + this.currentCount = currentCount; + this.totalPostCount = totalPostCount; + this.teamCalendars = teamCalendars; + this.noticePosts = noticePosts; + this.freePosts = freePosts; + } - @Schema(description = "게시글 리스트") - private List studyPosts; + public static StudyResponse from(Study study, int progressDays, int totalDays, int totalPostCount, + List teamCalendars, List noticePosts, List freePosts) { + return StudyResponse.builder() + .progressDays(progressDays) + .totalDays(totalDays) + .capacity(study.getCapacity()) + .currentCount(study.getCurrentCount()) + .totalPostCount(totalPostCount) + .teamCalendars(teamCalendars) + .noticePosts(noticePosts) + .freePosts(freePosts) + .build(); + } } 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 d024140d..b7dd039e 100644 --- a/src/main/java/grep/neogulcoder/domain/study/service/StudyService.java +++ b/src/main/java/grep/neogulcoder/domain/study/service/StudyService.java @@ -1,5 +1,7 @@ package grep.neogulcoder.domain.study.service; +import grep.neogulcoder.domain.calender.controller.dto.response.TeamCalendarResponse; +import grep.neogulcoder.domain.calender.service.TeamCalendarService; import grep.neogulcoder.domain.recruitment.post.repository.RecruitmentPostRepository; import grep.neogulcoder.domain.study.Study; import grep.neogulcoder.domain.study.StudyMember; @@ -12,6 +14,10 @@ import grep.neogulcoder.domain.study.repository.StudyMemberRepository; import grep.neogulcoder.domain.study.repository.StudyQueryRepository; import grep.neogulcoder.domain.study.repository.StudyRepository; +import grep.neogulcoder.domain.studypost.controller.dto.response.FreePostInfo; +import grep.neogulcoder.domain.studypost.controller.dto.response.NoticePostInfo; +import grep.neogulcoder.domain.studypost.repository.StudyPostQueryRepository; +import grep.neogulcoder.domain.studypost.repository.StudyPostRepository; import grep.neogulcoder.domain.users.entity.User; import grep.neogulcoder.domain.users.repository.UserRepository; import grep.neogulcoder.global.exception.business.BusinessException; @@ -27,6 +33,8 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Optional; @@ -46,6 +54,9 @@ public class StudyService { private final RecruitmentPostRepository recruitmentPostRepository; private final StudyMemberQueryRepository studyMemberQueryRepository; private final UserRepository userRepository; + private final StudyPostRepository studyPostRepository; + private final StudyPostQueryRepository studyPostQueryRepository; + private final TeamCalendarService teamCalendarService; public StudyItemPagingResponse getMyStudiesPaging(Pageable pageable, Long userId, Boolean finished) { Page page = studyQueryRepository.findMyStudiesPaging(pageable, userId, finished); @@ -62,12 +73,30 @@ public List getMyStudies(Long userId) { } public StudyHeaderResponse getStudyHeader(Long studyId) { - Study study = studyRepository.findByIdAndActivatedTrue(studyId) - .orElseThrow(() -> new NotFoundException(STUDY_NOT_FOUND)); + Study study = findValidStudy(studyId); return StudyHeaderResponse.from(study); } + public StudyResponse getStudy(Long studyId) { + Study study = findValidStudy(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; + progressDays = Math.max(0, Math.min(progressDays, totalDays)); + int totalPostCount = studyPostRepository.countByStudyIdAndActivatedTrue(studyId); + + LocalDate now = LocalDate.now(); + int currentYear = now.getYear(); + int currentMonth = now.getMonthValue(); + List teamCalendars = teamCalendarService.findByMonth(studyId, currentYear, currentMonth); + + List noticePosts = studyPostQueryRepository.findLatestNoticeInfoBy(studyId); + List freePosts = studyPostQueryRepository.findLatestFreeInfoBy(studyId); + + return StudyResponse.from(study, progressDays, totalDays, totalPostCount, teamCalendars, noticePosts, freePosts); + } + public List getStudyImages(Long userId) { List myStudiesImage = studyMemberRepository.findStudiesByUserId(userId); @@ -77,8 +106,7 @@ public List getStudyImages(Long userId) { } public StudyInfoResponse getMyStudyContent(Long studyId, Long userId) { - Study study = studyRepository.findByIdAndActivatedTrue(studyId) - .orElseThrow(() -> new NotFoundException(STUDY_NOT_FOUND)); + Study study = findValidStudy(studyId); validateStudyMember(studyId, userId); validateStudyLeader(studyId, userId); @@ -118,8 +146,7 @@ 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 = studyRepository.findById(studyId) - .orElseThrow(() -> new NotFoundException(STUDY_NOT_FOUND)); + Study study = findValidStudy(studyId); validateLocation(request.getStudyType(), request.getLocation()); validateStudyMember(studyId, userId); @@ -142,8 +169,7 @@ public void updateStudy(Long studyId, StudyUpdateRequest request, Long userId, M @Transactional public void deleteStudy(Long studyId, Long userId) { - Study study = studyRepository.findById(studyId) - .orElseThrow(() -> new NotFoundException(STUDY_NOT_FOUND)); + Study study = findValidStudy(studyId); validateStudyMember(studyId, userId); validateStudyLeader(studyId, userId); @@ -156,12 +182,16 @@ public void deleteStudy(Long studyId, Long userId) { @Transactional public void deleteStudyByAdmin(Long studyId) { - Study study = studyRepository.findById(studyId) - .orElseThrow(() -> new NotFoundException(STUDY_NOT_FOUND)); + Study study = findValidStudy(studyId); study.delete(); } + private Study findValidStudy(Long studyId) { + return studyRepository.findById(studyId) + .orElseThrow(() -> new NotFoundException(STUDY_NOT_FOUND)); + } + 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); diff --git a/src/main/java/grep/neogulcoder/domain/studypost/controller/dto/response/FreePostInfo.java b/src/main/java/grep/neogulcoder/domain/studypost/controller/dto/response/FreePostInfo.java new file mode 100644 index 00000000..49867bb0 --- /dev/null +++ b/src/main/java/grep/neogulcoder/domain/studypost/controller/dto/response/FreePostInfo.java @@ -0,0 +1,33 @@ +package grep.neogulcoder.domain.studypost.controller.dto.response; + +import com.querydsl.core.annotations.QueryProjection; +import grep.neogulcoder.domain.studypost.Category; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Getter +public class FreePostInfo { + + @Schema(example = "3", description = "게시글 ID") + private long postId; + + @Schema(example = "공지", description = "게시글 타입") + private String category; + + @Schema(example = "제목", description = "공지글 제목") + private String title; + + @Schema(example = "2025-07-21", description = "생성일") + private LocalDate createdAt; + + @QueryProjection + public FreePostInfo(long postId, Category category, String title, LocalDateTime createdAt) { + this.postId = postId; + this.category = category.getKorean(); + this.title = title; + this.createdAt = createdAt.toLocalDate(); + } +} diff --git a/src/main/java/grep/neogulcoder/domain/studypost/repository/StudyPostQueryRepository.java b/src/main/java/grep/neogulcoder/domain/studypost/repository/StudyPostQueryRepository.java index c638890c..7bb38fe9 100644 --- a/src/main/java/grep/neogulcoder/domain/studypost/repository/StudyPostQueryRepository.java +++ b/src/main/java/grep/neogulcoder/domain/studypost/repository/StudyPostQueryRepository.java @@ -22,7 +22,7 @@ import java.util.Optional; import java.util.function.Supplier; -import static grep.neogulcoder.domain.studypost.Category.NOTICE; +import static grep.neogulcoder.domain.studypost.Category.*; import static grep.neogulcoder.domain.studypost.StudyPostErrorCode.NOT_VALID_CONDITION; import static grep.neogulcoder.domain.studypost.comment.QStudyPostComment.studyPostComment; import static grep.neogulcoder.domain.users.entity.QUser.user; @@ -34,6 +34,7 @@ public class StudyPostQueryRepository { private final JPAQueryFactory queryFactory; public static final int NOTICE_POST_LIMIT = 2; + public static final int FREE_POST_LIMIT = 3; private final QStudyPost studyPost = QStudyPost.studyPost; @@ -139,6 +140,26 @@ public List findLatestNoticeInfoBy(Long studyId) { .fetch(); } + public List findLatestFreeInfoBy(Long studyId) { + return queryFactory.select( + new QFreePostInfo( + studyPost.id, + studyPost.category, + studyPost.title, + studyPost.createdDate + ) + ) + .from(studyPost) + .where( + studyPost.activated.isTrue(), + studyPost.study.id.eq(studyId), + studyPost.category.eq(FREE) + ) + .orderBy(studyPost.createdDate.desc()) + .limit(FREE_POST_LIMIT) + .fetch(); + } + private OrderSpecifier resolveOrderSpecifier(String attributeName, Boolean isAsc) { if (attributeName == null || isAsc == null) { return null; diff --git a/src/main/java/grep/neogulcoder/domain/studypost/repository/StudyPostRepository.java b/src/main/java/grep/neogulcoder/domain/studypost/repository/StudyPostRepository.java index 32b12d31..6cb690cb 100644 --- a/src/main/java/grep/neogulcoder/domain/studypost/repository/StudyPostRepository.java +++ b/src/main/java/grep/neogulcoder/domain/studypost/repository/StudyPostRepository.java @@ -3,5 +3,6 @@ import grep.neogulcoder.domain.studypost.StudyPost; import org.springframework.data.jpa.repository.JpaRepository; -public interface StudyPostRepository extends JpaRepository { +public interface StudyPostRepository extends JpaRepository { + int countByStudyIdAndActivatedTrue(Long studyId); } From af2e86844ec4e463345aca2556bb4ebe8890202a Mon Sep 17 00:00:00 2001 From: endorsement0912 Date: Thu, 24 Jul 2025 10:57:53 +0900 Subject: [PATCH 08/21] =?UTF-8?q?[EA3-134]=20chore:=20time=5Fvote=5Fstat?= =?UTF-8?q?=20=ED=85=8C=EC=9D=B4=EB=B8=94=EC=97=90=20version=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/data.sql | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index afc30a24..088d3254 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -99,16 +99,16 @@ INSERT INTO time_vote (period_id, study_member_id, time_slot, activated) VALUES -- 2025-07-29 20:00:00 - 1명 INSERT INTO time_vote (period_id, study_member_id, time_slot, activated) VALUES (1, 8, '2025-07-29 20:00:00', TRUE); -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); +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 alarm (receive_user_id, alram_type, message, is_read, redirect_url) VALUES (5, 'APPLICATION', 'jiweon01님이 스터디 신청을 했습니다.', FALSE, '/admin/studies/2/applications'); -- INSERT INTO alarm (receive_user_id, alram_type, message, is_read, redirect_url) VALUES (3, 'APPLICATION_STATUS', '스터디 신청이 수락되었습니다.', FALSE, '/my/applications'); From 7c19d2678f3fbc1c9171362bf88f1dbb458abeeb Mon Sep 17 00:00:00 2001 From: endorsement0912 Date: Thu, 24 Jul 2025 10:58:08 +0900 Subject: [PATCH 09/21] =?UTF-8?q?[EA3-134]=20chore=20:=20=ED=86=B5?= =?UTF-8?q?=EA=B3=84=20API=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/timevote/controller/TimeVoteController.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/java/grep/neogulcoder/domain/timevote/controller/TimeVoteController.java b/src/main/java/grep/neogulcoder/domain/timevote/controller/TimeVoteController.java index 07d0f256..cf45102e 100644 --- a/src/main/java/grep/neogulcoder/domain/timevote/controller/TimeVoteController.java +++ b/src/main/java/grep/neogulcoder/domain/timevote/controller/TimeVoteController.java @@ -5,11 +5,12 @@ import grep.neogulcoder.domain.timevote.dto.request.TimeVoteUpdateRequest; import grep.neogulcoder.domain.timevote.dto.response.TimeVotePeriodResponse; import grep.neogulcoder.domain.timevote.dto.response.TimeVoteResponse; -import grep.neogulcoder.domain.timevote.dto.response.TimeVoteStatListResponse; +import grep.neogulcoder.domain.timevote.dto.response.TimeVoteStatResponse; import grep.neogulcoder.domain.timevote.dto.response.TimeVoteSubmissionStatusResponse; import grep.neogulcoder.domain.timevote.entity.TimeVotePeriod; import grep.neogulcoder.domain.timevote.service.TimeVotePeriodService; import grep.neogulcoder.domain.timevote.service.TimeVoteService; +import grep.neogulcoder.domain.timevote.service.TimeVoteStatService; import grep.neogulcoder.global.auth.Principal; import grep.neogulcoder.global.response.ApiResponse; import jakarta.validation.Valid; @@ -32,7 +33,7 @@ public class TimeVoteController implements TimeVoteSpecification { private final TimeVotePeriodService timeVotePeriodService; private final TimeVoteService timeVoteService; -// private final TimeVoteStatService timeVoteStatService; + private final TimeVoteStatService timeVoteStatService; @PostMapping("/periods") public ApiResponse createPeriod( @@ -92,10 +93,11 @@ public ApiResponse> getSubmissionStatusLi } @GetMapping("/periods/stats") - public ApiResponse getVoteStats( + public ApiResponse getVoteStats( @PathVariable("studyId") Long studyId, @AuthenticationPrincipal Principal userDetails ) { - return ApiResponse.success(new TimeVoteStatListResponse()); // mock response + TimeVoteStatResponse response = timeVoteStatService.getStats(studyId, userDetails.getUserId()); + return ApiResponse.success(response); } } From 691840843c9068b51c0f6c9a26207f1cd91a68ec Mon Sep 17 00:00:00 2001 From: endorsement0912 Date: Thu, 24 Jul 2025 10:58:33 +0900 Subject: [PATCH 10/21] =?UTF-8?q?[EA3-134]=20refactor=20:=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=BD=94=EB=93=9C=20=EC=A0=91=EB=91=90=EC=96=B4=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20=ED=88=AC=ED=91=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=97=90=EB=9F=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/code/TimeVoteErrorCode.java | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) 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 57494977..cb617c7a 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 @@ -7,16 +7,30 @@ @Getter public enum TimeVoteErrorCode implements ErrorCode { - FORBIDDEN_TIME_VOTE_CREATE("T001", HttpStatus.BAD_REQUEST, "모임 일정 조율 투표 생성은 스터디장만 가능합니다."), - STUDY_NOT_FOUND("T002", HttpStatus.NOT_FOUND, "해당 스터디를 찾을 수 없습니다."), - STUDY_MEMBER_NOT_FOUND("T003", HttpStatus.NOT_FOUND, "해당 스터디의 멤버가 아닙니다."), - TIME_VOTE_PERIOD_NOT_FOUND("T004", HttpStatus.NOT_FOUND, "해당 스터디에 대한 투표 기간이 존재하지 않습니다."), - INVALID_TIME_VOTE_PERIOD("T005", HttpStatus.BAD_REQUEST, "모일 일정 조율 기간은 최대 7일까지 설정할 수 있습니다."), - TIME_VOTE_ALREADY_SUBMITTED("T006", HttpStatus.CONFLICT, "이미 투표를 제출했습니다. PUT 요청으로 기존의 제출한 투표를 수정하세요."), - TIME_VOTE_OUT_OF_RANGE("T007", HttpStatus.BAD_REQUEST, "선택한 시간이 투표 기간을 벗어났습니다."), - TIME_VOTE_NOT_FOUND("T008", HttpStatus.BAD_REQUEST, "시간 투표 이력이 존재하지 않습니다."), - TIME_VOTE_STAT_CONFLICT("T009", HttpStatus.CONFLICT, "투표 통계 저장 중 충돌이 발생했습니다. 다시 시도해주세요."), - TIME_VOTE_THREAD_INTERRUPTED("T010", HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 스레드 오류가 발생했습니다. 다시 시도해주세요."); + // Access To Vote (e.g. 멤버 아님 등 접근 문제) + STUDY_MEMBER_NOT_FOUND("ATV_001", HttpStatus.NOT_FOUND, "해당 스터디의 멤버가 아닙니다."), + TIME_VOTE_PERIOD_NOT_FOUND("ATV_002", HttpStatus.NOT_FOUND, "해당 스터디에 대한 투표 기간이 존재하지 않습니다."), + + // Time Vote Period (e.g. 기간 생성 관련) + FORBIDDEN_TIME_VOTE_CREATE("TVP_001", HttpStatus.BAD_REQUEST, "모임 일정 조율 투표 생성은 스터디장만 가능합니다."), + STUDY_NOT_FOUND("TVP_002", HttpStatus.NOT_FOUND, "해당 스터디를 찾을 수 없습니다."), + 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, Time Vote Stats (e.g. 투표 기간 관련) + TIME_VOTE_OUT_OF_RANGE("TVAS_001", HttpStatus.BAD_REQUEST, "선택한 시간이 투표 기간을 벗어났습니다."), + + // Time Vote (e.g. 투표 제출, 수정) + TIME_VOTE_ALREADY_SUBMITTED("TV_001", HttpStatus.CONFLICT, "이미 투표를 제출했습니다. PUT 요청으로 기존의 제출한 투표를 수정하세요."), + TIME_VOTE_NOT_FOUND("TV_002", HttpStatus.BAD_REQUEST, "시간 투표 이력이 존재하지 않습니다."), + TIME_VOTE_DUPLICATED_TIME_SLOT("TV_003", HttpStatus.BAD_REQUEST, "중복된 시간이 포함되어 있습니다."), + TIME_VOTE_PERIOD_EXPIRED("TV_004", HttpStatus.BAD_REQUEST, "투표 기간이 만료되었습니다."), + TIME_VOTE_EMPTY("TV_005", HttpStatus.BAD_REQUEST, "한 개 이상의 시간을 선택해주세요."), + + // Time Vote Stats (e.g. 통계 충돌 등) + TIME_VOTE_STAT_CONFLICT("TVS_001", HttpStatus.CONFLICT, "투표 통계 저장 중 충돌이 발생했습니다. 다시 시도해주세요."), + TIME_VOTE_THREAD_INTERRUPTED("TVS_002", HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 스레드 오류가 발생했습니다. 다시 시도해주세요."); private final String code; private final HttpStatus status; From 8842bdc1fb989530e55d309a7d10dd88549d6e8d Mon Sep 17 00:00:00 2001 From: endorsement0912 Date: Thu, 24 Jul 2025 10:58:51 +0900 Subject: [PATCH 11/21] =?UTF-8?q?[EA3-134]=20refactor=20:=20equals=20?= =?UTF-8?q?=EB=B0=8F=20hashCode=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/timevote/entity/TimeVotePeriod.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/main/java/grep/neogulcoder/domain/timevote/entity/TimeVotePeriod.java b/src/main/java/grep/neogulcoder/domain/timevote/entity/TimeVotePeriod.java index 07c3292d..c8a7e0bc 100644 --- a/src/main/java/grep/neogulcoder/domain/timevote/entity/TimeVotePeriod.java +++ b/src/main/java/grep/neogulcoder/domain/timevote/entity/TimeVotePeriod.java @@ -36,4 +36,17 @@ public TimeVotePeriod(Long periodId, Long studyId, LocalDateTime startDate, Loca this.startDate = startDate; this.endDate = endDate; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + TimeVotePeriod that = (TimeVotePeriod) o; + return periodId != null && periodId.equals(that.periodId); + } + + @Override + public int hashCode() { + return periodId != null ? periodId.hashCode() : 0; + } } From 1a436e2e4f3bc6c1d36b8ab47babf37bf657552f Mon Sep 17 00:00:00 2001 From: endorsement0912 Date: Thu, 24 Jul 2025 10:59:07 +0900 Subject: [PATCH 12/21] =?UTF-8?q?[EA3-134]=20refactor=20:=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=EC=9D=BC=EC=9D=B4=20=EA=B3=BC=EA=B1=B0=EA=B1=B0?= =?UTF-8?q?=EB=82=98=20=EC=A2=85=EB=A3=8C=EC=9D=BC=EB=B3=B4=EB=8B=A4=20?= =?UTF-8?q?=EB=8A=A6=EC=9D=80=20=EA=B2=BD=EC=9A=B0=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/TimeVotePeriodService.java | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/main/java/grep/neogulcoder/domain/timevote/service/TimeVotePeriodService.java b/src/main/java/grep/neogulcoder/domain/timevote/service/TimeVotePeriodService.java index b4bd1986..45e73bff 100644 --- a/src/main/java/grep/neogulcoder/domain/timevote/service/TimeVotePeriodService.java +++ b/src/main/java/grep/neogulcoder/domain/timevote/service/TimeVotePeriodService.java @@ -13,14 +13,12 @@ import grep.neogulcoder.domain.timevote.repository.TimeVoteRepository; import grep.neogulcoder.domain.timevote.repository.TimeVoteStatRepository; import grep.neogulcoder.global.exception.business.BusinessException; -import java.time.Duration; import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -@Slf4j @Service @Transactional @RequiredArgsConstructor @@ -35,6 +33,9 @@ public class TimeVotePeriodService { public TimeVotePeriod createTimeVotePeriodAndReturn(TimeVotePeriodCreateRequest request, Long studyId, Long userId) { StudyMember studyMember = getValidStudyMember(studyId, userId); validateStudyLeader(studyMember); + + validateStartDateNotPast(request.getStartDate()); + validatePeriodRange(request.getStartDate(), request.getEndDate()); validateMaxPeriod(request.getStartDate(), request.getEndDate()); if (timeVotePeriodRepository.existsByStudyId(studyId)) { @@ -43,9 +44,6 @@ public TimeVotePeriod createTimeVotePeriodAndReturn(TimeVotePeriodCreateRequest TimeVotePeriod savedPeriod = timeVotePeriodRepository.save(request.toEntity(studyId)); - log.info("📝 TimeVotePeriod 생성됨 - studyId: {}, userId: {}, role: {}, periodId: {}, start: {}, end: {}", - studyId, userId, studyMember.getRole(), savedPeriod.getPeriodId(), savedPeriod.getStartDate(), savedPeriod.getEndDate()); - return savedPeriod; } @@ -58,23 +56,40 @@ public void deleteAllTimeVoteDate(Long studyId) { timeVotePeriodRepository.deleteAllByStudyId(studyId); } - // 검증 로직 + // ================================= 검증 로직 ================================ + // 유효한 스터디 멤버인지 확인 (활성화된 멤버만 허용) private StudyMember getValidStudyMember(Long studyId, Long userId) { return studyMemberRepository.findByStudyIdAndUserIdAndActivatedTrue(studyId, userId) .orElseThrow(() -> new BusinessException(STUDY_MEMBER_NOT_FOUND)); } + // 요청자가 스터디장(LEADER)인지 확인 (스터디장만 투표 기간 생성 가능) private void validateStudyLeader(StudyMember member){ if(member.getRole() != StudyMemberRole.LEADER) { throw new BusinessException(FORBIDDEN_TIME_VOTE_CREATE); } } + // 투표 가능 기간은 최대 7일로 제한 private void validateMaxPeriod(LocalDateTime startDate, LocalDateTime endDate) { - Long days = Duration.between(startDate, endDate).toDays(); + Long days = ChronoUnit.DAYS.between(startDate.toLocalDate(), endDate.toLocalDate()); - if(days > 6) { + if (days > 7) { throw new BusinessException(INVALID_TIME_VOTE_PERIOD); } } + + // 시작일이 종료일보다 늦은 비정상적인 입력 방지 + private void validatePeriodRange(LocalDateTime startDate, LocalDateTime endDate) { + if (startDate.isAfter(endDate)) { + throw new BusinessException(TIME_VOTE_INVALID_DATE_RANGE); + } + } + + // 시작일이 현재보다 과거이면 예외 + private void validateStartDateNotPast(LocalDateTime startDate) { + if (startDate.isBefore(LocalDateTime.now())) { + throw new BusinessException(TIME_VOTE_PERIOD_START_DATE_IN_PAST); + } + } } From 9a206a5b96e6275c212412da6197550319131453 Mon Sep 17 00:00:00 2001 From: endorsement0912 Date: Thu, 24 Jul 2025 10:59:29 +0900 Subject: [PATCH 13/21] =?UTF-8?q?[EA3-134]=20refactor=20:=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=ED=88=AC=ED=91=9C,=20=EB=A7=8C=EB=A3=8C=EB=90=9C?= =?UTF-8?q?=20=ED=88=AC=ED=91=9C,=20=EB=B9=88=20=ED=88=AC=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EA=B2=80=EC=A6=9D=20=EB=B0=8F=20=ED=86=B5?= =?UTF-8?q?=EA=B3=84=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timevote/service/TimeVoteService.java | 46 +++++++++++++++++-- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/src/main/java/grep/neogulcoder/domain/timevote/service/TimeVoteService.java b/src/main/java/grep/neogulcoder/domain/timevote/service/TimeVoteService.java index 2e4dcedf..ca1f7c82 100644 --- a/src/main/java/grep/neogulcoder/domain/timevote/service/TimeVoteService.java +++ b/src/main/java/grep/neogulcoder/domain/timevote/service/TimeVoteService.java @@ -15,7 +15,9 @@ import grep.neogulcoder.domain.timevote.repository.TimeVoteQueryRepository; import grep.neogulcoder.global.exception.business.BusinessException; import java.time.LocalDateTime; +import java.util.HashSet; import java.util.List; +import java.util.Set; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -29,6 +31,7 @@ public class TimeVoteService { private final TimeVotePeriodRepository timeVotePeriodRepository; private final TimeVoteRepository timeVoteRepository; private final TimeVoteQueryRepository timeVoteQueryRepository; + private final TimeVoteStatService timeVoteStatService; @Transactional(readOnly = true) public TimeVoteResponse getMyVotes(Long studyId, Long userId) { @@ -45,12 +48,16 @@ public TimeVoteResponse submitVotes(TimeVoteCreateRequest request, Long studyId, TimeVotePeriod period = getValidTimeVotePeriod(studyId); validateNotAlreadySubmitted(period, studyMember.getId()); + validateNotExpired(period); + + validateNotEmptyVotes(request.getTimeSlots()); validateVoteWithinPeriod(period, request.getTimeSlots()); + validateNoDuplicateTimeSlots(request.getTimeSlots()); List votes = request.toEntities(period, studyMember.getId()); timeVoteRepository.saveAll(votes); - // TODO : 통계 계산 (service 내부 또는 이벤트 방식) + timeVoteStatService.incrementStats(period.getPeriodId(), request.getTimeSlots()); List saved = timeVoteRepository.findByPeriodAndStudyMemberId(period, studyMember.getId()); @@ -62,14 +69,18 @@ public TimeVoteResponse updateVotes(TimeVoteUpdateRequest request, Long studyId, TimeVotePeriod period = getValidTimeVotePeriod(studyId); validateAlreadySubmitted(period, studyMember.getId()); + validateNotExpired(period); + + validateNotEmptyVotes(request.getTimeSlots()); validateVoteWithinPeriod(period, request.getTimeSlots()); + validateNoDuplicateTimeSlots(request.getTimeSlots()); timeVoteRepository.deleteAllByPeriodAndStudyMemberId(period, studyMember.getId()); List newVotes = request.toEntities(period, studyMember.getId()); timeVoteRepository.saveAll(newVotes); - // TODO : 통계 계산 (service 내부 또는 이벤트 방식) + timeVoteStatService.recalculateStats(period.getPeriodId()); List saved = timeVoteRepository.findByPeriodAndStudyMemberId(period, studyMember.getId()); @@ -82,7 +93,7 @@ public void deleteAllVotes(Long studyId, Long userId) { timeVoteRepository.deleteAllByPeriodAndStudyMemberId(period, studyMember.getId()); - // TODO : 통계 계산 (service 내부 또는 이벤트 방식) + timeVoteStatService.recalculateStats(period.getPeriodId()); } public List getSubmissionStatusList(Long studyId, Long userId) { @@ -92,17 +103,20 @@ public List getSubmissionStatusList(Long study return timeVoteQueryRepository.findSubmissionStatuses(studyId, period.getPeriodId()); } - // 검증 로직 + // ================================= 검증 로직 ================================ + // 투표 시 유효한 스터디 멤버인지 확인 (활성화된 멤버만 허용) private StudyMember getValidStudyMember(Long studyId, Long userId) { return studyMemberRepository.findByStudyIdAndUserIdAndActivatedTrue(studyId, userId) .orElseThrow(() -> new BusinessException(STUDY_MEMBER_NOT_FOUND)); } + // 스터디에 등록된 가장 최신의 투표 기간 정보 조회 (없으면 예외) private TimeVotePeriod getValidTimeVotePeriod(Long studyId) { return timeVotePeriodRepository.findTopByStudyIdOrderByStartDateDesc(studyId) .orElseThrow(() -> new BusinessException(TIME_VOTE_PERIOD_NOT_FOUND)); } + // 각 투표 시간이 투표 기간 내에 포함되는지 확인 private void validateVoteWithinPeriod(TimeVotePeriod period, List dateTimes) { for (LocalDateTime dateTime : dateTimes) { if (dateTime.isBefore(period.getStartDate()) || dateTime.isAfter(period.getEndDate())) { @@ -111,6 +125,7 @@ private void validateVoteWithinPeriod(TimeVotePeriod period, List } } + // 이미 투표한 적이 있는지 확인 (중복 투표 방지용) private void validateNotAlreadySubmitted(TimeVotePeriod period, Long studyMemberId) { boolean alreadySubmitted = timeVoteRepository.existsByPeriodAndStudyMemberId(period, studyMemberId); if (alreadySubmitted) { @@ -118,10 +133,33 @@ private void validateNotAlreadySubmitted(TimeVotePeriod period, Long studyMember } } + // 업데이트 시 기존에 투표한 적이 있는지 확인 (없는 경우 업데이트 불가) private void validateAlreadySubmitted(TimeVotePeriod period, Long studyMemberId) { boolean alreadySubmitted = timeVoteRepository.existsByPeriodAndStudyMemberId(period, studyMemberId); if (!alreadySubmitted) { throw new BusinessException(TIME_VOTE_NOT_FOUND); } } + + // 이미 제출한 시간에 중복 투표를 시도했는지 확인 + private void validateNoDuplicateTimeSlots(List timeSlots) { + Set unique = new HashSet<>(timeSlots); + if (unique.size() != timeSlots.size()) { + throw new BusinessException(TIME_VOTE_DUPLICATED_TIME_SLOT); + } + } + + // 투표 기간이 이미 만료되었는지 확인 (종료일 이후 투표 금지) + private void validateNotExpired(TimeVotePeriod period) { + if (LocalDateTime.now().isAfter(period.getEndDate())) { + throw new BusinessException(TIME_VOTE_PERIOD_EXPIRED); + } + } + + // 입력된 투표 시간이 비어 있는지 확인 (아예 투표하지 않은 경우) + private void validateNotEmptyVotes(List timeSlots) { + if (timeSlots == null || timeSlots.isEmpty()) { + throw new BusinessException(TIME_VOTE_EMPTY); + } + } } From 6178c4218d4be149fd7f3800357f09dc8cd59bec Mon Sep 17 00:00:00 2001 From: endorsement0912 Date: Thu, 24 Jul 2025 10:59:48 +0900 Subject: [PATCH 14/21] =?UTF-8?q?[EA3-134]=20chore=20:=20Swagger=20?= =?UTF-8?q?=EC=98=88=EC=8B=9C=EA=B0=92=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?=ED=86=B5=EA=B3=84=20=EC=9D=91=EB=8B=B5=20DTO=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/TimeVoteSpecification.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/java/grep/neogulcoder/domain/timevote/controller/TimeVoteSpecification.java b/src/main/java/grep/neogulcoder/domain/timevote/controller/TimeVoteSpecification.java index 61642874..720fe0d6 100644 --- a/src/main/java/grep/neogulcoder/domain/timevote/controller/TimeVoteSpecification.java +++ b/src/main/java/grep/neogulcoder/domain/timevote/controller/TimeVoteSpecification.java @@ -16,46 +16,46 @@ public interface TimeVoteSpecification { @Operation(summary = "스터디 모임 일정 투표 기간 생성", description = "팀장이 가능한 시간 요청을 생성합니다.") ApiResponse createPeriod( - @Parameter(description = "스터디 ID", example = "1") Long studyId, + @Parameter(description = "스터디 ID", example = "6") Long studyId, @RequestBody @Valid TimeVotePeriodCreateRequest request, Principal userDetails ); @Operation(summary = "사용자가 제출한 시간 목록 조회", description = "해당 사용자가 이전에 제출한 시간대 목록을 조회합니다.") ApiResponse getMyVotes( - @Parameter(description = "스터디 ID", example = "1") Long studyId, + @Parameter(description = "스터디 ID", example = "6") Long studyId, Principal userDetails ); @Operation(summary = "사용자 가능 시간대 제출", description = "스터디 멤버가 가능 시간을 제출합니다.") ApiResponse submitVote( - @Parameter(description = "스터디 ID", example = "1") Long studyId, + @Parameter(description = "스터디 ID", example = "6") Long studyId, @RequestBody @Valid TimeVoteCreateRequest request, Principal userDetails ); @Operation(summary = "사용자 시간대 수정", description = "사용자가 기존에 제출한 시간을 수정합니다.") ApiResponse updateVote( - @Parameter(description = "스터디 ID", example = "1") Long studyId, + @Parameter(description = "스터디 ID", example = "6") Long studyId, @RequestBody @Valid TimeVoteUpdateRequest request, Principal userDetails ); @Operation(summary = "사용자 전체 시간 삭제", description = "사용자가 제출한 시간 전체를 삭제합니다.") ApiResponse deleteAllVotes( - @Parameter(description = "스터디 ID", example = "1") Long studyId, + @Parameter(description = "스터디 ID", example = "6") Long studyId, Principal userDetails ); @Operation(summary = "투표 통계 조회", description = "투표 기간의 시간대별 통계 정보를 조회합니다.") - ApiResponse getVoteStats( - @Parameter(description = "스터디 ID", example = "1") Long studyId, + ApiResponse getVoteStats( + @Parameter(description = "스터디 ID", example = "6") Long studyId, Principal userDetails ); @Operation(summary = "사용자별 제출 여부 조회", description = "특정 스터디의 모든 멤버별 시간 제출 여부를 반환합니다.") ApiResponse> getSubmissionStatusList( - @Parameter(description = "스터디 ID", example = "1") Long studyId, + @Parameter(description = "스터디 ID", example = "6") Long studyId, Principal userDetails ); } From a0a94a3bc85be9ebb6b9ae71803f6b2ba877d63a Mon Sep 17 00:00:00 2001 From: endorsement0912 Date: Thu, 24 Jul 2025 11:00:17 +0900 Subject: [PATCH 15/21] =?UTF-8?q?[EA3-134]=20chore=20:=20=ED=86=B5?= =?UTF-8?q?=EA=B3=84=20=EC=9D=91=EB=8B=B5=20=ED=86=B5=ED=95=A9=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=88=ED=95=84=EC=9A=94=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../response/TimeVoteStatListResponse.java | 26 ------------------- 1 file changed, 26 deletions(-) delete mode 100644 src/main/java/grep/neogulcoder/domain/timevote/dto/response/TimeVoteStatListResponse.java diff --git a/src/main/java/grep/neogulcoder/domain/timevote/dto/response/TimeVoteStatListResponse.java b/src/main/java/grep/neogulcoder/domain/timevote/dto/response/TimeVoteStatListResponse.java deleted file mode 100644 index 62c53a2f..00000000 --- a/src/main/java/grep/neogulcoder/domain/timevote/dto/response/TimeVoteStatListResponse.java +++ /dev/null @@ -1,26 +0,0 @@ -package grep.neogulcoder.domain.timevote.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDateTime; -import java.util.List; -import lombok.Getter; - -@Getter -@Schema(description = "스터디 모임 일정 조율 - 시간대별 통계 리스트 응답 DTO") -public class TimeVoteStatListResponse { - - @Schema(description = "시작일", example = "2025-07-15T00:00:00") - private LocalDateTime startDate; - - @Schema(description = "종료일", example = "2025-07-22T00:00:00") - private LocalDateTime endDate; - - @Schema( - description = "투표 통계 리스트", - example = "[" + - "{\"timeSlot\": \"2025-07-16T10:00:00\", \"voteCount\": 3}," + - "{\"timeSlot\": \"2025-07-16T11:00:00\", \"voteCount\": 2}" + - "]" - ) - private List stats; -} From 37d8733ea292b3ae08dc9d65af07cdc50dc0ca1a Mon Sep 17 00:00:00 2001 From: endorsement0912 Date: Thu, 24 Jul 2025 11:00:57 +0900 Subject: [PATCH 16/21] =?UTF-8?q?[EA3-134]=20feat=20:=20=ED=86=B5=EA=B3=84?= =?UTF-8?q?=20=EA=B3=84=EC=82=B0=20=EB=B0=8F=20=EC=A6=9D=EA=B0=80=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20=EC=BF=BC=EB=A6=AC=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TimeVoteStatQueryRepository.java | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVoteStatQueryRepository.java diff --git a/src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVoteStatQueryRepository.java b/src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVoteStatQueryRepository.java new file mode 100644 index 00000000..1518c313 --- /dev/null +++ b/src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVoteStatQueryRepository.java @@ -0,0 +1,56 @@ +package grep.neogulcoder.domain.timevote.repository; + +import com.querydsl.core.Tuple; +import com.querydsl.jpa.impl.JPAQueryFactory; +import grep.neogulcoder.domain.timevote.entity.QTimeVote; +import grep.neogulcoder.domain.timevote.entity.QTimeVoteStat; +import grep.neogulcoder.domain.timevote.entity.TimeVotePeriod; +import grep.neogulcoder.domain.timevote.entity.TimeVoteStat; +import jakarta.persistence.EntityManager; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; +import org.springframework.stereotype.Repository; + +@Repository +public class TimeVoteStatQueryRepository { + + private final JPAQueryFactory queryFactory; + private final EntityManager em; + + public TimeVoteStatQueryRepository(EntityManager em) { + this.em = em; + this.queryFactory = new JPAQueryFactory(em); + } + + public List countStatsByPeriod(TimeVotePeriod period) { + QTimeVote timeVote = QTimeVote.timeVote; + + List result = queryFactory + .select(timeVote.timeSlot, timeVote.count()) + .from(timeVote) + .where(timeVote.period.eq(period)) + .groupBy(timeVote.timeSlot) + .fetch(); + + 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); + } + } +} From 210196d4706c22c4926e5032613314dd83a0c11a Mon Sep 17 00:00:00 2001 From: endorsement0912 Date: Thu, 24 Jul 2025 11:01:13 +0900 Subject: [PATCH 17/21] =?UTF-8?q?[EA3-134]=20refactor=20:=20=EA=B8=B0?= =?UTF-8?q?=EA=B0=84=20=EA=B8=B0=EB=B0=98=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C=20=EC=BF=BC=EB=A6=AC=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timevote/repository/TimeVoteStatRepository.java | 9 +++++++++ 1 file changed, 9 insertions(+) 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 a391733e..b0dcafc0 100644 --- a/src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVoteStatRepository.java +++ b/src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVoteStatRepository.java @@ -1,9 +1,18 @@ package grep.neogulcoder.domain.timevote.repository; +import grep.neogulcoder.domain.timevote.entity.TimeVotePeriod; import grep.neogulcoder.domain.timevote.entity.TimeVoteStat; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface TimeVoteStatRepository extends JpaRepository { void deleteAllByPeriod_StudyId(Long studyId); + + void deleteByPeriod(TimeVotePeriod period); + + @Query("SELECT s FROM TimeVoteStat s WHERE s.period.periodId = :periodId") + List findAllByPeriodId(@Param("periodId") Long periodId); } From eaed49c46b82077cca62dd3a725d838c510b6226 Mon Sep 17 00:00:00 2001 From: endorsement0912 Date: Thu, 24 Jul 2025 11:01:38 +0900 Subject: [PATCH 18/21] =?UTF-8?q?[EA3-134]=20refactor=20:=20TimeVoteStatLi?= =?UTF-8?q?stResponse=20=ED=86=B5=ED=95=A9=20=EB=B0=8F=20from=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/TimeVoteStatResponse.java | 60 +++++++++++++++---- 1 file changed, 49 insertions(+), 11 deletions(-) diff --git a/src/main/java/grep/neogulcoder/domain/timevote/dto/response/TimeVoteStatResponse.java b/src/main/java/grep/neogulcoder/domain/timevote/dto/response/TimeVoteStatResponse.java index 604b48fc..4de22a69 100644 --- a/src/main/java/grep/neogulcoder/domain/timevote/dto/response/TimeVoteStatResponse.java +++ b/src/main/java/grep/neogulcoder/domain/timevote/dto/response/TimeVoteStatResponse.java @@ -1,31 +1,69 @@ package grep.neogulcoder.domain.timevote.dto.response; import grep.neogulcoder.domain.timevote.entity.TimeVoteStat; +import grep.neogulcoder.domain.timevote.entity.TimeVotePeriod; import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDateTime; +import java.util.Comparator; import lombok.Builder; import lombok.Getter; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; @Getter @Schema(description = "스터디 모임 일정 조율 - 시간대별 통계 응답 DTO") public class TimeVoteStatResponse { - @Schema(description = "시간대", example = "2025-07-16T10:00:00") - private LocalDateTime timeSlot; + @Schema(description = "시작일", example = "2025-07-15") + private LocalDateTime startDate; - @Schema(description = "해당 시간대의 투표 수", example = "3") - private Long voteCount; + @Schema(description = "종료일", example = "2025-07-22") + private LocalDateTime endDate; + + @Schema( + description = "투표 통계 리스트", + example = "[" + + "{\"timeSlot\": \"2025-07-16T10:00:00\", \"voteCount\": 3}," + + "{\"timeSlot\": \"2025-07-16T11:00:00\", \"voteCount\": 2}" + + "]" + ) + private List stats; @Builder - private TimeVoteStatResponse(LocalDateTime timeSlot, Long voteCount) { - this.timeSlot = timeSlot; - this.voteCount = voteCount; + private TimeVoteStatResponse(LocalDateTime startDate, LocalDateTime endDate, List stats) { + this.startDate = startDate; + this.endDate = endDate; + this.stats = stats; + } + + @Getter + @Schema(description = "개별 시간대별 통계 DTO") + public static class TimeSlotStat { + + @Schema(description = "시간대", example = "2025-07-16T10:00:00") + private LocalDateTime timeSlot; + + @Schema(description = "해당 시간대의 투표 수", example = "3") + private Long voteCount; + + @Builder + public TimeSlotStat(LocalDateTime timeSlot, Long voteCount) { + this.timeSlot = timeSlot; + this.voteCount = voteCount; + } } - public static TimeVoteStatResponse from(TimeVoteStat stat) { + public static TimeVoteStatResponse from(TimeVotePeriod period, List stats) { return TimeVoteStatResponse.builder() - .timeSlot(stat.getTimeSlot()) - .voteCount(stat.getVoteCount()) + .startDate(period.getStartDate()) + .endDate(period.getEndDate()) + .stats(stats.stream() + .sorted(Comparator.comparing(TimeVoteStat::getTimeSlot)) + .map(s -> TimeSlotStat.builder() + .timeSlot(s.getTimeSlot()) + .voteCount(s.getVoteCount()) + .build()) + .collect(Collectors.toList())) .build(); } } From 52c2a88f834b4e8a71f40c423d09911318bbf14b Mon Sep 17 00:00:00 2001 From: endorsement0912 Date: Thu, 24 Jul 2025 11:01:57 +0900 Subject: [PATCH 19/21] =?UTF-8?q?[EA3-134]=20refactor=20:=20=ED=86=B5?= =?UTF-8?q?=EA=B3=84=20=EA=B3=84=EC=82=B0/=EC=9E=AC=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=82=99?= =?UTF-8?q?=EA=B4=80=EC=A0=81=20=EB=9D=BD=20=EC=B6=A9=EB=8F=8C=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC,=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timevote/service/TimeVoteStatService.java | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/src/main/java/grep/neogulcoder/domain/timevote/service/TimeVoteStatService.java b/src/main/java/grep/neogulcoder/domain/timevote/service/TimeVoteStatService.java index 8b54e430..7415c4c8 100644 --- a/src/main/java/grep/neogulcoder/domain/timevote/service/TimeVoteStatService.java +++ b/src/main/java/grep/neogulcoder/domain/timevote/service/TimeVoteStatService.java @@ -1,10 +1,123 @@ package grep.neogulcoder.domain.timevote.service; +import static grep.neogulcoder.domain.timevote.exception.code.TimeVoteErrorCode.*; + +import grep.neogulcoder.domain.study.StudyMember; +import grep.neogulcoder.domain.study.repository.StudyMemberRepository; +import grep.neogulcoder.domain.timevote.dto.response.TimeVoteStatResponse; +import grep.neogulcoder.domain.timevote.entity.TimeVotePeriod; +import grep.neogulcoder.domain.timevote.entity.TimeVoteStat; +import grep.neogulcoder.domain.timevote.repository.TimeVotePeriodRepository; +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 java.time.LocalDateTime; +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.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @RequiredArgsConstructor +@Transactional public class TimeVoteStatService { + private final StudyMemberRepository studyMemberRepository; + private final TimeVotePeriodRepository timeVotePeriodRepository; + private final TimeVoteStatRepository timeVoteStatRepository; + private final TimeVoteStatQueryRepository timeVoteStatQueryRepository; + + @Transactional(readOnly = true) + public TimeVoteStatResponse getStats(Long studyId, Long userId) { + getValidStudyMember(studyId, userId); + TimeVotePeriod period = getValidTimeVotePeriodByStudyId(studyId); + + List stats = timeVoteStatRepository.findAllByPeriodId(period.getPeriodId()); + + validateStatTimeSlotsWithinPeriod(period, stats); + + return TimeVoteStatResponse.from(period, stats); + } + + public void incrementStats(Long periodId, List timeSlots) { + log.info("[TimeVoteStatService] 투표 통계 계산 시작: periodId={}, timeSlots={}", periodId, timeSlots); + TimeVotePeriod period = getValidTimeVotePeriodByPeriodId(periodId); + + Map countMap = timeSlots.stream() + .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); + + 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++; + log.warn("낙관적 락 충돌 발생: slot={} 재시도 {}/3", slot, retry); // 통계 동시성 충돌 시 재시도 (최대 3회) + try { + Thread.sleep(10L); + } catch (InterruptedException e2) { + Thread.currentThread().interrupt(); + throw new BusinessException(TIME_VOTE_THREAD_INTERRUPTED); + } + } + } + + if (!success) { + throw new BusinessException(TIME_VOTE_STAT_CONFLICT); + } + }); + } + + public void recalculateStats(Long periodId) { + log.info("[TimeVoteStatService] 투표 통계 재계산 시작: periodId={}", periodId); + synchronized (("recalc-lock:" + periodId).intern()) { + TimeVotePeriod period = getValidTimeVotePeriodByPeriodId(periodId); + + timeVoteStatRepository.deleteByPeriod(period); + + List stats = timeVoteStatQueryRepository.countStatsByPeriod(period); + + timeVoteStatRepository.saveAll(stats); + } + } + + // ================================= 검증 로직 ================================ + // 투표 시 유효한 스터디 멤버인지 확인 (활성화된 멤버만 허용) + private StudyMember getValidStudyMember(Long studyId, Long userId) { + return studyMemberRepository.findByStudyIdAndUserIdAndActivatedTrue(studyId, userId) + .orElseThrow(() -> new BusinessException(STUDY_MEMBER_NOT_FOUND)); + } + + // studyId 기준으로 스터디에 등록된 가장 최신의 투표 기간 정보 조회 (없으면 예외) + private TimeVotePeriod getValidTimeVotePeriodByStudyId(Long studyId) { + return timeVotePeriodRepository.findTopByStudyIdOrderByStartDateDesc(studyId) + .orElseThrow(() -> new BusinessException(TIME_VOTE_PERIOD_NOT_FOUND)); + } + + // periodId 기준으로 투표 기간이 존재하는지 정보 조회 + private TimeVotePeriod getValidTimeVotePeriodByPeriodId(Long periodId) { + return timeVotePeriodRepository.findById(periodId) + .orElseThrow(() -> new BusinessException(TIME_VOTE_PERIOD_NOT_FOUND)); + } + + // 통계에 포함된 각 시간대가 기간 안에 들어가는지 확인 + private void validateStatTimeSlotsWithinPeriod(TimeVotePeriod period, List stats) { + boolean invalid = stats.stream() + .map(TimeVoteStat::getTimeSlot) + .anyMatch(slot -> slot.isBefore(period.getStartDate()) || slot.isAfter(period.getEndDate())); + + if (invalid) { + throw new BusinessException(TIME_VOTE_OUT_OF_RANGE); + } + } } From e4dd29db78497a9073b25da3dae3b04262010e3e Mon Sep 17 00:00:00 2001 From: endorsement0912 Date: Thu, 24 Jul 2025 11:03:09 +0900 Subject: [PATCH 20/21] =?UTF-8?q?[EA3-134]=20refactor=20:=20version=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20of=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=A9=94=EC=84=9C=EB=93=9C,=20addVotes=20?= =?UTF-8?q?=EC=9C=A0=ED=8B=B8=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/timevote/entity/TimeVoteStat.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/main/java/grep/neogulcoder/domain/timevote/entity/TimeVoteStat.java b/src/main/java/grep/neogulcoder/domain/timevote/entity/TimeVoteStat.java index 3c93321d..b008e689 100644 --- a/src/main/java/grep/neogulcoder/domain/timevote/entity/TimeVoteStat.java +++ b/src/main/java/grep/neogulcoder/domain/timevote/entity/TimeVoteStat.java @@ -9,6 +9,7 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.Version; import java.time.LocalDateTime; import lombok.Builder; import lombok.Getter; @@ -31,6 +32,9 @@ public class TimeVoteStat extends BaseEntity { @Column(nullable = false) private Long voteCount; + @Version + private Long version; + protected TimeVoteStat() {}; @Builder @@ -39,4 +43,16 @@ public TimeVoteStat(TimeVotePeriod period, LocalDateTime timeSlot, Long voteCoun this.timeSlot = timeSlot; this.voteCount = voteCount; } + + public static TimeVoteStat of(TimeVotePeriod period, LocalDateTime timeSlot, Long voteCount) { + return TimeVoteStat.builder() + .period(period) + .timeSlot(timeSlot) + .voteCount(voteCount) + .build(); + } + + public void addVotes(Long countToAdd) { + this.voteCount += countToAdd; + } } From e8e97b637067805597fd89e8af72709f759a09a5 Mon Sep 17 00:00:00 2001 From: endorsement0912 Date: Thu, 24 Jul 2025 12:06:10 +0900 Subject: [PATCH 21/21] =?UTF-8?q?[EA3-134]=20refactor=20:=20id=EB=AA=85=20?= =?UTF-8?q?statId=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../grep/neogulcoder/domain/timevote/entity/TimeVoteStat.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/grep/neogulcoder/domain/timevote/entity/TimeVoteStat.java b/src/main/java/grep/neogulcoder/domain/timevote/entity/TimeVoteStat.java index b008e689..3c6d3272 100644 --- a/src/main/java/grep/neogulcoder/domain/timevote/entity/TimeVoteStat.java +++ b/src/main/java/grep/neogulcoder/domain/timevote/entity/TimeVoteStat.java @@ -20,7 +20,7 @@ public class TimeVoteStat extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + private Long statId; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "period_id", nullable = false)