diff --git a/src/main/java/com/yeogido/backend/domain/course/controller/CourseController.java b/src/main/java/com/yeogido/backend/domain/course/controller/CourseController.java index c027ded..3398a81 100644 --- a/src/main/java/com/yeogido/backend/domain/course/controller/CourseController.java +++ b/src/main/java/com/yeogido/backend/domain/course/controller/CourseController.java @@ -46,26 +46,46 @@ public class CourseController { ) @Operation(summary = "추천 코스 등록", description = "추천 코스를 등록합니다.") @PostMapping - public ApiResponse createCourse( + public ApiResponse createCourse( @Valid @RequestBody CourseReqDTO.CourseCreateReq request ) { - CourseResDTO.CourseCreateRes response = courseService.createCourse(request); + CourseResDTO.CourseIdRes response = courseService.createCourse(request); return ApiResponse.onSuccess(SuccessCode.CREATED, response); } + @io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + content = @Content( + examples = @ExampleObject( + name = "추천 코스 수정 예시", + value = SwaggerExamples.COURSE_UPDATE + ) + ) + ) @Operation(summary = "추천 코스 수정", description = "추천 코스를 수정합니다.") @PatchMapping("/{courseId}") - public ApiResponse updateCourse( + public ApiResponse updateCourse( @Parameter(description = "코스 ID", example = "15") @PathVariable Long courseId, - @Valid @RequestBody CourseReqDTO.CourseCreateReq request + @Valid @RequestBody CourseReqDTO.CourseUpdateReq request ) { - CourseResDTO.CourseCreateRes response = courseService.updateCourse(courseId, request); + CourseResDTO.CourseIdRes response = courseService.updateCourse(courseId, request); return ApiResponse.onSuccess(SuccessCode.OK, response); } + @Operation(summary = "추천 코스 삭제", description = "추천 코스를 삭제합니다.") + @DeleteMapping("/{courseId}") + public ApiResponse deleteCourse( + @Parameter(description = "코스 ID", example = "15") + @PathVariable Long courseId + ) { + courseService.deleteCourse(courseId); + + return ApiResponse.onSuccess(SuccessCode.OK); + } + @Operation(summary = "추천 코스 목록 조회", description = "추천 코스 목록을 조회합니다.") @GetMapping public ApiResponse> getCourses( diff --git a/src/main/java/com/yeogido/backend/domain/course/converter/CourseConverter.java b/src/main/java/com/yeogido/backend/domain/course/converter/CourseConverter.java index 944ec75..c864c8f 100644 --- a/src/main/java/com/yeogido/backend/domain/course/converter/CourseConverter.java +++ b/src/main/java/com/yeogido/backend/domain/course/converter/CourseConverter.java @@ -29,6 +29,8 @@ public static Course toCourse(CourseReqDTO.CourseCreateReq request, User user, R .durationType(request.durationType()) .transportType(request.transportType()) .companionType(request.companionType()) + .monthStart(request.monthStart()) + .monthEnd(request.monthEnd()) .thumbnailKey(request.thumbnailKey()) .build(); } diff --git a/src/main/java/com/yeogido/backend/domain/course/dto/request/CourseReqDTO.java b/src/main/java/com/yeogido/backend/domain/course/dto/request/CourseReqDTO.java index fff4604..5caeefb 100644 --- a/src/main/java/com/yeogido/backend/domain/course/dto/request/CourseReqDTO.java +++ b/src/main/java/com/yeogido/backend/domain/course/dto/request/CourseReqDTO.java @@ -10,6 +10,8 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.DecimalMax; import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; @@ -47,6 +49,18 @@ public record CourseCreateReq( @Schema(description = "동행 유형", example = "FRIEND") CompanionType companionType, + @NotNull(message = "여행 시작 월은 필수입니다") + @Min(value = 1, message = "여행 시작 월은 1 이상이어야 합니다") + @Max(value = 12, message = "여행 시작 월은 12 이하이어야 합니다") + @Schema(description = "여행 시작 월", example = "4") + Integer monthStart, + + @NotNull(message = "여행 종료 월은 필수입니다") + @Min(value = 1, message = "여행 종료 월은 1 이상이어야 합니다") + @Max(value = 12, message = "여행 종료 월은 12 이하이어야 합니다") + @Schema(description = "여행 종료 월", example = "10") + Integer monthEnd, + @Schema(description = "대표 이미지 S3 key", example = "courses/thumbnail/abcd1234.jpg") @NotBlank(message = "대표 이미지는 필수입니다") String thumbnailKey, @@ -63,7 +77,50 @@ public record CourseCreateReq( @Valid @NotEmpty(message = "코스 구성 항목은 최소 1개 이상 필요합니다.") @Schema(description = "코스 구성 항목") - List courseItems + List<@NotNull(message = "코스 구성 항목이 올바르지 않습니다.") CourseItemCreateReq> courseItems + ) { } + + @Schema(name = "CourseUpdateRequest", description = "추천 코스 수정 요청") + public record CourseUpdateReq( + + @Schema(description = "코스 제목", example = "부산 야경 여행") + String title, + + @Schema(description = "코스 설명", example = "부산의 야경과 축제를 함께 즐길 수 있는 코스입니다.") + String description, + + @Schema(description = "여행 기간", example = "DAY_TRIP") + DurationType durationType, + + @Schema(description = "이동 수단", example = "CAR") + TransportType transportType, + + @Schema(description = "동행 유형", example = "FRIEND") + CompanionType companionType, + + @Min(value = 1, message = "여행 시작 월은 1 이상이어야 합니다") + @Max(value = 12, message = "여행 시작 월은 12 이하이어야 합니다") + @Schema(description = "여행 시작 월", example = "5") + Integer monthStart, + + @Min(value = 1, message = "여행 종료 월은 1 이상이어야 합니다") + @Max(value = 12, message = "여행 종료 월은 12 이하이어야 합니다") + @Schema(description = "여행 종료 월", example = "9") + Integer monthEnd, + + @Schema(description = "대표 이미지 S3 key", example = "courses/thumbnail/abcd1234.jpg") + String thumbnailKey, + + @Schema(description = "해시태그 ID 목록", example = "[1, 3, 5]") + @Size( + max = 5, + message = "해시태그는 5개 이하로 선택할 수 있습니다." + ) + List<@NotNull(message = "유효하지 않은 해시태그입니다.") Long> hashtagIds, + + @Valid + @Schema(description = "코스 구성 항목") + List<@NotNull(message = "코스 구성 항목이 올바르지 않습니다.") CourseItemCreateReq> courseItems ) { } @Schema(name = "CourseItemCreateRequest", description = "추천 코스 구성 항목 등록 요청") @@ -74,12 +131,9 @@ public record CourseItemCreateReq( Integer order, @NotNull(message = "코스 항목 타입은 필수입니다") - @Schema(description = "코스 항목 타입. PLACE인 경우 placeId와 imageKey를 사용하고, CONTENT인 경우 contentId를 사용합니다.", example = "PLACE") + @Schema(description = "PLACE인 경우 externalPlaceId, categoryGroupCode, name, roadAddress(또는 lotAddress), latitude, longitude, imageKey를 사용하고, CONTENT인 경우 contentId를 사용합니다.", example = "PLACE") CourseItemType type, - @Schema(description = "장소 ID. type=PLACE인 경우 사용합니다.", example = "1") - Long placeId, - @Schema(description = "콘텐츠 ID. type=CONTENT인 경우 사용합니다.", example = "10") Long contentId, diff --git a/src/main/java/com/yeogido/backend/domain/course/dto/response/CourseResDTO.java b/src/main/java/com/yeogido/backend/domain/course/dto/response/CourseResDTO.java index 177d549..3b39efa 100644 --- a/src/main/java/com/yeogido/backend/domain/course/dto/response/CourseResDTO.java +++ b/src/main/java/com/yeogido/backend/domain/course/dto/response/CourseResDTO.java @@ -13,8 +13,8 @@ public class CourseResDTO { - @Schema(name = "CourseCreateResponse", description = "추천 코스 등록 응답") - public record CourseCreateRes( + @Schema(name = "CourseIdResponse", description = "추천 코스 ID 응답") + public record CourseIdRes( @Schema(description = "코스 ID", example = "15") Long courseId diff --git a/src/main/java/com/yeogido/backend/domain/course/entity/Course.java b/src/main/java/com/yeogido/backend/domain/course/entity/Course.java index fedb9e0..526b1ea 100644 --- a/src/main/java/com/yeogido/backend/domain/course/entity/Course.java +++ b/src/main/java/com/yeogido/backend/domain/course/entity/Course.java @@ -76,4 +76,44 @@ public class Course extends BaseEntity { @Column(name = "deleted_at") private LocalDateTime deletedAt; + + public void update( + String title, + String description, + DurationType durationType, + TransportType transportType, + CompanionType companionType, + Integer monthStart, + Integer monthEnd, + String thumbnailKey + ) { + if (title != null) { + this.title = title; + } + if (description != null) { + this.description = description; + } + if (durationType != null) { + this.durationType = durationType; + } + if (transportType != null) { + this.transportType = transportType; + } + if (companionType != null) { + this.companionType = companionType; + } + if (monthStart != null) { + this.monthStart = monthStart; + } + if (monthEnd != null) { + this.monthEnd = monthEnd; + } + if (thumbnailKey != null) { + this.thumbnailKey = thumbnailKey; + } + } + + public void delete() { + this.deletedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/yeogido/backend/domain/course/repository/CourseHashtagRepository.java b/src/main/java/com/yeogido/backend/domain/course/repository/CourseHashtagRepository.java index 535e097..372c7d0 100644 --- a/src/main/java/com/yeogido/backend/domain/course/repository/CourseHashtagRepository.java +++ b/src/main/java/com/yeogido/backend/domain/course/repository/CourseHashtagRepository.java @@ -2,6 +2,13 @@ import com.yeogido.backend.domain.course.entity.CourseHashtag; 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; public interface CourseHashtagRepository extends JpaRepository { + + @Modifying + @Query("delete from CourseHashtag ch where ch.course.id = :courseId") + void deleteAllByCourseId(@Param("courseId") Long courseId); } diff --git a/src/main/java/com/yeogido/backend/domain/course/repository/CourseItemRepository.java b/src/main/java/com/yeogido/backend/domain/course/repository/CourseItemRepository.java index ab60b8f..e5fd390 100644 --- a/src/main/java/com/yeogido/backend/domain/course/repository/CourseItemRepository.java +++ b/src/main/java/com/yeogido/backend/domain/course/repository/CourseItemRepository.java @@ -2,6 +2,13 @@ import com.yeogido.backend.domain.course.entity.CourseItem; 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; public interface CourseItemRepository extends JpaRepository { + + @Modifying + @Query("delete from CourseItem ci where ci.course.id = :courseId") + void deleteAllByCourseId(@Param("courseId") Long courseId); } diff --git a/src/main/java/com/yeogido/backend/domain/course/service/CourseService.java b/src/main/java/com/yeogido/backend/domain/course/service/CourseService.java index 39e5860..0d28179 100644 --- a/src/main/java/com/yeogido/backend/domain/course/service/CourseService.java +++ b/src/main/java/com/yeogido/backend/domain/course/service/CourseService.java @@ -8,9 +8,11 @@ public interface CourseService { - CourseResDTO.CourseCreateRes createCourse(CourseReqDTO.CourseCreateReq request); + CourseResDTO.CourseIdRes createCourse(CourseReqDTO.CourseCreateReq request); - CourseResDTO.CourseCreateRes updateCourse(Long courseId, CourseReqDTO.CourseCreateReq request); + CourseResDTO.CourseIdRes updateCourse(Long courseId, CourseReqDTO.CourseUpdateReq request); + + void deleteCourse(Long courseId); CursorResponse getCourses(CourseReqDTO.CourseListReq request); diff --git a/src/main/java/com/yeogido/backend/domain/course/service/CourseServiceImpl.java b/src/main/java/com/yeogido/backend/domain/course/service/CourseServiceImpl.java index b30b0be..65e4bfc 100644 --- a/src/main/java/com/yeogido/backend/domain/course/service/CourseServiceImpl.java +++ b/src/main/java/com/yeogido/backend/domain/course/service/CourseServiceImpl.java @@ -31,8 +31,8 @@ import com.yeogido.backend.domain.region.entity.Region; import com.yeogido.backend.domain.region.exception.RegionErrorCode; import com.yeogido.backend.domain.region.repository.RegionRepository; -import com.yeogido.backend.domain.region.service.RegionService; import com.yeogido.backend.domain.user.entity.User; +import com.yeogido.backend.domain.user.enums.UserRole; import com.yeogido.backend.domain.user.repository.UserRepository; import com.yeogido.backend.global.common.response.CursorResponse; import com.yeogido.backend.global.exception.GeneralErrorCode; @@ -69,7 +69,7 @@ public class CourseServiceImpl implements CourseService { @Override @Transactional - public CourseResDTO.CourseCreateRes createCourse(CourseReqDTO.CourseCreateReq request) { + public CourseResDTO.CourseIdRes createCourse(CourseReqDTO.CourseCreateReq request) { validateCourseCreateRequest(request); User user = getCurrentUser(); @@ -79,13 +79,49 @@ public CourseResDTO.CourseCreateRes createCourse(CourseReqDTO.CourseCreateReq re saveCourseHashtags(course, request.hashtagIds()); saveCourseItems(course, request.courseItems()); - return new CourseResDTO.CourseCreateRes(course.getId()); + return new CourseResDTO.CourseIdRes(course.getId()); } @Override - public CourseResDTO.CourseCreateRes updateCourse(Long courseId, CourseReqDTO.CourseCreateReq request) { - // TODO: 추천 코스 수정 로직 구현 - return new CourseResDTO.CourseCreateRes(15L); + @Transactional + public CourseResDTO.CourseIdRes updateCourse(Long courseId, CourseReqDTO.CourseUpdateReq request) { + Course course = getActiveCourse(courseId); + User user = getCurrentUser(); + + validateCourseAuthority(course, user); + validateCourseUpdateRequest(request); + + course.update( + request.title(), + request.description(), + request.durationType(), + request.transportType(), + request.companionType(), + request.monthStart(), + request.monthEnd(), + request.thumbnailKey() + ); + + if (request.hashtagIds() != null) { + replaceCourseHashtags(course, request.hashtagIds()); + } + + if (request.courseItems() != null) { + replaceCourseItems(course, request.courseItems()); + } + + return new CourseResDTO.CourseIdRes(course.getId()); + } + + @Override + @Transactional + public void deleteCourse(Long courseId) { + Course course = getActiveCourse(courseId); + User user = getCurrentUser(); + + validateCourseAuthority(course, user); + + course.delete(); } @Override @@ -226,11 +262,40 @@ private Region getRegion(Long regionId) { .orElseThrow(() -> new GeneralException(RegionErrorCode.REGION_NOT_FOUND)); } + private void validateCourseAuthority(Course course, User user) { + switch (course.getCourseType()) { + case OFFICIAL -> { + if (user.getRole() != UserRole.ADMIN) { + throw new GeneralException(GeneralErrorCode.FORBIDDEN); + } + } + + case LOCAL -> { + if (course.getUser() == null || + !Objects.equals(course.getUser().getId(), user.getId())) { + throw new GeneralException(GeneralErrorCode.FORBIDDEN); + } + } + + default -> throw new GeneralException(GeneralErrorCode.FORBIDDEN); + } + } + private void validateCourseCreateRequest(CourseReqDTO.CourseCreateReq request) { validateHashtags(request.hashtagIds()); validateCourseItems(request.courseItems()); } + private void validateCourseUpdateRequest(CourseReqDTO.CourseUpdateReq request) { + if (request.hashtagIds() != null) { + validateHashtags(request.hashtagIds()); + } + + if (request.courseItems() != null) { + validateCourseItems(request.courseItems()); + } + } + private void validateHashtags(List hashtagIds) { if (new HashSet<>(hashtagIds).size() != hashtagIds.size()) { throw new GeneralException(CourseErrorCode.DUPLICATE_HASHTAG); @@ -279,7 +344,6 @@ private void validatePlaceItem(CourseReqDTO.CourseItemCreateReq item) { private void validateContentItem(CourseReqDTO.CourseItemCreateReq item) { if (item.contentId() == null - || item.placeId() != null || StringUtils.hasText(item.externalPlaceId()) || StringUtils.hasText(item.categoryGroupCode()) || StringUtils.hasText(item.name()) @@ -307,6 +371,11 @@ private void saveCourseHashtags(Course course, List hashtagIds) { courseHashtagRepository.saveAll(courseHashtags); } + private void replaceCourseHashtags(Course course, List hashtagIds) { + courseHashtagRepository.deleteAllByCourseId(course.getId()); + saveCourseHashtags(course, hashtagIds); + } + private void saveCourseItems(Course course, List courseItems) { Map placeMap = placeService.getPlaceMap(courseItems); Map contentMap = getContentMap(courseItems); @@ -329,6 +398,11 @@ private void saveCourseItems(Course course, List courseItems) { + courseItemRepository.deleteAllByCourseId(course.getId()); + saveCourseItems(course, courseItems); + } + private Map getContentMap(List courseItems) { Set contentIds = courseItems.stream() .filter(item -> item.type() == CourseItemType.CONTENT) diff --git a/src/main/java/com/yeogido/backend/global/swagger/SwaggerExamples.java b/src/main/java/com/yeogido/backend/global/swagger/SwaggerExamples.java index cc04cab..422c31a 100644 --- a/src/main/java/com/yeogido/backend/global/swagger/SwaggerExamples.java +++ b/src/main/java/com/yeogido/backend/global/swagger/SwaggerExamples.java @@ -13,6 +13,8 @@ private SwaggerExamples() { "durationType": "DAY_TRIP", "transportType": "CAR", "companionType": "FRIEND", + "monthStart": 4, + "monthEnd": 10, "thumbnailKey": "courses/thumbnail/abcd1234.jpg", "hashtagIds": [1, 3], "courseItems": [ @@ -36,4 +38,46 @@ private SwaggerExamples() { ] } """; -} \ No newline at end of file + + public static final String COURSE_UPDATE = """ + { + "title": "부산 감성 여행 코스", + "description": "광안리와 해운대를 중심으로 감성 카페와 야경을 즐기는 코스입니다.", + "monthStart": 5, + "monthEnd": 9, + "thumbnailKey": "courses/thumbnail/busan-emotion.jpg", + "hashtagIds": [2, 4, 6], + "courseItems": [ + { + "order": 1, + "type": "PLACE", + "externalPlaceId": "654321", + "categoryGroupCode": "CE7", + "name": "흰여울문화마을", + "roadAddress": "부산 영도구 흰여울길 379", + "lotAddress": "부산 영도구 영선동4가", + "latitude": 35.078961, + "longitude": 129.045771, + "imageKey": "courses/place/huinnyeoul.jpg" + }, + { + "order": 2, + "type": "CONTENT", + "contentId": 8 + }, + { + "order": 3, + "type": "PLACE", + "externalPlaceId": "987654", + "categoryGroupCode": "AT4", + "name": "해운대해수욕장", + "roadAddress": "부산 해운대구 해운대해변로 264", + "lotAddress": "부산 해운대구 우동", + "latitude": 35.158698, + "longitude": 129.160384, + "imageKey": null + } + ] + } + """; +}