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))