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 list(String keyword, Double longitude, Double latitude, Integer limit) { validateCoordinates(longitude, latitude); List scenicSpots = scenicSpotMapper.selectList( new LambdaQueryWrapper() .eq(OperationScenicSpot::getStatus, 1) .orderByAsc(OperationScenicSpot::getId)); if (scenicSpots.isEmpty()) { return Collections.emptyList(); } Map cityMap = loadCityMap(scenicSpots); Map> meetingPointMap = loadMeetingPointMap(scenicSpots); String normalizedKeyword = StringUtils.hasText(keyword) ? keyword.trim().toLowerCase(Locale.ROOT) : ""; List items = new ArrayList(); 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 responses = list(null, longitude, latitude, 1); return responses.isEmpty() ? null : responses.get(0); } public List meetingPoints(Long scenicSpotId, Double longitude, Double latitude) { validateCoordinates(longitude, latitude); requireEnabledScenicSpot(scenicSpotId); List points = meetingPointMapper.selectList( new LambdaQueryWrapper() .eq(ScenicMeetingPoint::getScenicSpotId, scenicSpotId) .eq(ScenicMeetingPoint::getStatus, 1) .orderByDesc(ScenicMeetingPoint::getIsDefault) .orderByAsc(ScenicMeetingPoint::getSortOrder) .orderByAsc(ScenicMeetingPoint::getId)); Map guideCountMap = loadAvailableGuideCountMap(scenicSpotId); List responses = new ArrayList(); 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)); } responses.sort(Comparator .comparing(MiniScenicMeetingPointResponse::getDistanceMeters, Comparator.nullsLast(Long::compareTo)) .thenComparing(MiniScenicMeetingPointResponse::getMeetingPointId)); return responses; } public List services(Long scenicSpotId) { requireEnabledScenicSpot(scenicSpotId); List pricingList = pricingMapper.selectList( new LambdaQueryWrapper() .eq(ScenicPricing::getScenicSpotId, scenicSpotId) .orderByAsc(ScenicPricing::getId)); Map pricingMap = pricingList.stream().collect(Collectors.toMap( ScenicPricing::getServiceType, value -> value, (left, right) -> left)); List responses = new ArrayList(); 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 loadCityMap(List scenicSpots) { List 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> loadMeetingPointMap(List scenicSpots) { List scenicSpotIds = scenicSpots.stream().map(OperationScenicSpot::getId).collect(Collectors.toList()); List points = meetingPointMapper.selectList( new LambdaQueryWrapper() .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 loadAvailableGuideCountMap(Long scenicSpotId) { List statuses = guideRealtimeStatusMapper.selectList( new LambdaQueryWrapper() .eq(GuideRealtimeStatus::getScenicSpotId, scenicSpotId) .eq(GuideRealtimeStatus::getOnlineStatus, 1) .eq(GuideRealtimeStatus::getAvailableStatus, 1) .ge(GuideRealtimeStatus::getLastLocationAt, LocalDateTime.now().minusMinutes(5)) .isNotNull(GuideRealtimeStatus::getMeetingPointId)); Map countMap = new java.util.HashMap(); 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 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, item.getScenicSpot().getGrade()); } private void addServiceResponse(List 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() .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; } } }