Commit 815273bb20af37aa0e8c8bc10deed38d29a23254

Authored by shaofan
1 parent f3ec2e0c

新增高德电动车路径规划支持,完善配送费计算与路线规划接口

src/main/java/com/diligrp/rider/config/AmapProperties.java 0 → 100644
  1 +package com.diligrp.rider.config;
  2 +
  3 +import lombok.Data;
  4 +import org.springframework.boot.context.properties.ConfigurationProperties;
  5 +import org.springframework.context.annotation.Configuration;
  6 +
  7 +/**
  8 + * 高德 Web 服务 API 配置(电动车路径规划)。
  9 + */
  10 +@Data
  11 +@Configuration
  12 +@ConfigurationProperties(prefix = "amap")
  13 +public class AmapProperties {
  14 +
  15 + /** 是否启用高德 ETA;false 时调用直接返回空,由上游降级到直线距离公式 */
  16 + private boolean enabled = true;
  17 +
  18 + /** 高德 Web 服务 API Key */
  19 + private String key;
  20 +
  21 + /** 高德 Web 服务基础 URL */
  22 + private String baseUrl = "https://restapi.amap.com";
  23 +
  24 + /** HTTP 连接超时(毫秒) */
  25 + private int connectTimeoutMillis = 3000;
  26 +
  27 + /** HTTP 读取超时(毫秒) */
  28 + private int readTimeoutMillis = 3000;
  29 +}
src/main/java/com/diligrp/rider/controller/OpenApiController.java
@@ -4,15 +4,21 @@ import com.diligrp.rider.common.exception.BizException; @@ -4,15 +4,21 @@ import com.diligrp.rider.common.exception.BizException;
4 import com.diligrp.rider.common.result.Result; 4 import com.diligrp.rider.common.result.Result;
5 import com.diligrp.rider.dto.DeliveryFeeCalcDTO; 5 import com.diligrp.rider.dto.DeliveryFeeCalcDTO;
6 import com.diligrp.rider.dto.OpenDeliveryFeeCalcDTO; 6 import com.diligrp.rider.dto.OpenDeliveryFeeCalcDTO;
  7 +import com.diligrp.rider.dto.OpenRouteCalcDTO;
7 import com.diligrp.rider.entity.OpenApp; 8 import com.diligrp.rider.entity.OpenApp;
8 import com.diligrp.rider.service.DeliveryFeeService; 9 import com.diligrp.rider.service.DeliveryFeeService;
9 import com.diligrp.rider.service.OpenAppService; 10 import com.diligrp.rider.service.OpenAppService;
  11 +import com.diligrp.rider.service.amap.AmapRouteClient;
  12 +import com.diligrp.rider.util.GeoUtil;
10 import com.diligrp.rider.vo.DeliveryFeeResultVO; 13 import com.diligrp.rider.vo.DeliveryFeeResultVO;
  14 +import com.diligrp.rider.vo.RouteCalcVO;
11 import jakarta.servlet.http.HttpServletRequest; 15 import jakarta.servlet.http.HttpServletRequest;
12 import jakarta.validation.Valid; 16 import jakarta.validation.Valid;
13 import lombok.RequiredArgsConstructor; 17 import lombok.RequiredArgsConstructor;
14 import org.springframework.web.bind.annotation.*; 18 import org.springframework.web.bind.annotation.*;
15 19
  20 +import java.util.Optional;
  21 +
