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

This commit is contained in:
2026-02-19 17:13:00 +08:00
parent ad245078a2
commit 53f7c54c82
33 changed files with 9514 additions and 11 deletions

View File

@@ -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; }
}

View File

@@ -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);
}
}

View File

@@ -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");

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}
}

View File

@@ -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)