网点反查与服务范围

03 · 核心亮点

亮点三 · 用户填了个地址,归哪个网点管

MongoDB 2dsphere 空间索引 + 高德地理编码。难点在于 GeoJSON 的坐标约定和多边形重叠的歧义;困难在于写库不是原子的。

涉及模块:chain-cloud-ms-service-scope-service :18089 + chain-cloud-ms-oms-service 消费方

亮点三 · 用户填了个地址,归哪个网点管

业务问题: 客户在 App 上填了「北京市朝阳区建国路 88 号 SOHO 现代城 B 座 1201」。 系统必须回答两个问题: ① 这个地址归哪个网点管?(决定谁去取件) ② 如果不属于任何一个网点,货还能不能发?(决定下单能不能成功)

答错第一个 → 快递员跑错地方,客户体验崩塌。 答错第二个 → 接了不该接的单,发不出去,赔钱。


一、业务场景

1.1 "服务范围"在快递业务里到底是什么

它不是一个圆形半径,也不是一个矩形范围,而是一个不规则多边形。

现实原因非常直接:

  • 快递网点是按小区、写字楼、商圈自然形成的,不是按行政区划划的;
  • 一个城市的不同区域归属不同网点,边界犬牙交错;
  • 有时候两个网点部分重叠(交接带),有时候中间有服务盲区(两不管区)。

所以业务给出的数据形态就是:运营在管理后台用地图画一个多边形,存起来。

1.2 两个使用方,同一套数据

类型 ServiceTypeEnum谁用来干什么
ORGAN(1) 机构网点下单反查:这个地址归哪个网点
COURIER(2) 快递员快递员任务派发:哪些快递员能覆盖这个地址

同一个 cc_service_scope 集合,靠 type 字段区分。一张表服务两个业务场景, 这也是为什么它要用 MongoDB——因为要建空间索引。

1.3 主链路上的位置(这是它在业务里的分量)

text
客户下单
  → 取出寄件地址和收件地址
  → 高德 geoCode(address) 得到经纬度          ← 外部依赖
  → ServiceScopeFeign.queryListByLocation(1, lng, lat)
  → MongoDB: {polygon: {$geoIntersects: point}, type: 1}
  → 命中多条时取第一条的 bid 作为网点 id
  → 没命中 → 抛「地址不在服务范围」→ 下单失败

注意这个链路是同步阻塞的,而且串了两跳: 下单接口 → OMS → Feign → service-scope → MongoDB, 中间还夹了一个高德 HTTP 调用。


二、方案设计

2.1 数据模型

java
// chain-cloud-ms-service-scope-service entity/ServiceScopeEntity.java
@Data
@Document("cc_service_scope")
public class ServiceScopeEntity {

    @Id @JsonIgnore private ObjectId id;

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

    /** 类型 ServiceTypeEnum */
    @Indexed
    private Integer type;

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

    private Long created;
    private Long updated;
}

索引设计:

字段索引类型作用
bidB-tree(@Indexed)按业务 id 查/删
typeB-tree(@Indexed)过滤机构还是快递员
polygonGEO_2DSPHERE$geoIntersects 的服务索引

@GeoSpatialIndexed(type = GEO_2DSPHERE) 会在集合上自动建: db.cc_service_scope.createIndex({polygon: "2dsphere"})

为什么必须用 2dsphere 而不是 2d?

2d(平面)2dsphere(球面)
适用汽车导航、矩形区域球面上的点/线/多边形
经度处理直接当 X 轴正确处理经度环绕(东经 179° 和西经 179° 其实只差 2°)
数据类型只能索引点点、线、多边形都支持

我们存的是多边形,所以只能用 2dsphere。

2.2 核心查询:$geoIntersects

java
// ScopeServiceImpl#queryListByPoint
@Override
public List<ServiceScopeEntity> queryListByPoint(ServiceTypeEnum type, GeoJsonPoint point) {
    var query = Query.query(Criteria.where("polygon")
            .intersects(point)
            .and("type").is(type.getCode()));
    return this.mongoTemplate.find(query, ServiceScopeEntity.class);
}

