package com.tourguide.modules.operation.service;
|
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.tourguide.common.exception.BusinessException;
|
import com.tourguide.modules.operation.domain.ServiceType;
|
import com.tourguide.modules.operation.dto.MiniScenicMeetingPointResponse;
|
import com.tourguide.modules.operation.dto.MiniScenicServiceResponse;
|
import com.tourguide.modules.operation.dto.MiniScenicSpotResponse;
|
import com.tourguide.modules.operation.entity.OperationCity;
|
import com.tourguide.modules.operation.entity.OperationScenicSpot;
|
import com.tourguide.modules.operation.entity.ScenicMeetingPoint;
|
import com.tourguide.modules.operation.entity.ScenicPricing;
|
import com.tourguide.modules.operation.mapper.OperationCityMapper;
|
import com.tourguide.modules.operation.mapper.OperationScenicSpotMapper;
|
import com.tourguide.modules.operation.mapper.ScenicMeetingPointMapper;
|
import com.tourguide.modules.operation.mapper.ScenicPricingMapper;
|
import com.tourguide.modules.user.entity.GuideRealtimeStatus;
|
import com.tourguide.modules.user.mapper.GuideRealtimeStatusMapper;
|
import org.springframework.stereotype.Service;
|
import org.springframework.util.StringUtils;
|
|
import java.math.BigDecimal;
|
import java.math.RoundingMode;
|
import java.time.LocalDateTime;
|
import java.util.ArrayList;
|
import java.util.Collections;
|
import java.util.Comparator;
|
import java.util.List;
|
import java.util.Locale;
|
import java.util.Map;
|
import java.util.stream.Collectors;
|
|
/** 小程序景区查询服务。 */
|
@Service
|
public class MiniScenicSpotService {
|
private static final double EARTH_RADIUS_METERS = 6371000D;
|
private static final int MAX_LIST_LIMIT = 100;
|
|
private final OperationScenicSpotMapper scenicSpotMapper;
|
private final OperationCityMapper cityMapper;
|
private final ScenicMeetingPointMapper meetingPointMapper;
|
private final ScenicPricingMapper pricingMapper;
|
private final GuideRealtimeStatusMapper guideRealtimeStatusMapper;
|
|
public MiniScenicSpotService(OperationScenicSpotMapper scenicSpotMapper,
|
OperationCityMapper cityMapper,
|
ScenicMeetingPointMapper meetingPointMapper,
|
ScenicPricingMapper pricingMapper,
|
GuideRealtimeStatusMapper guideRealtimeStatusMapper) {
|
this.scenicSpotMapper = scenicSpotMapper;
|
this.cityMapper = cityMapper;
|
this.meetingPointMapper = meetingPointMapper;
|
this.pricingMapper = pricingMapper;
|
this.guideRealtimeStatusMapper = guideRealtimeStatusMapper;
|
}
|
|
public List<MiniScenicSpotResponse> list(String keyword, Double longitude, Double latitude, Integer limit) {
|
validateCoordinates(longitude, latitude);
|
List<OperationScenicSpot> scenicSpots = scenicSpotMapper.selectList(
|
new LambdaQueryWrapper<OperationScenicSpot>()
|
.eq(OperationScenicSpot::getStatus, 1)
|
.orderByAsc(OperationScenicSpot::getId));
|
if (scenicSpots.isEmpty()) {
|
return Collections.emptyList();
|
}
|
|
Map<Long, OperationCity> cityMap = loadCityMap(scenicSpots);
|
Map<Long, List<ScenicMeetingPoint>> meetingPointMap = loadMeetingPointMap(scenicSpots);
|
String normalizedKeyword = StringUtils.hasText(keyword) ? keyword.trim().toLowerCase(Locale.ROOT) : "";
|
List<ScenicDistanceItem> items = new ArrayList<ScenicDistanceItem>();
|
|
for (OperationScenicSpot scenicSpot : scenicSpots) {
|
OperationCity city = cityMap.get(scenicSpot.getCityId());
|
String cityName = city == null ? "" : city.getCityName();
|
if (!matchesKeyword(scenicSpot, cityName, normalizedKeyword)) {
|
continue;
|
}
|
|
ScenicMeetingPoint centerPoint = firstMeetingPoint(meetingPointMap.get(scenicSpot.getId()));
|
Long distanceMeters = calculateDistance(longitude, latitude, centerPoint);
|
items.add(new ScenicDistanceItem(scenicSpot, cityName, centerPoint, distanceMeters));
|
}
|
|
items.sort(Comparator
|
.comparing(ScenicDistanceItem::getDistanceMeters, Comparator.nullsLast(Long::compareTo))
|
.thenComparing(item -> item.getScenicSpot().getId()));
|
|
int resultLimit = normalizeLimit(limit, items.size());
|
return items.stream().limit(resultLimit).map(this::toScenicResponse).collect(Collectors.toList());
|
}
|
|
public MiniScenicSpotResponse nearest(Double longitude, Double latitude) {
|
List<MiniScenicSpotResponse> responses = list(null, longitude, latitude, 1);
|
return responses.isEmpty() ? null : responses.get(0);
|
}
|
|
public List<MiniScenicMeetingPointResponse> meetingPoints(Long scenicSpotId,
|
Double longitude, Double latitude) {
|
validateCoordinates(longitude, latitude);
|
requireEnabledScenicSpot(scenicSpotId);
|
List<ScenicMeetingPoint> points = meetingPointMapper.selectList(
|
new LambdaQueryWrapper<ScenicMeetingPoint>()
|
.eq(ScenicMeetingPoint::getScenicSpotId, scenicSpotId)
|
.eq(ScenicMeetingPoint::getStatus, 1)
|
.orderByDesc(ScenicMeetingPoint::getIsDefault)
|
.orderByAsc(ScenicMeetingPoint::getSortOrder)
|
.orderByAsc(ScenicMeetingPoint::getId));
|
Map<Long, Integer> guideCountMap = loadAvailableGuideCountMap(scenicSpotId);
|
List<MiniScenicMeetingPointResponse> responses = new ArrayList<MiniScenicMeetingPointResponse>();
|
for (int index = 0; index < points.size(); index++) {
|
ScenicMeetingPoint point = points.get(index);
|
Long distanceMeters = calculateDistance(longitude, latitude, point);
|
responses.add(new MiniScenicMeetingPointResponse(
|
point.getId(), point.getPointName(), point.getLongitude(), point.getLatitude(),
|
distanceMeters, formatDistance(distanceMeters),
|
guideCountMap.getOrDefault(point.getId(), 0), index == 0));
|
}
|
return responses;
|
}
|
|
public List<MiniScenicServiceResponse> services(Long scenicSpotId) {
|
requireEnabledScenicSpot(scenicSpotId);
|
List<ScenicPricing> pricingList = pricingMapper.selectList(
|
new LambdaQueryWrapper<ScenicPricing>()
|
.eq(ScenicPricing::getScenicSpotId, scenicSpotId)
|
.orderByAsc(ScenicPricing::getId));
|
Map<String, ScenicPricing> pricingMap = pricingList.stream().collect(Collectors.toMap(
|
ScenicPricing::getServiceType, value -> value, (left, right) -> left));
|
List<MiniScenicServiceResponse> responses = new ArrayList<MiniScenicServiceResponse>();
|
addServiceResponse(responses, pricingMap.get(ServiceType.GROUP_QUICK.getCode()), "拼团 · 快讲", true);
|
addServiceResponse(responses, pricingMap.get(ServiceType.GROUP_PREMIUM.getCode()), "拼团 · 精讲", true);
|
addServiceResponse(responses, pricingMap.get(ServiceType.PRIVATE_QUICK.getCode()), "包团 · 快讲", false);
|
addServiceResponse(responses, pricingMap.get(ServiceType.PRIVATE_PREMIUM.getCode()), "包团 · 精讲", false);
|
return responses;
|
}
|
|
private Map<Long, OperationCity> loadCityMap(List<OperationScenicSpot> scenicSpots) {
|
List<Long> cityIds = scenicSpots.stream().map(OperationScenicSpot::getCityId).distinct().collect(Collectors.toList());
|
if (cityIds.isEmpty()) {
|
return Collections.emptyMap();
|
}
|
return cityMapper.selectBatchIds(cityIds).stream().collect(Collectors.toMap(OperationCity::getId, value -> value));
|
}
|
|
private Map<Long, List<ScenicMeetingPoint>> loadMeetingPointMap(List<OperationScenicSpot> scenicSpots) {
|
List<Long> scenicSpotIds = scenicSpots.stream().map(OperationScenicSpot::getId).collect(Collectors.toList());
|
List<ScenicMeetingPoint> points = meetingPointMapper.selectList(
|
new LambdaQueryWrapper<ScenicMeetingPoint>()
|
.in(ScenicMeetingPoint::getScenicSpotId, scenicSpotIds)
|
.eq(ScenicMeetingPoint::getStatus, 1)
|
.orderByDesc(ScenicMeetingPoint::getIsDefault)
|
.orderByAsc(ScenicMeetingPoint::getSortOrder)
|
.orderByAsc(ScenicMeetingPoint::getId));
|
return points.stream().collect(Collectors.groupingBy(ScenicMeetingPoint::getScenicSpotId));
|
}
|
|
private Map<Long, Integer> loadAvailableGuideCountMap(Long scenicSpotId) {
|
List<GuideRealtimeStatus> statuses = guideRealtimeStatusMapper.selectList(
|
new LambdaQueryWrapper<GuideRealtimeStatus>()
|
.eq(GuideRealtimeStatus::getScenicSpotId, scenicSpotId)
|
.eq(GuideRealtimeStatus::getOnlineStatus, 1)
|
.eq(GuideRealtimeStatus::getAvailableStatus, 1)
|
.ge(GuideRealtimeStatus::getLastLocationAt, LocalDateTime.now().minusMinutes(5))
|
.isNotNull(GuideRealtimeStatus::getMeetingPointId));
|
Map<Long, Integer> countMap = new java.util.HashMap<Long, Integer>();
|
for (GuideRealtimeStatus status : statuses) {
|
Long meetingPointId = status.getMeetingPointId();
|
countMap.put(meetingPointId, countMap.getOrDefault(meetingPointId, 0) + 1);
|
}
|
return countMap;
|
}
|
|
private boolean matchesKeyword(OperationScenicSpot scenicSpot, String cityName, String keyword) {
|
if (!StringUtils.hasText(keyword)) {
|
return true;
|
}
|
return scenicSpot.getScenicName().toLowerCase(Locale.ROOT).contains(keyword)
|
|| cityName.toLowerCase(Locale.ROOT).contains(keyword);
|
}
|
|
private ScenicMeetingPoint firstMeetingPoint(List<ScenicMeetingPoint> points) {
|
return points == null || points.isEmpty() ? null : points.get(0);
|
}
|
|
private MiniScenicSpotResponse toScenicResponse(ScenicDistanceItem item) {
|
ScenicMeetingPoint point = item.getCenterPoint();
|
String pointName = point == null ? "" : point.getPointName();
|
return new MiniScenicSpotResponse(
|
item.getScenicSpot().getId(), item.getScenicSpot().getScenicCode(),
|
item.getScenicSpot().getScenicName(), item.getCityName(), pointName,
|
point == null ? null : point.getLongitude(), point == null ? null : point.getLatitude(),
|
item.getDistanceMeters(), formatDistance(item.getDistanceMeters()), pointName);
|
}
|
|
private void addServiceResponse(List<MiniScenicServiceResponse> responses, ScenicPricing pricing,
|
String name, boolean groupService) {
|
if (pricing == null) {
|
return;
|
}
|
long originalPriceCent = toPriceCent(pricing.getOriginalPrice());
|
long discountPriceCent = toPriceCent(pricing.getDiscountPrice());
|
Integer maxPeople = pricing.getMaxParticipants() == null ? pricing.getMinParticipants() : pricing.getMaxParticipants();
|
responses.add(new MiniScenicServiceResponse(
|
pricing.getId(), pricing.getServiceType(), name, pricing.getDurationMinutes(),
|
pricing.getMinParticipants(), maxPeople, originalPriceCent, discountPriceCent,
|
groupService ? "person" : "group", groupService));
|
}
|
|
private long toPriceCent(BigDecimal price) {
|
BigDecimal value = price == null ? BigDecimal.ZERO : price;
|
return value.multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP).longValue();
|
}
|
|
private void requireEnabledScenicSpot(Long scenicSpotId) {
|
OperationScenicSpot scenicSpot = scenicSpotMapper.selectOne(
|
new LambdaQueryWrapper<OperationScenicSpot>()
|
.eq(OperationScenicSpot::getId, scenicSpotId)
|
.eq(OperationScenicSpot::getStatus, 1));
|
if (scenicSpot == null) {
|
throw new BusinessException(404, "景区不存在或未启用");
|
}
|
}
|
|
private int normalizeLimit(Integer limit, int size) {
|
if (limit == null) {
|
return size;
|
}
|
if (limit <= 0) {
|
throw new BusinessException(400, "limit 必须大于 0");
|
}
|
return Math.min(limit, MAX_LIST_LIMIT);
|
}
|
|
private void validateCoordinates(Double longitude, Double latitude) {
|
if (longitude == null && latitude == null) {
|
return;
|
}
|
if (longitude == null || latitude == null) {
|
throw new BusinessException(400, "经纬度必须同时提供");
|
}
|
if (longitude < -180D || longitude > 180D || latitude < -90D || latitude > 90D) {
|
throw new BusinessException(400, "经纬度格式不正确");
|
}
|
}
|
|
private Long calculateDistance(Double longitude, Double latitude, ScenicMeetingPoint point) {
|
if (longitude == null || latitude == null || point == null
|
|| point.getLongitude() == null || point.getLatitude() == null) {
|
return null;
|
}
|
double startLatitude = Math.toRadians(latitude);
|
double endLatitude = Math.toRadians(point.getLatitude().doubleValue());
|
double latitudeDelta = endLatitude - startLatitude;
|
double longitudeDelta = Math.toRadians(point.getLongitude().doubleValue() - longitude);
|
double haversine = Math.pow(Math.sin(latitudeDelta / 2D), 2D)
|
+ Math.cos(startLatitude) * Math.cos(endLatitude)
|
* Math.pow(Math.sin(longitudeDelta / 2D), 2D);
|
double distance = 2D * EARTH_RADIUS_METERS
|
* Math.atan2(Math.sqrt(haversine), Math.sqrt(1D - haversine));
|
return Math.round(distance);
|
}
|
|
private String formatDistance(Long distanceMeters) {
|
if (distanceMeters == null) {
|
return "";
|
}
|
if (distanceMeters < 1000L) {
|
return distanceMeters + "m";
|
}
|
return String.format(Locale.ROOT, "%.1fkm", distanceMeters / 1000D);
|
}
|
|
private static class ScenicDistanceItem {
|
private final OperationScenicSpot scenicSpot;
|
private final String cityName;
|
private final ScenicMeetingPoint centerPoint;
|
private final Long distanceMeters;
|
|
private ScenicDistanceItem(OperationScenicSpot scenicSpot, String cityName,
|
ScenicMeetingPoint centerPoint, Long distanceMeters) {
|
this.scenicSpot = scenicSpot;
|
this.cityName = cityName;
|
this.centerPoint = centerPoint;
|
this.distanceMeters = distanceMeters;
|
}
|
|
public OperationScenicSpot getScenicSpot() { return scenicSpot; }
|
public String getCityName() { return cityName; }
|
public ScenicMeetingPoint getCenterPoint() { return centerPoint; }
|
public Long getDistanceMeters() { return distanceMeters; }
|
}
|
}
|