diff --git a/src/main/java/com/tnt/common/constant/ImageConstant.java b/src/main/java/com/tnt/common/constant/ImageConstant.java index e32e69ce..e2d9bbac 100644 --- a/src/main/java/com/tnt/common/constant/ImageConstant.java +++ b/src/main/java/com/tnt/common/constant/ImageConstant.java @@ -12,5 +12,9 @@ public class ImageConstant { public static final String TRAINER_S3_PROFILE_IMAGE_PATH = "profiles/trainers"; public static final String TRAINEE_S3_PROFILE_IMAGE_PATH = "profiles/trainees"; + public static final String WORKOUT_AEROBIC_S3_IMAGE_PATH = "workouts/aerobic"; + public static final String WORKOUT_ANAEROBIC_S3_IMAGE_PATH = "workouts/anaerobic"; + public static final String WORKOUT_RECORD_S3_IMAGE_PATH = "workout-records/"; + public static final String DIET_S3_IMAGE_PATH = "diets/trainees"; } diff --git a/src/main/java/com/tnt/common/error/model/ErrorMessage.java b/src/main/java/com/tnt/common/error/model/ErrorMessage.java index fe3b91bd..e57fe3bd 100644 --- a/src/main/java/com/tnt/common/error/model/ErrorMessage.java +++ b/src/main/java/com/tnt/common/error/model/ErrorMessage.java @@ -65,7 +65,15 @@ public enum ErrorMessage { DIET_INVALID_MEMO("식단 메모가 올바르지 않습니다."), UNSUPPORTED_DIET_TYPE("지원하지 않는 식단 타입입니다."), DIET_NOT_FOUND("존재하지 않는 식단입니다."), - DIET_DUPLICATE_TIME("이미 등록된 시간대입니다."); + DIET_DUPLICATE_TIME("이미 등록된 시간대입니다."), + + WORKOUT_INVALID_NAME("운동 이름이 올바르지 않습니다."), + UNSUPPORTED_BODY_PART("지원하지 않는 부위입니다."), + UNSUPPORTED_MACHINE("지원하지 않는 기구입니다."), + UNSUPPORTED_WORKOUT_TYPE("지원하지 않는 운동 타입입니다."), + UNSUPPORTED_RECORD_TYPE("지원하지 않는 기록 타입입니다."), + + WORKOUT_RECORD_NOT_FOUND("존재하지 않는 운동 기록입니다."); private final String message; } diff --git a/src/main/java/com/tnt/image/application/S3Service.java b/src/main/java/com/tnt/image/application/S3Service.java index cdd282db..55726f43 100644 --- a/src/main/java/com/tnt/image/application/S3Service.java +++ b/src/main/java/com/tnt/image/application/S3Service.java @@ -5,9 +5,11 @@ import static com.tnt.common.constant.ImageConstant.TRAINEE_S3_PROFILE_IMAGE_PATH; import static com.tnt.common.constant.ImageConstant.TRAINER_DEFAULT_IMAGE; import static com.tnt.common.constant.ImageConstant.TRAINER_S3_PROFILE_IMAGE_PATH; +import static com.tnt.common.constant.ImageConstant.WORKOUT_RECORD_S3_IMAGE_PATH; import static com.tnt.common.error.model.ErrorMessage.IMAGE_NOT_FOUND; import static com.tnt.common.error.model.ErrorMessage.IMAGE_NOT_SUPPORT; import static com.tnt.common.error.model.ErrorMessage.UNSUPPORTED_MEMBER_TYPE; +import static com.tnt.image.infrastructure.S3Adapter.IMAGE_BASE_URL; import static java.util.Objects.isNull; import java.awt.image.BufferedImage; @@ -70,6 +72,17 @@ public String uploadProfileImage(@Nullable MultipartFile profileImage, MemberTyp return uploadImage(defaultImage, folderPath, profileImage); } + public List uploadWorkoutRecordImages(@Nullable List images) { + if (isNull(images) || images.isEmpty()) { + return List.of(); + } + + return images.stream() + .map(image -> uploadImage("", WORKOUT_RECORD_S3_IMAGE_PATH, image)) + .filter(url -> !url.isEmpty()) + .toList(); + } + public String uploadImage(String defaultImage, String folderPath, @Nullable MultipartFile image) { if (isNull(image)) { return defaultImage; @@ -97,7 +110,7 @@ public void deleteProfileImage(String imageUrl) { } try { - String s3Key = imageUrl.replace(S3Adapter.IMAGE_BASE_URL, ""); + String s3Key = imageUrl.replace(IMAGE_BASE_URL, ""); s3Adapter.deleteFile(s3Key); } catch (Exception e) { @@ -106,6 +119,21 @@ public void deleteProfileImage(String imageUrl) { } } + public void deleteWorkoutRecordImage(String imageUrl) { + if (isNull(imageUrl) || imageUrl.isEmpty()) { + return; + } + + try { + String s3Key = imageUrl.replace(IMAGE_BASE_URL, ""); + + s3Adapter.deleteFile(s3Key); + } catch (Exception e) { + // S3 삭제 실패해도 로그만 남김 + log.error("운동 기록 이미지 삭제 실패: {}", imageUrl, e); + } + } + private String validateImageFormat(MultipartFile image) { String originalFilename = image.getOriginalFilename(); diff --git a/src/main/java/com/tnt/pt/application/PtService.java b/src/main/java/com/tnt/pt/application/PtService.java index 87e57c05..e29a1d77 100644 --- a/src/main/java/com/tnt/pt/application/PtService.java +++ b/src/main/java/com/tnt/pt/application/PtService.java @@ -8,6 +8,7 @@ import static com.tnt.common.error.model.ErrorMessage.PT_TRAINEE_ALREADY_EXIST; import static com.tnt.common.error.model.ErrorMessage.PT_TRAINER_TRAINEE_ALREADY_EXIST; import static com.tnt.common.error.model.ErrorMessage.PT_TRAINER_TRAINEE_NOT_FOUND; +import static java.lang.Boolean.TRUE; import static java.util.stream.Collectors.groupingBy; import java.time.LocalDate; @@ -41,6 +42,7 @@ import com.tnt.trainer.domain.Trainer; import com.tnt.trainer.dto.ConnectWithTrainerDto; import com.tnt.trainer.dto.request.CreatePtLessonRequest; +import com.tnt.trainer.dto.request.UpdatePtLessonRequest; import com.tnt.trainer.dto.response.ConnectWithTraineeResponse; import com.tnt.trainer.dto.response.ConnectWithTraineeResponse.ConnectTraineeInfo; import com.tnt.trainer.dto.response.ConnectWithTraineeResponse.ConnectTrainerInfo; @@ -264,6 +266,51 @@ public GetTraineeCalendarPtLessonCountResponse getTraineeCalendarPtLessonCount(L return new GetTraineeCalendarPtLessonCountResponse(dates); } + @Transactional + public void updatePtLesson(Long memberId, Long ptLessonId, UpdatePtLessonRequest request) { + trainerService.validateTrainerRegistration(memberId); + + PtLesson ptLesson = getPtLessonWithId(ptLessonId); + PtTrainerTrainee ptTrainerTrainee = ptLesson.getPtTrainerTrainee(); + + // 시간 변경 시 중복 검증 + if (!ptLesson.getLessonStart().equals(request.lessonStart()) || !ptLesson.getLessonEnd() + .equals(request.lessonEnd())) { + validateLessonTimeForUpdate(ptTrainerTrainee, request.lessonStart(), request.lessonEnd(), ptLessonId); + } + + ptLesson.update(request.lessonStart(), request.lessonEnd(), request.memo()); + + ptLessonRepository.save(ptLesson); + } + + @Transactional + public void deletePtLesson(Long memberId, Long ptLessonId) { + trainerService.validateTrainerRegistration(memberId); + + PtLesson ptLesson = getPtLessonWithId(ptLessonId); + + // 완료된 수업 삭제 시 세션 카운트 조정 + if (TRUE.equals(ptLesson.getIsCompleted())) { + PtTrainerTrainee ptTrainerTrainee = ptLesson.getPtTrainerTrainee(); + ptTrainerTrainee.cancelLesson(); + + // 삭제되는 수업 이후의 미완료 수업들의 세션 번호 조정 + List lessonsNotCompleted = + ptLessonRepository.findAllByPtTrainerTraineeAndIsCompletedIsFalseWithout(ptTrainerTrainee, ptLessonId); + + lessonsNotCompleted.forEach(lesson -> { + if (lesson.getSession() > ptLesson.getSession()) { + lesson.decreaseSession(); + } + }); + } + + ptLesson.softDelete(); + + ptLessonRepository.save(ptLesson); + } + @Transactional(readOnly = true) public GetTraineeDailyRecordsResponse getDailyRecords(Long memberId, LocalDate date) { Trainee trainee = traineeService.getByMemberIdNoFetch(memberId); @@ -363,6 +410,21 @@ private void validateLessonTime(PtTrainerTrainee ptTrainerTrainee, LocalDateTime } } + private void validateLessonTimeForUpdate(PtTrainerTrainee ptTrainerTrainee, LocalDateTime start, + LocalDateTime end, Long excludeId) { + if (ptTrainerTrainee.getStartedAt().isAfter(start.toLocalDate())) { + throw new BadRequestException(PT_LESSON_CREATE_BEFORE_START); + } + + if (ptLessonRepository.existsByStartAndEndExcludingId(ptTrainerTrainee, start, end, excludeId)) { + throw new ConflictException(PT_LESSON_DUPLICATE_TIME); + } + + if (ptLessonRepository.existsByStartExcludingId(ptTrainerTrainee, start, excludeId)) { + throw new ConflictException(PT_LESSON_MORE_THAN_ONE_A_DAY); + } + } + private int validateAndGetNextSession(PtTrainerTrainee ptTrainerTrainee) { List lessonsForTrainee = ptLessonRepository.findAllByPtTrainerTrainee(ptTrainerTrainee); diff --git a/src/main/java/com/tnt/pt/application/repository/PtLessonRepository.java b/src/main/java/com/tnt/pt/application/repository/PtLessonRepository.java index 07a19e8d..24dae510 100644 --- a/src/main/java/com/tnt/pt/application/repository/PtLessonRepository.java +++ b/src/main/java/com/tnt/pt/application/repository/PtLessonRepository.java @@ -34,4 +34,8 @@ public interface PtLessonRepository { boolean existsByStartAndEnd(PtTrainerTrainee pt, LocalDateTime start, LocalDateTime end); boolean existsByStart(PtTrainerTrainee pt, LocalDateTime start); + + boolean existsByStartAndEndExcludingId(PtTrainerTrainee pt, LocalDateTime start, LocalDateTime end, Long excludeId); + + boolean existsByStartExcludingId(PtTrainerTrainee pt, LocalDateTime start, Long excludeId); } diff --git a/src/main/java/com/tnt/pt/domain/PtLesson.java b/src/main/java/com/tnt/pt/domain/PtLesson.java index 5b844b6e..046e31db 100644 --- a/src/main/java/com/tnt/pt/domain/PtLesson.java +++ b/src/main/java/com/tnt/pt/domain/PtLesson.java @@ -14,8 +14,8 @@ public class PtLesson { private final Long id; private final PtTrainerTrainee ptTrainerTrainee; - private final LocalDateTime lessonStart; - private final LocalDateTime lessonEnd; + private LocalDateTime lessonStart; + private LocalDateTime lessonEnd; private Boolean isCompleted; private String memo; private Integer session; @@ -56,6 +56,12 @@ public void softDelete() { this.deletedAt = LocalDateTime.now(); } + public void update(LocalDateTime lessonStart, LocalDateTime lessonEnd, String memo) { + this.lessonStart = requireNonNull(lessonStart); + this.lessonEnd = requireNonNull(lessonEnd); + validateAndSetMemo(memo); + } + private void validateAndSetMemo(String memo) { if (memo == null) { return; diff --git a/src/main/java/com/tnt/pt/infrastructure/PtLessonRepositoryImpl.java b/src/main/java/com/tnt/pt/infrastructure/PtLessonRepositoryImpl.java index d1925657..2178b278 100644 --- a/src/main/java/com/tnt/pt/infrastructure/PtLessonRepositoryImpl.java +++ b/src/main/java/com/tnt/pt/infrastructure/PtLessonRepositoryImpl.java @@ -1,5 +1,6 @@ package com.tnt.pt.infrastructure; +import static com.tnt.common.error.model.ErrorMessage.PT_LESSON_NOT_FOUND; import static com.tnt.member.infrastructure.QMemberJpaEntity.memberJpaEntity; import static com.tnt.pt.infrastructure.QPtLessonJpaEntity.ptLessonJpaEntity; import static com.tnt.pt.infrastructure.QPtTrainerTraineeJpaEntity.ptTrainerTraineeJpaEntity; @@ -16,7 +17,6 @@ import com.querydsl.jpa.impl.JPAQueryFactory; import com.tnt.common.error.exception.NotFoundException; -import com.tnt.common.error.model.ErrorMessage; import com.tnt.pt.application.repository.PtLessonRepository; import com.tnt.pt.domain.PtLesson; import com.tnt.pt.domain.PtTrainerTrainee; @@ -170,7 +170,7 @@ public PtLesson findById(Long id) { ptTrainerTraineeJpaEntity.deletedAt.isNull() ) .fetchOne()) - .orElseThrow(() -> new NotFoundException(ErrorMessage.PT_LESSON_NOT_FOUND)) + .orElseThrow(() -> new NotFoundException(PT_LESSON_NOT_FOUND)) .toModel(); } @@ -207,4 +207,41 @@ public boolean existsByStart(PtTrainerTrainee pt, LocalDateTime start) { ) .fetchFirst() != null; } + + @Override + public boolean existsByStartAndEndExcludingId(PtTrainerTrainee pt, LocalDateTime start, LocalDateTime end, + Long excludeId) { + return jpaQueryFactory + .selectOne() + .from(ptLessonJpaEntity) + .join(ptLessonJpaEntity.ptTrainerTrainee, ptTrainerTraineeJpaEntity) + .where( + ptTrainerTraineeJpaEntity.trainer.id.eq(pt.getTrainer().getId()), + ptLessonJpaEntity.lessonStart.lt(end), + ptLessonJpaEntity.lessonEnd.gt(start), + ptLessonJpaEntity.id.ne(excludeId), + ptLessonJpaEntity.deletedAt.isNull(), + ptTrainerTraineeJpaEntity.deletedAt.isNull() + ) + .fetchFirst() != null; + } + + @Override + public boolean existsByStartExcludingId(PtTrainerTrainee pt, LocalDateTime start, Long excludeId) { + return jpaQueryFactory + .selectOne() + .from(ptLessonJpaEntity) + .join(ptLessonJpaEntity.ptTrainerTrainee, ptTrainerTraineeJpaEntity) + .where( + ptTrainerTraineeJpaEntity.trainer.id.eq(pt.getTrainer().getId()), + ptTrainerTraineeJpaEntity.trainee.id.eq(pt.getTrainee().getId()), + ptLessonJpaEntity.lessonStart.year().eq(start.getYear()) + .and(ptLessonJpaEntity.lessonStart.month().eq(start.getMonthValue())) + .and(ptLessonJpaEntity.lessonStart.dayOfMonth().eq(start.getDayOfMonth())), + ptLessonJpaEntity.id.ne(excludeId), + ptLessonJpaEntity.deletedAt.isNull(), + ptTrainerTraineeJpaEntity.deletedAt.isNull() + ) + .fetchFirst() != null; + } } diff --git a/src/main/java/com/tnt/trainer/dto/request/UpdatePtLessonRequest.java b/src/main/java/com/tnt/trainer/dto/request/UpdatePtLessonRequest.java new file mode 100644 index 00000000..7a07c045 --- /dev/null +++ b/src/main/java/com/tnt/trainer/dto/request/UpdatePtLessonRequest.java @@ -0,0 +1,25 @@ +package com.tnt.trainer.dto.request; + +import java.time.LocalDateTime; + +import org.hibernate.validator.constraints.Length; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "PT 수업 수정 요청") +public record UpdatePtLessonRequest( + @Schema(description = "수업 시작 날짜 및 시간", example = "2025-03-20T10:00:00", nullable = false) + @NotNull(message = "수업 시작 시간은 필수입니다.") + LocalDateTime lessonStart, + + @Schema(description = "수업 끝 날짜 및 시간", example = "2025-03-20T11:00:00", nullable = false) + @NotNull(message = "수업 종료 시간은 필수입니다.") + LocalDateTime lessonEnd, + + @Schema(description = "메모", example = "하체 운동 시키기", nullable = true) + @Length(max = 30, message = "메모는 30자 이하여야 합니다.") + String memo +) { + +} diff --git a/src/main/java/com/tnt/trainer/presentation/TrainerController.java b/src/main/java/com/tnt/trainer/presentation/TrainerController.java index 46a528d5..27e571f3 100644 --- a/src/main/java/com/tnt/trainer/presentation/TrainerController.java +++ b/src/main/java/com/tnt/trainer/presentation/TrainerController.java @@ -1,10 +1,12 @@ package com.tnt.trainer.presentation; import static org.springframework.http.HttpStatus.CREATED; +import static org.springframework.http.HttpStatus.NO_CONTENT; import static org.springframework.http.HttpStatus.OK; import java.time.LocalDate; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -19,6 +21,7 @@ import com.tnt.pt.application.PtService; import com.tnt.trainer.application.TrainerService; import com.tnt.trainer.dto.request.CreatePtLessonRequest; +import com.tnt.trainer.dto.request.UpdatePtLessonRequest; import com.tnt.trainer.dto.response.ConnectWithTraineeResponse; import com.tnt.trainer.dto.response.GetActiveTraineesResponse; import com.tnt.trainer.dto.response.GetCalendarPtLessonCountResponse; @@ -118,4 +121,19 @@ public void cancelPtLesson(@AuthMember Long memberId, @Parameter(description = "PT 수업 ID", example = "123456789") @PathVariable("ptLessonId") Long ptLessonId) { ptService.cancelPtLesson(memberId, ptLessonId); } + + @Operation(summary = "PT 수업 수정 API") + @PutMapping("/lessons/{ptLessonId}/edit") + @ResponseStatus(NO_CONTENT) + public void updatePtLesson(@AuthMember Long memberId, @PathVariable("ptLessonId") Long ptLessonId, + @RequestBody @Valid UpdatePtLessonRequest request) { + ptService.updatePtLesson(memberId, ptLessonId, request); + } + + @Operation(summary = "PT 수업 삭제 API") + @DeleteMapping("/lessons/{ptLessonId}/delete") + @ResponseStatus(NO_CONTENT) + public void deletePtLesson(@AuthMember Long memberId, @PathVariable("ptLessonId") Long ptLessonId) { + ptService.deletePtLesson(memberId, ptLessonId); + } } diff --git a/src/main/java/com/tnt/workout/application/WorkoutService.java b/src/main/java/com/tnt/workout/application/WorkoutService.java new file mode 100644 index 00000000..61aa40a6 --- /dev/null +++ b/src/main/java/com/tnt/workout/application/WorkoutService.java @@ -0,0 +1,38 @@ +package com.tnt.workout.application; + +import java.util.List; + +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.tnt.workout.application.repository.WorkoutRepository; +import com.tnt.workout.domain.BodyPart; +import com.tnt.workout.domain.Machine; +import com.tnt.workout.domain.Workout; +import com.tnt.workout.dto.response.SearchWorkoutResponse; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class WorkoutService { + + private static final int DEFAULT_PAGE_SIZE = 10; + + private final WorkoutRepository workoutRepository; + + public SearchWorkoutResponse search(@Nullable String keyword, @Nullable List bodyParts, + @Nullable List machines, @Nullable Long lastId, @Nullable Integer size) { + + int pageSize = size != null ? size : DEFAULT_PAGE_SIZE; + List workouts = workoutRepository.search(keyword, bodyParts, machines, lastId, pageSize); + + // hasNext 판단을 위해 size+1 만큼 조회했으므로, 실제 반환할 데이터는 size까지만 + boolean hasNext = workouts.size() > pageSize; + List content = hasNext ? workouts.subList(0, pageSize) : workouts; + + return SearchWorkoutResponse.from(content, hasNext); + } +} diff --git a/src/main/java/com/tnt/workout/application/repository/WorkoutRepository.java b/src/main/java/com/tnt/workout/application/repository/WorkoutRepository.java new file mode 100644 index 00000000..63df82d8 --- /dev/null +++ b/src/main/java/com/tnt/workout/application/repository/WorkoutRepository.java @@ -0,0 +1,15 @@ +package com.tnt.workout.application.repository; + +import java.util.List; + +import org.springframework.lang.Nullable; + +import com.tnt.workout.domain.BodyPart; +import com.tnt.workout.domain.Machine; +import com.tnt.workout.domain.Workout; + +public interface WorkoutRepository { + + List search(@Nullable String keyword, @Nullable List bodyParts, + @Nullable List machines, @Nullable Long lastId, int size); +} diff --git a/src/main/java/com/tnt/workout/domain/BodyPart.java b/src/main/java/com/tnt/workout/domain/BodyPart.java new file mode 100644 index 00000000..37e652a7 --- /dev/null +++ b/src/main/java/com/tnt/workout/domain/BodyPart.java @@ -0,0 +1,14 @@ +package com.tnt.workout.domain; + +public enum BodyPart { + + // 가슴, 삼두, 어깨, 등, 하체, 코어, 전완근, 유산소 + CHEST, + TRICEPS, + SHOULDERS, + BACK, + LEG, + CORE, + FOREARM, + AEROBIC +} diff --git a/src/main/java/com/tnt/workout/domain/Machine.java b/src/main/java/com/tnt/workout/domain/Machine.java new file mode 100644 index 00000000..3cebd1d4 --- /dev/null +++ b/src/main/java/com/tnt/workout/domain/Machine.java @@ -0,0 +1,14 @@ +package com.tnt.workout.domain; + +public enum Machine { + + // 바벨, 덤벨, 케틀벨, 머신, 밴드, 케이블머신, 스미스머신, 맨몸 + BARBELL, + DUMBBELL, + KETTLEBELL, + MACHINE, + BAND, + CABLE, + SMITH, + BODYWEIGHT +} diff --git a/src/main/java/com/tnt/workout/domain/Workout.java b/src/main/java/com/tnt/workout/domain/Workout.java new file mode 100644 index 00000000..f695876e --- /dev/null +++ b/src/main/java/com/tnt/workout/domain/Workout.java @@ -0,0 +1,40 @@ +package com.tnt.workout.domain; + +import static com.tnt.common.error.model.ErrorMessage.TRAINER_INVALID_INVITATION_CODE; + +import java.util.List; + +import lombok.Builder; +import lombok.Getter; + +@Getter +public class Workout { + + public static final int NAME_LENGTH = 20; + + private Long id; + private String name; + private String imageUrl; + private List bodyParts; + private List machines; + private WorkoutType workoutType; + + @Builder + public Workout(Long id, String name, String imageUrl, List bodyParts, List machines, + WorkoutType workoutType) { + this.id = id; + this.name = validateName(name); + this.imageUrl = imageUrl; + this.bodyParts = bodyParts; + this.machines = machines; + this.workoutType = workoutType; + } + + private String validateName(String name) { + if (name == null || name.isBlank() || name.length() > NAME_LENGTH) { + throw new IllegalArgumentException(TRAINER_INVALID_INVITATION_CODE.getMessage()); + } + + return name; + } +} diff --git a/src/main/java/com/tnt/workout/domain/WorkoutType.java b/src/main/java/com/tnt/workout/domain/WorkoutType.java new file mode 100644 index 00000000..2276b389 --- /dev/null +++ b/src/main/java/com/tnt/workout/domain/WorkoutType.java @@ -0,0 +1,7 @@ +package com.tnt.workout.domain; + +public enum WorkoutType { + + AEROBIC, + ANAEROBIC +} diff --git a/src/main/java/com/tnt/workout/dto/response/SearchWorkoutResponse.java b/src/main/java/com/tnt/workout/dto/response/SearchWorkoutResponse.java new file mode 100644 index 00000000..7f1e6cbb --- /dev/null +++ b/src/main/java/com/tnt/workout/dto/response/SearchWorkoutResponse.java @@ -0,0 +1,59 @@ +package com.tnt.workout.dto.response; + +import java.util.List; + +import com.tnt.workout.domain.BodyPart; +import com.tnt.workout.domain.Machine; +import com.tnt.workout.domain.Workout; +import com.tnt.workout.domain.WorkoutType; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "운동 검색 응답") +public record SearchWorkoutResponse( + @Schema(description = "운동 목록") + List workouts, + + @Schema(description = "다음 페이지 존재 여부", example = "true") + boolean hasNext +) { + + public static SearchWorkoutResponse from(List workouts, boolean hasNext) { + List workoutInfos = workouts.stream() + .map(WorkoutInfo::from) + .toList(); + return new SearchWorkoutResponse(workoutInfos, hasNext); + } + + @Schema(description = "운동 정보") + public record WorkoutInfo( + @Schema(description = "운동 ID", example = "1") + Long id, + + @Schema(description = "운동 이름", example = "벤치프레스") + String name, + + @Schema(description = "운동 이미지 URL", example = "https://images.tntapp.co.kr/bench-press.jpg") + String imageUrl, + + @Schema(description = "운동 부위 목록", example = "[\"CHEST\", \"TRICEPS\"]") + List bodyParts, + + @Schema(description = "운동 기구 목록", example = "[\"BARBELL\"]") + List machines, + + @Schema(description = "운동 타입", example = "ANAEROBIC") + WorkoutType workoutType + ) { + public static WorkoutInfo from(Workout workout) { + return new WorkoutInfo( + workout.getId(), + workout.getName(), + workout.getImageUrl(), + workout.getBodyParts(), + workout.getMachines(), + workout.getWorkoutType() + ); + } + } +} diff --git a/src/main/java/com/tnt/workout/infrastructure/WorkoutJpaEntity.java b/src/main/java/com/tnt/workout/infrastructure/WorkoutJpaEntity.java new file mode 100644 index 00000000..06f23ac2 --- /dev/null +++ b/src/main/java/com/tnt/workout/infrastructure/WorkoutJpaEntity.java @@ -0,0 +1,86 @@ +package com.tnt.workout.infrastructure; + +import java.util.List; + +import com.tnt.workout.domain.BodyPart; +import com.tnt.workout.domain.Machine; +import com.tnt.workout.domain.Workout; +import com.tnt.workout.domain.WorkoutType; + +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "workout") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class WorkoutJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false, unique = true) + private Long id; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "imageUrl", nullable = false) + private String imageUrl; + + @ElementCollection(fetch = FetchType.LAZY) + @Enumerated(EnumType.STRING) + private List bodyParts; + + @ElementCollection(fetch = FetchType.LAZY) + @Enumerated(EnumType.STRING) + private List machines; + + @Enumerated(EnumType.STRING) + @Column(name = "workout_type", nullable = false) + private WorkoutType workoutType; + + @Builder + public WorkoutJpaEntity(Long id, String name, String imageUrl, List bodyParts, List machines, + WorkoutType workoutType) { + this.id = id; + this.name = name; + this.imageUrl = imageUrl; + this.bodyParts = bodyParts; + this.machines = machines; + this.workoutType = workoutType; + } + + public static WorkoutJpaEntity from(Workout workout) { + return WorkoutJpaEntity.builder() + .id(workout.getId()) + .name(workout.getName()) + .imageUrl(workout.getImageUrl()) + .bodyParts(workout.getBodyParts()) + .machines(workout.getMachines()) + .workoutType(workout.getWorkoutType()) + .build(); + } + + public Workout toModel() { + return Workout.builder() + .id(id) + .name(name) + .imageUrl(imageUrl) + .bodyParts(bodyParts) + .machines(machines) + .workoutType(workoutType) + .build(); + } +} diff --git a/src/main/java/com/tnt/workout/infrastructure/WorkoutJpaRepository.java b/src/main/java/com/tnt/workout/infrastructure/WorkoutJpaRepository.java new file mode 100644 index 00000000..575075b8 --- /dev/null +++ b/src/main/java/com/tnt/workout/infrastructure/WorkoutJpaRepository.java @@ -0,0 +1,6 @@ +package com.tnt.workout.infrastructure; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface WorkoutJpaRepository extends JpaRepository { +} diff --git a/src/main/java/com/tnt/workout/infrastructure/WorkoutRepositoryImpl.java b/src/main/java/com/tnt/workout/infrastructure/WorkoutRepositoryImpl.java new file mode 100644 index 00000000..f607408d --- /dev/null +++ b/src/main/java/com/tnt/workout/infrastructure/WorkoutRepositoryImpl.java @@ -0,0 +1,75 @@ +package com.tnt.workout.infrastructure; + +import static com.tnt.common.jpa.DynamicQuery.generateEq; +import static com.tnt.workout.infrastructure.QWorkoutJpaEntity.workoutJpaEntity; + +import java.util.List; + +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Repository; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.tnt.workout.application.repository.WorkoutRepository; +import com.tnt.workout.domain.BodyPart; +import com.tnt.workout.domain.Machine; +import com.tnt.workout.domain.Workout; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class WorkoutRepositoryImpl implements WorkoutRepository { + + private final JPAQueryFactory jpaQueryFactory; + + @Override + public List search(@Nullable String keyword, @Nullable List bodyParts, + @Nullable List machines, @Nullable Long lastId, int size) { + + return jpaQueryFactory + .selectFrom(workoutJpaEntity) + .where( + generateEq(keyword, workoutJpaEntity.name::contains), + bodyPartsIn(bodyParts), + machinesIn(machines), + generateEq(lastId, workoutJpaEntity.id::lt) + ) + .orderBy(workoutJpaEntity.id.desc()) + .limit((long)size + 1) + .fetch() + .stream() + .map(WorkoutJpaEntity::toModel) + .toList(); + } + + private BooleanExpression bodyPartsIn(@Nullable List bodyParts) { + if (bodyParts == null || bodyParts.isEmpty()) { + return null; + } + // ElementCollection의 경우 any()를 사용하여 리스트 내의 요소가 포함되는지 확인 + BooleanExpression expression = null; + + for (BodyPart bodyPart : bodyParts) { + BooleanExpression condition = workoutJpaEntity.bodyParts.any().eq(bodyPart); + expression = expression == null ? condition : expression.or(condition); + } + + return expression; + } + + private BooleanExpression machinesIn(@Nullable List machines) { + if (machines == null || machines.isEmpty()) { + return null; + } + // ElementCollection의 경우 any()를 사용하여 리스트 내의 요소가 포함되는지 확인 + BooleanExpression expression = null; + + for (Machine machine : machines) { + BooleanExpression condition = workoutJpaEntity.machines.any().eq(machine); + expression = expression == null ? condition : expression.or(condition); + } + + return expression; + } +} diff --git a/src/main/java/com/tnt/workout/presentation/WorkoutController.java b/src/main/java/com/tnt/workout/presentation/WorkoutController.java new file mode 100644 index 00000000..2dccadc6 --- /dev/null +++ b/src/main/java/com/tnt/workout/presentation/WorkoutController.java @@ -0,0 +1,52 @@ +package com.tnt.workout.presentation; + +import static org.springframework.http.HttpStatus.OK; + +import java.util.List; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.tnt.workout.application.WorkoutService; +import com.tnt.workout.domain.BodyPart; +import com.tnt.workout.domain.Machine; +import com.tnt.workout.dto.response.SearchWorkoutResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag(name = "운동", description = "운동 관련 API") +@RestController +@RequestMapping("/workouts") +@RequiredArgsConstructor +public class WorkoutController { + + private final WorkoutService workoutService; + + @Operation(summary = "운동 검색 API") + @ResponseStatus(OK) + @GetMapping("/search") + public SearchWorkoutResponse search( + @Parameter(description = "검색 키워드 (운동 이름)", example = "벤치") + @RequestParam(required = false) String keyword, + + @Parameter(description = "운동 부위 목록 (복수 선택 가능)", example = "[\"CHEST\", \"SHOULDERS\"]") + @RequestParam(required = false) List bodyParts, + + @Parameter(description = "운동 기구 목록 (복수 선택 가능)", example = "[\"BARBELL\", \"DUMBBELL\"]") + @RequestParam(required = false) List machines, + + @Parameter(description = "마지막으로 조회한 운동 ID (커서)", example = "100") + @RequestParam(required = false) Long lastId, + + @Parameter(description = "페이지 크기 (기본값: 10)", example = "10") + @RequestParam(required = false) Integer size + ) { + return workoutService.search(keyword, bodyParts, machines, lastId, size); + } +} diff --git a/src/main/java/com/tnt/workoutrecord/application/WorkoutRecordService.java b/src/main/java/com/tnt/workoutrecord/application/WorkoutRecordService.java new file mode 100644 index 00000000..ec8877f7 --- /dev/null +++ b/src/main/java/com/tnt/workoutrecord/application/WorkoutRecordService.java @@ -0,0 +1,201 @@ +package com.tnt.workoutrecord.application; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.tnt.workoutrecord.application.repository.RoutineRepository; +import com.tnt.workoutrecord.application.repository.SetRepository; +import com.tnt.workoutrecord.application.repository.WorkoutRecordRepository; +import com.tnt.workoutrecord.domain.Routine; +import com.tnt.workoutrecord.domain.Set; +import com.tnt.workoutrecord.domain.WorkoutNote; +import com.tnt.workoutrecord.domain.WorkoutRecord; +import com.tnt.workoutrecord.dto.request.CreateWorkoutRecordRequest; +import com.tnt.workoutrecord.dto.request.UpdateWorkoutRecordRequest; +import com.tnt.workoutrecord.dto.response.GetWorkoutRecordResponse; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class WorkoutRecordService { + + private final SetRepository setRepository; + private final RoutineRepository routineRepository; + private final WorkoutRecordRepository workoutRecordRepository; + + @Transactional + public void createWorkoutRecord(Long memberId, CreateWorkoutRecordRequest request, List imageUrls) { + WorkoutNote workoutNote = WorkoutNote.builder().feedback(request.feedback()).imageUrls(imageUrls).build(); + + WorkoutRecord workoutRecord = WorkoutRecord.builder() + .memberId(memberId) + .ptLessonId(request.ptLessonId()) + .date(request.date()) + .workoutNote(workoutNote) + .recordType(request.recordType()) + .build(); + + WorkoutRecord savedWorkoutRecord = workoutRecordRepository.save(workoutRecord); + + List routines = request.routines().stream() + .map(routineInfo -> Routine.builder() + .workoutRecordId(savedWorkoutRecord.getId()) + .workoutId(routineInfo.workoutId()) + .build()) + .toList(); + + List savedRoutines = routineRepository.saveAll(routines); + + for (int i = 0; i < savedRoutines.size(); i++) { + Routine savedRoutine = savedRoutines.get(i); + CreateWorkoutRecordRequest.RoutineInfo routineInfo = request.routines().get(i); + + List sets = routineInfo.sets().stream() + .map(setInfo -> Set.builder() + .routineId(savedRoutine.getId()) + .durationMinutes(setInfo.durationMinutes()) + .repetition(setInfo.repetition()) + .weight(setInfo.weight()) + .build()) + .toList(); + + setRepository.saveAll(sets); + } + } + + @Transactional(readOnly = true) + public GetWorkoutRecordResponse getWorkoutRecord(Long workoutRecordId) { + WorkoutRecord workoutRecord = workoutRecordRepository.findById(workoutRecordId); + List routines = routineRepository.findAllByWorkoutRecordId(workoutRecordId); + List routineIds = routines.stream().map(Routine::getId).toList(); + List sets = setRepository.findAllByRoutineIds(routineIds); + + // Routine별로 Set 그룹화 + Map> setsByRoutineId = sets.stream().collect(Collectors.groupingBy(Set::getRoutineId)); + + List routineResponses = routines.stream() + .map(routine -> { + List routineSets = setsByRoutineId.getOrDefault(routine.getId(), List.of()); + + List setResponses = routineSets.stream() + .map(set -> new GetWorkoutRecordResponse.SetResponse( + set.getId(), + set.getDurationMinutes(), + set.getRepetition(), + set.getWeight() + )) + .toList(); + + return new GetWorkoutRecordResponse.RoutineResponse(routine.getId(), routine.getWorkoutId(), + setResponses); + }) + .toList(); + + WorkoutNote workoutNote = workoutRecord.getWorkoutNote(); + + return new GetWorkoutRecordResponse( + workoutRecord.getId(), + workoutRecord.getMemberId(), + workoutRecord.getPtLessonId(), + workoutRecord.getDate(), + workoutRecord.getRecordType(), + workoutNote != null ? workoutNote.getFeedback() : null, + workoutNote != null ? workoutNote.getImageUrl() : List.of(), + routineResponses + ); + } + + @Transactional + public void updateWorkoutRecord(Long memberId, Long workoutRecordId, UpdateWorkoutRecordRequest request, + List newImageUrls, List imageUrlsToDelete) { + WorkoutRecord workoutRecord = workoutRecordRepository.findById(workoutRecordId); + + // 기존 이미지 URL 리스트 가져오기 + List existingImageUrls = new ArrayList<>(); + + if (workoutRecord.getWorkoutNote() != null && workoutRecord.getWorkoutNote().getImageUrl() != null) { + existingImageUrls = new ArrayList<>(workoutRecord.getWorkoutNote().getImageUrl()); + } + + // 삭제할 이미지 제거 + if (imageUrlsToDelete != null && !imageUrlsToDelete.isEmpty()) { + existingImageUrls.removeAll(imageUrlsToDelete); + } + + // 새 이미지 추가 + if (newImageUrls != null && !newImageUrls.isEmpty()) { + existingImageUrls.addAll(newImageUrls); + } + + if (existingImageUrls.size() > 6) { + throw new IllegalArgumentException("이미지는 최대 6장까지 등록 가능합니다."); + } + + WorkoutNote updatedWorkoutNote = WorkoutNote.builder() + .feedback(request.feedback()) + .imageUrls(existingImageUrls) + .build(); + + // 기존 Routines와 Sets 삭제 + List oldRoutines = routineRepository.findAllByWorkoutRecordId(workoutRecordId); + List oldRoutineIds = oldRoutines.stream().map(Routine::getId).toList(); + + setRepository.deleteAllByRoutineIds(oldRoutineIds); + routineRepository.deleteAllByWorkoutRecordId(workoutRecordId); + + WorkoutRecord updatedWorkoutRecord = WorkoutRecord.builder() + .id(workoutRecordId) + .memberId(memberId) + .ptLessonId(workoutRecord.getPtLessonId()) + .date(workoutRecord.getDate()) + .workoutNote(updatedWorkoutNote) + .recordType(workoutRecord.getRecordType()) + .build(); + + workoutRecordRepository.save(updatedWorkoutRecord); + + List newRoutines = request.routines().stream() + .map(routineInfo -> Routine.builder() + .workoutRecordId(workoutRecordId) + .workoutId(routineInfo.workoutId()) + .build()) + .toList(); + + List savedRoutines = routineRepository.saveAll(newRoutines); + + for (int i = 0; i < savedRoutines.size(); i++) { + Routine savedRoutine = savedRoutines.get(i); + UpdateWorkoutRecordRequest.RoutineInfo routineInfo = request.routines().get(i); + + List sets = routineInfo.sets().stream() + .map(setInfo -> Set.builder() + .routineId(savedRoutine.getId()) + .durationMinutes(setInfo.durationMinutes()) + .repetition(setInfo.repetition()) + .weight(setInfo.weight()) + .build()) + .toList(); + + setRepository.saveAll(sets); + } + } + + @Transactional + public void deleteWorkoutRecord(Long workoutRecordId) { + List routines = routineRepository.findAllByWorkoutRecordId(workoutRecordId); + List routineIds = routines.stream().map(Routine::getId).toList(); + + setRepository.deleteAllByRoutineIds(routineIds); + + routineRepository.deleteAllByWorkoutRecordId(workoutRecordId); + + workoutRecordRepository.deleteById(workoutRecordId); + } + +} diff --git a/src/main/java/com/tnt/workoutrecord/application/repository/RoutineRepository.java b/src/main/java/com/tnt/workoutrecord/application/repository/RoutineRepository.java new file mode 100644 index 00000000..233b4af1 --- /dev/null +++ b/src/main/java/com/tnt/workoutrecord/application/repository/RoutineRepository.java @@ -0,0 +1,14 @@ +package com.tnt.workoutrecord.application.repository; + +import java.util.List; + +import com.tnt.workoutrecord.domain.Routine; + +public interface RoutineRepository { + + List saveAll(List routines); + + List findAllByWorkoutRecordId(Long workoutRecordId); + + void deleteAllByWorkoutRecordId(Long workoutRecordId); +} diff --git a/src/main/java/com/tnt/workoutrecord/application/repository/SetRepository.java b/src/main/java/com/tnt/workoutrecord/application/repository/SetRepository.java new file mode 100644 index 00000000..a0c8296c --- /dev/null +++ b/src/main/java/com/tnt/workoutrecord/application/repository/SetRepository.java @@ -0,0 +1,14 @@ +package com.tnt.workoutrecord.application.repository; + +import java.util.List; + +import com.tnt.workoutrecord.domain.Set; + +public interface SetRepository { + + List saveAll(List sets); + + List findAllByRoutineIds(List routineIds); + + void deleteAllByRoutineIds(List routineIds); +} diff --git a/src/main/java/com/tnt/workoutrecord/application/repository/WorkoutRecordRepository.java b/src/main/java/com/tnt/workoutrecord/application/repository/WorkoutRecordRepository.java new file mode 100644 index 00000000..fe25968e --- /dev/null +++ b/src/main/java/com/tnt/workoutrecord/application/repository/WorkoutRecordRepository.java @@ -0,0 +1,12 @@ +package com.tnt.workoutrecord.application.repository; + +import com.tnt.workoutrecord.domain.WorkoutRecord; + +public interface WorkoutRecordRepository { + + WorkoutRecord save(WorkoutRecord workoutRecord); + + WorkoutRecord findById(Long workoutRecordId); + + void deleteById(Long workoutRecordId); +} diff --git a/src/main/java/com/tnt/workoutrecord/domain/RecordType.java b/src/main/java/com/tnt/workoutrecord/domain/RecordType.java new file mode 100644 index 00000000..75785505 --- /dev/null +++ b/src/main/java/com/tnt/workoutrecord/domain/RecordType.java @@ -0,0 +1,22 @@ +package com.tnt.workoutrecord.domain; + +import static com.tnt.common.error.model.ErrorMessage.UNSUPPORTED_RECORD_TYPE; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.tnt.common.error.exception.TnTException; + +public enum RecordType { + PT, + TRAINEE; + + @JsonCreator + public static RecordType of(String value) { + for (RecordType type : RecordType.values()) { + if (type.name().equalsIgnoreCase(value)) { + return type; + } + } + + throw new TnTException(UNSUPPORTED_RECORD_TYPE); + } +} diff --git a/src/main/java/com/tnt/workoutrecord/domain/Routine.java b/src/main/java/com/tnt/workoutrecord/domain/Routine.java new file mode 100644 index 00000000..e384e831 --- /dev/null +++ b/src/main/java/com/tnt/workoutrecord/domain/Routine.java @@ -0,0 +1,23 @@ +package com.tnt.workoutrecord.domain; + +import java.util.List; + +import lombok.Builder; +import lombok.Getter; + +@Getter +public class Routine { + + private Long id; + private Long workoutRecordId; + private Long workoutId; + private List sets; + + @Builder + public Routine(Long id, Long workoutRecordId, Long workoutId, List sets) { + this.id = id; + this.workoutRecordId = workoutRecordId; + this.workoutId = workoutId; + this.sets = sets; + } +} diff --git a/src/main/java/com/tnt/workoutrecord/domain/Set.java b/src/main/java/com/tnt/workoutrecord/domain/Set.java new file mode 100644 index 00000000..0499e3cc --- /dev/null +++ b/src/main/java/com/tnt/workoutrecord/domain/Set.java @@ -0,0 +1,23 @@ +package com.tnt.workoutrecord.domain; + +import lombok.Builder; +import lombok.Getter; + +@Getter +public class Set { + + private Long id; + private Long routineId; + private Integer durationMinutes; + private Integer repetition; + private Integer weight; + + @Builder + public Set(Long id, Long routineId, Integer durationMinutes, Integer repetition, Integer weight) { + this.id = id; + this.routineId = routineId; + this.durationMinutes = durationMinutes; + this.repetition = repetition; + this.weight = weight; + } +} diff --git a/src/main/java/com/tnt/workoutrecord/domain/WorkoutNote.java b/src/main/java/com/tnt/workoutrecord/domain/WorkoutNote.java new file mode 100644 index 00000000..ddfa1c5d --- /dev/null +++ b/src/main/java/com/tnt/workoutrecord/domain/WorkoutNote.java @@ -0,0 +1,23 @@ +package com.tnt.workoutrecord.domain; + +import java.util.List; + +import jakarta.persistence.Embeddable; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@NoArgsConstructor +public class WorkoutNote { + + private String feedback; + private List imageUrl; + + @Builder + public WorkoutNote(String feedback, List imageUrls) { + this.feedback = feedback; + this.imageUrl = imageUrls; + } +} diff --git a/src/main/java/com/tnt/workoutrecord/domain/WorkoutRecord.java b/src/main/java/com/tnt/workoutrecord/domain/WorkoutRecord.java new file mode 100644 index 00000000..7f70c939 --- /dev/null +++ b/src/main/java/com/tnt/workoutrecord/domain/WorkoutRecord.java @@ -0,0 +1,31 @@ +package com.tnt.workoutrecord.domain; + +import java.time.LocalDateTime; +import java.util.List; + +import lombok.Builder; +import lombok.Getter; + +@Getter +public class WorkoutRecord { + + private Long id; + private Long memberId; + private Long ptLessonId; + private List routines; + private LocalDateTime date; + private RecordType recordType; + private WorkoutNote workoutNote; + + @Builder + public WorkoutRecord(Long id, Long memberId, Long ptLessonId, List routines, LocalDateTime date, + RecordType recordType, WorkoutNote workoutNote) { + this.id = id; + this.memberId = memberId; + this.ptLessonId = ptLessonId; + this.routines = routines; + this.date = date; + this.recordType = recordType; + this.workoutNote = workoutNote; + } +} diff --git a/src/main/java/com/tnt/workoutrecord/dto/request/CreateWorkoutRecordRequest.java b/src/main/java/com/tnt/workoutrecord/dto/request/CreateWorkoutRecordRequest.java new file mode 100644 index 00000000..a72082a0 --- /dev/null +++ b/src/main/java/com/tnt/workoutrecord/dto/request/CreateWorkoutRecordRequest.java @@ -0,0 +1,62 @@ +package com.tnt.workoutrecord.dto.request; + +import java.time.LocalDateTime; +import java.util.List; + +import com.tnt.workoutrecord.domain.RecordType; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "운동 기록 등록 요청") +public record CreateWorkoutRecordRequest( + @Schema(description = "PT 수업 ID (PT 기록인 경우)", example = "123456789", nullable = true) + Long ptLessonId, + + @Schema(description = "운동한 날짜 및 시간", example = "2025-01-15T14:30:00", nullable = false) + @NotNull + LocalDateTime date, + + @Schema(description = "기록 타입 (PT, TRAINEE)", example = "TRAINEE", nullable = false) + @NotNull + RecordType recordType, + + @Schema(description = "운동 루틴 목록", nullable = false) + @NotEmpty + @Valid + List routines, + + @Schema(description = "피드백 내용", example = "오늘 운동 강도가 적절했습니다.", nullable = true) + String feedback +) { + + @Schema(description = "운동 루틴 정보") + public record RoutineInfo( + @Schema(description = "운동 ID", example = "1", nullable = false) + @NotNull + Long workoutId, + + @Schema(description = "세트 목록", nullable = false) + @NotEmpty + @Valid + List sets + ) { + + } + + @Schema(description = "운동 세트 정보") + public record SetInfo( + @Schema(description = "운동 시간 (분 단위, 유산소 운동용)", example = "30", nullable = true) + Integer durationMinutes, + + @Schema(description = "반복 횟수 (무산소 운동용)", example = "12", nullable = true) + Integer repetition, + + @Schema(description = "무게 (kg, 무산소 운동용)", example = "50", nullable = true) + Integer weight + ) { + + } +} diff --git a/src/main/java/com/tnt/workoutrecord/dto/request/UpdateWorkoutRecordRequest.java b/src/main/java/com/tnt/workoutrecord/dto/request/UpdateWorkoutRecordRequest.java new file mode 100644 index 00000000..18446116 --- /dev/null +++ b/src/main/java/com/tnt/workoutrecord/dto/request/UpdateWorkoutRecordRequest.java @@ -0,0 +1,50 @@ +package com.tnt.workoutrecord.dto.request; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +@Schema(description = "운동 기록 수정 요청") +public record UpdateWorkoutRecordRequest( + @Schema(description = "피드백", example = "수정된 피드백입니다.", nullable = true) + String feedback, + + @Schema(description = "삭제할 이미지 URL 리스트", nullable = true) + List imageUrlsToDelete, + + @Schema(description = "루틴 정보 리스트", nullable = false) + @Valid + @Size(min = 1, message = "최소 1개 이상의 루틴이 필요합니다.") + @NotNull(message = "루틴 정보는 필수입니다.") + List routines +) { + @Schema(description = "루틴 정보") + public record RoutineInfo( + @Schema(description = "운동 ID", example = "10", nullable = false) + @NotNull(message = "운동 ID는 필수입니다.") + Long workoutId, + + @Schema(description = "세트 정보 리스트", nullable = false) + @Valid + @Size(min = 1, message = "최소 1개 이상의 세트가 필요합니다.") + @NotNull(message = "세트 정보는 필수입니다.") + List sets + ) { + } + + @Schema(description = "세트 정보") + public record SetInfo( + @Schema(description = "지속 시간 (분, 유산소 운동용)", example = "30", nullable = true) + Integer durationMinutes, + + @Schema(description = "반복 횟수 (무산소 운동용)", example = "12", nullable = true) + Integer repetition, + + @Schema(description = "무게 (kg)", example = "50", nullable = true) + Integer weight + ) { + } +} diff --git a/src/main/java/com/tnt/workoutrecord/dto/response/GetWorkoutRecordResponse.java b/src/main/java/com/tnt/workoutrecord/dto/response/GetWorkoutRecordResponse.java new file mode 100644 index 00000000..7e3e3d20 --- /dev/null +++ b/src/main/java/com/tnt/workoutrecord/dto/response/GetWorkoutRecordResponse.java @@ -0,0 +1,64 @@ +package com.tnt.workoutrecord.dto.response; + +import java.time.LocalDateTime; +import java.util.List; + +import com.tnt.workoutrecord.domain.RecordType; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "운동 기록 조회 응답") +public record GetWorkoutRecordResponse( + @Schema(description = "운동 기록 ID", example = "1") + Long id, + + @Schema(description = "회원 ID", example = "1") + Long memberId, + + @Schema(description = "PT 수업 ID (nullable)", example = "5", nullable = true) + Long ptLessonId, + + @Schema(description = "운동 기록 날짜", example = "2025-03-20T14:30:00") + LocalDateTime date, + + @Schema(description = "기록 타입 (PT 또는 TRAINEE)", example = "PT") + RecordType recordType, + + @Schema(description = "피드백", example = "오늘 운동 잘했습니다!", nullable = true) + String feedback, + + @Schema(description = "운동 사진 URL 리스트 (최대 6장)") + List imageUrls, + + @Schema(description = "루틴 목록") + List routines +) { + @Schema(description = "루틴 정보") + public record RoutineResponse( + @Schema(description = "루틴 ID", example = "1") + Long id, + + @Schema(description = "운동 ID", example = "10") + Long workoutId, + + @Schema(description = "세트 목록") + List sets + ) { + } + + @Schema(description = "세트 정보") + public record SetResponse( + @Schema(description = "세트 ID", example = "1") + Long id, + + @Schema(description = "지속 시간 (분, 유산소 운동용)", example = "30", nullable = true) + Integer durationMinutes, + + @Schema(description = "반복 횟수 (무산소 운동용)", example = "12", nullable = true) + Integer repetition, + + @Schema(description = "무게 (kg)", example = "50", nullable = true) + Integer weight + ) { + } +} diff --git a/src/main/java/com/tnt/workoutrecord/infrastructure/RoutineJpaEntity.java b/src/main/java/com/tnt/workoutrecord/infrastructure/RoutineJpaEntity.java new file mode 100644 index 00000000..3a15cb18 --- /dev/null +++ b/src/main/java/com/tnt/workoutrecord/infrastructure/RoutineJpaEntity.java @@ -0,0 +1,55 @@ +package com.tnt.workoutrecord.infrastructure; + +import com.tnt.workoutrecord.domain.Routine; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "routine") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RoutineJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false, unique = true) + private Long id; + + @Column(name = "workout_record_id", nullable = false) + private Long workoutRecordId; + + @Column(name = "workout_id", nullable = false) + private Long workoutId; + + @Builder + public RoutineJpaEntity(Long id, Long workoutRecordId, Long workoutId) { + this.id = id; + this.workoutRecordId = workoutRecordId; + this.workoutId = workoutId; + } + + public static RoutineJpaEntity from(Routine routine) { + return RoutineJpaEntity.builder() + .id(routine.getId()) + .workoutRecordId(routine.getWorkoutRecordId()) + .workoutId(routine.getWorkoutId()) + .build(); + } + + public Routine toModel() { + return Routine.builder() + .id(this.id) + .workoutRecordId(this.workoutRecordId) + .workoutId(this.workoutId) + .build(); + } +} diff --git a/src/main/java/com/tnt/workoutrecord/infrastructure/RoutineJpaRepository.java b/src/main/java/com/tnt/workoutrecord/infrastructure/RoutineJpaRepository.java new file mode 100644 index 00000000..38047c57 --- /dev/null +++ b/src/main/java/com/tnt/workoutrecord/infrastructure/RoutineJpaRepository.java @@ -0,0 +1,7 @@ +package com.tnt.workoutrecord.infrastructure; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RoutineJpaRepository extends JpaRepository { + +} diff --git a/src/main/java/com/tnt/workoutrecord/infrastructure/RoutineRepositoryImpl.java b/src/main/java/com/tnt/workoutrecord/infrastructure/RoutineRepositoryImpl.java new file mode 100644 index 00000000..8cce80f8 --- /dev/null +++ b/src/main/java/com/tnt/workoutrecord/infrastructure/RoutineRepositoryImpl.java @@ -0,0 +1,53 @@ +package com.tnt.workoutrecord.infrastructure; + +import static com.tnt.workoutrecord.infrastructure.QRoutineJpaEntity.routineJpaEntity; + +import java.util.List; + +import org.springframework.stereotype.Service; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.tnt.workoutrecord.application.repository.RoutineRepository; +import com.tnt.workoutrecord.domain.Routine; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class RoutineRepositoryImpl implements RoutineRepository { + + private final RoutineJpaRepository routineJpaRepository; + private final JPAQueryFactory jpaQueryFactory; + + @Override + public List saveAll(List routines) { + if (routines.isEmpty()) { + return List.of(); + } + + List routineJpaEntities = routines.stream().map(RoutineJpaEntity::from).toList(); + + List saved = routineJpaRepository.saveAll(routineJpaEntities); + + return saved.stream().map(RoutineJpaEntity::toModel).toList(); + } + + @Override + public List findAllByWorkoutRecordId(Long workoutRecordId) { + return jpaQueryFactory + .selectFrom(routineJpaEntity) + .where(routineJpaEntity.workoutRecordId.eq(workoutRecordId)) + .fetch() + .stream() + .map(RoutineJpaEntity::toModel) + .toList(); + } + + @Override + public void deleteAllByWorkoutRecordId(Long workoutRecordId) { + jpaQueryFactory + .delete(routineJpaEntity) + .where(routineJpaEntity.workoutRecordId.eq(workoutRecordId)) + .execute(); + } +} diff --git a/src/main/java/com/tnt/workoutrecord/infrastructure/SetJpaEntity.java b/src/main/java/com/tnt/workoutrecord/infrastructure/SetJpaEntity.java new file mode 100644 index 00000000..41125869 --- /dev/null +++ b/src/main/java/com/tnt/workoutrecord/infrastructure/SetJpaEntity.java @@ -0,0 +1,68 @@ +package com.tnt.workoutrecord.infrastructure; + +import com.tnt.workoutrecord.domain.Set; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "workout_set") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class SetJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false, unique = true) + private Long id; + + @Column(name = "routine_id", nullable = false) + private Long routineId; + + @Column(name = "duration_minutes", nullable = true) + private Integer durationMinutes; + + @Column(name = "repetition", nullable = true) + private Integer repetition; + + @Column(name = "weight", nullable = true) + private Integer weight; + + @Builder + public SetJpaEntity(Long id, Long routineId, Integer durationMinutes, Integer repetition, + Integer weight) { + this.id = id; + this.routineId = routineId; + this.durationMinutes = durationMinutes; + this.repetition = repetition; + this.weight = weight; + } + + public static SetJpaEntity from(Set set) { + return SetJpaEntity.builder() + .id(set.getId()) + .routineId(set.getRoutineId()) + .durationMinutes(set.getDurationMinutes()) + .repetition(set.getRepetition()) + .weight(set.getWeight()) + .build(); + } + + public Set toModel() { + return Set.builder() + .id(this.id) + .routineId(this.routineId) + .durationMinutes(this.durationMinutes) + .repetition(this.repetition) + .weight(this.weight) + .build(); + } +} diff --git a/src/main/java/com/tnt/workoutrecord/infrastructure/SetJpaRepository.java b/src/main/java/com/tnt/workoutrecord/infrastructure/SetJpaRepository.java new file mode 100644 index 00000000..d974ae1b --- /dev/null +++ b/src/main/java/com/tnt/workoutrecord/infrastructure/SetJpaRepository.java @@ -0,0 +1,7 @@ +package com.tnt.workoutrecord.infrastructure; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SetJpaRepository extends JpaRepository { + +} diff --git a/src/main/java/com/tnt/workoutrecord/infrastructure/SetRepositoryImpl.java b/src/main/java/com/tnt/workoutrecord/infrastructure/SetRepositoryImpl.java new file mode 100644 index 00000000..a8674856 --- /dev/null +++ b/src/main/java/com/tnt/workoutrecord/infrastructure/SetRepositoryImpl.java @@ -0,0 +1,61 @@ +package com.tnt.workoutrecord.infrastructure; + +import static com.tnt.workoutrecord.infrastructure.QSetJpaEntity.setJpaEntity; + +import java.util.List; + +import org.springframework.stereotype.Service; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.tnt.workoutrecord.application.repository.SetRepository; +import com.tnt.workoutrecord.domain.Set; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class SetRepositoryImpl implements SetRepository { + + private final SetJpaRepository setJpaRepository; + private final JPAQueryFactory jpaQueryFactory; + + @Override + public List saveAll(List sets) { + if (sets.isEmpty()) { + return List.of(); + } + + List setJpaEntities = sets.stream().map(SetJpaEntity::from).toList(); + + List saved = setJpaRepository.saveAll(setJpaEntities); + + return saved.stream().map(SetJpaEntity::toModel).toList(); + } + + @Override + public List findAllByRoutineIds(List routineIds) { + if (routineIds.isEmpty()) { + return List.of(); + } + + return jpaQueryFactory + .selectFrom(setJpaEntity) + .where(setJpaEntity.routineId.in(routineIds)) + .fetch() + .stream() + .map(SetJpaEntity::toModel) + .toList(); + } + + @Override + public void deleteAllByRoutineIds(List routineIds) { + if (routineIds.isEmpty()) { + return; + } + + jpaQueryFactory + .delete(setJpaEntity) + .where(setJpaEntity.routineId.in(routineIds)) + .execute(); + } +} diff --git a/src/main/java/com/tnt/workoutrecord/infrastructure/WorkoutRecordJpaEntity.java b/src/main/java/com/tnt/workoutrecord/infrastructure/WorkoutRecordJpaEntity.java new file mode 100644 index 00000000..1539530c --- /dev/null +++ b/src/main/java/com/tnt/workoutrecord/infrastructure/WorkoutRecordJpaEntity.java @@ -0,0 +1,82 @@ +package com.tnt.workoutrecord.infrastructure; + +import java.time.LocalDateTime; + +import com.tnt.workoutrecord.domain.RecordType; +import com.tnt.workoutrecord.domain.WorkoutNote; +import com.tnt.workoutrecord.domain.WorkoutRecord; + +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "workout_record") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class WorkoutRecordJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false, unique = true) + private Long id; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(name = "pt_lesson_id", nullable = true) + private Long ptLessonId; + + @Column(name = "date", nullable = false) + private LocalDateTime date; + + @Embedded + private WorkoutNote workoutNote; + + @Enumerated(EnumType.STRING) + @Column(name = "record_type", nullable = false) + private RecordType recordType; + + @Builder + public WorkoutRecordJpaEntity(Long id, Long memberId, Long ptLessonId, LocalDateTime date, WorkoutNote workoutNote, + RecordType recordType) { + this.id = id; + this.memberId = memberId; + this.ptLessonId = ptLessonId; + this.date = date; + this.workoutNote = workoutNote; + this.recordType = recordType; + } + + public static WorkoutRecordJpaEntity from(WorkoutRecord workoutRecord) { + return WorkoutRecordJpaEntity.builder() + .id(workoutRecord.getId()) + .memberId(workoutRecord.getMemberId()) + .ptLessonId(workoutRecord.getPtLessonId()) + .date(workoutRecord.getDate()) + .workoutNote(workoutRecord.getWorkoutNote()) + .recordType(workoutRecord.getRecordType()) + .build(); + } + + public WorkoutRecord toModel() { + return WorkoutRecord.builder() + .id(this.id) + .memberId(this.memberId) + .ptLessonId(this.ptLessonId) + .date(this.date) + .workoutNote(this.workoutNote) + .recordType(this.recordType) + .build(); + } +} diff --git a/src/main/java/com/tnt/workoutrecord/infrastructure/WorkoutRecordJpaRepository.java b/src/main/java/com/tnt/workoutrecord/infrastructure/WorkoutRecordJpaRepository.java new file mode 100644 index 00000000..a09f6f51 --- /dev/null +++ b/src/main/java/com/tnt/workoutrecord/infrastructure/WorkoutRecordJpaRepository.java @@ -0,0 +1,7 @@ +package com.tnt.workoutrecord.infrastructure; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface WorkoutRecordJpaRepository extends JpaRepository { + +} diff --git a/src/main/java/com/tnt/workoutrecord/infrastructure/WorkoutRecordRepositoryImpl.java b/src/main/java/com/tnt/workoutrecord/infrastructure/WorkoutRecordRepositoryImpl.java new file mode 100644 index 00000000..4b997d3a --- /dev/null +++ b/src/main/java/com/tnt/workoutrecord/infrastructure/WorkoutRecordRepositoryImpl.java @@ -0,0 +1,37 @@ +package com.tnt.workoutrecord.infrastructure; + +import static com.tnt.common.error.model.ErrorMessage.WORKOUT_RECORD_NOT_FOUND; + +import org.springframework.stereotype.Repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.tnt.common.error.exception.NotFoundException; +import com.tnt.workoutrecord.application.repository.WorkoutRecordRepository; +import com.tnt.workoutrecord.domain.WorkoutRecord; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class WorkoutRecordRepositoryImpl implements WorkoutRecordRepository { + + private final WorkoutRecordJpaRepository workoutRecordJpaRepository; + private final JPAQueryFactory jpaQueryFactory; + + @Override + public WorkoutRecord save(WorkoutRecord workoutRecord) { + return workoutRecordJpaRepository.save(WorkoutRecordJpaEntity.from(workoutRecord)).toModel(); + } + + @Override + public WorkoutRecord findById(Long workoutRecordId) { + return workoutRecordJpaRepository.findById(workoutRecordId) + .map(WorkoutRecordJpaEntity::toModel) + .orElseThrow(() -> new NotFoundException(WORKOUT_RECORD_NOT_FOUND)); + } + + @Override + public void deleteById(Long workoutRecordId) { + workoutRecordJpaRepository.deleteById(workoutRecordId); + } +} diff --git a/src/main/java/com/tnt/workoutrecord/presentation/WorkoutRecordController.java b/src/main/java/com/tnt/workoutrecord/presentation/WorkoutRecordController.java new file mode 100644 index 00000000..60c0549e --- /dev/null +++ b/src/main/java/com/tnt/workoutrecord/presentation/WorkoutRecordController.java @@ -0,0 +1,106 @@ +package com.tnt.workoutrecord.presentation; + +import static org.springframework.http.HttpStatus.CREATED; +import static org.springframework.http.HttpStatus.NO_CONTENT; +import static org.springframework.http.HttpStatus.OK; +import static org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE; + +import java.util.List; + +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.tnt.gateway.config.AuthMember; +import com.tnt.image.application.S3Service; +import com.tnt.workoutrecord.application.WorkoutRecordService; +import com.tnt.workoutrecord.dto.request.CreateWorkoutRecordRequest; +import com.tnt.workoutrecord.dto.request.UpdateWorkoutRecordRequest; +import com.tnt.workoutrecord.dto.response.GetWorkoutRecordResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Size; +import lombok.RequiredArgsConstructor; + +@Tag(name = "운동 기록", description = "운동 기록 관련 API") +@RestController +@RequestMapping("/workout-records") +@RequiredArgsConstructor +public class WorkoutRecordController { + + private final WorkoutRecordService workoutRecordService; + private final S3Service s3Service; + + @Operation(summary = "운동 기록 등록 API", description = "JSON 데이터와 이미지 파일(최대 6장)을 함께 전송합니다.") + @PostMapping(consumes = MULTIPART_FORM_DATA_VALUE) + @ResponseStatus(CREATED) + public void createWorkoutRecord(@AuthMember Long memberId, + @Parameter(description = "운동 기록 정보", required = true) + @RequestPart("request") @Valid CreateWorkoutRecordRequest request, + + @Parameter(description = "운동 사진 (최대 6장)", schema = @Schema(type = "array", format = "binary")) + @Size(max = 6, message = "이미지는 최대 6장까지 등록 가능합니다.") + @RequestPart(value = "images", required = false) List images + ) { + List imageUrls = s3Service.uploadWorkoutRecordImages(images); + + workoutRecordService.createWorkoutRecord(memberId, request, imageUrls); + } + + @Operation(summary = "운동 기록 조회 API", description = "운동 기록 상세 정보를 조회합니다.") + @GetMapping("/{recordId}") + @ResponseStatus(OK) + public GetWorkoutRecordResponse getWorkoutRecord(@PathVariable Long recordId) { + return workoutRecordService.getWorkoutRecord(recordId); + } + + @Operation(summary = "운동 기록 수정 API", + description = "운동 기록을 수정합니다. 새 이미지 추가와 기존 이미지 삭제를 동시에 처리하며, 루틴 정보는 전체 교체됩니다.") + @PutMapping(value = "/{recordId}", consumes = MULTIPART_FORM_DATA_VALUE) + @ResponseStatus(NO_CONTENT) + public void updateWorkoutRecord(@AuthMember Long memberId, + @PathVariable Long recordId, + + @Parameter(description = "수정할 운동 기록 정보", required = true) + @RequestPart("request") @Valid UpdateWorkoutRecordRequest request, + + @Parameter(description = "추가할 운동 사진 (기존 사진과 합쳐 최대 6장)", schema = @Schema(type = "array", format = "binary")) + @RequestPart(value = "newImages", required = false) List newImages + ) { + // 새 이미지 업로드 + List newImageUrls = s3Service.uploadWorkoutRecordImages(newImages); + + // 삭제할 이미지 S3에서 삭제 + if (request.imageUrlsToDelete() != null && !request.imageUrlsToDelete().isEmpty()) { + request.imageUrlsToDelete().forEach(s3Service::deleteWorkoutRecordImage); + } + + workoutRecordService.updateWorkoutRecord(memberId, recordId, request, newImageUrls, + request.imageUrlsToDelete()); + } + + @Operation(summary = "운동 기록 삭제 API", description = "운동 기록과 관련된 루틴, 세트를 모두 삭제합니다.") + @DeleteMapping("/{recordId}") + @ResponseStatus(NO_CONTENT) + public void deleteWorkoutRecord(@PathVariable Long recordId) { + // 이미지 URL 조회 후 S3에서 삭제 + GetWorkoutRecordResponse workoutRecord = workoutRecordService.getWorkoutRecord(recordId); + + if (workoutRecord.imageUrls() != null && !workoutRecord.imageUrls().isEmpty()) { + workoutRecord.imageUrls().forEach(s3Service::deleteWorkoutRecordImage); + } + + workoutRecordService.deleteWorkoutRecord(recordId); + } +} diff --git a/src/test/java/com/tnt/image/application/S3ServiceTest.java b/src/test/java/com/tnt/image/application/S3ServiceTest.java index c9f8ad7b..f78b7cc8 100644 --- a/src/test/java/com/tnt/image/application/S3ServiceTest.java +++ b/src/test/java/com/tnt/image/application/S3ServiceTest.java @@ -3,6 +3,7 @@ import static com.tnt.common.constant.ImageConstant.TRAINEE_DEFAULT_IMAGE; import static com.tnt.member.domain.MemberType.TRAINEE; import static com.tnt.member.domain.MemberType.TRAINER; +import static java.awt.image.BufferedImage.TYPE_INT_RGB; import static java.util.Objects.requireNonNull; import static org.apache.commons.imaging.formats.tiff.constants.TiffTagConstants.TIFF_TAG_ORIENTATION; import static org.assertj.core.api.Assertions.assertThat; @@ -10,6 +11,8 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.any; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.springframework.http.MediaType.IMAGE_JPEG_VALUE; import java.awt.Color; @@ -17,6 +20,7 @@ import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.util.List; import javax.imageio.ImageIO; @@ -45,7 +49,7 @@ class S3ServiceTest { private S3Adapter s3Adapter; private BufferedImage createTestImage() { - BufferedImage image = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB); + BufferedImage image = new BufferedImage(100, 100, TYPE_INT_RGB); Graphics2D graphics = image.createGraphics(); graphics.setColor(Color.WHITE); @@ -146,4 +150,65 @@ void rotate_image_orientation_6_success() throws IOException { assertThat(requireNonNull(rotatedImage).getRGB(25, 50)).isEqualTo(Color.BLACK.getRGB()); assertThat(requireNonNull(rotatedImage).getRGB(75, 50)).isEqualTo(Color.WHITE.getRGB()); } + + @Test + @DisplayName("운동 기록 이미지 여러 개 업로드 성공") + void upload_workout_record_images_success() throws Exception { + // given + MockMultipartFile image1 = new MockMultipartFile("image1", "test1.jpg", IMAGE_JPEG_VALUE, + createDummyImageData(1)); + MockMultipartFile image2 = new MockMultipartFile("image2", "test2.jpg", IMAGE_JPEG_VALUE, + createDummyImageData(1)); + String expectedUrl1 = "https://bucket.s3.amazonaws.com/workout-record/123.jpg"; + String expectedUrl2 = "https://bucket.s3.amazonaws.com/workout-record/456.jpg"; + + given(s3Adapter.uploadFile(any(byte[].class), anyString(), anyString())).willReturn(expectedUrl1) + .willReturn(expectedUrl2); + + // when + var result = s3Service.uploadWorkoutRecordImages(List.of(image1, image2)); + + // then + assertThat(result).hasSize(2).contains(expectedUrl1, expectedUrl2); + } + + @Test + @DisplayName("운동 기록 이미지 null일 경우 빈 리스트 반환") + void upload_workout_record_images_null_returns_empty_list() { + // when + var result = s3Service.uploadWorkoutRecordImages(null); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("운동 기록 이미지 빈 리스트일 경우 빈 리스트 반환") + void upload_workout_record_images_empty_returns_empty_list() { + // when + var result = s3Service.uploadWorkoutRecordImages(List.of()); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("운동 기록 이미지 null일 경우 삭제하지 않음") + void delete_workout_record_image_null_does_nothing() { + // when + s3Service.deleteWorkoutRecordImage(null); + + // then + verify(s3Adapter, never()).deleteFile(anyString()); + } + + @Test + @DisplayName("운동 기록 이미지 빈 문자열일 경우 삭제하지 않음") + void delete_workout_record_image_empty_does_nothing() { + // when + s3Service.deleteWorkoutRecordImage(""); + + // then + verify(s3Adapter, never()).deleteFile(anyString()); + } } diff --git a/src/test/java/com/tnt/trainer/presentation/TrainerControllerTest.java b/src/test/java/com/tnt/trainer/presentation/TrainerControllerTest.java index 087c9bc0..79281748 100644 --- a/src/test/java/com/tnt/trainer/presentation/TrainerControllerTest.java +++ b/src/test/java/com/tnt/trainer/presentation/TrainerControllerTest.java @@ -6,6 +6,7 @@ import static com.tnt.trainee.domain.PtGoal.WEIGHT_LOSS; import static java.util.Comparator.comparing; import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; @@ -1089,4 +1090,102 @@ void cancel_pt_lesson_success() throws Exception { mockMvc.perform(put("/trainers/lessons/{ptLessonId}/cancel", ptLesson.getId())) .andExpect(status().isOk()); } + + @Test + @DisplayName("통합 테스트 - PT 수업 수정 성공") + void update_pt_lesson_success() throws Exception { + // given + Member trainerMember = MemberFixture.getTrainerMember1(); + Member traineeMember = MemberFixture.getTraineeMember1(); + + trainerMember = memberRepository.save(trainerMember); + traineeMember = memberRepository.save(traineeMember); + + CustomUserDetails trainerUserDetails = new CustomUserDetails(trainerMember.getId(), + trainerMember.getId().toString(), + authoritiesMapper.mapAuthorities(List.of(new SimpleGrantedAuthority("ROLE_USER")))); + + Authentication authentication = new UsernamePasswordAuthenticationToken(trainerUserDetails, null, + authoritiesMapper.mapAuthorities(trainerUserDetails.getAuthorities())); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + Trainer trainer = TrainerFixture.getTrainer1(trainerMember); + Trainee trainee = TraineeFixture.getTrainee1(traineeMember); + + trainer = trainerRepository.save(trainer); + trainee = traineeRepository.save(trainee); + + PtTrainerTrainee ptTrainerTrainee = PtTrainerTraineeFixture.getPtTrainerTrainee1(trainer, trainee); + ptTrainerTrainee = ptTrainerTraineeRepository.save(ptTrainerTrainee); + + PtLesson ptLesson = PtLesson.builder() + .ptTrainerTrainee(ptTrainerTrainee) + .session(1) + .lessonStart(LocalDateTime.of(2025, 1, 15, 10, 0)) + .lessonEnd(LocalDateTime.of(2025, 1, 15, 11, 0)) + .memo("원래 메모") + .build(); + + ptLesson = ptLessonRepository.save(ptLesson); + + String updateRequest = """ + { + "lessonStart": "2025-01-15T14:00:00", + "lessonEnd": "2025-01-15T15:00:00", + "memo": "수정된 메모" + } + """; + + // when & then + mockMvc.perform(put("/trainers/lessons/{ptLessonId}/edit", ptLesson.getId()) + .contentType("application/json") + .content(updateRequest)) + .andExpect(status().isNoContent()) + .andDo(print()); + } + + @Test + @DisplayName("통합 테스트 - PT 수업 삭제 성공") + void delete_pt_lesson_success() throws Exception { + // given + Member trainerMember = MemberFixture.getTrainerMember1(); + Member traineeMember = MemberFixture.getTraineeMember1(); + + trainerMember = memberRepository.save(trainerMember); + traineeMember = memberRepository.save(traineeMember); + + CustomUserDetails trainerUserDetails = new CustomUserDetails(trainerMember.getId(), + trainerMember.getId().toString(), + authoritiesMapper.mapAuthorities(List.of(new SimpleGrantedAuthority("ROLE_USER")))); + + Authentication authentication = new UsernamePasswordAuthenticationToken(trainerUserDetails, null, + authoritiesMapper.mapAuthorities(trainerUserDetails.getAuthorities())); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + Trainer trainer = TrainerFixture.getTrainer1(trainerMember); + Trainee trainee = TraineeFixture.getTrainee1(traineeMember); + + trainer = trainerRepository.save(trainer); + trainee = traineeRepository.save(trainee); + + PtTrainerTrainee ptTrainerTrainee = PtTrainerTraineeFixture.getPtTrainerTrainee1(trainer, trainee); + ptTrainerTrainee = ptTrainerTraineeRepository.save(ptTrainerTrainee); + + PtLesson ptLesson = PtLesson.builder() + .ptTrainerTrainee(ptTrainerTrainee) + .session(1) + .lessonStart(LocalDateTime.of(2025, 1, 15, 10, 0)) + .lessonEnd(LocalDateTime.of(2025, 1, 15, 11, 0)) + .memo("삭제될 메모") + .build(); + + ptLesson = ptLessonRepository.save(ptLesson); + + // when & then + mockMvc.perform(delete("/trainers/lessons/{ptLessonId}/delete", ptLesson.getId())) + .andExpect(status().isNoContent()) + .andDo(print()); + } } diff --git a/src/test/java/com/tnt/workout/application/WorkoutServiceTest.java b/src/test/java/com/tnt/workout/application/WorkoutServiceTest.java new file mode 100644 index 00000000..4fea8af5 --- /dev/null +++ b/src/test/java/com/tnt/workout/application/WorkoutServiceTest.java @@ -0,0 +1,187 @@ +package com.tnt.workout.application; + +import static com.tnt.workout.domain.BodyPart.CHEST; +import static com.tnt.workout.domain.BodyPart.LEG; +import static com.tnt.workout.domain.BodyPart.SHOULDERS; +import static com.tnt.workout.domain.Machine.BARBELL; +import static com.tnt.workout.domain.Machine.MACHINE; +import static com.tnt.workout.domain.WorkoutType.ANAEROBIC; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.tnt.workout.application.repository.WorkoutRepository; +import com.tnt.workout.domain.BodyPart; +import com.tnt.workout.domain.Machine; +import com.tnt.workout.domain.Workout; +import com.tnt.workout.dto.response.SearchWorkoutResponse; + +@ExtendWith(MockitoExtension.class) +class WorkoutServiceTest { + + @InjectMocks + private WorkoutService workoutService; + + @Mock + private WorkoutRepository workoutRepository; + + @Test + @DisplayName("운동 검색 - 키워드로 검색 성공") + void search_with_keyword_success() { + // given + String keyword = "벤치"; + List workouts = List.of(createWorkout(1L, "벤치프레스", List.of(CHEST), List.of(BARBELL)), + createWorkout(2L, "인클라인벤치프레스", List.of(CHEST, SHOULDERS), List.of(BARBELL))); + + given(workoutRepository.search(keyword, null, null, null, 10)).willReturn(workouts); + + // when + SearchWorkoutResponse response = workoutService.search(keyword, null, null, null, 10); + + // then + assertThat(response.workouts()).hasSize(2); + assertThat(response.hasNext()).isFalse(); + } + + @Test + @DisplayName("운동 검색 - 부위로 검색 성공") + void search_with_bodyParts_success() { + // given + List bodyParts = List.of(CHEST); + List workouts = List.of(createWorkout(1L, "벤치프레스", List.of(CHEST), List.of(BARBELL)), + createWorkout(2L, "체스트프레스", List.of(CHEST), List.of(MACHINE))); + + given(workoutRepository.search(null, bodyParts, null, null, 10)).willReturn(workouts); + + // when + SearchWorkoutResponse response = workoutService.search(null, bodyParts, null, null, 10); + + // then + assertThat(response.workouts()).hasSize(2); + assertThat(response.hasNext()).isFalse(); + } + + @Test + @DisplayName("운동 검색 - 기구로 검색 성공") + void search_with_machines_success() { + // given + List machines = List.of(BARBELL); + List workouts = List.of( + createWorkout(1L, "벤치프레스", List.of(CHEST), List.of(BARBELL)), + createWorkout(2L, "스쿼트", List.of(LEG), List.of(BARBELL))); + + given(workoutRepository.search(null, null, machines, null, 10)).willReturn(workouts); + + // when + SearchWorkoutResponse response = workoutService.search(null, null, machines, null, 10); + + // then + assertThat(response.workouts()).hasSize(2); + assertThat(response.hasNext()).isFalse(); + } + + @Test + @DisplayName("운동 검색 - 복합 조건 검색 성공") + void search_with_multiple_conditions_success() { + // given + String keyword = "프레스"; + List bodyParts = List.of(CHEST); + List machines = List.of(BARBELL); + + List workouts = List.of(createWorkout(1L, "벤치프레스", List.of(CHEST), List.of(BARBELL))); + + given(workoutRepository.search(keyword, bodyParts, machines, null, 10)).willReturn(workouts); + + // when + SearchWorkoutResponse response = workoutService.search(keyword, bodyParts, machines, null, 10); + + // then + assertThat(response.workouts()).hasSize(1); + assertThat(response.workouts().getFirst().name()).isEqualTo("벤치프레스"); + assertThat(response.hasNext()).isFalse(); + } + + @Test + @DisplayName("운동 검색 - 페이지네이션 hasNext true") + void search_with_pagination_hasNext_true() { + // given + int pageSize = 10; + + List workouts = List.of( + createWorkout(11L, "운동11", List.of(CHEST), List.of(BARBELL)), + createWorkout(10L, "운동10", List.of(CHEST), List.of(BARBELL)), + createWorkout(9L, "운동9", List.of(CHEST), List.of(BARBELL)), + createWorkout(8L, "운동8", List.of(CHEST), List.of(BARBELL)), + createWorkout(7L, "운동7", List.of(CHEST), List.of(BARBELL)), + createWorkout(6L, "운동6", List.of(CHEST), List.of(BARBELL)), + createWorkout(5L, "운동5", List.of(CHEST), List.of(BARBELL)), + createWorkout(4L, "운동4", List.of(CHEST), List.of(BARBELL)), + createWorkout(3L, "운동3", List.of(CHEST), List.of(BARBELL)), + createWorkout(2L, "운동2", List.of(CHEST), List.of(BARBELL)), + createWorkout(1L, "운동1", List.of(CHEST), List.of(BARBELL)) + ); + + given(workoutRepository.search(null, null, null, null, pageSize)).willReturn(workouts); + + // when + SearchWorkoutResponse response = workoutService.search(null, null, null, null, pageSize); + + // then + assertThat(response.workouts()).hasSize(pageSize); + assertThat(response.hasNext()).isTrue(); + } + + @Test + @DisplayName("운동 검색 - 커서 페이지네이션") + void search_with_cursor_pagination() { + // given + Long lastId = 100L; + List workouts = List.of( + createWorkout(99L, "운동99", List.of(CHEST), List.of(BARBELL)), + createWorkout(98L, "운동98", List.of(CHEST), List.of(BARBELL)) + ); + + given(workoutRepository.search(null, null, null, lastId, 10)).willReturn(workouts); + + // when + SearchWorkoutResponse response = workoutService.search(null, null, null, lastId, 10); + + // then + assertThat(response.workouts()).hasSize(2); + assertThat(response.workouts().getFirst().id()).isEqualTo(99L); + assertThat(response.hasNext()).isFalse(); + } + + @Test + @DisplayName("운동 검색 - 결과 없음") + void search_no_results() { + // given + given(workoutRepository.search(null, null, null, null, 10)).willReturn(List.of()); + + // when + SearchWorkoutResponse response = workoutService.search(null, null, null, null, 10); + + // then + assertThat(response.workouts()).isEmpty(); + assertThat(response.hasNext()).isFalse(); + } + + private Workout createWorkout(Long id, String name, List bodyParts, List machines) { + return Workout.builder() + .id(id) + .name(name) + .imageUrl("https://images.tntapp.co.kr/" + name + ".jpg") + .bodyParts(bodyParts) + .machines(machines) + .workoutType(ANAEROBIC) + .build(); + } +} diff --git a/src/test/java/com/tnt/workout/presentation/WorkoutControllerTest.java b/src/test/java/com/tnt/workout/presentation/WorkoutControllerTest.java new file mode 100644 index 00000000..e9733b4a --- /dev/null +++ b/src/test/java/com/tnt/workout/presentation/WorkoutControllerTest.java @@ -0,0 +1,187 @@ +package com.tnt.workout.presentation; + +import static com.tnt.workout.domain.BodyPart.BACK; +import static com.tnt.workout.domain.BodyPart.CHEST; +import static com.tnt.workout.domain.BodyPart.LEG; +import static com.tnt.workout.domain.Machine.BARBELL; +import static com.tnt.workout.domain.Machine.DUMBBELL; +import static com.tnt.workout.domain.Machine.MACHINE; +import static com.tnt.workout.domain.WorkoutType.ANAEROBIC; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import com.tnt.workout.domain.BodyPart; +import com.tnt.workout.domain.Machine; +import com.tnt.workout.domain.Workout; +import com.tnt.workout.infrastructure.WorkoutJpaEntity; +import com.tnt.workout.infrastructure.WorkoutJpaRepository; + +@Transactional +@SpringBootTest +@TestPropertySource("classpath:application-test.properties") +@AutoConfigureMockMvc(addFilters = false) +class WorkoutControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private WorkoutJpaRepository workoutJpaRepository; + + @Test + @DisplayName("통합 테스트 - 운동 검색 성공 (키워드)") + void search_with_keyword_success() throws Exception { + // given + saveWorkout("벤치프레스", List.of(CHEST), List.of(BARBELL)); + saveWorkout("인클라인벤치프레스", List.of(CHEST), List.of(BARBELL)); + saveWorkout("스쿼트", List.of(LEG), List.of(BARBELL)); + + // when & then + mockMvc.perform(get("/workouts/search") + .param("keyword", "벤치")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.workouts").isArray()) + .andExpect(jsonPath("$.workouts.length()").value(2)) + .andExpect(jsonPath("$.hasNext").value(false)); + } + + @Test + @DisplayName("통합 테스트 - 운동 검색 성공 (부위)") + void search_with_bodyPart_success() throws Exception { + // given + saveWorkout("벤치프레스", List.of(CHEST), List.of(BARBELL)); + saveWorkout("스쿼트", List.of(LEG), List.of(BARBELL)); + saveWorkout("데드리프트", List.of(BACK), List.of(BARBELL)); + + // when & then + mockMvc.perform(get("/workouts/search") + .param("bodyParts", "CHEST")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.workouts").isArray()) + .andExpect(jsonPath("$.workouts.length()").value(1)) + .andExpect(jsonPath("$.workouts[0].name").value("벤치프레스")) + .andExpect(jsonPath("$.hasNext").value(false)); + } + + @Test + @DisplayName("통합 테스트 - 운동 검색 성공 (기구)") + void search_with_machine_success() throws Exception { + // given + saveWorkout("벤치프레스", List.of(CHEST), List.of(BARBELL)); + saveWorkout("덤벨프레스", List.of(CHEST), List.of(DUMBBELL)); + saveWorkout("체스트프레스", List.of(CHEST), List.of(MACHINE)); + + // when & then + mockMvc.perform(get("/workouts/search") + .param("machines", "DUMBBELL")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.workouts").isArray()) + .andExpect(jsonPath("$.workouts.length()").value(1)) + .andExpect(jsonPath("$.workouts[0].name").value("덤벨프레스")) + .andExpect(jsonPath("$.hasNext").value(false)); + } + + @Test + @DisplayName("통합 테스트 - 운동 검색 성공 (복합 조건)") + void search_with_multiple_conditions_success() throws Exception { + // given + saveWorkout("벤치프레스", List.of(CHEST), List.of(BARBELL)); + saveWorkout("덤벨프레스", List.of(CHEST), List.of(DUMBBELL)); + saveWorkout("스쿼트", List.of(LEG), List.of(BARBELL)); + + // when & then + mockMvc.perform(get("/workouts/search") + .param("keyword", "프레스") + .param("bodyParts", "CHEST") + .param("machines", "BARBELL")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.workouts").isArray()) + .andExpect(jsonPath("$.workouts.length()").value(1)) + .andExpect(jsonPath("$.workouts[0].name").value("벤치프레스")) + .andExpect(jsonPath("$.hasNext").value(false)); + } + + @Test + @DisplayName("통합 테스트 - 운동 검색 성공 (페이지네이션)") + void search_with_pagination_success() throws Exception { + // given + for (int i = 1; i <= 15; i++) { + saveWorkout("운동" + i, List.of(CHEST), List.of(BARBELL)); + } + + // when & then - 첫 페이지 + mockMvc.perform(get("/workouts/search") + .param("size", "10")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.workouts").isArray()) + .andExpect(jsonPath("$.workouts.length()").value(10)) + .andExpect(jsonPath("$.hasNext").value(true)); + } + + @Test + @DisplayName("통합 테스트 - 운동 검색 성공 (커서 페이지네이션)") + void search_with_cursor_pagination_success() throws Exception { + // given + List savedWorkouts = List.of( + saveWorkout("운동1", List.of(CHEST), List.of(BARBELL)), + saveWorkout("운동2", List.of(CHEST), List.of(BARBELL)), + saveWorkout("운동3", List.of(CHEST), List.of(BARBELL)), + saveWorkout("운동4", List.of(CHEST), List.of(BARBELL)), + saveWorkout("운동5", List.of(CHEST), List.of(BARBELL)) + ); + + Long lastId = savedWorkouts.get(2).getId(); // 운동3의 ID + + // when & then - 커서 기반 다음 페이지 + mockMvc.perform(get("/workouts/search") + .param("lastId", lastId.toString()) + .param("size", "10")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.workouts").isArray()) + .andExpect(jsonPath("$.hasNext").value(false)); + } + + @Test + @DisplayName("통합 테스트 - 운동 검색 결과 없음") + void search_no_results() throws Exception { + // when & then + mockMvc.perform(get("/workouts/search") + .param("keyword", "존재하지않는운동")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.workouts").isArray()) + .andExpect(jsonPath("$.workouts.length()").value(0)) + .andExpect(jsonPath("$.hasNext").value(false)); + } + + private WorkoutJpaEntity saveWorkout(String name, List bodyParts, List machines) { + Workout workout = Workout.builder() + .name(name) + .imageUrl("https://images.tntapp.co.kr/" + name + ".jpg") + .bodyParts(bodyParts) + .machines(machines) + .workoutType(ANAEROBIC) + .build(); + + return workoutJpaRepository.save(WorkoutJpaEntity.from(workout)); + } +} diff --git a/src/test/java/com/tnt/workoutrecord/presentation/WorkoutRecordControllerTest.java b/src/test/java/com/tnt/workoutrecord/presentation/WorkoutRecordControllerTest.java new file mode 100644 index 00000000..d1879ca3 --- /dev/null +++ b/src/test/java/com/tnt/workoutrecord/presentation/WorkoutRecordControllerTest.java @@ -0,0 +1,448 @@ +package com.tnt.workoutrecord.presentation; + +import static com.tnt.workoutrecord.domain.RecordType.TRAINEE; +import static org.springframework.http.HttpMethod.PUT; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; +import static org.springframework.http.MediaType.IMAGE_JPEG_VALUE; +import static org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; +import org.springframework.security.core.authority.mapping.NullAuthoritiesMapper; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import com.tnt.fixture.MemberFixture; +import com.tnt.gateway.filter.CustomUserDetails; +import com.tnt.member.application.repository.MemberRepository; +import com.tnt.member.domain.Member; +import com.tnt.workoutrecord.application.repository.RoutineRepository; +import com.tnt.workoutrecord.application.repository.SetRepository; +import com.tnt.workoutrecord.application.repository.WorkoutRecordRepository; +import com.tnt.workoutrecord.domain.Routine; +import com.tnt.workoutrecord.domain.Set; +import com.tnt.workoutrecord.domain.WorkoutNote; +import com.tnt.workoutrecord.domain.WorkoutRecord; + +@Transactional +@SpringBootTest +@TestPropertySource("classpath:application-test.properties") +@AutoConfigureMockMvc(addFilters = false) +class WorkoutRecordControllerTest { + + private final GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper(); + + @Autowired + private MockMvc mockMvc; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private WorkoutRecordRepository workoutRecordRepository; + + @Autowired + private RoutineRepository routineRepository; + + @Autowired + private SetRepository setRepository; + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("통합 테스트 - 운동 기록 등록 성공") + void create_workout_record_with_images_success() throws Exception { + // given + Member member = MemberFixture.getTraineeMember1(); + member = memberRepository.save(member); + + setAuthentication(member); + + String requestJson = """ + { + "ptLessonId": 100, + "date": "2025-01-15T14:30:00", + "recordType": "TRAINEE", + "routines": [ + { + "workoutId": 1, + "sets": [ + {"durationMinutes": null, "repetition": 12, "weight": 50}, + {"durationMinutes": null, "repetition": 10, "weight": 50} + ] + }, + { + "workoutId": 2, + "sets": [ + {"durationMinutes": 30, "repetition": null, "weight": null} + ] + } + ], + "feedback": "오늘 운동 강도가 적절했습니다." + } + """; + + MockMultipartFile image1 = new MockMultipartFile("images", "workout1.jpg", IMAGE_JPEG_VALUE, + "workout image 1".getBytes()); + MockMultipartFile image2 = new MockMultipartFile("images", "workout2.jpg", IMAGE_JPEG_VALUE, + "workout image 2".getBytes()); + + // when + var jsonRequest = new MockMultipartFile("request", "", APPLICATION_JSON_VALUE, + requestJson.getBytes()); + + var result = mockMvc.perform(multipart("/workout-records") + .file(jsonRequest) + .file(image1) + .file(image2) + .contentType(MULTIPART_FORM_DATA_VALUE)); + + // then + result.andExpect(status().isCreated()) + .andDo(print()); + } + + @Test + @DisplayName("통합 테스트 - 이미지 없이 운동 기록 등록 성공") + void create_workout_record_without_images_success() throws Exception { + // given + Member member = MemberFixture.getTraineeMember2(); + member = memberRepository.save(member); + + setAuthentication(member); + + String requestJson = """ + { + "ptLessonId": null, + "date": "2025-01-16T10:00:00", + "recordType": "TRAINEE", + "routines": [ + { + "workoutId": 3, + "sets": [ + {"durationMinutes": null, "repetition": 15, "weight": 60} + ] + } + ], + "feedback": null + } + """; + + // when + var jsonRequest = new MockMultipartFile("request", "", APPLICATION_JSON_VALUE, + requestJson.getBytes()); + + var result = mockMvc.perform(multipart("/workout-records") + .file(jsonRequest) + .contentType(MULTIPART_FORM_DATA_VALUE)); + + // then + result.andExpect(status().isCreated()) + .andDo(print()); + } + + @Test + @DisplayName("통합 테스트 - PT 운동 기록 등록 성공") + void create_workout_record_pt_type_success() throws Exception { + // given + Member member = MemberFixture.getTraineeMember1(); + member = memberRepository.save(member); + + setAuthentication(member); + + String requestJson = """ + { + "ptLessonId": 200, + "date": "2025-01-17T16:00:00", + "recordType": "PT", + "routines": [ + { + "workoutId": 10, + "sets": [ + {"durationMinutes": null, "repetition": 12, "weight": 40}, + {"durationMinutes": null, "repetition": 10, "weight": 40}, + {"durationMinutes": null, "repetition": 8, "weight": 45} + ] + } + ], + "feedback": "강도를 점차 높여가고 있습니다." + } + """; + + MockMultipartFile image = new MockMultipartFile("images", "pt_record.jpg", IMAGE_JPEG_VALUE, + "pt workout image".getBytes()); + + // when + var jsonRequest = new MockMultipartFile("request", "", APPLICATION_JSON_VALUE, + requestJson.getBytes()); + + var result = mockMvc.perform(multipart("/workout-records") + .file(jsonRequest) + .file(image) + .contentType(MULTIPART_FORM_DATA_VALUE)); + + // then + result.andExpect(status().isCreated()) + .andDo(print()); + } + + @Test + @DisplayName("통합 테스트 - 다양한 운동 루틴 운동 기록 등록 성공") + void create_workout_record_multiple_routines_success() throws Exception { + // given + Member member = MemberFixture.getTrainerMember1(); + member = memberRepository.save(member); + + setAuthentication(member); + + String requestJson = """ + { + "ptLessonId": 300, + "date": "2025-01-21T09:00:00", + "recordType": "PT", + "routines": [ + { + "workoutId": 10, + "sets": [ + {"durationMinutes": null, "repetition": 12, "weight": 40}, + {"durationMinutes": null, "repetition": 10, "weight": 40}, + {"durationMinutes": null, "repetition": 8, "weight": 45} + ] + }, + { + "workoutId": 11, + "sets": [ + {"durationMinutes": null, "repetition": 15, "weight": 30}, + {"durationMinutes": null, "repetition": 15, "weight": 30} + ] + }, + { + "workoutId": 12, + "sets": [ + {"durationMinutes": 20, "repetition": null, "weight": null} + ] + } + ], + "feedback": "다양한 운동을 진행했습니다." + } + """; + + MockMultipartFile image1 = new MockMultipartFile("images", "routine1.jpg", IMAGE_JPEG_VALUE, + "routine 1".getBytes()); + MockMultipartFile image2 = new MockMultipartFile("images", "routine2.jpg", IMAGE_JPEG_VALUE, + "routine 2".getBytes()); + MockMultipartFile image3 = new MockMultipartFile("images", "routine3.jpg", IMAGE_JPEG_VALUE, + "routine 3".getBytes()); + + // when + var jsonRequest = new MockMultipartFile("request", "", APPLICATION_JSON_VALUE, + requestJson.getBytes()); + + var result = mockMvc.perform(multipart("/workout-records") + .file(jsonRequest) + .file(image1) + .file(image2) + .file(image3) + .contentType(MULTIPART_FORM_DATA_VALUE)); + + // then + result.andExpect(status().isCreated()) + .andDo(print()); + } + + @Test + @DisplayName("통합 테스트 - 운동 기록 조회 성공") + void get_workout_record_success() throws Exception { + // given + Member member = MemberFixture.getTraineeMember1(); + member = memberRepository.save(member); + + setAuthentication(member); + + // 운동 기록 직접 생성 및 저장 + WorkoutRecord workoutRecord = WorkoutRecord.builder() + .memberId(member.getId()) + .ptLessonId(null) + .date(LocalDateTime.of(2025, 1, 18, 15, 0)) + .recordType(TRAINEE) + .workoutNote(WorkoutNote.builder().feedback("조회 테스트용 기록").build()) + .build(); + + workoutRecord = workoutRecordRepository.save(workoutRecord); + + Routine routine = Routine.builder() + .workoutRecordId(workoutRecord.getId()) + .workoutId(5L) + .build(); + + List savedRoutines = routineRepository.saveAll(List.of(routine)); + routine = savedRoutines.getFirst(); + + Set set = Set.builder() + .routineId(routine.getId()) + .durationMinutes(null) + .repetition(10) + .weight(55) + .build(); + + setRepository.saveAll(List.of(set)); + + // when & then + mockMvc.perform(get("/workout-records/{recordId}", workoutRecord.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(workoutRecord.getId())) + .andExpect(jsonPath("$.date").value("2025-01-18T15:00:00")) + .andExpect(jsonPath("$.recordType").value("TRAINEE")) + .andExpect(jsonPath("$.feedback").value("조회 테스트용 기록")) + .andExpect(jsonPath("$.routines").isArray()) + .andDo(print()); + } + + @Test + @DisplayName("통합 테스트 - 운동 기록 수정 성공") + void update_workout_record_success() throws Exception { + // given + Member member = MemberFixture.getTraineeMember1(); + member = memberRepository.save(member); + + setAuthentication(member); + + // 운동 기록 직접 생성 및 저장 + WorkoutRecord workoutRecord = WorkoutRecord.builder() + .memberId(member.getId()) + .ptLessonId(null) + .date(LocalDateTime.of(2025, 1, 19, 16, 0)) + .recordType(TRAINEE) + .workoutNote(WorkoutNote.builder().feedback("수정 전 피드백").build()) + .build(); + + workoutRecord = workoutRecordRepository.save(workoutRecord); + + Routine routine = Routine.builder() + .workoutRecordId(workoutRecord.getId()) + .workoutId(6L) + .build(); + + List savedRoutines = routineRepository.saveAll(List.of(routine)); + routine = savedRoutines.getFirst(); + + Set set = Set.builder() + .routineId(routine.getId()) + .durationMinutes(null) + .repetition(12) + .weight(60) + .build(); + + setRepository.saveAll(List.of(set)); + + // 수정 요청 데이터 + String updateRequest = """ + { + "date": "2025-01-19T17:00:00", + "routines": [ + { + "workoutId": 6, + "sets": [ + {"durationMinutes": null, "repetition": 15, "weight": 65} + ] + }, + { + "workoutId": 7, + "sets": [ + {"durationMinutes": 25, "repetition": null, "weight": null} + ] + } + ], + "feedback": "수정된 피드백", + "imageUrlsToDelete": [] + } + """; + + var updateJsonRequest = new MockMultipartFile("request", "", APPLICATION_JSON_VALUE, + updateRequest.getBytes()); + + // when & then + mockMvc.perform(multipart(PUT, "/workout-records/{recordId}", workoutRecord.getId()) + .file(updateJsonRequest) + .contentType(MULTIPART_FORM_DATA_VALUE)) + .andExpect(status().isNoContent()) + .andDo(print()); + } + + @Test + @DisplayName("통합 테스트 - 운동 기록 삭제 성공") + void delete_workout_record_success() throws Exception { + // given + Member member = MemberFixture.getTraineeMember1(); + member = memberRepository.save(member); + + setAuthentication(member); + + // 운동 기록 직접 생성 및 저장 + WorkoutRecord workoutRecord = WorkoutRecord.builder() + .memberId(member.getId()) + .ptLessonId(null) + .date(LocalDateTime.of(2025, 1, 20, 18, 0)) + .recordType(TRAINEE) + .workoutNote(WorkoutNote.builder().feedback("삭제 될 기록").build()) + .build(); + + workoutRecord = workoutRecordRepository.save(workoutRecord); + + Routine routine = Routine.builder() + .workoutRecordId(workoutRecord.getId()) + .workoutId(8L) + .build(); + + List savedRoutines = routineRepository.saveAll(List.of(routine)); + routine = savedRoutines.getFirst(); + + Set set = Set.builder() + .routineId(routine.getId()) + .durationMinutes(null) + .repetition(10) + .weight(50) + .build(); + + setRepository.saveAll(List.of(set)); + + // when & then + mockMvc.perform(delete("/workout-records/{recordId}", workoutRecord.getId())) + .andExpect(status().isNoContent()) + .andDo(print()); + } + + private void setAuthentication(Member member) { + CustomUserDetails userDetails = new CustomUserDetails(member.getId(), + member.getId().toString(), + authoritiesMapper.mapAuthorities(List.of(new SimpleGrantedAuthority("ROLE_USER")))); + + Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, + authoritiesMapper.mapAuthorities(userDetails.getAuthorities())); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } +}