判断坐标在大量地理围栏中落点的集合

323 阅读4分钟

需求

业务内容

在租车业务中,以门店服务用户模式运行。门店可以配置不限数量且可以重叠的地理围栏,来决定不同的区域给用户提供什么样的服务(送车上门,接送取车等),同时还可以跨城市划取地理围栏。在接口层面,用户传入参数(城市,经纬度,时间)就能获取到一系列的报价结果。

问题

1.在以前的设计中,直接取出搜索的城市中所有的门店,再进行围栏判断,效率低下,导致接口超时。
2.需要在大量的地理围栏中,快速判断坐标落在哪些围栏里。

实现思路

1.在配置地理围栏时,将所有的坐标点所属城市编码取出来,可以在队列里调用高德逆编码API,并且统一到查询需要的一层,如API获取到区,用户接口搜索则是市,那就都统一到市。
2.将坐标的城市依次筛选去重,在redis中使用hash表保存,表现为某个市下有哪些地理围栏,一个地理围栏可能属于多个城市,所以需要取出所有坐标点的城市编码。
3.用户进行筛选查询,传入城市编码和经纬度,先在reidis中取出对应城市的所有地理围栏,然后使用射线法,判断用户经纬度在哪些围栏里,再一次取出围栏进行业务判断,再取出门店内容。

总结

在三千多个地理围栏中,加上redis耗时,平均20ms即可确定结果。基本解决问题。

  1. 门店在地理围栏的概念上套了一个服务范围。也就是一个服务范围会有多个地理围栏,通过改造hash内的key实现。

贴几张图

消息队列进行逆编码和保存


public async Task ConsumeAsync(StoreServiceScoreQueueDto message, CancellationToken cancellationToken = new CancellationToken())
    {
        var model = await _storeServiceScopeRepository.GetModelAsync(message.ScoreId);
        if (model == null)
            return;

        //循环调用每个坐标,高德逆编码
        //只有旧数据,代表删除
        //如果数据里面带了城市,则不请求高德

        if (message.NewCoordinatesArr != null && message.NewCoordinatesArr.Any())
        {
            foreach (var coordinates in message.NewCoordinatesArr)
            {
                foreach (var item in coordinates)
                {
                    if (!string.IsNullOrEmpty(item.CityCode))
                        continue;
                    //高德逆编码
                    var code = await ReGeoCityCodeAsync(item.Lng, item.Lat);
                    item.CityCode = code;
                }
            }
        }

        var fields = $"{model.StoreId}_{message.ScoreId}";
        var pip = RedisHelper.StartPipe();
        //删除旧数据
        pip = DeleteCache(pip, message.OldCoordinatesArr, fields);
        //增加新数据
        pip = CreateCache(pip, message.NewCoordinatesArr, fields);
        pip.EndPipe();

        if (message.NewCoordinatesArr != null && message.NewCoordinatesArr.Any())
        {
            var oldCoordinates = model.Coordinates;
            model.Coordinates = JsonConvert.SerializeObject(message.NewCoordinatesArr);
            await _storeServiceScopeRepository.FieldUpdateAsync(model, new List<string> { nameof(model.Coordinates) });
            await _storeLogRepository.AddAsync(new StoreLogEntity
            {
                StoreId = model.StoreId,
                Type = StoreLogType.ServiceScope,
                Detail = $"更新服务范围({model.Name})围栏,旧:{oldCoordinates},新{model.Coordinates}"
            });
        }
    }

    /// <summary>
    /// 删除所有历史缓存
    /// </summary>
    /// <param name="pip"></param>
    /// <param name="data"></param>
    /// <param name="fields"></param>
    /// <returns></returns>
    private static CSRedisClientPipe<string> DeleteCache(CSRedisClientPipe<string> pip, List<List<CoordinatesDto>> data, string fields)
    {
        if (data == null || !data.Any())
            return pip;

        var cityCodes = new List<string>();
        foreach (var code in data)
        {
            cityCodes.AddRange(code.Select(w => w.CityCode));
        }

        cityCodes = cityCodes.Distinct().ToList();

        for (var i = 0; i < data.Count; i++)
        {
            foreach (var cityCode in cityCodes)
            {
                if (!string.IsNullOrEmpty(cityCode))
                    pip.HDel(string.Format(VendorRedisConsts.StoreServiceScoreCityKey, cityCode), $"{fields}_{i}");
            }
        }

        return pip;
    }

    /// <summary>
    /// 创建缓存
    /// </summary>
    /// <param name="pip"></param>
    /// <param name="data"></param>
    /// <param name="fields"></param>
    /// <returns></returns>
    private static CSRedisClientPipe<string> CreateCache(CSRedisClientPipe<string> pip, List<List<CoordinatesDto>> data,
        string fields)
    {
        if (data == null || !data.Any())
            return pip;

        for (var i = 0; i < data.Count; i++)
        {
            //根据每个电子围栏创建范围
            var coordinates = data[i];
            var cityCodes = coordinates.Select(d => d.CityCode).ToList();
            foreach (var cityCode in cityCodes)
            {
                if (!string.IsNullOrEmpty(cityCode))
                    pip.HSet(string.Format(VendorRedisConsts.StoreServiceScoreCityKey, cityCode), $"{fields}_{i}", coordinates);
            }
        }

        return pip;
    }

    /// <summary>
    /// 城市Code
    /// </summary>
    /// <param name="lng"></param>
    /// <param name="lat"></param>
    /// <returns></returns>
    private async Task<string> ReGeoCityCodeAsync(decimal lng, decimal lat)
    {
        try
        {
            var reGeo = await _gaoDeSdk.ReGeoAsync(lng, lat);
            if (reGeo == null || !reGeo.Success || string.IsNullOrEmpty(reGeo.RegeoCode.AddressComponent.AdCode))
            {
                _logger.LogWarning($"经纬度{lng}_{lat}逆编码失败");
                return string.Empty;
            }

            var adCode = reGeo.RegeoCode.AddressComponent.AdCode;
            var cityCode = adCode.Remove(adCode.Length - 2) + "00";
            var city = await _cityRepository.GetByCodeAsync(cityCode);
            if (city == null)
            {
                _logger.LogWarning($"高德城市{cityCode}在系统查询为空");
                return string.Empty;
            }
            return cityCode;
        }
        catch (Exception e)
        {
            _logger.LogWarning(e, "高德城市获取失败");
        }

        return string.Empty;

    }

