MongoDB GEO 项目场景 ms-scope 实战

大家好,我是此林。

关于 MongoDB,我们在这篇文章中已经详细讲述了其基本增删改查、SpringBoot 整合等内容。一文快速入门 MongoDB 、MongoDB 8.2 下载安装、增删改查操作、索引、SpringBoot整合 Spring Data MongoDB

今天我们分享的是 MongoDB GEO 项目场景实战。

1. 场景描述

小王在某物流平台上下单,系统需要快速找到能为他服务的快递员。通过对比小王的位置与快递员的服务范围,平台能准确匹配到合适人员,解决了订单分配效率低、匹配不精准的问题。

那系统具体怎么做呢?

首先快递网点会有个作业范围,比如图中红色框标注的多边形,一个由多个坐标点组成的多边形,并且必须是闭合的多边形。小王位置为灰色的竖线。

这个就比较适合用MongoDB来存储。

用户小王下了订单,如何找到属于该服务范围内的快递员呢?我们使用MongoDB的$geoIntersects查询操作,其原理就是查找小王的位置坐标点与哪个多边形有交叉,这个就是为其服务的快递员。

2. 定义 MongoDB 实体

机构和快递员的作业范围逻辑一般是一样的,所以可以共存一张表中,通过type进行区分,1-机构,2-快递员。

java 复制代码
/**
 * 服务范围实体
 */
@Data
@Document("service_scope")
public class ServiceScopeEntity {

    @Id
    @JsonIgnore
    private ObjectId id;

    /**
     * 业务id,可以是机构或快递员
     */
    @Indexed
    private Long bid;

    /**
     * 类型 {@link com.ms.scope.enums.ServiceTypeEnum}
     */
    @Indexed
    private Integer type;

    /**
     * 多边形范围,是闭合的范围,开始经纬度与结束经纬度必须一样
     * x: 经度,y:纬度
     */
    @GeoSpatialIndexed(type = GeoSpatialIndexType.GEO_2DSPHERE)
    private GeoJsonPolygon polygon;

    private Long created; //创建时间
    private Long updated; //更新时间
}

这里面的 @Document("service_scope") 指定 MongoDB 集合名称(类似于关系型数据库中的表名),里面的每条记录对应一片服务区域。

**id:**ObjectId 类型。ObjectId 是 MongoDB 内置的唯一标识类型,@Id 表示该字段是主键,@JsonIgnore 表示序列化为 JSON 时忽略该字段(对外不暴露)。

**bid:**某个网点 ID,或某个快递员的 ID, @Indexed 表示该字段会在 MongoDB 中建立索引,加快查询速度。

**type:**1=网点,2=快递员,也加了索引,提高查询效率。

**polygon:**GeoJsonPolygon 类型。用 GeoJsonPolygon 来存储一个多边形(由多个经纬度点组成)。多边形必须闭合,即起点和终点的经纬度必须相同。@GeoSpatialIndexed(type = GeoSpatialIndexType.GEO_2DSPHERE) 用于建立地理空间索引(GEO_2DSPHERE)。

**created:**创建时间(时间戳,毫秒值)

**updated:**更新时间(时间戳,毫秒值)

这里再贴一下服务类型枚举:

java 复制代码
import cn.hutool.core.util.EnumUtil;

/**
 * 服务类型枚举
 */
public enum ServiceTypeEnum {

    ORGAN(1, "机构"),
    COURIER(2, "快递员");

    /**
     * 类型编码
     */
    private final Integer code;

    /**
     * 类型值
     */
    private final String value;


    ServiceTypeEnum(Integer code, String value) {
        this.code = code;
        this.value = value;
    }

    public Integer getCode() {
        return code;
    }

    public String getValue() {
        return value;
    }

    public static ServiceTypeEnum codeOf(Integer code) {
        return EnumUtil.getBy(ServiceTypeEnum::getCode, code);
    }
}

3. ScopeService 接口编写

我们 Service 要编写哪些方法呢?

主要有下面几个:

  1. 新增或更新服务范围。即新增或更新服务范围的多边形各个顶点坐标。

  2. 根据主键id、业务id、类型删除+查询服务范围

  3. 根据坐标点查询所属哪个服务范围。这个坐标点就是用户位置坐标。

  4. 根据用户详细地址查询所属的服务范围。