16 /** 22 /**
17 * 开放平台对外接口 23 * 开放平台对外接口
18 * 路径前缀 /api/open/** 受 OpenApiInterceptor 签名鉴权保护 24 * 路径前缀 /api/open/** 受 OpenApiInterceptor 签名鉴权保护
@@ -25,6 +31,7 @@ public class OpenApiController { @@ -25,6 +31,7 @@ public class OpenApiController {
25 31
26 private final DeliveryFeeService deliveryFeeService; 32 private final DeliveryFeeService deliveryFeeService;
27 private final OpenAppService openAppService; 33 private final OpenAppService openAppService;
  34 + private final AmapRouteClient amapRouteClient;
28 35
29 /** 36 /**
30 * 计算配送费(对外开放版) 37 * 计算配送费(对外开放版)
@@ -55,6 +62,32 @@ public class OpenApiController { @@ -55,6 +62,32 @@ public class OpenApiController {
55 return Result.success(deliveryFeeService.isServiceEnabled(resolveCityId(request), orderType)); 62 return Result.success(deliveryFeeService.isServiceEnabled(resolveCityId(request), orderType));
56 } 63 }
57 64
  65 + /**
  66 + * 路线计算:传入起止经纬度,返回距离(米)与导航耗时(秒)。
  67 + * - distance 始终有值:优先高德电动车路径距离,失败时回退到直线距离
  68 + * - duration 高德未命中时为 null
  69 + */
  70 + @PostMapping("/route/calc")
  71 + public Result<RouteCalcVO> routeCalc(@Valid @RequestBody OpenRouteCalcDTO dto) {
  72 + Optional<AmapRouteClient.AmapRoute> route = amapRouteClient.getElectrobikeRoute(
  73 + dto.getStartLng(), dto.getStartLat(), dto.getEndLng(), dto.getEndLat());
  74 + if (route.isPresent()) {
  75 + return Result.success(new RouteCalcVO(route.get().distanceMeters(), route.get().durationSeconds()));
  76 + }
  77 + double km;
  78 + try {
  79 + km = GeoUtil.calcDistanceKm(
  80 + Double.parseDouble(dto.getStartLat()),
  81 + Double.parseDouble(dto.getStartLng()),
  82 + Double.parseDouble(dto.getEndLat()),
  83 + Double.parseDouble(dto.getEndLng()));
  84 + } catch (Exception e) {
  85 + throw new BizException("起止经纬度格式错误");
  86 + }
  87 + int distanceMeters = (int) Math.round(km * 1000);
  88 + return Result.success(new RouteCalcVO(distanceMeters, null));
  89 + }
  90 +
