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,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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace TakeoutSaaS.Domain.Common.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 地理定位状态。
|
||||
/// </summary>
|
||||
public enum GeoLocationStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// 待定位(可重试)。
|
||||
/// </summary>
|
||||
Pending = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 定位成功。
|
||||
/// </summary>
|
||||
Success = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 定位失败(达到重试上限或不可恢复错误)。
|
||||
/// </summary>
|
||||
Failed = 2
|
||||
}
|
||||
@@ -99,6 +99,31 @@ public sealed class Merchant : MultiTenantEntityBase
|
||||
/// </summary>
|
||||
public double? Latitude { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地理定位状态。
|
||||
/// </summary>
|
||||
public GeoLocationStatus GeoStatus { get; set; } = GeoLocationStatus.Pending;
|
||||
|
||||
/// <summary>
|
||||
/// 地理定位失败原因。
|
||||
/// </summary>
|
||||
public string? GeoFailReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地理定位重试次数。
|
||||
/// </summary>
|
||||
public int GeoRetryCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地理定位最近成功时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? GeoUpdatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 下次地理定位重试时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? GeoNextRetryAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 入驻状态。
|
||||
/// </summary>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using TakeoutSaaS.Domain.Common.Enums;
|
||||
using TakeoutSaaS.Domain.Stores.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
@@ -153,6 +154,31 @@ public sealed class Store : MultiTenantEntityBase
|
||||
/// </summary>
|
||||
public double? Latitude { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地理定位状态。
|
||||
/// </summary>
|
||||
public GeoLocationStatus GeoStatus { get; set; } = GeoLocationStatus.Pending;
|
||||
|
||||
/// <summary>
|
||||
/// 地理定位失败原因。
|
||||
/// </summary>
|
||||
public string? GeoFailReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地理定位重试次数。
|
||||
/// </summary>
|
||||
public int GeoRetryCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地理定位最近成功时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? GeoUpdatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 下次地理定位重试时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? GeoNextRetryAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店描述或公告。
|
||||
/// </summary>
|
||||
|
||||
@@ -69,6 +69,41 @@ public sealed class Tenant : AuditableEntityBase
|
||||
/// </summary>
|
||||
public string? Address { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 经度信息。
|
||||
/// </summary>
|
||||
public double? Longitude { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 纬度信息。
|
||||
/// </summary>
|
||||
public double? Latitude { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地理定位状态。
|
||||
/// </summary>
|
||||
public GeoLocationStatus GeoStatus { get; set; } = GeoLocationStatus.Pending;
|
||||
|
||||
/// <summary>
|
||||
/// 地理定位失败原因。
|
||||
/// </summary>
|
||||
public string? GeoFailReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地理定位重试次数。
|
||||
/// </summary>
|
||||
public int GeoRetryCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地理定位最近成功时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? GeoUpdatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 下次地理定位重试时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? GeoNextRetryAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 主联系人姓名。
|
||||
/// </summary>
|
||||
|
||||
@@ -5,6 +5,7 @@ using TakeoutSaaS.Domain.Coupons.Entities;
|
||||
using TakeoutSaaS.Domain.CustomerService.Entities;
|
||||
using TakeoutSaaS.Domain.Deliveries.Entities;
|
||||
using TakeoutSaaS.Domain.Distribution.Entities;
|
||||
using TakeoutSaaS.Domain.Common.Enums;
|
||||
using TakeoutSaaS.Domain.Engagement.Entities;
|
||||
using TakeoutSaaS.Domain.GroupBuying.Entities;
|
||||
using TakeoutSaaS.Domain.Inventory.Entities;
|
||||
@@ -505,10 +506,16 @@ public sealed class TakeoutAppDbContext(
|
||||
builder.Property(x => x.ContactEmail).HasMaxLength(128);
|
||||
builder.Property(x => x.Industry).HasMaxLength(64);
|
||||
builder.Property(x => x.LogoUrl).HasColumnType("text");
|
||||
builder.Property(x => x.GeoStatus).HasConversion<int>().HasDefaultValue(GeoLocationStatus.Pending);
|
||||
builder.Property(x => x.GeoFailReason).HasMaxLength(500);
|
||||
builder.Property(x => x.GeoRetryCount).HasDefaultValue(0);
|
||||
builder.Property(x => x.Remarks).HasMaxLength(512);
|
||||
builder.Property(x => x.OperatingMode).HasConversion<int>();
|
||||
builder.HasIndex(x => x.Code).IsUnique();
|
||||
builder.HasIndex(x => x.ContactPhone).IsUnique();
|
||||
builder.HasIndex(x => new { x.GeoStatus, x.GeoNextRetryAt });
|
||||
builder.HasIndex(x => new { x.Longitude, x.Latitude })
|
||||
.HasFilter("\"Longitude\" IS NOT NULL AND \"Latitude\" IS NOT NULL");
|
||||
}
|
||||
|
||||
private static void ConfigureTenantVerificationProfile(EntityTypeBuilder<TenantVerificationProfile> builder)
|
||||
@@ -558,6 +565,9 @@ public sealed class TakeoutAppDbContext(
|
||||
builder.Property(x => x.Address).HasMaxLength(256);
|
||||
builder.Property(x => x.ReviewRemarks).HasMaxLength(512);
|
||||
builder.Property(x => x.OperatingMode).HasConversion<int>();
|
||||
builder.Property(x => x.GeoStatus).HasConversion<int>().HasDefaultValue(GeoLocationStatus.Pending);
|
||||
builder.Property(x => x.GeoFailReason).HasMaxLength(500);
|
||||
builder.Property(x => x.GeoRetryCount).HasDefaultValue(0);
|
||||
builder.Property(x => x.IsFrozen).HasDefaultValue(false);
|
||||
builder.Property(x => x.FrozenReason).HasMaxLength(500);
|
||||
builder.Property(x => x.ClaimedByName).HasMaxLength(100);
|
||||
@@ -570,6 +580,9 @@ public sealed class TakeoutAppDbContext(
|
||||
builder.HasIndex(x => x.TenantId);
|
||||
builder.HasIndex(x => new { x.TenantId, x.Status });
|
||||
builder.HasIndex(x => x.ClaimedBy);
|
||||
builder.HasIndex(x => new { x.TenantId, x.GeoStatus, x.GeoNextRetryAt });
|
||||
builder.HasIndex(x => new { x.Longitude, x.Latitude })
|
||||
.HasFilter("\"Longitude\" IS NOT NULL AND \"Latitude\" IS NOT NULL");
|
||||
}
|
||||
|
||||
private static void ConfigureStore(EntityTypeBuilder<Store> builder)
|
||||
@@ -599,11 +612,15 @@ public sealed class TakeoutAppDbContext(
|
||||
builder.Property(x => x.BusinessHours).HasMaxLength(256);
|
||||
builder.Property(x => x.Announcement).HasMaxLength(512);
|
||||
builder.Property(x => x.DeliveryRadiusKm).HasPrecision(6, 2);
|
||||
builder.Property(x => x.GeoStatus).HasConversion<int>().HasDefaultValue(GeoLocationStatus.Pending);
|
||||
builder.Property(x => x.GeoFailReason).HasMaxLength(500);
|
||||
builder.Property(x => x.GeoRetryCount).HasDefaultValue(0);
|
||||
builder.HasIndex(x => new { x.TenantId, x.MerchantId });
|
||||
builder.HasIndex(x => new { x.TenantId, x.Code }).IsUnique();
|
||||
builder.HasIndex(x => new { x.TenantId, x.AuditStatus });
|
||||
builder.HasIndex(x => new { x.TenantId, x.BusinessStatus });
|
||||
builder.HasIndex(x => new { x.TenantId, x.OwnershipType });
|
||||
builder.HasIndex(x => new { x.TenantId, x.MerchantId, x.GeoStatus, x.GeoNextRetryAt });
|
||||
builder.HasIndex(x => new { x.Longitude, x.Latitude })
|
||||
.HasFilter("\"Longitude\" IS NOT NULL AND \"Latitude\" IS NOT NULL");
|
||||
builder.HasIndex(x => new { x.MerchantId, x.BusinessLicenseNumber })
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,262 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGeoLocationRetryMetadata : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "GeoFailReason",
|
||||
table: "tenants",
|
||||
type: "character varying(500)",
|
||||
maxLength: 500,
|
||||
nullable: true,
|
||||
comment: "地理定位失败原因。");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "GeoNextRetryAt",
|
||||
table: "tenants",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true,
|
||||
comment: "下次地理定位重试时间(UTC)。");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "GeoRetryCount",
|
||||
table: "tenants",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0,
|
||||
comment: "地理定位重试次数。");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "GeoStatus",
|
||||
table: "tenants",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0,
|
||||
comment: "地理定位状态。");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "GeoUpdatedAt",
|
||||
table: "tenants",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true,
|
||||
comment: "地理定位最近成功时间(UTC)。");
|
||||
|
||||
migrationBuilder.AddColumn<double>(
|
||||
name: "Latitude",
|
||||
table: "tenants",
|
||||
type: "double precision",
|
||||
nullable: true,
|
||||
comment: "纬度信息。");
|
||||
|
||||
migrationBuilder.AddColumn<double>(
|
||||
name: "Longitude",
|
||||
table: "tenants",
|
||||
type: "double precision",
|
||||
nullable: true,
|
||||
comment: "经度信息。");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "GeoFailReason",
|
||||
table: "stores",
|
||||
type: "character varying(500)",
|
||||
maxLength: 500,
|
||||
nullable: true,
|
||||
comment: "地理定位失败原因。");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "GeoNextRetryAt",
|
||||
table: "stores",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true,
|
||||
comment: "下次地理定位重试时间(UTC)。");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "GeoRetryCount",
|
||||
table: "stores",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0,
|
||||
comment: "地理定位重试次数。");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "GeoStatus",
|
||||
table: "stores",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0,
|
||||
comment: "地理定位状态。");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "GeoUpdatedAt",
|
||||
table: "stores",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true,
|
||||
comment: "地理定位最近成功时间(UTC)。");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "GeoFailReason",
|
||||
table: "merchants",
|
||||
type: "character varying(500)",
|
||||
maxLength: 500,
|
||||
nullable: true,
|
||||
comment: "地理定位失败原因。");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "GeoNextRetryAt",
|
||||
table: "merchants",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true,
|
||||
comment: "下次地理定位重试时间(UTC)。");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "GeoRetryCount",
|
||||
table: "merchants",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0,
|
||||
comment: "地理定位重试次数。");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "GeoStatus",
|
||||
table: "merchants",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0,
|
||||
comment: "地理定位状态。");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "GeoUpdatedAt",
|
||||
table: "merchants",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true,
|
||||
comment: "地理定位最近成功时间(UTC)。");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_tenants_GeoStatus_GeoNextRetryAt",
|
||||
table: "tenants",
|
||||
columns: new[] { "GeoStatus", "GeoNextRetryAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_tenants_Longitude_Latitude",
|
||||
table: "tenants",
|
||||
columns: new[] { "Longitude", "Latitude" },
|
||||
filter: "\"Longitude\" IS NOT NULL AND \"Latitude\" IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stores_TenantId_MerchantId_GeoStatus_GeoNextRetryAt",
|
||||
table: "stores",
|
||||
columns: new[] { "TenantId", "MerchantId", "GeoStatus", "GeoNextRetryAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_merchants_Longitude_Latitude",
|
||||
table: "merchants",
|
||||
columns: new[] { "Longitude", "Latitude" },
|
||||
filter: "\"Longitude\" IS NOT NULL AND \"Latitude\" IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_merchants_TenantId_GeoStatus_GeoNextRetryAt",
|
||||
table: "merchants",
|
||||
columns: new[] { "TenantId", "GeoStatus", "GeoNextRetryAt" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_tenants_GeoStatus_GeoNextRetryAt",
|
||||
table: "tenants");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_tenants_Longitude_Latitude",
|
||||
table: "tenants");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_stores_TenantId_MerchantId_GeoStatus_GeoNextRetryAt",
|
||||
table: "stores");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_merchants_Longitude_Latitude",
|
||||
table: "merchants");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_merchants_TenantId_GeoStatus_GeoNextRetryAt",
|
||||
table: "merchants");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GeoFailReason",
|
||||
table: "tenants");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GeoNextRetryAt",
|
||||
table: "tenants");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GeoRetryCount",
|
||||
table: "tenants");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GeoStatus",
|
||||
table: "tenants");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GeoUpdatedAt",
|
||||
table: "tenants");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Latitude",
|
||||
table: "tenants");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Longitude",
|
||||
table: "tenants");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GeoFailReason",
|
||||
table: "stores");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GeoNextRetryAt",
|
||||
table: "stores");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GeoRetryCount",
|
||||
table: "stores");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GeoStatus",
|
||||
table: "stores");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GeoUpdatedAt",
|
||||
table: "stores");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GeoFailReason",
|
||||
table: "merchants");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GeoNextRetryAt",
|
||||
table: "merchants");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GeoRetryCount",
|
||||
table: "merchants");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GeoStatus",
|
||||
table: "merchants");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GeoUpdatedAt",
|
||||
table: "merchants");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2468,6 +2468,31 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasComment("冻结原因。");
|
||||
|
||||
b.Property<string>("GeoFailReason")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasComment("地理定位失败原因。");
|
||||
|
||||
b.Property<DateTime?>("GeoNextRetryAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("下次地理定位重试时间(UTC)。");
|
||||
|
||||
b.Property<int>("GeoRetryCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0)
|
||||
.HasComment("地理定位重试次数。");
|
||||
|
||||
b.Property<int>("GeoStatus")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0)
|
||||
.HasComment("地理定位状态。");
|
||||
|
||||
b.Property<DateTime?>("GeoUpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("地理定位最近成功时间(UTC)。");
|
||||
|
||||
b.Property<bool>("IsFrozen")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
@@ -2557,8 +2582,13 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("Longitude", "Latitude")
|
||||
.HasFilter("\"Longitude\" IS NOT NULL AND \"Latitude\" IS NOT NULL");
|
||||
|
||||
b.HasIndex("TenantId", "Status");
|
||||
|
||||
b.HasIndex("TenantId", "GeoStatus", "GeoNextRetryAt");
|
||||
|
||||
b.ToTable("merchants", null, t =>
|
||||
{
|
||||
t.HasComment("商户主体信息,承载入驻和资质审核结果。");
|
||||
@@ -4935,6 +4965,31 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("强制关闭时间。");
|
||||
|
||||
b.Property<string>("GeoFailReason")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasComment("地理定位失败原因。");
|
||||
|
||||
b.Property<DateTime?>("GeoNextRetryAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("下次地理定位重试时间(UTC)。");
|
||||
|
||||
b.Property<int>("GeoRetryCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0)
|
||||
.HasComment("地理定位重试次数。");
|
||||
|
||||
b.Property<int>("GeoStatus")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0)
|
||||
.HasComment("地理定位状态。");
|
||||
|
||||
b.Property<DateTime?>("GeoUpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("地理定位最近成功时间(UTC)。");
|
||||
|
||||
b.Property<double?>("Latitude")
|
||||
.HasColumnType("double precision")
|
||||
.HasComment("纬度。");
|
||||
@@ -5056,6 +5111,8 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("TenantId", "OwnershipType");
|
||||
|
||||
b.HasIndex("TenantId", "MerchantId", "GeoStatus", "GeoNextRetryAt");
|
||||
|
||||
b.ToTable("stores", null, t =>
|
||||
{
|
||||
t.HasComment("门店信息,承载营业配置与能力。");
|
||||
@@ -6460,11 +6517,40 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("服务到期时间(UTC)。");
|
||||
|
||||
b.Property<string>("GeoFailReason")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasComment("地理定位失败原因。");
|
||||
|
||||
b.Property<DateTime?>("GeoNextRetryAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("下次地理定位重试时间(UTC)。");
|
||||
|
||||
b.Property<int>("GeoRetryCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0)
|
||||
.HasComment("地理定位重试次数。");
|
||||
|
||||
b.Property<int>("GeoStatus")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0)
|
||||
.HasComment("地理定位状态。");
|
||||
|
||||
b.Property<DateTime?>("GeoUpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("地理定位最近成功时间(UTC)。");
|
||||
|
||||
b.Property<string>("Industry")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("所属行业,如餐饮、零售等。");
|
||||
|
||||
b.Property<double?>("Latitude")
|
||||
.HasColumnType("double precision")
|
||||
.HasComment("纬度信息。");
|
||||
|
||||
b.Property<string>("LegalEntityName")
|
||||
.HasColumnType("text")
|
||||
.HasComment("法人或公司主体名称。");
|
||||
@@ -6473,6 +6559,10 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
.HasColumnType("text")
|
||||
.HasComment("LOGO 图片地址。");
|
||||
|
||||
b.Property<double?>("Longitude")
|
||||
.HasColumnType("double precision")
|
||||
.HasComment("经度信息。");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
@@ -6537,6 +6627,11 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
b.HasIndex("ContactPhone")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("GeoStatus", "GeoNextRetryAt");
|
||||
|
||||
b.HasIndex("Longitude", "Latitude")
|
||||
.HasFilter("\"Longitude\" IS NOT NULL AND \"Latitude\" IS NOT NULL");
|
||||
|
||||
b.ToTable("tenants", null, t =>
|
||||
{
|
||||
t.HasComment("租户信息,描述租户的生命周期与基础资料。");
|
||||
|
||||
Reference in New Issue
Block a user