翻译成 MongoDB shell 就是:

javascript
db.cc_service_scope.find({
  polygon: { $geoIntersects: { $geometry: { type: "Point", coordinates: [ 116.46, 39.92 ] } } },
  type: 1
})

这条查询走的就是 polygon 上的 2dsphere 索引, type 是普通的等值过滤(residual filter)。

为什么用 $geoIntersects 而不是 $near?

这是高频面试点,两者不是"哪个更好"的问题,是业务形态决定的:

$geoIntersects$near
语义"这个点在不在这个多边形里""离我最近的 N 个点/文档"
数据形态多边形 Polygon点 Point
返回全部命中的多边形按距离排序的 N 条
需要索引2dsphere2d 或 2dsphere

我们的业务是"点是否落在运营画的多边形内"——这是一个纯粹的包含关系判断, 所以必须用 $geoIntersects。 用 $near 只能查到"最近的网点", 但最近不代表覆盖,而业务要的是"覆盖"。

2.3 地理编码:把文字地址变成点

java
// ScopeServiceImpl#queryListByPoint(type, address)  —— 重载版本
@Override
public List<ServiceScopeEntity> queryListByPoint(ServiceTypeEnum type, String address) {
    // 根据详细地址查询坐标
    var geoResult = this.eagleMapTemplate.opsForBase().geoCode(ProviderEnum.AMAP, address, null);
    var coordinate = geoResult.getLocation();
    return this.queryListByPoint(type, new GeoJsonPoint(coordinate.getLongitude(), coordinate.getLatitude()));
}

这是一个模板方法模式的干净用法:

text
queryListByPoint(type, address)          —— 字符串入口(内部做地理编码)
        ↓
queryListByPoint(type, GeoJsonPoint)     —— 空间入口(真正的查询)

有坐标的场景(比如下单时 OMS 已经算过一次坐标)可以直接调空间版本, 避免重复调高德。这是很值得在面试里讲的接口设计。

2.4 OMS 侧的真实调用(含降级)

java
// chain-cloud-ms-oms-service OrderServiceImpl#getAgencyId
private HashMap getAgencyId(String address) throws CCException {
    if (ObjectUtil.isEmpty(address)) {
        throw new CCException("下单时发货地址不能为空");
    }
    // 根据详细地址查询坐标
    GeoResult geoResult = this.eagleMapTemplate.opsForBase().geoCode(ProviderEnum.AMAP, address, null);
    Coordinate coordinate = geoResult.getLocation();
    if (ObjectUtil.isEmpty(coordinate)) {
        throw new CCException("地址无法定位");
    }
    double lng = coordinate.getLongitude();   // 经度
    double lat  = coordinate.getLatitude();   // 纬度
    DecimalFormat df = new DecimalFormat("#.######");
    String location = StrUtil.format("{},{}", df.format(lng), df.format(lat));

    // 反查网点
    List<ServiceScopeDTO> serviceScopeDTOS =
        agencyScopeFeign.queryListByLocation(1, coordinate.getLongitude(), coordinate.getLatitude());
    if (CollectionUtils.isEmpty(serviceScopeDTOS)) {
        throw new CCException("地址不在服务范围");
    }
    HashMap result = new HashMap();
    result.put("agencyId", serviceScopeDTOS.get(0).getBid());   // ← 命中多条时取第一条
    result.put("location", location);
    return result;
}

三个业务决策:

  1. type 硬编码为 1 —— 下单场景只关心网点(机构),不关心快递员;
  2. 没命中就抛异常阻断下单 —— "不能发"比"发错"好;
  3. 命中多条时取 get(0) —— 交接带的处理策略(见难点 2)。

三、难点拆解

难点 1:GeoJSON 的坐标约定是"约定",代码不保证

