feat(store-service): Just-Pickup 가게 검색 기능 추가

- slice 를 이용하여 더보기 버튼 로직 추가
- 쿼리에서 haversine 공식을 사용하여 가까운 거리순으로 가져오도록 구현
This commit is contained in:
bum12ark
2022-03-03 16:08:55 +09:00
parent 2252a53e26
commit 652d7acd75
9 changed files with 291 additions and 1 deletions

View File

@@ -1,8 +1,17 @@
package com.justpickup.storeservice;
import com.justpickup.storeservice.domain.map.entity.Map;
import com.justpickup.storeservice.domain.store.entity.Store;
import com.justpickup.storeservice.domain.store.repository.StoreRepository;
import com.justpickup.storeservice.global.entity.Address;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;
import org.springframework.context.annotation.Bean;
import java.util.ArrayList;
import java.util.List;
@SpringBootApplication
@EnableEurekaClient
@@ -12,4 +21,48 @@ public class StoreServiceApplication {
SpringApplication.run(StoreServiceApplication.class, args);
}
@Bean
CommandLineRunner run(StoreRepository storeRepository) {
return args -> {
List<Store> stores = new ArrayList<>();
stores.add(
Store.of(
new Address("서울시", "마포구 도화동", "201-20"),
Map.of(37.5398271003404, 126.94769672415691),
1L,
"커피온리 마포역점"
)
);
stores.add(
Store.of(
new Address("서울시", "마포구 도화동", "50-10"),
Map.of(37.54010719003089, 126.94556661330861),
2L,
"만랩커피 마포점"
)
);
stores.add(
Store.of(
new Address("서울시", "마포구 도화동", "555"),
Map.of(37.539797393793755, 126.9453578838543),
3L,
"이디야커피 마포오벨리스크점"
)
);
stores.add(
Store.of(
new Address("서울시", "영등포구 도림로", "31길 2"),
Map.of(37.493033141569505, 126.89593667847592),
4L,
"이디야커피 대림역점"
)
);
storeRepository.saveAll(stores);
};
}
}

View File

@@ -14,4 +14,15 @@ public class Map extends BaseEntity {
@Id @GeneratedValue
@Column(name = "map_id")
private Long id;
private Double latitude;
private Double longitude;
public static Map of (double latitude, double longitude) {
Map map = new Map();
map.latitude = latitude;
map.longitude = longitude;
return map;
}
}

View File

@@ -0,0 +1,15 @@
package com.justpickup.storeservice.domain.store.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import javax.validation.constraints.NotNull;
@Getter @AllArgsConstructor
public class SearchStoreCondition {
@NotNull(message = "필수 값입니다.")
private double latitude;
@NotNull(message = "필수 값입니다.")
private double longitude;
private String storeName;
}

View File

@@ -0,0 +1,26 @@
package com.justpickup.storeservice.domain.store.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.text.DecimalFormat;
@Getter @AllArgsConstructor
public class SearchStoreResult {
private Long storeId;
private String storeName;
private Double distanceMeter;
public String convertDistanceToString() {
// km 으로 표시
if (distanceMeter >= 1000) {
double km = distanceMeter * 0.001;
String format = new DecimalFormat("0.0").format(km);
// ex) 1.7km
return format + "km";
}
// ex) 621m
return new DecimalFormat("0").format(distanceMeter) + "m";
}
}

View File

