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

补齐自部署地图旧地址接口兼容及发布验证文档

qmj 1 неделя назад
Родитель
Сommit
90d47a4e9c

+ 3 - 3
docs/map-api-usage.md

@@ -7,7 +7,7 @@
 | 服务 | 能力 | 封装位置 | 状态 |
 |---|---|---|---|
 | Google | Directions 路线 | `GetArea.getluxian` → `/utils/getluxianjvli` | Google分支兼容保留;自部署分支转换相同legs结构 |
-| Google | Nearby/Geocoding 逆地理 | `GetArea.getgoogle` / `getgoogleadd` → `/utils/getLocationAddr`(type=1)、`/utils/getAddr` | 在用(key 硬编码) |
+| Google / 自部署 | 旧附近/逆地理 | `/utils/getLocationAddr`(type=1)、`/utils/getAddr` | 按来源开关选择;SELF_HOSTED只调用MapLegacyLocationService,GOOGLE保留旧返回和语言兼容 |
 | Google | Routes API v2 | `GoogleRoutesDistanceProvider`(闪送报价距离) | 在用(key 走字典 `sys_googlemap_key`;**可经距离来源开关切自部署,见下节**) |
 | Google / 自部署 | 外卖道路矩阵 | `DistanceSourceRouteMatrixProvider` | 普通/v2/多店共用;自配送运费同口径,自取/堂食不算路 |
 | Google / 自部署 | 搜索/附近/反查/详情 | `/system/map/*` | 新App统一接入,token鉴权,结构一致;旧直连Google页面保留 |
