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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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,

View File

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

View File

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

View File

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