From a12eb33fdab92aa0231f81b2d98b8ba4e23e8f2b Mon Sep 17 00:00:00 2001 From: eunjungL <62099953+eunjungL@users.noreply.github.com> Date: Thu, 18 Jul 2024 12:19:37 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[Feature]=20-=20=EC=97=AC=ED=96=89=20?= =?UTF-8?q?=EA=B3=84=ED=9A=8D=20=EC=9E=91=EC=84=B1=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: TravelPlan 관련 Entity 추가 * feat: 여행기 작성 API 구현 * feat: swagger 설명 추가 * feat: 여행 계획 작성 시 지난 날짜에 대한 검증 추가 * refactor: TravelPlanService method 분리 * refactor: PlanPlaceRequest에 toPlace 추가 * style: TravelPlanControllere 개행 정리 --- .../global/exception/BadRequestException.java | 8 +++ .../touroot/place/domain/Place.java | 15 +++-- .../place/repository/PlaceRepository.java | 4 ++ .../controller/TravelPlanController.java | 31 ++++++++++ .../touroot/travelplan/domain/TravelPlan.java | 42 +++++++++++++ .../travelplan/domain/TravelPlanDay.java | 35 +++++++++++ .../travelplan/domain/TravelPlanPlace.java | 37 +++++++++++ .../travelplan/dto/PlanDayCreateRequest.java | 23 +++++++ .../dto/PlanLocationCreateRequest.java | 14 +++++ .../dto/PlanPlaceCreateRequest.java | 30 +++++++++ .../dto/TravelPlanCreateRequest.java | 26 ++++++++ .../dto/TravelPlanCreateResponse.java | 9 +++ .../repository/TravelPlanDayRepository.java | 7 +++ .../repository/TravelPlanPlaceRepository.java | 7 +++ .../repository/TravelPlanRepository.java | 7 +++ .../travelplan/service/TravelPlanService.java | 61 +++++++++++++++++++ .../travelplan/domain/TravelPlanTest.java | 25 ++++++++ 17 files changed, 376 insertions(+), 5 deletions(-) create mode 100644 backend/src/main/java/woowacourse/touroot/global/exception/BadRequestException.java create mode 100644 backend/src/main/java/woowacourse/touroot/travelplan/controller/TravelPlanController.java create mode 100644 backend/src/main/java/woowacourse/touroot/travelplan/domain/TravelPlan.java create mode 100644 backend/src/main/java/woowacourse/touroot/travelplan/domain/TravelPlanDay.java create mode 100644 backend/src/main/java/woowacourse/touroot/travelplan/domain/TravelPlanPlace.java create mode 100644 backend/src/main/java/woowacourse/touroot/travelplan/dto/PlanDayCreateRequest.java create mode 100644 backend/src/main/java/woowacourse/touroot/travelplan/dto/PlanLocationCreateRequest.java create mode 100644 backend/src/main/java/woowacourse/touroot/travelplan/dto/PlanPlaceCreateRequest.java create mode 100644 backend/src/main/java/woowacourse/touroot/travelplan/dto/TravelPlanCreateRequest.java create mode 100644 backend/src/main/java/woowacourse/touroot/travelplan/dto/TravelPlanCreateResponse.java create mode 100644 backend/src/main/java/woowacourse/touroot/travelplan/repository/TravelPlanDayRepository.java create mode 100644 backend/src/main/java/woowacourse/touroot/travelplan/repository/TravelPlanPlaceRepository.java create mode 100644 backend/src/main/java/woowacourse/touroot/travelplan/repository/TravelPlanRepository.java create mode 100644 backend/src/main/java/woowacourse/touroot/travelplan/service/TravelPlanService.java create mode 100644 backend/src/test/java/woowacourse/touroot/travelplan/domain/TravelPlanTest.java diff --git a/backend/src/main/java/woowacourse/touroot/global/exception/BadRequestException.java b/backend/src/main/java/woowacourse/touroot/global/exception/BadRequestException.java new file mode 100644 index 00000000..94306533 --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/global/exception/BadRequestException.java @@ -0,0 +1,8 @@ +package woowacourse.touroot.global.exception; + +public class BadRequestException extends RuntimeException { + + public BadRequestException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/woowacourse/touroot/place/domain/Place.java b/backend/src/main/java/woowacourse/touroot/place/domain/Place.java index 5509ca4c..630c3a94 100644 --- a/backend/src/main/java/woowacourse/touroot/place/domain/Place.java +++ b/backend/src/main/java/woowacourse/touroot/place/domain/Place.java @@ -1,14 +1,15 @@ package woowacourse.touroot.place.domain; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import woowacourse.touroot.entity.BaseEntity; @Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) @Entity public class Place extends BaseEntity { @@ -26,4 +27,8 @@ public class Place extends BaseEntity { private String longitude; private String googlePlaceId; + + public Place(String name, String latitude, String longitude) { + this(null, name, latitude, longitude, null); + } } diff --git a/backend/src/main/java/woowacourse/touroot/place/repository/PlaceRepository.java b/backend/src/main/java/woowacourse/touroot/place/repository/PlaceRepository.java index a3933b86..4b0c8ce6 100644 --- a/backend/src/main/java/woowacourse/touroot/place/repository/PlaceRepository.java +++ b/backend/src/main/java/woowacourse/touroot/place/repository/PlaceRepository.java @@ -3,5 +3,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import woowacourse.touroot.place.domain.Place; +import java.util.Optional; + public interface PlaceRepository extends JpaRepository { + + Optional findByNameAndLatitudeAndLongitude(String name, String lat, String lng); } diff --git a/backend/src/main/java/woowacourse/touroot/travelplan/controller/TravelPlanController.java b/backend/src/main/java/woowacourse/touroot/travelplan/controller/TravelPlanController.java new file mode 100644 index 00000000..de03821f --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelplan/controller/TravelPlanController.java @@ -0,0 +1,31 @@ +package woowacourse.touroot.travelplan.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import woowacourse.touroot.travelplan.dto.TravelPlanCreateRequest; +import woowacourse.touroot.travelplan.dto.TravelPlanCreateResponse; +import woowacourse.touroot.travelplan.service.TravelPlanService; + +@Tag(name = "여행기") +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/travel-plans") +public class TravelPlanController { + + private final TravelPlanService travelPlanService; + + @Operation(summary = "여행기 생성") + @PostMapping + public ResponseEntity createTravelPlan(@Valid @RequestBody TravelPlanCreateRequest request) { + TravelPlanCreateResponse data = travelPlanService.createTravelPlan(request); + return ResponseEntity.ok() + .body(data); + } +} diff --git a/backend/src/main/java/woowacourse/touroot/travelplan/domain/TravelPlan.java b/backend/src/main/java/woowacourse/touroot/travelplan/domain/TravelPlan.java new file mode 100644 index 00000000..4699b2bc --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelplan/domain/TravelPlan.java @@ -0,0 +1,42 @@ +package woowacourse.touroot.travelplan.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import woowacourse.touroot.entity.BaseEntity; +import woowacourse.touroot.global.exception.BadRequestException; + +import java.time.LocalDate; +import java.util.List; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Entity +public class TravelPlan extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @Column(nullable = false) + private LocalDate startDate; + + @OneToMany(mappedBy = "plan") + private List days; + + public TravelPlan(String title, LocalDate startDate) { + this(null, title, startDate, null); + } + + public void validateStartDate() { + if (startDate.isBefore(LocalDate.now())) { + throw new BadRequestException("지난 날짜에 대한 계획은 작성할 수 없습니다."); + } + } +} diff --git a/backend/src/main/java/woowacourse/touroot/travelplan/domain/TravelPlanDay.java b/backend/src/main/java/woowacourse/touroot/travelplan/domain/TravelPlanDay.java new file mode 100644 index 00000000..fe08d0d3 --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelplan/domain/TravelPlanDay.java @@ -0,0 +1,35 @@ +package woowacourse.touroot.travelplan.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import woowacourse.touroot.entity.BaseEntity; + +import java.util.List; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Entity +public class TravelPlanDay extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "plan_day_order", nullable = false) + Integer order; + + @JoinColumn(name = "plan_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private TravelPlan plan; + + @OneToMany(mappedBy = "day") + private List places; + + public TravelPlanDay(int order, TravelPlan plan) { + this(null, order, plan, null); + } +} diff --git a/backend/src/main/java/woowacourse/touroot/travelplan/domain/TravelPlanPlace.java b/backend/src/main/java/woowacourse/touroot/travelplan/domain/TravelPlanPlace.java new file mode 100644 index 00000000..71733dad --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelplan/domain/TravelPlanPlace.java @@ -0,0 +1,37 @@ +package woowacourse.touroot.travelplan.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import woowacourse.touroot.entity.BaseEntity; +import woowacourse.touroot.place.domain.Place; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Entity +public class TravelPlanPlace extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String description; + + @Column(name = "plan_place_order", nullable = false) + private Integer order; + + @JoinColumn(name = "plan_day_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private TravelPlanDay day; + + @JoinColumn(name = "place_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Place place; + + public TravelPlanPlace(String description, int order, TravelPlanDay day, Place place) { + this(null, description, order, day, place); + } +} diff --git a/backend/src/main/java/woowacourse/touroot/travelplan/dto/PlanDayCreateRequest.java b/backend/src/main/java/woowacourse/touroot/travelplan/dto/PlanDayCreateRequest.java new file mode 100644 index 00000000..99aedbca --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelplan/dto/PlanDayCreateRequest.java @@ -0,0 +1,23 @@ +package woowacourse.touroot.travelplan.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import woowacourse.touroot.travelplan.domain.TravelPlan; +import woowacourse.touroot.travelplan.domain.TravelPlanDay; + +import java.util.List; + +public record PlanDayCreateRequest( + @Schema(description = "여행 계획 날짜", example = "1") + @NotNull(message = "날짜는 비어있을 수 없습니다.") + @Min(value = 0, message = "날짜는 1 이상이어야 합니다.") + int day, + @Schema(description = "여행 장소 정보") + @NotNull(message = "여행 장소 정보는 비어있을 수 없습니다.") List places +) { + + public TravelPlanDay toPlanDay(TravelPlan plan) { + return new TravelPlanDay(day, plan); + } +} diff --git a/backend/src/main/java/woowacourse/touroot/travelplan/dto/PlanLocationCreateRequest.java b/backend/src/main/java/woowacourse/touroot/travelplan/dto/PlanLocationCreateRequest.java new file mode 100644 index 00000000..5ba1bd42 --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelplan/dto/PlanLocationCreateRequest.java @@ -0,0 +1,14 @@ +package woowacourse.touroot.travelplan.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record PlanLocationCreateRequest( + @Schema(description = "여행 장소 위도", example = "37.5175896") + @NotNull(message = "위도는 비어있을 수 없습니다.") + String lat, + @Schema(description = "여행 장소 경도", example = "127.0867236") + @NotNull(message = "경도는 비어있을 수 없습니다.") + String lng +) { +} diff --git a/backend/src/main/java/woowacourse/touroot/travelplan/dto/PlanPlaceCreateRequest.java b/backend/src/main/java/woowacourse/touroot/travelplan/dto/PlanPlaceCreateRequest.java new file mode 100644 index 00000000..182cf88b --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelplan/dto/PlanPlaceCreateRequest.java @@ -0,0 +1,30 @@ +package woowacourse.touroot.travelplan.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import woowacourse.touroot.place.domain.Place; +import woowacourse.touroot.travelplan.domain.TravelPlanDay; +import woowacourse.touroot.travelplan.domain.TravelPlanPlace; + +public record PlanPlaceCreateRequest( + @Schema(description = "여행 장소 이름", example = "신나는 여행 장소") + @NotBlank(message = "장소명은 비어있을 수 없습니다.") String placeName, + @Schema(description = "여행 장소 설명", example = "잠실한강공원") + String description, + @Schema(description = "여행 장소 순서", example = "1") + @NotNull + @Min(value = 0, message = "순서는 1 이상이어야 합니다.") + int order, + @NotNull PlanLocationCreateRequest location +) { + + public TravelPlanPlace toPlanPlace(TravelPlanDay day, Place place) { + return new TravelPlanPlace(description, order, day, place); + } + + public Place toPlace() { + return new Place(placeName, location.lat(), location.lng()); + } +} diff --git a/backend/src/main/java/woowacourse/touroot/travelplan/dto/TravelPlanCreateRequest.java b/backend/src/main/java/woowacourse/touroot/travelplan/dto/TravelPlanCreateRequest.java new file mode 100644 index 00000000..e88e2fce --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelplan/dto/TravelPlanCreateRequest.java @@ -0,0 +1,26 @@ +package woowacourse.touroot.travelplan.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import woowacourse.touroot.travelplan.domain.TravelPlan; + +import java.time.LocalDate; +import java.util.List; + +public record TravelPlanCreateRequest( + @Schema(description = "여행 계획 제목", example = "신나는 잠실 한강 여행") + @NotBlank(message = "여행 계획 제목은 비어있을 수 없습니다.") + String title, + @Schema(description = "여행 계획 시작일", example = "2024-11-16") + @NotNull(message = "시작일은 비어있을 수 없습니다.") + LocalDate startDate, + @Schema(description = "여행 날짜 정보") + @NotNull(message = "여행 날짜 정보는 비어있을 수 없습니다.") + List days +) { + + public TravelPlan toTravelPlan() { + return new TravelPlan(title, startDate); + } +} diff --git a/backend/src/main/java/woowacourse/touroot/travelplan/dto/TravelPlanCreateResponse.java b/backend/src/main/java/woowacourse/touroot/travelplan/dto/TravelPlanCreateResponse.java new file mode 100644 index 00000000..26b147be --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelplan/dto/TravelPlanCreateResponse.java @@ -0,0 +1,9 @@ +package woowacourse.touroot.travelplan.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record TravelPlanCreateResponse( + @Schema(description = "생성된 여행 계획 id") + Long id +) { +} diff --git a/backend/src/main/java/woowacourse/touroot/travelplan/repository/TravelPlanDayRepository.java b/backend/src/main/java/woowacourse/touroot/travelplan/repository/TravelPlanDayRepository.java new file mode 100644 index 00000000..18bf271f --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelplan/repository/TravelPlanDayRepository.java @@ -0,0 +1,7 @@ +package woowacourse.touroot.travelplan.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import woowacourse.touroot.travelplan.domain.TravelPlanDay; + +public interface TravelPlanDayRepository extends JpaRepository { +} diff --git a/backend/src/main/java/woowacourse/touroot/travelplan/repository/TravelPlanPlaceRepository.java b/backend/src/main/java/woowacourse/touroot/travelplan/repository/TravelPlanPlaceRepository.java new file mode 100644 index 00000000..66d04742 --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelplan/repository/TravelPlanPlaceRepository.java @@ -0,0 +1,7 @@ +package woowacourse.touroot.travelplan.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import woowacourse.touroot.travelplan.domain.TravelPlanPlace; + +public interface TravelPlanPlaceRepository extends JpaRepository { +} diff --git a/backend/src/main/java/woowacourse/touroot/travelplan/repository/TravelPlanRepository.java b/backend/src/main/java/woowacourse/touroot/travelplan/repository/TravelPlanRepository.java new file mode 100644 index 00000000..0665c7a6 --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelplan/repository/TravelPlanRepository.java @@ -0,0 +1,7 @@ +package woowacourse.touroot.travelplan.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import woowacourse.touroot.travelplan.domain.TravelPlan; + +public interface TravelPlanRepository extends JpaRepository { +} diff --git a/backend/src/main/java/woowacourse/touroot/travelplan/service/TravelPlanService.java b/backend/src/main/java/woowacourse/touroot/travelplan/service/TravelPlanService.java new file mode 100644 index 00000000..f962ea03 --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelplan/service/TravelPlanService.java @@ -0,0 +1,61 @@ +package woowacourse.touroot.travelplan.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import woowacourse.touroot.place.domain.Place; +import woowacourse.touroot.place.repository.PlaceRepository; +import woowacourse.touroot.travelplan.domain.TravelPlan; +import woowacourse.touroot.travelplan.domain.TravelPlanDay; +import woowacourse.touroot.travelplan.dto.PlanDayCreateRequest; +import woowacourse.touroot.travelplan.dto.PlanPlaceCreateRequest; +import woowacourse.touroot.travelplan.dto.TravelPlanCreateRequest; +import woowacourse.touroot.travelplan.dto.TravelPlanCreateResponse; +import woowacourse.touroot.travelplan.repository.TravelPlanDayRepository; +import woowacourse.touroot.travelplan.repository.TravelPlanPlaceRepository; +import woowacourse.touroot.travelplan.repository.TravelPlanRepository; + +import java.util.List; + +@RequiredArgsConstructor +@Service +public class TravelPlanService { + + private final TravelPlanRepository travelPlanRepository; + private final TravelPlanDayRepository travelPlanDayRepository; + private final TravelPlanPlaceRepository travelPlanPlaceRepository; + private final PlaceRepository placeRepository; + + @Transactional + public TravelPlanCreateResponse createTravelPlan(TravelPlanCreateRequest request) { + TravelPlan travelPlan = request.toTravelPlan(); + travelPlan.validateStartDate(); + + TravelPlan savedTravelPlan = travelPlanRepository.save(travelPlan); + createPlanDay(request, savedTravelPlan); + + return new TravelPlanCreateResponse(savedTravelPlan.getId()); + } + + private void createPlanDay(TravelPlanCreateRequest request, TravelPlan savedTravelPlan) { + for (PlanDayCreateRequest dayRequest : request.days()) { + TravelPlanDay travelPlanDay = travelPlanDayRepository.save(dayRequest.toPlanDay(savedTravelPlan)); + createPlanPlace(dayRequest.places(), travelPlanDay); + } + } + + private void createPlanPlace(List request, TravelPlanDay travelPlanDay) { + for (PlanPlaceCreateRequest planRequest : request) { + Place place = getPlace(planRequest); + travelPlanPlaceRepository.save(planRequest.toPlanPlace(travelPlanDay, place)); + } + } + + private Place getPlace(PlanPlaceCreateRequest planRequest) { + return placeRepository.findByNameAndLatitudeAndLongitude( + planRequest.placeName(), + planRequest.location().lat(), + planRequest.location().lng() + ).orElseGet(() -> placeRepository.save(planRequest.toPlace())); + } +} diff --git a/backend/src/test/java/woowacourse/touroot/travelplan/domain/TravelPlanTest.java b/backend/src/test/java/woowacourse/touroot/travelplan/domain/TravelPlanTest.java new file mode 100644 index 00000000..0548a6a6 --- /dev/null +++ b/backend/src/test/java/woowacourse/touroot/travelplan/domain/TravelPlanTest.java @@ -0,0 +1,25 @@ +package woowacourse.touroot.travelplan.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import woowacourse.touroot.global.exception.BadRequestException; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThatCode; + +@DisplayName("여행 계획") +class TravelPlanTest { + + @DisplayName("여행 계획은 지난 날짜를 검증할 시 예외가 발생한다.") + @Test + void validateStartDate() { + // given + TravelPlan travelPlan = new TravelPlan("test", LocalDate.MIN); + + // when & then + assertThatCode(travelPlan::validateStartDate) + .isInstanceOf(BadRequestException.class) + .hasMessage("지난 날짜에 대한 계획은 작성할 수 없습니다."); + } +} From 15f98221039fe699ba91f294b106a068a17cf423 Mon Sep 17 00:00:00 2001 From: Lee Hangil Date: Thu, 18 Jul 2024 17:03:23 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[Feature]=20-=20=EC=97=AC=ED=96=89=EA=B8=B0?= =?UTF-8?q?=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20(#54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 패키지 구조 변경 * feat: 여행기 상세 조회 로직 리팩토링 - 각 엔티티 별 Service 계층으로 역할 위임 * refactor: 여행기 상세 조회 로직에 Facade 패턴 적용 * feat: 통합 테스트를 위한 RestAssured 의존성 추가 * feat: 테스트 Profile 추가 * test: 통합 테스트를 위한 유틸 클래스 작성 * refactor: 영속화 이전의 엔티티 객체 생성을 위한 생성자 추가 * test: 여행기 상세 조회 API 통합 테스트 작성 * refactor: DTO Bean Validation 추가 * refactor: Test profile 활성화 조건 추가 * fix: application.yml EOL 추가 * refactor: 코드 컨벤션에 맞춰 공백 라인 추가 * refactor: 커스텀 예외를 활용하도록 리팩토링 * refactor: Facade 클래스의 계층을 클래스명에 명시 * refactor: 전역 사용 커스텀 어노테이션 패키지 경로 변경 * refactor: Facade 패턴에 더 알맞게 각 서비스 클래스의 역할 분리 --- backend/build.gradle | 47 ++++++------ .../touroot/place/domain/Place.java | 4 + .../controller/TravelogueController.java | 13 +++- .../day/dto/TravelogueDayResponse.java | 7 -- .../repository/TravelogueDayRepository.java | 7 -- .../touroot/travelogue/domain/Travelogue.java | 13 ++-- .../day/domain/TravelogueDay.java | 15 ++-- .../domain/day/dto/TravelogueDayResponse.java | 18 +++++ .../repository/TravelogueDayRepository.java | 11 +++ .../day/service/TravelogueDayService.java | 28 +++++++ .../photo/domain/TraveloguePhoto.java | 13 +++- .../repository/TraveloguePhotoRepository.java | 11 +++ .../photo/service/TraveloguePhotoService.java | 25 ++++++ .../place/domain/TraveloguePlace.java | 29 +++++-- .../place/dto/TraveloguePlaceResponse.java | 28 +++++++ .../repsitory/TraveloguePlaceRepository.java | 11 +++ .../place/service/TraveloguePlaceService.java | 28 +++++++ .../travelogue/dto/TravelogueResponse.java | 21 ++++- .../repository/TraveloguePhotoRepository.java | 7 -- .../place/dto/TraveloguePlaceResponse.java | 12 --- .../repsitory/TraveloguePlaceRepository.java | 7 -- .../service/TravelogueFacadeService.java | 76 +++++++++++++++++++ .../travelogue/service/TravelogueService.java | 46 +---------- backend/src/main/resources/application.yml | 24 ++++++ .../touroot/global/AcceptanceTest.java | 13 ++++ .../controller/TravelogueControllerTest.java | 46 +++++++++++ .../touroot/utils/DatabaseCleaner.java | 74 ++++++++++++++++++ .../touroot/utils/TestFixture.java | 70 +++++++++++++++++ 28 files changed, 573 insertions(+), 131 deletions(-) delete mode 100644 backend/src/main/java/woowacourse/touroot/travelogue/day/dto/TravelogueDayResponse.java delete mode 100644 backend/src/main/java/woowacourse/touroot/travelogue/day/repository/TravelogueDayRepository.java rename backend/src/main/java/woowacourse/touroot/travelogue/{ => domain}/day/domain/TravelogueDay.java (67%) create mode 100644 backend/src/main/java/woowacourse/touroot/travelogue/domain/day/dto/TravelogueDayResponse.java create mode 100644 backend/src/main/java/woowacourse/touroot/travelogue/domain/day/repository/TravelogueDayRepository.java create mode 100644 backend/src/main/java/woowacourse/touroot/travelogue/domain/day/service/TravelogueDayService.java rename backend/src/main/java/woowacourse/touroot/travelogue/{ => domain}/photo/domain/TraveloguePhoto.java (62%) create mode 100644 backend/src/main/java/woowacourse/touroot/travelogue/domain/photo/repository/TraveloguePhotoRepository.java create mode 100644 backend/src/main/java/woowacourse/touroot/travelogue/domain/photo/service/TraveloguePhotoService.java rename backend/src/main/java/woowacourse/touroot/travelogue/{ => domain}/place/domain/TraveloguePlace.java (55%) create mode 100644 backend/src/main/java/woowacourse/touroot/travelogue/domain/place/dto/TraveloguePlaceResponse.java create mode 100644 backend/src/main/java/woowacourse/touroot/travelogue/domain/place/repsitory/TraveloguePlaceRepository.java create mode 100644 backend/src/main/java/woowacourse/touroot/travelogue/domain/place/service/TraveloguePlaceService.java delete mode 100644 backend/src/main/java/woowacourse/touroot/travelogue/photo/repository/TraveloguePhotoRepository.java delete mode 100644 backend/src/main/java/woowacourse/touroot/travelogue/place/dto/TraveloguePlaceResponse.java delete mode 100644 backend/src/main/java/woowacourse/touroot/travelogue/place/repsitory/TraveloguePlaceRepository.java create mode 100644 backend/src/main/java/woowacourse/touroot/travelogue/service/TravelogueFacadeService.java create mode 100644 backend/src/test/java/woowacourse/touroot/global/AcceptanceTest.java create mode 100644 backend/src/test/java/woowacourse/touroot/travelogue/controller/TravelogueControllerTest.java create mode 100644 backend/src/test/java/woowacourse/touroot/utils/DatabaseCleaner.java create mode 100644 backend/src/test/java/woowacourse/touroot/utils/TestFixture.java diff --git a/backend/build.gradle b/backend/build.gradle index a8d8e817..c631365e 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -1,43 +1,44 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.3.1' - id 'io.spring.dependency-management' version '1.1.5' + id 'java' + id 'org.springframework.boot' version '3.3.1' + id 'io.spring.dependency-management' version '1.1.5' } group = 'woowacourse' version = '0.0.1-SNAPSHOT' java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } } configurations { - compileOnly { - extendsFrom annotationProcessor - } + compileOnly { + extendsFrom annotationProcessor + } } repositories { - mavenCentral() + mavenCentral() } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' - - compileOnly 'org.projectlombok:lombok' - runtimeOnly 'com.mysql:mysql-connector-j' - annotationProcessor 'org.projectlombok:lombok' - runtimeOnly 'com.h2database:h2' - - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' + + compileOnly 'org.projectlombok:lombok' + runtimeOnly 'com.mysql:mysql-connector-j' + annotationProcessor 'org.projectlombok:lombok' + runtimeOnly 'com.h2database:h2' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'io.rest-assured:rest-assured:5.5.0' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() } diff --git a/backend/src/main/java/woowacourse/touroot/place/domain/Place.java b/backend/src/main/java/woowacourse/touroot/place/domain/Place.java index 630c3a94..555d74cc 100644 --- a/backend/src/main/java/woowacourse/touroot/place/domain/Place.java +++ b/backend/src/main/java/woowacourse/touroot/place/domain/Place.java @@ -28,6 +28,10 @@ public class Place extends BaseEntity { private String googlePlaceId; + public Place(String name, String latitude, String longitude, String googlePlaceId) { + this(null, name, latitude, longitude, googlePlaceId); + } + public Place(String name, String latitude, String longitude) { this(null, name, latitude, longitude, null); } diff --git a/backend/src/main/java/woowacourse/touroot/travelogue/controller/TravelogueController.java b/backend/src/main/java/woowacourse/touroot/travelogue/controller/TravelogueController.java index cb2d5a8c..41792d9d 100644 --- a/backend/src/main/java/woowacourse/touroot/travelogue/controller/TravelogueController.java +++ b/backend/src/main/java/woowacourse/touroot/travelogue/controller/TravelogueController.java @@ -1,5 +1,8 @@ package woowacourse.touroot.travelogue.controller; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -7,17 +10,19 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import woowacourse.touroot.travelogue.dto.TravelogueResponse; -import woowacourse.touroot.travelogue.service.TravelogueService; +import woowacourse.touroot.travelogue.service.TravelogueFacadeService; +@Tag(name = "여행기") @RequiredArgsConstructor @RestController @RequestMapping("api/v1/travelogues") public class TravelogueController { - private final TravelogueService travelogueService; + private final TravelogueFacadeService travelogueFacadeService; + @Operation(description = "여행기 상세 조회") @GetMapping("/{id}") - public ResponseEntity findTravelogue(@PathVariable Long id) { - return ResponseEntity.ok(travelogueService.findTravelogueById(id)); + public ResponseEntity findTravelogue(@Valid @PathVariable Long id) { + return ResponseEntity.ok(travelogueFacadeService.findTravelogueById(id)); } } diff --git a/backend/src/main/java/woowacourse/touroot/travelogue/day/dto/TravelogueDayResponse.java b/backend/src/main/java/woowacourse/touroot/travelogue/day/dto/TravelogueDayResponse.java deleted file mode 100644 index ca111591..00000000 --- a/backend/src/main/java/woowacourse/touroot/travelogue/day/dto/TravelogueDayResponse.java +++ /dev/null @@ -1,7 +0,0 @@ -package woowacourse.touroot.travelogue.day.dto; - -import java.util.List; -import woowacourse.touroot.travelogue.place.dto.TraveloguePlaceResponse; - -public record TravelogueDayResponse(List places) { -} diff --git a/backend/src/main/java/woowacourse/touroot/travelogue/day/repository/TravelogueDayRepository.java b/backend/src/main/java/woowacourse/touroot/travelogue/day/repository/TravelogueDayRepository.java deleted file mode 100644 index a4e3c65d..00000000 --- a/backend/src/main/java/woowacourse/touroot/travelogue/day/repository/TravelogueDayRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package woowacourse.touroot.travelogue.day.repository; - -import org.springframework.data.jpa.repository.JpaRepository; -import woowacourse.touroot.travelogue.day.domain.TravelogueDay; - -public interface TravelogueDayRepository extends JpaRepository { -} diff --git a/backend/src/main/java/woowacourse/touroot/travelogue/domain/Travelogue.java b/backend/src/main/java/woowacourse/touroot/travelogue/domain/Travelogue.java index d3d0029c..045a53d2 100644 --- a/backend/src/main/java/woowacourse/touroot/travelogue/domain/Travelogue.java +++ b/backend/src/main/java/woowacourse/touroot/travelogue/domain/Travelogue.java @@ -5,13 +5,15 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.OneToMany; -import java.util.List; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import woowacourse.touroot.entity.BaseEntity; -import woowacourse.touroot.travelogue.day.domain.TravelogueDay; @Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) @Entity public class Travelogue extends BaseEntity { @@ -25,6 +27,7 @@ public class Travelogue extends BaseEntity { @Column(nullable = false) private String thumbnail; - @OneToMany(mappedBy = "travelogue") - private List travelogueDays; + public Travelogue(String title, String thumbnail) { + this(null, title, thumbnail); + } } diff --git a/backend/src/main/java/woowacourse/touroot/travelogue/day/domain/TravelogueDay.java b/backend/src/main/java/woowacourse/touroot/travelogue/domain/day/domain/TravelogueDay.java similarity index 67% rename from backend/src/main/java/woowacourse/touroot/travelogue/day/domain/TravelogueDay.java rename to backend/src/main/java/woowacourse/touroot/travelogue/domain/day/domain/TravelogueDay.java index d9d36580..e3d306e9 100644 --- a/backend/src/main/java/woowacourse/touroot/travelogue/day/domain/TravelogueDay.java +++ b/backend/src/main/java/woowacourse/touroot/travelogue/domain/day/domain/TravelogueDay.java @@ -1,4 +1,4 @@ -package woowacourse.touroot.travelogue.day.domain; +package woowacourse.touroot.travelogue.domain.day.domain; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -8,14 +8,16 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToMany; -import java.util.List; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import woowacourse.touroot.entity.BaseEntity; import woowacourse.touroot.travelogue.domain.Travelogue; -import woowacourse.touroot.travelogue.place.domain.TraveloguePlace; @Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) @Entity public class TravelogueDay extends BaseEntity { @@ -30,6 +32,7 @@ public class TravelogueDay extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) private Travelogue travelogue; - @OneToMany(mappedBy = "travelogueDay") - private List traveloguePlaces; + public TravelogueDay(Integer order, Travelogue travelogue) { + this(null, order, travelogue); + } } diff --git a/backend/src/main/java/woowacourse/touroot/travelogue/domain/day/dto/TravelogueDayResponse.java b/backend/src/main/java/woowacourse/touroot/travelogue/domain/day/dto/TravelogueDayResponse.java new file mode 100644 index 00000000..15fbd212 --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelogue/domain/day/dto/TravelogueDayResponse.java @@ -0,0 +1,18 @@ +package woowacourse.touroot.travelogue.domain.day.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import lombok.Builder; +import woowacourse.touroot.travelogue.domain.place.dto.TraveloguePlaceResponse; + +@Builder +public record TravelogueDayResponse( + @Schema(description = "여행기 일자 ID", example = "1") + @NotNull(message = "ID는 비어있을 수 없습니다.") + Long id, + @Schema(description = "여행기 장소 목록") + @NotNull(message = "여행기 장소 정보는 비어있을 수 없습니다.") + List places +) { +} diff --git a/backend/src/main/java/woowacourse/touroot/travelogue/domain/day/repository/TravelogueDayRepository.java b/backend/src/main/java/woowacourse/touroot/travelogue/domain/day/repository/TravelogueDayRepository.java new file mode 100644 index 00000000..d480bf83 --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelogue/domain/day/repository/TravelogueDayRepository.java @@ -0,0 +1,11 @@ +package woowacourse.touroot.travelogue.domain.day.repository; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import woowacourse.touroot.travelogue.domain.Travelogue; +import woowacourse.touroot.travelogue.domain.day.domain.TravelogueDay; + +public interface TravelogueDayRepository extends JpaRepository { + + List findByTravelogue(Travelogue travelogue); +} diff --git a/backend/src/main/java/woowacourse/touroot/travelogue/domain/day/service/TravelogueDayService.java b/backend/src/main/java/woowacourse/touroot/travelogue/domain/day/service/TravelogueDayService.java new file mode 100644 index 00000000..93a99bbd --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelogue/domain/day/service/TravelogueDayService.java @@ -0,0 +1,28 @@ +package woowacourse.touroot.travelogue.domain.day.service; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import woowacourse.touroot.global.exception.BadRequestException; +import woowacourse.touroot.travelogue.domain.Travelogue; +import woowacourse.touroot.travelogue.domain.day.domain.TravelogueDay; +import woowacourse.touroot.travelogue.domain.day.repository.TravelogueDayRepository; + +@RequiredArgsConstructor +@Service +public class TravelogueDayService { + + private final TravelogueDayRepository travelogueDayRepository; + + @Transactional(readOnly = true) + public List findDaysByTravelogue(Travelogue travelogue) { + return travelogueDayRepository.findByTravelogue(travelogue); + } + + @Transactional(readOnly = true) + public TravelogueDay findDayById(Long id) { + return travelogueDayRepository.findById(id) + .orElseThrow(() -> new BadRequestException("존재하지 않는 여행 일자입니다.")); + } +} diff --git a/backend/src/main/java/woowacourse/touroot/travelogue/photo/domain/TraveloguePhoto.java b/backend/src/main/java/woowacourse/touroot/travelogue/domain/photo/domain/TraveloguePhoto.java similarity index 62% rename from backend/src/main/java/woowacourse/touroot/travelogue/photo/domain/TraveloguePhoto.java rename to backend/src/main/java/woowacourse/touroot/travelogue/domain/photo/domain/TraveloguePhoto.java index 1c32f522..bdc59bd6 100644 --- a/backend/src/main/java/woowacourse/touroot/travelogue/photo/domain/TraveloguePhoto.java +++ b/backend/src/main/java/woowacourse/touroot/travelogue/domain/photo/domain/TraveloguePhoto.java @@ -1,4 +1,4 @@ -package woowacourse.touroot.travelogue.photo.domain; +package woowacourse.touroot.travelogue.domain.photo.domain; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -8,11 +8,16 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import woowacourse.touroot.entity.BaseEntity; -import woowacourse.touroot.travelogue.place.domain.TraveloguePlace; +import woowacourse.touroot.travelogue.domain.place.domain.TraveloguePlace; @Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) @Entity public class TraveloguePhoto extends BaseEntity { @@ -29,4 +34,8 @@ public class TraveloguePhoto extends BaseEntity { @JoinColumn(nullable = false) @ManyToOne(fetch = FetchType.LAZY) private TraveloguePlace traveloguePlace; + + public TraveloguePhoto(String key, Integer order, TraveloguePlace traveloguePlace) { + this(null, key, order, traveloguePlace); + } } diff --git a/backend/src/main/java/woowacourse/touroot/travelogue/domain/photo/repository/TraveloguePhotoRepository.java b/backend/src/main/java/woowacourse/touroot/travelogue/domain/photo/repository/TraveloguePhotoRepository.java new file mode 100644 index 00000000..a63e83a3 --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelogue/domain/photo/repository/TraveloguePhotoRepository.java @@ -0,0 +1,11 @@ +package woowacourse.touroot.travelogue.domain.photo.repository; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import woowacourse.touroot.travelogue.domain.photo.domain.TraveloguePhoto; +import woowacourse.touroot.travelogue.domain.place.domain.TraveloguePlace; + +public interface TraveloguePhotoRepository extends JpaRepository { + + List findByTraveloguePlace(TraveloguePlace traveloguePlace); +} diff --git a/backend/src/main/java/woowacourse/touroot/travelogue/domain/photo/service/TraveloguePhotoService.java b/backend/src/main/java/woowacourse/touroot/travelogue/domain/photo/service/TraveloguePhotoService.java new file mode 100644 index 00000000..eb082eb8 --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelogue/domain/photo/service/TraveloguePhotoService.java @@ -0,0 +1,25 @@ +package woowacourse.touroot.travelogue.domain.photo.service; + +import java.util.Comparator; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import woowacourse.touroot.travelogue.domain.photo.domain.TraveloguePhoto; +import woowacourse.touroot.travelogue.domain.photo.repository.TraveloguePhotoRepository; +import woowacourse.touroot.travelogue.domain.place.domain.TraveloguePlace; + +@RequiredArgsConstructor +@Service +public class TraveloguePhotoService { + + private final TraveloguePhotoRepository traveloguePhotoRepository; + + public List findPhotoUrlsByPlace(TraveloguePlace traveloguePlace) { + List photos = traveloguePhotoRepository.findByTraveloguePlace(traveloguePlace); + + return photos.stream() + .sorted(Comparator.comparing(TraveloguePhoto::getOrder)) + .map(TraveloguePhoto::getKey) + .toList(); + } +} diff --git a/backend/src/main/java/woowacourse/touroot/travelogue/place/domain/TraveloguePlace.java b/backend/src/main/java/woowacourse/touroot/travelogue/domain/place/domain/TraveloguePlace.java similarity index 55% rename from backend/src/main/java/woowacourse/touroot/travelogue/place/domain/TraveloguePlace.java rename to backend/src/main/java/woowacourse/touroot/travelogue/domain/place/domain/TraveloguePlace.java index a4bd7a6d..21942047 100644 --- a/backend/src/main/java/woowacourse/touroot/travelogue/place/domain/TraveloguePlace.java +++ b/backend/src/main/java/woowacourse/touroot/travelogue/domain/place/domain/TraveloguePlace.java @@ -1,4 +1,4 @@ -package woowacourse.touroot.travelogue.place.domain; +package woowacourse.touroot.travelogue.domain.place.domain; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -8,15 +8,17 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToMany; -import java.util.List; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import woowacourse.touroot.entity.BaseEntity; import woowacourse.touroot.place.domain.Place; -import woowacourse.touroot.travelogue.day.domain.TravelogueDay; -import woowacourse.touroot.travelogue.photo.domain.TraveloguePhoto; +import woowacourse.touroot.travelogue.domain.day.domain.TravelogueDay; @Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) @Entity public class TraveloguePlace extends BaseEntity { @@ -37,6 +39,19 @@ public class TraveloguePlace extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) private TravelogueDay travelogueDay; - @OneToMany(mappedBy = "traveloguePlace") - private List traveloguePhotos; + public TraveloguePlace(Integer order, String description, Place place, TravelogueDay travelogueDay) { + this(null, order, description, place, travelogueDay); + } + + public String getName() { + return place.getName(); + } + + public String getLatitude() { + return place.getLatitude(); + } + + public String getLongitude() { + return place.getLongitude(); + } } diff --git a/backend/src/main/java/woowacourse/touroot/travelogue/domain/place/dto/TraveloguePlaceResponse.java b/backend/src/main/java/woowacourse/touroot/travelogue/domain/place/dto/TraveloguePlaceResponse.java new file mode 100644 index 00000000..d89220af --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelogue/domain/place/dto/TraveloguePlaceResponse.java @@ -0,0 +1,28 @@ +package woowacourse.touroot.travelogue.domain.place.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import lombok.Builder; + +@Builder +public record TraveloguePlaceResponse( + @Schema(description = "여행기 장소 ID", example = "1") + @NotNull(message = "ID는 비어있을 수 없습니다.") + Long id, + @Schema(description = "여행기 장소 이름", example = "선릉 캠퍼스") + @NotBlank(message = "여행기 장소 이름은 비어있을 수 없습니다.") + String name, + @Schema(description = "여행기 장소 설명", example = "성담 빌딩에 위치한 선릉 캠퍼스입니다.") + @NotBlank(message = "여행기 장소 설명은 비어있을 수 없습니다.") + String description, + @Schema(description = "여행기 장소 위도", example = "37.5175896") + @NotBlank(message = "여행기 장소 위도는 비어있을 수 없습니다.") + String lat, + @Schema(description = "여행기 장소 설명", example = "127.0867236") + @NotBlank(message = "여행기 장소 경도는 비어있을 수 없습니다.") + String lng, + List photoUrls +) { +} diff --git a/backend/src/main/java/woowacourse/touroot/travelogue/domain/place/repsitory/TraveloguePlaceRepository.java b/backend/src/main/java/woowacourse/touroot/travelogue/domain/place/repsitory/TraveloguePlaceRepository.java new file mode 100644 index 00000000..d6e886c4 --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelogue/domain/place/repsitory/TraveloguePlaceRepository.java @@ -0,0 +1,11 @@ +package woowacourse.touroot.travelogue.domain.place.repsitory; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import woowacourse.touroot.travelogue.domain.day.domain.TravelogueDay; +import woowacourse.touroot.travelogue.domain.place.domain.TraveloguePlace; + +public interface TraveloguePlaceRepository extends JpaRepository { + + List findByTravelogueDay(TravelogueDay travelogueDay); +} diff --git a/backend/src/main/java/woowacourse/touroot/travelogue/domain/place/service/TraveloguePlaceService.java b/backend/src/main/java/woowacourse/touroot/travelogue/domain/place/service/TraveloguePlaceService.java new file mode 100644 index 00000000..9a6694de --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelogue/domain/place/service/TraveloguePlaceService.java @@ -0,0 +1,28 @@ +package woowacourse.touroot.travelogue.domain.place.service; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import woowacourse.touroot.global.exception.BadRequestException; +import woowacourse.touroot.travelogue.domain.day.domain.TravelogueDay; +import woowacourse.touroot.travelogue.domain.place.domain.TraveloguePlace; +import woowacourse.touroot.travelogue.domain.place.repsitory.TraveloguePlaceRepository; + +@RequiredArgsConstructor +@Service +public class TraveloguePlaceService { + + private final TraveloguePlaceRepository traveloguePlaceRepository; + + @Transactional(readOnly = true) + public List findTraveloguePlaceByDay(TravelogueDay travelogueDay) { + return traveloguePlaceRepository.findByTravelogueDay(travelogueDay); + } + + @Transactional(readOnly = true) + public TraveloguePlace findTraveloguePlaceById(Long id) { + return traveloguePlaceRepository.findById(id) + .orElseThrow(() -> new BadRequestException("존재하지 않는 여행 장소입니다.")); + } +} diff --git a/backend/src/main/java/woowacourse/touroot/travelogue/dto/TravelogueResponse.java b/backend/src/main/java/woowacourse/touroot/travelogue/dto/TravelogueResponse.java index bd669104..e9f44219 100644 --- a/backend/src/main/java/woowacourse/touroot/travelogue/dto/TravelogueResponse.java +++ b/backend/src/main/java/woowacourse/touroot/travelogue/dto/TravelogueResponse.java @@ -1,7 +1,24 @@ package woowacourse.touroot.travelogue.dto; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; import java.util.List; -import woowacourse.touroot.travelogue.day.dto.TravelogueDayResponse; +import lombok.Builder; +import woowacourse.touroot.travelogue.domain.day.dto.TravelogueDayResponse; -public record TravelogueResponse(String title, String thumbnail, List days) { +@Builder +public record TravelogueResponse( + @Schema(description = "여행기 ID", example = "1") + @NotNull(message = "ID는 비어있을 수 없습니다.") + Long id, + @Schema(description = "여행기 제목", example = "서울 강남 여행기") + @NotNull(message = "여행기 제목은 비어있을 수 없습니다.") + String title, + @Schema(description = "여행기 섬네일 링크", example = "https://섬네일.png") + @NotNull(message = "여행기 섬네일 링크는 비어있을 수 없습니다.") + String thumbnail, + @Schema(description = "여행기 일자 목록") + @NotNull(message = "여행기 일자 목록은 비어있을 수 없습니다.") + List days +) { } diff --git a/backend/src/main/java/woowacourse/touroot/travelogue/photo/repository/TraveloguePhotoRepository.java b/backend/src/main/java/woowacourse/touroot/travelogue/photo/repository/TraveloguePhotoRepository.java deleted file mode 100644 index e492080f..00000000 --- a/backend/src/main/java/woowacourse/touroot/travelogue/photo/repository/TraveloguePhotoRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package woowacourse.touroot.travelogue.photo.repository; - -import org.springframework.data.jpa.repository.JpaRepository; -import woowacourse.touroot.travelogue.photo.domain.TraveloguePhoto; - -public interface TraveloguePhotoRepository extends JpaRepository { -} diff --git a/backend/src/main/java/woowacourse/touroot/travelogue/place/dto/TraveloguePlaceResponse.java b/backend/src/main/java/woowacourse/touroot/travelogue/place/dto/TraveloguePlaceResponse.java deleted file mode 100644 index 349c784d..00000000 --- a/backend/src/main/java/woowacourse/touroot/travelogue/place/dto/TraveloguePlaceResponse.java +++ /dev/null @@ -1,12 +0,0 @@ -package woowacourse.touroot.travelogue.place.dto; - -import java.util.List; - -public record TraveloguePlaceResponse( - String name, - List photoUrls, - String description, - String latitude, - String longitude -) { -} diff --git a/backend/src/main/java/woowacourse/touroot/travelogue/place/repsitory/TraveloguePlaceRepository.java b/backend/src/main/java/woowacourse/touroot/travelogue/place/repsitory/TraveloguePlaceRepository.java deleted file mode 100644 index 3395e41b..00000000 --- a/backend/src/main/java/woowacourse/touroot/travelogue/place/repsitory/TraveloguePlaceRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package woowacourse.touroot.travelogue.place.repsitory; - -import org.springframework.data.jpa.repository.JpaRepository; -import woowacourse.touroot.travelogue.place.domain.TraveloguePlace; - -public interface TraveloguePlaceRepository extends JpaRepository { -} diff --git a/backend/src/main/java/woowacourse/touroot/travelogue/service/TravelogueFacadeService.java b/backend/src/main/java/woowacourse/touroot/travelogue/service/TravelogueFacadeService.java new file mode 100644 index 00000000..d2418d36 --- /dev/null +++ b/backend/src/main/java/woowacourse/touroot/travelogue/service/TravelogueFacadeService.java @@ -0,0 +1,76 @@ +package woowacourse.touroot.travelogue.service; + +import java.util.Comparator; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import woowacourse.touroot.travelogue.domain.Travelogue; +import woowacourse.touroot.travelogue.domain.day.domain.TravelogueDay; +import woowacourse.touroot.travelogue.domain.day.dto.TravelogueDayResponse; +import woowacourse.touroot.travelogue.domain.day.service.TravelogueDayService; +import woowacourse.touroot.travelogue.domain.photo.service.TraveloguePhotoService; +import woowacourse.touroot.travelogue.domain.place.domain.TraveloguePlace; +import woowacourse.touroot.travelogue.domain.place.dto.TraveloguePlaceResponse; +import woowacourse.touroot.travelogue.domain.place.service.TraveloguePlaceService; +import woowacourse.touroot.travelogue.dto.TravelogueResponse; + +@RequiredArgsConstructor +@Service +public class TravelogueFacadeService { + + private final TravelogueService travelogueService; + private final TravelogueDayService travelogueDayService; + private final TraveloguePlaceService traveloguePlaceService; + private final TraveloguePhotoService traveloguePhotoService; + + public TravelogueResponse findTravelogueById(Long id) { + Travelogue travelogue = travelogueService.getTravelogueById(id); + + return TravelogueResponse.builder() + .id(travelogue.getId()) + .title(travelogue.getTitle()) + .thumbnail(travelogue.getThumbnail()) + .days(findDaysOfTravelogue(travelogue)) + .build(); + } + + private List findDaysOfTravelogue(Travelogue travelogue) { + List travelogueDays = travelogueDayService.findDaysByTravelogue(travelogue); + + return travelogueDays.stream() + .sorted(Comparator.comparing(TravelogueDay::getOrder)) + .map(this::getTravelogueDayResponse) + .toList(); + } + + private TravelogueDayResponse getTravelogueDayResponse(TravelogueDay day) { + return TravelogueDayResponse.builder() + .id(day.getId()) + .places(findPlacesOfTravelogueDay(day)) + .build(); + } + + private List findPlacesOfTravelogueDay(TravelogueDay travelogueDay) { + List places = traveloguePlaceService.findTraveloguePlaceByDay(travelogueDay); + + return places.stream() + .sorted(Comparator.comparing(TraveloguePlace::getOrder)) + .map(this::getTraveloguePlaceResponse) + .toList(); + } + + private TraveloguePlaceResponse getTraveloguePlaceResponse(TraveloguePlace place) { + return TraveloguePlaceResponse.builder() + .id(place.getId()) + .name(place.getName()) + .description(place.getDescription()) + .lat(place.getLatitude()) + .lng(place.getLongitude()) + .photoUrls(findPhotoUrlsOfTraveloguePlace(place)) + .build(); + } + + private List findPhotoUrlsOfTraveloguePlace(TraveloguePlace place) { + return traveloguePhotoService.findPhotoUrlsByPlace(place); + } +} diff --git a/backend/src/main/java/woowacourse/touroot/travelogue/service/TravelogueService.java b/backend/src/main/java/woowacourse/touroot/travelogue/service/TravelogueService.java index fc1a7ed8..41fe3571 100644 --- a/backend/src/main/java/woowacourse/touroot/travelogue/service/TravelogueService.java +++ b/backend/src/main/java/woowacourse/touroot/travelogue/service/TravelogueService.java @@ -1,17 +1,10 @@ package woowacourse.touroot.travelogue.service; -import java.util.Comparator; -import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import woowacourse.touroot.travelogue.day.domain.TravelogueDay; -import woowacourse.touroot.travelogue.day.dto.TravelogueDayResponse; +import woowacourse.touroot.global.exception.BadRequestException; import woowacourse.touroot.travelogue.domain.Travelogue; -import woowacourse.touroot.travelogue.dto.TravelogueResponse; -import woowacourse.touroot.travelogue.photo.domain.TraveloguePhoto; -import woowacourse.touroot.travelogue.place.domain.TraveloguePlace; -import woowacourse.touroot.travelogue.place.dto.TraveloguePlaceResponse; import woowacourse.touroot.travelogue.repository.TravelogueRepository; @RequiredArgsConstructor @@ -21,39 +14,8 @@ public class TravelogueService { private final TravelogueRepository travelogueRepository; @Transactional(readOnly = true) - public TravelogueResponse findTravelogueById(Long id) { - Travelogue travelogue = travelogueRepository.findById(id).get(); - - return new TravelogueResponse(travelogue.getTitle(), travelogue.getThumbnail(), getDayResponses(travelogue)); - } - - private List getDayResponses(Travelogue travelogue) { - return travelogue.getTravelogueDays() - .stream() - .sorted(Comparator.comparing(TravelogueDay::getOrder)) - .map(travelogueDay -> new TravelogueDayResponse(getPlaceResponses(travelogueDay))) - .toList(); - } - - private List getPlaceResponses(TravelogueDay day) { - return day.getTraveloguePlaces() - .stream() - .sorted(Comparator.comparing(TraveloguePlace::getOrder)) - .map(traveloguePlace -> new TraveloguePlaceResponse( - traveloguePlace.getPlace().getName(), - getPhotoUrls(traveloguePlace), - traveloguePlace.getDescription(), - traveloguePlace.getPlace().getLatitude(), - traveloguePlace.getPlace().getLongitude() - )) - .toList(); - } - - private List getPhotoUrls(TraveloguePlace traveloguePlace) { - return traveloguePlace.getTraveloguePhotos() - .stream() - .sorted(Comparator.comparing(TraveloguePhoto::getOrder)) - .map(TraveloguePhoto::getKey) - .toList(); + public Travelogue getTravelogueById(Long id) { + return travelogueRepository.findById(id) + .orElseThrow(() -> new BadRequestException("존재하지 않는 여행기입니다.")); } } diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index bedfde4e..ea33582a 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -39,3 +39,27 @@ spring: dialect: org.hibernate.dialect.MySQLDialect hibernate: ddl-auto: none +--- +server: + port: 8081 +spring: + config: + activate: + on-profile: test + datasource: + url: jdbc:h2:mem:test + h2: + console: + enabled: true + path: /h2-console + jpa: + show-sql: true + properties: + hibernate: + format_sql: true + hibernate: + ddl-auto: create + defer-datasource-initialization: true + sql: + init: + mode: never diff --git a/backend/src/test/java/woowacourse/touroot/global/AcceptanceTest.java b/backend/src/test/java/woowacourse/touroot/global/AcceptanceTest.java new file mode 100644 index 00000000..2cd03d48 --- /dev/null +++ b/backend/src/test/java/woowacourse/touroot/global/AcceptanceTest.java @@ -0,0 +1,13 @@ +package woowacourse.touroot.global; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +@Retention(RetentionPolicy.RUNTIME) +public @interface AcceptanceTest { +} diff --git a/backend/src/test/java/woowacourse/touroot/travelogue/controller/TravelogueControllerTest.java b/backend/src/test/java/woowacourse/touroot/travelogue/controller/TravelogueControllerTest.java new file mode 100644 index 00000000..adfa3748 --- /dev/null +++ b/backend/src/test/java/woowacourse/touroot/travelogue/controller/TravelogueControllerTest.java @@ -0,0 +1,46 @@ +package woowacourse.touroot.travelogue.controller; + +import static org.hamcrest.Matchers.is; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.server.LocalServerPort; +import woowacourse.touroot.global.AcceptanceTest; +import woowacourse.touroot.utils.DatabaseCleaner; +import woowacourse.touroot.utils.TestFixture; + +@AcceptanceTest +class TravelogueControllerTest { + + @LocalServerPort + private int port; + + @Autowired + private DatabaseCleaner databaseCleaner; + + @Autowired + private TestFixture testFixture; + + @BeforeEach + void setUp() { + RestAssured.port = port; + + databaseCleaner.executeTruncate(); + testFixture.initTravelogueTestData(); + } + + @DisplayName("여행기를 상세 조회한다.") + @Test + void findTravelogue() { + RestAssured.given().log().all() + .accept(ContentType.JSON) + .when().get("/api/v1/travelogues/1") + .then().log().all() + .statusCode(200) + .body("title", is("여행기 1")); + } +} \ No newline at end of file diff --git a/backend/src/test/java/woowacourse/touroot/utils/DatabaseCleaner.java b/backend/src/test/java/woowacourse/touroot/utils/DatabaseCleaner.java new file mode 100644 index 00000000..264e6f63 --- /dev/null +++ b/backend/src/test/java/woowacourse/touroot/utils/DatabaseCleaner.java @@ -0,0 +1,74 @@ +package woowacourse.touroot.utils; + +import jakarta.annotation.PostConstruct; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.metamodel.EntityType; +import jakarta.transaction.Transactional; +import java.util.List; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +@Component +@Profile("test") +public class DatabaseCleaner { + + public static final String CAMEL_CASE = "([a-z])([A-Z])"; + public static final String SNAKE_CASE = "$1_$2"; + private static final String TRUNCATE_TABLE = "TRUNCATE TABLE %s"; + private static final String ALTER_COLUMN_ID = "ALTER TABLE %s ALTER COLUMN id RESTART WITH 1"; + public static final String INTEGRITY_FALSE = "SET REFERENTIAL_INTEGRITY FALSE"; + public static final String INTEGRITY_TRUE = "SET REFERENTIAL_INTEGRITY TRUE"; + + @PersistenceContext + private EntityManager entityManager; + + private List tableNames; + + @PostConstruct + public void findTableNames() { + tableNames = entityManager.getMetamodel().getEntities().stream() + .filter(e -> e.getJavaType().getAnnotation(Entity.class) != null) + .map(DatabaseCleaner::convertCamelToSnake) + .toList(); + } + + private static String convertCamelToSnake(final EntityType e) { + return e.getName() + .replaceAll(CAMEL_CASE, SNAKE_CASE) + .toLowerCase(); + } + + @Transactional + public void executeTruncate() { + entityManager.clear(); + + disableIntegrity(); + for (String tableName : tableNames) { + truncateTable(tableName); + resetIdColumn(tableName); + } + enableIntegrity(); + } + + private void disableIntegrity() { + entityManager.createNativeQuery(INTEGRITY_FALSE) + .executeUpdate(); + } + + private void truncateTable(final String tableName) { + entityManager.createNativeQuery(String.format(TRUNCATE_TABLE, tableName)) + .executeUpdate(); + } + + private void resetIdColumn(final String tableName) { + entityManager.createNativeQuery(String.format(ALTER_COLUMN_ID, tableName)) + .executeUpdate(); + } + + private void enableIntegrity() { + entityManager.createNativeQuery(INTEGRITY_TRUE) + .executeUpdate(); + } +} diff --git a/backend/src/test/java/woowacourse/touroot/utils/TestFixture.java b/backend/src/test/java/woowacourse/touroot/utils/TestFixture.java new file mode 100644 index 00000000..32a27c68 --- /dev/null +++ b/backend/src/test/java/woowacourse/touroot/utils/TestFixture.java @@ -0,0 +1,70 @@ +package woowacourse.touroot.utils; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import woowacourse.touroot.place.domain.Place; +import woowacourse.touroot.place.repository.PlaceRepository; +import woowacourse.touroot.travelogue.domain.Travelogue; +import woowacourse.touroot.travelogue.domain.day.domain.TravelogueDay; +import woowacourse.touroot.travelogue.domain.day.repository.TravelogueDayRepository; +import woowacourse.touroot.travelogue.domain.photo.domain.TraveloguePhoto; +import woowacourse.touroot.travelogue.domain.photo.repository.TraveloguePhotoRepository; +import woowacourse.touroot.travelogue.domain.place.domain.TraveloguePlace; +import woowacourse.touroot.travelogue.domain.place.repsitory.TraveloguePlaceRepository; +import woowacourse.touroot.travelogue.repository.TravelogueRepository; + +@Component +@Profile("test") +public class TestFixture { + + @Autowired + TravelogueRepository travelogueRepository; + + @Autowired + TravelogueDayRepository travelogueDayRepository; + + @Autowired + TraveloguePlaceRepository traveloguePlaceRepository; + + @Autowired + TraveloguePhotoRepository traveloguePhotoRepository; + + @Autowired + private PlaceRepository placeRepository; + + public static Travelogue getTravelogue(String name, String thumbnail) { + return new Travelogue(name, thumbnail); + } + + public static TravelogueDay getTravelogueDay(Integer order, Travelogue travelogue) { + return new TravelogueDay(order, travelogue); + } + + public static Place getPlace(String name, String latitude, String longitude, String googlePlaceId) { + return new Place(name, latitude, longitude, googlePlaceId); + } + + public static TraveloguePlace getTraveloguePlace(Integer order, String description, Place place, + TravelogueDay travelogueDay) { + return new TraveloguePlace(order, description, place, travelogueDay); + } + + public static TraveloguePhoto getTraveloguePhoto(String key, Integer order, TraveloguePlace traveloguePlace) { + return new TraveloguePhoto(key, order, traveloguePlace); + } + + public void initTravelogueTestData() { + Travelogue travelogue = getTravelogue("여행기 1", "썸네일.png"); + TravelogueDay travelogueDay = getTravelogueDay(1, travelogue); + Place place = getPlace("장소 1", "33.3333", "127.2727", ""); + TraveloguePlace traveloguePlace = getTraveloguePlace(1, "좋은 장소", place, travelogueDay); + TraveloguePhoto traveloguePhoto = getTraveloguePhoto("image", 1, traveloguePlace); + + travelogueRepository.save(travelogue); + travelogueDayRepository.save(travelogueDay); + placeRepository.save(place); + traveloguePlaceRepository.save(traveloguePlace); + traveloguePhotoRepository.save(traveloguePhoto); + } +}