Skip to content

Commit cf3cd84

Browse files
authored
Fix: [Course] 내 코스 수정 및 삭제 (#67)
* Fix: [Course] 내 코스 수정 및 삭제 * Fix: [Course] 내 코스 수정 및 삭제 api 명세 작성
1 parent 4b215da commit cf3cd84

9 files changed

Lines changed: 251 additions & 3 deletions

File tree

runtracker/src/main/java/com/runtracker/domain/course/controller/CourseController.java

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

33
import com.runtracker.domain.course.dto.CourseDetailDTO;
44
import com.runtracker.domain.course.dto.CourseCreateDTO;
5+
import com.runtracker.domain.course.dto.CourseUpdateDTO;
56
import com.runtracker.domain.course.dto.NearbyCoursesDTO.Response;
67
import com.runtracker.domain.course.dto.FinishRunning;
78
import com.runtracker.domain.course.service.CourseService;
@@ -90,4 +91,21 @@ public ApiResponse<List<CourseDetailDTO>> getRecommendedCoursesBySetting(
9091
userDetails.getMemberId(), latitude, longitude);
9192
return ApiResponse.ok(recommendedCourses);
9293
}
94+
95+
@PatchMapping("/{courseId}")
96+
public ApiResponse<Void> updateCourse(
97+
@AuthenticationPrincipal UserDetailsImpl userDetails,
98+
@PathVariable Long courseId,
99+
@RequestBody CourseUpdateDTO courseUpdateDTO) {
100+
courseService.updateCourse(userDetails.getMemberId(), courseId, courseUpdateDTO);
101+
return ApiResponse.ok();
102+
}
103+
104+
@DeleteMapping("/{courseId}")
105+
public ApiResponse<Void> deleteCourse(
106+
@AuthenticationPrincipal UserDetailsImpl userDetails,
107+
@PathVariable Long courseId) {
108+
courseService.deleteCourse(userDetails.getMemberId(), courseId);
109+
return ApiResponse.ok();
110+
}
93111
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.runtracker.domain.course.dto;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Builder;
5+
import lombok.Getter;
6+
import lombok.NoArgsConstructor;
7+
8+
@Getter
9+
@Builder
10+
@NoArgsConstructor
11+
@AllArgsConstructor
12+
public class CourseUpdateDTO {
13+
private String name;
14+
private String difficulty;
15+
}

runtracker/src/main/java/com/runtracker/domain/course/entity/Course.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,13 @@ public class Course extends BaseEntity {
4949

5050
@Column(length = 100)
5151
private String region;
52+
53+
public void updateCourse(String name, Difficulty difficulty) {
54+
if (name != null) {
55+
this.name = name;
56+
}
57+
if (difficulty != null) {
58+
this.difficulty = difficulty;
59+
}
60+
}
5261
}

