diff --git a/src/main/java/org/runnect/server/common/advice/ControllerExceptionAdvice.java b/src/main/java/org/runnect/server/common/advice/ControllerExceptionAdvice.java index 6642621..71cbc50 100644 --- a/src/main/java/org/runnect/server/common/advice/ControllerExceptionAdvice.java +++ b/src/main/java/org/runnect/server/common/advice/ControllerExceptionAdvice.java @@ -1,7 +1,6 @@ package org.runnect.server.common.advice; import io.sentry.Sentry; -import java.io.IOException; import java.util.Objects; import javax.servlet.http.HttpServletRequest; import javax.validation.ConstraintViolationException; @@ -10,6 +9,7 @@ import org.runnect.server.common.dto.ApiResponseDto; import org.runnect.server.common.exception.BasicException; import org.runnect.server.config.slack.SlackApi; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; @@ -21,6 +21,7 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; +@Slf4j @RestControllerAdvice @Component @RequiredArgsConstructor @@ -71,9 +72,17 @@ protected ApiResponseDto handleMissingRequestParameterException(final MissingSer */ @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) @ExceptionHandler(Exception.class) - protected ApiResponseDto handleException(final Exception error, final HttpServletRequest request) throws IOException { - slackApi.sendAlert(error, request); - Sentry.captureException(error); + protected ApiResponseDto handleException(final Exception error, final HttpServletRequest request) { + try { + slackApi.sendAlert(error, request); + } catch (Exception e) { + log.error("Slack 알림 전송 실패", e); + } + try { + Sentry.captureException(error); + } catch (Exception e) { + log.error("Sentry 전송 실패", e); + } return ApiResponseDto.error(ErrorStatus.INTERNAL_SERVER_ERROR); } diff --git a/src/main/java/org/runnect/server/common/constant/ErrorStatus.java b/src/main/java/org/runnect/server/common/constant/ErrorStatus.java index 392d7fa..0903bd3 100644 --- a/src/main/java/org/runnect/server/common/constant/ErrorStatus.java +++ b/src/main/java/org/runnect/server/common/constant/ErrorStatus.java @@ -31,6 +31,9 @@ public enum ErrorStatus { NOT_FOUND_SCRAP_EXCEPTION(HttpStatus.BAD_REQUEST, "스크랩한 코스가 없습니다."), NOT_FOUND_IMAGE_EXCEPTION(HttpStatus.BAD_REQUEST, "잘못된 이미지 파일입니다"), NOT_FOUND_PUBLICCOURSE_EXCEPTION(HttpStatus.BAD_REQUEST, "존재하지 않는 public course id입니다."), + INVALID_HEALTH_DATA_EXCEPTION(HttpStatus.BAD_REQUEST, "유효하지 않은 건강 데이터입니다"), + INVALID_DATE_RANGE_EXCEPTION(HttpStatus.BAD_REQUEST, "유효하지 않은 날짜 범위입니다"), + EXCEED_HEART_RATE_SAMPLES_EXCEPTION(HttpStatus.BAD_REQUEST, "심박수 샘플은 최대 5000건까지 허용됩니다"), /** * 401 UNAUTHORIZED @@ -51,6 +54,7 @@ public enum ErrorStatus { */ PERMISSION_DENIED_PUBLIC_COURSE_DELETE_EXCEPTION(HttpStatus.FORBIDDEN, "퍼블릭 코스를 삭제할 권한이 존재하지 않습니다."), PERMISSION_DENIED_RECORD_DELETE_EXCEPTION(HttpStatus.FORBIDDEN, "기록을 삭제할 권한이 존재하지 않습니다."), + PERMISSION_DENIED_HEALTH_DATA_EXCEPTION(HttpStatus.FORBIDDEN, "건강 데이터에 대한 접근 권한이 없습니다"), /** * 404 NOT FOUND @@ -63,6 +67,7 @@ public enum ErrorStatus { ALREADY_EXIST_USER_EXCEPTION(HttpStatus.CONFLICT, "이미 존재하는 유저입니다"), ALREADY_EXIST_NICKNAME_EXCEPTION(HttpStatus.CONFLICT, "중복된 닉네임입니다."), ALREADY_UPLOAD_COURSE_EXCEPTION(HttpStatus.CONFLICT, "이미 업로드된 코스입니다."), + ALREADY_EXIST_HEALTH_DATA_EXCEPTION(HttpStatus.CONFLICT, "이미 건강 데이터가 등록된 기록입니다"), /** * 500 INTERNAL SERVER ERROR diff --git a/src/main/java/org/runnect/server/common/constant/SuccessStatus.java b/src/main/java/org/runnect/server/common/constant/SuccessStatus.java index 563b9d8..b374589 100644 --- a/src/main/java/org/runnect/server/common/constant/SuccessStatus.java +++ b/src/main/java/org/runnect/server/common/constant/SuccessStatus.java @@ -29,6 +29,9 @@ public enum SuccessStatus { SEARCH_PUBLIC_COURSE_SUCCESS(HttpStatus.OK,"업로드된 코스 검색 성공"), + GET_HEALTH_DATA_SUCCESS(HttpStatus.OK, "건강 데이터 조회 성공"), + GET_HEALTH_SUMMARY_SUCCESS(HttpStatus.OK, "건강 통계 조회 성공"), + UPDATE_RECORD_SUCCESS(HttpStatus.OK, "활동 기록 수정 성공"), UPDATE_USER_NICKNAME_SUCCESS(HttpStatus.OK, "닉네임 변경에 성공했습니다."), @@ -41,6 +44,7 @@ public enum SuccessStatus { DELETE_PUBLIC_COURSE_SUCCESS(HttpStatus.OK, "퍼블릭 코스 삭제에 성공했습니다."), DELETE_RECORD_SUCCESS(HttpStatus.OK, "기록 삭제에 성공했습니다."), DELETE_COURSES_SUCCESS(HttpStatus.OK, "코스 삭제 성공"), + DELETE_HEALTH_DATA_SUCCESS(HttpStatus.OK, "건강 데이터 삭제 성공"), /** @@ -52,6 +56,7 @@ public enum SuccessStatus { CREATE_PUBLIC_COURSE_SUCCESS(HttpStatus.CREATED, "코드 업로드에 성공했습니다."), CREATE_SCRAP_SUCCESS(HttpStatus.CREATED, "코스 스크랩 성공"), NEW_TOKEN_SUCCESS(HttpStatus.CREATED, "토큰 재발급에 성공했습니다."), + CREATE_HEALTH_DATA_SUCCESS(HttpStatus.CREATED, "건강 데이터 저장 성공"), ; private final HttpStatus httpStatus; diff --git a/src/main/java/org/runnect/server/config/slack/SlackApi.java b/src/main/java/org/runnect/server/config/slack/SlackApi.java index d6fe854..19df6f5 100644 --- a/src/main/java/org/runnect/server/config/slack/SlackApi.java +++ b/src/main/java/org/runnect/server/config/slack/SlackApi.java @@ -27,8 +27,6 @@ public class SlackApi { private final static String NEW_LINE = "\n"; private final static String DOUBLE_NEW_LINE = "\n\n"; - private StringBuilder sb = new StringBuilder(); - public void sendAlert(Exception error, HttpServletRequest request) throws IOException { List layoutBlocks = generateLayoutBlock(error, request); @@ -53,7 +51,7 @@ private List generateLayoutBlock(Exception error, HttpServletReques } private String generateErrorMessage(Exception error) { - sb.setLength(0); + StringBuilder sb = new StringBuilder(); sb.append("*[🔥 Exception]*" + NEW_LINE + error.toString() + DOUBLE_NEW_LINE); sb.append("*[📩 From]*" + NEW_LINE + readRootStackTrace(error) + DOUBLE_NEW_LINE); @@ -61,7 +59,7 @@ private String generateErrorMessage(Exception error) { } private String generateErrorPointMessage(HttpServletRequest request) { - sb.setLength(0); + StringBuilder sb = new StringBuilder(); sb.append("*[🧾세부정보]*" + NEW_LINE); sb.append("Request URL : " + request.getRequestURL().toString() + NEW_LINE); sb.append("Request Method : " + request.getMethod() + NEW_LINE); @@ -71,6 +69,9 @@ private String generateErrorPointMessage(HttpServletRequest request) { } private String readRootStackTrace(Exception error) { + if (error.getStackTrace() == null || error.getStackTrace().length == 0) { + return "Unknown"; + } return error.getStackTrace()[0].toString(); } diff --git a/src/main/java/org/runnect/server/health/controller/HealthController.java b/src/main/java/org/runnect/server/health/controller/HealthController.java new file mode 100644 index 0000000..865bb01 --- /dev/null +++ b/src/main/java/org/runnect/server/health/controller/HealthController.java @@ -0,0 +1,68 @@ +package org.runnect.server.health.controller; + +import javax.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.runnect.server.common.constant.SuccessStatus; +import org.runnect.server.common.dto.ApiResponseDto; +import org.runnect.server.common.resolver.userId.UserId; +import org.runnect.server.health.dto.request.HealthDataRequestDto; +import org.runnect.server.health.dto.response.CreateHealthDataResponseDto; +import org.runnect.server.health.dto.response.GetHealthDataResponseDto; +import org.runnect.server.health.dto.response.GetHealthSummaryResponseDto; +import org.runnect.server.health.service.HealthService; +import org.springframework.http.HttpStatus; +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.RequestBody; +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; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api") +public class HealthController { + + private final HealthService healthService; + + @PostMapping("record/{recordId}/health") + @ResponseStatus(HttpStatus.CREATED) + public ApiResponseDto createHealthData( + @UserId Long userId, + @PathVariable(name = "recordId") Long recordId, + @RequestBody @Valid final HealthDataRequestDto request) { + return ApiResponseDto.success(SuccessStatus.CREATE_HEALTH_DATA_SUCCESS, + healthService.createHealthData(userId, recordId, request)); + } + + @GetMapping("record/{recordId}/health") + @ResponseStatus(HttpStatus.OK) + public ApiResponseDto getHealthData( + @UserId Long userId, + @PathVariable(name = "recordId") Long recordId) { + return ApiResponseDto.success(SuccessStatus.GET_HEALTH_DATA_SUCCESS, + healthService.getHealthData(userId, recordId)); + } + + @GetMapping("health/summary") + @ResponseStatus(HttpStatus.OK) + public ApiResponseDto getHealthSummary( + @UserId Long userId, + @RequestParam(name = "startDate") String startDate, + @RequestParam(name = "endDate") String endDate) { + return ApiResponseDto.success(SuccessStatus.GET_HEALTH_SUMMARY_SUCCESS, + healthService.getHealthSummary(userId, startDate, endDate)); + } + + @DeleteMapping("record/{recordId}/health") + @ResponseStatus(HttpStatus.OK) + public ApiResponseDto deleteHealthData( + @UserId Long userId, + @PathVariable(name = "recordId") Long recordId) { + healthService.deleteHealthData(userId, recordId); + return ApiResponseDto.success(SuccessStatus.DELETE_HEALTH_DATA_SUCCESS); + } +} diff --git a/src/main/java/org/runnect/server/health/dto/request/HealthDataRequestDto.java b/src/main/java/org/runnect/server/health/dto/request/HealthDataRequestDto.java new file mode 100644 index 0000000..18fe6cb --- /dev/null +++ b/src/main/java/org/runnect/server/health/dto/request/HealthDataRequestDto.java @@ -0,0 +1,44 @@ +package org.runnect.server.health.dto.request; + +import java.util.List; +import javax.validation.Valid; +import javax.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor +public class HealthDataRequestDto { + @NotNull + private Double avgHeartRate; + + private Double maxHeartRate; + + private Double minHeartRate; + + @NotNull + private Double calories; + + @NotNull + private Integer zone1Seconds; + + @NotNull + private Integer zone2Seconds; + + @NotNull + private Integer zone3Seconds; + + @NotNull + private Integer zone4Seconds; + + @NotNull + private Integer zone5Seconds; + + private Double maxHeartRateConfig; + + @Valid + private List heartRateSamples; +} diff --git a/src/main/java/org/runnect/server/health/dto/request/HeartRateSampleRequestDto.java b/src/main/java/org/runnect/server/health/dto/request/HeartRateSampleRequestDto.java new file mode 100644 index 0000000..3d7929d --- /dev/null +++ b/src/main/java/org/runnect/server/health/dto/request/HeartRateSampleRequestDto.java @@ -0,0 +1,24 @@ +package org.runnect.server.health.dto.request; + +import javax.validation.constraints.Max; +import javax.validation.constraints.Min; +import javax.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor +public class HeartRateSampleRequestDto { + @NotNull + private Double heartRate; + + @NotNull + private Integer elapsedSeconds; + + @NotNull + @Min(1) @Max(5) + private Integer zone; +} diff --git a/src/main/java/org/runnect/server/health/dto/response/CreateHealthDataResponseDto.java b/src/main/java/org/runnect/server/health/dto/response/CreateHealthDataResponseDto.java new file mode 100644 index 0000000..a310452 --- /dev/null +++ b/src/main/java/org/runnect/server/health/dto/response/CreateHealthDataResponseDto.java @@ -0,0 +1,17 @@ +package org.runnect.server.health.dto.response; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class CreateHealthDataResponseDto { + private Long healthDataId; + + public static CreateHealthDataResponseDto of(Long healthDataId) { + return new CreateHealthDataResponseDto(healthDataId); + } +} diff --git a/src/main/java/org/runnect/server/health/dto/response/GetHealthDataResponseDto.java b/src/main/java/org/runnect/server/health/dto/response/GetHealthDataResponseDto.java new file mode 100644 index 0000000..3712316 --- /dev/null +++ b/src/main/java/org/runnect/server/health/dto/response/GetHealthDataResponseDto.java @@ -0,0 +1,70 @@ +package org.runnect.server.health.dto.response; + +import java.util.List; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class GetHealthDataResponseDto { + private HealthDataDetailResponse healthData; + + public static GetHealthDataResponseDto of(HealthDataDetailResponse healthData) { + return new GetHealthDataResponseDto(healthData); + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PRIVATE) + @AllArgsConstructor(access = AccessLevel.PRIVATE) + public static class HealthDataDetailResponse { + private Long id; + private Long recordId; + private Double avgHeartRate; + private Double maxHeartRate; + private Double minHeartRate; + private Double calories; + private ZoneResponse zones; + private Double maxHeartRateConfig; + private List heartRateSamples; + + public static HealthDataDetailResponse of(Long id, Long recordId, Double avgHeartRate, + Double maxHeartRate, Double minHeartRate, Double calories, + ZoneResponse zones, Double maxHeartRateConfig, + List heartRateSamples) { + return new HealthDataDetailResponse(id, recordId, avgHeartRate, maxHeartRate, + minHeartRate, calories, zones, maxHeartRateConfig, heartRateSamples); + } + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PRIVATE) + @AllArgsConstructor(access = AccessLevel.PRIVATE) + public static class ZoneResponse { + private Integer zone1Seconds; + private Integer zone2Seconds; + private Integer zone3Seconds; + private Integer zone4Seconds; + private Integer zone5Seconds; + + public static ZoneResponse of(Integer zone1Seconds, Integer zone2Seconds, + Integer zone3Seconds, Integer zone4Seconds, Integer zone5Seconds) { + return new ZoneResponse(zone1Seconds, zone2Seconds, zone3Seconds, zone4Seconds, zone5Seconds); + } + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PRIVATE) + @AllArgsConstructor(access = AccessLevel.PRIVATE) + public static class HeartRateSampleResponse { + private Double heartRate; + private Integer elapsedSeconds; + private Integer zone; + + public static HeartRateSampleResponse of(Double heartRate, Integer elapsedSeconds, Integer zone) { + return new HeartRateSampleResponse(heartRate, elapsedSeconds, zone); + } + } +} diff --git a/src/main/java/org/runnect/server/health/dto/response/GetHealthSummaryResponseDto.java b/src/main/java/org/runnect/server/health/dto/response/GetHealthSummaryResponseDto.java new file mode 100644 index 0000000..d97fbac --- /dev/null +++ b/src/main/java/org/runnect/server/health/dto/response/GetHealthSummaryResponseDto.java @@ -0,0 +1,36 @@ +package org.runnect.server.health.dto.response; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class GetHealthSummaryResponseDto { + private HealthSummaryResponse summary; + + public static GetHealthSummaryResponseDto of(HealthSummaryResponse summary) { + return new GetHealthSummaryResponseDto(summary); + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PRIVATE) + @AllArgsConstructor(access = AccessLevel.PRIVATE) + public static class HealthSummaryResponse { + private Long totalRecords; + private Long recordsWithHealth; + private Double avgHeartRate; + private Double avgCalories; + private Double totalCalories; + private GetHealthDataResponseDto.ZoneResponse zoneDistribution; + + public static HealthSummaryResponse of(Long totalRecords, Long recordsWithHealth, + Double avgHeartRate, Double avgCalories, Double totalCalories, + GetHealthDataResponseDto.ZoneResponse zoneDistribution) { + return new HealthSummaryResponse(totalRecords, recordsWithHealth, avgHeartRate, + avgCalories, totalCalories, zoneDistribution); + } + } +} diff --git a/src/main/java/org/runnect/server/health/entity/HeartRateSample.java b/src/main/java/org/runnect/server/health/entity/HeartRateSample.java new file mode 100644 index 0000000..ffbef46 --- /dev/null +++ b/src/main/java/org/runnect/server/health/entity/HeartRateSample.java @@ -0,0 +1,55 @@ +package org.runnect.server.health.entity; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.runnect.server.common.entity.AuditingTimeEntity; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class HeartRateSample extends AuditingTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "record_health_data_id", nullable = false) + private RecordHealthData recordHealthData; + + @Column(nullable = false) + private Double heartRate; + + @Column(nullable = false) + private Integer elapsedSeconds; + + @Column(nullable = false) + private Integer zone; + + @Builder + public HeartRateSample(RecordHealthData recordHealthData, Double heartRate, Integer elapsedSeconds, Integer zone) { + this.recordHealthData = recordHealthData; + this.heartRate = heartRate; + this.elapsedSeconds = elapsedSeconds; + this.zone = zone; + } + + public void setRecordHealthData(RecordHealthData recordHealthData) { + this.recordHealthData = recordHealthData; + } + + @Override + public void updateDeletedAt() { + throw new RuntimeException("Course를 제외한 테이블은 정상적으로 삭제됩니다."); + } +} diff --git a/src/main/java/org/runnect/server/health/entity/RecordHealthData.java b/src/main/java/org/runnect/server/health/entity/RecordHealthData.java new file mode 100644 index 0000000..a3e5b9b --- /dev/null +++ b/src/main/java/org/runnect/server/health/entity/RecordHealthData.java @@ -0,0 +1,94 @@ +package org.runnect.server.health.entity; + +import java.util.ArrayList; +import java.util.List; +import javax.persistence.CascadeType; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.OneToMany; +import javax.persistence.OneToOne; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.runnect.server.common.entity.AuditingTimeEntity; +import org.runnect.server.record.entity.Record; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RecordHealthData extends AuditingTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "record_id", nullable = false, unique = true) + private Record record; + + @Column(nullable = false) + private Double avgHeartRate; + + private Double maxHeartRate; + + private Double minHeartRate; + + @Column(nullable = false) + private Double calories; + + @Column(nullable = false) + private Integer zone1Seconds = 0; + + @Column(nullable = false) + private Integer zone2Seconds = 0; + + @Column(nullable = false) + private Integer zone3Seconds = 0; + + @Column(nullable = false) + private Integer zone4Seconds = 0; + + @Column(nullable = false) + private Integer zone5Seconds = 0; + + @Column(nullable = false) + private Double maxHeartRateConfig = 190.0; + + @OneToMany(mappedBy = "recordHealthData", cascade = CascadeType.ALL, orphanRemoval = true) + private List heartRateSamples = new ArrayList<>(); + + @Builder + public RecordHealthData(Record record, Double avgHeartRate, Double maxHeartRate, Double minHeartRate, + Double calories, Integer zone1Seconds, Integer zone2Seconds, Integer zone3Seconds, + Integer zone4Seconds, Integer zone5Seconds, Double maxHeartRateConfig) { + this.record = record; + this.avgHeartRate = avgHeartRate; + this.maxHeartRate = maxHeartRate; + this.minHeartRate = minHeartRate; + this.calories = calories; + this.zone1Seconds = zone1Seconds; + this.zone2Seconds = zone2Seconds; + this.zone3Seconds = zone3Seconds; + this.zone4Seconds = zone4Seconds; + this.zone5Seconds = zone5Seconds; + this.maxHeartRateConfig = maxHeartRateConfig; + } + + public void addHeartRateSamples(List samples) { + for (HeartRateSample sample : samples) { + sample.setRecordHealthData(this); + this.heartRateSamples.add(sample); + } + } + + @Override + public void updateDeletedAt() { + throw new RuntimeException("Course를 제외한 테이블은 정상적으로 삭제됩니다."); + } +} diff --git a/src/main/java/org/runnect/server/health/repository/HeartRateSampleRepository.java b/src/main/java/org/runnect/server/health/repository/HeartRateSampleRepository.java new file mode 100644 index 0000000..14a5ded --- /dev/null +++ b/src/main/java/org/runnect/server/health/repository/HeartRateSampleRepository.java @@ -0,0 +1,13 @@ +package org.runnect.server.health.repository; + +import org.runnect.server.health.entity.HeartRateSample; +import org.springframework.data.repository.Repository; + +import java.util.List; + +public interface HeartRateSampleRepository extends Repository { + + void saveAll(Iterable samples); + + List findByRecordHealthDataIdOrderByElapsedSecondsAsc(Long recordHealthDataId); +} diff --git a/src/main/java/org/runnect/server/health/repository/RecordHealthDataRepository.java b/src/main/java/org/runnect/server/health/repository/RecordHealthDataRepository.java new file mode 100644 index 0000000..afdc4a9 --- /dev/null +++ b/src/main/java/org/runnect/server/health/repository/RecordHealthDataRepository.java @@ -0,0 +1,34 @@ +package org.runnect.server.health.repository; + +import org.runnect.server.health.entity.RecordHealthData; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface RecordHealthDataRepository extends Repository { + + void save(RecordHealthData recordHealthData); + + Optional findByRecordId(Long recordId); + + boolean existsByRecordId(Long recordId); + + void deleteByRecordId(Long recordId); + + @Query("SELECT h FROM RecordHealthData h LEFT JOIN FETCH h.heartRateSamples WHERE h.record.id = :recordId") + Optional findByRecordIdWithSamples(@Param("recordId") Long recordId); + + @Query("SELECT COUNT(r.id), COUNT(h.id), AVG(h.avgHeartRate), AVG(h.calories), SUM(h.calories), " + + "COALESCE(SUM(h.zone1Seconds), 0), COALESCE(SUM(h.zone2Seconds), 0), " + + "COALESCE(SUM(h.zone3Seconds), 0), COALESCE(SUM(h.zone4Seconds), 0), " + + "COALESCE(SUM(h.zone5Seconds), 0) " + + "FROM Record r LEFT JOIN RecordHealthData h ON r.id = h.record.id " + + "WHERE r.runnectUser.id = :userId AND r.createdAt >= :startDate AND r.createdAt < :endDate") + List getHealthSummary(@Param("userId") Long userId, + @Param("startDate") LocalDateTime startDate, + @Param("endDate") LocalDateTime endDate); +} diff --git a/src/main/java/org/runnect/server/health/service/HealthService.java b/src/main/java/org/runnect/server/health/service/HealthService.java new file mode 100644 index 0000000..23f67be --- /dev/null +++ b/src/main/java/org/runnect/server/health/service/HealthService.java @@ -0,0 +1,242 @@ +package org.runnect.server.health.service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.runnect.server.common.constant.ErrorStatus; +import org.runnect.server.common.exception.BadRequestException; +import org.runnect.server.common.exception.ConflictException; +import org.runnect.server.common.exception.NotFoundException; +import org.runnect.server.common.exception.PermissionDeniedException; +import org.runnect.server.health.dto.request.HealthDataRequestDto; +import org.runnect.server.health.dto.request.HeartRateSampleRequestDto; +import org.runnect.server.health.dto.response.CreateHealthDataResponseDto; +import org.runnect.server.health.dto.response.GetHealthDataResponseDto; +import org.runnect.server.health.dto.response.GetHealthDataResponseDto.HealthDataDetailResponse; +import org.runnect.server.health.dto.response.GetHealthDataResponseDto.HeartRateSampleResponse; +import org.runnect.server.health.dto.response.GetHealthDataResponseDto.ZoneResponse; +import org.runnect.server.health.dto.response.GetHealthSummaryResponseDto; +import org.runnect.server.health.dto.response.GetHealthSummaryResponseDto.HealthSummaryResponse; +import org.runnect.server.health.entity.HeartRateSample; +import org.runnect.server.health.entity.RecordHealthData; +import org.runnect.server.health.repository.RecordHealthDataRepository; +import org.runnect.server.record.entity.Record; +import org.runnect.server.record.repository.RecordRepository; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class HealthService { + + private final RecordHealthDataRepository recordHealthDataRepository; + private final RecordRepository recordRepository; + + private static final int MAX_HEART_RATE_SAMPLES = 5000; + private static final double DEFAULT_MAX_HEART_RATE_CONFIG = 190.0; + + @Transactional + public CreateHealthDataResponseDto createHealthData(Long userId, Long recordId, HealthDataRequestDto request) { + // 1. Record 존재 확인 + Record record = recordRepository.findById(recordId) + .orElseThrow(() -> new NotFoundException( + ErrorStatus.NOT_FOUND_RECORD_EXCEPTION, + ErrorStatus.NOT_FOUND_RECORD_EXCEPTION.getMessage())); + + // 2. 소유권 확인 + if (!record.getRunnectUser().getId().equals(userId)) { + throw new PermissionDeniedException( + ErrorStatus.PERMISSION_DENIED_HEALTH_DATA_EXCEPTION, + ErrorStatus.PERMISSION_DENIED_HEALTH_DATA_EXCEPTION.getMessage()); + } + + // 3. 유효성 검증 + validateHealthData(request); + + // 4. 중복 확인 + if (recordHealthDataRepository.existsByRecordId(recordId)) { + throw new ConflictException( + ErrorStatus.ALREADY_EXIST_HEALTH_DATA_EXCEPTION, + ErrorStatus.ALREADY_EXIST_HEALTH_DATA_EXCEPTION.getMessage()); + } + + // 5. RecordHealthData 생성 + Double maxHeartRateConfig = request.getMaxHeartRateConfig() != null + ? request.getMaxHeartRateConfig() : DEFAULT_MAX_HEART_RATE_CONFIG; + + RecordHealthData healthData = RecordHealthData.builder() + .record(record) + .avgHeartRate(request.getAvgHeartRate()) + .maxHeartRate(request.getMaxHeartRate()) + .minHeartRate(request.getMinHeartRate()) + .calories(request.getCalories()) + .zone1Seconds(request.getZone1Seconds()) + .zone2Seconds(request.getZone2Seconds()) + .zone3Seconds(request.getZone3Seconds()) + .zone4Seconds(request.getZone4Seconds()) + .zone5Seconds(request.getZone5Seconds()) + .maxHeartRateConfig(maxHeartRateConfig) + .build(); + + // 6. HeartRateSamples 처리 + if (request.getHeartRateSamples() != null && !request.getHeartRateSamples().isEmpty()) { + List samples = request.getHeartRateSamples().stream() + .map(dto -> HeartRateSample.builder() + .heartRate(dto.getHeartRate()) + .elapsedSeconds(dto.getElapsedSeconds()) + .zone(dto.getZone()) + .build()) + .collect(Collectors.toList()); + healthData.addHeartRateSamples(samples); + } + + // 7. 저장 (UNIQUE 제약 위반 시 409) + try { + recordHealthDataRepository.save(healthData); + } catch (DataIntegrityViolationException e) { + throw new ConflictException( + ErrorStatus.ALREADY_EXIST_HEALTH_DATA_EXCEPTION, + ErrorStatus.ALREADY_EXIST_HEALTH_DATA_EXCEPTION.getMessage()); + } + + return CreateHealthDataResponseDto.of(healthData.getId()); + } + + @Transactional(readOnly = true) + public GetHealthDataResponseDto getHealthData(Long userId, Long recordId) { + // 1. Record 존재 확인 + Record record = recordRepository.findById(recordId) + .orElseThrow(() -> new NotFoundException( + ErrorStatus.NOT_FOUND_RECORD_EXCEPTION, + ErrorStatus.NOT_FOUND_RECORD_EXCEPTION.getMessage())); + + // 2. 소유권 확인 + if (!record.getRunnectUser().getId().equals(userId)) { + throw new PermissionDeniedException( + ErrorStatus.PERMISSION_DENIED_HEALTH_DATA_EXCEPTION, + ErrorStatus.PERMISSION_DENIED_HEALTH_DATA_EXCEPTION.getMessage()); + } + + // 3. 건강 데이터 조회 (없으면 null 반환, 404가 아님) + return recordHealthDataRepository.findByRecordIdWithSamples(recordId) + .map(this::toHealthDataDetailResponse) + .orElse(GetHealthDataResponseDto.of(null)); + } + + @Transactional(readOnly = true) + public GetHealthSummaryResponseDto getHealthSummary(Long userId, String startDateStr, String endDateStr) { + // 1. 날짜 파싱 및 검증 + LocalDate startDate; + LocalDate endDate; + try { + startDate = LocalDate.parse(startDateStr); + endDate = LocalDate.parse(endDateStr); + } catch (Exception e) { + throw new BadRequestException( + ErrorStatus.INVALID_DATE_RANGE_EXCEPTION, + ErrorStatus.INVALID_DATE_RANGE_EXCEPTION.getMessage()); + } + + if (endDate.isBefore(startDate)) { + throw new BadRequestException( + ErrorStatus.INVALID_DATE_RANGE_EXCEPTION, + ErrorStatus.INVALID_DATE_RANGE_EXCEPTION.getMessage()); + } + + // 2. 날짜 범위 변환 (endDate 당일 포함을 위해 다음날 00:00:00 사용) + LocalDateTime startDateTime = startDate.atStartOfDay(); + LocalDateTime endDateTime = endDate.plusDays(1).atStartOfDay(); + + // 3. 통계 쿼리 실행 + List results = recordHealthDataRepository.getHealthSummary(userId, startDateTime, endDateTime); + Object[] result = results.isEmpty() ? new Object[10] : results.get(0); + + Long totalRecords = result[0] != null ? ((Number) result[0]).longValue() : 0L; + Long recordsWithHealth = result[1] != null ? ((Number) result[1]).longValue() : 0L; + Double avgHeartRate = result[2] != null ? ((Number) result[2]).doubleValue() : null; + Double avgCalories = result[3] != null ? ((Number) result[3]).doubleValue() : null; + Double totalCalories = result[4] != null ? ((Number) result[4]).doubleValue() : null; + Integer zone1 = result[5] != null ? ((Number) result[5]).intValue() : 0; + Integer zone2 = result[6] != null ? ((Number) result[6]).intValue() : 0; + Integer zone3 = result[7] != null ? ((Number) result[7]).intValue() : 0; + Integer zone4 = result[8] != null ? ((Number) result[8]).intValue() : 0; + Integer zone5 = result[9] != null ? ((Number) result[9]).intValue() : 0; + + ZoneResponse zoneDistribution = ZoneResponse.of(zone1, zone2, zone3, zone4, zone5); + HealthSummaryResponse summary = HealthSummaryResponse.of( + totalRecords, recordsWithHealth, avgHeartRate, avgCalories, totalCalories, zoneDistribution); + + return GetHealthSummaryResponseDto.of(summary); + } + + @Transactional + public void deleteHealthData(Long userId, Long recordId) { + // 1. Record 존재 확인 + Record record = recordRepository.findById(recordId) + .orElseThrow(() -> new NotFoundException( + ErrorStatus.NOT_FOUND_RECORD_EXCEPTION, + ErrorStatus.NOT_FOUND_RECORD_EXCEPTION.getMessage())); + + // 2. 소유권 확인 + if (!record.getRunnectUser().getId().equals(userId)) { + throw new PermissionDeniedException( + ErrorStatus.PERMISSION_DENIED_HEALTH_DATA_EXCEPTION, + ErrorStatus.PERMISSION_DENIED_HEALTH_DATA_EXCEPTION.getMessage()); + } + + // 3. 건강 데이터 존재 확인 + if (!recordHealthDataRepository.existsByRecordId(recordId)) { + throw new NotFoundException( + ErrorStatus.NOT_FOUND_RECORD_EXCEPTION, + ErrorStatus.NOT_FOUND_RECORD_EXCEPTION.getMessage()); + } + + // 4. 삭제 (CASCADE로 samples도 삭제) + recordHealthDataRepository.deleteByRecordId(recordId); + } + + private void validateHealthData(HealthDataRequestDto request) { + if (request.getAvgHeartRate() <= 0) { + throw new BadRequestException( + ErrorStatus.INVALID_HEALTH_DATA_EXCEPTION, + ErrorStatus.INVALID_HEALTH_DATA_EXCEPTION.getMessage()); + } + + if (request.getCalories() < 0) { + throw new BadRequestException( + ErrorStatus.INVALID_HEALTH_DATA_EXCEPTION, + ErrorStatus.INVALID_HEALTH_DATA_EXCEPTION.getMessage()); + } + + // heartRateSamples 최대 건수 제한 + if (request.getHeartRateSamples() != null && request.getHeartRateSamples().size() > MAX_HEART_RATE_SAMPLES) { + throw new BadRequestException( + ErrorStatus.EXCEED_HEART_RATE_SAMPLES_EXCEPTION, + ErrorStatus.EXCEED_HEART_RATE_SAMPLES_EXCEPTION.getMessage()); + } + } + + private GetHealthDataResponseDto toHealthDataDetailResponse(RecordHealthData healthData) { + List sampleResponses = healthData.getHeartRateSamples().stream() + .map(s -> HeartRateSampleResponse.of(s.getHeartRate(), s.getElapsedSeconds(), s.getZone())) + .collect(Collectors.toList()); + + ZoneResponse zones = ZoneResponse.of( + healthData.getZone1Seconds(), healthData.getZone2Seconds(), + healthData.getZone3Seconds(), healthData.getZone4Seconds(), + healthData.getZone5Seconds()); + + HealthDataDetailResponse detail = HealthDataDetailResponse.of( + healthData.getId(), healthData.getRecord().getId(), + healthData.getAvgHeartRate(), healthData.getMaxHeartRate(), + healthData.getMinHeartRate(), healthData.getCalories(), + zones, healthData.getMaxHeartRateConfig(), sampleResponses); + + return GetHealthDataResponseDto.of(detail); + } +} diff --git a/src/main/java/org/runnect/server/record/dto/response/HealthDataResponse.java b/src/main/java/org/runnect/server/record/dto/response/HealthDataResponse.java new file mode 100644 index 0000000..599e213 --- /dev/null +++ b/src/main/java/org/runnect/server/record/dto/response/HealthDataResponse.java @@ -0,0 +1,18 @@ +package org.runnect.server.record.dto.response; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class HealthDataResponse { + private Double avgHeartRate; + private Double calories; + + public static HealthDataResponse of(Double avgHeartRate, Double calories) { + return new HealthDataResponse(avgHeartRate, calories); + } +} diff --git a/src/main/java/org/runnect/server/record/dto/response/RecordResponse.java b/src/main/java/org/runnect/server/record/dto/response/RecordResponse.java index cca3bf8..f83e4dc 100644 --- a/src/main/java/org/runnect/server/record/dto/response/RecordResponse.java +++ b/src/main/java/org/runnect/server/record/dto/response/RecordResponse.java @@ -21,9 +21,16 @@ public class RecordResponse { private String time; private String pace; private DepartureResponse departure; + private HealthDataResponse healthData; public static RecordResponse of(Long id, Long courseId, Long publicCourseId, Long userId, String title, String image, String createdAt, Float distance, String time, String pace, DepartureResponse departure) { - return new RecordResponse(id, courseId, publicCourseId, userId, title, image, createdAt, distance, time, pace, departure); + return new RecordResponse(id, courseId, publicCourseId, userId, title, image, createdAt, distance, time, pace, departure, null); + } + + public static RecordResponse of(Long id, Long courseId, Long publicCourseId, Long userId, String title, + String image, String createdAt, Float distance, String time, String pace, DepartureResponse departure, + HealthDataResponse healthData) { + return new RecordResponse(id, courseId, publicCourseId, userId, title, image, createdAt, distance, time, pace, departure, healthData); } } diff --git a/src/main/java/org/runnect/server/record/service/RecordService.java b/src/main/java/org/runnect/server/record/service/RecordService.java index 1e207a4..c2dc96b 100644 --- a/src/main/java/org/runnect/server/record/service/RecordService.java +++ b/src/main/java/org/runnect/server/record/service/RecordService.java @@ -18,8 +18,11 @@ import org.runnect.server.record.dto.response.CreateRecordDto; import org.runnect.server.record.dto.response.CreateRecordResponseDto; import org.runnect.server.record.dto.response.DeleteRecordsResponseDto; +import org.runnect.server.health.entity.RecordHealthData; +import org.runnect.server.health.repository.RecordHealthDataRepository; import org.runnect.server.record.dto.response.DepartureResponse; import org.runnect.server.record.dto.response.GetRecordResponseDto; +import org.runnect.server.record.dto.response.HealthDataResponse; import org.runnect.server.record.dto.response.RecordResponse; import org.runnect.server.record.dto.response.UpdateRecordResponse; import org.runnect.server.record.dto.response.UpdateRecordResponseDto; @@ -43,6 +46,7 @@ public class RecordService { private final CourseRepository courseRepository; private final PublicCourseRepository publicCourseRepository; private final UserStampService userStampService; + private final RecordHealthDataRepository recordHealthDataRepository; @Transactional public CreateRecordResponseDto createRecord(Long userId, CreateRecordRequestDto request) { @@ -106,9 +110,19 @@ public GetRecordResponseDto getRecordByUser(Long userId) { DepartureResponse departure = DepartureResponse.of(course.getDepartureRegion(), course.getDepartureCity()); + // 건강 데이터 조회 (실패해도 기록 목록은 정상 반환) + HealthDataResponse healthData = null; + try { + healthData = recordHealthDataRepository.findByRecordId(record.getId()) + .map(h -> HealthDataResponse.of(h.getAvgHeartRate(), h.getCalories())) + .orElse(null); + } catch (Exception e) { + // 건강 데이터 테이블 미생성 등 예외 발생 시 무시 + } + RecordResponse recordResponse = RecordResponse.of(record.getId(), course.getId(), publicCourseId, userId, record.getTitle(), course.getImage(), record.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS")), course.getDistance(), record.getTime().toString(), - record.getPace().toString(), departure); + record.getPace().toString(), departure, healthData); recordResponses.add(recordResponse);