运营区边缘计算:区域数据缓存

This commit is contained in:
PC 2026-03-09 09:41:00 +08:00
parent 3df6c925ea
commit db2d7734c8
3 changed files with 359 additions and 4 deletions

View File

@ -0,0 +1,339 @@
package com.cdzy.operations.component;
import com.cdzy.operations.utils.RedisUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.locationtech.jts.geom.*;
import org.locationtech.jts.io.WKTReader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.*;
import java.util.stream.Collectors;
/**
* 网格索引构建器Spring Bean 版本无本地缓存
* 将运营区多边形边界切分为网格每个网格关联其覆盖的边界线段
* 提供构建索引发布到 Redis以及实时判断车辆是否靠近边缘的功能
* 每次判断均直接访问 Redis保证数据实时性
*/
@Component
public class GridIndexBuilder {
private static final Logger log = LoggerFactory.getLogger(GridIndexBuilder.class);
private static final double EARTH_RADIUS = 6371000; // 地球半径
private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory();
private static final WKTReader WKT_READER = new WKTReader();
private final ObjectMapper objectMapper = new ObjectMapper();
// 配置参数可从 application.yml 注入
@Value("${fence.threshold.meters:100}")
private double thresholdMeters;
@Resource
private RedisUtil redisUtil;
// 无参构造Spring使用
public GridIndexBuilder() {}
// 带参构造方便手动调用或单元测试
public GridIndexBuilder(double thresholdMeters, RedisUtil redisUtil) {
this.thresholdMeters = thresholdMeters;
this.redisUtil = redisUtil;
}
// ==================== 索引构建与发布 ====================
/**
* 构建网格索引并发布到 Redis
* @param polygon 运营区多边形
* @param regionId 运营区编号
*/
public void buildIndex(Polygon polygon, Long regionId) {
// 1. 获取外包矩形
Envelope envelope = polygon.getEnvelopeInternal();
double minLon = envelope.getMinX();
double maxLon = envelope.getMaxX();
double minLat = envelope.getMinY();
double maxLat = envelope.getMaxY();
// 2. 计算网格尺寸米转度
double centerLat = (minLat + maxLat) / 2;
double lonPerMeter = 1 / (111320 * Math.cos(Math.toRadians(centerLat)));
double latPerMeter = (double)1 / 110574;
double gridLonWidth = (thresholdMeters / 2) * lonPerMeter;
double gridLatWidth = (thresholdMeters / 2) * latPerMeter;
// 3. 提取所有边界线段
List<Coordinate[]> edges = extractEdges(polygon);
// 4. 构建网格索引
Map<String, List<Coordinate[]>> gridIndex = new HashMap<>();
for (Coordinate[] seg : edges) {
Coordinate a = seg[0];
Coordinate b = seg[1];
double minSegLon = Math.min(a.x, b.x) - gridLonWidth;
double maxSegLon = Math.max(a.x, b.x) + gridLonWidth;
double minSegLat = Math.min(a.y, b.y) - gridLatWidth;
double maxSegLat = Math.max(a.y, b.y) + gridLatWidth;
int minLonIdx = (int) Math.floor((minSegLon - minLon) / gridLonWidth);
int maxLonIdx = (int) Math.floor((maxSegLon - minLon) / gridLonWidth);
int minLatIdx = (int) Math.floor((minSegLat - minLat) / gridLatWidth);
int maxLatIdx = (int) Math.floor((maxSegLat - minLat) / gridLatWidth);
for (int lonIdx = minLonIdx; lonIdx <= maxLonIdx; lonIdx++) {
for (int latIdx = minLatIdx; latIdx <= maxLatIdx; latIdx++) {
String gridId = lonIdx + "_" + latIdx;
gridIndex.computeIfAbsent(gridId, k -> new ArrayList<>()).add(seg);
}
}
}
// 5. 创建元数据
GridMetadata metadata = new GridMetadata(minLon, minLat, gridLonWidth, gridLatWidth, thresholdMeters);
// 6. 发布到 Redis
try {
publishToRedis(gridIndex, metadata, polygon, regionId);
log.info("网格索引发布成功regionId: {}, 网格数量: {}", regionId, gridIndex.size());
} catch (Exception e) {
throw new RuntimeException("发布网格索引到 Redis 失败", e);
}
}
/**
* 从多边形提取所有边界线段
*/
private List<Coordinate[]> extractEdges(Polygon polygon) {
List<Coordinate[]> edges = new ArrayList<>();
LineString exterior = polygon.getExteriorRing();
addEdgesFromLineString(exterior, edges);
return edges;
}
private void addEdgesFromLineString(LineString lineString, List<Coordinate[]> edges) {
Coordinate[] coords = lineString.getCoordinates();
for (int i = 0; i < coords.length - 1; i++) {
edges.add(new Coordinate[]{coords[i], coords[i + 1]});
}
}
/**
* 发布网格索引元数据和多边形到 Redis
*/
private void publishToRedis(Map<String, List<Coordinate[]>> gridIndex, GridMetadata metadata,
Polygon polygon, Long regionId) throws Exception {
String gridKey = "bike:grid:" + regionId;
String metaKey = "bike:grid:meta:" + regionId;
String wktKey = "bike:fence:wkt:" + regionId;
// 清理可能存在的类型错误键
checkAndCleanKey(gridKey, "hash");
checkAndCleanKey(metaKey, "string");
checkAndCleanKey(wktKey, "string");
// 写入网格索引Hash
Map<String, Object> hashMap = new HashMap<>();
for (Map.Entry<String, List<Coordinate[]>> entry : gridIndex.entrySet()) {
hashMap.put(entry.getKey(), serializeSegments(entry.getValue()));
}
redisUtil.hSetAll(gridKey, hashMap,RedisUtil.Database.DB2);
// 写入元数据String JSON
String metaJson = objectMapper.writeValueAsString(metadata);
redisUtil.set(RedisUtil.Database.DB2,metaKey, metaJson);
// 写入多边形 WKT用于包含判断
redisUtil.set(RedisUtil.Database.DB2,wktKey, polygon.toText());
}
/**
* 检查并清理不匹配类型的 key
*/
private void checkAndCleanKey(String key, String expectedType) {
String type = redisUtil.type(key, RedisUtil.Database.DB2);
if (!"none".equals(type) && !expectedType.equals(type)) {
redisUtil.delete(RedisUtil.Database.DB2,key);
log.warn("清理类型不匹配的 key: {}, 原类型: {}, 期望类型: {}", key, type, expectedType);
}
}
// ==================== 实时判断方法 ====================
/**
* 判断车辆是否接近运营区边缘从内部骑出
* 每次调用均直接访问 Redis保证数据实时性
* @param regionId 运营区ID
* @param lon 经度
* @param lat 纬度
* @return true 表示需要提醒距离小于阈值且车辆在多边形内否则 false
*/
public boolean isNearEdge(Long regionId, double lon, double lat) {
try {
// 1. Redis 获取多边形 WKT 并判断是否在多边形内部
String wktKey = "bike:fence:wkt:" + regionId;
Object wktObj = redisUtil.get(RedisUtil.Database.DB2,wktKey);
if (wktObj == null) {
log.warn("多边形数据不存在regionId: {}", regionId);
return false;
}
Polygon polygon = (Polygon) WKT_READER.read(wktObj.toString());
Point point = GEOMETRY_FACTORY.createPoint(new Coordinate(lon, lat));
if (!polygon.contains(point)) {
return false; // 不在内部不处理
}
// 2. Redis 获取网格元数据
String metaKey = "bike:grid:meta:" + regionId;
Object metaObj = redisUtil.get(RedisUtil.Database.DB2,metaKey);
if (metaObj == null) {
log.warn("网格元数据不存在regionId: {}", regionId);
return false;
}
GridMetadata metadata = objectMapper.readValue(metaObj.toString(), GridMetadata.class);
// 3. 计算网格及邻域
int lonIdx = (int) Math.floor((lon - metadata.minLon) / metadata.gridLonWidth);
int latIdx = (int) Math.floor((lat - metadata.minLat) / metadata.gridLatWidth);
List<String> neighborIds = getNeighborGridIds(lonIdx, latIdx);
// 4. Redis 获取这些网格的线段数据
String gridKey = "bike:grid:" + regionId;
List<Coordinate[]> candidateSegments = new ArrayList<>();
for (String gridId : neighborIds) {
Object jsonObj = redisUtil.hGet(gridKey, gridId);
if (jsonObj != null) {
candidateSegments.addAll(deserializeSegments(jsonObj.toString()));
}
}
// 5. 计算距离并判断
return isDistanceLessThanThreshold(lon, lat, candidateSegments, metadata.threshold);
} catch (Exception e) {
log.error("判断边缘距离异常regionId: {}, lon: {}, lat: {}", regionId, lon, lat, e);
return false;
}
}
// ---------- 距离计算工具 ----------
private boolean isDistanceLessThanThreshold(double lon, double lat, List<Coordinate[]> segments, double threshold) {
if (segments == null || segments.isEmpty()) return false;
Coordinate point = new Coordinate(lon, lat);
double minDist = Double.MAX_VALUE;
for (Coordinate[] seg : segments) {
double d = distanceToSegment(point, seg[0], seg[1]);
if (d < minDist) {
minDist = d;
if (minDist < threshold) return true;
}
}
return minDist < threshold;
}
private double distanceToSegment(Coordinate p, Coordinate a, Coordinate b) {
double refLat = Math.toRadians((a.y + b.y) / 2);
double[] pM = toMeters(p, refLat);
double[] aM = toMeters(a, refLat);
double[] bM = toMeters(b, refLat);
double dx = bM[0] - aM[0];
double dy = bM[1] - aM[1];
double lenSq = dx * dx + dy * dy;
if (lenSq == 0) {
return Math.hypot(pM[0] - aM[0], pM[1] - aM[1]);
}
double t = ((pM[0] - aM[0]) * dx + (pM[1] - aM[1]) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
double projX = aM[0] + t * dx;
double projY = aM[1] + t * dy;
return Math.hypot(pM[0] - projX, pM[1] - projY);
}
private double[] toMeters(Coordinate p, double refLat) {
double lonRad = Math.toRadians(p.x);
double latRad = Math.toRadians(p.y);
double x = lonRad * Math.cos(refLat) * EARTH_RADIUS;
double y = latRad * EARTH_RADIUS;
return new double[]{x, y};
}
// ==================== 静态工具方法 ====================
/**
* 获取网格ID
*/
public static String getGridId(double lon, double lat, GridMetadata metadata) {
int lonIdx = (int) Math.floor((lon - metadata.minLon) / metadata.gridLonWidth);
int latIdx = (int) Math.floor((lat - metadata.minLat) / metadata.gridLatWidth);
return lonIdx + "_" + latIdx;
}
/**
* 获取邻域网格ID列表3x3
*/
public static List<String> getNeighborGridIds(int lonIdx, int latIdx) {
List<String> ids = new ArrayList<>();
for (int dl = -1; dl <= 1; dl++) {
for (int db = -1; db <= 1; db++) {
ids.add((lonIdx + dl) + "_" + (latIdx + db));
}
}
return ids;
}
/**
* 反序列化线段 JSON
*/
public static List<Coordinate[]> deserializeSegments(String json) throws Exception {
ObjectMapper mapper = new ObjectMapper();
List<List<double[]>> segList = mapper.readValue(json,
mapper.getTypeFactory().constructCollectionType(List.class,
mapper.getTypeFactory().constructCollectionType(List.class, double[].class)));
List<Coordinate[]> result = new ArrayList<>();
for (List<double[]> seg : segList) {
Coordinate a = new Coordinate(seg.get(0)[0], seg.get(0)[1]);
Coordinate b = new Coordinate(seg.get(1)[0], seg.get(1)[1]);
result.add(new Coordinate[]{a, b});
}
return result;
}
/**
* 序列化线段列表为 JSON
*/
private String serializeSegments(List<Coordinate[]> segments) throws Exception {
List<List<double[]>> segList = segments.stream()
.map(seg -> Arrays.asList(
new double[]{seg[0].x, seg[0].y},
new double[]{seg[1].x, seg[1].y}))
.collect(Collectors.toList());
return objectMapper.writeValueAsString(segList);
}
// ==================== 元数据内部类 ====================
public static class GridMetadata {
public double minLon;
public double minLat;
public double gridLonWidth;
public double gridLatWidth;
public double threshold;
// 无参构造Jackson 反序列化需要
public GridMetadata() {}
public GridMetadata(double minLon, double minLat, double gridLonWidth, double gridLatWidth, double threshold) {
this.minLon = minLon;
this.minLat = minLat;
this.gridLonWidth = gridLonWidth;
this.gridLatWidth = gridLatWidth;
this.threshold = threshold;
}
}
}

View File

@ -2,6 +2,7 @@ package com.cdzy.operations.service.impl;
import cn.dev33.satoken.stp.StpUtil; import cn.dev33.satoken.stp.StpUtil;
import com.cdzy.common.ex.EbikeException; import com.cdzy.common.ex.EbikeException;
import com.cdzy.operations.component.GridIndexBuilder;
import com.cdzy.operations.enums.RegionStatus; import com.cdzy.operations.enums.RegionStatus;
import com.cdzy.operations.mapper.EbikeOperationConfigMapper; import com.cdzy.operations.mapper.EbikeOperationConfigMapper;
import com.cdzy.operations.mapper.EbikeOperationLockConfigMapper; import com.cdzy.operations.mapper.EbikeOperationLockConfigMapper;
@ -55,7 +56,11 @@ public class EbikeRegionServiceImpl extends ServiceImpl<EbikeRegionMapper, Ebike
@Resource @Resource
private EbikeOperationReturnConfigMapper returnConfigMapper; private EbikeOperationReturnConfigMapper returnConfigMapper;
@Resource
private GridIndexBuilder gridIndexBuilder;
@Override @Override
@Transactional
public void save(EbikeRegionVo ebikeRegion) { public void save(EbikeRegionVo ebikeRegion) {
EbikeRegion entity = EbikeRegion.builder() EbikeRegion entity = EbikeRegion.builder()
.operatorId(ebikeRegion.getOperatorId()) .operatorId(ebikeRegion.getOperatorId())
@ -66,6 +71,7 @@ public class EbikeRegionServiceImpl extends ServiceImpl<EbikeRegionMapper, Ebike
.createdBy(StpUtil.getLoginIdAsLong()) .createdBy(StpUtil.getLoginIdAsLong())
.build(); .build();
this.mapper.insert(entity); this.mapper.insert(entity);
gridIndexBuilder.buildIndex(entity.getRegionPolygon(),entity.getRegionId());
} }
@Override @Override
@ -80,6 +86,7 @@ public class EbikeRegionServiceImpl extends ServiceImpl<EbikeRegionMapper, Ebike
region.setRegionPolygon(ebikeRegion.getRegionPolygon()); region.setRegionPolygon(ebikeRegion.getRegionPolygon());
region.setRegionName(ebikeRegion.getRegionName()); region.setRegionName(ebikeRegion.getRegionName());
region.setRegionSimpleName(ebikeRegion.getRegionSimpleName()); region.setRegionSimpleName(ebikeRegion.getRegionSimpleName());
gridIndexBuilder.buildIndex(region.getRegionPolygon(),region.getRegionId());
this.mapper.update(region); this.mapper.update(region);
} }

