Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEAT/#37] 추천 검색어 조회 API 구현 #43

Merged
merged 14 commits into from
Jan 21, 2025
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ public class MemberService {
private final VerifiedAreaRepository verifiedAreaRepository;
private final SpotRepository spotRepository;

private final GuidedSpotMapper guidedSpotMapper;
private final MemberMapper memberMapper;
private final PreferenceMapper preferenceMapper;
private final GuidedSpotMapper guidedSpotMapper;

private final JwtUtils jwtUtils;
private final GoogleSocialService googleSocialService;
Expand Down Expand Up @@ -142,7 +142,6 @@ public void createGuidedSpot(final Long spotId, final Long memberId) {
.build()
)
);

}

public String createMemberArea(final Double latitude, final Double longitude, final Long memberId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@
public interface GuidedSpotRepository extends JpaRepository<GuidedSpotEntity, Long> {

Optional<GuidedSpotEntity> findByMemberIdAndSpotId(Long memberId, Long spotId);

Optional<GuidedSpotEntity> findTopByMemberIdOrderByUpdatedAtDesc(Long memberId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@

import com.acon.server.spot.api.response.MenuListResponse;
import com.acon.server.spot.api.response.SearchSpotListResponse;
import com.acon.server.spot.api.response.SearchSuggestionListResponse;
import com.acon.server.spot.api.response.SpotDetailResponse;
import com.acon.server.spot.api.response.VerifiedSpotResponse;
import com.acon.server.spot.application.service.SpotService;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Positive;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
Expand Down Expand Up @@ -42,6 +45,20 @@ public ResponseEntity<MenuListResponse> getMenus(
);
}

@GetMapping("/search-suggestions")
public ResponseEntity<SearchSuggestionListResponse> getSearchSuggestions(
@DecimalMin(value = "33.1", message = "위도는 최소 33.1°N 이상이어야 합니다.(대한민국 기준)")
@DecimalMax(value = "38.6", message = "위도는 최대 38.6°N 이하이어야 합니다.(대한민국 기준)")
@Validated @RequestParam(name = "latitude") final Double latitude,
@DecimalMin(value = "124.6", message = "경도는 최소 124.6°E 이상이어야 합니다.(대한민국 기준)")
@DecimalMax(value = "131.9", message = "경도는 최대 131.9°E 이하이어야 합니다.(대한민국 기준)")
@Validated @RequestParam(name = "longitude") final Double longitude
) {
return ResponseEntity.ok(
spotService.fetchSearchSuggestions(latitude, longitude)
);
}

// TODO: 메서드 네이밍 수정 필요
@GetMapping("/spots/search")
public ResponseEntity<SearchSpotListResponse> searchSpot(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.acon.server.spot.api.response;

import java.util.List;

public record SearchSuggestionListResponse(
List<SearchSuggestionResponse> suggestionList
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.acon.server.spot.api.response;

public record SearchSuggestionResponse(
Long spotId,
String spotName
) {

}
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
package com.acon.server.spot.application.mapper;

import com.acon.server.spot.api.response.SearchSuggestionResponse;
import com.acon.server.spot.api.response.SpotDetailResponse;
import com.acon.server.spot.infra.entity.SpotEntity;
import java.util.List;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.ReportingPolicy;

@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface SpotDtoMapper {

SpotDetailResponse toSpotDetailResponse(SpotEntity spotEntity, List<String> imageList, boolean openStatus);

@Mapping(target = "spotId", source = "id")
@Mapping(target = "spotName", source = "name")
SearchSuggestionResponse toSearchSuggestionResponse(SpotEntity spotEntity);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@
import com.acon.server.global.exception.ErrorType;
import com.acon.server.global.external.GeoCodingResponse;
import com.acon.server.global.external.NaverMapsAdapter;
import com.acon.server.member.infra.repository.GuidedSpotRepository;
import com.acon.server.spot.api.response.MenuListResponse;
import com.acon.server.spot.api.response.MenuResponse;
import com.acon.server.spot.api.response.SearchSpotListResponse;
import com.acon.server.spot.api.response.SearchSpotResponse;
import com.acon.server.spot.api.response.SearchSuggestionListResponse;
import com.acon.server.spot.api.response.SearchSuggestionResponse;
import com.acon.server.spot.api.response.SpotDetailResponse;
import com.acon.server.spot.application.mapper.SpotDtoMapper;
import com.acon.server.spot.application.mapper.SpotMapper;
Expand All @@ -24,6 +27,9 @@
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
Expand All @@ -34,12 +40,15 @@
@Slf4j
public class SpotService {

private final static int DISTANCE_RANGE = 250;
private static final int WALKING_RADIUS_30_MIN = 2000;
private static final int SUGGESTION_LIMIT = 5;
private static final int VERIFICATION_DISTANCE = 250;

private final SpotRepository spotRepository;
private final MenuRepository menuRepository;
private final OpeningHourRepository openingHourRepository;
private final SpotImageRepository spotImageRepository;
private final GuidedSpotRepository guidedSpotRepository;

private final SpotDtoMapper spotDtoMapper;
private final SpotMapper spotMapper;
Expand All @@ -58,16 +67,16 @@ public void updateNullCoordinatesForSpots() {

log.info("위도 또는 경도 정보가 비어 있는 Spot 데이터를 {}건 찾았습니다.", spotEntityList.size());

List<SpotEntity> updatedEntities = spotEntityList.stream()
List<SpotEntity> updatedEntityList = spotEntityList.stream()
.map(spotEntity -> {
Spot spot = spotMapper.toDomain(spotEntity);
updateSpotCoordinate(spot);
return spotMapper.toEntity(spot);
})
.toList();
spotRepository.saveAll(updatedEntities);
spotRepository.saveAll(updatedEntityList);

log.info("위도 또는 경도 정보가 비어 있는 Spot 데이터 {}건을 업데이트 했습니다.", updatedEntities.size());
log.info("위도 또는 경도 정보가 비어 있는 Spot 데이터 {}건을 업데이트 했습니다.", updatedEntityList.size());
}

// 메서드 설명: spotId에 해당하는 Spot의 좌표를 업데이트한다. (주소 -> 좌표)
Expand All @@ -78,8 +87,11 @@ private void updateSpotCoordinate(final Spot spot) {
Double.parseDouble(geoCodingResponse.latitude()),
Double.parseDouble(geoCodingResponse.longitude())
);
spot.updateLocation();
}

// TODO: 장소 추천 시 메뉴 가격 변동이면 메인 메뉴 X 처리

// TODO: 트랜잭션 범위 고민하기
// 메서드 설명: spotId에 해당하는 Spot의 상세 정보를 조회한다. (메뉴, 이미지, 영업 여부 등)
@Transactional
Expand Down Expand Up @@ -164,8 +176,54 @@ public MenuListResponse fetchMenus(final Long spotId) {
return new MenuListResponse(menuResponseList);
}

@Transactional(readOnly = true)
public SearchSuggestionListResponse fetchSearchSuggestions(final Double latitude, final Double longitude) {
// TODO: 토큰 검증 이후 MemberID 추출 로직 필요
List<SearchSuggestionResponse> recentSpotSuggestion =
guidedSpotRepository.findTopByMemberIdOrderByUpdatedAtDesc(1L)
.flatMap(recentGuidedSpot -> spotRepository.findById(recentGuidedSpot.getSpotId()))
.map(spotDtoMapper::toSearchSuggestionResponse)
.stream()
.toList();

List<SearchSuggestionResponse> nearestSpotList =
findNearestSpotList(longitude, latitude, WALKING_RADIUS_30_MIN, SUGGESTION_LIMIT);

// Set을 통한 필터링 성능 향상
Set<Long> recentSpotIds = recentSpotSuggestion.stream()
.map(SearchSuggestionResponse::spotId)
.collect(Collectors.toSet());

List<SearchSuggestionResponse> filteredNearestSpotList = nearestSpotList.stream()
.filter(nearestSpot -> !recentSpotIds.contains(nearestSpot.spotId()))
.toList();

List<SearchSuggestionResponse> combinedSuggestionList = Stream.concat(
recentSpotSuggestion.stream(),
filteredNearestSpotList.stream()
)
.limit(SUGGESTION_LIMIT)
.toList();

return new SearchSuggestionListResponse(combinedSuggestionList);
}

public List<SearchSuggestionResponse> findNearestSpotList(
double longitude,
double latitude,
double radius,
int limit) {
List<Object[]> rawFindResults = spotRepository.findNearestSpots(longitude, latitude, radius, limit);

return rawFindResults.stream()
.map(result -> new SearchSuggestionResponse((Long) result[0], (String) result[1]))
.toList();
}

public SearchSpotListResponse searchSpot(final String keyword) {
List<SpotEntity> spotEntityList = spotRepository.findTop10ByNameContainsIgnoreCase(keyword);

// TODO: mapper로 변경
List<SearchSpotResponse> spotList = spotEntityList.stream()
.map(spotEntity -> SearchSpotResponse.builder()
.spotId(spotEntity.getId())
Expand All @@ -183,9 +241,9 @@ public boolean verifySpot(Long spotId, Double memberLongitude, Double memberLati
SpotEntity spotEntity = spotRepository.findByIdOrThrow(spotId);
Double spotLongitude = spotEntity.getLongitude();
Double spotLatitude = spotEntity.getLatitude();
Double distance = spotRepository.calculateDistance(memberLongitude, memberLatitude, spotLongitude,
spotLatitude);
Double distance =
spotRepository.calculateDistance(spotLongitude, spotLatitude, memberLongitude, memberLatitude);

return distance < DISTANCE_RANGE;
return distance < VERIFICATION_DISTANCE;
}
}
15 changes: 15 additions & 0 deletions src/main/java/com/acon/server/spot/domain/entity/Spot.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.Point;

@Getter
@ToString
public class Spot {

private static final GeometryFactory geometryFactory = new GeometryFactory();

private final Long id;
private final String name;
private final SpotType spotType;
Expand All @@ -21,6 +26,7 @@ public class Spot {
private LocalDateTime basicAcornUpdatedAt;
private Double latitude;
private Double longitude;
private Point geom;
private String adminDong;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
Expand All @@ -37,6 +43,7 @@ public Spot(
LocalDateTime basicAcornUpdatedAt,
Double latitude,
Double longitude,
Point geom,
String adminDong,
LocalDateTime createdAt,
LocalDateTime updatedAt
Expand All @@ -51,6 +58,7 @@ public Spot(
this.basicAcornUpdatedAt = basicAcornUpdatedAt;
this.latitude = latitude;
this.longitude = longitude;
this.geom = geom;
this.adminDong = adminDong;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
Expand All @@ -60,4 +68,11 @@ public void updateCoordinate(Double latitude, Double longitude) {
this.latitude = latitude;
this.longitude = longitude;
}

public void updateLocation() {
if (latitude != null && longitude != null) {
this.geom = geometryFactory.createPoint(new Coordinate(longitude, latitude));
this.geom.setSRID(4326);
}
}
}
10 changes: 10 additions & 0 deletions src/main/java/com/acon/server/spot/infra/entity/SpotEntity.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,15 @@
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import org.locationtech.jts.geom.Point;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "spot")
// TODO: 공간 인덱스 설정
public class SpotEntity extends BaseTimeEntity {

@Id
Expand Down Expand Up @@ -54,6 +58,10 @@ public class SpotEntity extends BaseTimeEntity {
@Column(name = "longitude")
private Double longitude;

@JdbcTypeCode(SqlTypes.GEOMETRY)
@Column(name = "geom", columnDefinition = "geometry(Point, 4326)")
private Point geom;

@Column(name = "admin_dong")
private String adminDong;

Expand All @@ -69,6 +77,7 @@ public SpotEntity(
String address,
Double latitude,
Double longitude,
Point geom,
String adminDong
) {
this.id = id;
Expand All @@ -82,6 +91,7 @@ public SpotEntity(
this.address = address;
this.latitude = latitude;
this.longitude = longitude;
this.geom = geom;
this.adminDong = adminDong;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,39 @@ default SpotEntity findByIdOrThrow(Long id) {
() -> new BusinessException(ErrorType.NOT_FOUND_SPOT_ERROR)
);
}


@Query(value = """
SELECT s.id, s.name
FROM spot s
WHERE ST_DWithin(
s.geom,
ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326),
:radius
)
ORDER BY ST_DistanceSphere(
s.geom,
ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)
)
LIMIT :limit
""", nativeQuery = true)
List<Object[]> findNearestSpots(
@Param("longitude") double longitude,
@Param("latitude") double latitude,
@Param("radius") double radius,
@Param("limit") int limit
);

// TODO: 함수 위치에 대한 고민 필요
@Query(value = """
SELECT ST_DistanceSphere(
ST_SetSRID(ST_MakePoint(:lon1, :lat1), 4326),
ST_SetSRID(ST_MakePoint(:lon2, :lat2), 4326)
)
""", nativeQuery = true)
Double calculateDistance(@Param("lon1") Double lon1,
@Param("lat1") Double lat1,
@Param("lon2") Double lon2,
@Param("lat2") Double lat2);
Double calculateDistance(
@Param("lon1") Double lon1,
@Param("lat1") Double lat1,
@Param("lon2") Double lon2,
@Param("lat2") Double lat2
);
}
Loading