微信登录和创建应收和应付费用

This commit is contained in:
zzh 2026-06-24 17:00:35 +08:00
parent c1219e0238
commit 1c5a38ad73
50 changed files with 3384 additions and 9580 deletions

5
db.sql
View File

@ -60,6 +60,10 @@ CREATE TABLE `t_order` (
`ChannelCode` VARCHAR(128) NULL COMMENT '渠道编码',
`ConsigneeCountry` VARCHAR(8) NULL COMMENT '国家二字码',
`CargoAttribute` VARCHAR(32) NULL COMMENT '货物属性:general/electronics/sensitive',
`ExportCustomsMode` VARCHAR(32) NULL COMMENT '出口报关方式',
`PredeclareFailReason` VARCHAR(512) NULL COMMENT '预报失败原因',
`SalesProductRulesVersion` VARCHAR(64) NULL COMMENT '销售产品规则快照版本',
`SalesProductPriceVersion` VARCHAR(64) NULL COMMENT '销售产品价格快照版本',
`OrderStatus` VARCHAR(32) NOT NULL COMMENT '订单状态',
`WarehouseId` INT NULL COMMENT '仓库ID',
`CustomerId` INT NULL COMMENT '客户ID',
@ -280,6 +284,7 @@ CREATE TABLE `t_order_fee` (
`DownstreamProvider` VARCHAR(128) NULL COMMENT '下游服务商',
`PriceNo` VARCHAR(64) NULL COMMENT '价格编号',
`PriceEffectiveTime` DATETIME NULL COMMENT '价格编号生效时间',
`ChargingStage` VARCHAR(16) NULL COMMENT '计费阶段:forecast/inbound/outbound',
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

11
sql/parcel_billing.sql Normal file
View File

@ -0,0 +1,11 @@
-- 小包预报计费:订单版本字段 + 费用计费阶段
-- 若列已存在可跳过对应语句
ALTER TABLE `t_order`
ADD COLUMN IF NOT EXISTS `ExportCustomsMode` VARCHAR(32) NULL COMMENT '出口报关方式' AFTER `CargoAttribute`,
ADD COLUMN IF NOT EXISTS `PredeclareFailReason` VARCHAR(512) NULL COMMENT '预报失败原因' AFTER `IsRemote`,
ADD COLUMN IF NOT EXISTS `SalesProductRulesVersion` VARCHAR(64) NULL COMMENT '销售产品规则快照版本' AFTER `PredeclareFailReason`,
ADD COLUMN IF NOT EXISTS `SalesProductPriceVersion` VARCHAR(64) NULL COMMENT '销售产品价格快照版本' AFTER `SalesProductRulesVersion`;
ALTER TABLE `t_order_fee`
ADD COLUMN IF NOT EXISTS `ChargingStage` VARCHAR(16) NULL COMMENT '计费阶段:forecast/inbound/outbound' AFTER `PriceEffectiveTime`;

5
sql/wechat_login.sql Normal file
View File

@ -0,0 +1,5 @@
-- 微信登录:客户 openId 唯一索引(允许多个 NULL)
-- 执行前请确认索引不存在
CREATE UNIQUE INDEX IF NOT EXISTS uk_customer_wechat_openid
ON t_customer (WechatOpenid);

View File

@ -0,0 +1,22 @@
namespace Oms.Application.Dtos.Channels;
/// <summary>
/// 服务渠道分组列表查询入参。
/// </summary>
public class ServiceChannelGroupListInput
{
/// <summary>
/// 关键词(分组名称模糊匹配)。
/// </summary>
public string? Keyword { get; set; }
/// <summary>
/// 应用 ID。
/// </summary>
public int? AppId { get; set; }
/// <summary>
/// API 服务商 ID。
/// </summary>
public int? ApiServiceProviderId { get; set; }
}

View File

@ -0,0 +1,37 @@
namespace Oms.Application.Dtos.Channels;
/// <summary>
/// 服务渠道分组列表出参。
/// </summary>
public class ServiceChannelGroupListOutput
{
/// <summary>
/// 主键。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 分组名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 应用 ID。
/// </summary>
public int AppId { get; set; }
/// <summary>
/// API 服务商 ID。
/// </summary>
public int ApiServiceProviderId { get; set; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreateTime { get; set; }
/// <summary>
/// 更新时间。
/// </summary>
public DateTime? UpdateTime { get; set; }
}

View File

@ -40,6 +40,11 @@ public class OrderParcelCreateInput
/// </summary>
public string? CargoAttribute { get; set; }
/// <summary>
/// 出口报关方式。
/// </summary>
public string? ExportCustomsMode { get; set; }
/// <summary>
/// 仓库主键。
/// </summary>

View File

@ -64,4 +64,24 @@ public class OrderPriceCheckOutput
/// 总成本数值。
/// </summary>
public decimal TotalCostNum { get; set; }
/// <summary>
/// 产品编码。
/// </summary>
public string? ProductCode { get; set; }
/// <summary>
/// 绑定的规则快照版本。
/// </summary>
public string? RulesVersion { get; set; }
/// <summary>
/// 绑定的价格快照版本。
/// </summary>
public string? PriceVersion { get; set; }
/// <summary>
/// 是否偏远。
/// </summary>
public bool IsRemote { get; set; }
}

View File

@ -0,0 +1,41 @@
using Oms.Application.Dtos.Channels;
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// 销售产品价格发布快照(与渠道发布 JSON 同构)。
/// </summary>
public class SalesProductPricePublishSnapshotDto
{
public int SalesProductId { get; set; }
public List<SalesProductPriceListOutput> Prices { get; set; } = [];
}
/// <summary>
/// 销售产品规则发布快照(与渠道发布 JSON 同构)。
/// </summary>
public class SalesProductRulesPublishSnapshotDto
{
public SalesProductSaveInput? Product { get; set; }
public List<SalesProductAllocationProviderOutput> Providers { get; set; } = [];
}
/// <summary>
/// 绑定到订单的快照版本信息。
/// </summary>
public class ProductSnapshotBinding
{
public string RulesVersion { get; set; } = string.Empty;
public string PriceVersion { get; set; } = string.Empty;
public DateTime RulesEffectiveTime { get; set; }
public DateTime PriceEffectiveTime { get; set; }
public SalesProductRulesPublishSnapshotDto Rules { get; set; } = new();
public SalesProductPricePublishSnapshotDto Price { get; set; } = new();
}

View File

@ -22,4 +22,8 @@
<PackageReference Include="MiniExcel" Version="1.43.1" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Oms.Tests" />
</ItemGroup>
</Project>

View File

@ -276,6 +276,19 @@ public partial class ServiceChannelService
entity.IsActive = input.IsActive;
}
private static ServiceChannelGroupListOutput MapGroupListOutput(ServiceChannelGroup entity)
{
return new ServiceChannelGroupListOutput
{
Id = entity.Id,
Name = entity.Name,
AppId = entity.AppId,
ApiServiceProviderId = entity.ApiServiceProviderId,
CreateTime = entity.CreateTime,
UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime)
};
}
private static ServiceChannelListOutput MapListOutput(ServiceChannel entity, IReadOnlyDictionary<int, string> groupMap)
{
return new ServiceChannelListOutput

View File

@ -125,6 +125,27 @@ public partial class ServiceChannelService : ApplicationService
await ServiceChannelRepository.DeleteAsync(entity);
}
/// <summary>
/// 查询服务渠道分组列表。
/// </summary>
/// <param name="input">入参:分组名称、应用 ID、API 服务商 ID 等筛选条件。</param>
/// <returns>服务渠道分组列表,供下拉与左侧树筛选使用。</returns>
/// <remarks>
/// 核心业务逻辑:
/// 1. 按 keyword、appId、apiServiceProviderId 构造筛选条件。
/// 2. 查询未删除分组并按 id 倒序返回。
/// 3. 无数据时返回空数组。
/// </remarks>
[HttpGet("/channel/service-channel/group/list")]
public async Task<List<ServiceChannelGroupListOutput>> GetGroupListAsync([FromQuery] ServiceChannelGroupListInput input)
{
var predicate = BuildGroupListPredicate(input);
var entities = await ServiceChannelGroupRepository.GetListAsync(
predicate,
new Dictionary<string, bool> { [nameof(ServiceChannelGroup.Id)] = false });
return entities.Select(MapGroupListOutput).ToList();
}
/// <summary>
/// 新增服务渠道分组。
/// </summary>
@ -465,4 +486,17 @@ public partial class ServiceChannelService : ApplicationService
(string.IsNullOrWhiteSpace(keyword) || entity.ChannelCode.Contains(keyword) || entity.ChannelName.Contains(keyword)) &&
(!hasIsActiveFilter || entity.IsActive == isActiveValue);
}
private static Expression<Func<ServiceChannelGroup, bool>> BuildGroupListPredicate(ServiceChannelGroupListInput input)
{
var keyword = input.Keyword?.Trim();
var hasAppId = input.AppId.HasValue;
var appId = input.AppId.GetValueOrDefault();
var hasApiServiceProviderId = input.ApiServiceProviderId.HasValue;
var apiServiceProviderId = input.ApiServiceProviderId.GetValueOrDefault();
return entity =>
(string.IsNullOrWhiteSpace(keyword) || entity.Name.Contains(keyword)) &&
(!hasAppId || entity.AppId == appId) &&
(!hasApiServiceProviderId || entity.ApiServiceProviderId == apiServiceProviderId);
}
}

View File