View File

@ -1,7 +1,6 @@
package com.cdzy.operations.utils; package com.cdzy.operations.utils;
import com.cdzy.common.enums.EbikeContents; import com.cdzy.common.enums.EbikeContents;
import com.cdzy.operations.model.vo.EbikeEcuSnInfoVo;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
@ -180,8 +179,8 @@ public class RedisUtil {
* @param key * @param key
* @param map 多个Hash键值对 * @param map 多个Hash键值对
*/ */
public void hSetAll(String key, Map<String, Object> map) { public void hSetAll(String key, Map<String, Object> map,Integer DB) {
redisTemplate.opsForHash().putAll(key, map); getRedisTemplate(DB).opsForHash().putAll(key, map);
} }
/** /**
@ -332,6 +331,7 @@ public class RedisUtil {
getRedisTemplate(database).opsForValue().set(key, value); getRedisTemplate(database).opsForValue().set(key, value);
} }
/** /**
* 设置键值对到指定数据库并设置过期时间 * 设置键值对到指定数据库并设置过期时间
*/ */
@ -603,4 +603,13 @@ public class RedisUtil {
return result; return result;
} }
/**
* 获取 key 的类型
* @param key
* @return 类型字符串 "string", "hash", "none"
*/
public String type(String key,Integer DB) {
return getRedisTemplate(DB).type(key).code();
}
} }