From db2d7734c8b1c4c2745cd97139082c798893dfb7990f078d48b1729ab12da1ea Mon Sep 17 00:00:00 2001 From: PC <2413103649@qq.com> Date: Mon, 9 Mar 2026 09:41:00 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=90=E8=90=A5=E5=8C=BA=E8=BE=B9=E7=BC=98?= =?UTF-8?q?=E8=AE=A1=E7=AE=97=EF=BC=9A=E5=8C=BA=E5=9F=9F=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../component/GridIndexBuilder.java | 339 ++++++++++++++++++ .../service/impl/EbikeRegionServiceImpl.java | 7 + .../com/cdzy/operations/utils/RedisUtil.java | 17 +- 3 files changed, 359 insertions(+), 4 deletions(-) create mode 100644 ebike-operations/src/main/java/com/cdzy/operations/component/GridIndexBuilder.java diff --git a/ebike-operations/src/main/java/com/cdzy/operations/component/GridIndexBuilder.java b/ebike-operations/src/main/java/com/cdzy/operations/component/GridIndexBuilder.java new file mode 100644 index 0000000..66426a5 --- /dev/null +++ b/ebike-operations/src/main/java/com/cdzy/operations/component/GridIndexBuilder.java @@ -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 edges = extractEdges(polygon); + + // 4. 构建网格索引 + Map> 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 extractEdges(Polygon polygon) { + List edges = new ArrayList<>(); + LineString exterior = polygon.getExteriorRing(); + addEdgesFromLineString(exterior, edges); + return edges; + } + + private void addEdgesFromLineString(LineString lineString, List 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> 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 hashMap = new HashMap<>(); + for (Map.Entry> 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 neighborIds = getNeighborGridIds(lonIdx, latIdx); + + // 4. 从 Redis 获取这些网格的线段数据 + String gridKey = "bike:grid:" + regionId; + List 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 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 getNeighborGridIds(int lonIdx, int latIdx) { + List 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 deserializeSegments(String json) throws Exception { + ObjectMapper mapper = new ObjectMapper(); + List> segList = mapper.readValue(json, + mapper.getTypeFactory().constructCollectionType(List.class, + mapper.getTypeFactory().constructCollectionType(List.class, double[].class))); + List result = new ArrayList<>(); + for (List 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 segments) throws Exception { + List> 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; + } + } +} \ No newline at end of file diff --git a/ebike-operations/src/main/java/com/cdzy/operations/service/impl/EbikeRegionServiceImpl.java b/ebike-operations/src/main/java/com/cdzy/operations/service/impl/EbikeRegionServiceImpl.java index e48e99f..0ced490 100644 --- a/ebike-operations/src/main/java/com/cdzy/operations/service/impl/EbikeRegionServiceImpl.java +++ b/ebike-operations/src/main/java/com/cdzy/operations/service/impl/EbikeRegionServiceImpl.java @@ -2,6 +2,7 @@ package com.cdzy.operations.service.impl; import cn.dev33.satoken.stp.StpUtil; import com.cdzy.common.ex.EbikeException; +import com.cdzy.operations.component.GridIndexBuilder; import com.cdzy.operations.enums.RegionStatus; import com.cdzy.operations.mapper.EbikeOperationConfigMapper; import com.cdzy.operations.mapper.EbikeOperationLockConfigMapper; @@ -55,7 +56,11 @@ public class EbikeRegionServiceImpl extends ServiceImpl map) { - redisTemplate.opsForHash().putAll(key, map); + public void hSetAll(String key, Map map,Integer DB) { + getRedisTemplate(DB).opsForHash().putAll(key, map); } /** @@ -310,7 +309,7 @@ public class RedisUtil { * @param timeout 超时时间 * @param unit 时间单位 */ - public Boolean tryLock(String key, Object value, long timeout, TimeUnit unit) { + public Boolean tryLock(String key, Object value, long timeout, TimeUnit unit) { return redisTemplate.opsForValue().setIfAbsent(key, value, timeout, unit); } @@ -332,6 +331,7 @@ public class RedisUtil { getRedisTemplate(database).opsForValue().set(key, value); } + /** * 设置键值对到指定数据库并设置过期时间 */ @@ -603,4 +603,13 @@ public class RedisUtil { return result; } + + /** + * 获取 key 的类型 + * @param key 键 + * @return 类型字符串(如 "string", "hash", "none" 等) + */ + public String type(String key,Integer DB) { + return getRedisTemplate(DB).type(key).code(); + } } \ No newline at end of file