From 3b4f8a29d81b9d95c3fbcd1577f61bc0bcddb110 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E5=BF=A0=E6=9E=97?= <170083662@qq.com> Date: Mon, 9 Feb 2026 11:16:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(order):=20=E6=B7=BB=E5=8A=A0=E9=85=8D?= =?UTF-8?q?=E9=80=81=E8=8C=83=E5=9B=B4=E7=94=B5=E5=AD=90=E5=9B=B4=E6=A0=8F?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在订单创建流程中集成电子围栏校验机制 - 实现不信任前端坐标的地址表坐标验证策略 - 添加多种格式的围栏points解析支持(JSON、分号分隔等) - 实现射线投射算法进行点在多边形内判断 - 添加自提和无需物流订单的围栏校验跳过逻辑 - 实现坐标缺失和异常情况的错误处理机制 - 添加围栏配置异常时的订单拒绝保护机制 - 创建GeoFenceUtil工具类提供完整的围栏功能支持 --- .../controller/GltTicketOrderController.java | 15 ++ .../shop/controller/ShopOrderController.java | 39 +++ .../shop/service/OrderBusinessService.java | 82 ++++++- .../shop/service/ShopStoreFenceService.java | 20 ++ .../impl/ShopStoreFenceServiceImpl.java | 62 +++++ .../com/gxwebsoft/shop/util/GeoFenceUtil.java | 224 ++++++++++++++++++ .../gxwebsoft/shop/util/GeoFenceUtilTest.java | 36 +++ 7 files changed, 469 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/gxwebsoft/shop/util/GeoFenceUtil.java create mode 100644 src/test/java/com/gxwebsoft/shop/util/GeoFenceUtilTest.java diff --git a/src/main/java/com/gxwebsoft/glt/controller/GltTicketOrderController.java b/src/main/java/com/gxwebsoft/glt/controller/GltTicketOrderController.java index b0672cd..f7e49ee 100644 --- a/src/main/java/com/gxwebsoft/glt/controller/GltTicketOrderController.java +++ b/src/main/java/com/gxwebsoft/glt/controller/GltTicketOrderController.java @@ -13,6 +13,7 @@ import com.gxwebsoft.glt.param.GltTicketOrderParam; import com.gxwebsoft.glt.service.GltTicketOrderService; import com.gxwebsoft.shop.entity.ShopStoreRider; import com.gxwebsoft.shop.entity.ShopUserAddress; +import com.gxwebsoft.shop.service.ShopStoreFenceService; import com.gxwebsoft.shop.service.ShopStoreRiderService; import com.gxwebsoft.shop.service.ShopUserAddressService; import cn.hutool.core.util.StrUtil; @@ -40,6 +41,8 @@ public class GltTicketOrderController extends BaseController { @Resource private ShopUserAddressService shopUserAddressService; @Resource + private ShopStoreFenceService shopStoreFenceService; + @Resource private ShopStoreRiderService shopStoreRiderService; @Operation(summary = "分页查询送水订单") @@ -119,6 +122,18 @@ public class GltTicketOrderController extends BaseController { gltTicketOrder.setAddressId(userAddress.getId()); gltTicketOrder.setAddress(buildAddressSnapshot(userAddress)); + // 下单时校验配送范围(电子围栏):不信任前端传经纬度,使用地址表坐标校验 + if (StrUtil.isBlank(userAddress.getLat()) || StrUtil.isBlank(userAddress.getLng())) { + return fail("收货地址坐标缺失,请重新选择收货地址"); + } + try { + double lat = Double.parseDouble(userAddress.getLat().trim()); + double lng = Double.parseDouble(userAddress.getLng().trim()); + shopStoreFenceService.validatePointInEnabledFences(loginUser.getTenantId(), lng, lat); + } catch (Exception e) { + return fail(e.getMessage() == null ? "收货地址不在配送范围内" : e.getMessage()); + } + gltTicketOrderService.createWithWriteOff(gltTicketOrder, loginUser.getUserId(), loginUser.getTenantId()); return success("下单成功"); } diff --git a/src/main/java/com/gxwebsoft/shop/controller/ShopOrderController.java b/src/main/java/com/gxwebsoft/shop/controller/ShopOrderController.java index de69242..5db5277 100644 --- a/src/main/java/com/gxwebsoft/shop/controller/ShopOrderController.java +++ b/src/main/java/com/gxwebsoft/shop/controller/ShopOrderController.java @@ -14,6 +14,7 @@ import com.gxwebsoft.common.core.utils.WechatPayConfigValidator; import com.gxwebsoft.common.core.web.BaseController; import com.gxwebsoft.common.system.entity.Payment; import com.gxwebsoft.shop.entity.ShopOrderDelivery; +import com.gxwebsoft.shop.entity.ShopUserAddress; import com.gxwebsoft.shop.service.*; import com.gxwebsoft.shop.service.impl.KuaiDi100Impl; import com.gxwebsoft.shop.task.OrderAutoCancelTask; @@ -99,6 +100,8 @@ public class ShopOrderController extends BaseController { private ShopWechatShippingSyncService shopWechatShippingSyncService; @Resource private PaymentService paymentService; + @Resource + private ShopStoreFenceService shopStoreFenceService; @Operation(summary = "分页查询订单") @GetMapping("/page") @@ -148,6 +151,9 @@ public class ShopOrderController extends BaseController { shopOrder.setUserId(loginUser.getUserId()); shopOrder.setOpenid(loginUser.getOpenid()); shopOrder.setPayUserId(loginUser.getUserId()); + if (shopOrder.getTenantId() == null) { + shopOrder.setTenantId(loginUser.getTenantId()); + } // 下单时间 & 订单过期时间:默认下单后10分钟过期(用于发起支付等场景校验) LocalDateTime now = LocalDateTime.now(); @@ -178,6 +184,39 @@ public class ShopOrderController extends BaseController { shopOrder.setPrice(new BigDecimal("0.01")); shopOrder.setTotalPrice(new BigDecimal("0.01")); } + + // 下单时校验配送范围(电子围栏):不信任前端传经纬度,使用 addressId 反查地址表坐标 + try { + // 自提/无需物流跳过 + boolean needFenceCheck = !(shopOrder.getSelfTakeMerchantId() != null && shopOrder.getSelfTakeMerchantId() > 0) + && !(shopOrder.getDeliveryType() != null && shopOrder.getDeliveryType().equals(1)); + if (needFenceCheck && shopOrder.getAddressId() == null) { + if (shopStoreFenceService.hasEnabledFences(shopOrder.getTenantId())) { + return fail("请先选择收货地址"); + } + } + if (needFenceCheck && shopOrder.getAddressId() != null) { + ShopUserAddress address = shopUserAddressService.getById(shopOrder.getAddressId()); + if (address == null || address.getUserId() == null || !address.getUserId().equals(loginUser.getUserId())) { + return fail("收货地址不存在"); + } + if (address.getTenantId() != null && shopOrder.getTenantId() != null && !address.getTenantId().equals(shopOrder.getTenantId())) { + return fail("收货地址不存在"); + } + if (StrUtil.isBlank(address.getLat()) || StrUtil.isBlank(address.getLng())) { + return fail("收货地址坐标缺失,请重新选择收货地址"); + } + double lat = Double.parseDouble(address.getLat().trim()); + double lng = Double.parseDouble(address.getLng().trim()); + shopOrder.setAddressLat(address.getLat()); + shopOrder.setAddressLng(address.getLng()); + shopStoreFenceService.validatePointInEnabledFences(shopOrder.getTenantId(), lng, lat); + } + } catch (Exception e) { + // BusinessException message 直接透出;其他异常做兜底 + return fail(e.getMessage() == null ? "收货地址不在配送范围内" : e.getMessage()); + } + if (shopOrderService.save(shopOrder)) { return success("下单成功", shopOrderService.createWxOrder(shopOrder)); } diff --git a/src/main/java/com/gxwebsoft/shop/service/OrderBusinessService.java b/src/main/java/com/gxwebsoft/shop/service/OrderBusinessService.java index e660b3b..337b32c 100644 --- a/src/main/java/com/gxwebsoft/shop/service/OrderBusinessService.java +++ b/src/main/java/com/gxwebsoft/shop/service/OrderBusinessService.java @@ -7,6 +7,7 @@ import com.gxwebsoft.common.system.entity.User; import com.gxwebsoft.shop.config.OrderConfigProperties; import com.gxwebsoft.shop.dto.OrderCreateRequest; import com.gxwebsoft.shop.entity.*; +import com.gxwebsoft.shop.service.ShopStoreFenceService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.BeanUtils; import org.springframework.stereotype.Service; @@ -51,6 +52,8 @@ public class OrderBusinessService { private ShopUserAddressService shopUserAddressService; @Resource private ShopUserCouponService shopUserCouponService; + @Resource + private ShopStoreFenceService shopStoreFenceService; /** * 创建订单 @@ -71,6 +74,9 @@ public class OrderBusinessService { // 3. 处理收货地址信息 processDeliveryAddress(shopOrder, request, loginUser); + // 3.1 下单时校验配送范围(电子围栏) + validateDeliveryFenceIfNeeded(shopOrder, loginUser); + // 4. 应用业务规则 applyBusinessRules(shopOrder, loginUser); @@ -402,13 +408,9 @@ public class OrderBusinessService { */ private void processDeliveryAddress(ShopOrder shopOrder, OrderCreateRequest request, User loginUser) { try { - // 1. 如果前端已经传入了完整的收货地址信息,直接使用 - if (isAddressInfoComplete(request)) { - log.info("使用前端传入的收货地址信息,用户ID:{}", loginUser.getUserId()); - return; - } + // 前端可能会传 addressLat/addressLng,但不可信;这里一律以地址表中的坐标为准(需 addressId)。 - // 2. 如果指定了地址ID,获取该地址信息 + // 1. 如果指定了地址ID,优先使用该地址 if (request.getAddressId() != null) { ShopUserAddress userAddress = shopUserAddressService.getById(request.getAddressId()); if (userAddress != null && userAddress.getUserId().equals(loginUser.getUserId())) { @@ -420,6 +422,13 @@ public class OrderBusinessService { request.getAddressId(), loginUser.getUserId()); } + // 2. 若前端已传完整地址快照但没传 addressId:保持快照,不在这里“猜测”绑定地址。 + // 围栏校验会根据是否配置围栏决定是否强制要求 addressId。 + if (isAddressInfoComplete(request)) { + log.warn("前端传入了收货地址快照字段,但未传 addressId,用户ID:{}", loginUser.getUserId()); + return; + } + // 3. 获取用户默认收货地址 ShopUserAddress defaultAddress = shopUserAddressService.getDefaultAddress(loginUser.getUserId()); if (defaultAddress != null) { @@ -482,12 +491,12 @@ public class OrderBusinessService { request.setRealName(userAddress.getName()); } - // 复制经纬度信息 - if (request.getAddressLat() == null && userAddress.getLat() != null) { + // 复制经纬度信息(不信任前端传入坐标,一律以地址表为准) + if (userAddress.getLat() != null && !userAddress.getLat().trim().isEmpty()) { shopOrder.setAddressLat(userAddress.getLat()); request.setAddressLat(userAddress.getLat()); } - if (request.getAddressLng() == null && userAddress.getLng() != null) { + if (userAddress.getLng() != null && !userAddress.getLng().trim().isEmpty()) { shopOrder.setAddressLng(userAddress.getLng()); request.setAddressLng(userAddress.getLng()); } @@ -496,6 +505,61 @@ public class OrderBusinessService { userAddress.getId(), userAddress.getName(), shopOrder.getAddress()); } + /** + * 下单时校验配送范围(电子围栏)。 + *

+ * 规则: + * - 仅对需要配送到地址的订单做校验;自提/无需物流跳过; + * - 不信任前端传经纬度:用 addressId 反查地址表中的 lat/lng; + * - 无围栏配置默认放行;围栏配置异常则拒单(避免误送)。 + */ + private void validateDeliveryFenceIfNeeded(ShopOrder shopOrder, User loginUser) { + if (shopOrder == null || loginUser == null) { + return; + } + + // 自提/无需物流:不校验配送围栏 + if (shopOrder.getSelfTakeMerchantId() != null && shopOrder.getSelfTakeMerchantId() > 0) { + return; + } + if (shopOrder.getDeliveryType() != null && shopOrder.getDeliveryType().equals(1)) { + return; + } + + // 若已配置围栏,则必须有 addressId 才能从地址表取坐标进行校验 + if (shopOrder.getAddressId() == null) { + if (shopStoreFenceService.hasEnabledFences(shopOrder.getTenantId())) { + throw new BusinessException("请先选择收货地址"); + } + return; + } + + ShopUserAddress address = shopUserAddressService.getById(shopOrder.getAddressId()); + if (address == null || address.getUserId() == null || !address.getUserId().equals(loginUser.getUserId())) { + throw new BusinessException("收货地址不存在"); + } + if (address.getTenantId() != null && shopOrder.getTenantId() != null && !address.getTenantId().equals(shopOrder.getTenantId())) { + throw new BusinessException("收货地址不存在"); + } + + String latStr = address.getLat(); + String lngStr = address.getLng(); + if (latStr == null || latStr.trim().isEmpty() || lngStr == null || lngStr.trim().isEmpty()) { + throw new BusinessException("收货地址坐标缺失,请重新选择收货地址"); + } + double lat; + double lng; + try { + lat = Double.parseDouble(latStr.trim()); + lng = Double.parseDouble(lngStr.trim()); + } catch (Exception e) { + throw new BusinessException("收货地址坐标异常,请重新选择收货地址"); + } + + // 只做校验;具体围栏列表/points 异常处理由围栏服务统一处理 + shopStoreFenceService.validatePointInEnabledFences(shopOrder.getTenantId(), lng, lat); + } + /** * 应用业务规则 */ diff --git a/src/main/java/com/gxwebsoft/shop/service/ShopStoreFenceService.java b/src/main/java/com/gxwebsoft/shop/service/ShopStoreFenceService.java index 8b1751c..b930b30 100644 --- a/src/main/java/com/gxwebsoft/shop/service/ShopStoreFenceService.java +++ b/src/main/java/com/gxwebsoft/shop/service/ShopStoreFenceService.java @@ -39,4 +39,24 @@ public interface ShopStoreFenceService extends IService { */ ShopStoreFence getByIdRel(Integer id); + /** + * 当前租户是否配置了任一启用围栏(status=0)。 + */ + boolean hasEnabledFences(Integer tenantId); + + /** + * 校验坐标是否落在任一启用围栏内。 + *

+ * 约定: + * - 围栏按 tenantId + status=0 过滤; + * - 支持多个围栏:命中任意一个即通过; + * - 无围栏配置:直接放行; + * - 围栏 points 异常:抛出异常(避免误送)。 + * + * @param tenantId 租户ID + * @param lng 经度 + * @param lat 纬度 + */ + void validatePointInEnabledFences(Integer tenantId, double lng, double lat); + } diff --git a/src/main/java/com/gxwebsoft/shop/service/impl/ShopStoreFenceServiceImpl.java b/src/main/java/com/gxwebsoft/shop/service/impl/ShopStoreFenceServiceImpl.java index 77b001c..7d597cc 100644 --- a/src/main/java/com/gxwebsoft/shop/service/impl/ShopStoreFenceServiceImpl.java +++ b/src/main/java/com/gxwebsoft/shop/service/impl/ShopStoreFenceServiceImpl.java @@ -1,12 +1,17 @@ package com.gxwebsoft.shop.service.impl; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.gxwebsoft.common.core.exception.BusinessException; import com.gxwebsoft.shop.mapper.ShopStoreFenceMapper; import com.gxwebsoft.shop.service.ShopStoreFenceService; import com.gxwebsoft.shop.entity.ShopStoreFence; import com.gxwebsoft.shop.param.ShopStoreFenceParam; import com.gxwebsoft.common.core.web.PageParam; import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.shop.util.GeoFenceUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import java.util.List; @@ -19,6 +24,7 @@ import java.util.List; */ @Service public class ShopStoreFenceServiceImpl extends ServiceImpl implements ShopStoreFenceService { + private static final Logger log = LoggerFactory.getLogger(ShopStoreFenceServiceImpl.class); @Override public PageResult pageRel(ShopStoreFenceParam param) { @@ -44,4 +50,60 @@ public class ShopStoreFenceServiceImpl extends ServiceImpl() + .eq(ShopStoreFence::getTenantId, tenantId) + .eq(ShopStoreFence::getStatus, 0)) > 0; + } + + @Override + public void validatePointInEnabledFences(Integer tenantId, double lng, double lat) { + if (tenantId == null) { + // tenantId 缺失时不做围栏校验,避免误伤;上层应保证 tenantId 正确传入 + return; + } + + List fences = this.list(new LambdaQueryWrapper() + .eq(ShopStoreFence::getTenantId, tenantId) + .eq(ShopStoreFence::getStatus, 0) + .orderByAsc(ShopStoreFence::getSortNumber) + .orderByDesc(ShopStoreFence::getCreateTime)); + + // 无围栏配置:默认放行 + if (fences == null || fences.isEmpty()) { + return; + } + + for (ShopStoreFence fence : fences) { + if (fence == null) { + continue; + } + List polygon; + try { + polygon = GeoFenceUtil.parsePolygonPoints(fence.getPoints()); + } catch (Exception e) { + // points 异常:直接拒单并记录日志,避免误送 + log.error("围栏 points 解析失败,tenantId={}, fenceId={}, points={}", + tenantId, fence.getId(), fence.getPoints(), e); + throw new BusinessException("配送范围配置异常,请联系商家"); + } + + if (polygon == null || polygon.size() < 3) { + log.error("围栏 points 点数不足,tenantId={}, fenceId={}, points={}", + tenantId, fence.getId(), fence.getPoints()); + throw new BusinessException("配送范围配置异常,请联系商家"); + } + + if (GeoFenceUtil.containsInclusive(polygon, lng, lat)) { + return; // 命中任一围栏即通过 + } + } + + throw new BusinessException("收货地址不在配送范围内"); + } + } diff --git a/src/main/java/com/gxwebsoft/shop/util/GeoFenceUtil.java b/src/main/java/com/gxwebsoft/shop/util/GeoFenceUtil.java new file mode 100644 index 0000000..40c1401 --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/util/GeoFenceUtil.java @@ -0,0 +1,224 @@ +package com.gxwebsoft.shop.util; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * 电子围栏(多边形)相关工具: + * - points 字符串解析(兼容常见格式:JSON数组 / "lng,lat;lng,lat" / 纯数字逗号序列) + * - 点在多边形内判断(边界视为“在围栏内”) + */ +public final class GeoFenceUtil { + + private GeoFenceUtil() { + } + + public static final class Point { + public final double lng; + public final double lat; + + public Point(double lng, double lat) { + this.lng = lng; + this.lat = lat; + } + } + + private static final ObjectMapper OM = new ObjectMapper(); + + /** + * 解析围栏 points 字段为多边形点列表。 + *

+ * 支持格式示例: + * - [[lng,lat],[lng,lat],...] + * - [{"lng":..,"lat":..}, ...] / [{"longitude":..,"latitude":..}, ...] + * - "lng,lat;lng,lat;..." + * - "lng,lat|lng,lat|..." + * - "lng,lat,lng,lat,lng,lat,..."(纯逗号序列,偶数个数字) + */ + public static List parsePolygonPoints(String pointsRaw) { + if (pointsRaw == null) { + return Collections.emptyList(); + } + String s = pointsRaw.trim(); + if (s.isEmpty()) { + return Collections.emptyList(); + } + + // JSON array + if (s.startsWith("[")) { + try { + JsonNode root = OM.readTree(s); + if (!root.isArray()) { + throw new IllegalArgumentException("points 不是数组"); + } + List pts = new ArrayList<>(); + for (JsonNode node : root) { + if (node == null || node.isNull()) { + continue; + } + if (node.isArray() && node.size() >= 2) { + double a = node.get(0).asDouble(); + double b = node.get(1).asDouble(); + pts.add(normalizeLngLat(a, b)); + continue; + } + if (node.isObject()) { + JsonNode lngNode = node.get("lng"); + if (lngNode == null) lngNode = node.get("longitude"); + JsonNode latNode = node.get("lat"); + if (latNode == null) latNode = node.get("latitude"); + if (lngNode == null || latNode == null) { + throw new IllegalArgumentException("points 对象缺少 lng/lat 字段"); + } + pts.add(normalizeLngLat(lngNode.asDouble(), latNode.asDouble())); + continue; + } + throw new IllegalArgumentException("points 数组元素格式不支持"); + } + return trimClosingPoint(pts); + } catch (Exception e) { + throw new IllegalArgumentException("points JSON 解析失败: " + e.getMessage(), e); + } + } + + // 非 JSON:尝试按分隔符拆分 + String normalized = s.replace("\n", "").replace("\r", "").trim(); + String[] pairTokens; + if (normalized.contains(";")) { + pairTokens = normalized.split(";+"); + return trimClosingPoint(parsePairs(pairTokens)); + } + if (normalized.contains("|")) { + pairTokens = normalized.split("\\|+"); + return trimClosingPoint(parsePairs(pairTokens)); + } + + // 纯逗号序列:lng,lat,lng,lat,... + if (normalized.contains(",")) { + String[] nums = normalized.split(",+"); + List ds = new ArrayList<>(); + for (String num : nums) { + String t = num.trim(); + if (t.isEmpty()) continue; + ds.add(Double.parseDouble(t)); + } + if (ds.size() % 2 != 0) { + throw new IllegalArgumentException("points 逗号序列数字个数必须为偶数"); + } + List pts = new ArrayList<>(); + for (int i = 0; i < ds.size(); i += 2) { + pts.add(normalizeLngLat(ds.get(i), ds.get(i + 1))); + } + return trimClosingPoint(pts); + } + + throw new IllegalArgumentException("points 格式不支持"); + } + + private static List parsePairs(String[] pairTokens) { + List pts = new ArrayList<>(); + for (String token : pairTokens) { + if (token == null) continue; + String t = token.trim(); + if (t.isEmpty()) continue; + String[] parts = t.split("[,\\s]+"); + if (parts.length < 2) { + throw new IllegalArgumentException("points 点格式错误: " + t); + } + double a = Double.parseDouble(parts[0].trim()); + double b = Double.parseDouble(parts[1].trim()); + pts.add(normalizeLngLat(a, b)); + } + return pts; + } + + /** + * 归一化输入的两个数为 (lng,lat)。 + * 若可明显判断是 (lat,lng) 则自动交换(仅在一个维度超出经纬常规范围时触发)。 + */ + private static Point normalizeLngLat(double first, double second) { + boolean firstLooksLat = Math.abs(first) <= 90; + boolean secondLooksLat = Math.abs(second) <= 90; + boolean firstLooksLng = Math.abs(first) <= 180; + boolean secondLooksLng = Math.abs(second) <= 180; + + // 明显是 (lat,lng):first 像纬度且 second 像经度,但 second 不像纬度 + if (firstLooksLat && secondLooksLng && !secondLooksLat) { + return new Point(second, first); + } + // 默认按 (lng,lat) + if (!firstLooksLng || !secondLooksLat) { + // 仍返回默认顺序,让上层决定是否拒单(避免隐式容错导致误判) + return new Point(first, second); + } + return new Point(first, second); + } + + private static List trimClosingPoint(List pts) { + if (pts == null || pts.size() < 2) { + return pts == null ? Collections.emptyList() : pts; + } + Point first = pts.get(0); + Point last = pts.get(pts.size() - 1); + if (nearlyEqual(first.lng, last.lng, 1e-12) && nearlyEqual(first.lat, last.lat, 1e-12)) { + return new ArrayList<>(pts.subList(0, pts.size() - 1)); + } + return pts; + } + + /** + * 点在多边形内判断(ray casting),边界视为 inside。 + */ + public static boolean containsInclusive(List polygon, double lng, double lat) { + if (polygon == null || polygon.size() < 3) { + return false; + } + final double eps = 1e-12; + boolean inside = false; + int n = polygon.size(); + for (int i = 0, j = n - 1; i < n; j = i++) { + Point pi = polygon.get(i); + Point pj = polygon.get(j); + + // 边界:点在线段上 + if (pointOnSegment(lng, lat, pj.lng, pj.lat, pi.lng, pi.lat, eps)) { + return true; + } + + // 射线与边相交:以 lat 为 y + boolean intersect = ((pi.lat > lat) != (pj.lat > lat)) + && (lng < (pj.lng - pi.lng) * (lat - pi.lat) / (pj.lat - pi.lat + 0.0) + pi.lng); + if (intersect) { + inside = !inside; + } + } + return inside; + } + + private static boolean pointOnSegment(double px, double py, + double ax, double ay, + double bx, double by, + double eps) { + // 叉积为 0 表示共线 + double cross = (px - ax) * (by - ay) - (py - ay) * (bx - ax); + if (Math.abs(cross) > eps) { + return false; + } + // 点积判断是否在线段范围内 + double dot = (px - ax) * (bx - ax) + (py - ay) * (by - ay); + if (dot < -eps) { + return false; + } + double lenSq = (bx - ax) * (bx - ax) + (by - ay) * (by - ay); + return dot - lenSq <= eps; + } + + private static boolean nearlyEqual(double a, double b, double eps) { + return Math.abs(a - b) <= eps; + } +} + diff --git a/src/test/java/com/gxwebsoft/shop/util/GeoFenceUtilTest.java b/src/test/java/com/gxwebsoft/shop/util/GeoFenceUtilTest.java new file mode 100644 index 0000000..ada1824 --- /dev/null +++ b/src/test/java/com/gxwebsoft/shop/util/GeoFenceUtilTest.java @@ -0,0 +1,36 @@ +package com.gxwebsoft.shop.util; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class GeoFenceUtilTest { + + @Test + void parsePoints_semicolonPairs() { + List pts = GeoFenceUtil.parsePolygonPoints("0,0;10,0;10,10;0,10"); + assertEquals(4, pts.size()); + assertEquals(0.0, pts.get(0).lng, 1e-9); + assertEquals(0.0, pts.get(0).lat, 1e-9); + } + + @Test + void parsePoints_jsonArray() { + List pts = GeoFenceUtil.parsePolygonPoints("[[0,0],[10,0],[10,10],[0,10]]"); + assertEquals(4, pts.size()); + assertEquals(10.0, pts.get(2).lng, 1e-9); + assertEquals(10.0, pts.get(2).lat, 1e-9); + } + + @Test + void containsInclusive_insideOutsideAndBoundary() { + List square = GeoFenceUtil.parsePolygonPoints("0,0;10,0;10,10;0,10"); + assertTrue(GeoFenceUtil.containsInclusive(square, 5, 5), "inside"); + assertFalse(GeoFenceUtil.containsInclusive(square, 15, 5), "outside"); + assertTrue(GeoFenceUtil.containsInclusive(square, 0, 5), "on edge"); + assertTrue(GeoFenceUtil.containsInclusive(square, 0, 0), "on vertex"); + } +} +