feat: 商品SKU支持异步保存与软禁用替换策略
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 56s

This commit is contained in:
2026-02-25 09:23:15 +08:00
parent c2a6cf7b1e
commit 5fcc1e1484
14 changed files with 9897 additions and 100 deletions

View File

@@ -292,6 +292,104 @@ public sealed class SaveProductSkuAttributeRequest
public string OptionId { get; set; } = string.Empty;
}
/// <summary>
/// 创建商品 SKU 异步保存任务请求。
/// </summary>
public sealed class CreateProductSkuSaveJobRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 商品 ID。
/// </summary>
public string ProductId { get; set; } = string.Empty;
/// <summary>
/// 关联规格模板 ID可选未传则使用当前商品关联
/// </summary>
public List<string>? SpecTemplateIds { get; set; }
/// <summary>
/// SKU 列表。
/// </summary>
public List<SaveProductSkuRequest>? Skus { get; set; }
}
/// <summary>
/// 查询商品 SKU 异步任务状态请求。
/// </summary>
public sealed class ProductSkuSaveJobStatusRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
}
/// <summary>
/// 商品 SKU 异步保存任务响应。
/// </summary>
public sealed class ProductSkuSaveJobResponse
{
/// <summary>
/// 任务 ID。
/// </summary>
public string JobId { get; set; } = string.Empty;
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 商品 ID。
/// </summary>
public string ProductId { get; set; } = string.Empty;
/// <summary>
/// 任务状态queued/running/succeeded/failed/canceled
/// </summary>
public string Status { get; set; } = "queued";
/// <summary>
/// 总处理数。
/// </summary>
public int ProgressTotal { get; set; }
/// <summary>
/// 已处理数。
/// </summary>
public int ProgressProcessed { get; set; }
/// <summary>
/// 失败数。
/// </summary>
public int FailedCount { get; set; }
/// <summary>
/// 错误信息。
/// </summary>
public string? ErrorMessage { get; set; }
/// <summary>
/// 创建时间。
/// </summary>
public string CreatedAt { get; set; } = string.Empty;
/// <summary>
/// 开始时间。
/// </summary>
public string? StartedAt { get; set; }
/// <summary>
/// 完成时间。
/// </summary>
public string? FinishedAt { get; set; }
}
/// <summary>
/// 删除商品请求。
/// </summary>

View File