难点在哪: GeoJSON 规范强制要求坐标顺序是 [经度, 纬度](lng, lat)。 但:

  • 人类口头说的是"北纬 39.92,东经 116.46",顺序是纬度在前;
  • Java 的 Coordinate 类构造顺序也是 (longitude, latitude),

但很多人写代码习惯性地写 new Coordinate(lat, lng);

  • 这个项目里 transport 模块的 Point 用的是 x=纬度(见亮点 2 坑 1),

而 scope 模块用的是 x=经度 —— 两个模块约定相反。

为什么危险: 经纬度写反了,查询不会报错,只是不命中。 或者更糟——命中了错误的网点。

怎么解:

java
// 1. 在 Entity 上把约定写死在 Javadoc 里(现有代码做了这一步)
/** x: 经度,y: 纬度 */

// 2. 在 DTO 转换处集中做一次方向转换,不要在各处重复
public static ServiceScopeEntity toEntity(ServiceScopeDTO dto) {
    ...
    List<Point> pointList = dto.getPolygon().stream()
            // 注意:Coordinate(longitude, latitude) → Point(x=lng, y=lat)
            .map(c -> new Point(c.getLongitude(), c.getLatitude()))
            .collect(Collectors.toList());
    serviceScopeEntity.setPolygon(new GeoJsonPolygon(pointList));
    return serviceScopeEntity;
}
java
// 3. 启动时做一次数据体检(强烈建议加)
//    经度必须在 [-180, 180],纬度必须在 [-90, 90]
//    一旦发现纬度 > 90 或经度 > 90,就是写反了

这个难点的通用价值: "静默错误比崩溃更可怕"。写反了不会 NPE,只会让业务出问题, 所以必须靠约定 + 校验两道防线。

难点 2:多边形重叠时,"取第一条"是业务决策不是技术选择

难点在哪: 交接带(两个网点都覆盖的区域)在业务上是必然存在的。 那么命中多条时选谁?

现有实现是 serviceScopeDTOS.get(0),也就是 依赖 MongoDB 的默认返回顺序——这在 find 未指定 sort 时 没有任何稳定性保证,理论上可能每次返回不同的结果。

三种业务上合理的策略:

策略规则适用
就近原则多个多边形命中时,选网点坐标距离最近的用户体验最好,语义最自然
优先级多边形加 priority 字段,命中的按优先级取有主次网点之分时
不裁决返回全部,由运营/客服人工指派交接带本来就模糊

我推荐"就近原则",因为它和业务的直觉一致: 用户觉得"这个地址离哪个网点近,就该归哪个网点",这是用户的心理模型。

java
// 就近原则的实现(在 scope 服务里做,而不是让 OMS 排)
public ServiceScopeEntity queryNearestByPoint(ServiceTypeEnum type, GeoJsonPoint point) {
    var query = Query.query(Criteria.where("polygon").intersects(point)
            .and("type").is(type.getCode()));
    var list = mongoTemplate.find(query, ServiceScopeEntity.class);
    return list.stream()
            .min(Comparator.comparingDouble(e -> distance(point, e.getCentroid())))
            .orElse(null);
}

但这里暴露一个更深层的问题: 多边形没有"中心点"这个属性。 一个 L 形的网点服务范围,它的"中心"可能根本不在多边形内。 要做就近,就得让运营额外标注一个"网点坐标"—— 也就是说,这个空间数据模型是不完整的。

正确的模型应该是:一个 ServiceScopeEntity(多边形,覆盖范围) 加上 一个关联的机构/快递员实体(带经纬度)。 现在是用 bid 弱关联,导致要么反查机构(多一次 Feign),要么无处可取。

难点 3:写库不是原子的

难点在哪:

