feat: 新增商品单接口异步保存并内置SKU入队
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 50s
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 50s
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user