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:
@@ -0,0 +1,23 @@
|
||||
namespace TakeoutSaaS.Application.App.Common.Geo;
|
||||
|
||||
/// <summary>
|
||||
/// 地址地理编码结果。
|
||||
/// </summary>
|
||||
public sealed record AddressGeocodingResult(
|
||||
bool Succeeded,
|
||||
decimal? Latitude,
|
||||
decimal? Longitude,
|
||||
string? Message)
|
||||
{
|
||||
/// <summary>
|
||||
/// 构建成功结果。
|
||||
/// </summary>
|
||||
public static AddressGeocodingResult Success(decimal latitude, decimal longitude)
|
||||
=> new(true, latitude, longitude, null);
|
||||
|
||||
/// <summary>
|
||||
/// 构建失败结果。
|
||||
/// </summary>
|
||||
public static AddressGeocodingResult Failed(string? message)
|
||||
=> new(false, null, null, message);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using TakeoutSaaS.Domain.Merchants.Entities;
|
||||
using TakeoutSaaS.Domain.Stores.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Common.Geo;
|
||||
|
||||
/// <summary>
|
||||
/// 地理定位地址构建器。
|
||||
/// </summary>
|
||||
public static class GeoAddressBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// 构建门店地理编码候选地址。
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> BuildStoreCandidates(Store store, Merchant? merchant = null)
|
||||
{
|
||||
var candidates = new List<string>();
|
||||
AddCandidate(candidates, BuildAddress(store.Province, store.City, store.District, store.Address));
|
||||
AddCandidate(candidates, store.Address);
|
||||
|
||||
if (merchant is not null)
|
||||
{
|
||||
AddCandidate(candidates, BuildAddress(merchant.Province, merchant.City, merchant.District, merchant.Address));
|
||||
AddCandidate(candidates, BuildAddress(merchant.Province, merchant.City, merchant.District));
|
||||
AddCandidate(candidates, BuildAddress(merchant.City, merchant.District));
|
||||
AddCandidate(candidates, merchant.Address);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建商户地理编码候选地址。
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> BuildMerchantCandidates(Merchant merchant)
|
||||
{
|
||||
var candidates = new List<string>();
|
||||
AddCandidate(candidates, BuildAddress(merchant.Province, merchant.City, merchant.District, merchant.Address));
|
||||
AddCandidate(candidates, merchant.Address);
|
||||
AddCandidate(candidates, BuildAddress(merchant.Province, merchant.City, merchant.District));
|
||||
AddCandidate(candidates, BuildAddress(merchant.City, merchant.District));
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建租户地理编码候选地址。
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> BuildTenantCandidates(Tenant tenant)
|
||||
{
|
||||
var candidates = new List<string>();
|
||||
AddCandidate(candidates, BuildAddress(tenant.Country, tenant.Province, tenant.City, tenant.Address));
|
||||
AddCandidate(candidates, tenant.Address);
|
||||
AddCandidate(candidates, BuildAddress(tenant.Country, tenant.Province, tenant.City));
|
||||
AddCandidate(candidates, BuildAddress(tenant.Province, tenant.City));
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private static string BuildAddress(params string?[] parts)
|
||||
{
|
||||
var normalized = parts
|
||||
.Select(part => part?.Trim())
|
||||
.Where(part => !string.IsNullOrWhiteSpace(part))
|
||||
.ToArray();
|
||||
|
||||
return normalized.Length == 0 ? string.Empty : string.Join(string.Empty, normalized);
|
||||
}
|
||||
|
||||
private static void AddCandidate(ICollection<string> candidates, string? candidate)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (candidates.Any(existing => string.Equals(existing, candidate, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
candidates.Add(candidate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using TakeoutSaaS.Domain.Common.Enums;
|
||||
using TakeoutSaaS.Domain.Merchants.Entities;
|
||||
using TakeoutSaaS.Domain.Stores.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Common.Geo;
|
||||
|
||||
/// <summary>
|
||||
/// 地理定位状态写入助手。
|
||||
/// </summary>
|
||||
public static class GeoLocationStateHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// 最大重试次数。
|
||||
/// </summary>
|
||||
public const int MaxRetryCount = 5;
|
||||
|
||||
/// <summary>
|
||||
/// 获取重试间隔。
|
||||
/// </summary>
|
||||
public static TimeSpan GetRetryDelay(int retryCount)
|
||||
{
|
||||
return retryCount switch
|
||||
{
|
||||
<= 1 => TimeSpan.FromMinutes(1),
|
||||
2 => TimeSpan.FromMinutes(5),
|
||||
3 => TimeSpan.FromMinutes(30),
|
||||
4 => TimeSpan.FromHours(2),
|
||||
_ => TimeSpan.FromHours(12)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入门店定位成功状态。
|
||||
/// </summary>
|
||||
public static void MarkSuccess(Store store, double latitude, double longitude, DateTime now)
|
||||
{
|
||||
store.Latitude = latitude;
|
||||
store.Longitude = longitude;
|
||||
store.GeoStatus = GeoLocationStatus.Success;
|
||||
store.GeoFailReason = null;
|
||||
store.GeoRetryCount = 0;
|
||||
store.GeoUpdatedAt = now;
|
||||
store.GeoNextRetryAt = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入商户定位成功状态。
|
||||
/// </summary>
|
||||
public static void MarkSuccess(Merchant merchant, double latitude, double longitude, DateTime now)
|
||||
{
|
||||
merchant.Latitude = latitude;
|
||||
merchant.Longitude = longitude;
|
||||
merchant.GeoStatus = GeoLocationStatus.Success;
|
||||
merchant.GeoFailReason = null;
|
||||
merchant.GeoRetryCount = 0;
|
||||
merchant.GeoUpdatedAt = now;
|
||||
merchant.GeoNextRetryAt = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入租户定位成功状态。
|
||||
/// </summary>
|
||||
public static void MarkSuccess(Tenant tenant, double latitude, double longitude, DateTime now)
|
||||
{
|
||||
tenant.Latitude = latitude;
|
||||
tenant.Longitude = longitude;
|
||||
tenant.GeoStatus = GeoLocationStatus.Success;
|
||||
tenant.GeoFailReason = null;
|
||||
tenant.GeoRetryCount = 0;
|
||||
tenant.GeoUpdatedAt = now;
|
||||
tenant.GeoNextRetryAt = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入门店待重试状态。
|
||||
/// </summary>
|
||||
public static void MarkPending(Store store, string? reason, DateTime now)
|
||||
{
|
||||
store.GeoStatus = GeoLocationStatus.Pending;
|
||||
store.GeoFailReason = reason;
|
||||
store.GeoRetryCount = 0;
|
||||
store.GeoNextRetryAt = now.Add(GetRetryDelay(1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入商户待重试状态。
|
||||
/// </summary>
|
||||
public static void MarkPending(Merchant merchant, string? reason, DateTime now)
|
||||
{
|
||||
merchant.GeoStatus = GeoLocationStatus.Pending;
|
||||
merchant.GeoFailReason = reason;
|
||||
merchant.GeoRetryCount = 0;
|
||||
merchant.GeoNextRetryAt = now.Add(GetRetryDelay(1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入租户待重试状态。
|
||||
/// </summary>
|
||||
public static void MarkPending(Tenant tenant, string? reason, DateTime now)
|
||||
{
|
||||
tenant.GeoStatus = GeoLocationStatus.Pending;
|
||||
tenant.GeoFailReason = reason;
|
||||
tenant.GeoRetryCount = 0;
|
||||
tenant.GeoNextRetryAt = now.Add(GetRetryDelay(1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入门店重试失败状态。
|
||||
/// </summary>
|
||||
public static void MarkRetryFailure(Store store, string? reason, DateTime now)
|
||||
{
|
||||
var nextRetryCount = store.GeoRetryCount + 1;
|
||||
store.GeoRetryCount = nextRetryCount;
|
||||
store.GeoFailReason = reason;
|
||||
if (nextRetryCount >= MaxRetryCount)
|
||||
{
|
||||
store.GeoStatus = GeoLocationStatus.Failed;
|
||||
store.GeoNextRetryAt = null;
|
||||
return;
|
||||
}
|
||||
|
||||
store.GeoStatus = GeoLocationStatus.Pending;
|
||||
store.GeoNextRetryAt = now.Add(GetRetryDelay(nextRetryCount + 1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入商户重试失败状态。
|
||||
/// </summary>
|
||||
public static void MarkRetryFailure(Merchant merchant, string? reason, DateTime now)
|
||||
{
|
||||
var nextRetryCount = merchant.GeoRetryCount + 1;
|
||||
merchant.GeoRetryCount = nextRetryCount;
|
||||
merchant.GeoFailReason = reason;
|
||||
if (nextRetryCount >= MaxRetryCount)
|
||||
{
|
||||
merchant.GeoStatus = GeoLocationStatus.Failed;
|
||||
merchant.GeoNextRetryAt = null;
|
||||
return;
|
||||
}
|
||||
|
||||
merchant.GeoStatus = GeoLocationStatus.Pending;
|
||||
merchant.GeoNextRetryAt = now.Add(GetRetryDelay(nextRetryCount + 1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入租户重试失败状态。
|
||||
/// </summary>
|
||||
public static void MarkRetryFailure(Tenant tenant, string? reason, DateTime now)
|
||||
{
|
||||
var nextRetryCount = tenant.GeoRetryCount + 1;
|
||||
tenant.GeoRetryCount = nextRetryCount;
|
||||
tenant.GeoFailReason = reason;
|
||||
if (nextRetryCount >= MaxRetryCount)
|
||||
{
|
||||
tenant.GeoStatus = GeoLocationStatus.Failed;
|
||||
tenant.GeoNextRetryAt = null;
|
||||
return;
|
||||
}
|
||||
|
||||
tenant.GeoStatus = GeoLocationStatus.Pending;
|
||||
tenant.GeoNextRetryAt = now.Add(GetRetryDelay(nextRetryCount + 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace TakeoutSaaS.Application.App.Common.Geo;
|
||||
|
||||
/// <summary>
|
||||
/// 地址地理编码服务契约。
|
||||
/// </summary>
|
||||
public interface IAddressGeocodingService
|
||||
{
|
||||
/// <summary>
|
||||
/// 将地址解析为经纬度。
|
||||
/// </summary>
|
||||
/// <param name="address">地址文本。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>地理编码结果。</returns>
|
||||
Task<AddressGeocodingResult> GeocodeAsync(string address, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -46,6 +46,30 @@ public sealed class CreateMerchantCommand : IRequest<MerchantDto>
|
||||
[MaxLength(128)]
|
||||
public string? ContactEmail { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 所在省份。
|
||||
/// </summary>
|
||||
[MaxLength(64)]
|
||||
public string? Province { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 所在城市。
|
||||
/// </summary>
|
||||
[MaxLength(64)]
|
||||
public string? City { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 所在区县。
|
||||
/// </summary>
|
||||
[MaxLength(64)]
|
||||
public string? District { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 详细地址。
|
||||
/// </summary>
|
||||
[MaxLength(256)]
|
||||
public string? Address { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态,可用于直接设为审核通过等场景。
|
||||
/// </summary>
|
||||
|
||||
@@ -77,6 +77,21 @@ public sealed class MerchantDetailDto
|
||||
/// </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,6 +1,7 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Security.Cryptography;
|
||||
using TakeoutSaaS.Application.App.Common.Geo;
|
||||
using TakeoutSaaS.Application.App.Merchants.Commands;
|
||||
using TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
using TakeoutSaaS.Domain.Merchants.Entities;
|
||||
@@ -11,7 +12,10 @@ namespace TakeoutSaaS.Application.App.Merchants.Handlers;
|
||||
/// <summary>
|
||||
/// 创建商户命令处理器。
|
||||
/// </summary>
|
||||
public sealed class CreateMerchantCommandHandler(IMerchantRepository merchantRepository, ILogger<CreateMerchantCommandHandler> logger)
|
||||
public sealed class CreateMerchantCommandHandler(
|
||||
IMerchantRepository merchantRepository,
|
||||
IAddressGeocodingService geocodingService,
|
||||
ILogger<CreateMerchantCommandHandler> logger)
|
||||
: IRequestHandler<CreateMerchantCommand, MerchantDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
@@ -26,10 +30,15 @@ public sealed class CreateMerchantCommandHandler(IMerchantRepository merchantRep
|
||||
Category = request.Category?.Trim(),
|
||||
ContactPhone = request.ContactPhone.Trim(),
|
||||
ContactEmail = request.ContactEmail?.Trim(),
|
||||
Province = request.Province?.Trim(),
|
||||
City = request.City?.Trim(),
|
||||
District = request.District?.Trim(),
|
||||
Address = request.Address?.Trim(),
|
||||
Status = request.Status,
|
||||
RowVersion = RandomNumberGenerator.GetBytes(16),
|
||||
JoinedAt = DateTime.UtcNow
|
||||
};
|
||||
await TryGeocodeMerchantAsync(merchant, cancellationToken);
|
||||
|
||||
// 2. 持久化
|
||||
await merchantRepository.AddMerchantAsync(merchant, cancellationToken);
|
||||
@@ -40,6 +49,36 @@ public sealed class CreateMerchantCommandHandler(IMerchantRepository merchantRep
|
||||
return MapToDto(merchant);
|
||||
}
|
||||
|
||||
private async Task TryGeocodeMerchantAsync(Merchant merchant, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var candidates = GeoAddressBuilder.BuildMerchantCandidates(merchant);
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
GeoLocationStateHelper.MarkPending(merchant, "缺少地址信息,等待补全后自动定位", 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(
|
||||
merchant,
|
||||
(double)result.Latitude.Value,
|
||||
(double)result.Longitude.Value,
|
||||
now);
|
||||
return;
|
||||
}
|
||||
|
||||
lastError = result.Message;
|
||||
}
|
||||
|
||||
GeoLocationStateHelper.MarkPending(merchant, lastError ?? "地址地理编码失败,等待自动重试", now);
|
||||
}
|
||||
|
||||
private static MerchantDto MapToDto(Merchant merchant) => new()
|
||||
{
|
||||
Id = merchant.Id,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TakeoutSaaS.Application.App.Common.Geo;
|
||||
using TakeoutSaaS.Application.App.Merchants.Commands;
|
||||
using TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
using TakeoutSaaS.Domain.Merchants.Entities;
|
||||
@@ -23,6 +24,7 @@ public sealed class UpdateMerchantCommandHandler(
|
||||
ITenantRepository tenantRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAddressGeocodingService geocodingService,
|
||||
ILogger<UpdateMerchantCommandHandler> logger)
|
||||
: IRequestHandler<UpdateMerchantCommand, UpdateMerchantResultDto?>
|
||||
{
|
||||
@@ -47,6 +49,9 @@ public sealed class UpdateMerchantCommandHandler(
|
||||
var legalRepresentative = NormalizeOptional(request.LegalRepresentative);
|
||||
var registeredAddress = NormalizeOptional(request.RegisteredAddress);
|
||||
var contactEmail = NormalizeOptional(request.ContactEmail);
|
||||
var shouldReGeocode = !merchant.Latitude.HasValue ||
|
||||
!merchant.Longitude.HasValue ||
|
||||
!string.Equals(merchant.Address, registeredAddress, StringComparison.Ordinal);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var actorId = currentUserAccessor.UserId == 0 ? (long?)null : currentUserAccessor.UserId;
|
||||
@@ -68,6 +73,10 @@ public sealed class UpdateMerchantCommandHandler(
|
||||
merchant.Address = registeredAddress;
|
||||
merchant.ContactPhone = contactPhone;
|
||||
merchant.ContactEmail = contactEmail;
|
||||
if (shouldReGeocode)
|
||||
{
|
||||
await TryGeocodeMerchantAsync(merchant, now, cancellationToken);
|
||||
}
|
||||
|
||||
var requiresReview = merchant.Status == MerchantStatus.Approved && criticalChanged;
|
||||
if (requiresReview)
|
||||
@@ -156,6 +165,35 @@ public sealed class UpdateMerchantCommandHandler(
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TryGeocodeMerchantAsync(Merchant merchant, DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
var candidates = GeoAddressBuilder.BuildMerchantCandidates(merchant);
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
GeoLocationStateHelper.MarkPending(merchant, "缺少地址信息,等待补全后自动定位", 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(
|
||||
merchant,
|
||||
(double)result.Latitude.Value,
|
||||
(double)result.Longitude.Value,
|
||||
now);
|
||||
return;
|
||||
}
|
||||
|
||||
lastError = result.Message;
|
||||
}
|
||||
|
||||
GeoLocationStateHelper.MarkPending(merchant, lastError ?? "地址地理编码失败,等待自动重试", now);
|
||||
}
|
||||
|
||||
private static string NormalizeRequired(string? value, string fieldName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
|
||||
@@ -65,6 +65,9 @@ internal static class MerchantMapping
|
||||
District = merchant.District,
|
||||
Longitude = merchant.Longitude,
|
||||
Latitude = merchant.Latitude,
|
||||
GeoStatus = merchant.GeoStatus,
|
||||
GeoFailReason = merchant.GeoFailReason,
|
||||
GeoUpdatedAt = merchant.GeoUpdatedAt,
|
||||
ContactPhone = merchant.ContactPhone,
|
||||
ContactEmail = merchant.ContactEmail,
|
||||
Status = merchant.Status,
|
||||
|
||||
@@ -19,5 +19,9 @@ public sealed class CreateMerchantCommandValidator : AbstractValidator<CreateMer
|
||||
RuleFor(x => x.Category).MaximumLength(64);
|
||||
RuleFor(x => x.ContactPhone).NotEmpty().MaximumLength(32);
|
||||
RuleFor(x => x.ContactEmail).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.ContactEmail));
|
||||
RuleFor(x => x.Province).MaximumLength(64);
|
||||
RuleFor(x => x.City).MaximumLength(64);
|
||||
RuleFor(x => x.District).MaximumLength(64);
|
||||
RuleFor(x => x.Address).MaximumLength(256);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -37,6 +37,30 @@ public sealed record SelfRegisterTenantCommand : IRequest<SelfRegisterResultDto>
|
||||
[StringLength(32)]
|
||||
public string AdminPhone { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 国家/地区。
|
||||
/// </summary>
|
||||
[StringLength(64)]
|
||||
public string? Country { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 省份。
|
||||
/// </summary>
|
||||
[StringLength(64)]
|
||||
public string? Province { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 城市。
|
||||
/// </summary>
|
||||
[StringLength(64)]
|
||||
public string? City { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 详细地址。
|
||||
/// </summary>
|
||||
[StringLength(256)]
|
||||
public string? Address { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 初始管理员登录密码(前端自定义)。
|
||||
/// </summary>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using TakeoutSaaS.Application.App.Common.Geo;
|
||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Application.Identity.Commands;
|
||||
@@ -25,7 +26,8 @@ public sealed class SelfRegisterTenantCommandHandler(
|
||||
IPasswordHasher<IdentityUser> passwordHasher,
|
||||
IIdGenerator idGenerator,
|
||||
IMediator mediator,
|
||||
ITenantContextAccessor tenantContextAccessor)
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
IAddressGeocodingService geocodingService)
|
||||
: IRequestHandler<SelfRegisterTenantCommand, SelfRegisterResultDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
@@ -59,10 +61,15 @@ public sealed class SelfRegisterTenantCommandHandler(
|
||||
ContactName = string.IsNullOrWhiteSpace(request.AdminDisplayName) ? normalizedAccount : request.AdminDisplayName!.Trim(),
|
||||
ContactPhone = normalizedPhone,
|
||||
ContactEmail = request.AdminEmail,
|
||||
Country = NormalizeOptional(request.Country),
|
||||
Province = NormalizeOptional(request.Province),
|
||||
City = NormalizeOptional(request.City),
|
||||
Address = NormalizeOptional(request.Address),
|
||||
Status = TenantStatus.PendingReview,
|
||||
EffectiveFrom = null,
|
||||
EffectiveTo = null
|
||||
};
|
||||
await TryGeocodeTenantAsync(tenant, geocodingService, cancellationToken);
|
||||
|
||||
// 4. 写入审计日志
|
||||
var auditLog = new TenantAuditLog
|
||||
@@ -137,4 +144,40 @@ public sealed class SelfRegisterTenantCommandHandler(
|
||||
tenantContextAccessor.Current = previousContext;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task TryGeocodeTenantAsync(
|
||||
Tenant tenant,
|
||||
IAddressGeocodingService geocodingService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var candidates = GeoAddressBuilder.BuildTenantCandidates(tenant);
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
GeoLocationStateHelper.MarkPending(tenant, "缺少地址信息,等待补全后自动定位", 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(
|
||||
tenant,
|
||||
(double)result.Latitude.Value,
|
||||
(double)result.Longitude.Value,
|
||||
now);
|
||||
return;
|
||||
}
|
||||
|
||||
lastError = result.Message;
|
||||
}
|
||||
|
||||
GeoLocationStateHelper.MarkPending(tenant, lastError ?? "地址地理编码失败,等待自动重试", now);
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
@@ -18,5 +18,9 @@ public sealed class SelfRegisterTenantCommandValidator : AbstractValidator<SelfR
|
||||
.MaximumLength(64)
|
||||
.Matches("^[A-Za-z0-9]+$")
|
||||
.WithMessage("登录账号仅允许大小写字母和数字");
|
||||
RuleFor(x => x.Country).MaximumLength(64);
|
||||
RuleFor(x => x.Province).MaximumLength(64);
|
||||
RuleFor(x => x.City).MaximumLength(64);
|
||||
RuleFor(x => x.Address).MaximumLength(256);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user