From 6be97e7232e261d2c40d847eab32df0fce954e80 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Mon, 26 Oct 2026 18:06:49 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E5=8A=A0=E9=80=9F=E6=9F=A5=E4=BB=B7?= =?UTF-8?q?=E2=80=94=E2=80=94=E6=89=B9=E9=87=8F=E5=BF=AB=E7=85=A7=E3=80=81?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=E7=BC=93=E5=AD=98=E4=B8=8E=E5=B9=B6=E8=A1=8C?= =?UTF-8?q?=E7=AE=97=E4=BB=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../Orders/OrderService.ParcelBilling.cs | 157 ++++++++--- .../Orders/OrderService.ParcelQuote.cs | 249 ++++++++++++------ .../Repositories/ISalesProductRepository.cs | 9 + .../Channels/SalesProductRepository.cs | 48 ++++ 4 files changed, 336 insertions(+), 127 deletions(-) diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index a364dbf..3237847 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -3,6 +3,7 @@ using Fake.Application; using Fake.UnitOfWork; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Newtonsoft.Json; @@ -44,6 +45,19 @@ public partial class OrderService private static readonly ConcurrentDictionary InboundBillingInFlight = new(); + /// + /// 已发布规则+价格快照绑定缓存(按 productId+version);LIVE 不入缓存。 + /// + private static readonly MemoryCache SnapshotBindingCache = new(new MemoryCacheOptions + { + SizeLimit = 256 + }); + + /// + /// 计重进位回读 DB 时串行化,避免查价并行路径共用 SqlSugar 连接。 + /// + private static readonly SemaphoreSlim WeightRoundingDbGate = new(1, 1); + private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); private ICustomerExportQualificationRepository ExportQualificationRepository => LazyServiceProvider.GetRequiredService(); @@ -553,6 +567,30 @@ public partial class OrderService { var rulesRecord = await SalesProductRepository.GetEffectivePublishRecordAsync(product.Id, RulesOperatorType, bindTime); var priceRecord = await SalesProductRepository.GetEffectivePublishRecordAsync(product.Id, PriceOperatorType, bindTime); + return await BuildSnapshotBindingAsync(product, bindTime, rulesRecord, priceRecord); + } + + /// + /// 由已查到的发布记录组装绑定;已发布版本命中进程内 MemoryCache。 + /// + private async Task BuildSnapshotBindingAsync( + SalesProduct product, + DateTime bindTime, + SalesProductPublishRecord? rulesRecord, + SalesProductPublishRecord? priceRecord) + { + var canCache = rulesRecord != null + && priceRecord != null + && !string.IsNullOrWhiteSpace(rulesRecord.Version) + && !string.IsNullOrWhiteSpace(priceRecord.Version); + if (canCache) + { + var cacheKey = BuildSnapshotBindingCacheKey(product.Id, rulesRecord!.Version, priceRecord!.Version); + if (SnapshotBindingCache.TryGetValue(cacheKey, out ProductSnapshotBinding? cached) && cached != null) + { + return cached; + } + } SalesProductRulesPublishSnapshotDto rulesSnapshot; if (rulesRecord == null) @@ -599,9 +637,25 @@ public partial class OrderService Price = priceSnapshot }; await KpiAreaPackService.HydrateBindingAsync(binding); + + if (canCache) + { + SnapshotBindingCache.Set( + BuildSnapshotBindingCacheKey(product.Id, rulesRecord!.Version, priceRecord!.Version), + binding, + new MemoryCacheEntryOptions + { + Size = 1, + SlidingExpiration = TimeSpan.FromMinutes(10) + }); + } + return binding; } + private static string BuildSnapshotBindingCacheKey(int productId, string rulesVersion, string priceVersion) => + $"sp-bind|{productId}|{rulesVersion}|{priceVersion}"; + private async Task ResolveSnapshotBindingFromOrderAsync(SalesProduct product, Order order) { var bindTime = order.CreateTime == default ? DateTime.Now : order.CreateTime; @@ -1908,13 +1962,21 @@ public partial class OrderService } else { - var weightRule = await WeightRuleRepository.FirstOrDefaultAsync(x => x.Id == weightRuleId.Value); - if (weightRule == null) + await WeightRoundingDbGate.WaitAsync(); + try { - throw new BusinessException("未配置计重规则"); - } + var weightRule = await WeightRuleRepository.FirstOrDefaultAsync(x => x.Id == weightRuleId.Value); + if (weightRule == null) + { + throw new BusinessException("未配置计重规则"); + } - ruleInfos = await WeightRuleRepository.GetAllInfosByRuleIdAsync(weightRuleId.Value); + ruleInfos = await WeightRuleRepository.GetAllInfosByRuleIdAsync(weightRuleId.Value); + } + finally + { + WeightRoundingDbGate.Release(); + } } var (volumetricWeight, rawWeight) = ParcelBillingCalculator.CalculateRawBillableWeight(forecast, ruleInfos); @@ -1927,14 +1989,11 @@ public partial class OrderService } var ranges = await ResolveWeightRoundingRangesAsync(product, rulesSnapshot, weightRoundingId.Value); - var dbRanges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value); - - forecast.ChargeableWeight = ApplyRoundingWithSnapshotFallback( + forecast.ChargeableWeight = await ApplyRoundingPreferSnapshotAsync( rawWeight, ranges, rulesSnapshot, - weightRoundingId.Value, - dbRanges); + weightRoundingId.Value); } catch (BusinessException) { @@ -1959,31 +2018,20 @@ public partial class OrderService return RulesSnapshotHelper.ToWeightRoundingEntities(snapshotRanges!); } - var rounding = await WeightRoundingRepository.FirstOrDefaultAsync(x => x.Id == weightRoundingId); - if (rounding == null) - { - throw new BusinessException("未配置计重进位"); - } - - return await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId); - } - - private static decimal ApplyRoundingWithSnapshotFallback( - decimal rawWeight, - IReadOnlyList ranges, - SalesProductRulesPublishSnapshotDto? rulesSnapshot, - int weightRoundingId, - IReadOnlyList dbFallbackRanges) - { - var fromSnapshot = rulesSnapshot?.WeightRoundingInfos is { Count: > 0 } - && rulesSnapshot.WeightRoundingInfos.Any(x => !string.IsNullOrWhiteSpace(x.Public)); + await WeightRoundingDbGate.WaitAsync(); try { - return ParcelBillingCalculator.ApplyRounding(rawWeight, ranges); + var rounding = await WeightRoundingRepository.FirstOrDefaultAsync(x => x.Id == weightRoundingId); + if (rounding == null) + { + throw new BusinessException("未配置计重进位"); + } + + return await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId); } - catch (InvalidOperationException) when (fromSnapshot && dbFallbackRanges.Count > 0) + finally { - return ParcelBillingCalculator.ApplyRounding(rawWeight, dbFallbackRanges); + WeightRoundingDbGate.Release(); } } @@ -2011,21 +2059,13 @@ public partial class OrderService } var ranges = await ResolveWeightRoundingRangesAsync(product, rulesSnapshot, weightRoundingId.Value); - var dbRanges = ranges; - if (rulesSnapshot?.WeightRoundingInfos is { Count: > 0 } - && rulesSnapshot.WeightRoundingInfos.Any(x => !string.IsNullOrWhiteSpace(x.Public))) - { - dbRanges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value); - } - try { - forecast.ChargeableWeight = ApplyRoundingWithSnapshotFallback( + forecast.ChargeableWeight = await ApplyRoundingPreferSnapshotAsync( actualWeight, ranges, rulesSnapshot, - weightRoundingId.Value, - dbRanges); + weightRoundingId.Value); } catch (InvalidOperationException ex) { @@ -2033,6 +2073,41 @@ public partial class OrderService } } + /// + /// 优先用快照进位;仅失败时回读 DB(串行门闩,兼容查价并行)。 + /// + private async Task ApplyRoundingPreferSnapshotAsync( + decimal rawWeight, + IReadOnlyList ranges, + SalesProductRulesPublishSnapshotDto? rulesSnapshot, + int weightRoundingId) + { + var fromSnapshot = rulesSnapshot?.WeightRoundingInfos is { Count: > 0 } + && rulesSnapshot.WeightRoundingInfos.Any(x => !string.IsNullOrWhiteSpace(x.Public)); + try + { + return ParcelBillingCalculator.ApplyRounding(rawWeight, ranges); + } + catch (InvalidOperationException) when (fromSnapshot) + { + await WeightRoundingDbGate.WaitAsync(); + try + { + var dbRanges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId); + if (dbRanges.Count == 0) + { + throw; + } + + return ParcelBillingCalculator.ApplyRounding(rawWeight, dbRanges); + } + finally + { + WeightRoundingDbGate.Release(); + } + } + } + private static void EnsureBillingProductActive(SalesProduct product) { if (product.IsActive != EnableStatus.Enabled) diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs index 929f1db..03e7300 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using Fake.Application; using Oms.Application.Dtos.Marketing; using Oms.Application.Dtos.Orders; @@ -274,99 +275,161 @@ public partial class OrderService return result; } - foreach (var product in products.OrderBy(x => x.Id)) + var orderedProducts = products.OrderBy(x => x.Id).ToList(); + var productIds = orderedProducts.Select(x => x.Id).ToList(); + // 批量拉发布记录(2 次查询替代 2N),再组装绑定;SqlSugar 同连接不可并发查库 + var rulesMap = await SalesProductRepository.GetEffectivePublishRecordsAsync( + productIds, RulesOperatorType, bindTime); + var priceMap = await SalesProductRepository.GetEffectivePublishRecordsAsync( + productIds, PriceOperatorType, bindTime); + + var bindingByProductId = new Dictionary(orderedProducts.Count); + var bindingErrorByProductId = new Dictionary(); + foreach (var product in orderedProducts) + { + rulesMap.TryGetValue(product.Id, out var rulesRecord); + priceMap.TryGetValue(product.Id, out var priceRecord); + try + { + bindingByProductId[product.Id] = await BuildSnapshotBindingAsync( + product, bindTime, rulesRecord, priceRecord); + } + catch (BusinessException ex) + { + bindingErrorByProductId[product.Id] = ex.Message; + } + } + + var workItems = new List( + orderedProducts.Count * Math.Max(1, cargoTargets.Count) * Math.Max(1, warehouses.Count)); + foreach (var product in orderedProducts) { foreach (var cargo in cargoTargets) { foreach (var warehouse in warehouses) { - var quoteContext = new ParcelQuoteContext - { - CountryCode = baseContext.CountryCode, - Province = baseContext.Province, - City = baseContext.City, - PostalCode = baseContext.PostalCode, - CargoAttribute = cargo.CargoAttribute, - CargoTypeCode = cargo.Code, - WarehouseId = warehouse.Id, - WarehouseName = warehouse.WarehouseName, - Length = baseContext.Length, - Width = baseContext.Width, - Height = baseContext.Height, - ActualWeight = baseContext.ActualWeight, - AdditionalServices = baseContext.AdditionalServices, - DeclaredSum = baseContext.DeclaredSum, - DeclaredCurrency = baseContext.DeclaredCurrency - }; - - var attempt = await TryComputePriceCheckQuoteAsync( - product, - quoteContext, - profile, - priceTierKey, - bindTime, - cargo.DisplayName, - warehouse.WarehouseName); - - if (attempt.Success) - { - var quote = attempt.Quote!; - var summary = ParcelBillingCalculator.SummarizeQuoteFees(quote.FeeLines); - var itemHint = PriceCheckHintBuilder.BuildItemHint(profile, quote.UsedZoneFallback); - var groupCode = string.IsNullOrWhiteSpace(product.ProductGroup) - ? UngroupedProductGroupCode - : product.ProductGroup.Trim(); - var groupName = productGroupNameMap.TryGetValue(groupCode, out var mappedName) - ? mappedName - : groupCode; - result.Items.Add(new OrderPriceCheckOutput - { - Id = quote.ProductId, - ProductCode = quote.ProductCode, - ShippingMethod = quote.ProductName, - CargoType = cargo.Code, - CargoTypeName = cargo.DisplayName, - WarehouseId = warehouse.Id, - Warehouse = warehouse.WarehouseName, - DeliveryTime = $"{product.MinTimeliness ?? 0}-{product.MaxTimeliness ?? 0}", - TradeTerms = string.IsNullOrWhiteSpace(product.TradeTerms) ? null : product.TradeTerms.Trim().ToUpperInvariant(), - ProductGroup = groupCode, - ProductGroupName = groupName, - ProductDescription = string.IsNullOrWhiteSpace(product.ProductDescription) - ? null - : product.ProductDescription.Trim(), - VolumetricWeight = quote.VolumetricWeight, - ChargeableWeight = quote.ChargeableWeight, - CostFormula = summary.CostFormula, - Freight = summary.Freight, - OtherFees = summary.OtherFees, - OtherFeeList = summary.OtherFeeList, - TotalCost = summary.TotalCost, - MaxDeliveryDays = product.MaxTimeliness, - TotalCostNum = summary.TotalCost, - RulesVersion = quote.RulesVersion, - PriceVersion = quote.PriceVersion, - IsRemote = quote.IsRemote, - QuoteLevel = itemHint.QuoteLevel, - MissingFieldCodes = itemHint.MissingFieldCodes, - Disclaimers = itemHint.Disclaimers, - ZoneName = quote.ZoneName - }); - continue; - } - - if (attempt.Failure != null) - { - result.Failures.Add(attempt.Failure); - } + workItems.Add(new PriceCheckWorkItem(product, cargo, warehouse)); } } } + var itemBag = new ConcurrentBag(); + var failureBag = new ConcurrentBag(); + var parallelOptions = new ParallelOptions + { + // 快照已预加载后主要为 CPU(分区匹配/算费);限制并发避免打满 CPU + MaxDegreeOfParallelism = Math.Clamp(Environment.ProcessorCount, 2, 8) + }; + + await Parallel.ForEachAsync(workItems, parallelOptions, async (work, _) => + { + var product = work.Product; + var cargo = work.Cargo; + var warehouse = work.Warehouse; + bindingByProductId.TryGetValue(product.Id, out var snapshotBinding); + bindingErrorByProductId.TryGetValue(product.Id, out var snapshotError); + + var quoteContext = new ParcelQuoteContext + { + CountryCode = baseContext.CountryCode, + Province = baseContext.Province, + City = baseContext.City, + PostalCode = baseContext.PostalCode, + CargoAttribute = cargo.CargoAttribute, + CargoTypeCode = cargo.Code, + WarehouseId = warehouse.Id, + WarehouseName = warehouse.WarehouseName, + Length = baseContext.Length, + Width = baseContext.Width, + Height = baseContext.Height, + ActualWeight = baseContext.ActualWeight, + AdditionalServices = baseContext.AdditionalServices, + DeclaredSum = baseContext.DeclaredSum, + DeclaredCurrency = baseContext.DeclaredCurrency + }; + + var attempt = await TryComputePriceCheckQuoteAsync( + product, + quoteContext, + profile, + priceTierKey, + bindTime, + cargo.DisplayName, + warehouse.WarehouseName, + snapshotBinding, + snapshotError); + + if (attempt.Success) + { + var quote = attempt.Quote!; + var summary = ParcelBillingCalculator.SummarizeQuoteFees(quote.FeeLines); + var itemHint = PriceCheckHintBuilder.BuildItemHint(profile, quote.UsedZoneFallback); + var groupCode = string.IsNullOrWhiteSpace(product.ProductGroup) + ? UngroupedProductGroupCode + : product.ProductGroup.Trim(); + var groupName = productGroupNameMap.TryGetValue(groupCode, out var mappedName) + ? mappedName + : groupCode; + itemBag.Add(new OrderPriceCheckOutput + { + Id = quote.ProductId, + ProductCode = quote.ProductCode, + ShippingMethod = quote.ProductName, + CargoType = cargo.Code, + CargoTypeName = cargo.DisplayName, + WarehouseId = warehouse.Id, + Warehouse = warehouse.WarehouseName, + DeliveryTime = $"{product.MinTimeliness ?? 0}-{product.MaxTimeliness ?? 0}", + TradeTerms = string.IsNullOrWhiteSpace(product.TradeTerms) + ? null + : product.TradeTerms.Trim().ToUpperInvariant(), + ProductGroup = groupCode, + ProductGroupName = groupName, + ProductDescription = string.IsNullOrWhiteSpace(product.ProductDescription) + ? null + : product.ProductDescription.Trim(), + VolumetricWeight = quote.VolumetricWeight, + ChargeableWeight = quote.ChargeableWeight, + CostFormula = summary.CostFormula, + Freight = summary.Freight, + OtherFees = summary.OtherFees, + OtherFeeList = summary.OtherFeeList, + TotalCost = summary.TotalCost, + MaxDeliveryDays = product.MaxTimeliness, + TotalCostNum = summary.TotalCost, + RulesVersion = quote.RulesVersion, + PriceVersion = quote.PriceVersion, + IsRemote = quote.IsRemote, + QuoteLevel = itemHint.QuoteLevel, + MissingFieldCodes = itemHint.MissingFieldCodes, + Disclaimers = itemHint.Disclaimers, + ZoneName = quote.ZoneName + }); + return; + } + + if (attempt.Failure != null) + { + failureBag.Add(attempt.Failure); + } + }); + + result.Items.AddRange( + itemBag + .OrderBy(x => x.Id) + .ThenBy(x => x.CargoType, StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.WarehouseId)); + result.Failures.AddRange(failureBag); + await ApplyMarketingToPriceCheckResultAsync(input, result); return result; } + private sealed record PriceCheckWorkItem( + SalesProduct Product, + PriceCheckCargoTarget Cargo, + WarehouseAddress Warehouse); + private async Task ApplyMarketingToPriceCheckResultAsync(OrderPriceCheckInput input, OrderPriceCheckResultOutput result) { if (result.Items.Count == 0) @@ -574,21 +637,35 @@ public partial class OrderService string? priceTierKey, DateTime bindTime, string cargoTypeName, - string warehouseName) + string warehouseName, + ProductSnapshotBinding? preloadedBinding = null, + string? preloadedBindingError = null) { if (product.IsActive != EnableStatus.Enabled) { return PriceCheckFailure(product, "product", "产品未启用", cargoTypeName, warehouseName); } - ProductSnapshotBinding snapshotBinding; - try + if (!string.IsNullOrWhiteSpace(preloadedBindingError)) { - snapshotBinding = await ResolveSnapshotBindingAsync(product, bindTime); + return PriceCheckFailure(product, "snapshot", preloadedBindingError, cargoTypeName, warehouseName); } - catch (BusinessException ex) + + ProductSnapshotBinding snapshotBinding; + if (preloadedBinding != null) { - return PriceCheckFailure(product, "snapshot", ex.Message, cargoTypeName, warehouseName); + snapshotBinding = preloadedBinding; + } + else + { + try + { + snapshotBinding = await ResolveSnapshotBindingAsync(product, bindTime); + } + catch (BusinessException ex) + { + return PriceCheckFailure(product, "snapshot", ex.Message, cargoTypeName, warehouseName); + } } if (await IsBlacklistHitAsync( diff --git a/src/Oms.Domain/ChannelsAggregate/Repositories/ISalesProductRepository.cs b/src/Oms.Domain/ChannelsAggregate/Repositories/ISalesProductRepository.cs index e72dc7f..606284b 100644 --- a/src/Oms.Domain/ChannelsAggregate/Repositories/ISalesProductRepository.cs +++ b/src/Oms.Domain/ChannelsAggregate/Repositories/ISalesProductRepository.cs @@ -77,4 +77,13 @@ public interface ISalesProductRepository : IRepository string operatorType, DateTime bindTime, CancellationToken cancellationToken = default); + + /// + /// 批量取各产品在 bindTime 时点生效的最新 Active 发布记录(含 Content)。 + /// + Task> GetEffectivePublishRecordsAsync( + IReadOnlyCollection salesProductIds, + string operatorType, + DateTime bindTime, + CancellationToken cancellationToken = default); } diff --git a/src/Oms.SqlSugarCore/Channels/SalesProductRepository.cs b/src/Oms.SqlSugarCore/Channels/SalesProductRepository.cs index 6fcf92a..f8b1be3 100644 --- a/src/Oms.SqlSugarCore/Channels/SalesProductRepository.cs +++ b/src/Oms.SqlSugarCore/Channels/SalesProductRepository.cs @@ -288,6 +288,54 @@ public class SalesProductRepository : OmsSqlSugarRepository, ISale return records.FirstOrDefault(); } + public async Task> GetEffectivePublishRecordsAsync( + IReadOnlyCollection salesProductIds, + string operatorType, + DateTime bindTime, + CancellationToken cancellationToken = default) + { + var ids = salesProductIds.Where(x => x > 0).Distinct().ToArray(); + if (ids.Length == 0) + { + return new Dictionary(); + } + + var db = await GetDbContextAsync(cancellationToken); + // 先只取头字段,避免把历史版本的 Content 一并拉回 + var headers = await db.Queryable() + .Where(x => ids.Contains(x.SalesProductId) + && x.OperatorType == operatorType + && x.Status == EsSnapshotStatus.Active + && x.PublishTime <= bindTime) + .Select(x => new SalesProductPublishRecord + { + Id = x.Id, + SalesProductId = x.SalesProductId, + PublishTime = x.PublishTime + }) + .ToListAsync(cancellationToken); + + if (headers.Count == 0) + { + return new Dictionary(); + } + + var winningIds = headers + .GroupBy(x => x.SalesProductId) + .Select(g => g.OrderByDescending(x => x.PublishTime).ThenByDescending(x => x.Id).First().Id) + .ToArray(); + + var records = await db.Queryable() + .Where(x => winningIds.Contains(x.Id)) + .ToListAsync(cancellationToken); + + return records + .GroupBy(x => x.SalesProductId) + .ToDictionary( + g => g.Key, + g => g.OrderByDescending(x => x.PublishTime).ThenByDescending(x => x.Id).First()); + } + private static async Task InsertPriceChildrenAsync( SqlSugar.ISqlSugarClient db, int priceId,