亮点三 · 用户填了个地址,归哪个网点管
业务问题: 客户在 App 上填了「北京市朝阳区建国路 88 号 SOHO 现代城 B 座 1201」。 系统必须回答两个问题: ① 这个地址归哪个网点管?(决定谁去取件) ② 如果不属于任何一个网点,货还能不能发?(决定下单能不能成功)
答错第一个 → 快递员跑错地方,客户体验崩塌。 答错第二个 → 接了不该接的单,发不出去,赔钱。
一、业务场景
1.1 "服务范围"在快递业务里到底是什么
它不是一个圆形半径,也不是一个矩形范围,而是一个不规则多边形。
现实原因非常直接:
- 快递网点是按小区、写字楼、商圈自然形成的,不是按行政区划划的;
- 一个城市的不同区域归属不同网点,边界犬牙交错;
- 有时候两个网点部分重叠(交接带),有时候中间有服务盲区(两不管区)。
所以业务给出的数据形态就是:运营在管理后台用地图画一个多边形,存起来。
1.2 两个使用方,同一套数据
类型 ServiceTypeEnum | 谁 | 用来干什么 |
|---|---|---|
ORGAN(1) 机构 | 网点 | 下单反查:这个地址归哪个网点 |
COURIER(2) 快递员 | 快递员 | 任务派发:哪些快递员能覆盖这个地址 |
同一个 cc_service_scope 集合,靠 type 字段区分。一张表服务两个业务场景, 这也是为什么它要用 MongoDB——因为要建空间索引。
1.3 主链路上的位置(这是它在业务里的分量)
客户下单
→ 取出寄件地址和收件地址
→ 高德 geoCode(address) 得到经纬度 ← 外部依赖
→ ServiceScopeFeign.queryListByLocation(1, lng, lat)
→ MongoDB: {polygon: {$geoIntersects: point}, type: 1}
→ 命中多条时取第一条的 bid 作为网点 id
→ 没命中 → 抛「地址不在服务范围」→ 下单失败注意这个链路是同步阻塞的,而且串了两跳: 下单接口 → OMS → Feign → service-scope → MongoDB, 中间还夹了一个高德 HTTP 调用。
二、方案设计
2.1 数据模型
// 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;
}索引设计:
| 字段 | 索引类型 | 作用 |
|---|---|---|
bid | B-tree(@Indexed) | 按业务 id 查/删 |
type | B-tree(@Indexed) | 过滤机构还是快递员 |
polygon | GEO_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
// 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 就是:
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 条 |
| 需要索引 | 2dsphere | 2d 或 2dsphere |
我们的业务是"点是否落在运营画的多边形内"——这是一个纯粹的包含关系判断, 所以必须用 $geoIntersects。 用 $near 只能查到"最近的网点", 但最近不代表覆盖,而业务要的是"覆盖"。
2.3 地理编码:把文字地址变成点
// 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()));
}这是一个模板方法模式的干净用法:
queryListByPoint(type, address) —— 字符串入口(内部做地理编码)
↓
queryListByPoint(type, GeoJsonPoint) —— 空间入口(真正的查询)有坐标的场景(比如下单时 OMS 已经算过一次坐标)可以直接调空间版本, 避免重复调高德。这是很值得在面试里讲的接口设计。
2.4 OMS 侧的真实调用(含降级)
// 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;
}三个业务决策:
type硬编码为 1 —— 下单场景只关心网点(机构),不关心快递员;- 没命中就抛异常阻断下单 —— "不能发"比"发错"好;
- 命中多条时取
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=经度 —— 两个模块约定相反。
为什么危险: 经纬度写反了,查询不会报错,只是不命中。 或者更糟——命中了错误的网点。
怎么解:
// 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;
}// 3. 启动时做一次数据体检(强烈建议加)
// 经度必须在 [-180, 180],纬度必须在 [-90, 90]
// 一旦发现纬度 > 90 或经度 > 90,就是写反了这个难点的通用价值: "静默错误比崩溃更可怕"。写反了不会 NPE,只会让业务出问题, 所以必须靠约定 + 校验两道防线。
难点 2:多边形重叠时,"取第一条"是业务决策不是技术选择
难点在哪: 交接带(两个网点都覆盖的区域)在业务上是必然存在的。 那么命中多条时选谁?
现有实现是 serviceScopeDTOS.get(0),也就是 依赖 MongoDB 的默认返回顺序——这在 find 未指定 sort 时 没有任何稳定性保证,理论上可能每次返回不同的结果。
三种业务上合理的策略:
| 策略 | 规则 | 适用 |
|---|---|---|
| 就近原则 | 多个多边形命中时,选网点坐标距离最近的 | 用户体验最好,语义最自然 |
| 优先级 | 多边形加 priority 字段,命中的按优先级取 | 有主次网点之分时 |
| 不裁决 | 返回全部,由运营/客服人工指派 | 交接带本来就模糊 |
我推荐"就近原则",因为它和业务的直觉一致: 用户觉得"这个地址离哪个网点近,就该归哪个网点",这是用户的心理模型。
// 就近原则的实现(在 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:写库不是原子的
难点在哪:
// 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(先查后写) 模式:
线程 A: findOne → null ┐
线程 B: findOne → null ├ 两个线程都认为"要新增"
线程 A: save(新文档) │
线程 B: save(新文档) ┘ → 集合里出现两条 (bid, type) 相同的文档为什么会真的并发? 因为这是管理后台的接口——运营通常不会两个人同时操作同一个网点, 但下面的场景一定会:
- 运营重复点击"保存"(前端没做按钮防抖);
- 两个运营各改各的(虽然应该加乐观锁)。
- 批量导入时并发执行。
而且没有唯一索引兜底:
@Indexed private Long bid; // 非唯一
@Indexed private Integer type; // 非唯一没有 @CompoundIndex(name="bid_type", def="{'bid':1,'type':1}", unique=true)。
后果链条(这是最严重的部分):
出现两条重复文档
→ queryListByPoint 空间查询返回重复的网点 id ← 结果"看起来"还行
→ queryByBidAndType 用 findOne
→ 如果两条都被命中,Spring Data 抛 IncorrectResultSizeDataAccessException
→ 保存 / 查询接口直接 500修复方案(三层,从便宜到彻底):
// 第 1 层:加唯一复合索引(必须)
@CompoundIndex(name = "bid_type_uniq", def = "{'bid':1,'type':1}", unique = true)
@Document("cc_service_scope")
public class ServiceScopeEntity { ... }// 第 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;
}// 第 3 层:历史重复数据清理
db.cc_service_scope.aggregate([
{ $group: { _id: "$bid", n: { $sum: 1 } } },
{ $match: { n: { $gt: 1 } } }
])这个难点的通用价值: 它是一个教科书级的"业务代码里的并发问题"。 面试时讲这个比讲任何分布式锁都有说服力,因为它说明你真的在生产环境踩过。
四、实际遇到的困难与踩坑
坑 1:地理编码失败会变成 NPE + 堆栈泄露
事实:
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 + 堆栈,而不是"地址无法定位,请补充门牌号"。
修复:
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 索引会拒绝该文档,或者空间查询行为异常。
// 正确的多边形:首尾同点
[
[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 校验里加,不要指望前端):
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 是空实现
事实:
// ScopeServiceImpl
@Override
public List<ServiceScopeEntity> queryAdjacentScopes(Double longitude, Double latitude,
ServiceTypeEnum type, Integer limit) {
return null; // ← 返回 null
}// 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/recommend | controller 返回 null body → Spring 认为这是"无 body",返回 HTTP 200 空响应。调用方以为"推荐成功但没结果",实际上是这个功能根本没有 |
最隐蔽的一条: ServiceScopeFeign 没有暴露这两个方法。 也就是说没有任何服务能通过 Feign 调到它们—— 它们既没实现,也没接线,是完全孤立的。
这个坑的业务意义: "智能推荐网点"对应 README 里的 Agent 1007(LangGraph 多节点流程: 模糊地址解析 → 候选网点 → 智能推荐 → 理由生成)。 这是十个 Agent 里设计上最完整的一个,但 Java 侧一行都没有。 ServiceScopeResponse DTO 存在,从 com.itheima.chaincloud.domain.aigc.domain 导入—— 契约设计好了,接线没做。
如果重做(这个 Agent 是最有意思的一个):
LangGraph 流程:
地址模糊解析节点 → 高德地理编码 + 行政区划补全("国贸三期" → 朝阳区 + 完整地址)
↓
候选网点召回节点 → $geoNear 取周边 N 个网点(注意是 $near,这里正好合适)
↓
边界判定节点 → 优先用 $geoIntersects 判断覆盖;覆盖内的直接给答案
↓
兜底推荐节点 → 没覆盖时给最近网点 + 明说"该地址暂未覆盖,已推荐 XX 网点"
↓
理由生成节点 → LLM 依据以上结构化事实生成一句人话这个设计里最值得讲的一点: $geoIntersects 和 $near 在同一个功能里各有各的位置—— 前者判断"是否覆盖"(精确、必须),后者做"候选召回"(粗略、召回), 最后再用 intersects 精确裁决。先粗后精是空间检索的标准范式。
五、如果重做,我会怎么改
| 优先级 | 改动 | 收益 |
|---|---|---|
| P0 | 加 (bid, type) 唯一复合索引 + 改 upsert | 消除重复文档和 500 |
| P0 | geoCode 结果判空,抛业务异常 | 消除 NPE + 堆栈泄露 |
| P0 | queryListByPoint(type, address) 里不再判空返回,直接抛异常 | 消除 /adjacent 的"假成功" |
| P1 | 多边形自动闭合 + 点数上限校验(比如 ≤ 500 点) | 防止超大文档 + 非法数据 |
| P1 | 多边形加 priority 字段,命中多条时按优先级裁决 | 消除"取第一条"的不确定性 |
| P1 | 高德 geoCode 加本地缓存(地址 → 坐标,TTL 30 天) | 下单链路去掉一个外部 HTTP 依赖 |
| P2 | 把"网点坐标"作为独立实体存,空间表只存覆盖范围 | 支持就近裁决 |
| P2 | 补 Agent 1007(模糊地址 → 候选召回 → 边界裁决 → 理由生成) | 解决"地址填不全"这个真实痛点 |
关于 P1 的"高德缓存",值得多说两句
geoCode 是高频 + 低变化的: 同一个地址会被反复查询(用户在地址簿里选了又下单), 而一个地址的坐标在物理上几乎不变(只会因为高德 POI 数据更新而微调)。
// 缓存 key 设计
String key = "geo:amap:" + DigestUtils.md5Hex(address.trim().replaceAll("\\s+", ""));
// 缓存策略:先查 Redis → 未命中调高德 → 回填,TTL 30 天
// 高德失败时不要缓存失败结果,但要加短 TTL 的"负缓存"防穿透(TTL 60s)注意负缓存(缓存"这个地址定位不到"): 没有负缓存的话,恶意用户可以用随机地址刷爆高德配额。 用 60 秒 TTL 的负缓存挡住,是成本很低但很实用的一个防护。
六、面试问答
Q1:为什么用 MongoDB 而不是 MySQL 8 的空间索引?
A: 三个理由:
- 数据结构:我们要存的是多边形。MySQL 8 虽然有
GEOMETRY类型和
SPATIAL INDEX,但生态弱——没有开箱即用的空间查询 DSL, ST_Contains 这些函数要么手写坐标遍历要么依赖扩展。 MongoDB 的 GeoJSON 是原生一等公民,Java 侧用 GeoJsonPolygon / GeoJsonPoint 直接映射,序列化格式就是标准 GeoJSON, 可以直接被前端地图库(Leaflet / Mapbox / 高德)消费,不需要转换层。
- 索引能力:2dsphere 索引直接支撑
$geoIntersects/$geoNear/$geoWithin,
而这些正是我们需要的。MySQL 的 spatial index 主要服务 MBRContains / ST_Within,语义弱一些。
- 这个服务本来就不需要事务:它只有单文档的 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 索引。
但有三个性能陷阱值得说:
- 索引可能没建。
@GeoSpatialIndexed依赖 Spring Data MongoDB 的
auto-index-creation,它在部分版本里默认是关的。 上线前必须确认:db.cc_service_scope.getIndexes() 里能看到 polygon_2dsphere。
type字段没做复合索引。polygon走索引 +type做 residual filter 没问题,
但如果集合里有几十万条、且 type 的区分度低,要考虑建 {type:1, polygon:"2dsphere"} 的复合索引。
- 多边形面积。一个大到覆盖整座城的多边形,
2dsphere 索引会为它生成海量索引条目,写入变慢、索引膨胀。 这是"画多边形"这个交互设计要约束的(拆成多个小区块而不是画一整城)。
Q5:为什么下单要串两跳(OMS → service-scope),不能合并吗?
A: 理论上可以合并——服务范围和运费都是下单要用的。
不合并的理由是服务边界: service-scope 是独立的领域,它还会被快递员任务派发、推荐网点复用。 如果把它的逻辑抄进 OMS,两边就会各改各的,迟早不一致。
这个问题的通用答法是: "微服务的边界应该按业务能力的复用度划分,不是按调用链路的物理位置划分。 合并会让今天的调用少一跳,但会让明天的维护多一份重复代码。 我更倾向于保留 Feign 边界,优化走缓存(坐标缓存 + 结果缓存) 而不是靠合并。"
七、源码定位速查
| 文件 | 行 | 作用 |
|---|---|---|
scope/entity/ServiceScopeEntity.java | — | @Document("cc_service_scope") + 2dsphere 索引 |
scope/service/impl/ScopeServiceImpl.java | 78-84 | queryListByPoint $geoIntersects 查询 |
| 同上 | 86-92 | 地址 → geoCode → 点(模板方法重载) |
| 同上 | 28-53 | saveOrUpdate 先查后写(非原子) |
| 同上 | 94-97 | queryAdjacentScopes 返回 null |
scope/controller/ScopeController.java | 167-176 | /recommend TODO,返回 null |
scope/util/EntityUtils.java | — | 多边形 ⇄ DTO 转换 |
scope/enums/ServiceTypeEnum.java | 11-12 | ORGAN(1) / COURIER(2) |
oms/service/impl/OrderServiceImpl.java | 286-317 | getAgencyId 下单反查网点 + 业务异常 |
ms-domain/.../aigc/domain/ServiceScopeResponse.java | — | Agent 1007 的响应 DTO(未接线) |