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; } = [];
|
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>
|
||||||
/// 保存商品套餐分组请求。
|
/// 保存商品套餐分组请求。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -317,6 +317,231 @@ public sealed class ProductController(
|
|||||||
savedRelationState.Skus));
|
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>
|
/// <summary>
|
||||||
/// 创建 SKU 异步保存任务。
|
/// 创建 SKU 异步保存任务。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -366,39 +591,14 @@ public sealed class ProductController(
|
|||||||
? ParseSnowflakeListStrict(request.SpecTemplateIds, nameof(request.SpecTemplateIds))
|
? ParseSnowflakeListStrict(request.SpecTemplateIds, nameof(request.SpecTemplateIds))
|
||||||
: (await LoadProductRelationStateAsync(productId, storeId, cancellationToken)).SpecTemplateIds.ToList();
|
: (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 保存任务创建失败");
|
return ApiResponse<ProductSkuSaveJobResponse>.Error(ErrorCodes.InternalServerError, "SKU 保存任务创建失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1329,6 +1529,51 @@ public sealed class ProductController(
|
|||||||
.ToList();
|
.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)
|
private static ProductSkuSaveJobResponse MapProductSkuSaveJob(ProductSkuSaveJob source)
|
||||||
{
|
{
|
||||||
return new ProductSkuSaveJobResponse
|
return new ProductSkuSaveJobResponse
|
||||||
|
|||||||
Reference in New Issue
Block a user