fix: 配送地图地理编码改为后端签名代理
Some checks failed
Build and Deploy TenantApi / build-and-deploy (push) Failing after 36s

This commit is contained in:
2026-02-19 16:05:57 +08:00
parent 2c086d1149
commit d5b22a8d85
7 changed files with 580 additions and 4 deletions

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.TenantApi.Contracts.Store;
/// <summary>
/// 地址地理编码返回结果。
/// </summary>
public sealed class StoreDeliveryGeocodeDto
{
/// <summary>
/// 输入地址。
/// </summary>
public string Address { get; set; } = string.Empty;
/// <summary>
/// 纬度。
/// </summary>
public decimal? Latitude { get; set; }
/// <summary>
/// 经度。
/// </summary>
public decimal? Longitude { get; set; }
}

View File

@@ -6,9 +6,12 @@ using TakeoutSaaS.Application.App.Stores.Services;
using TakeoutSaaS.Domain.Stores.Entities;
using TakeoutSaaS.Domain.Stores.Enums;
using TakeoutSaaS.Infrastructure.App.Persistence;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
using TakeoutSaaS.TenantApi.Contracts.Store;
using TakeoutSaaS.TenantApi.Services;
namespace TakeoutSaaS.TenantApi.Controllers;
@@ -20,7 +23,8 @@ namespace TakeoutSaaS.TenantApi.Controllers;
[Route("api/tenant/v{version:apiVersion}/store")]
public sealed class StoreDeliveryController(
TakeoutAppDbContext dbContext,
StoreContextService storeContextService) : BaseApiController
StoreContextService storeContextService,
TencentMapGeocodingService tencentMapGeocodingService) : BaseApiController
{
/// <summary>
/// 获取门店配送设置。
@@ -48,6 +52,8 @@ public sealed class StoreDeliveryController(
{
StoreId = parsedStoreId.ToString(),
Mode = StoreApiHelpers.ToDeliveryModeText(setting?.Mode ?? StoreDeliveryMode.Radius),
RadiusCenterLatitude = setting?.RadiusCenterLatitude,
RadiusCenterLongitude = setting?.RadiusCenterLongitude,
RadiusTiers = radiusTiers,
PolygonZones = polygonZones.Select(MapPolygonZone).ToList(),
GeneralSettings = new DeliveryGeneralSettingsDto
@@ -60,6 +66,30 @@ public sealed class StoreDeliveryController(
});
}
/// <summary>
/// 地址地理编码(服务端签名)。
/// </summary>
[HttpGet("delivery/geocode")]
[ProducesResponseType(typeof(ApiResponse<StoreDeliveryGeocodeDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<StoreDeliveryGeocodeDto>> Geocode(
[FromQuery] string address,
CancellationToken cancellationToken)
{
var normalizedAddress = address?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(normalizedAddress))
{
throw new BusinessException(ErrorCodes.BadRequest, "address 不能为空");
}
var result = await tencentMapGeocodingService.GeocodeAsync(normalizedAddress, cancellationToken);
return ApiResponse<StoreDeliveryGeocodeDto>.Ok(new StoreDeliveryGeocodeDto
{
Address = normalizedAddress,
Latitude = result?.Latitude,
Longitude = result?.Longitude
});
}
/// <summary>
/// 保存门店配送设置。
/// </summary>
@@ -83,10 +113,15 @@ public sealed class StoreDeliveryController(
}
setting.Mode = StoreApiHelpers.ToDeliveryMode(request.Mode);
var (radiusCenterLatitude, radiusCenterLongitude) = NormalizeRadiusCenter(
request.RadiusCenterLatitude,
request.RadiusCenterLongitude);
setting.EtaAdjustmentMinutes = Math.Clamp(request.GeneralSettings.EtaAdjustmentMinutes, 0, 240);
setting.FreeDeliveryThreshold = request.GeneralSettings.FreeDeliveryThreshold;
setting.HourlyCapacityLimit = Math.Clamp(request.GeneralSettings.HourlyCapacityLimit, 1, 9999);
setting.MaxDeliveryDistance = Math.Max(0m, request.GeneralSettings.MaxDeliveryDistance);
setting.RadiusCenterLatitude = radiusCenterLatitude;
setting.RadiusCenterLongitude = radiusCenterLongitude;
setting.RadiusTiersJson = JsonSerializer.Serialize(NormalizeRadiusTiers(request.RadiusTiers), StoreApiHelpers.JsonOptions);
var existingZones = await dbContext.StoreDeliveryZones
@@ -109,13 +144,13 @@ public sealed class StoreDeliveryController(
{
entity = new StoreDeliveryZone
{
StoreId = parsedStoreId,
PolygonGeoJson = "{}"
StoreId = parsedStoreId
};
await dbContext.StoreDeliveryZones.AddAsync(entity, cancellationToken);
}
entity.ZoneName = zone.Name?.Trim() ?? string.Empty;
entity.PolygonGeoJson = NormalizePolygonGeoJson(zone.PolygonGeoJson);
entity.Color = string.IsNullOrWhiteSpace(zone.Color) ? "#1677ff" : zone.Color.Trim();
entity.MinimumOrderAmount = Math.Max(0m, zone.MinOrderAmount);
entity.DeliveryFee = Math.Max(0m, zone.DeliveryFee);
@@ -190,6 +225,8 @@ public sealed class StoreDeliveryController(
targetSetting.FreeDeliveryThreshold = sourceSetting?.FreeDeliveryThreshold ?? 30m;
targetSetting.HourlyCapacityLimit = sourceSetting?.HourlyCapacityLimit ?? 50;
targetSetting.MaxDeliveryDistance = sourceSetting?.MaxDeliveryDistance ?? 5m;
targetSetting.RadiusCenterLatitude = sourceSetting?.RadiusCenterLatitude;
targetSetting.RadiusCenterLongitude = sourceSetting?.RadiusCenterLongitude;
targetSetting.RadiusTiersJson = sourceSetting?.RadiusTiersJson;
}
@@ -310,7 +347,187 @@ public sealed class StoreDeliveryController(
DeliveryFee = source.DeliveryFee ?? 0m,
EtaMinutes = source.EstimatedMinutes ?? 20,
MinOrderAmount = source.MinimumOrderAmount ?? 0m,
Priority = source.Priority
Priority = source.Priority,
PolygonGeoJson = source.PolygonGeoJson
};
}
private static string NormalizePolygonGeoJson(string? polygonGeoJson)
{
if (string.IsNullOrWhiteSpace(polygonGeoJson))
{
throw new BusinessException(ErrorCodes.BadRequest, "请先绘制配送区域");
}
try
{
using var document = JsonDocument.Parse(polygonGeoJson);
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
throw new BusinessException(ErrorCodes.BadRequest, "区域图形格式非法");
}
if (!root.TryGetProperty("type", out var typeElement) ||
!string.Equals(typeElement.GetString(), "FeatureCollection", StringComparison.OrdinalIgnoreCase))
{
throw new BusinessException(ErrorCodes.BadRequest, "区域图形必须为 FeatureCollection");
}
if (!root.TryGetProperty("features", out var featuresElement) ||
featuresElement.ValueKind != JsonValueKind.Array ||
featuresElement.GetArrayLength() == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "至少绘制一个配送区域");
}
var polygonCount = 0;
foreach (var feature in featuresElement.EnumerateArray())
{
if (feature.ValueKind != JsonValueKind.Object ||
!feature.TryGetProperty("geometry", out var geometryElement) ||
geometryElement.ValueKind != JsonValueKind.Object)
{
throw new BusinessException(ErrorCodes.BadRequest, "区域图形格式非法");
}
if (!geometryElement.TryGetProperty("type", out var geometryTypeElement) ||
!string.Equals(geometryTypeElement.GetString(), "Polygon", StringComparison.OrdinalIgnoreCase))
{
throw new BusinessException(ErrorCodes.BadRequest, "仅支持 Polygon 多边形区域");
}
if (!geometryElement.TryGetProperty("coordinates", out var coordinatesElement) ||
coordinatesElement.ValueKind != JsonValueKind.Array ||
coordinatesElement.GetArrayLength() == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "多边形坐标不能为空");
}
ValidatePolygonCoordinates(coordinatesElement);
polygonCount++;
}
if (polygonCount == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "至少绘制一个配送区域");
}
return JsonSerializer.Serialize(root, StoreApiHelpers.JsonOptions);
}
catch (JsonException)
{
throw new BusinessException(ErrorCodes.BadRequest, "区域图形格式非法");
}
}
private static void ValidatePolygonCoordinates(JsonElement coordinatesElement)
{
foreach (var ringElement in coordinatesElement.EnumerateArray())
{
if (ringElement.ValueKind != JsonValueKind.Array)
{
throw new BusinessException(ErrorCodes.BadRequest, "多边形坐标格式非法");
}
double? firstLng = null;
double? firstLat = null;
double? lastLng = null;
double? lastLat = null;
var pointCount = 0;
foreach (var pointElement in ringElement.EnumerateArray())
{
if (!TryReadLngLat(pointElement, out var lng, out var lat))
{
throw new BusinessException(ErrorCodes.BadRequest, "多边形坐标格式非法");
}
if (lng is < -180 or > 180 || lat is < -90 or > 90)
{
throw new BusinessException(ErrorCodes.BadRequest, "多边形坐标越界");
}
if (pointCount == 0)
{
firstLng = lng;
firstLat = lat;
}
lastLng = lng;
lastLat = lat;
pointCount++;
}
if (pointCount < 4)
{
throw new BusinessException(ErrorCodes.BadRequest, "多边形顶点数量不足");
}
if (firstLng is null || firstLat is null || lastLng is null || lastLat is null)
{
throw new BusinessException(ErrorCodes.BadRequest, "多边形坐标格式非法");
}
if (Math.Abs(firstLng.Value - lastLng.Value) > 1e-8 || Math.Abs(firstLat.Value - lastLat.Value) > 1e-8)
{
throw new BusinessException(ErrorCodes.BadRequest, "多边形首尾坐标必须闭合");
}
}
}
private static bool TryReadLngLat(JsonElement pointElement, out double lng, out double lat)
{
lng = 0;
lat = 0;
if (pointElement.ValueKind != JsonValueKind.Array)
{
return false;
}
var coordinateEnumerator = pointElement.EnumerateArray();
if (!coordinateEnumerator.MoveNext() ||
coordinateEnumerator.Current.ValueKind != JsonValueKind.Number ||
!coordinateEnumerator.Current.TryGetDouble(out lng))
{
return false;
}
if (!coordinateEnumerator.MoveNext() ||
coordinateEnumerator.Current.ValueKind != JsonValueKind.Number ||
!coordinateEnumerator.Current.TryGetDouble(out lat))
{
return false;
}
return true;
}
private static (decimal? Latitude, decimal? Longitude) NormalizeRadiusCenter(
decimal? latitude,
decimal? longitude)
{
if (latitude is null && longitude is null)
{
return (null, null);
}
if (latitude is null || longitude is null)
{
throw new BusinessException(ErrorCodes.BadRequest, "半径配送中心点经纬度必须同时填写");
}
if (latitude < -90m || latitude > 90m)
{
throw new BusinessException(ErrorCodes.BadRequest, "纬度必须在 -90 到 90 之间");
}
if (longitude < -180m || longitude > 180m)
{
throw new BusinessException(ErrorCodes.BadRequest, "经度必须在 -180 到 180 之间");
}
return (decimal.Round(latitude.Value, 7), decimal.Round(longitude.Value, 7));
}
}

