From aeef4ca649be6f0b2972a04bbea71f8b99145d09 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Wed, 25 Feb 2026 10:51:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=95=86=E5=93=81?= =?UTF-8?q?=E5=8D=95=E6=8E=A5=E5=8F=A3=E5=BC=82=E6=AD=A5=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E5=B9=B6=E5=86=85=E7=BD=AESKU=E5=85=A5=E9=98=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Contracts/Product/ProductContracts.cs | 31 ++ .../Controllers/ProductController.cs | 309 ++++++++++++++++-- 2 files changed, 308 insertions(+), 32 deletions(-) diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Product/ProductContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Product/ProductContracts.cs index 19aea03..aa4be28 100644 --- a/src/Api/TakeoutSaaS.TenantApi/Contracts/Product/ProductContracts.cs +++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Product/ProductContracts.cs @@ -183,6 +183,37 @@ public sealed class SaveProductRequest public List ComboGroups { get; set; } = []; } +/// +/// 商品异步保存响应(基础信息已落库,SKU 任务异步处理)。 +/// +public sealed class SaveProductAsyncResponse +{ + /// + /// 门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 商品 ID。 + /// + public string ProductId { get; set; } = string.Empty; + + /// + /// SKU 任务 ID(无任务时为 null)。 + /// + public string? SkuJobId { get; set; } + + /// + /// SKU 任务状态(queued/running/failed/not_required)。 + /// + public string SkuJobStatus { get; set; } = "not_required"; + + /// + /// 结果说明。 + /// + public string? Message { get; set; } +} + /// /// 保存商品套餐分组请求。 /// diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/ProductController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/ProductController.cs index d7c3971..16f3838 100644 --- a/src/Api/TakeoutSaaS.TenantApi/Controllers/ProductController.cs +++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/ProductController.cs @@ -317,6 +317,231 @@ public sealed class ProductController( savedRelationState.Skus)); } + /// + /// 异步保存商品(基础信息立即落库,SKU 走异步任务)。 + /// + [HttpPost("save-async")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> 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.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.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.Ok(new SaveProductAsyncResponse + { + StoreId = storeId.ToString(), + ProductId = savedProduct.Id.ToString(), + SkuJobId = skuJobId, + SkuJobStatus = skuJobStatus, + Message = resultMessage + }); + } + /// /// 创建 SKU 异步保存任务。 /// @@ -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(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.Error(ErrorCodes.InternalServerError, "SKU 保存任务创建失败"); } @@ -1329,6 +1529,51 @@ public sealed class ProductController( .ToList(); } + private async Task CreateSkuSaveJobAsync( + long storeId, + long productId, + IReadOnlyList normalizedSkus, + IReadOnlyCollection 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(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