From 04ebfd669aa571c7770fb9a61afc0c55bbab482a Mon Sep 17 00:00:00 2001 From: catomat0 Date: Sat, 12 Sep 2026 04:08:44 +0900 Subject: [PATCH] =?UTF-8?q?chore:=20=EC=9D=BC=EB=B3=84/=EC=9B=94=EB=B3=84?= =?UTF-8?q?=20=EA=B7=B8=EB=A3=B9=20=EC=A0=9C=EA=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../group/controller/GroupController.java | 43 ++++++++++++++ .../GroupDailyWorkoutBarResponseDTO.java | 17 ++++++ .../GroupMemberWorkoutBarResponseDTO.java | 30 ++++++++++ .../GroupMonthlyWorkoutBarResponseDTO.java | 17 ++++++ .../GroupDailyWorkoutRecordRepository.java | 52 +++++++++++++++++ ...GroupMemberWorkoutAggregateProjection.java | 11 ++++ .../service/GroupWorkoutRecordService.java | 56 +++++++++++++++++++ .../GroupWorkoutRecordServiceTest.java | 4 +- 8 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/atmd/backend/domain/group/dto/response/GroupDailyWorkoutBarResponseDTO.java create mode 100644 src/main/java/com/atmd/backend/domain/group/dto/response/GroupMemberWorkoutBarResponseDTO.java create mode 100644 src/main/java/com/atmd/backend/domain/group/dto/response/GroupMonthlyWorkoutBarResponseDTO.java create mode 100644 src/main/java/com/atmd/backend/domain/group/repository/GroupMemberWorkoutAggregateProjection.java diff --git a/src/main/java/com/atmd/backend/domain/group/controller/GroupController.java b/src/main/java/com/atmd/backend/domain/group/controller/GroupController.java index ca81b0c..ecf5df7 100644 --- a/src/main/java/com/atmd/backend/domain/group/controller/GroupController.java +++ b/src/main/java/com/atmd/backend/domain/group/controller/GroupController.java @@ -1,11 +1,14 @@ package com.atmd.backend.domain.group.controller; import com.atmd.backend.domain.group.dto.request.GroupCreateRequestDTO; +import com.atmd.backend.domain.group.dto.response.GroupDailyWorkoutBarResponseDTO; import com.atmd.backend.domain.group.dto.response.GroupInviteLinkResponseDTO; import com.atmd.backend.domain.group.dto.response.GroupJoinResponseDTO; import com.atmd.backend.domain.group.dto.response.GroupMemberResponseDTO; +import com.atmd.backend.domain.group.dto.response.GroupMonthlyWorkoutBarResponseDTO; import com.atmd.backend.domain.group.dto.response.GroupResponseDTO; import com.atmd.backend.domain.group.service.GroupService; +import com.atmd.backend.domain.group.service.GroupWorkoutRecordService; import com.atmd.backend.global.auth.util.SecurityUtil; import com.atmd.backend.global.common.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; @@ -13,9 +16,12 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; +import java.time.YearMonth; import java.util.List; @Tag(name = "Group", description = "그룹 API") @@ -25,6 +31,7 @@ public class GroupController { private final GroupService groupService; + private final GroupWorkoutRecordService groupWorkoutRecordService; @Operation( summary = "그룹 생성", @@ -108,4 +115,40 @@ public ResponseEntity> leaveGroup(@PathVariable Long groupId) groupService.leaveGroup(groupId, SecurityUtil.getCurrentUserId()); return ResponseEntity.ok(ApiResponse.success(null)); } + + @Operation( + summary = "그룹 일별 운동량 조회 (바 차트용)", + description = """ + 특정 날짜(기본값: 오늘, Asia/Seoul) 기준으로 그룹에 소속된 모든 멤버의 + 운동별(CHAIR_STAND / PUSH_UP / SIT_UP / PLANK) 실행량을 반환합니다. + 운동 기록이 없는 멤버는 0으로 채워져 함께 반환됩니다. + """ + ) + @GetMapping("/{groupId}/workout-records/daily") + public ResponseEntity> getDailyWorkoutBar( + @PathVariable Long groupId, + @Parameter(description = "조회 날짜 (yyyy-MM-dd). 미지정 시 오늘 기준.") + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) { + return ResponseEntity.ok(ApiResponse.success( + groupWorkoutRecordService.getDailyBar(groupId, SecurityUtil.getCurrentUserId(), date))); + } + + @Operation( + summary = "그룹 월별 운동량 조회 (바 차트용)", + description = """ + 특정 월(기본값: 이번 달, Asia/Seoul) 기준으로 그룹에 소속된 모든 멤버의 + 운동별(CHAIR_STAND / PUSH_UP / SIT_UP / PLANK) 누적 실행량을 반환합니다. + 운동 기록이 없는 멤버는 0으로 채워져 함께 반환됩니다. + """ + ) + @GetMapping("/{groupId}/workout-records/monthly") + public ResponseEntity> getMonthlyWorkoutBar( + @PathVariable Long groupId, + @Parameter(description = "조회 연월 (yyyy-MM). 미지정 시 이번 달 기준.") + @RequestParam(required = false) + @DateTimeFormat(pattern = "yyyy-MM") YearMonth yearMonth) { + return ResponseEntity.ok(ApiResponse.success( + groupWorkoutRecordService.getMonthlyBar(groupId, SecurityUtil.getCurrentUserId(), yearMonth))); + } } diff --git a/src/main/java/com/atmd/backend/domain/group/dto/response/GroupDailyWorkoutBarResponseDTO.java b/src/main/java/com/atmd/backend/domain/group/dto/response/GroupDailyWorkoutBarResponseDTO.java new file mode 100644 index 0000000..1a64d7b --- /dev/null +++ b/src/main/java/com/atmd/backend/domain/group/dto/response/GroupDailyWorkoutBarResponseDTO.java @@ -0,0 +1,17 @@ +package com.atmd.backend.domain.group.dto.response; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; +import java.util.List; + +@Getter +@Builder +public class GroupDailyWorkoutBarResponseDTO { + + private Long groupId; + private String groupName; + private LocalDate date; + private List members; +} diff --git a/src/main/java/com/atmd/backend/domain/group/dto/response/GroupMemberWorkoutBarResponseDTO.java b/src/main/java/com/atmd/backend/domain/group/dto/response/GroupMemberWorkoutBarResponseDTO.java new file mode 100644 index 0000000..fe5b6a4 --- /dev/null +++ b/src/main/java/com/atmd/backend/domain/group/dto/response/GroupMemberWorkoutBarResponseDTO.java @@ -0,0 +1,30 @@ +package com.atmd.backend.domain.group.dto.response; + +import com.atmd.backend.domain.group.repository.GroupMemberWorkoutAggregateProjection; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class GroupMemberWorkoutBarResponseDTO { + + private Long userId; + private String nickname; + private boolean isOwner; + private long chairStandCount; + private long pushUpCount; + private long sitUpCount; + private long plankDurationMs; + + public static GroupMemberWorkoutBarResponseDTO from(GroupMemberWorkoutAggregateProjection p) { + return GroupMemberWorkoutBarResponseDTO.builder() + .userId(p.getUserId()) + .nickname(p.getNickname()) + .isOwner(Boolean.TRUE.equals(p.getIsOwner())) + .chairStandCount(p.getChairStandCount() == null ? 0L : p.getChairStandCount()) + .pushUpCount(p.getPushUpCount() == null ? 0L : p.getPushUpCount()) + .sitUpCount(p.getSitUpCount() == null ? 0L : p.getSitUpCount()) + .plankDurationMs(p.getPlankDurationMs() == null ? 0L : p.getPlankDurationMs()) + .build(); + } +} diff --git a/src/main/java/com/atmd/backend/domain/group/dto/response/GroupMonthlyWorkoutBarResponseDTO.java b/src/main/java/com/atmd/backend/domain/group/dto/response/GroupMonthlyWorkoutBarResponseDTO.java new file mode 100644 index 0000000..db44698 --- /dev/null +++ b/src/main/java/com/atmd/backend/domain/group/dto/response/GroupMonthlyWorkoutBarResponseDTO.java @@ -0,0 +1,17 @@ +package com.atmd.backend.domain.group.dto.response; + +import lombok.Builder; +import lombok.Getter; + +import java.time.YearMonth; +import java.util.List; + +@Getter +@Builder +public class GroupMonthlyWorkoutBarResponseDTO { + + private Long groupId; + private String groupName; + private YearMonth yearMonth; + private List members; +} diff --git a/src/main/java/com/atmd/backend/domain/group/repository/GroupDailyWorkoutRecordRepository.java b/src/main/java/com/atmd/backend/domain/group/repository/GroupDailyWorkoutRecordRepository.java index 5d5de2c..2af5e59 100644 --- a/src/main/java/com/atmd/backend/domain/group/repository/GroupDailyWorkoutRecordRepository.java +++ b/src/main/java/com/atmd/backend/domain/group/repository/GroupDailyWorkoutRecordRepository.java @@ -7,9 +7,61 @@ import org.springframework.data.repository.query.Param; import java.time.LocalDate; +import java.util.List; public interface GroupDailyWorkoutRecordRepository extends JpaRepository { + @Query(value = """ + SELECT + u.id AS userId, + u.nickname AS nickname, + (u.id = g.owner_id) AS isOwner, + COALESCE(r.chair_stand_count, 0) AS chairStandCount, + COALESCE(r.push_up_count, 0) AS pushUpCount, + COALESCE(r.sit_up_count, 0) AS sitUpCount, + COALESCE(r.plank_duration_ms, 0) AS plankDurationMs + FROM group_users gu + JOIN users u ON u.id = gu.user_id + JOIN groups g ON g.id = gu.group_id + LEFT JOIN group_daily_workout_records r + ON r.group_id = gu.group_id + AND r.user_id = gu.user_id + AND r.workout_date = :workoutDate + WHERE gu.group_id = :groupId + ORDER BY u.id + """, nativeQuery = true) + List aggregateByGroupAndDate( + @Param("groupId") Long groupId, + @Param("workoutDate") LocalDate workoutDate + ); + + @Query(value = """ + SELECT + u.id AS userId, + u.nickname AS nickname, + (u.id = g.owner_id) AS isOwner, + COALESCE(SUM(r.chair_stand_count), 0) AS chairStandCount, + COALESCE(SUM(r.push_up_count), 0) AS pushUpCount, + COALESCE(SUM(r.sit_up_count), 0) AS sitUpCount, + COALESCE(SUM(r.plank_duration_ms), 0) AS plankDurationMs + FROM group_users gu + JOIN users u ON u.id = gu.user_id + JOIN groups g ON g.id = gu.group_id + LEFT JOIN group_daily_workout_records r + ON r.group_id = gu.group_id + AND r.user_id = gu.user_id + AND r.workout_date >= :startDate + AND r.workout_date < :endDateExclusive + WHERE gu.group_id = :groupId + GROUP BY u.id, u.nickname, g.owner_id + ORDER BY u.id + """, nativeQuery = true) + List aggregateByGroupAndDateRange( + @Param("groupId") Long groupId, + @Param("startDate") LocalDate startDate, + @Param("endDateExclusive") LocalDate endDateExclusive + ); + @Modifying @Query(value = """ INSERT INTO group_daily_workout_records ( diff --git a/src/main/java/com/atmd/backend/domain/group/repository/GroupMemberWorkoutAggregateProjection.java b/src/main/java/com/atmd/backend/domain/group/repository/GroupMemberWorkoutAggregateProjection.java new file mode 100644 index 0000000..1315515 --- /dev/null +++ b/src/main/java/com/atmd/backend/domain/group/repository/GroupMemberWorkoutAggregateProjection.java @@ -0,0 +1,11 @@ +package com.atmd.backend.domain.group.repository; + +public interface GroupMemberWorkoutAggregateProjection { + Long getUserId(); + String getNickname(); + Boolean getIsOwner(); + Long getChairStandCount(); + Long getPushUpCount(); + Long getSitUpCount(); + Long getPlankDurationMs(); +} diff --git a/src/main/java/com/atmd/backend/domain/group/service/GroupWorkoutRecordService.java b/src/main/java/com/atmd/backend/domain/group/service/GroupWorkoutRecordService.java index 60e109a..def231d 100644 --- a/src/main/java/com/atmd/backend/domain/group/service/GroupWorkoutRecordService.java +++ b/src/main/java/com/atmd/backend/domain/group/service/GroupWorkoutRecordService.java @@ -1,22 +1,33 @@ package com.atmd.backend.domain.group.service; import com.atmd.backend.domain.fitness.enums.ExerciseType; +import com.atmd.backend.domain.group.dto.response.GroupDailyWorkoutBarResponseDTO; +import com.atmd.backend.domain.group.dto.response.GroupMemberWorkoutBarResponseDTO; +import com.atmd.backend.domain.group.dto.response.GroupMonthlyWorkoutBarResponseDTO; +import com.atmd.backend.domain.group.entity.Group; import com.atmd.backend.domain.group.entity.GroupUser; +import com.atmd.backend.domain.group.exception.GroupErrorCode; import com.atmd.backend.domain.group.repository.GroupDailyWorkoutRecordRepository; +import com.atmd.backend.domain.group.repository.GroupRepository; import com.atmd.backend.domain.group.repository.GroupUserRepository; +import com.atmd.backend.global.common.exception.GeneralException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.YearMonth; import java.time.ZoneId; +import java.util.List; @Service @RequiredArgsConstructor public class GroupWorkoutRecordService { private static final ZoneId SERVER_ZONE = ZoneId.of("Asia/Seoul"); + private final GroupRepository groupRepository; private final GroupUserRepository groupUserRepository; private final GroupDailyWorkoutRecordRepository groupDailyWorkoutRecordRepository; @@ -49,4 +60,49 @@ public void accumulateCompletedWorkout( ); } } + + @Transactional(readOnly = true) + public GroupDailyWorkoutBarResponseDTO getDailyBar(Long groupId, Long viewerId, LocalDate date) { + Group group = findGroupAndCheckMembership(groupId, viewerId); + LocalDate targetDate = date != null ? date : LocalDate.now(SERVER_ZONE); + List members = groupDailyWorkoutRecordRepository + .aggregateByGroupAndDate(groupId, targetDate) + .stream() + .map(GroupMemberWorkoutBarResponseDTO::from) + .toList(); + return GroupDailyWorkoutBarResponseDTO.builder() + .groupId(group.getId()) + .groupName(group.getName()) + .date(targetDate) + .members(members) + .build(); + } + + @Transactional(readOnly = true) + public GroupMonthlyWorkoutBarResponseDTO getMonthlyBar(Long groupId, Long viewerId, YearMonth yearMonth) { + Group group = findGroupAndCheckMembership(groupId, viewerId); + YearMonth targetMonth = yearMonth != null ? yearMonth : YearMonth.now(SERVER_ZONE); + LocalDate startDate = targetMonth.atDay(1); + LocalDate endDateExclusive = targetMonth.plusMonths(1).atDay(1); + List members = groupDailyWorkoutRecordRepository + .aggregateByGroupAndDateRange(groupId, startDate, endDateExclusive) + .stream() + .map(GroupMemberWorkoutBarResponseDTO::from) + .toList(); + return GroupMonthlyWorkoutBarResponseDTO.builder() + .groupId(group.getId()) + .groupName(group.getName()) + .yearMonth(targetMonth) + .members(members) + .build(); + } + + private Group findGroupAndCheckMembership(Long groupId, Long userId) { + Group group = groupRepository.findByIdAndIsDeletedFalse(groupId) + .orElseThrow(() -> new GeneralException(GroupErrorCode.GROUP_NOT_FOUND)); + if (!groupUserRepository.existsByGroupIdAndUserId(groupId, userId)) { + throw new GeneralException(GroupErrorCode.ACCESS_DENIED); + } + return group; + } } diff --git a/src/test/java/com/atmd/backend/domain/group/service/GroupWorkoutRecordServiceTest.java b/src/test/java/com/atmd/backend/domain/group/service/GroupWorkoutRecordServiceTest.java index ed6c12a..c8a39ac 100644 --- a/src/test/java/com/atmd/backend/domain/group/service/GroupWorkoutRecordServiceTest.java +++ b/src/test/java/com/atmd/backend/domain/group/service/GroupWorkoutRecordServiceTest.java @@ -4,6 +4,7 @@ import com.atmd.backend.domain.group.entity.Group; import com.atmd.backend.domain.group.entity.GroupUser; import com.atmd.backend.domain.group.repository.GroupDailyWorkoutRecordRepository; +import com.atmd.backend.domain.group.repository.GroupRepository; import com.atmd.backend.domain.group.repository.GroupUserRepository; import org.junit.jupiter.api.Test; @@ -18,11 +19,12 @@ import static org.mockito.Mockito.when; class GroupWorkoutRecordServiceTest { + private final GroupRepository groupRepository = mock(GroupRepository.class); private final GroupUserRepository groupUserRepository = mock(GroupUserRepository.class); private final GroupDailyWorkoutRecordRepository recordRepository = mock(GroupDailyWorkoutRecordRepository.class); private final GroupWorkoutRecordService service = - new GroupWorkoutRecordService(groupUserRepository, recordRepository); + new GroupWorkoutRecordService(groupRepository, groupUserRepository, recordRepository); @Test void accumulatesSitUpCountForMembershipCreatedBeforeWorkout() {