Просмотр исходного кода

feat: 外卖闪送支持双地图接入及谷歌切换回滚

qmj 1 неделя назад
Родитель
Сommit
ec802694b1
55 измененных файлов с 2338 добавлено и 143 удалено
  1. 13 10
      docs/map-api-usage.md
  2. 10 22
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/DistanceSourceRouteProvider.java
  3. 6 2
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteService.java
  4. 25 38
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/SelfHostedRouteDistanceProvider.java
  5. 66 0
      ruoyi-admin/src/main/java/com/ruoyi/app/map/GoogleMapLocationClient.java
  6. 75 0
      ruoyi-admin/src/main/java/com/ruoyi/app/map/MapLegacyRouteService.java
  7. 23 0
      ruoyi-admin/src/main/java/com/ruoyi/app/map/MapLocation.java
  8. 51 0
      ruoyi-admin/src/main/java/com/ruoyi/app/map/MapLocationController.java
  9. 207 0
      ruoyi-admin/src/main/java/com/ruoyi/app/map/MapLocationService.java
  10. 24 0
      ruoyi-admin/src/main/java/com/ruoyi/app/map/MapSourceSelector.java
  11. 23 0
      ruoyi-admin/src/main/java/com/ruoyi/app/map/MerchantMapSyncController.java
  12. 14 0
      ruoyi-admin/src/main/java/com/ruoyi/app/map/MerchantMapSyncRequest.java
  13. 148 0
      ruoyi-admin/src/main/java/com/ruoyi/app/map/MerchantMapSyncService.java
  14. 89 0
      ruoyi-admin/src/main/java/com/ruoyi/app/map/SelfHostedMapAdminClient.java
  15. 94 0
      ruoyi-admin/src/main/java/com/ruoyi/app/map/SelfHostedMapClient.java
  16. 11 0
      ruoyi-admin/src/main/java/com/ruoyi/app/map/SelfHostedMapException.java
  17. 50 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/FoodDeliveryLegacyFreightService.java
  18. 41 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/FoodDeliveryPromotionQuoteService.java
  19. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderPromotionHelper.java
  20. 4 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java
  21. 32 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/route/DistanceSourceRouteMatrixProvider.java
  22. 51 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/route/SelfHostedRouteDistanceMatrixProvider.java
  23. 38 37
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/SysFileInfoController.java
  24. 10 2
      ruoyi-admin/src/main/resources/application.yml
  25. 8 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  26. 8 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  27. 8 0
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  28. 8 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  29. 8 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  30. 8 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  31. 4 3
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/route/DistanceSourceRouteProviderTest.java
  32. 11 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteServiceTest.java
  33. 26 2
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/route/SelfHostedRouteDistanceProviderTest.java
  34. 28 0
      ruoyi-admin/src/test/java/com/ruoyi/app/map/GoogleMapLocationClientTest.java
  35. 60 0
      ruoyi-admin/src/test/java/com/ruoyi/app/map/LegacyCheckoutRouteDirectionTest.java
  36. 36 0
      ruoyi-admin/src/test/java/com/ruoyi/app/map/LegacyFoodFreightEndpointTest.java
  37. 26 0
      ruoyi-admin/src/test/java/com/ruoyi/app/map/MapLegacyRouteServiceTest.java
  38. 35 0
      ruoyi-admin/src/test/java/com/ruoyi/app/map/MapLocationControllerTest.java
  39. 179 0
      ruoyi-admin/src/test/java/com/ruoyi/app/map/MapLocationServiceTest.java
  40. 73 0
      ruoyi-admin/src/test/java/com/ruoyi/app/map/MerchantMapSyncControllerTest.java
  41. 194 0
      ruoyi-admin/src/test/java/com/ruoyi/app/map/MerchantMapSyncServiceTest.java
  42. 91 0
      ruoyi-admin/src/test/java/com/ruoyi/app/map/SelfHostedMapAdminClientTest.java
  43. 51 0
      ruoyi-admin/src/test/java/com/ruoyi/app/map/SelfHostedMapClientTest.java
  44. 50 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/FoodDeliveryLegacyFreightServiceTest.java
  45. 2 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/FoodDeliveryPromotionQuoteServiceTest.java
  46. 5 1
      ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderPromotionHelperFreeDeliveryTest.java
  47. 29 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/route/DistanceSourceRouteMatrixProviderTest.java
  48. 36 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/route/SelfHostedRouteDistanceMatrixProviderTest.java
  49. 6 0
      ruoyi-system/src/main/java/com/ruoyi/system/dto/PromotionCalcResponse.java
  50. 51 0
      specs/032-self-hosted-map-integration/frontend-app-integration.md
  51. 55 0
      specs/032-self-hosted-map-integration/merchant-map-sync.md
  52. 39 1
      specs/032-self-hosted-map-integration/plan.md
  53. 31 0
      specs/032-self-hosted-map-integration/release-runbook.md
  54. 38 23
      specs/032-self-hosted-map-integration/spec.md
  55. 27 1
      specs/032-self-hosted-map-integration/tasks.md

+ 13 - 10
docs/map-api-usage.md

@@ -1,14 +1,16 @@
 # 地图功能使用现状
 
-面向后端维护者的地图能力清单。2026-09-29 起,**百度、Mapbox 的外部接口已标记停用**:代码保留、行为未变,仅加注解与本文记录,禁止新增调用。
+面向后端维护者的地图能力清单。2026-10-26 更新:外卖/闪送算路和新选址支持双来源;Google保留,可通过字典回滚。百度、Mapbox保留原停用边界,禁止新增调用。
 
 ## 外部地图服务状态
 
 | 服务 | 能力 | 封装位置 | 状态 |
 |---|---|---|---|
-| Google | Directions 路线 | `GetArea.getluxian` → `/utils/getluxianjvli` | 在用(key 硬编码,切换计划中作废) |
+| Google | Directions 路线 | `GetArea.getluxian` → `/utils/getluxianjvli` | Google分支兼容保留;自部署分支转换相同legs结构 |
 | Google | Nearby/Geocoding 逆地理 | `GetArea.getgoogle` / `getgoogleadd` → `/utils/getLocationAddr`(type=1)、`/utils/getAddr` | 在用(key 硬编码) |
 | Google | Routes API v2 | `GoogleRoutesDistanceProvider`(闪送报价距离) | 在用(key 走字典 `sys_googlemap_key`;**可经距离来源开关切自部署,见下节**) |
+| Google / 自部署 | 外卖道路矩阵 | `DistanceSourceRouteMatrixProvider` | 普通/v2/多店共用;自配送运费同口径,自取/堂食不算路 |
+| Google / 自部署 | 搜索/附近/反查/详情 | `/system/map/*` | 新App统一接入,token鉴权,结构一致;旧直连Google页面保留 |
 | Mapbox | Directions cycling | `GetArea.getmpluxian` → `/utils/getluxianjvliMp` | **已停用**(@Deprecated,端点保留) |
 | Mapbox | Directions driving 耗时 | `GetArea.getMotorbikeDuration` | **已停用**(见下方存量调用点) |
 | 百度 | 逆地理 reverse_geocoding | `GetArea.getarearess` → `/utils/getLocationAddr`(type=0) | **已停用**(分支保留兜底) |
@@ -18,7 +20,6 @@
 
 | 能力 | 位置 | 实现 |
 |---|---|---|
-| 外卖运费估算 | `/utils/getyunfei` | `GetJvli.getDistance` Haversine 直线 + `pos_freight` 跳表 |
 | 打车计价 | `TaxiPricesController /ganyunfei` | 直线距离 + `taxi_prices` 表 |
 | 两点距离 | `UserOrderController /getDistance`、`GetArea.getDistance` | Haversine 纯数学 |
 | GCJ02→WGS84 | `/utils/GCJ2TOWGS` | 纯数学转换 |
@@ -32,16 +33,18 @@
 
 | 值 | 行为 |
 |---|---|
-| `GOOGLE`(默认) | 闪送路线距离走 Google Routes(现状) |
-| `SELF_HOSTED` | 闪送路线距离走自部署地图(Foodie_HCM_Map `GET /route/multi`) |
+| `GOOGLE`(默认) | 闪送Google Routes、外卖Google矩阵、新选址Google |
+| `SELF_HOSTED` | 闪送`GET /route/multi`、外卖`POST /route/matrix`、新选址自部署 |
 
 仅在启用的字典项中,**恰好一条** `is_default=Y` 时才采用该项的值;没有默认项、多个默认项或默认值未知时均回退 `GOOGLE`。安装 SQL 在 `updatesql/sql.md`,会创建 `GOOGLE`(默认)与 `SELF_HOSTED` 两项。切换时在后台把旧项改为 `N`、目标项改为 `Y`;后台保存字典数据会更新缓存,手工执行 SQL 后则需在后台刷新字典缓存或重启服务。新增一行选项或只改排序不会切换。回滚同理,无需发版。
 
-实现:`DistanceSourceRouteProvider`(@Primary 委托)+ `SelfHostedRouteDistanceProvider`(单次请求地图 `GET /route/multi?coords=`,多站点按用户顺序途经、整线道路距离;`map.self-hosted.base-url` 配置地址,默认 `http://127.0.0.1:8080`)。失败语义与 Google 路径一致——路线失败/超时/吸附失败直线兜底并落库 `STRAIGHT_LINE`;**坐标越界(OUTSIDE_REGION)不降级**,报「地址超出可服务范围」。
+实现:两类路线提供器共用 `MapSourceSelector`,自部署统一通过 `SelfHostedMapClient`,默认反代地址 `http://127.0.0.1:8080/api`。闪送按用户1/2传motorcycle/car;外卖默认car保留当前Google DRIVE计价口径,可显式配置自部署车型。闪送故障直线兜底并落库STRAIGHT_LINE,越界不降级;外卖失败拒绝报价,不能直线补价或部分成功。读令牌仅请求头,读超时默认18秒,与地图16秒矩阵预算协调。
 
 地图侧配套(2026-09-29,Foodie_HCM_Map 仓库):`routing.py` 泛化 `calculate_route_multi`(N 站点单次 OSRM 请求、逐站吸附校验)+ 新端点 `/route/multi`(`coords=lat,lng;...`,2~30 站),`/route` 两点入口保持不变。多站点错误中,越界或吸附失败可定位时返回从 1 开始的 `point_index`;OSRM `NoSegment` 映射为 `SNAP_TOO_FAR`,整体不可达 `NoRoute` 为 `NO_ROUTE` 且不伪造站点序号。032 US4 的地图侧扩展已落地,foodie 不再需要逐段累加。部署时若走地图 Nginx 的 8080 入口,`map.self-hosted.base-url` 应包含 `/api`;直接访问地图 API 服务则按实际入口配置。
 
-边界:开关目前**只管闪送路线距离**;`/getluxianjvli`、逆地理等代理接口仍在 Google(属 032 US3,需 App 配合)。
+边界:开关影响外卖/闪送报价、新选址及旧`/getluxianjvli`、`/getyunfei`。旧`/getLocationAddr`、`/getAddr`和App内直接Google页面兼容保留,前端迁移后使用新`/system/map/*`;不能把只改字典当作App地图画面也已切换。`/getyunfei`保留jvli/freight/yunf字段,但改用道路矩阵和同一跳表;下单金额差异必须重报。
+
+旧结算页`/getluxianjvli`参数约定为latlng1=收货点、latlng2=门店,内部按门店→收货点请求两种来源。保留绘图结构,但用于计价的首段distance.value以同源权威矩阵为准,避免Google Directions与矩阵选路差异造成重复报价仍无法下单。该兼容接口暂需路线和矩阵两次请求,升级完整deliveryQuote后只需矩阵一次。`/getyunfei`参数顺序则为lat1/lng1=门店、lat2/lng2=收货点,两者不要混用。
 
 ## 存量调用点警示
 
@@ -49,10 +52,10 @@
 - `PosOrderController.setYjsdTime`(state=4 订单的预计送达时间,调用方为改单状态取餐分支与测试端点 `/testYjsdTime`)**已停用**(2026-09-29 标记):方法不再使用,保留调用不影响状态流转;其依赖的 `getMotorbikeDuration`(Mapbox driving ×1.5)因此实际无在用调用。切自部署路线源(032)时可一并清理。
 - `/utils/getLocationAddr` 的 `type=0` 百度分支保留兜底;App 端应使用 `type=1` 谷歌分支。
 - `GetJvli.getOptimalRoute`(Google Routes v2)全库无调用方,属死代码,不在本次标注范围。
-- `GetArea`/`GetJvli` 硬编码的 Google key 与 Mapbox token 已随 git 历史泄漏,切换自部署后应全部作废。
+- `GetArea`/`GetJvli` 的历史硬编码凭据由运维单独核对限制和轮换;不能在保留Google回滚需求时直接作废全部有效密钥。新接口只读服务端字典,不复制硬编码密钥。
 
 ## 关联
 
-- 台湾 OSM 需求差距与后续事项(2026-10-10,先记录、暂不实施):[osm-requirements-assessment.md](../specs/032-self-hosted-map-integration/osm-requirements-assessment.md)。测试服务器已支持独立机车路网,但 foodie 自部署适配尚未传车型;现有 car-only 假设不代表该测试服务现状。
-- 切换规格:`specs/032-self-hosted-map-integration/spec.md`(自部署 Foodie_HCM_Map:PostGIS 搜索 + OSRM 算路 + OpenFreeMap 底图)。2026-10-12 四项机车模型评估已验证,见 [model-evaluation.md](../specs/032-self-hosted-map-integration/model-evaluation.md);plan/tasks 仅覆盖评估,后续生产改造尚未开始。
+- 台湾真实路段、车型法规、时速、动态限制、全境性能与地址体检仍待部署验收,详见[差距记录](../specs/032-self-hosted-map-integration/osm-requirements-assessment.md)。本地适配现已显式传车型,不代表生产已部署。
+- 切换规格:[032](../specs/032-self-hosted-map-integration/spec.md)。当前双地图实施计划/任务与历史机车评估分别记录;[前端接入](../specs/032-self-hosted-map-integration/frontend-app-integration.md)、[发布与回滚](../specs/032-self-hosted-map-integration/release-runbook.md)。
 - 自部署地图仓库:`D:\QtwCode\foodie\Foodie_HCM_Map`

+ 10 - 22
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/DistanceSourceRouteProvider.java

@@ -1,7 +1,6 @@
 package com.ruoyi.app.flashdelivery.route;
 
-import com.ruoyi.common.core.domain.entity.SysDictData;
-import com.ruoyi.common.utils.DictUtils;
+import com.ruoyi.app.map.MapSourceSelector;
 import org.springframework.context.annotation.Primary;
 import org.springframework.stereotype.Component;
 
@@ -11,23 +10,24 @@ import java.util.List;
 /**
  * 距离来源开关(032 FR-001):按字典 {@code sys_map_distance_source} 在 Google Routes 与
  * 自部署地图间逐请求切换,改字典即生效,回滚不发版。仅一个启用项标记 is_default=Y
- * 时读取其值;缺失、多选或未知值一律按 GOOGLE(默认不切换)。切换仅影响闪送路线距离来源,
+ * 时读取其值;缺失、多选或未知值一律按 GOOGLE(默认不切换)。与外卖/新选址共用选择器,
  * 直线兜底与距离来源落库逻辑不变(FR-002)。
  */
 @Primary
 @Component
 public class DistanceSourceRouteProvider implements RouteDistanceProvider {
 
-    /** 自部署地图开关值(忽略大小写与首尾空白)。 */
-    static final String SELF_HOSTED = "SELF_HOSTED";
+    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(DistanceSourceRouteProvider.class);
 
     private final GoogleRoutesDistanceProvider googleProvider;
     private final SelfHostedRouteDistanceProvider selfHostedProvider;
+    private final MapSourceSelector selector;
 
     public DistanceSourceRouteProvider(GoogleRoutesDistanceProvider googleProvider,
-                                       SelfHostedRouteDistanceProvider selfHostedProvider) {
+                                       SelfHostedRouteDistanceProvider selfHostedProvider, MapSourceSelector selector) {
         this.googleProvider = googleProvider;
         this.selfHostedProvider = selfHostedProvider;
+        this.selector = selector;
     }
 
     @Override
@@ -44,26 +44,14 @@ public class DistanceSourceRouteProvider implements RouteDistanceProvider {
     }
 
     private RouteDistanceProvider delegate() {
-        return selfHostedEnabled() ? selfHostedProvider : googleProvider;
+        boolean local = selfHostedEnabled();
+        log.debug("Flash route provider={}", local ? "SELF_HOSTED" : "GOOGLE");
+        return local ? selfHostedProvider : googleProvider;
     }
 
     /** 开关判定:唯一默认项的值为 SELF_HOSTED 时启用自部署地图。 */
     boolean selfHostedEnabled() {
-        List<SysDictData> rows = DictUtils.getDictCache(dictType());
-        if (rows == null) return false;
-        SysDictData selected = null;
-        for (SysDictData row : rows) {
-            if (row == null || row.getIsDefault() == null
-                    || !"Y".equalsIgnoreCase(row.getIsDefault().trim())) continue;
-            if (selected != null) return false;
-            selected = row;
-        }
-        return selected != null && selected.getDictValue() != null
-                && SELF_HOSTED.equalsIgnoreCase(selected.getDictValue().trim());
+        return selector.selfHostedEnabled();
     }
 
-    /** 闪送与外卖共用的距离来源开关字典类型。 */
-    static String dictType() {
-        return "sys_map_distance_source";
-    }
 }

+ 6 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteService.java