@@ -29,6 +29,8 @@ public class Store extends BaseEntity {
private LocalDateTime businessEndTime;
private String name;
private String phoneNumber;
@Embedded
@@ -37,7 +39,7 @@ public class Store extends BaseEntity {
@Embedded
private Photo photo;
@OneToOne(fetch = LAZY)
@OneToOne(fetch = LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "map_id")
private Map map;
@@ -60,4 +62,13 @@ public class Store extends BaseEntity {
items.add(item);
item.setStore(this);
}
public static Store of(Address address, Map map, Long userId, String name) {
Store store = new Store();
store.address = address;
store.map = map;
store.userId = userId;
store.name = name;
return store;
}
}

View File

@@ -0,0 +1,80 @@
package com.justpickup.storeservice.domain.store.repository;
import com.justpickup.storeservice.domain.store.dto.SearchStoreCondition;
import com.justpickup.storeservice.domain.store.dto.SearchStoreResult;
import com.querydsl.core.types.Expression;
import com.querydsl.core.types.Projections;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.core.types.dsl.Expressions;
import com.querydsl.core.types.dsl.NumberExpression;
import com.querydsl.core.types.dsl.NumberPath;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.SliceImpl;
import org.springframework.stereotype.Repository;
import java.util.List;
import static com.justpickup.storeservice.domain.store.entity.QStore.store;
import static com.querydsl.core.types.dsl.MathExpressions.*;
@Repository
@RequiredArgsConstructor
@Slf4j
public class StoreRepositoryCustom {
private final JPAQueryFactory queryFactory;
public SliceImpl<SearchStoreResult> findSearchStoreScroll(SearchStoreCondition condition, Pageable pageable) {
Expression<Double> latitude = Expressions.constant(condition.getLatitude());
Expression<Double> longitude = Expressions.constant(condition.getLongitude());
NumberPath<Double> distanceAlias = Expressions.numberPath(Double.class, "distance");
int earthRadius = 6371;
NumberExpression<Double> haversineDistance = acos(
cos(radians(latitude))
.multiply(cos(radians(store.map.latitude)))
.multiply(
cos(radians(store.map.longitude)
.subtract(radians(longitude)))
)
.add(
sin(radians(latitude))
.multiply(sin(radians(store.map.latitude)))
)
)
.multiply(Expressions.constant(earthRadius * 1000));
List<SearchStoreResult> content = queryFactory.select(
Projections.constructor(SearchStoreResult.class,
store.id,
store.name,
haversineDistance.as(distanceAlias))
)
.from(store)
.join(store.map)
.where(
storeNameContains(condition.getStoreName())
)
.orderBy(distanceAlias.asc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize() + 1)
.fetch();
boolean hasNext = false;
if (content.size() > pageable.getPageSize()) {
content.remove(pageable.getPageSize());
hasNext = true;
}
return new SliceImpl<>(content, pageable, hasNext);
}
private BooleanExpression storeNameContains(String storeName) {
return storeName == null ? null : store.name.contains(storeName);
}
}

View File

@@ -0,0 +1,10 @@
package com.justpickup.storeservice.domain.store.service;
import com.justpickup.storeservice.domain.store.dto.SearchStoreCondition;
import com.justpickup.storeservice.domain.store.dto.SearchStoreResult;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.SliceImpl;
public interface StoreService {
SliceImpl<SearchStoreResult> findSearchStoreScroll(SearchStoreCondition condition, Pageable pageable);
}

View File

@@ -0,0 +1,21 @@
package com.justpickup.storeservice.domain.store.service;
import com.justpickup.storeservice.domain.store.dto.SearchStoreCondition;
import com.justpickup.storeservice.domain.store.dto.SearchStoreResult;
import com.justpickup.storeservice.domain.store.repository.StoreRepositoryCustom;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.SliceImpl;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class StoreServiceImpl implements StoreService {
private final StoreRepositoryCustom storeRepositoryCustom;
@Override
public SliceImpl<SearchStoreResult> findSearchStoreScroll(SearchStoreCondition condition, Pageable pageable) {
return storeRepositoryCustom.findSearchStoreScroll(condition, pageable);
}
}

View File

@@ -0,0 +1,63 @@
package com.justpickup.storeservice.domain.store.web;
import com.justpickup.storeservice.domain.store.dto.SearchStoreCondition;
import com.justpickup.storeservice.domain.store.dto.SearchStoreResult;
import com.justpickup.storeservice.domain.store.service.StoreService;
import com.justpickup.storeservice.global.dto.Result;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.SliceImpl;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequiredArgsConstructor
public class StoreController {
private final StoreService storeService;
@GetMapping("/searchStore")
public ResponseEntity<Result> searchStore(@Valid SearchStoreCondition condition,
@PageableDefault(page = 0, size = 2) Pageable pageable) {
SliceImpl<SearchStoreResult> searchStoreScroll = storeService.findSearchStoreScroll(condition, pageable);
SearchStoreResponse searchStoreResponse =
new SearchStoreResponse(searchStoreScroll.getContent(), searchStoreScroll.hasNext());
return ResponseEntity.status(HttpStatus.OK)
.body(Result.createSuccessResult(searchStoreResponse));
}
@Data @NoArgsConstructor
static class SearchStoreResponse {
private List<StoreDto> stores;
private boolean hasNext;
@Data @AllArgsConstructor
static class StoreDto {
private Long id;
private String name;
private String distance;
}
public SearchStoreResponse(List<SearchStoreResult> content, boolean hasNext) {
this.stores = content.stream()
.map(result ->
new StoreDto(
result.getStoreId(), result.getStoreName(), result.convertDistanceToString())
)
.collect(Collectors.toList());
this.hasNext = hasNext;
}
}
}