@ -111,41 +111,6 @@ public partial class OrderService
}).ToList();
}
private async Task<List<OrderPriceCheckOutput>> BuildPriceCheckOutputsAsync(OrderPriceCheckInput input)
{
var products = await SalesProductRepository.GetListAsync(
x => x.IsActive == Oms.Domain.Shared.Enums.EnableStatus.Enabled &&
(string.IsNullOrWhiteSpace(input.ShippingMethod) || x.ProductName.Contains(input.ShippingMethod) || x.ProductCode.Contains(input.ShippingMethod)));
var warehouses = await WarehouseAddressRepository.GetListAsync();
var warehouse = warehouses.OrderBy(x => x.Id).FirstOrDefault();
var volumetricWeight = input.Length.HasValue && input.Width.HasValue && input.Height.HasValue
? Math.Round((input.Length.Value * input.Width.Value * input.Height.Value) / 6000M, 3)
: 0;
var chargeableWeight = Math.Max(input.CargoWeight ?? 0, volumetricWeight);
return products.Select(product =>
{
var freight = Math.Round(chargeableWeight * 62M, 2);
var otherFees = 5M;
var totalCost = freight + otherFees;
return new OrderPriceCheckOutput
{
Id = product.Id,
ShippingMethod = product.ProductName,
Warehouse = warehouse?.WarehouseName,
DeliveryTime = $"{product.MinTimeliness ?? 0}-{product.MaxTimeliness ?? 0}",
VolumetricWeight = volumetricWeight,
ChargeableWeight = chargeableWeight,
CostFormula = $"(62*{chargeableWeight})+{otherFees}",
Freight = freight,
OtherFees = otherFees,
TotalCost = totalCost,
MaxDeliveryDays = product.MaxTimeliness,
TotalCostNum = totalCost
};
}).ToList();
}
private async Task<OmsPageResult<OrderTemplateOutput>> GetTemplateOutputsAsync(OrderTemplateListInput input)
{
var page = OmsDocHelper.NormalizePage(input.Page);
@ -394,177 +359,6 @@ public partial class OrderService
};
}
private async Task<OrderParcelCreateOutput> CreateParcelCoreAsync(OrderParcelCreateInput input)
{
if (!string.IsNullOrWhiteSpace(input.RequestId))
{
var existed = await OrderRepository.GetByRequestIdAsync(input.RequestId);
if (existed != null)
{
return new OrderParcelCreateOutput
{
ClientNo = existed.ClientNo,
ServiceNo = existed.ServiceNo,
TrackNo = existed.TrackNo,
LabelUrl = await CreateOrderPrintFileAsync((await MapOrderOutputsAsync([existed])).First())
};
}
}
var serviceNo = input.ServiceNo?.Trim();
if (string.IsNullOrWhiteSpace(serviceNo))
{
throw new BusinessException("运单号不能为空");
}
if (await OrderRepository.HasServiceNoAsync(serviceNo))
{
throw new BusinessException("运单号已存在");
}
var order = await OrderRepository.InsertAsync(BuildOrderEntity(input, serviceNo));
await SaveOrderChildrenAsync(order, input);
return new OrderParcelCreateOutput
{
ClientNo = order.ClientNo,
ServiceNo = order.ServiceNo,
TrackNo = order.TrackNo,
LabelUrl = await CreateOrderPrintFileAsync((await MapOrderOutputsAsync([order])).First())
};
}
private Order BuildOrderEntity(OrderParcelCreateInput input, string serviceNo)
{
return new Order
{
RequestId = input.RequestId,
ClientNo = input.ClientNo,
TrackNo = input.TrackNo,
ServiceNo = serviceNo,
ProductCode = input.ProductCode,
ChannelCode = input.ChannelCode,
CargoAttribute = input.CargoAttribute ?? CargoAttributeCodes.General,
WarehouseId = input.WarehouseId,
CustomerId = input.CustomerId,
IsCod = input.IsCod ? (byte)1 : (byte)0,
CodCurrency = input.CodCurrency,
CodSum = input.CodSum,
DeclaredCurrency = input.DeclaredCurrency,
DeclaredSum = input.DeclaredSum,
Quantity = input.Quantity,
IsIntercept = 0,
IsRemote = 0,
OrderStatus = OrderStatusCodes.PendingPayment
};
}
private async Task SaveOrderChildrenAsync(Order order, OrderParcelCreateInput input)
{
var now = DateTime.Now;
if (input.Sender != null)
{
var sender = await OrderRepository.InsertSenderAsync(new OrderSender
{
OrderId = order.Id,
SenderName = input.Sender.SenderName,
Company = input.Sender.Company,
Country = input.Sender.CountryCode,
Province = input.Sender.Province,
City = input.Sender.City,
ZipCode = input.Sender.ZipCode,
StreetAddress1 = input.Sender.StreetAddress1,
StreetAddress2 = input.Sender.StreetAddress2,
StreetAddress3 = input.Sender.StreetAddress3,
HouseNumber = input.Sender.HouseNumber,
Phone = input.Sender.Phone,
TelePhone = input.Sender.TelePhone,
Email = input.Sender.Email,
IsDefault = 0
});
order.SenderId = sender.Id;
}
if (input.Receiver != null)
{
var recipient = await OrderRepository.InsertRecipientAsync(new OrderRecipient
{
OrderId = order.Id,
RecipientsName = input.Receiver.RecipientsName,
CompanyName = input.Receiver.CompanyName,
Country = input.Receiver.CountryCode,
Phone = input.Receiver.Phone,
TelePhone = input.Receiver.TelePhone,
ZipCode = input.Receiver.ZipCode,
Province = input.Receiver.Province,
City = input.Receiver.City,
StreetNumber = input.Receiver.StreetNumber,
StreetAddress1 = input.Receiver.StreetAddress1,
StreetAddress2 = input.Receiver.StreetAddress2,
StreetAddress3 = input.Receiver.StreetAddress3,
Email = input.Receiver.Email,
TaxType = input.Receiver.TaxType,
TaxNo = input.Receiver.TaxNo,
CertificateType = input.Receiver.CertificateType,
CertificateNo = input.Receiver.CertificateNo,
Eori = input.Receiver.Eori,
Vat = input.Receiver.Vat
});
order.RecipientId = recipient.Id;
}
if (input.Items.Count > 0)
{
await OrderRepository.InsertDeclarationsAsync(input.Items.Select(x => new OrderDeclaration
{
OrderId = order.Id,
CnName = x.CnName,
EnName = x.EnName,
Sku = x.Sku,
Unit = x.Unit,
UnitPrice = x.UnitPrice,
UnitWeight = x.UnitWeight,
Quantity = x.Quantity,
Amount = x.Amount,
Weight = x.Weight,
HsCode = x.ExHsCode ?? x.ImHsCode,
ImHsCode = x.ImHsCode,
ExHsCode = x.ExHsCode,
SkuImage = x.SkuImage,
Category = x.Category,
Brand = x.Brand,
BrandType = x.BrandType,
IsMagnetic = x.IsMagnetic ? (byte)1 : (byte)0,
Distribution = x.Distribution,
Material = x.Material,
EnMaterial = x.EnMaterial,
Purpose = x.Purpose,
Attributes = x.Attributes,
OriginCountry = x.OriginCountry,
SalesLink = x.SalesLink,
Model = x.Model,
Specifications = x.Specifications,
Comment = x.Comment
}));
}
await OrderRepository.InsertWeightsAsync([
new OrderWeight
{
OrderId = order.Id,
Type = OrderWeightTypeCodes.Forecast,
Length = input.Length,
Width = input.Width,
Height = input.Height,
ActualWeight = input.ActualWeight,
VolumetricWeight = input.VolumetricWeight,
ChargeableWeight = input.ChargeableWeight,
Cubic = input.Cubic
}
]);
await OrderRepository.UpdateAsync(order);
}
private async Task<string> CreateOrderPrintFileAsync(OrderOutput order)
{
var directory = Path.Combine(GetWebRootDirectory(), "uploads", "prints", "orders");

View File

@ -0,0 +1,693 @@
using Fake.Application;
using Oms.Application.Dtos.Channels;
using Oms.Application.Dtos.Orders;
using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Enums;
using Oms.Domain.CustomersAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.Shared.Enums;
using Oms.Domain.SystemAggregate.Repositories;
namespace Oms.Application.Services.Orders;
public partial class OrderService
{
private const string PriceOperatorType = "price";
private const string RulesOperatorType = "rules";
private const int ParcelBatchMaxCount = 2000;
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
private ICountryRepository CountryRepository => LazyServiceProvider.GetRequiredService<ICountryRepository>();
private IWeightRuleRepository WeightRuleRepository => LazyServiceProvider.GetRequiredService<IWeightRuleRepository>();
private IWeightRoundingRepository WeightRoundingRepository => LazyServiceProvider.GetRequiredService<IWeightRoundingRepository>();
private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService<IKpiAreaRepository>();
private async Task<OrderParcelCreateOutput> CreateParcelCoreAsync(OrderParcelCreateInput input)
{
if (!string.IsNullOrWhiteSpace(input.RequestId))
{
var existed = await OrderRepository.GetByRequestIdAsync(input.RequestId);
if (existed != null)
{
return await BuildParcelCreateOutputAsync(existed);
}
}
var bindTime = DateTime.Now;
await ValidateParcelCreateInputAsync(input);
var product = await SalesProductRepository.GetByProductCodeAsync(input.ProductCode!.Trim());
if (product == null || product.IsActive != EnableStatus.Enabled)
{
throw new BusinessException("销售产品不存在或未启用");
}
var snapshotBinding = await ResolveSnapshotBindingAsync(product, bindTime);
var country = await CountryRepository.FirstOrDefaultAsync(x => x.Id == input.Receiver!.CountryCode);
if (country == null)
{
throw new BusinessException("收件国家不存在");
}
if (await IsBlacklistHitAsync(
product,
country.CountryCode,
input.Receiver?.Province,
input.Receiver?.City,
input.Receiver?.ZipCode))
{
throw new BusinessException("收件地址命中黑名单区域");
}
var serviceNo = await ResolveServiceNoAsync(input.ServiceNo);
var order = BuildDraftOrderEntity(input, product, country.CountryCode, snapshotBinding, serviceNo);
order = await OrderRepository.InsertAsync(order);
var weights = BuildInitialWeightRows(order.Id, input);
await SaveParcelChildrenAsync(order, input, weights);
try
{
order.IsRemote = await IsRemoteHitAsync(
product,
country.CountryCode,
input.Receiver?.Province,
input.Receiver?.City,
input.Receiver?.ZipCode) ? (byte)1 : (byte)0;
var forecast = weights.First(x => x.Type == OrderWeightTypeCodes.Forecast);
await ApplyChargeableWeightAsync(product, forecast);
SyncWeightRows(weights, forecast);
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
var zoneName = await MatchPriceZoneAsync(
product,
country.CountryCode,
input.Receiver?.Province,
input.Receiver?.City,
input.Receiver?.ZipCode);
if (string.IsNullOrWhiteSpace(zoneName))
{
throw new BusinessException("价格分区未匹配(规则快照)");
}
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId);
var fees = ParcelBillingCalculator.CalculateForecastReceivableFees(
snapshotBinding.Price,
snapshotBinding.PriceVersion,
snapshotBinding.PriceEffectiveTime,
forecast.ChargeableWeight ?? 0,
zoneName,
order.CargoAttribute ?? CargoAttributeCodes.General,
customer?.CustomerGroup);
if (fees.Count == 0 && ParcelBillingCalculator.HasEnabledForecastPriceItems(snapshotBinding.Price))
{
throw new BusinessException("产品卖价快照未配置或未匹配到有效价格");
}
if (fees.Count > 0)
{
await OrderRepository.UpsertFeesAsync(order.Id, fees);
}
order.OrderStatus = OrderStatusCodes.Predeclared;
order.PredeclareFailReason = string.Empty;
order.IsRemote = order.IsRemote;
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
return await BuildParcelCreateOutputAsync(order);
}
catch (BusinessException ex)
{
await MarkParcelDraftFailedAsync(order, ex.Message);
throw;
}
catch (Exception ex)
{
await MarkParcelDraftFailedAsync(order, ex.Message);
throw new BusinessException(ex.Message);
}
}
private async Task ValidateParcelCreateInputAsync(OrderParcelCreateInput input)
{
if (string.IsNullOrWhiteSpace(input.ProductCode))
{
throw new BusinessException("产品代码不能为空");
}
if (!input.WarehouseId.HasValue || input.WarehouseId <= 0)
{
throw new BusinessException("仓库ID不能为空");
}
if (!input.CustomerId.HasValue || input.CustomerId <= 0)
{
throw new BusinessException("客户ID不能为空");
}
if (!input.Length.HasValue || !input.Width.HasValue || !input.Height.HasValue || !input.ActualWeight.HasValue
|| input.Length <= 0 || input.Width <= 0 || input.Height <= 0 || input.ActualWeight <= 0)
{
throw new BusinessException("件重尺必须大于0");
}
if (string.IsNullOrWhiteSpace(input.CargoAttribute)
|| input.CargoAttribute is not (CargoAttributeCodes.General or CargoAttributeCodes.Electronics or CargoAttributeCodes.Sensitive))
{
throw new BusinessException("货物属性不合法");
}
if (!string.IsNullOrWhiteSpace(input.ExportCustomsMode)
&& !ExportCustomsModeCodes.All.Contains(input.ExportCustomsMode))
{
throw new BusinessException("出口报关方式不合法");
}
if (string.IsNullOrWhiteSpace(input.DeclaredCurrency))
{
throw new BusinessException("申报币种不能为空");
}
if (!input.Quantity.HasValue || input.Quantity <= 0)
{
throw new BusinessException("包裹数量不能为空");
}
if (input.Receiver == null
|| string.IsNullOrWhiteSpace(input.Receiver.RecipientsName)
|| !input.Receiver.CountryCode.HasValue
|| string.IsNullOrWhiteSpace(input.Receiver.ZipCode)
|| string.IsNullOrWhiteSpace(input.Receiver.StreetAddress1))
{
throw new BusinessException("收件人信息不完整");
}
if (input.Sender == null || string.IsNullOrWhiteSpace(input.Sender.SenderName))
{
throw new BusinessException("发件人姓名不能为空");
}
if (input.Items.Count == 0)
{
throw new BusinessException("申报明细不能为空");
}
foreach (var item in input.Items)
{
if (string.IsNullOrWhiteSpace(item.EnName)
|| !item.UnitPrice.HasValue
|| !item.UnitWeight.HasValue
|| !item.Quantity.HasValue
|| item.Quantity <= 0)
{
throw new BusinessException("申报明细必填字段不完整");
}
}
var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == input.WarehouseId.Value);
if (warehouse == null)
{
throw new BusinessException("仓库不存在");
}
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == input.CustomerId.Value);
if (customer == null || customer.Status != CustomerStatus.Normal)
{
throw new BusinessException("客户不存在或状态不可用");
}
}
private async Task<ProductSnapshotBinding> ResolveSnapshotBindingAsync(SalesProduct product, DateTime bindTime)
{
var rulesRecord = await SalesProductRepository.GetEffectivePublishRecordAsync(product.Id, RulesOperatorType, bindTime);
var priceRecord = await SalesProductRepository.GetEffectivePublishRecordAsync(product.Id, PriceOperatorType, bindTime);
SalesProductRulesPublishSnapshotDto rulesSnapshot;
if (rulesRecord == null)
{
rulesSnapshot = await BuildLiveRulesSnapshotAsync(product);
}
else
{
rulesSnapshot = OmsDocHelper.DeserializeJson<SalesProductRulesPublishSnapshotDto>(rulesRecord.Content)
?? throw new BusinessException("未命中规则快照版本(内存/Redis/DB)");
}
SalesProductPricePublishSnapshotDto priceSnapshot;
if (priceRecord == null)
{
priceSnapshot = await BuildLivePriceSnapshotAsync(product);
if (priceSnapshot.Prices.Count == 0)
{
throw new BusinessException("未命中价格快照版本(内存/Redis/DB)");
}
}
else
{
priceSnapshot = OmsDocHelper.DeserializeJson<SalesProductPricePublishSnapshotDto>(priceRecord.Content)
?? throw new BusinessException("未命中价格快照版本(内存/Redis/DB)");
if (priceSnapshot.Prices.Count == 0)
{
throw new BusinessException("未命中价格快照版本(内存/Redis/DB)");
}
}
if (rulesRecord == null && !product.WeightRuleId.HasValue)
{
throw new BusinessException("未命中规则快照版本(内存/Redis/DB)");
}
return new ProductSnapshotBinding
{
RulesVersion = rulesRecord?.Version ?? $"LIVE-{product.Id}",
PriceVersion = priceRecord?.Version ?? $"LIVE-{product.Id}",
RulesEffectiveTime = rulesRecord?.PublishTime ?? bindTime,
PriceEffectiveTime = priceRecord?.PublishTime ?? bindTime,
Rules = rulesSnapshot,
Price = priceSnapshot
};
}
private async Task<SalesProductRulesPublishSnapshotDto> BuildLiveRulesSnapshotAsync(SalesProduct product)
{
return new SalesProductRulesPublishSnapshotDto
{
Product = new SalesProductSaveInput
{
Id = product.Id,
ProductCode = product.ProductCode,
ProductName = product.ProductName,
BlacklistAreaId = product.BlacklistAreaId,
RemoteAreaId = product.RemoteAreaId,
PriceAreaId = product.PriceAreaId,
WeightRuleId = product.WeightRuleId,
WeightRoundingId = product.WeightRoundingId,
IsActive = product.IsActive,
Scheme = product.Scheme
}
};
}
private async Task<SalesProductPricePublishSnapshotDto> BuildLivePriceSnapshotAsync(SalesProduct product)
{
var prices = await SalesProductRepository.GetPricesAsync(product.Id);
var priceIds = prices.Select(x => x.Id).ToArray();
var factorMap = (await SalesProductRepository.GetPriceFactorsAsync(priceIds))
.GroupBy(x => x.PriceId)
.ToDictionary(x => x.Key, x => x.OrderBy(item => item.SerialNumber).ToList());
var detailMap = (await SalesProductRepository.GetPriceDetailsAsync(priceIds))
.GroupBy(x => x.PriceId)
.ToDictionary(x => x.Key, x => x.OrderBy(item => item.Id).ToList());
return new SalesProductPricePublishSnapshotDto
{
SalesProductId = product.Id,
Prices = prices.Select(price => new SalesProductPriceListOutput
{
Id = price.Id,
SalesProductId = price.SalesProductId,
SerialNumber = price.SerialNumber,
Status = (byte)price.Status,
PriceName = price.PriceName,
CostItem = price.CostItem,
CostUnit = price.CostUnit,
Currency = price.Currency,
ChargingTime = price.ChargingTime,
ChargingCondition = price.ChargingCondition,
Factors = factorMap.TryGetValue(price.Id, out var factors)
? factors.Select(MapPriceFactorOutput).ToList()
: [],
Details = detailMap.TryGetValue(price.Id, out var details)
? details.Select(MapPriceDetailOutput).ToList()
: []
}).ToList()
};
}
private static ChannelPriceFactorOutput MapPriceFactorOutput(SalesProductPriceFactor entity)
{
return new ChannelPriceFactorOutput
{
Id = entity.Id,
PriceId = entity.PriceId,
SerialNumber = entity.SerialNumber,
Factor = entity.Factor,
FactorValues = OmsDocHelper.DeserializeJson<List<string>>(entity.FactorValues) ?? []
};
}
private static ChannelPriceDetailOutput MapPriceDetailOutput(SalesProductPriceDetail entity)
{
return new ChannelPriceDetailOutput
{
Id = entity.Id,
PriceId = entity.PriceId,
FactorValuesJson = OmsDocHelper.DeserializeJsonObject(entity.FactorValuesJson),
CalculationFormula = entity.CalculationFormula,
CalculatedValue = entity.CalculatedValue,
CustomerGroupValues = OmsDocHelper.DeserializeJsonObject(entity.CustomerGroupValues)
};
}
private async Task<bool> IsBlacklistHitAsync(
SalesProduct product,
string countryCode,
string? province,
string? city,
string? zipCode)
{
if (!product.BlacklistAreaId.HasValue || product.BlacklistAreaId <= 0)
{
return false;
}
var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(product.BlacklistAreaId.Value);
return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched(
rule,
countryCode,
province,
city,
zipCode));
}
private async Task<bool> IsRemoteHitAsync(
SalesProduct product,
string countryCode,
string? province,
string? city,
string? zipCode)
{
if (!product.RemoteAreaId.HasValue || product.RemoteAreaId <= 0)
{
return false;
}
var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(product.RemoteAreaId.Value);
return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched(
rule,
countryCode,
province,
city,
zipCode));
}
private async Task<string?> MatchPriceZoneAsync(
SalesProduct product,
string countryCode,
string? province,
string? city,
string? zipCode)
{
if (!product.PriceAreaId.HasValue || product.PriceAreaId <= 0)
{
return "默认分区";
}
var area = await KpiAreaRepository.FirstOrDefaultAsync(x => x.Id == product.PriceAreaId.Value);
var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(product.PriceAreaId.Value);
var matched = rules.FirstOrDefault(rule => ParcelBillingCalculator.IsAreaMatched(
rule,
countryCode,
province,
city,
zipCode));
return ParcelBillingCalculator.ResolveZoneName(matched, area);
}
private async Task ApplyChargeableWeightAsync(SalesProduct product, OrderWeight forecast)
{
try
{
if (!product.WeightRuleId.HasValue || product.WeightRuleId <= 0)
{
throw new BusinessException("未配置计重规则");
}
var weightRule = await WeightRuleRepository.FirstOrDefaultAsync(x => x.Id == product.WeightRuleId.Value);
if (weightRule == null)
{
throw new BusinessException("未配置计重规则");
}
var ruleInfos = await WeightRuleRepository.GetAllInfosByRuleIdAsync(product.WeightRuleId.Value);
var (volumetricWeight, rawWeight) = ParcelBillingCalculator.CalculateRawBillableWeight(forecast, ruleInfos);
forecast.VolumetricWeight = volumetricWeight;
if (!product.WeightRoundingId.HasValue || product.WeightRoundingId <= 0)
{
throw new BusinessException("未配置计重进位");
}
var rounding = await WeightRoundingRepository.FirstOrDefaultAsync(x => x.Id == product.WeightRoundingId.Value);
if (rounding == null)
{
throw new BusinessException("未配置计重进位");
}
var ranges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(product.WeightRoundingId.Value);
forecast.ChargeableWeight = ParcelBillingCalculator.ApplyRounding(rawWeight, ranges);
}
catch (BusinessException)
{
throw;
}
catch (InvalidOperationException ex)
{
throw new BusinessException(ex.Message);
}
}
private static List<OrderWeight> BuildInitialWeightRows(int orderId, OrderParcelCreateInput input)
{
var cubic = ParcelBillingCalculator.CalculateCubic(input.Length!.Value, input.Width!.Value, input.Height!.Value);
var forecast = new OrderWeight
{
OrderId = orderId,
Type = OrderWeightTypeCodes.Forecast,
Length = input.Length,
Width = input.Width,
Height = input.Height,
ActualWeight = input.ActualWeight,
VolumetricWeight = input.VolumetricWeight ?? 0,
ChargeableWeight = input.ChargeableWeight ?? input.ActualWeight,
Cubic = input.Cubic ?? cubic
};
return
[
forecast,
CloneWeightRow(orderId, OrderWeightTypeCodes.Receive, forecast),
CloneWeightRow(orderId, OrderWeightTypeCodes.Ship, forecast),
CloneWeightRow(orderId, OrderWeightTypeCodes.Downstream, forecast)
];
}
private static OrderWeight CloneWeightRow(int orderId, string type, OrderWeight source) =>
new()
{
OrderId = orderId,
Type = type,
Length = source.Length,
Width = source.Width,
Height = source.Height,
ActualWeight = source.ActualWeight,
VolumetricWeight = source.VolumetricWeight,
ChargeableWeight = source.ChargeableWeight,
Cubic = source.Cubic
};
private static void SyncWeightRows(List<OrderWeight> weights, OrderWeight forecast)
{
foreach (var weight in weights)
{
weight.Length = forecast.Length;
weight.Width = forecast.Width;
weight.Height = forecast.Height;
weight.ActualWeight = forecast.ActualWeight;
weight.VolumetricWeight = forecast.VolumetricWeight;
weight.ChargeableWeight = forecast.ChargeableWeight;
weight.Cubic = forecast.Cubic;
}
}
private static Order BuildDraftOrderEntity(
OrderParcelCreateInput input,
SalesProduct product,
string consigneeCountry,
ProductSnapshotBinding snapshotBinding,
string serviceNo)
{
return new Order
{
RequestId = input.RequestId,
ClientNo = input.ClientNo,
TrackNo = input.TrackNo,
ServiceNo = serviceNo,
ProductCode = input.ProductCode,
ChannelCode = input.ChannelCode ?? product.ProductCode,
ConsigneeCountry = consigneeCountry,
CargoAttribute = input.CargoAttribute ?? CargoAttributeCodes.General,
ExportCustomsMode = input.ExportCustomsMode,
WarehouseId = input.WarehouseId,
CustomerId = input.CustomerId,
IsCod = input.IsCod ? (byte)1 : (byte)0,
CodCurrency = input.CodCurrency,
CodSum = input.CodSum,
DeclaredCurrency = input.DeclaredCurrency,
DeclaredSum = input.DeclaredSum,
Quantity = input.Quantity,
IsIntercept = 0,
IsRemote = 0,
PredeclareFailReason = string.Empty,
SalesProductRulesVersion = snapshotBinding.RulesVersion,
SalesProductPriceVersion = snapshotBinding.PriceVersion,
OrderStatus = OrderStatusCodes.Draft
};
}
private async Task SaveParcelChildrenAsync(Order order, OrderParcelCreateInput input, List<OrderWeight> weights)
{
if (input.Sender != null)
{
var sender = await OrderRepository.InsertSenderAsync(new OrderSender
{
OrderId = order.Id,
SenderName = input.Sender.SenderName,
Company = input.Sender.Company,
Country = input.Sender.CountryCode,
Province = input.Sender.Province,
City = input.Sender.City,
ZipCode = input.Sender.ZipCode,
StreetAddress1 = input.Sender.StreetAddress1,
StreetAddress2 = input.Sender.StreetAddress2,
StreetAddress3 = input.Sender.StreetAddress3,
HouseNumber = input.Sender.HouseNumber,
Phone = input.Sender.Phone,
TelePhone = input.Sender.TelePhone,
Email = input.Sender.Email,
IsDefault = 0
});
order.SenderId = sender.Id;
}
if (input.Receiver != null)
{
var recipient = await OrderRepository.InsertRecipientAsync(new OrderRecipient
{
OrderId = order.Id,
RecipientsName = input.Receiver.RecipientsName,
CompanyName = input.Receiver.CompanyName,
Country = input.Receiver.CountryCode,
Phone = input.Receiver.Phone,
TelePhone = input.Receiver.TelePhone,
ZipCode = input.Receiver.ZipCode,
Province = input.Receiver.Province,
City = input.Receiver.City,
StreetNumber = input.Receiver.StreetNumber,
StreetAddress1 = input.Receiver.StreetAddress1,
StreetAddress2 = input.Receiver.StreetAddress2,
StreetAddress3 = input.Receiver.StreetAddress3,
Email = input.Receiver.Email,
TaxType = input.Receiver.TaxType,
TaxNo = input.Receiver.TaxNo,
CertificateType = input.Receiver.CertificateType,
CertificateNo = input.Receiver.CertificateNo,
Eori = input.Receiver.Eori,
Vat = input.Receiver.Vat
});
order.RecipientId = recipient.Id;
}
if (input.Items.Count > 0)
{
await OrderRepository.InsertDeclarationsAsync(input.Items.Select(x => new OrderDeclaration
{
OrderId = order.Id,
CnName = x.CnName,
EnName = x.EnName,
Sku = x.Sku,
Unit = x.Unit,
UnitPrice = x.UnitPrice,
UnitWeight = x.UnitWeight,
Quantity = x.Quantity,
Amount = x.Amount,
Weight = x.Weight,
HsCode = x.ExHsCode ?? x.ImHsCode,
ImHsCode = x.ImHsCode,
ExHsCode = x.ExHsCode,
SkuImage = x.SkuImage,
Category = x.Category,
Brand = x.Brand,
BrandType = x.BrandType,
IsMagnetic = x.IsMagnetic ? (byte)1 : (byte)0,
Distribution = x.Distribution,
Material = x.Material,
EnMaterial = x.EnMaterial,
Purpose = x.Purpose,
Attributes = x.Attributes,
OriginCountry = x.OriginCountry,
SalesLink = x.SalesLink,
Model = x.Model,
Specifications = x.Specifications,
Comment = x.Comment
}));
}
await OrderRepository.InsertWeightsAsync(weights);
await OrderRepository.UpdateAsync(order);
}
private async Task<string> ResolveServiceNoAsync(string? serviceNo)
{
var normalized = serviceNo?.Trim();
if (!string.IsNullOrWhiteSpace(normalized))
{
if (await OrderRepository.HasServiceNoAsync(normalized))
{
throw new BusinessException("运单号已存在");
}
return normalized;
}
for (var i = 0; i < 5; i++)
{
var generated = $"LP{DateTime.Now:yyyyMMddHHmmss}{Random.Shared.Next(1000, 9999)}";
if (!await OrderRepository.HasServiceNoAsync(generated))
{
return generated;
}
}
throw new BusinessException("运单号生成失败");
}
private async Task MarkParcelDraftFailedAsync(Order order, string reason)
{
order.OrderStatus = OrderStatusCodes.Draft;
order.PredeclareFailReason = reason;
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
}
private async Task<OrderParcelCreateOutput> BuildParcelCreateOutputAsync(Order order)
{
var mapped = (await MapOrderOutputsAsync([order])).First();
return new OrderParcelCreateOutput
{
ClientNo = order.ClientNo,
ServiceNo = order.ServiceNo,
TrackNo = order.TrackNo,
LabelUrl = order.OrderStatus == OrderStatusCodes.Predeclared
? await CreateOrderPrintFileAsync(mapped)
: string.Empty
};
}
}