@@ -34,7 +34,11 @@ public class FlashDeliveryRouteService {
         try {
             RouteDistance route = provider.route(ordered, selectedVehicle);
             if (route != null && route.distanceMeters() > 0
-                    && (route.durationSeconds() == null || route.durationSeconds() >= 0)) return route;
+                    && (route.durationSeconds() == null || route.durationSeconds() >= 0)) {
+                // 道路距离成功但没有有效时长:保留道路计价来源,仅补车型行驶时长,避免客户端显示1分钟。
+                return route.durationSeconds() != null && route.durationSeconds() > 0 ? route
+                        : new RouteDistance(route.distanceMeters(), calculateFallbackDurationSeconds(route.distanceMeters(), selectedVehicle), route.source());
+            }
         } catch (RouteOutsideRegionException exception) {
             // 自部署地图判定坐标越界:按 spec 边界场景不降级计价,以国际化提示结束本次请求。
             throw new ServiceException(MessageUtils.message("flash.delivery.map.outside.region"));
@@ -90,7 +94,7 @@ public class FlashDeliveryRouteService {
         return EARTH_RADIUS_METERS * 2D * Math.atan2(Math.sqrt(a), Math.sqrt(1D - a));
     }
 
-    /** 26 个取货点加唯一收货点,保持与业务字典上限一致。 */
+    /** 最多27个有序站点,对齐Google的25个中间点;取送业务规则在站点服务另外校验。 */
     static void validatePoints(List<GeoPoint> points) {
         if (points == null || points.size() < 2 || points.size() > 27) {
             throw new IllegalArgumentException("invalid route point count");

+ 25 - 38
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/SelfHostedRouteDistanceProvider.java

@@ -1,34 +1,26 @@
 package com.ruoyi.app.flashdelivery.route;
 
-import com.alibaba.fastjson2.JSON;
 import com.alibaba.fastjson2.JSONObject;
-import org.apache.http.client.config.RequestConfig;
-import org.apache.http.client.methods.CloseableHttpResponse;
-import org.apache.http.client.methods.HttpGet;
-import org.apache.http.impl.client.CloseableHttpClient;
-import org.apache.http.impl.client.HttpClients;
-import org.apache.http.util.EntityUtils;
-import org.springframework.beans.factory.annotation.Value;
+import com.ruoyi.app.map.SelfHostedMapClient;
+import com.ruoyi.app.map.SelfHostedMapException;
+import com.ruoyi.system.domain.flash.FlashDeliveryVehicleType;
 import org.springframework.stereotype.Component;
 
 import java.math.BigDecimal;
-import java.nio.charset.StandardCharsets;
 import java.util.List;
+import java.util.Map;
 
 /**
  * 自部署地图(Foodie_HCM_Map)路线距离实现:单次请求 {@code GET /route/multi?coords=},
- * 地图按原始顺序途经全部站点返回整线道路距离(每站仅吸附一次)。车型不参与请求——
- * 地图仅 car 档,距离同值,时长仅作展示参考(032 FR-008 由平台系数折算)。
+ * 地图按原始顺序途经全部站点返回整线道路距离,显式选择汽车/机车独立路网。
  * 失败语义:路线失败/超时/吸附失败抛错交上层直线兜底;坐标越界(OUTSIDE_REGION)抛
  * {@link RouteOutsideRegionException},不降级。
  */
 @Component
 public class SelfHostedRouteDistanceProvider implements RouteDistanceProvider {
-    private static final RequestConfig TIMEOUTS = RequestConfig.custom()
-            .setConnectTimeout(2500).setConnectionRequestTimeout(2500).setSocketTimeout(3500).build();
+    private final SelfHostedMapClient client;
 
-    @Value("${map.self-hosted.base-url:http://127.0.0.1:8080}")
-    private String baseUrl;
+    public SelfHostedRouteDistanceProvider(SelfHostedMapClient client) { this.client = client; }
 
     @Override
     public RouteDistance route(BigDecimal originLatitude, BigDecimal originLongitude,
@@ -41,11 +33,11 @@ public class SelfHostedRouteDistanceProvider implements RouteDistanceProvider {
     @Override
     public RouteDistance route(List<GeoPoint> points, Integer vehicleType) {
         FlashDeliveryRouteService.validatePoints(points);
-        return parseRoute(fetchRoute(points));
+        return parseRoute(fetchRoute(points, vehicleType));
     }
 
     /** 请求完整多站点路线;非 2xx 按 code 区分:OUTSIDE_REGION 不降级,其余抛错交上层直线兜底。 */
-    JSONObject fetchRoute(List<GeoPoint> points) {
+    JSONObject fetchRoute(List<GeoPoint> points, Integer vehicleType) {
         StringBuilder coords = new StringBuilder();
         for (GeoPoint point : points) {
             if (coords.length() > 0) coords.append(';');
@@ -53,33 +45,28 @@ public class SelfHostedRouteDistanceProvider implements RouteDistanceProvider {
             coords.append(point.latitude().toPlainString()).append(',')
                     .append(point.longitude().toPlainString());
         }
-        HttpGet request = new HttpGet(baseUrl + "/route/multi?coords=" + coords);
-        request.setConfig(TIMEOUTS);
-        try (CloseableHttpClient client = HttpClients.custom().disableAutomaticRetries().build();
-             CloseableHttpResponse response = client.execute(request)) {
-            int statusCode = response.getStatusLine().getStatusCode();
-            JSONObject json = JSON.parseObject(EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8));
-            if (statusCode < 200 || statusCode >= 300) {
-                // 仅记录状态与错误码,不记录坐标等用户数据。
-                if (json != null && "OUTSIDE_REGION".equals(json.getString("code"))) {
-                    throw new RouteOutsideRegionException("self hosted map outside region");
-                }
-                throw new IllegalStateException("self hosted map http status " + statusCode);
-            }
-            return json;
-        } catch (IllegalStateException exception) {
-            throw exception;
-        } catch (Exception exception) {
-            throw new IllegalStateException("self hosted map request failed", exception);
+        try {
+            return client.getObject("/route/multi", Map.of("coords", coords.toString(),
+                    "profile", vehicleType != null && vehicleType == FlashDeliveryVehicleType.CAR ? "car" : "motorcycle"));
+        } catch (SelfHostedMapException failure) {
+            if ("OUTSIDE_REGION".equals(failure.getCode())) throw new RouteOutsideRegionException("self hosted map outside region");
+            throw failure;
         }
     }
 
     /** 解析 /route/multi 成功响应,只取计价需要的距离与时长两项。 */
     RouteDistance parseRoute(JSONObject json) {
         if (json == null) throw new IllegalStateException("self hosted map empty response");
-        Integer meters = json.getInteger("distance_meters");
-        if (meters == null || meters <= 0) throw new IllegalStateException("self hosted map invalid distance");
-        Integer seconds = json.getInteger("duration_seconds");
+        if (!(json.get("distance_meters") instanceof Number)) throw new IllegalStateException("self hosted map invalid distance");
+        int meters = json.getBigDecimal("distance_meters").intValueExact();
+        // 重复站点可整线为0,保留既有计价最小1米,不将合法同址误当地图故障。
+        if (meters < 0) throw new IllegalStateException("self hosted map invalid distance");
+        meters = Math.max(1, meters);
+        Integer seconds = null;
+        if (json.get("duration_seconds") instanceof Number) {
+            try { seconds = json.getBigDecimal("duration_seconds").intValueExact(); }
+            catch (ArithmeticException ignored) { /* 缺失或无效时长交路由服务按车型估算。 */ }
+        }
         return new RouteDistance(meters, seconds == null || seconds < 0 ? null : seconds, "ROUTE");
     }
 }

+ 66 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/map/GoogleMapLocationClient.java

@@ -0,0 +1,66 @@
+package com.ruoyi.app.map;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.common.core.domain.entity.SysDictData;
+import com.ruoyi.common.utils.DictUtils;
+import org.apache.http.client.config.RequestConfig;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.utils.URIBuilder;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.util.EntityUtils;
+import org.springframework.stereotype.Component;
+
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/** Google 地址接口固定主机与路径;参数编码后发送,不跟随第三方重定向。 */
+@Component
+public class GoogleMapLocationClient {
+    private static final Set<String> PATHS = Set.of("/maps/api/place/textsearch/json",
+            "/maps/api/place/nearbysearch/json", "/maps/api/geocode/json", "/maps/api/place/details/json");
+    private static final RequestConfig TIMEOUTS = RequestConfig.custom().setConnectTimeout(2500)
+            .setConnectionRequestTimeout(2500).setSocketTimeout(3500).build();
+
+    public JSONObject getObject(String path, Map<String, ?> query) {
+        List<SysDictData> keys = DictUtils.getDictCache("sys_googlemap_key");
+        if (keys == null || keys.isEmpty() || keys.get(0).getDictValue() == null
+                || keys.get(0).getDictValue().isBlank()) {
+            throw new IllegalStateException("google map key unavailable");
+        }
+        HttpGet request = new HttpGet(requestUri(path, query, keys.get(0).getDictValue()));
+        request.setConfig(TIMEOUTS);
+        try (CloseableHttpClient client = HttpClients.custom().disableRedirectHandling()
+                .disableAutomaticRetries().build();
+             CloseableHttpResponse response = client.execute(request)) {
+            int status = response.getStatusLine().getStatusCode();
+            if (status < 200 || status >= 300 || response.getEntity() == null) {
+                throw new IllegalStateException("google map response unavailable");
+            }
+            JSONObject result = JSON.parseObject(EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8));
+            if (result == null) throw new IllegalStateException("google map response invalid");
+            return result;
+        } catch (Exception exception) {
+            // 不保留带有 Key、用户地址或完整 URL 的底层异常。
+            throw new IllegalStateException("google map request failed");
+        }
+    }
+
+    static URI requestUri(String path, Map<String, ?> query, String key) {
+        if (!PATHS.contains(path) || query.containsKey("key")) {
+            throw new IllegalArgumentException("unsupported google map request");
+        }
+        try {
+            URIBuilder uri = new URIBuilder().setScheme("https").setHost("maps.googleapis.com").setPath(path);
+            query.forEach((name, value) -> uri.addParameter(name, String.valueOf(value)));
+            return uri.addParameter("key", key).build();
+        } catch (java.net.URISyntaxException exception) {
+            throw new IllegalArgumentException("invalid google map request");
+        }
+    }
+}

+ 75 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/map/MapLegacyRouteService.java

@@ -0,0 +1,75 @@
+package com.ruoyi.app.map;
+
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import java.math.BigDecimal;
+import java.util.Map;
+
+/** 为旧App保留Google legs/overview_polyline形状;只做展示适配,运费仍由服务端核实。 */
+@Service
+public class MapLegacyRouteService {
+    private final SelfHostedMapClient client;
+    @Value("${map.food-delivery.profile:car}") private String profile = "car";
+    public MapLegacyRouteService(SelfHostedMapClient client) { this.client = client; }
+    public JSONArray routes(String origin, String destination) {
+        try {
+            String[] first = coordinate(origin), last = coordinate(destination);
+            JSONObject result = client.getObject("/route/multi", Map.of("coords",
+                    String.join(",", first) + ";" + String.join(",", last), "profile", profile));
+            int meters = result.getBigDecimal("distance_meters").intValueExact();
+            if (meters < 0) throw new IllegalArgumentException();
+            Integer seconds = null;
+            try { seconds = result.getBigDecimal("duration_seconds").intValueExact(); }
+            catch (RuntimeException ignored) { /* 同一车型保守估时,不伪造0时长。 */ }
+            if (seconds == null || seconds <= 0) seconds = Math.max(60,
+                    (int) Math.ceil(meters * 3.6D / ("motorcycle".equals(profile) ? 25 : 30)));
+            var line = result.getJSONObject("geometry");
+            if (line == null || !"LineString".equals(line.getString("type"))) throw new IllegalArgumentException();
+            JSONArray coordinates = line.getJSONArray("coordinates");
+            if (coordinates == null || coordinates.size() < 2) throw new IllegalArgumentException();
+            JSONObject distance = new JSONObject(Map.of("value", meters, "text", meters / 1000D + " km"));
+            JSONObject duration = new JSONObject(Map.of("value", seconds, "text", (seconds + 59) / 60 + " min"));
+            JSONObject leg = new JSONObject();
+            leg.put("distance", distance); leg.put("duration", duration);
+            leg.put("start_location", Map.of("lat", new BigDecimal(first[0]), "lng", new BigDecimal(first[1])));
+            leg.put("end_location", Map.of("lat", new BigDecimal(last[0]), "lng", new BigDecimal(last[1])));
+            JSONObject route = new JSONObject();
+            route.put("legs", JSONArray.of(leg));
+            route.put("overview_polyline", Map.of("points", encode(coordinates)));
+            // 新前端可直接使用GeoJSON;旧前端继续读取Google字段。
+            route.put("geometry", line); route.put("distanceSource", "ROUTE");
+            return JSONArray.of(route);
+        } catch (RuntimeException failure) {
+            throw new ServiceException(MessageUtils.message("no.map.exception"));
+        }
+    }
+    private String[] coordinate(String text) {
+        String[] parts = text == null ? new String[0] : text.split(",", -1);
+        if (parts.length != 2 || new BigDecimal(parts[0].trim()).abs().compareTo(BigDecimal.valueOf(90)) > 0
+                || new BigDecimal(parts[1].trim()).abs().compareTo(BigDecimal.valueOf(180)) > 0)
+            throw new IllegalArgumentException();
+        return new String[]{parts[0].trim(), parts[1].trim()};
+    }
+    /** Google polyline5,GeoJSON坐标顺序是经度/纬度;保持旧客户端解码兼容。 */
+    private String encode(JSONArray points) {
+        StringBuilder result = new StringBuilder(); long lat = 0, lng = 0;
+        for (Object value : points) {
+            JSONArray point = (JSONArray) value;
+            double longitude = point.getDoubleValue(0), latitude = point.getDoubleValue(1);
+            if (!Double.isFinite(longitude) || !Double.isFinite(latitude) || Math.abs(longitude) > 180 || Math.abs(latitude) > 90)
+                throw new IllegalArgumentException();
+            long nextLat = Math.round(latitude * 1e5), nextLng = Math.round(longitude * 1e5);
+            append(result, nextLat - lat); append(result, nextLng - lng); lat = nextLat; lng = nextLng;
+        }
+        return result.toString();
+    }
+    private void append(StringBuilder text, long delta) {
+        long value = delta < 0 ? ~(delta << 1) : delta << 1;
+        while (value >= 0x20) { text.append((char) ((0x20 | (value & 0x1f)) + 63)); value >>= 5; }
+        text.append((char) (value + 63));
+    }
+}

+ 23 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/map/MapLocation.java

@@ -0,0 +1,23 @@
+package com.ruoyi.app.map;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/** Google 与自部署地图共用的选址结果;不包含任何服务端凭据。 */
+@Data
+public class MapLocation {
+    private String id;
+    private String name;
+    private String address;
+    private BigDecimal latitude;
+    private BigDecimal longitude;
+    private String source;
+    private String precision;
+    private boolean requiresConfirmation;
+    private BigDecimal distanceMeters;
+    private String country;
+    private String province;
+    private String city;
+    private String district;
+}

+ 51 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/map/MapLocationController.java

@@ -0,0 +1,51 @@
+package com.ruoyi.app.map;
+
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.system.utils.Auth;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.math.BigDecimal;
+
+/** App 地址聚合接口;Anonymous 放行管理端安全链,Auth 仍校验 App 登录会话。 */
+@RestController
+@RequestMapping("/system/map")
+public class MapLocationController {
+    private final MapLocationService service;
+
+    public MapLocationController(MapLocationService service) {
+        this.service = service;
+    }
+
+    @Anonymous @Auth
+    @GetMapping("/search")
+    public AjaxResult search(@RequestHeader String token, @RequestParam String q) {
+        return AjaxResult.success(service.search(q));
+    }
+
+    @Anonymous @Auth
+    @GetMapping("/nearby")
+    public AjaxResult nearby(@RequestHeader String token, @RequestParam BigDecimal latitude,
+                             @RequestParam BigDecimal longitude,
+                             @RequestParam(required = false) Integer radius,
+                             @RequestParam(required = false) Integer limit) {
+        return AjaxResult.success(service.nearby(latitude, longitude, radius, limit));
+    }
+
+    @Anonymous @Auth
+    @GetMapping("/reverse")
+    public AjaxResult reverse(@RequestHeader String token, @RequestParam BigDecimal latitude,
+                              @RequestParam BigDecimal longitude) {
+        return AjaxResult.success(service.reverse(latitude, longitude));
+    }
+
+    @Anonymous @Auth
+    @GetMapping("/place")
+    public AjaxResult place(@RequestHeader String token, @RequestParam String id) {
+        return AjaxResult.success(service.place(id));
+    }
+}

+ 207 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/map/MapLocationService.java

@@ -0,0 +1,207 @@
+package com.ruoyi.app.map;
+
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.function.Supplier;
+
+/** 选址聚合;来源切换不改变结果字段,也不跨来源静默降级。 */
+@Service
+public class MapLocationService {
+    private static final Logger logger = LoggerFactory.getLogger(MapLocationService.class);
+    private final MapSourceSelector selector;
+    private final SelfHostedMapClient selfHosted;
+    private final GoogleMapLocationClient google;
+
+    public MapLocationService(MapSourceSelector selector, SelfHostedMapClient selfHosted, GoogleMapLocationClient google) {
+        this.selector = selector;
+        this.selfHosted = selfHosted;
+        this.google = google;
+    }
+
+    public List<MapLocation> search(String query) {
+        if (query == null || query.isBlank() || query.trim().length() > 200) throw invalid();
+        String keyword = query.trim();
+        return lookup("search", () -> selector.selfHostedEnabled()
+                ? locations(selfHosted.getArray("/search", Map.of("q", keyword, "limit", 20)), true, 20)
+                : locations(googleResults(google.getObject("/maps/api/place/textsearch/json",
+                        Map.of("query", keyword))), false, 20));
+    }
+
+    public List<MapLocation> nearby(BigDecimal latitude, BigDecimal longitude, Integer radius, Integer limit) {
+        requireCoordinates(latitude, longitude);
+        int meters = radius == null ? 300 : radius;
+        int count = limit == null ? 10 : limit;
+        if (meters < 10 || meters > 2000 || count < 1 || count > 50) throw invalid();
+        return lookup("nearby", () -> selector.selfHostedEnabled()
+                ? locations(selfHosted.getArray("/nearby", Map.of("lat", latitude, "lng", longitude,
+                        "radius", meters, "limit", count)), true, count)
+                : locations(googleResults(google.getObject("/maps/api/place/nearbysearch/json",
+                        Map.of("location", latitude.toPlainString() + "," + longitude.toPlainString(),
+                                "radius", meters))), false, count));
+    }
+
+    public MapLocation reverse(BigDecimal latitude, BigDecimal longitude) {
+        requireCoordinates(latitude, longitude);
+        return lookup("reverse", () -> {
+            MapLocation location;
+            if (selector.selfHostedEnabled()) {
+                location = selfHostedLocation(selfHosted.getObject("/reverse_geocode",
+                        Map.of("lat", latitude, "lng", longitude)));
+            } else {
+                JSONArray results = googleResults(google.getObject("/maps/api/geocode/json",
+                        Map.of("latlng", latitude.toPlainString() + "," + longitude.toPlainString())));
+                location = results.isEmpty() ? coordinateLocation(latitude, longitude) : googleLocation(results.getJSONObject(0));
+                if (location.getLatitude().compareTo(latitude) != 0 || location.getLongitude().compareTo(longitude) != 0) {
+                    location.setPrecision("nearby_place");
+                }
+            }
+            // 反查返回附近名称,坐标必须始终保持用户点选的原始精度。
+            location.setId(("SELF_HOSTED".equals(location.getSource()) ? "self_hosted:" : "google:")
+                    + "pin:" + latitude.toPlainString() + "," + longitude.toPlainString());
+            location.setLatitude(latitude);
+            location.setLongitude(longitude);
+            location.setRequiresConfirmation(true);
+            return location;
+        });
+    }
+
+    public MapLocation place(String id) {
+        if (id == null || id.isBlank() || id.length() > 212) throw invalid();
+        boolean local = id.startsWith("self_hosted:");
+        if (!local && !id.startsWith("google:")) throw invalid();
+        String providerId = id.substring(local ? 12 : 7);
+        if (providerId.isBlank() || providerId.length() > 200) throw invalid();
+        return lookup("place", () -> {
+            // 显式 ID 来源优先于当前开关,避免切换后把自部署 ID 发送给 Google。
+            if (local) return selfHostedLocation(selfHosted.getObject("/place_detail", Map.of("id", providerId)));
+            JSONObject response = google.getObject("/maps/api/place/details/json", Map.of("place_id", providerId));
+            if (response == null || !"OK".equals(response.getString("status"))) throw malformed();
+            return googleLocation(response.getJSONObject("result"));
+        });
+    }
+
+    private List<MapLocation> locations(JSONArray rows, boolean local, int limit) {
+        if (rows == null) throw malformed();
+        List<MapLocation> result = new ArrayList<>();
+        for (int index = 0; index < Math.min(rows.size(), limit); index++) {
+            JSONObject row = rows.getJSONObject(index);
+            result.add(local ? selfHostedLocation(row) : googleLocation(row));
+        }
+        return result;
+    }
+
+    private JSONArray googleResults(JSONObject response) {
+        if (response == null) throw malformed();
+        if ("ZERO_RESULTS".equals(response.getString("status"))) return new JSONArray();
+        if (!"OK".equals(response.getString("status")) || response.getJSONArray("results") == null) throw malformed();
+        return response.getJSONArray("results");
+    }
+
+    private MapLocation selfHostedLocation(JSONObject row) {
+        if (row == null) throw malformed();
+        MapLocation result = baseLocation(row.getString("id"), row.getString("name"),
+                row.getString("address"), row.getBigDecimal("lat"), row.getBigDecimal("lng"), true);
+        String precision = row.getString("precision");
+        result.setPrecision(precision == null || precision.isBlank() ? "unknown" : precision);
+        result.setRequiresConfirmation(!"address".equals(precision)
+                || Boolean.TRUE.equals(row.getBoolean("requires_confirmation")));
+        BigDecimal distance = row.getBigDecimal("distance_meters");
+        if (distance != null && distance.signum() < 0) throw malformed();
+        result.setDistanceMeters(distance);
+        result.setCountry(row.getString("country"));
+        result.setProvince(row.getString("province"));
+        result.setCity(row.getString("city"));
+        result.setDistrict(row.getString("district"));
+        return result;
+    }
+
+    private MapLocation googleLocation(JSONObject row) {
+        if (row == null || row.getJSONObject("geometry") == null) throw malformed();
+        JSONObject geometry = row.getJSONObject("geometry");
+        JSONObject coordinates = geometry.getJSONObject("location");
+        if (coordinates == null) throw malformed();
+        String address = row.getString("formatted_address");
+        if (address == null || address.isBlank()) address = row.getString("vicinity");
+        MapLocation result = baseLocation(row.getString("place_id"), row.getString("name"), address,
+                coordinates.getBigDecimal("lat"), coordinates.getBigDecimal("lng"), false);
+        String type = geometry.getString("location_type");
+        result.setPrecision("ROOFTOP".equals(type) ? "address" : type == null ? "poi" : type.toLowerCase(Locale.ROOT));
+        result.setRequiresConfirmation(!"address".equals(result.getPrecision()));
+        JSONArray components = row.getJSONArray("address_components");
+        if (components != null) {
+            for (int index = 0; index < components.size(); index++) {
+                JSONObject component = components.getJSONObject(index);
+                if (component == null || component.getJSONArray("types") == null) continue;
+                JSONArray types = component.getJSONArray("types");
+                String name = component.getString("long_name");
+                if (types.contains("country")) result.setCountry(name);
+                if (types.contains("administrative_area_level_1")) result.setProvince(name);
+                if (types.contains("locality")) result.setCity(name);
+                if (types.contains("administrative_area_level_2") && result.getCity() == null) result.setCity(name);
+                if (types.contains("sublocality") || types.contains("sublocality_level_1")
+                        || types.contains("administrative_area_level_3")) result.setDistrict(name);
+            }
+        }
+        return result;
+    }
+
+    private MapLocation baseLocation(String id, String name, String address, BigDecimal latitude,
+                                     BigDecimal longitude, boolean local) {
+        if (id == null || id.isBlank() || id.length() > 200 || !validCoordinates(latitude, longitude)) throw malformed();
+        if (name == null || name.isBlank()) name = address;
+        if (name == null || name.isBlank()) throw malformed();
+        MapLocation result = new MapLocation();
+        result.setId((local ? "self_hosted:" : "google:") + id);
+        result.setSource(local ? "SELF_HOSTED" : "GOOGLE");
+        result.setName(name);
+        result.setAddress(address);
+        result.setLatitude(latitude);
+        result.setLongitude(longitude);
+        return result;
+    }
+
+    private MapLocation coordinateLocation(BigDecimal latitude, BigDecimal longitude) {
+        String coordinates = latitude.toPlainString() + "," + longitude.toPlainString();
+        MapLocation result = baseLocation("pin:" + coordinates, coordinates, null, latitude, longitude, false);
+        result.setPrecision("coordinate");
+        return result;
+    }
+
+    private void requireCoordinates(BigDecimal latitude, BigDecimal longitude) {
+        if (!validCoordinates(latitude, longitude)) throw invalid();
+    }
+
+    private boolean validCoordinates(BigDecimal latitude, BigDecimal longitude) {
+        return latitude != null && longitude != null && latitude.abs().compareTo(BigDecimal.valueOf(90)) <= 0
+                && longitude.abs().compareTo(BigDecimal.valueOf(180)) <= 0;
+    }
+
+    private <T> T lookup(String operation, Supplier<T> request) {
+        try {
+            return request.get();
+        } catch (RuntimeException exception) {
+            // 日志不包含用户地址、坐标、令牌、Key 或底层异常中的 URL。
+            logger.warn("map location lookup failed operation={}", operation);
+            throw new ServiceException(MessageUtils.message("map.location.unavailable"));
+        }
+    }
+
+    private ServiceException invalid() {
+        return new ServiceException(MessageUtils.message("map.location.invalid"));
+    }
+
+    private IllegalStateException malformed() {
+        return new IllegalStateException("map location response invalid");
+    }
+}

+ 24 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/map/MapSourceSelector.java

@@ -0,0 +1,24 @@
+package com.ruoyi.app.map;
+
+import com.ruoyi.common.core.domain.entity.SysDictData;
+import com.ruoyi.common.utils.DictUtils;
+import org.springframework.stereotype.Component;
+import java.util.List;
+
+/** 外卖、闪送和新选址接口共用一个来源开关;每次读取缓存以支持不发版回滚。 */
+@Component
+public class MapSourceSelector {
+    public boolean selfHostedEnabled() {
+        List<SysDictData> rows = DictUtils.getDictCache("sys_map_distance_source");
+        if (rows == null) return false;
+        SysDictData selected = null;
+        for (SysDictData row : rows) {
+            if (row == null || (row.getStatus() != null && !"0".equals(row.getStatus()))
+                    || row.getIsDefault() == null || !"Y".equalsIgnoreCase(row.getIsDefault().trim())) continue;
+            if (selected != null) return false;
+            selected = row;
+        }
+        return selected != null && selected.getDictValue() != null
+                && "SELF_HOSTED".equalsIgnoreCase(selected.getDictValue().trim());
+    }
+}

+ 23 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/map/MerchantMapSyncController.java

@@ -0,0 +1,23 @@
+package com.ruoyi.app.map;
+
+import com.ruoyi.common.core.domain.AjaxResult;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/** 平台管理端人工触发,沿用现有门店编辑权限和管理端登录链。 */
+@RestController
+@RequestMapping("/system/map/merchants")
+public class MerchantMapSyncController {
+    private final MerchantMapSyncService service;
+
+    public MerchantMapSyncController(MerchantMapSyncService service) { this.service = service; }
+
+    @PreAuthorize("@ss.hasPermi('chanting:store:edit')")
+    @PostMapping("/sync")
+    public AjaxResult sync(@RequestBody MerchantMapSyncRequest request) {
+        return AjaxResult.success(service.sync(request));
+    }
+}

+ 14 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/map/MerchantMapSyncRequest.java

@@ -0,0 +1,14 @@
+package com.ruoyi.app.map;
+
+import java.util.List;
+
+/** 仅由平台管理员手动选择门店;删除清单必须显式提供。 */
+public class MerchantMapSyncRequest {
+    private List<Long> storeIds;
+    private List<Long> removeStoreIds;
+
+    public List<Long> getStoreIds() { return storeIds; }
+    public void setStoreIds(List<Long> storeIds) { this.storeIds = storeIds; }
+    public List<Long> getRemoveStoreIds() { return removeStoreIds; }
+    public void setRemoveStoreIds(List<Long> removeStoreIds) { this.removeStoreIds = removeStoreIds; }
+}

+ 148 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/map/MerchantMapSyncService.java

@@ -0,0 +1,148 @@
+package com.ruoyi.app.map;
+
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.mapper.PosStoreMapper;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/** 只读所选业务门店、手动同步人工地点源表;搜索索引另行发布。 */
+@Service
+public class MerchantMapSyncService {
+    private final PosStoreMapper stores;
+    private final SelfHostedMapAdminClient map;
+
+    public MerchantMapSyncService(PosStoreMapper stores, SelfHostedMapAdminClient map) {
+        this.stores = stores;
+        this.map = map;
+    }
+
+    public SyncResult sync(MerchantMapSyncRequest request) {
+        if (request == null) throw invalidRequest();
+        List<Long> ids = request.getStoreIds() == null ? List.of() : request.getStoreIds();
+        List<Long> removals = request.getRemoveStoreIds() == null ? List.of() : request.getRemoveStoreIds();
+        Set<Long> seen = new HashSet<>();
+        if (ids.size() + removals.size() < 1 || ids.size() + removals.size() > 100) throw invalidRequest();
+        for (List<Long> group : List.of(ids, removals)) {
+            for (Long id : group) if (id == null || id <= 0 || !seen.add(id)) throw invalidRequest();
+        }
+
+        List<RowResult> rows = new ArrayList<>();
+        List<JSONObject> places = new ArrayList<>();
+        List<RowResult> submitted = new ArrayList<>();
+        for (Long id : ids) {
+            RowResult row = new RowResult(id, "UPSERT");
+            rows.add(row);
+            PosStore store = stores.selectPosStoreById(id);
+            if (store == null) {
+                row.fail("STORE_NOT_FOUND", "map.merchantSync.storeMissing");
+                continue;
+            }
+            if (!validStore(store)) {
+                row.fail("INVALID_STORE", "map.merchantSync.invalidStore");
+                continue;
+            }
+            JSONObject place = new JSONObject();
+            place.put("external_id", externalId(id));
+            place.put("name", store.getPosName().trim());
+            place.put("address", store.getAddress() == null ? "" : store.getAddress().trim());
+            place.put("lat", store.getLatitude());
+            place.put("lng", store.getLongitude());
+            place.put("type", "business");
+            places.add(place);
+            submitted.add(row);
+        }
+        // 已尝试的写入遇到超时/响应异常也可能部分成功,仍提示索引发布和幂等重试。
+        boolean rebuild = !places.isEmpty() || !removals.isEmpty();
+        if (!places.isEmpty()) {
+            try {
+                Set<Integer> rejected = rejectedIndexes(map.importPlaces(places), places.size());
+                for (int index = 0; index < submitted.size(); index++) {
+                    if (rejected.contains(index)) submitted.get(index).fail("INVALID_STORE", "map.merchantSync.invalidStore");
+                    else submitted.get(index).succeed();
+                }
+            } catch (RuntimeException failure) {
+                for (RowResult row : submitted) row.fail("MAP_UNAVAILABLE", "map.merchantSync.unavailable");
+            }
+        }
+        for (Long id : removals) {
+            RowResult row = new RowResult(id, "REMOVE");
+            rows.add(row);
+            try {
+                JSONObject result = map.deletePlace(externalId(id));
+                Object removed = result == null ? null : result.get("removed");
+                if (result == null || !"removed".equals(result.getString("status"))
+                        || !(removed instanceof Integer count) || count < 0 || count > 1
+                        || !Boolean.TRUE.equals(result.get("index_rebuild_required"))) throw malformed();
+                row.succeed();
+            } catch (RuntimeException failure) {
+                row.fail("MAP_UNAVAILABLE", "map.merchantSync.unavailable");
+            }
+        }
+        return new SyncResult(rows, rebuild);
+    }
+
+    private Set<Integer> rejectedIndexes(JSONObject response, int total) {
+        if (response == null || !(response.get("imported") instanceof Integer imported)
+                || !(response.get("rejected") instanceof Integer rejected)
+                || imported < 0 || rejected < 0 || imported + rejected != total
+                || !(response.get("rejections") instanceof JSONArray rejections) || rejections.size() != rejected)
+            throw malformed();
+        Set<Integer> indexes = new HashSet<>();
+        for (Object value : rejections) {
+            if (!(value instanceof JSONObject row) || !(row.get("index") instanceof Integer index)
+                    || index < 0 || index >= total || !indexes.add(index)) throw malformed();
+        }
+        if (imported > 0 && !Boolean.TRUE.equals(response.get("index_rebuild_required"))) throw malformed();
+        return indexes;
+    }
+
+    private boolean validStore(PosStore store) {
+        // state=1 是休息/打烊,不作为永久停用依据;移除只能来自显式 removeStoreIds。
+        return store.getPosName() != null && !store.getPosName().isBlank() && store.getPosName().trim().length() <= 200
+                && (store.getAddress() == null || store.getAddress().trim().length() <= 500)
+                && (store.getDelFlag() == null || "0".equals(store.getDelFlag()))
+                && (store.getOffShelf() == null || "0".equals(store.getOffShelf()))
+                && store.getLatitude() != null && store.getLatitude().abs().compareTo(BigDecimal.valueOf(90)) <= 0
+                && store.getLongitude() != null && store.getLongitude().abs().compareTo(BigDecimal.valueOf(180)) <= 0;
+    }
+
+    private String externalId(Long id) { return "foodie-store-" + id; }
+    private IllegalStateException malformed() { return new IllegalStateException("invalid merchant sync response"); }
+    private ServiceException invalidRequest() { return new ServiceException(MessageUtils.message("map.merchantSync.invalid")); }
+
+    public static class SyncResult {
+        private final List<RowResult> rows;
+        private final boolean indexRebuildRequired;
+        public SyncResult(List<RowResult> rows, boolean indexRebuildRequired) {
+            this.rows = rows;
+            this.indexRebuildRequired = indexRebuildRequired;
+        }
+        public List<RowResult> getRows() { return rows; }
+        public boolean isIndexRebuildRequired() { return indexRebuildRequired; }
+    }
+
+    public static class RowResult {
+        private final Long storeId;
+        private final String action;
+        private boolean success;
+        private String code;
+        private String message;
+        public RowResult(Long storeId, String action) { this.storeId = storeId; this.action = action; }
+        private void succeed() { success = true; code = "OK"; message = null; }
+        private void fail(String code, String key) { success = false; this.code = code; message = MessageUtils.message(key); }
+        public Long getStoreId() { return storeId; }
+        public String getAction() { return action; }
+        public boolean isSuccess() { return success; }
+        public String getCode() { return code; }
+        public String getMessage() { return message; }
+    }
+}

+ 89 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/map/SelfHostedMapAdminClient.java

@@ -0,0 +1,89 @@
+package com.ruoyi.app.map;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import org.apache.http.client.config.RequestConfig;
+import org.apache.http.client.methods.HttpDelete;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.client.methods.HttpRequestBase;
+import org.apache.http.client.utils.URIBuilder;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.util.EntityUtils;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/** 管理写接口使用独立凭据;固定端点,不允许调用方指定 URL。 */
+@Component
+public class SelfHostedMapAdminClient {
+    @Value("${map.self-hosted.base-url:http://127.0.0.1:8080/api}") private String baseUrl;
+    @Value("${map.self-hosted.admin-token:}") private String adminToken;
+    @Value("${map.self-hosted.connect-timeout-ms:2500}") private int connectTimeout = 2500;
+    @Value("${map.self-hosted.read-timeout-ms:18000}") private int readTimeout = 18000;
+
+    public JSONObject importPlaces(List<JSONObject> places) {
+        try {
+            HttpPost request = new HttpPost(uri("popular_places", "import"));
+            request.setEntity(new StringEntity(JSON.toJSONString(Map.of("places", places)), ContentType.APPLICATION_JSON));
+            return execute(request);
+        } catch (SelfHostedMapException failure) {
+            throw failure;
+        } catch (Exception failure) {
+            throw new SelfHostedMapException("ADMIN_REQUEST_FAILED", 502);
+        }
+    }
+
+    public JSONObject deletePlace(String externalId) {
+        if (externalId == null || !externalId.matches("foodie-store-[1-9][0-9]*"))
+            throw new SelfHostedMapException("INVALID_ARGUMENT", 400);
+        try {
+            return execute(new HttpDelete(uri("popular_places", externalId)));
+        } catch (SelfHostedMapException failure) {
+            throw failure;
+        } catch (Exception failure) {
+            throw new SelfHostedMapException("ADMIN_REQUEST_FAILED", 502);
+        }
+    }
+
+    private URI uri(String... endpoint) throws Exception {
+        if (adminToken == null || adminToken.isBlank())
+            throw new SelfHostedMapException("ADMIN_NOT_CONFIGURED", 503);
+        URIBuilder builder = new URIBuilder(baseUrl);
+        if (!("http".equals(builder.getScheme()) || "https".equals(builder.getScheme()))
+                || builder.getHost() == null || builder.getUserInfo() != null
+                || builder.getFragment() != null || builder.getQueryParams().size() > 0)
+            throw new IllegalArgumentException("invalid map base url");
+        List<String> segments = new ArrayList<>(builder.getPathSegments());
+        while (!segments.isEmpty() && segments.get(segments.size() - 1).isEmpty()) segments.remove(segments.size() - 1);
+        segments.addAll(List.of(endpoint));
+        // URIBuilder 对每个 path segment 编码,不把外部 ID 解释为新的路径或查询。
+        return builder.setPathSegments(segments).build();
+    }
+
+    private JSONObject execute(HttpRequestBase request) {
+        request.setHeader("Authorization", "Bearer " + adminToken.trim());
+        request.setConfig(RequestConfig.custom().setConnectTimeout(connectTimeout)
+                .setConnectionRequestTimeout(connectTimeout).setSocketTimeout(readTimeout).build());
+        try (var client = HttpClients.custom().disableAutomaticRetries().disableRedirectHandling().build();
+             var response = client.execute(request)) {
+            int status = response.getStatusLine().getStatusCode();
+            if (status < 200 || status >= 300) throw new SelfHostedMapException("ADMIN_REQUEST_FAILED", status);
+            String body = response.getEntity() == null ? "" : EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
+            Object value = JSON.parse(body);
+            if (!(value instanceof JSONObject result)) throw new SelfHostedMapException("INVALID_RESPONSE", 502);
+            return result;
+        } catch (SelfHostedMapException failure) {
+            throw failure;
+        } catch (Exception failure) {
+            // 不携带可能含令牌、坐标、完整 URL 或上游正文的底层异常。
+            throw new SelfHostedMapException("ADMIN_REQUEST_FAILED", 502);
+        }
+    }
+}

+ 94 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/map/SelfHostedMapClient.java

@@ -0,0 +1,94 @@
+package com.ruoyi.app.map;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import org.apache.http.client.config.RequestConfig;
+import org.apache.http.client.methods.*;
+import org.apache.http.client.utils.URIBuilder;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.util.EntityUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+import java.util.Set;
+
+/** 自部署读接口统一边界。base-url 可含反代的 /api 前缀,凭据只在请求头传递。 */
+@Component
+public class SelfHostedMapClient {
+    private static final Logger log = LoggerFactory.getLogger(SelfHostedMapClient.class);
+    private static final Set<String> CODES = Set.of("OUTSIDE_REGION", "NO_ROUTE", "SNAP_TOO_FAR",
+            "INVALID_PROFILE", "PROFILE_UNAVAILABLE", "DATASET_MISMATCH", "UNAUTHORIZED",
+            "ROUTING_TIMEOUT", "ROUTING_UNAVAILABLE", "DATA_UNAVAILABLE", "INVALID_ARGUMENT", "NOT_FOUND");
+    @Value("${map.self-hosted.base-url:http://127.0.0.1:8080/api}") private String baseUrl;
+    @Value("${map.self-hosted.read-token:}") private String readToken;
+    @Value("${map.self-hosted.connect-timeout-ms:2500}") private int connectTimeout = 2500;
+    // 地图矩阵整批预算16秒;必须大于其预算且小于反代20秒读取超时。
+    @Value("${map.self-hosted.read-timeout-ms:18000}") private int readTimeout = 18000;
+
+    public JSONObject getObject(String path, Map<String, ?> query) {
+        Object value = execute("GET", path, query, null);
+        if (!(value instanceof JSONObject json)) throw new SelfHostedMapException("INVALID_RESPONSE", 502);
+        return json;
+    }
+    public JSONArray getArray(String path, Map<String, ?> query) {
+        Object value = execute("GET", path, query, null);
+        if (!(value instanceof JSONArray json)) throw new SelfHostedMapException("INVALID_RESPONSE", 502);
+        return json;
+    }
+    public JSONObject post(String path, Object body) {
+        Object value = execute("POST", path, Map.of(), body);
+        if (!(value instanceof JSONObject json)) throw new SelfHostedMapException("INVALID_RESPONSE", 502);
+        return json;
+    }
+
+    private Object execute(String method, String path, Map<String, ?> query, Object body) {
+        long started = System.nanoTime();
+        try {
+            if (!path.startsWith("/") || path.startsWith("//") || path.contains(".."))
+                throw new IllegalArgumentException("invalid map endpoint");
+            URIBuilder uri = new URIBuilder(baseUrl.replaceAll("/+$", "") + path);
+            if (!("http".equals(uri.getScheme()) || "https".equals(uri.getScheme())) || uri.getHost() == null
+                    || uri.getUserInfo() != null || uri.getFragment() != null)
+                throw new IllegalArgumentException("invalid map base url");
+            query.forEach((key, value) -> { if (value != null) uri.addParameter(key, value.toString()); });
+            HttpRequestBase request;
+            if ("POST".equals(method)) {
+                HttpPost post = new HttpPost(uri.build());
+                post.setEntity(new StringEntity(JSON.toJSONString(body), ContentType.APPLICATION_JSON));
+                request = post;
+            } else request = new HttpGet(uri.build());
+            request.setConfig(RequestConfig.custom().setConnectTimeout(connectTimeout)
+                    .setConnectionRequestTimeout(connectTimeout).setSocketTimeout(readTimeout).build());
+            if (readToken != null && !readToken.isBlank()) request.setHeader("Authorization", "Bearer " + readToken.trim());
+            try (var client = HttpClients.custom().disableAutomaticRetries().disableRedirectHandling().build();
+                 var response = client.execute(request)) {
+                int status = response.getStatusLine().getStatusCode();
+                String content = response.getEntity() == null ? "" : EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
+                if (status < 200 || status >= 300) {
+                    String code = "HTTP_ERROR";
+                    try {
+                        JSONObject error = JSON.parseObject(content);
+                        if (error != null && CODES.contains(error.getString("code"))) code = error.getString("code");
+                    } catch (RuntimeException ignored) { /* 反代可能返回HTML,只保留状态码。 */ }
+                    throw new SelfHostedMapException(code, status);
+                }
+                return JSON.parse(content);
+            }
+        } catch (SelfHostedMapException failure) {
+            log.warn("Map provider=SELF_HOSTED endpoint={} failed: {}", path, failure.getMessage());
+            throw failure;
+        } catch (Exception failure) {
+            // 不传播第三方异常消息:其可能带完整 URL/坐标/凭据。
+            log.warn("Map provider=SELF_HOSTED endpoint={} failed type={}", path, failure.getClass().getSimpleName());
+            throw new SelfHostedMapException("REQUEST_FAILED", 502);
+        } finally {
+            log.debug("Map provider=SELF_HOSTED endpoint={} elapsedMs={}", path, (System.nanoTime() - started) / 1_000_000);
+        }
+    }
+}

+ 11 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/map/SelfHostedMapException.java

@@ -0,0 +1,11 @@
+package com.ruoyi.app.map;
+
+/** 只携带上游状态和已知错误码,不携带坐标、响应正文、URL或凭据。 */
+public class SelfHostedMapException extends IllegalStateException {
+    private final String code;
+    public SelfHostedMapException(String code, int status) {
+        super("self-hosted map status=" + status + ", code=" + code);
+        this.code = code;
+    }
+    public String getCode() { return code; }
+}

+ 50 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/FoodDeliveryLegacyFreightService.java

@@ -0,0 +1,50 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.order.dto.OrderCreateInput;
+import com.ruoyi.app.order.dto.OrderCreatItem;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.mapper.InfoAddressMapper;
+import com.ruoyi.system.mapper.PosStoreMapper;
+import org.springframework.stereotype.Service;
+import java.math.BigDecimal;
+import java.util.List;
+
+/** 旧客户端外送的运费闸门:核实运费但不更改商品、优惠、支付金额或履约状态。 */
+@Service
+public class FoodDeliveryLegacyFreightService {
+    private final FoodDeliveryPromotionQuoteService quotes;
+    private final PosStoreMapper stores;
+    private final InfoAddressMapper addresses;
+    public FoodDeliveryLegacyFreightService(FoodDeliveryPromotionQuoteService quotes,
+            PosStoreMapper stores, InfoAddressMapper addresses) {
+        this.quotes = quotes; this.stores = stores; this.addresses = addresses;
+    }
+    public void verifyAndFill(OrderCreateInput input, Long userId) {
+        if (input == null) return;
+        if (input.getItems() == null || input.getItems().isEmpty()) throw invalid("promotion.freeDelivery.unsupportedOrder");
+        // 履约读取子单type,不能让“父自取、子外送”跳过外送运费闸门。
+        if (input.getItems().stream().anyMatch(item -> item == null || input.getType() == null
+                || !input.getType().equals(item.getType()))) throw invalid("promotion.freeDelivery.unsupportedOrder");
+        if (!Long.valueOf(0).equals(input.getType())) return;
+        var address = addresses.selectById(input.getShdzId());
+        if (address == null || !userId.equals(address.getUserId())) throw invalid("promotion.freeDelivery.invalidAddress");
+        List<OrderCreatItem> items = input.getItems();
+        if (items.stream().anyMatch(item -> item == null || item.getMdId() == null || !Long.valueOf(0).equals(item.getType())))
+            throw invalid("promotion.freeDelivery.unsupportedOrder");
+        var participants = items.stream().map(item -> stores.selectById(item.getMdId())).toList();
+        var routes = quotes.quoteFreightRoutes(participants, address);
+        // 先核对整批,再回填;金额变化提示重报,不能自动更改父单和已展示的应付金额。
+        for (int index = 0; index < items.size(); index++) {
+            Double sent = items.get(index).getFreight();
+            if (sent == null || !Double.isFinite(sent)
+                    || BigDecimal.valueOf(sent).compareTo(routes.get(index).freight()) != 0)
+                throw invalid("promotion.freeDelivery.quoteChanged");
+        }
+        for (int index = 0; index < items.size(); index++) {
+            items.get(index).setJvli(routes.get(index).distanceMeters().movePointLeft(3).doubleValue());
+            items.get(index).setFreight(routes.get(index).freight().doubleValue());
+        }
+    }
+    private ServiceException invalid(String key) { return new ServiceException(MessageUtils.message(key)); }
+}

+ 41 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/FoodDeliveryPromotionQuoteService.java

@@ -153,10 +153,12 @@ public class FoodDeliveryPromotionQuoteService
         }
 
         BigDecimal deliveryFee = BigDecimal.ZERO;
+        BigDecimal routeMeters = null;
         if (delivery)
         {
             PosFreight fare = currentFreight();
             BigDecimal distanceMeters = routeDistanceMeters(store, address);
+            routeMeters = distanceMeters;
             deliveryFee = originalFreight(fare, distanceMeters);
         }
 
@@ -166,8 +168,46 @@ public class FoodDeliveryPromotionQuoteService
         calcRequest.setCouponId(request.getCouponId());
         calcRequest.setForcePath(request.getForcePath());
         // 满额免运仅平台骑手外送评估;自配送与自取/堂食 eligible=false(F=0,D 语义见契约)
-        return promotions.calculate(calcRequest, userId,
+        PromotionCalcResponse response = promotions.calculate(calcRequest, userId,
                 new PromotionDeliveryContext(deliveryFee, delivery && !selfDeliveryMode));
+        if (response != null && routeMeters != null) {
+            response.setRouteDistanceMeters(routeMeters);
+            response.setDistanceSource("ROUTE");
+        }
+        return response;
+    }
+
+    /** 旧外卖请求不重算商品促销,只核实逐店运费与距离;保持与v2同一矩阵/跳表规则。 */
+    public List<FoodRouteQuote> quoteFreightRoutes(List<PosStore> participants, InfoAddress address) {
+        if (participants == null || participants.isEmpty() || address == null || !hasCoordinates(address)
+                || participants.stream().anyMatch(store -> store == null || !hasCoordinates(store)))
+            throw invalid("promotion.freeDelivery.freightUnavailable");
+        try {
+            List<GeoPoint> starts = participants.stream()
+                    .map(store -> new GeoPoint(store.getLatitude(), store.getLongitude())).toList();
+            List<BigDecimal> distances = routeMatrix.distancesToDestination(starts,
+                    new GeoPoint(address.getLatitude(), address.getLongitude()));
+            if (distances == null || distances.size() != participants.size()
+                    || distances.stream().anyMatch(value -> value == null || value.signum() <= 0
+                    || value.stripTrailingZeros().scale() > 0)) throw invalid("promotion.freeDelivery.freightUnavailable");
+            PosFreight fare = currentFreight();
+            return distances.stream().map(meters -> new FoodRouteQuote(meters, originalFreight(fare, meters))).toList();
+        } catch (ServiceException known) { throw known; }
+        catch (Exception unavailable) { throw invalid("promotion.freeDelivery.freightUnavailable"); }
+    }
+
+    public record FoodRouteQuote(BigDecimal distanceMeters, BigDecimal freight) { }
+
+    /** 旧运费/路线报价端点也复用订单的道路矩阵与运价,防止重复报价始终无法通过下单校验。 */
+    public FoodRouteQuote quoteFreightCoordinates(GeoPoint origin, GeoPoint destination) {
+        try {
+            List<BigDecimal> distances = routeMatrix.distancesToDestination(List.of(origin), destination);
+            if (distances == null || distances.size() != 1 || distances.get(0) == null
+                    || distances.get(0).signum() <= 0 || distances.get(0).stripTrailingZeros().scale() > 0)
+                throw invalid("promotion.freeDelivery.freightUnavailable");
+            return new FoodRouteQuote(distances.get(0), originalFreight(currentFreight(), distances.get(0)));
+        } catch (ServiceException known) { throw known; }
+        catch (Exception unavailable) { throw invalid("promotion.freeDelivery.freightUnavailable"); }
     }
 
     /**

+ 2 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderPromotionHelper.java

@@ -195,6 +195,8 @@ public class OrderPromotionHelper {
         }
         posOrder.setAmount(calc.getPayableAmount().intValue());
         posOrder.setFreight(calc.getDeliveryFee().doubleValue());
+        // 与本次计价同源的道路距离;不能再保留客户端传来的 jvli。
+        if (calc.getRouteDistanceMeters() != null) posOrder.setJvli(calc.getRouteDistanceMeters().movePointLeft(3).doubleValue());
         posOrder.setFoodAmount(calc.getOriginalAmount().intValue());
         posOrder.setPromotionPricingVersion(2);
         posOrder.setMerchantDeliverySubsidy(calc.getDeliveryDiscountAmount());

+ 4 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java

@@ -104,6 +104,8 @@ public class UserOrderController extends BaseController {
     @Autowired  //008 满额免运:完整配送报价(版本2订单权威重算)
     private FoodDeliveryPromotionQuoteService foodDeliveryPromotionQuoteService;
     @Autowired
+    private FoodDeliveryLegacyFreightService foodDeliveryLegacyFreightService;
+    @Autowired
     private MultiStoreCheckoutLegacyGuard multiStoreCheckoutLegacyGuard;
 
     /**
@@ -199,6 +201,8 @@ public class UserOrderController extends BaseController {
         // 008 满额免运:显式 promotionPricingVersion=2 的单店外送单先在同一事务内权威报价,
         // 核对客户端应付/运费并确定父单金额,差异拒绝回滚;旧请求不经此分支。
         PromotionCalcResponse versionTwoQuote = prepareVersionTwoQuote(input, Long.valueOf(id));
+        // 未升级v2的普通外卖也核实道路运费;不改旧促销算法或静默调整支付金额。
+        if (versionTwoQuote == null) foodDeliveryLegacyFreightService.verifyAndFill(input, Long.valueOf(id));
         createOrderParent(input,Long.valueOf(id),shdz,versionTwoQuote);
         createOrderChild(input,Long.valueOf(id),Long.valueOf(id),shdz,versionTwoQuote);
         // saveAsDefault:把载具/统编存为默认抬头(同一用户仅一条 is_default=1)

+ 32 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/route/DistanceSourceRouteMatrixProvider.java

@@ -0,0 +1,32 @@
+package com.ruoyi.app.order.route;
+
+import com.ruoyi.app.flashdelivery.route.GeoPoint;
+import com.ruoyi.app.map.MapSourceSelector;
+import org.springframework.context.annotation.Primary;
+import org.springframework.stereotype.Component;
+import java.math.BigDecimal;
+import java.util.List;
+
+/** 外卖与闪送读取同一字典;失败原样交报价业务处理,绝不暗中跨提供方重试。 */
+@Primary
+@Component
+public class DistanceSourceRouteMatrixProvider implements RouteDistanceMatrixProvider {
+    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(DistanceSourceRouteMatrixProvider.class);
+    private final GoogleRouteDistanceMatrixProvider google;
+    private final SelfHostedRouteDistanceMatrixProvider local;
+    private final MapSourceSelector selector;
+    public DistanceSourceRouteMatrixProvider(GoogleRouteDistanceMatrixProvider google,
+            SelfHostedRouteDistanceMatrixProvider local, MapSourceSelector selector) {
+        this.google = google; this.local = local; this.selector = selector;
+    }
+    @Override
+    public List<BigDecimal> distancesToDestination(List<GeoPoint> origins, GeoPoint destination) {
+        boolean selfHosted = selector.selfHostedEnabled();
+        long started = System.nanoTime();
+        try { return (selfHosted ? local : google).distancesToDestination(origins, destination); }
+        finally {
+            log.debug("Food route provider={} origins={} elapsedMs={}", selfHosted ? "SELF_HOSTED" : "GOOGLE",
+                    origins == null ? 0 : origins.size(), (System.nanoTime() - started) / 1_000_000);
+        }
+    }
+}

+ 51 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/route/SelfHostedRouteDistanceMatrixProvider.java

@@ -0,0 +1,51 @@
+package com.ruoyi.app.order.route;
+
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.app.flashdelivery.route.GeoPoint;
+import com.ruoyi.app.map.SelfHostedMapClient;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/** 外卖每店分别到用户的道路米数;不使用闪送整线总距离,不返回部分成功或直线替代值。 */
+@Component
+public class SelfHostedRouteDistanceMatrixProvider implements RouteDistanceMatrixProvider {
+    private final SelfHostedMapClient client;
+    @Value("${map.food-delivery.profile:car}") private String profile = "car";
+    public SelfHostedRouteDistanceMatrixProvider(SelfHostedMapClient client) { this.client = client; }
+
+    @Override
+    public List<BigDecimal> distancesToDestination(List<GeoPoint> origins, GeoPoint destination) {
+        if (origins == null || origins.isEmpty() || origins.size() > 30
+                || !("car".equals(profile) || "motorcycle".equals(profile)))
+            throw new IllegalArgumentException("invalid map matrix input or profile");
+        Map<String, BigDecimal> target = coordinate(destination);
+        List<Map<String, BigDecimal>> starts = origins.stream().map(this::coordinate).toList();
+        JSONObject response = client.post("/route/matrix", Map.of("origins", starts,
+                "destination", target, "profile", profile));
+        JSONArray values = response == null ? null : response.getJSONArray("distances_meters");
+        if (values == null || values.size() != origins.size()) throw invalid();
+        List<BigDecimal> distances = new ArrayList<>();
+        for (Object value : values) {
+            if (!(value instanceof Number)) throw invalid();
+            try {
+                int meters = new BigDecimal(value.toString()).intValueExact();
+                if (meters <= 0) throw invalid();
+                distances.add(BigDecimal.valueOf(meters));
+            } catch (ArithmeticException failure) { throw invalid(); }
+        }
+        return List.copyOf(distances);
+    }
+    private Map<String, BigDecimal> coordinate(GeoPoint point) {
+        if (point == null || point.latitude() == null || point.longitude() == null
+                || point.latitude().abs().compareTo(BigDecimal.valueOf(90)) > 0
+                || point.longitude().abs().compareTo(BigDecimal.valueOf(180)) > 0)
+            throw new IllegalArgumentException("invalid map matrix coordinate");
+        return Map.of("lat", point.latitude(), "lng", point.longitude());
+    }
+    private IllegalStateException invalid() { return new IllegalStateException("self-hosted matrix invalid response"); }
+}

+ 38 - 37
ruoyi-admin/src/main/java/com/ruoyi/app/utils/SysFileInfoController.java

@@ -42,6 +42,9 @@ public class SysFileInfoController extends BaseController {
     private PosFreightMapper posFreightMapper;
     @Autowired
     private JedisFactory jedisFactory;
+    @Autowired private com.ruoyi.app.map.MapSourceSelector mapSourceSelector;
+    @Autowired private com.ruoyi.app.map.MapLegacyRouteService mapLegacyRouteService;
+    @Autowired private com.ruoyi.app.order.FoodDeliveryPromotionQuoteService foodDeliveryPromotionQuoteService;
     private static final Logger log = LoggerFactory.getLogger(SysFileInfoController.class);
 
     //上传
@@ -127,41 +130,22 @@ public class SysFileInfoController extends BaseController {
         }
     }
 
-    //计算运费----直线
+    // 旧外卖报价保留字段形状,但道路里程/运价与下单使用同一提供器;坐标1为门店、坐标2为收货点。
     @Anonymous
     @GetMapping("/getyunfei")
     public AjaxResult getyunfei(@RequestParam double lat1,
                                 @RequestParam double lng1,
                                 @RequestParam double lat2,
                                 @RequestParam double lng2) {
-        List<PosFreight> freights = posFreightMapper.getyunjia();
-        Long yunjia;
-        Long qyjvli;
-        Long qyjiage;
-        if (freights.size() == 0) {
-            List<PosFreight> hts = posFreightMapper.getmiuyunjia();
-            yunjia = hts.get(0).getFreight() / hts.get(0).getDistance();
-            qyjvli = hts.get(0).getStartingDistance();
-            qyjiage = hts.get(0).getStartingFare();
-        } else {
-            yunjia = freights.get(0).getFreight() / freights.get(0).getDistance();
-            qyjvli = freights.get(0).getStartingDistance();
-            qyjiage = freights.get(0).getStartingFare();
-        }
-        GetJvli jvli = new GetJvli();
-        Double psjvli = jvli.getDistance(lat1, lng1, lat2, lng2);
-        DecimalFormat df = new DecimalFormat("#");
-        DecimalFormat jl = new DecimalFormat("#.0");
-        Double jsjl = psjvli <= qyjvli ? 0 : psjvli - qyjvli;
-        System.out.println("==========" + jsjl);
-        String yunf = df.format(yunjia * (jsjl < 0.5 ? 0 : jsjl >= 0.5 && jsjl < 1 ? 1 : jsjl));
-        Long sjyunf = Long.parseLong(yunf) + qyjiage;
-        BigDecimal bb = BigDecimal.valueOf(sjyunf);
-        BigDecimal Res = sjyunf < 1000 ? bb : bb.setScale(-3, BigDecimal.ROUND_HALF_UP);
+        if (!Double.isFinite(lat1) || !Double.isFinite(lng1) || !Double.isFinite(lat2) || !Double.isFinite(lng2))
+            return error(MessageUtils.message("map.location.invalid"));
+        var quote = foodDeliveryPromotionQuoteService.quoteFreightCoordinates(
+                new com.ruoyi.app.flashdelivery.route.GeoPoint(BigDecimal.valueOf(lat1), BigDecimal.valueOf(lng1)),
+                new com.ruoyi.app.flashdelivery.route.GeoPoint(BigDecimal.valueOf(lat2), BigDecimal.valueOf(lng2)));
         JSONObject org = new JSONObject();
-        org.put("jvli", Double.valueOf(jl.format(psjvli)));
-        org.put("freight", Res);
-        org.put("yunf", sjyunf);
+        org.put("jvli", quote.distanceMeters().movePointLeft(3));
+        org.put("freight", quote.freight());
+        org.put("yunf", quote.freight());
         return success(MessageUtils.message("no.obtained.permission"), org);
     }
 
@@ -205,21 +189,38 @@ public class SysFileInfoController extends BaseController {
     public AjaxResult getluxianjvli(@RequestParam String latlng1,
                                     @RequestParam String latlng2,
                                     @RequestParam(defaultValue = "") String id) {
-        log.info("谷歌地图:开始处理路径计算请求 -> ID: {}, 坐标1: {}, 坐标2: {}", id, latlng1, latlng2);
-        try(Jedis jedis=jedisFactory.create()) {
-            String lang = jedis.get(id) == null ? "zh-TW" : jedis.get(id);
-            GetArea area = new GetArea();
-            JSONObject reg = area.getluxian(latlng1, latlng2, lang);
-            log.info("谷歌地图:返回---" + reg.toString());
-            return success(reg.getJSONArray("routes"));
+        // 旧结算页实际传收货点在前、门店在后,配送应按门店→收货点算路(单行路不能反用)。
+        try {
+            var quote = foodDeliveryPromotionQuoteService.quoteFreightCoordinates(
+                    legacyCoordinate(latlng2), legacyCoordinate(latlng1));
+            com.alibaba.fastjson2.JSONArray routes;
+            if (mapSourceSelector.selfHostedEnabled()) routes = mapLegacyRouteService.routes(latlng2, latlng1);
+            else {
+                try (Jedis jedis = jedisFactory.create()) {
+                    String saved = jedis.get(id);
+                    JSONObject result = new GetArea().getluxian(latlng2, latlng1, saved == null ? "zh-TW" : saved);
+                    routes = com.alibaba.fastjson2.JSON.parseArray(com.alibaba.fastjson2.JSON.toJSONString(result.getJSONArray("routes")));
+                }
+            }
+            if (routes == null || routes.isEmpty()) throw new IllegalStateException("empty route");
+            // Directions与矩阵即使同向也可能有选路差异。旧App用此米数算价,故必须对齐权威矩阵,
+            // 保留路线绘图字段;旧接口暂需路线+矩阵两次请求,新完整报价接口仅需矩阵一次。
+            routes.getJSONObject(0).getJSONArray("legs").getJSONObject(0).put("distance",
+                    java.util.Map.of("value", quote.distanceMeters(), "text", quote.distanceMeters().movePointLeft(3) + " km"));
+            return success(routes);
         } catch (Exception e) {
-            // 异常捕获
-            log.error("谷歌地图:处理路径计算请求异常 -> ID: {}, 错误信息: {}", id, e.getMessage(), e);
+            log.warn("Legacy food route unavailable, type={}", e.getClass().getSimpleName());
             return error(MessageUtils.message("no.map.exception"));
         }
 
     }
 
+    private com.ruoyi.app.flashdelivery.route.GeoPoint legacyCoordinate(String pair) {
+        String[] values = pair == null ? new String[0] : pair.split(",", -1);
+        if (values.length != 2) throw new IllegalArgumentException("invalid coordinate");
+        return new com.ruoyi.app.flashdelivery.route.GeoPoint(new BigDecimal(values[0].trim()), new BigDecimal(values[1].trim()));
+    }
+
     /**
      * Mapbox 计算导航路径
      * @param lnglat1

+ 10 - 2
ruoyi-admin/src/main/resources/application.yml

@@ -272,7 +272,15 @@ line-pay:
     auth-deadline-minutes: 30
     unknown-deadline-hours: 24
 
-# 自部署地图(Foodie_HCM_Map):字典 sys_map_distance_source 值为 SELF_HOSTED 时,闪送路线距离改走此服务
+# 外卖/闪送/新选址统一使用 sys_map_distance_source;Google 路径保留,改字典即可回滚。
 map:
   self-hosted:
-    base-url: ${MAP_SELF_HOSTED_BASE_URL:http://127.0.0.1:8080}
+    # 反代入口含 /api;若直连 Flask,可显式配 http://host:5050(不含/api)。
+    base-url: ${MAP_SELF_HOSTED_BASE_URL:http://127.0.0.1:8080/api}
+    read-token: ${MAP_SELF_HOSTED_READ_TOKEN:}
+    admin-token: ${MAP_SELF_HOSTED_ADMIN_TOKEN:}
+    connect-timeout-ms: ${MAP_CONNECT_TIMEOUT_MS:2500}
+    read-timeout-ms: ${MAP_READ_TIMEOUT_MS:18000}
+  food-delivery:
+    # 保留现有 Google DRIVE 计费口径;机车路网需运营验证后显式选 motorcycle。
+    profile: ${MAP_FOOD_DELIVERY_PROFILE:car}

+ 8 - 0
ruoyi-admin/src/main/resources/i18n/messages.properties

@@ -502,3 +502,11 @@ promotion.freeDelivery.duplicateOrder=订单已提交,请勿重复下单
 native.push.test.invalid=原生推送测试参数不完整或不正确
 native.push.test.accepted=推送服务已接受消息,请检查手机通知
 native.push.test.failed=原生推送发送失败
+
+# 032 dual map integration
+map.location.invalid=地图查询参数或坐标无效
+map.location.unavailable=地图服务暂时不可用,请稍后重试
+map.merchantSync.invalid=商家地图同步参数无效
+map.merchantSync.invalidStore=门店地图资料无效或门店已下架
+map.merchantSync.storeMissing=门店不存在
+map.merchantSync.unavailable=门店地图同步失败,请稍后重试

+ 8 - 0
ruoyi-admin/src/main/resources/i18n/messages_en_US.properties

@@ -488,3 +488,11 @@ promotion.freeDelivery.duplicateOrder=The order has already been submitted, plea
 native.push.test.invalid=Invalid or incomplete native push test parameters
 native.push.test.accepted=The push provider accepted the message; check the phone notification
 native.push.test.failed=Native push delivery failed
+
+# 032 dual map integration
+map.location.invalid=Invalid map query or coordinates
+map.location.unavailable=Map service is temporarily unavailable. Please try again
+map.merchantSync.invalid=Invalid merchant map synchronization request
+map.merchantSync.invalidStore=The store has invalid or unavailable map information
+map.merchantSync.storeMissing=The store does not exist
+map.merchantSync.unavailable=Unable to synchronize the store with the map service

+ 8 - 0
ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties

@@ -489,3 +489,11 @@ promotion.freeDelivery.duplicateOrder=คำสั่งซื้อถูกส
 native.push.test.invalid=ข้อมูลทดสอบการแจ้งเตือนแบบเนทีฟไม่ครบถ้วนหรือไม่ถูกต้อง
 native.push.test.accepted=ผู้ให้บริการรับข้อความแล้ว โปรดตรวจสอบการแจ้งเตือนบนโทรศัพท์
 native.push.test.failed=ส่งการแจ้งเตือนแบบเนทีฟไม่สำเร็จ
+
+# 032 dual map integration
+map.location.invalid=คำค้นหาหรือพิกัดแผนที่ไม่ถูกต้อง
+map.location.unavailable=บริการแผนที่ไม่พร้อมใช้งานชั่วคราว โปรดลองอีกครั้ง
+map.merchantSync.invalid=คำขอซิงค์แผนที่ร้านค้าไม่ถูกต้อง
+map.merchantSync.invalidStore=ข้อมูลแผนที่ร้านค้าไม่ถูกต้องหรือร้านถูกซ่อน
+map.merchantSync.storeMissing=ไม่พบร้านค้า
+map.merchantSync.unavailable=ไม่สามารถซิงค์ร้านค้ากับแผนที่ได้ โปรดลองอีกครั้ง

+ 8 - 0
ruoyi-admin/src/main/resources/i18n/messages_vi.properties

@@ -26,3 +26,11 @@ multi.store.checkout.excluded.outside.fence=ngoài phạm vi giao hàng tính t
 multi.store.checkout.excluded.no.payment=không có phương thức thanh toán khả dụng
 multi.store.checkout.payment.dispatched=Cửa hàng này đã gửi yêu cầu thanh toán {0} nên không thể đổi phương thức thanh toán. Vui lòng quay lại hoàn tất thanh toán, hoặc hủy giao dịch nhiều cửa hàng này và đặt lại.
 multi.store.checkout.invoice.unavailable=Các cửa hàng tham gia chưa hỗ trợ hóa đơn điện tử
+
+# 032 dual map integration
+map.location.invalid=Tham số tìm kiếm hoặc tọa độ bản đồ không hợp lệ
+map.location.unavailable=Dịch vụ bản đồ tạm thời không khả dụng, vui lòng thử lại
+map.merchantSync.invalid=Tham số đồng bộ cửa hàng với bản đồ không hợp lệ
+map.merchantSync.invalidStore=Thông tin bản đồ của cửa hàng không hợp lệ hoặc cửa hàng đã bị ẩn
+map.merchantSync.storeMissing=Cửa hàng không tồn tại
+map.merchantSync.unavailable=Không thể đồng bộ cửa hàng với bản đồ, vui lòng thử lại

+ 8 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties

@@ -490,3 +490,11 @@ promotion.freeDelivery.duplicateOrder=订单已提交,请勿重复下单
 native.push.test.invalid=原生推送测试参数不完整或不正确
 native.push.test.accepted=推送服务已接受消息,请检查手机通知
 native.push.test.failed=原生推送发送失败
+
+# 032 dual map integration
+map.location.invalid=地图查询参数或坐标无效
+map.location.unavailable=地图服务暂时不可用,请稍后重试
+map.merchantSync.invalid=商家地图同步参数无效
+map.merchantSync.invalidStore=门店地图资料无效或门店已下架
+map.merchantSync.storeMissing=门店不存在
+map.merchantSync.unavailable=门店地图同步失败,请稍后重试

+ 8 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties

@@ -489,3 +489,11 @@ promotion.freeDelivery.duplicateOrder=訂單已提交,請勿重複下單
 native.push.test.invalid=原生推播測試參數不完整或不正確
 native.push.test.accepted=推播服務已接受訊息,請檢查手機通知
 native.push.test.failed=原生推播傳送失敗
+
+# 032 dual map integration
+map.location.invalid=地圖查詢參數或座標無效
+map.location.unavailable=地圖服務暫時無法使用,請稍後再試
+map.merchantSync.invalid=商家地圖同步參數無效
+map.merchantSync.invalidStore=門店地圖資料無效或門店已下架
+map.merchantSync.storeMissing=門店不存在
+map.merchantSync.unavailable=門店地圖同步失敗,請稍後再試

+ 4 - 3
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/route/DistanceSourceRouteProviderTest.java

@@ -2,6 +2,7 @@ package com.ruoyi.app.flashdelivery.route;
 
 import com.ruoyi.common.core.domain.entity.SysDictData;
 import com.ruoyi.common.utils.DictUtils;
+import com.ruoyi.app.map.MapSourceSelector;
 import org.junit.jupiter.api.Test;
 import org.mockito.MockedStatic;
 
@@ -24,7 +25,7 @@ class DistanceSourceRouteProviderTest {
     private final SelfHostedRouteDistanceProvider selfHosted = mock(SelfHostedRouteDistanceProvider.class);
 
     private DistanceSourceRouteProvider router(boolean enabled) {
-        return new DistanceSourceRouteProvider(google, selfHosted) {
+        return new DistanceSourceRouteProvider(google, selfHosted, new MapSourceSelector()) {
             @Override
             boolean selfHostedEnabled() {
                 return enabled;
@@ -74,7 +75,7 @@ class DistanceSourceRouteProviderTest {
 
     @Test
     void readsUniqueDefaultRatherThanFirstDictionaryRow() {
-        DistanceSourceRouteProvider provider = new DistanceSourceRouteProvider(google, selfHosted);
+        DistanceSourceRouteProvider provider = new DistanceSourceRouteProvider(google, selfHosted, new MapSourceSelector());
         try (MockedStatic<DictUtils> dictionaries = mockStatic(DictUtils.class)) {
             dictionaries.when(() -> DictUtils.getDictCache("sys_map_distance_source"))
                     .thenReturn(List.of(row("GOOGLE", "N"), row("SELF_HOSTED", "Y")));
@@ -85,7 +86,7 @@ class DistanceSourceRouteProviderTest {
 
     @Test
     void missingOrAmbiguousDefaultFallsBackToGoogle() {
-        DistanceSourceRouteProvider provider = new DistanceSourceRouteProvider(google, selfHosted);
+        DistanceSourceRouteProvider provider = new DistanceSourceRouteProvider(google, selfHosted, new MapSourceSelector());
         try (MockedStatic<DictUtils> dictionaries = mockStatic(DictUtils.class)) {
             dictionaries.when(() -> DictUtils.getDictCache("sys_map_distance_source"))
                     .thenReturn(List.of(row("SELF_HOSTED", "N"), row("GOOGLE", "N")),

+ 11 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteServiceTest.java

@@ -26,6 +26,17 @@ import static org.mockito.Mockito.verifyNoMoreInteractions;
 
 class FlashDeliveryRouteServiceTest {
 
+    @Test
+    void missingRoadDurationEstimatesByVehicleWithoutLosingRoadDistanceSource() {
+        RouteDistanceProvider provider = mock(RouteDistanceProvider.class);
+        List<GeoPoint> points = List.of(point("25", "121"), point("25.1", "121.1"));
+        when(provider.route(points, 1)).thenReturn(new RouteDistance(3000, null, "ROUTE"));
+        when(provider.route(points, 2)).thenReturn(new RouteDistance(3000, 0, "ROUTE"));
+        FlashDeliveryRouteService service = new FlashDeliveryRouteService(provider);
+        assertEquals(new RouteDistance(3000, 432, "ROUTE"), service.calculate(points, 1));
+        assertEquals(new RouteDistance(3000, 360, "ROUTE"), service.calculate(points, 2));
+    }
+
     private static ConfigurableListableBeanFactory originalBeanFactory;
 
     @BeforeAll

+ 26 - 2
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/route/SelfHostedRouteDistanceProviderTest.java

@@ -12,14 +12,38 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
 /** 自部署地图路线 Provider 单测:响应解析、单次多站点请求保持用户顺序、越界不降级语义。 */
 class SelfHostedRouteDistanceProviderTest {
 
+    @Test
+    void realRequestMustSelectMotorcycleInsteadOfMapDefaultCar() throws Exception {
+        com.sun.net.httpserver.HttpServer server = com.sun.net.httpserver.HttpServer.create(
+                new java.net.InetSocketAddress("127.0.0.1", 0), 0);
+        server.createContext("/route/multi", exchange -> {
+            boolean motorcycle = exchange.getRequestURI().getQuery().contains("profile=motorcycle");
+            byte[] body = ("{\"distance_meters\":" + (motorcycle ? 1000 : 2000)
+                    + ",\"duration_seconds\":120}").getBytes(java.nio.charset.StandardCharsets.UTF_8);
+            exchange.sendResponseHeaders(200, body.length);
+            try (var output = exchange.getResponseBody()) { output.write(body); }
+        });
+        server.start();
+        try {
+            com.ruoyi.app.map.SelfHostedMapClient client = new com.ruoyi.app.map.SelfHostedMapClient();
+            SelfHostedRouteDistanceProvider provider = new SelfHostedRouteDistanceProvider(client);
+            org.springframework.test.util.ReflectionTestUtils.setField(client, "baseUrl",
+                    "http://127.0.0.1:" + server.getAddress().getPort());
+            assertEquals(1000, provider.route(List.of(point("25", "121"), point("25.1", "121.1")), 1).distanceMeters());
+        } finally {
+            server.stop(0);
+        }
+    }
+
     /** 捕获请求站点并返回固定响应,替代 HTTP。 */
     static class StubProvider extends SelfHostedRouteDistanceProvider {
+        StubProvider() { super(org.mockito.Mockito.mock(com.ruoyi.app.map.SelfHostedMapClient.class)); }
         JSONObject response;
         RuntimeException error;
         List<GeoPoint> requestedPoints;
 
         @Override
-        JSONObject fetchRoute(List<GeoPoint> points) {
+        JSONObject fetchRoute(List<GeoPoint> points, Integer vehicleType) {
             requestedPoints = points;
             if (error != null) throw error;
             return response;
@@ -44,7 +68,7 @@ class SelfHostedRouteDistanceProviderTest {
     @Test
     void invalidDistanceFailsInsteadOfFabricating() {
         JSONObject response = new JSONObject();
-        response.put("distance_meters", 0);
+        response.put("distance_meters", -1);
         assertThrows(IllegalStateException.class,
                 () -> new StubProvider().parseRoute(response));
         assertThrows(IllegalStateException.class, () -> new StubProvider().parseRoute(null));

+ 28 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/map/GoogleMapLocationClientTest.java

@@ -0,0 +1,28 @@
+package com.ruoyi.app.map;
+
+import org.apache.http.client.utils.URIBuilder;
+import org.junit.jupiter.api.Test;
+
+import java.net.URI;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class GoogleMapLocationClientTest {
+    @Test
+    void queryCannotChangeHostPathOrApiKey() throws Exception {
+        String input = "台北&key=attacker#https://evil.example/path";
+        URI uri = GoogleMapLocationClient.requestUri("/maps/api/place/textsearch/json",
+                Map.of("query", input), "server-key");
+        assertEquals("https", uri.getScheme());
+        assertEquals("maps.googleapis.com", uri.getHost());
+        assertEquals("/maps/api/place/textsearch/json", uri.getPath());
+        assertNull(uri.getFragment());
+        Map<String, String> values = new java.util.HashMap<>();
+        new URIBuilder(uri).getQueryParams().forEach(pair -> values.put(pair.getName(), pair.getValue()));
+        assertEquals(input, values.get("query"));
+        assertEquals("server-key", values.get("key"));
+        assertThrows(IllegalArgumentException.class, () -> GoogleMapLocationClient.requestUri(
+                "https://evil.example", Map.of(), "server-key"));
+    }
+}

+ 60 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/map/LegacyCheckoutRouteDirectionTest.java

@@ -0,0 +1,60 @@
+package com.ruoyi.app.map;
+
+import com.alibaba.fastjson.JSONObject;
+import com.ruoyi.app.order.FoodDeliveryPromotionQuoteService;
+import com.ruoyi.app.utils.SysFileInfoController;
+import com.ruoyi.system.utils.GetArea;
+import com.ruoyi.system.utils.JedisFactory;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+import redis.clients.jedis.Jedis;
+import java.math.BigDecimal;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class LegacyCheckoutRouteDirectionTest {
+    private org.mockito.MockedStatic<com.ruoyi.common.utils.MessageUtils> messages;
+    @org.junit.jupiter.api.BeforeEach
+    void initializeMessages() { messages = mockStatic(com.ruoyi.common.utils.MessageUtils.class, call -> call.getArgument(0)); }
+    @org.junit.jupiter.api.AfterEach
+    void closeMessages() { messages.close(); }
+    @Test
+    void oldCustomerFirstParametersUseStoreToCustomerDirectionForBothProviders() {
+        var controller = new SysFileInfoController();
+        var selector = mock(MapSourceSelector.class);
+        var local = mock(MapLegacyRouteService.class);
+        var quote = mock(FoodDeliveryPromotionQuoteService.class);
+        var jedis = mock(Jedis.class);
+        var factory = mock(JedisFactory.class);
+        when(factory.create()).thenReturn(jedis);
+        when(quote.quoteFreightCoordinates(any(), any())).thenReturn(
+                new FoodDeliveryPromotionQuoteService.FoodRouteQuote(BigDecimal.valueOf(5000), BigDecimal.valueOf(40)));
+        ReflectionTestUtils.setField(controller, "mapSourceSelector", selector);
+        ReflectionTestUtils.setField(controller, "mapLegacyRouteService", local);
+        ReflectionTestUtils.setField(controller, "foodDeliveryPromotionQuoteService", quote);
+        ReflectionTestUtils.setField(controller, "jedisFactory", factory);
+        when(local.routes(anyString(), anyString())).thenAnswer(call -> com.alibaba.fastjson2.JSON.parseArray(
+                body("25.1,121.1".equals(call.getArgument(0)) ? 5000 : 3000)));
+        when(selector.selfHostedEnabled()).thenReturn(true);
+        var result = controller.getluxianjvli("25,121", "25.1,121.1", "");
+        assertEquals(5000, distance(result.get("data")));
+        verify(local).routes("25.1,121.1", "25,121");
+        when(selector.selfHostedEnabled()).thenReturn(false);
+        try (var google = mockConstruction(GetArea.class, (mock, context) ->
+                when(mock.getluxian(anyString(), anyString(), anyString())).thenAnswer(call -> {
+                    JSONObject response = new JSONObject();
+                    response.put("routes", com.alibaba.fastjson.JSON.parseArray(body(
+                            "25.1,121.1".equals(call.getArgument(0)) ? 5000 : 3000)));
+                    return response;
+                }))) {
+            var googleResult = controller.getluxianjvli("25,121", "25.1,121.1", "");
+            assertEquals(5000, distance(googleResult.get("data")));
+            verify(google.constructed().get(0)).getluxian("25.1,121.1", "25,121", "zh-TW");
+        }
+    }
+    private static String body(int meters) { return "[{\"legs\":[{\"distance\":{\"value\":" + meters + "}}]}]"; }
+    private static int distance(Object body) {
+        return com.alibaba.fastjson2.JSON.parseArray(com.alibaba.fastjson2.JSON.toJSONString(body))
+                .getJSONObject(0).getJSONArray("legs").getJSONObject(0).getJSONObject("distance").getIntValue("value");
+    }
+}

+ 36 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/map/LegacyFoodFreightEndpointTest.java

@@ -0,0 +1,36 @@
+package com.ruoyi.app.map;
+
+import com.ruoyi.app.order.FoodDeliveryPromotionQuoteService;
+import com.ruoyi.app.order.route.RouteDistanceMatrixProvider;
+import com.ruoyi.app.utils.SysFileInfoController;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.PosFreight;
+import com.ruoyi.system.mapper.PosFreightMapper;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+import java.math.BigDecimal;
+import java.util.List;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class LegacyFoodFreightEndpointTest {
+    @Test
+    void legacyFeeEndpointQuotesRoadDistanceSoOldClientsCanPassOrderValidation() {
+        var matrix = mock(RouteDistanceMatrixProvider.class);
+        when(matrix.distancesToDestination(any(), any())).thenReturn(List.of(new BigDecimal("5000")));
+        var fares = mock(PosFreightMapper.class);
+        PosFreight fare = new PosFreight(); fare.setStartingFare(20L); fare.setStartingDistance(3L);
+        fare.setFreight(10L); fare.setDistance(1L);
+        when(fares.getyunjia()).thenReturn(List.of(fare));
+        var quotes = new FoodDeliveryPromotionQuoteService(null, null, null, null, null, null, fares, null, null, matrix);
+        var controller = new SysFileInfoController();
+        ReflectionTestUtils.setField(controller, "posFreightMapper", fares);
+        ReflectionTestUtils.setField(controller, "foodDeliveryPromotionQuoteService", quotes);
+        try (var messages = mockStatic(MessageUtils.class, call -> call.getArgument(0))) {
+            var response = controller.getyunfei(25, 121, 25.02, 121.02);
+            var body = com.alibaba.fastjson2.JSON.parseObject(com.alibaba.fastjson2.JSON.toJSONString(response.get("data")));
+            assertEquals(40, body.getIntValue("freight"));
+            assertEquals(5D, body.getDoubleValue("jvli"));
+        }
+    }
+}

+ 26 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/map/MapLegacyRouteServiceTest.java

@@ -0,0 +1,26 @@
+package com.ruoyi.app.map;
+
+import com.alibaba.fastjson2.JSON;
+import com.ruoyi.common.exception.ServiceException;
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class MapLegacyRouteServiceTest {
+    @Test
+    void legacyClientReceivesGoogleLegShapeAndEncodedPolylineFromSelfHostedGeoJson() {
+        var client = mock(SelfHostedMapClient.class);
+        when(client.getObject(eq("/route/multi"), anyMap())).thenReturn(JSON.parseObject("""
+            {"distance_meters":1200,"duration_seconds":180,"geometry":{"type":"LineString",
+              "coordinates":[[120.2,25.5],[120.3,25.6]]}}
+            """));
+        var routes = new MapLegacyRouteService(client).routes("25.5,120.2", "25.6,120.3");
+        var route = routes.getJSONObject(0);
+        assertEquals(1200, route.getJSONArray("legs").getJSONObject(0).getJSONObject("distance").getIntValue("value"));
+        assertEquals(180, route.getJSONArray("legs").getJSONObject(0).getJSONObject("duration").getIntValue("value"));
+        assertFalse(route.getJSONObject("overview_polyline").getString("points").isBlank());
+        try (var messages = mockStatic(com.ruoyi.common.utils.MessageUtils.class, call -> call.getArgument(0))) {
+            assertThrows(ServiceException.class, () -> new MapLegacyRouteService(client).routes("NaN,120", "25,121"));
+        }
+    }
+}

+ 35 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/map/MapLocationControllerTest.java

@@ -0,0 +1,35 @@
+package com.ruoyi.app.map;
+
+import com.ruoyi.common.core.redis.RedisCache;
+import com.ruoyi.system.utils.AuthAspect;
+import org.junit.jupiter.api.Test;
+import org.springframework.aop.aspectj.annotation.AspectJProxyFactory;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+class MapLocationControllerTest {
+    @Test
+    void allRoutesRequireTokenAndAuthAspectRejectsInvalidSessionBeforeProvider() throws Exception {
+        MapLocationService service = mock(MapLocationService.class);
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.addHeader("token", "invalid-token");
+        AuthAspect auth = new AuthAspect(mock(RedisCache.class));
+        auth.setHttpServletRequest(request);
+        AspectJProxyFactory factory = new AspectJProxyFactory(new MapLocationController(service));
+        factory.setProxyTargetClass(true);
+        factory.addAspect(auth);
+        MockMvc mvc = MockMvcBuilders.standaloneSetup((Object) factory.getProxy()).build();
+        for (String path : new String[]{"/system/map/search?q=test", "/system/map/nearby?latitude=25&longitude=121",
+                "/system/map/reverse?latitude=25&longitude=121", "/system/map/place?id=google:abc"}) {
+            mvc.perform(get(path)).andExpect(status().isBadRequest());
+            mvc.perform(get(path).header("token", "invalid-token"))
+                    .andExpect(status().isOk()).andExpect(jsonPath("$.code").value(401));
+        }
+        verifyNoInteractions(service);
+    }
+}

+ 179 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/map/MapLocationServiceTest.java

@@ -0,0 +1,179 @@
+package com.ruoyi.app.map;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.context.support.StaticMessageSource;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+class MapLocationServiceTest {
+    private static ConfigurableListableBeanFactory originalBeanFactory;
+    private final MapSourceSelector selector = mock(MapSourceSelector.class);
+    private final SelfHostedMapClient selfHosted = mock(SelfHostedMapClient.class);
+    private final GoogleMapLocationClient google = mock(GoogleMapLocationClient.class);
+    private MapLocationService service;
+
+    @BeforeAll
+    static void messages() {
+        originalBeanFactory = (ConfigurableListableBeanFactory)
+                ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+        StaticMessageSource messages = new StaticMessageSource();
+        messages.setUseCodeAsDefaultMessage(true);
+        DefaultListableBeanFactory beans = new DefaultListableBeanFactory();
+        beans.registerSingleton("messageSource", messages);
+        new SpringUtils().postProcessBeanFactory(beans);
+    }
+
+    @AfterAll
+    static void restoreMessages() {
+        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
+    }
+
+    @BeforeEach
+    void setUp() {
+        service = new MapLocationService(selector, selfHosted, google);
+        when(selector.selfHostedEnabled()).thenReturn(true);
+    }
+
+    @Test
+    void searchMapsFlatSelfHostedAddressAndProviderNamespace() {
+        when(selfHosted.getArray(eq("/search"), anyMap())).thenReturn(JSON.parseArray("""
+                [{"id":"osm:123","name":"台北車站","address":"臺北市中正區","lat":25.0478,
+                  "lng":121.517,"source":"osm","precision":"poi"}]
+                """));
+        MapLocation result = service.search(" 台北車站 ").get(0);
+        assertEquals("self_hosted:osm:123", result.getId());
+        assertEquals("台北車站", result.getName());
+        assertEquals(new BigDecimal("25.0478"), result.getLatitude());
+        assertEquals("SELF_HOSTED", result.getSource());
+        assertTrue(result.isRequiresConfirmation());
+        verifyNoInteractions(google);
+    }
+
+    @Test
+    void reverseRetainsExactTapInsteadOfNearestCoordinates() {
+        when(selfHosted.getObject(eq("/reverse_geocode"), anyMap())).thenReturn(JSON.parseObject("""
+                {"id":"pin:25,121","name":"松智路","address":"松智路","lat":25.03,"lng":121.56,
+                "precision":"nearby_place","requires_confirmation":true,
+                "nearest":{"id":"osm:8","lat":25.04,"lng":121.57}}
+                """));
+        MapLocation result = service.reverse(new BigDecimal("25.033000123"), new BigDecimal("121.565400987"));
+        assertEquals(new BigDecimal("25.033000123"), result.getLatitude());
+        assertEquals(new BigDecimal("121.565400987"), result.getLongitude());
+        assertEquals("nearby_place", result.getPrecision());
+        assertTrue(result.isRequiresConfirmation());
+    }
+
+    @Test
+    void googleSearchAndReverseNormalizeGeometryAndStructuredAddress() {
+        when(selector.selfHostedEnabled()).thenReturn(false);
+        JSONObject place = JSON.parseObject("""
+                {"place_id":"ChIJ123","name":"台北車站","formatted_address":"臺北市中正區",
+                "geometry":{"location":{"lat":25.0478,"lng":121.517},"location_type":"ROOFTOP"},
+                "address_components":[{"long_name":"臺灣","types":["country"]},
+                {"long_name":"臺北市","types":["administrative_area_level_1"]}]}
+                """);
+        JSONObject response = new JSONObject();
+        response.put("status", "OK");
+        response.put("results", List.of(place));
+        when(google.getObject(eq("/maps/api/place/textsearch/json"), anyMap())).thenReturn(response);
+        when(google.getObject(eq("/maps/api/geocode/json"), anyMap())).thenReturn(response);
+        MapLocation found = service.search("台北").get(0);
+        assertEquals("google:ChIJ123", found.getId());
+        assertEquals("GOOGLE", found.getSource());
+        assertEquals("臺灣", found.getCountry());
+        assertEquals("臺北市", found.getProvince());
+        MapLocation reverse = service.reverse(new BigDecimal("25.1"), new BigDecimal("121.6"));
+        assertEquals(new BigDecimal("25.1"), reverse.getLatitude());
+        assertEquals(new BigDecimal("121.6"), reverse.getLongitude());
+        assertEquals("google:pin:25.1,121.6", reverse.getId());
+        assertEquals("nearby_place", reverse.getPrecision());
+        assertTrue(reverse.isRequiresConfirmation());
+        verifyNoInteractions(selfHosted);
+    }
+
+    @Test
+    void namespacedDetailRemainsResolvableAfterSourceSwitch() {
+        when(selector.selfHostedEnabled()).thenReturn(false);
+        when(selfHosted.getObject("/place_detail", Map.of("id", "osm:9"))).thenReturn(JSON.parseObject("""
+                {"id":"osm:9","name":"門店","lat":25,"lng":121,"precision":"poi"}
+                """));
+        assertEquals("self_hosted:osm:9", service.place("self_hosted:osm:9").getId());
+        verifyNoInteractions(google);
+        when(selector.selfHostedEnabled()).thenReturn(true);
+        when(google.getObject("/maps/api/place/details/json", Map.of("place_id", "abc")))
+                .thenReturn(JSON.parseObject("""
+                {"status":"OK","result":{"place_id":"abc","name":"店",
+                "geometry":{"location":{"lat":25,"lng":121}}}}
+                """));
+        assertEquals("google:abc", service.place("google:abc").getId());
+    }
+
+    @Test
+    void nearbyMapsDistanceAndForwardsBoundedRequest() {
+        when(selfHosted.getArray(eq("/nearby"), anyMap())).thenReturn(JSON.parseArray("""
+                [{"id":"osm:1","name":"道路","lat":25,"lng":121,
+                "precision":"street","distance_meters":123.4}]
+                """));
+        MapLocation result = service.nearby(BigDecimal.valueOf(25), BigDecimal.valueOf(121), 300, 10).get(0);
+        assertEquals(new BigDecimal("123.4"), result.getDistanceMeters());
+        verify(selfHosted).getArray("/nearby", Map.of("lat", BigDecimal.valueOf(25),
+                "lng", BigDecimal.valueOf(121), "radius", 300, "limit", 10));
+    }
+
+    @Test
+    void malformedResponsesAndFailureNeverFallBackToGoogle() {
+        for (String body : List.of("[{\"id\":\"1\",\"name\":\"bad\"}]",
+                "[{\"id\":\"1\",\"name\":\"bad\",\"lat\":91,\"lng\":121}]", "[null]")) {
+            when(selfHosted.getArray(eq("/search"), anyMap())).thenReturn(JSON.parseArray(body));
+            assertEquals("map.location.unavailable", assertThrows(ServiceException.class,
+                    () -> service.search("台北")).getMessage());
+        }
+        when(selfHosted.getArray(eq("/search"), anyMap())).thenThrow(new IllegalStateException("offline"));
+        assertThrows(ServiceException.class, () -> service.search("台北"));
+        verifyNoInteractions(google);
+    }
+
+    @Test
+    void googleZeroResultsIsEmptyButDeniedOrMalformedResponseFails() {
+        when(selector.selfHostedEnabled()).thenReturn(false);
+        when(google.getObject(anyString(), anyMap())).thenReturn(JSON.parseObject("{\"status\":\"ZERO_RESULTS\"}"));
+        assertTrue(service.search("unknown").isEmpty());
+        MapLocation pin = service.reverse(new BigDecimal("25.1"), new BigDecimal("121.6"));
+        assertEquals("google:pin:25.1,121.6", pin.getId());
+        assertEquals("coordinate", pin.getPrecision());
+        assertTrue(pin.isRequiresConfirmation());
+        for (String body : List.of("{\"status\":\"REQUEST_DENIED\"}", "{\"status\":\"OK\"}",
+                "{\"status\":\"OK\",\"results\":[{\"place_id\":\"bad\"}]}")) {
+            when(google.getObject(anyString(), anyMap())).thenReturn(JSON.parseObject(body));
+            assertThrows(ServiceException.class, () -> service.search("台北"));
+        }
+    }
+
+    @Test
+    void invalidRequestsAreRejectedBeforeProviderCall() {
+        assertThrows(ServiceException.class, () -> service.search(" "));
+        assertThrows(ServiceException.class, () -> service.search("a".repeat(201)));
+        assertThrows(ServiceException.class, () -> service.reverse(null, BigDecimal.ONE));
+        assertThrows(ServiceException.class, () -> service.reverse(BigDecimal.valueOf(91), BigDecimal.ONE));
+        assertThrows(ServiceException.class, () -> service.nearby(BigDecimal.ONE, BigDecimal.ONE, 2001, 10));
+        assertThrows(ServiceException.class, () -> service.nearby(BigDecimal.ONE, BigDecimal.ONE, 300, 51));
+        assertThrows(ServiceException.class, () -> service.place("osm:123"));
+        verifyNoInteractions(selfHosted, google);
+    }
+}

+ 73 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/map/MerchantMapSyncControllerTest.java

@@ -0,0 +1,73 @@
+package com.ruoyi.app.map;
+
+import com.ruoyi.common.utils.spring.SpringUtils;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.context.annotation.AnnotationConfigApplicationContext;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.support.StaticMessageSource;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class MerchantMapSyncControllerTest {
+    @Test
+    void administratorStoreEditPermissionIsRequiredBeforeAnySync() {
+        var originalBeanFactory = (ConfigurableListableBeanFactory) ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+        try (var context = new AnnotationConfigApplicationContext(SecurityConfiguration.class)) {
+            new SpringUtils().postProcessBeanFactory(context.getBeanFactory());
+            var controller = context.getBean(MerchantMapSyncController.class);
+            var service = context.getBean(MerchantMapSyncService.class);
+            MerchantMapSyncRequest request = new MerchantMapSyncRequest();
+            request.setStoreIds(List.of(1L));
+            SecurityContextHolder.clearContext();
+            assertThrows(AuthenticationCredentialsNotFoundException.class, () -> controller.sync(request));
+            login("chanting:store:query");
+            assertThrows(AccessDeniedException.class, () -> controller.sync(request));
+            verifyNoInteractions(service);
+            login("chanting:store:edit");
+            when(service.sync(request)).thenReturn(new MerchantMapSyncService.SyncResult(List.of(), true));
+            assertEquals(200, controller.sync(request).get("code"));
+            verify(service).sync(request);
+        } finally {
+            SecurityContextHolder.clearContext();
+            new SpringUtils().postProcessBeanFactory(originalBeanFactory);
+        }
+    }
+
+    private static void login(String permission) {
+        SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
+                "platform-user", "unused", List.of(new SimpleGrantedAuthority(permission))));
+    }
+
+    @Configuration
+    @EnableMethodSecurity
+    static class SecurityConfiguration {
+        @Bean MerchantMapSyncService service() { return mock(MerchantMapSyncService.class); }
+        @Bean MerchantMapSyncController controller(MerchantMapSyncService service) { return new MerchantMapSyncController(service); }
+        @Bean(name = "ss") PermissionGate permissions() { return new PermissionGate(); }
+        @Bean(name = "messageSource") StaticMessageSource messages() {
+            StaticMessageSource result = new StaticMessageSource();
+            result.setUseCodeAsDefaultMessage(true);
+            return result;
+        }
+    }
+
+    public static class PermissionGate {
+        public boolean hasPermi(String permission) {
+            var auth = SecurityContextHolder.getContext().getAuthentication();
+            if (auth == null) throw new AuthenticationCredentialsNotFoundException("login required");
+            return auth.getAuthorities().stream().anyMatch(authority -> permission.equals(authority.getAuthority()));
+        }
+    }
+}

+ 194 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/map/MerchantMapSyncServiceTest.java

@@ -0,0 +1,194 @@
+package com.ruoyi.app.map;
+
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.mapper.PosStoreMapper;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.context.support.StaticMessageSource;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.stream.LongStream;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.Mockito.*;
+
+class MerchantMapSyncServiceTest {
+    private static ConfigurableListableBeanFactory originalBeanFactory;
+    private final PosStoreMapper stores = mock(PosStoreMapper.class);
+    private final SelfHostedMapAdminClient map = mock(SelfHostedMapAdminClient.class);
+    private final MerchantMapSyncService service = new MerchantMapSyncService(stores, map);
+
+    @BeforeAll
+    static void messages() {
+        originalBeanFactory = (ConfigurableListableBeanFactory) ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+        StaticMessageSource messages = new StaticMessageSource();
+        messages.setUseCodeAsDefaultMessage(true);
+        DefaultListableBeanFactory beans = new DefaultListableBeanFactory();
+        beans.registerSingleton("messageSource", messages);
+        new SpringUtils().postProcessBeanFactory(beans);
+    }
+
+    @AfterAll
+    static void restoreMessages() {
+        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
+    }
+
+    @Test
+    void changedNameAndCoordinatesReuseStableIdAndRestingStoreIsNotRemoved() {
+        PosStore store = store(42, "Original");
+        store.setState(1L);
+        when(stores.selectPosStoreById(42L)).thenReturn(store);
+        when(map.importPlaces(anyList())).thenAnswer(call -> {
+            List<JSONObject> places = call.getArgument(0);
+            assertEquals("foodie-store-42", places.get(0).getString("external_id"));
+            assertEquals(store.getPosName(), places.get(0).getString("name"));
+            assertEquals(store.getLatitude(), places.get(0).getBigDecimal("lat"));
+            return imported(1);
+        });
+        assertTrue(service.sync(request(List.of(42L), null)).getRows().get(0).isSuccess());
+        store.setPosName("Renamed");
+        store.setLatitude(new BigDecimal("25.04"));
+        var result = service.sync(request(List.of(42L), null));
+        assertTrue(result.getRows().get(0).isSuccess());
+        assertTrue(result.isIndexRebuildRequired());
+        verify(map, times(2)).importPlaces(anyList());
+        verify(map, never()).deletePlace(anyString());
+        verify(stores, times(2)).selectPosStoreById(42L);
+    }
+
+    @Test
+    void malformedOrMissingStoresDoNotBlockValidStoreAndOnlyRequestedIdsAreRead() {
+        PosStore missingCoordinates = store(2, "B");
+        missingCoordinates.setLongitude(null);
+        PosStore noName = store(3, " ");
+        when(stores.selectPosStoreById(1L)).thenReturn(store(1, "A"));
+        when(stores.selectPosStoreById(2L)).thenReturn(missingCoordinates);
+        when(stores.selectPosStoreById(3L)).thenReturn(noName);
+        when(map.importPlaces(anyList())).thenReturn(imported(1));
+        var result = service.sync(request(List.of(1L, 2L, 3L, 4L), null));
+        assertEquals(List.of(true, false, false, false), result.getRows().stream().map(MerchantMapSyncService.RowResult::isSuccess).toList());
+        assertEquals(List.of("OK", "INVALID_STORE", "INVALID_STORE", "STORE_NOT_FOUND"),
+                result.getRows().stream().map(MerchantMapSyncService.RowResult::getCode).toList());
+        verify(map).importPlaces(org.mockito.ArgumentMatchers.argThat(places -> places.size() == 1
+                && "foodie-store-1".equals(places.get(0).getString("external_id"))));
+        verify(stores, never()).selectPosStoreList(any());
+    }
+
+    @Test
+    void outOfRangeAndOffShelfStoresAreRejectedWithoutMapWrites() {
+        PosStore invalid = store(1, "A");
+        invalid.setLongitude(new BigDecimal("181"));
+        PosStore offShelf = store(2, "B");
+        offShelf.setOffShelf("1");
+        when(stores.selectPosStoreById(1L)).thenReturn(invalid);
+        when(stores.selectPosStoreById(2L)).thenReturn(offShelf);
+        var result = service.sync(request(List.of(1L, 2L), null));
+        assertTrue(result.getRows().stream().noneMatch(MerchantMapSyncService.RowResult::isSuccess));
+        assertFalse(result.isIndexRebuildRequired());
+        verifyNoInteractions(map);
+    }
+
+    @Test
+    void providerRejectionIndexIsMappedToOriginalStore() {
+        when(stores.selectPosStoreById(1L)).thenReturn(store(1, "A"));
+        when(stores.selectPosStoreById(2L)).thenReturn(store(2, "B"));
+        JSONObject response = imported(1);
+        response.put("rejected", 1);
+        JSONObject rejection = new JSONObject();
+        rejection.put("index", 1);
+        rejection.put("code", "OUTSIDE_REGION");
+        rejection.put("error", "Do not expose provider body or secrets");
+        response.put("rejections", new JSONArray(List.of(rejection)));
+        when(map.importPlaces(anyList())).thenReturn(response);
+        var result = service.sync(request(List.of(1L, 2L), null));
+        assertTrue(result.getRows().get(0).isSuccess());
+        assertFalse(result.getRows().get(1).isSuccess());
+        assertEquals(2L, result.getRows().get(1).getStoreId());
+        assertEquals("INVALID_STORE", result.getRows().get(1).getCode());
+        assertEquals("map.merchantSync.invalidStore", result.getRows().get(1).getMessage());
+    }
+
+    @Test
+    void explicitRemovalSupportsAlreadyDeletedStoreAndIdempotentRetryWithoutDatabaseLookup() {
+        JSONObject response = new JSONObject();
+        response.put("status", "removed");
+        response.put("removed", 0);
+        response.put("index_rebuild_required", true);
+        when(map.deletePlace("foodie-store-99")).thenReturn(response);
+        for (int attempt = 0; attempt < 2; attempt++) {
+            var result = service.sync(request(null, List.of(99L)));
+            assertTrue(result.getRows().get(0).isSuccess());
+            assertEquals("REMOVE", result.getRows().get(0).getAction());
+            assertTrue(result.isIndexRebuildRequired());
+        }
+        verifyNoInteractions(stores);
+    }
+
+    @Test
+    void requestLimitDuplicatesAndConflictsFailBeforeAnyReadOrExternalWrite() {
+        for (MerchantMapSyncRequest request : List.of(request(null, null), request(List.of(0L), null),
+                request(List.of(1L, 1L), null), request(List.of(1L), List.of(1L)),
+                request(LongStream.rangeClosed(1, 101).boxed().toList(), null))) {
+            assertThrows(ServiceException.class, () -> service.sync(request));
+        }
+        assertThrows(ServiceException.class, () -> service.sync(null));
+        verifyNoInteractions(stores, map);
+    }
+
+    @Test
+    void uncertainExternalFailureReportsRowsAndStillRequiresIndexRebuild() {
+        when(stores.selectPosStoreById(1L)).thenReturn(store(1, "A"));
+        when(map.importPlaces(anyList())).thenThrow(new SelfHostedMapException("REQUEST_FAILED", 502));
+        var result = service.sync(request(List.of(1L), null));
+        assertFalse(result.getRows().get(0).isSuccess());
+        assertEquals("MAP_UNAVAILABLE", result.getRows().get(0).getCode());
+        assertTrue(result.isIndexRebuildRequired());
+    }
+
+    @Test
+    void malformedBatchResponseCannotReportAnyStoreAsSuccess() {
+        when(stores.selectPosStoreById(1L)).thenReturn(store(1, "A"));
+        when(map.importPlaces(anyList())).thenReturn(imported(0));
+        var result = service.sync(request(List.of(1L), null));
+        assertFalse(result.getRows().get(0).isSuccess());
+        assertEquals("MAP_UNAVAILABLE", result.getRows().get(0).getCode());
+    }
+
+    private static MerchantMapSyncRequest request(List<Long> ids, List<Long> removals) {
+        MerchantMapSyncRequest result = new MerchantMapSyncRequest();
+        result.setStoreIds(ids);
+        result.setRemoveStoreIds(removals);
+        return result;
+    }
+
+    private static PosStore store(int id, String name) {
+        PosStore store = new PosStore();
+        store.setId(id);
+        store.setPosName(name);
+        store.setAddress("Store address");
+        store.setLatitude(new BigDecimal("25.03"));
+        store.setLongitude(new BigDecimal("121.56"));
+        store.setDelFlag("0");
+        store.setOffShelf("0");
+        return store;
+    }
+
+    private static JSONObject imported(int count) {
+        JSONObject result = new JSONObject();
+        result.put("imported", count);
+        result.put("rejected", 0);
+        result.put("rejections", new JSONArray());
+        result.put("index_rebuild_required", count > 0);
+        return result;
+    }
+}

+ 91 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/map/SelfHostedMapAdminClientTest.java

@@ -0,0 +1,91 @@
+package com.ruoyi.app.map;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.sun.net.httpserver.HttpServer;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class SelfHostedMapAdminClientTest {
+    @Test
+    void adminWritesUseFixedApiPathsAndAdminBearerOnly() throws Exception {
+        HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+        AtomicReference<String> method = new AtomicReference<>();
+        AtomicReference<String> path = new AtomicReference<>();
+        AtomicReference<String> token = new AtomicReference<>();
+        AtomicReference<String> body = new AtomicReference<>();
+        server.createContext("/api/", exchange -> {
+            method.set(exchange.getRequestMethod());
+            path.set(exchange.getRequestURI().getRawPath());
+            token.set(exchange.getRequestHeaders().getFirst("Authorization"));
+            body.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
+            byte[] response = "{\"status\":\"removed\",\"removed\":0,\"index_rebuild_required\":true}".getBytes(StandardCharsets.UTF_8);
+            exchange.sendResponseHeaders(200, response.length);
+            exchange.getResponseBody().write(response);
+            exchange.close();
+        });
+        server.start();
+        try {
+            SelfHostedMapAdminClient client = new SelfHostedMapAdminClient();
+            ReflectionTestUtils.setField(client, "baseUrl", "http://127.0.0.1:" + server.getAddress().getPort() + "/api/");
+            ReflectionTestUtils.setField(client, "adminToken", "admin-secret");
+            JSONObject place = new JSONObject();
+            place.put("external_id", "foodie-store-42");
+            client.importPlaces(List.of(place));
+            assertEquals("POST", method.get());
+            assertEquals("/api/popular_places/import", path.get());
+            assertEquals("Bearer admin-secret", token.get());
+            assertEquals("foodie-store-42", JSONObject.parseObject(body.get()).getJSONArray("places").getJSONObject(0).getString("external_id"));
+            assertFalse(body.get().contains("admin-secret"));
+            client.deletePlace("foodie-store-42");
+            assertEquals("DELETE", method.get());
+            assertEquals("/api/popular_places/foodie-store-42", path.get());
+            assertEquals("", body.get());
+            assertThrows(SelfHostedMapException.class, () -> client.deletePlace("../../street_aliases"));
+        } finally {
+            server.stop(0);
+        }
+    }
+
+    @Test
+    void missingAdminTokenFailsBeforeNetworkAndDoesNotExposeCredentials() {
+        SelfHostedMapAdminClient client = new SelfHostedMapAdminClient();
+        ReflectionTestUtils.setField(client, "baseUrl", "http://127.0.0.1:1/api");
+        ReflectionTestUtils.setField(client, "adminToken", " ");
+        SelfHostedMapException error = assertThrows(SelfHostedMapException.class,
+                () -> client.deletePlace("foodie-store-42"));
+        assertEquals("ADMIN_NOT_CONFIGURED", error.getCode());
+    }
+
+    @Test
+    void errorResponseCannotLeakProviderBodyOrAdminToken() throws Exception {
+        HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+        server.createContext("/api/", exchange -> {
+            byte[] response = "admin-secret http://private-host/ secret-address".getBytes(StandardCharsets.UTF_8);
+            exchange.sendResponseHeaders(401, response.length);
+            exchange.getResponseBody().write(response);
+            exchange.close();
+        });
+        server.start();
+        try {
+            SelfHostedMapAdminClient client = new SelfHostedMapAdminClient();
+            ReflectionTestUtils.setField(client, "baseUrl", "http://127.0.0.1:" + server.getAddress().getPort() + "/api");
+            ReflectionTestUtils.setField(client, "adminToken", "admin-secret");
+            SelfHostedMapException error = assertThrows(SelfHostedMapException.class,
+                    () -> client.deletePlace("foodie-store-42"));
+            assertEquals("ADMIN_REQUEST_FAILED", error.getCode());
+            assertFalse(error.getMessage().contains("admin-secret"));
+            assertFalse(error.getMessage().contains("private-host"));
+            assertFalse(error.getMessage().contains("secret-address"));
+            assertNull(error.getCause());
+        } finally {
+            server.stop(0);
+        }
+    }
+}

+ 51 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/map/SelfHostedMapClientTest.java

@@ -0,0 +1,51 @@
+package com.ruoyi.app.map;
+
+import com.sun.net.httpserver.HttpServer;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+import static org.junit.jupiter.api.Assertions.*;
+
+class SelfHostedMapClientTest {
+    @Test
+    void proxyPrefixEncodedQueryAndReadCredentialRemainAtServerBoundary() throws Exception {
+        HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+        server.createContext("/api/search", exchange -> {
+            boolean valid = "Bearer fixture-read-token".equals(exchange.getRequestHeaders().getFirst("Authorization"))
+                    && "q=food%26limit%3D50".equals(exchange.getRequestURI().getRawQuery());
+            byte[] bytes = (valid ? "[{\"id\":\"place:1\"}]" : "[]").getBytes(StandardCharsets.UTF_8);
+            exchange.sendResponseHeaders(200, bytes.length);
+            try (var out = exchange.getResponseBody()) { out.write(bytes); }
+        });
+        server.createContext("/api/route/multi", exchange -> {
+            byte[] bytes = "{\"code\":\"UNAUTHORIZED\",\"error\":\"sensitive-provider-body\"}".getBytes(StandardCharsets.UTF_8);
+            exchange.sendResponseHeaders(401, bytes.length);
+            try (var out = exchange.getResponseBody()) { out.write(bytes); }
+        });
+        server.createContext("/api/route/matrix", exchange -> {
+            String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
+            boolean valid = "POST".equals(exchange.getRequestMethod())
+                    && "Bearer fixture-read-token".equals(exchange.getRequestHeaders().getFirst("Authorization"))
+                    && com.alibaba.fastjson2.JSON.parseObject(body).getJSONArray("origins").size() == 1;
+            byte[] bytes = ("{\"distances_meters\":[" + (valid ? 1200 : -1) + "]}").getBytes(StandardCharsets.UTF_8);
+            exchange.sendResponseHeaders(200, bytes.length);
+            try (var out = exchange.getResponseBody()) { out.write(bytes); }
+        });
+        server.start();
+        try {
+            SelfHostedMapClient client = new SelfHostedMapClient();
+            ReflectionTestUtils.setField(client, "baseUrl", "http://127.0.0.1:" + server.getAddress().getPort() + "/api/");
+            ReflectionTestUtils.setField(client, "readToken", "fixture-read-token");
+            assertEquals("place:1", client.getArray("/search", Map.of("q", "food&limit=50")).getJSONObject(0).getString("id"));
+            assertEquals(1200, client.post("/route/matrix", Map.of("origins", java.util.List.of(Map.of("lat", 25, "lng", 121))))
+                    .getJSONArray("distances_meters").getIntValue(0));
+            var failure = assertThrows(SelfHostedMapException.class, () -> client.getObject("/route/multi", Map.of()));
+            assertEquals("UNAUTHORIZED", failure.getCode());
+            assertFalse(failure.getMessage().contains("sensitive-provider-body"));
+            assertFalse(failure.getMessage().contains("fixture-read-token"));
+            assertThrows(SelfHostedMapException.class, () -> client.getObject("//other-host", Map.of()));
+        } finally { server.stop(0); }
+    }
+}

+ 50 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/FoodDeliveryLegacyFreightServiceTest.java

@@ -0,0 +1,50 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.order.dto.OrderCreateInput;
+import com.ruoyi.app.order.dto.OrderCreatItem;
+import com.ruoyi.system.domain.InfoAddress;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.mapper.InfoAddressMapper;
+import com.ruoyi.system.mapper.PosStoreMapper;
+import com.ruoyi.common.exception.ServiceException;
+import org.junit.jupiter.api.Test;
+import java.math.BigDecimal;
+import java.util.List;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class FoodDeliveryLegacyFreightServiceTest {
+    @Test
+    void legacySnapshotUsesServerDistanceAndChangedFeeRejectsWithoutChangingPaymentAmount() {
+        var quotes = mock(FoodDeliveryPromotionQuoteService.class);
+        var stores = mock(PosStoreMapper.class);
+        var addresses = mock(InfoAddressMapper.class);
+        var service = new FoodDeliveryLegacyFreightService(quotes, stores, addresses);
+        InfoAddress address = new InfoAddress(); address.setUserId(7L);
+        when(addresses.selectById(8L)).thenReturn(address);
+        when(stores.selectById(9L)).thenReturn(new PosStore());
+        when(quotes.quoteFreightRoutes(anyList(), eq(address))).thenReturn(List.of(
+                new FoodDeliveryPromotionQuoteService.FoodRouteQuote(new BigDecimal("3500"), new BigDecimal("40"))));
+        OrderCreateInput input = new OrderCreateInput(); input.setType(0L); input.setShdzId(8L);
+        input.setActualPayAmount(100L);
+        OrderCreatItem item = new OrderCreatItem(); item.setType(0L); item.setMdId(9L); item.setFreight(40D); item.setJvli(99D);
+        input.setItems(List.of(item));
+        service.verifyAndFill(input, 7L);
+        assertEquals(3.5D, item.getJvli());
+        assertEquals(100L, input.getActualPayAmount());
+        item.setFreight(39D);
+        // 单元测试无Spring容器;只替换国际化查询,保留业务校验真实执行。
+        try (var messages = mockStatic(com.ruoyi.common.utils.MessageUtils.class, call -> call.getArgument(0))) {
+            assertThrows(ServiceException.class, () -> service.verifyAndFill(input, 7L));
+            assertEquals(100L, input.getActualPayAmount());
+            address.setUserId(10L);
+            assertThrows(ServiceException.class, () -> service.verifyAndFill(input, 7L));
+        }
+        input.setType(1L);
+        try (var messages = mockStatic(com.ruoyi.common.utils.MessageUtils.class, call -> call.getArgument(0))) {
+            assertThrows(ServiceException.class, () -> service.verifyAndFill(input, 7L)); // 父自取/子外送不得绕过。
+        }
+        item.setType(1L);
+        service.verifyAndFill(input, 7L); // 真正自取不新增地图依赖。
+    }
+}

+ 2 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/FoodDeliveryPromotionQuoteServiceTest.java

@@ -295,6 +295,8 @@ class FoodDeliveryPromotionQuoteServiceTest
         PromotionCalcResponse response = service.quote(request(), USER_ID);
         assertEquals(new BigDecimal("30"), response.getDeliveryFee());
         assertEquals(new BigDecimal("150"), response.getPayableAmount());
+        assertEquals(new BigDecimal("3500"), response.getRouteDistanceMeters());
+        assertEquals("ROUTE", response.getDistanceSource());
         verify(routeMatrix).distancesToDestination(List.of(new GeoPoint(new BigDecimal("25.0330"),
                         new BigDecimal("121.5654"))),
                 new GeoPoint(new BigDecimal("25.0400"), new BigDecimal("121.5700")));

+ 5 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderPromotionHelperFreeDeliveryTest.java

@@ -122,10 +122,14 @@ class OrderPromotionHelperFreeDeliveryTest
     void applyVersion2_shouldPersistAmountsAndSnapshotColumns()
     {
         PosOrder order = new PosOrder();
-        helper.applyVersion2Pricing(order, quote());
+        PromotionCalcResponse roadQuote = quote();
+        roadQuote.setRouteDistanceMeters(new BigDecimal("3456"));
+        order.setJvli(99D);
+        helper.applyVersion2Pricing(order, roadQuote);
 
         assertEquals(Integer.valueOf(100), order.getAmount());
         assertEquals(Double.valueOf(30.0), order.getFreight());
+        assertEquals(Double.valueOf(3.456), order.getJvli());
         assertEquals(Integer.valueOf(100), order.getFoodAmount());
         assertEquals(Integer.valueOf(2), order.getPromotionPricingVersion());
         assertEquals(0, new BigDecimal("30").compareTo(order.getMerchantDeliverySubsidy()));

+ 29 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/route/DistanceSourceRouteMatrixProviderTest.java

@@ -0,0 +1,29 @@
+package com.ruoyi.app.order.route;
+
+import com.ruoyi.app.flashdelivery.route.GeoPoint;
+import com.ruoyi.app.map.MapSourceSelector;
+import org.junit.jupiter.api.Test;
+import java.math.BigDecimal;
+import java.util.List;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class DistanceSourceRouteMatrixProviderTest {
+    @Test
+    void liveSwitchAndRollbackSelectOnlyTheRequestedProviderAndDoNotFallbackOnFailure() {
+        var google = mock(GoogleRouteDistanceMatrixProvider.class);
+        var local = mock(SelfHostedRouteDistanceMatrixProvider.class);
+        var selector = mock(MapSourceSelector.class);
+        var provider = new DistanceSourceRouteMatrixProvider(google, local, selector);
+        var point = new GeoPoint(BigDecimal.valueOf(25), BigDecimal.valueOf(121));
+        when(selector.selfHostedEnabled()).thenReturn(false, true, false, true);
+        when(google.distancesToDestination(any(), any())).thenReturn(List.of(BigDecimal.valueOf(1000)));
+        when(local.distancesToDestination(any(), any())).thenReturn(List.of(BigDecimal.valueOf(1400)));
+        assertEquals(List.of(BigDecimal.valueOf(1000)), provider.distancesToDestination(List.of(point), point));
+        assertEquals(List.of(BigDecimal.valueOf(1400)), provider.distancesToDestination(List.of(point), point));
+        assertEquals(List.of(BigDecimal.valueOf(1000)), provider.distancesToDestination(List.of(point), point));
+        when(local.distancesToDestination(any(), any())).thenThrow(new IllegalStateException("unavailable"));
+        assertThrows(IllegalStateException.class, () -> provider.distancesToDestination(List.of(point), point));
+        verify(google, times(2)).distancesToDestination(any(), any());
+    }
+}

+ 36 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/route/SelfHostedRouteDistanceMatrixProviderTest.java

@@ -0,0 +1,36 @@
+package com.ruoyi.app.order.route;
+
+import com.alibaba.fastjson2.JSON;
+import com.ruoyi.app.flashdelivery.route.GeoPoint;
+import com.ruoyi.app.map.SelfHostedMapClient;
+import org.junit.jupiter.api.Test;
+import java.math.BigDecimal;
+import java.util.List;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class SelfHostedRouteDistanceMatrixProviderTest {
+    @Test
+    void independentOriginsAndDuplicatesRemainInStoreOrderAndAnyBadElementRejectsWholeQuote() {
+        SelfHostedMapClient client = mock(SelfHostedMapClient.class);
+        SelfHostedRouteDistanceMatrixProvider provider = new SelfHostedRouteDistanceMatrixProvider(client);
+        List<GeoPoint> starts = List.of(point("25", "121"), point("25", "121"));
+        when(client.post(eq("/route/matrix"), any())).thenAnswer(call -> {
+            var request = JSON.parseObject(JSON.toJSONString(call.getArgument(1)));
+            assertEquals("car", request.getString("profile"));
+            assertEquals(2, request.getJSONArray("origins").size());
+            assertEquals(new BigDecimal("25"), request.getJSONArray("origins").getJSONObject(0).getBigDecimal("lat"));
+            assertEquals(new BigDecimal("121.1"), request.getJSONObject("destination").getBigDecimal("lng"));
+            return JSON.parseObject("{\"distances_meters\":[1200,1300],\"profile\":\"car\"}");
+        });
+        assertEquals(List.of(new BigDecimal("1200"), new BigDecimal("1300")),
+                provider.distancesToDestination(starts, point("25.1", "121.1")));
+        for (String response : List.of("{}", "{\"distances_meters\":[1]}", "{\"distances_meters\":[1200,null]}",
+                "{\"distances_meters\":[1200,0]}", "{\"distances_meters\":[1200,1.5]}",
+                "{\"distances_meters\":[1200,\"1300\"]}")) {
+            when(client.post(eq("/route/matrix"), any())).thenReturn(JSON.parseObject(response));
+            assertThrows(IllegalStateException.class, () -> provider.distancesToDestination(starts, point("25.1", "121.1")));
+        }
+    }
+    private static GeoPoint point(String lat, String lng) { return new GeoPoint(new BigDecimal(lat), new BigDecimal(lng)); }
+}

+ 6 - 0
ruoyi-system/src/main/java/com/ruoyi/system/dto/PromotionCalcResponse.java

@@ -92,6 +92,12 @@ public class PromotionCalcResponse {
     /** 客户整单应付 A = finalAmount(G) + D */
     private BigDecimal payableAmount;
 
+    /** 完整外送报价的服务端道路米数;自取/堂食和旧商品算价不返回。 */
+    private BigDecimal routeDistanceMeters;
+
+    /** ROUTE 表示道路路线;不将 GOOGLE/SELF_HOSTED 与道路/直线业务语义混用。 */
+    private String distanceSource;
+
     /** 命中的满额免运活动ID(未命中为null) */
     private Long freeDeliveryActivityId;
 

+ 51 - 0
specs/032-self-hosted-map-integration/frontend-app-integration.md

@@ -0,0 +1,51 @@
+# App 地图与计价对接契约(2026-10-23)
+
+本轮后端保留 Google 与自部署两套能力,共用 `sys_map_distance_source`。旧 Google 页面与 Key 接口继续可用;App 选址 UI 替换由前端单独实施,本仓库不修改 App 源码。自部署服务地址与读令牌仅在服务端使用。
+
+## 地址接口
+
+以下均为 GET,必须携带登录 `token` 请求头,返回既有 `AjaxResult`,结果放在 `data`。不要把自部署服务地址、令牌或 Google Key 传给这些接口。
+
+| 接口 | 查询参数 | data |
+| --- | --- | --- |
+| `/system/map/search` | `q`:1~200 字关键词 | 候选数组,最多 20 项 |
+| `/system/map/nearby` | `latitude`、`longitude`;`radius` 默认 300 米,范围 10~2000;`limit` 默认 10,范围 1~50 | 候选数组(Google 最多返回其第一页 20 项) |
+| `/system/map/reverse` | `latitude`、`longitude` | 地图点选位置 |
+| `/system/map/place` | `id`:前述候选的完整 ID | 地点详情 |
+
+经纬度采用 WGS84 十进制度,纬度 -90~90、经度 -180~180。参数校验错误与上游不可用使用国际化业务错误;空搜索结果返回空数组。选择自部署后上游故障不会暗中调用 Google。
+
+每个地点统一包含:
+
+```json
+{
+  "id": "self_hosted:osm:123",
+  "name": "台北車站",
+  "address": "臺北市中正區",
+  "latitude": 25.0478,
+  "longitude": 121.517,
+  "source": "SELF_HOSTED",
+  "precision": "poi",
+  "requiresConfirmation": true
+}
+```
+
+`source` 为 `GOOGLE` 或 `SELF_HOSTED`。`id` 含 `google:` / `self_hosted:` 前缀,前端必须原样传回;详情按 ID 来源解析,因此配置切换后已有候选仍可查到原来源详情。不能把旧页面原始 Google ID 或地图原始 ID 直接传入;需要先使用新搜索接口获得完整 ID。
+
+`distanceMeters`(附近候选距离)、`country`、`province`、`city`、`district` 在来源提供时返回;不存在时不要拼成“null”地址。`precision` 保留自部署精度;Google 的门牌点为 `address`,地点为 `poi`,其它结果标明其精度。`requiresConfirmation=true` 时让用户核对地址、入口、楼层或门牌,不可把近似结果显示为精确门牌。
+
+逆查永远返回请求的原始点选经纬度,即使附近候选在其它坐标。前端保存和画点均使用返回的 `latitude` / `longitude`,不要再次用候选详情覆盖;无候选时仍返回坐标并要求确认。逆查结果的 `pin` ID 只是本次点选标识,不用于地点详情;地点详情只用于搜索和附近候选。新地址接口不保存地址簿,选定后继续使用现有地址簿接口。
+
+## 运费与路线
+
+现有运费响应字段沿用当前接口;前端不要因地图来源切换改名、删除金额字段,或把地图路线距离直接当作费用。普通外卖、多店外卖、自配送运费与闪送仍按平台既有规则由服务端计价。
+
+完整普通外卖报价`POST /app/userPromotionCalc/deliveryQuote`额外返回`routeDistanceMeters`(道路米数)和`distanceSource=ROUTE`;金额字段不改。订单`jvli`仍为公里,由后端保存,不以客户端提供的距离作为收费依据。闪送继续使用原有`distanceMeters / estimatedDurationSeconds / distanceSource`;时长缺失时按车型估算,`distanceSource`只标明距离来源。
+
+旧结算链路兼容:`GET /utils/getluxianjvli?latlng1=收货点纬度,经度&latlng2=门店纬度,经度`仍读取`data[0].legs[0].distance.value`(米),内部按门店→收货点计价;可继续把该米数/1000传给旧`getZuiyyfei`。Google和自部署均保留这个参数约定。旧`getyunfei`则传门店坐标为lat1/lng1、收货点为lat2/lng2,返回jvli(公里)、freight/yunf(最终运费),已改为道路矩阵。新接入优先完整报价,减少旧兼容接口的额外请求。
+
+旧路线响应保留绘图所需的overview_polyline,自部署时额外提供GeoJSON geometry,坐标为[经度,纬度]。这不等同于提供逐转向导航指令;App地图画面与导航应用调用由前端另行接入。
+
+报价与下单使用同一经纬度和服务端金额。金额不一致时按现有错误提示重新报价;不能在 App 静默覆盖后直接提交。自取、堂食不请求配送路线。地图行驶时长是参考值,前端不要将其直接显示为承诺送达时间。
+
+Google 回滚保留现有字典、Key 和旧有效接口。切换配置不会改写历史订单金额或支付快照。真实设备选址、四语言错误展示、报价→下单金额一致性及 Google/自部署切换联调仍需人工验收。

+ 55 - 0
specs/032-self-hosted-map-integration/merchant-map-sync.md

@@ -0,0 +1,55 @@
+# 门店人工同步到自部署地图
+
+日期:2026-10-23。此接口仅供平台管理员显式操作,不随门店编辑、营业状态变化、报价、订单或定时任务自动调用。实现过程中没有调用真实地图写接口,没有修改业务数据库。
+
+## 管理接口
+
+`POST /system/map/merchants/sync`,使用平台管理端现有登录凭据与 `chanting:store:edit` 权限。接口没有 App 的匿名放行或 App 登录注解,普通 App 登录凭据不能代替管理端授权。请求为 JSON DTO:
+
+```json
+{"storeIds":[12,34],"removeStoreIds":[56]}
+```
+
+两组 ID 合计 1~100 个,必须为正整数,不允许重复或交叉。`storeIds` 仅查询明确指定的 `PosStore`,不扫描全表;从数据库读取门店名称、地址、WGS84 经纬度,不接受调用方覆盖数据。缺失/已删除门店、下架门店或无效名称/坐标逐条报告失败,其余有效门店继续。`state=1` 表示休息/打烊,不作为永久停用或自动删除依据。请求不更新业务库,不改变门店营业状态。
+
+有效门店使用稳定外部标识 `foodie-store-{门店ID}`。改名、迁址或重试均更新同一人工地点;普通门店和摊位都按各自门店 ID 处理。名称最多 200 字符、地址最多 500 字符;经纬度须齐全且在全球合法范围。实际部署区域由地图 API 继续校验,越界行进入失败列表。
+
+`removeStoreIds` 为管理员明确选择的移除清单,以相同外部标识删除。允许本地已物理删除/软删除或已不存在的 ID,用于清理地图遗留源记录;不会因为查不到业务门店而拒绝清理。只移除人工地点来源,不删除 OSM 同名地点。删除已不存在记录视为成功,支持幂等重试。
+
+响应 `AjaxResult.data` 包含:
+
+```json
+{
+  "rows":[
+    {"storeId":12,"action":"UPSERT","success":true,"code":"OK","message":null},
+    {"storeId":34,"action":"UPSERT","success":false,"code":"INVALID_STORE","message":"本地化错误信息"},
+    {"storeId":56,"action":"REMOVE","success":true,"code":"OK","message":null}
+  ],
+  "indexRebuildRequired":true
+}
+```
+
+`STORE_NOT_FOUND` 为业务门店不存在;`INVALID_STORE` 为本地字段不合法、已删除/下架或地图拒绝的行;`MAP_UNAVAILABLE` 为服务/鉴权/网络故障或返回结构不可信。错误消息使用四语言国际化,不回传上游正文、地址、坐标、URL 或凭据。请求整体格式错误则按业务异常返回,不进行数据库查询或外部写入。
+
+## 地图侧协议与凭据
+
+配置 `map.self-hosted.base-url`、`map.self-hosted.admin-token`(环境变量 `MAP_SELF_HOSTED_ADMIN_TOKEN`)。后端管理令牌必须与地图服务器 `MAP_ADMIN_TOKEN` 一致,与普通读接口令牌分别配置;不发送到 App、页面或日志。经地图 Nginx 的 base URL 含 `/api`,直连 Flask 容器时不含 `/api`。管理客户端只允许两个固定端点,外部 ID 使用 URI path segment 编码:
+
+- `POST /popular_places/import`,`{"places":[{"external_id":"foodie-store-12","name":"...","address":"...","lat":25.03,"lng":121.56,"type":"business"}]}`。按地图零基拒绝行号映射回原门店;只接受完整可信的成功/拒绝统计。
+- `DELETE /popular_places/foodie-store-56`,成功响应 `{"status":"removed","removed":0或1,"index_rebuild_required":true}`。
+
+两者都携带管理员 Bearer 令牌,禁用自动重试及跳转;缺令牌时请求失败,不进行匿名写入。调用独立于当前 Google/自部署业务开关,管理员可在放量前准备自部署地点。
+
+## 索引发布与重试
+
+同步只写地图人工地点源表,不自动重建搜索索引。跨行、批量导入与各删除请求没有整体事务;前面的行可能已成功。超时或响应异常时也可能已写入,因此 `indexRebuildRequired` 在尝试任何外部写入后保守返回 true,不能把它解释为所有行成功。
+
+管理员核对逐行结果并以相同门店 ID 修正/重试后,由开发者在地图项目指定配置执行:
+
+```bash
+python3 scripts/deploy.py index --env-file <地图环境配置>
+```
+
+此命令使用地图 `build_index.py` 构建并发布搜索索引。只有索引发布成功后,新增/改名/迁址/移除才在搜索生效;删除重试返回 `removed:0` 时也需要完成本批索引发布。不要对 foodie 数据库执行该命令,也不要在同步请求内触发全量重建。存量地图库迁移仍由开发者手动执行地图仓库已有 SQL 登记。
+
+本地验证覆盖稳定 ID 更新、营业休息状态、无效行/缺失门店、地图拒绝行号、显式不存在门店的幂等删除、输入限制、模糊写入失败、固定管理路径与 Bearer 凭据,以及方法权限拒绝。真实业务库读取、PostGIS 写入、索引重建、服务器凭据与 App 联调均未执行。

+ 39 - 1
specs/032-self-hosted-map-integration/plan.md

@@ -1,4 +1,42 @@
-# 地图机车模型四项评估计划
+# 032 计划(历史评估与双地图实施)
+
+## 2026-10-23 实施计划(当前)
+
+**Goal:** 普通/多店外卖、自配送运费和闪送双地图可切换回滚。
+**Architecture:** 统一选择器与自部署 HTTP 客户端;外卖矩阵和闪送有序路线保持独立,Google 原实现保留。新选址通过后端聚合。
+**Tech Stack:** JDK21 / Spring Boot / HttpClient4 / fastjson2;Flask / OSRM。
+**Spec:** [spec.md](spec.md) 当日实施契约覆盖旧评估计划及历史假设。
+
+### 约束
+
+后端沿用当前新分支,地图沿用 master;不另建 worktree。按已确认方案继续实施,无需重复确认。App 代码不改,交付接入文档。不自动提交/推送/部署/执行 SQL。外卖车型默认 car 保持现有口径;闪送按用户车型。外卖失败拒绝,闪送直线估时降级;历史订单不重算,改价提示重报。
+
+### Review Focus
+
+切回 Google 后三类订单均恢复;矩阵顺序/重复/部分失败;车型与鉴权正确;旧订单与v2的距离/运费快照一致;地址反查不替换点选坐标。
+
+### 实施任务(每批先补失败测试,再实现,再运行相关回归)
+
+1. 共同配置与算路:新增 `app/map/MapSourceSelector`、`SelfHostedMapClient`,修改闪送两个提供器;新增外卖 SelfHosted/DistanceSource 矩阵提供器。客户端接口 `getObject(String, Map<String, ?>)`、`getArray(String, Map<String, ?>)`、`post(String, Object)`;统一超时/鉴权,严格解析距离,显式传 profile。
+2. 计价与旧接口:修改 FoodDeliveryPromotionQuoteService/UserOrderController/OrderPromotionHelper/PromotionCalcResponse;报价返回道路米数,普通单保存真实 jvli,旧单金额差异拒绝;旧路线接口转换 legs/distance/duration/overview_polyline。
+3. 选址:新增 app/map/MapLocationController、MapLocationService、DTO 与测试;两来源搜索/详情/反查/附近返回统一字段,显式 token/参数、国际化错误;编写 frontend-app-integration.md。
+4. 地图配套和商家同步:地图侧补启用读保护缺令牌启动前拒绝;后端手动平台同步稳定门店ID/停用,提供索引发布步骤,不引入默认外部写入任务。
+5. 收口:JDK21 定向与模块构建、Python/页面测试、diff/凭据检查,更新文档;真实部署/地址体检/路段/性能/App联调分别记录未执行,不能勾完成。
+
+### 历史评估计划(保留备查)
+
+### 2026-10-26 执行记录与实现决策
+
+最终本地验证:完整reactor测试1484项(admin1314/system168/framework2)0失败/错误/跳过,package成功;地图64项及页面测试通过。I001–I008本地交付,V001–V003保留真实部署验收。无提交/推送/迁移/部署。
+
+- 按既有新分支原地实施,没有创建额外worktree;地图既有master脏修改保留。未自动提交、推送、部署或执行迁移。
+- 外卖/闪送及新地址统一选择器;外卖独立矩阵,闪送有序路线,服务端令牌/超时统一。Google代码保留。
+- 普通旧请求核对道路运费并保存jvli;父子type不一致直接拒绝,防止子外送绕过。v2持久化同源公里数。
+- 旧getyunfei改为道路矩阵,保留响应字段。旧getluxianjvli按当前唯一结算调用方的“收货点在前、门店在后”参数转为配送方向;绘图保留,计价米数用同源矩阵,避免Directions与矩阵选路差异。这一兼容接口暂多一次请求,新完整报价不增加。
+- 新地址使用命名空间ID,切换后详情仍按ID来源;逆查保留点选坐标和近似精度。旧Google地址端点/画面保留,App未改。
+- 商家同步由有权限管理员明确触发,稳定门店ID;普通营业休息不作为删除依据,索引发布须独立手动完成。
+- 复核发现的三个Important均补回归:父子类型绕过、直线报价与道路校验不一致、旧结算坐标反向。测试中缺Spring国际化容器按现有静态测试方式隔离,不改变业务错误返回。
+- 发布验收范围固定为真实OSRM/PostGIS、存量地址、机车法规、性能和前端联调;本地构建通过不代表这些已经完成。
 
 > 执行方式:在当前会话直接完成用户已确认的四项评估;不创建分支,不交付生产实现。本文件是研究与隔离试验计划,不是地图全量切换实施计划。
 

+ 31 - 0
specs/032-self-hosted-map-integration/release-runbook.md

@@ -0,0 +1,31 @@
+# 双地图发布、体检和回滚
+
+更新:2026-10-26。本轮仅本地代码/测试,不执行SQL、部署、真实商家同步或流量切换。
+
+## 后端配置
+
+- Google:继续配置既有 `sys_googlemap_key`,保证当前使用的Routes、矩阵和地址接口可用;新配置不写死密钥、不在日志输出。
+- 自部署:`MAP_SELF_HOSTED_BASE_URL` 经地图Nginx应含`/api`,直连Flask用实际服务根地址;读令牌`MAP_SELF_HOSTED_READ_TOKEN`对应地图`MAP_READ_TOKEN`,管理令牌`MAP_SELF_HOSTED_ADMIN_TOKEN`对应地图`MAP_ADMIN_TOKEN`。全部保留在服务端。
+- 外卖:默认自部署`MAP_FOOD_DELIVERY_PROFILE=car`,保留现有Google DRIVE距离口径;改motorcycle需另验实际价格及Google回滚后的差异。闪送按用户车型1/2选择,不受此默认项影响。
+- 默认连接2.5秒、读取18秒。地图矩阵整批约16秒预算,Nginx读取20秒;还须检查App网关总超时、实际门店数和并发,不以这些配置代替性能验收。
+- 本次后端只新增响应字段,不新增业务表/列,无新增后端DDL。地图仓库存量SQL按其updatesql/sql.md由开发者手工执行。
+
+## 发布顺序与验收
+
+1. 地图保持一个API、两个车型OSRM引擎;核对数据集隔离、模型/路网文件,按[地图发布检查清单](../../../Foodie_HCM_Map/docs/2026-10-23-release-checklist.md)发布,分别验证两车型真实算路。不能只检查健康接口200。
+2. 后端先保留GOOGLE发布。验证普通/v2/多店、自配送运费及闪送两点/多站点报价与建单;自取/堂食不新增地图依赖。支付方式、优惠/满额免运、骑手容量/接单范围、预约与履约状态继续原规则。
+3. 显式同步门店并按地图CLI重建搜索索引;详情见[手动同步接口](merchant-map-sync.md)。更名、搬迁、停用分别核对稳定ID,不能把营业休息等同永久删除。
+4. 导出仅含稳定ID/WGS84坐标的存量门店/收货点样本,使用地图audit_routes.py分别体检两车型。网络/鉴权/5xx属于服务故障,不能计入坏地址。失败清单由业务确认修正和放量条件。
+5. 隔离/测试环境将`sys_map_distance_source`设置为唯一启用默认SELF_HOSTED,验证完整矩阵顺序、重复点、最大站点数、无路/吸附/越界/超时/令牌错误。外卖失败拒绝报价;闪送服务故障降级直线并估时,越界拒绝。不自动跨来源重试。
+6. 前端按[接入文档](frontend-app-integration.md)改新选址及地图画面。旧App直连Google及旧地址接口兼容保留,只有算路来源切换不代表所有Google请求消失。
+7. 真实并发、P95/P99、车型法规、门牌/入口精度和App真机联调单独验收。前述本地单测不能代替真实OSRM/PostGIS或上线批准。
+
+## 回滚
+
+在平台字典管理将SELF_HOSTED默认改为N、GOOGLE改为唯一Y,保存后刷新缓存(后台保存通常已更新)。配置缺失、无默认、多默认或未知值均选GOOGLE。正式放量前保留Google有效配置并演练一次切回。
+
+新报价、新下单和新选址请求恢复Google;已创建订单保留原距离、运费、促销和付款快照。报价与建单之间切换造成金额/批次指纹变化,按现有错误要求重新报价,不能静默改客户应付。店铺源表或地图DDL回滚按相应备份单独处理,改字典不恢复这些数据。
+
+## 运维观察
+
+日志关注提供方、接口、车型、耗时和结构化错误;不要采集完整地址、坐标、令牌、Key或原始上游响应。分别统计路线失败、闪送直线降级、外卖报价失败、新地址失败及金额变化。地图服务中断不影响数据库直线围栏、附近排序或已有订单状态操作。

+ 38 - 23
specs/032-self-hosted-map-integration/spec.md

@@ -1,10 +1,10 @@
 # Feature Specification: 自部署地图集成(台湾外卖/闪送)
 
-**Feature Branch**: N/A(按用户要求本次不建分支,文档随当前分支走)
+**Feature Branch**: `codex/self-hosted-map-integration`(2026-10-23 用户要求仅后端新建分支;地图继续 master)
 
 **Created**: 2026-09-24
 
-**Status**: Draft(2026-09-29 更新:US1 距离来源开关已实现——`DistanceSourceRouteProvider`(@Primary,字典 `sys_map_distance_source` 缺省 GOOGLE、SELF_HOSTED 切自部署)+ `SelfHostedRouteDistanceProvider`(单次请求地图 `/route/multi`,多站点按序途经整线计距,`map.self-hosted.base-url` 默认 `http://127.0.0.1:8080`)+ 越界 OUTSIDE_REGION 不降级(`flash.delivery.map.outside.region` ×5 语言);145 项闪送相关单测通过。**US4 地图侧多站点扩展同日落地**:Foodie_HCM_Map `routing.py` 泛化 `calculate_route_multi` + 新端点 `/route/multi`(coords 2~30 站,逐站吸附校验),2026-09-30 地图测试 20/20,FR-006 跨仓库依赖解除。plan/tasks 未生成,US2/US3/US5/US6 未开工;存量地址体检(FR-010)未做)
+**Status**: 已验证(2026-10-26,仅本地代码:后端1484项测试及打包、地图64项与页面检查通过,审查问题已修复。未提交、未部署;真实地址/路段/性能及App联调待验收,见tasks V001–V003)。
 
 **Input**: User description: "自部署地图集成:台湾外卖/闪送切换到自部署地图服务(Foodie_HCM_Map,PostGIS 搜索 + OSRM 算路),外卖/闪送运费按平台设置计价(pos_freight / flash_delivery_pricing),所有地图功能逐步切换,已有完整差距分析结论。"
 
@@ -12,6 +12,21 @@
 
 **机车模型评估(2026-10-12)**:用户确认的四项评估已验证,见 [model-evaluation.md](model-evaluation.md)。评估用 plan/tasks 已补充,只覆盖本轮研究与隔离试验,不是 US2/US3/US5/US6 的实施计划。测试服务器 GET /route 和 GET /route/multi 已支持 profile=car/motorcycle;foodie 车型透传仍未实现。35 项常规断言通过,1 项限制探针确认条件禁行未执行;生产规则、路网和部署未改变,未宣称全境导航或性能验收完成。
 
+## 2026-10-23 实施契约(优先于下文历史假设)
+
+当前状态:实现中。用户确认普通外卖、多店外卖、自配送运费及闪送均支持 Google/自部署地图切换与回滚。原有“仅汽车模型”“停用 Google”“费用清零”“外卖失败直线兜底”“金额不一致静默覆盖”等历史约束被下述规则替代。
+
+- 共用 `sys_map_distance_source`;缺失/未知/多个默认均选 GOOGLE;禁止选择自部署后暗中调用 Google。保留现有 Google 密钥与有效接口,不自动作废密钥。
+- 外卖独立 N×1 矩阵 `/route/matrix`,每店→用户;闪送 `/route/multi` 按站点顺序整线计算,两者不能互换。
+- 闪送 `vehicleType=1/2` 映射 motorcycle/car;外卖默认 car 保持当前 DRIVE 运价口径,服务端可显式配置 motorcycle。
+- 普通单/v2/多店均由服务端核实运费,保存与计价同源的 jvli 公里;金额不一致按既有错误拒绝并提示重报。切换不改历史订单金额或支付快照。
+- 外卖路线失败拒绝报价,闪送故障仍直线+车型估时;越界不降级。有效车型路线时长为行驶参考,不代表接单等待+取送件后的承诺送达时刻,缺时长继续估算。
+- 保留促销、免运、自配送、预约、支付和履约状态;自取/堂食不请求路线。接单范围/围栏/附近排序保留既有直线计算。
+- 新地址搜索/详情/逆查/附近统一经 foodie 后端,Google 和自部署返回同一字段;保留点选坐标、标明精度,不以附近候选替换坐标。App 源码由前端修改,旧 Google 页面继续可用。
+- 自部署客户端统一地址前缀、读令牌、超时、错误码与非敏感日志;令牌不提供给 App。
+- 商家地点以稳定门店 ID 同步,支持更新/停用,索引发布单独可执行;地图 API 不计算订单价格。
+- 本轮原地完成可交付代码、测试与文档;不自动 commit/push、部署、执行数据库迁移或访问业务数据。正式体检、真实路段/法规/性能和前端联调保留手动验收。
+
 ## User Scenarios & Testing *(mandatory)*
 
 ### User Story 1 - 闪送报价距离切换到自部署地图(Priority: P1)
@@ -41,14 +56,14 @@
 **Acceptance Scenarios**:
 
 1. **Given** 用户下单且地址、门店坐标有效,**When** 订单创建,**Then** 订单运费 = 平台 pos_freight 当前时段规则 × 地图道路距离(保留现有跳表规则:超起步距离 <0.5km 不计、0.5~1km 按 1km)。
-2. **Given** 前端传入的运费与后端重算不一致,**When** 订单创建,**Then** 以后端计算为准落库并记录差异,不阻断下单。
-3. **Given** 地图服务失败,**When** 用户下单,**Then** 运费按直线距离兜底计算,订单仍可创建,距离来源落库。
+2. **Given** 前端运费与后端重算不同,**When** 创建外卖订单,**Then** 拒绝并提示重新报价,不能静默修改付款金额。
+3. **Given** 地图服务失败,**When** 外卖报价或建单,**Then** 返回明确错误,不返回0运费或直线替代价。
 
 ---
 
 ### User Story 3 - 选址、逆地理与地址搜索切自部署地图(Priority: P2)
 
-用户在 App 选择收货/取件地址时,地址关键词搜索、地图点选反查、附近候选列表来自自部署地图;App 不直连地图服务,统一经 foodie 后端聚合接口访问(地图服务读接口无鉴权,仅限内网调用)。
+用户在 App 选择收货/取件地址时,新接口统一经foodie后端按来源聚合,App不直连自部署地图或持有读令牌;旧Google页面和旧地址接口保留兼容。
 
 **Why this priority**: 选址是 App 侧高频功能,依赖 App 前端改造与联调,排在服务端能力切换之后;完成此切片后,用户侧不再产生 Google/百度地址类调用。
 
@@ -92,30 +107,30 @@
 
 ---
 
-### User Story 6 - 预计送达时间与旧外部地图服务退场(Priority: P3)
+### User Story 6 - 对应车型参考时长与双来源长期保留(Priority: P3)
 
-预计送达时间不直接使用地图的汽车时长,而按平台配置的车型/时段折算系数估算;切换稳定后,旧的 Google/Mapbox/百度地图调用端点停止对外服务,已泄漏在代码仓库中的第三方地图密钥全部作废。
+使用对应车型的路线行驶时长作为参考,缺失时按车型估算;不把行驶时长当承诺送达时刻。保留Google及有效配置以支持切回,不以全部下线Google或费用清零作为验收目标。
 
-**Why this priority**: 收尾性质——ETA 折算需要运营数据支撑系数,旧端点下线需等全部调用方迁移完成。
+**Why this priority**: 保留车型正确的参考时长和Google回滚路径,不让来源切换改变历史金额或伪造承诺送达时间。
 
-**Independent Test**: 修改平台折算系数后,订单预计送达时间按新系数变化;访问旧外部地图端点返回已停用提示;泄漏密钥在第三方控制台已失效。
+**Independent Test**: 两车型分别返回路线时长;时长缺失时仍有合理估算;切回Google后新报价正常,已有订单快照不变。
 
 **Acceptance Scenarios**:
 
-1. **Given** 平台配置了机车折算系数,**When** 产生闪送/外卖订单,**Then** 预计送达时间 = 地图汽车时长 × 系数。
-2. **Given** 全部调用方已迁移,**When** 访问旧地图端点,**Then** 返回已停用(410/明确提示),不再产生第三方调用费用。
+1. **Given** 用户选机车或轿车,**When** 闪送算路,**Then** 使用对应车型时长,缺失时估算,不把汽车时间直接当机车时间。
+2. **Given** 平台切回Google,**When** 新报价或新选址,**Then** 使用保留的Google路径;已有订单不被回写,旧有效接口不被自动停用。
 
 ---
 
 ### Edge Cases
 
-- 路线不可达(NO_ROUTE,如离岛/无路网区域):报价与下单按直线兜底并标记来源,不阻断交易。
+- 路线不可达(NO_ROUTE):闪送直线距离/车型估时降级;外卖拒绝报价,不返回免费运费或部分距离。
 - 坐标吸附超限(SNAP_TOO_FAR,默认 250 米):老地址库坐标可能大面积触发——切换前必须用存量地址批量试算体检,按结果决定调整吸附上限或引导重选地址。
 - 坐标越界(OUTSIDE_REGION):拦截并提示地址无效,不降级。
 - 多站点路线中,越界或吸附失败可定位到具体站点时,地图错误返回从 1 开始的 `point_index`;整条路线不可达(NO_ROUTE)不指定站点序号。
-- 地图服务整体宕机:闪送/外卖计价全部走直线兜底,选址接口明确报错;派单与附近骑手不受影响(数据库半径计算,不依赖地图)。
+- 地图服务整体宕机:闪送直线距离/车型估时降级;外卖报价拒绝,选址明确报错。数据库派单和附近骑手不受影响。
 - 多站点单在地图多点能力空窗期:维持 Google 或直线兜底,不坏单。
-- 外卖订单坐标取自门店坐标快照:门店坐标缺失/非法的商家,下单时按直线兜底并提示运营补录。
+- 外卖坐标取自有效门店和用户拥有的地址;坐标缺失/非法拒绝报价,不能伪造有效运费。
 
 ## Requirements *(mandatory)*
 
@@ -123,13 +138,13 @@
 
 - **FR-001**: 平台 MUST 提供距离来源开关(第三方路线服务 / 自部署地图),可运行时切换与回滚,不依赖发版。`sys_map_distance_source` 仅在启用项中恰好一个 `is_default=Y` 时采用其值;缺失、多默认或未知值回退 Google。后台修改会更新缓存,手工执行 SQL 后需刷新缓存或重启。
 - **FR-002**: 闪送报价距离 MUST 优先取自配置的路线来源,失败时按直线距离兜底,且每笔报价/订单 MUST 落库实际距离来源。
-- **FR-003**: 外卖运费 MUST 由后端在下单时按订单坐标 + 平台 pos_freight 规则重算并落库;前端传入运费仅作一致性比对,不一致时以后端为准并记录差异。
+- **FR-003**: 普通/v2/多店外卖 MUST 服务端重算道路运费并保存同源jvli;前端运费不一致时拒绝并提示重报,不能静默修改应付金额;自取/堂食不算路。
 - **FR-004**: 外卖运费报价接口 MUST 返回道路距离、费用与距离来源三项信息,供前端展示与后端校验共用。
-- **FR-005**: 地址搜索、逆地理、附近候选能力 MUST 经 foodie 后端聚合提供,App MUST NOT 直连地图服务(地图服务无读鉴权,仅限内网)。
-- **FR-006**: 地图服务 MUST 支持多站点顺序路线(跨仓库扩展项);能力上线前 foodie MUST 保证多站点单可用既有路径完成报价。
+- **FR-005**: 新选址搜索/反查/附近/详情 MUST 经foodie后端,App MUST NOT 直连自部署服务或持有读/管理令牌;旧Google选址保留兼容。
+- **FR-006**: 地图支持两点、有序多站路线及独立起点矩阵;闪送整线距离不得用作多店各店运费,外卖矩阵不得部分成功。
 - **FR-007**: 商家信息(名称/地址/坐标)MUST 可同步至地图搜索库,支持新增与更新。
-- **FR-008**: 预计送达时间 MUST 按平台可配置的车型折算系数计算,MUST NOT 直接使用地图汽车时长作为送达时间展示。
-- **FR-009**: 切换稳定后,旧第三方地图端点 MUST 停止服务并返回明确停用标识;代码仓库中已泄漏的第三方地图密钥 MUST 作废。
+- **FR-008**: 闪送 MUST 显式传用户车型,使用对应路线时长;缺时长按车型估算。行驶时长不含接单等待/取送件,不是承诺送达时间,不新增未经确认的倍率。
+- **FR-009**: MUST 保留Google及有效配置,支持运行时切回;已有订单不重新计价。历史硬编码凭据权限/轮换由运维处理,不在本轮作废全部回滚密钥。
 - **FR-010**: 切换前 MUST 提供存量收货地址的批量路线试算体检能力,输出吸附失败比例与清单供运营决策。
 - **FR-011**: 派单、附近骑手、门店距离排序 MUST 维持现有数据库半径计算,不依赖地图服务可用性。
 
@@ -147,7 +162,7 @@
 - **SC-001**: 切换自部署地图后,闪送报价成功率不低于切换前基线的 99%(兜底机制保证)。
 - **SC-002**: 外卖订单运费 100% 由后端计算落库,抽样核对与平台规则手算结果一致。
 - **SC-003**: 切换后选址搜索与路线计算的 95% 请求在 2 秒内返回。
-- **SC-004**: 除明确保留项(如临时 ETA)外,平台对第三方地图服务的调用量降为 0,月度地图 API 费用清零。
+- **SC-004**: Google、自部署、切回Google均通过普通/多店/闪送回归;旧客户端字段兼容、历史订单金额快照不变。
 - **SC-005**: 存量地址体检完成并输出吸附失败清单后,方可全量切换;全量切换后因地图错误导致的下单失败率 < 0.1%。
 
 ## Assumptions
@@ -155,8 +170,8 @@
 - 本 spec 覆盖 foodie 后端与运营配置;App 前端(uni-app,不在本仓库)的选址页、费用展示改造由前端团队按本 spec 接口对接。
 - `PosOrderController` 的通用管理接口 `POST /system/order`(`add(...)`)和 `PUT /system/order`(`edit(...)`)已废弃,不纳入本特性的订单创建、修改入口;它们与仍在使用的 `/system/order/addorder` 不同。
 - 地图服务(Foodie_HCM_Map)部署在与 foodie 服务器同内网或经私网打通;其多站点路线扩展、读接口防护属地图仓库工作,作为跨仓库依赖跟踪。
-- 地图仅提供 car 档道路距离,不提供机车档与实时交通;距离用于计价可信,时长仅作折算参考(见 FR-008)。
+- 地图提供car/motorcycle独立路网,无实时交通;外卖默认car保持现有DRIVE计价口径,改机车需另验价格。模型不代表完整台湾法规验收。
 - 现有运费规则载体(pos_freight、flash_delivery_pricing)与管理端 CRUD 继续沿用,本特性不改动计价规则本身。
 - 派单/附近骑手/门店排序维持数据库半径计算,不纳入切换范围。
-- 外卖前端运费与后端不一致时,默认以后端为准落库并记日志,不阻断下单;若运营要求阻断式校验,在实现阶段调整。
-- 泄漏的 Google/百度密钥作废动作需运维在第三方控制台执行,spec 只约束代码侧停止引用并留痕。
+- 外卖前端运费与后端不一致时拒绝并提示重新报价,不静默改支付金额。
+- 历史凭据权限核对与轮换由运维执行;保留Google有效配置,本轮不调用第三方控制台。

+ 27 - 1
specs/032-self-hosted-map-integration/tasks.md

@@ -1,4 +1,30 @@
-# 032 本轮机车模型评估任务
+# 032 任务(历史评估 + 双地图接入)
+
+## 2026-10-23 双地图接入(2026-10-26 本地已验证,待发布)
+
+- [x] I001 共用开关、自部署鉴权/超时、闪送车型和时长。
+- [x] I002 外卖自部署矩阵与 Google 可回滚选择器。
+- [x] I003 普通/v2/多店距离运费快照与旧接口兼容。
+- [x] I004 双来源选址接口与前端接入文档。
+- [x] I005 门店地点同步、停用清理与索引发布步骤。
+- [x] I006 地图读令牌启动校验与发布检查。
+- [x] I007 两来源/回滚/故障业务回归、JDK21构建和地图测试(本地夹具,不含真实外部服务)。
+- [x] I008 手动部署、数据更新、体检、监控与回滚文档。
+- [ ] V001 开发者执行 SQL(如有)、部署及真实双路网验收。
+- [ ] V002 存量真实地址体检、并发性能与实际回滚演练。
+- [ ] V003 前端接入及 App/平台/商家页面联调。
+
+V001–V003 不随本地测试勾选。不自动 commit/push;历史评估记录如下。
+
+2026-10-26 用户已追加明确要求提交并推送本轮代码,完成后后端切回test-202609v2、地图保持master。提交状态及SHA以Git日志为准;此操作不包含合并到v2或部署。
+
+### 2026-10-26 验证证据
+
+- JDK `C:/Users/99029/.jdks/graalvm-jdk-21.0.12+7.1`,`mvn -q -pl ruoyi-admin -am package` 退出0,产出ruoyi-admin.jar。受影响完整reactor测试:admin 1314、system 168、framework 2,合计 **1484**,失败/错误/跳过均0。
+- 地图 `.venv/Scripts/python.exe -m unittest discover -s tests -p 'test_*.py'`:**64项通过**;`node tests/test_frontend.mjs`通过;两仓库`git diff --check`通过(地图spec存在Git自动换行提示,无差异格式错误)。
+- 独立只读审查3项Important已修复并补回归:父子订单type绕过、旧直线报价与道路校验不一致、旧结算页反向算路。补了v2公里快照、缺失时长、HTTP前缀/令牌/POST/错误正文不泄露检查。
+- 没有执行外部地图调用、业务数据库访问/迁移、真实同步/索引发布、部署、真实并发/地址/法规/App验收。没有提交/推送,无交付提交SHA。
+- [发布手册](release-runbook.md)、[前端接入](frontend-app-integration.md)、[商家同步](merchant-map-sync.md)。无新增后端DDL;地图既有SQL仍由开发者手工执行。
 
 日期:2026-10-12。范围见 [plan.md](plan.md),不包含生产改造、部署或全量地图切换。