feat: 商品SKU支持异步保存与软禁用替换策略
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 56s
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 56s
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
421
src/Api/TakeoutSaaS.TenantApi/Services/ProductSkuSaveService.cs
Normal file
421
src/Api/TakeoutSaaS.TenantApi/Services/ProductSkuSaveService.cs
Normal 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; } = [];
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user