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,4 +1,5 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Common.Enums;
using TakeoutSaaS.Domain.Stores.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
@@ -143,6 +144,21 @@ public sealed class StoreDto
/// </summary>
public double? Latitude { get; init; }
/// <summary>
/// 地理定位状态。
/// </summary>
public GeoLocationStatus GeoStatus { get; init; }
/// <summary>
/// 地理定位失败原因。
/// </summary>
public string? GeoFailReason { get; init; }
/// <summary>
/// 地理定位最近成功时间。
/// </summary>
public DateTime? GeoUpdatedAt { get; init; }
/// <summary>
/// 公告。
/// </summary>

View File

@@ -1,4 +1,5 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Common.Enums;
using TakeoutSaaS.Application.App.Stores.Enums;
using TakeoutSaaS.Domain.Stores.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
@@ -66,6 +67,21 @@ public sealed record StoreListItemDto
/// </summary>
public double? Latitude { get; init; }
/// <summary>
/// 地理定位状态。
/// </summary>
public GeoLocationStatus GeoStatus { get; init; }
/// <summary>
/// 地理定位失败原因。
/// </summary>
public string? GeoFailReason { get; init; }
/// <summary>
/// 地理定位最近成功时间。
/// </summary>
public DateTime? GeoUpdatedAt { get; init; }
/// <summary>
/// 门店封面图。
/// </summary>

View File

