feat(order): 添加配送范围电子围栏校验功能

- 在订单创建流程中集成电子围栏校验机制
- 实现不信任前端坐标的地址表坐标验证策略
- 添加多种格式的围栏points解析支持(JSON、分号分隔等)
- 实现射线投射算法进行点在多边形内判断
- 添加自提和无需物流订单的围栏校验跳过逻辑
- 实现坐标缺失和异常情况的错误处理机制
- 添加围栏配置异常时的订单拒绝保护机制
- 创建GeoFenceUtil工具类提供完整的围栏功能支持
This commit is contained in:
2026-02-09 11:16:04 +08:00
parent efe7904755
commit 3b4f8a29d8
7 changed files with 469 additions and 9 deletions

View File

@@ -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("下单成功");
}

View File

@@ -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));
}

View File

@@ -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);
}
/**
* 应用业务规则
*/

View File

@@ -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);
}

View File

@@ -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("收货地址不在配送范围内");
}
}

View 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;
}
}

View 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");
}
}