所以我们定义如下接口:

java 复制代码
/**
 * 服务范围Service
 */
public interface ScopeService {

    /**
     * 新增或更新服务范围
     *
     * @param bid     业务id
     * @param type    类型
     * @param polygon 多边形坐标点
     * @return 是否成功
     */
    Boolean saveOrUpdate(Long bid, ServiceTypeEnum type, GeoJsonPolygon polygon);

    /**
     * 根据主键id删除数据
     *
     * @param id 主键
     * @return 是否成功
     */
    Boolean delete(String id);

    /**
     * 根据业务id和类型删除数据
     *
     * @param bid  业务id
     * @param type 类型
     * @return 是否成功
     */
    Boolean delete(Long bid, ServiceTypeEnum type);

    /**
     * 根据主键查询数据
     *
     * @param id 主键
     * @return 服务范围数据
     */
    ServiceScopeEntity queryById(String id);

    /**
     * 根据业务id和类型查询数据
     *
     * @param bid  业务id
     * @param type 类型
     * @return 服务范围数据
     */
    ServiceScopeEntity queryByBidAndType(Long bid, ServiceTypeEnum type);

    /**
     * 根据坐标点查询所属的服务对象
     *
     * @param type  类型
     * @param point 坐标点
     * @return 服务范围数据
     */
    List<ServiceScopeEntity> queryListByPoint(ServiceTypeEnum type, GeoJsonPoint point);

    /**
     * 根据详细地址查询所属的服务对象
     *
     * @param type    类型
     * @param address 详细地址,如:石家庄市桥西区XXX小区XX号楼XXX室
     * @return 服务范围数据
     */
    List<ServiceScopeEntity> queryListByPoint(ServiceTypeEnum type, String address);
}

4. ScopeController API 编写

java 复制代码
@Api(tags = "服务范围")
@RestController
@RequestMapping("/scopes")
@Validated
public class ScopeController {

    @Resource
    private ScopeService scopeService;

    /**
     * 新增或更新服务服务范围
     *
     * @return REST标准响应
     */
    @ApiOperation(value = "新增/更新", notes = "新增或更新服务服务范围")
    @PostMapping
    public ResponseEntity<Void> saveScope(@RequestBody ServiceScopeDTO serviceScopeDTO) {
        ServiceScopeEntity serviceScopeEntity = EntityUtils.toEntity(serviceScopeDTO);
        Long bid = serviceScopeEntity.getBid();
        ServiceTypeEnum type = ServiceTypeEnum.codeOf(serviceScopeEntity.getType());
        Boolean result = this.scopeService.saveOrUpdate(bid, type, serviceScopeEntity.getPolygon());
        if (result) {
            return ResponseEntityUtils.ok();
        }
        return ResponseEntityUtils.error();
    }

    /**
     * 删除服务范围
     *
     * @param bid  业务id
     * @param type 类型
     * @return REST标准响应
     */
    @ApiImplicitParams({
            @ApiImplicitParam(name = "bid", value = "业务id,可以是机构或快递员", dataTypeClass = Long.class),
            @ApiImplicitParam(name = "type", value = "类型,1-机构,2-快递员", dataTypeClass = Integer.class)
    })
    @ApiOperation(value = "删除", notes = "删除服务范围")
    @DeleteMapping("{bid}/{type}")
    public ResponseEntity<Void> delete(@NotNull(message = "bid不能为空") @PathVariable("bid") Long bid,
                                       @NotNull(message = "type不能为空") @PathVariable("type") Integer type) {
        Boolean result = this.scopeService.delete(bid, ServiceTypeEnum.codeOf(type));
        if (result) {
            return ResponseEntityUtils.ok();
        }
        return ResponseEntityUtils.error();
    }