View File

@@ -0,0 +1,32 @@
namespace TakeoutSaaS.TenantApi.Options;
/// <summary>
/// 腾讯地图 WebService 配置。
/// </summary>
public sealed class TencentMapOptions
{
/// <summary>
/// 配置节名称。
/// </summary>
public const string SectionName = "TencentMap";
/// <summary>
/// WebService 基础地址。
/// </summary>
public string BaseUrl { get; set; } = "https://apis.map.qq.com";
/// <summary>
/// 地理编码路径。
/// </summary>
public string GeocoderPath { get; set; } = "/ws/geocoder/v1/";
/// <summary>
/// WebService Key。
/// </summary>
public string WebServiceKey { get; set; } = string.Empty;
/// <summary>
/// WebService SecretKeySK
/// </summary>
public string WebServiceSecret { get; set; } = string.Empty;
}

View File

@@ -22,6 +22,8 @@ using TakeoutSaaS.Shared.Web.Extensions;
using TakeoutSaaS.Shared.Web.Filters;
using TakeoutSaaS.Shared.Web.Security;
using TakeoutSaaS.Shared.Web.Swagger;
using TakeoutSaaS.TenantApi.Options;
using TakeoutSaaS.TenantApi.Services;
// 1. 创建构建器与日志模板
var builder = WebApplication.CreateBuilder(args);
@@ -108,6 +110,14 @@ builder.Services.AddDictionaryInfrastructure(builder.Configuration);
builder.Services.AddMessagingApplication();
builder.Services.AddMessagingModule(builder.Configuration);
// 9.1 注册腾讯地图地理编码服务(服务端签名)
builder.Services.Configure<TencentMapOptions>(builder.Configuration.GetSection(TencentMapOptions.SectionName));
builder.Services.AddHttpClient(TencentMapGeocodingService.HttpClientName, client =>
{
client.Timeout = TimeSpan.FromSeconds(8);
});
builder.Services.AddScoped<TencentMapGeocodingService>();
// 10. 配置 OpenTelemetry 采集
var otelSection = builder.Configuration.GetSection("Otel");
var otelEndpoint = otelSection.GetValue<string>("Endpoint");

