fix: 配送地图地理编码改为后端签名代理
Some checks failed
Build and Deploy TenantApi / build-and-deploy (push) Failing after 36s
Some checks failed
Build and Deploy TenantApi / build-and-deploy (push) Failing after 36s
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
32
src/Api/TakeoutSaaS.TenantApi/Options/TencentMapOptions.cs
Normal file
32
src/Api/TakeoutSaaS.TenantApi/Options/TencentMapOptions.cs
Normal 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 SecretKey(SK)。
|
||||
/// </summary>
|
||||
public string WebServiceSecret { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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": []
|
||||
},
|
||||
|
||||
@@ -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": []
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user