需求
业务内容
在租车业务中,以门店服务用户模式运行。门店可以配置不限数量且可以重叠的地理围栏,来决定不同的区域给用户提供什么样的服务(送车上门,接送取车等),同时还可以跨城市划取地理围栏。在接口层面,用户传入参数(城市,经纬度,时间)就能获取到一系列的报价结果。
问题
1.在以前的设计中,直接取出搜索的城市中所有的门店,再进行围栏判断,效率低下,导致接口超时。
2.需要在大量的地理围栏中,快速判断坐标落在哪些围栏里。
实现思路
1.在配置地理围栏时,将所有的坐标点所属城市编码取出来,可以在队列里调用高德逆编码API,并且统一到查询需要的一层,如API获取到区,用户接口搜索则是市,那就都统一到市。
2.将坐标的城市依次筛选去重,在redis中使用hash表保存,表现为某个市下有哪些地理围栏,一个地理围栏可能属于多个城市,所以需要取出所有坐标点的城市编码。
3.用户进行筛选查询,传入城市编码和经纬度,先在reidis中取出对应城市的所有地理围栏,然后使用射线法,判断用户经纬度在哪些围栏里,再一次取出围栏进行业务判断,再取出门店内容。
总结
在三千多个地理围栏中,加上redis耗时,平均20ms即可确定结果。基本解决问题。
坑
- 门店在地理围栏的概念上套了一个服务范围。也就是一个服务范围会有多个地理围栏,通过改造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; }
}
缓存内容
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;
}