Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/main/java/com/tnt/common/constant/ImageConstant.java
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
10 changes: 9 additions & 1 deletion src/main/java/com/tnt/common/error/model/ErrorMessage.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
30 changes: 29 additions & 1 deletion src/main/java/com/tnt/image/application/S3Service.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -70,6 +72,17 @@ public String uploadProfileImage(@Nullable MultipartFile profileImage, MemberTyp
return uploadImage(defaultImage, folderPath, profileImage);
}

public List<String> uploadWorkoutRecordImages(@Nullable List<MultipartFile> 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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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();

Expand Down
62 changes: 62 additions & 0 deletions src/main/java/com/tnt/pt/application/PtService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<PtLesson> 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);
Expand Down Expand Up @@ -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<PtLesson> lessonsForTrainee =
ptLessonRepository.findAllByPtTrainerTrainee(ptTrainerTrainee);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
10 changes: 8 additions & 2 deletions src/main/java/com/tnt/pt/domain/PtLesson.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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
) {

}
18 changes: 18 additions & 0 deletions src/main/java/com/tnt/trainer/presentation/TrainerController.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
}
38 changes: 38 additions & 0 deletions src/main/java/com/tnt/workout/application/WorkoutService.java
Original file line number Diff line number Diff line change
@@ -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<BodyPart> bodyParts,
@Nullable List<Machine> machines, @Nullable Long lastId, @Nullable Integer size) {

int pageSize = size != null ? size : DEFAULT_PAGE_SIZE;
List<Workout> workouts = workoutRepository.search(keyword, bodyParts, machines, lastId, pageSize);

// hasNext 판단을 위해 size+1 만큼 조회했으므로, 실제 반환할 데이터는 size까지만
boolean hasNext = workouts.size() > pageSize;
List<Workout> content = hasNext ? workouts.subList(0, pageSize) : workouts;

return SearchWorkoutResponse.from(content, hasNext);
}
}
Original file line number Diff line number Diff line change
@@ -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<Workout> search(@Nullable String keyword, @Nullable List<BodyPart> bodyParts,
@Nullable List<Machine> machines, @Nullable Long lastId, int size);
}
Loading
Loading