Prechádzať zdrojové kódy

feat(map): switch flash routes by dictionary default

qmj 2 týždňov pred
rodič
commit
a0ae4ecd87

+ 19 - 1
docs/map-api-usage.md

@@ -8,7 +8,7 @@
 |---|---|---|---|
 |---|---|---|---|
 | Google | Directions 路线 | `GetArea.getluxian` → `/utils/getluxianjvli` | 在用(key 硬编码,切换计划中作废) |
 | Google | Directions 路线 | `GetArea.getluxian` → `/utils/getluxianjvli` | 在用(key 硬编码,切换计划中作废) |
 | Google | Nearby/Geocoding 逆地理 | `GetArea.getgoogle` / `getgoogleadd` → `/utils/getLocationAddr`(type=1)、`/utils/getAddr` | 在用(key 硬编码) |
 | Google | Nearby/Geocoding 逆地理 | `GetArea.getgoogle` / `getgoogleadd` → `/utils/getLocationAddr`(type=1)、`/utils/getAddr` | 在用(key 硬编码) |
-| Google | Routes API v2 | `GoogleRoutesDistanceProvider`(闪送报价距离) | 在用(key 走字典 `sys_googlemap_key`) |
+| Google | Routes API v2 | `GoogleRoutesDistanceProvider`(闪送报价距离) | 在用(key 走字典 `sys_googlemap_key`;**可经距离来源开关切自部署,见下节**) |
 | Mapbox | Directions cycling | `GetArea.getmpluxian` → `/utils/getluxianjvliMp` | **已停用**(@Deprecated,端点保留) |
 | Mapbox | Directions cycling | `GetArea.getmpluxian` → `/utils/getluxianjvliMp` | **已停用**(@Deprecated,端点保留) |
 | Mapbox | Directions driving 耗时 | `GetArea.getMotorbikeDuration` | **已停用**(见下方存量调用点) |
 | Mapbox | Directions driving 耗时 | `GetArea.getMotorbikeDuration` | **已停用**(见下方存量调用点) |
 | 百度 | 逆地理 reverse_geocoding | `GetArea.getarearess` → `/utils/getLocationAddr`(type=0) | **已停用**(分支保留兜底) |
 | 百度 | 逆地理 reverse_geocoding | `GetArea.getarearess` → `/utils/getLocationAddr`(type=0) | **已停用**(分支保留兜底) |
@@ -26,8 +26,26 @@
 | 骑手派单 / 附近订单 | `PosOrderMapper.getqisjvli` / `getstate` | MySQL `st_distance` DB 半径 |
 | 骑手派单 / 附近订单 | `PosOrderMapper.getqisjvli` / `getstate` | MySQL `st_distance` DB 半径 |
 | 闪送报价兜底 | `FlashDeliveryRouteService` | Google 路线失败时直线距离 + `distanceSource=STRAIGHT_LINE` 落库 |
 | 闪送报价兜底 | `FlashDeliveryRouteService` | Google 路线失败时直线距离 + `distanceSource=STRAIGHT_LINE` 落库 |
 
 
