diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Product/ProductContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Product/ProductContracts.cs
index a9d9684..19aea03 100644
--- a/src/Api/TakeoutSaaS.TenantApi/Contracts/Product/ProductContracts.cs
+++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Product/ProductContracts.cs
@@ -292,6 +292,104 @@ public sealed class SaveProductSkuAttributeRequest
public string OptionId { get; set; } = string.Empty;
}
+///
+/// 创建商品 SKU 异步保存任务请求。
+///
+public sealed class CreateProductSkuSaveJobRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 商品 ID。
+ ///
+ public string ProductId { get; set; } = string.Empty;
+
+ ///
+ /// 关联规格模板 ID(可选,未传则使用当前商品关联)。
+ ///
+ public List? SpecTemplateIds { get; set; }
+
+ ///
+ /// SKU 列表。
+ ///
+ public List? Skus { get; set; }
+}
+
+///
+/// 查询商品 SKU 异步任务状态请求。
+///
+public sealed class ProductSkuSaveJobStatusRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+}
+
+///
+/// 商品 SKU 异步保存任务响应。
+///
+public sealed class ProductSkuSaveJobResponse
+{
+ ///
+ /// 任务 ID。
+ ///
+ public string JobId { get; set; } = string.Empty;
+
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 商品 ID。
+ ///
+ public string ProductId { get; set; } = string.Empty;
+
+ ///
+ /// 任务状态(queued/running/succeeded/failed/canceled)。
+ ///
+ public string Status { get; set; } = "queued";
+
+ ///
+ /// 总处理数。
+ ///
+ public int ProgressTotal { get; set; }
+
+ ///
+ /// 已处理数。
+ ///
+ public int ProgressProcessed { get; set; }
+
+ ///
+ /// 失败数。
+ ///
+ public int FailedCount { get; set; }
+
+ ///
+ /// 错误信息。
+ ///
+ public string? ErrorMessage { get; set; }
+
+ ///
+ /// 创建时间。
+ ///
+ public string CreatedAt { get; set; } = string.Empty;
+
+ ///
+ /// 开始时间。
+ ///
+ public string? StartedAt { get; set; }
+
+ ///
+ /// 完成时间。
+ ///
+ public string? FinishedAt { get; set; }
+}
+
///
/// 删除商品请求。
///
diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/ProductController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/ProductController.cs
index 260db9a..2356c0f 100644
--- a/src/Api/TakeoutSaaS.TenantApi/Controllers/ProductController.cs
+++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/ProductController.cs
@@ -1,5 +1,6 @@
using System.Globalization;
using System.Text.Json;
+using Hangfire;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -16,6 +17,7 @@ using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
using TakeoutSaaS.TenantApi.Contracts.Product;
+using TakeoutSaaS.TenantApi.Services;
namespace TakeoutSaaS.TenantApi.Controllers;
@@ -28,7 +30,9 @@ namespace TakeoutSaaS.TenantApi.Controllers;
public sealed class ProductController(
IMediator mediator,
TakeoutAppDbContext dbContext,
- StoreContextService storeContextService) : BaseApiController
+ StoreContextService storeContextService,
+ ProductSkuSaveService productSkuSaveService,
+ IBackgroundJobClient backgroundJobClient) : BaseApiController
{
///
/// 商品列表。
@@ -313,6 +317,119 @@ public sealed class ProductController(
savedRelationState.Skus));
}
+ ///
+ /// 创建 SKU 异步保存任务。
+ ///
+ [HttpPost("sku-save-jobs")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> CreateSkuSaveJob(
+ [FromBody] CreateProductSkuSaveJobRequest request,
+ CancellationToken cancellationToken)
+ {
+ var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
+ await EnsureStoreAccessibleAsync(storeId, cancellationToken);
+
+ var productId = StoreApiHelpers.ParseRequiredSnowflake(request.ProductId, nameof(request.ProductId));
+ var existingProduct = await mediator.Send(new GetProductByIdQuery
+ {
+ ProductId = productId
+ }, cancellationToken);
+ if (existingProduct is null || existingProduct.StoreId != storeId)
+ {
+ return ApiResponse.Error(ErrorCodes.NotFound, "商品不存在");
+ }
+
+ if (request.Skus is null || request.Skus.Count == 0)
+ {
+ return ApiResponse.Error(ErrorCodes.BadRequest, "skus 不能为空");
+ }
+
+ var hasProcessingJob = await dbContext.ProductSkuSaveJobs
+ .AsNoTracking()
+ .AnyAsync(
+ item => item.StoreId == storeId &&
+ item.ProductId == productId &&
+ (item.Status == ProductSkuSaveJobStatus.Queued || item.Status == ProductSkuSaveJobStatus.Running),
+ cancellationToken);
+ if (hasProcessingJob)
+ {
+ return ApiResponse.Error(ErrorCodes.BadRequest, "该商品已有进行中的 SKU 保存任务");
+ }
+
+ var normalizedSkus = NormalizeSkus(
+ request.Skus,
+ existingProduct.Price,
+ existingProduct.OriginalPrice,
+ existingProduct.StockQuantity ?? 0);
+
+ var specTemplateIds = request.SpecTemplateIds is { Count: > 0 }
+ ? ParseSnowflakeListStrict(request.SpecTemplateIds, nameof(request.SpecTemplateIds))
+ : (await LoadProductRelationStateAsync(productId, storeId, cancellationToken)).SpecTemplateIds.ToList();
+
+ var payload = new ProductSkuSaveJobPayload
+ {
+ Skus = ToProductSkuUpsertInputs(normalizedSkus),
+ SpecTemplateIds = specTemplateIds.Distinct().ToList()
+ };
+
+ var entity = new ProductSkuSaveJob
+ {
+ StoreId = storeId,
+ ProductId = productId,
+ Status = ProductSkuSaveJobStatus.Queued,
+ Mode = "replace",
+ PayloadJson = JsonSerializer.Serialize(payload, StoreApiHelpers.JsonOptions),
+ ProgressTotal = payload.Skus.Count,
+ ProgressProcessed = 0,
+ FailedCount = 0
+ };
+ await dbContext.ProductSkuSaveJobs.AddAsync(entity, cancellationToken);
+ await dbContext.SaveChangesAsync(cancellationToken);
+
+ try
+ {
+ var hangfireJobId = backgroundJobClient.Enqueue(runner => runner.ExecuteAsync(entity.Id));
+ entity.HangfireJobId = hangfireJobId;
+ await dbContext.SaveChangesAsync(cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ entity.Status = ProductSkuSaveJobStatus.Failed;
+ entity.FailedCount = entity.ProgressTotal;
+ entity.ErrorMessage = Truncate(ex.Message, 2000);
+ entity.FinishedAt = DateTime.UtcNow;
+ await dbContext.SaveChangesAsync(cancellationToken);
+ return ApiResponse.Error(ErrorCodes.InternalServerError, "SKU 保存任务创建失败");
+ }
+
+ return ApiResponse.Ok(MapProductSkuSaveJob(entity));
+ }
+
+ ///
+ /// 查询 SKU 异步保存任务状态。
+ ///
+ [HttpGet("sku-save-jobs/{jobId}")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> GetSkuSaveJob(
+ [FromRoute] string jobId,
+ [FromQuery] ProductSkuSaveJobStatusRequest request,
+ CancellationToken cancellationToken)
+ {
+ var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
+ await EnsureStoreAccessibleAsync(storeId, cancellationToken);
+
+ var parsedJobId = StoreApiHelpers.ParseRequiredSnowflake(jobId, nameof(jobId));
+ var job = await dbContext.ProductSkuSaveJobs
+ .AsNoTracking()
+ .SingleOrDefaultAsync(item => item.Id == parsedJobId && item.StoreId == storeId, cancellationToken);
+ if (job is null)
+ {
+ return ApiResponse.Error(ErrorCodes.NotFound, "任务不存在");
+ }
+
+ return ApiResponse.Ok(MapProductSkuSaveJob(job));
+ }
+
///
/// 删除商品。
///
@@ -1182,93 +1299,75 @@ public sealed class ProductController(
IReadOnlyCollection specTemplateIds,
CancellationToken cancellationToken)
{
- var allowedSpecTemplateIds = specTemplateIds.ToHashSet();
- var templateIdsInSkus = skus
- .SelectMany(item => item.Attributes)
- .Select(item => item.TemplateId)
- .Distinct()
- .ToList();
+ await productSkuSaveService.ReplaceSkusAsync(
+ productId,
+ storeId,
+ ToProductSkuUpsertInputs(skus),
+ specTemplateIds,
+ cancellationToken);
+ }
- var outOfSelectedTemplateId = templateIdsInSkus.FirstOrDefault(item => !allowedSpecTemplateIds.Contains(item));
- if (outOfSelectedTemplateId > 0)
- {
- throw new BusinessException(ErrorCodes.BadRequest, $"SKU 使用了未关联的规格模板: {outOfSelectedTemplateId}");
- }
-
- if (templateIdsInSkus.Count > 0)
- {
- var templateTypeLookup = await dbContext.ProductSpecTemplates
- .AsNoTracking()
- .Where(item => item.StoreId == storeId && templateIdsInSkus.Contains(item.Id))
- .ToDictionaryAsync(item => item.Id, item => item.TemplateType, cancellationToken);
-
- var missingTemplateId = templateIdsInSkus.FirstOrDefault(item => !templateTypeLookup.ContainsKey(item));
- if (missingTemplateId > 0)
+ private static List ToProductSkuUpsertInputs(IReadOnlyList skus)
+ {
+ return skus
+ .Select(item => new ProductSkuUpsertInput
{
- throw new BusinessException(ErrorCodes.BadRequest, $"SKU 规格模板不存在: {missingTemplateId}");
- }
-
- var invalidTemplateId = templateTypeLookup
- .FirstOrDefault(item => item.Value == ProductSpecTemplateType.Addon)
- .Key;
- if (invalidTemplateId > 0)
- {
- throw new BusinessException(ErrorCodes.BadRequest, $"SKU 规格模板类型错误: {invalidTemplateId}");
- }
- }
-
- var optionIdsInSkus = skus
- .SelectMany(item => item.Attributes)
- .Select(item => item.OptionId)
- .Distinct()
- .ToList();
-
- var optionTemplateLookup = optionIdsInSkus.Count == 0
- ? new Dictionary()
- : await dbContext.ProductSpecTemplateOptions
- .AsNoTracking()
- .Where(item => optionIdsInSkus.Contains(item.Id))
- .ToDictionaryAsync(item => item.Id, item => item.TemplateId, cancellationToken);
-
- var missingOptionId = optionIdsInSkus.FirstOrDefault(item => !optionTemplateLookup.ContainsKey(item));
- if (missingOptionId > 0)
- {
- throw new BusinessException(ErrorCodes.BadRequest, $"SKU 规格选项不存在: {missingOptionId}");
- }
-
- foreach (var sku in skus)
- {
- foreach (var attr in sku.Attributes)
- {
- if (optionTemplateLookup[attr.OptionId] != attr.TemplateId)
- {
- throw new BusinessException(
- ErrorCodes.BadRequest,
- $"SKU 规格选项与模板不匹配: templateId={attr.TemplateId}, optionId={attr.OptionId}");
- }
- }
- }
-
- await dbContext.ProductSkus
- .Where(item => item.ProductId == productId)
- .ExecuteDeleteAsync(cancellationToken);
-
- var skuEntities = skus
- .Select((item, index) => new ProductSku
- {
- ProductId = productId,
- SkuCode = item.SkuCode ?? GenerateSkuCode(productId, index + 1),
+ SkuCode = item.SkuCode,
Price = item.Price,
OriginalPrice = item.OriginalPrice,
- StockQuantity = item.Stock,
- AttributesJson = SerializeSkuAttributes(item.Attributes),
+ Stock = item.Stock,
+ IsEnabled = item.IsEnabled,
SortOrder = item.SortOrder,
- IsEnabled = item.IsEnabled
+ Attributes = item.Attributes
+ .Select(attr => new ProductSkuUpsertAttributeInput
+ {
+ TemplateId = attr.TemplateId,
+ OptionId = attr.OptionId
+ })
+ .ToList()
})
.ToList();
+ }
- await dbContext.ProductSkus.AddRangeAsync(skuEntities, cancellationToken);
- await dbContext.SaveChangesAsync(cancellationToken);
+ private static ProductSkuSaveJobResponse MapProductSkuSaveJob(ProductSkuSaveJob source)
+ {
+ return new ProductSkuSaveJobResponse
+ {
+ JobId = source.Id.ToString(),
+ StoreId = source.StoreId.ToString(),
+ ProductId = source.ProductId.ToString(),
+ Status = ToProductSkuSaveJobStatusText(source.Status),
+ ProgressTotal = Math.Max(0, source.ProgressTotal),
+ ProgressProcessed = Math.Max(0, source.ProgressProcessed),
+ FailedCount = Math.Max(0, source.FailedCount),
+ ErrorMessage = source.ErrorMessage,
+ CreatedAt = source.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss"),
+ StartedAt = source.StartedAt?.ToString("yyyy-MM-dd HH:mm:ss"),
+ FinishedAt = source.FinishedAt?.ToString("yyyy-MM-dd HH:mm:ss")
+ };
+ }
+
+ private static string ToProductSkuSaveJobStatusText(ProductSkuSaveJobStatus status)
+ {
+ return status switch
+ {
+ ProductSkuSaveJobStatus.Queued => "queued",
+ ProductSkuSaveJobStatus.Running => "running",
+ ProductSkuSaveJobStatus.Succeeded => "succeeded",
+ ProductSkuSaveJobStatus.Failed => "failed",
+ ProductSkuSaveJobStatus.Canceled => "canceled",
+ _ => "queued"
+ };
+ }
+
+ private static string Truncate(string? value, int maxLength)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return string.Empty;
+ }
+
+ return value.Length <= maxLength ? value : value[..maxLength];
}
private async Task LoadProductRelationStateAsync(
@@ -1382,21 +1481,6 @@ public sealed class ProductController(
}
}
- private static string SerializeSkuAttributes(IReadOnlyList attributes)
- {
- if (attributes.Count == 0)
- {
- return "[]";
- }
-
- var payload = attributes
- .OrderBy(item => item.TemplateId)
- .ThenBy(item => item.OptionId)
- .Select(item => new SkuAttributePayload(item.TemplateId, item.OptionId))
- .ToList();
- return JsonSerializer.Serialize(payload);
- }
-
private async Task> BuildComboGroupResponsesAsync(long productId, long storeId, CancellationToken cancellationToken)
{
var groups = await dbContext.ProductComboGroups
@@ -1681,9 +1765,4 @@ public sealed class ProductController(
var seed = (int)(now.Ticks % 10_000);
return $"SPU{now:yyyyMMddHHmmss}{seed:D4}";
}
-
- private static string GenerateSkuCode(long productId, int sequence)
- {
- return $"SKU{productId}{sequence:D2}";
- }
}
diff --git a/src/Api/TakeoutSaaS.TenantApi/Program.cs b/src/Api/TakeoutSaaS.TenantApi/Program.cs
index 4b65fd3..aeec0ef 100644
--- a/src/Api/TakeoutSaaS.TenantApi/Program.cs
+++ b/src/Api/TakeoutSaaS.TenantApi/Program.cs
@@ -17,6 +17,7 @@ using TakeoutSaaS.Infrastructure.Dictionary.Extensions;
using TakeoutSaaS.Infrastructure.Identity.Extensions;
using TakeoutSaaS.Module.Authorization.Extensions;
using TakeoutSaaS.Module.Messaging.Extensions;
+using TakeoutSaaS.Module.Scheduler.Extensions;
using TakeoutSaaS.Module.Storage.Extensions;
using TakeoutSaaS.Module.Tenancy.Extensions;
using TakeoutSaaS.Shared.Abstractions.Security;
@@ -114,6 +115,7 @@ builder.Services.AddMessagingApplication();
builder.Services.AddMessagingModule(builder.Configuration);
builder.Services.AddStorageModule(builder.Configuration);
builder.Services.AddStorageApplication();
+builder.Services.AddSchedulerModule(builder.Configuration);
// 9.1 注册腾讯地图地理编码服务(服务端签名)
builder.Services.Configure(builder.Configuration.GetSection(TencentMapOptions.SectionName));
@@ -124,6 +126,8 @@ builder.Services.AddHttpClient(TencentMapGeocodingService.HttpClientName, client
builder.Services.AddScoped();
builder.Services.AddScoped(provider => provider.GetRequiredService());
builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.AddHostedService();
// 10. 配置 OpenTelemetry 采集
@@ -199,6 +203,7 @@ app.UseSharedWebCore();
// 4. (空行后) 执行授权
app.UseAuthorization();
+app.UseSchedulerDashboard(builder.Configuration);
// 5. (空行后) 开发环境启用 Swagger
if (app.Environment.IsDevelopment())
diff --git a/src/Api/TakeoutSaaS.TenantApi/Services/ProductSkuSaveJobRunner.cs b/src/Api/TakeoutSaaS.TenantApi/Services/ProductSkuSaveJobRunner.cs
new file mode 100644
index 0000000..81131c6
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Services/ProductSkuSaveJobRunner.cs
@@ -0,0 +1,163 @@
+using System.Text.Json;
+using Hangfire;
+using Microsoft.EntityFrameworkCore;
+using TakeoutSaaS.Domain.Products.Enums;
+using TakeoutSaaS.Infrastructure.App.Persistence;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+using TakeoutSaaS.TenantApi.Controllers;
+
+namespace TakeoutSaaS.TenantApi.Services;
+
+///
+/// 商品 SKU 异步保存任务执行器。
+///
+public sealed class ProductSkuSaveJobRunner(
+ TakeoutAppDbContext dbContext,
+ ITenantContextAccessor tenantContextAccessor,
+ ProductSkuSaveService productSkuSaveService,
+ ILogger logger)
+{
+ ///
+ /// 执行指定任务。
+ ///
+ [AutomaticRetry(Attempts = 0)]
+ public async Task ExecuteAsync(long jobId)
+ {
+ var jobMeta = await dbContext.ProductSkuSaveJobs
+ .IgnoreQueryFilters()
+ .AsNoTracking()
+ .Where(item => item.Id == jobId)
+ .Select(item => new JobMeta(item.Id, item.TenantId))
+ .SingleOrDefaultAsync();
+
+ if (jobMeta is null || jobMeta.TenantId <= 0)
+ {
+ logger.LogWarning("SKU 异步保存任务不存在或租户无效,JobId={JobId}", jobId);
+ return;
+ }
+
+ using var _ = tenantContextAccessor.EnterTenantScope(jobMeta.TenantId, "scheduler", $"tenant-{jobMeta.TenantId}");
+ try
+ {
+ await RunWithExecutionStrategyAsync(jobMeta.Id);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "SKU 异步保存任务执行失败,JobId={JobId}", jobId);
+ await MarkFailedAsync(jobMeta.Id, ex.Message);
+ }
+ }
+
+ private async Task RunWithExecutionStrategyAsync(long jobId)
+ {
+ var executionStrategy = dbContext.Database.CreateExecutionStrategy();
+ await executionStrategy.ExecuteAsync(async () =>
+ {
+ dbContext.ChangeTracker.Clear();
+
+ await using var transaction = await dbContext.Database.BeginTransactionAsync();
+ var job = await dbContext.ProductSkuSaveJobs
+ .SingleOrDefaultAsync(item => item.Id == jobId);
+ if (job is null)
+ {
+ return;
+ }
+
+ job.Status = ProductSkuSaveJobStatus.Running;
+ job.StartedAt ??= DateTime.UtcNow;
+ job.FinishedAt = null;
+ job.ErrorMessage = null;
+ job.FailedCount = 0;
+ job.ProgressProcessed = 0;
+ await dbContext.SaveChangesAsync();
+
+ var payload = DeserializePayload(job.PayloadJson);
+ if (payload.Skus.Count == 0)
+ {
+ throw new InvalidOperationException("SKU 保存任务缺少有效数据。");
+ }
+
+ var productExists = await dbContext.Products
+ .AsNoTracking()
+ .AnyAsync(item => item.Id == job.ProductId && item.StoreId == job.StoreId);
+ if (!productExists)
+ {
+ throw new InvalidOperationException($"商品不存在或不属于门店,ProductId={job.ProductId}");
+ }
+
+ await productSkuSaveService.ReplaceSkusAsync(
+ job.ProductId,
+ job.StoreId,
+ payload.Skus,
+ payload.SpecTemplateIds,
+ CancellationToken.None);
+
+ job.Status = ProductSkuSaveJobStatus.Succeeded;
+ job.ProgressProcessed = job.ProgressTotal;
+ job.FailedCount = 0;
+ job.FinishedAt = DateTime.UtcNow;
+ job.ErrorMessage = null;
+ await dbContext.SaveChangesAsync();
+ await transaction.CommitAsync();
+ });
+ }
+
+ private async Task MarkFailedAsync(long jobId, string errorMessage)
+ {
+ var executionStrategy = dbContext.Database.CreateExecutionStrategy();
+ await executionStrategy.ExecuteAsync(async () =>
+ {
+ dbContext.ChangeTracker.Clear();
+
+ var job = await dbContext.ProductSkuSaveJobs
+ .SingleOrDefaultAsync(item => item.Id == jobId);
+ if (job is null)
+ {
+ return;
+ }
+
+ job.Status = ProductSkuSaveJobStatus.Failed;
+ job.FinishedAt = DateTime.UtcNow;
+ job.ErrorMessage = Truncate(errorMessage, 2000);
+ job.FailedCount = Math.Max(1, job.ProgressTotal - job.ProgressProcessed);
+ await dbContext.SaveChangesAsync();
+ });
+ }
+
+ private static ProductSkuSaveJobPayload DeserializePayload(string payloadJson)
+ {
+ if (string.IsNullOrWhiteSpace(payloadJson))
+ {
+ throw new InvalidOperationException("SKU 保存任务负载为空。");
+ }
+
+ try
+ {
+ var payload = JsonSerializer.Deserialize(payloadJson, StoreApiHelpers.JsonOptions);
+ if (payload is null)
+ {
+ throw new InvalidOperationException("SKU 保存任务负载解析结果为空。");
+ }
+
+ payload.Skus ??= [];
+ payload.SpecTemplateIds ??= [];
+ return payload;
+ }
+ catch (JsonException ex)
+ {
+ throw new InvalidOperationException("SKU 保存任务负载解析失败。", ex);
+ }
+ }
+
+ private static string Truncate(string? value, int maxLength)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return string.Empty;
+ }
+
+ return value.Length <= maxLength ? value : value[..maxLength];
+ }
+
+ private sealed record JobMeta(long Id, long TenantId);
+}
diff --git a/src/Api/TakeoutSaaS.TenantApi/Services/ProductSkuSaveService.cs b/src/Api/TakeoutSaaS.TenantApi/Services/ProductSkuSaveService.cs
new file mode 100644
index 0000000..16911b8
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Services/ProductSkuSaveService.cs
@@ -0,0 +1,421 @@
+using System.Security.Cryptography;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using TakeoutSaaS.Domain.Products.Entities;
+using TakeoutSaaS.Domain.Products.Enums;
+using TakeoutSaaS.Infrastructure.App.Persistence;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+using TakeoutSaaS.TenantApi.Controllers;
+
+namespace TakeoutSaaS.TenantApi.Services;
+
+///
+/// 商品 SKU 保存服务(replace 语义)。
+///
+public sealed class ProductSkuSaveService(TakeoutAppDbContext dbContext)
+{
+ ///
+ /// 按 replace 语义保存 SKU:
+ /// 1. 命中的 SKU 更新并恢复启用。
+ /// 2. 未命中的 SKU 新增。
+ /// 3. 缺失的历史 SKU 软禁用(IsEnabled=false, Stock=0)。
+ ///
+ public async Task ReplaceSkusAsync(
+ long productId,
+ long storeId,
+ IReadOnlyList skus,
+ IReadOnlyCollection specTemplateIds,
+ CancellationToken cancellationToken)
+ {
+ var normalizedSkus = skus ?? [];
+ if (normalizedSkus.Count == 0)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "skus 不能为空");
+ }
+
+ await ValidateSkuTemplateRefsAsync(storeId, normalizedSkus, specTemplateIds, cancellationToken);
+
+ var explicitSkuCodes = normalizedSkus
+ .Select(item => NormalizeSkuCode(item.SkuCode))
+ .Where(item => !string.IsNullOrWhiteSpace(item))
+ .Cast()
+ .ToList();
+
+ var duplicateSkuCode = explicitSkuCodes
+ .GroupBy(item => item, StringComparer.Ordinal)
+ .FirstOrDefault(group => group.Count() > 1)?
+ .Key;
+ if (!string.IsNullOrWhiteSpace(duplicateSkuCode))
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"SKU 编码重复: {duplicateSkuCode}");
+ }
+
+ if (explicitSkuCodes.Count > 0)
+ {
+ var codeConflict = await dbContext.ProductSkus
+ .AsNoTracking()
+ .Where(item => item.ProductId != productId && explicitSkuCodes.Contains(item.SkuCode))
+ .Select(item => item.SkuCode)
+ .FirstOrDefaultAsync(cancellationToken);
+ if (!string.IsNullOrWhiteSpace(codeConflict))
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"SKU 编码已存在: {codeConflict}");
+ }
+ }
+
+ var existingSkus = await dbContext.ProductSkus
+ .Where(item => item.ProductId == productId)
+ .OrderBy(item => item.Id)
+ .ToListAsync(cancellationToken);
+
+ var existingBySkuCode = existingSkus
+ .Where(item => !string.IsNullOrWhiteSpace(item.SkuCode))
+ .GroupBy(item => item.SkuCode, StringComparer.Ordinal)
+ .ToDictionary(group => group.Key, group => new Queue(group), StringComparer.Ordinal);
+
+ var existingByAttrKey = existingSkus
+ .GroupBy(item => BuildSkuAttributeKey(ParseSkuAttributes(item.AttributesJson)), StringComparer.Ordinal)
+ .ToDictionary(group => group.Key, group => new Queue(group), StringComparer.Ordinal);
+
+ var usedExistingSkuIds = new HashSet();
+ var currentProductSkuCodes = existingSkus
+ .Where(item => !string.IsNullOrWhiteSpace(item.SkuCode))
+ .Select(item => item.SkuCode)
+ .ToHashSet(StringComparer.Ordinal);
+ foreach (var explicitSkuCode in explicitSkuCodes)
+ {
+ currentProductSkuCodes.Add(explicitSkuCode);
+ }
+ var createdSkus = new List();
+
+ foreach (var sku in normalizedSkus)
+ {
+ var normalizedSkuCode = NormalizeSkuCode(sku.SkuCode);
+ var attrKey = BuildSkuAttributeKey(sku.Attributes);
+
+ ProductSku? matched = null;
+ if (!string.IsNullOrWhiteSpace(normalizedSkuCode) &&
+ existingBySkuCode.TryGetValue(normalizedSkuCode, out var byCodeQueue))
+ {
+ matched = PickUnmatchedSku(byCodeQueue, usedExistingSkuIds);
+ }
+
+ if (matched is null && existingByAttrKey.TryGetValue(attrKey, out var byAttrQueue))
+ {
+ matched = PickUnmatchedSku(byAttrQueue, usedExistingSkuIds);
+ }
+
+ if (matched is not null)
+ {
+ usedExistingSkuIds.Add(matched.Id);
+ matched.Price = sku.Price;
+ matched.OriginalPrice = sku.OriginalPrice;
+ matched.StockQuantity = Math.Max(0, sku.Stock);
+ matched.AttributesJson = SerializeSkuAttributes(sku.Attributes);
+ matched.SortOrder = sku.SortOrder;
+ matched.IsEnabled = sku.IsEnabled;
+ matched.DeletedAt = null;
+ matched.DeletedBy = null;
+ if (!string.IsNullOrWhiteSpace(normalizedSkuCode))
+ {
+ matched.SkuCode = normalizedSkuCode;
+ }
+
+ continue;
+ }
+
+ var generatedCode = normalizedSkuCode ?? GenerateUniqueSkuCode(productId, currentProductSkuCodes);
+ currentProductSkuCodes.Add(generatedCode);
+ createdSkus.Add(new ProductSku
+ {
+ ProductId = productId,
+ SkuCode = generatedCode,
+ Price = sku.Price,
+ OriginalPrice = sku.OriginalPrice,
+ StockQuantity = Math.Max(0, sku.Stock),
+ AttributesJson = SerializeSkuAttributes(sku.Attributes),
+ SortOrder = sku.SortOrder,
+ IsEnabled = sku.IsEnabled
+ });
+ }
+
+ foreach (var existing in existingSkus)
+ {
+ if (usedExistingSkuIds.Contains(existing.Id))
+ {
+ continue;
+ }
+
+ existing.IsEnabled = false;
+ existing.StockQuantity = 0;
+ }
+
+ if (createdSkus.Count > 0)
+ {
+ await dbContext.ProductSkus.AddRangeAsync(createdSkus, cancellationToken);
+ }
+
+ await dbContext.SaveChangesAsync(cancellationToken);
+ }
+
+ private async Task ValidateSkuTemplateRefsAsync(
+ long storeId,
+ IReadOnlyList skus,
+ IReadOnlyCollection specTemplateIds,
+ CancellationToken cancellationToken)
+ {
+ var allowedSpecTemplateIds = specTemplateIds.ToHashSet();
+ var templateIdsInSkus = skus
+ .SelectMany(item => item.Attributes)
+ .Select(item => item.TemplateId)
+ .Distinct()
+ .ToList();
+
+ var outOfSelectedTemplateId = templateIdsInSkus.FirstOrDefault(item => !allowedSpecTemplateIds.Contains(item));
+ if (outOfSelectedTemplateId > 0)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"SKU 使用了未关联的规格模板: {outOfSelectedTemplateId}");
+ }
+
+ if (templateIdsInSkus.Count > 0)
+ {
+ var templateTypeLookup = await dbContext.ProductSpecTemplates
+ .AsNoTracking()
+ .Where(item => item.StoreId == storeId && templateIdsInSkus.Contains(item.Id))
+ .ToDictionaryAsync(item => item.Id, item => item.TemplateType, cancellationToken);
+
+ var missingTemplateId = templateIdsInSkus.FirstOrDefault(item => !templateTypeLookup.ContainsKey(item));
+ if (missingTemplateId > 0)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"SKU 规格模板不存在: {missingTemplateId}");
+ }
+
+ var invalidTemplateId = templateTypeLookup
+ .FirstOrDefault(item => item.Value == ProductSpecTemplateType.Addon)
+ .Key;
+ if (invalidTemplateId > 0)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"SKU 规格模板类型错误: {invalidTemplateId}");
+ }
+ }
+
+ var optionIdsInSkus = skus
+ .SelectMany(item => item.Attributes)
+ .Select(item => item.OptionId)
+ .Distinct()
+ .ToList();
+
+ var optionTemplateLookup = optionIdsInSkus.Count == 0
+ ? new Dictionary()
+ : await dbContext.ProductSpecTemplateOptions
+ .AsNoTracking()
+ .Where(item => optionIdsInSkus.Contains(item.Id))
+ .ToDictionaryAsync(item => item.Id, item => item.TemplateId, cancellationToken);
+
+ var missingOptionId = optionIdsInSkus.FirstOrDefault(item => !optionTemplateLookup.ContainsKey(item));
+ if (missingOptionId > 0)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"SKU 规格选项不存在: {missingOptionId}");
+ }
+
+ foreach (var sku in skus)
+ {
+ foreach (var attr in sku.Attributes)
+ {
+ if (optionTemplateLookup[attr.OptionId] != attr.TemplateId)
+ {
+ throw new BusinessException(
+ ErrorCodes.BadRequest,
+ $"SKU 规格选项与模板不匹配: templateId={attr.TemplateId}, optionId={attr.OptionId}");
+ }
+ }
+ }
+ }
+
+ private static ProductSku? PickUnmatchedSku(Queue queue, HashSet usedExistingSkuIds)
+ {
+ while (queue.Count > 0)
+ {
+ var current = queue.Peek();
+ if (usedExistingSkuIds.Contains(current.Id))
+ {
+ queue.Dequeue();
+ continue;
+ }
+
+ return current;
+ }
+
+ return null;
+ }
+
+ private static string BuildSkuAttributeKey(IReadOnlyList attributes)
+ {
+ if (attributes.Count == 0)
+ {
+ return "default";
+ }
+
+ return string.Join('|', attributes
+ .OrderBy(item => item.TemplateId)
+ .ThenBy(item => item.OptionId)
+ .Select(item => $"{item.TemplateId}:{item.OptionId}"));
+ }
+
+ private static List ParseSkuAttributes(string? attributesJson)
+ {
+ if (string.IsNullOrWhiteSpace(attributesJson))
+ {
+ return [];
+ }
+
+ try
+ {
+ var parsed = JsonSerializer.Deserialize>(attributesJson, StoreApiHelpers.JsonOptions) ?? [];
+ return parsed
+ .Where(item => item.TemplateId > 0 && item.OptionId > 0)
+ .Select(item => new ProductSkuUpsertAttributeInput
+ {
+ TemplateId = item.TemplateId,
+ OptionId = item.OptionId
+ })
+ .DistinctBy(item => $"{item.TemplateId}:{item.OptionId}")
+ .ToList();
+ }
+ catch
+ {
+ return [];
+ }
+ }
+
+ private static string SerializeSkuAttributes(IReadOnlyList attributes)
+ {
+ if (attributes.Count == 0)
+ {
+ return "[]";
+ }
+
+ var payload = attributes
+ .OrderBy(item => item.TemplateId)
+ .ThenBy(item => item.OptionId)
+ .Select(item => new SkuAttributePayload(item.TemplateId, item.OptionId))
+ .ToList();
+ return JsonSerializer.Serialize(payload);
+ }
+
+ private static string NormalizeSkuCode(string? skuCode)
+ {
+ var normalized = (skuCode ?? string.Empty).Trim();
+ return string.IsNullOrWhiteSpace(normalized) ? string.Empty : normalized;
+ }
+
+ private static string GenerateUniqueSkuCode(long productId, IReadOnlySet currentProductSkuCodes)
+ {
+ for (var i = 0; i < 200; i++)
+ {
+ var random = RandomNumberGenerator.GetInt32(0, 60_466_176);
+ var candidate = $"SKU{productId}{ToBase36((ulong)random).PadLeft(5, '0')}";
+ if (!currentProductSkuCodes.Contains(candidate))
+ {
+ return candidate;
+ }
+ }
+
+ throw new BusinessException(ErrorCodes.InternalServerError, "SKU 编码生成失败,请稍后重试");
+ }
+
+ private static string ToBase36(ulong value)
+ {
+ const string chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+ if (value == 0)
+ {
+ return "0";
+ }
+
+ Span buffer = stackalloc char[16];
+ var pos = buffer.Length;
+ while (value > 0)
+ {
+ buffer[--pos] = chars[(int)(value % 36)];
+ value /= 36;
+ }
+
+ return new string(buffer[pos..]);
+ }
+
+ private sealed record SkuAttributePayload(long TemplateId, long OptionId);
+}
+
+///
+/// SKU replace 输入模型。
+///
+public sealed class ProductSkuUpsertInput
+{
+ ///
+ /// SKU 编码(可选)。
+ ///
+ public string? SkuCode { get; set; }
+
+ ///
+ /// 售价。
+ ///
+ public decimal Price { get; set; }
+
+ ///
+ /// 划线价。
+ ///
+ public decimal? OriginalPrice { get; set; }
+
+ ///
+ /// 库存。
+ ///
+ public int Stock { get; set; }
+
+ ///
+ /// 是否启用。
+ ///
+ public bool IsEnabled { get; set; } = true;
+
+ ///
+ /// 排序值。
+ ///
+ public int SortOrder { get; set; }
+
+ ///
+ /// 规格属性组合。
+ ///
+ public List Attributes { get; set; } = [];
+}
+
+///
+/// SKU replace 属性输入模型。
+///
+public sealed class ProductSkuUpsertAttributeInput
+{
+ ///
+ /// 规格模板 ID。
+ ///
+ public long TemplateId { get; set; }
+
+ ///
+ /// 模板选项 ID。
+ ///
+ public long OptionId { get; set; }
+}
+
+///
+/// SKU 异步保存任务负载。
+///
+public sealed class ProductSkuSaveJobPayload
+{
+ ///
+ /// SKU 列表快照。
+ ///
+ public List Skus { get; set; } = [];
+
+ ///
+ /// 允许使用的规格模板 ID 列表。
+ ///
+ public List SpecTemplateIds { get; set; } = [];
+}
diff --git a/src/Api/TakeoutSaaS.TenantApi/TakeoutSaaS.TenantApi.csproj b/src/Api/TakeoutSaaS.TenantApi/TakeoutSaaS.TenantApi.csproj
index 781f2b0..258b1b4 100644
--- a/src/Api/TakeoutSaaS.TenantApi/TakeoutSaaS.TenantApi.csproj
+++ b/src/Api/TakeoutSaaS.TenantApi/TakeoutSaaS.TenantApi.csproj
@@ -25,6 +25,7 @@
+
diff --git a/src/Api/TakeoutSaaS.TenantApi/appsettings.Development.json b/src/Api/TakeoutSaaS.TenantApi/appsettings.Development.json
index 666f4f9..816f291 100644
--- a/src/Api/TakeoutSaaS.TenantApi/appsettings.Development.json
+++ b/src/Api/TakeoutSaaS.TenantApi/appsettings.Development.json
@@ -125,6 +125,27 @@
"AntiLeechTokenSecret": "ReplaceWithARandomToken"
}
},
+ "Scheduler": {
+ "ConnectionString": "Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
+ "WorkerCount": 10,
+ "DashboardEnabled": false,
+ "DashboardPath": "/hangfire",
+ "SubscriptionAutomation": {
+ "AutoRenewalExecuteHourUtc": 1,
+ "AutoRenewalDaysBeforeExpiry": 3,
+ "RenewalReminderExecuteHourUtc": 10,
+ "ReminderDaysBeforeExpiry": [
+ 7,
+ 3,
+ 1
+ ],
+ "SubscriptionExpiryCheckExecuteHourUtc": 2,
+ "GracePeriodDays": 7
+ },
+ "BillingAutomation": {
+ "OverdueBillingProcessCron": "*/10 * * * *"
+ }
+ },
"Otel": {
"Endpoint": "",
"Sampling": "ParentBasedAlwaysOn",
diff --git a/src/Api/TakeoutSaaS.TenantApi/appsettings.Production.json b/src/Api/TakeoutSaaS.TenantApi/appsettings.Production.json
index 3f30cd0..052d290 100644
--- a/src/Api/TakeoutSaaS.TenantApi/appsettings.Production.json
+++ b/src/Api/TakeoutSaaS.TenantApi/appsettings.Production.json
@@ -123,6 +123,27 @@
"AntiLeechTokenSecret": "ReplaceWithARandomToken"
}
},
+ "Scheduler": {
+ "ConnectionString": "Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
+ "WorkerCount": 10,
+ "DashboardEnabled": false,
+ "DashboardPath": "/hangfire",
+ "SubscriptionAutomation": {
+ "AutoRenewalExecuteHourUtc": 1,
+ "AutoRenewalDaysBeforeExpiry": 3,
+ "RenewalReminderExecuteHourUtc": 10,
+ "ReminderDaysBeforeExpiry": [
+ 7,
+ 3,
+ 1
+ ],
+ "SubscriptionExpiryCheckExecuteHourUtc": 2,
+ "GracePeriodDays": 7
+ },
+ "BillingAutomation": {
+ "OverdueBillingProcessCron": "*/10 * * * *"
+ }
+ },
"Otel": {
"Endpoint": "",
"Sampling": "ParentBasedAlwaysOn",
diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductSkuSaveJob.cs b/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductSkuSaveJob.cs
new file mode 100644
index 0000000..0ee9f82
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductSkuSaveJob.cs
@@ -0,0 +1,70 @@
+using TakeoutSaaS.Domain.Products.Enums;
+using TakeoutSaaS.Shared.Abstractions.Entities;
+
+namespace TakeoutSaaS.Domain.Products.Entities;
+
+///
+/// 商品 SKU 异步保存任务。
+///
+public sealed class ProductSkuSaveJob : MultiTenantEntityBase
+{
+ ///
+ /// 所属门店 ID。
+ ///
+ public long StoreId { get; set; }
+
+ ///
+ /// 所属商品 ID。
+ ///
+ public long ProductId { get; set; }
+
+ ///
+ /// 任务状态。
+ ///
+ public ProductSkuSaveJobStatus Status { get; set; } = ProductSkuSaveJobStatus.Queued;
+
+ ///
+ /// 任务模式(当前固定 replace)。
+ ///
+ public string Mode { get; set; } = "replace";
+
+ ///
+ /// 任务请求负载 JSON 快照。
+ ///
+ public string PayloadJson { get; set; } = string.Empty;
+
+ ///
+ /// 总处理数。
+ ///
+ public int ProgressTotal { get; set; }
+
+ ///
+ /// 已处理数。
+ ///
+ public int ProgressProcessed { get; set; }
+
+ ///
+ /// 失败条数。
+ ///
+ public int FailedCount { get; set; }
+
+ ///
+ /// 失败摘要。
+ ///
+ public string? ErrorMessage { get; set; }
+
+ ///
+ /// Hangfire 任务 ID。
+ ///
+ public string? HangfireJobId { get; set; }
+
+ ///
+ /// 开始执行时间(UTC)。
+ ///
+ public DateTime? StartedAt { get; set; }
+
+ ///
+ /// 完成时间(UTC)。
+ ///
+ public DateTime? FinishedAt { get; set; }
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Enums/ProductSkuSaveJobStatus.cs b/src/Domain/TakeoutSaaS.Domain/Products/Enums/ProductSkuSaveJobStatus.cs
new file mode 100644
index 0000000..da53d99
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Products/Enums/ProductSkuSaveJobStatus.cs
@@ -0,0 +1,32 @@
+namespace TakeoutSaaS.Domain.Products.Enums;
+
+///
+/// 商品 SKU 异步保存任务状态。
+///
+public enum ProductSkuSaveJobStatus
+{
+ ///
+ /// 已排队。
+ ///
+ Queued = 0,
+
+ ///
+ /// 执行中。
+ ///
+ Running = 1,
+
+ ///
+ /// 已成功。
+ ///
+ Succeeded = 2,
+
+ ///
+ /// 执行失败。
+ ///
+ Failed = 3,
+
+ ///
+ /// 已取消。
+ ///
+ Canceled = 4,
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
index adc8c97..c5c2eeb 100644
--- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
@@ -233,6 +233,10 @@ public sealed class TakeoutAppDbContext(
///
public DbSet ProductSkus => Set();
///
+ /// SKU 异步保存任务。
+ ///
+ public DbSet ProductSkuSaveJobs => Set();
+ ///
/// 套餐分组。
///
public DbSet ProductComboGroups => Set();
@@ -485,6 +489,7 @@ public sealed class TakeoutAppDbContext(
ConfigureProductSchedule(modelBuilder.Entity());
ConfigureProductScheduleProduct(modelBuilder.Entity());
ConfigureProductSku(modelBuilder.Entity());
+ ConfigureProductSkuSaveJob(modelBuilder.Entity());
ConfigureProductComboGroup(modelBuilder.Entity());
ConfigureProductComboGroupItem(modelBuilder.Entity());
ConfigureProductAddonGroup(modelBuilder.Entity());
@@ -1321,9 +1326,28 @@ public sealed class TakeoutAppDbContext(
builder.Property(x => x.Weight).HasPrecision(10, 3);
builder.Property(x => x.AttributesJson).HasColumnType("text");
builder.Property(x => x.IsEnabled).HasDefaultValue(true);
+ builder.HasIndex(x => new { x.TenantId, x.ProductId });
builder.HasIndex(x => new { x.TenantId, x.SkuCode }).IsUnique();
}
+ private static void ConfigureProductSkuSaveJob(EntityTypeBuilder builder)
+ {
+ builder.ToTable("product_sku_save_jobs");
+ builder.HasKey(x => x.Id);
+ builder.Property(x => x.StoreId).IsRequired();
+ builder.Property(x => x.ProductId).IsRequired();
+ builder.Property(x => x.Status).HasConversion();
+ builder.Property(x => x.Mode).HasMaxLength(16).IsRequired();
+ builder.Property(x => x.PayloadJson).HasColumnType("text").IsRequired();
+ builder.Property(x => x.ProgressTotal).IsRequired();
+ builder.Property(x => x.ProgressProcessed).IsRequired();
+ builder.Property(x => x.FailedCount).IsRequired();
+ builder.Property(x => x.ErrorMessage).HasMaxLength(2000);
+ builder.Property(x => x.HangfireJobId).HasMaxLength(64);
+ builder.HasIndex(x => new { x.TenantId, x.ProductId, x.CreatedAt });
+ builder.HasIndex(x => new { x.TenantId, x.Status, x.CreatedAt });
+ }
+
private static void ConfigureProductComboGroup(EntityTypeBuilder builder)
{
builder.ToTable("product_combo_groups");
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260225012043_AddProductSkuSaveJobsAndReplaceIndex.Designer.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260225012043_AddProductSkuSaveJobsAndReplaceIndex.Designer.cs
new file mode 100644
index 0000000..8a78c5d
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260225012043_AddProductSkuSaveJobsAndReplaceIndex.Designer.cs
@@ -0,0 +1,8684 @@
+//
+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("20260225012043_AddProductSkuSaveJobsAndReplaceIndex")]
+ partial class AddProductSkuSaveJobsAndReplaceIndex
+ {
+ ///
+ 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