View File

@@ -0,0 +1,283 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Options;
using TakeoutSaaS.TenantApi.Options;
namespace TakeoutSaaS.TenantApi.Services;
/// <summary>
/// 腾讯地图地理编码服务(服务端签名版)。
/// </summary>
public sealed class TencentMapGeocodingService(
IHttpClientFactory httpClientFactory,
IOptionsMonitor<TencentMapOptions> optionsMonitor,
ILogger<TencentMapGeocodingService> logger)
{
/// <summary>
/// HttpClient 名称。
/// </summary>
public const string HttpClientName = "TencentMapWebService";
/// <summary>
/// 根据地址解析经纬度。
/// </summary>
/// <param name="rawAddress">地址文本。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>解析结果;失败时返回 <c>null</c>。</returns>
public async Task<(decimal Latitude, decimal Longitude)?> GeocodeAsync(
string rawAddress,
CancellationToken cancellationToken)
{
// 1. 预处理地址文本,空值直接返回。
var address = rawAddress?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(address))
{
return null;
}
// 2. 读取腾讯地图配置并做兜底校验。
var options = optionsMonitor.CurrentValue;
var key = options.WebServiceKey?.Trim() ?? string.Empty;
var secret = options.WebServiceSecret?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(secret))
{
logger.LogWarning("腾讯地图 WebService 未配置 key/sk已跳过地址解析。");
return null;
}
// 3. 同时尝试两种签名方式,兼容不同编码策略。
var baseUrl = NormalizeBaseUrl(options.BaseUrl);
var geocoderPath = NormalizePath(options.GeocoderPath);
foreach (var useEncodedValueInSignature in new[] { false, true })
{
var query = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
["address"] = address,
["key"] = key
};
query["sig"] = BuildSignature(
geocoderPath,
query,
secret,
useEncodedValueInSignature);
var requestUri = BuildRequestUri(baseUrl, geocoderPath, query);
var response = await RequestAsync(requestUri, cancellationToken);
if (response is null)
{
continue;
}
// 4. 成功状态直接返回经纬度。
if (response.Status == 0 &&
response.Latitude is not null &&
response.Longitude is not null)
{
return (response.Latitude.Value, response.Longitude.Value);
}
// 5. 仅在签名错误时继续下一轮重试,其他状态直接终止。
if (response.Status != 111)
{
break;
}
}
return null;
}
private async Task<TencentGeocodeResponse?> RequestAsync(
string requestUri,
CancellationToken cancellationToken)
{
// 1. 发起请求并获取响应文本。
using var httpClient = httpClientFactory.CreateClient(HttpClientName);
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
// 2. 解析响应,失败时返回 null。
if (!TryParseGeocodeResponse(responseBody, out var parsed))
{
logger.LogWarning("腾讯地图地理编码响应无法解析。");
return null;
}
// 3. 非 0 状态记录诊断信息(不包含敏感密钥)。
if (parsed.Status != 0)
{
logger.LogWarning(
"腾讯地图地理编码失败Status={Status}, Message={Message}",
parsed.Status,
parsed.Message);
}
return parsed;
}
private static string BuildRequestUri(
string baseUrl,
string geocoderPath,
IReadOnlyDictionary<string, string> query)
{
var canonicalQuery = string.Join(
"&",
query
.OrderBy(x => x.Key, StringComparer.Ordinal)
.Select(x => $"{x.Key}={Uri.EscapeDataString(x.Value)}"));
var requestUri = new Uri(new Uri(baseUrl, UriKind.Absolute), geocoderPath);
return $"{requestUri}?{canonicalQuery}";
}
private static string BuildSignature(
string geocoderPath,
IReadOnlyDictionary<string, string> query,
string secret,
bool useEncodedValue)
{
var canonicalQuery = string.Join(
"&",
query
.OrderBy(x => x.Key, StringComparer.Ordinal)
.Select(x =>
{
var value = useEncodedValue
? Uri.EscapeDataString(x.Value)
: x.Value;
return $"{x.Key}={value}";
}));
var payload = $"{geocoderPath}?{canonicalQuery}{secret}";
var hashBytes = MD5.HashData(Encoding.UTF8.GetBytes(payload));
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
private static bool TryParseGeocodeResponse(
string responseBody,
out TencentGeocodeResponse response)
{
response = new TencentGeocodeResponse();
try
{
using var document = JsonDocument.Parse(responseBody);
var root = document.RootElement;
response.Status = ReadStatus(root);
response.Message = root.TryGetProperty("message", out var messageElement)
? messageElement.GetString() ?? string.Empty
: string.Empty;
if (!root.TryGetProperty("result", out var resultElement) ||
resultElement.ValueKind != JsonValueKind.Object ||
!resultElement.TryGetProperty("location", out var locationElement) ||
locationElement.ValueKind != JsonValueKind.Object)
{
return true;
}
if (!TryReadDecimal(locationElement, "lat", out var latitude) ||
!TryReadDecimal(locationElement, "lng", out var longitude))
{
return true;
}
response.Latitude = decimal.Round(latitude, 7);
response.Longitude = decimal.Round(longitude, 7);
return true;
}
catch (JsonException)
{
return false;
}
}
private static int ReadStatus(JsonElement root)
{
if (!root.TryGetProperty("status", out var statusElement))
{
return -1;
}
return statusElement.ValueKind switch
{
JsonValueKind.Number when statusElement.TryGetInt32(out var numeric) => numeric,
JsonValueKind.String when int.TryParse(
statusElement.GetString(),
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out var parsed) => parsed,
_ => -1
};
}
private static bool TryReadDecimal(
JsonElement parent,
string propertyName,
out decimal value)
{
value = 0m;
if (!parent.TryGetProperty(propertyName, out var element))
{
return false;
}
return element.ValueKind switch
{
JsonValueKind.Number when element.TryGetDecimal(out var numericValue)
=> Assign(out value, numericValue),
JsonValueKind.String when decimal.TryParse(
element.GetString(),
NumberStyles.Float,
CultureInfo.InvariantCulture,
out var parsedValue) => Assign(out value, parsedValue),
_ => false
};
}
private static bool Assign(out decimal target, decimal value)
{
target = value;
return true;
}
private static string NormalizeBaseUrl(string? baseUrl)
{
var normalized = string.IsNullOrWhiteSpace(baseUrl)
? "https://apis.map.qq.com"
: baseUrl.Trim();
return normalized.EndsWith('/') ? normalized : $"{normalized}/";
}
private static string NormalizePath(string? path)
{
var normalized = string.IsNullOrWhiteSpace(path)
? "/ws/geocoder/v1/"
: path.Trim();
if (!normalized.StartsWith('/'))
{
normalized = $"/{normalized}";
}
if (!normalized.EndsWith('/'))
{
normalized = $"{normalized}/";
}
return normalized;
}
private sealed class TencentGeocodeResponse
{
public decimal? Latitude { get; set; }
public decimal? Longitude { get; set; }
public string Message { get; set; } = string.Empty;
public int Status { get; set; }
}
}

View File

@@ -60,6 +60,12 @@
"CodeTenantMap": {},
"ThrowIfUnresolved": false
},
"TencentMap": {
"BaseUrl": "https://apis.map.qq.com",
"GeocoderPath": "/ws/geocoder/v1/",
"WebServiceKey": "DGIBZ-YUM64-5ONUT-F4RIA-MPUP2-XFFXZ",
"WebServiceSecret": "6ztzMqwtuOyaJLuBs1koRDyqNpnVyda8"
},
"Cors": {
"Tenant": []
},

View File

@@ -58,6 +58,12 @@
],
"RootDomain": ""
},
"TencentMap": {
"BaseUrl": "https://apis.map.qq.com",
"GeocoderPath": "/ws/geocoder/v1/",
"WebServiceKey": "DGIBZ-YUM64-5ONUT-F4RIA-MPUP2-XFFXZ",
"WebServiceSecret": "6ztzMqwtuOyaJLuBs1koRDyqNpnVyda8"
},
"Cors": {
"Tenant": []
},