java
// ScopeServiceImpl#saveOrUpdate
var query = Query.query(Criteria.where("bid").is(bid).and("type").is(type.getCode()));
var serviceScopeEntity = this.mongoTemplate.findOne(query, ServiceScopeEntity.class);
if (null == serviceScopeEntity) {
    // 新增
    serviceScopeEntity = new ServiceScopeEntity();
    ...
} else {
    // 更新
    serviceScopeEntity.setPolygon(polygon);
    ...
}
try {
    this.mongoTemplate.save(serviceScopeEntity);
    return true;
} catch (Exception e) {
    log.error("新增/更新服务范围数据失败!...", e);
}
return false;

这是典型的 check-then-act(先查后写) 模式:

text
线程 A: findOne → null      ┐
线程 B: findOne → null      ├ 两个线程都认为"要新增"
线程 A: save(新文档)        │
线程 B: save(新文档)        ┘ → 集合里出现两条 (bid, type) 相同的文档

为什么会真的并发? 因为这是管理后台的接口——运营通常不会两个人同时操作同一个网点, 但下面的场景一定会:

  • 运营重复点击"保存"(前端没做按钮防抖);
  • 两个运营各改各的(虽然应该加乐观锁)。
  • 批量导入时并发执行。

而且没有唯一索引兜底:

java
@Indexed private Long bid;      // 非唯一
@Indexed private Integer type;  // 非唯一

没有 @CompoundIndex(name="bid_type", def="{'bid':1,'type':1}", unique=true)。

后果链条(这是最严重的部分):

text
出现两条重复文档
  → queryListByPoint 空间查询返回重复的网点 id  ← 结果"看起来"还行
  → queryByBidAndType 用 findOne
  → 如果两条都被命中,Spring Data 抛 IncorrectResultSizeDataAccessException
  → 保存 / 查询接口直接 500

修复方案(三层,从便宜到彻底):

java
// 第 1 层:加唯一复合索引(必须)
@CompoundIndex(name = "bid_type_uniq", def = "{'bid':1,'type':1}", unique = true)
@Document("cc_service_scope")
public class ServiceScopeEntity { ... }
java
// 第 2 层:改用 MongoDB 原生 upsert,彻底消除 check-then-act
public Boolean saveOrUpdate(Long bid, ServiceTypeEnum type, GeoJsonPolygon polygon) {
    Query query = Query.query(Criteria.where("bid").is(bid).and("type").is(type.getCode()));
    Update update = new Update()
            .set("polygon", polygon)
            .set("updated", System.currentTimeMillis())
            .setOnInsert("created", System.currentTimeMillis());
    UpdateResult result = mongoTemplate.upsert(query, update, ServiceScopeEntity.class);
    return result.getUpsertedCount() > 0 || result.getModifiedCount() >= 0;
}
java
// 第 3 层:历史重复数据清理
db.cc_service_scope.aggregate([
  { $group: { _id: "$bid", n: { $sum: 1 } } },
  { $match: { n: { $gt: 1 } } }
])

这个难点的通用价值: 它是一个教科书级的"业务代码里的并发问题"。 面试时讲这个比讲任何分布式锁都有说服力,因为它说明你真的在生产环境踩过。


四、实际遇到的困难与踩坑

坑 1:地理编码失败会变成 NPE + 堆栈泄露

事实:

java
var geoResult = this.eagleMapTemplate.opsForBase().geoCode(ProviderEnum.AMAP, address, null);
var coordinate = geoResult.getLocation();      // geoResult 为 null → NPE
return this.queryListByPoint(type, new GeoJsonPoint(
        coordinate.getLongitude(), coordinate.getLatitude()));  // coordinate 为 null → NPE

两个都没有判空。高德返回 GeoResult 对象但里面 location 为 null 的情况是常见的 (比如地址写得过于模糊、或新开发区)。

OMS 那边的写法是对的(ObjectUtil.isEmpty(coordinate) 判空后抛业务异常), 但 scope 服务的重载版本没有。

后果: 客户填了个"XX市XX区"这种模糊地址, 得到的是 HTTP 500 + 堆栈,而不是"地址无法定位,请补充门牌号"。

修复:

