Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public enum SuccessStatus implements BaseStatus {
MOUNTAIN_MAP_SUCCESS(HttpStatus.OK, "MTN_200_7", "지도 영역 내 산 조회에 성공했습니다."),
MOUNTAIN_RECOMMENDATION_SUCCESS(HttpStatus.OK, "MTN_200_8", "레벨 맞춤 산 추천 조회에 성공했습니다."),
COURSE_DETAIL_SUCCESS(HttpStatus.OK, "MTN_200_9", "코스 상세 정보 조회에 성공했습니다."),
COURSE_LIKE_TOGGLE_SUCCESS(HttpStatus.OK, "MTN_200_10", "코스 좋아요 처리에 성공했습니다."),

/**
* Hiking Record
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@
import com.semosan.api.common.status.SuccessStatus;
import com.semosan.api.domain.mountain.controller.docs.CourseControllerDocs;
import com.semosan.api.domain.mountain.dto.response.CourseDetailResponse;
import com.semosan.api.domain.mountain.dto.response.CourseLikeToggleResponse;
import com.semosan.api.domain.mountain.service.CourseLikeService;
import com.semosan.api.domain.mountain.service.CourseService;
import lombok.RequiredArgsConstructor;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

Expand All @@ -19,14 +22,27 @@
public class CourseController implements CourseControllerDocs {

private final CourseService courseService;
private final CourseLikeService courseLikeService;

@GetMapping("/{courseId}")
@Override
public ResponseEntity<ApiResponse<CourseDetailResponse>> getCourseDetail(
@AuthenticationPrincipal Long userId,
@PathVariable Long courseId
) {
CourseDetailResponse response = courseService.getCourseDetail(courseId);
CourseDetailResponse response = courseService.getCourseDetail(userId, courseId);
return ApiResponse.success(SuccessStatus.COURSE_DETAIL_SUCCESS, response);
}

@PostMapping("/{courseId}/like")
@Override
public ResponseEntity<ApiResponse<CourseLikeToggleResponse>> toggleCourseLike(
@AuthenticationPrincipal Long userId,
@PathVariable Long courseId
) {
return ApiResponse.success(
SuccessStatus.COURSE_LIKE_TOGGLE_SUCCESS,
courseLikeService.toggleCourseLike(userId, courseId)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.semosan.api.common.response.ApiResponse;
import com.semosan.api.domain.mountain.dto.response.CourseDetailResponse;
import com.semosan.api.domain.mountain.dto.response.CourseLikeToggleResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
Expand Down Expand Up @@ -36,4 +37,25 @@ ResponseEntity<ApiResponse<CourseDetailResponse>> getCourseDetail(
@Parameter(description = "코스 ID", required = true)
@PathVariable Long courseId
);

@Operation(
summary = "코스 좋아요 토글",
description = "로그인한 사용자가 코스 좋아요를 누르거나 취소합니다."
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "코스 좋아요 토글 성공"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "코스를 찾을 수 없음 (MTN_404_3)",
content = @Content(schema = @Schema(implementation = ApiResponse.class))
)
})
ResponseEntity<ApiResponse<CourseLikeToggleResponse>> toggleCourseLike(
@AuthenticationPrincipal Long userId,
@Parameter(description = "코스 ID", required = true)
@PathVariable Long courseId
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
*/
public record CourseDetailResponse(
Long id,
Long mountainId,
String name,
Difficulty difficulty,
Double distance,
Expand All @@ -21,12 +22,14 @@ public record CourseDetailResponse(
Double ascent,
Double descent,
Double maxAltitude,
boolean likedByMe,
@JsonRawValue String polyline,
@JsonRawValue String altitudes
) {
public static CourseDetailResponse from(CourseDetailProjection p) {
public static CourseDetailResponse from(CourseDetailProjection p, boolean likedByMe) {
return new CourseDetailResponse(
p.getId(),
p.getMountainId(),
p.getName(),
p.getDifficulty() == null ? null : Difficulty.valueOf(p.getDifficulty()),
p.getDistance(),
Expand All @@ -36,6 +39,7 @@ public static CourseDetailResponse from(CourseDetailProjection p) {
p.getAscent(),
p.getDescent(),
p.getMaxAltitude(),
likedByMe,
p.getPolyline(),
p.getAltitudes()
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.semosan.api.domain.mountain.dto.response;

public record CourseLikeToggleResponse(
boolean liked
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.semosan.api.domain.mountain.entity;

import com.semosan.api.common.base.BaseEntity;
import com.semosan.api.domain.user.entity.User;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Table(
name = "course_likes",
uniqueConstraints = {
@UniqueConstraint(
name = "uk_course_likes_user_id_course_id",
columnNames = {"user_id", "course_id"}
)
},
indexes = {
@Index(name = "idx_course_likes_user_id_created_at", columnList = "user_id, created_at DESC")
}
)
@Getter
@Entity
@Builder(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class CourseLike extends BaseEntity {

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

@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;

public static CourseLike create(User user, Course course) {
return CourseLike.builder()
.user(user)
.course(course)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.semosan.api.domain.mountain.repository;

import com.semosan.api.domain.mountain.entity.CourseLike;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.Optional;

public interface CourseLikeRepository extends JpaRepository<CourseLike, Long> {

boolean existsByUser_IdAndCourse_Id(Long userId, Long courseId);

Optional<CourseLike> findByUser_IdAndCourse_Id(Long userId, Long courseId);

@Modifying(clearAutomatically = true)
@Query("DELETE FROM CourseLike cl WHERE cl.user.id = :userId")
void deleteByUser_Id(@Param("userId") Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public interface CourseRepository extends JpaRepository<Course, Long> {
value = """
SELECT
c.id AS id,
c.mountain_id AS mountainId,
c.name AS name,
c.difficulty AS difficulty,
c.distance AS distance,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/
public interface CourseDetailProjection {
Long getId();
Long getMountainId();
String getName();
String getDifficulty();
Double getDistance();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.semosan.api.domain.mountain.service;

import com.semosan.api.common.exception.GeneralException;
import com.semosan.api.common.status.ErrorStatus;
import com.semosan.api.domain.mountain.dto.response.CourseLikeToggleResponse;
import com.semosan.api.domain.mountain.entity.Course;
import com.semosan.api.domain.mountain.entity.CourseLike;
import com.semosan.api.domain.mountain.repository.CourseLikeRepository;
import com.semosan.api.domain.mountain.repository.CourseRepository;
import com.semosan.api.domain.user.entity.User;
import com.semosan.api.domain.user.service.UserReader;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
@RequiredArgsConstructor
public class CourseLikeService {

private final CourseLikeRepository courseLikeRepository;
private final CourseRepository courseRepository;
private final UserReader userReader;

@Transactional(noRollbackFor = DataIntegrityViolationException.class)
public CourseLikeToggleResponse toggleCourseLike(Long userId, Long courseId) {
boolean liked = toggle(userId, courseId);
return new CourseLikeToggleResponse(liked);
}

private boolean toggle(Long userId, Long courseId) {
User user = userReader.findCompletedOnboardingUserById(userId);
Course course = findCourseById(courseId);

return courseLikeRepository.findByUser_IdAndCourse_Id(userId, courseId)
.map(existing -> {
courseLikeRepository.delete(existing);
return false;
})
.orElseGet(() -> createCourseLike(user, course, userId, courseId));
}

private boolean createCourseLike(User user, Course course, Long userId, Long courseId) {
try {
courseLikeRepository.save(CourseLike.create(user, course));
return true;
} catch (DataIntegrityViolationException e) {
log.warn("CourseLike 동시 요청 감지: courseId={}, userId={}", courseId, userId);
return true;
}
}
Comment thread
pooreumjung marked this conversation as resolved.

private Course findCourseById(Long courseId) {
return courseRepository.findById(courseId)
.orElseThrow(() -> new GeneralException(ErrorStatus.COURSE_NOT_FOUND));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
import com.semosan.api.common.exception.GeneralException;
import com.semosan.api.common.status.ErrorStatus;
import com.semosan.api.domain.mountain.dto.response.CourseDetailResponse;
import com.semosan.api.domain.mountain.repository.CourseLikeRepository;
import com.semosan.api.domain.mountain.repository.CourseRepository;
import com.semosan.api.domain.mountain.repository.projection.CourseDetailProjection;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -14,10 +16,12 @@
public class CourseService {

private final CourseRepository courseRepository;
private final CourseLikeRepository courseLikeRepository;

public CourseDetailResponse getCourseDetail(Long courseId) {
return courseRepository.findCourseDetailById(courseId)
.map(CourseDetailResponse::from)
public CourseDetailResponse getCourseDetail(Long userId, Long courseId) {
CourseDetailProjection course = courseRepository.findCourseDetailById(courseId)
.orElseThrow(() -> new GeneralException(ErrorStatus.COURSE_NOT_FOUND));
boolean likedByMe = userId != null && courseLikeRepository.existsByUser_IdAndCourse_Id(userId, courseId);
return CourseDetailResponse.from(course, likedByMe);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.semosan.api.common.status.ErrorStatus;
import com.semosan.api.domain.hiking.repository.HikingMemberRepository;
import com.semosan.api.domain.hiking.repository.HikingRecordRepository;
import com.semosan.api.domain.mountain.repository.CourseLikeRepository;
import com.semosan.api.domain.mountain.repository.MountainLikeRepository;
import com.semosan.api.domain.notification.repository.NotificationRepository;
import com.semosan.api.domain.review.repository.ReviewRepository;
Expand Down Expand Up @@ -33,6 +34,7 @@ public class UserService {
private final UserNotificationSettingRepository userNotificationSettingRepository;
private final UserOnboardingRepository userOnboardingRepository;
private final MountainLikeRepository mountainLikeRepository;
private final CourseLikeRepository courseLikeRepository;
private final ReviewRepository reviewRepository;
private final HikingMemberRepository hikingMemberRepository;
private final HikingRecordRepository hikingRecordRepository;
Expand Down Expand Up @@ -143,6 +145,7 @@ public void withdrawUser(User user) {

private void deleteUserChildRecords(Long userId) {
mountainLikeRepository.deleteByUser_Id(userId);
courseLikeRepository.deleteByUser_Id(userId);
reviewRepository.deleteByUser_Id(userId);
List<Long> recordIdsToDelete = hikingRecordRepository.findRecordIdsOnlyParticipatedByUser(userId);
hikingMemberRepository.deleteByUser_Id(userId);
Expand Down
18 changes: 18 additions & 0 deletions src/main/resources/db/migration/V26__create_course_likes.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS course_likes
(
id bigint generated by default as identity
primary key,
created_at timestamp(6) not null,
updated_at timestamp(6) not null,
user_id bigint not null
constraint fk_course_likes_user
references users,
course_id bigint not null
constraint fk_course_likes_course
references courses,
constraint uk_course_likes_user_id_course_id
unique (user_id, course_id)
);

CREATE INDEX IF NOT EXISTS idx_course_likes_user_id_created_at
on course_likes (user_id asc, created_at desc);
Loading