View File

@ -0,0 +1,206 @@
using Fake.Application;
using Oms.Application.Dtos.Orders;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.CustomersAggregate.Enums;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.Shared.Enums;
namespace Oms.Application.Services.Orders;
public partial class OrderService
{
private async Task<List<OrderPriceCheckOutput>> BuildPriceCheckOutputsAsync(OrderPriceCheckInput input)
{
ValidatePriceCheckInput(input);
var bindTime = DateTime.Now;
var countryCode = await ResolveDestinationCountryCodeAsync(input.DestinationCountry);
var cargoAttribute = ParcelBillingCalculator.MapCargoTypeToAttribute(input.CargoType);
var quoteContext = new ParcelQuoteContext
{
CountryCode = countryCode,
Province = input.DestinationProvince,
PostalCode = input.PostalCode,
CargoAttribute = cargoAttribute,
Length = input.Length!.Value,
Width = input.Width!.Value,
Height = input.Height!.Value,
ActualWeight = input.CargoWeight!.Value
};
var customerGroup = await ResolveCurrentCustomerGroupAsync();
var shippingKeyword = input.ShippingMethod?.Trim();
var products = await SalesProductRepository.GetListAsync(x =>
x.IsActive == EnableStatus.Enabled &&
(string.IsNullOrWhiteSpace(shippingKeyword)
|| x.ProductName.Contains(shippingKeyword)
|| x.ProductCode.Contains(shippingKeyword)));
var warehouses = await WarehouseAddressRepository.GetListAsync();
var defaultWarehouse = warehouses.OrderBy(x => x.Id).FirstOrDefault();
var outputs = new List<OrderPriceCheckOutput>();
foreach (var product in products.OrderBy(x => x.Id))
{
var quote = await TryComputeProductQuoteAsync(product, quoteContext, customerGroup, bindTime);
if (quote == null)
{
continue;
}
var (freight, otherFees, totalCost, costFormula) = ParcelBillingCalculator.SummarizeFees(quote.Fees);
outputs.Add(new OrderPriceCheckOutput
{
Id = quote.ProductId,
ProductCode = quote.ProductCode,
ShippingMethod = quote.ProductName,
Warehouse = defaultWarehouse?.WarehouseName,
DeliveryTime = $"{product.MinTimeliness ?? 0}-{product.MaxTimeliness ?? 0}",
VolumetricWeight = quote.VolumetricWeight,
ChargeableWeight = quote.ChargeableWeight,
CostFormula = costFormula,
Freight = freight,
OtherFees = otherFees,
TotalCost = totalCost,
MaxDeliveryDays = product.MaxTimeliness,
TotalCostNum = totalCost,
RulesVersion = quote.RulesVersion,
PriceVersion = quote.PriceVersion,
IsRemote = quote.IsRemote
});
}
return outputs;
}
private static void ValidatePriceCheckInput(OrderPriceCheckInput input)
{
if (string.IsNullOrWhiteSpace(input.DestinationCountry))
{
throw new BusinessException("目的国不能为空");
}
if (!input.CargoWeight.HasValue || input.CargoWeight <= 0)
{
throw new BusinessException("货物重量必须大于0");
}
if (!input.Length.HasValue || !input.Width.HasValue || !input.Height.HasValue
|| input.Length <= 0 || input.Width <= 0 || input.Height <= 0)
{
throw new BusinessException("长宽高必须大于0");
}
}
private async Task<string> ResolveDestinationCountryCodeAsync(string destinationCountry)
{
var trimmed = destinationCountry.Trim();
var country = await CountryRepository.FirstOrDefaultAsync(x =>
x.CountryCode == trimmed || x.ChineseName == trimmed || x.EnglishName == trimmed);
return country?.CountryCode ?? trimmed.ToUpperInvariant();
}
private async Task<CustomerGroup?> ResolveCurrentCustomerGroupAsync()
{
try
{
var userId = CurrentUser.GetId<int>();
if (userId <= 0)
{
return null;
}
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.UserId == userId);
return customer?.CustomerGroup;
}
catch
{
return null;
}
}
/// <summary>
/// 对单个销售产品执行预报试算(与 parcel/create 同源,不落库)。
/// </summary>
private async Task<ParcelQuoteResult?> TryComputeProductQuoteAsync(
SalesProduct product,
ParcelQuoteContext context,
CustomerGroup? customerGroup,
DateTime bindTime)
{
try
{
if (product.IsActive != EnableStatus.Enabled)
{
return null;
}
var snapshotBinding = await ResolveSnapshotBindingAsync(product, bindTime);
if (await IsBlacklistHitAsync(
product,
context.CountryCode,
context.Province,
context.City,
context.PostalCode))
{
return null;
}
var forecast = ParcelBillingCalculator.BuildForecastWeight(context);
await ApplyChargeableWeightAsync(product, forecast);
var zoneName = await MatchPriceZoneAsync(
product,
context.CountryCode,
context.Province,
context.City,
context.PostalCode);
if (string.IsNullOrWhiteSpace(zoneName))
{
return null;
}
var fees = ParcelBillingCalculator.CalculateForecastReceivableFees(
snapshotBinding.Price,
snapshotBinding.PriceVersion,
snapshotBinding.PriceEffectiveTime,
forecast.ChargeableWeight ?? 0,
zoneName,
context.CargoAttribute,
customerGroup);
if (fees.Count == 0 && ParcelBillingCalculator.HasEnabledForecastPriceItems(snapshotBinding.Price))
{
return null;
}
var isRemote = await IsRemoteHitAsync(
product,
context.CountryCode,
context.Province,
context.City,
context.PostalCode);
return new ParcelQuoteResult
{
ProductId = product.Id,
ProductCode = product.ProductCode,
ProductName = product.ProductName,
RulesVersion = snapshotBinding.RulesVersion,
PriceVersion = snapshotBinding.PriceVersion,
VolumetricWeight = forecast.VolumetricWeight ?? 0,
ChargeableWeight = forecast.ChargeableWeight ?? 0,
ZoneName = zoneName,
IsRemote = isRemote,
Fees = fees
};
}
catch (BusinessException)
{
return null;
}
catch (InvalidOperationException)
{
return null;
}
}
}

