From e0216e96926cb6e46ea7168dd69f01a69733b557 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Thu, 30 Jul 2026 11:48:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=94=80=E5=94=AE=E4=BA=A7=E5=93=81?= =?UTF-8?q?=E8=B4=B8=E6=98=93=E6=9D=A1=E6=AC=BE=E4=B8=8E=E6=9F=A5=E4=BB=B7?= =?UTF-8?q?=E5=88=86=E7=BB=84=E5=B1=95=E7=A4=BA=EF=BC=8C=E5=B9=B6=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E7=94=A8=E6=88=B7=E8=B5=84=E6=96=99=20SQL=20=E7=A9=BA?= =?UTF-8?q?=E5=80=BC=E5=86=99=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 查价返回 DDP/DDU 与产品分组;用户更新微信二维码时避免 EF 使用 DBNull。 Co-authored-by: Cursor --- sql/20260730_sales_product_trade_terms.sql | 3 ++ src/Oms.Application/DataPerm/DataPermStore.cs | 3 +- .../Dtos/Channels/SalesProductDetailOutput.cs | 5 +++ .../Dtos/Channels/SalesProductSaveInput.cs | 5 +++ .../Dtos/Orders/OrderPriceCheckOutput.cs | 15 ++++++++ .../Rbac/UserProfileRepository.cs | 15 ++++---- .../Channels/SalesProductService.Helpers.cs | 9 +++++ .../Orders/OrderService.ParcelQuote.cs | 27 ++++++++++++-- .../Entities/SalesProduct.cs | 5 +++ .../Auth/AuthService.WeChatMp.cs | 33 +++++++++++++++++ .../WeChat/Services/WeChatMpApiClient.cs | 35 +++++++++++++++++++ 11 files changed, 145 insertions(+), 10 deletions(-) create mode 100644 sql/20260730_sales_product_trade_terms.sql diff --git a/sql/20260730_sales_product_trade_terms.sql b/sql/20260730_sales_product_trade_terms.sql new file mode 100644 index 0000000..9817317 --- /dev/null +++ b/sql/20260730_sales_product_trade_terms.sql @@ -0,0 +1,3 @@ +-- 销售产品:贸易条款 DDP / DDU(查价线路返回并展示于价卡) +ALTER TABLE `t_sales_product` + ADD COLUMN `TradeTerms` VARCHAR(16) NULL COMMENT '贸易条款:DDP/DDU' AFTER `MaxTimeliness`; diff --git a/src/Oms.Application/DataPerm/DataPermStore.cs b/src/Oms.Application/DataPerm/DataPermStore.cs index 135d25c..c2afb49 100644 --- a/src/Oms.Application/DataPerm/DataPermStore.cs +++ b/src/Oms.Application/DataPerm/DataPermStore.cs @@ -15,7 +15,8 @@ public class DataPermStore(FakeRbacDbContext dbContext, DataPermContextCache cac PropertyNameCaseInsensitive = true }; - private static object SqlParam(object? value) => value ?? DBNull.Value; + // EF Core 原始 SQL 参数不接受 DBNull,空值直接传 null + private static object? SqlParam(object? value) => value; public async Task GetUserDataPermAsync(Guid userId, CancellationToken cancellationToken = default) { diff --git a/src/Oms.Application/Dtos/Channels/SalesProductDetailOutput.cs b/src/Oms.Application/Dtos/Channels/SalesProductDetailOutput.cs index 50d519f..92eba36 100644 --- a/src/Oms.Application/Dtos/Channels/SalesProductDetailOutput.cs +++ b/src/Oms.Application/Dtos/Channels/SalesProductDetailOutput.cs @@ -35,6 +35,11 @@ public class SalesProductDetailOutput /// public int? MaxTimeliness { get; set; } + /// + /// 贸易条款:DDP / DDU。 + /// + public string? TradeTerms { get; set; } + /// /// 系统面单类型。 /// diff --git a/src/Oms.Application/Dtos/Channels/SalesProductSaveInput.cs b/src/Oms.Application/Dtos/Channels/SalesProductSaveInput.cs index d7d71ec..73ef3a7 100644 --- a/src/Oms.Application/Dtos/Channels/SalesProductSaveInput.cs +++ b/src/Oms.Application/Dtos/Channels/SalesProductSaveInput.cs @@ -37,6 +37,11 @@ public class SalesProductSaveInput /// public int? MaxTimeliness { get; set; } + /// + /// 贸易条款:DDP / DDU。 + /// + public string? TradeTerms { get; set; } + /// /// 系统面单类型(旧枚举字段,兼容展示)。 /// diff --git a/src/Oms.Application/Dtos/Orders/OrderPriceCheckOutput.cs b/src/Oms.Application/Dtos/Orders/OrderPriceCheckOutput.cs index 38f8cbf..dfad13e 100644 --- a/src/Oms.Application/Dtos/Orders/OrderPriceCheckOutput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderPriceCheckOutput.cs @@ -42,6 +42,21 @@ public class OrderPriceCheckOutput /// public string DeliveryTime { get; set; } = string.Empty; + /// + /// 贸易条款:DDP / DDU。 + /// + public string? TradeTerms { get; set; } + + /// + /// 销售产品分组编码(字典 sales_product_group)。 + /// + public string? ProductGroup { get; set; } + + /// + /// 销售产品分组名称(展示用)。 + /// + public string? ProductGroupName { get; set; } + /// /// 体积重量。 /// diff --git a/src/Oms.Application/Rbac/UserProfileRepository.cs b/src/Oms.Application/Rbac/UserProfileRepository.cs index 6a6d720..a90da01 100644 --- a/src/Oms.Application/Rbac/UserProfileRepository.cs +++ b/src/Oms.Application/Rbac/UserProfileRepository.cs @@ -93,10 +93,11 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende sql, [ userId.ToString(), - NormalizeOptional(profile.Phone) ?? (object)DBNull.Value, - NormalizeOptional(profile.EmployeeNo) ?? (object)DBNull.Value, - NormalizeOptional(profile.WechatId) ?? (object)DBNull.Value, - NormalizeOptional(profile.WechatQrCode) ?? (object)DBNull.Value, + // EF Core 参数不接受 DBNull,空值直接传 null 即可映射为 SQL NULL + (object?)NormalizeOptional(profile.Phone), + (object?)NormalizeOptional(profile.EmployeeNo), + (object?)NormalizeOptional(profile.WechatId), + (object?)NormalizeOptional(profile.WechatQrCode), ], cancellationToken); } @@ -119,9 +120,9 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende sql, [ userId.ToString(), - NormalizeOptional(profile.Phone) ?? (object)DBNull.Value, - NormalizeOptional(profile.WechatId) ?? (object)DBNull.Value, - NormalizeOptional(profile.WechatQrCode) ?? (object)DBNull.Value, + (object?)NormalizeOptional(profile.Phone), + (object?)NormalizeOptional(profile.WechatId), + (object?)NormalizeOptional(profile.WechatQrCode), ], cancellationToken); } diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs index efda9ae..859d4ee 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs @@ -209,6 +209,7 @@ public partial class SalesProductService ProductGroup = product.ProductGroup, MinTimeliness = product.MinTimeliness, MaxTimeliness = product.MaxTimeliness, + TradeTerms = product.TradeTerms, CustomerWaybill = product.CustomerWaybill, LabelTemplateId = product.LabelTemplateId, BlacklistAreaId = product.BlacklistAreaId, @@ -314,6 +315,7 @@ public partial class SalesProductService entity.ProductGroup = input.ProductGroup; entity.MinTimeliness = input.MinTimeliness; entity.MaxTimeliness = input.MaxTimeliness; + entity.TradeTerms = NormalizeTradeTerms(input.TradeTerms); entity.CustomerWaybill = input.CustomerWaybill; entity.LabelTemplateId = input.LabelTemplateId; entity.BlacklistAreaId = input.BlacklistAreaId; @@ -328,6 +330,12 @@ public partial class SalesProductService entity.Scheme = string.IsNullOrWhiteSpace(input.Scheme) ? AllocationSchemeCodes.CostLowest : input.Scheme; } + private static string? NormalizeTradeTerms(string? raw) + { + var value = (raw ?? string.Empty).Trim().ToUpperInvariant(); + return value is "DDP" or "DDU" ? value : null; + } + private static SalesProductListOutput MapListOutput(SalesProduct entity) { return new SalesProductListOutput @@ -365,6 +373,7 @@ public partial class SalesProductService ProductGroup = entity.ProductGroup, MinTimeliness = entity.MinTimeliness, MaxTimeliness = entity.MaxTimeliness, + TradeTerms = entity.TradeTerms, CustomerWaybill = entity.CustomerWaybill, LabelTemplateId = entity.LabelTemplateId, BlacklistAreaId = entity.BlacklistAreaId, diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs index f5fffad..fb2a1a8 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs @@ -164,8 +164,22 @@ public partial class OrderService } } - // 查价页面不展示「国内揽收」分组的产品 - var domesticPickupGroupCodes = (await DictRepository.GetListAsync(x => x.DictType == SalesProductGroupDictType)) + // 查价页面不展示「国内揽收」分组的产品;同时解析分组展示名 + var productGroupDictRows = await DictRepository.GetListAsync(x => x.DictType == SalesProductGroupDictType); + var productGroupNameMap = productGroupDictRows + .Where(x => !string.IsNullOrWhiteSpace(x.DictCode)) + .GroupBy(x => x.DictCode!.Trim(), StringComparer.OrdinalIgnoreCase) + .ToDictionary( + g => g.Key, + g => + { + var name = g.First().DictName?.Trim(); + return string.IsNullOrWhiteSpace(name) ? g.Key : name; + }, + StringComparer.OrdinalIgnoreCase); + productGroupNameMap[UngroupedProductGroupCode] = "未分组"; + + var domesticPickupGroupCodes = productGroupDictRows .Where(x => !string.IsNullOrWhiteSpace(x.DictName) && x.DictName.Trim().Contains(DomesticPickupGroupNameKeyword)) .Select(x => x.DictCode?.Trim()) @@ -236,6 +250,12 @@ public partial class OrderService var quote = attempt.Quote!; var summary = ParcelBillingCalculator.SummarizeQuoteFees(quote.FeeLines); var itemHint = PriceCheckHintBuilder.BuildItemHint(profile, quote.UsedZoneFallback); + var groupCode = string.IsNullOrWhiteSpace(product.ProductGroup) + ? UngroupedProductGroupCode + : product.ProductGroup.Trim(); + var groupName = productGroupNameMap.TryGetValue(groupCode, out var mappedName) + ? mappedName + : groupCode; result.Items.Add(new OrderPriceCheckOutput { Id = quote.ProductId, @@ -246,6 +266,9 @@ public partial class OrderService WarehouseId = warehouse.Id, Warehouse = warehouse.WarehouseName, DeliveryTime = $"{product.MinTimeliness ?? 0}-{product.MaxTimeliness ?? 0}", + TradeTerms = string.IsNullOrWhiteSpace(product.TradeTerms) ? null : product.TradeTerms.Trim().ToUpperInvariant(), + ProductGroup = groupCode, + ProductGroupName = groupName, VolumetricWeight = quote.VolumetricWeight, ChargeableWeight = quote.ChargeableWeight, CostFormula = summary.CostFormula, diff --git a/src/Oms.Domain/ChannelsAggregate/Entities/SalesProduct.cs b/src/Oms.Domain/ChannelsAggregate/Entities/SalesProduct.cs index 5b44626..c1f95e8 100644 --- a/src/Oms.Domain/ChannelsAggregate/Entities/SalesProduct.cs +++ b/src/Oms.Domain/ChannelsAggregate/Entities/SalesProduct.cs @@ -17,6 +17,11 @@ public class SalesProduct : OmsAuditedAggregateRoot public int? MaxTimeliness { get; set; } + /// + /// 贸易条款:DDP / DDU。 + /// + public string? TradeTerms { get; set; } + public string? CustomerWaybill { get; set; } /// diff --git a/src/SimpleAdmin.Api/Auth/AuthService.WeChatMp.cs b/src/SimpleAdmin.Api/Auth/AuthService.WeChatMp.cs index 623337b..9872afd 100644 --- a/src/SimpleAdmin.Api/Auth/AuthService.WeChatMp.cs +++ b/src/SimpleAdmin.Api/Auth/AuthService.WeChatMp.cs @@ -185,4 +185,37 @@ public partial class AuthService ExpiresIn = expireSeconds }; } + + /// + /// 同源代理微信 showqrcode 图片,便于微信内长按识别(避免跨域直连 mp.weixin.qq.com)。 + /// + [AllowAnonymous] + [HttpGet("/wechat/mp/showqrcode")] + [ApiExplorerSettings(GroupName = "WeChat")] + public async Task GetWeChatShowQrCodeAsync( + [FromQuery] string ticket, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(ticket)) + { + return new BadRequestObjectResult("ticket required"); + } + + try + { + var (bytes, contentType) = await WeChatMpApiClient.DownloadShowQrCodeAsync( + ticket, + cancellationToken); + // 短缓存:同一次 ticket 有效期内可复用,减少重复拉取 + return new FileContentResult(bytes, contentType) + { + EnableRangeProcessing = false + }; + } + catch (Exception ex) + { + WeChatLogger.LogWarning(ex, "代理下载微信登录二维码失败"); + return new NotFoundObjectResult("qrcode not found"); + } + } } diff --git a/src/SimpleAdmin.Api/WeChat/Services/WeChatMpApiClient.cs b/src/SimpleAdmin.Api/WeChat/Services/WeChatMpApiClient.cs index b54c44b..155336a 100644 --- a/src/SimpleAdmin.Api/WeChat/Services/WeChatMpApiClient.cs +++ b/src/SimpleAdmin.Api/WeChat/Services/WeChatMpApiClient.cs @@ -10,6 +10,13 @@ public interface IWeChatMpApiClient { Task CreateLoginQrCodeUrlAsync(string loginUuid, int expireSeconds, CancellationToken cancellationToken = default); + /// + /// 代理下载公众号 showqrcode 图片,供 H5 同源展示以提升微信长按识别成功率。 + /// + Task<(byte[] Content, string ContentType)> DownloadShowQrCodeAsync( + string ticket, + CancellationToken cancellationToken = default); + Task GetOAuthAccessTokenAsync(string code, CancellationToken cancellationToken = default); Task GetOAuthUserInfoAsync(string accessToken, string openId, CancellationToken cancellationToken = default); @@ -104,6 +111,34 @@ internal sealed class WeChatMpApiClient( return $"https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket={Uri.EscapeDataString(result.Ticket)}"; } + public async Task<(byte[] Content, string ContentType)> DownloadShowQrCodeAsync( + string ticket, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(ticket)) + { + throw new ArgumentException("ticket 不能为空", nameof(ticket)); + } + + var client = httpClientFactory.CreateClient(nameof(WeChatMpApiClient)); + var url = $"https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket={Uri.EscapeDataString(ticket.Trim())}"; + using var response = await client.GetAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); + var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken); + if (bytes.Length == 0) + { + throw new InvalidOperationException("微信二维码图片为空"); + } + + var contentType = response.Content.Headers.ContentType?.MediaType; + if (string.IsNullOrWhiteSpace(contentType) || contentType.Contains("text", StringComparison.OrdinalIgnoreCase)) + { + contentType = "image/jpeg"; + } + + return (bytes, contentType); + } + public async Task GetOAuthAccessTokenAsync(string code, CancellationToken cancellationToken = default) { var mp = await configProvider.GetAsync(cancellationToken);