@@ -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
{
/// <summary>
/// 商品列表。
@@ -313,6 +317,119 @@ public sealed class ProductController(
savedRelationState.Skus));
}
/// <summary>
/// 创建 SKU 异步保存任务。
/// </summary>
[HttpPost("sku-save-jobs")]
[ProducesResponseType(typeof(ApiResponse<ProductSkuSaveJobResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<ProductSkuSaveJobResponse>> 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<ProductSkuSaveJobResponse>.Error(ErrorCodes.NotFound, "商品不存在");
}
if (request.Skus is null || request.Skus.Count == 0)
{
return ApiResponse<ProductSkuSaveJobResponse>.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<ProductSkuSaveJobResponse>.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<ProductSkuSaveJobRunner>(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<ProductSkuSaveJobResponse>.Error(ErrorCodes.InternalServerError, "SKU 保存任务创建失败");
}
return ApiResponse<ProductSkuSaveJobResponse>.Ok(MapProductSkuSaveJob(entity));
}
/// <summary>
/// 查询 SKU 异步保存任务状态。
/// </summary>
[HttpGet("sku-save-jobs/{jobId}")]
[ProducesResponseType(typeof(ApiResponse<ProductSkuSaveJobResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<ProductSkuSaveJobResponse>> 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<ProductSkuSaveJobResponse>.Error(ErrorCodes.NotFound, "任务不存在");
}
return ApiResponse<ProductSkuSaveJobResponse>.Ok(MapProductSkuSaveJob(job));
}
/// <summary>
/// 删除商品。
/// </summary>
@@ -1182,93 +1299,75 @@ public sealed class ProductController(
IReadOnlyCollection<long> 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<ProductSkuUpsertInput> ToProductSkuUpsertInputs(IReadOnlyList<NormalizedSkuRequest> 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<long, long>()
: 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<ProductRelationState> LoadProductRelationStateAsync(
@@ -1382,21 +1481,6 @@ public sealed class ProductController(
}
}
private static string SerializeSkuAttributes(IReadOnlyList<NormalizedSkuAttributeRequest> 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<List<ProductComboGroupResponse>> 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}";
}
}

View File

@@ -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<TencentMapOptions>(builder.Configuration.GetSection(TencentMapOptions.SectionName));
@@ -124,6 +126,8 @@ builder.Services.AddHttpClient(TencentMapGeocodingService.HttpClientName, client
builder.Services.AddScoped<TencentMapGeocodingService>();
builder.Services.AddScoped<IAddressGeocodingService>(provider => provider.GetRequiredService<TencentMapGeocodingService>());
builder.Services.AddScoped<GeoLocationOrchestrator>();
builder.Services.AddScoped<ProductSkuSaveService>();
builder.Services.AddScoped<ProductSkuSaveJobRunner>();
builder.Services.AddHostedService<GeoLocationRetryBackgroundService>();
// 10. 配置 OpenTelemetry 采集
@@ -199,6 +203,7 @@ app.UseSharedWebCore();
// 4. (空行后) 执行授权
app.UseAuthorization();
app.UseSchedulerDashboard(builder.Configuration);
// 5. (空行后) 开发环境启用 Swagger
if (app.Environment.IsDevelopment())

View File

@@ -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;
/// <summary>
/// 商品 SKU 异步保存任务执行器。
/// </summary>
public sealed class ProductSkuSaveJobRunner(
TakeoutAppDbContext dbContext,
ITenantContextAccessor tenantContextAccessor,
ProductSkuSaveService productSkuSaveService,
ILogger<ProductSkuSaveJobRunner> logger)
{
/// <summary>
/// 执行指定任务。
/// </summary>
[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<ProductSkuSaveJobPayload>(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);
}

View File

@@ -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;
/// <summary>
/// 商品 SKU 保存服务replace 语义)。
/// </summary>
public sealed class ProductSkuSaveService(TakeoutAppDbContext dbContext)
{
/// <summary>
/// 按 replace 语义保存 SKU
/// 1. 命中的 SKU 更新并恢复启用。
/// 2. 未命中的 SKU 新增。
/// 3. 缺失的历史 SKU 软禁用IsEnabled=false, Stock=0
/// </summary>
public async Task ReplaceSkusAsync(
long productId,
long storeId,
IReadOnlyList<ProductSkuUpsertInput> skus,
IReadOnlyCollection<long> 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<string>()
.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<ProductSku>(group), StringComparer.Ordinal);
var existingByAttrKey = existingSkus
.GroupBy(item => BuildSkuAttributeKey(ParseSkuAttributes(item.AttributesJson)), StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => new Queue<ProductSku>(group), StringComparer.Ordinal);
var usedExistingSkuIds = new HashSet<long>();
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<ProductSku>();
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<ProductSkuUpsertInput> skus,
IReadOnlyCollection<long> 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<long, long>()
: 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<ProductSku> queue, HashSet<long> 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<ProductSkuUpsertAttributeInput> 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<ProductSkuUpsertAttributeInput> ParseSkuAttributes(string? attributesJson)
{
if (string.IsNullOrWhiteSpace(attributesJson))
{
return [];
}
try
{
var parsed = JsonSerializer.Deserialize<List<SkuAttributePayload>>(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<ProductSkuUpsertAttributeInput> 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<string> 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<char> 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);
}
/// <summary>
/// SKU replace 输入模型。
/// </summary>
public sealed class ProductSkuUpsertInput
{
/// <summary>
/// SKU 编码(可选)。
/// </summary>
public string? SkuCode { get; set; }
/// <summary>
/// 售价。
/// </summary>
public decimal Price { get; set; }
/// <summary>
/// 划线价。
/// </summary>
public decimal? OriginalPrice { get; set; }
/// <summary>
/// 库存。
/// </summary>
public int Stock { get; set; }
/// <summary>
/// 是否启用。
/// </summary>
public bool IsEnabled { get; set; } = true;
/// <summary>
/// 排序值。
/// </summary>
public int SortOrder { get; set; }
/// <summary>
/// 规格属性组合。
/// </summary>
public List<ProductSkuUpsertAttributeInput> Attributes { get; set; } = [];
}
/// <summary>
/// SKU replace 属性输入模型。
/// </summary>
public sealed class ProductSkuUpsertAttributeInput
{
/// <summary>
/// 规格模板 ID。
/// </summary>
public long TemplateId { get; set; }
/// <summary>
/// 模板选项 ID。
/// </summary>
public long OptionId { get; set; }
}
/// <summary>
/// SKU 异步保存任务负载。
/// </summary>
public sealed class ProductSkuSaveJobPayload
{
/// <summary>
/// SKU 列表快照。
/// </summary>
public List<ProductSkuUpsertInput> Skus { get; set; } = [];
/// <summary>
/// 允许使用的规格模板 ID 列表。
/// </summary>
public List<long> SpecTemplateIds { get; set; } = [];
}

View File

@@ -25,6 +25,7 @@
<ProjectReference Include="..\..\Application\TakeoutSaaS.Application\TakeoutSaaS.Application.csproj" />
<ProjectReference Include="..\..\Infrastructure\TakeoutSaaS.Infrastructure\TakeoutSaaS.Infrastructure.csproj" />
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Authorization\TakeoutSaaS.Module.Authorization.csproj" />
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Scheduler\TakeoutSaaS.Module.Scheduler.csproj" />
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Tenancy\TakeoutSaaS.Module.Tenancy.csproj" />
</ItemGroup>
</Project>

View File

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

View File

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

View File

@@ -0,0 +1,70 @@
using TakeoutSaaS.Domain.Products.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Products.Entities;
/// <summary>
/// 商品 SKU 异步保存任务。
/// </summary>
public sealed class ProductSkuSaveJob : MultiTenantEntityBase
{
/// <summary>
/// 所属门店 ID。
/// </summary>
public long StoreId { get; set; }
/// <summary>
/// 所属商品 ID。
/// </summary>
public long ProductId { get; set; }
/// <summary>
/// 任务状态。
/// </summary>
public ProductSkuSaveJobStatus Status { get; set; } = ProductSkuSaveJobStatus.Queued;
/// <summary>
/// 任务模式(当前固定 replace
/// </summary>
public string Mode { get; set; } = "replace";
/// <summary>
/// 任务请求负载 JSON 快照。
/// </summary>
public string PayloadJson { get; set; } = string.Empty;
/// <summary>
/// 总处理数。
/// </summary>
public int ProgressTotal { get; set; }
/// <summary>
/// 已处理数。
/// </summary>
public int ProgressProcessed { get; set; }
/// <summary>
/// 失败条数。
/// </summary>
public int FailedCount { get; set; }
/// <summary>
/// 失败摘要。
/// </summary>
public string? ErrorMessage { get; set; }
/// <summary>
/// Hangfire 任务 ID。
/// </summary>
public string? HangfireJobId { get; set; }
/// <summary>
/// 开始执行时间UTC
/// </summary>
public DateTime? StartedAt { get; set; }
/// <summary>
/// 完成时间UTC
/// </summary>
public DateTime? FinishedAt { get; set; }
}

View File

@@ -0,0 +1,32 @@
namespace TakeoutSaaS.Domain.Products.Enums;
/// <summary>
/// 商品 SKU 异步保存任务状态。
/// </summary>
public enum ProductSkuSaveJobStatus
{
/// <summary>
/// 已排队。
/// </summary>
Queued = 0,
/// <summary>
/// 执行中。
/// </summary>
Running = 1,
/// <summary>
/// 已成功。
/// </summary>
Succeeded = 2,
/// <summary>
/// 执行失败。
/// </summary>
Failed = 3,
/// <summary>
/// 已取消。
/// </summary>
Canceled = 4,
}

View File

@@ -233,6 +233,10 @@ public sealed class TakeoutAppDbContext(
/// </summary>
public DbSet<ProductSku> ProductSkus => Set<ProductSku>();
/// <summary>
/// SKU 异步保存任务。
/// </summary>
public DbSet<ProductSkuSaveJob> ProductSkuSaveJobs => Set<ProductSkuSaveJob>();
/// <summary>
/// 套餐分组。
/// </summary>
public DbSet<ProductComboGroup> ProductComboGroups => Set<ProductComboGroup>();
@@ -485,6 +489,7 @@ public sealed class TakeoutAppDbContext(
ConfigureProductSchedule(modelBuilder.Entity<ProductSchedule>());
ConfigureProductScheduleProduct(modelBuilder.Entity<ProductScheduleProduct>());
ConfigureProductSku(modelBuilder.Entity<ProductSku>());
ConfigureProductSkuSaveJob(modelBuilder.Entity<ProductSkuSaveJob>());
ConfigureProductComboGroup(modelBuilder.Entity<ProductComboGroup>());
ConfigureProductComboGroupItem(modelBuilder.Entity<ProductComboGroupItem>());
ConfigureProductAddonGroup(modelBuilder.Entity<ProductAddonGroup>());
@@ -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<ProductSkuSaveJob> 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<int>();
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<ProductComboGroup> builder)
{
builder.ToTable("product_combo_groups");

View File

@@ -0,0 +1,74 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddProductSkuSaveJobsAndReplaceIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "product_sku_save_jobs",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "所属门店 ID。"),
ProductId = table.Column<long>(type: "bigint", nullable: false, comment: "所属商品 ID。"),
Status = table.Column<int>(type: "integer", nullable: false, comment: "任务状态。"),
Mode = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false, comment: "任务模式(当前固定 replace。"),
PayloadJson = table.Column<string>(type: "text", nullable: false, comment: "任务请求负载 JSON 快照。"),
ProgressTotal = table.Column<int>(type: "integer", nullable: false, comment: "总处理数。"),
ProgressProcessed = table.Column<int>(type: "integer", nullable: false, comment: "已处理数。"),
FailedCount = table.Column<int>(type: "integer", nullable: false, comment: "失败条数。"),
ErrorMessage = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true, comment: "失败摘要。"),
HangfireJobId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true, comment: "Hangfire 任务 ID。"),
StartedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "开始执行时间UTC。"),
FinishedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "完成时间UTC。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_product_sku_save_jobs", x => x.Id);
},
comment: "商品 SKU 异步保存任务。");
migrationBuilder.CreateIndex(
name: "IX_product_skus_TenantId_ProductId",
table: "product_skus",
columns: new[] { "TenantId", "ProductId" });
migrationBuilder.CreateIndex(
name: "IX_product_sku_save_jobs_TenantId_ProductId_CreatedAt",
table: "product_sku_save_jobs",
columns: new[] { "TenantId", "ProductId", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_product_sku_save_jobs_TenantId_Status_CreatedAt",
table: "product_sku_save_jobs",
columns: new[] { "TenantId", "Status", "CreatedAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "product_sku_save_jobs");
migrationBuilder.DropIndex(
name: "IX_product_skus_TenantId_ProductId",
table: "product_skus");
}
}
}

View File

@@ -5138,6 +5138,8 @@ namespace TakeoutSaaS.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("TenantId", "ProductId");
b.HasIndex("TenantId", "SkuCode")
.IsUnique();
@@ -5147,6 +5149,108 @@ namespace TakeoutSaaS.Infrastructure.Migrations
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Products.Entities.ProductSkuSaveJob", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<string>("ErrorMessage")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)")
.HasComment("失败摘要。");
b.Property<int>("FailedCount")
.HasColumnType("integer")
.HasComment("失败条数。");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("timestamp with time zone")
.HasComment("完成时间UTC。");
b.Property<string>("HangfireJobId")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("Hangfire 任务 ID。");
b.Property<string>("Mode")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasComment("任务模式(当前固定 replace。");
b.Property<string>("PayloadJson")
.IsRequired()
.HasColumnType("text")
.HasComment("任务请求负载 JSON 快照。");
b.Property<long>("ProductId")
.HasColumnType("bigint")
.HasComment("所属商品 ID。");
b.Property<int>("ProgressProcessed")
.HasColumnType("integer")
.HasComment("已处理数。");
b.Property<int>("ProgressTotal")
.HasColumnType("integer")
.HasComment("总处理数。");
b.Property<DateTime?>("StartedAt")
.HasColumnType("timestamp with time zone")
.HasComment("开始执行时间UTC。");
b.Property<int>("Status")
.HasColumnType("integer")
.HasComment("任务状态。");
b.Property<long>("StoreId")
.HasColumnType("bigint")
.HasComment("所属门店 ID。");
b.Property<long>("TenantId")
.HasColumnType("bigint")
.HasComment("所属租户 ID。");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最近一次更新时间UTC从未更新时为 null。");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.HasKey("Id");
b.HasIndex("TenantId", "ProductId", "CreatedAt");
b.HasIndex("TenantId", "Status", "CreatedAt");
b.ToTable("product_sku_save_jobs", null, t =>
{
t.HasComment("商品 SKU 异步保存任务。");
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Products.Entities.ProductSpecTemplate", b =>
{
b.Property<long>("Id")