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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user