runtracker/src/main/java/com/runtracker/domain/course/enums/CourseErrorCode.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ public enum CourseErrorCode implements ResponseCode {
2222
GOOGLE_MAPS_API_ERROR("CR012", "Google Maps API error"),
2323
INSUFFICIENT_PATH_DATA("CR013", "Insufficient path data for route analysis"),
2424
RECOMMENDATION_SERVICE_UNAVAILABLE("CR014", "Recommendation service is unavailable"),
25-
NO_RECOMMENDED_COURSES("CR015", "No recommended courses found");
25+
NO_RECOMMENDED_COURSES("CR015", "No recommended courses found"),
26+
COURSE_UPDATE_FORBIDDEN("CR016", "You do not have permission to update this course"),
27+
COURSE_DELETE_FORBIDDEN("CR017", "You do not have permission to delete this course");
2628

2729
private final String statusCode;
2830
private final String message;
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.runtracker.domain.course.exception;
2+
3+
import com.runtracker.domain.course.enums.CourseErrorCode;
4+
import com.runtracker.global.exception.CustomException;
5+
6+
public class CourseDeleteForbiddenException extends CustomException {
7+
public CourseDeleteForbiddenException() {
8+
super(CourseErrorCode.COURSE_DELETE_FORBIDDEN);
9+
}
10+
11+
public CourseDeleteForbiddenException(String message) {
12+
super(CourseErrorCode.COURSE_DELETE_FORBIDDEN, message);
13+
}
14+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.runtracker.domain.course.exception;
2+
3+
import com.runtracker.domain.course.enums.CourseErrorCode;
4+
import com.runtracker.global.exception.CustomException;
5+
6+
public class CourseUpdateForbiddenException extends CustomException {
7+
public CourseUpdateForbiddenException() {
8+
super(CourseErrorCode.COURSE_UPDATE_FORBIDDEN);
9+
}
10+
11+
public CourseUpdateForbiddenException(String message) {
12+
super(CourseErrorCode.COURSE_UPDATE_FORBIDDEN, message);
13+
}
14+
}

runtracker/src/main/java/com/runtracker/domain/course/service/CourseCacheService.java

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,4 +224,47 @@ private void evictOldestCourseDetailSlot() {
224224
log.warn("Failed to evict oldest course detail slot: {}", e.getMessage());
225225
}
226226
}
227+
228+
/**
229+
* 특정 코스 상세 정보 캐시 삭제
230+
*/
231+
public void evictCourseDetail(Long courseId) {
232+
try {
233+
redisTemplate.opsForHash().delete(COURSE_DETAIL_HASH, courseId.toString());
234+
} catch (Exception e) {
235+
log.warn("Failed to evict course detail cache for courseId: {}, error: {}", courseId, e.getMessage());
236+
}
237+
}
238+
239+
/**
240+
* 슬롯에서 특정 코스 ID 제거
241+
*/
242+
public void removeCourseFromSlot(Double latitude, Double longitude, Long courseId) {
243+
try {
244+
String groupKey = generateLocationKey(latitude, longitude);
245+
SlotData slot = (SlotData) redisTemplate.opsForHash().get(NEARBY_SLOTS_HASH, groupKey);
246+
247+
if (slot != null) {
248+
slot.getCourseIds().remove(courseId);
249+
redisTemplate.opsForHash().put(NEARBY_SLOTS_HASH, groupKey, slot);
250+
}
251+
} catch (Exception e) {
252+
log.warn("Failed to remove course from slot for lat: {}, lng: {}, courseId: {}, error: {}",
253+
latitude, longitude, courseId, e.getMessage());
254+
}
255+
}
256+
257+
/**
258+
* 코스 상세 정보 업데이트
259+
*/
260+
public void updateCourseDetail(Long courseId, CourseDetailDTO detail) {
261+
try {
262+
Object existing = redisTemplate.opsForHash().get(COURSE_DETAIL_HASH, courseId.toString());
263+
if (existing != null) {
264+
redisTemplate.opsForHash().put(COURSE_DETAIL_HASH, courseId.toString(), detail);
265+
}
266+
} catch (Exception e) {
267+
log.warn("Failed to update course detail cache for courseId: {}, error: {}", courseId, e.getMessage());
268+
}
269+
}
227270
}