58 private Long resolveCityId(HttpServletRequest request) { 91 private Long resolveCityId(HttpServletRequest request) {
59 String appKey = request.getHeader("X-App-Key"); 92 String appKey = request.getHeader("X-App-Key");
60 OpenApp app = openAppService.getByAppKey(appKey); 93 OpenApp app = openAppService.getByAppKey(appKey);
src/main/java/com/diligrp/rider/dto/OpenRouteCalcDTO.java 0 → 100644
  1 +package com.diligrp.rider.dto;
  2 +
  3 +import jakarta.validation.constraints.NotNull;
  4 +import lombok.Data;
  5 +
  6 +/**
  7 + * 开放平台路线计算请求 DTO。
  8 + */
  9 +@Data
  10 +public class OpenRouteCalcDTO {
  11 +
  12 + /** 起点经度 */
  13 + @NotNull(message = "起点经度不能为空")
  14 + private String startLng;
  15 +
  16 + /** 起点纬度 */
  17 + @NotNull(message = "起点纬度不能为空")
  18 + private String startLat;
  19 +
  20 + /** 终点经度 */
  21 + @NotNull(message = "终点经度不能为空")
  22 + private String endLng;
  23 +
  24 + /** 终点纬度 */
  25 + @NotNull(message = "终点纬度不能为空")
  26 + private String endLat;
  27 +}
src/main/java/com/diligrp/rider/service/amap/AmapRouteClient.java 0 → 100644
  1 +package com.diligrp.rider.service.amap;
  2 +
  3 +import com.diligrp.rider.config.AmapProperties;
  4 +import com.fasterxml.jackson.databind.JsonNode;
  5 +import com.fasterxml.jackson.databind.ObjectMapper;
  6 +import lombok.extern.slf4j.Slf4j;
  7 +import org.springframework.stereotype.Component;
  8 +
  9 +import java.net.URI;
  10 +import java.net.URLEncoder;
  11 +import java.net.http.HttpClient;
  12 +import java.net.http.HttpRequest;
  13 +import java.net.http.HttpResponse;
  14 +import java.nio.charset.StandardCharsets;
  15 +import java.time.Duration;
  16 +import java.util.Optional;
  17 +
  18 +/**
  19 + * 高德电动车路径规划客户端(/v5/direction/electrobike)。
  20 + *
  21 + * 任何失败场景(未启用 / Key 缺失 / 坐标非法 / 起止同点 / HTTP 异常 / status != "1" / paths 空 / 字段缺失)
  22 + * 均返回 Optional.empty(),由调用方决定降级策略。
  23 + */
  24 +@Slf4j
  25 +@Component
  26 +public class AmapRouteClient {
  27 +
  28 + private final AmapProperties amapProperties;
  29 + private final ObjectMapper objectMapper;
  30 + private final HttpClient httpClient;
  31 +
  32 + public AmapRouteClient(AmapProperties amapProperties, ObjectMapper objectMapper) {
  33 + this.amapProperties = amapProperties;
  34 + this.objectMapper = objectMapper;
  35 + this.httpClient = HttpClient.newBuilder()
  36 + .connectTimeout(Duration.ofMillis(amapProperties.getConnectTimeoutMillis()))
  37 + .build();
  38 + }
  39 +
  40 + /** 高德电动车路径规划结果:原始距离(米)与耗时(秒)。 */
  41 + public record AmapRoute(int distanceMeters, int durationSeconds) {}
  42 +
  43 + /**
  44 + * 调用高德电动车路径规划,返回原始距离(米)+ 耗时(秒)。
  45 + */
  46 + public Optional<AmapRoute> getElectrobikeRoute(String startLng, String startLat,
  47 + String endLng, String endLat) {
  48 + if (!amapProperties.isEnabled()) {
  49 + return Optional.empty();
  50 + }
  51 + if (amapProperties.getKey() == null || amapProperties.getKey().isBlank()) {
  52 + log.warn("Amap key 未配置,跳过高德调用");
  53 + return Optional.empty();
  54 + }
  55 +
  56 + double sLng;
  57 + double sLat;
  58 + double eLng;
  59 + double eLat;
  60 + try {
  61 + sLng = Double.parseDouble(startLng);
  62 + sLat = Double.parseDouble(startLat);
  63 + eLng = Double.parseDouble(endLng);
  64 + eLat = Double.parseDouble(endLat);
  65 + } catch (Exception e) {
  66 + return Optional.empty();
  67 + }
  68 + if (sLng == eLng && sLat == eLat) {
  69 + return Optional.empty();
  70 + }
  71 +
  72 + String origin = String.format("%.6f,%.6f", sLng, sLat);
  73 + String destination = String.format("%.6f,%.6f", eLng, eLat);
  74 + String url = amapProperties.getBaseUrl() + "/v5/direction/electrobike"
  75 + + "?key=" + URLEncoder.encode(amapProperties.getKey(), StandardCharsets.UTF_8)
  76 + + "&origin=" + URLEncoder.encode(origin, StandardCharsets.UTF_8)
  77 + + "&destination=" + URLEncoder.encode(destination, StandardCharsets.UTF_8)
  78 + + "&show_fields=cost";
  79 +
  80 + try {
  81 + HttpRequest request = HttpRequest.newBuilder()
  82 + .uri(URI.create(url))
  83 + .timeout(Duration.ofMillis(amapProperties.getReadTimeoutMillis()))
  84 + .GET()
  85 + .build();
  86 + HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
  87 + if (response.statusCode() != 200) {
  88 + log.warn("Amap electrobike HTTP {} body={}", response.statusCode(), trim(response.body()));
  89 + return Optional.empty();
  90 + }
  91 + JsonNode root = objectMapper.readTree(response.body());
  92 + String status = root.path("status").asText();
  93 + if (!"1".equals(status)) {
  94 + log.warn("Amap electrobike status={} info={} infocode={}",
  95 + status, root.path("info").asText(), root.path("infocode").asText());
  96 + return Optional.empty();
  97 + }
  98 + JsonNode paths = root.path("route").path("paths");
  99 + if (!paths.isArray() || paths.isEmpty()) {
  100 + log.warn("Amap electrobike paths 空 body={}", trim(response.body()));
  101 + return Optional.empty();
  102 + }
  103 + JsonNode first = paths.get(0);
  104 + long distanceMeters = first.path("distance").asLong(-1);
  105 + long durationSeconds = first.path("cost").path("duration").asLong(-1);
  106 + if (distanceMeters < 0 || durationSeconds <= 0) {
  107 + log.warn("Amap electrobike 字段异常 distance={} duration={} body={}",
  108 + distanceMeters, durationSeconds, trim(response.body()));
  109 + return Optional.empty();
  110 + }
  111 + return Optional.of(new AmapRoute((int) distanceMeters, (int) durationSeconds));
  112 + } catch (Exception e) {
  113 + log.warn("Amap electrobike 调用失败 err={}", e.getMessage());
  114 + return Optional.empty();
  115 + }
  116 + }
  117 +
  118 + /**
  119 + * 返回预计骑行分钟数(向上取整)。委托 {@link #getElectrobikeRoute}。
  120 + */
  121 + public Optional<Integer> getElectrobikeDurationMinutes(String startLng, String startLat,
  122 + String endLng, String endLat) {
  123 + return getElectrobikeRoute(startLng, startLat, endLng, endLat)
  124 + .map(r -> (int) Math.ceil(r.durationSeconds() / 60.0));
  125 + }
  126 +
  127 + private String trim(String body) {
  128 + if (body == null) return "";
  129 + return body.length() > 300 ? body.substring(0, 300) : body;
  130 + }
  131 +}
src/main/java/com/diligrp/rider/service/impl/DeliveryFeeServiceImpl.java
@@ -6,6 +6,7 @@ import com.diligrp.rider.dto.DeliveryPricingRuleDTO; @@ -6,6 +6,7 @@ import com.diligrp.rider.dto.DeliveryPricingRuleDTO;
6 import com.diligrp.rider.dto.DeliveryFeeCalcDTO; 6 import com.diligrp.rider.dto.DeliveryFeeCalcDTO;
7 import com.diligrp.rider.service.CityService; 7 import com.diligrp.rider.service.CityService;
8 import com.diligrp.rider.service.DeliveryFeeService; 8 import com.diligrp.rider.service.DeliveryFeeService;
  9 +import com.diligrp.rider.service.amap.AmapRouteClient;
9 import com.diligrp.rider.util.GeoUtil; 10 import com.diligrp.rider.util.GeoUtil;
10 import com.diligrp.rider.vo.DeliveryFeeResultVO; 11 import com.diligrp.rider.vo.DeliveryFeeResultVO;
11 import lombok.RequiredArgsConstructor; 12 import lombok.RequiredArgsConstructor;
@@ -20,6 +21,7 @@ import java.time.ZonedDateTime; @@ -20,6 +21,7 @@ import java.time.ZonedDateTime;
20 import java.util.ArrayList; 21 import java.util.ArrayList;
21 import java.util.Comparator; 22 import java.util.Comparator;
22 import java.util.List; 23 import java.util.List;
  24 +import java.util.Optional;
23 25
24 /** 26 /**
25 * 配送费计算引擎 27 * 配送费计算引擎
@@ -31,6 +33,7 @@ import java.util.List; @@ -31,6 +33,7 @@ import java.util.List;
31 public class DeliveryFeeServiceImpl implements DeliveryFeeService { 33 public class DeliveryFeeServiceImpl implements DeliveryFeeService {
32 34
33 private final CityService cityService; 35 private final CityService cityService;
  36 + private final AmapRouteClient amapRouteClient;
34 37
35 @Override 38 @Override
36 public DeliveryFeeResultVO calcFee(DeliveryFeeCalcDTO dto) { 39 public DeliveryFeeResultVO calcFee(DeliveryFeeCalcDTO dto) {
@@ -182,7 +185,14 @@ public class DeliveryFeeServiceImpl implements DeliveryFeeService { @@ -182,7 +185,14 @@ public class DeliveryFeeServiceImpl implements DeliveryFeeService {
182 185
183 BigDecimal total = moneyBasic.add(moneyDistance).add(moneyWeight).add(moneyPiece).add(moneyTime); 186 BigDecimal total = moneyBasic.add(moneyDistance).add(moneyWeight).add(moneyPiece).add(moneyTime);
184 result.setTotalFee(applyMinFee(total, typeConfig, result)); 187 result.setTotalFee(applyMinFee(total, typeConfig, result));
185 - result.setEstimatedMinutes(calcEstimatedMinutes(pricingConfig, distanceKm)); 188 +
  189 + // ETA:优先取高德电动车路径耗时,失败降级到 distance_basic_time + 公式
  190 + Optional<Integer> amapMinutes = amapRouteClient.getElectrobikeDurationMinutes(
  191 + dto.getStartLng(), dto.getStartLat(), dto.getEndLng(), dto.getEndLat());
  192 + int estimatedMinutes = amapMinutes.isPresent()
  193 + ? amapMinutes.get()
  194 + : calcEstimatedMinutes(pricingConfig, distanceKm);
  195 + result.setEstimatedMinutes(estimatedMinutes);
186 196
187 return result; 197 return result;
188 } 198 }
src/main/java/com/diligrp/rider/vo/RouteCalcVO.java 0 → 100644
  1 +package com.diligrp.rider.vo;
  2 +
  3 +import lombok.AllArgsConstructor;
  4 +import lombok.Data;
  5 +import lombok.NoArgsConstructor;
  6 +
  7 +/**
  8 + * 开放平台路线计算结果 VO。
  9 + */
  10 +@Data
  11 +@NoArgsConstructor
  12 +@AllArgsConstructor
  13 +public class RouteCalcVO {
  14 +
  15 + /** 距离(米)。高德命中时为路径距离,否则为直线距离向上取整。 */
  16 + private Integer distance;
  17 +
  18 + /** 导航耗时(秒)。高德未命中时为 null。 */
  19 + private Integer duration;
  20 +}
src/main/resources/application.yml
@@ -32,12 +32,19 @@ jwt: @@ -32,12 +32,19 @@ jwt:
32 expire: 604800 # 7天,单位秒 32 expire: 604800 # 7天,单位秒
33 33
34 jpush: 34 jpush:
35 - enabled: false # 未配置 AppKey 前置 false,避免启动时报错 35 + enabled: true # 未配置 AppKey 前置 false,避免启动时报错
36 app-key: fd6e826ae6e67eaf7a7062c4 36 app-key: fd6e826ae6e67eaf7a7062c4
37 master-secret: 6eaa9a8d11493b56812b6ee9 37 master-secret: 6eaa9a8d11493b56812b6ee9
38 apns-production: false # iOS APNs:true=生产 false=开发 38 apns-production: false # iOS APNs:true=生产 false=开发
39 time-to-live: 86400 # 离线消息保留秒数(默认 1 天) 39 time-to-live: 86400 # 离线消息保留秒数(默认 1 天)
40 40
  41 +amap:
  42 + enabled: true # false 时跳过高德调用,ETA 走直线距离 + 公式
  43 + key: 5aa7f0260a51dcb2f1dda3130f9a0dbd # 高德 Web 服务 API Key,可由环境变量注入
  44 + base-url: https://restapi.amap.com
  45 + connect-timeout-millis: 3000
  46 + read-timeout-millis: 3000
  47 +
41 logging: 48 logging:
42 level: 49 level:
43 com.diligrp.rider: debug 50 com.diligrp.rider: debug
src/test/java/com/diligrp/rider/controller/OpenApiControllerTest.java 0 → 100644
  1 +package com.diligrp.rider.controller;
  2 +
  3 +import com.diligrp.rider.common.exception.BizException;
  4 +import com.diligrp.rider.common.result.Result;
  5 +import com.diligrp.rider.dto.OpenRouteCalcDTO;
  6 +import com.diligrp.rider.service.DeliveryFeeService;
  7 +import com.diligrp.rider.service.OpenAppService;
  8 +import com.diligrp.rider.service.amap.AmapRouteClient;
  9 +import com.diligrp.rider.util.GeoUtil;
  10 +import com.diligrp.rider.vo.RouteCalcVO;
  11 +import org.junit.jupiter.api.Test;
  12 +
  13 +import java.util.Optional;
  14 +
  15 +import static org.junit.jupiter.api.Assertions.assertEquals;
  16 +import static org.junit.jupiter.api.Assertions.assertNotNull;
  17 +import static org.junit.jupiter.api.Assertions.assertNull;
  18 +import static org.junit.jupiter.api.Assertions.assertThrows;
  19 +import static org.mockito.ArgumentMatchers.any;
  20 +import static org.mockito.Mockito.mock;
  21 +import static org.mockito.Mockito.when;
  22 +
  23 +class OpenApiControllerTest {
  24 +
  25 + private final DeliveryFeeService deliveryFeeService = mock(DeliveryFeeService.class);
  26 + private final OpenAppService openAppService = mock(OpenAppService.class);
  27 + private final AmapRouteClient amapRouteClient = mock(AmapRouteClient.class);
  28 + private final OpenApiController controller =
  29 + new OpenApiController(deliveryFeeService, openAppService, amapRouteClient);
  30 +
  31 + @Test
  32 + void routeCalcShouldReturnAmapValuesWhenHit() {
  33 + OpenRouteCalcDTO dto = dto("121.4737", "31.2304", "121.5067", "31.2454");
  34 + when(amapRouteClient.getElectrobikeRoute(
  35 + dto.getStartLng(), dto.getStartLat(), dto.getEndLng(), dto.getEndLat()))
  36 + .thenReturn(Optional.of(new AmapRouteClient.AmapRoute(4830, 1080)));
  37 +
  38 + Result<RouteCalcVO> result = controller.routeCalc(dto);
  39 +
  40 + assertEquals(0, result.getCode());
  41 + assertEquals(4830, result.getData().getDistance());
  42 + assertEquals(1080, result.getData().getDuration());
  43 + }
  44 +
  45 + @Test
  46 + void routeCalcShouldFallbackToGeoUtilWhenAmapMisses() {
  47 + OpenRouteCalcDTO dto = dto("121.4737", "31.2304", "121.4737", "31.2574");
  48 + when(amapRouteClient.getElectrobikeRoute(any(), any(), any(), any()))
  49 + .thenReturn(Optional.empty());
  50 +
  51 + Result<RouteCalcVO> result = controller.routeCalc(dto);
  52 +
  53 + int expected = (int) Math.round(
  54 + GeoUtil.calcDistanceKm(31.2304, 121.4737, 31.2574, 121.4737) * 1000);
  55 + assertEquals(0, result.getCode());
  56 + assertNotNull(result.getData().getDistance());
  57 + assertEquals(expected, result.getData().getDistance());
  58 + assertNull(result.getData().getDuration());
  59 + }
  60 +
  61 + @Test
  62 + void routeCalcShouldThrowOnInvalidCoordinatesWhenAmapMisses() {
  63 + OpenRouteCalcDTO dto = dto("not-a-number", "31.2304", "121.5067", "31.2454");
  64 + when(amapRouteClient.getElectrobikeRoute(any(), any(), any(), any()))
  65 + .thenReturn(Optional.empty());
  66 +
  67 + assertThrows(BizException.class, () -> controller.routeCalc(dto));
  68 + }
  69 +
  70 + private OpenRouteCalcDTO dto(String startLng, String startLat, String endLng, String endLat) {
  71 + OpenRouteCalcDTO dto = new OpenRouteCalcDTO();
  72 + dto.setStartLng(startLng);
  73 + dto.setStartLat(startLat);
  74 + dto.setEndLng(endLng);
  75 + dto.setEndLat(endLat);
  76 + return dto;
  77 + }
  78 +}
src/test/java/com/diligrp/rider/service/impl/DeliveryFeeServiceImplTest.java
@@ -4,6 +4,7 @@ import com.diligrp.rider.dto.DeliveryPricingConfigDTO; @@ -4,6 +4,7 @@ import com.diligrp.rider.dto.DeliveryPricingConfigDTO;
4 import com.diligrp.rider.dto.DeliveryPricingRuleDTO; 4 import com.diligrp.rider.dto.DeliveryPricingRuleDTO;
5 import com.diligrp.rider.dto.DeliveryFeeCalcDTO; 5 import com.diligrp.rider.dto.DeliveryFeeCalcDTO;
6 import com.diligrp.rider.service.CityService; 6 import com.diligrp.rider.service.CityService;
  7 +import com.diligrp.rider.service.amap.AmapRouteClient;
7 import com.diligrp.rider.util.GeoUtil; 8 import com.diligrp.rider.util.GeoUtil;
8 import com.diligrp.rider.vo.DeliveryFeeResultVO; 9 import com.diligrp.rider.vo.DeliveryFeeResultVO;
9 import org.junit.jupiter.api.Test; 10 import org.junit.jupiter.api.Test;
@@ -14,13 +15,19 @@ import java.time.LocalDateTime; @@ -14,13 +15,19 @@ import java.time.LocalDateTime;
14 import java.time.ZoneId; 15 import java.time.ZoneId;
15 import java.util.Arrays; 16 import java.util.Arrays;
16 import java.util.List; 17 import java.util.List;
  18 +import java.util.Optional;
17 19
18 import static org.junit.jupiter.api.Assertions.assertEquals; 20 import static org.junit.jupiter.api.Assertions.assertEquals;
  21 +import static org.mockito.ArgumentMatchers.any;
19 import static org.mockito.Mockito.mock; 22 import static org.mockito.Mockito.mock;
  23 +import static org.mockito.Mockito.never;
  24 +import static org.mockito.Mockito.verify;
  25 +import static org.mockito.Mockito.when;
20 26
21 class DeliveryFeeServiceImplTest { 27 class DeliveryFeeServiceImplTest {
22 28
23 - private final DeliveryFeeServiceImpl service = new DeliveryFeeServiceImpl(mock(CityService.class)); 29 + private final AmapRouteClient amapRouteClient = mock(AmapRouteClient.class);
  30 + private final DeliveryFeeServiceImpl service = new DeliveryFeeServiceImpl(mock(CityService.class), amapRouteClient);
24 31
25 @Test 32 @Test
26 void shouldApplyMinFee() { 33 void shouldApplyMinFee() {
@@ -126,6 +133,52 @@ class DeliveryFeeServiceImplTest { @@ -126,6 +133,52 @@ class DeliveryFeeServiceImplTest {
126 assertEquals(new BigDecimal("2.50"), result.getTotalFee()); 133 assertEquals(new BigDecimal("2.50"), result.getTotalFee());
127 } 134 }
128 135
  136 + @Test
  137 + void shouldUseAmapDurationWhenAvailable() {
  138 + DeliveryPricingConfigDTO pricingConfig = defaultPricingConfig();
  139 + pricingConfig.getType6().setDistanceSwitch(0);
  140 + pricingConfig.getType6().setWeightSwitch(0);
  141 +
  142 + DeliveryFeeCalcDTO calc = baseCalc();
  143 + calc.setEndLat("31.2574");
  144 + calc.setEndLng("121.4737");
  145 + when(amapRouteClient.getElectrobikeDurationMinutes(
  146 + calc.getStartLng(), calc.getStartLat(), calc.getEndLng(), calc.getEndLat()))
  147 + .thenReturn(Optional.of(18));
  148 +
  149 + DeliveryFeeResultVO result = service.calcFeeByConfig(pricingConfig, calc);
  150 +
  151 + assertEquals(18, result.getEstimatedMinutes());
  152 + }
  153 +
  154 + @Test
  155 + void shouldFallbackToFormulaWhenAmapEmpty() {
  156 + DeliveryPricingConfigDTO pricingConfig = defaultPricingConfig();
  157 + pricingConfig.getType6().setDistanceSwitch(0);
  158 + pricingConfig.getType6().setWeightSwitch(0);
  159 +
  160 + when(amapRouteClient.getElectrobikeDurationMinutes(any(), any(), any(), any()))
  161 + .thenReturn(Optional.empty());
  162 +
  163 + DeliveryFeeResultVO result = service.calcFeeByConfig(pricingConfig, baseCalc());
  164 +
  165 + // distanceKm=0 时低于 distance_basic=3,应取基础时长 distance_basic_time=30
  166 + assertEquals(30, result.getEstimatedMinutes());
  167 + }
  168 +
  169 + @Test
  170 + void shouldNotCallAmapForFixedFee() {
  171 + DeliveryPricingConfigDTO pricingConfig = defaultPricingConfig();
  172 + pricingConfig.getType6().setFeeMode(1);
  173 + pricingConfig.getType6().setFixMoney(new BigDecimal("8.00"));
  174 +
  175 + DeliveryFeeResultVO result = service.calcFeeByConfig(pricingConfig, baseCalc());
  176 +
  177 + assertEquals(new BigDecimal("8.00"), result.getTotalFee());
  178 + assertEquals(30, result.getEstimatedMinutes());
  179 + verify(amapRouteClient, never()).getElectrobikeDurationMinutes(any(), any(), any(), any());
  180 + }
  181 +
129 private DeliveryPricingConfigDTO defaultPricingConfig() { 182 private DeliveryPricingConfigDTO defaultPricingConfig() {
130 DeliveryPricingConfigDTO config = new DeliveryPricingConfigDTO(); 183 DeliveryPricingConfigDTO config = new DeliveryPricingConfigDTO();
131 config.setType(Arrays.asList(6)); 184 config.setType(Arrays.asList(6));