Sfoglia il codice sorgente

fix: 闪送复用地图服务密钥

qmj 2 giorni fa
parent
commit
cbd68f62c3

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

@@ -1,12 +1,15 @@
 package com.ruoyi.app.flashdelivery.route;
 
 import com.ruoyi.system.domain.flash.FlashDeliveryVehicleType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
 
 /** 路线距离服务:优先请求地图路线,异常时使用球面直线距离保证报价仍然可用。 */
 @Service
 public class FlashDeliveryRouteService {
     private static final double EARTH_RADIUS_METERS = 6_371_000D;
+    private static final Logger log = LoggerFactory.getLogger(FlashDeliveryRouteService.class);
     private final RouteDistanceProvider provider;
 
     public FlashDeliveryRouteService(RouteDistanceProvider provider) {
@@ -25,8 +28,10 @@ public class FlashDeliveryRouteService {
             if (route != null && route.distanceMeters() > 0) {
                 return route;
             }
-        } catch (RuntimeException ignored) {
-            // 地图超时、限流或配置缺失不阻断下单,统一进入本地 Haversine 降级。
+        } catch (RuntimeException exception) {
+            // 不记录地址、坐标或 Key;保留车型和失败原因以定位 Google Routes 降级。
+            log.warn("Google Routes unavailable; use straight-line fallback. vehicleType={}, reason={}",
+                    selectedVehicle, exception.getMessage());
         }
         // Haversine 只作为降级结果,并通过 source=STRAIGHT_LINE 明确告知调用方。
         double lat1 = Math.toRadians(origin.latitude().doubleValue());

+ 14 - 5
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/GoogleRoutesDistanceProvider.java

@@ -20,7 +20,7 @@ import java.math.BigDecimal;
 import java.nio.charset.StandardCharsets;
 import java.util.List;
 
-/** Google Routes API 的服务端实现,密钥只从后端专用字典读取。 */
+/** Google Routes API 的服务端实现,复用既有地图字典中的 Key。 */
 @Component
 public class GoogleRoutesDistanceProvider implements RouteDistanceProvider {
     private static final String ENDPOINT = "https://routes.googleapis.com/directions/v2:computeRoutes";
@@ -32,8 +32,8 @@ public class GoogleRoutesDistanceProvider implements RouteDistanceProvider {
     public RouteDistance route(BigDecimal originLatitude, BigDecimal originLongitude,
                                BigDecimal destinationLatitude, BigDecimal destinationLongitude,
                                Integer vehicleType) {
-        // 不复用可能被浏览器端读取的 sys_googlemap_key,防止服务端 API 权限泄露
-        List<SysDictData> keys = DictUtils.getDictCache("sys_google_routes_key");
+        // 闪送与外卖共用既有地图 Key,避免维护第二套字典配置
+        List<SysDictData> keys = DictUtils.getDictCache(mapKeyDictType());
         if (keys == null || keys.isEmpty() || keys.get(0).getDictValue() == null
                 || keys.get(0).getDictValue().isBlank()) {
             throw new IllegalStateException("google routes key unavailable");
@@ -53,8 +53,10 @@ public class GoogleRoutesDistanceProvider implements RouteDistanceProvider {
         request.setEntity(new StringEntity(body.toJSONString(), ContentType.APPLICATION_JSON));
         try (CloseableHttpClient client = HttpClients.custom().disableAutomaticRetries().build();
              CloseableHttpResponse response = client.execute(request)) {
-            if (response.getStatusLine().getStatusCode() < 200 || response.getStatusLine().getStatusCode() >= 300) {
-                throw new IllegalStateException("google routes non-success response");
+            int statusCode = response.getStatusLine().getStatusCode();
+            if (statusCode < 200 || statusCode >= 300) {
+                // 仅保留 HTTP 状态,绝不记录 Key、地址或完整 Google 响应内容。
+                throw new IllegalStateException("google routes http status " + statusCode);
             }
             JSONObject json = JSON.parseObject(EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8));
             JSONArray routes = json.getJSONArray("routes");
@@ -67,6 +69,8 @@ public class GoogleRoutesDistanceProvider implements RouteDistanceProvider {
                 throw new IllegalStateException("google routes invalid distance");
             }
             return new RouteDistance(meters, parseDuration(first.getString("duration")), "ROUTE");
+        } catch (IllegalStateException exception) {
+            throw exception;
         } catch (Exception exception) {
             throw new IllegalStateException("google routes request failed", exception);
         }
@@ -89,6 +93,11 @@ public class GoogleRoutesDistanceProvider implements RouteDistanceProvider {
                 ? "DRIVE" : "TWO_WHEELER";
     }
 
+    /** 闪送与外卖共用的地图 Key 字典类型。 */
+    static String mapKeyDictType() {
+        return "sys_googlemap_key";
+    }
+
     private Integer parseDuration(String value) {
         if (value == null || !value.endsWith("s")) return null;
         try {

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

@@ -36,7 +36,7 @@ public class InfoAddressController extends BaseController {
     @Autowired private InfoAddressMapper infoAddressMapper;
     @Autowired private InfoAddressBookService addressBookService;
 
-    /** 返回历史浏览器地图密钥;服务端 Routes 使用另一套不公开的密钥。 */
+    /** 返回既有浏览器地图 Key;闪送 Routes 与外卖共用同一字典配置。 */
     @Anonymous
     @GetMapping("/getGoogleMapKey")
     public AjaxResult getGoogleMapKey() {

+ 5 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/route/GoogleRoutesDistanceProviderTest.java

@@ -7,6 +7,11 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
 
 class GoogleRoutesDistanceProviderTest {
 
+    @Test
+    void usesExistingSharedMapKeyDictionaryForRoutes() {
+        assertEquals("sys_googlemap_key", GoogleRoutesDistanceProvider.mapKeyDictType());
+    }
+
     @Test
     void mapsUserSelectedVehicleToGoogleTravelMode() {
         assertEquals("TWO_WHEELER", GoogleRoutesDistanceProvider.travelModeFor(FlashDeliveryVehicleType.MOTORCYCLE));

+ 7 - 0
specs/024-flash-delivery/plan.md

@@ -740,3 +740,10 @@ The staged list must not contain `.claude/homunculus/observations.jsonl`、`.tmp
 - [x] 路线服务已按车型透传:机车为 Google Routes `TWO_WHEELER`,轿车为 `DRIVE`;报价、创建及待接单改址均覆盖,历史空值按机车回落。
 - [x] 路线车型映射、路线服务透传、车型报价、Mapper/DTO/i18n/接口契约定向测试通过;JDK 21 执行 `mvn -q -pl ruoyi-admin -am -DskipTests package` 通过;平台端 `npm run test:flash-delivery` 8 项通过;两个仓库 `git diff --check` 通过。
 - [ ] 未执行 MySQL 迁移、真实 Google Routes 请求、真实用户 App 联调或完整回归。平台端 `npm run build:prod` 因本机缺少 `vue-cli-service` 未完成。两轮路线是 Google Beta;用户 App 若展示两轮路线,需要另行补充 Google 要求的提示文案。
+
+### 2026-09-17 共享地图 Key 与路线降级诊断
+
+- [x] 闪送改为与外卖共用 `sys_googlemap_key`,不再依赖测试库中缺失的 `sys_google_routes_key`;Key 不进入闪送响应或业务日志。
+- [x] Google Routes 失败时记录车型与脱敏失败原因后降级直线距离;HTTP 非成功响应记录状态码,不记录地址、坐标、Key 或完整响应。
+- [x] `GoogleRoutesDistanceProviderTest` 与 `FlashDeliveryRouteServiceTest` 共 5 项通过。
+- [ ] 从当前开发机访问 `routes.googleapis.com:443` 连接超时,未能验证共享 Key 的 Routes API 权限;需在测试服务器部署后查看新告警日志或从该服务器网络发起验证。

+ 1 - 1
specs/024-flash-delivery/quickstart.md

@@ -1,6 +1,6 @@
 # 闪送 API 联调快速开始
 
-1. 开发者人工执行 `updatesql/sql.md` 中既有闪送迁移及 2026-09-07 统一运价增量,核对每个时段的加急比例和最低加急费;并在字典 `sys_google_routes_key` 配置仅供后端使用、已启用 Google Routes API 的独立密钥,不得复用匿名接口可读取的 `sys_googlemap_key`
+1. 开发者人工执行 `updatesql/sql.md` 中既有闪送迁移及 2026-09-07 统一运价增量,核对每个时段的加急比例和最低加急费;闪送与外卖共用字典 `sys_googlemap_key`,该 Key 必须启用 Google Routes API,并在 Google Cloud 配置 API 限制、来源限制和配额
 2. 启动 `ruoyi-admin`;用户和骑手请求携带现有 `token` 请求头,平台请求使用后台权限。
 3. 调用 `/system/flashDelivery/home`、`/quote`、`/orders`;报价提交帮送/帮取业务场景、普通/加急配送等级、立即/预约取件时段、完整物品信息和骑手小费。创建时回传报价 ID、版本及各项金额,服务端复算不一致时返回最新报价且不创建订单;收件账号匹配结果固化在新订单中,不追溯历史订单。
 4. 用户通过 `/system/flashDelivery/orders?role=sender` 查看“我发的”,通过 `role=receiver` 查看“我收的”;省略 `role` 时默认 `sender`。发件人与固化收件人均可查看详情和确认收货,只有发件人可以取消。

+ 1 - 1
specs/024-flash-delivery/research.md

@@ -4,7 +4,7 @@
 
 采用 Google Routes API v2 的 `POST https://routes.googleapis.com/directions/v2:computeRoutes`。请求头使用 `X-Goog-Api-Key` 和 `X-Goog-FieldMask: routes.distanceMeters,routes.duration`,请求体使用取件、收件经纬度、`DRIVE`、`TRAFFIC_AWARE`。参考 Google 官方 [Compute Routes](https://developers.google.com/maps/documentation/routes/reference/rest/v2/TopLevel/computeRoutes)、[计算路线](https://developers.google.com/maps/documentation/routes/compute_route_directions) 和 [字段掩码](https://developers.google.com/maps/documentation/routes/choose_fields)。
 
-服务端只从字典 `sys_google_routes_key` 读取独立密钥,不复用可能由现有匿名接口返回的浏览器密钥 `sys_googlemap_key`,也不在响应或日志中输出服务端密钥。该密钥需启用 Routes API 并限制服务端来源。连接/读取超时、HTTP 非成功、JSON 无路线或距离不大于 0 均降级。
+闪送与外卖共用字典 `sys_googlemap_key`,不在闪送响应或日志中输出 Key。该 Key 需启用 Routes API,并在 Google Cloud 配置 API 限制、来源限制和配额。连接/读取超时、HTTP 非成功、JSON 无路线或距离不大于 0 均降级;降级告警仅记录车型和失败原因,不记录地址、坐标或 Key
 
 ## 降级与精度
 

+ 1 - 1
specs/024-flash-delivery/spec.md

@@ -178,7 +178,7 @@
 - **FR-005**:立即订单必须按报价时刻匹配唯一运价时段,预约订单必须按 `scheduledPickupStartAt` 匹配唯一运价时段;全部时间段之间不得重叠,没有匹配时段时必须拒绝报价和创建订单。
 - **FR-006**:计价必须与现有外卖订单一致:起送距离内只收起送价格;超出不足 0.5 公里不加价,超出 0.5 公里但不足 1 公里按 1 公里计,超出至少 1 公里按实际超出距离计;里程费用四舍五入到整数元后与起送价格相加,所有 TWD 金额均为整数且不再按百位或千位特殊取整。
 - **FR-007**:系统必须优先使用地图路线距离,地图失败时使用经纬度直线距离并向客户端返回距离来源。
-- **FR-008**:地图密钥必须从服务端配置读取,不得返回客户端或写入业务日志
+- **FR-008**:闪送与外卖共用字典 `sys_googlemap_key`;闪送接口和业务日志不得返回或写入该 Key,Google Cloud 必须限制其 API、来源和配额
 - **FR-009**:创建订单时前端必须回传报价配置 ID、版本、基础配送费、距离费、加急费和总金额;服务端必须重新计算距离和价格并逐项精确校验,一致后才能保存地址、路线、金额和计价配置快照,不一致时不得创建订单并必须返回最新报价。
 - **FR-010**:系统必须使用客户端请求号保证同一用户的订单创建幂等。
 - **FR-011**:闪送订单不得复用餐饮订单或打车订单数据模型。

+ 1 - 0
specs/024-flash-delivery/tasks.md

@@ -181,3 +181,4 @@
 - [ ] T100 补充并执行顺序调整验收:有无原因/声明、直接取件绕过、导航不推进状态、B 取件后回 A、原顺序与金额不变、进度/取消按钮、声明展示、异常交接、调整重复及与取件/取消并发、已取站重试;配合 T095 记录实际测试与 App/数据库联调结果
 
 - [x] T101 【2026-09-17】按 `vehicleType`(1=机车、2=轿车)扩展闪送运价、报价、订单快照、骑手待抢/详情/接单校验、Google Routes(机车 TWO_WHEELER、轿车 DRIVE)与平台运价配置;历史数据默认机车;重量范围和规格改为选填。本期不修改用户 App,详见 spec.md「按配送车型计价」。
+- [x] T102 【2026-09-17】闪送与外卖共用 `sys_googlemap_key`;Google Routes 降级日志仅记录车型和失败原因,不记录地址、坐标或 Key;同步契约、快速开始和研究文档。