From a271ff90358574007963e95ea94afa5fb319315b Mon Sep 17 00:00:00 2001 From: hyeonkangkimm Date: Sat, 12 Sep 2026 02:42:50 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9A=B4=EB=8F=99=EA=B0=9C=EC=88=98=20?= =?UTF-8?q?=EB=94=94=EB=B9=84=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ExerciseSessionService.java | 11 ++++ .../group/entity/GroupDailyWorkoutRecord.java | 59 +++++++++++++++++ .../GroupDailyWorkoutRecordRepository.java | 41 ++++++++++++ .../service/GroupWorkoutRecordService.java | 52 +++++++++++++++ .../service/ExerciseSessionServiceTest.java | 4 +- .../GroupWorkoutRecordServiceTest.java | 65 +++++++++++++++++++ 6 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/atmd/backend/domain/group/entity/GroupDailyWorkoutRecord.java create mode 100644 src/main/java/com/atmd/backend/domain/group/repository/GroupDailyWorkoutRecordRepository.java create mode 100644 src/main/java/com/atmd/backend/domain/group/service/GroupWorkoutRecordService.java create mode 100644 src/test/java/com/atmd/backend/domain/group/service/GroupWorkoutRecordServiceTest.java diff --git a/src/main/java/com/atmd/backend/domain/fitness/service/ExerciseSessionService.java b/src/main/java/com/atmd/backend/domain/fitness/service/ExerciseSessionService.java index 9f3481f..f53a2bb 100644 --- a/src/main/java/com/atmd/backend/domain/fitness/service/ExerciseSessionService.java +++ b/src/main/java/com/atmd/backend/domain/fitness/service/ExerciseSessionService.java @@ -23,6 +23,7 @@ import com.atmd.backend.domain.fitness.pose.PoseFrameSmoother; import com.atmd.backend.domain.fitness.pose.PoseFrameValidator; import com.atmd.backend.domain.fitness.repository.ExerciseSessionRepository; +import com.atmd.backend.domain.group.service.GroupWorkoutRecordService; import com.atmd.backend.domain.user.entity.User; import com.atmd.backend.domain.user.repository.UserRepository; import com.atmd.backend.global.common.exception.GeneralException; @@ -58,6 +59,7 @@ public class ExerciseSessionService { private final PushUpAnalyzer pushUpAnalyzer; private final SitUpAnalyzer sitUpAnalyzer; private final PlankAnalyzer plankAnalyzer; + private final GroupWorkoutRecordService groupWorkoutRecordService; @Value("${fitness.exercise-session.idle-timeout-seconds:15}") private long idleTimeoutSeconds; @@ -349,6 +351,15 @@ private void completeRuntime( runtime.validDurationMs(completedAt), toLocalDateTime(completedAt) ); + if (session.getMode() == ExerciseSessionMode.WORKOUT) { + groupWorkoutRecordService.accumulateCompletedWorkout( + runtime.getUserId(), + runtime.getExerciseType(), + runtime.validCount(), + runtime.validDurationMs(completedAt), + completedAt + ); + } } finalizeRuntimeAfterCommit(runtime); } diff --git a/src/main/java/com/atmd/backend/domain/group/entity/GroupDailyWorkoutRecord.java b/src/main/java/com/atmd/backend/domain/group/entity/GroupDailyWorkoutRecord.java new file mode 100644 index 0000000..e2556b9 --- /dev/null +++ b/src/main/java/com/atmd/backend/domain/group/entity/GroupDailyWorkoutRecord.java @@ -0,0 +1,59 @@ +package com.atmd.backend.domain.group.entity; + +import com.atmd.backend.domain.user.entity.User; +import com.atmd.backend.global.common.entity.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Getter +@Entity +@Table( + name = "group_daily_workout_records", + uniqueConstraints = @UniqueConstraint( + name = "uk_group_daily_workout_group_user_date", + columnNames = {"group_id", "user_id", "workout_date"} + ) +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class GroupDailyWorkoutRecord extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "group_id", nullable = false) + private Group group; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "workout_date", nullable = false) + private LocalDate workoutDate; + + @Column(name = "chair_stand_count", nullable = false) + private long chairStandCount; + + @Column(name = "push_up_count", nullable = false) + private long pushUpCount; + + @Column(name = "sit_up_count", nullable = false) + private long sitUpCount; + + @Column(name = "plank_duration_ms", nullable = false) + private long plankDurationMs; +} 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 new file mode 100644 index 0000000..5d5de2c --- /dev/null +++ b/src/main/java/com/atmd/backend/domain/group/repository/GroupDailyWorkoutRecordRepository.java @@ -0,0 +1,41 @@ +package com.atmd.backend.domain.group.repository; + +import com.atmd.backend.domain.group.entity.GroupDailyWorkoutRecord; +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.time.LocalDate; + +public interface GroupDailyWorkoutRecordRepository extends JpaRepository { + + @Modifying + @Query(value = """ + INSERT INTO group_daily_workout_records ( + group_id, user_id, workout_date, + chair_stand_count, push_up_count, sit_up_count, plank_duration_ms, + created_at, updated_at + ) VALUES ( + :groupId, :userId, :workoutDate, + :chairStandCount, :pushUpCount, :sitUpCount, :plankDurationMs, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + ON CONFLICT (group_id, user_id, workout_date) + DO UPDATE SET + chair_stand_count = group_daily_workout_records.chair_stand_count + EXCLUDED.chair_stand_count, + push_up_count = group_daily_workout_records.push_up_count + EXCLUDED.push_up_count, + sit_up_count = group_daily_workout_records.sit_up_count + EXCLUDED.sit_up_count, + plank_duration_ms = group_daily_workout_records.plank_duration_ms + EXCLUDED.plank_duration_ms, + updated_at = CURRENT_TIMESTAMP + """, nativeQuery = true) + void accumulate( + @Param("groupId") Long groupId, + @Param("userId") Long userId, + @Param("workoutDate") LocalDate workoutDate, + @Param("chairStandCount") long chairStandCount, + @Param("pushUpCount") long pushUpCount, + @Param("sitUpCount") long sitUpCount, + @Param("plankDurationMs") long plankDurationMs + ); +} 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 new file mode 100644 index 0000000..60e109a --- /dev/null +++ b/src/main/java/com/atmd/backend/domain/group/service/GroupWorkoutRecordService.java @@ -0,0 +1,52 @@ +package com.atmd.backend.domain.group.service; + +import com.atmd.backend.domain.fitness.enums.ExerciseType; +import com.atmd.backend.domain.group.entity.GroupUser; +import com.atmd.backend.domain.group.repository.GroupDailyWorkoutRecordRepository; +import com.atmd.backend.domain.group.repository.GroupUserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; + +@Service +@RequiredArgsConstructor +public class GroupWorkoutRecordService { + private static final ZoneId SERVER_ZONE = ZoneId.of("Asia/Seoul"); + + private final GroupUserRepository groupUserRepository; + private final GroupDailyWorkoutRecordRepository groupDailyWorkoutRecordRepository; + + public void accumulateCompletedWorkout( + Long userId, + ExerciseType exerciseType, + int validCount, + long validDurationMs, + Instant completedAt + ) { + LocalDateTime completedDateTime = LocalDateTime.ofInstant(completedAt, SERVER_ZONE); + LocalDate workoutDate = completedDateTime.toLocalDate(); + long chairStandCount = exerciseType == ExerciseType.CHAIR_STAND ? validCount : 0; + long pushUpCount = exerciseType == ExerciseType.PUSH_UP ? validCount : 0; + long sitUpCount = exerciseType == ExerciseType.SIT_UP ? validCount : 0; + long plankDurationMs = exerciseType == ExerciseType.PLANK ? validDurationMs : 0; + + for (GroupUser membership : groupUserRepository.findAllByUserIdWithGroup(userId)) { + if (membership.getCreatedAt().isAfter(completedDateTime)) { + continue; + } + groupDailyWorkoutRecordRepository.accumulate( + membership.getGroup().getId(), + userId, + workoutDate, + chairStandCount, + pushUpCount, + sitUpCount, + plankDurationMs + ); + } + } +} diff --git a/src/test/java/com/atmd/backend/domain/fitness/service/ExerciseSessionServiceTest.java b/src/test/java/com/atmd/backend/domain/fitness/service/ExerciseSessionServiceTest.java index d499423..7ad1fd5 100644 --- a/src/test/java/com/atmd/backend/domain/fitness/service/ExerciseSessionServiceTest.java +++ b/src/test/java/com/atmd/backend/domain/fitness/service/ExerciseSessionServiceTest.java @@ -14,6 +14,7 @@ import com.atmd.backend.domain.fitness.repository.ExerciseSessionRepository; import com.atmd.backend.domain.user.repository.UserRepository; import com.atmd.backend.domain.user.entity.User; +import com.atmd.backend.domain.group.service.GroupWorkoutRecordService; import com.atmd.backend.global.common.exception.GeneralException; import org.junit.jupiter.api.Test; @@ -73,7 +74,8 @@ private ExerciseSessionService service( mock(ChairStandAnalyzer.class), mock(PushUpAnalyzer.class), mock(SitUpAnalyzer.class), - mock(PlankAnalyzer.class) + mock(PlankAnalyzer.class), + mock(GroupWorkoutRecordService.class) ); } } 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 new file mode 100644 index 0000000..ed6c12a --- /dev/null +++ b/src/test/java/com/atmd/backend/domain/group/service/GroupWorkoutRecordServiceTest.java @@ -0,0 +1,65 @@ +package com.atmd.backend.domain.group.service; + +import com.atmd.backend.domain.fitness.enums.ExerciseType; +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.GroupUserRepository; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class GroupWorkoutRecordServiceTest { + private final GroupUserRepository groupUserRepository = mock(GroupUserRepository.class); + private final GroupDailyWorkoutRecordRepository recordRepository = + mock(GroupDailyWorkoutRecordRepository.class); + private final GroupWorkoutRecordService service = + new GroupWorkoutRecordService(groupUserRepository, recordRepository); + + @Test + void accumulatesSitUpCountForMembershipCreatedBeforeWorkout() { + GroupUser membership = membership(10L, LocalDateTime.of(2026, 9, 12, 8, 0)); + when(groupUserRepository.findAllByUserIdWithGroup(1L)).thenReturn(List.of(membership)); + + service.accumulateCompletedWorkout( + 1L, ExerciseType.SIT_UP, 6, 0, + Instant.parse("2026-09-12T01:00:00Z") + ); + + verify(recordRepository).accumulate( + 10L, 1L, LocalDate.of(2026, 9, 12), 0, 0, 6, 0 + ); + } + + @Test + void ignoresWorkoutCompletedBeforeMembership() { + GroupUser membership = membership(10L, LocalDateTime.of(2026, 9, 12, 11, 0)); + when(groupUserRepository.findAllByUserIdWithGroup(1L)).thenReturn(List.of(membership)); + + service.accumulateCompletedWorkout( + 1L, ExerciseType.SIT_UP, 6, 0, + Instant.parse("2026-09-12T01:00:00Z") + ); + + verify(recordRepository, never()).accumulate( + 10L, 1L, LocalDate.of(2026, 9, 12), 0, 0, 6, 0 + ); + } + + private GroupUser membership(Long groupId, LocalDateTime joinedAt) { + Group group = mock(Group.class); + when(group.getId()).thenReturn(groupId); + GroupUser membership = mock(GroupUser.class); + when(membership.getGroup()).thenReturn(group); + when(membership.getCreatedAt()).thenReturn(joinedAt); + return membership; + } +}