View File

@ -354,6 +354,11 @@ public partial class OrderService : ApplicationService
throw new BusinessException("批量创建请求不能为空");
}
if (input.Count > ParcelBatchMaxCount)
{
throw new BusinessException($"单次批量创建不能超过{ParcelBatchMaxCount}条");
}
var batchNo = $"PCB{DateTime.Now:yyyyMMddHHmmssfff}";
var items = new List<OrderParcelBatchCreateItemOutput>(input.Count);
var successCount = 0;

View File

@ -0,0 +1,597 @@
using System.Globalization;
using System.Text.Json;
using System.Text.RegularExpressions;
using Oms.Application.Dtos.Channels;
using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.CustomersAggregate.Enums;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
namespace Oms.Application.Services.Orders;
/// <summary>
/// 小包预报:计重、分区匹配、卖价算价。
/// </summary>
internal static class ParcelBillingCalculator
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
public static decimal CalculateCubic(decimal length, decimal width, decimal height) =>
Math.Round(length * width * height / 1_000_000M, 6, MidpointRounding.AwayFromZero);
public static (decimal VolumetricWeight, decimal RawBillableWeight) CalculateRawBillableWeight(
OrderWeight forecast,
IReadOnlyList<WeightRuleInfo> rules)
{
var length = forecast.Length ?? 0;
var width = forecast.Width ?? 0;
var height = forecast.Height ?? 0;
var gw = forecast.ActualWeight ?? 0;
if (gw <= 0 || length <= 0 || width <= 0 || height <= 0)
{
throw new InvalidOperationException("件重尺数据不完整");
}
if (rules.Count == 0)
{
throw new InvalidOperationException("计重规则明细为空");
}
foreach (var rule in rules.OrderBy(x => x.Id))
{
if (!decimal.TryParse(rule.VolumetricFactor, NumberStyles.Any, CultureInfo.InvariantCulture, out var factor) || factor <= 0)
{
continue;
}
var vw = Math.Round(length * width * height / factor, 3, MidpointRounding.AwayFromZero);
if (!EvaluateCondition(rule.Condition, gw, vw))
{
continue;
}
var raw = string.Equals(rule.BillableWeight, "gw", StringComparison.OrdinalIgnoreCase) ? gw : vw;
return (vw, raw);
}
throw new InvalidOperationException("计重规则条件均未命中");
}
public static decimal ApplyRounding(decimal rawWeight, IReadOnlyList<WeightRoundingInfo> ranges)
{
var hit = ranges
.OrderBy(x => x.Id)
.FirstOrDefault(x => rawWeight >= x.StartValue && rawWeight <= x.EndValue);
if (hit == null)
{
throw new InvalidOperationException("计重进位区间未匹配");
}
if (!decimal.TryParse(hit.Public, NumberStyles.Any, CultureInfo.InvariantCulture, out var unit) || unit <= 0)
{
throw new InvalidOperationException("计重进位值无效");
}
var units = Math.Ceiling(rawWeight / unit);
return Math.Round(units * unit, 3, MidpointRounding.AwayFromZero);
}
public static bool IsAreaMatched(
KpiAreaInfo rule,
string countryCode,
string? province,
string? city,
string? zipCode)
{
if (!string.IsNullOrWhiteSpace(rule.CountryCode)
&& !string.Equals(rule.CountryCode, countryCode, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!string.IsNullOrWhiteSpace(rule.Province)
&& !string.Equals(rule.Province, province, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!string.IsNullOrWhiteSpace(rule.City)
&& !string.Equals(rule.City, city, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!string.IsNullOrWhiteSpace(rule.LeftPostcode) || !string.IsNullOrWhiteSpace(rule.RightPostcode))
{
if (string.IsNullOrWhiteSpace(zipCode))
{
return false;
}
if (!IsPostcodeInRange(zipCode, rule.LeftPostcode, rule.RightPostcode))
{
return false;
}
}
return true;
}
public static string? ResolveZoneName(KpiAreaInfo? matched, KpiArea? area)
{
if (matched == null)
{
return null;
}
if (!string.IsNullOrWhiteSpace(matched.Province))
{
return matched.Province;
}
if (!string.IsNullOrWhiteSpace(matched.City))
{
return matched.City;
}
if (!string.IsNullOrWhiteSpace(matched.CountryName))
{
return matched.CountryName;
}
return area?.Name;
}
public static List<OrderFee> CalculateForecastReceivableFees(
SalesProductPricePublishSnapshotDto priceSnapshot,
string priceVersion,
DateTime priceEffectiveTime,
decimal chargeableWeight,
string zoneName,
string cargoAttribute,
CustomerGroup? customerGroup)
{
var items = priceSnapshot.Prices.Select(p => new BillingPriceItem(
p.Status, p.ChargingTime, p.CostItem, p.Currency, p.Details));
return CalculateForecastFees(
items,
OrderFeeTypeCodes.Receivable,
priceVersion,
priceEffectiveTime,
chargeableWeight,
zoneName,
cargoAttribute,
customerGroup);
}
/// <summary>
/// 预报阶段应付费用(买价快照,计费重取 downstream 行,调用方传入对应重量)。
/// </summary>
public static List<OrderFee> CalculateForecastPayableFees(
IReadOnlyList<ServiceChannelPriceListOutput> prices,
string priceVersion,
DateTime priceEffectiveTime,
decimal chargeableWeight,
string zoneName,
string cargoAttribute,
CustomerGroup? customerGroup)
{
var items = prices.Select(p => new BillingPriceItem(
p.Status, p.ChargingTime, p.CostItem, p.Currency, p.Details));
return CalculateForecastFees(
items,
OrderFeeTypeCodes.Payable,
priceVersion,
priceEffectiveTime,
chargeableWeight,
zoneName,
cargoAttribute,
customerGroup);
}
public static bool HasEnabledForecastPriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) =>
priceSnapshot.Prices.Any(x => x.Status == 1 && IsForecastChargingTime(x.ChargingTime));
public static bool HasEnabledForecastPayablePriceItems(IReadOnlyList<ServiceChannelPriceListOutput> prices) =>
prices.Any(x => x.Status == 1 && IsForecastChargingTime(x.ChargingTime));
public static string NormalizeChargingTime(string? chargingTime)
{
var value = chargingTime?.Trim();
if (string.IsNullOrWhiteSpace(value))
{
return OrderFeeChargingStageCodes.Forecast;
}
return value switch
{
"预报" => OrderFeeChargingStageCodes.Forecast,
"入库" => OrderFeeChargingStageCodes.Inbound,
"出库" => OrderFeeChargingStageCodes.Outbound,
_ => value.ToLowerInvariant()
};
}
public static string GetCargoAttributeLabel(string? cargoAttribute) => cargoAttribute switch
{
CargoAttributeCodes.Electronics => "3c&带电",
CargoAttributeCodes.Sensitive => "敏感货",
_ => "普货"
};
public static string MapCargoTypeToAttribute(string? cargoType)
{
if (string.IsNullOrWhiteSpace(cargoType))
{
return CargoAttributeCodes.General;
}
var value = cargoType.Trim();
if (value.Contains("敏感", StringComparison.OrdinalIgnoreCase))
{
return CargoAttributeCodes.Sensitive;
}
if (value.Contains("带电", StringComparison.OrdinalIgnoreCase)
|| value.Contains("3c", StringComparison.OrdinalIgnoreCase)
|| value.Equals("electronics", StringComparison.OrdinalIgnoreCase))
{
return CargoAttributeCodes.Electronics;
}
if (value.Equals(CargoAttributeCodes.General, StringComparison.OrdinalIgnoreCase)
|| value.Equals(CargoAttributeCodes.Electronics, StringComparison.OrdinalIgnoreCase)
|| value.Equals(CargoAttributeCodes.Sensitive, StringComparison.OrdinalIgnoreCase))
{
return value.ToLowerInvariant();
}
return CargoAttributeCodes.General;
}
public static (decimal Freight, decimal OtherFees, decimal TotalCost, string CostFormula) SummarizeFees(
IReadOnlyList<OrderFee> fees)
{
if (fees.Count == 0)
{
return (0, 0, 0, string.Empty);
}
var freight = fees
.Where(x => x.CostItem.Equals("freight", StringComparison.OrdinalIgnoreCase)
|| x.CostItem.Contains("运费", StringComparison.OrdinalIgnoreCase))
.Sum(x => x.Amount);
if (freight <= 0)
{
freight = fees[0].Amount;
}
var total = fees.Sum(x => x.Amount);
var otherFees = Math.Max(0, total - freight);
var formula = string.Join(" + ", fees.Select(x => $"{x.CostItem}:{x.Amount:0.####}"));
return (freight, otherFees, total, formula);
}
public static OrderWeight BuildForecastWeight(ParcelQuoteContext context) =>
new()
{
Type = OrderWeightTypeCodes.Forecast,
Length = context.Length,
Width = context.Width,
Height = context.Height,
ActualWeight = context.ActualWeight,
Cubic = CalculateCubic(context.Length, context.Width, context.Height)
};
private static List<OrderFee> CalculateForecastFees(
IEnumerable<BillingPriceItem> priceItems,
string feeType,
string priceVersion,
DateTime priceEffectiveTime,
decimal chargeableWeight,
string zoneName,
string cargoAttribute,
CustomerGroup? customerGroup)
{
var fees = new List<OrderFee>();
foreach (var priceItem in priceItems.Where(x => x.Status == 1))
{
if (!IsForecastChargingTime(priceItem.ChargingTime))
{
continue;
}
var detail = MatchPriceDetail(priceItem.Details, zoneName, cargoAttribute, chargeableWeight);
if (detail == null)
{
continue;
}
var amount = CalculateDetailAmount(detail, chargeableWeight, customerGroup);
fees.Add(new OrderFee
{
Type = feeType,
CostItem = priceItem.CostItem,
Amount = amount,
Currency = priceItem.Currency,
PriceNo = priceVersion,
PriceEffectiveTime = priceEffectiveTime,
ChargingStage = OrderFeeChargingStageCodes.Forecast
});
}
return fees;
}
private readonly record struct BillingPriceItem(
byte Status,
string? ChargingTime,
string CostItem,
string Currency,
List<ChannelPriceDetailOutput> Details);
private static bool IsForecastChargingTime(string? chargingTime)
{
var normalized = NormalizeChargingTime(chargingTime);
return normalized is OrderFeeChargingStageCodes.Forecast or "";
}
private static ChannelPriceDetailOutput? MatchPriceDetail(
IReadOnlyList<ChannelPriceDetailOutput> details,
string zoneName,
string cargoAttribute,
decimal chargeableWeight)
{
var cargoLabel = GetCargoAttributeLabel(cargoAttribute);
foreach (var detail in details)
{
var factors = ParseFactorDictionary(detail.FactorValuesJson);
if (!MatchFactorValue(factors, "分区", zoneName)
&& !MatchFactorValue(factors, "价格分区", zoneName))
{
continue;
}
if (!MatchFactorValue(factors, "货物类型", cargoLabel)
&& factors.ContainsKey("货物类型"))
{
continue;
}
if (!MatchWeightRange(factors, chargeableWeight))
{
continue;
}
return detail;
}
return null;
}
private static Dictionary<string, string> ParseFactorDictionary(object? factorValuesJson)
{
if (factorValuesJson == null)
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
if (factorValuesJson is JsonElement element)
{
return element.EnumerateObject()
.ToDictionary(x => x.Name, x => x.Value.ToString(), StringComparer.OrdinalIgnoreCase);
}
var json = JsonSerializer.Serialize(factorValuesJson, JsonOptions);
return JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOptions)
?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
private static bool MatchFactorValue(Dictionary<string, string> factors, string key, string expected)
{
if (!factors.TryGetValue(key, out var actual) || string.IsNullOrWhiteSpace(actual))
{
return true;
}
return string.Equals(actual.Trim(), expected, StringComparison.OrdinalIgnoreCase);
}
private static bool MatchWeightRange(Dictionary<string, string> factors, decimal chargeableWeight)
{
if (!factors.TryGetValue("重量", out var rangeText) || string.IsNullOrWhiteSpace(rangeText))
{
return true;
}
var parts = rangeText.Split('-', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2)
{
return true;
}
if (!decimal.TryParse(parts[0], NumberStyles.Any, CultureInfo.InvariantCulture, out var start))
{
return true;
}
if (!decimal.TryParse(parts[1], NumberStyles.Any, CultureInfo.InvariantCulture, out var end))
{
return true;
}
return chargeableWeight >= start && chargeableWeight <= end;
}
private static decimal CalculateDetailAmount(
ChannelPriceDetailOutput detail,
decimal chargeableWeight,
CustomerGroup? customerGroup)
{
var formula = detail.CalculationFormula?.Trim() ?? string.Empty;
if (formula is "单价" or "固定价")
{
return ResolveCustomerGroupPrice(detail, customerGroup) ?? detail.CalculatedValue ?? 0;
}
if (formula is "续单价" or "续重价")
{
return CalculateContinuePrice(detail, chargeableWeight, customerGroup);
}
return detail.CalculatedValue ?? 0;
}
private static decimal CalculateContinuePrice(
ChannelPriceDetailOutput detail,
decimal chargeableWeight,
CustomerGroup? customerGroup)
{
var factors = ParseFactorDictionary(detail.FactorValuesJson);
var continuePricing = TryReadContinuePricing(factors, detail);
var firstWeight = continuePricing?.FirstWeight ?? 1M;
var firstPrice = continuePricing?.FirstPrice ?? detail.CalculatedValue ?? 0;
var continueUnit = continuePricing?.ContinueUnit ?? 0.5M;
var continuePrice = continuePricing?.ContinuePrice ?? firstPrice;
if (customerGroup.HasValue)
{
var groupPrice = ResolveCustomerGroupPrice(detail, customerGroup);
if (groupPrice.HasValue)
{
firstPrice = groupPrice.Value;
}
}
if (chargeableWeight <= firstWeight)
{
return Math.Round(firstPrice, 4, MidpointRounding.AwayFromZero);
}
var overflow = chargeableWeight - firstWeight;
var continueUnits = Math.Ceiling(overflow / continueUnit);
return Math.Round(firstPrice + continueUnits * continuePrice, 4, MidpointRounding.AwayFromZero);
}
private static ContinuePricingConfig? TryReadContinuePricing(
Dictionary<string, string> factors,
ChannelPriceDetailOutput detail)
{
if (factors.TryGetValue("continuePricing", out var json) && !string.IsNullOrWhiteSpace(json))
{
return JsonSerializer.Deserialize<ContinuePricingConfig>(json, JsonOptions);
}
if (detail.FactorValuesJson is JsonElement element
&& element.TryGetProperty("continuePricing", out var continueNode))
{
return continueNode.Deserialize<ContinuePricingConfig>(JsonOptions);
}
if (factors.TryGetValue("首重", out var firstWeightText)
&& decimal.TryParse(firstWeightText, NumberStyles.Any, CultureInfo.InvariantCulture, out var firstWeight))
{
decimal.TryParse(factors.GetValueOrDefault("首重价"), NumberStyles.Any, CultureInfo.InvariantCulture, out var firstPrice);
decimal.TryParse(factors.GetValueOrDefault("续重单位"), NumberStyles.Any, CultureInfo.InvariantCulture, out var continueUnit);
decimal.TryParse(factors.GetValueOrDefault("续重价"), NumberStyles.Any, CultureInfo.InvariantCulture, out var continuePrice);
return new ContinuePricingConfig
{
FirstWeight = firstWeight,
FirstPrice = firstPrice > 0 ? firstPrice : detail.CalculatedValue ?? 0,
ContinueUnit = continueUnit > 0 ? continueUnit : 0.5M,
ContinuePrice = continuePrice > 0 ? continuePrice : detail.CalculatedValue ?? 0
};
}
return null;
}
private static decimal? ResolveCustomerGroupPrice(ChannelPriceDetailOutput detail, CustomerGroup? customerGroup)
{
if (!customerGroup.HasValue || detail.CustomerGroupValues == null)
{
return null;
}
var json = JsonSerializer.Serialize(detail.CustomerGroupValues, JsonOptions);
var map = JsonSerializer.Deserialize<Dictionary<string, decimal>>(json, JsonOptions);
if (map == null)
{
return null;
}
var key = customerGroup.Value switch
{
CustomerGroup.Vip1 => "客户组1",
CustomerGroup.Vip2 => "客户组2",
CustomerGroup.Vip3 => "客户组3",
CustomerGroup.Vip4 => "客户组4",
CustomerGroup.Vip5 => "客户组5",
_ => null
};
if (key != null && map.TryGetValue(key, out var value))
{
return value;
}
return null;
}
private static bool EvaluateCondition(string condition, decimal gw, decimal vw)
{
if (string.IsNullOrWhiteSpace(condition))
{
return true;
}
var expr = Regex.Replace(condition.Trim(), @"\bgw\b", gw.ToString(CultureInfo.InvariantCulture), RegexOptions.IgnoreCase);
expr = Regex.Replace(expr, @"\bvw\b", vw.ToString(CultureInfo.InvariantCulture), RegexOptions.IgnoreCase);
expr = expr.Replace("==", "=");
try
{
var result = new System.Data.DataTable().Compute(expr, string.Empty);
return result is bool boolResult && boolResult
|| result is int intResult && intResult != 0
|| result is decimal decimalResult && decimalResult != 0
|| result is double doubleResult && Math.Abs(doubleResult) > double.Epsilon;
}
catch
{
return false;
}
}
private static bool IsPostcodeInRange(string zipCode, string? left, string? right)
{
if (string.IsNullOrWhiteSpace(left) && string.IsNullOrWhiteSpace(right))
{
return true;
}
if (long.TryParse(zipCode, out var zip)
&& long.TryParse(left, out var leftValue)
&& long.TryParse(right, out var rightValue))
{
return zip >= leftValue && zip <= rightValue;
}
return string.Compare(zipCode, left, StringComparison.OrdinalIgnoreCase) >= 0
&& string.Compare(zipCode, right, StringComparison.OrdinalIgnoreCase) <= 0;
}
private sealed class ContinuePricingConfig
{
public decimal FirstWeight { get; set; }
public decimal FirstPrice { get; set; }
public decimal ContinueUnit { get; set; }
public decimal ContinuePrice { get; set; }
}
}

View File

@ -0,0 +1,25 @@
namespace Oms.Application.Services.Orders;
/// <summary>
/// 查价/试算用的地址与件重尺上下文(不落库)。
/// </summary>
public sealed class ParcelQuoteContext
{
public string CountryCode { get; init; } = string.Empty;
public string? Province { get; init; }
public string? City { get; init; }
public string? PostalCode { get; init; }
public string CargoAttribute { get; init; } = string.Empty;
public decimal Length { get; init; }
public decimal Width { get; init; }
public decimal Height { get; init; }
public decimal ActualWeight { get; init; }
}

View File

@ -0,0 +1,29 @@
using Oms.Domain.OrdersAggregate.Entities;
namespace Oms.Application.Services.Orders;
/// <summary>
/// 单产品销售产品试算结果(与 parcel/create 预报计费同源)。
/// </summary>
public sealed class ParcelQuoteResult
{
public int ProductId { get; init; }
public string ProductCode { get; init; } = string.Empty;
public string ProductName { get; init; } = string.Empty;
public string RulesVersion { get; init; } = string.Empty;
public string PriceVersion { get; init; } = string.Empty;
public decimal VolumetricWeight { get; init; }
public decimal ChargeableWeight { get; init; }
public string? ZoneName { get; init; }
public bool IsRemote { get; init; }
public IReadOnlyList<OrderFee> Fees { get; init; } = [];
}

View File

@ -20,4 +20,6 @@ public interface IKpiAreaRepository : IRepository<KpiArea>
Task UpdateInfoAsync(KpiAreaInfo info, CancellationToken cancellationToken = default);
Task DeleteInfoAsync(int id, CancellationToken cancellationToken = default);
Task<List<KpiAreaInfo>> GetAllInfosByAreaIdAsync(int areaId, CancellationToken cancellationToken = default);
}

