diff --git a/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java b/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java index 69f11e8..fa46b91 100644 --- a/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java +++ b/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java @@ -2,10 +2,12 @@ import static com.server.running_handai.global.response.ResponseCode.*; +import com.server.running_handai.domain.course.dto.CourseCreateRequestDto; import com.server.running_handai.domain.course.dto.CourseDetailDto; import com.server.running_handai.domain.course.dto.CourseFilterRequestDto; import com.server.running_handai.domain.course.dto.CourseInfoWithDetailsDto; import com.server.running_handai.domain.course.dto.CourseSummaryDto; +import com.server.running_handai.domain.course.dto.GpxCourseRequestDto; import com.server.running_handai.domain.course.service.CourseService; import com.server.running_handai.global.oauth.CustomOAuth2User; import com.server.running_handai.global.response.CommonResponse; @@ -14,21 +16,26 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.MediaType; 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.ModelAttribute; 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.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; @Slf4j @RestController -@RequestMapping("/api/courses") @RequiredArgsConstructor @Tag(name = "Course", description = "코스 관련 API") public class CourseController { @@ -40,7 +47,7 @@ public class CourseController { @ApiResponse(responseCode = "200", description = "성공"), @ApiResponse(responseCode = "400", description = "실패 (요청 파라미터 오류)") }) - @GetMapping + @GetMapping("/api/courses") public ResponseEntity>> getFilteredCourses( @ParameterObject @ModelAttribute CourseFilterRequestDto filterOption, @AuthenticationPrincipal CustomOAuth2User customOAuth2User @@ -60,7 +67,7 @@ public ResponseEntity>> getFiltere @ApiResponse(responseCode = "200", description = "성공"), @ApiResponse(responseCode = "404", description = "실패 (존재하지 않는 코스)") }) - @GetMapping("/{courseId}") + @GetMapping("/api/courses/{courseId}") public ResponseEntity> getCourseDetails( @Parameter(description = "조회하려는 코스 ID", required = true) @PathVariable("courseId") Long courseId, @@ -77,7 +84,7 @@ public ResponseEntity> getCourseDetails( @ApiResponse(responseCode = "200", description = "성공"), @ApiResponse(responseCode = "404", description = "실패 (존재하지 않는 코스)") }) - @GetMapping("/{courseId}/summary") + @GetMapping("/api/courses/{courseId}/summary") public ResponseEntity> getCourseSummary( @Parameter(description = "조회하려는 코스 ID", required = true) @PathVariable("courseId") Long courseId, @@ -89,4 +96,35 @@ public ResponseEntity> getCourseSummary( return ResponseEntity.ok(CommonResponse.success(SUCCESS, courseSummary)); } + @Operation(summary = "지역 판별", description = "특정 위치 좌표가 부산 내 지역인지 판별합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공") + }) + @GetMapping("/api/locations/is-in-busan") + public ResponseEntity> isBusanCourse( + @Parameter(description = "시작포인트의 경도", required = true, example = "129.004480714") + @RequestParam("lon") double longitude, + @Parameter(description = "시작포인트의 위도", required = true, example = "35.08747067199999") + @RequestParam("lat") double latitude + ) { + boolean isBusanCourse = courseService.isInsideBusan(longitude, latitude); + return ResponseEntity.ok(CommonResponse.success(SUCCESS, isBusanCourse)); + } + + @Operation(summary = "내 코스 생성", description = "회원의 코스를 생성합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse(responseCode = "400", description = "요청 파라미터 오류") + }) + @PostMapping(value = "/api/members/me/courses", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> createMemberCourseWithGpx( + @Valid @ModelAttribute CourseCreateRequestDto request, + @AuthenticationPrincipal CustomOAuth2User customOAuth2User + ) { + Long memberId = customOAuth2User.getMember().getId(); + log.info("[내 코스 생성] startPointName: {}, endPointName: {}", request.startPointName(), request.endPointName()); + Long courseId = courseService.createMemberCourse(memberId, request); + return ResponseEntity.ok(CommonResponse.success(SUCCESS, courseId)); + } + } diff --git a/src/main/java/com/server/running_handai/domain/course/controller/CourseDataController.java b/src/main/java/com/server/running_handai/domain/course/controller/CourseDataController.java index 42e0742..434c8be 100644 --- a/src/main/java/com/server/running_handai/domain/course/controller/CourseDataController.java +++ b/src/main/java/com/server/running_handai/domain/course/controller/CourseDataController.java @@ -1,6 +1,7 @@ package com.server.running_handai.domain.course.controller; import com.server.running_handai.domain.course.dto.GpxCourseRequestDto; +import com.server.running_handai.domain.course.entity.Course; import com.server.running_handai.domain.course.service.CourseDataService; import com.server.running_handai.global.response.CommonResponse; import com.server.running_handai.global.response.ResponseCode; @@ -39,9 +40,11 @@ public ResponseEntity> updateCourseImage(@PathVariable Long co } @PostMapping(value = "/gpx", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ResponseEntity> createCourseToGpx(@RequestPart("courseInfo") GpxCourseRequestDto gpxCourseRequestDto, - @RequestParam("courseGpxFile") MultipartFile courseGpxFile) { - courseDataService.createCourseToGpx(gpxCourseRequestDto, courseGpxFile); - return ResponseEntity.ok().body(CommonResponse.success(ResponseCode.SUCCESS, null)); + public ResponseEntity> createCourseToGpx( + @RequestPart("courseInfo") GpxCourseRequestDto gpxCourseRequestDto, + @RequestParam("courseGpxFile") MultipartFile courseGpxFile + ) { + Long courseId = courseDataService.createCourseToGpx(gpxCourseRequestDto, courseGpxFile).getId(); + return ResponseEntity.ok().body(CommonResponse.success(ResponseCode.SUCCESS, courseId)); } } diff --git a/src/main/java/com/server/running_handai/domain/course/dto/CourseCreateRequestDto.java b/src/main/java/com/server/running_handai/domain/course/dto/CourseCreateRequestDto.java new file mode 100644 index 0000000..a89f2a9 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/course/dto/CourseCreateRequestDto.java @@ -0,0 +1,26 @@ +package com.server.running_handai.domain.course.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import org.springframework.web.multipart.MultipartFile; + +public record CourseCreateRequestDto( + @NotBlank(message = "시작 지점 이름은 필수입니다.") + @Size(max = 20, message = "시작 지점 이름은 20자를 초과할 수 없습니다.") + String startPointName, + + @NotBlank(message = "종료 지점 이름은 필수입니다.") + @Size(max = 20, message = "종료 지점 이름은 20자를 초과할 수 없습니다.") + String endPointName, + + @NotNull(message = "GPX 파일은 필수입니다.") + MultipartFile gpxFile, + + @NotNull(message = "썸네일 이미지는 필수입니다.") + MultipartFile thumbnailImage, + + @NotNull(message = "부산 지역 여부는 필수입니다.") + boolean isInsideBusan +) { +} diff --git a/src/main/java/com/server/running_handai/domain/course/entity/Course.java b/src/main/java/com/server/running_handai/domain/course/entity/Course.java index 9cd9224..427a9f8 100644 --- a/src/main/java/com/server/running_handai/domain/course/entity/Course.java +++ b/src/main/java/com/server/running_handai/domain/course/entity/Course.java @@ -1,5 +1,6 @@ package com.server.running_handai.domain.course.entity; +import com.server.running_handai.domain.member.entity.Member; import com.server.running_handai.domain.spot.entity.CourseSpot; import com.server.running_handai.domain.review.entity.Review; import com.server.running_handai.global.entity.BaseTimeEntity; @@ -13,6 +14,8 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.OneToOne; import jakarta.persistence.Table; @@ -23,6 +26,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; import org.locationtech.jts.geom.Point; @Entity @@ -94,6 +99,12 @@ public class Course extends BaseTimeEntity { @OneToMany(mappedBy = "course", cascade = CascadeType.ALL, orphanRemoval = true) private List courseSpots = new ArrayList<>(); + // Member와 다대일 관계 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + @OnDelete(action = OnDeleteAction.SET_NULL) + private Member creator; + @Builder public Course(String externalId, String name, double distance, int duration, CourseLevel level, Area area, String gpxPath, @@ -168,4 +179,17 @@ public void removeTheme(Theme theme) { this.themes.remove(theme); } + public void setCreator(Member creator) { + // 기존 Member와의 연관관계 제거 + if (this.creator != null) { + this.creator.getCourses().remove(this); + } + // 새로운 Member와의 연관관계 설정 + this.creator = creator; + // 새로운 Member의 코스 목록에 자신을 추가 + if (creator != null) { + creator.getCourses().add(this); + } + } + } diff --git a/src/main/java/com/server/running_handai/domain/course/event/CourseCreatedEvent.java b/src/main/java/com/server/running_handai/domain/course/event/CourseCreatedEvent.java new file mode 100644 index 0000000..12af903 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/course/event/CourseCreatedEvent.java @@ -0,0 +1,13 @@ +package com.server.running_handai.domain.course.event; + +/** + * 회원이 만든 코스가 성공적으로 생성되고 트랜잭션이 커밋된 후 발행되는 이벤트 + * + * @param courseId 생성된 코스의 ID + * @param isInsideBusan 부산 지역 내 코스인지 여부 + */ +public record CourseCreatedEvent( + Long courseId, + boolean isInsideBusan +) { +} diff --git a/src/main/java/com/server/running_handai/domain/course/event/CourseEventListener.java b/src/main/java/com/server/running_handai/domain/course/event/CourseEventListener.java new file mode 100644 index 0000000..4e54159 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/course/event/CourseEventListener.java @@ -0,0 +1,43 @@ +package com.server.running_handai.domain.course.event; + +import com.server.running_handai.domain.spot.service.SpotDataService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CourseEventListener { + + private final SpotDataService spotDataService; + + /** + * CourseCreatedEvent를 수신하여 비동기적으로 즐길거리를 초기화합니다. + * + * @param event 코스 생성 이벤트 객체 + */ + @Async + @TransactionalEventListener + public void handleCourseCreatedEvent(CourseCreatedEvent event) { + log.info("[이벤트 수신] 코스 생성 이벤트 수신. courseId: {}", event.courseId()); + + // 부산 외 코스는 즐길거리 초기화 생략 + if (!event.isInsideBusan()) { + log.info("부산 외 코스이므로 초기화를 생략합니다. courseId: {}", event.courseId()); + return; + } + + // 부산 내 코스는 즐길거리 초기화 진행 + try { + log.info("부산 내 코스이므로 비동기 즐길거리 초기화 작업을 시작합니다. courseId: {}", event.courseId()); + spotDataService.updateSpots(event.courseId()); + log.info("비동기 즐길거리 초기화 작업을 완료했습니다. courseId: {}", event.courseId()); + } catch (Exception e) { + log.error("비동기 즐길거리 초기화 작업 중 오류 발생. courseId: {}", event.courseId(), e); + } + } + +} diff --git a/src/main/java/com/server/running_handai/domain/course/repository/CourseRepository.java b/src/main/java/com/server/running_handai/domain/course/repository/CourseRepository.java index 277d712..f8fdac2 100644 --- a/src/main/java/com/server/running_handai/domain/course/repository/CourseRepository.java +++ b/src/main/java/com/server/running_handai/domain/course/repository/CourseRepository.java @@ -91,4 +91,6 @@ public interface CourseRepository extends JpaRepository { "JOIN FETCH c.trackPoints " + "WHERE c.id = :courseId") Optional findCourseWithDetailsById(@Param("courseId") Long courseId); + + boolean existsByName(String name); } diff --git a/src/main/java/com/server/running_handai/domain/course/service/CourseDataService.java b/src/main/java/com/server/running_handai/domain/course/service/CourseDataService.java index 893614b..c49694a 100644 --- a/src/main/java/com/server/running_handai/domain/course/service/CourseDataService.java +++ b/src/main/java/com/server/running_handai/domain/course/service/CourseDataService.java @@ -478,7 +478,7 @@ public void updateCourseImage(Long courseId, MultipartFile courseImageFile) { * @param courseGpxFile 업로드된 GPX 파일 */ @Transactional - public void createCourseToGpx(GpxCourseRequestDto gpxCourseRequestDto, MultipartFile courseGpxFile) { + public Course createCourseToGpx(GpxCourseRequestDto gpxCourseRequestDto, MultipartFile courseGpxFile) { log.info("[GPX 코스 생성] 시작: 파일명={}, 크기={} bytes", courseGpxFile.getOriginalFilename(), courseGpxFile.getSize()); // 1. 코스 이름 조합 @@ -589,6 +589,7 @@ public void createCourseToGpx(GpxCourseRequestDto gpxCourseRequestDto, Multipart log.info("[GPX 코스 생성] TrackPoint {}개 저장 완료", trackPoints.size()); log.info("[GPX 코스 생성] 전체 작업 완료: 코스명={})", courseName); + return course; } /** diff --git a/src/main/java/com/server/running_handai/domain/course/service/CourseService.java b/src/main/java/com/server/running_handai/domain/course/service/CourseService.java index c923fa5..023bd35 100644 --- a/src/main/java/com/server/running_handai/domain/course/service/CourseService.java +++ b/src/main/java/com/server/running_handai/domain/course/service/CourseService.java @@ -1,10 +1,14 @@ package com.server.running_handai.domain.course.service; import static com.server.running_handai.global.response.ResponseCode.COURSE_NOT_FOUND; +import static com.server.running_handai.global.response.ResponseCode.DUPLICATE_COURSE_NAME; import static com.server.running_handai.global.response.ResponseCode.INVALID_AREA_PARAMETER; import static com.server.running_handai.global.response.ResponseCode.INVALID_THEME_PARAMETER; +import static com.server.running_handai.global.response.ResponseCode.MEMBER_NOT_FOUND; +import com.fasterxml.jackson.databind.JsonNode; import com.server.running_handai.domain.bookmark.repository.BookmarkRepository; +import com.server.running_handai.domain.course.dto.CourseCreateRequestDto; import com.server.running_handai.domain.course.dto.CourseSummaryDto; import com.server.running_handai.domain.bookmark.dto.BookmarkCountDto; import com.server.running_handai.domain.bookmark.dto.BookmarkInfoDto; @@ -12,11 +16,15 @@ import com.server.running_handai.domain.course.dto.CourseFilterRequestDto; import com.server.running_handai.domain.course.dto.CourseInfoDto; import com.server.running_handai.domain.course.dto.CourseInfoWithDetailsDto; +import com.server.running_handai.domain.course.dto.GpxCourseRequestDto; import com.server.running_handai.domain.course.dto.TrackPointDto; import com.server.running_handai.domain.course.entity.Course; import com.server.running_handai.domain.course.entity.TrackPoint; +import com.server.running_handai.domain.course.event.CourseCreatedEvent; import com.server.running_handai.domain.course.repository.CourseRepository; import com.server.running_handai.domain.course.repository.TrackPointRepository; +import com.server.running_handai.domain.member.entity.Member; +import com.server.running_handai.domain.member.repository.MemberRepository; import com.server.running_handai.domain.review.dto.ReviewInfoDto; import com.server.running_handai.domain.review.repository.ReviewRepository; import com.server.running_handai.domain.review.service.ReviewService; @@ -36,6 +44,7 @@ import org.locationtech.jts.geom.LineString; import org.locationtech.jts.simplify.DouglasPeuckerSimplifier; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -50,10 +59,15 @@ public class CourseService { private final CourseRepository courseRepository; private final TrackPointRepository trackPointRepository; private final BookmarkRepository bookmarkRepository; - private final GeometryFactory geometryFactory; - private final SpotRepository spotRepository; private final ReviewRepository reviewRepository; + private final SpotRepository spotRepository; + private final MemberRepository memberRepository; + private final GeometryFactory geometryFactory; private final ReviewService reviewService; + private final CourseDataService courseDataService; + private final FileService fileService; + private final KakaoMapService kakaoMapService; + private final ApplicationEventPublisher eventPublisher; @Value("${course.simplification.distance-tolerance}") private double distanceTolerance; @@ -220,4 +234,62 @@ public CourseSummaryDto getCourseSummary(Long courseId, Long memberId) { return CourseSummaryDto.from(course, reviewCount, starAverage, reviewInfoDtos, spotInfoDtos); } + + /** + * 주어진 좌표가 부산 내에 있는지 판별합니다. + * + * @param longitude 경도 (x) + * @param latitude 위도 (y) + * @return 부산 지역 내에 있으면 true, 아니면 false + */ + public boolean isInsideBusan(double longitude, double latitude) { + JsonNode addressNode = kakaoMapService.getAddressFromCoordinate(longitude, latitude); + + if (addressNode == null) { + log.warn("[지역 판별] 주소 정보를 찾을 수 없어 부산 외 지역으로 판별합니다: x={}, y={}", longitude, latitude); + return false; + } + + String city = addressNode.path("address").path("region_1depth_name").asText(); + log.info("[지역 판별] city={}", city); + + return city.startsWith("부산"); + } + + /** + * 회원이 생성한 코스를 저장하고, 트랜잭션 커밋 후 이벤트를 발행합니다. + * + * @param memberId 요청 회원의 ID + * @param request 코스 생성에 필요한 데이터 DTO + * @return 저장된 코스의 ID + */ + @Transactional + public Long createMemberCourse(Long memberId, CourseCreateRequestDto request) { + checkCourseNameDuplicated(request); + Course newCourse = saveMemberCourse(memberId, request); + courseDataService.updateCourseImage(newCourse.getId(), request.thumbnailImage()); + publishCourseCreatedEvent(newCourse.getId(), request.isInsideBusan()); + return newCourse.getId(); + } + + private void checkCourseNameDuplicated(CourseCreateRequestDto request) { + String courseName = request.startPointName().trim() + "-" + request.endPointName().trim(); + if (courseRepository.existsByName(courseName)) { + throw new BusinessException(DUPLICATE_COURSE_NAME); + } + } + + private Course saveMemberCourse(Long memberId, CourseCreateRequestDto request) { + Member member = memberRepository.findById(memberId).orElseThrow(() -> new BusinessException(MEMBER_NOT_FOUND)); + Course newCourse = courseDataService.createCourseToGpx( + new GpxCourseRequestDto(request.startPointName(), request.endPointName()), request.gpxFile()); + newCourse.setCreator(member); + return newCourse; + } + + private void publishCourseCreatedEvent(Long courseId, boolean isInsideBusan) { + CourseCreatedEvent event = new CourseCreatedEvent(courseId, isInsideBusan); + log.info("코스 생성 이벤트 발행. courseId: {}, isInsideBusan: {}", courseId, isInsideBusan); + eventPublisher.publishEvent(event); + } } diff --git a/src/main/java/com/server/running_handai/domain/member/entity/Member.java b/src/main/java/com/server/running_handai/domain/member/entity/Member.java index 7bf20ce..a4ba52b 100644 --- a/src/main/java/com/server/running_handai/domain/member/entity/Member.java +++ b/src/main/java/com/server/running_handai/domain/member/entity/Member.java @@ -1,5 +1,6 @@ package com.server.running_handai.domain.member.entity; +import com.server.running_handai.domain.course.entity.Course; import com.server.running_handai.domain.review.entity.Review; import com.server.running_handai.global.entity.BaseTimeEntity; import jakarta.persistence.*; @@ -51,6 +52,10 @@ public class Member extends BaseTimeEntity { @OneToMany(mappedBy = "writer", cascade = {CascadeType.PERSIST, CascadeType.MERGE}) private List reviews = new ArrayList<>(); + // Course와 일대다 관계 + @OneToMany(mappedBy = "creator", cascade = {CascadeType.PERSIST, CascadeType.MERGE}) + private List courses = new ArrayList<>(); + @Builder public Member(String providerId, String email, String nickname, Provider provider, Role role) { this.providerId = providerId; diff --git a/src/main/java/com/server/running_handai/global/config/MultipartJackson2HttpMessageConverter.java b/src/main/java/com/server/running_handai/global/config/MultipartJackson2HttpMessageConverter.java new file mode 100644 index 0000000..280844e --- /dev/null +++ b/src/main/java/com/server/running_handai/global/config/MultipartJackson2HttpMessageConverter.java @@ -0,0 +1,29 @@ +package com.server.running_handai.global.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.lang.reflect.Type; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter; +import org.springframework.stereotype.Component; + +@Component +public class MultipartJackson2HttpMessageConverter extends AbstractJackson2HttpMessageConverter { + public MultipartJackson2HttpMessageConverter(ObjectMapper objectMapper) { + super(objectMapper, MediaType.APPLICATION_OCTET_STREAM); + } + + @Override + public boolean canWrite(Class clazz, MediaType mediaType) { + return false; + } + + @Override + public boolean canWrite(Type type, Class clazz, MediaType mediaType) { + return false; + } + + @Override + protected boolean canWrite(MediaType mediaType) { + return false; + } +} diff --git a/src/main/java/com/server/running_handai/global/config/WebConfig.java b/src/main/java/com/server/running_handai/global/config/WebConfig.java new file mode 100644 index 0000000..1b63db8 --- /dev/null +++ b/src/main/java/com/server/running_handai/global/config/WebConfig.java @@ -0,0 +1,19 @@ +package com.server.running_handai.global.config; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final MultipartJackson2HttpMessageConverter multipartJackson2HttpMessageConverter; + + @Override + public void extendMessageConverters(List> converters) { + converters.add(multipartJackson2HttpMessageConverter); + } +} diff --git a/src/main/java/com/server/running_handai/global/response/ResponseCode.java b/src/main/java/com/server/running_handai/global/response/ResponseCode.java index f659431..170820e 100644 --- a/src/main/java/com/server/running_handai/global/response/ResponseCode.java +++ b/src/main/java/com/server/running_handai/global/response/ResponseCode.java @@ -32,6 +32,9 @@ public enum ResponseCode { INVALID_NICKNAME_LENGTH(BAD_REQUEST, "닉네임은 2글자부터 10글자까지 입력할 수 있습니다."), INVALID_NICKNAME_FORMAT(BAD_REQUEST, "닉네임은 영문, 한글, 숫자만 입력할 수 있습니다."), SAME_AS_CURRENT_NICKNAME(BAD_REQUEST, "현재 사용 중인 닉네임과 동일합니다."), + EMPTY_FILE(BAD_REQUEST, "파일이 누락되었습니다."), + INVALID_POINT_NAME(BAD_REQUEST, "포인트 이름이 누락되었습니다."), + DUPLICATE_COURSE_NAME(BAD_REQUEST, "이미 존재하는 코스 이름입니다."), // UNAUTHORIZED (401) INVALID_ACCESS_TOKEN(UNAUTHORIZED, "유효하지 않은 액세스 토큰입니다."), diff --git a/src/test/java/com/server/running_handai/domain/course/service/CourseServiceTest.java b/src/test/java/com/server/running_handai/domain/course/service/CourseServiceTest.java index 3b92c7c..e747a3a 100644 --- a/src/test/java/com/server/running_handai/domain/course/service/CourseServiceTest.java +++ b/src/test/java/com/server/running_handai/domain/course/service/CourseServiceTest.java @@ -3,6 +3,8 @@ import static com.server.running_handai.domain.course.entity.CourseFilter.*; import static com.server.running_handai.domain.course.service.CourseService.MYSQL_POINT_FORMAT; import static com.server.running_handai.global.response.ResponseCode.COURSE_NOT_FOUND; +import static com.server.running_handai.global.response.ResponseCode.DUPLICATE_COURSE_NAME; +import static com.server.running_handai.global.response.ResponseCode.MEMBER_NOT_FOUND; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; @@ -13,14 +15,19 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.server.running_handai.domain.bookmark.repository.BookmarkRepository; import com.server.running_handai.domain.bookmark.dto.BookmarkCountDto; +import com.server.running_handai.domain.course.dto.CourseCreateRequestDto; import com.server.running_handai.domain.course.dto.CourseDetailDto; import com.server.running_handai.domain.course.dto.CourseFilterRequestDto; import com.server.running_handai.domain.course.dto.CourseInfoDto; import com.server.running_handai.domain.course.dto.CourseInfoWithDetailsDto; import com.server.running_handai.domain.course.dto.CourseSummaryDto; +import com.server.running_handai.domain.course.dto.GpxCourseRequestDto; import com.server.running_handai.domain.course.entity.Area; import com.server.running_handai.domain.course.entity.Course; import com.server.running_handai.domain.course.entity.CourseFilter; @@ -28,11 +35,13 @@ import com.server.running_handai.domain.course.entity.RoadCondition; import com.server.running_handai.domain.course.entity.Theme; import com.server.running_handai.domain.course.entity.TrackPoint; +import com.server.running_handai.domain.course.event.CourseCreatedEvent; import com.server.running_handai.domain.course.repository.CourseRepository; import com.server.running_handai.domain.course.repository.TrackPointRepository; import com.server.running_handai.domain.member.entity.Member; import com.server.running_handai.domain.member.entity.Provider; import com.server.running_handai.domain.member.entity.Role; +import com.server.running_handai.domain.member.repository.MemberRepository; import com.server.running_handai.domain.review.dto.ReviewInfoDto; import com.server.running_handai.domain.review.entity.Review; import com.server.running_handai.domain.review.repository.ReviewRepository; @@ -55,15 +64,20 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.LineString; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; @ActiveProfiles("test") @ExtendWith(MockitoExtension.class) @@ -93,6 +107,18 @@ class CourseServiceTest { @Mock private GeometryFactory geometryFactory; + @Mock + private CourseDataService courseDataService; + + @Mock + private MemberRepository memberRepository; + + @Mock + private KakaoMapService kakaoMapService; + + @Mock + private ApplicationEventPublisher eventPublisher; + private static final Long COURSE_ID = 1L; private static final Long MEMBER_ID = 1L; private static final Double USER_LAT = 37.5665; @@ -530,4 +556,165 @@ void getCourseSummary_fail_courseNotFound() { assertThat(exception.getResponseCode()).isEqualTo(COURSE_NOT_FOUND); } } + + @Nested + @DisplayName("지역 판별 테스트") + class RegionCheckTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + @DisplayName("부산 지역 판별 성공 - true 반환") + void isInsideBusan_whenCoordinateIsInBusan_shouldReturnTrue() throws Exception { + // given + double busanLon = 129.004480714; + double busanLat = 35.08747067199999; + JsonNode busanAddressNode = createMockAddressNode("부산광역시"); + + when(kakaoMapService.getAddressFromCoordinate(busanLon, busanLat)).thenReturn(busanAddressNode); + + // when + boolean result = courseService.isInsideBusan(busanLon, busanLat); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("부산이 아닌 지역 판별 성공 - false 반환") + void isInsideBusan_whenCoordinateIsNotInBusan_shouldReturnFalse() throws Exception { + // given + double seoulLon = 127.0276; + double seoulLat = 37.4979; + JsonNode seoulAddressNode = createMockAddressNode("서울특별시"); + + when(kakaoMapService.getAddressFromCoordinate(seoulLon, seoulLat)).thenReturn(seoulAddressNode); + + // when + boolean result = courseService.isInsideBusan(seoulLon, seoulLat); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("카카오 API에서 주소 정보를 반환하지 않은 경우(null) false 반환") + void isInsideBusan_whenAddressNodeIsNull_shouldReturnFalse() { + // given + double someLon = 128.0; + double someLat = 36.0; + + when(kakaoMapService.getAddressFromCoordinate(someLon, someLat)).thenReturn(null); + + // when + boolean result = courseService.isInsideBusan(someLon, someLat); + + // then + assertThat(result).isFalse(); + } + + private JsonNode createMockAddressNode(String cityName) throws Exception { + String jsonString = String.format( + "{\"address\": {\"region_1depth_name\": \"%s\"}}", + cityName + ); + return objectMapper.readTree(jsonString); + } + } + + @Nested + @DisplayName("내 코스 생성 테스트") + class MyCourseCreationTest { + + private final String START_POINT_NAME = "광안리해수욕장"; + private final String END_POINT_NAME = "해운대해수욕장"; + private final String COURSE_NAME = START_POINT_NAME + "-" + END_POINT_NAME; + + private Member member; + private MultipartFile gpxFile; + private MultipartFile thumbnailImgFile; + private CourseCreateRequestDto request; + + @BeforeEach + void setUp() { + member = Member.builder() + .nickname("nickname1") + .providerId("providerId1") + .provider(Provider.GOOGLE) + .email("email1") + .role(Role.USER) + .build(); + gpxFile = new MockMultipartFile("gpxFile", "test.gpx", "application/gpx+xml", "".getBytes()); + thumbnailImgFile = new MockMultipartFile("thumbnail", "thumb.jpg", "image/jpeg", "thumbnail-image".getBytes()); + request = new CourseCreateRequestDto(START_POINT_NAME, END_POINT_NAME, gpxFile, thumbnailImgFile, true); + } + + @Test + @DisplayName("내 코스 생성 성공") + void createMemberCourse_success() { + // given + Long memberId = 1L; + Long courseId = 100L; + Course newCourse = createMockCourse(courseId); + + when(courseRepository.existsByName(COURSE_NAME)).thenReturn(false); // 중복된 이름 없음 + when(memberRepository.findById(memberId)).thenReturn(Optional.of(member)); + when(courseDataService.createCourseToGpx(any(GpxCourseRequestDto.class), any(MultipartFile.class))).thenReturn(newCourse); + + // when + Long result = courseService.createMemberCourse(memberId, request); + + // then + assertThat(result).isEqualTo(newCourse.getId()); + assertThat(newCourse.getCreator()).isEqualTo(member); + assertThat(member.getCourses()).contains(newCourse); + + // 이벤트 캡처 + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(CourseCreatedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + + // 캡처한 이벤트의 내용 검증 + CourseCreatedEvent capturedEvent = eventCaptor.getValue(); + assertThat(capturedEvent.courseId()).isEqualTo(newCourse.getId()); + assertThat(capturedEvent.isInsideBusan()).isTrue(); + + verify(courseRepository).existsByName(COURSE_NAME); + verify(memberRepository).findById(memberId); + verify(courseDataService).createCourseToGpx(any(GpxCourseRequestDto.class), eq(gpxFile)); + verify(courseDataService).updateCourseImage(newCourse.getId(), thumbnailImgFile); + } + + @Test + @DisplayName("실패 - 중복된 코스 이름") + void createMemberCourse_fail_duplicateCourseName() { + // given + Long memberId = 1L; + when(courseRepository.existsByName(COURSE_NAME)).thenReturn(true); // 코스 이름이 이미 존재함 + + // when, then + BusinessException exception = assertThrows(BusinessException.class, + () -> courseService.createMemberCourse(memberId, request)); + assertThat(exception.getResponseCode()).isEqualTo(DUPLICATE_COURSE_NAME); + + verify(memberRepository, never()).findById(anyLong()); + verify(courseDataService, never()).createCourseToGpx(any(), any()); + verify(courseDataService, never()).updateCourseImage(any(), any()); + } + + @Test + @DisplayName("실패 - 존재하지 않는 회원") + void createMemberCourse_fail_memberNotFound() { + // given + Long nonExistentMemberId = 999L; + when(courseRepository.existsByName(COURSE_NAME)).thenReturn(false); // 중복은 통과 + when(memberRepository.findById(nonExistentMemberId)).thenReturn(Optional.empty()); // 존재하지 않는 회원 + + // when, then + BusinessException exception = assertThrows(BusinessException.class, + () -> courseService.createMemberCourse(nonExistentMemberId, request)); + assertThat(exception.getResponseCode()).isEqualTo(MEMBER_NOT_FOUND); + + verify(courseDataService, never()).createCourseToGpx(any(), any()); + verify(courseDataService, never()).updateCourseImage(any(), any()); + } + } } \ No newline at end of file