    /**
     * 查询服务范围
     *
     * @param bid  业务id
     * @param type 类型
     * @return 服务范围数据
     */
    @ApiImplicitParams({
            @ApiImplicitParam(name = "bid", value = "业务id,可以是机构或快递员", dataTypeClass = Long.class),
            @ApiImplicitParam(name = "type", value = "类型,1-机构,2-快递员", dataTypeClass = Integer.class)
    })
    @ApiOperation(value = "查询", notes = "查询服务范围")
    @GetMapping("{bid}/{type}")
    public ResponseEntity<ServiceScopeDTO> queryServiceScope(@NotNull(message = "bid不能为空") @PathVariable("bid") Long bid,
                                                             @NotNull(message = "type不能为空") @PathVariable("type") Integer type) {
        ServiceScopeEntity serviceScopeEntity = this.scopeService.queryByBidAndType(bid, ServiceTypeEnum.codeOf(type));
        return ResponseEntityUtils.ok(EntityUtils.toDTO(serviceScopeEntity));
    }

    /**
     * 地址查询服务范围
     *
     * @param type    类型,1-机构,2-快递员
     * @param address 详细地址,如:北京市昌平区金燕龙办公楼传智教育总部
     * @return 服务范围数据列表
     */
    @ApiImplicitParams({
            @ApiImplicitParam(name = "type", value = "类型,1-机构,2-快递员", dataTypeClass = Integer.class),
            @ApiImplicitParam(name = "address", value = "详细地址,如:北京市昌平区金燕龙办公楼传智教育总部", dataTypeClass = String.class)
    })
    @ApiOperation(value = "地址查询服务范围", notes = "地址查询服务范围")
    @GetMapping("address")
    public ResponseEntity<List<ServiceScopeDTO>> queryListByAddress(@NotNull(message = "type不能为空") @RequestParam("type") Integer type,
                                                                    @NotNull(message = "address不能为空") @RequestParam("address") String address) {
        List<ServiceScopeEntity> serviceScopeEntityList = this.scopeService.queryListByPoint(ServiceTypeEnum.codeOf(type), address);
        return ResponseEntityUtils.ok(EntityUtils.toDTOList(serviceScopeEntityList));
    }

    /**
     * 位置查询服务范围
     *
     * @param type      类型,1-机构,2-快递员
     * @param longitude 经度
     * @param latitude  纬度
     * @return 服务范围数据列表
     */
    @ApiImplicitParams({
            @ApiImplicitParam(name = "type", value = "类型,1-机构,2-快递员", dataTypeClass = Integer.class),
            @ApiImplicitParam(name = "longitude", value = "经度", dataTypeClass = Double.class),
            @ApiImplicitParam(name = "latitude", value = "纬度", dataTypeClass = Double.class)
    })
    @ApiOperation(value = "位置查询服务范围", notes = "位置查询服务范围")
    @GetMapping("location")
    public ResponseEntity<List<ServiceScopeDTO>> queryListByAddress(@NotNull(message = "type不能为空") @RequestParam("type") Integer type,
                                                                    @NotNull(message = "longitude不能为空") @RequestParam("longitude") Double longitude,
                                                                    @NotNull(message = "latitude不能为空") @RequestParam("latitude") Double latitude) {
        List<ServiceScopeEntity> serviceScopeEntityList = this.scopeService.queryListByPoint(ServiceTypeEnum.codeOf(type), new GeoJsonPoint(longitude, latitude));
        return ResponseEntityUtils.ok(EntityUtils.toDTOList(serviceScopeEntityList));
    }
}

Spring MVC 的 @RequestParam@PathVariable 默认不会自动生成 Swagger 文档里参数的详细信息。

@ApiImplicitParam 是 Swagger(API 文档生成工具) 的注解,用于描述接口的请求参数,让 Swagger UI 能够自动生成可交互的 API 文档。

  • name:参数名,对应接口方法里的 @RequestParam@PathVariable 名称。
  • value:参数说明,会显示在 Swagger 文档里。
  • dataTypeClass:参数的数据类型(如 Integer.classString.class)。
  • 还可以写 required = true 表示必填。

5. ScopeServiceImpl 实现

  1. 删除或更新服务范围