@@ -1,8 +1,10 @@
using MediatR;
using System.Security.Cryptography;
using TakeoutSaaS.Application.App.Common.Geo;
using TakeoutSaaS.Application.App.Stores.Commands;
using TakeoutSaaS.Application.App.Stores.Enums;
using TakeoutSaaS.Application.App.Stores.Services;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Domain.Stores.Entities;
using TakeoutSaaS.Domain.Stores.Enums;
using TakeoutSaaS.Domain.Stores.Repositories;
@@ -18,6 +20,8 @@ namespace TakeoutSaaS.Application.App.Stores.Handlers;
public sealed class CreateStoreCommandHandler(
StoreContextService storeContextService,
IStoreRepository storeRepository,
IMerchantRepository merchantRepository,
IAddressGeocodingService geocodingService,
ICurrentUserAccessor currentUserAccessor)
: IRequestHandler<CreateStoreCommand>
{
@@ -64,6 +68,7 @@ public sealed class CreateStoreCommandHandler(
Status = StoreStatus.Operating
};
StoreListMapping.ApplyServiceTypes(store, serviceTypes);
await TryGeocodeStoreAsync(store, context.TenantId, context.MerchantId, now, cancellationToken);
// 4. 持久化门店并记录自动审核通过
await storeRepository.AddStoreAsync(store, cancellationToken);
@@ -82,6 +87,41 @@ public sealed class CreateStoreCommandHandler(
await storeRepository.SaveChangesAsync(cancellationToken);
}
private async Task TryGeocodeStoreAsync(
Store store,
long tenantId,
long merchantId,
DateTime now,
CancellationToken cancellationToken)
{
var merchant = await merchantRepository.FindByIdAsync(merchantId, tenantId, cancellationToken);
var candidates = GeoAddressBuilder.BuildStoreCandidates(store, merchant);
if (candidates.Count == 0)
{
GeoLocationStateHelper.MarkPending(store, "缺少地址信息,等待补全后自动定位", now);
return;
}
string? lastError = null;
foreach (var candidate in candidates)
{
var result = await geocodingService.GeocodeAsync(candidate, cancellationToken);
if (result.Succeeded && result.Latitude is not null && result.Longitude is not null)
{
GeoLocationStateHelper.MarkSuccess(
store,
(double)result.Latitude.Value,
(double)result.Longitude.Value,
now);
return;
}
lastError = result.Message;
}
GeoLocationStateHelper.MarkPending(store, lastError ?? "地址地理编码失败,等待自动重试", now);
}
/// <summary>
/// 生成当前租户商户内唯一的门店编码。
/// </summary>

View File

@@ -1,7 +1,10 @@
using MediatR;
using TakeoutSaaS.Application.App.Common.Geo;
using TakeoutSaaS.Application.App.Stores.Commands;
using TakeoutSaaS.Application.App.Stores.Enums;
using TakeoutSaaS.Application.App.Stores.Services;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Domain.Stores.Entities;
using TakeoutSaaS.Domain.Stores.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
@@ -13,7 +16,9 @@ namespace TakeoutSaaS.Application.App.Stores.Handlers;
/// </summary>
public sealed class UpdateStoreCommandHandler(
StoreContextService storeContextService,
IStoreRepository storeRepository)
IStoreRepository storeRepository,
IMerchantRepository merchantRepository,
IAddressGeocodingService geocodingService)
: IRequestHandler<UpdateStoreCommand>
{
/// <inheritdoc />
@@ -29,6 +34,11 @@ public sealed class UpdateStoreCommandHandler(
{
throw new BusinessException(ErrorCodes.Forbidden, "无权操作其他商户门店");
}
var now = DateTime.UtcNow;
var normalizedAddress = request.Address.Trim();
var shouldReGeocode = !existing.Latitude.HasValue ||
!existing.Longitude.HasValue ||
!string.Equals(existing.Address?.Trim(), normalizedAddress, StringComparison.Ordinal);
// 3. 若传入编码且发生变化,则校验唯一性并更新编码。
var normalizedCode = request.Code?.Trim();
@@ -60,7 +70,7 @@ public sealed class UpdateStoreCommandHandler(
existing.Name = request.Name.Trim();
existing.Phone = request.ContactPhone.Trim();
existing.ManagerName = request.ManagerName.Trim();
existing.Address = request.Address.Trim();
existing.Address = normalizedAddress;
existing.CoverImageUrl = request.CoverImage?.Trim();
existing.SignboardImageUrl = request.CoverImage?.Trim();
existing.BusinessStatus = request.BusinessStatus ?? existing.BusinessStatus;
@@ -68,9 +78,48 @@ public sealed class UpdateStoreCommandHandler(
? request.ServiceTypes
: [ServiceType.Delivery];
StoreListMapping.ApplyServiceTypes(existing, serviceTypes);
if (shouldReGeocode)
{
await TryGeocodeStoreAsync(existing, context.TenantId, context.MerchantId, now, cancellationToken);
}
// 5. 保存修改
await storeRepository.UpdateStoreAsync(existing, cancellationToken);
await storeRepository.SaveChangesAsync(cancellationToken);
}
private async Task TryGeocodeStoreAsync(
Store store,
long tenantId,
long merchantId,
DateTime now,
CancellationToken cancellationToken)
{
var merchant = await merchantRepository.FindByIdAsync(merchantId, tenantId, cancellationToken);
var candidates = GeoAddressBuilder.BuildStoreCandidates(store, merchant);
if (candidates.Count == 0)
{
GeoLocationStateHelper.MarkPending(store, "缺少地址信息,等待补全后自动定位", now);
return;
}
string? lastError = null;
foreach (var candidate in candidates)
{
var result = await geocodingService.GeocodeAsync(candidate, cancellationToken);
if (result.Succeeded && result.Latitude is not null && result.Longitude is not null)
{
GeoLocationStateHelper.MarkSuccess(
store,
(double)result.Latitude.Value,
(double)result.Longitude.Value,
now);
return;
}
lastError = result.Message;
}
GeoLocationStateHelper.MarkPending(store, lastError ?? "地址地理编码失败,等待自动重试", now);
}
}

View File

@@ -27,6 +27,9 @@ public static class StoreListMapping
District = store.District,
Longitude = store.Longitude,
Latitude = store.Latitude,
GeoStatus = store.GeoStatus,
GeoFailReason = store.GeoFailReason,
GeoUpdatedAt = store.GeoUpdatedAt,
CoverImage = string.IsNullOrWhiteSpace(store.CoverImageUrl) ? store.SignboardImageUrl : store.CoverImageUrl,
BusinessStatus = store.BusinessStatus,
AuditStatus = store.AuditStatus,

View File

@@ -42,6 +42,9 @@ public static class StoreMapping
Address = store.Address,
Longitude = store.Longitude,
Latitude = store.Latitude,
GeoStatus = store.GeoStatus,
GeoFailReason = store.GeoFailReason,
GeoUpdatedAt = store.GeoUpdatedAt,
Announcement = store.Announcement,
Tags = store.Tags,
DeliveryRadiusKm = store.DeliveryRadiusKm,