diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Product/ProductContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Product/ProductContracts.cs index 647d910..a9d9684 100644 --- a/src/Api/TakeoutSaaS.TenantApi/Contracts/Product/ProductContracts.cs +++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Product/ProductContracts.cs @@ -80,7 +80,7 @@ public sealed class SaveProductRequest /// /// 商品类型(single/combo)。 /// - public string Kind { get; set; } = "single"; + public string? Kind { get; set; } /// /// 商品名称。 diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/ProductController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/ProductController.cs index ddfdc14..260db9a 100644 --- a/src/Api/TakeoutSaaS.TenantApi/Controllers/ProductController.cs +++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/ProductController.cs @@ -126,12 +126,10 @@ public sealed class ProductController( await EnsureStoreAccessibleAsync(storeId, cancellationToken); var categoryId = StoreApiHelpers.ParseRequiredSnowflake(request.CategoryId, nameof(request.CategoryId)); - var kind = ParseKind(request.Kind); 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); - var comboGroups = NormalizeComboGroups(request.ComboGroups, kind); ProductDto? existing = null; if (productId.HasValue) @@ -147,6 +145,9 @@ public sealed class ProductController( } } + 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 shouldReplaceSkus = existing is null || request.Skus is not null; @@ -187,9 +188,16 @@ public sealed class ProductController( int? remainStock = soldoutMode.HasValue ? Math.Max(0, existing?.RemainStock ?? request.Stock) : null; var soldoutReason = soldoutMode.HasValue ? existing?.SoldoutReason : null; - ProductDto? saved; - await using (var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken)) + 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 @@ -261,7 +269,8 @@ public sealed class ProductController( if (saved is null) { - return ApiResponse.Error(ErrorCodes.NotFound, "商品不存在"); + notFoundInTransaction = true; + return; } if (shouldReplaceSpecAddon) @@ -281,9 +290,14 @@ public sealed class ProductController( 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!; + var savedProduct = saved; var detailComboGroups = await BuildComboGroupResponsesAsync(savedProduct.Id, storeId, cancellationToken); var categoryNameLookup = await BuildCategoryNameLookupAsync(storeId, cancellationToken); var savedRelationState = await LoadProductRelationStateAsync(savedProduct.Id, storeId, cancellationToken); @@ -727,6 +741,16 @@ public sealed class ProductController( }; } + private static ProductKind ResolveKindForSave(string? kind, ProductDto? existing) + { + if (string.IsNullOrWhiteSpace(kind)) + { + return existing?.Kind ?? ProductKind.Single; + } + + return ParseKind(kind); + } + private static ProductKind? ParseKindOrNull(string? kind) { if (string.IsNullOrWhiteSpace(kind))