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": []
},