diff --git a/src/main/java/com/semosan/api/common/status/ErrorStatus.java b/src/main/java/com/semosan/api/common/status/ErrorStatus.java index 781edaa1..77a64eb4 100644 --- a/src/main/java/com/semosan/api/common/status/ErrorStatus.java +++ b/src/main/java/com/semosan/api/common/status/ErrorStatus.java @@ -62,6 +62,16 @@ public enum ErrorStatus implements BaseStatus { APPLE_PUBLIC_KEY_NOT_FOUND(HttpStatus.UNAUTHORIZED, "APPLE_401_1", "유효한 애플 공개키를 찾을 수 없습니다."), APPLE_IDENTITY_TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "APPLE_401_2", "애플 identity token이 유효하지 않습니다."), + /** + * Mountain + */ + MOUNTAIN_NOT_FOUND(HttpStatus.NOT_FOUND, "MTN_404_1", "산을 찾을 수 없습니다."), + + /** + * Image + */ + IMAGE_UPLOAD_FAILED(HttpStatus.BAD_GATEWAY, "IMG_502_1", "이미지 업로드 URL 생성에 실패했습니다."), + /** * Withdraw */ diff --git a/src/main/java/com/semosan/api/common/status/SuccessStatus.java b/src/main/java/com/semosan/api/common/status/SuccessStatus.java index 0972aba0..e033c271 100644 --- a/src/main/java/com/semosan/api/common/status/SuccessStatus.java +++ b/src/main/java/com/semosan/api/common/status/SuccessStatus.java @@ -22,6 +22,18 @@ public enum SuccessStatus implements BaseStatus { */ WITHDRAW_SUCCESS(HttpStatus.OK, "USER_200_1", "회원 탈퇴가 완료되었습니다."), + /** + * Mountain + */ + MOUNTAIN_LIST_SUCCESS(HttpStatus.OK, "MTN_200_1", "산 목록 조회에 성공했습니다."), + MOUNTAIN_SEARCH_SUCCESS(HttpStatus.OK, "MTN_200_2", "산 검색에 성공했습니다."), + MOUNTAIN_DETAIL_SUCCESS(HttpStatus.OK, "MTN_200_3", "산 상세 정보 조회에 성공했습니다."), + + /** + * Image + */ + PRESIGNED_URL_SUCCESS(HttpStatus.OK, "IMG_200_1", "Presigned URL 발급에 성공했습니다."); + /** * FCM / Notification */ diff --git a/src/main/java/com/semosan/api/domain/mountain/controller/MountainController.java b/src/main/java/com/semosan/api/domain/mountain/controller/MountainController.java new file mode 100644 index 00000000..d895c1b7 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/controller/MountainController.java @@ -0,0 +1,46 @@ +package com.semosan.api.domain.mountain.controller; + +import com.semosan.api.common.response.ApiResponse; +import com.semosan.api.common.status.SuccessStatus; +import com.semosan.api.domain.mountain.controller.docs.MountainControllerDocs; +import com.semosan.api.domain.mountain.dto.response.MountainDetailResponse; +import com.semosan.api.domain.mountain.dto.response.MountainListResponse; +import com.semosan.api.domain.mountain.service.MountainService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/mountains") +@RequiredArgsConstructor +public class MountainController implements MountainControllerDocs { + + private final MountainService mountainService; + + @GetMapping + @Override + public ResponseEntity>> getMountains() { + List response = mountainService.getMountains(); + return ApiResponse.success(SuccessStatus.MOUNTAIN_LIST_SUCCESS, response); + } + + @GetMapping("/search") + @Override + public ResponseEntity>> searchMountains( + @RequestParam String keyword + ) { + List response = mountainService.searchMountains(keyword); + return ApiResponse.success(SuccessStatus.MOUNTAIN_SEARCH_SUCCESS, response); + } + + @GetMapping("/{mountainId}") + @Override + public ResponseEntity> getMountainDetail( + @PathVariable Long mountainId + ) { + MountainDetailResponse response = mountainService.getMountainDetail(mountainId); + return ApiResponse.success(SuccessStatus.MOUNTAIN_DETAIL_SUCCESS, response); + } +} diff --git a/src/main/java/com/semosan/api/domain/mountain/controller/docs/MountainControllerDocs.java b/src/main/java/com/semosan/api/domain/mountain/controller/docs/MountainControllerDocs.java new file mode 100644 index 00000000..8cdb18b1 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/controller/docs/MountainControllerDocs.java @@ -0,0 +1,67 @@ +package com.semosan.api.domain.mountain.controller.docs; + +import com.semosan.api.common.response.ApiResponse; +import com.semosan.api.domain.mountain.dto.response.MountainDetailResponse; +import com.semosan.api.domain.mountain.dto.response.MountainListResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; + +@Tag(name = "Mountain", description = "산 관련 API") +public interface MountainControllerDocs { + + @Operation( + summary = "산 목록 조회", + description = "등록된 모든 산의 목록을 조회합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "산 목록 조회 성공" + ) + }) + ResponseEntity>> getMountains(); + + @Operation( + summary = "산 검색", + description = "산 이름 또는 주소로 검색합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "산 검색 성공" + ) + }) + ResponseEntity>> searchMountains( + @Parameter(description = "검색 키워드 (산 이름 또는 주소)", required = true) + @RequestParam String keyword + ); + + @Operation( + summary = "산 상세 정보 조회", + description = "산의 상세 정보를 조회합니다. 코스, 교통, 편의시설, 맛집, 리뷰 정보를 포함합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "산 상세 정보 조회 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "산을 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ) + }) + ResponseEntity> getMountainDetail( + @Parameter(description = "산 ID", required = true) + @PathVariable Long mountainId + ); +} diff --git a/src/main/java/com/semosan/api/domain/mountain/dto/response/MountainDetailResponse.java b/src/main/java/com/semosan/api/domain/mountain/dto/response/MountainDetailResponse.java new file mode 100644 index 00000000..e952a381 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/dto/response/MountainDetailResponse.java @@ -0,0 +1,156 @@ +package com.semosan.api.domain.mountain.dto.response; + +import com.semosan.api.domain.mountain.entity.*; +import com.semosan.api.domain.mountain.enums.AmenityType; +import com.semosan.api.domain.mountain.enums.Difficulty; +import com.semosan.api.domain.mountain.enums.TransportationType; +import com.semosan.api.domain.review.entity.Review; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public record MountainDetailResponse( + MountainInfo mountain, + List courses, + TransportationGroup transportations, + Map> amenities, + List restaurantSections, + List reviews +) { + + public record MountainInfo( + Long mountainId, + String name, + String address, + Double altitude, + Difficulty difficulty, + Integer duration, + String imageUrl + ) { + public static MountainInfo from(Mountain mountain) { + return new MountainInfo( + mountain.getId(), + mountain.getName(), + mountain.getAddress(), + mountain.getAltitude(), + mountain.getDifficulty(), + mountain.getDuration(), + mountain.getImageUrl() + ); + } + } + + public record CourseInfo( + Long courseId, + String name, + Difficulty difficulty, + Double distance, + Integer duration + ) { + public static CourseInfo from(Course course) { + return new CourseInfo( + course.getId(), + course.getName(), + course.getDifficulty(), + course.getDistance(), + course.getDuration() + ); + } + } + + public record TransportationGroup( + Map> publicTransport, + Map> parking + ) { + public static TransportationGroup from(List transportations) { + Map> publicTransport = transportations.stream() + .filter(t -> t.getType() == TransportationType.SUBWAY || t.getType() == TransportationType.BUS) + .collect(Collectors.groupingBy( + Transportation::getDirection, + Collectors.mapping(TransportationItem::from, Collectors.toList()) + )); + Map> parking = transportations.stream() + .filter(t -> t.getType() == TransportationType.PARKING) + .collect(Collectors.groupingBy( + Transportation::getDirection, + Collectors.mapping(TransportationItem::from, Collectors.toList()) + )); + return new TransportationGroup(publicTransport, parking); + } + } + + public record TransportationItem( + Long transportationId, + TransportationType type, + String name, + String description + ) { + public static TransportationItem from(Transportation transportation) { + return new TransportationItem( + transportation.getId(), + transportation.getType(), + transportation.getName(), + transportation.getDescription() + ); + } + } + + public static Map> groupAmenities(List amenities) { + return amenities.stream() + .collect(Collectors.groupingBy( + Amenity::getDirection, + Collectors.mapping(Amenity::getType, Collectors.toList()) + )); + } + + public record RestaurantSectionInfo( + String title, + List restaurants + ) { + public static RestaurantSectionInfo from(RestaurantSection section) { + return new RestaurantSectionInfo( + section.getTitle(), + section.getRestaurants().stream() + .map(RestaurantInfo::from) + .toList() + ); + } + } + + public record RestaurantInfo( + Long restaurantId, + String name, + String category, + String imageUrl + ) { + public static RestaurantInfo from(Restaurant restaurant) { + return new RestaurantInfo( + restaurant.getId(), + restaurant.getName(), + restaurant.getCategory(), + restaurant.getImageUrl() + ); + } + } + + public record ReviewInfo( + Long reviewId, + String imageUrl, + String authorName, + String content, + Difficulty difficulty, + String courseName + ) { + public static ReviewInfo from(Review review) { + return new ReviewInfo( + review.getId(), + review.getImageUrl(), + review.getUser().getName(), + review.getContent(), + review.getDifficulty(), + review.getCourse() != null ? review.getCourse().getName() : null + ); + } + } +} diff --git a/src/main/java/com/semosan/api/domain/mountain/dto/response/MountainListResponse.java b/src/main/java/com/semosan/api/domain/mountain/dto/response/MountainListResponse.java new file mode 100644 index 00000000..b37b3f6f --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/dto/response/MountainListResponse.java @@ -0,0 +1,31 @@ +package com.semosan.api.domain.mountain.dto.response; + +import com.semosan.api.domain.mountain.entity.Mountain; +import com.semosan.api.domain.mountain.enums.Difficulty; + +public record MountainListResponse( + Long mountainId, + String name, + String address, + Double altitude, + Difficulty difficulty, + Integer duration, + String imageUrl, + Double latitude, + Double longitude +) { + + public static MountainListResponse from(Mountain mountain) { + return new MountainListResponse( + mountain.getId(), + mountain.getName(), + mountain.getAddress(), + mountain.getAltitude(), + mountain.getDifficulty(), + mountain.getDuration(), + mountain.getImageUrl(), + mountain.getLatitude(), + mountain.getLongitude() + ); + } +} diff --git a/src/main/java/com/semosan/api/domain/mountain/entity/Amenity.java b/src/main/java/com/semosan/api/domain/mountain/entity/Amenity.java new file mode 100644 index 00000000..c967bc45 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/entity/Amenity.java @@ -0,0 +1,30 @@ +package com.semosan.api.domain.mountain.entity; + +import com.semosan.api.common.base.BaseEntity; +import com.semosan.api.domain.mountain.enums.AmenityType; +import jakarta.persistence.*; +import lombok.*; + +@Table(name = "amenities") +@Getter +@Entity +@Builder(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Amenity extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mountain_id", nullable = false) + private Mountain mountain; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 20) + private AmenityType type; + + @Column(name = "direction", nullable = false, length = 50) + private String direction; +} diff --git a/src/main/java/com/semosan/api/domain/mountain/entity/Course.java b/src/main/java/com/semosan/api/domain/mountain/entity/Course.java new file mode 100644 index 00000000..b3af49fa --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/entity/Course.java @@ -0,0 +1,36 @@ +package com.semosan.api.domain.mountain.entity; + +import com.semosan.api.common.base.BaseEntity; +import com.semosan.api.domain.mountain.enums.Difficulty; +import jakarta.persistence.*; +import lombok.*; + +@Table(name = "courses") +@Getter +@Entity +@Builder(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Course extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mountain_id", nullable = false) + private Mountain mountain; + + @Column(name = "name", nullable = false, length = 100) + private String name; + + @Enumerated(EnumType.STRING) + @Column(name = "difficulty", nullable = false, length = 20) + private Difficulty difficulty; + + @Column(name = "distance", nullable = false) + private Double distance; + + @Column(name = "duration", nullable = false) + private Integer duration; +} diff --git a/src/main/java/com/semosan/api/domain/mountain/entity/Mountain.java b/src/main/java/com/semosan/api/domain/mountain/entity/Mountain.java new file mode 100644 index 00000000..98d13fa4 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/entity/Mountain.java @@ -0,0 +1,44 @@ +package com.semosan.api.domain.mountain.entity; + +import com.semosan.api.common.base.BaseEntity; +import com.semosan.api.domain.mountain.enums.Difficulty; +import jakarta.persistence.*; +import lombok.*; + +@Table(name = "mountains") +@Getter +@Entity +@Builder(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Mountain extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "name", nullable = false, length = 50) + private String name; + + @Column(name = "address", nullable = false, length = 255) + private String address; + + @Column(name = "altitude", nullable = false) + private Double altitude; + + @Enumerated(EnumType.STRING) + @Column(name = "difficulty", nullable = false, length = 20) + private Difficulty difficulty; + + @Column(name = "duration") + private Integer duration; + + @Column(name = "image_url", length = 500) + private String imageUrl; + + @Column(name = "latitude") + private Double latitude; + + @Column(name = "longitude") + private Double longitude; +} diff --git a/src/main/java/com/semosan/api/domain/mountain/entity/Restaurant.java b/src/main/java/com/semosan/api/domain/mountain/entity/Restaurant.java new file mode 100644 index 00000000..a365ec7f --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/entity/Restaurant.java @@ -0,0 +1,31 @@ +package com.semosan.api.domain.mountain.entity; + +import com.semosan.api.common.base.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +@Table(name = "restaurants") +@Getter +@Entity +@Builder(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Restaurant extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "section_id", nullable = false) + private RestaurantSection section; + + @Column(name = "name", nullable = false, length = 100) + private String name; + + @Column(name = "category", length = 50) + private String category; + + @Column(name = "image_url", length = 500) + private String imageUrl; +} diff --git a/src/main/java/com/semosan/api/domain/mountain/entity/RestaurantSection.java b/src/main/java/com/semosan/api/domain/mountain/entity/RestaurantSection.java new file mode 100644 index 00000000..fb129bd0 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/entity/RestaurantSection.java @@ -0,0 +1,32 @@ +package com.semosan.api.domain.mountain.entity; + +import com.semosan.api.common.base.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; + +@Table(name = "restaurant_sections") +@Getter +@Entity +@Builder(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RestaurantSection extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mountain_id", nullable = false) + private Mountain mountain; + + @Column(name = "title", nullable = false, length = 100) + private String title; + + @OneToMany(mappedBy = "section", fetch = FetchType.LAZY) + @Builder.Default + private List restaurants = new ArrayList<>(); +} diff --git a/src/main/java/com/semosan/api/domain/mountain/entity/Transportation.java b/src/main/java/com/semosan/api/domain/mountain/entity/Transportation.java new file mode 100644 index 00000000..df8faf25 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/entity/Transportation.java @@ -0,0 +1,36 @@ +package com.semosan.api.domain.mountain.entity; + +import com.semosan.api.common.base.BaseEntity; +import com.semosan.api.domain.mountain.enums.TransportationType; +import jakarta.persistence.*; +import lombok.*; + +@Table(name = "transportations") +@Getter +@Entity +@Builder(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Transportation extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mountain_id", nullable = false) + private Mountain mountain; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 20) + private TransportationType type; + + @Column(name = "direction", nullable = false, length = 50) + private String direction; + + @Column(name = "name", nullable = false, length = 100) + private String name; + + @Column(name = "description", length = 500) + private String description; +} diff --git a/src/main/java/com/semosan/api/domain/mountain/enums/AmenityType.java b/src/main/java/com/semosan/api/domain/mountain/enums/AmenityType.java new file mode 100644 index 00000000..314c1486 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/enums/AmenityType.java @@ -0,0 +1,5 @@ +package com.semosan.api.domain.mountain.enums; + +public enum AmenityType { + RESTROOM, INFORMATION, SHELTER, PARKING, STORE +} diff --git a/src/main/java/com/semosan/api/domain/mountain/enums/Difficulty.java b/src/main/java/com/semosan/api/domain/mountain/enums/Difficulty.java new file mode 100644 index 00000000..3deac48b --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/enums/Difficulty.java @@ -0,0 +1,5 @@ +package com.semosan.api.domain.mountain.enums; + +public enum Difficulty { + EASY, NORMAL, HARD +} diff --git a/src/main/java/com/semosan/api/domain/mountain/enums/TransportationType.java b/src/main/java/com/semosan/api/domain/mountain/enums/TransportationType.java new file mode 100644 index 00000000..6c0d97ea --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/enums/TransportationType.java @@ -0,0 +1,5 @@ +package com.semosan.api.domain.mountain.enums; + +public enum TransportationType { + SUBWAY, BUS, PARKING +} diff --git a/src/main/java/com/semosan/api/domain/mountain/repository/AmenityRepository.java b/src/main/java/com/semosan/api/domain/mountain/repository/AmenityRepository.java new file mode 100644 index 00000000..784ec5a4 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/repository/AmenityRepository.java @@ -0,0 +1,11 @@ +package com.semosan.api.domain.mountain.repository; + +import com.semosan.api.domain.mountain.entity.Amenity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface AmenityRepository extends JpaRepository { + + List findByMountainId(Long mountainId); +} diff --git a/src/main/java/com/semosan/api/domain/mountain/repository/CourseRepository.java b/src/main/java/com/semosan/api/domain/mountain/repository/CourseRepository.java new file mode 100644 index 00000000..55c85e31 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/repository/CourseRepository.java @@ -0,0 +1,11 @@ +package com.semosan.api.domain.mountain.repository; + +import com.semosan.api.domain.mountain.entity.Course; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CourseRepository extends JpaRepository { + + List findByMountainId(Long mountainId); +} diff --git a/src/main/java/com/semosan/api/domain/mountain/repository/MountainRepository.java b/src/main/java/com/semosan/api/domain/mountain/repository/MountainRepository.java new file mode 100644 index 00000000..760e59d1 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/repository/MountainRepository.java @@ -0,0 +1,14 @@ +package com.semosan.api.domain.mountain.repository; + +import com.semosan.api.domain.mountain.entity.Mountain; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface MountainRepository extends JpaRepository { + + @Query("SELECT m FROM Mountain m WHERE m.name LIKE CONCAT('%', :keyword, '%') OR m.address LIKE CONCAT('%', :keyword, '%')") + List searchByKeyword(@Param("keyword") String keyword); +} diff --git a/src/main/java/com/semosan/api/domain/mountain/repository/RestaurantRepository.java b/src/main/java/com/semosan/api/domain/mountain/repository/RestaurantRepository.java new file mode 100644 index 00000000..5afd63da --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/repository/RestaurantRepository.java @@ -0,0 +1,7 @@ +package com.semosan.api.domain.mountain.repository; + +import com.semosan.api.domain.mountain.entity.Restaurant; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RestaurantRepository extends JpaRepository { +} diff --git a/src/main/java/com/semosan/api/domain/mountain/repository/RestaurantSectionRepository.java b/src/main/java/com/semosan/api/domain/mountain/repository/RestaurantSectionRepository.java new file mode 100644 index 00000000..a1f318b2 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/repository/RestaurantSectionRepository.java @@ -0,0 +1,14 @@ +package com.semosan.api.domain.mountain.repository; + +import com.semosan.api.domain.mountain.entity.RestaurantSection; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface RestaurantSectionRepository extends JpaRepository { + + @Query("SELECT DISTINCT s FROM RestaurantSection s LEFT JOIN FETCH s.restaurants WHERE s.mountain.id = :mountainId") + List findByMountainIdWithRestaurants(@Param("mountainId") Long mountainId); +} diff --git a/src/main/java/com/semosan/api/domain/mountain/repository/TransportationRepository.java b/src/main/java/com/semosan/api/domain/mountain/repository/TransportationRepository.java new file mode 100644 index 00000000..ddb564e7 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/repository/TransportationRepository.java @@ -0,0 +1,11 @@ +package com.semosan.api.domain.mountain.repository; + +import com.semosan.api.domain.mountain.entity.Transportation; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface TransportationRepository extends JpaRepository { + + List findByMountainId(Long mountainId); +} diff --git a/src/main/java/com/semosan/api/domain/mountain/service/MountainService.java b/src/main/java/com/semosan/api/domain/mountain/service/MountainService.java new file mode 100644 index 00000000..e06dfecc --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/service/MountainService.java @@ -0,0 +1,89 @@ +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.MountainDetailResponse; +import com.semosan.api.domain.mountain.dto.response.MountainDetailResponse.*; +import com.semosan.api.domain.mountain.dto.response.MountainListResponse; +import com.semosan.api.domain.mountain.entity.Mountain; +import com.semosan.api.domain.mountain.enums.AmenityType; +import com.semosan.api.domain.mountain.repository.*; +import com.semosan.api.domain.review.service.ReviewService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MountainService { + + private final MountainRepository mountainRepository; + private final CourseRepository courseRepository; + private final TransportationRepository transportationRepository; + private final AmenityRepository amenityRepository; + private final RestaurantSectionRepository restaurantSectionRepository; + private final ReviewService reviewService; + + public List getMountains() { + return mountainRepository.findAll().stream() + .map(MountainListResponse::from) + .toList(); + } + + public List searchMountains(String keyword) { + if (keyword == null || keyword.isBlank()) { + throw new GeneralException(ErrorStatus.BAD_REQUEST); + } + return mountainRepository.searchByKeyword(keyword.trim()).stream() + .map(MountainListResponse::from) + .toList(); + } + + public MountainDetailResponse getMountainDetail(Long mountainId) { + Mountain mountain = findMountainById(mountainId); + + return new MountainDetailResponse( + MountainInfo.from(mountain), + findCourses(mountainId), + findTransportationGroup(mountainId), + findAmenitiesByDirection(mountainId), + findRestaurantSections(mountainId), + findReviews(mountainId) + ); + } + + private Mountain findMountainById(Long mountainId) { + return mountainRepository.findById(mountainId) + .orElseThrow(() -> new GeneralException(ErrorStatus.MOUNTAIN_NOT_FOUND)); + } + + private List findCourses(Long mountainId) { + return courseRepository.findByMountainId(mountainId).stream() + .map(CourseInfo::from) + .toList(); + } + + private TransportationGroup findTransportationGroup(Long mountainId) { + return TransportationGroup.from(transportationRepository.findByMountainId(mountainId)); + } + + private Map> findAmenitiesByDirection(Long mountainId) { + return MountainDetailResponse.groupAmenities(amenityRepository.findByMountainId(mountainId)); + } + + private List findRestaurantSections(Long mountainId) { + return restaurantSectionRepository.findByMountainIdWithRestaurants(mountainId).stream() + .map(RestaurantSectionInfo::from) + .toList(); + } + + private List findReviews(Long mountainId) { + return reviewService.getReviewsByMountainId(mountainId).stream() + .map(ReviewInfo::from) + .toList(); + } +} diff --git a/src/main/java/com/semosan/api/domain/review/entity/Review.java b/src/main/java/com/semosan/api/domain/review/entity/Review.java new file mode 100644 index 00000000..be5590f7 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/review/entity/Review.java @@ -0,0 +1,44 @@ +package com.semosan.api.domain.review.entity; + +import com.semosan.api.common.base.BaseEntity; +import com.semosan.api.domain.mountain.entity.Course; +import com.semosan.api.domain.mountain.entity.Mountain; +import com.semosan.api.domain.mountain.enums.Difficulty; +import com.semosan.api.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +@Table(name = "reviews") +@Getter +@Entity +@Builder(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Review extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mountain_id", nullable = false) + private Mountain mountain; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "course_id") + private Course course; + + @Column(name = "content", nullable = false, columnDefinition = "TEXT") + private String content; + + @Enumerated(EnumType.STRING) + @Column(name = "difficulty", nullable = false, length = 20) + private Difficulty difficulty; + + @Column(name = "image_url", length = 500) + private String imageUrl; +} diff --git a/src/main/java/com/semosan/api/domain/review/repository/ReviewRepository.java b/src/main/java/com/semosan/api/domain/review/repository/ReviewRepository.java new file mode 100644 index 00000000..2fea9860 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/review/repository/ReviewRepository.java @@ -0,0 +1,16 @@ +package com.semosan.api.domain.review.repository; + +import com.semosan.api.domain.review.entity.Review; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface ReviewRepository extends JpaRepository { + + @EntityGraph(attributePaths = {"user", "course"}) + @Query("SELECT r FROM Review r WHERE r.mountain.id = :mountainId ORDER BY r.createdAt DESC LIMIT :limit") + List findRecentByMountainId(@Param("mountainId") Long mountainId, @Param("limit") int limit); +} diff --git a/src/main/java/com/semosan/api/domain/review/service/ReviewService.java b/src/main/java/com/semosan/api/domain/review/service/ReviewService.java new file mode 100644 index 00000000..63557112 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/review/service/ReviewService.java @@ -0,0 +1,23 @@ +package com.semosan.api.domain.review.service; + +import com.semosan.api.domain.review.entity.Review; +import com.semosan.api.domain.review.repository.ReviewRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ReviewService { + + private static final int DEFAULT_REVIEW_LIMIT = 20; + + private final ReviewRepository reviewRepository; + + public List getReviewsByMountainId(Long mountainId) { + return reviewRepository.findRecentByMountainId(mountainId, DEFAULT_REVIEW_LIMIT); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index adec01bd..5ec112ad 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -5,5 +5,32 @@ spring: profiles: default: local + datasource: + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: update + + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + +jwt: + secret: ${JWT_SECRET} + access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION} + refresh-token-expiration: ${JWT_REFRESH_TOKEN_EXPIRATION} + +kakao: + client-id: ${KAKAO_CLIENT_ID} + redirect-uri: ${KAKAO_REDIRECT_URI} + client-secret: ${KAKAO_CLIENT_SECRET} + admin-key: ${KAKAO_ADMIN_KEY} + +test: + secret-key: ${TEST_SECRET_KEY} firebase: service-account-path: ${FIREBASE_SERVICE_ACCOUNT_PATH:firebase/semosan-b2593-firebase-adminsdk-fbsvc-0d55d3b4e9.json}