坐标对象

public class SetStoreServiceScopeCoordinatesDto : BaseDto
{
    /// <summary>
    /// 服务范围坐标集合
    /// </summary>
    [Required]
    public List<List<CoordinatesDto>> CoordinatesArr { get; set; }
}

/// <summary>
/// 坐标
/// </summary>
public class CoordinatesDto
{
    /// <summary>
    /// 经度 
    /// </summary>
    [Required]
    public decimal Lng { get; set; }

    /// <summary>
    /// 纬度 
    /// </summary>
    [Required]
    public decimal Lat { get; set; }

    /// <summary>
    /// 城市Code
    /// </summary>
    public string CityCode { get; set; }
}

缓存内容 image.png hashkey表示:门店ID_服务范围ID_地理围栏序号

查询方法

  public async Task<List<StorePickScopeReturnScopeDto>> GetServiceScopeAsync(SearchStoreScopeDto dto)
    {
        //取出城市所有的电子围栏
        var key = string.Format(VendorRedisConsts.StoreServiceScoreCityKey, dto.CityCode.Remove(dto.CityCode.Length - 2) + "00");
        var areas = await _redisClient.HGetAllAsync<List<CoordinatesDto>>(key);
        if (areas == null || !areas.Any())
            return null;

        var pickIds = new List<string>();
        var returnIds = new List<string>();
        var same = dto.PickupLng == dto.ReturnLng && dto.PickupLat == dto.ReturnLat;

        //判断在范围的电子围栏
        //需要判断两次,分别是取还车
        foreach (var pair in areas)
        {
            var id = pair.Key.Split('_')[1];
            //需要判断,只有一个坐标则是五公里范围
            if (pair.Value.Count == 1)
            {
                //判断直线距离
                if (DistanceLessFive(dto.PickupLng, dto.PickupLat, pair.Value.First().Lng, pair.Value.First().Lat))
                {
                    pickIds.Add(id);
                    if (same)
                    {
                        returnIds.Add(id);
                        continue;
                    }
                }

                if (DistanceLessFive(dto.ReturnLng, dto.ReturnLat, pair.Value.First().Lng, pair.Value.First().Lat))
                {
                    returnIds.Add(id);
                }
            }
            else
            {
                //判断多边形范围
                if (ContainsPoint(pair.Value, dto.PickupLng, dto.PickupLat))
                {
                    pickIds.Add(id);
                    if (same)
                    {
                        returnIds.Add(id);
                        continue;
                    }
                }

                if (ContainsPoint(pair.Value, dto.ReturnLng, dto.ReturnLat))
                {
                    returnIds.Add(id);
                }
            }
        }

        if (!pickIds.Any() || !returnIds.Any())
            return null;
   }

射线法

 /// <summary>
    /// 判断一个点是否在一个多边形区域内
    /// </summary>
    /// <param name="mPoints"></param>
    /// <param name="point"></param>
    /// <returns></returns>
    public bool IsPolygonContainsPoint(List<GaoDePoint> mPoints, GaoDePoint point)
    {
        //https://www.codeprj.com/blog/abd1ce1.html
        //使用引射线法来判断,就是从该点出发引一条射线,看这条射线和所有边的交点数目。
        //如果有奇数个交点,则说明在内部,如果有偶数个交点,则说明在外部。
        //这是所有方法中计算量最小的方法,在光线追踪算法中有大量的应用。
        var nCross = 0;
        for (var i = 0; i < mPoints.Count; i++)
        {
            var p1 = mPoints[i];
            var p2 = mPoints[(i + 1) % mPoints.Count]; //相邻两条边p1,p2
            // 取多边形任意一个边,做点point的水平延长线,求解与当前边的交点个数
            // p1p2是水平线段,要么没有交点,要么有无限个交点
            if (p1.Longitude == p2.Longitude)
                continue;
            // point 在p1p2 底部 --> 无交点
            if (point.Longitude < Math.Min(p1.Longitude, p2.Longitude))
                continue;
            // point 在p1p2 顶部 --> 无交点
            if (point.Longitude >= Math.Max(p1.Longitude, p2.Longitude))
                continue;
            // 求解 point点水平线与当前p1p2边的交点的 X 坐标
            var latitude = (point.Longitude - p1.Longitude)
                           * (p2.Latitude - p1.Latitude) / (p2.Longitude - p1.Longitude)
                           + p1.Latitude;
            // 当x=point.x时,说明point在p1p2线段上
            if (latitude > point.Latitude)
                nCross++; // 只统计单边交点
        }

        // 单边交点为偶数,点在多边形之外
        return nCross % 2 == 1;
    }