@@ -42,7 +42,7 @@
 
 地图侧配套(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`、`/getyunfei`。旧`/getLocationAddr`、`/getAddr`和App内直接Google页面兼容保留,前端迁移后使用新`/system/map/*`;不能把只改字典当作App地图画面也已切换。`/getyunfei`保留jvli/freight/yunf字段,但改用道路矩阵和同一跳表;下单金额差异必须重报。
+边界:开关影响外卖/闪送报价、新选址及旧`/getluxianjvli`、`/getyunfei`、`/getLocationAddr`、`/getAddr`。2026-10-28起旧地址入口在SELF_HOSTED下不访问Google/Baidu或旧Redis语言缓存,失败不跨源;返回旧地址/候选结构、额外精度与确认标识,附近保留10km/最多20项。自部署不支持停用的百度type=0,防止旧页面把WGS84再次转换。前端地图页面仍由前端人员迁移,浏览器/App直连Google的请求不受此开关控制。`/getyunfei`保留jvli/freight/yunf字段,但改用道路矩阵和同一跳表;下单金额差异必须重报。
 
 旧结算页`/getluxianjvli`参数约定为latlng1=收货点、latlng2=门店,内部按门店→收货点请求两种来源。保留绘图结构,但用于计价的首段distance.value以同源权威矩阵为准,避免Google Directions与矩阵选路差异造成重复报价仍无法下单。该兼容接口暂需路线和矩阵两次请求,升级完整deliveryQuote后只需矩阵一次。`/getyunfei`参数顺序则为lat1/lng1=门店、lat2/lng2=收货点,两者不要混用。
 
@@ -50,7 +50,7 @@
 
 
 - `PosOrderController.setYjsdTime`(state=4 订单的预计送达时间,调用方为改单状态取餐分支与测试端点 `/testYjsdTime`)**已停用**(2026-09-29 标记):方法不再使用,保留调用不影响状态流转;其依赖的 `getMotorbikeDuration`(Mapbox driving ×1.5)因此实际无在用调用。切自部署路线源(032)时可一并清理。
-- `/utils/getLocationAddr` 的 `type=0` 百度分支保留兜底;App 端应使用 `type=1` 谷歌分支。
+- `/utils/getLocationAddr` 当前在用 `type=1`,按开关选Google或自部署。停用的 `type=0` 只在Google兼容分支保留;自部署拒绝,不伪造百度坐标。
 - `GetJvli.getOptimalRoute`(Google Routes v2)全库无调用方,属死代码,不在本次标注范围。
 - `GetArea`/`GetJvli` 的历史硬编码凭据由运维单独核对限制和轮换;不能在保留Google回滚需求时直接作废全部有效密钥。新接口只读服务端字典,不复制硬编码密钥。
 

+ 108 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/map/MapLegacyLocationService.java

@@ -0,0 +1,108 @@
+package com.ruoyi.app.map;
+
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.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.Map;
+import java.util.function.Supplier;
+
+/** 旧选址入口的自部署适配;只访问自部署客户端,失败不跨源请求 Google 或百度。 */
+@Service
+public class MapLegacyLocationService {
+    private static final Logger log = LoggerFactory.getLogger(MapLegacyLocationService.class);
+    private final SelfHostedMapClient client;
+
+    public MapLegacyLocationService(SelfHostedMapClient client) { this.client = client; }
+
+    public JSONObject reverse(String latlng) {
+        BigDecimal[] point = coordinates(latlng);
+        return lookup(() -> reverse(point));
+    }
+
+    public JSONObject nearby(String latlng, Integer type) {
+        // type=0 的旧百度页面会再次转换坐标;不能把 WGS84 伪装成百度坐标返回。
+        if (!Integer.valueOf(1).equals(type)) throw new ServiceException(MessageUtils.message("map.location.invalid"));
+        BigDecimal[] point = coordinates(latlng);
+        return lookup(() -> {
+            JSONObject address = reverse(point);
+            var rows = client.getArray("/nearby", Map.of("lat", point[0], "lng", point[1],
+                    "radius", 10000, "limit", 20));
+            if (rows == null) throw new IllegalStateException();
+            JSONArray results = new JSONArray();
+            for (int index = 0; index < Math.min(rows.size(), 20); index++) {
+                var row = rows.getJSONObject(index);
+                if (row == null) throw new IllegalStateException();
+                BigDecimal lat = row.getBigDecimal("lat"), lng = row.getBigDecimal("lng");
+                if (!valid(lat, lng) || row.getString("id") == null || row.getString("id").isBlank())
+                    throw new IllegalStateException();
+                String name = row.getString("name"), vicinity = row.getString("address");
+                if (name == null || name.isBlank()) name = vicinity;
+                if (name == null || name.isBlank()) throw new IllegalStateException();
+                if (vicinity == null) vicinity = "";
+                JSONObject candidate = new JSONObject();
+                candidate.put("place_id", "self_hosted:" + row.getString("id"));
+                candidate.put("name", name);
+                candidate.put("vicinity", vicinity);
+                candidate.put("geometry", Map.of("location", Map.of("lat", lat, "lng", lng)));
+                candidate.put("source", "SELF_HOSTED");
+                candidate.put("precision", row.getString("precision"));
+                candidate.put("requiresConfirmation", !"address".equals(row.getString("precision"))
+                        || Boolean.TRUE.equals(row.getBoolean("requires_confirmation")));
+                results.add(candidate);
+            }
+            JSONObject response = new JSONObject();
+            response.put("address", address);
+            response.put("results", results);
+            return response;
+        });
+    }
+
+    private JSONObject reverse(BigDecimal[] point) {
+        var row = client.getObject("/reverse_geocode", Map.of("lat", point[0], "lng", point[1]));
+        if (row == null) throw new IllegalStateException();
+        String name = row.getString("name");
+        JSONObject result = new JSONObject();
+        result.put("name", name == null || name.isBlank() ? point[0].toPlainString() + "," + point[1].toPlainString() : name);
+        result.put("address", row.getString("address") == null ? "" : row.getString("address"));
+        // 近似道路名不是精确门牌;返回的坐标始终为请求原点,不替换为附近地点。
+        result.put("latitude", point[0]); result.put("longitude", point[1]);
+        result.put("source", "SELF_HOSTED");
+        result.put("precision", row.getString("precision") == null ? "unknown" : row.getString("precision"));
+        result.put("requiresConfirmation", true);
+        return result;
+    }
+
+    private BigDecimal[] coordinates(String text) {
+        try {
+            if (text == null || text.length() > 120) throw new IllegalArgumentException();
+            String[] parts = text.split(",", -1);
+            if (parts.length != 2) throw new IllegalArgumentException();
+            BigDecimal lat = new BigDecimal(parts[0].trim()), lng = new BigDecimal(parts[1].trim());
+            // 拒绝超大指数,防止匿名请求在展开坐标文本时分配过量内存。
+            if (!valid(lat, lng) || Math.abs((long) lat.scale()) > 30 || Math.abs((long) lng.scale()) > 30)
+                throw new IllegalArgumentException();
+            return new BigDecimal[]{lat, lng};
+        } catch (RuntimeException failure) {
+            throw new ServiceException(MessageUtils.message("map.location.invalid"));
+        }
+    }
+
+    private boolean valid(BigDecimal lat, BigDecimal lng) {
+        return lat != null && lng != null && lat.abs().compareTo(BigDecimal.valueOf(90)) <= 0
+                && lng.abs().compareTo(BigDecimal.valueOf(180)) <= 0;
+    }
+
+    private JSONObject lookup(Supplier<JSONObject> request) {
+        try { return request.get(); }
+        catch (RuntimeException failure) {
+            log.warn("Legacy map location lookup failed provider=SELF_HOSTED");
+            throw new ServiceException(MessageUtils.message("map.location.unavailable"));
+        }
+    }
+}

+ 5 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/utils/SysFileInfoController.java

@@ -44,6 +44,7 @@ public class SysFileInfoController extends BaseController {
     private JedisFactory jedisFactory;
     @Autowired private com.ruoyi.app.map.MapSourceSelector mapSourceSelector;
     @Autowired private com.ruoyi.app.map.MapLegacyRouteService mapLegacyRouteService;
+    @Autowired private com.ruoyi.app.map.MapLegacyLocationService mapLegacyLocationService;
     @Autowired private com.ruoyi.app.order.FoodDeliveryPromotionQuoteService foodDeliveryPromotionQuoteService;
     private static final Logger log = LoggerFactory.getLogger(SysFileInfoController.class);
 
@@ -65,6 +66,8 @@ public class SysFileInfoController extends BaseController {
     public AjaxResult getLocationAddr(@RequestParam String latlng,
                                       @RequestParam Integer type,
                                       @RequestParam(defaultValue = "") String id) {
+        // 必须先选来源:自部署无需访问旧语言缓存,也不能构造外部地图客户端。
+        if (mapSourceSelector.selfHostedEnabled()) return success(mapLegacyLocationService.nearby(latlng, type));
         GetArea area = new GetArea();
         String lang;
         try (Jedis jedis = jedisFactory.create()) {
@@ -86,6 +89,8 @@ public class SysFileInfoController extends BaseController {
     @GetMapping("/getAddr")
     public AjaxResult getAddr(@RequestParam String latlng,
                               @RequestParam(defaultValue = "") String id) {
+        // 保留旧响应 name/address;切回 Google 后仍走原有语言及返回规则。
+        if (mapSourceSelector.selfHostedEnabled()) return success(mapLegacyLocationService.reverse(latlng));
         String lang;
         try (Jedis jedis = jedisFactory.create()) {
             String savedLanguage = jedis.get(id);

+ 63 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/map/LegacyMapLocationControllerTest.java

@@ -0,0 +1,63 @@
+package com.ruoyi.app.map;
+
+import com.alibaba.fastjson.JSONObject;
+import com.ruoyi.app.utils.SysFileInfoController;
+import com.ruoyi.common.utils.MessageUtils;
+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 static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class LegacyMapLocationControllerTest {
+    @Test
+    void selfHostedLegacyAddressesNeverCreateGoogleClientOrReadRedis() {
+        var controller = new SysFileInfoController();
+        var selector = mock(MapSourceSelector.class);
+        var client = mock(SelfHostedMapClient.class);
+        var jedis = mock(JedisFactory.class);
+        when(jedis.create()).thenReturn(mock(Jedis.class));
+        when(selector.selfHostedEnabled()).thenReturn(true);
+        when(client.getObject(eq("/reverse_geocode"), anyMap())).thenReturn(
+                com.alibaba.fastjson2.JSON.parseObject("{\"name\":\"道路\",\"address\":\"台北\",\"precision\":\"street\"}"));
+        when(client.getArray(eq("/nearby"), anyMap())).thenReturn(new com.alibaba.fastjson2.JSONArray());
+        ReflectionTestUtils.setField(controller, "mapSourceSelector", selector);
+        ReflectionTestUtils.setField(controller, "jedisFactory", jedis);
+        ReflectionTestUtils.setField(controller, "mapLegacyLocationService", new MapLegacyLocationService(client));
+        try (var legacy = mockConstruction(GetArea.class);
+             var messages = mockStatic(MessageUtils.class, call -> call.getArgument(0))) {
+            controller.getLocationAddr("25.033,121.56", 1, "user");
+            controller.getAddr("25.033,121.56", "user");
+            assertTrue(legacy.constructed().isEmpty());
+            verifyNoInteractions(jedis);
+        }
+    }
+
+    @Test
+    void googleRollbackRetainsOldLanguageAndResponse() {
+        var controller = new SysFileInfoController();
+        var selector = mock(MapSourceSelector.class);
+        var factory = mock(JedisFactory.class);
+        var jedis = mock(Jedis.class);
+        when(factory.create()).thenReturn(jedis);
+        when(jedis.get("user")).thenReturn("en_US");
+        ReflectionTestUtils.setField(controller, "mapSourceSelector", selector);
+        ReflectionTestUtils.setField(controller, "jedisFactory", factory);
+        JSONObject old = new JSONObject();
+        old.put("name", "old");
+        old.put("address", "old address");
+        try (var messages = mockStatic(MessageUtils.class, call -> call.getArgument(0));
+             var legacy = mockConstruction(GetArea.class, (mock, context) -> {
+            when(mock.getgoogleadd(anyString(), anyString())).thenReturn(old);
+            when(mock.getgoogle(anyString(), anyString())).thenReturn(old);
+        })) {
+            assertSame(old, controller.getAddr("25,121", "user").get("data"));
+            assertSame(old, controller.getLocationAddr("25,121", 1, "user").get("data"));
+            verify(legacy.constructed().get(0)).getgoogleadd("25,121", "en_US");
+            verify(legacy.constructed().get(1)).getgoogle("25,121", "en_US");
+        }
+    }
+}

+ 90 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/map/MapLegacyLocationServiceTest.java

@@ -0,0 +1,90 @@
+package com.ruoyi.app.map;
+
+import com.alibaba.fastjson2.JSON;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class MapLegacyLocationServiceTest {
+    private final SelfHostedMapClient client = mock(SelfHostedMapClient.class);
+    private final MapLegacyLocationService service = new MapLegacyLocationService(client);
+
+    private void address() {
+        when(client.getObject(eq("/reverse_geocode"), anyMap())).thenReturn(JSON.parseObject(
+                "{\"name\":\"道路\",\"address\":\"臺北市道路\",\"lat\":25,\"lng\":121,\"precision\":\"nearby_place\"}"));
+    }
+
+    @Test
+    void preservesOldNearbyShapeRadiusAndTapPrecision() {
+        address();
+        when(client.getArray(eq("/nearby"), anyMap())).thenReturn(JSON.parseArray(
+                "[{\"id\":\"osm:1\",\"name\":\"商店\",\"address\":\"臺北市\",\"lat\":25.04,\"lng\":121.57,\"precision\":\"poi\"}]"));
+        var response = service.nearby("25.033000123,121.560000987", 1);
+        var address = response.getJSONObject("address");
+        assertEquals("道路", address.getString("name"));
+        assertEquals("臺北市道路", address.getString("address"));
+        assertEquals(new BigDecimal("25.033000123"), address.getBigDecimal("latitude"));
+        assertEquals(new BigDecimal("121.560000987"), address.getBigDecimal("longitude"));
+        assertTrue(address.getBooleanValue("requiresConfirmation"));
+        var place = response.getJSONArray("results").getJSONObject(0);
+        assertEquals("self_hosted:osm:1", place.getString("place_id"));
+        assertEquals("臺北市", place.getString("vicinity"));
+        assertEquals(new BigDecimal("25.04"), place.getJSONObject("geometry").getJSONObject("location").getBigDecimal("lat"));
+        verify(client).getArray("/nearby", Map.of("lat", new BigDecimal("25.033000123"),
+                "lng", new BigDecimal("121.560000987"), "radius", 10000, "limit", 20));
+    }
+
+    @Test
+    void emptyNearbyRemainsEmptyAndMissingAddressIsNotInvented() {
+        when(client.getObject(eq("/reverse_geocode"), anyMap())).thenReturn(JSON.parseObject(
+                "{\"name\":\"25,121\",\"precision\":\"coordinate\"}"));
+        when(client.getArray(eq("/nearby"), anyMap())).thenReturn(new com.alibaba.fastjson2.JSONArray());
+        var response = service.nearby("25,121", 1);
+        assertTrue(response.getJSONArray("results").isEmpty());
+        assertEquals("", response.getJSONObject("address").getString("address"));
+        assertEquals("coordinate", response.getJSONObject("address").getString("precision"));
+    }
+
+    @Test
+    void deprecatedTypeZeroCannotMislabelWgs84AsBaiduCoordinates() {
+        try (var messages = mockStatic(MessageUtils.class, call -> call.getArgument(0))) {
+            assertEquals("map.location.invalid", assertThrows(ServiceException.class,
+                    () -> service.nearby("25,121", 0)).getMessage());
+            verifyNoInteractions(client);
+        }
+    }
+
+    @Test
+    void invalidCoordinatesFailBeforeAnyMapRequest() {
+        try (var messages = mockStatic(MessageUtils.class, call -> call.getArgument(0))) {
+            for (String text : List.of("", "25", "25,121,1", "91,121", "25,181", "NaN,121", "1e-999999999,121")) {
+                assertEquals("map.location.invalid", assertThrows(ServiceException.class,
+                        () -> service.nearby(text, 1)).getMessage());
+            }
+            assertThrows(ServiceException.class, () -> service.reverse(null));
+            verifyNoInteractions(client);
+        }
+    }
+
+    @Test
+    void failedOrMalformedResponsesReturnLocalizedErrorWithoutLeakingUpstreamDetails() {
+        try (var messages = mockStatic(MessageUtils.class, call -> call.getArgument(0))) {
+            address();
+            for (String data : List.of("[null]", "[{\"id\":\"1\",\"name\":\"bad\",\"lat\":91,\"lng\":121}]")) {
+                when(client.getArray(eq("/nearby"), anyMap())).thenReturn(JSON.parseArray(data));
+                assertEquals("map.location.unavailable", assertThrows(ServiceException.class,
+                        () -> service.nearby("25,121", 1)).getMessage());
+            }
+            when(client.getObject(eq("/reverse_geocode"), anyMap())).thenThrow(new IllegalStateException("private token"));
+            assertEquals("map.location.unavailable", assertThrows(ServiceException.class,
+                    () -> service.reverse("25,121")).getMessage());
+        }
+    }
+}

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

@@ -49,3 +49,13 @@
 报价与下单使用同一经纬度和服务端金额。金额不一致时按现有错误提示重新报价;不能在 App 静默覆盖后直接提交。自取、堂食不请求配送路线。地图行驶时长是参考值,前端不要将其直接显示为承诺送达时间。
 
 Google 回滚保留现有字典、Key 和旧有效接口。切换配置不会改写历史订单金额或支付快照。真实设备选址、四语言错误展示、报价→下单金额一致性及 Google/自部署切换联调仍需人工验收。
+
+## 2026-10-28 旧地址接口兼容(本轮不改前端代码)
+
+`GET /utils/getAddr?latlng=纬度,经度&id=旧语言缓存ID` 保留 `data.name/data.address`;自部署额外返回原点选 `latitude/longitude`、`source`、`precision`、`requiresConfirmation`。缺门牌时不伪造地址,`address` 可为空字符串。
+
+`GET /utils/getLocationAddr?latlng=纬度,经度&type=1&id=...` 保留 `data.address:{name,address}` 和 `data.results[]:{place_id,name,vicinity,geometry:{location:{lat,lng}}}`,附近仍为10km/最多20项;候选可以是0项或1项,不能直接读取第二项。自部署 `place_id` 带 `self_hosted:` 前缀,不能传给 Google SDK 地点详情。新页面建议使用前述统一接口。
+
+两入口遵循同一来源字典:SELF_HOSTED下只请求自部署服务,不读旧Redis语言缓存、不请求Google/Baidu,不因失败自动回Google;GOOGLE下保留旧响应及语言行为。已停用的type=0百度页面存在坐标再次转换逻辑,自部署拒绝该类型,不把WGS84冒充百度坐标。
+
+只读核查发现旧App `pages/UserCenter/map/map.vue` 在结果非空时直接读取 `pois[1]`,单候选也可能越界。按用户要求本轮未改该文件,交由前端人员处理;后端不会为此伪造第二条候选。页面底图、SDK、导航调用和旧页面自身直连Google仍由前端负责,后端字典不能控制这些请求。

+ 7 - 0
specs/032-self-hosted-map-integration/plan.md

@@ -25,6 +25,13 @@
 
 ### 历史评估计划(保留备查)
 
+### 2026-10-28 追加计划
+
+1. 在 `app/map/LegacyMapLocationControllerTest`、`MapLegacyLocationServiceTest` 覆盖旧入口的 SELF_HOSTED 成功/空结果/非法坐标/服务异常、无 Google/Baidu/Redis 调用,以及 GOOGLE 回滚原结构/语言。先观察旧控制器仍调用 Google 的失败。
+2. 新增 `MapLegacyLocationService` 专门转换旧地址响应,使用既有 `SelfHostedMapClient`;`SysFileInfoController` 在旧外部调用前判断共用来源。只在地图 `app.py` 放宽已受限 nearby 最大半径到10km并补边界测试,保留旧10km搜索范围。
+3. JDK21 定向检查通过后执行合并后的完整 reactor package,地图 Python/前端既有测试;核查源码、分支及凭据差异。前端只读,不修改页面,不自动切流或执行 SQL。
+4. 只读核对 tokyo-map 的运行版本、路网/数据集、schema、双车型和矩阵;形成真实发布差距与受控操作方案。未执行的部署、存量数据体检、法规和负载验收保留明确状态,不能以本地测试代替。
+
 ### 2026-10-26 执行记录与实现决策
 
 最终本地验证:完整reactor测试1484项(admin1314/system168/framework2)0失败/错误/跳过,package成功;地图64项及页面测试通过。I001–I008本地交付,V001–V003保留真实部署验收。无提交/推送/迁移/部署。

+ 19 - 1
specs/032-self-hosted-map-integration/release-runbook.md

@@ -1,6 +1,6 @@
 # 双地图发布、体检和回滚
 
-更新:2026-10-26。本轮仅本地代码/测试,不执行SQL、部署、真实商家同步或流量切换。
+更新:2026-10-28。测试地图API已按下方记录升级;用户明确foodie-server暂不发布,未切业务字典、未执行SQL或真实商家同步。此前2026-10-26记录仅为当时本地验证状态。
 
 ## 后端配置
 
@@ -29,3 +29,21 @@
 ## 运维观察
 
 日志关注提供方、接口、车型、耗时和结构化错误;不要采集完整地址、坐标、令牌、Key或原始上游响应。分别统计路线失败、闪送直线降级、外卖报价失败、新地址失败及金额变化。地图服务中断不影响数据库直线围栏、附近排序或已有订单状态操作。
+
+## 2026-10-28 测试地图发布实录
+
+- 目标:`tokyo-map`,`/opt/foodie-map-tw-20260909`。原API缺失外卖矩阵(404),但双车型OSRM可用。只读确认地图库已有dataset字段、TW/taiwan元数据和10,311,783条搜索索引,现有机车Lua指纹与本地一致,不需要数据库迁移或路网重建。
+- 候选镜像 `foodie-map-api:candidate-20261028` 先在仅回环15050端口验证,复现并修复10km附近排序数据库超时。新查询使用现有geography GiST索引的KNN,附近展示米数与排序统一球面距离,不用于配送费或骑手接单围栏。
+- 验证后仅重建API容器,重载既有Nginx以刷新API地址。数据库、汽车、机车和前端容器ID未改变,前端源文件未修改。备用候选容器已清理。
+- 发布API镜像ID:`sha256:8fa2fe9975a4f92777ec207e3b33a8d9909a22eec27862b5d9738fd5b38c5b8e`。部署源来自地图master `7160933` 加本轮未提交的API改动,精确文件哈希、打包件及验证结果保存在私有备份,不能仅以Git基线当作最终源版本。
+- 私有备份目录 `/opt/foodie-map-backups/20261028-api`:`before.json`记录原镜像ID/tag,`source-before.tar.gz`保留原源码,旧镜像另标记`foodie-map-api:rollback-20261028`;`deployment.json`记录实际源指纹,`live-validation.json`记录两车型及小样本性能。含环境凭据的candidate.env仅在服务器私有目录,未复制到Git或本地报告。
+
+地图API回退时,在服务器用`before.json`中的镜像ID重新标记为其中原image_tag,然后在原项目目录执行 `docker compose up -d --no-deps --no-build api`,重载 `docker compose exec -T frontend nginx -s reload`,核验健康与两车型路线;需要后续重建旧镜像时先恢复source-before.tar.gz中的map_api源码。不要执行会重建数据库/路网的全栈命令。旧版不支持矩阵,回退后外卖不能继续选择自部署;业务来源仍需按前述字典规则切Google。此处未实际切换业务字典或触发Google计费请求。
+
+### 实测范围和未通过项
+
+公开台北样本:汽车6476米/546秒,机车6238米/694秒;三起点矩阵分别为`[6476,3168,6476]`与`[6238,3168,6238]`,重复起点保持顺序。最终40请求、4并发均成功;10km附近最大约1090ms,不能宣称满足全境搜索P99<200ms。新2km接口上限仍保持,旧10km接口保留兼容。
+
+只读业务库坐标体检覆盖24个台湾范围门店及35个地址,用最近配对生成52组,每车型50成功/2组NO_ROUTE。失败组的两个端点分别可自行吸附算路,不能直接认定地址错误;需业务确认跨不连通路网的配送范围,不自动改坐标、加渡轮或放宽机车禁行。详细含坐标CSV仅保存在上述私有目录与本地`ruoyi-admin/target/map-release`,不提交Git。
+
+本轮不修改业务数据,不同步门店、不重建搜索索引:现有测试商家同步属于另一次明确操作,须先确认目标门店与是否对外开放。本轮用户要求后端暂不发布,因此foodie-server JAR仅构建,真实App/商家/平台联调、Google真实切流回滚、全境法规/动态条件限制及正式P99均保留后续验收。

+ 10 - 1
specs/032-self-hosted-map-integration/spec.md

@@ -4,7 +4,7 @@
 
 **Created**: 2026-09-24
 
-**Status**: 已验证(2026-10-26,仅本地代码:后端1484项测试及打包、地图64项与页面检查通过,审查问题已修复。未提交、未部署;真实地址/路段/性能及App联调待验收,见tasks V001–V003)。
+**Status**: 实现中(2026-10-28:地图分支已合入 test-202609v2,继续补旧地址入口兼容与合并后回归;此前本地验证记录保留,真实发布验收见 tasks)。
 
 **Input**: User description: "自部署地图集成:台湾外卖/闪送切换到自部署地图服务(Foodie_HCM_Map,PostGIS 搜索 + OSRM 算路),外卖/闪送运费按平台设置计价(pos_freight / flash_delivery_pricing),所有地图功能逐步切换,已有完整差距分析结论。"
 
@@ -29,6 +29,15 @@
 
 ## User Scenarios & Testing *(mandatory)*
 
+### 2026-10-28 追加:旧地址入口随来源切换
+
+- 用户确认本轮不修改 App、平台或商家端地图页面。`/utils/getLocationAddr` 与 `/utils/getAddr` 沿用原参数及响应结构;SELF_HOSTED 下只访问自部署,故障明确报错,不请求 Google/Baidu,也不依赖旧 Redis 语言缓存。GOOGLE 下保留原语言及响应兼容行为。
+- 旧附近接口保留原 10km 半径及最多 20 个结果,地图 `/nearby` 的受限半径上限扩为 10km,默认300m及最多50项不变;新 `/system/map/nearby` 对外仍限制2km,不扩大新接口契约。
+- 自部署结果转换为旧 `address:{name,address}`、`results:[{place_id,name,vicinity,geometry:{location:{lat,lng}}}]`;保留精度、确认标识和原点选坐标,不把近似地址伪装为门牌。空候选返回空数组,错误不能伪装成功或向 Google 重试。
+- 自部署旧附近入口仅接受当前在用的 type=1。已停用的 type=0 页面会将百度坐标再转换,不能向其伪装返回 WGS84;返回既有国际化参数错误,不请求百度或Google。
+- 外卖仍默认 car,避免未经业务确认改变运费;闪送按用户车型。法规、动态交通能力不在未经真实样本验证时擅自更改。
+- 本轮补齐兼容代码、两仓测试、合并后后端回归和发布记录。只读核对测试服务器;发布或迁移必须先具备可审阅的变更、备份和回退步骤,数据库迁移按项目规范由开发者手动执行。真实存量地址、车型法规与性能验收只能按实际执行结果记录。
+
 ### User Story 1 - 闪送报价距离切换到自部署地图(Priority: P1)
 
 运营在平台配置中把路线距离来源从 Google 切到自部署地图后,用户发起闪送报价时,距离与费用按自部署地图的**道路距离**计算;当地图服务不可达、路线不可达或坐标吸附失败时,沿用现有直线距离兜底保证报价可用,且每单记录实际使用的距离来源,运营可事后核对。

+ 18 - 0
specs/032-self-hosted-map-integration/tasks.md

@@ -1,5 +1,23 @@
 # 032 任务(历史评估 + 双地图接入)
 
+## 2026-10-28 追加(当前实现中)
+
+- [x] I009 旧 getLocationAddr/getAddr 自部署转换保留结构、10km范围及Google回滚;覆盖成功/失败不跨源与不读旧语言缓存,新增注释。
+- [x] I010 地图 nearby 最大半径支持旧入口10km,默认值、结果上限、新选址2km限制不变,补边界回归;真实大库超时已用现有GiST近邻索引修复,无DDL。
+- [x] I011 合并 test-202609v2 后执行后端定向与完整打包、地图测试,更新前端接入/现状/发布手册并纠正旧交付状态。
+- [x] V004 只读核对测试服务器版本/数据/实际双车型及矩阵,记录备份、升级、回退和真实验收差距;SQL不自动执行。
+
+合并记录:本地地图分支 `23d808d0` 合入 v2 的 `bf5f34ac`。地图 master 当前 `7160933`;Git 实际记录优先于下方历史“未提交”表述。本轮前端地图页面不改。
+
+### 2026-10-28 验证与发布记录
+
+- 旧控制器回归先复现SELF_HOSTED仍构造GetArea;实现后通过。完整JDK21 reactor package成功:1540项(admin1367/system171/framework2),0失败/错误/跳过;随后补超大坐标指数防护,相关7项再测及package成功。地图66项Python测试与既有Node页面测试通过。
+- 测试服务器原 `/route/matrix` 404。候选API先验证,发现台湾1031万条索引下旧10km附近排序触发10秒数据库超时;使用现有GiST geography KNN修复,附近距离统一球面口径(仅候选展示,非配送费依据)。真实EXPLAIN确认索引扫描与增量排序。
+- 测试地图API已升级,汽车/机车真实矩阵、重复起点、附近/反查及边界通过;数据库、双OSRM及前端容器保持原ID,未执行DDL或修改业务数据。旧镜像和源文件备份在 `/opt/foodie-map-backups/20261028-api`,详见release-runbook。
+- 两轮各40请求/4并发的公开台北样本验证通过,属于冒烟压力样本,不证明全境P99或正式容量。最终样本矩阵最大约61ms、搜索57ms、10km附近1090ms。
+- 只读测试业务库:51个门店中24个坐标在台湾边界,571个地址中35个在台湾边界。以最近配对覆盖这59个点,得到52组;car和motorcycle各50成功/2个NO_ROUTE。不是历史订单路线重放,不把无路当坐标必定错误,不自动修业务数据。含坐标的CSV/详细报告只在私有备份及本地target,Git文档只记录汇总。
+- 用户明确后端暂不发布:不部署foodie-server、不切业务字典。前端页面/单候选越界由前端人员处理;全境法规、动态条件限制、真实交通、正式并发P99、真实Google切流回滚和2组不可达的业务确认仍未验收,不能勾完成。
+
 ## 2026-10-23 双地图接入(2026-10-26 本地已验证,待发布)
 
 - [x] I001 共用开关、自部署鉴权/超时、闪送车型和时长。