feat: 新增商品单接口异步保存并内置SKU入队
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 50s

This commit is contained in:
2026-02-25 10:51:04 +08:00
parent 18af62e111
commit aeef4ca649
2 changed files with 308 additions and 32 deletions

View File

@@ -183,6 +183,37 @@ public sealed class SaveProductRequest
public List<SaveProductComboGroupRequest> ComboGroups { get; set; } = [];
}
/// <summary>
/// 商品异步保存响应基础信息已落库SKU 任务异步处理)。
/// </summary>
public sealed class SaveProductAsyncResponse
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 商品 ID。
/// </summary>
public string ProductId { get; set; } = string.Empty;
/// <summary>
/// SKU 任务 ID无任务时为 null
/// </summary>
public string? SkuJobId { get; set; }
/// <summary>
/// SKU 任务状态queued/running/failed/not_required
/// </summary>
public string SkuJobStatus { get; set; } = "not_required";
/// <summary>
/// 结果说明。
/// </summary>
public string? Message { get; set; }
}
/// <summary>
/// 保存商品套餐分组请求。
/// </summary>

View File

@@ -317,6 +317,231 @@ public sealed class ProductController(
savedRelationState.Skus));
}
/// <summary>
/// 异步保存商品基础信息立即落库SKU 走异步任务)。
/// </summary>
[HttpPost("save-async")]
[ProducesResponseType(typeof(ApiResponse<SaveProductAsyncResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<SaveProductAsyncResponse>> SaveAsync(
[FromBody] SaveProductRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var categoryId = StoreApiHelpers.ParseRequiredSnowflake(request.CategoryId, nameof(request.CategoryId));
var productId = StoreApiHelpers.ParseSnowflakeOrNull(request.Id);
var timedOnShelfAt = ParseTimedOnShelfAt(request.ShelfMode, request.TimedOnShelfAt);
var normalizedStatus = ResolveStatusByShelfMode(request.ShelfMode, request.Status);
var imageUrls = NormalizeImageUrls(request.ImageUrls);
ProductDto? existing = null;
if (productId.HasValue)
{
existing = await mediator.Send(new GetProductByIdQuery
{
ProductId = productId.Value
}, cancellationToken);
if (existing is null || existing.StoreId != storeId)
{
return ApiResponse<SaveProductAsyncResponse>.Error(ErrorCodes.NotFound, "商品不存在");
}
}
var kind = ResolveKindForSave(request.Kind, existing);
var comboGroups = NormalizeComboGroups(request.ComboGroups, kind);
var shouldReplaceSpecAddon = existing is null || request.SpecTemplateIds is not null || request.AddonGroupIds is not null;
var shouldReplaceLabels = existing is null || request.LabelIds is not null;
var specTemplateIds = shouldReplaceSpecAddon
? ParseSnowflakeListStrict(request.SpecTemplateIds, nameof(request.SpecTemplateIds))
: [];
var addonGroupIds = shouldReplaceSpecAddon
? ParseSnowflakeListStrict(request.AddonGroupIds, nameof(request.AddonGroupIds))
: [];
if (existing is not null &&
shouldReplaceSpecAddon &&
(request.SpecTemplateIds is null || request.AddonGroupIds is null))
{
var currentRelationState = await LoadProductRelationStateAsync(existing.Id, storeId, cancellationToken);
if (request.SpecTemplateIds is null)
{
specTemplateIds = currentRelationState.SpecTemplateIds.ToList();
}
if (request.AddonGroupIds is null)
{
addonGroupIds = currentRelationState.AddonGroupIds.ToList();
}
}
var labelIds = shouldReplaceLabels
? ParseSnowflakeListStrict(request.LabelIds, nameof(request.LabelIds))
: [];
var normalizedSkus = request.Skus is { Count: > 0 }
? NormalizeSkus(request.Skus, request.Price, request.OriginalPrice, request.Stock)
: [];
var tagsJson = SerializeTagsJson(request.Tags);
var soldoutMode = ResolveSoldoutModeForSave(existing, normalizedStatus, request.Status);
var recoverAt = soldoutMode == ProductSoldoutMode.Timed ? existing?.RecoverAt : null;
int? remainStock = soldoutMode.HasValue ? Math.Max(0, existing?.RemainStock ?? request.Stock) : null;
var soldoutReason = soldoutMode.HasValue ? existing?.SoldoutReason : null;
ProductDto? saved = null;
var notFoundInTransaction = false;
var executionStrategy = dbContext.Database.CreateExecutionStrategy();
await executionStrategy.ExecuteAsync(async () =>
{
saved = null;
notFoundInTransaction = false;
dbContext.ChangeTracker.Clear();
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
if (existing is null)
{
saved = await mediator.Send(new CreateProductCommand
{
StoreId = storeId,
CategoryId = categoryId,
SpuCode = string.IsNullOrWhiteSpace(request.SpuCode) ? GenerateSpuCode() : request.SpuCode.Trim(),
Name = request.Name.Trim(),
Subtitle = request.Subtitle?.Trim(),
Description = request.Description?.Trim(),
Price = request.Price,
OriginalPrice = request.OriginalPrice.HasValue && request.OriginalPrice.Value > 0 ? request.OriginalPrice : null,
StockQuantity = Math.Max(0, request.Stock),
Status = normalizedStatus,
Kind = kind,
TagsJson = tagsJson,
TimedOnShelfAt = timedOnShelfAt,
SoldoutMode = soldoutMode,
RecoverAt = recoverAt,
RemainStock = remainStock,
SoldoutReason = soldoutReason,
SyncToPlatform = true,
NotifyManager = false,
CoverImage = imageUrls.FirstOrDefault(),
GalleryImages = string.Join(',', imageUrls),
SortWeight = Math.Max(0, request.SortWeight ?? 0),
WarningStock = request.WarningStock.HasValue ? Math.Max(0, request.WarningStock.Value) : null,
PackingFee = NormalizePackingFee(request.PackingFee)
}, cancellationToken);
}
else
{
saved = await mediator.Send(new UpdateProductCommand
{
ProductId = existing.Id,
StoreId = storeId,
CategoryId = categoryId,
SpuCode = string.IsNullOrWhiteSpace(request.SpuCode) ? existing.SpuCode : request.SpuCode.Trim(),
Name = request.Name.Trim(),
Subtitle = request.Subtitle?.Trim(),
Unit = existing.Unit,
Price = request.Price,
OriginalPrice = request.OriginalPrice.HasValue && request.OriginalPrice.Value > 0 ? request.OriginalPrice : null,
StockQuantity = Math.Max(0, request.Stock),
MaxQuantityPerOrder = existing.MaxQuantityPerOrder,
Status = normalizedStatus,
Kind = kind,
SalesMonthly = existing.SalesMonthly,
TagsJson = tagsJson,
SoldoutMode = soldoutMode,
RecoverAt = recoverAt,
RemainStock = remainStock,
SoldoutReason = soldoutReason,
SyncToPlatform = existing.SyncToPlatform,
NotifyManager = existing.NotifyManager,
TimedOnShelfAt = timedOnShelfAt,
CoverImage = imageUrls.FirstOrDefault() ?? existing.CoverImage,
GalleryImages = imageUrls.Count > 0 ? string.Join(',', imageUrls) : existing.GalleryImages,
Description = request.Description?.Trim(),
EnableDineIn = existing.EnableDineIn,
EnablePickup = existing.EnablePickup,
EnableDelivery = existing.EnableDelivery,
IsFeatured = existing.IsFeatured,
SortWeight = Math.Max(0, request.SortWeight ?? 0),
WarningStock = request.WarningStock.HasValue ? Math.Max(0, request.WarningStock.Value) : null,
PackingFee = NormalizePackingFee(request.PackingFee)
}, cancellationToken);
}
if (saved is null)
{
notFoundInTransaction = true;
return;
}
if (shouldReplaceSpecAddon)
{
await ReplaceSpecTemplateRelationsAsync(saved.Id, storeId, specTemplateIds, addonGroupIds, cancellationToken);
}
if (shouldReplaceLabels)
{
await ReplaceLabelRelationsAsync(saved.Id, storeId, labelIds, cancellationToken);
}
await ReplaceComboGroupsAsync(saved.Id, storeId, kind, comboGroups, cancellationToken);
await transaction.CommitAsync(cancellationToken);
});
if (notFoundInTransaction || saved is null)
{
return ApiResponse<SaveProductAsyncResponse>.Error(ErrorCodes.NotFound, "商品不存在");
}
var savedProduct = saved;
string? skuJobId = null;
var skuJobStatus = "not_required";
var resultMessage = "商品基础信息已保存";
if (normalizedSkus.Count > 0)
{
var processingJob = await dbContext.ProductSkuSaveJobs
.AsNoTracking()
.Where(item => item.StoreId == storeId &&
item.ProductId == savedProduct.Id &&
(item.Status == ProductSkuSaveJobStatus.Queued || item.Status == ProductSkuSaveJobStatus.Running))
.OrderByDescending(item => item.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (processingJob is not null)
{
skuJobId = processingJob.Id.ToString();
skuJobStatus = ToProductSkuSaveJobStatusText(processingJob.Status);
resultMessage = "检测到进行中的 SKU 保存任务,已复用任务";
}
else
{
var skuSpecTemplateIds = shouldReplaceSpecAddon
? specTemplateIds
: (await LoadProductRelationStateAsync(savedProduct.Id, storeId, cancellationToken)).SpecTemplateIds.ToList();
var createdJob = await CreateSkuSaveJobAsync(
storeId,
savedProduct.Id,
normalizedSkus,
skuSpecTemplateIds,
cancellationToken);
skuJobId = createdJob.Id.ToString();
skuJobStatus = ToProductSkuSaveJobStatusText(createdJob.Status);
resultMessage = createdJob.Status == ProductSkuSaveJobStatus.Failed
? "商品基础信息已保存,但 SKU 异步任务创建失败"
: "商品基础信息已保存SKU 异步任务已创建";
}
}
return ApiResponse<SaveProductAsyncResponse>.Ok(new SaveProductAsyncResponse
{
StoreId = storeId.ToString(),
ProductId = savedProduct.Id.ToString(),
SkuJobId = skuJobId,
SkuJobStatus = skuJobStatus,
Message = resultMessage
});
}
/// <summary>
/// 创建 SKU 异步保存任务。
/// </summary>
@@ -366,39 +591,14 @@ public sealed class ProductController(
? ParseSnowflakeListStrict(request.SpecTemplateIds, nameof(request.SpecTemplateIds))
: (await LoadProductRelationStateAsync(productId, storeId, cancellationToken)).SpecTemplateIds.ToList();
var payload = new ProductSkuSaveJobPayload
var entity = await CreateSkuSaveJobAsync(
storeId,
productId,
normalizedSkus,
specTemplateIds,
cancellationToken);
if (entity.Status == ProductSkuSaveJobStatus.Failed)
{
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(BuildDetailedErrorMessage(ex), 2000);
entity.FinishedAt = DateTime.UtcNow;
await dbContext.SaveChangesAsync(cancellationToken);
return ApiResponse<ProductSkuSaveJobResponse>.Error(ErrorCodes.InternalServerError, "SKU 保存任务创建失败");
}
@@ -1329,6 +1529,51 @@ public sealed class ProductController(
.ToList();
}
private async Task<ProductSkuSaveJob> CreateSkuSaveJobAsync(
long storeId,
long productId,
IReadOnlyList<NormalizedSkuRequest> normalizedSkus,
IReadOnlyCollection<long> specTemplateIds,
CancellationToken cancellationToken)
{
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(BuildDetailedErrorMessage(ex), 2000);
entity.FinishedAt = DateTime.UtcNow;
await dbContext.SaveChangesAsync(cancellationToken);
}
return entity;
}
private static ProductSkuSaveJobResponse MapProductSkuSaveJob(ProductSkuSaveJob source)
{
return new ProductSkuSaveJobResponse