Skip to content

Commit eef7014

Browse files
authored
Merge pull request #200 from prgrms-web-devcourse-final-project/feature/EA3-134-study-optimal-time-vote
[EA3-134] feature : 시간 투표 통계 로직 구현 및 관련 예외 처리/검증 로직 정비
2 parents f28a12d + e8e97b6 commit eef7014

File tree

13 files changed

+371
-83
lines changed

13 files changed

+371
-83
lines changed

src/main/java/grep/neogulcoder/domain/timevote/controller/TimeVoteController.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@
55
import grep.neogulcoder.domain.timevote.dto.request.TimeVoteUpdateRequest;
66
import grep.neogulcoder.domain.timevote.dto.response.TimeVotePeriodResponse;
77
import grep.neogulcoder.domain.timevote.dto.response.TimeVoteResponse;
8-
import grep.neogulcoder.domain.timevote.dto.response.TimeVoteStatListResponse;
8+
import grep.neogulcoder.domain.timevote.dto.response.TimeVoteStatResponse;
99
import grep.neogulcoder.domain.timevote.dto.response.TimeVoteSubmissionStatusResponse;
1010
import grep.neogulcoder.domain.timevote.entity.TimeVotePeriod;
1111
import grep.neogulcoder.domain.timevote.service.TimeVotePeriodService;
1212
import grep.neogulcoder.domain.timevote.service.TimeVoteService;
13+
import grep.neogulcoder.domain.timevote.service.TimeVoteStatService;
1314
import grep.neogulcoder.global.auth.Principal;
1415
import grep.neogulcoder.global.response.ApiResponse;
1516
import jakarta.validation.Valid;
@@ -32,7 +33,7 @@ public class TimeVoteController implements TimeVoteSpecification {
3233

3334
private final TimeVotePeriodService timeVotePeriodService;
3435
private final TimeVoteService timeVoteService;
35-
// private final TimeVoteStatService timeVoteStatService;
36+
private final TimeVoteStatService timeVoteStatService;
3637

3738
@PostMapping("/periods")
3839
public ApiResponse<TimeVotePeriodResponse> createPeriod(
@@ -92,10 +93,11 @@ public ApiResponse<List<TimeVoteSubmissionStatusResponse>> getSubmissionStatusLi
9293
}
9394

9495
@GetMapping("/periods/stats")
95-
public ApiResponse<TimeVoteStatListResponse> getVoteStats(
96+
public ApiResponse<TimeVoteStatResponse> getVoteStats(
9697
@PathVariable("studyId") Long studyId,
9798
@AuthenticationPrincipal Principal userDetails
9899
) {
99-
return ApiResponse.success(new TimeVoteStatListResponse()); // mock response
100+
TimeVoteStatResponse response = timeVoteStatService.getStats(studyId, userDetails.getUserId());
101+
return ApiResponse.success(response);
100102
}
101103
}

src/main/java/grep/neogulcoder/domain/timevote/controller/TimeVoteSpecification.java

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,46 +16,46 @@ public interface TimeVoteSpecification {
1616

1717
@Operation(summary = "스터디 모임 일정 투표 기간 생성", description = "팀장이 가능한 시간 요청을 생성합니다.")
1818
ApiResponse<TimeVotePeriodResponse> createPeriod(
19-
@Parameter(description = "스터디 ID", example = "1") Long studyId,
19+
@Parameter(description = "스터디 ID", example = "6") Long studyId,
2020
@RequestBody @Valid TimeVotePeriodCreateRequest request,
2121
Principal userDetails
2222
);
2323

2424
@Operation(summary = "사용자가 제출한 시간 목록 조회", description = "해당 사용자가 이전에 제출한 시간대 목록을 조회합니다.")
2525
ApiResponse<TimeVoteResponse> getMyVotes(
26-
@Parameter(description = "스터디 ID", example = "1") Long studyId,
26+
@Parameter(description = "스터디 ID", example = "6") Long studyId,
2727
Principal userDetails
2828
);
2929

3030
@Operation(summary = "사용자 가능 시간대 제출", description = "스터디 멤버가 가능 시간을 제출합니다.")
3131
ApiResponse<TimeVoteResponse> submitVote(
32-
@Parameter(description = "스터디 ID", example = "1") Long studyId,
32+
@Parameter(description = "스터디 ID", example = "6") Long studyId,
3333
@RequestBody @Valid TimeVoteCreateRequest request,
3434
Principal userDetails
3535
);
3636

3737
@Operation(summary = "사용자 시간대 수정", description = "사용자가 기존에 제출한 시간을 수정합니다.")
3838
ApiResponse<TimeVoteResponse> updateVote(
39-
@Parameter(description = "스터디 ID", example = "1") Long studyId,
39+
@Parameter(description = "스터디 ID", example = "6") Long studyId,
4040
@RequestBody @Valid TimeVoteUpdateRequest request,
4141
Principal userDetails
4242
);
4343

4444
@Operation(summary = "사용자 전체 시간 삭제", description = "사용자가 제출한 시간 전체를 삭제합니다.")
4545
ApiResponse<Void> deleteAllVotes(
46-
@Parameter(description = "스터디 ID", example = "1") Long studyId,
46+
@Parameter(description = "스터디 ID", example = "6") Long studyId,
4747
Principal userDetails
4848
);
4949

5050
@Operation(summary = "투표 통계 조회", description = "투표 기간의 시간대별 통계 정보를 조회합니다.")
51-
ApiResponse<TimeVoteStatListResponse> getVoteStats(
52-
@Parameter(description = "스터디 ID", example = "1") Long studyId,
51+
ApiResponse<TimeVoteStatResponse> getVoteStats(
52+
@Parameter(description = "스터디 ID", example = "6") Long studyId,
5353
Principal userDetails
5454
);
5555

5656
@Operation(summary = "사용자별 제출 여부 조회", description = "특정 스터디의 모든 멤버별 시간 제출 여부를 반환합니다.")
5757
ApiResponse<List<TimeVoteSubmissionStatusResponse>> getSubmissionStatusList(
58-
@Parameter(description = "스터디 ID", example = "1") Long studyId,
58+
@Parameter(description = "스터디 ID", example = "6") Long studyId,
5959
Principal userDetails
6060
);
6161
}

src/main/java/grep/neogulcoder/domain/timevote/dto/response/TimeVoteStatListResponse.java

Lines changed: 0 additions & 26 deletions
This file was deleted.
Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,69 @@
11
package grep.neogulcoder.domain.timevote.dto.response;
22

33
import grep.neogulcoder.domain.timevote.entity.TimeVoteStat;
4+
import grep.neogulcoder.domain.timevote.entity.TimeVotePeriod;
45
import io.swagger.v3.oas.annotations.media.Schema;
5-
import java.time.LocalDateTime;
6+
import java.util.Comparator;
67
import lombok.Builder;
78
import lombok.Getter;
9+
import java.time.LocalDateTime;
10+
import java.util.List;
11+
import java.util.stream.Collectors;
812

913
@Getter
1014
@Schema(description = "스터디 모임 일정 조율 - 시간대별 통계 응답 DTO")
1115
public class TimeVoteStatResponse {
1216

13-
@Schema(description = "시간대", example = "2025-07-16T10:00:00")
14-
private LocalDateTime timeSlot;
17+
@Schema(description = "시작일", example = "2025-07-15")
18+
private LocalDateTime startDate;
1519

16-
@Schema(description = "해당 시간대의 투표 수", example = "3")
17-
private Long voteCount;
20+
@Schema(description = "종료일", example = "2025-07-22")
21+
private LocalDateTime endDate;
22+
23+
@Schema(
24+
description = "투표 통계 리스트",
25+
example = "[" +
26+
"{\"timeSlot\": \"2025-07-16T10:00:00\", \"voteCount\": 3}," +
27+
"{\"timeSlot\": \"2025-07-16T11:00:00\", \"voteCount\": 2}" +
28+
"]"
29+
)
30+
private List<TimeSlotStat> stats;
1831

1932
@Builder
20-
private TimeVoteStatResponse(LocalDateTime timeSlot, Long voteCount) {
21-
this.timeSlot = timeSlot;
22-
this.voteCount = voteCount;
33+
private TimeVoteStatResponse(LocalDateTime startDate, LocalDateTime endDate, List<TimeSlotStat> stats) {
34+
this.startDate = startDate;
35+
this.endDate = endDate;
36+
this.stats = stats;
37+
}
38+
39+
@Getter
40+
@Schema(description = "개별 시간대별 통계 DTO")
41+
public static class TimeSlotStat {
42+
43+
@Schema(description = "시간대", example = "2025-07-16T10:00:00")
44+
private LocalDateTime timeSlot;
45+
46+
@Schema(description = "해당 시간대의 투표 수", example = "3")
47+
private Long voteCount;
48+
49+
@Builder
50+
public TimeSlotStat(LocalDateTime timeSlot, Long voteCount) {
51+
this.timeSlot = timeSlot;
52+
this.voteCount = voteCount;
53+
}
2354
}
2455

25-
public static TimeVoteStatResponse from(TimeVoteStat stat) {
56+
public static TimeVoteStatResponse from(TimeVotePeriod period, List<TimeVoteStat> stats) {
2657
return TimeVoteStatResponse.builder()
27-
.timeSlot(stat.getTimeSlot())
28-
.voteCount(stat.getVoteCount())
58+
.startDate(period.getStartDate())
59+
.endDate(period.getEndDate())
60+
.stats(stats.stream()
61+
.sorted(Comparator.comparing(TimeVoteStat::getTimeSlot))
62+
.map(s -> TimeSlotStat.builder()
63+
.timeSlot(s.getTimeSlot())
64+
.voteCount(s.getVoteCount())
65+
.build())
66+
.collect(Collectors.toList()))
2967
.build();
3068
}
3169
}

src/main/java/grep/neogulcoder/domain/timevote/entity/TimeVotePeriod.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,17 @@ public TimeVotePeriod(Long periodId, Long studyId, LocalDateTime startDate, Loca
3636
this.startDate = startDate;
3737
this.endDate = endDate;
3838
}
39+
40+
@Override
41+
public boolean equals(Object o) {
42+
if (this == o) return true;
43+
if (o == null || getClass() != o.getClass()) return false;
44+
TimeVotePeriod that = (TimeVotePeriod) o;
45+
return periodId != null && periodId.equals(that.periodId);
46+
}
47+
48+
@Override
49+
public int hashCode() {
50+
return periodId != null ? periodId.hashCode() : 0;
51+
}
3952
}

src/main/java/grep/neogulcoder/domain/timevote/entity/TimeVoteStat.java

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import jakarta.persistence.Id;
1010
import jakarta.persistence.JoinColumn;
1111
import jakarta.persistence.ManyToOne;
12+
import jakarta.persistence.Version;
1213
import java.time.LocalDateTime;
1314
import lombok.Builder;
1415
import lombok.Getter;
@@ -19,7 +20,7 @@ public class TimeVoteStat extends BaseEntity {
1920

2021
@Id
2122
@GeneratedValue(strategy = GenerationType.IDENTITY)
22-
private Long id;
23+
private Long statId;
2324

2425
@ManyToOne(fetch = FetchType.LAZY)
2526
@JoinColumn(name = "period_id", nullable = false)
@@ -31,6 +32,9 @@ public class TimeVoteStat extends BaseEntity {
3132
@Column(nullable = false)
3233
private Long voteCount;
3334

35+
@Version
36+
private Long version;
37+
3438
protected TimeVoteStat() {};
3539

3640
@Builder
@@ -39,4 +43,16 @@ public TimeVoteStat(TimeVotePeriod period, LocalDateTime timeSlot, Long voteCoun
3943
this.timeSlot = timeSlot;
4044
this.voteCount = voteCount;
4145
}
46+
47+
public static TimeVoteStat of(TimeVotePeriod period, LocalDateTime timeSlot, Long voteCount) {
48+
return TimeVoteStat.builder()
49+
.period(period)
50+
.timeSlot(timeSlot)
51+
.voteCount(voteCount)
52+
.build();
53+
}
54+
55+
public void addVotes(Long countToAdd) {
56+
this.voteCount += countToAdd;
57+
}
4258
}

src/main/java/grep/neogulcoder/domain/timevote/exception/code/TimeVoteErrorCode.java

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,30 @@
77
@Getter
88
public enum TimeVoteErrorCode implements ErrorCode {
99

10-
FORBIDDEN_TIME_VOTE_CREATE("T001", HttpStatus.BAD_REQUEST, "모임 일정 조율 투표 생성은 스터디장만 가능합니다."),
11-
STUDY_NOT_FOUND("T002", HttpStatus.NOT_FOUND, "해당 스터디를 찾을 수 없습니다."),
12-
STUDY_MEMBER_NOT_FOUND("T003", HttpStatus.NOT_FOUND, "해당 스터디의 멤버가 아닙니다."),
13-
TIME_VOTE_PERIOD_NOT_FOUND("T004", HttpStatus.NOT_FOUND, "해당 스터디에 대한 투표 기간이 존재하지 않습니다."),
14-
INVALID_TIME_VOTE_PERIOD("T005", HttpStatus.BAD_REQUEST, "모일 일정 조율 기간은 최대 7일까지 설정할 수 있습니다."),
15-
TIME_VOTE_ALREADY_SUBMITTED("T006", HttpStatus.CONFLICT, "이미 투표를 제출했습니다. PUT 요청으로 기존의 제출한 투표를 수정하세요."),
16-
TIME_VOTE_OUT_OF_RANGE("T007", HttpStatus.BAD_REQUEST, "선택한 시간이 투표 기간을 벗어났습니다."),
17-
TIME_VOTE_NOT_FOUND("T008", HttpStatus.BAD_REQUEST, "시간 투표 이력이 존재하지 않습니다."),
18-
TIME_VOTE_STAT_CONFLICT("T009", HttpStatus.CONFLICT, "투표 통계 저장 중 충돌이 발생했습니다. 다시 시도해주세요."),
19-
TIME_VOTE_THREAD_INTERRUPTED("T010", HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 스레드 오류가 발생했습니다. 다시 시도해주세요.");
10+
// Access To Vote (e.g. 멤버 아님 등 접근 문제)
11+
STUDY_MEMBER_NOT_FOUND("ATV_001", HttpStatus.NOT_FOUND, "해당 스터디의 멤버가 아닙니다."),
12+
TIME_VOTE_PERIOD_NOT_FOUND("ATV_002", HttpStatus.NOT_FOUND, "해당 스터디에 대한 투표 기간이 존재하지 않습니다."),
13+
14+
// Time Vote Period (e.g. 기간 생성 관련)
15+
FORBIDDEN_TIME_VOTE_CREATE("TVP_001", HttpStatus.BAD_REQUEST, "모임 일정 조율 투표 생성은 스터디장만 가능합니다."),
16+
STUDY_NOT_FOUND("TVP_002", HttpStatus.NOT_FOUND, "해당 스터디를 찾을 수 없습니다."),
17+
INVALID_TIME_VOTE_PERIOD("TVP_003", HttpStatus.BAD_REQUEST, "모일 일정 조율 기간은 최대 7일까지 설정할 수 있습니다."),
18+
TIME_VOTE_PERIOD_START_DATE_IN_PAST("TVP_004", HttpStatus.BAD_REQUEST, "투표 시작일은 현재 시각보다 이전일 수 없습니다."),
19+
TIME_VOTE_INVALID_DATE_RANGE("TVP_005", HttpStatus.BAD_REQUEST, "종료일은 시작일보다 이후여야 합니다."),
20+
21+
// Time Vote, Time Vote Stats (e.g. 투표 기간 관련)
22+
TIME_VOTE_OUT_OF_RANGE("TVAS_001", HttpStatus.BAD_REQUEST, "선택한 시간이 투표 기간을 벗어났습니다."),
23+
24+
// Time Vote (e.g. 투표 제출, 수정)
25+
TIME_VOTE_ALREADY_SUBMITTED("TV_001", HttpStatus.CONFLICT, "이미 투표를 제출했습니다. PUT 요청으로 기존의 제출한 투표를 수정하세요."),
26+
TIME_VOTE_NOT_FOUND("TV_002", HttpStatus.BAD_REQUEST, "시간 투표 이력이 존재하지 않습니다."),
27+
TIME_VOTE_DUPLICATED_TIME_SLOT("TV_003", HttpStatus.BAD_REQUEST, "중복된 시간이 포함되어 있습니다."),
28+
TIME_VOTE_PERIOD_EXPIRED("TV_004", HttpStatus.BAD_REQUEST, "투표 기간이 만료되었습니다."),
29+
TIME_VOTE_EMPTY("TV_005", HttpStatus.BAD_REQUEST, "한 개 이상의 시간을 선택해주세요."),
30+
31+
// Time Vote Stats (e.g. 통계 충돌 등)
32+
TIME_VOTE_STAT_CONFLICT("TVS_001", HttpStatus.CONFLICT, "투표 통계 저장 중 충돌이 발생했습니다. 다시 시도해주세요."),
33+
TIME_VOTE_THREAD_INTERRUPTED("TVS_002", HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 스레드 오류가 발생했습니다. 다시 시도해주세요.");
2034

2135
private final String code;
2236
private final HttpStatus status;
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package grep.neogulcoder.domain.timevote.repository;
2+
3+
import com.querydsl.core.Tuple;
4+
import com.querydsl.jpa.impl.JPAQueryFactory;
5+
import grep.neogulcoder.domain.timevote.entity.QTimeVote;
6+
import grep.neogulcoder.domain.timevote.entity.QTimeVoteStat;
7+
import grep.neogulcoder.domain.timevote.entity.TimeVotePeriod;
8+
import grep.neogulcoder.domain.timevote.entity.TimeVoteStat;
9+
import jakarta.persistence.EntityManager;
10+
import java.time.LocalDateTime;
11+
import java.util.List;
12+
import java.util.stream.Collectors;
13+
import org.springframework.stereotype.Repository;
14+
15+
@Repository
16+
public class TimeVoteStatQueryRepository {
17+
18+
private final JPAQueryFactory queryFactory;
19+
private final EntityManager em;
20+
21+
public TimeVoteStatQueryRepository(EntityManager em) {
22+
this.em = em;
23+
this.queryFactory = new JPAQueryFactory(em);
24+
}
25+
26+
public List<TimeVoteStat> countStatsByPeriod(TimeVotePeriod period) {
27+
QTimeVote timeVote = QTimeVote.timeVote;
28+
29+
List<Tuple> result = queryFactory
30+
.select(timeVote.timeSlot, timeVote.count())
31+
.from(timeVote)
32+
.where(timeVote.period.eq(period))
33+
.groupBy(timeVote.timeSlot)
34+
.fetch();
35+
36+
return result.stream()
37+
.map(tuple -> TimeVoteStat.of(period, tuple.get(timeVote.timeSlot), tuple.get(timeVote.count())))
38+
.collect(Collectors.toList());
39+
}
40+
41+
public void incrementOrInsert(TimeVotePeriod period, LocalDateTime slot, Long countToAdd) {
42+
QTimeVoteStat stat = QTimeVoteStat.timeVoteStat;
43+
44+
TimeVoteStat existing = queryFactory
45+
.selectFrom(stat)
46+
.where(stat.period.eq(period), stat.timeSlot.eq(slot))
47+
.fetchOne();
48+
49+
if (existing != null) {
50+
existing.addVotes(countToAdd);
51+
} else {
52+
TimeVoteStat newStat = TimeVoteStat.of(period, slot, countToAdd);
53+
em.persist(newStat);
54+
}
55+
}
56+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
package grep.neogulcoder.domain.timevote.repository;
22

3+
import grep.neogulcoder.domain.timevote.entity.TimeVotePeriod;
34
import grep.neogulcoder.domain.timevote.entity.TimeVoteStat;
5+
import java.util.List;
46
import org.springframework.data.jpa.repository.JpaRepository;
7+
import org.springframework.data.jpa.repository.Query;
8+
import org.springframework.data.repository.query.Param;
59

610
public interface TimeVoteStatRepository extends JpaRepository<TimeVoteStat, Long> {
711

812
void deleteAllByPeriod_StudyId(Long studyId);
13+
14+
void deleteByPeriod(TimeVotePeriod period);
15+
16+
@Query("SELECT s FROM TimeVoteStat s WHERE s.period.periodId = :periodId")
17+
List<TimeVoteStat> findAllByPeriodId(@Param("periodId") Long periodId);
918
}

0 commit comments

Comments
 (0)