View File

@ -51,4 +51,10 @@ public interface ISalesProductRepository : IRepository<SalesProduct>
Task<SalesProductPublishRecord> InsertPublishRecordAsync(SalesProductPublishRecord record, CancellationToken cancellationToken = default);
Task<SalesProductPublishRecord?> GetPublishRecordByIdAsync(int id, CancellationToken cancellationToken = default);
Task<SalesProductPublishRecord?> GetEffectivePublishRecordAsync(
int salesProductId,
string operatorType,
DateTime bindTime,
CancellationToken cancellationToken = default);
}

View File

@ -20,4 +20,6 @@ public interface IWeightRoundingRepository : IRepository<WeightRounding>
Task UpdateInfoAsync(WeightRoundingInfo info, CancellationToken cancellationToken = default);
Task DeleteInfoAsync(int id, CancellationToken cancellationToken = default);
Task<List<WeightRoundingInfo>> GetAllInfosByRoundingIdAsync(int roundingId, CancellationToken cancellationToken = default);
}

View File

@ -20,4 +20,6 @@ public interface IWeightRuleRepository : IRepository<WeightRule>
Task UpdateInfoAsync(WeightRuleInfo info, CancellationToken cancellationToken = default);
Task DeleteInfoAsync(int id, CancellationToken cancellationToken = default);
Task<List<WeightRuleInfo>> GetAllInfosByRuleIdAsync(int ruleId, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,34 @@
namespace Oms.Domain.OrdersAggregate.Consts;
public static class OrderFeeTypeCodes
{
public const string Receivable = "receivable";
public const string Payable = "payable";
}
public static class OrderFeeChargingStageCodes
{
public const string Forecast = "forecast";
public const string Inbound = "inbound";
public const string Outbound = "outbound";
}
public static class ExportCustomsModeCodes
{
public const string Mode9610Simple = "9610Simple";
public const string Mode9610Summary = "9610Summary";
public const string Mode9610Separate = "9610Separate";
public const string Mode9710 = "9710";
public const string Mode9810 = "9810";
public const string Mode0110 = "0110";
public static readonly HashSet<string> All =
[
Mode9610Simple,
Mode9610Summary,
Mode9610Separate,
Mode9710,
Mode9810,
Mode0110
];
}

View File

@ -22,6 +22,14 @@ public class Order : OmsAuditedAggregateRoot
public string? CargoAttribute { get; set; }
public string? ExportCustomsMode { get; set; }
public string? PredeclareFailReason { get; set; }
public string? SalesProductRulesVersion { get; set; }
public string? SalesProductPriceVersion { get; set; }
public string OrderStatus { get; set; } = string.Empty;
public int? WarehouseId { get; set; }

View File

@ -21,4 +21,6 @@ public class OrderFee : OmsAuditedEntity
public string? PriceNo { get; set; }
public DateTime? PriceEffectiveTime { get; set; }
public string? ChargingStage { get; set; }
}

View File

@ -30,4 +30,10 @@ public interface IOrderRepository : IRepository<Order>
Task InsertDeclarationsAsync(IEnumerable<OrderDeclaration> declarations, CancellationToken cancellationToken = default);
Task InsertWeightsAsync(IEnumerable<OrderWeight> weights, CancellationToken cancellationToken = default);
Task ReplaceWeightsAsync(int orderId, IEnumerable<OrderWeight> weights, CancellationToken cancellationToken = default);
Task UpsertFeesAsync(int orderId, IEnumerable<OrderFee> fees, CancellationToken cancellationToken = default);
Task<List<OrderFee>> GetFeesByOrderIdsAsync(IEnumerable<int> orderIds, CancellationToken cancellationToken = default);
}

View File

@ -55,4 +55,13 @@ public class KpiAreaRepository : OmsSqlSugarRepository<KpiArea>, IKpiAreaReposit
var db = await GetDbContextAsync(cancellationToken);
await db.Deleteable<KpiAreaInfo>().Where(x => x.Id == id).ExecuteCommandAsync(cancellationToken);
}
public async Task<List<KpiAreaInfo>> GetAllInfosByAreaIdAsync(int areaId, CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
return await db.Queryable<KpiAreaInfo>()
.Where(x => x.AreaId == areaId)
.OrderBy(x => x.Id)
.ToListAsync(cancellationToken);
}
}

View File

@ -195,6 +195,24 @@ public class SalesProductRepository : OmsSqlSugarRepository<SalesProduct>, ISale
return await db.Queryable<SalesProductPublishRecord>().FirstAsync(x => x.Id == id, cancellationToken);
}
public async Task<SalesProductPublishRecord?> GetEffectivePublishRecordAsync(
int salesProductId,
string operatorType,
DateTime bindTime,
CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
var records = await db.Queryable<SalesProductPublishRecord>()
.Where(x => x.SalesProductId == salesProductId
&& x.OperatorType == operatorType
&& x.Status == "生效"
&& x.PublishTime <= bindTime)
.OrderBy(x => x.PublishTime, SqlSugar.OrderByType.Desc)
.Take(1)
.ToListAsync(cancellationToken);
return records.FirstOrDefault();
}
private static async Task InsertPriceChildrenAsync(
SqlSugar.ISqlSugarClient db,
int priceId,

View File

@ -55,4 +55,13 @@ public class WeightRoundingRepository : OmsSqlSugarRepository<WeightRounding>, I
var db = await GetDbContextAsync(cancellationToken);
await db.Deleteable<WeightRoundingInfo>().Where(x => x.Id == id).ExecuteCommandAsync(cancellationToken);
}
public async Task<List<WeightRoundingInfo>> GetAllInfosByRoundingIdAsync(int roundingId, CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
return await db.Queryable<WeightRoundingInfo>()
.Where(x => x.RoundingId == roundingId)
.OrderBy(x => x.Id)
.ToListAsync(cancellationToken);
}
}

View File

@ -55,4 +55,13 @@ public class WeightRuleRepository : OmsSqlSugarRepository<WeightRule>, IWeightRu
var db = await GetDbContextAsync(cancellationToken);
await db.Deleteable<WeightRuleInfo>().Where(x => x.Id == id).ExecuteCommandAsync(cancellationToken);
}
public async Task<List<WeightRuleInfo>> GetAllInfosByRuleIdAsync(int ruleId, CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
return await db.Queryable<WeightRuleInfo>()
.Where(x => x.RuleId == ruleId)
.OrderBy(x => x.Id)
.ToListAsync(cancellationToken);
}
}

View File

@ -123,4 +123,65 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
var db = await GetDbContextAsync(cancellationToken);
await db.Insertable(items).ExecuteCommandAsync(cancellationToken);
}
public async Task ReplaceWeightsAsync(int orderId, IEnumerable<OrderWeight> weights, CancellationToken cancellationToken = default)
{
var items = weights.ToArray();
var db = await GetDbContextAsync(cancellationToken);
await db.Deleteable<OrderWeight>().Where(x => x.OrderId == orderId).ExecuteCommandAsync(cancellationToken);
if (items.Length > 0)
{
await db.Insertable(items).ExecuteCommandAsync(cancellationToken);
}
}
public async Task UpsertFeesAsync(int orderId, IEnumerable<OrderFee> fees, CancellationToken cancellationToken = default)
{
var items = fees.ToArray();
if (items.Length == 0)
{
return;
}
var db = await GetDbContextAsync(cancellationToken);
foreach (var fee in items)
{
fee.OrderId = orderId;
var existingList = await db.Queryable<OrderFee>()
.Where(x => x.OrderId == orderId
&& x.Type == fee.Type
&& x.CostItem == fee.CostItem
&& x.ChargingStage == fee.ChargingStage)
.Take(1)
.ToListAsync(cancellationToken);
var existing = existingList.FirstOrDefault();
if (existing == null)
{
await db.Insertable(fee).ExecuteCommandAsync(cancellationToken);
continue;
}
existing.Amount = fee.Amount;
existing.Currency = fee.Currency;
existing.PriceNo = fee.PriceNo;
existing.PriceEffectiveTime = fee.PriceEffectiveTime;
existing.DownstreamProvider = fee.DownstreamProvider;
existing.UpdateTime = DateTime.Now;
await db.Updateable(existing).ExecuteCommandAsync(cancellationToken);
}
}
public async Task<List<OrderFee>> GetFeesByOrderIdsAsync(IEnumerable<int> orderIds, CancellationToken cancellationToken = default)
{
var ids = orderIds.Distinct().ToArray();
if (ids.Length == 0)
{
return [];
}
var db = await GetDbContextAsync(cancellationToken);
return await db.Queryable<OrderFee>()
.Where(x => ids.Contains(x.OrderId))
.ToListAsync(cancellationToken);
}
}

View File

