feat(order): 添加配送范围电子围栏校验功能
- 在订单创建流程中集成电子围栏校验机制 - 实现不信任前端坐标的地址表坐标验证策略 - 添加多种格式的围栏points解析支持(JSON、分号分隔等) - 实现射线投射算法进行点在多边形内判断 - 添加自提和无需物流订单的围栏校验跳过逻辑 - 实现坐标缺失和异常情况的错误处理机制 - 添加围栏配置异常时的订单拒绝保护机制 - 创建GeoFenceUtil工具类提供完整的围栏功能支持
This commit is contained in:
@@ -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("下单成功");
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 下单时校验配送范围(电子围栏)。
|
||||
* <p>
|
||||
* 规则:
|
||||
* - 仅对需要配送到地址的订单做校验;自提/无需物流跳过;
|
||||
* - 不信任前端传经纬度:用 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用业务规则
|
||||
*/
|
||||
|
||||
@@ -39,4 +39,24 @@ public interface ShopStoreFenceService extends IService<ShopStoreFence> {
|
||||
*/
|
||||
ShopStoreFence getByIdRel(Integer id);
|
||||
|
||||
/**
|
||||
* 当前租户是否配置了任一启用围栏(status=0)。
|
||||
*/
|
||||
boolean hasEnabledFences(Integer tenantId);
|
||||
|
||||
/**
|
||||
* 校验坐标是否落在任一启用围栏内。
|
||||
* <p>
|
||||
* 约定:
|
||||
* - 围栏按 tenantId + status=0 过滤;
|
||||
* - 支持多个围栏:命中任意一个即通过;
|
||||
* - 无围栏配置:直接放行;
|
||||
* - 围栏 points 异常:抛出异常(避免误送)。
|
||||
*
|
||||
* @param tenantId 租户ID
|
||||
* @param lng 经度
|
||||
* @param lat 纬度
|
||||
*/
|
||||
void validatePointInEnabledFences(Integer tenantId, double lng, double lat);
|
||||
|
||||
}
|
||||
|
||||
@@ -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<ShopStoreFenceMapper, ShopStoreFence> implements ShopStoreFenceService {
|
||||
private static final Logger log = LoggerFactory.getLogger(ShopStoreFenceServiceImpl.class);
|
||||
|
||||
@Override
|
||||
public PageResult<ShopStoreFence> pageRel(ShopStoreFenceParam param) {
|
||||
@@ -44,4 +50,60 @@ public class ShopStoreFenceServiceImpl extends ServiceImpl<ShopStoreFenceMapper,
|
||||
return param.getOne(baseMapper.selectListRel(param));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasEnabledFences(Integer tenantId) {
|
||||
if (tenantId == null) {
|
||||
return false;
|
||||
}
|
||||
return this.count(new LambdaQueryWrapper<ShopStoreFence>()
|
||||
.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<ShopStoreFence> fences = this.list(new LambdaQueryWrapper<ShopStoreFence>()
|
||||
.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<GeoFenceUtil.Point> 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("收货地址不在配送范围内");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
224
src/main/java/com/gxwebsoft/shop/util/GeoFenceUtil.java
Normal file
224
src/main/java/com/gxwebsoft/shop/util/GeoFenceUtil.java
Normal file
@@ -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 字段为多边形点列表。
|
||||
* <p>
|
||||
* 支持格式示例:
|
||||
* - [[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<Point> 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<Point> 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<Double> 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<Point> 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<Point> parsePairs(String[] pairTokens) {
|
||||
List<Point> 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<Point> trimClosingPoint(List<Point> 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<Point> 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;
|
||||
}
|
||||
}
|
||||
|
||||
36
src/test/java/com/gxwebsoft/shop/util/GeoFenceUtilTest.java
Normal file
36
src/test/java/com/gxwebsoft/shop/util/GeoFenceUtilTest.java
Normal file
@@ -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<GeoFenceUtil.Point> 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<GeoFenceUtil.Point> 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<GeoFenceUtil.Point> 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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user