diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Store/StoreDeliveryGeocodeContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Store/StoreDeliveryGeocodeContracts.cs new file mode 100644 index 0000000..446a8e6 --- /dev/null +++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Store/StoreDeliveryGeocodeContracts.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.TenantApi.Contracts.Store; + +/// +/// 地址地理编码返回结果。 +/// +public sealed class StoreDeliveryGeocodeDto +{ + /// + /// 输入地址。 + /// + public string Address { get; set; } = string.Empty; + + /// + /// 纬度。 + /// + public decimal? Latitude { get; set; } + + /// + /// 经度。 + /// + public decimal? Longitude { get; set; } +} diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/StoreDeliveryController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/StoreDeliveryController.cs index 4f0d09f..79ba327 100644 --- a/src/Api/TakeoutSaaS.TenantApi/Controllers/StoreDeliveryController.cs +++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/StoreDeliveryController.cs @@ -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 { /// /// 获取门店配送设置。 @@ -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( }); } + /// + /// 地址地理编码(服务端签名)。 + /// + [HttpGet("delivery/geocode")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> 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.Ok(new StoreDeliveryGeocodeDto + { + Address = normalizedAddress, + Latitude = result?.Latitude, + Longitude = result?.Longitude + }); + } + /// /// 保存门店配送设置。 /// @@ -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)); + } } diff --git a/src/Api/TakeoutSaaS.TenantApi/Options/TencentMapOptions.cs b/src/Api/TakeoutSaaS.TenantApi/Options/TencentMapOptions.cs new file mode 100644 index 0000000..ec1a488 --- /dev/null +++ b/src/Api/TakeoutSaaS.TenantApi/Options/TencentMapOptions.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.TenantApi.Options; + +/// +/// 腾讯地图 WebService 配置。 +/// +public sealed class TencentMapOptions +{ + /// + /// 配置节名称。 + /// + public const string SectionName = "TencentMap"; + + /// + /// WebService 基础地址。 + /// + public string BaseUrl { get; set; } = "https://apis.map.qq.com"; + + /// + /// 地理编码路径。 + /// + public string GeocoderPath { get; set; } = "/ws/geocoder/v1/"; + + /// + /// WebService Key。 + /// + public string WebServiceKey { get; set; } = string.Empty; + + /// + /// WebService SecretKey(SK)。 + /// + public string WebServiceSecret { get; set; } = string.Empty; +} diff --git a/src/Api/TakeoutSaaS.TenantApi/Program.cs b/src/Api/TakeoutSaaS.TenantApi/Program.cs index e12afc5..a7cc4d7 100644 --- a/src/Api/TakeoutSaaS.TenantApi/Program.cs +++ b/src/Api/TakeoutSaaS.TenantApi/Program.cs @@ -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(builder.Configuration.GetSection(TencentMapOptions.SectionName)); +builder.Services.AddHttpClient(TencentMapGeocodingService.HttpClientName, client => +{ + client.Timeout = TimeSpan.FromSeconds(8); +}); +builder.Services.AddScoped(); + // 10. 配置 OpenTelemetry 采集 var otelSection = builder.Configuration.GetSection("Otel"); var otelEndpoint = otelSection.GetValue("Endpoint"); diff --git a/src/Api/TakeoutSaaS.TenantApi/Services/TencentMapGeocodingService.cs b/src/Api/TakeoutSaaS.TenantApi/Services/TencentMapGeocodingService.cs new file mode 100644 index 0000000..cfd4a92 --- /dev/null +++ b/src/Api/TakeoutSaaS.TenantApi/Services/TencentMapGeocodingService.cs @@ -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; + +/// +/// 腾讯地图地理编码服务(服务端签名版)。 +/// +public sealed class TencentMapGeocodingService( + IHttpClientFactory httpClientFactory, + IOptionsMonitor optionsMonitor, + ILogger logger) +{ + /// + /// HttpClient 名称。 + /// + public const string HttpClientName = "TencentMapWebService"; + + /// + /// 根据地址解析经纬度。 + /// + /// 地址文本。 + /// 取消令牌。 + /// 解析结果;失败时返回 null + 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(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 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 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 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; } + } +} diff --git a/src/Api/TakeoutSaaS.TenantApi/appsettings.Development.json b/src/Api/TakeoutSaaS.TenantApi/appsettings.Development.json index 68b96da..9a37cf6 100644 --- a/src/Api/TakeoutSaaS.TenantApi/appsettings.Development.json +++ b/src/Api/TakeoutSaaS.TenantApi/appsettings.Development.json @@ -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": [] }, diff --git a/src/Api/TakeoutSaaS.TenantApi/appsettings.Production.json b/src/Api/TakeoutSaaS.TenantApi/appsettings.Production.json index 1ee4856..e78b196 100644 --- a/src/Api/TakeoutSaaS.TenantApi/appsettings.Production.json +++ b/src/Api/TakeoutSaaS.TenantApi/appsettings.Production.json @@ -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": [] },