Skip to content
Merged
2 changes: 2 additions & 0 deletions src/main/java/com/semosan/api/common/status/ErrorStatus.java
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,10 @@ public enum ErrorStatus implements BaseStatus {
/**
* Hiking (등산 기록)
*/
HIKING_RECORD_COURSE_REQUIRED(HttpStatus.BAD_REQUEST, "HIKE_400_1", "코스 기반 등산 기록에만 난이도 피드백을 남길 수 있습니다."),
HIKING_RECORD_FORBIDDEN(HttpStatus.FORBIDDEN, "HIKE_403_1", "본인이 참여한 등산 기록만 공유할 수 있습니다."),
HIKING_RECORD_NOT_FOUND(HttpStatus.NOT_FOUND, "HIKE_404_1", "등산 기록을 찾을 수 없습니다."),
COURSE_DIFFICULTY_FEEDBACK_ALREADY_EXISTS(HttpStatus.CONFLICT, "HIKE_409_1", "이미 난이도 피드백을 남긴 등산 기록입니다."),

/**
* Tracking (트래킹 세션)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ public enum SuccessStatus implements BaseStatus {
GET_HIKING_RECORD_SUMMARY_SUCCESS(HttpStatus.OK, "HIKING_200_2", "나의 등산 기록 요약 조회에 성공했습니다."),
GET_HIKING_RECORD_LIST_SUCCESS(HttpStatus.OK, "HIKING_200_3", "나의 등산 기록 목록 조회에 성공했습니다."),
GET_HIKING_RECORD_LIST_BY_MOUNTAIN_SUCCESS(HttpStatus.OK, "HIKING_200_4", "특정 산의 나의 등산 기록 목록 조회에 성공했습니다."),
CREATE_COURSE_DIFFICULTY_FEEDBACK_SUCCESS(HttpStatus.CREATED, "HIKING_201_1", "코스 난이도 피드백 저장에 성공했습니다."),

/**
* Tracking — 진입 화면 (#45) & 세션 (#18)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,22 @@
import com.semosan.api.common.response.PageResponse;
import com.semosan.api.common.status.SuccessStatus;
import com.semosan.api.domain.hiking.controller.docs.HikingRecordControllerDocs;
import com.semosan.api.domain.hiking.dto.request.CreateCourseDifficultyFeedbackRequest;
import com.semosan.api.domain.hiking.dto.response.CourseDifficultyFeedbackResponse;
import com.semosan.api.domain.hiking.dto.response.GetUserHikingRecordResponse;
import com.semosan.api.domain.hiking.dto.response.GetUserHikingMountainRecordResponse;
import com.semosan.api.domain.hiking.dto.response.GetUserHikingRecordSummaryResponse;
import com.semosan.api.domain.hiking.service.HikingRecordService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
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.RestController;

Expand Down Expand Up @@ -70,4 +75,20 @@ public ResponseEntity<ApiResponse<GetUserHikingRecordSummaryResponse>> getUserHi
GetUserHikingRecordSummaryResponse response = hikingRecordService.getUserHikingRecordSummary(userId);
return ApiResponse.success(SuccessStatus.GET_HIKING_RECORD_SUMMARY_SUCCESS, response);
}

// 코스 난이도 피드백을 저장합니다.
@PostMapping("/{hikingRecordId}/difficulty-feedback")
@Override
public ResponseEntity<ApiResponse<CourseDifficultyFeedbackResponse>> createCourseDifficultyFeedback(
@AuthenticationPrincipal Long userId,
@PathVariable Long hikingRecordId,
@Valid @RequestBody CreateCourseDifficultyFeedbackRequest request
) {
CourseDifficultyFeedbackResponse response = hikingRecordService.createCourseDifficultyFeedback(
userId,
hikingRecordId,
request
);
return ApiResponse.success(SuccessStatus.CREATE_COURSE_DIFFICULTY_FEEDBACK_SUCCESS, response);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@

import com.semosan.api.common.response.ApiResponse;
import com.semosan.api.common.response.PageResponse;
import com.semosan.api.domain.hiking.dto.request.CreateCourseDifficultyFeedbackRequest;
import com.semosan.api.domain.hiking.dto.response.CourseDifficultyFeedbackResponse;
import com.semosan.api.domain.hiking.dto.response.GetUserHikingRecordResponse;
import com.semosan.api.domain.hiking.dto.response.GetUserHikingMountainRecordResponse;
import com.semosan.api.domain.hiking.dto.response.GetUserHikingRecordSummaryResponse;
import jakarta.validation.Valid;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
Expand All @@ -16,6 +19,7 @@
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;

@Tag(name = "Hiking Record", description = "등산 기록 관련 API")
public interface HikingRecordControllerDocs {
Expand Down Expand Up @@ -89,4 +93,42 @@ ResponseEntity<ApiResponse<GetUserHikingRecordSummaryResponse>> getUserHikingRec
@Parameter(hidden = true)
@AuthenticationPrincipal Long userId
);

@Operation(
summary = "코스 난이도 피드백 저장",
description = "코스 기반 등산 기록 1개에 대해 사용자가 체감한 난이도 비교 피드백을 저장합니다."
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "201",
description = "코스 난이도 피드백 저장 성공"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "400",
description = "자유 기록 등 코스 비교가 불가능한 등산 기록",
content = @Content(schema = @Schema(implementation = ApiResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "403",
description = "본인이 참여한 등산 기록이 아님",
content = @Content(schema = @Schema(implementation = ApiResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "등산 기록을 찾을 수 없음",
content = @Content(schema = @Schema(implementation = ApiResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "409",
description = "이미 난이도 피드백을 저장한 등산 기록",
content = @Content(schema = @Schema(implementation = ApiResponse.class))
)
})
ResponseEntity<ApiResponse<CourseDifficultyFeedbackResponse>> createCourseDifficultyFeedback(
@Parameter(hidden = true)
@AuthenticationPrincipal Long userId,
@Parameter(description = "등산 기록 ID", required = true)
@PathVariable Long hikingRecordId,
@Valid @RequestBody CreateCourseDifficultyFeedbackRequest request
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.semosan.api.domain.hiking.dto.request;

import com.semosan.api.domain.hiking.enums.DifficultyFeedbackType;
import jakarta.validation.constraints.NotNull;

public record CreateCourseDifficultyFeedbackRequest(
@NotNull DifficultyFeedbackType comparison
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.semosan.api.domain.hiking.dto.response;

import com.semosan.api.domain.hiking.entity.CourseDifficultyFeedback;
import com.semosan.api.domain.hiking.enums.DifficultyFeedbackType;
import com.semosan.api.domain.mountain.enums.Difficulty;

public record CourseDifficultyFeedbackResponse(
Long feedbackId,
Long hikingRecordId,
Long mountainId,
String mountainName,
Long courseId,
String courseName,
Difficulty guideDifficulty,
DifficultyFeedbackType comparison
) {

public static CourseDifficultyFeedbackResponse from(CourseDifficultyFeedback feedback) {
return new CourseDifficultyFeedbackResponse(
feedback.getId(),
feedback.getHikingRecord().getId(),
feedback.getCourse().getMountain().getId(),
feedback.getCourse().getMountain().getName(),
feedback.getCourse().getId(),
feedback.getCourse().getName(),
feedback.getGuideDifficulty(),
feedback.getComparison()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.semosan.api.domain.hiking.entity;

import com.semosan.api.common.base.BaseEntity;
import com.semosan.api.common.exception.GeneralException;
import com.semosan.api.common.status.ErrorStatus;
import com.semosan.api.domain.hiking.enums.DifficultyFeedbackType;
import com.semosan.api.domain.mountain.entity.Course;
import com.semosan.api.domain.mountain.enums.Difficulty;
import com.semosan.api.domain.user.entity.User;
import jakarta.persistence.*;
import lombok.*;

@Table(
name = "course_difficulty_feedbacks",
uniqueConstraints = {
@UniqueConstraint(
name = "uk_course_difficulty_feedback_hiking_record",
columnNames = "hiking_record_id"
)
},
indexes = {
@Index(name = "idx_course_difficulty_feedback_course_id", columnList = "course_id"),
@Index(name = "idx_course_difficulty_feedback_user_id", columnList = "user_id")
}
)
@Getter
@Entity
@Builder(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class CourseDifficultyFeedback extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "hiking_record_id", nullable = false)
private HikingRecord hikingRecord;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "course_id", nullable = false)
private Course course;

@Enumerated(EnumType.STRING)
@Column(name = "guide_difficulty", nullable = false, length = 20)
private Difficulty guideDifficulty;

@Enumerated(EnumType.STRING)
@Column(name = "comparison", nullable = false, length = 20)
private DifficultyFeedbackType comparison;

public static CourseDifficultyFeedback create(
HikingRecord hikingRecord,
User user,
DifficultyFeedbackType comparison
) {
Course course = hikingRecord.getCourse();
if (course == null) {
throw new GeneralException(ErrorStatus.HIKING_RECORD_COURSE_REQUIRED);
}
return CourseDifficultyFeedback.builder()
.hikingRecord(hikingRecord)
.user(user)
.course(course)
.guideDifficulty(course.getDifficulty())
.comparison(comparison)
.build();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.semosan.api.domain.hiking.enums;

public enum DifficultyFeedbackType {
SIMILAR,
EASIER,
HARDER
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.semosan.api.domain.hiking.repository;

import com.semosan.api.domain.hiking.entity.CourseDifficultyFeedback;
import org.springframework.data.jpa.repository.JpaRepository;

public interface CourseDifficultyFeedbackRepository extends JpaRepository<CourseDifficultyFeedback, Long> {

boolean existsByHikingRecord_Id(Long hikingRecordId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Page<UserHikingMountainRecordProjection> findUserHikingMountainRecordsByUserId(
FROM hiking_records hr
JOIN hiking_members hm ON hm.hiking_record_id = hr.id
JOIN mountains m ON m.id = hr.mountain_id
JOIN courses c ON c.id = hr.course_id
LEFT JOIN courses c ON c.id = hr.course_id
WHERE hm.user_id = :userId
ORDER BY hr.created_at DESC, hr.id DESC
""",
Expand Down Expand Up @@ -121,7 +121,7 @@ Page<UserHikingRecordProjection> findUserHikingRecordsByUserId(
FROM hiking_records hr
JOIN hiking_members hm ON hm.hiking_record_id = hr.id
JOIN mountains m ON m.id = hr.mountain_id
JOIN courses c ON c.id = hr.course_id
LEFT JOIN courses c ON c.id = hr.course_id
WHERE hm.user_id = :userId AND hr.mountain_id = :mountainId
ORDER BY hr.created_at DESC, hr.id DESC
""",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,23 @@

import com.semosan.api.common.exception.GeneralException;
import com.semosan.api.common.status.ErrorStatus;
import com.semosan.api.domain.hiking.dto.request.CreateCourseDifficultyFeedbackRequest;
import com.semosan.api.domain.hiking.dto.response.CourseDifficultyFeedbackResponse;
import com.semosan.api.domain.hiking.dto.response.GetUserHikingRecordResponse;
import com.semosan.api.domain.hiking.dto.response.GetUserHikingMountainRecordResponse;
import com.semosan.api.domain.hiking.dto.response.GetUserHikingRecordSummaryResponse;
import com.semosan.api.domain.hiking.entity.CourseDifficultyFeedback;
import com.semosan.api.domain.hiking.entity.HikingRecord;
import com.semosan.api.domain.hiking.repository.CourseDifficultyFeedbackRepository;
import com.semosan.api.domain.hiking.repository.HikingMemberRepository;
import com.semosan.api.domain.hiking.repository.HikingRecordRepository;
import com.semosan.api.domain.hiking.repository.projection.UserHikingRecordSummaryProjection;
import com.semosan.api.domain.mountain.repository.MountainRepository;
import com.semosan.api.domain.user.entity.User;
import com.semosan.api.domain.user.service.UserReader;
import lombok.RequiredArgsConstructor;
import org.hibernate.exception.ConstraintViolationException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
Expand All @@ -19,9 +28,14 @@
@RequiredArgsConstructor
public class HikingRecordService {

private static final String DIFFICULTY_FEEDBACK_HIKING_RECORD_UNIQUE_CONSTRAINT =
"uk_course_difficulty_feedback_hiking_record";

private final HikingRecordRepository hikingRecordRepository;
private final UserReader userReader;
private final MountainRepository mountainRepository;
private final HikingMemberRepository hikingMemberRepository;
private final CourseDifficultyFeedbackRepository courseDifficultyFeedbackRepository;

// 유저가 다녀온 산 목록을 산 단위로 묶어 조회합니다.
@Transactional(readOnly = true)
Expand Down Expand Up @@ -65,4 +79,56 @@ public GetUserHikingRecordSummaryResponse getUserHikingRecordSummary(Long userId
}
return GetUserHikingRecordSummaryResponse.from(projection);
}

// 코스 기반 등산 기록에 대한 난이도 체감 피드백을 저장합니다.
@Transactional
public CourseDifficultyFeedbackResponse createCourseDifficultyFeedback(
Long userId,
Long hikingRecordId,
CreateCourseDifficultyFeedbackRequest request
) {
User user = userReader.findCompletedOnboardingUserById(userId);
HikingRecord hikingRecord = hikingRecordRepository.findById(hikingRecordId)
.orElseThrow(() -> new GeneralException(ErrorStatus.HIKING_RECORD_NOT_FOUND));

if (!hikingMemberRepository.existsByHikingRecordAndUser(hikingRecord, user)) {
throw new GeneralException(ErrorStatus.HIKING_RECORD_FORBIDDEN);
}
if (hikingRecord.getCourse() == null) {
throw new GeneralException(ErrorStatus.HIKING_RECORD_COURSE_REQUIRED);
}
if (courseDifficultyFeedbackRepository.existsByHikingRecord_Id(hikingRecordId)) {
throw new GeneralException(ErrorStatus.COURSE_DIFFICULTY_FEEDBACK_ALREADY_EXISTS);
}

CourseDifficultyFeedback feedback = CourseDifficultyFeedback.create(
hikingRecord,
user,
request.comparison()
);
try {
return CourseDifficultyFeedbackResponse.from(courseDifficultyFeedbackRepository.saveAndFlush(feedback));
} catch (DataIntegrityViolationException e) {
if (isDifficultyFeedbackHikingRecordUniqueViolation(e)) {
throw new GeneralException(ErrorStatus.COURSE_DIFFICULTY_FEEDBACK_ALREADY_EXISTS);
}
throw e;
}
}

private boolean isDifficultyFeedbackHikingRecordUniqueViolation(Throwable exception) {
Throwable current = exception;
while (current != null) {
if (current instanceof ConstraintViolationException constraintViolation
&& DIFFICULTY_FEEDBACK_HIKING_RECORD_UNIQUE_CONSTRAINT.equals(constraintViolation.getConstraintName())) {
return true;
}
String message = current.getMessage();
if (message != null && message.contains(DIFFICULTY_FEEDBACK_HIKING_RECORD_UNIQUE_CONSTRAINT)) {
return true;
}
current = current.getCause();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return false;
}
}
Loading