diff --git a/src/main/java/com/solucitation/midpoint_backend/domain/history2/api/SearchHistoryControllerV2.java b/src/main/java/com/solucitation/midpoint_backend/domain/history2/api/SearchHistoryControllerV2.java new file mode 100644 index 0000000..3b42eb4 --- /dev/null +++ b/src/main/java/com/solucitation/midpoint_backend/domain/history2/api/SearchHistoryControllerV2.java @@ -0,0 +1,132 @@ +package com.solucitation.midpoint_backend.domain.history2.api; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.solucitation.midpoint_backend.domain.history2.dto.PlaceDtoV2; +import com.solucitation.midpoint_backend.domain.history2.dto.SearchHistoryRequestDtoV2; +import com.solucitation.midpoint_backend.domain.history2.dto.SearchHistoryResponseDtoV2; +import com.solucitation.midpoint_backend.domain.history2.service.SearchHistoryServiceV2; +import com.solucitation.midpoint_backend.domain.member.dto.ValidationErrorResponse; +import com.solucitation.midpoint_backend.domain.member.entity.Member; +import com.solucitation.midpoint_backend.domain.member.service.MemberService; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validator; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/search-history-v2") +public class SearchHistoryControllerV2 { + private final MemberService memberService; + private final Validator validator; + private final SearchHistoryServiceV2 searchHistoryService; + + /** + * + * @param authentication 인증 정보 + * @param request 검색 기록 저장에 필요한 정보 (동 정보, 장소 리스트) + * @return 장소를 성공적으로 저장하면 201 CREATED를 반환합니다. + * 로그인을 하지 않고 시도 시 401 UNAUTHORIZED를 반환합니다. + * 저장하려는 장소 정보에 문제가 있을 때 (필드가 공백이거나 null일 경우) 400 BAD_REQUEST를 반환합니다. + * 기타 사유로 저장 실패 시 500 INTERNAL_SERVER_ERROR를 반환합니다. + */ + @PostMapping("") + public ResponseEntity saveHistory(Authentication authentication, + @RequestBody SearchHistoryRequestDtoV2 request) { + try { + String neighborhood = request.getNeighborhood(); + List searchHistoryRequestDtos = request.getHistoryDto(); + + System.out.println("ssearchHistoryRequestDtos.size()= " +searchHistoryRequestDtos.size()); + for (PlaceDtoV2 searchHistoryRequestDto : searchHistoryRequestDtos) { + System.out.println("searchHistoryRequestDto = " + searchHistoryRequestDto.getPlaceId()); + } + + // neighborhood 검증 + if (neighborhood == null || neighborhood.trim().isEmpty()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "EMPTY_FIELD", "message", "동 정보가 누락되었습니다.")); + } + + // 인증 검증 + if (authentication == null || !authentication.isAuthenticated()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "UNAUTHORIZED", "message", "해당 서비스를 이용하기 위해서는 로그인이 필요합니다.")); + } + + String memberEmail = authentication.getName(); + Member member = memberService.getMemberByEmail(memberEmail); + + // 리스트 내의 모든 요소에 대한 검증 수행 + for (PlaceDtoV2 place : searchHistoryRequestDtos) { + Set> violations = validator.validate(place); + if (!violations.isEmpty()) { + List fieldErrors = violations.stream() + .map(violation -> new ValidationErrorResponse.FieldError(violation.getPropertyPath().toString(), violation.getMessage())) + .collect(Collectors.toList()); + ValidationErrorResponse errorResponse = new ValidationErrorResponse(fieldErrors); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + } + + // 데이터 저장 + searchHistoryService.save(neighborhood, memberEmail, searchHistoryRequestDtos); + return ResponseEntity.status(HttpStatus.CREATED).body(Map.of("message", "장소를 저장하였습니다.")); + + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage()); + } catch (RuntimeException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "USER_NOT_FOUND", "message", "사용자를 찾을 수 없습니다.")); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", e.getMessage(), "message", "장소 저장 중 오류가 발생하였습니다.")); + } + } + + + /** + * 사용자가 지금까지 저장한 검색 정보를 전부 최신순부터 가져옵니다. + * + * @param authentication 인증 정보 + * @return 검색 기록 조회가 성공하면 200 OK와 결과 리스트를 반환합니다. + * 로그인을 하지 않고 시도 시 401 UNAUTHORIZED를 반환합니다. + * 사용자를 찾을 수 없는 경우 404 NOT_FOUND를 반환합니다. + * 기타 사유로 저장 실패 시 500 INTERNAL_SERVER_ERROR를 반환합니다. + */ + @GetMapping("") + public ResponseEntity getHistory(Authentication authentication) { + try { + if (authentication == null || !authentication.isAuthenticated()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "UNAUTHORIZED", "message", "해당 서비스를 이용하기 위해서는 로그인이 필요합니다.")); + } + + String memberEmail = authentication.getName(); + Member member = memberService.getMemberByEmail(memberEmail); + if (member == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "USER_NOT_FOUND", "message", "사용자를 찾을 수 없습니다.")); + } + + List searchHistoryResponseDtos = searchHistoryService.getHistory(member); + return ResponseEntity.ok(searchHistoryResponseDtos); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", e.getMessage(), "message", "검색 기록 조회 중 오류가 발생하였습니다.")); + } + } + +} \ No newline at end of file diff --git a/src/main/java/com/solucitation/midpoint_backend/domain/history2/dto/PlaceDtoV2.java b/src/main/java/com/solucitation/midpoint_backend/domain/history2/dto/PlaceDtoV2.java new file mode 100644 index 0000000..ea76e1e --- /dev/null +++ b/src/main/java/com/solucitation/midpoint_backend/domain/history2/dto/PlaceDtoV2.java @@ -0,0 +1,22 @@ +package com.solucitation.midpoint_backend.domain.history2.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class PlaceDtoV2 { + @NotBlank(message = "장소ID가 누락되었습니다.") + private String placeId; // 장소의 고유 ID + + @NotBlank(message = "장소명이 누락되었습니다.") + private String placeName; // 장소의 이름 + + @NotBlank(message = "주소가 누락되었습니다.") + private String placeAddress; // 장소의 주소 + + private String imageUrl; // 장소 이미지 +} \ No newline at end of file diff --git a/src/main/java/com/solucitation/midpoint_backend/domain/history2/dto/SearchHistoryRequestDtoV2.java b/src/main/java/com/solucitation/midpoint_backend/domain/history2/dto/SearchHistoryRequestDtoV2.java new file mode 100644 index 0000000..61ce773 --- /dev/null +++ b/src/main/java/com/solucitation/midpoint_backend/domain/history2/dto/SearchHistoryRequestDtoV2.java @@ -0,0 +1,19 @@ +package com.solucitation.midpoint_backend.domain.history2.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class SearchHistoryRequestDtoV2 { + @NotBlank + private String neighborhood; + + private List historyDto = new ArrayList<>(); +} \ No newline at end of file diff --git a/src/main/java/com/solucitation/midpoint_backend/domain/history2/dto/SearchHistoryResponseDtoV2.java b/src/main/java/com/solucitation/midpoint_backend/domain/history2/dto/SearchHistoryResponseDtoV2.java new file mode 100644 index 0000000..af54737 --- /dev/null +++ b/src/main/java/com/solucitation/midpoint_backend/domain/history2/dto/SearchHistoryResponseDtoV2.java @@ -0,0 +1,18 @@ +package com.solucitation.midpoint_backend.domain.history2.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class SearchHistoryResponseDtoV2 { + private String neighborhood; + private LocalDateTime serachTime; + private List places = new ArrayList<>(); +} \ No newline at end of file diff --git a/src/main/java/com/solucitation/midpoint_backend/domain/history2/entity/PlaceInfoV2.java b/src/main/java/com/solucitation/midpoint_backend/domain/history2/entity/PlaceInfoV2.java new file mode 100644 index 0000000..3f76535 --- /dev/null +++ b/src/main/java/com/solucitation/midpoint_backend/domain/history2/entity/PlaceInfoV2.java @@ -0,0 +1,33 @@ +package com.solucitation.midpoint_backend.domain.history2.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "place_info") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class PlaceInfoV2 { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "place_info_id") + private Long id; + + @Column(name="place_id", nullable = false) + private String placeId; + + @ManyToOne + @JoinColumn(name = "search_history_id", nullable = false) + private SearchHistoryV2 searchHistory; + + @Column(name = "place_name", nullable = false) + private String name; + + @Column(name = "place_address", nullable = false) + private String address; + + @Column(name="place_image_url", nullable = false) + private String imageUrl; +} diff --git a/src/main/java/com/solucitation/midpoint_backend/domain/history2/entity/SearchHistoryV2.java b/src/main/java/com/solucitation/midpoint_backend/domain/history2/entity/SearchHistoryV2.java new file mode 100644 index 0000000..4b78595 --- /dev/null +++ b/src/main/java/com/solucitation/midpoint_backend/domain/history2/entity/SearchHistoryV2.java @@ -0,0 +1,40 @@ +package com.solucitation.midpoint_backend.domain.history2.entity; + +import com.solucitation.midpoint_backend.domain.member.entity.Member; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static jakarta.persistence.FetchType.LAZY; + +@Entity +@Table(name = "search_history") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class SearchHistoryV2 { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "search_history_id") + private Long id; + + @Column(name="neighborhood", nullable = false) + private String neighborhood; // 동 정보 ex. 청파동 + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @CreationTimestamp + @Column(name = "search_date", nullable = false, updatable = false) + private LocalDateTime searchDate; + + @OneToMany(mappedBy = "searchHistory", fetch = LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List placeList = new ArrayList<>(); +} diff --git a/src/main/java/com/solucitation/midpoint_backend/domain/history2/repository/SearchHistoryRepositoryV2.java b/src/main/java/com/solucitation/midpoint_backend/domain/history2/repository/SearchHistoryRepositoryV2.java new file mode 100644 index 0000000..0d86d35 --- /dev/null +++ b/src/main/java/com/solucitation/midpoint_backend/domain/history2/repository/SearchHistoryRepositoryV2.java @@ -0,0 +1,13 @@ +package com.solucitation.midpoint_backend.domain.history2.repository; + +import com.solucitation.midpoint_backend.domain.history2.entity.SearchHistoryV2; +import com.solucitation.midpoint_backend.domain.member.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface SearchHistoryRepositoryV2 extends JpaRepository { + List findByMemberOrderBySearchDateDesc(Member member); +} \ No newline at end of file diff --git a/src/main/java/com/solucitation/midpoint_backend/domain/history2/service/SearchHistoryServiceV2.java b/src/main/java/com/solucitation/midpoint_backend/domain/history2/service/SearchHistoryServiceV2.java new file mode 100644 index 0000000..5bfe767 --- /dev/null +++ b/src/main/java/com/solucitation/midpoint_backend/domain/history2/service/SearchHistoryServiceV2.java @@ -0,0 +1,78 @@ +package com.solucitation.midpoint_backend.domain.history2.service; + +import com.solucitation.midpoint_backend.domain.history2.dto.PlaceDtoV2; +import com.solucitation.midpoint_backend.domain.history2.dto.SearchHistoryRequestDtoV2; +import com.solucitation.midpoint_backend.domain.history2.dto.SearchHistoryResponseDtoV2; +import com.solucitation.midpoint_backend.domain.history2.entity.PlaceInfoV2; +import com.solucitation.midpoint_backend.domain.history2.entity.SearchHistoryV2; +import com.solucitation.midpoint_backend.domain.history2.repository.SearchHistoryRepositoryV2; +import com.solucitation.midpoint_backend.domain.member.entity.Member; +import com.solucitation.midpoint_backend.domain.member.service.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + + +@Service +@RequiredArgsConstructor +public class SearchHistoryServiceV2 { + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + private final String defaultImageUrl = String.format("https://%s.s3.%s.amazonaws.com/%s", bucket, "ap-northeast-2", "place_default_image.png"); + private final SearchHistoryRepositoryV2 searchHistoryRepository; + private final MemberService memberService; + + @Transactional + public void save(String neighborhood, String memberEmail, List placeDtos) { + Member member = memberService.getMemberByEmail(memberEmail); + + SearchHistoryV2 searchHistory = SearchHistoryV2.builder() + .member(member) + .neighborhood(neighborhood) + .build(); + + System.out.println("placeDtos.size() = " + placeDtos.size()); + for (PlaceDtoV2 dto : placeDtos) { + String imageUrl = dto.getImageUrl(); + if (imageUrl == null || imageUrl.trim().isEmpty()) { // 이미지 url이 없거나 공백 + imageUrl = defaultImageUrl; + } + + PlaceInfoV2 placeInfo = PlaceInfoV2.builder() + .name(dto.getPlaceName()) + .placeId(dto.getPlaceId()) + .address(dto.getPlaceAddress()) + .imageUrl(imageUrl) + .searchHistory(searchHistory) // searchHistory 필드 설정 + .build(); + searchHistory.getPlaceList().add(placeInfo); + System.out.println("placeInfo = " + placeInfo); + } + searchHistoryRepository.save(searchHistory); + } + + @Transactional(readOnly = true) + public List getHistory(Member member) { + List histories = searchHistoryRepository.findByMemberOrderBySearchDateDesc(member); + + return histories.stream() + .map(history -> new SearchHistoryResponseDtoV2( + history.getNeighborhood(), + history.getSearchDate(), + history.getPlaceList().stream() + .map(placeInfo -> new PlaceDtoV2( + placeInfo.getPlaceId(), + placeInfo.getName(), + placeInfo.getAddress(), + placeInfo.getImageUrl() + )) + .collect(Collectors.toList()) + )) + .collect(Collectors.toList()); + } +} \ No newline at end of file