runtracker/src/main/java/com/runtracker/domain/course/service/CourseService.java

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.fasterxml.jackson.databind.ObjectMapper;
55
import com.runtracker.domain.course.dto.CourseDetailDTO;
66
import com.runtracker.domain.course.dto.CourseCreateDTO;
7+
import com.runtracker.domain.course.dto.CourseUpdateDTO;
78
import com.runtracker.domain.course.service.dto.GoogleMapsDTO;
89
import com.runtracker.domain.course.dto.NearbyCoursesDTO.Response;
910
import com.runtracker.domain.course.dto.FinishRunning;
@@ -13,6 +14,8 @@
1314
import com.runtracker.domain.course.exception.AlreadyRunningException;
1415
import com.runtracker.domain.course.exception.CourseCreationFailedException;
1516
import com.runtracker.domain.course.exception.CourseNotFoundException;
17+
import com.runtracker.domain.course.exception.CourseUpdateForbiddenException;
18+
import com.runtracker.domain.course.exception.CourseDeleteForbiddenException;
1619
import com.runtracker.domain.course.exception.InsufficientPathDataException;
1720
import com.runtracker.domain.course.exception.InvalidStartTimeException;
1821
import com.runtracker.domain.course.exception.MultipleActiveRunningException;
@@ -563,4 +566,46 @@ private List<CourseDetailDTO> getRecommendedCourseDetails(List<Long> recommended
563566
.map(this::convertToCourseDetailDTO)
564567
.toList();
565568
}
569+
570+
@Transactional
571+
public void updateCourse(Long memberId, Long courseId, CourseUpdateDTO courseUpdateDTO) {
572+
Course course = courseRepository.findById(courseId)
573+
.orElseThrow(() -> new CourseNotFoundException("Course not found with id: " + courseId));
574+
575+
if (!course.getMemberId().equals(memberId)) {
576+
throw new CourseUpdateForbiddenException("You do not have permission to update this course");
577+
}
578+
579+
Difficulty difficulty = null;
580+
if (courseUpdateDTO.getDifficulty() != null && !courseUpdateDTO.getDifficulty().trim().isEmpty()) {
581+
try {
582+
difficulty = Difficulty.valueOf(courseUpdateDTO.getDifficulty().toUpperCase());
583+
} catch (IllegalArgumentException e) {
584+
throw new ValidationErrorException("Invalid difficulty value: " + courseUpdateDTO.getDifficulty());
585+
}
586+
}
587+
588+
course.updateCourse(courseUpdateDTO.getName(), difficulty);
589+
Course updatedCourse = courseRepository.save(course);
590+
591+
CourseDetailDTO updatedDetail = convertToCourseDetailDTO(updatedCourse);
592+
courseCacheService.updateCourseDetail(courseId, updatedDetail);
593+
}
594+
595+
@Transactional
596+
public void deleteCourse(Long memberId, Long courseId) {
597+
Course course = courseRepository.findById(courseId)
598+
.orElseThrow(() -> new CourseNotFoundException("Course not found with id: " + courseId));
599+
600+
if (!course.getMemberId().equals(memberId)) {
601+
throw new CourseDeleteForbiddenException("You do not have permission to delete this course");
602+
}
603+
604+
courseRepository.delete(course);
605+
606+
courseCacheService.evictCourseDetail(courseId);
607+
if (course.getStartLat() != null && course.getStartLng() != null) {
608+
courseCacheService.removeCourseFromSlot(course.getStartLat(), course.getStartLng(), courseId);
609+
}
610+
}
566611
}

runtracker/src/test/java/com/runtracker/domain/course/controller/CourseControllerTest.java

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.epages.restdocs.apispec.ResourceSnippetParameters;
44
import com.runtracker.RunTrackerDocumentApiTester;
55
import com.runtracker.domain.course.dto.CourseCreateDTO;
6+
import com.runtracker.domain.course.dto.CourseUpdateDTO;
67
import com.runtracker.domain.course.dto.CourseDetailDTO;
78
import com.runtracker.domain.course.dto.NearbyCoursesDTO;
89
import com.runtracker.domain.course.enums.Difficulty;
@@ -30,8 +31,7 @@
3031
import static org.mockito.BDDMockito.given;
3132
import static org.mockito.Mockito.doNothing;
3233
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
33-
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get;
34-
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post;
34+
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*;
3535
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
3636
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
3737

@@ -689,4 +689,92 @@ void getRecommendedCoursesBySettingTest() throws Exception {
689689
));
690690
}
691691

