Skip to content

Commit 0b49d4e

Browse files
thingineeerclaude
andcommitted
feat: Apple Watch 건강 데이터 연동 API 추가
- RecordHealthData, HeartRateSample 엔티티 및 리포지토리 추가 - POST /api/record/{recordId}/health (건강 데이터 저장) - GET /api/record/{recordId}/health (건강 데이터 상세 조회) - GET /api/health/summary (기간별 건강 통계) - DELETE /api/record/{recordId}/health (건강 데이터 삭제) - GET /api/record/user 응답에 healthData 필드 추가 (하위 호환) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 49140f5 commit 0b49d4e

16 files changed

Lines changed: 741 additions & 2 deletions

src/main/java/org/runnect/server/common/constant/ErrorStatus.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ public enum ErrorStatus {
3131
NOT_FOUND_SCRAP_EXCEPTION(HttpStatus.BAD_REQUEST, "스크랩한 코스가 없습니다."),
3232
NOT_FOUND_IMAGE_EXCEPTION(HttpStatus.BAD_REQUEST, "잘못된 이미지 파일입니다"),
3333
NOT_FOUND_PUBLICCOURSE_EXCEPTION(HttpStatus.BAD_REQUEST, "존재하지 않는 public course id입니다."),
34+
INVALID_HEALTH_DATA_EXCEPTION(HttpStatus.BAD_REQUEST, "유효하지 않은 건강 데이터입니다"),
35+
INVALID_DATE_RANGE_EXCEPTION(HttpStatus.BAD_REQUEST, "유효하지 않은 날짜 범위입니다"),
36+
EXCEED_HEART_RATE_SAMPLES_EXCEPTION(HttpStatus.BAD_REQUEST, "심박수 샘플은 최대 5000건까지 허용됩니다"),
3437

3538
/**
3639
* 401 UNAUTHORIZED
@@ -51,6 +54,7 @@ public enum ErrorStatus {
5154
*/
5255
PERMISSION_DENIED_PUBLIC_COURSE_DELETE_EXCEPTION(HttpStatus.FORBIDDEN, "퍼블릭 코스를 삭제할 권한이 존재하지 않습니다."),
5356
PERMISSION_DENIED_RECORD_DELETE_EXCEPTION(HttpStatus.FORBIDDEN, "기록을 삭제할 권한이 존재하지 않습니다."),
57+
PERMISSION_DENIED_HEALTH_DATA_EXCEPTION(HttpStatus.FORBIDDEN, "건강 데이터에 대한 접근 권한이 없습니다"),
5458

5559
/**
5660
* 404 NOT FOUND
@@ -63,6 +67,7 @@ public enum ErrorStatus {
6367
ALREADY_EXIST_USER_EXCEPTION(HttpStatus.CONFLICT, "이미 존재하는 유저입니다"),
6468
ALREADY_EXIST_NICKNAME_EXCEPTION(HttpStatus.CONFLICT, "중복된 닉네임입니다."),
6569
ALREADY_UPLOAD_COURSE_EXCEPTION(HttpStatus.CONFLICT, "이미 업로드된 코스입니다."),
70+
ALREADY_EXIST_HEALTH_DATA_EXCEPTION(HttpStatus.CONFLICT, "이미 건강 데이터가 등록된 기록입니다"),
6671

6772
/**
6873
* 500 INTERNAL SERVER ERROR

src/main/java/org/runnect/server/common/constant/SuccessStatus.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ public enum SuccessStatus {
2929

3030
SEARCH_PUBLIC_COURSE_SUCCESS(HttpStatus.OK,"업로드된 코스 검색 성공"),
3131

32+
GET_HEALTH_DATA_SUCCESS(HttpStatus.OK, "건강 데이터 조회 성공"),
33+
GET_HEALTH_SUMMARY_SUCCESS(HttpStatus.OK, "건강 통계 조회 성공"),
34+
3235

3336
UPDATE_RECORD_SUCCESS(HttpStatus.OK, "활동 기록 수정 성공"),
3437
UPDATE_USER_NICKNAME_SUCCESS(HttpStatus.OK, "닉네임 변경에 성공했습니다."),
@@ -41,6 +44,7 @@ public enum SuccessStatus {
4144
DELETE_PUBLIC_COURSE_SUCCESS(HttpStatus.OK, "퍼블릭 코스 삭제에 성공했습니다."),
4245
DELETE_RECORD_SUCCESS(HttpStatus.OK, "기록 삭제에 성공했습니다."),
4346
DELETE_COURSES_SUCCESS(HttpStatus.OK, "코스 삭제 성공"),
47+
DELETE_HEALTH_DATA_SUCCESS(HttpStatus.OK, "건강 데이터 삭제 성공"),
4448

4549

4650
/**
@@ -52,6 +56,7 @@ public enum SuccessStatus {
5256
CREATE_PUBLIC_COURSE_SUCCESS(HttpStatus.CREATED, "코드 업로드에 성공했습니다."),
5357
CREATE_SCRAP_SUCCESS(HttpStatus.CREATED, "코스 스크랩 성공"),
5458
NEW_TOKEN_SUCCESS(HttpStatus.CREATED, "토큰 재발급에 성공했습니다."),
59+
CREATE_HEALTH_DATA_SUCCESS(HttpStatus.CREATED, "건강 데이터 저장 성공"),
5560
;
5661

5762
private final HttpStatus httpStatus;
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package org.runnect.server.health.controller;
2+
3+
import javax.validation.Valid;
4+
import lombok.RequiredArgsConstructor;
5+
import org.runnect.server.common.constant.SuccessStatus;
6+
import org.runnect.server.common.dto.ApiResponseDto;
7+
import org.runnect.server.common.resolver.userId.UserId;
8+
import org.runnect.server.health.dto.request.HealthDataRequestDto;
9+
import org.runnect.server.health.dto.response.CreateHealthDataResponseDto;
10+
import org.runnect.server.health.dto.response.GetHealthDataResponseDto;
11+
import org.runnect.server.health.dto.response.GetHealthSummaryResponseDto;
12+
import org.runnect.server.health.service.HealthService;
13+
import org.springframework.http.HttpStatus;
14+
import org.springframework.web.bind.annotation.DeleteMapping;
15+
import org.springframework.web.bind.annotation.GetMapping;
16+
import org.springframework.web.bind.annotation.PathVariable;
17+
import org.springframework.web.bind.annotation.PostMapping;
18+
import org.springframework.web.bind.annotation.RequestBody;
19+
import org.springframework.web.bind.annotation.RequestMapping;
20+
import org.springframework.web.bind.annotation.RequestParam;
21+
import org.springframework.web.bind.annotation.ResponseStatus;
22+
import org.springframework.web.bind.annotation.RestController;
23+
24+
@RestController
25+
@RequiredArgsConstructor
26+
@RequestMapping("/api")
27+
public class HealthController {
28+
29+
private final HealthService healthService;
30+
31+
@PostMapping("record/{recordId}/health")
32+
@ResponseStatus(HttpStatus.CREATED)
33+
public ApiResponseDto<CreateHealthDataResponseDto> createHealthData(
34+
@UserId Long userId,
35+
@PathVariable(name = "recordId") Long recordId,
36+
@RequestBody @Valid final HealthDataRequestDto request) {
37+
return ApiResponseDto.success(SuccessStatus.CREATE_HEALTH_DATA_SUCCESS,
38+
healthService.createHealthData(userId, recordId, request));
39+
}
40+
41+
@GetMapping("record/{recordId}/health")
42+
@ResponseStatus(HttpStatus.OK)
43+
public ApiResponseDto<GetHealthDataResponseDto> getHealthData(
44+
@UserId Long userId,
45+
@PathVariable(name = "recordId") Long recordId) {
46+
return ApiResponseDto.success(SuccessStatus.GET_HEALTH_DATA_SUCCESS,
47+
healthService.getHealthData(userId, recordId));
48+
}
49+
50+
@GetMapping("health/summary")
51+
@ResponseStatus(HttpStatus.OK)
52+
public ApiResponseDto<GetHealthSummaryResponseDto> getHealthSummary(
53+
@UserId Long userId,
54+
@RequestParam(name = "startDate") String startDate,
55+
@RequestParam(name = "endDate") String endDate) {
56+
return ApiResponseDto.success(SuccessStatus.GET_HEALTH_SUMMARY_SUCCESS,
57+
healthService.getHealthSummary(userId, startDate, endDate));
58+
}
59+
60+
@DeleteMapping("record/{recordId}/health")
61+
@ResponseStatus(HttpStatus.OK)
62+
public ApiResponseDto deleteHealthData(
63+
@UserId Long userId,
64+
@PathVariable(name = "recordId") Long recordId) {
65+
healthService.deleteHealthData(userId, recordId);
66+
return ApiResponseDto.success(SuccessStatus.DELETE_HEALTH_DATA_SUCCESS);
67+
}
68+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package org.runnect.server.health.dto.request;
2+
3+
import java.util.List;
4+
import javax.validation.Valid;
5+
import javax.validation.constraints.NotNull;
6+
import lombok.AccessLevel;
7+
import lombok.AllArgsConstructor;
8+
import lombok.Getter;
9+
import lombok.NoArgsConstructor;
10+
11+
@Getter
12+
@NoArgsConstructor(access = AccessLevel.PRIVATE)
13+
@AllArgsConstructor
14+
public class HealthDataRequestDto {
15+
@NotNull
16+
private Double avgHeartRate;
17+
18+
private Double maxHeartRate;
19+
20+
private Double minHeartRate;
21+
22+
@NotNull
23+
private Double calories;
24+
25+
@NotNull
26+
private Integer zone1Seconds;
27+
28+
@NotNull
29+
private Integer zone2Seconds;
30+
31+
@NotNull
32+
private Integer zone3Seconds;
33+
34+
@NotNull
35+
private Integer zone4Seconds;
36+
37+
@NotNull
38+
private Integer zone5Seconds;
39+
40+
private Double maxHeartRateConfig;
41+
42+
@Valid
43+
private List<HeartRateSampleRequestDto> heartRateSamples;
44+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package org.runnect.server.health.dto.request;
2+
3+
import javax.validation.constraints.Max;
4+
import javax.validation.constraints.Min;
5+
import javax.validation.constraints.NotNull;
6+
import lombok.AccessLevel;
7+
import lombok.AllArgsConstructor;
8+
import lombok.Getter;
9+
import lombok.NoArgsConstructor;
10+
11+
@Getter
12+
@NoArgsConstructor(access = AccessLevel.PRIVATE)
13+
@AllArgsConstructor
14+
public class HeartRateSampleRequestDto {
15+
@NotNull
16+
private Double heartRate;
17+
18+
@NotNull
19+
private Integer elapsedSeconds;
20+
21+
@NotNull
22+
@Min(1) @Max(5)
23+
private Integer zone;
24+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package org.runnect.server.health.dto.response;
2+
3+
import lombok.AccessLevel;
4+
import lombok.AllArgsConstructor;
5+
import lombok.Getter;
6+
import lombok.NoArgsConstructor;
7+
8+
@Getter
9+
@NoArgsConstructor(access = AccessLevel.PRIVATE)
10+
@AllArgsConstructor(access = AccessLevel.PRIVATE)
11+
public class CreateHealthDataResponseDto {
12+
private Long healthDataId;
13+
14+
public static CreateHealthDataResponseDto of(Long healthDataId) {
15+
return new CreateHealthDataResponseDto(healthDataId);
16+
}
17+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package org.runnect.server.health.dto.response;
2+
3+
import java.util.List;
4+
import lombok.AccessLevel;
5+
import lombok.AllArgsConstructor;
6+
import lombok.Getter;
7+
import lombok.NoArgsConstructor;
8+
9+
@Getter
10+
@NoArgsConstructor(access = AccessLevel.PRIVATE)
11+
@AllArgsConstructor(access = AccessLevel.PRIVATE)
12+
public class GetHealthDataResponseDto {
13+
private HealthDataDetailResponse healthData;
14+
15+
public static GetHealthDataResponseDto of(HealthDataDetailResponse healthData) {
16+
return new GetHealthDataResponseDto(healthData);
17+
}
18+
19+
@Getter
20+
@NoArgsConstructor(access = AccessLevel.PRIVATE)
21+
@AllArgsConstructor(access = AccessLevel.PRIVATE)
22+
public static class HealthDataDetailResponse {
23+
private Long id;
24+
private Long recordId;
25+
private Double avgHeartRate;
26+
private Double maxHeartRate;
27+
private Double minHeartRate;
28+
private Double calories;
29+
private ZoneResponse zones;
30+
private Double maxHeartRateConfig;
31+
private List<HeartRateSampleResponse> heartRateSamples;
32+
33+
public static HealthDataDetailResponse of(Long id, Long recordId, Double avgHeartRate,
34+
Double maxHeartRate, Double minHeartRate, Double calories,
35+
ZoneResponse zones, Double maxHeartRateConfig,
36+
List<HeartRateSampleResponse> heartRateSamples) {
37+
return new HealthDataDetailResponse(id, recordId, avgHeartRate, maxHeartRate,
38+
minHeartRate, calories, zones, maxHeartRateConfig, heartRateSamples);
39+
}
40+
}
41+
42+
@Getter
43+
@NoArgsConstructor(access = AccessLevel.PRIVATE)
44+
@AllArgsConstructor(access = AccessLevel.PRIVATE)
45+
public static class ZoneResponse {
46+
private Integer zone1Seconds;
47+
private Integer zone2Seconds;
48+
private Integer zone3Seconds;
49+
private Integer zone4Seconds;
50+
private Integer zone5Seconds;
51+
52+
public static ZoneResponse of(Integer zone1Seconds, Integer zone2Seconds,
53+
Integer zone3Seconds, Integer zone4Seconds, Integer zone5Seconds) {
54+
return new ZoneResponse(zone1Seconds, zone2Seconds, zone3Seconds, zone4Seconds, zone5Seconds);
55+
}
56+
}
57+
58+
@Getter
59+
@NoArgsConstructor(access = AccessLevel.PRIVATE)
60+
@AllArgsConstructor(access = AccessLevel.PRIVATE)
61+
public static class HeartRateSampleResponse {
62+
private Double heartRate;
63+
private Integer elapsedSeconds;
64+
private Integer zone;
65+
66+
public static HeartRateSampleResponse of(Double heartRate, Integer elapsedSeconds, Integer zone) {
67+
return new HeartRateSampleResponse(heartRate, elapsedSeconds, zone);
68+
}
69+
}
70+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package org.runnect.server.health.dto.response;
2+
3+
import lombok.AccessLevel;
4+
import lombok.AllArgsConstructor;
5+
import lombok.Getter;
6+
import lombok.NoArgsConstructor;
7+
8+
@Getter
9+
@NoArgsConstructor(access = AccessLevel.PRIVATE)
10+
@AllArgsConstructor(access = AccessLevel.PRIVATE)
11+
public class GetHealthSummaryResponseDto {
12+
private HealthSummaryResponse summary;
13+
14+
public static GetHealthSummaryResponseDto of(HealthSummaryResponse summary) {
15+
return new GetHealthSummaryResponseDto(summary);
16+
}
17+
18+
@Getter
19+
@NoArgsConstructor(access = AccessLevel.PRIVATE)
20+
@AllArgsConstructor(access = AccessLevel.PRIVATE)
21+
public static class HealthSummaryResponse {
22+
private Long totalRecords;
23+
private Long recordsWithHealth;
24+
private Double avgHeartRate;
25+
private Double avgCalories;
26+
private Double totalCalories;
27+
private GetHealthDataResponseDto.ZoneResponse zoneDistribution;
28+
29+
public static HealthSummaryResponse of(Long totalRecords, Long recordsWithHealth,
30+
Double avgHeartRate, Double avgCalories, Double totalCalories,
31+
GetHealthDataResponseDto.ZoneResponse zoneDistribution) {
32+
return new HealthSummaryResponse(totalRecords, recordsWithHealth, avgHeartRate,
33+
avgCalories, totalCalories, zoneDistribution);
34+
}
35+
}
36+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package org.runnect.server.health.entity;
2+
3+
import javax.persistence.Column;
4+
import javax.persistence.Entity;
5+
import javax.persistence.FetchType;
6+
import javax.persistence.GeneratedValue;
7+
import javax.persistence.GenerationType;
8+
import javax.persistence.Id;
9+
import javax.persistence.JoinColumn;
10+
import javax.persistence.ManyToOne;
11+
import lombok.AccessLevel;
12+
import lombok.Builder;
13+
import lombok.Getter;
14+
import lombok.NoArgsConstructor;
15+
import org.runnect.server.common.entity.AuditingTimeEntity;
16+
17+
@Getter
18+
@Entity
19+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
20+
public class HeartRateSample extends AuditingTimeEntity {
21+
22+
@Id
23+
@GeneratedValue(strategy = GenerationType.IDENTITY)
24+
private Long id;
25+
26+
@ManyToOne(fetch = FetchType.LAZY)
27+
@JoinColumn(name = "record_health_data_id", nullable = false)
28+
private RecordHealthData recordHealthData;
29+
30+
@Column(nullable = false)
31+
private Double heartRate;
32+
33+
@Column(nullable = false)
34+
private Integer elapsedSeconds;
35+
36+
@Column(nullable = false)
37+
private Integer zone;
38+
39+
@Builder
40+
public HeartRateSample(RecordHealthData recordHealthData, Double heartRate, Integer elapsedSeconds, Integer zone) {
41+
this.recordHealthData = recordHealthData;
42+
this.heartRate = heartRate;
43+
this.elapsedSeconds = elapsedSeconds;
44+
this.zone = zone;
45+
}
46+
47+
public void setRecordHealthData(RecordHealthData recordHealthData) {
48+
this.recordHealthData = recordHealthData;
49+
}
50+
51+
@Override
52+
public void updateDeletedAt() {
53+
throw new RuntimeException("Course를 제외한 테이블은 정상적으로 삭제됩니다.");
54+
}
55+
}

0 commit comments

Comments
 (0)