feat(geo): add tenant/merchant/store geocode fallback and retry workflow
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 43s
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 43s
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Merchants.Commands;
|
||||
using TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
using TakeoutSaaS.Application.App.Merchants.Queries;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Services;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
@@ -14,7 +18,10 @@ namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/merchant")]
|
||||
public sealed class MerchantController(IMediator mediator) : BaseApiController
|
||||
public sealed class MerchantController(
|
||||
IMediator mediator,
|
||||
ITenantProvider tenantProvider,
|
||||
GeoLocationOrchestrator geoLocationOrchestrator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取当前登录用户对应的商户中心信息。
|
||||
@@ -32,4 +39,106 @@ public sealed class MerchantController(IMediator mediator) : BaseApiController
|
||||
// 2. 返回聚合信息
|
||||
return ApiResponse<CurrentMerchantCenterDto>.Ok(info);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新当前商户基础信息。
|
||||
/// </summary>
|
||||
/// <param name="request">更新请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>更新结果。</returns>
|
||||
[HttpPost("update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<UpdateMerchantResultDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<UpdateMerchantResultDto>), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ApiResponse<UpdateMerchantResultDto>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ApiResponse<UpdateMerchantResultDto>> Update(
|
||||
[FromBody] UpdateCurrentMerchantRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var merchantId = StoreApiHelpers.ParseRequiredSnowflake(request.Id, nameof(request.Id));
|
||||
var result = await mediator.Send(new UpdateMerchantCommand
|
||||
{
|
||||
MerchantId = merchantId,
|
||||
Name = request.Name,
|
||||
LicenseNumber = request.LicenseNumber,
|
||||
LegalRepresentative = request.LegalRepresentative,
|
||||
RegisteredAddress = request.RegisteredAddress,
|
||||
ContactPhone = request.ContactPhone,
|
||||
ContactEmail = request.ContactEmail
|
||||
}, cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return ApiResponse<UpdateMerchantResultDto>.Error(ErrorCodes.NotFound, "商户不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<UpdateMerchantResultDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 手动重试当前商户地理定位。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>重试结果。</returns>
|
||||
[HttpPost("geocode/retry")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ApiResponse<object>> RetryCurrentMerchantGeocode(CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (tenantId <= 0)
|
||||
{
|
||||
return ApiResponse<object>.Error(ErrorCodes.BadRequest, "缺少租户标识");
|
||||
}
|
||||
|
||||
var currentMerchant = await mediator.Send(new GetCurrentMerchantCenterQuery(), cancellationToken);
|
||||
var merchantId = currentMerchant.Merchant.Id;
|
||||
var success = await geoLocationOrchestrator.RetryMerchantAsync(tenantId, merchantId, cancellationToken);
|
||||
if (!success)
|
||||
{
|
||||
return ApiResponse<object>.Error(ErrorCodes.NotFound, "商户不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新当前商户请求。
|
||||
/// </summary>
|
||||
public sealed class UpdateCurrentMerchantRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 商户标识。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商户名称。
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 营业执照号。
|
||||
/// </summary>
|
||||
public string? LicenseNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 法人/负责人。
|
||||
/// </summary>
|
||||
public string? LegalRepresentative { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册地址。
|
||||
/// </summary>
|
||||
public string? RegisteredAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系电话。
|
||||
/// </summary>
|
||||
public string? ContactPhone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系邮箱。
|
||||
/// </summary>
|
||||
public string? ContactEmail { get; set; }
|
||||
}
|
||||
|
||||
@@ -4,8 +4,11 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Stores.Commands;
|
||||
using TakeoutSaaS.Application.App.Stores.Dto;
|
||||
using TakeoutSaaS.Application.App.Stores.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Services;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
@@ -15,7 +18,10 @@ namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/store")]
|
||||
public sealed class StoreController(IMediator mediator) : BaseApiController
|
||||
public sealed class StoreController(
|
||||
IMediator mediator,
|
||||
StoreContextService storeContextService,
|
||||
GeoLocationOrchestrator geoLocationOrchestrator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 查询门店列表。
|
||||
@@ -129,5 +135,32 @@ public sealed class StoreController(IMediator mediator) : BaseApiController
|
||||
// 2. 返回切换结果
|
||||
return ApiResponse<StoreDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 手动重试门店地理定位。
|
||||
/// </summary>
|
||||
/// <param name="storeId">门店 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>重试结果。</returns>
|
||||
[HttpPost("{storeId}/geocode/retry")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ApiResponse<object>> RetryGeocode(string storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
var success = await geoLocationOrchestrator.RetryStoreAsync(
|
||||
tenantId,
|
||||
merchantId,
|
||||
parsedStoreId,
|
||||
cancellationToken);
|
||||
if (!success)
|
||||
{
|
||||
return ApiResponse<object>.Error(ErrorCodes.NotFound, "门店不存在或无权限访问");
|
||||
}
|
||||
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
using Serilog;
|
||||
using TakeoutSaaS.Application.App.Common.Geo;
|
||||
using TakeoutSaaS.Application.App.Extensions;
|
||||
using TakeoutSaaS.Application.Dictionary.Extensions;
|
||||
using TakeoutSaaS.Application.Identity.Extensions;
|
||||
@@ -117,6 +118,9 @@ builder.Services.AddHttpClient(TencentMapGeocodingService.HttpClientName, client
|
||||
client.Timeout = TimeSpan.FromSeconds(8);
|
||||
});
|
||||
builder.Services.AddScoped<TencentMapGeocodingService>();
|
||||
builder.Services.AddScoped<IAddressGeocodingService>(provider => provider.GetRequiredService<TencentMapGeocodingService>());
|
||||
builder.Services.AddScoped<GeoLocationOrchestrator>();
|
||||
builder.Services.AddHostedService<GeoLocationRetryBackgroundService>();
|
||||
|
||||
// 10. 配置 OpenTelemetry 采集
|
||||
var otelSection = builder.Configuration.GetSection("Otel");
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Application.App.Common.Geo;
|
||||
using TakeoutSaaS.Domain.Common.Enums;
|
||||
using TakeoutSaaS.Domain.Merchants.Entities;
|
||||
using TakeoutSaaS.Domain.Stores.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 商户/门店/租户地理定位编排服务。
|
||||
/// </summary>
|
||||
public sealed class GeoLocationOrchestrator(
|
||||
TakeoutAppDbContext dbContext,
|
||||
IAddressGeocodingService geocodingService,
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
ILogger<GeoLocationOrchestrator> logger)
|
||||
{
|
||||
private const int BatchSizePerEntity = 30;
|
||||
|
||||
/// <summary>
|
||||
/// 手动重试门店地理编码。
|
||||
/// </summary>
|
||||
public async Task<bool> RetryStoreAsync(
|
||||
long tenantId,
|
||||
long merchantId,
|
||||
long storeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var store = await dbContext.Stores
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.Id == storeId && x.TenantId == tenantId && x.MerchantId == merchantId,
|
||||
cancellationToken);
|
||||
if (store is null)
|
||||
{
|
||||
logger.LogWarning("手动重试门店定位失败:Store={StoreId}, Tenant={TenantId}, Merchant={MerchantId}", storeId, tenantId, merchantId);
|
||||
return false;
|
||||
}
|
||||
|
||||
var merchant = await dbContext.Merchants
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == merchantId && x.TenantId == tenantId, cancellationToken);
|
||||
store.GeoRetryCount = 0;
|
||||
await GeocodeStoreAsync(store, merchant, DateTime.UtcNow, isRetry: true, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("手动重试门店定位已执行:Store={StoreId}", storeId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 手动重试商户地理编码。
|
||||
/// </summary>
|
||||
public async Task<bool> RetryMerchantAsync(
|
||||
long tenantId,
|
||||
long merchantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var merchant = await dbContext.Merchants
|
||||
.FirstOrDefaultAsync(x => x.Id == merchantId && x.TenantId == tenantId, cancellationToken);
|
||||
if (merchant is null)
|
||||
{
|
||||
logger.LogWarning("手动重试商户定位失败:Merchant={MerchantId}, Tenant={TenantId}", merchantId, tenantId);
|
||||
return false;
|
||||
}
|
||||
|
||||
merchant.GeoRetryCount = 0;
|
||||
await GeocodeMerchantAsync(merchant, DateTime.UtcNow, isRetry: true, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("手动重试商户定位已执行:Merchant={MerchantId}", merchantId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 后台批量处理待定位记录。
|
||||
/// </summary>
|
||||
public async Task<int> ProcessPendingAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var processedCount = 0;
|
||||
var tenantIds = await CollectPendingTenantIdsAsync(now, cancellationToken);
|
||||
foreach (var tenantId in tenantIds)
|
||||
{
|
||||
processedCount += await ProcessTenantEntitiesAsync(tenantId, now, cancellationToken);
|
||||
}
|
||||
|
||||
var pendingTenants = await dbContext.Tenants
|
||||
.Where(x =>
|
||||
x.GeoStatus != GeoLocationStatus.Success &&
|
||||
x.GeoRetryCount < GeoLocationStateHelper.MaxRetryCount &&
|
||||
(x.GeoNextRetryAt == null || x.GeoNextRetryAt <= now))
|
||||
.OrderBy(x => x.GeoNextRetryAt)
|
||||
.ThenBy(x => x.Id)
|
||||
.Take(BatchSizePerEntity)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var tenant in pendingTenants)
|
||||
{
|
||||
await GeocodeTenantAsync(tenant, now, isRetry: true, cancellationToken);
|
||||
}
|
||||
|
||||
processedCount += pendingTenants.Count;
|
||||
|
||||
if (processedCount > 0)
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
logger.LogDebug("地理定位批处理完成,处理记录数:{Count}", processedCount);
|
||||
}
|
||||
|
||||
return processedCount;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<long>> CollectPendingTenantIdsAsync(DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
var storeTenantIds = await dbContext.Stores
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.DeletedAt == null &&
|
||||
x.GeoStatus != GeoLocationStatus.Success &&
|
||||
x.GeoRetryCount < GeoLocationStateHelper.MaxRetryCount &&
|
||||
(x.GeoNextRetryAt == null || x.GeoNextRetryAt <= now))
|
||||
.Select(x => x.TenantId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var merchantTenantIds = await dbContext.Merchants
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.DeletedAt == null &&
|
||||
x.GeoStatus != GeoLocationStatus.Success &&
|
||||
x.GeoRetryCount < GeoLocationStateHelper.MaxRetryCount &&
|
||||
(x.GeoNextRetryAt == null || x.GeoNextRetryAt <= now))
|
||||
.Select(x => x.TenantId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return storeTenantIds
|
||||
.Concat(merchantTenantIds)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<int> ProcessTenantEntitiesAsync(
|
||||
long tenantId,
|
||||
DateTime now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var previousContext = tenantContextAccessor.Current;
|
||||
tenantContextAccessor.Current = new TenantContext(tenantId, $"t{tenantId}", "geo-location-retry");
|
||||
try
|
||||
{
|
||||
var processedCount = 0;
|
||||
var pendingStores = await dbContext.Stores
|
||||
.Where(x =>
|
||||
x.GeoStatus != GeoLocationStatus.Success &&
|
||||
x.GeoRetryCount < GeoLocationStateHelper.MaxRetryCount &&
|
||||
(x.GeoNextRetryAt == null || x.GeoNextRetryAt <= now))
|
||||
.OrderBy(x => x.GeoNextRetryAt)
|
||||
.ThenBy(x => x.Id)
|
||||
.Take(BatchSizePerEntity)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var store in pendingStores)
|
||||
{
|
||||
var merchant = await dbContext.Merchants
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == store.MerchantId, cancellationToken);
|
||||
await GeocodeStoreAsync(store, merchant, now, isRetry: true, cancellationToken);
|
||||
}
|
||||
|
||||
processedCount += pendingStores.Count;
|
||||
|
||||
var pendingMerchants = await dbContext.Merchants
|
||||
.Where(x =>
|
||||
x.GeoStatus != GeoLocationStatus.Success &&
|
||||
x.GeoRetryCount < GeoLocationStateHelper.MaxRetryCount &&
|
||||
(x.GeoNextRetryAt == null || x.GeoNextRetryAt <= now))
|
||||
.OrderBy(x => x.GeoNextRetryAt)
|
||||
.ThenBy(x => x.Id)
|
||||
.Take(BatchSizePerEntity)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var merchant in pendingMerchants)
|
||||
{
|
||||
await GeocodeMerchantAsync(merchant, now, isRetry: true, cancellationToken);
|
||||
}
|
||||
|
||||
processedCount += pendingMerchants.Count;
|
||||
|
||||
if (processedCount > 0)
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return processedCount;
|
||||
}
|
||||
finally
|
||||
{
|
||||
dbContext.ChangeTracker.Clear();
|
||||
tenantContextAccessor.Current = previousContext;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GeocodeStoreAsync(
|
||||
Store store,
|
||||
Merchant? merchant,
|
||||
DateTime now,
|
||||
bool isRetry,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var candidates = GeoAddressBuilder.BuildStoreCandidates(store, merchant);
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
WriteFailedState(store, "缺少地址信息,无法定位", now, isRetry);
|
||||
return;
|
||||
}
|
||||
|
||||
var geocodeResult = await ResolveCandidatesAsync(candidates, cancellationToken);
|
||||
if (geocodeResult.Succeeded && geocodeResult.Latitude is not null && geocodeResult.Longitude is not null)
|
||||
{
|
||||
GeoLocationStateHelper.MarkSuccess(
|
||||
store,
|
||||
(double)geocodeResult.Latitude.Value,
|
||||
(double)geocodeResult.Longitude.Value,
|
||||
now);
|
||||
return;
|
||||
}
|
||||
|
||||
WriteFailedState(store, geocodeResult.Message, now, isRetry);
|
||||
}
|
||||
|
||||
private async Task GeocodeMerchantAsync(
|
||||
Merchant merchant,
|
||||
DateTime now,
|
||||
bool isRetry,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var candidates = GeoAddressBuilder.BuildMerchantCandidates(merchant);
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
WriteFailedState(merchant, "缺少地址信息,无法定位", now, isRetry);
|
||||
return;
|
||||
}
|
||||
|
||||
var geocodeResult = await ResolveCandidatesAsync(candidates, cancellationToken);
|
||||
if (geocodeResult.Succeeded && geocodeResult.Latitude is not null && geocodeResult.Longitude is not null)
|
||||
{
|
||||
GeoLocationStateHelper.MarkSuccess(
|
||||
merchant,
|
||||
(double)geocodeResult.Latitude.Value,
|
||||
(double)geocodeResult.Longitude.Value,
|
||||
now);
|
||||
return;
|
||||
}
|
||||
|
||||
WriteFailedState(merchant, geocodeResult.Message, now, isRetry);
|
||||
}
|
||||
|
||||
private async Task GeocodeTenantAsync(
|
||||
Tenant tenant,
|
||||
DateTime now,
|
||||
bool isRetry,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var candidates = GeoAddressBuilder.BuildTenantCandidates(tenant);
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
WriteFailedState(tenant, "缺少地址信息,无法定位", now, isRetry);
|
||||
return;
|
||||
}
|
||||
|
||||
var geocodeResult = await ResolveCandidatesAsync(candidates, cancellationToken);
|
||||
if (geocodeResult.Succeeded && geocodeResult.Latitude is not null && geocodeResult.Longitude is not null)
|
||||
{
|
||||
GeoLocationStateHelper.MarkSuccess(
|
||||
tenant,
|
||||
(double)geocodeResult.Latitude.Value,
|
||||
(double)geocodeResult.Longitude.Value,
|
||||
now);
|
||||
return;
|
||||
}
|
||||
|
||||
WriteFailedState(tenant, geocodeResult.Message, now, isRetry);
|
||||
}
|
||||
|
||||
private async Task<AddressGeocodingResult> ResolveCandidatesAsync(
|
||||
IReadOnlyList<string> candidates,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string? lastError = null;
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var result = await geocodingService.GeocodeAsync(candidate, cancellationToken);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
lastError = result.Message;
|
||||
}
|
||||
|
||||
return AddressGeocodingResult.Failed(lastError ?? "地址地理编码失败");
|
||||
}
|
||||
|
||||
private static void WriteFailedState(Store store, string? reason, DateTime now, bool isRetry)
|
||||
{
|
||||
if (isRetry)
|
||||
{
|
||||
GeoLocationStateHelper.MarkRetryFailure(store, reason, now);
|
||||
return;
|
||||
}
|
||||
|
||||
GeoLocationStateHelper.MarkPending(store, reason, now);
|
||||
}
|
||||
|
||||
private static void WriteFailedState(Merchant merchant, string? reason, DateTime now, bool isRetry)
|
||||
{
|
||||
if (isRetry)
|
||||
{
|
||||
GeoLocationStateHelper.MarkRetryFailure(merchant, reason, now);
|
||||
return;
|
||||
}
|
||||
|
||||
GeoLocationStateHelper.MarkPending(merchant, reason, now);
|
||||
}
|
||||
|
||||
private static void WriteFailedState(Tenant tenant, string? reason, DateTime now, bool isRetry)
|
||||
{
|
||||
if (isRetry)
|
||||
{
|
||||
GeoLocationStateHelper.MarkRetryFailure(tenant, reason, now);
|
||||
return;
|
||||
}
|
||||
|
||||
GeoLocationStateHelper.MarkPending(tenant, reason, now);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace TakeoutSaaS.TenantApi.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 地理定位自动重试后台服务。
|
||||
/// </summary>
|
||||
public sealed class GeoLocationRetryBackgroundService(
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
ILogger<GeoLocationRetryBackgroundService> logger) : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan PollingInterval = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = serviceScopeFactory.CreateScope();
|
||||
var orchestrator = scope.ServiceProvider.GetRequiredService<GeoLocationOrchestrator>();
|
||||
var processedCount = await orchestrator.ProcessPendingAsync(stoppingToken);
|
||||
if (processedCount > 0)
|
||||
{
|
||||
logger.LogInformation("地理定位重试任务完成,处理记录数:{Count}", processedCount);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "地理定位重试任务执行失败");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(PollingInterval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Application.App.Common.Geo;
|
||||
using TakeoutSaaS.TenantApi.Options;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Services;
|
||||
@@ -13,7 +14,7 @@ namespace TakeoutSaaS.TenantApi.Services;
|
||||
public sealed class TencentMapGeocodingService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptionsMonitor<TencentMapOptions> optionsMonitor,
|
||||
ILogger<TencentMapGeocodingService> logger)
|
||||
ILogger<TencentMapGeocodingService> logger) : IAddressGeocodingService
|
||||
{
|
||||
/// <summary>
|
||||
/// HttpClient 名称。
|
||||
@@ -29,12 +30,28 @@ public sealed class TencentMapGeocodingService(
|
||||
public async Task<(decimal Latitude, decimal Longitude)?> GeocodeAsync(
|
||||
string rawAddress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await GeocodeWithDetailsAsync(rawAddress, cancellationToken);
|
||||
return result.Succeeded && result.Latitude is not null && result.Longitude is not null
|
||||
? (result.Latitude.Value, result.Longitude.Value)
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据地址解析经纬度(含失败原因)。
|
||||
/// </summary>
|
||||
/// <param name="rawAddress">地址文本。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>解析结果。</returns>
|
||||
public async Task<AddressGeocodingResult> GeocodeWithDetailsAsync(
|
||||
string rawAddress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 预处理地址文本,空值直接返回。
|
||||
var address = rawAddress?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(address))
|
||||
{
|
||||
return null;
|
||||
return AddressGeocodingResult.Failed("地址为空,无法定位");
|
||||
}
|
||||
|
||||
// 2. 读取腾讯地图配置并做兜底校验。
|
||||
@@ -44,12 +61,13 @@ public sealed class TencentMapGeocodingService(
|
||||
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(secret))
|
||||
{
|
||||
logger.LogWarning("腾讯地图 WebService 未配置 key/sk,已跳过地址解析。");
|
||||
return null;
|
||||
return AddressGeocodingResult.Failed("地图服务未配置");
|
||||
}
|
||||
|
||||
// 3. 同时尝试两种签名方式,兼容不同编码策略。
|
||||
var baseUrl = NormalizeBaseUrl(options.BaseUrl);
|
||||
var geocoderPath = NormalizePath(options.GeocoderPath);
|
||||
string? lastMessage = null;
|
||||
foreach (var useEncodedValueInSignature in new[] { false, true })
|
||||
{
|
||||
var query = new SortedDictionary<string, string>(StringComparer.Ordinal)
|
||||
@@ -67,6 +85,7 @@ public sealed class TencentMapGeocodingService(
|
||||
var response = await RequestAsync(requestUri, cancellationToken);
|
||||
if (response is null)
|
||||
{
|
||||
lastMessage = "地图服务响应无法解析";
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -75,9 +94,13 @@ public sealed class TencentMapGeocodingService(
|
||||
response.Latitude is not null &&
|
||||
response.Longitude is not null)
|
||||
{
|
||||
return (response.Latitude.Value, response.Longitude.Value);
|
||||
return AddressGeocodingResult.Success(response.Latitude.Value, response.Longitude.Value);
|
||||
}
|
||||
|
||||
lastMessage = string.IsNullOrWhiteSpace(response.Message)
|
||||
? $"地理编码失败,状态码:{response.Status}"
|
||||
: response.Message;
|
||||
|
||||
// 5. 仅在签名错误时继续下一轮重试,其他状态直接终止。
|
||||
if (response.Status != 111)
|
||||
{
|
||||
@@ -85,9 +108,14 @@ public sealed class TencentMapGeocodingService(
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return AddressGeocodingResult.Failed(lastMessage ?? "地理编码失败");
|
||||
}
|
||||
|
||||
Task<AddressGeocodingResult> IAddressGeocodingService.GeocodeAsync(
|
||||
string address,
|
||||
CancellationToken cancellationToken)
|
||||
=> GeocodeWithDetailsAsync(address, cancellationToken);
|
||||
|
||||
private async Task<TencentGeocodeResponse?> RequestAsync(
|
||||
string requestUri,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
Reference in New Issue
Block a user