@ -0,0 +1,258 @@
using FluentAssertions;
using Oms.Application.Dtos.Orders;
using Oms.Application.Services.Orders;
using Oms.Domain.CustomersAggregate.Enums;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Tests.Support;
namespace Oms.Tests;
/// <summary>
/// 小包预报应收/应付计费单元测试(与设计文档 §4.4.3.1 续单价示例对齐)。
/// </summary>
public class ParcelBillingCalculatorTests
{
private const string Zone = "美西";
private readonly (string PriceVersion, DateTime EffectiveTime) _meta = ParcelBillingTestData.SnapshotMeta();
#region 计重
[Fact]
public void CalculateRawBillableWeight_ShouldUseGrossWeightWhenConditionHits()
{
var forecast = new OrderWeight
{
Length = 30,
Width = 20,
Height = 10,
ActualWeight = 1.5M
};
var rules = new[] { ParcelBillingTestData.GwRule() };
var (vw, raw) = ParcelBillingCalculator.CalculateRawBillableWeight(forecast, rules);
vw.Should().Be(1M);
raw.Should().Be(1.5M);
}
[Fact]
public void ApplyRounding_ShouldCeilToConfiguredUnit()
{
var ranges = new[] { ParcelBillingTestData.RoundingRange(0, 30, "0.5") };
var rounded = ParcelBillingCalculator.ApplyRounding(1.23M, ranges);
rounded.Should().Be(1.5M);
}
#endregion
#region 应收(卖价)
[Theory]
[InlineData(0.8, 18)]
[InlineData(1.2, 24)]
[InlineData(1.6, 30)]
public void CalculateForecastReceivableFees_ContinuePrice_ShouldMatchDesignExamples(
double chargeableWeight,
decimal expectedFreight)
{
var snapshot = ParcelBillingTestData.BuildReceivableSnapshot(
ParcelBillingTestData.ReceivablePriceItem(
"预报",
1,
"freight",
ParcelBillingTestData.ContinueFreightDetail()));
var fees = ParcelBillingCalculator.CalculateForecastReceivableFees(
snapshot,
_meta.PriceVersion,
_meta.EffectiveTime,
(decimal)chargeableWeight,
Zone,
"general",
null);
fees.Should().ContainSingle();
fees[0].Type.Should().Be(OrderFeeTypeCodes.Receivable);
fees[0].CostItem.Should().Be("freight");
fees[0].Amount.Should().Be(expectedFreight);
fees[0].ChargingStage.Should().Be(OrderFeeChargingStageCodes.Forecast);
fees[0].PriceNo.Should().Be(_meta.PriceVersion);
}
[Theory]
[InlineData("入库")]
[InlineData("出库")]
[InlineData("inbound")]
[InlineData("outbound")]
public void CalculateForecastReceivableFees_ShouldSkipNonForecastChargingTime(string chargingTime)
{
var snapshot = ParcelBillingTestData.BuildReceivableSnapshot(
ParcelBillingTestData.ReceivablePriceItem(
chargingTime,
1,
"freight",
ParcelBillingTestData.ContinueFreightDetail()));
var fees = ParcelBillingCalculator.CalculateForecastReceivableFees(
snapshot,
_meta.PriceVersion,
_meta.EffectiveTime,
1.2M,
Zone,
"general",
null);
fees.Should().BeEmpty();
}
[Fact]
public void CalculateForecastReceivableFees_ShouldIncludeForecastAndFixedPriceItems()
{
var snapshot = ParcelBillingTestData.BuildReceivableSnapshot(
ParcelBillingTestData.ReceivablePriceItem(
"预报",
1,
"freight",
ParcelBillingTestData.ContinueFreightDetail()),
ParcelBillingTestData.ReceivablePriceItem(
"预报",
1,
"操作费",
ParcelBillingTestData.FixedPriceDetail(5M)));
var fees = ParcelBillingCalculator.CalculateForecastReceivableFees(
snapshot,
_meta.PriceVersion,
_meta.EffectiveTime,
0.8M,
Zone,
"general",
null);
fees.Should().HaveCount(2);
fees.Should().Contain(x => x.CostItem == "freight" && x.Amount == 18M);
fees.Should().Contain(x => x.CostItem == "操作费" && x.Amount == 5M);
var summary = ParcelBillingCalculator.SummarizeFees(fees);
summary.Freight.Should().Be(18M);
summary.OtherFees.Should().Be(5M);
summary.TotalCost.Should().Be(23M);
}
[Fact]
public void CalculateForecastReceivableFees_ShouldApplyCustomerGroupPrice()
{
var detail = ParcelBillingTestData.UnitPriceDetail(20M);
detail.CustomerGroupValues = new Dictionary<string, decimal> { ["客户组1"] = 15M };
var snapshot = ParcelBillingTestData.BuildReceivableSnapshot(
ParcelBillingTestData.ReceivablePriceItem("预报", 1, "freight", detail));
var fees = ParcelBillingCalculator.CalculateForecastReceivableFees(
snapshot,
_meta.PriceVersion,
_meta.EffectiveTime,
2M,
Zone,
"general",
CustomerGroup.Vip1);
fees.Should().ContainSingle();
fees[0].Amount.Should().Be(15M);
}
[Fact]
public void HasEnabledForecastPriceItems_ShouldDetectForecastEnabledItems()
{
var snapshot = ParcelBillingTestData.BuildReceivableSnapshot(
ParcelBillingTestData.ReceivablePriceItem("入库", 1, "freight", ParcelBillingTestData.ContinueFreightDetail()),
ParcelBillingTestData.ReceivablePriceItem("预报", 1, "freight", ParcelBillingTestData.ContinueFreightDetail()));
ParcelBillingCalculator.HasEnabledForecastPriceItems(snapshot).Should().BeTrue();
}
#endregion
#region 应付(买价)
[Theory]
[InlineData(0.8, 12)]
[InlineData(1.2, 16)]
[InlineData(1.6, 20)]
public void CalculateForecastPayableFees_ContinuePrice_ShouldUseSameEngine(
double chargeableWeight,
decimal expectedFreight)
{
var prices = ParcelBillingTestData.BuildPayablePrices(
ParcelBillingTestData.PayablePriceItem(
"预报",
1,
"freight",
ParcelBillingTestData.ContinueFreightDetail(
firstWeight: 1M,
firstPrice: 12M,
continueUnit: 0.5M,
continuePrice: 4M)));
var fees = ParcelBillingCalculator.CalculateForecastPayableFees(
prices,
_meta.PriceVersion,
_meta.EffectiveTime,
(decimal)chargeableWeight,
Zone,
"general",
null);
fees.Should().ContainSingle();
fees[0].Type.Should().Be(OrderFeeTypeCodes.Payable);
fees[0].Amount.Should().Be(expectedFreight);
fees[0].ChargingStage.Should().Be(OrderFeeChargingStageCodes.Forecast);
}
[Fact]
public void CalculateForecastPayableFees_ShouldSkipDisabledPriceItems()
{
var prices = ParcelBillingTestData.BuildPayablePrices(
ParcelBillingTestData.PayablePriceItem(
"预报",
0,
"freight",
ParcelBillingTestData.ContinueFreightDetail()));
var fees = ParcelBillingCalculator.CalculateForecastPayableFees(
prices,
_meta.PriceVersion,
_meta.EffectiveTime,
1.2M,
Zone,
"general",
null);
fees.Should().BeEmpty();
ParcelBillingCalculator.HasEnabledForecastPayablePriceItems(prices).Should().BeFalse();
}
[Fact]
public void ReceivableAndPayable_ShouldDifferByTypeButShareZoneMatching()
{
var detail = ParcelBillingTestData.ContinueFreightDetail();
var snapshot = ParcelBillingTestData.BuildReceivableSnapshot(
ParcelBillingTestData.ReceivablePriceItem("预报", 1, "freight", detail));
var payablePrices = ParcelBillingTestData.BuildPayablePrices(
ParcelBillingTestData.PayablePriceItem("预报", 1, "freight", detail));
var receivable = ParcelBillingCalculator.CalculateForecastReceivableFees(
snapshot, _meta.PriceVersion, _meta.EffectiveTime, 1.2M, Zone, "general", null);
var payable = ParcelBillingCalculator.CalculateForecastPayableFees(
payablePrices, _meta.PriceVersion, _meta.EffectiveTime, 1.2M, Zone, "general", null);
receivable[0].Type.Should().Be(OrderFeeTypeCodes.Receivable);
payable[0].Type.Should().Be(OrderFeeTypeCodes.Payable);
receivable[0].Amount.Should().Be(payable[0].Amount);
}
#endregion
}

View File

@ -0,0 +1,114 @@
using System.Globalization;
using Oms.Application.Dtos.Channels;
using Oms.Application.Dtos.Orders;
using Oms.Domain.ChannelsAggregate.Entities;
namespace Oms.Tests.Support;
internal static class ParcelBillingTestData
{
private static readonly DateTime EffectiveTime = new(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc);
private const string PriceVersion = "P20260401001";
private const string Zone = "美西";
public static SalesProductPricePublishSnapshotDto BuildReceivableSnapshot(
params SalesProductPriceListOutput[] prices) =>
new() { SalesProductId = 1, Prices = prices.ToList() };
public static List<ServiceChannelPriceListOutput> BuildPayablePrices(
params ServiceChannelPriceListOutput[] prices) => prices.ToList();
public static SalesProductPriceListOutput ReceivablePriceItem(
string chargingTime,
byte status,
string costItem,
params ChannelPriceDetailOutput[] details) =>
new()
{
Status = status,
CostItem = costItem,
Currency = "CNY",
ChargingTime = chargingTime,
Details = details.ToList()
};
public static ServiceChannelPriceListOutput PayablePriceItem(
string chargingTime,
byte status,
string costItem,
params ChannelPriceDetailOutput[] details) =>
new()
{
Status = status,
CostItem = costItem,
Currency = "CNY",
ChargingTime = chargingTime,
Details = details.ToList()
};
public static ChannelPriceDetailOutput ContinueFreightDetail(
decimal firstWeight = 1M,
decimal firstPrice = 18M,
decimal continueUnit = 0.5M,
decimal continuePrice = 6M,
string zone = Zone,
string cargoType = "普货") =>
new()
{
CalculationFormula = "续单价",
FactorValuesJson = new Dictionary<string, string>
{
["分区"] = zone,
["货物类型"] = cargoType,
["首重"] = firstWeight.ToString(CultureInfo.InvariantCulture),
["首重价"] = firstPrice.ToString(CultureInfo.InvariantCulture),
["续重单位"] = continueUnit.ToString(CultureInfo.InvariantCulture),
["续重价"] = continuePrice.ToString(CultureInfo.InvariantCulture)
}
};
public static ChannelPriceDetailOutput FixedPriceDetail(
decimal amount,
string zone = Zone,
string cargoType = "普货") =>
new()
{
CalculationFormula = "固定价",
CalculatedValue = amount,
FactorValuesJson = new Dictionary<string, string>
{
["分区"] = zone,
["货物类型"] = cargoType
}
};
public static ChannelPriceDetailOutput UnitPriceDetail(
decimal unitPrice,
string zone = Zone) =>
new()
{
CalculationFormula = "单价",
CalculatedValue = unitPrice,
FactorValuesJson = new Dictionary<string, string> { ["分区"] = zone }
};
public static WeightRuleInfo GwRule(string condition = "gw>=vw", string factor = "6000") =>
new()
{
Id = 1,
Condition = condition,
BillableWeight = "gw",
VolumetricFactor = factor
};
public static WeightRoundingInfo RoundingRange(decimal start, decimal end, string unit) =>
new()
{
Id = 1,
StartValue = start,
EndValue = end,
Public = unit
};
public static (string PriceVersion, DateTime EffectiveTime) SnapshotMeta() => (PriceVersion, EffectiveTime);
}

View File

@ -25,6 +25,12 @@ public class OmsRefreshTokenInput
public string RefreshToken { get; set; } = string.Empty;
}
public class OmsWxLoginInput
{
[Required]
public string LoginUuid { get; set; } = string.Empty;
}
public class OmsChangePasswordInput
{
[Required]

View File

@ -18,6 +18,7 @@ using Oms.Application.Shared;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Enums;
using Oms.Domain.CustomersAggregate.Repositories;
using SimpleAdmin.Api.WeChat.Services;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
@ -37,7 +38,8 @@ public class AuthService(
IWebHostEnvironment webHostEnvironment,
IHttpContextAccessor httpContextAccessor,
FakeRbacDbContext rbacDbContext,
ICustomerRepository customerRepository)
ICustomerRepository customerRepository,
IWeChatLoginSessionStore weChatLoginSessionStore)
: ApplicationService
{
private Guid CurrentUserGuid => CurrentUser.FindClaimValueOrNull(ClaimTypes.NameIdentifier)!.To<Guid>();
@ -60,6 +62,56 @@ public class AuthService(
};
}
/// <summary>
/// 微信扫码登录(轮询)。
/// </summary>
[AllowAnonymous]
[HttpPost("/rbac/auth/wx-login")]
public async Task<LoginResultDto> WxLoginAsync(
[FromBody] OmsWxLoginInput input,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(input.LoginUuid))
{
throw new BusinessException("缺少 loginUuid");
}
var loginUuid = input.LoginUuid.Trim();
var openId = await weChatLoginSessionStore.GetOpenIdAsync(loginUuid, cancellationToken);
if (string.IsNullOrWhiteSpace(openId))
{
throw new BusinessException("请扫码登录");
}
var customer = await customerRepository.GetByWechatOpenidAsync(openId, cancellationToken);
if (customer == null)
{
throw new BusinessException("未绑定微信,请先注册或绑定账号");
}
if (customer.ParentCustomerId.HasValue)
{
throw new BusinessException("子客户不支持微信扫码登录");
}
ValidateMainCustomerStatus(customer);
customer.LastLoginIp = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
customer.LastLoginTime = DateTime.Now;
await customerRepository.UpdateAsync(customer, cancellationToken);
var userGuid = await GetRbacUserGuidByMapIdAsync(customer.UserId, cancellationToken);
if (!userGuid.HasValue)
{
throw new BusinessException("客户未关联用户");
}
await weChatLoginSessionStore.RemoveAsync(loginUuid, cancellationToken);
var claims = await BuildClaimsAsync(userGuid.Value, OmsLoginType.Customer, cancellationToken);
return BuildLoginResult(userGuid.Value, claims);
}
/// <summary>
/// 刷新登录令牌。
/// </summary>

View File

@ -12,6 +12,7 @@
<PackageReference Include="Fake.Consul" Version="$(FakePackageVersion)" />
<PackageReference Include="Fake.Rbac.Application" Version="$(FakePackageVersion)" />
<PackageReference Include="Fake.Serilog" Version="$(FakePackageVersion)" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.11" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3"/>
</ItemGroup>

View File

@ -15,6 +15,7 @@ using Microsoft.AspNetCore.HttpOverrides;
using Oms.Application;
using Oms.SqlSugarCore;
using SimpleAdmin.Api.Auth;
using SimpleAdmin.Api.WeChat;
namespace SimpleAdmin.Api;
@ -49,6 +50,10 @@ public class SimpleAdminApiModule : FakeModule
settings.RootPath = "rbac";
settings.TypePredicate = type => type == typeof(AuthService);
});
options.ApplicationServices2Controller<SimpleAdminApiModule>(settings =>
{
settings.TypePredicate = type => type.Namespace == "SimpleAdmin.Api.WeChat";
});
options.ApplicationServices2Controller<FakeRbacApplicationModule>(settings =>
{
settings.RootPath = "rbac";
@ -56,6 +61,8 @@ public class SimpleAdminApiModule : FakeModule
});
});
services.AddOmsWeChat(configuration);
//Json格式化全局处理
context.Services.AddFakeNewtonsoft();

View File

