From 08ce6c275feb8e3f0d78587f08f2fd518baef139 Mon Sep 17 00:00:00 2001 From: catomat0 Date: Sat, 12 Sep 2026 04:42:39 +0900 Subject: [PATCH] =?UTF-8?q?chore:=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GroupDailyWorkoutBarResponseDTO.java | 2 +- .../response/GroupExerciseBarSectionDTO.java | 17 ++ .../response/GroupExerciseMemberValueDTO.java | 14 ++ .../response/GroupMemberDailyRecordDTO.java | 18 ++ .../GroupMemberWorkoutBarResponseDTO.java | 30 ---- .../GroupMonthlyMemberSummaryDTO.java | 20 +++ .../GroupMonthlyWorkoutBarResponseDTO.java | 3 +- .../GroupDailyRecordItemProjection.java | 12 ++ .../GroupDailyWorkoutRecordRepository.java | 30 ++-- .../service/GroupWorkoutRecordService.java | 166 ++++++++++++++++-- 10 files changed, 248 insertions(+), 64 deletions(-) create mode 100644 src/main/java/com/atmd/backend/domain/group/dto/response/GroupExerciseBarSectionDTO.java create mode 100644 src/main/java/com/atmd/backend/domain/group/dto/response/GroupExerciseMemberValueDTO.java create mode 100644 src/main/java/com/atmd/backend/domain/group/dto/response/GroupMemberDailyRecordDTO.java delete 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/GroupMonthlyMemberSummaryDTO.java create mode 100644 src/main/java/com/atmd/backend/domain/group/repository/GroupDailyRecordItemProjection.java 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 index 1a64d7b..4d5db11 100644 --- 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 @@ -13,5 +13,5 @@ public class GroupDailyWorkoutBarResponseDTO { private Long groupId; private String groupName; private LocalDate date; - private List members; + private List exercises; } diff --git a/src/main/java/com/atmd/backend/domain/group/dto/response/GroupExerciseBarSectionDTO.java b/src/main/java/com/atmd/backend/domain/group/dto/response/GroupExerciseBarSectionDTO.java new file mode 100644 index 0000000..5b02957 --- /dev/null +++ b/src/main/java/com/atmd/backend/domain/group/dto/response/GroupExerciseBarSectionDTO.java @@ -0,0 +1,17 @@ +package com.atmd.backend.domain.group.dto.response; + +import com.atmd.backend.domain.fitness.enums.ExerciseType; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +public class GroupExerciseBarSectionDTO { + + private ExerciseType type; + private String unit; + private long topValue; + private List members; +} diff --git a/src/main/java/com/atmd/backend/domain/group/dto/response/GroupExerciseMemberValueDTO.java b/src/main/java/com/atmd/backend/domain/group/dto/response/GroupExerciseMemberValueDTO.java new file mode 100644 index 0000000..02d823b --- /dev/null +++ b/src/main/java/com/atmd/backend/domain/group/dto/response/GroupExerciseMemberValueDTO.java @@ -0,0 +1,14 @@ +package com.atmd.backend.domain.group.dto.response; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class GroupExerciseMemberValueDTO { + + private Long userId; + private String nickname; + private boolean isOwner; + private long value; +} diff --git a/src/main/java/com/atmd/backend/domain/group/dto/response/GroupMemberDailyRecordDTO.java b/src/main/java/com/atmd/backend/domain/group/dto/response/GroupMemberDailyRecordDTO.java new file mode 100644 index 0000000..50ecd9d --- /dev/null +++ b/src/main/java/com/atmd/backend/domain/group/dto/response/GroupMemberDailyRecordDTO.java @@ -0,0 +1,18 @@ +package com.atmd.backend.domain.group.dto.response; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +@Builder +public class GroupMemberDailyRecordDTO { + + private LocalDate date; + private int exerciseTypeCount; + private long chairStandCount; + private long pushUpCount; + private long sitUpCount; + private long plankDurationMs; +} 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 deleted file mode 100644 index fe5b6a4..0000000 --- a/src/main/java/com/atmd/backend/domain/group/dto/response/GroupMemberWorkoutBarResponseDTO.java +++ /dev/null @@ -1,30 +0,0 @@ -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/GroupMonthlyMemberSummaryDTO.java b/src/main/java/com/atmd/backend/domain/group/dto/response/GroupMonthlyMemberSummaryDTO.java new file mode 100644 index 0000000..28ed47a --- /dev/null +++ b/src/main/java/com/atmd/backend/domain/group/dto/response/GroupMonthlyMemberSummaryDTO.java @@ -0,0 +1,20 @@ +package com.atmd.backend.domain.group.dto.response; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +public class GroupMonthlyMemberSummaryDTO { + + private int rank; + private Long userId; + private String nickname; + private boolean isOwner; + private double executionRate; + private int executedDays; + private int totalExerciseTypes; + private List days; +} 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 index db44698..46ec9c5 100644 --- 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 @@ -13,5 +13,6 @@ public class GroupMonthlyWorkoutBarResponseDTO { private Long groupId; private String groupName; private YearMonth yearMonth; - private List members; + private int daysInMonth; + private List members; } diff --git a/src/main/java/com/atmd/backend/domain/group/repository/GroupDailyRecordItemProjection.java b/src/main/java/com/atmd/backend/domain/group/repository/GroupDailyRecordItemProjection.java new file mode 100644 index 0000000..fec164f --- /dev/null +++ b/src/main/java/com/atmd/backend/domain/group/repository/GroupDailyRecordItemProjection.java @@ -0,0 +1,12 @@ +package com.atmd.backend.domain.group.repository; + +import java.time.LocalDate; + +public interface GroupDailyRecordItemProjection { + Long getUserId(); + LocalDate getWorkoutDate(); + Long getChairStandCount(); + Long getPushUpCount(); + Long getSitUpCount(); + Long getPlankDurationMs(); +} 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 2af5e59..fa74c23 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 @@ -37,26 +37,18 @@ List aggregateByGroupAndDate( @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 + r.user_id AS userId, + r.workout_date AS workoutDate, + r.chair_stand_count AS chairStandCount, + r.push_up_count AS pushUpCount, + r.sit_up_count AS sitUpCount, + r.plank_duration_ms AS plankDurationMs + FROM group_daily_workout_records r + WHERE r.group_id = :groupId + AND r.workout_date >= :startDate + AND r.workout_date < :endDateExclusive """, nativeQuery = true) - List aggregateByGroupAndDateRange( + List findItemsByGroupAndDateRange( @Param("groupId") Long groupId, @Param("startDate") LocalDate startDate, @Param("endDateExclusive") LocalDate endDateExclusive 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 def231d..f4f419e 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 @@ -2,12 +2,17 @@ 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.GroupExerciseBarSectionDTO; +import com.atmd.backend.domain.group.dto.response.GroupExerciseMemberValueDTO; +import com.atmd.backend.domain.group.dto.response.GroupMemberDailyRecordDTO; +import com.atmd.backend.domain.group.dto.response.GroupMonthlyMemberSummaryDTO; 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.GroupDailyRecordItemProjection; import com.atmd.backend.domain.group.repository.GroupDailyWorkoutRecordRepository; +import com.atmd.backend.domain.group.repository.GroupMemberWorkoutAggregateProjection; import com.atmd.backend.domain.group.repository.GroupRepository; import com.atmd.backend.domain.group.repository.GroupUserRepository; import com.atmd.backend.global.common.exception.GeneralException; @@ -20,12 +25,19 @@ import java.time.LocalDateTime; import java.time.YearMonth; import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.function.ToLongFunction; @Service @RequiredArgsConstructor public class GroupWorkoutRecordService { private static final ZoneId SERVER_ZONE = ZoneId.of("Asia/Seoul"); + private static final String COUNT_UNIT = "COUNT"; + private static final String MS_UNIT = "MS"; private final GroupRepository groupRepository; private final GroupUserRepository groupUserRepository; @@ -65,16 +77,22 @@ public void accumulateCompletedWorkout( 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(); + + List rows = + groupDailyWorkoutRecordRepository.aggregateByGroupAndDate(groupId, targetDate); + + List exercises = List.of( + buildExerciseSection(ExerciseType.CHAIR_STAND, COUNT_UNIT, rows, this::chairStand), + buildExerciseSection(ExerciseType.PUSH_UP, COUNT_UNIT, rows, this::pushUp), + buildExerciseSection(ExerciseType.SIT_UP, COUNT_UNIT, rows, this::sitUp), + buildExerciseSection(ExerciseType.PLANK, MS_UNIT, rows, this::plankMs) + ); + return GroupDailyWorkoutBarResponseDTO.builder() .groupId(group.getId()) .groupName(group.getName()) .date(targetDate) - .members(members) + .exercises(exercises) .build(); } @@ -84,19 +102,141 @@ public GroupMonthlyWorkoutBarResponseDTO getMonthlyBar(Long groupId, Long viewer 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(); + int daysInMonth = targetMonth.lengthOfMonth(); + Long ownerId = group.getOwner().getId(); + + List memberships = groupUserRepository.findAllByGroupIdWithUser(groupId); + List items = + groupDailyWorkoutRecordRepository.findItemsByGroupAndDateRange(groupId, startDate, endDateExclusive); + + Map> itemsByUserId = new HashMap<>(); + for (GroupDailyRecordItemProjection item : items) { + itemsByUserId.computeIfAbsent(item.getUserId(), k -> new ArrayList<>()).add(item); + } + + List summaries = new ArrayList<>(); + for (GroupUser membership : memberships) { + Long userId = membership.getUser().getId(); + List userItems = + itemsByUserId.getOrDefault(userId, List.of()); + summaries.add(buildMemberMonthlySummary( + membership, ownerId, userItems, daysInMonth)); + } + + summaries.sort(Comparator + .comparingDouble(GroupMonthlyMemberSummaryDTO::getExecutionRate).reversed() + .thenComparingInt(GroupMonthlyMemberSummaryDTO::getTotalExerciseTypes).reversed() + .thenComparing(GroupMonthlyMemberSummaryDTO::getUserId)); + + List ranked = new ArrayList<>(summaries.size()); + for (int i = 0; i < summaries.size(); i++) { + GroupMonthlyMemberSummaryDTO s = summaries.get(i); + ranked.add(GroupMonthlyMemberSummaryDTO.builder() + .rank(i + 1) + .userId(s.getUserId()) + .nickname(s.getNickname()) + .isOwner(s.isOwner()) + .executionRate(s.getExecutionRate()) + .executedDays(s.getExecutedDays()) + .totalExerciseTypes(s.getTotalExerciseTypes()) + .days(s.getDays()) + .build()); + } + return GroupMonthlyWorkoutBarResponseDTO.builder() .groupId(group.getId()) .groupName(group.getName()) .yearMonth(targetMonth) - .members(members) + .daysInMonth(daysInMonth) + .members(ranked) .build(); } + private GroupExerciseBarSectionDTO buildExerciseSection( + ExerciseType type, + String unit, + List rows, + ToLongFunction valueFn + ) { + List values = new ArrayList<>(rows.size()); + long topValue = 0L; + for (GroupMemberWorkoutAggregateProjection r : rows) { + long value = valueFn.applyAsLong(r); + if (value > topValue) topValue = value; + values.add(GroupExerciseMemberValueDTO.builder() + .userId(r.getUserId()) + .nickname(r.getNickname()) + .isOwner(Boolean.TRUE.equals(r.getIsOwner())) + .value(value) + .build()); + } + values.sort(Comparator + .comparingLong(GroupExerciseMemberValueDTO::getValue).reversed() + .thenComparing(GroupExerciseMemberValueDTO::getUserId)); + return GroupExerciseBarSectionDTO.builder() + .type(type) + .unit(unit) + .topValue(topValue) + .members(values) + .build(); + } + + private GroupMonthlyMemberSummaryDTO buildMemberMonthlySummary( + GroupUser membership, + Long ownerId, + List userItems, + int daysInMonth + ) { + List days = new ArrayList<>(userItems.size()); + int executedDays = 0; + int totalExerciseTypes = 0; + for (GroupDailyRecordItemProjection item : userItems) { + long chair = zero(item.getChairStandCount()); + long push = zero(item.getPushUpCount()); + long sit = zero(item.getSitUpCount()); + long plank = zero(item.getPlankDurationMs()); + int typeCount = (chair > 0 ? 1 : 0) + (push > 0 ? 1 : 0) + + (sit > 0 ? 1 : 0) + (plank > 0 ? 1 : 0); + if (typeCount == 0) continue; + executedDays++; + totalExerciseTypes += typeCount; + days.add(GroupMemberDailyRecordDTO.builder() + .date(item.getWorkoutDate()) + .exerciseTypeCount(typeCount) + .chairStandCount(chair) + .pushUpCount(push) + .sitUpCount(sit) + .plankDurationMs(plank) + .build()); + } + days.sort(Comparator.comparing(GroupMemberDailyRecordDTO::getDate)); + + double executionRate = daysInMonth == 0 + ? 0.0 + : totalExerciseTypes * 100.0 / (daysInMonth * 4); + + Long userId = membership.getUser().getId(); + return GroupMonthlyMemberSummaryDTO.builder() + .rank(0) + .userId(userId) + .nickname(membership.getUser().getNickname()) + .isOwner(userId.equals(ownerId)) + .executionRate(round1(executionRate)) + .executedDays(executedDays) + .totalExerciseTypes(totalExerciseTypes) + .days(days) + .build(); + } + + private long chairStand(GroupMemberWorkoutAggregateProjection r) { return zero(r.getChairStandCount()); } + private long pushUp(GroupMemberWorkoutAggregateProjection r) { return zero(r.getPushUpCount()); } + private long sitUp(GroupMemberWorkoutAggregateProjection r) { return zero(r.getSitUpCount()); } + private long plankMs(GroupMemberWorkoutAggregateProjection r) { return zero(r.getPlankDurationMs()); } + + private long zero(Long v) { return v == null ? 0L : v; } + + private double round1(double v) { return Math.round(v * 10.0) / 10.0; } + private Group findGroupAndCheckMembership(Long groupId, Long userId) { Group group = groupRepository.findByIdAndIsDeletedFalse(groupId) .orElseThrow(() -> new GeneralException(GroupErrorCode.GROUP_NOT_FOUND));