perf: 加速查价——批量快照、绑定缓存与并行算价
All checks were successful
Build and Deploy / deploy (push) Successful in 1m13s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-10-26 18:06:49 +08:00
parent 5af567b519
commit 6be97e7232
4 changed files with 336 additions and 127 deletions

View File

@ -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<int, byte> InboundBillingInFlight = new();
/// <summary>
/// 已发布规则+价格快照绑定缓存(按 productId+version);LIVE 不入缓存。
/// </summary>
private static readonly MemoryCache SnapshotBindingCache = new(new MemoryCacheOptions
{
SizeLimit = 256
});
/// <summary>
/// 计重进位回读 DB 时串行化,避免查价并行路径共用 SqlSugar 连接。
/// </summary>
private static readonly SemaphoreSlim WeightRoundingDbGate = new(1, 1);
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
private ICustomerExportQualificationRepository ExportQualificationRepository =>
LazyServiceProvider.GetRequiredService<ICustomerExportQualificationRepository>();
@ -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);
}
/// <summary>
/// 由已查到的发布记录组装绑定;已发布版本命中进程内 MemoryCache。
/// </summary>
private async Task<ProductSnapshotBinding> 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<ProductSnapshotBinding> 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<WeightRoundingInfo> ranges,
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
int weightRoundingId,
IReadOnlyList<WeightRoundingInfo> 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
}
}
/// <summary>
/// 优先用快照进位;仅失败时回读 DB(串行门闩,兼容查价并行)。
/// </summary>
private async Task<decimal> ApplyRoundingPreferSnapshotAsync(
decimal rawWeight,
IReadOnlyList<WeightRoundingInfo> 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)

View File

@ -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<int, ProductSnapshotBinding>(orderedProducts.Count);
var bindingErrorByProductId = new Dictionary<int, string>();
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<PriceCheckWorkItem>(
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<OrderPriceCheckOutput>();
var failureBag = new ConcurrentBag<OrderPriceCheckFailureOutput>();
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(

View File

@ -77,4 +77,13 @@ public interface ISalesProductRepository : IRepository<SalesProduct>
string operatorType,
DateTime bindTime,
CancellationToken cancellationToken = default);
/// <summary>
/// 批量取各产品在 bindTime 时点生效的最新 Active 发布记录(含 Content)。
/// </summary>
Task<Dictionary<int, SalesProductPublishRecord>> GetEffectivePublishRecordsAsync(
IReadOnlyCollection<int> salesProductIds,
string operatorType,
DateTime bindTime,
CancellationToken cancellationToken = default);
}

View File

@ -288,6 +288,54 @@ public class SalesProductRepository : OmsSqlSugarRepository<SalesProduct>, ISale
return records.FirstOrDefault();
}
public async Task<Dictionary<int, SalesProductPublishRecord>> GetEffectivePublishRecordsAsync(
IReadOnlyCollection<int> salesProductIds,
string operatorType,
DateTime bindTime,
CancellationToken cancellationToken = default)
{
var ids = salesProductIds.Where(x => x > 0).Distinct().ToArray();
if (ids.Length == 0)
{
return new Dictionary<int, SalesProductPublishRecord>();
}
var db = await GetDbContextAsync(cancellationToken);
// 先只取头字段,避免把历史版本的 Content 一并拉回
var headers = await db.Queryable<SalesProductPublishRecord>()
.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<int, SalesProductPublishRecord>();
}
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<SalesProductPublishRecord>()
.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,