@ -0,0 +1,82 @@
using System.ComponentModel.DataAnnotations;
namespace SimpleAdmin.Api.WeChat.Dtos;
public class WxLoginQrcodeOutput
{
public string QrCodeUrl { get; set; } = string.Empty;
public int ExpiresIn { get; set; } = 600;
}
public class WeChatOAuthCodeInput
{
[Required]
public string Code { get; set; } = string.Empty;
}
public class WeChatOAuthUserInfoOutput
{
public string Openid { get; set; } = string.Empty;
public string? Nickname { get; set; }
public int Sex { get; set; }
public string? Province { get; set; }
public string? City { get; set; }
public string? Country { get; set; }
public string? Headimgurl { get; set; }
public string? Unionid { get; set; }
public bool AlreadyBound { get; set; }
}
public class WeChatProfileInput
{
[Required]
public string Openid { get; set; } = string.Empty;
public string? Nickname { get; set; }
public string? Avatar { get; set; }
public string? Unionid { get; set; }
}
public class WeChatCustomerRegisterInput
{
[Required]
public string CustomerCode { get; set; } = string.Empty;
[Required]
public string CustomerName { get; set; } = string.Empty;
[Required]
public string Phone { get; set; } = string.Empty;
public string? Email { get; set; }
[Required]
[MinLength(6)]
public string Password { get; set; } = string.Empty;
[Required]
public WeChatProfileInput Wechat { get; set; } = new();
}
public class WeChatCustomerBindInput
{
[Required]
public string Account { get; set; } = string.Empty;
[Required]
public string Password { get; set; } = string.Empty;
[Required]
public WeChatProfileInput Wechat { get; set; } = new();
}

View File

@ -0,0 +1,22 @@
namespace SimpleAdmin.Api.WeChat.Options;
/// <summary>
/// 微信公众号配置。
/// </summary>
public class WeChatMpOptions
{
public const string SectionName = "WeChat:Mp";
public string AppId { get; set; } = string.Empty;
public string Secret { get; set; } = string.Empty;
public string Token { get; set; } = string.Empty;
public string? EncodingAesKey { get; set; }
/// <summary>
/// OAuth 回调基础域名,如 https://oms.nanafa.com
/// </summary>
public string OAuthRedirectBase { get; set; } = string.Empty;
}

View File

@ -0,0 +1,13 @@
namespace SimpleAdmin.Api.WeChat.Services;
/// <summary>
/// 微信扫码登录会话:loginUuid → openId 桥接。
/// </summary>
public interface IWeChatLoginSessionStore
{
Task SetOpenIdAsync(string loginUuid, string openId, TimeSpan ttl, CancellationToken cancellationToken = default);
Task<string?> GetOpenIdAsync(string loginUuid, CancellationToken cancellationToken = default);
Task RemoveAsync(string loginUuid, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,90 @@
using Oms.Domain.CustomersAggregate.Repositories;
namespace SimpleAdmin.Api.WeChat.Services;
public interface IWeChatEventHandler
{
Task<string?> HandleAsync(WeChatIncomingMessage message, CancellationToken cancellationToken = default);
}
public class WeChatEventHandler(
ICustomerRepository customerRepository,
IWeChatLoginSessionStore sessionStore,
ILogger<WeChatEventHandler> logger) : IWeChatEventHandler
{
private const string QrScenePrefix = "qrscene_";
public async Task<string?> HandleAsync(WeChatIncomingMessage message, CancellationToken cancellationToken = default)
{
if (!string.Equals(message.MsgType, "event", StringComparison.OrdinalIgnoreCase))
{
return null;
}
var eventName = message.Event ?? string.Empty;
if (string.Equals(eventName, "SCAN", StringComparison.OrdinalIgnoreCase)
|| string.Equals(eventName, "subscribe", StringComparison.OrdinalIgnoreCase))
{
return await HandleScanLoginAsync(message, cancellationToken);
}
logger.LogDebug("未处理的微信事件: {Event}", eventName);
return null;
}
private async Task<string?> HandleScanLoginAsync(WeChatIncomingMessage message, CancellationToken cancellationToken)
{
var openId = message.FromUserName;
if (string.IsNullOrWhiteSpace(openId))
{
return null;
}
var existing = await customerRepository.GetByWechatOpenidAsync(openId, cancellationToken);
if (existing == null)
{
return BuildTextReply(message, "未绑定账号,请在公众号菜单注册账号或绑定已有账号。");
}
var scene = NormalizeScene(message.EventKey);
if (string.IsNullOrWhiteSpace(scene))
{
if (string.Equals(message.Event, "subscribe", StringComparison.OrdinalIgnoreCase))
{
return BuildTextReply(message, "感谢关注");
}
return null;
}
await sessionStore.SetOpenIdAsync(scene, openId, TimeSpan.FromMinutes(10), cancellationToken);
logger.LogInformation("微信扫码登录会话已写入: loginUuid={LoginUuid}, openId={OpenId}", scene, openId);
if (string.Equals(message.Event, "subscribe", StringComparison.OrdinalIgnoreCase))
{
return BuildTextReply(message, "感谢关注,请返回电脑端完成登录。");
}
return BuildTextReply(message, "扫码成功,请返回电脑端完成登录。");
}
private static string? NormalizeScene(string? eventKey)
{
if (string.IsNullOrWhiteSpace(eventKey))
{
return null;
}
return eventKey.StartsWith(QrScenePrefix, StringComparison.Ordinal)
? eventKey[QrScenePrefix.Length..]
: eventKey;
}
private static string BuildTextReply(WeChatIncomingMessage message, string content)
{
return WeChatXmlMessageBuilder.BuildTextReply(
message.FromUserName ?? string.Empty,
message.ToUserName ?? string.Empty,
content);
}
}

View File

@ -0,0 +1,29 @@
using Microsoft.Extensions.Caching.Distributed;
namespace SimpleAdmin.Api.WeChat.Services;
public class WeChatLoginSessionStore(IDistributedCache cache) : IWeChatLoginSessionStore
{
private const string KeyPrefix = "oms:wechat:login:";
public Task SetOpenIdAsync(string loginUuid, string openId, TimeSpan ttl, CancellationToken cancellationToken = default)
{
var options = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = ttl
};
return cache.SetStringAsync(BuildKey(loginUuid), openId, options, cancellationToken);
}
public Task<string?> GetOpenIdAsync(string loginUuid, CancellationToken cancellationToken = default)
{
return cache.GetStringAsync(BuildKey(loginUuid), cancellationToken);
}
public Task RemoveAsync(string loginUuid, CancellationToken cancellationToken = default)
{
return cache.RemoveAsync(BuildKey(loginUuid), cancellationToken);
}
private static string BuildKey(string loginUuid) => $"{KeyPrefix}{loginUuid}";
}

View File

@ -0,0 +1,293 @@
using System.Text.Json.Serialization;
using System.Xml.Linq;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using SimpleAdmin.Api.WeChat.Options;
namespace SimpleAdmin.Api.WeChat.Services;
public interface IWeChatMpApiClient
{
Task<string> CreateLoginQrCodeUrlAsync(string loginUuid, int expireSeconds, CancellationToken cancellationToken = default);
Task<WeChatOAuthTokenResult?> GetOAuthAccessTokenAsync(string code, CancellationToken cancellationToken = default);
Task<WeChatOAuthUserResult?> GetOAuthUserInfoAsync(string accessToken, string openId, CancellationToken cancellationToken = default);
}
public sealed record WeChatOAuthTokenResult(string AccessToken, string OpenId);
public sealed record WeChatOAuthUserResult(
string Openid,
string? Nickname,
int Sex,
string? Province,
string? City,
string? Country,
string? Headimgurl,
string? Unionid);
internal sealed class WeChatMpApiClient(
IHttpClientFactory httpClientFactory,
IMemoryCache memoryCache,
IOptions<WeChatMpOptions> options,
ILogger<WeChatMpApiClient> logger) : IWeChatMpApiClient
{
private const string AccessTokenCacheKey = "oms:wechat:mp:access_token";
public async Task<string> CreateLoginQrCodeUrlAsync(
string loginUuid,
int expireSeconds,
CancellationToken cancellationToken = default)
{
var accessToken = await GetAccessTokenAsync(cancellationToken);
var client = httpClientFactory.CreateClient(nameof(WeChatMpApiClient));
var payload = new
{
expire_seconds = expireSeconds,
action_name = "QR_STR_SCENE",
action_info = new
{
scene = new
{
scene_str = loginUuid
}
}
};
using var response = await client.PostAsJsonAsync(
$"https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token={Uri.EscapeDataString(accessToken)}",
payload,
cancellationToken);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<WeChatQrTicketResponse>(cancellationToken: cancellationToken);
if (result == null || string.IsNullOrWhiteSpace(result.Ticket))
{
throw new InvalidOperationException(result?.Errmsg ?? "创建微信二维码失败");
}
if (result.Errcode is > 0)
{
throw new InvalidOperationException(result.Errmsg ?? $"创建微信二维码失败({result.Errcode})");
}
return $"https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket={Uri.EscapeDataString(result.Ticket)}";
}
public async Task<WeChatOAuthTokenResult?> GetOAuthAccessTokenAsync(string code, CancellationToken cancellationToken = default)
{
var mp = options.Value;
var client = httpClientFactory.CreateClient(nameof(WeChatMpApiClient));
var url =
$"https://api.weixin.qq.com/sns/oauth2/access_token?appid={Uri.EscapeDataString(mp.AppId)}&secret={Uri.EscapeDataString(mp.Secret)}&code={Uri.EscapeDataString(code)}&grant_type=authorization_code";
using var response = await client.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<WeChatOAuthTokenResponse>(cancellationToken: cancellationToken);
if (body == null || string.IsNullOrWhiteSpace(body.AccessToken) || string.IsNullOrWhiteSpace(body.Openid))
{
logger.LogWarning("微信 OAuth 换取 access_token 失败: {Message}", body?.Errmsg);
return null;
}
return new WeChatOAuthTokenResult(body.AccessToken, body.Openid);
}
public async Task<WeChatOAuthUserResult?> GetOAuthUserInfoAsync(
string accessToken,
string openId,
CancellationToken cancellationToken = default)
{
var client = httpClientFactory.CreateClient(nameof(WeChatMpApiClient));
var url =
$"https://api.weixin.qq.com/sns/userinfo?access_token={Uri.EscapeDataString(accessToken)}&openid={Uri.EscapeDataString(openId)}&lang=zh_CN";
using var response = await client.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<WeChatOAuthUserResponse>(cancellationToken: cancellationToken);
if (body == null || string.IsNullOrWhiteSpace(body.Openid))
{
logger.LogWarning("微信 OAuth 获取用户信息失败: {Message}", body?.Errmsg);
return null;
}
return new WeChatOAuthUserResult(
body.Openid,
body.Nickname,
body.Sex,
body.Province,
body.City,
body.Country,
body.Headimgurl,
body.Unionid);
}
private async Task<string> GetAccessTokenAsync(CancellationToken cancellationToken)
{
if (memoryCache.TryGetValue(AccessTokenCacheKey, out string? cached) && !string.IsNullOrWhiteSpace(cached))
{
return cached;
}
var mp = options.Value;
var client = httpClientFactory.CreateClient(nameof(WeChatMpApiClient));
var url =
$"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={Uri.EscapeDataString(mp.AppId)}&secret={Uri.EscapeDataString(mp.Secret)}";
using var response = await client.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<WeChatAccessTokenResponse>(cancellationToken: cancellationToken);
if (body == null || string.IsNullOrWhiteSpace(body.AccessToken))
{
throw new InvalidOperationException(body?.Errmsg ?? "获取微信 access_token 失败");
}
memoryCache.Set(AccessTokenCacheKey, body.AccessToken, TimeSpan.FromSeconds(Math.Max(300, body.ExpiresIn - 300)));
return body.AccessToken;
}
private sealed class WeChatAccessTokenResponse
{
[JsonPropertyName("access_token")]
public string? AccessToken { get; set; }
[JsonPropertyName("expires_in")]
public int ExpiresIn { get; set; }
[JsonPropertyName("errcode")]
public int Errcode { get; set; }
[JsonPropertyName("errmsg")]
public string? Errmsg { get; set; }
}
private sealed class WeChatQrTicketResponse
{
[JsonPropertyName("ticket")]
public string? Ticket { get; set; }
[JsonPropertyName("expire_seconds")]
public int ExpireSeconds { get; set; }
[JsonPropertyName("errcode")]
public int Errcode { get; set; }
[JsonPropertyName("errmsg")]
public string? Errmsg { get; set; }
}
private sealed class WeChatOAuthTokenResponse
{
[JsonPropertyName("access_token")]
public string? AccessToken { get; set; }
[JsonPropertyName("openid")]
public string? Openid { get; set; }
[JsonPropertyName("errcode")]
public int Errcode { get; set; }
[JsonPropertyName("errmsg")]
public string? Errmsg { get; set; }
}
private sealed class WeChatOAuthUserResponse
{
[JsonPropertyName("openid")]
public string? Openid { get; set; }
[JsonPropertyName("nickname")]
public string? Nickname { get; set; }
[JsonPropertyName("sex")]
public int Sex { get; set; }
[JsonPropertyName("province")]
public string? Province { get; set; }
[JsonPropertyName("city")]
public string? City { get; set; }
[JsonPropertyName("country")]
public string? Country { get; set; }
[JsonPropertyName("headimgurl")]
public string? Headimgurl { get; set; }
[JsonPropertyName("unionid")]
public string? Unionid { get; set; }
[JsonPropertyName("errcode")]
public int Errcode { get; set; }
[JsonPropertyName("errmsg")]
public string? Errmsg { get; set; }
}
}
internal static class WeChatXmlMessageParser
{
public static WeChatIncomingMessage? Parse(string xml)
{
if (string.IsNullOrWhiteSpace(xml))
{
return null;
}
var doc = XDocument.Parse(xml);
var root = doc.Root;
if (root == null)
{
return null;
}
string? Value(string name) => root.Element(name)?.Value;
return new WeChatIncomingMessage
{
ToUserName = Value("ToUserName"),
FromUserName = Value("FromUserName"),
CreateTime = Value("CreateTime"),
MsgType = Value("MsgType"),
Event = Value("Event"),
EventKey = Value("EventKey"),
Content = Value("Content")
};
}
}
internal sealed class WeChatIncomingMessage
{
public string? ToUserName { get; init; }
public string? FromUserName { get; init; }
public string? CreateTime { get; init; }
public string? MsgType { get; init; }
public string? Event { get; init; }
public string? EventKey { get; init; }
public string? Content { get; init; }
}
internal static class WeChatXmlMessageBuilder
{
public static string BuildTextReply(string toUser, string fromUser, string content)
{
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
return $"""
<xml>
<ToUserName><![CDATA[{toUser}]]></ToUserName>
<FromUserName><![CDATA[{fromUser}]]></FromUserName>
<CreateTime>{now}</CreateTime>
<MsgType><![CDATA[text]]></MsgType>
<Content><![CDATA[{content}]]></Content>
</xml>
""";
}
}

View File

@ -0,0 +1,24 @@
using System.Security.Cryptography;
using System.Text;
namespace SimpleAdmin.Api.WeChat.Services;
internal static class WeChatSignatureHelper
{
public static bool Validate(string token, string timestamp, string nonce, string signature)
{
if (string.IsNullOrWhiteSpace(token)
|| string.IsNullOrWhiteSpace(timestamp)
|| string.IsNullOrWhiteSpace(nonce)
|| string.IsNullOrWhiteSpace(signature))
{
return false;
}
var parts = new[] { token, timestamp, nonce }.OrderBy(x => x, StringComparer.Ordinal).ToArray();
var raw = string.Concat(parts);
var hash = SHA1.HashData(Encoding.UTF8.GetBytes(raw));
var hex = Convert.ToHexString(hash).ToLowerInvariant();
return string.Equals(hex, signature, StringComparison.OrdinalIgnoreCase);
}
}

View File

@ -0,0 +1,297 @@
using Fake.Application;
using Fake.Domain.Exceptions;
using Fake.Rbac.Application.Dtos.Role;
using Fake.Rbac.Application.Dtos.User;
using Fake.Rbac.Application.Services;
using Fake.Rbac.Infrastructure;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Oms.Application.Shared;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Enums;
using Oms.Domain.CustomersAggregate.Repositories;
using SimpleAdmin.Api.WeChat.Dtos;
namespace SimpleAdmin.Api.WeChat;
/// <summary>
/// 微信 OAuth 注册 / 绑定客户。
/// </summary>
[AllowAnonymous]
[ApiExplorerSettings(GroupName = "WeChat")]
public class WeChatCustomerAppService(
IWeChatMpApiClient weChatMpApiClient,
ICustomerRepository customerRepository,
UserService userService,
RoleService roleService,
FakeRbacDbContext rbacDbContext) : ApplicationService
{
private const string CustomerRoleCode = "Customer";
/// <summary>
/// OAuth code 换取微信用户信息。
/// </summary>
[HttpPost("/wechat/oauth/user-info")]
public async Task<WeChatOAuthUserInfoOutput> GetOAuthUserInfoAsync(
[FromBody] WeChatOAuthCodeInput input,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(input.Code))
{
throw new BusinessException("缺少 code");
}
var token = await weChatMpApiClient.GetOAuthAccessTokenAsync(input.Code.Trim(), cancellationToken);
if (token == null)
{
throw new BusinessException("获取授权信息出现错误");
}
var userInfo = await weChatMpApiClient.GetOAuthUserInfoAsync(token.AccessToken, token.OpenId, cancellationToken);
if (userInfo == null)
{
throw new BusinessException("获取授权信息出现错误");
}
var existing = await customerRepository.GetByWechatOpenidAsync(userInfo.Openid, cancellationToken);
return new WeChatOAuthUserInfoOutput
{
Openid = userInfo.Openid,
Nickname = userInfo.Nickname,
Sex = userInfo.Sex,
Province = userInfo.Province,
City = userInfo.City,
Country = userInfo.Country,
Headimgurl = userInfo.Headimgurl,
Unionid = userInfo.Unionid,
AlreadyBound = existing != null
};
}
/// <summary>
/// 微信授权注册客户。
/// </summary>
[HttpPost("/wechat/customer/register")]
public async Task RegisterAsync(
[FromBody] WeChatCustomerRegisterInput input,
CancellationToken cancellationToken = default)
{
ValidateWechatProfile(input.Wechat);
var customerCode = NormalizeRequired(input.CustomerCode, "客户编号");
var customerName = NormalizeRequired(input.CustomerName, "客户名称");
var phone = NormalizeRequired(input.Phone, "手机号");
var email = NormalizeOptional(input.Email);
var password = NormalizeRequired(input.Password, "密码");
if (password.Length < 6)
{
throw new BusinessException("密码长度不能少于6位");
}
if (await customerRepository.GetByWechatOpenidAsync(input.Wechat.Openid, cancellationToken) != null)
{
throw new BusinessException("当前微信已绑定账号");
}
await EnsureCustomerIdentityUniqueAsync(customerCode, phone, email, cancellationToken);
var customerRoleId = await GetCustomerRoleIdOrThrowAsync(cancellationToken);
var user = await userService.CreateAsync(new UserCreateDto
{
Name = customerName,
Account = phone,
Password = password,
Email = email,
Avatar = NormalizeOptional(input.Wechat.Avatar),
RoleIds = []
});
try
{
await userService.AssignRolesAsync(user.Id, [customerRoleId]);
var userId = await GetRbacUserMapIdOrThrowAsync(user.Id, cancellationToken);
var entity = new Customer
{
UserId = userId,
CustomerCode = customerCode,
CustomerName = customerName,
Phone = phone,
Email = email,
Password = OmsPasswordHelper.ToMd5(password),
WechatOpenid = input.Wechat.Openid,
WechatNickname = NormalizeOptional(input.Wechat.Nickname),
WechatAvatar = NormalizeOptional(input.Wechat.Avatar),
Status = CustomerStatus.Normal
};
await customerRepository.InsertAsync(entity);
}
catch
{
await TryRollbackRbacUserAsync(user.Id);
throw;
}
}
/// <summary>
/// 微信授权绑定已有客户。
/// </summary>
[HttpPost("/wechat/customer/bind")]
public async Task BindAsync(
[FromBody] WeChatCustomerBindInput input,
CancellationToken cancellationToken = default)
{
ValidateWechatProfile(input.Wechat);
var account = NormalizeRequired(input.Account, "账号");
var password = NormalizeRequired(input.Password, "密码");
if (await customerRepository.GetByWechatOpenidAsync(input.Wechat.Openid, cancellationToken) != null)
{
throw new BusinessException("当前微信已绑定账号");
}
var customer = await FindCustomerByAccountAsync(account, cancellationToken);
if (customer == null)
{
throw new BusinessException("未查询到账号信息");
}
if (customer.ParentCustomerId.HasValue)
{
throw new BusinessException("子客户不支持绑定微信");
}
if (!string.IsNullOrWhiteSpace(customer.WechatOpenid))
{
throw new BusinessException("当前账号已绑定微信");
}
ValidateMainCustomerStatus(customer);
if (!string.Equals(customer.Password, OmsPasswordHelper.ToMd5(password), StringComparison.OrdinalIgnoreCase))
{
throw new BusinessException("账号或密码错误");
}
customer.WechatOpenid = input.Wechat.Openid;
customer.WechatNickname = NormalizeOptional(input.Wechat.Nickname) ?? customer.WechatNickname;
customer.WechatAvatar = NormalizeOptional(input.Wechat.Avatar) ?? customer.WechatAvatar;
await customerRepository.UpdateAsync(customer, cancellationToken);
}
private static void ValidateWechatProfile(WeChatProfileInput wechat)
{
if (string.IsNullOrWhiteSpace(wechat.Openid))
{
throw new BusinessException("缺少 Openid 授权信息");
}
}
private async Task EnsureCustomerIdentityUniqueAsync(
string customerCode,
string phone,
string? email,
CancellationToken cancellationToken)
{
if (await customerRepository.GetByCustomerCodeAsync(customerCode, cancellationToken) != null)
{
throw new BusinessException("客户编号已存在");
}
if (await customerRepository.GetByPhoneAsync(phone, cancellationToken) != null)
{
throw new BusinessException("手机号已存在");
}
if (email != null && await customerRepository.GetByEmailAsync(email, cancellationToken) != null)
{
throw new BusinessException("邮箱已存在");
}
}
private async Task<Customer?> FindCustomerByAccountAsync(string account, CancellationToken cancellationToken)
{
var customer = await customerRepository.GetByPhoneAsync(account, cancellationToken);
if (customer != null)
{
return customer;
}
customer = await customerRepository.GetByEmailAsync(account, cancellationToken);
if (customer != null)
{
return customer;
}
return await customerRepository.GetByCustomerCodeAsync(account, cancellationToken);
}
private async Task<Guid> GetCustomerRoleIdOrThrowAsync(CancellationToken cancellationToken)
{
var roles = await roleService.GetAllRolesAsync();
var role = roles.FirstOrDefault(x => string.Equals(x.Code, CustomerRoleCode, StringComparison.OrdinalIgnoreCase));
if (role == null)
{
throw new BusinessException("客户角色未初始化");
}
return role.Id;
}
private async Task<int> GetRbacUserMapIdOrThrowAsync(Guid id, CancellationToken cancellationToken)
{
var userId = await rbacDbContext.Database.SqlQuery<int?>(
$"select `userid` as `Value` from `user` where `Id` = {id}")
.FirstOrDefaultAsync(cancellationToken);
if (!userId.HasValue || userId.Value <= 0)
{
throw new BusinessException("用户编号创建失败");
}
return userId.Value;
}
private async Task TryRollbackRbacUserAsync(Guid userId)
{
try
{
await userService.DeleteAsync(userId);
}
catch
{
// ignore rollback failure
}
}
private static void ValidateMainCustomerStatus(Customer customer)
{
if (customer.Status == CustomerStatus.Disabled)
{
throw new BusinessException("客户已禁用");
}
if (customer.Status == CustomerStatus.Frozen)
{
throw new BusinessException("客户已冻结");
}
}
private static string NormalizeRequired(string? value, string fieldName)
{
var normalized = value?.Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
throw new BusinessException($"{fieldName}不能为空");
}
return normalized;
}
private static string? NormalizeOptional(string? value)
{
var normalized = value?.Trim();
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
}
}