java 复制代码
    @Override
    public Boolean saveOrUpdate(Long bid, ServiceTypeEnum type, GeoJsonPolygon polygon) {
        Query query = Query.query(Criteria.where("bid").is(bid).and("type").is(type.getCode())); //构造查询条件
        ServiceScopeEntity serviceScopeEntity = this.mongoTemplate.findOne(query, ServiceScopeEntity.class);
        if (ObjectUtil.isEmpty(serviceScopeEntity)) {
            //新增
            serviceScopeEntity = new ServiceScopeEntity();
            serviceScopeEntity.setBid(bid);
            serviceScopeEntity.setType(type.getCode());
            serviceScopeEntity.setPolygon(polygon);
            serviceScopeEntity.setCreated(System.currentTimeMillis());
            serviceScopeEntity.setUpdated(serviceScopeEntity.getCreated());
        } else {
            //更新
            serviceScopeEntity.setPolygon(polygon);
            serviceScopeEntity.setUpdated(System.currentTimeMillis());
        }

        try {
            this.mongoTemplate.save(serviceScopeEntity);
            return true;
        } catch (Exception e) {
            log.error("新增/更新服务范围数据失败! bid = {}, type = {}, points = {}", bid, type, polygon.getPoints(), e);
        }
        return false;
    }
  1. 删除服务范围
java 复制代码
    @Override
    public Boolean delete(String id) {
        Query query = Query.query(Criteria.where("id").is(new ObjectId(id))); //构造查询条件
        return this.mongoTemplate.remove(query, ServiceScopeEntity.class).getDeletedCount() > 0;
    }

    @Override
    public Boolean delete(Long bid, ServiceTypeEnum type) {
        Query query = Query.query(Criteria.where("bid").is(bid).and("type").is(type.getCode())); //构造查询条件
        return this.mongoTemplate.remove(query, ServiceScopeEntity.class).getDeletedCount() > 0;
    }
  1. 基础查询
java 复制代码
    @Override
    public ServiceScopeEntity queryById(String id) {
        return this.mongoTemplate.findById(new ObjectId(id), ServiceScopeEntity.class);
    }

    @Override
    public ServiceScopeEntity queryByBidAndType(Long bid, ServiceTypeEnum type) {
        Query query = Query.query(Criteria.where("bid").is(bid).and("type").is(type.getCode())); //构造查询条件
        return this.mongoTemplate.findOne(query, ServiceScopeEntity.class);
    }
  1. 根据用户经纬度坐标查询所属哪个服务范围
java 复制代码
    @Override
    public List<ServiceScopeEntity> queryListByPoint(ServiceTypeEnum type, GeoJsonPoint point) {
        Query query = Query.query(Criteria.where("polygon").intersects(point)
                .and("type").is(type.getCode()));
        return this.mongoTemplate.find(query, ServiceScopeEntity.class);
    }

**GeoJsonPoint point **是用户传过来的经纬度坐标,比如:

java 复制代码
new GeoJsonPoint(116.395645, 39.929986) // 北京天安门

Criteria.where("polygon").intersects(point)

  • polygon 是我们之前在 ServiceScopeEntity 中定义的服务范围多边形(闭合区域)。

  • intersects(point) 的意思是:查询所有 polygon 多边形包含该点的记录

  • MongoDB 的地理空间查询会自动判断点是否落在多边形内部。

  1. 根据详细地址查询坐标
java 复制代码
    @Override
    public List<ServiceScopeEntity> queryListByPoint(ServiceTypeEnum type, String address) {
        //根据详细地址查询坐标
        GeoResult geoResult = this.mapTemplate.opsForBase().geoCode(ProviderEnum.AMAP, address, null);
        Coordinate coordinate = geoResult.getLocation();
        return this.queryListByPoint(type, new GeoJsonPoint(coordinate.getLongitude(), coordinate.getLatitude()));
    }

这里我们要调用地图服务的接口(比如高德或百度地图服务)。只要传入地址,高德或百度都可以返回经纬度。具体接口调用可以看看官方文档。

6. 测试

java 复制代码
@SpringBootTest
public class ScopeServiceTest {

    @Resource
    private ScopeService scopeService;

    @Test
    void saveOrUpdate() {
        List<Point> pointList = Arrays.asList(new Point(116.340064,40.061245),
                new Point(116.347081,40.061836),
                new Point(116.34751,40.05842),
                new Point(116.342446,40.058092),
                new Point(116.340064,40.061245));
        Boolean result = this.scopeService.saveOrUpdate(2L, ServiceTypeEnum.ORGAN, new GeoJsonPolygon(pointList));
        System.out.println(result);
    }

