Skip to content

Commit 7195a37

Browse files
authored
Merge pull request #33 from SEMOSAN/feat/#24-user-hiking-record
[Feat] 등산 기록 관련 기능 추가
2 parents f20f549 + 266c8fe commit 7195a37

13 files changed

Lines changed: 511 additions & 0 deletions

src/main/java/com/semosan/api/common/status/SuccessStatus.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ public enum SuccessStatus implements BaseStatus {
4141
MOUNTAIN_UNLIKE_SUCCESS(HttpStatus.OK, "MTN_200_5", "산 좋아요 취소에 성공했습니다."),
4242
LIKED_MOUNTAIN_LIST_SUCCESS(HttpStatus.OK, "MTN_200_6", "좋아요한 산 목록 조회에 성공했습니다."),
4343

44+
/**
45+
* Hiking Record
46+
*/
47+
GET_HIKING_RECORD_MOUNTAIN_LIST_SUCCESS(HttpStatus.OK, "HIKING_200_1", "내가 다녀온 산 목록 조회에 성공했습니다."),
48+
GET_HIKING_RECORD_SUMMARY_SUCCESS(HttpStatus.OK, "HIKING_200_2", "나의 등산 기록 요약 조회에 성공했습니다."),
49+
GET_HIKING_RECORD_LIST_SUCCESS(HttpStatus.OK, "HIKING_200_3", "나의 등산 기록 목록 조회에 성공했습니다."),
50+
4451
/**
4552
* Image
4653
*/
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package com.semosan.api.domain.hiking.controller;
2+
3+
import com.semosan.api.common.response.ApiResponse;
4+
import com.semosan.api.common.response.PageResponse;
5+
import com.semosan.api.common.status.SuccessStatus;
6+
import com.semosan.api.domain.hiking.controller.docs.HikingRecordControllerDocs;
7+
import com.semosan.api.domain.hiking.dto.response.GetUserHikingRecordResponse;
8+
import com.semosan.api.domain.hiking.dto.response.GetUserHikingMountainRecordResponse;
9+
import com.semosan.api.domain.hiking.dto.response.GetUserHikingRecordSummaryResponse;
10+
import com.semosan.api.domain.hiking.service.HikingRecordService;
11+
import lombok.RequiredArgsConstructor;
12+
import org.springframework.data.domain.Pageable;
13+
import org.springframework.data.web.PageableDefault;
14+
import org.springframework.http.ResponseEntity;
15+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
16+
import org.springframework.web.bind.annotation.GetMapping;
17+
import org.springframework.web.bind.annotation.RequestMapping;
18+
import org.springframework.web.bind.annotation.RestController;
19+
20+
@RestController
21+
@RequestMapping("/api/hiking-records")
22+
@RequiredArgsConstructor
23+
public class HikingRecordController implements HikingRecordControllerDocs {
24+
25+
private final HikingRecordService hikingRecordService;
26+
27+
// 나의 등산 기록 목록을 조회합니다.
28+
@GetMapping("/me")
29+
@Override
30+
public ResponseEntity<ApiResponse<PageResponse<GetUserHikingRecordResponse>>> getUserHikingRecords(
31+
@AuthenticationPrincipal Long userId,
32+
@PageableDefault(size = 10) Pageable pageable
33+
) {
34+
PageResponse<GetUserHikingRecordResponse> response = PageResponse.from(hikingRecordService.getUserHikingRecords(userId, pageable));
35+
return ApiResponse.success(SuccessStatus.GET_HIKING_RECORD_LIST_SUCCESS, response);
36+
}
37+
38+
// 내가 다녀온 산 목록을 조회합니다.
39+
@GetMapping("/me/mountains")
40+
@Override
41+
public ResponseEntity<ApiResponse<PageResponse<GetUserHikingMountainRecordResponse>>> getUserHikingMountainRecords(
42+
@AuthenticationPrincipal Long userId,
43+
@PageableDefault(size = 10) Pageable pageable
44+
) {
45+
PageResponse<GetUserHikingMountainRecordResponse> response = PageResponse.from(hikingRecordService.getUserHikingMountainRecords(userId, pageable));
46+
return ApiResponse.success(SuccessStatus.GET_HIKING_RECORD_MOUNTAIN_LIST_SUCCESS, response);
47+
}
48+
49+
// 나의 등산 기록 요약 정보를 조회합니다.
50+
@GetMapping("/me/summary")
51+
@Override
52+
public ResponseEntity<ApiResponse<GetUserHikingRecordSummaryResponse>> getUserHikingRecordSummary(
53+
@AuthenticationPrincipal Long userId
54+
) {
55+
GetUserHikingRecordSummaryResponse response = hikingRecordService.getUserHikingRecordSummary(userId);
56+
return ApiResponse.success(SuccessStatus.GET_HIKING_RECORD_SUMMARY_SUCCESS, response);
57+
}
58+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package com.semosan.api.domain.hiking.controller.docs;
2+
3+
import com.semosan.api.common.response.ApiResponse;
4+
import com.semosan.api.common.response.PageResponse;
5+
import com.semosan.api.domain.hiking.dto.response.GetUserHikingRecordResponse;
6+
import com.semosan.api.domain.hiking.dto.response.GetUserHikingMountainRecordResponse;
7+
import com.semosan.api.domain.hiking.dto.response.GetUserHikingRecordSummaryResponse;
8+
import io.swagger.v3.oas.annotations.Operation;
9+
import io.swagger.v3.oas.annotations.Parameter;
10+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
11+
import io.swagger.v3.oas.annotations.tags.Tag;
12+
import org.springframework.data.domain.Pageable;
13+
import org.springframework.data.web.PageableDefault;
14+
import org.springframework.http.ResponseEntity;
15+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
16+
17+
@Tag(name = "Hiking Record", description = "등산 기록 관련 API")
18+
public interface HikingRecordControllerDocs {
19+
20+
@Operation(
21+
summary = "나의 등산 기록 목록 조회",
22+
description = "사용자가 참여한 등산 기록을 기록 단위로 조회합니다."
23+
)
24+
@ApiResponses({
25+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
26+
responseCode = "200",
27+
description = "나의 등산 기록 목록 조회 성공"
28+
)
29+
})
30+
ResponseEntity<ApiResponse<PageResponse<GetUserHikingRecordResponse>>> getUserHikingRecords(
31+
@Parameter(hidden = true)
32+
@AuthenticationPrincipal Long userId,
33+
@PageableDefault(size = 10) Pageable pageable
34+
);
35+
36+
@Operation(
37+
summary = "내가 다녀온 산 목록 조회",
38+
description = "사용자가 등산 기록을 남긴 산 목록을 산 단위로 묶어 조회합니다."
39+
)
40+
@ApiResponses({
41+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
42+
responseCode = "200",
43+
description = "내가 다녀온 산 목록 조회 성공"
44+
)
45+
})
46+
ResponseEntity<ApiResponse<PageResponse<GetUserHikingMountainRecordResponse>>> getUserHikingMountainRecords(
47+
@Parameter(hidden = true)
48+
@AuthenticationPrincipal Long userId,
49+
@PageableDefault(size = 10) Pageable pageable
50+
);
51+
52+
@Operation(
53+
summary = "나의 등산 기록 요약 조회",
54+
description = "마이페이지에서 사용하는 누적 등산 횟수, 정복한 산 수, 누적 등산 고도를 조회합니다."
55+
)
56+
@ApiResponses({
57+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
58+
responseCode = "200",
59+
description = "나의 등산 기록 요약 조회 성공"
60+
)
61+
})
62+
ResponseEntity<ApiResponse<GetUserHikingRecordSummaryResponse>> getUserHikingRecordSummary(
63+
@Parameter(hidden = true)
64+
@AuthenticationPrincipal Long userId
65+
);
66+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.semosan.api.domain.hiking.dto.response;
2+
3+
import com.semosan.api.domain.hiking.repository.projection.UserHikingMountainRecordProjection;
4+
5+
import java.time.LocalDate;
6+
7+
public record GetUserHikingMountainRecordResponse(
8+
Long mountainId,
9+
String mountainName,
10+
String imageUrl,
11+
Long hikingCount,
12+
LocalDate lastHikedAt
13+
) {
14+
15+
// 조회 projection을 API 응답 DTO로 변환합니다.
16+
public static GetUserHikingMountainRecordResponse from(UserHikingMountainRecordProjection projection) {
17+
return new GetUserHikingMountainRecordResponse(
18+
projection.getMountainId(),
19+
projection.getMountainName(),
20+
projection.getImageUrl(),
21+
projection.getHikingCount(),
22+
projection.getLastHikedAt().toLocalDate()
23+
);
24+
}
25+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.semosan.api.domain.hiking.dto.response;
2+
3+
import com.semosan.api.domain.hiking.repository.projection.UserHikingRecordProjection;
4+
5+
import java.time.LocalDate;
6+
7+
public record GetUserHikingRecordResponse(
8+
Long hikingRecordId,
9+
Long mountainId,
10+
String mountainName,
11+
Long courseId,
12+
String courseName,
13+
String imageUrl,
14+
Double distance,
15+
Integer duration,
16+
LocalDate hikedAt
17+
) {
18+
19+
// 조회 projection을 나의 등산 기록 상세 응답 DTO로 변환합니다.
20+
public static GetUserHikingRecordResponse from(UserHikingRecordProjection projection) {
21+
return new GetUserHikingRecordResponse(
22+
projection.getHikingRecordId(),
23+
projection.getMountainId(),
24+
projection.getMountainName(),
25+
projection.getCourseId(),
26+
projection.getCourseName(),
27+
projection.getImageUrl(),
28+
projection.getDistance(),
29+
projection.getDuration(),
30+
projection.getHikedAt().toLocalDate()
31+
);
32+
}
33+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.semosan.api.domain.hiking.dto.response;
2+
3+
import com.semosan.api.domain.hiking.repository.projection.UserHikingRecordSummaryProjection;
4+
5+
public record GetUserHikingRecordSummaryResponse(
6+
Long totalHikingCount,
7+
Long conqueredMountainCount,
8+
Double totalAltitude
9+
) {
10+
11+
// 조회 projection을 등산 기록 요약 응답 DTO로 변환합니다.
12+
public static GetUserHikingRecordSummaryResponse from(UserHikingRecordSummaryProjection projection) {
13+
return new GetUserHikingRecordSummaryResponse(
14+
projection.getTotalHikingCount(),
15+
projection.getConqueredMountainCount(),
16+
projection.getTotalAltitude()
17+
);
18+
}
19+
20+
// 등산 기록이 없는 유저의 빈 요약 응답을 생성합니다.
21+
public static GetUserHikingRecordSummaryResponse empty() {
22+
return new GetUserHikingRecordSummaryResponse(0L, 0L, 0.0);
23+
}
24+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.semosan.api.domain.hiking.entity;
2+
3+
import com.semosan.api.common.base.BaseEntity;
4+
import com.semosan.api.domain.user.entity.User;
5+
import jakarta.persistence.*;
6+
import lombok.*;
7+
8+
@Table(
9+
name = "hiking_members",
10+
uniqueConstraints = {
11+
@UniqueConstraint(
12+
name = "uk_hiking_member_record_user",
13+
columnNames = {"hiking_record_id", "user_id"}
14+
)
15+
},
16+
indexes = @Index(name = "idx_hiking_member_user_id", columnList = "user_id")
17+
)
18+
@Getter
19+
@Entity
20+
@Builder(access = AccessLevel.PROTECTED)
21+
@AllArgsConstructor(access = AccessLevel.PROTECTED)
22+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
23+
public class HikingMember extends BaseEntity {
24+
25+
@Id
26+
@GeneratedValue(strategy = GenerationType.IDENTITY)
27+
private Long id;
28+
29+
@ManyToOne(fetch = FetchType.LAZY)
30+
@JoinColumn(name = "hiking_record_id", nullable = false)
31+
private HikingRecord hikingRecord;
32+
33+
@ManyToOne(fetch = FetchType.LAZY)
34+
@JoinColumn(name = "user_id", nullable = false)
35+
private User user;
36+
37+
// 등산 기록에 참여한 유저를 생성합니다.
38+
public static HikingMember create(HikingRecord hikingRecord, User user) {
39+
return HikingMember.builder()
40+
.hikingRecord(hikingRecord)
41+
.user(user)
42+
.build();
43+
}
44+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package com.semosan.api.domain.hiking.entity;
2+
3+
import com.semosan.api.common.base.BaseEntity;
4+
import com.semosan.api.domain.mountain.entity.Course;
5+
import com.semosan.api.domain.mountain.entity.Mountain;
6+
import jakarta.persistence.*;
7+
import lombok.*;
8+
9+
@Table(name = "hiking_records")
10+
@Getter
11+
@Entity
12+
@Builder(access = AccessLevel.PROTECTED)
13+
@AllArgsConstructor(access = AccessLevel.PROTECTED)
14+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
15+
public class HikingRecord extends BaseEntity {
16+
17+
@Id
18+
@GeneratedValue(strategy = GenerationType.IDENTITY)
19+
private Long id;
20+
21+
@ManyToOne(fetch = FetchType.LAZY)
22+
@JoinColumn(name = "mountain_id", nullable = false)
23+
private Mountain mountain;
24+
25+
@ManyToOne(fetch = FetchType.LAZY)
26+
@JoinColumn(name = "course_id", nullable = false)
27+
private Course course;
28+
29+
/** 실제 소요 시간 (초) */
30+
@Column(name = "duration", nullable = false)
31+
private Integer duration;
32+
33+
/** 등산 중 도달한 최고 고도 (m) */
34+
@Column(name = "max_altitude", nullable = false)
35+
private Double altitude;
36+
37+
/** 소모 칼로리 (kcal) */
38+
@Column(name = "calories", nullable = false)
39+
private Integer calories;
40+
41+
@Column(name = "clive_image_url", length = 500)
42+
private String cliveImageUrl;
43+
44+
@Column(name = "photo_report_image_url", length = 500)
45+
private String photoReportImageUrl;
46+
47+
// 등산 기록을 생성합니다.
48+
public static HikingRecord create(
49+
Mountain mountain,
50+
Course course,
51+
Integer duration,
52+
Double altitude,
53+
Integer calories,
54+
String cliveImageUrl,
55+
String photoReportImageUrl
56+
) {
57+
return HikingRecord.builder()
58+
.mountain(mountain)
59+
.course(course)
60+
.duration(duration)
61+
.altitude(altitude)
62+
.calories(calories)
63+
.cliveImageUrl(cliveImageUrl)
64+
.photoReportImageUrl(photoReportImageUrl)
65+
.build();
66+
}
67+
}

0 commit comments

Comments
 (0)