From 53f7c54c8251e6fc1338d33322c50a67af40fce4 Mon Sep 17 00:00:00 2001
From: MSuMshk <2039814060@qq.com>
Date: Thu, 19 Feb 2026 17:13:00 +0800
Subject: [PATCH] feat(geo): add tenant/merchant/store geocode fallback and
retry workflow
---
.../Controllers/MerchantController.cs | 111 +-
.../Controllers/StoreController.cs | 35 +-
src/Api/TakeoutSaaS.TenantApi/Program.cs | 4 +
.../Services/GeoLocationOrchestrator.cs | 340 +
.../GeoLocationRetryBackgroundService.cs | 46 +
.../Services/TencentMapGeocodingService.cs | 38 +-
.../App/Common/Geo/AddressGeocodingResult.cs | 23 +
.../App/Common/Geo/GeoAddressBuilder.cs | 82 +
.../App/Common/Geo/GeoLocationStateHelper.cs | 164 +
.../Common/Geo/IAddressGeocodingService.cs | 15 +
.../Commands/CreateMerchantCommand.cs | 24 +
.../App/Merchants/Dto/MerchantDetailDto.cs | 15 +
.../Handlers/CreateMerchantCommandHandler.cs | 41 +-
.../Handlers/UpdateMerchantCommandHandler.cs | 38 +
.../App/Merchants/MerchantMapping.cs | 3 +
.../CreateMerchantCommandValidator.cs | 4 +
.../App/Stores/Dto/StoreDto.cs | 16 +
.../App/Stores/Dto/StoreListItemDto.cs | 16 +
.../Handlers/CreateStoreCommandHandler.cs | 40 +
.../Handlers/UpdateStoreCommandHandler.cs | 53 +-
.../App/Stores/StoreListMapping.cs | 3 +
.../App/Stores/StoreMapping.cs | 3 +
.../Commands/SelfRegisterTenantCommand.cs | 24 +
.../SelfRegisterTenantCommandHandler.cs | 45 +-
.../SelfRegisterTenantCommandValidator.cs | 4 +
.../Common/Enums/GeoLocationStatus.cs | 22 +
.../Merchants/Entities/Merchant.cs | 25 +
.../Stores/Entities/Store.cs | 26 +
.../Tenants/Entities/Tenant.cs | 35 +
.../App/Persistence/TakeoutAppDbContext.cs | 17 +
...47_AddGeoLocationRetryMetadata.Designer.cs | 7856 +++++++++++++++++
...60219090447_AddGeoLocationRetryMetadata.cs | 262 +
.../TakeoutAppDbContextModelSnapshot.cs | 95 +
33 files changed, 9514 insertions(+), 11 deletions(-)
create mode 100644 src/Api/TakeoutSaaS.TenantApi/Services/GeoLocationOrchestrator.cs
create mode 100644 src/Api/TakeoutSaaS.TenantApi/Services/GeoLocationRetryBackgroundService.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Common/Geo/AddressGeocodingResult.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Common/Geo/GeoAddressBuilder.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Common/Geo/GeoLocationStateHelper.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Common/Geo/IAddressGeocodingService.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Common/Enums/GeoLocationStatus.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260219090447_AddGeoLocationRetryMetadata.Designer.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260219090447_AddGeoLocationRetryMetadata.cs
diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/MerchantController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/MerchantController.cs
index ea5ac1f..3bc0239 100644
--- a/src/Api/TakeoutSaaS.TenantApi/Controllers/MerchantController.cs
+++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/MerchantController.cs
@@ -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
{
///
/// 获取当前登录用户对应的商户中心信息。
@@ -32,4 +39,106 @@ public sealed class MerchantController(IMediator mediator) : BaseApiController
// 2. 返回聚合信息
return ApiResponse.Ok(info);
}
+
+ ///
+ /// 更新当前商户基础信息。
+ ///
+ /// 更新请求。
+ /// 取消标记。
+ /// 更新结果。
+ [HttpPost("update")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)]
+ public async Task> 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.Error(ErrorCodes.NotFound, "商户不存在");
+ }
+
+ return ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 手动重试当前商户地理定位。
+ ///
+ /// 取消标记。
+ /// 重试结果。
+ [HttpPost("geocode/retry")]
+ [ProducesResponseType(typeof(ApiResponse
public double? Latitude { get; init; }
+ ///
+ /// 地理定位状态。
+ ///
+ public GeoLocationStatus GeoStatus { get; init; }
+
+ ///
+ /// 地理定位失败原因。
+ ///
+ public string? GeoFailReason { get; init; }
+
+ ///
+ /// 地理定位最近成功时间。
+ ///
+ public DateTime? GeoUpdatedAt { get; init; }
+
///
/// 门店封面图。
///
diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreCommandHandler.cs
index e5d7ebd..db1ddf2 100644
--- a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreCommandHandler.cs
+++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreCommandHandler.cs
@@ -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
{
@@ -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);
+ }
+
///
/// 生成当前租户商户内唯一的门店编码。
///
diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreCommandHandler.cs
index e375762..56c0274 100644
--- a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreCommandHandler.cs
+++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreCommandHandler.cs
@@ -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;
///
public sealed class UpdateStoreCommandHandler(
StoreContextService storeContextService,
- IStoreRepository storeRepository)
+ IStoreRepository storeRepository,
+ IMerchantRepository merchantRepository,
+ IAddressGeocodingService geocodingService)
: IRequestHandler
{
///
@@ -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);
+ }
}
diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/StoreListMapping.cs b/src/Application/TakeoutSaaS.Application/App/Stores/StoreListMapping.cs
index c41997e..0a29b63 100644
--- a/src/Application/TakeoutSaaS.Application/App/Stores/StoreListMapping.cs
+++ b/src/Application/TakeoutSaaS.Application/App/Stores/StoreListMapping.cs
@@ -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,
diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/StoreMapping.cs b/src/Application/TakeoutSaaS.Application/App/Stores/StoreMapping.cs
index e7e4939..e68d481 100644
--- a/src/Application/TakeoutSaaS.Application/App/Stores/StoreMapping.cs
+++ b/src/Application/TakeoutSaaS.Application/App/Stores/StoreMapping.cs
@@ -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,
diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/SelfRegisterTenantCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/SelfRegisterTenantCommand.cs
index cddd7ed..e0296c5 100644
--- a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/SelfRegisterTenantCommand.cs
+++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/SelfRegisterTenantCommand.cs
@@ -37,6 +37,30 @@ public sealed record SelfRegisterTenantCommand : IRequest
[StringLength(32)]
public string AdminPhone { get; init; } = string.Empty;
+ ///
+ /// 国家/地区。
+ ///
+ [StringLength(64)]
+ public string? Country { get; init; }
+
+ ///
+ /// 省份。
+ ///
+ [StringLength(64)]
+ public string? Province { get; init; }
+
+ ///
+ /// 城市。
+ ///
+ [StringLength(64)]
+ public string? City { get; init; }
+
+ ///
+ /// 详细地址。
+ ///
+ [StringLength(256)]
+ public string? Address { get; init; }
+
///
/// 初始管理员登录密码(前端自定义)。
///
diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SelfRegisterTenantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SelfRegisterTenantCommandHandler.cs
index 4d15ddf..96cdd01 100644
--- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SelfRegisterTenantCommandHandler.cs
+++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SelfRegisterTenantCommandHandler.cs
@@ -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 passwordHasher,
IIdGenerator idGenerator,
IMediator mediator,
- ITenantContextAccessor tenantContextAccessor)
+ ITenantContextAccessor tenantContextAccessor,
+ IAddressGeocodingService geocodingService)
: IRequestHandler
{
///
@@ -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();
}
diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Validators/SelfRegisterTenantCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Validators/SelfRegisterTenantCommandValidator.cs
index 411d834..5ca05ff 100644
--- a/src/Application/TakeoutSaaS.Application/App/Tenants/Validators/SelfRegisterTenantCommandValidator.cs
+++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Validators/SelfRegisterTenantCommandValidator.cs
@@ -18,5 +18,9 @@ public sealed class SelfRegisterTenantCommandValidator : AbstractValidator x.Country).MaximumLength(64);
+ RuleFor(x => x.Province).MaximumLength(64);
+ RuleFor(x => x.City).MaximumLength(64);
+ RuleFor(x => x.Address).MaximumLength(256);
}
}
diff --git a/src/Domain/TakeoutSaaS.Domain/Common/Enums/GeoLocationStatus.cs b/src/Domain/TakeoutSaaS.Domain/Common/Enums/GeoLocationStatus.cs
new file mode 100644
index 0000000..6d208b0
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Common/Enums/GeoLocationStatus.cs
@@ -0,0 +1,22 @@
+namespace TakeoutSaaS.Domain.Common.Enums;
+
+///
+/// 地理定位状态。
+///
+public enum GeoLocationStatus
+{
+ ///
+ /// 待定位(可重试)。
+ ///
+ Pending = 0,
+
+ ///
+ /// 定位成功。
+ ///
+ Success = 1,
+
+ ///
+ /// 定位失败(达到重试上限或不可恢复错误)。
+ ///
+ Failed = 2
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/Merchant.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/Merchant.cs
index e6b0f73..4530900 100644
--- a/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/Merchant.cs
+++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/Merchant.cs
@@ -99,6 +99,31 @@ public sealed class Merchant : MultiTenantEntityBase
///
public double? Latitude { get; set; }
+ ///
+ /// 地理定位状态。
+ ///
+ public GeoLocationStatus GeoStatus { get; set; } = GeoLocationStatus.Pending;
+
+ ///
+ /// 地理定位失败原因。
+ ///
+ public string? GeoFailReason { get; set; }
+
+ ///
+ /// 地理定位重试次数。
+ ///
+ public int GeoRetryCount { get; set; }
+
+ ///
+ /// 地理定位最近成功时间(UTC)。
+ ///
+ public DateTime? GeoUpdatedAt { get; set; }
+
+ ///
+ /// 下次地理定位重试时间(UTC)。
+ ///
+ public DateTime? GeoNextRetryAt { get; set; }
+
///
/// 入驻状态。
///
diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Entities/Store.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/Store.cs
index cac5aab..448b372 100644
--- a/src/Domain/TakeoutSaaS.Domain/Stores/Entities/Store.cs
+++ b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/Store.cs
@@ -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
///
public double? Latitude { get; set; }
+ ///
+ /// 地理定位状态。
+ ///
+ public GeoLocationStatus GeoStatus { get; set; } = GeoLocationStatus.Pending;
+
+ ///
+ /// 地理定位失败原因。
+ ///
+ public string? GeoFailReason { get; set; }
+
+ ///
+ /// 地理定位重试次数。
+ ///
+ public int GeoRetryCount { get; set; }
+
+ ///
+ /// 地理定位最近成功时间(UTC)。
+ ///
+ public DateTime? GeoUpdatedAt { get; set; }
+
+ ///
+ /// 下次地理定位重试时间(UTC)。
+ ///
+ public DateTime? GeoNextRetryAt { get; set; }
+
///
/// 门店描述或公告。
///
diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/Tenant.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/Tenant.cs
index caee976..29b14ec 100644
--- a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/Tenant.cs
+++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/Tenant.cs
@@ -69,6 +69,41 @@ public sealed class Tenant : AuditableEntityBase
///
public string? Address { get; set; }
+ ///
+ /// 经度信息。
+ ///
+ public double? Longitude { get; set; }
+
+ ///
+ /// 纬度信息。
+ ///
+ public double? Latitude { get; set; }
+
+ ///
+ /// 地理定位状态。
+ ///
+ public GeoLocationStatus GeoStatus { get; set; } = GeoLocationStatus.Pending;
+
+ ///
+ /// 地理定位失败原因。
+ ///
+ public string? GeoFailReason { get; set; }
+
+ ///
+ /// 地理定位重试次数。
+ ///
+ public int GeoRetryCount { get; set; }
+
+ ///
+ /// 地理定位最近成功时间(UTC)。
+ ///
+ public DateTime? GeoUpdatedAt { get; set; }
+
+ ///
+ /// 下次地理定位重试时间(UTC)。
+ ///
+ public DateTime? GeoNextRetryAt { get; set; }
+
///
/// 主联系人姓名。
///
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
index 037675c..c982879 100644
--- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
@@ -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().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();
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 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();
+ builder.Property(x => x.GeoStatus).HasConversion().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 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().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 })
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260219090447_AddGeoLocationRetryMetadata.Designer.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260219090447_AddGeoLocationRetryMetadata.Designer.cs
new file mode 100644
index 0000000..d74f087
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260219090447_AddGeoLocationRetryMetadata.Designer.cs
@@ -0,0 +1,7856 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using TakeoutSaaS.Infrastructure.App.Persistence;
+
+#nullable disable
+
+namespace TakeoutSaaS.Infrastructure.Migrations
+{
+ [DbContext(typeof(TakeoutAppDbContext))]
+ [Migration("20260219090447_AddGeoLocationRetryMetadata")]
+ partial class AddGeoLocationRetryMetadata
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.1")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Analytics.Entities.MetricAlertRule", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ConditionJson")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasComment("触发条件 JSON。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("Enabled")
+ .HasColumnType("boolean")
+ .HasComment("是否启用。");
+
+ b.Property("MetricDefinitionId")
+ .HasColumnType("bigint")
+ .HasComment("关联指标。");
+
+ b.Property("NotificationChannels")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasComment("通知渠道。");
+
+ b.Property("Severity")
+ .HasColumnType("integer")
+ .HasComment("告警级别。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "MetricDefinitionId", "Severity");
+
+ b.ToTable("metric_alert_rules", null, t =>
+ {
+ t.HasComment("指标告警规则。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Analytics.Entities.MetricDefinition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasComment("指标编码。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DefaultAggregation")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasComment("默认聚合方式。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("Description")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasComment("说明。");
+
+ b.Property("DimensionsJson")
+ .HasColumnType("text")
+ .HasComment("维度描述 JSON。");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasComment("指标名称。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "Code")
+ .IsUnique();
+
+ b.ToTable("metric_definitions", null, t =>
+ {
+ t.HasComment("指标定义,描述可观测的数据点。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Analytics.Entities.MetricSnapshot", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("DimensionKey")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasComment("维度键(JSON)。");
+
+ b.Property("MetricDefinitionId")
+ .HasColumnType("bigint")
+ .HasComment("指标定义 ID。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("Value")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)")
+ .HasComment("数值。");
+
+ b.Property("WindowEnd")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("统计时间窗口结束。");
+
+ b.Property("WindowStart")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("统计时间窗口开始。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "MetricDefinitionId", "DimensionKey", "WindowStart", "WindowEnd")
+ .IsUnique();
+
+ b.ToTable("metric_snapshots", null, t =>
+ {
+ t.HasComment("指标快照,用于大盘展示。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.Coupon", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasComment("券码或序列号。");
+
+ b.Property("CouponTemplateId")
+ .HasColumnType("bigint")
+ .HasComment("模板标识。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("ExpireAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("到期时间。");
+
+ b.Property("IssuedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("发放时间。");
+
+ b.Property("OrderId")
+ .HasColumnType("bigint")
+ .HasComment("订单 ID(已使用时记录)。");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasComment("状态。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("UsedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("使用时间。");
+
+ b.Property("UserId")
+ .HasColumnType("bigint")
+ .HasComment("归属用户。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "Code")
+ .IsUnique();
+
+ b.ToTable("coupons", null, t =>
+ {
+ t.HasComment("用户领取的券。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.CouponTemplate", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AllowStack")
+ .HasColumnType("boolean")
+ .HasComment("是否允许叠加其他优惠。");
+
+ b.Property("ChannelsJson")
+ .HasColumnType("text")
+ .HasComment("发放渠道(JSON)。");
+
+ b.Property("ClaimedQuantity")
+ .HasColumnType("integer")
+ .HasComment("已领取数量。");
+
+ b.Property("CouponType")
+ .HasColumnType("integer")
+ .HasComment("券类型。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("Description")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasComment("备注。");
+
+ b.Property("DiscountCap")
+ .HasColumnType("numeric")
+ .HasComment("折扣上限(针对折扣券)。");
+
+ b.Property("MinimumSpend")
+ .HasColumnType("numeric")
+ .HasComment("最低消费门槛。");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasComment("模板名称。");
+
+ b.Property("ProductScopeJson")
+ .HasColumnType("text")
+ .HasComment("适用品类或商品范围(JSON)。");
+
+ b.Property("RelativeValidDays")
+ .HasColumnType("integer")
+ .HasComment("有效天数(相对发放时间)。");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasComment("状态。");
+
+ b.Property("StoreScopeJson")
+ .HasColumnType("text")
+ .HasComment("适用门店 ID 集合(JSON)。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("TotalQuantity")
+ .HasColumnType("integer")
+ .HasComment("总发放数量上限。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("ValidFrom")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("可用开始时间。");
+
+ b.Property("ValidTo")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("可用结束时间。");
+
+ b.Property("Value")
+ .HasColumnType("numeric")
+ .HasComment("面值或折扣额度。");
+
+ b.HasKey("Id");
+
+ b.ToTable("coupon_templates", null, t =>
+ {
+ t.HasComment("优惠券模板。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.PromotionCampaign", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AudienceDescription")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasComment("目标人群描述。");
+
+ b.Property("BannerUrl")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasComment("营销素材(如 banner)。");
+
+ b.Property("Budget")
+ .HasColumnType("numeric")
+ .HasComment("预算金额。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("EndAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("结束时间。");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasComment("活动名称。");
+
+ b.Property("PromotionType")
+ .HasColumnType("integer")
+ .HasComment("活动类型。");
+
+ b.Property("RulesJson")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasComment("活动规则 JSON。");
+
+ b.Property("StartAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("开始时间。");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasComment("活动状态。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.HasKey("Id");
+
+ b.ToTable("promotion_campaigns", null, t =>
+ {
+ t.HasComment("营销活动配置。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.CustomerService.Entities.ChatMessage", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ChatSessionId")
+ .HasColumnType("bigint")
+ .HasComment("会话标识。");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("character varying(1024)")
+ .HasComment("消息内容。");
+
+ b.Property("ContentType")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasComment("消息类型(文字/图片/语音等)。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("IsRead")
+ .HasColumnType("boolean")
+ .HasComment("是否已读。");
+
+ b.Property("ReadAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("读取时间。");
+
+ b.Property("SenderType")
+ .HasColumnType("integer")
+ .HasComment("发送方类型。");
+
+ b.Property("SenderUserId")
+ .HasColumnType("bigint")
+ .HasComment("发送方用户 ID。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "ChatSessionId", "CreatedAt");
+
+ b.ToTable("chat_messages", null, t =>
+ {
+ t.HasComment("会话消息。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.CustomerService.Entities.ChatSession", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AgentUserId")
+ .HasColumnType("bigint")
+ .HasComment("当前客服员工 ID。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("CustomerUserId")
+ .HasColumnType("bigint")
+ .HasComment("顾客用户 ID。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("EndedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("结束时间。");
+
+ b.Property("IsBotActive")
+ .HasColumnType("boolean")
+ .HasComment("是否机器人接待中。");
+
+ b.Property("SessionCode")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasComment("会话编号。");
+
+ b.Property("StartedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("开始时间。");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasComment("会话状态。");
+
+ b.Property("StoreId")
+ .HasColumnType("bigint")
+ .HasComment("所属门店(可空为系统会话)。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "SessionCode")
+ .IsUnique();
+
+ b.ToTable("chat_sessions", null, t =>
+ {
+ t.HasComment("客服会话。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.CustomerService.Entities.SupportTicket", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AssignedAgentId")
+ .HasColumnType("bigint")
+ .HasComment("指派的客服。");
+
+ b.Property("ClosedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("关闭时间。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("CustomerUserId")
+ .HasColumnType("bigint")
+ .HasComment("客户用户 ID。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasComment("工单详情。");
+
+ b.Property("OrderId")
+ .HasColumnType("bigint")
+ .HasComment("关联订单(如有)。");
+
+ b.Property("Priority")
+ .HasColumnType("integer")
+ .HasComment("优先级。");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasComment("状态。");
+
+ b.Property("Subject")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasComment("工单主题。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("TicketNo")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasComment("工单编号。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "TicketNo")
+ .IsUnique();
+
+ b.ToTable("support_tickets", null, t =>
+ {
+ t.HasComment("客服工单。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.CustomerService.Entities.TicketComment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AttachmentsJson")
+ .HasColumnType("text")
+ .HasComment("附件 JSON。");
+
+ b.Property("AuthorUserId")
+ .HasColumnType("bigint")
+ .HasComment("评论人 ID。");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("character varying(1024)")
+ .HasComment("评论内容。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("IsInternal")
+ .HasColumnType("boolean")
+ .HasComment("是否内部备注。");
+
+ b.Property("SupportTicketId")
+ .HasColumnType("bigint")
+ .HasComment("工单标识。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "SupportTicketId");
+
+ b.ToTable("ticket_comments", null, t =>
+ {
+ t.HasComment("工单评论/流转记录。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Deliveries.Entities.DeliveryEvent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("DeliveryOrderId")
+ .HasColumnType("bigint")
+ .HasComment("配送单标识。");
+
+ b.Property("EventType")
+ .HasColumnType("integer")
+ .HasComment("事件类型。");
+
+ b.Property("Message")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasComment("事件描述。");
+
+ b.Property("OccurredAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("发生时间。");
+
+ b.Property("Payload")
+ .HasColumnType("text")
+ .HasComment("原始数据 JSON。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "DeliveryOrderId", "EventType");
+
+ b.ToTable("delivery_events", null, t =>
+ {
+ t.HasComment("配送状态事件流水。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Deliveries.Entities.DeliveryOrder", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CourierName")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasComment("骑手姓名。");
+
+ b.Property("CourierPhone")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasComment("骑手电话。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("DeliveredAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("完成时间。");
+
+ b.Property("DeliveryFee")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasComment("配送费。");
+
+ b.Property("DispatchedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("下发时间。");
+
+ b.Property("FailureReason")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasComment("异常原因。");
+
+ b.Property("OrderId")
+ .HasColumnType("bigint")
+ .HasComment("获取或设置关联订单 ID。");
+
+ b.Property("PickedUpAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("取餐时间。");
+
+ b.Property("Provider")
+ .HasColumnType("integer")
+ .HasComment("配送服务商。");
+
+ b.Property("ProviderOrderId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasComment("第三方配送单号。");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasComment("状态。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "OrderId")
+ .IsUnique();
+
+ b.ToTable("delivery_orders", null, t =>
+ {
+ t.HasComment("配送单。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Distribution.Entities.AffiliateOrder", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AffiliatePartnerId")
+ .HasColumnType("bigint")
+ .HasComment("推广人标识。");
+
+ b.Property("BuyerUserId")
+ .HasColumnType("bigint")
+ .HasComment("用户 ID。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("EstimatedCommission")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasComment("预计佣金。");
+
+ b.Property("OrderAmount")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasComment("订单金额。");
+
+ b.Property("OrderId")
+ .HasColumnType("bigint")
+ .HasComment("关联订单。");
+
+ b.Property("SettledAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("结算完成时间。");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasComment("当前状态。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "AffiliatePartnerId", "OrderId")
+ .IsUnique();
+
+ b.ToTable("affiliate_orders", null, t =>
+ {
+ t.HasComment("分销订单记录。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Distribution.Entities.AffiliatePartner", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ChannelType")
+ .HasColumnType("integer")
+ .HasComment("渠道类型。");
+
+ b.Property("CommissionRate")
+ .HasColumnType("numeric")
+ .HasComment("分成比例(0-1)。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("DisplayName")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasComment("昵称或渠道名称。");
+
+ b.Property("Phone")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasComment("联系电话。");
+
+ b.Property("Remarks")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasComment("审核备注。");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasComment("当前状态。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("UserId")
+ .HasColumnType("bigint")
+ .HasComment("用户 ID(如绑定登录账号)。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "DisplayName");
+
+ b.ToTable("affiliate_partners", null, t =>
+ {
+ t.HasComment("分销/推广合作伙伴。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Distribution.Entities.AffiliatePayout", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AffiliatePartnerId")
+ .HasColumnType("bigint")
+ .HasComment("合作伙伴标识。");
+
+ b.Property("Amount")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasComment("结算金额。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property