    @Test
    void testQueryListByPoint() {
        GeoJsonPoint point = new GeoJsonPoint(116.344828,40.05911);
        List<ServiceScopeEntity> serviceScopeEntities = this.scopeService.queryListByPoint(ServiceTypeEnum.ORGAN, point);
        serviceScopeEntities.forEach(serviceScopeEntity -> System.out.println(serviceScopeEntity));
    }

    @Test
    void testQueryListByPoint2() {
        String address = "石家庄市桥西区永德小区2号楼802室";
        List<ServiceScopeEntity> serviceScopeEntities = this.scopeService.queryListByPoint(ServiceTypeEnum.ORGAN, address);
        serviceScopeEntities.forEach(serviceScopeEntity -> System.out.println(serviceScopeEntity));
    }
}

最终返回类似如下的匹配到的服务范围。

7. 其他思考点

其实目前代码所做,基本上是确定出用户被分派给哪个机构,后续还需要把派件任务委派给快递员。

确定好了起始的 网点,还需要确定终点网点。

同样的,取 收件人地址 → 转坐标 (lng, lat),查询 MongoDB:找哪个机构的服务范围包含这个点,结果 = 终点网点。

中途还会有 **路线规划(用于调度),**起点网点 → 中转中心(多级)→ 终点网点。这些后续会介绍。

为什么会用 MongoDB 存储服务范围?

  • 快递员、网点、机构都有"服务范围",这些范围通常是多边形(polygon)。
  • 如果用 MySQL 来存:我们可能想到用 JSON 字符串存经纬度点,但是查询时需要自己写数学算法判断点是否在多边形内,比较复杂且慢。
  • MongoDB 支持 GeoJSON 和 地理空间索引,自带点、多边形、圆形的查询能力,查询效率高。

常见应用:

  • "附近的餐馆/快递员/加油站"
  • "这个地址属于哪个网点"
  • "用户当前位置在不在配送范围"

所以,这类"地理空间场景"适合 MongoDB,既然有开箱即用的工具为啥不用呢?

不过,话虽然这么说。

MySQL 从 5.7 开始其实也支持 GIS ,可以存储 POINTPOLYGON 等几何类型。除了 MySQL、MongoDB,还有其他的技术实现对比:

技术 支持点查询 支持多边形 性能 适用场景
MongoDB 中等 普通互联网业务,范围查询、附近的人
MySQL GIS 一般 小规模场景,已有 MySQL 就能用
PostGIS 需要复杂地理计算的大型系统
Elasticsearch 搜索 + 地理混合场景
Redis GEO ❌(无多边形) 超高 附近的人,高并发场景

Redis GEO性能超高的原因还是主要是因为它是内存计算。

不管哪种技术选型,适合自己的业务才是王道,项目里之所以用 MongoDB 其实也是因为 MongoDB 不仅仅用于地理范围搜索,还有其他用途。

当然比如说项目里有百万千万级别的全文搜索 + 地理位置搜索场景,用 Elasticsearch也是可以的。

今天的分享就到这里了。

我是此林,关注我吧!带你看不一样的世界!

相关推荐
代码栈上的思考2 分钟前
SpringBoot 拦截器
java·spring boot·spring
AI_56784 分钟前
阿里云OSS成本优化:生命周期规则+分层存储省70%
运维·数据库·人工智能·ai
送秋三十五6 分钟前
一次大文件处理性能优化实录————Java 优化过程
java·开发语言·性能优化
choke2337 分钟前
软件测试任务测试
服务器·数据库·sqlserver
龙山云仓8 分钟前
MES系统超融合架构
大数据·数据库·人工智能·sql·机器学习·架构·全文检索
雨中飘荡的记忆8 分钟前
千万级数据秒级对账!银行日终批处理对账系统从理论到实战
java
IT邦德9 分钟前
OEL9.7 安装 Oracle 26ai RAC
数据库·oracle
jbtianci14 分钟前
Spring Boot管理用户数据
java·spring boot·后端
Sylvia-girl17 分钟前
线程池~~
java·开发语言
编程彩机18 分钟前
互联网大厂Java面试:从Jakarta EE到微服务架构的技术场景深度解读
spring boot·分布式事务·微服务架构·java面试·jakarta ee