java
var geoResult = this.eagleMapTemplate.opsForBase().geoCode(ProviderEnum.AMAP, address, null);
if (ObjectUtil.isEmpty(geoResult) || ObjectUtil.isEmpty(geoResult.getLocation())) {
    throw new CCException(ScopeExceptionEnum.ADDRESS_NOT_LOCATABLE);  // 业务异常 + 友好提示
}

坑 2:多边形必须闭合,但代码不保证

GeoJSON 规范要求多边形的第一个点和最后一个点必须相同(闭合)。 否则 MongoDB 2dsphere 索引会拒绝该文档,或者空间查询行为异常。

java
// 正确的多边形:首尾同点
[
  [116.340064, 40.061245],
  [116.347081, 40.061836],
  [116.347510, 40.058420],
  [116.342446, 40.058092],
  [116.340064, 40.061245]    // ← 必须和第一个点一样
]

当前实现没有任何校验。 EntityUtils.toEntity 只是把 DTO 的点列表 直接 new GeoJsonPolygon(pointList)。

运营在地图上画多边形时,如果前端多边形库的行为有差异(比如自动闭合 vs 不自动闭合), 就会写入不合法的文档。

修复(在服务端的 DTO 校验里加,不要指望前端):

java
List<Point> pts = pointList;
if (!pts.isEmpty() && !pts.get(0).equals(pts.get(pts.size() - 1))) {
    // 自动闭合,而不是报错——对运营更友好
    pts = new ArrayList<>(pts);
    pts.add(pts.get(0));
}

坑 3:/scopes/recommend 和 /scopes/adjacent 是空实现

事实:

java
// ScopeServiceImpl
@Override
public List<ServiceScopeEntity> queryAdjacentScopes(Double longitude, Double latitude,
                                                   ServiceTypeEnum type, Integer limit) {
    return null;      // ← 返回 null
}
java
// ScopeController
@GetMapping("recommend")
@ApiOperation(value = "智能推荐网点", notes = "基于AI智能推荐最优网点")
public ResponseEntity<ServiceScopeResponse> recommendAgency(@RequestParam("address") String address) {
    // TODO 待实现
    return null;
}

两个不同的坑:

接口问题
/scopes/adjacent返回 null 列表,但 controller 调了 EntityUtils.toDTOList(null),那个方法判了空返回空集合,所以返回 [],看起来正常。但单元测试直接调 adjacentScopes.size() 会 NPE —— 测试是照着未实现的契约写的
/scopes/recommendcontroller 返回 null body → Spring 认为这是"无 body",返回 HTTP 200 空响应。调用方以为"推荐成功但没结果",实际上是这个功能根本没有

最隐蔽的一条: ServiceScopeFeign 没有暴露这两个方法。 也就是说没有任何服务能通过 Feign 调到它们—— 它们既没实现,也没接线,是完全孤立的。

这个坑的业务意义: "智能推荐网点"对应 README 里的 Agent 1007(LangGraph 多节点流程: 模糊地址解析 → 候选网点 → 智能推荐 → 理由生成)。 这是十个 Agent 里设计上最完整的一个,但 Java 侧一行都没有。 ServiceScopeResponse DTO 存在,从 com.itheima.chaincloud.domain.aigc.domain 导入—— 契约设计好了,接线没做。

如果重做(这个 Agent 是最有意思的一个):

text
LangGraph 流程:
  地址模糊解析节点 → 高德地理编码 + 行政区划补全("国贸三期" → 朝阳区 + 完整地址)
       ↓
  候选网点召回节点 → $geoNear 取周边 N 个网点(注意是 $near,这里正好合适)
       ↓
  边界判定节点 → 优先用 $geoIntersects 判断覆盖;覆盖内的直接给答案
       ↓
  兜底推荐节点 → 没覆盖时给最近网点 + 明说"该地址暂未覆盖,已推荐 XX 网点"
       ↓
  理由生成节点 → LLM 依据以上结构化事实生成一句人话