View File

@ -0,0 +1,130 @@
using Fake.Application;
using Fake.Domain.Exceptions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using SimpleAdmin.Api.WeChat.Dtos;
using SimpleAdmin.Api.WeChat.Options;
using SimpleAdmin.Api.WeChat.Services;
namespace SimpleAdmin.Api.WeChat;
/// <summary>
/// 微信公众号服务器回调与登录二维码。
/// </summary>
[AllowAnonymous]
[ApiExplorerSettings(GroupName = "WeChat")]
public class WeChatMpAppService(
IOptions<WeChatMpOptions> options,
IWeChatMpApiClient weChatMpApiClient,
IWeChatEventHandler eventHandler,
ILogger<WeChatMpAppService> logger) : ApplicationService
{
/// <summary>
/// 微信服务器接入验签。
/// </summary>
[HttpGet("/wechat/mp/{appId}")]
[Produces("text/plain")]
public ContentResult ValidateAsync(
[FromRoute] string appId,
[FromQuery] string? signature,
[FromQuery] string? timestamp,
[FromQuery] string? nonce,
[FromQuery] string? echostr)
{
EnsureAppId(appId);
if (string.IsNullOrWhiteSpace(signature)
|| string.IsNullOrWhiteSpace(timestamp)
|| string.IsNullOrWhiteSpace(nonce)
|| string.IsNullOrWhiteSpace(echostr))
{
return Content("error", "text/plain; charset=utf-8");
}
var valid = WeChatSignatureHelper.Validate(options.Value.Token, timestamp, nonce, signature);
return Content(valid ? echostr : "error", "text/plain; charset=utf-8");
}
/// <summary>
/// 微信消息/事件回调。
/// </summary>
[HttpPost("/wechat/mp/{appId}")]
[Consumes("text/xml", "application/xml", "text/plain")]
[Produces("application/xml")]
public async Task<ContentResult> MessageAsync(
[FromRoute] string appId,
[FromQuery] string signature,
[FromQuery] string timestamp,
[FromQuery] string nonce,
[FromQuery] string? openid,
[FromQuery(Name = "encrypt_type")] string? encryptType,
[FromQuery(Name = "msg_signature")] string? msgSignature,
CancellationToken cancellationToken = default)
{
EnsureAppId(appId);
if (!WeChatSignatureHelper.Validate(options.Value.Token, timestamp, nonce, signature))
{
logger.LogWarning("微信消息验签失败");
return Content("error", "text/plain; charset=utf-8");
}
using var reader = new StreamReader(HttpContext.Request.Body);
var requestBody = await reader.ReadToEndAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(requestBody))
{
return Content(string.Empty, "application/xml; charset=utf-8");
}
if (!string.IsNullOrWhiteSpace(encryptType)
&& !string.Equals(encryptType, "raw", StringComparison.OrdinalIgnoreCase))
{
logger.LogWarning("收到加密微信消息({EncryptType}),当前仅支持明文模式,请在公众平台切换为明文或后续启用 AES。", encryptType);
return Content(string.Empty, "application/xml; charset=utf-8");
}
var message = WeChatXmlMessageParser.Parse(requestBody);
if (message == null)
{
return Content(string.Empty, "application/xml; charset=utf-8");
}
var reply = await eventHandler.HandleAsync(message, cancellationToken);
return Content(reply ?? string.Empty, "application/xml; charset=utf-8");
}
/// <summary>
/// 获取 PC 扫码登录二维码。
/// </summary>
[HttpGet("/wechat/mp/login-qrcode")]
public async Task<WxLoginQrcodeOutput> GetLoginQrcodeAsync(
[FromQuery] string loginUuid,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(loginUuid))
{
throw new BusinessException("缺少 loginUuid");
}
const int expireSeconds = 600;
var qrCodeUrl = await weChatMpApiClient.CreateLoginQrCodeUrlAsync(loginUuid.Trim(), expireSeconds, cancellationToken);
return new WxLoginQrcodeOutput
{
QrCodeUrl = qrCodeUrl,
ExpiresIn = expireSeconds
};
}
private void EnsureAppId(string appId)
{
var configured = options.Value.AppId;
if (string.IsNullOrWhiteSpace(configured))
{
throw new BusinessException("未配置微信公众号 AppId");
}
if (!string.Equals(configured, appId, StringComparison.OrdinalIgnoreCase))
{
throw new BusinessException("公众号 AppId 不匹配");
}
}
}

View File

@ -0,0 +1,32 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using SimpleAdmin.Api.WeChat.Options;
using SimpleAdmin.Api.WeChat.Services;
namespace SimpleAdmin.Api.WeChat;
public static class WeChatServiceExtensions
{
public static IServiceCollection AddOmsWeChat(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<WeChatMpOptions>(configuration.GetSection(WeChatMpOptions.SectionName));
services.AddHttpClient(nameof(WeChatMpApiClient));
services.AddMemoryCache();
var redisConnection = configuration.GetConnectionString("Redis") ?? configuration["Redis:ConnectionString"];
if (!string.IsNullOrWhiteSpace(redisConnection))
{
services.AddStackExchangeRedisCache(options => options.Configuration = redisConnection);
}
else
{
services.AddDistributedMemoryCache();
}
services.AddSingleton<IWeChatMpApiClient, WeChatMpApiClient>();
services.AddSingleton<IWeChatLoginSessionStore, WeChatLoginSessionStore>();
services.AddTransient<IWeChatEventHandler, WeChatEventHandler>();
return services;
}
}

View File

@ -1,9 +1,22 @@
{
"UseLocalConfigs": true,
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Information"
}
},
"Redis": {
"ConnectionString": ""
},
"WeChat": {
"Mp": {
"AppId": "",
"Secret": "",
"Token": "",
"EncodingAesKey": "",
"OAuthRedirectBase": "https://oms.nanafa.com"
}
}
}