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.glt.service.GltTicketOrderService;
|
||||||
import com.gxwebsoft.shop.entity.ShopStoreRider;
|
import com.gxwebsoft.shop.entity.ShopStoreRider;
|
||||||
import com.gxwebsoft.shop.entity.ShopUserAddress;
|
import com.gxwebsoft.shop.entity.ShopUserAddress;
|
||||||
|
import com.gxwebsoft.shop.service.ShopStoreFenceService;
|
||||||
import com.gxwebsoft.shop.service.ShopStoreRiderService;
|
import com.gxwebsoft.shop.service.ShopStoreRiderService;
|
||||||
import com.gxwebsoft.shop.service.ShopUserAddressService;
|
import com.gxwebsoft.shop.service.ShopUserAddressService;
|
||||||
import cn.hutool.core.util.StrUtil;
|
import cn.hutool.core.util.StrUtil;
|
||||||
@@ -40,6 +41,8 @@ public class GltTicketOrderController extends BaseController {
|
|||||||
@Resource
|
@Resource
|
||||||
private ShopUserAddressService shopUserAddressService;
|
private ShopUserAddressService shopUserAddressService;
|
||||||
@Resource
|
@Resource
|
||||||
|
private ShopStoreFenceService shopStoreFenceService;
|
||||||
|
@Resource
|
||||||
private ShopStoreRiderService shopStoreRiderService;
|
private ShopStoreRiderService shopStoreRiderService;
|
||||||
|
|
||||||
@Operation(summary = "分页查询送水订单")
|
@Operation(summary = "分页查询送水订单")
|
||||||
@@ -119,6 +122,18 @@ public class GltTicketOrderController extends BaseController {
|
|||||||
gltTicketOrder.setAddressId(userAddress.getId());
|
gltTicketOrder.setAddressId(userAddress.getId());
|
||||||
gltTicketOrder.setAddress(buildAddressSnapshot(userAddress));
|
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());
|
gltTicketOrderService.createWithWriteOff(gltTicketOrder, loginUser.getUserId(), loginUser.getTenantId());
|
||||||
return success("下单成功");
|
return success("下单成功");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import com.gxwebsoft.common.core.utils.WechatPayConfigValidator;
|
|||||||
import com.gxwebsoft.common.core.web.BaseController;
|
import com.gxwebsoft.common.core.web.BaseController;
|
||||||
import com.gxwebsoft.common.system.entity.Payment;
|
import com.gxwebsoft.common.system.entity.Payment;
|
||||||
import com.gxwebsoft.shop.entity.ShopOrderDelivery;
|
import com.gxwebsoft.shop.entity.ShopOrderDelivery;
|
||||||
|
import com.gxwebsoft.shop.entity.ShopUserAddress;
|
||||||
import com.gxwebsoft.shop.service.*;
|
import com.gxwebsoft.shop.service.*;
|
||||||
import com.gxwebsoft.shop.service.impl.KuaiDi100Impl;
|
import com.gxwebsoft.shop.service.impl.KuaiDi100Impl;
|
||||||
import com.gxwebsoft.shop.task.OrderAutoCancelTask;
|
import com.gxwebsoft.shop.task.OrderAutoCancelTask;
|
||||||
@@ -99,6 +100,8 @@ public class ShopOrderController extends BaseController {
|
|||||||
private ShopWechatShippingSyncService shopWechatShippingSyncService;
|
private ShopWechatShippingSyncService shopWechatShippingSyncService;
|
||||||
@Resource
|
@Resource
|
||||||
private PaymentService paymentService;
|
private PaymentService paymentService;
|
||||||
|
@Resource
|
||||||
|
private ShopStoreFenceService shopStoreFenceService;
|
||||||
|
|
||||||
@Operation(summary = "分页查询订单")
|
@Operation(summary = "分页查询订单")
|
||||||
@GetMapping("/page")
|
@GetMapping("/page")
|
||||||
@@ -148,6 +151,9 @@ public class ShopOrderController extends BaseController {
|
|||||||
shopOrder.setUserId(loginUser.getUserId());
|
shopOrder.setUserId(loginUser.getUserId());
|
||||||
shopOrder.setOpenid(loginUser.getOpenid());
|
shopOrder.setOpenid(loginUser.getOpenid());
|
||||||
shopOrder.setPayUserId(loginUser.getUserId());
|
shopOrder.setPayUserId(loginUser.getUserId());
|
||||||
|
if (shopOrder.getTenantId() == null) {
|
||||||
|
shopOrder.setTenantId(loginUser.getTenantId());
|
||||||
|
}
|
||||||
|
|
||||||
// 下单时间 & 订单过期时间:默认下单后10分钟过期(用于发起支付等场景校验)
|
// 下单时间 & 订单过期时间:默认下单后10分钟过期(用于发起支付等场景校验)
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
@@ -178,6 +184,39 @@ public class ShopOrderController extends BaseController {
|
|||||||
shopOrder.setPrice(new BigDecimal("0.01"));
|
shopOrder.setPrice(new BigDecimal("0.01"));
|
||||||
shopOrder.setTotalPrice(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)) {
|
if (shopOrderService.save(shopOrder)) {
|
||||||
return success("下单成功", shopOrderService.createWxOrder(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.config.OrderConfigProperties;
|
||||||
import com.gxwebsoft.shop.dto.OrderCreateRequest;
|
import com.gxwebsoft.shop.dto.OrderCreateRequest;
|
||||||
import com.gxwebsoft.shop.entity.*;
|
import com.gxwebsoft.shop.entity.*;
|
||||||
|
import com.gxwebsoft.shop.service.ShopStoreFenceService;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.BeanUtils;
|
import org.springframework.beans.BeanUtils;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -51,6 +52,8 @@ public class OrderBusinessService {
|
|||||||
private ShopUserAddressService shopUserAddressService;
|
private ShopUserAddressService shopUserAddressService;
|
||||||
@Resource
|
@Resource
|
||||||
private ShopUserCouponService shopUserCouponService;
|
private ShopUserCouponService shopUserCouponService;
|
||||||
|
@Resource
|
||||||
|
private ShopStoreFenceService shopStoreFenceService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建订单
|
* 创建订单
|
||||||
@@ -71,6 +74,9 @@ public class OrderBusinessService {
|
|||||||
// 3. 处理收货地址信息
|
// 3. 处理收货地址信息
|
||||||
processDeliveryAddress(shopOrder, request, loginUser);
|
processDeliveryAddress(shopOrder, request, loginUser);
|
||||||
|
|
||||||
|
// 3.1 下单时校验配送范围(电子围栏)
|
||||||
|
validateDeliveryFenceIfNeeded(shopOrder, loginUser);
|
||||||
|
|
||||||
// 4. 应用业务规则
|
// 4. 应用业务规则
|
||||||
applyBusinessRules(shopOrder, loginUser);
|
applyBusinessRules(shopOrder, loginUser);
|
||||||
|
|
||||||
@@ -402,13 +408,9 @@ public class OrderBusinessService {
|
|||||||
*/
|
*/
|
||||||
private void processDeliveryAddress(ShopOrder shopOrder, OrderCreateRequest request, User loginUser) {
|
private void processDeliveryAddress(ShopOrder shopOrder, OrderCreateRequest request, User loginUser) {
|
||||||
try {
|
try {
|
||||||
// 1. 如果前端已经传入了完整的收货地址信息,直接使用
|
// 前端可能会传 addressLat/addressLng,但不可信;这里一律以地址表中的坐标为准(需 addressId)。
|
||||||
if (isAddressInfoComplete(request)) {
|
|
||||||
log.info("使用前端传入的收货地址信息,用户ID:{}", loginUser.getUserId());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 如果指定了地址ID,获取该地址信息
|
// 1. 如果指定了地址ID,优先使用该地址
|
||||||
if (request.getAddressId() != null) {
|
if (request.getAddressId() != null) {
|
||||||
ShopUserAddress userAddress = shopUserAddressService.getById(request.getAddressId());
|
ShopUserAddress userAddress = shopUserAddressService.getById(request.getAddressId());
|
||||||
if (userAddress != null && userAddress.getUserId().equals(loginUser.getUserId())) {
|
if (userAddress != null && userAddress.getUserId().equals(loginUser.getUserId())) {
|
||||||
@@ -420,6 +422,13 @@ public class OrderBusinessService {
|
|||||||
request.getAddressId(), loginUser.getUserId());
|
request.getAddressId(), loginUser.getUserId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. 若前端已传完整地址快照但没传 addressId:保持快照,不在这里“猜测”绑定地址。
|
||||||
|
// 围栏校验会根据是否配置围栏决定是否强制要求 addressId。
|
||||||
|
if (isAddressInfoComplete(request)) {
|
||||||
|
log.warn("前端传入了收货地址快照字段,但未传 addressId,用户ID:{}", loginUser.getUserId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 获取用户默认收货地址
|
// 3. 获取用户默认收货地址
|
||||||
ShopUserAddress defaultAddress = shopUserAddressService.getDefaultAddress(loginUser.getUserId());
|
ShopUserAddress defaultAddress = shopUserAddressService.getDefaultAddress(loginUser.getUserId());
|
||||||
if (defaultAddress != null) {
|
if (defaultAddress != null) {
|
||||||
@@ -482,12 +491,12 @@ public class OrderBusinessService {
|
|||||||
request.setRealName(userAddress.getName());
|
request.setRealName(userAddress.getName());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 复制经纬度信息
|
// 复制经纬度信息(不信任前端传入坐标,一律以地址表为准)
|
||||||
if (request.getAddressLat() == null && userAddress.getLat() != null) {
|
if (userAddress.getLat() != null && !userAddress.getLat().trim().isEmpty()) {
|
||||||
shopOrder.setAddressLat(userAddress.getLat());
|
shopOrder.setAddressLat(userAddress.getLat());
|
||||||
request.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());
|
shopOrder.setAddressLng(userAddress.getLng());
|
||||||
request.setAddressLng(userAddress.getLng());
|
request.setAddressLng(userAddress.getLng());
|
||||||
}
|
}
|
||||||
@@ -496,6 +505,61 @@ public class OrderBusinessService {
|
|||||||
userAddress.getId(), userAddress.getName(), shopOrder.getAddress());
|
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);
|
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;
|
package com.gxwebsoft.shop.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
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.mapper.ShopStoreFenceMapper;
|
||||||
import com.gxwebsoft.shop.service.ShopStoreFenceService;
|
import com.gxwebsoft.shop.service.ShopStoreFenceService;
|
||||||
import com.gxwebsoft.shop.entity.ShopStoreFence;
|
import com.gxwebsoft.shop.entity.ShopStoreFence;
|
||||||
import com.gxwebsoft.shop.param.ShopStoreFenceParam;
|
import com.gxwebsoft.shop.param.ShopStoreFenceParam;
|
||||||
import com.gxwebsoft.common.core.web.PageParam;
|
import com.gxwebsoft.common.core.web.PageParam;
|
||||||
import com.gxwebsoft.common.core.web.PageResult;
|
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 org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -19,6 +24,7 @@ import java.util.List;
|
|||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class ShopStoreFenceServiceImpl extends ServiceImpl<ShopStoreFenceMapper, ShopStoreFence> implements ShopStoreFenceService {
|
public class ShopStoreFenceServiceImpl extends ServiceImpl<ShopStoreFenceMapper, ShopStoreFence> implements ShopStoreFenceService {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ShopStoreFenceServiceImpl.class);
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public PageResult<ShopStoreFence> pageRel(ShopStoreFenceParam param) {
|
public PageResult<ShopStoreFence> pageRel(ShopStoreFenceParam param) {
|
||||||
@@ -44,4 +50,60 @@ public class ShopStoreFenceServiceImpl extends ServiceImpl<ShopStoreFenceMapper,
|
|||||||
return param.getOne(baseMapper.selectListRel(param));
|
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