这个设计里最值得讲的一点: $geoIntersects 和 $near 在同一个功能里各有各的位置—— 前者判断"是否覆盖"(精确、必须),后者做"候选召回"(粗略、召回), 最后再用 intersects 精确裁决。先粗后精是空间检索的标准范式。


五、如果重做,我会怎么改

优先级改动收益
P0加 (bid, type) 唯一复合索引 + 改 upsert消除重复文档和 500
P0geoCode 结果判空,抛业务异常消除 NPE + 堆栈泄露
P0queryListByPoint(type, address) 里不再判空返回,直接抛异常消除 /adjacent 的"假成功"
P1多边形自动闭合 + 点数上限校验(比如 ≤ 500 点)防止超大文档 + 非法数据
P1多边形加 priority 字段,命中多条时按优先级裁决消除"取第一条"的不确定性
P1高德 geoCode 加本地缓存(地址 → 坐标,TTL 30 天)下单链路去掉一个外部 HTTP 依赖
P2把"网点坐标"作为独立实体存,空间表只存覆盖范围支持就近裁决
P2补 Agent 1007(模糊地址 → 候选召回 → 边界裁决 → 理由生成)解决"地址填不全"这个真实痛点

关于 P1 的"高德缓存",值得多说两句

geoCode 是高频 + 低变化的: 同一个地址会被反复查询(用户在地址簿里选了又下单), 而一个地址的坐标在物理上几乎不变(只会因为高德 POI 数据更新而微调)。

java
// 缓存 key 设计
String key = "geo:amap:" + DigestUtils.md5Hex(address.trim().replaceAll("\\s+", ""));
// 缓存策略:先查 Redis → 未命中调高德 → 回填,TTL 30 天
// 高德失败时不要缓存失败结果,但要加短 TTL 的"负缓存"防穿透(TTL 60s)

注意负缓存(缓存"这个地址定位不到"): 没有负缓存的话,恶意用户可以用随机地址刷爆高德配额。 用 60 秒 TTL 的负缓存挡住,是成本很低但很实用的一个防护。


六、面试问答

Q1:为什么用 MongoDB 而不是 MySQL 8 的空间索引?

A: 三个理由:

  1. 数据结构:我们要存的是多边形。MySQL 8 虽然有 GEOMETRY 类型和

SPATIAL INDEX,但生态弱——没有开箱即用的空间查询 DSL, ST_Contains 这些函数要么手写坐标遍历要么依赖扩展。 MongoDB 的 GeoJSON 是原生一等公民,Java 侧用 GeoJsonPolygon / GeoJsonPoint 直接映射,序列化格式就是标准 GeoJSON, 可以直接被前端地图库(Leaflet / Mapbox / 高德)消费,不需要转换层。

  1. 索引能力:2dsphere 索引直接支撑 $geoIntersects / $geoNear / $geoWithin,

而这些正是我们需要的。MySQL 的 spatial index 主要服务 MBRContains / ST_Within,语义弱一些。

  1. 这个服务本来就不需要事务:它只有单文档的 CRUD,

要 ACID 干什么?反而是无 schema 灵活更适合"多边形点数组长度不固定"。

追问:那查询性能怎么保证? 靠 polygon 上的 2dsphere 索引。$geoIntersects 走索引, 不是全表扫。但要注意:2dsphere 索引在写入时是"多索引条目"—— 一个覆盖大片区域的多边形会产生很多索引条目, 所以多边形不能画得太大,这也是我要加"点数上限校验"的原因。

Q2:$geoIntersects 和 $geoNear 有什么区别?什么时候用哪个?

A: 简单说:intersects 问"在不在里面",$near 问"最近的几个是哪些"。

$geoIntersects$geoNear
语义几何相交判断按距离排序取最近的 N 条
必须有 maxDistance?不需要必须,否则退化成全表扫
多边形字段polygon: {$geoIntersects: {$geometry: Point}}$nearSphere: {$geometry: Point, $maxDistance: 3000}
返回全部命中按距离升序 N 条

