Skip to content

Commit 807e97e

Browse files
authored
Merge pull request #157 from SEMOSAN/feat/#156-course-like
[Feat] 코스 좋아요 토글 API 추가
2 parents 3ff34e8 + 27f7410 commit 807e97e

16 files changed

Lines changed: 397 additions & 5 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ public enum SuccessStatus implements BaseStatus {
4343
MOUNTAIN_MAP_SUCCESS(HttpStatus.OK, "MTN_200_7", "지도 영역 내 산 조회에 성공했습니다."),
4444
MOUNTAIN_RECOMMENDATION_SUCCESS(HttpStatus.OK, "MTN_200_8", "레벨 맞춤 산 추천 조회에 성공했습니다."),
4545
COURSE_DETAIL_SUCCESS(HttpStatus.OK, "MTN_200_9", "코스 상세 정보 조회에 성공했습니다."),
46+
COURSE_LIKE_TOGGLE_SUCCESS(HttpStatus.OK, "MTN_200_10", "코스 좋아요 처리에 성공했습니다."),
4647

4748
/**
4849
* Hiking Record

src/main/java/com/semosan/api/domain/mountain/controller/CourseController.java

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@
44
import com.semosan.api.common.status.SuccessStatus;
55
import com.semosan.api.domain.mountain.controller.docs.CourseControllerDocs;
66
import com.semosan.api.domain.mountain.dto.response.CourseDetailResponse;
7+
import com.semosan.api.domain.mountain.dto.response.CourseLikeToggleResponse;
8+
import com.semosan.api.domain.mountain.service.CourseLikeService;
79
import com.semosan.api.domain.mountain.service.CourseService;
810
import lombok.RequiredArgsConstructor;
911
import org.springframework.http.ResponseEntity;
1012
import org.springframework.security.core.annotation.AuthenticationPrincipal;
1113
import org.springframework.web.bind.annotation.GetMapping;
1214
import org.springframework.web.bind.annotation.PathVariable;
15+
import org.springframework.web.bind.annotation.PostMapping;
1316
import org.springframework.web.bind.annotation.RequestMapping;
1417
import org.springframework.web.bind.annotation.RestController;
1518

@@ -19,14 +22,27 @@
1922
public class CourseController implements CourseControllerDocs {
2023

2124
private final CourseService courseService;
25+
private final CourseLikeService courseLikeService;
2226

2327
@GetMapping("/{courseId}")
2428
@Override
2529
public ResponseEntity<ApiResponse<CourseDetailResponse>> getCourseDetail(
2630
@AuthenticationPrincipal Long userId,
2731
@PathVariable Long courseId
2832
) {
29-
CourseDetailResponse response = courseService.getCourseDetail(courseId);
33+
CourseDetailResponse response = courseService.getCourseDetail(userId, courseId);
3034
return ApiResponse.success(SuccessStatus.COURSE_DETAIL_SUCCESS, response);
3135
}
36+
37+
@PostMapping("/{courseId}/like")
38+
@Override
39+
public ResponseEntity<ApiResponse<CourseLikeToggleResponse>> toggleCourseLike(
40+
@AuthenticationPrincipal Long userId,
41+
@PathVariable Long courseId
42+
) {
43+
return ApiResponse.success(
44+
SuccessStatus.COURSE_LIKE_TOGGLE_SUCCESS,
45+
courseLikeService.toggleCourseLike(userId, courseId)
46+
);
47+
}
3248
}

src/main/java/com/semosan/api/domain/mountain/controller/docs/CourseControllerDocs.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.semosan.api.common.response.ApiResponse;
44
import com.semosan.api.domain.mountain.dto.response.CourseDetailResponse;
5+
import com.semosan.api.domain.mountain.dto.response.CourseLikeToggleResponse;
56
import io.swagger.v3.oas.annotations.Operation;
67
import io.swagger.v3.oas.annotations.Parameter;
78
import io.swagger.v3.oas.annotations.media.Content;
@@ -36,4 +37,25 @@ ResponseEntity<ApiResponse<CourseDetailResponse>> getCourseDetail(
3637
@Parameter(description = "코스 ID", required = true)
3738
@PathVariable Long courseId
3839
);
40+
41+
@Operation(
42+
summary = "코스 좋아요 토글",
43+
description = "로그인한 사용자가 코스 좋아요를 누르거나 취소합니다."
44+
)
45+
@ApiResponses({
46+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
47+
responseCode = "200",
48+
description = "코스 좋아요 토글 성공"
49+
),
50+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
51+
responseCode = "404",
52+
description = "코스를 찾을 수 없음 (MTN_404_3)",
53+
content = @Content(schema = @Schema(implementation = ApiResponse.class))
54+
)
55+
})
56+
ResponseEntity<ApiResponse<CourseLikeToggleResponse>> toggleCourseLike(
57+
@AuthenticationPrincipal Long userId,
58+
@Parameter(description = "코스 ID", required = true)
59+
@PathVariable Long courseId
60+
);
3961
}

src/main/java/com/semosan/api/domain/mountain/dto/response/CourseDetailResponse.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
*/
1313
public record CourseDetailResponse(
1414
Long id,
15+
Long mountainId,
1516
String name,
1617
Difficulty difficulty,
1718
Double distance,
@@ -21,12 +22,14 @@ public record CourseDetailResponse(
2122
Double ascent,
2223
Double descent,
2324
Double maxAltitude,
25+
boolean likedByMe,
2426
@JsonRawValue String polyline,
2527
@JsonRawValue String altitudes
2628
) {
27-
public static CourseDetailResponse from(CourseDetailProjection p) {
29+
public static CourseDetailResponse from(CourseDetailProjection p, boolean likedByMe) {
2830
return new CourseDetailResponse(
2931
p.getId(),
32+
p.getMountainId(),
3033
p.getName(),
3134
p.getDifficulty() == null ? null : Difficulty.valueOf(p.getDifficulty()),
3235
p.getDistance(),
@@ -36,6 +39,7 @@ public static CourseDetailResponse from(CourseDetailProjection p) {
3639
p.getAscent(),
3740
p.getDescent(),
3841
p.getMaxAltitude(),
42+
likedByMe,
3943
p.getPolyline(),
4044
p.getAltitudes()
4145
);
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.semosan.api.domain.mountain.dto.response;
2+
3+
public record CourseLikeToggleResponse(
4+
boolean liked
5+
) {
6+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package com.semosan.api.domain.mountain.entity;
2+
3+
import com.semosan.api.common.base.BaseEntity;
4+
import com.semosan.api.domain.user.entity.User;
5+
import jakarta.persistence.Entity;
6+
import jakarta.persistence.FetchType;
7+
import jakarta.persistence.GeneratedValue;
8+
import jakarta.persistence.GenerationType;
9+
import jakarta.persistence.Id;
10+
import jakarta.persistence.Index;
11+
import jakarta.persistence.JoinColumn;
12+
import jakarta.persistence.ManyToOne;
13+
import jakarta.persistence.Table;
14+
import jakarta.persistence.UniqueConstraint;
15+
import lombok.AccessLevel;
16+
import lombok.AllArgsConstructor;
17+
import lombok.Builder;
18+
import lombok.Getter;
19+
import lombok.NoArgsConstructor;
20+
21+
@Table(
22+
name = "course_likes",
23+
uniqueConstraints = {
24+
@UniqueConstraint(
25+
name = "uk_course_likes_user_id_course_id",
26+
columnNames = {"user_id", "course_id"}
27+
)
28+
},
29+
indexes = {
30+
@Index(name = "idx_course_likes_user_id_created_at", columnList = "user_id, created_at DESC")
31+
}
32+
)
33+
@Getter
34+
@Entity
35+
@Builder(access = AccessLevel.PROTECTED)
36+
@AllArgsConstructor(access = AccessLevel.PROTECTED)
37+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
38+
public class CourseLike extends BaseEntity {
39+
40+
@Id
41+
@GeneratedValue(strategy = GenerationType.IDENTITY)
42+
private Long id;
43+
44+
@ManyToOne(fetch = FetchType.LAZY)
45+
@JoinColumn(name = "user_id", nullable = false)
46+
private User user;
47+
48+
@ManyToOne(fetch = FetchType.LAZY)
49+
@JoinColumn(name = "course_id", nullable = false)
50+
private Course course;
51+
52+
public static CourseLike create(User user, Course course) {
53+
return CourseLike.builder()
54+
.user(user)
55+
.course(course)
56+
.build();
57+
}
58+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.semosan.api.domain.mountain.repository;
2+
3+
import com.semosan.api.domain.mountain.entity.CourseLike;
4+
import org.springframework.data.jpa.repository.JpaRepository;
5+
import org.springframework.data.jpa.repository.Modifying;
6+
import org.springframework.data.jpa.repository.Query;
7+
import org.springframework.data.repository.query.Param;
8+
9+
import java.util.Optional;
10+
11+
public interface CourseLikeRepository extends JpaRepository<CourseLike, Long> {
12+
13+
boolean existsByUser_IdAndCourse_Id(Long userId, Long courseId);
14+
15+
Optional<CourseLike> findByUser_IdAndCourse_Id(Long userId, Long courseId);
16+
17+
@Modifying(clearAutomatically = true)
18+
@Query("DELETE FROM CourseLike cl WHERE cl.user.id = :userId")
19+
void deleteByUser_Id(@Param("userId") Long userId);
20+
}

src/main/java/com/semosan/api/domain/mountain/repository/CourseRepository.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ public interface CourseRepository extends JpaRepository<Course, Long> {
2929
value = """
3030
SELECT
3131
c.id AS id,
32+
c.mountain_id AS mountainId,
3233
c.name AS name,
3334
c.difficulty AS difficulty,
3435
c.distance AS distance,

src/main/java/com/semosan/api/domain/mountain/repository/projection/CourseDetailProjection.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88
public interface CourseDetailProjection {
99
Long getId();
10+
Long getMountainId();
1011
String getName();
1112
String getDifficulty();
1213
Double getDistance();
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package com.semosan.api.domain.mountain.service;
2+
3+
import com.semosan.api.common.exception.GeneralException;
4+
import com.semosan.api.common.status.ErrorStatus;
5+
import com.semosan.api.domain.mountain.dto.response.CourseLikeToggleResponse;
6+
import com.semosan.api.domain.mountain.entity.Course;
7+
import com.semosan.api.domain.mountain.entity.CourseLike;
8+
import com.semosan.api.domain.mountain.repository.CourseLikeRepository;
9+
import com.semosan.api.domain.mountain.repository.CourseRepository;
10+
import com.semosan.api.domain.user.entity.User;
11+
import com.semosan.api.domain.user.service.UserReader;
12+
import lombok.RequiredArgsConstructor;
13+
import lombok.extern.slf4j.Slf4j;
14+
import org.springframework.dao.DataIntegrityViolationException;
15+
import org.springframework.stereotype.Service;
16+
import org.springframework.transaction.annotation.Transactional;
17+
18+
@Slf4j
19+
@Service
20+
@RequiredArgsConstructor
21+
public class CourseLikeService {
22+
23+
private final CourseLikeRepository courseLikeRepository;
24+
private final CourseRepository courseRepository;
25+
private final UserReader userReader;
26+
27+
@Transactional(noRollbackFor = DataIntegrityViolationException.class)
28+
public CourseLikeToggleResponse toggleCourseLike(Long userId, Long courseId) {
29+
boolean liked = toggle(userId, courseId);
30+
return new CourseLikeToggleResponse(liked);
31+
}
32+
33+
private boolean toggle(Long userId, Long courseId) {
34+
User user = userReader.findCompletedOnboardingUserById(userId);
35+
Course course = findCourseById(courseId);
36+
37+
return courseLikeRepository.findByUser_IdAndCourse_Id(userId, courseId)
38+
.map(existing -> {
39+
courseLikeRepository.delete(existing);
40+
return false;
41+
})
42+
.orElseGet(() -> createCourseLike(user, course, userId, courseId));
43+
}
44+
45+
private boolean createCourseLike(User user, Course course, Long userId, Long courseId) {
46+
try {
47+
courseLikeRepository.save(CourseLike.create(user, course));
48+
return true;
49+
} catch (DataIntegrityViolationException e) {
50+
log.warn("CourseLike 동시 요청 감지: courseId={}, userId={}", courseId, userId);
51+
return true;
52+
}
53+
}
54+
55+
private Course findCourseById(Long courseId) {
56+
return courseRepository.findById(courseId)
57+
.orElseThrow(() -> new GeneralException(ErrorStatus.COURSE_NOT_FOUND));
58+
}
59+
}

0 commit comments

Comments
 (0)