692+
@Test
693+
void updateCourseTest() throws Exception {
694+
// given
695+
doNothing().when(courseService).updateCourse(anyLong(), anyLong(), any(CourseUpdateDTO.class));
696+
given(jwtUtil.getMemberIdFromToken(anyString())).willReturn(1L);
697+
given(jwtUtil.getSocialIdFromToken(anyString())).willReturn("kakao_123");
698+
699+
UserDetailsImpl mockUserDetails = UserDetailsImpl.builder()
700+
.memberId(1L)
701+
.socialId("kakao_123")
702+
.roles(List.of(MemberRole.USER))
703+
.build();
704+
given(userDetailsService.loadUserByUsername("1")).willReturn(mockUserDetails);
705+
706+
// when
707+
Map<String, Object> updateRequest = new LinkedHashMap<>();
708+
updateRequest.put("name", "수정된 코스 이름");
709+
updateRequest.put("difficulty", "HARD");
710+
711+
this.mockMvc.perform(patch("/api/courses/{courseId}", 1L)
712+
.header(AUTH_HEADER, TEST_ACCESS_TOKEN)
713+
.contentType("application/json")
714+
.content(toJson(updateRequest)))
715+
.andExpect(status().isOk())
716+
.andDo(document("course-update",
717+
resource(
718+
ResourceSnippetParameters.builder()
719+
.tag("courses")
720+
.description("내가 만든 코스 수정")
721+
.requestHeaders(
722+
headerWithName("Authorization").description("엑세스 토큰")
723+
)
724+
.pathParameters(
725+
parameterWithName("courseId").description("수정할 코스 ID")
726+
)
727+
.requestFields(
728+
fieldWithPath("name").type(JsonFieldType.STRING).description("수정할 코스 이름").optional(),
729+
fieldWithPath("difficulty").type(JsonFieldType.STRING).description("수정할 난이도 (EASY, MEDIUM, HARD)").optional()
730+
)
731+
.responseFields(
732+
fieldWithPath("status.statusCode").type(JsonFieldType.STRING).description("상태 코드"),
733+
fieldWithPath("status.message").type(JsonFieldType.STRING).description("상태 메시지"),
734+
fieldWithPath("status.description").type(JsonFieldType.STRING).description("상태 설명").optional()
735+
)
736+
.build()
737+
)
738+
));
739+
}
740+
741+
@Test
742+
void deleteCourseTest() throws Exception {
743+
// given
744+
doNothing().when(courseService).deleteCourse(anyLong(), anyLong());
745+
given(jwtUtil.getMemberIdFromToken(anyString())).willReturn(1L);
746+
given(jwtUtil.getSocialIdFromToken(anyString())).willReturn("kakao_123");
747+
748+
UserDetailsImpl mockUserDetails = UserDetailsImpl.builder()
749+
.memberId(1L)
750+
.socialId("kakao_123")
751+
.roles(List.of(MemberRole.USER))
752+
.build();
753+
given(userDetailsService.loadUserByUsername("1")).willReturn(mockUserDetails);
754+
755+
// when
756+
this.mockMvc.perform(delete("/api/courses/{courseId}", 1L)
757+
.header(AUTH_HEADER, TEST_ACCESS_TOKEN))
758+
.andExpect(status().isOk())
759+
.andDo(document("course-delete",
760+
resource(
761+
ResourceSnippetParameters.builder()
762+
.tag("courses")
763+
.description("내가 만든 코스 삭제")
764+
.requestHeaders(
765+
headerWithName("Authorization").description("엑세스 토큰")
766+
)
767+
.pathParameters(
768+
parameterWithName("courseId").description("삭제할 코스 ID")
769+
)
770+
.responseFields(
771+
fieldWithPath("status.statusCode").type(JsonFieldType.STRING).description("상태 코드"),
772+
fieldWithPath("status.message").type(JsonFieldType.STRING).description("상태 메시지"),
773+
fieldWithPath("status.description").type(JsonFieldType.STRING).description("상태 설명").optional()
774+
)
775+
.build()
776+
)
777+
));
778+
}
779+
692780
}

0 commit comments

Comments
 (0)