+## 距离来源开关(032 US1,2026-09-29 实现)
+
+字典 `sys_map_distance_source`(平台后台 系统管理-字典管理):
+
+| 值 | 行为 |
+|---|---|
+| `GOOGLE`(默认) | 闪送路线距离走 Google Routes(现状) |
+| `SELF_HOSTED` | 闪送路线距离走自部署地图(Foodie_HCM_Map `GET /route/multi`) |
+
+仅在启用的字典项中,**恰好一条** `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)不降级**,报「地址超出可服务范围」。
+
+地图侧配套(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 配合)。
+
 ## 存量调用点警示
 ## 存量调用点警示
 
 
+
 - `PosOrderController.setYjsdTime`(state=4 订单的预计送达时间,调用方为改单状态取餐分支与测试端点 `/testYjsdTime`)**已停用**(2026-09-29 标记):方法不再使用,保留调用不影响状态流转;其依赖的 `getMotorbikeDuration`(Mapbox driving ×1.5)因此实际无在用调用。切自部署路线源(032)时可一并清理。
 - `PosOrderController.setYjsdTime`(state=4 订单的预计送达时间,调用方为改单状态取餐分支与测试端点 `/testYjsdTime`)**已停用**(2026-09-29 标记):方法不再使用,保留调用不影响状态流转;其依赖的 `getMotorbikeDuration`(Mapbox driving ×1.5)因此实际无在用调用。切自部署路线源(032)时可一并清理。
 - `/utils/getLocationAddr` 的 `type=0` 百度分支保留兜底;App 端应使用 `type=1` 谷歌分支。
 - `/utils/getLocationAddr` 的 `type=0` 百度分支保留兜底;App 端应使用 `type=1` 谷歌分支。
 - `GetJvli.getOptimalRoute`(Google Routes v2)全库无调用方,属死代码,不在本次标注范围。
 - `GetJvli.getOptimalRoute`(Google Routes v2)全库无调用方,属死代码,不在本次标注范围。

+ 69 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/DistanceSourceRouteProvider.java

@@ -0,0 +1,69 @@
+package com.ruoyi.app.flashdelivery.route;
+
+import com.ruoyi.common.core.domain.entity.SysDictData;
+import com.ruoyi.common.utils.DictUtils;
+import org.springframework.context.annotation.Primary;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 距离来源开关(032 FR-001):按字典 {@code sys_map_distance_source} 在 Google Routes 与
+ * 自部署地图间逐请求切换,改字典即生效,回滚不发版。仅一个启用项标记 is_default=Y
+ * 时读取其值;缺失、多选或未知值一律按 GOOGLE(默认不切换)。切换仅影响闪送路线距离来源,
+ * 直线兜底与距离来源落库逻辑不变(FR-002)。
+ */
+@Primary
+@Component
+public class DistanceSourceRouteProvider implements RouteDistanceProvider {
+
+    /** 自部署地图开关值(忽略大小写与首尾空白)。 */
+    static final String SELF_HOSTED = "SELF_HOSTED";
+
+    private final GoogleRoutesDistanceProvider googleProvider;
+    private final SelfHostedRouteDistanceProvider selfHostedProvider;
+
+    public DistanceSourceRouteProvider(GoogleRoutesDistanceProvider googleProvider,
+                                       SelfHostedRouteDistanceProvider selfHostedProvider) {
+        this.googleProvider = googleProvider;
+        this.selfHostedProvider = selfHostedProvider;
+    }
+
+    @Override
+    public RouteDistance route(BigDecimal originLatitude, BigDecimal originLongitude,
+                               BigDecimal destinationLatitude, BigDecimal destinationLongitude,
+                               Integer vehicleType) {
+        return delegate().route(originLatitude, originLongitude, destinationLatitude,
+                destinationLongitude, vehicleType);
+    }
+
+    @Override
+    public RouteDistance route(List<GeoPoint> points, Integer vehicleType) {
+        return delegate().route(points, vehicleType);
+    }
+
+    private RouteDistanceProvider delegate() {
+        return selfHostedEnabled() ? 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());
+    }
+
+    /** 闪送与外卖共用的距离来源开关字典类型。 */
+    static String dictType() {
+        return "sys_map_distance_source";
+    }
+}

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

@@ -1,5 +1,7 @@
 package com.ruoyi.app.flashdelivery.route;
 package com.ruoyi.app.flashdelivery.route;
 
 
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.system.domain.flash.FlashDeliveryVehicleType;
 import com.ruoyi.system.domain.flash.FlashDeliveryVehicleType;
 import org.slf4j.Logger;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.slf4j.LoggerFactory;
@@ -33,9 +35,12 @@ public class FlashDeliveryRouteService {
             RouteDistance route = provider.route(ordered, selectedVehicle);
             RouteDistance route = provider.route(ordered, selectedVehicle);
             if (route != null && route.distanceMeters() > 0
             if (route != null && route.distanceMeters() > 0
                     && (route.durationSeconds() == null || route.durationSeconds() >= 0)) return route;
                     && (route.durationSeconds() == null || route.durationSeconds() >= 0)) return route;
+        } catch (RouteOutsideRegionException exception) {
+            // 自部署地图判定坐标越界:按 spec 边界场景不降级计价,以国际化提示结束本次请求。
+            throw new ServiceException(MessageUtils.message("flash.delivery.map.outside.region"));
         } catch (RuntimeException exception) {
         } catch (RuntimeException exception) {
-            // 不记录地址、坐标或 Key;保留车型和失败原因以定位 Google Routes 降级。
-            log.warn("Google Routes unavailable; use straight-line fallback. vehicleType={}, reason={}",
+            // 不记录地址、坐标或 Key;保留车型和失败原因以定位路线来源降级。
+            log.warn("Route provider unavailable; use straight-line fallback. vehicleType={}, reason={}",
                     selectedVehicle, exception.getMessage());
                     selectedVehicle, exception.getMessage());
         }
         }
         double meters = 0;
         double meters = 0;

+ 8 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/RouteOutsideRegionException.java

@@ -0,0 +1,8 @@
+package com.ruoyi.app.flashdelivery.route;
+
+/** 自部署地图判定坐标越界(OUTSIDE_REGION);按 spec 边界场景此类失败不降级直线计价。 */
+public class RouteOutsideRegionException extends IllegalStateException {
+    public RouteOutsideRegionException(String message) {
+        super(message);
+    }
+}

+ 85 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/SelfHostedRouteDistanceProvider.java

@@ -0,0 +1,85 @@
+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 org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+/**
+ * 自部署地图(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();
+
+    @Value("${map.self-hosted.base-url:http://127.0.0.1:8080}")
+    private String baseUrl;
+
+    @Override
+    public RouteDistance route(BigDecimal originLatitude, BigDecimal originLongitude,
+                               BigDecimal destinationLatitude, BigDecimal destinationLongitude,
+                               Integer vehicleType) {
+        return route(List.of(new GeoPoint(originLatitude, originLongitude),
+                new GeoPoint(destinationLatitude, destinationLongitude)), vehicleType);
+    }
+
+    @Override
+    public RouteDistance route(List<GeoPoint> points, Integer vehicleType) {
+        FlashDeliveryRouteService.validatePoints(points);
+        return parseRoute(fetchRoute(points));
+    }
+
+    /** 请求完整多站点路线;非 2xx 按 code 区分:OUTSIDE_REGION 不降级,其余抛错交上层直线兜底。 */
+    JSONObject fetchRoute(List<GeoPoint> points) {
+        StringBuilder coords = new StringBuilder();
+        for (GeoPoint point : points) {
+            if (coords.length() > 0) coords.append(';');
+            // toPlainString 避免科学计数法把 '+' 带进 URL(查询串里 '+' 会被解码成空格)
+            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);
+        }
+    }
+
+    /** 解析 /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");
+        return new RouteDistance(meters, seconds == null || seconds < 0 ? null : seconds, "ROUTE");
+    }
+}

+ 4 - 1
ruoyi-admin/src/main/resources/application.yml

@@ -268,4 +268,7 @@ line-pay:
     auth-deadline-minutes: 30
     auth-deadline-minutes: 30
     unknown-deadline-hours: 24
     unknown-deadline-hours: 24
 
 
-
+# 自部署地图(Foodie_HCM_Map):字典 sys_map_distance_source 值为 SELF_HOSTED 时,闪送路线距离改走此服务
+map:
+  self-hosted:
+    base-url: ${MAP_SELF_HOSTED_BASE_URL:http://127.0.0.1:8080}

+ 106 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/route/DistanceSourceRouteProviderTest.java

@@ -0,0 +1,106 @@
+package com.ruoyi.app.flashdelivery.route;
+
+import com.ruoyi.common.core.domain.entity.SysDictData;
+import com.ruoyi.common.utils.DictUtils;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+/** 距离来源开关单测(032 FR-001):缺省走 Google,SELF_HOSTED 切自部署,逐请求生效。 */
+class DistanceSourceRouteProviderTest {
+
+    private final GoogleRoutesDistanceProvider google = mock(GoogleRoutesDistanceProvider.class);
+    private final SelfHostedRouteDistanceProvider selfHosted = mock(SelfHostedRouteDistanceProvider.class);
+
+    private DistanceSourceRouteProvider router(boolean enabled) {
+        return new DistanceSourceRouteProvider(google, selfHosted) {
+            @Override
+            boolean selfHostedEnabled() {
+                return enabled;
+            }
+        };
+    }
+
+    @Test
+    void defaultsToGoogleWhenSwitchOff() {
+        when(google.route(List.of(new GeoPoint(new java.math.BigDecimal("25"), new java.math.BigDecimal("121")),
+                new GeoPoint(new java.math.BigDecimal("25.1"), new java.math.BigDecimal("121.1"))), null))
+                .thenReturn(new RouteDistance(100, 60, "ROUTE"));
+        List<GeoPoint> points = List.of(new GeoPoint(new java.math.BigDecimal("25"), new java.math.BigDecimal("121")),
+                new GeoPoint(new java.math.BigDecimal("25.1"), new java.math.BigDecimal("121.1")));
+
+        assertEquals(new RouteDistance(100, 60, "ROUTE"), router(false).route(points, null));
+
+        verify(google).route(points, null);
+        verifyNoMoreInteractions(google, selfHosted);
+    }
+
+    @Test
+    void switchOnDelegatesToSelfHosted() {
+        List<GeoPoint> points = List.of(new GeoPoint(new java.math.BigDecimal("25"), new java.math.BigDecimal("121")),
+                new GeoPoint(new java.math.BigDecimal("25.1"), new java.math.BigDecimal("121.1")));
+        when(selfHosted.route(points, null)).thenReturn(new RouteDistance(200, 120, "ROUTE"));
+
+        assertEquals(new RouteDistance(200, 120, "ROUTE"), router(true).route(points, null));
+
+        verify(selfHosted).route(points, null);
+        verifyNoMoreInteractions(google, selfHosted);
+    }
+
+    @Test
+    void twoPointOverloadFollowsTheSameSwitch() {
+        java.math.BigDecimal lat = new java.math.BigDecimal("25");
+        java.math.BigDecimal lng = new java.math.BigDecimal("121");
+        when(selfHosted.route(lat, lng, new java.math.BigDecimal("25.1"), new java.math.BigDecimal("121.1"), 2))
+                .thenReturn(new RouteDistance(300, 180, "ROUTE"));
+
+        assertEquals(300, router(true).route(lat, lng, new java.math.BigDecimal("25.1"),
+                new java.math.BigDecimal("121.1"), 2).distanceMeters());
+
+        verify(selfHosted).route(lat, lng, new java.math.BigDecimal("25.1"), new java.math.BigDecimal("121.1"), 2);
+        verifyNoMoreInteractions(google);
+    }
+
+    @Test
+    void readsUniqueDefaultRatherThanFirstDictionaryRow() {
+        DistanceSourceRouteProvider provider = new DistanceSourceRouteProvider(google, selfHosted);
+        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")));
+
+            assertTrue(provider.selfHostedEnabled());
+        }
+    }
+
+    @Test
+    void missingOrAmbiguousDefaultFallsBackToGoogle() {
+        DistanceSourceRouteProvider provider = new DistanceSourceRouteProvider(google, selfHosted);
+        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")),
+                            List.of(row("SELF_HOSTED", "Y"), row("GOOGLE", "Y")), null);
+
+            assertFalse(provider.selfHostedEnabled());
+            assertFalse(provider.selfHostedEnabled());
+            assertFalse(provider.selfHostedEnabled());
+        }
+    }
+
+    private static SysDictData row(String value, String isDefault) {
+        SysDictData row = new SysDictData();
+        row.setDictValue(value);
+        row.setIsDefault(isDefault);
+        return row;
+    }
+}

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

@@ -1,5 +1,7 @@
 package com.ruoyi.app.flashdelivery.route;
 package com.ruoyi.app.flashdelivery.route;
 
 
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.Test;
 
 
 import java.math.BigDecimal;
 import java.math.BigDecimal;
@@ -10,6 +12,13 @@ import com.ruoyi.system.domain.flash.FlashDeliveryVehicleType;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.spring.SpringUtils;
+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.util.Locale;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 import static org.mockito.Mockito.when;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verify;
@@ -17,6 +26,24 @@ import static org.mockito.Mockito.verifyNoMoreInteractions;
 
 
 class FlashDeliveryRouteServiceTest {
 class FlashDeliveryRouteServiceTest {
 
 
+    private static ConfigurableListableBeanFactory originalBeanFactory;
+
+    @BeforeAll
+    static void initializeMessageSource() {
+        originalBeanFactory = (ConfigurableListableBeanFactory)
+                ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+        DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
+        StaticMessageSource messageSource = new StaticMessageSource();
+        messageSource.addMessage("flash.delivery.map.outside.region", Locale.getDefault(), "地址超出可服务范围");
+        beanFactory.registerSingleton("messageSource", messageSource);
+        new SpringUtils().postProcessBeanFactory(beanFactory);
+    }
+
+    @AfterAll
+    static void restoreBeanFactory() {
+        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
+    }
+
     @Test
     @Test
     void mapReceivesTheCompleteOrderedRouteInOneCall() {
     void mapReceivesTheCompleteOrderedRouteInOneCall() {
         List<GeoPoint> points = List.of(point("25", "121"), point("25.1", "121.1"),
         List<GeoPoint> points = List.of(point("25", "121"), point("25.1", "121.1"),
@@ -95,6 +122,18 @@ class FlashDeliveryRouteServiceTest {
         assertEquals("ROUTE", result.source());
         assertEquals("ROUTE", result.source());
     }
     }
 
 
+    @Test
+    void outsideRegionFailsInsteadOfStraightLineFallback() {
+        RouteDistanceProvider provider = (a, b, c, d, vehicleType) -> {
+            throw new RouteOutsideRegionException("outside");
+        };
+        FlashDeliveryRouteService service = new FlashDeliveryRouteService(provider);
+
+        ServiceException exception = assertThrows(ServiceException.class,
+                () -> service.calculate(point("25.0330", "121.5645"), point("25.0478", "121.5319")));
+        assertEquals("地址超出可服务范围", exception.getMessage());
+    }
+
     @Test
     @Test
     void fallsBackToStraightLineWhenProviderFails() {
     void fallsBackToStraightLineWhenProviderFails() {
         RouteDistanceProvider provider = (a, b, c, d, vehicleType) -> { throw new IllegalStateException("down"); };
         RouteDistanceProvider provider = (a, b, c, d, vehicleType) -> { throw new IllegalStateException("down"); };

+ 84 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/route/SelfHostedRouteDistanceProviderTest.java

@@ -0,0 +1,84 @@
+package com.ruoyi.app.flashdelivery.route;
+
+import com.alibaba.fastjson2.JSONObject;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/** 自部署地图路线 Provider 单测:响应解析、单次多站点请求保持用户顺序、越界不降级语义。 */
+class SelfHostedRouteDistanceProviderTest {
+
+    /** 捕获请求站点并返回固定响应,替代 HTTP。 */
+    static class StubProvider extends SelfHostedRouteDistanceProvider {
+        JSONObject response;
+        RuntimeException error;
+        List<GeoPoint> requestedPoints;
+
+        @Override
+        JSONObject fetchRoute(List<GeoPoint> points) {
+            requestedPoints = points;
+            if (error != null) throw error;
+            return response;
+        }
+    }
+
+    private static GeoPoint point(String lat, String lng) {
+        return new GeoPoint(new BigDecimal(lat), new BigDecimal(lng));
+    }
+
+    @Test
+    void parsesOsmDistanceAndDurationAsRouteSource() {
+        JSONObject response = new JSONObject();
+        response.put("distance_meters", 1234);
+        response.put("duration_seconds", 567);
+        StubProvider provider = new StubProvider();
+        provider.response = response;
+        assertEquals(new RouteDistance(1234, 567, "ROUTE"),
+                provider.route(List.of(point("25.0", "121.0"), point("25.1", "121.1")), null));
+    }
+
+    @Test
+    void invalidDistanceFailsInsteadOfFabricating() {
+        JSONObject response = new JSONObject();
+        response.put("distance_meters", 0);
+        assertThrows(IllegalStateException.class,
+                () -> new StubProvider().parseRoute(response));
+        assertThrows(IllegalStateException.class, () -> new StubProvider().parseRoute(null));
+    }
+
+    @Test
+    void multiStopGoesInOneRequestPreservingUserOrder() {
+        JSONObject response = new JSONObject();
+        response.put("distance_meters", 3500);
+        response.put("duration_seconds", 400);
+        List<GeoPoint> points = List.of(point("25.0", "121.0"), point("25.1", "121.0"), point("25.1", "121.1"));
+        StubProvider provider = new StubProvider();
+        provider.response = response;
+
+        assertEquals(new RouteDistance(3500, 400, "ROUTE"), provider.route(points, null));
+
+        // 全部站点一次提交、顺序不变,由地图计算整线道路距离
+        assertEquals(points, provider.requestedPoints);
+    }
+
+    @Test
+    void anyMapFailureFailsTheWholeRoute() {
+        StubProvider provider = new StubProvider();
+        provider.error = new IllegalStateException("self hosted map http status 502");
+        assertThrows(IllegalStateException.class,
+                () -> provider.route(List.of(point("25.0", "121.0"), point("25.1", "121.0"),
+                        point("25.1", "121.1")), null));
+    }
+
+    @Test
+    void outsideRegionErrorIsTypedForNonDegradation() {
+        StubProvider provider = new StubProvider();
+        provider.error = new RouteOutsideRegionException("outside");
+        assertThrows(RouteOutsideRegionException.class,
+                () -> provider.route(List.of(point("25.0", "121.0"), point("30.0", "124.0")), null));
+    }
+}

+ 29 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/user/InfoUserControllerTest.java

@@ -13,6 +13,7 @@ import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.app.user.dto.OAuthBindDto;
 import com.ruoyi.app.user.dto.OAuthBindDto;
 import com.ruoyi.app.user.dto.OAuthLoginDto;
 import com.ruoyi.app.user.dto.OAuthLoginDto;
 import com.ruoyi.app.user.service.DeviceTrustService;
 import com.ruoyi.app.user.service.DeviceTrustService;
+import com.ruoyi.app.utils.RsaMima;
 import com.ruoyi.app.utils.oauth.OAuthIdentity;
 import com.ruoyi.app.utils.oauth.OAuthIdentity;
 import com.ruoyi.app.utils.oauth.OAuthVerifyService;
 import com.ruoyi.app.utils.oauth.OAuthVerifyService;
 import com.ruoyi.common.constant.CacheConstants;
 import com.ruoyi.common.constant.CacheConstants;
@@ -40,6 +41,7 @@ import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
 import org.mockito.ArgumentCaptor;
+import org.mockito.MockedConstruction;
 import org.mockito.MockedStatic;
 import org.mockito.MockedStatic;
 import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
 import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
 import org.springframework.beans.factory.support.DefaultListableBeanFactory;
 import org.springframework.beans.factory.support.DefaultListableBeanFactory;
@@ -63,6 +65,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
 import static org.mockito.Mockito.mockStatic;
 import static org.mockito.Mockito.mockStatic;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.doThrow;
@@ -382,6 +385,32 @@ class InfoUserControllerTest {
         assertEquals("0", createdCaptor.getValue().getUserType());
         assertEquals("0", createdCaptor.getValue().getUserType());
     }
     }
 
 
+    @Test
+    void merchantLoginPersistsCurrentCidAndCidType() {
+        UserDTO request = new UserDTO();
+        request.setUserName("merchant");
+        request.setPassword("encrypted-password");
+        request.setCid("new-cid");
+        request.setCidType("android");
+        InfoUser merchant = activeUser(42L, "1");
+        merchant.setPassword("encrypted-password");
+        merchant.setCid("old-cid");
+        merchant.setCidType("ios");
+        when(infoUserService.getOne(any(Wrapper.class))).thenReturn(merchant);
+        when(infoUserService.updateInfoUser(any(InfoUser.class))).thenReturn(1);
+
+        try (MockedConstruction<RsaMima> ignored = mockConstruction(RsaMima.class,
+                (rsa, context) -> when(rsa.decryptByPrivateKey(any())).thenReturn("password"))) {
+            AjaxResult result = controller.shanglodeing(request);
+            assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG));
+        }
+
+        ArgumentCaptor<InfoUser> update = ArgumentCaptor.forClass(InfoUser.class);
+        verify(infoUserService).updateInfoUser(update.capture());
+        assertEquals("new-cid", update.getValue().getCid());
+        assertEquals("android", update.getValue().getCidType());
+    }
+
     @Test
     @Test
     void riderRegistrationRejectsDuplicateBusinessPhone() {
     void riderRegistrationRejectsDuplicateBusinessPhone() {
         com.ruoyi.system.domain.vo.UserDTO request = new com.ruoyi.system.domain.vo.UserDTO();
         com.ruoyi.system.domain.vo.UserDTO request = new com.ruoyi.system.domain.vo.UserDTO();

+ 3 - 2
specs/032-self-hosted-map-integration/spec.md

@@ -4,7 +4,7 @@
 
 
 **Created**: 2026-09-24
 **Created**: 2026-09-24
 
 
-**Status**: Draft
+**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)未做)
 
 
 **Input**: User description: "自部署地图集成:台湾外卖/闪送切换到自部署地图服务(Foodie_HCM_Map,PostGIS 搜索 + OSRM 算路),外卖/闪送运费按平台设置计价(pos_freight / flash_delivery_pricing),所有地图功能逐步切换,已有完整差距分析结论。"
 **Input**: User description: "自部署地图集成:台湾外卖/闪送切换到自部署地图服务(Foodie_HCM_Map,PostGIS 搜索 + OSRM 算路),外卖/闪送运费按平台设置计价(pos_freight / flash_delivery_pricing),所有地图功能逐步切换,已有完整差距分析结论。"
 
 
@@ -108,6 +108,7 @@
 - 路线不可达(NO_ROUTE,如离岛/无路网区域):报价与下单按直线兜底并标记来源,不阻断交易。
 - 路线不可达(NO_ROUTE,如离岛/无路网区域):报价与下单按直线兜底并标记来源,不阻断交易。
 - 坐标吸附超限(SNAP_TOO_FAR,默认 250 米):老地址库坐标可能大面积触发——切换前必须用存量地址批量试算体检,按结果决定调整吸附上限或引导重选地址。
 - 坐标吸附超限(SNAP_TOO_FAR,默认 250 米):老地址库坐标可能大面积触发——切换前必须用存量地址批量试算体检,按结果决定调整吸附上限或引导重选地址。
 - 坐标越界(OUTSIDE_REGION):拦截并提示地址无效,不降级。
 - 坐标越界(OUTSIDE_REGION):拦截并提示地址无效,不降级。
+- 多站点路线中,越界或吸附失败可定位到具体站点时,地图错误返回从 1 开始的 `point_index`;整条路线不可达(NO_ROUTE)不指定站点序号。
 - 地图服务整体宕机:闪送/外卖计价全部走直线兜底,选址接口明确报错;派单与附近骑手不受影响(数据库半径计算,不依赖地图)。
 - 地图服务整体宕机:闪送/外卖计价全部走直线兜底,选址接口明确报错;派单与附近骑手不受影响(数据库半径计算,不依赖地图)。
 - 多站点单在地图多点能力空窗期:维持 Google 或直线兜底,不坏单。
 - 多站点单在地图多点能力空窗期:维持 Google 或直线兜底,不坏单。
 - 外卖订单坐标取自门店坐标快照:门店坐标缺失/非法的商家,下单时按直线兜底并提示运营补录。
 - 外卖订单坐标取自门店坐标快照:门店坐标缺失/非法的商家,下单时按直线兜底并提示运营补录。
@@ -116,7 +117,7 @@
 
 
 ### Functional Requirements
 ### Functional Requirements
 
 
-- **FR-001**: 平台 MUST 提供距离来源开关(第三方路线服务 / 自部署地图),可运行时切换与回滚,不依赖发版。
+- **FR-001**: 平台 MUST 提供距离来源开关(第三方路线服务 / 自部署地图),可运行时切换与回滚,不依赖发版。`sys_map_distance_source` 仅在启用项中恰好一个 `is_default=Y` 时采用其值;缺失、多默认或未知值回退 Google。后台修改会更新缓存,手工执行 SQL 后需刷新缓存或重启。
 - **FR-002**: 闪送报价距离 MUST 优先取自配置的路线来源,失败时按直线距离兜底,且每笔报价/订单 MUST 落库实际距离来源。
 - **FR-002**: 闪送报价距离 MUST 优先取自配置的路线来源,失败时按直线距离兜底,且每笔报价/订单 MUST 落库实际距离来源。
 - **FR-003**: 外卖运费 MUST 由后端在下单时按订单坐标 + 平台 pos_freight 规则重算并落库;前端传入运费仅作一致性比对,不一致时以后端为准并记录差异。
 - **FR-003**: 外卖运费 MUST 由后端在下单时按订单坐标 + 平台 pos_freight 规则重算并落库;前端传入运费仅作一致性比对,不一致时以后端为准并记录差异。
 - **FR-004**: 外卖运费报价接口 MUST 返回道路距离、费用与距离来源三项信息,供前端展示与后端校验共用。
 - **FR-004**: 外卖运费报价接口 MUST 返回道路距离、费用与距离来源三项信息,供前端展示与后端校验共用。

+ 22 - 0
updatesql/sql.md

@@ -1837,3 +1837,25 @@ WHERE user_id IS NOT NULL AND bank_name IS NOT NULL AND bank_name <> ''
   AND bank_account_no IS NOT NULL AND bank_account_no <> ''
   AND bank_account_no IS NOT NULL AND bank_account_no <> ''
   AND bank_account_name IS NOT NULL AND bank_account_name <> '';
   AND bank_account_name IS NOT NULL AND bank_account_name <> '';
 ```
 ```
+
+## 2026-09-30 032 自部署地图距离来源开关
+
+```sql
+-- 该字典启用项中,唯一 is_default='Y' 为当前来源;
+--   无默认项、多默认项或未知值均安全回到 GOOGLE。操作入口:平台后台 系统管理-字典管理 sys_map_distance_source。
+--   手工执行 SQL 后若服务已运行,需在后台刷新字典缓存或重启服务,再修改默认项切换。
+INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark)
+SELECT '地图距离来源', 'sys_map_distance_source', '0', 'admin', NOW(), '闪送路线距离来源开关:GOOGLE=谷歌(默认) SELF_HOSTED=自部署地图'
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'sys_map_distance_source');
+
+INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark)
+SELECT 1, '谷歌路线', 'GOOGLE', 'sys_map_distance_source', '', 'default',
+       CASE WHEN EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'sys_map_distance_source' AND status = '0' AND is_default = 'Y')
+            THEN 'N' ELSE 'Y' END,
+       '0', 'admin', NOW(), '默认来源;切换时与 SELF_HOSTED 保持恰好一个默认项'
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'sys_map_distance_source' AND dict_value = 'GOOGLE');
+
+INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark)
+SELECT 2, '自部署地图', 'SELF_HOSTED', 'sys_map_distance_source', '', 'default', 'N', '0', 'admin', NOW(), '切换时与 GOOGLE 保持恰好一个默认项'
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'sys_map_distance_source' AND dict_value = 'SELF_HOSTED');
+```