在我们这个业务里:

  • 下单反查归属网点 → 用 $geoIntersects,因为要的是"覆盖"这个确定性答案;
  • 推荐附近网点 / 召回候选 → 用 $geoNear,因为要的是"近"这个相对概念。

补 Agent 1007 的时候,这两者会在同一个流程里出现: 先用 $geoNear 召回 5 个候选,再用 $geoIntersects 精确裁决哪些真的覆盖。 先粗后精。

Q3:多边形命中多个网点怎么办?

A: 承认现有实现是取第一条,这不是好设计,因为依赖了 MongoDB 的无序返回。

正确的做法有三个,我推荐就近原则: 数据模型上加 priority 或关联机构坐标, 命中多条时按业务规则裁决(优先级 / 最近 / 人工指派)。

就近原则最符合用户直觉——用户会认为"离我近的那个网点来取件"是天经地义的。

追问:但 ServiceScopeEntity 里没有机构坐标啊? 对,这暴露了数据模型的缺口。空间表只存了"覆盖范围", 机构坐标在 transport 服务的 Neo4j 里。所以要么加一次 Feign, 要么把机构坐标冗余进空间表。我倾向于冗余 + 接受短暂不一致, 因为反查链路对延迟很敏感,少一次跨服务调用很重要。

Q4:这个查询的性能怎么样?会不会全表扫?

A: 不会,前提是 2dsphere 索引建上了。 $geoIntersects 是索引支持的算子,会走 polygon 索引。

但有三个性能陷阱值得说:

  1. 索引可能没建。@GeoSpatialIndexed 依赖 Spring Data MongoDB 的

auto-index-creation,它在部分版本里默认是关的。 上线前必须确认:db.cc_service_scope.getIndexes() 里能看到 polygon_2dsphere。

  1. type 字段没做复合索引。polygon 走索引 + type 做 residual filter 没问题,

但如果集合里有几十万条、且 type 的区分度低,要考虑建 {type:1, polygon:"2dsphere"} 的复合索引。

  1. 多边形面积。一个大到覆盖整座城的多边形,

2dsphere 索引会为它生成海量索引条目,写入变慢、索引膨胀。 这是"画多边形"这个交互设计要约束的(拆成多个小区块而不是画一整城)。

Q5:为什么下单要串两跳(OMS → service-scope),不能合并吗?

A: 理论上可以合并——服务范围和运费都是下单要用的。

不合并的理由是服务边界: service-scope 是独立的领域,它还会被快递员任务派发、推荐网点复用。 如果把它的逻辑抄进 OMS,两边就会各改各的,迟早不一致。

这个问题的通用答法是: "微服务的边界应该按业务能力的复用度划分,不是按调用链路的物理位置划分。 合并会让今天的调用少一跳,但会让明天的维护多一份重复代码。 我更倾向于保留 Feign 边界,优化走缓存(坐标缓存 + 结果缓存) 而不是靠合并。"


七、源码定位速查

文件行作用
scope/entity/ServiceScopeEntity.java—@Document("cc_service_scope") + 2dsphere 索引
scope/service/impl/ScopeServiceImpl.java78-84queryListByPoint $geoIntersects 查询
同上86-92地址 → geoCode → 点(模板方法重载)
同上28-53saveOrUpdate 先查后写(非原子)
同上94-97queryAdjacentScopes 返回 null
scope/controller/ScopeController.java167-176/recommend TODO,返回 null
scope/util/EntityUtils.java—多边形 ⇄ DTO 转换
scope/enums/ServiceTypeEnum.java11-12ORGAN(1) / COURIER(2)
oms/service/impl/OrderServiceImpl.java286-317getAgencyId 下单反查网点 + 业务异常
ms-domain/.../aigc/domain/ServiceScopeResponse.java—Agent 1007 的响应 DTO(未接线)