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 @@ -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;
Expand All @@ -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 {
Expand All @@ -40,7 +47,7 @@ public class CourseController {
@ApiResponse(responseCode = "200", description = "성공"),
@ApiResponse(responseCode = "400", description = "실패 (요청 파라미터 오류)")
})
@GetMapping
@GetMapping("/api/courses")
public ResponseEntity<CommonResponse<List<CourseInfoWithDetailsDto>>> getFilteredCourses(
@ParameterObject @ModelAttribute CourseFilterRequestDto filterOption,
@AuthenticationPrincipal CustomOAuth2User customOAuth2User
Expand All @@ -60,7 +67,7 @@ public ResponseEntity<CommonResponse<List<CourseInfoWithDetailsDto>>> getFiltere
@ApiResponse(responseCode = "200", description = "성공"),
@ApiResponse(responseCode = "404", description = "실패 (존재하지 않는 코스)")
})
@GetMapping("/{courseId}")
@GetMapping("/api/courses/{courseId}")
public ResponseEntity<CommonResponse<CourseDetailDto>> getCourseDetails(
@Parameter(description = "조회하려는 코스 ID", required = true)
@PathVariable("courseId") Long courseId,
Expand All @@ -77,7 +84,7 @@ public ResponseEntity<CommonResponse<CourseDetailDto>> getCourseDetails(
@ApiResponse(responseCode = "200", description = "성공"),
@ApiResponse(responseCode = "404", description = "실패 (존재하지 않는 코스)")
})
@GetMapping("/{courseId}/summary")
@GetMapping("/api/courses/{courseId}/summary")
public ResponseEntity<CommonResponse<CourseSummaryDto>> getCourseSummary(
@Parameter(description = "조회하려는 코스 ID", required = true)
@PathVariable("courseId") Long courseId,
Expand All @@ -89,4 +96,35 @@ public ResponseEntity<CommonResponse<CourseSummaryDto>> getCourseSummary(
return ResponseEntity.ok(CommonResponse.success(SUCCESS, courseSummary));
}

@Operation(summary = "지역 판별", description = "특정 위치 좌표가 부산 내 지역인지 판별합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "성공")
})
@GetMapping("/api/locations/is-in-busan")
public ResponseEntity<CommonResponse<Boolean>> 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<CommonResponse<Long>> 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));
}

}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -39,9 +40,11 @@ public ResponseEntity<CommonResponse<?>> updateCourseImage(@PathVariable Long co
}

@PostMapping(value = "/gpx", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<CommonResponse<?>> createCourseToGpx(@RequestPart("courseInfo") GpxCourseRequestDto gpxCourseRequestDto,
@RequestParam("courseGpxFile") MultipartFile courseGpxFile) {
courseDataService.createCourseToGpx(gpxCourseRequestDto, courseGpxFile);
return ResponseEntity.ok().body(CommonResponse.success(ResponseCode.SUCCESS, null));
public ResponseEntity<CommonResponse<Long>> 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));
}
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -94,6 +99,12 @@ public class Course extends BaseTimeEntity {
@OneToMany(mappedBy = "course", cascade = CascadeType.ALL, orphanRemoval = true)
private List<CourseSpot> 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,
Expand Down Expand Up @@ -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);
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.server.running_handai.domain.course.event;

/**
* 회원이 만든 코스가 성공적으로 생성되고 트랜잭션이 커밋된 후 발행되는 이벤트
*
* @param courseId 생성된 코스의 ID
* @param isInsideBusan 부산 지역 내 코스인지 여부
*/
public record CourseCreatedEvent(
Long courseId,
boolean isInsideBusan
) {
}
Original file line number Diff line number Diff line change
@@ -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);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,6 @@ public interface CourseRepository extends JpaRepository<Course, Long> {
"JOIN FETCH c.trackPoints " +
"WHERE c.id = :courseId")
Optional<Course> findCourseWithDetailsById(@Param("courseId") Long courseId);

boolean existsByName(String name);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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. 코스 이름 조합
Expand Down Expand Up @@ -589,6 +589,7 @@ public void createCourseToGpx(GpxCourseRequestDto gpxCourseRequestDto, Multipart
log.info("[GPX 코스 생성] TrackPoint {}개 저장 완료", trackPoints.size());

log.info("[GPX 코스 생성] 전체 작업 완료: 코스명={})", courseName);
return course;
}

/**
Expand Down
Loading