diff --git a/sql/20260730_customer_price_tier_key.sql b/sql/20260730_customer_price_tier_key.sql new file mode 100644 index 0000000..cbc9b9f --- /dev/null +++ b/sql/20260730_customer_price_tier_key.sql @@ -0,0 +1,46 @@ +-- 价区键迁到客户:与会员等级解耦 +-- 新注册默认价区键/会员等级均为「普通会员」;计价时空键回落「普通客户」 + +-- 1) 客户表增加价区键 +ALTER TABLE `t_customer` + ADD COLUMN `PriceTierKey` VARCHAR(64) NULL COMMENT '价区键(卖价 CustomerGroupValues JSON 键)' AFTER `MemberLevelId`; + +-- 2) 从原会员等级价区键回填(依赖本脚本执行前 t_member_level 仍有 PriceTierKey) +UPDATE `t_customer` c +INNER JOIN `t_member_level` ml ON ml.`Id` = c.`MemberLevelId` AND ml.`IsDeleted` = 0 +SET c.`PriceTierKey` = NULLIF(TRIM(ml.`PriceTierKey`), '') +WHERE c.`IsDeleted` = 0 + AND (c.`PriceTierKey` IS NULL OR TRIM(c.`PriceTierKey`) = '') + AND ml.`PriceTierKey` IS NOT NULL + AND TRIM(ml.`PriceTierKey`) <> ''; + +-- 3) 仍为空的主客户:默认「普通会员」(与新注册一致) +UPDATE `t_customer` +SET `PriceTierKey` = '普通会员' +WHERE `IsDeleted` = 0 + AND (`ParentCustomerId` IS NULL OR `ParentCustomerId` = 0) + AND (`PriceTierKey` IS NULL OR TRIM(`PriceTierKey`) = ''); + +-- 4) 子客户跟随主客户价区 +UPDATE `t_customer` sub +INNER JOIN `t_customer` main ON main.`Id` = sub.`ParentCustomerId` AND main.`IsDeleted` = 0 +SET sub.`PriceTierKey` = main.`PriceTierKey` +WHERE sub.`IsDeleted` = 0 + AND sub.`ParentCustomerId` IS NOT NULL + AND sub.`ParentCustomerId` <> 0; + +-- 5) 删除会员等级上的价区键列(须在插入默认等级前执行,避免 NOT NULL 约束) +ALTER TABLE `t_member_level` DROP COLUMN `PriceTierKey`; + +-- 6) 确保存在启用的「普通会员」等级(供新注册默认挂靠) +INSERT INTO `t_member_level` ( + `LevelCode`, `LevelName`, `MinPoints`, `ExtraDiscountRate`, `Sort`, + `Status`, `IsDeleted`, `CreateTime` +) +SELECT '普通会员', '普通会员', 0, 0, 0, 2, 0, NOW() +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `t_member_level` + WHERE `IsDeleted` = 0 + AND (`LevelName` = '普通会员' OR `LevelCode` = '普通会员') +); diff --git a/src/Oms.Application/Dtos/Customers/CreateCustomerInput.cs b/src/Oms.Application/Dtos/Customers/CreateCustomerInput.cs index 2a3d975..cfbef33 100644 --- a/src/Oms.Application/Dtos/Customers/CreateCustomerInput.cs +++ b/src/Oms.Application/Dtos/Customers/CreateCustomerInput.cs @@ -106,6 +106,12 @@ public class CreateCustomerInput /// public int? MemberLevelId { get; set; } + /// + /// 价区键(卖价 CustomerGroupValues JSON 键)。空则默认「普通会员」。 + /// + [StringLength(64)] + public string? PriceTierKey { get; set; } + /// /// 注册邀请码(引荐绑定,仅新客户有效)。 /// diff --git a/src/Oms.Application/Dtos/Customers/CustomerOutput.cs b/src/Oms.Application/Dtos/Customers/CustomerOutput.cs index a93c1a2..014262e 100644 --- a/src/Oms.Application/Dtos/Customers/CustomerOutput.cs +++ b/src/Oms.Application/Dtos/Customers/CustomerOutput.cs @@ -102,6 +102,11 @@ public class CustomerOutput /// public string? MemberLevelName { get; set; } + /// + /// 价区键。 + /// + public string? PriceTierKey { get; set; } + /// /// 最后登录 IP。 /// diff --git a/src/Oms.Application/Dtos/Customers/CustomerPaginatedOutput.cs b/src/Oms.Application/Dtos/Customers/CustomerPaginatedOutput.cs index 036d620..63fb35e 100644 --- a/src/Oms.Application/Dtos/Customers/CustomerPaginatedOutput.cs +++ b/src/Oms.Application/Dtos/Customers/CustomerPaginatedOutput.cs @@ -102,6 +102,11 @@ public class CustomerPaginatedOutput /// public string? MemberLevelName { get; set; } + /// + /// 价区键。 + /// + public string? PriceTierKey { get; set; } + /// /// 最后登录 IP。 /// diff --git a/src/Oms.Application/Dtos/Customers/UpdateCustomerInput.cs b/src/Oms.Application/Dtos/Customers/UpdateCustomerInput.cs index 5690a39..df706b0 100644 --- a/src/Oms.Application/Dtos/Customers/UpdateCustomerInput.cs +++ b/src/Oms.Application/Dtos/Customers/UpdateCustomerInput.cs @@ -105,4 +105,10 @@ public class UpdateCustomerInput /// public int? MemberLevelId { get; set; } + /// + /// 价区键(卖价 CustomerGroupValues JSON 键)。空则回落「普通客户」。 + /// + [StringLength(64)] + public string? PriceTierKey { get; set; } + } diff --git a/src/Oms.Application/Dtos/Marketing/MemberLevelOutput.cs b/src/Oms.Application/Dtos/Marketing/MemberLevelOutput.cs index 97a9d7f..773eed9 100644 --- a/src/Oms.Application/Dtos/Marketing/MemberLevelOutput.cs +++ b/src/Oms.Application/Dtos/Marketing/MemberLevelOutput.cs @@ -15,8 +15,6 @@ public class MemberLevelOutput public int MinPoints { get; set; } - public string PriceTierKey { get; set; } = string.Empty; - public decimal ExtraDiscountRate { get; set; } public int Sort { get; set; } diff --git a/src/Oms.Application/Dtos/Marketing/MemberLevelSaveInput.cs b/src/Oms.Application/Dtos/Marketing/MemberLevelSaveInput.cs index f183230..f2f35bd 100644 --- a/src/Oms.Application/Dtos/Marketing/MemberLevelSaveInput.cs +++ b/src/Oms.Application/Dtos/Marketing/MemberLevelSaveInput.cs @@ -18,9 +18,6 @@ public class MemberLevelSaveInput public int MinPoints { get; set; } - [Required] - public string PriceTierKey { get; set; } = string.Empty; - public decimal ExtraDiscountRate { get; set; } public int Sort { get; set; } diff --git a/src/Oms.Application/Services/Customers/CustomerService.cs b/src/Oms.Application/Services/Customers/CustomerService.cs index 5428b8f..5bb0d7c 100644 --- a/src/Oms.Application/Services/Customers/CustomerService.cs +++ b/src/Oms.Application/Services/Customers/CustomerService.cs @@ -226,7 +226,8 @@ public class CustomerService : ApplicationService await UserService.AssignRolesAsync(user.Id, [customerRoleId]); var userId = await GetRbacUserMapIdOrThrowAsync(user.Id); var entity = BuildCustomerEntity(input, normalized, null, userId); - entity.MemberLevelId = await ResolveMemberLevelIdForSaveAsync(input.MemberLevelId); + entity.MemberLevelId = await ResolveMemberLevelIdForCreateAsync(input.MemberLevelId); + entity.PriceTierKey = MarketingCustomerHelper.ResolveNewCustomerPriceTierKey(input.PriceTierKey); await MarketingReferralBinder.BindReferrerOnCreateAsync( entity, input.InviteCode, @@ -274,7 +275,11 @@ public class CustomerService : ApplicationService roleId = await CreateSubCustomerRoleAsync(userId, normalized.CustomerName, permissions); await UserService.AssignRolesAsync(user.Id, [roleId.Value]); var entity = BuildCustomerEntity(input, normalized, mainCustomer.Id, userId); - entity.MemberLevelId = await ResolveMemberLevelIdForSaveAsync(input.MemberLevelId); + // 子客户跟随主客户价区与等级展示,计价走主客户 + entity.MemberLevelId = mainCustomer.MemberLevelId + ?? await ResolveMemberLevelIdForCreateAsync(input.MemberLevelId); + entity.PriceTierKey = MarketingCustomerHelper.NormalizePriceTierKeyOrDefault( + mainCustomer.PriceTierKey ?? input.PriceTierKey); await CustomerRepository.InsertAsync(entity); } catch @@ -320,6 +325,7 @@ public class CustomerService : ApplicationService entity.CustomerService = NormalizeOptionalText(input.CustomerService); entity.CustomerGroup = input.CustomerGroup; entity.MemberLevelId = await ResolveMemberLevelIdForSaveAsync(input.MemberLevelId); + entity.PriceTierKey = MarketingCustomerHelper.NormalizePriceTierKeyOrDefault(input.PriceTierKey); await CustomerRepository.UpdateAsync(entity); } @@ -570,7 +576,8 @@ public class CustomerService : ApplicationService Salesman = NormalizeOptionalText(input.Salesman), CustomerService = NormalizeOptionalText(input.CustomerService), CustomerGroup = input.CustomerGroup, - MemberLevelId = input.MemberLevelId + MemberLevelId = input.MemberLevelId, + PriceTierKey = input.PriceTierKey }; } @@ -586,6 +593,17 @@ public class CustomerService : ApplicationService return levels.ToDictionary(x => x.Id, x => x.LevelName); } + /// 创建时:未指定则默认「普通会员」等级。 + private async Task ResolveMemberLevelIdForCreateAsync(int? memberLevelId) + { + if (memberLevelId.HasValue) + { + return await ResolveMemberLevelIdForSaveAsync(memberLevelId); + } + + return await MarketingCustomerHelper.ResolveDefaultMemberLevelIdAsync(MemberLevelRepository); + } + private async Task ResolveMemberLevelIdForSaveAsync(int? memberLevelId) { if (!memberLevelId.HasValue) diff --git a/src/Oms.Application/Services/Marketing/MarketingMemberLevelService.cs b/src/Oms.Application/Services/Marketing/MarketingMemberLevelService.cs index 7888f20..ee89690 100644 --- a/src/Oms.Application/Services/Marketing/MarketingMemberLevelService.cs +++ b/src/Oms.Application/Services/Marketing/MarketingMemberLevelService.cs @@ -115,15 +115,13 @@ public class MarketingMemberLevelService : ApplicationService } return x => x.LevelCode.Contains(keyword) - || x.LevelName.Contains(keyword) - || x.PriceTierKey.Contains(keyword); + || x.LevelName.Contains(keyword); } private async Task ValidateSaveAsync(MemberLevelSaveInput input, int? excludeId) { var levelCode = input.LevelCode.Trim(); var levelName = input.LevelName.Trim(); - var priceTierKey = input.PriceTierKey.Trim(); if (string.IsNullOrWhiteSpace(levelCode)) { throw new BusinessException("等级编码不能为空"); @@ -134,11 +132,6 @@ public class MarketingMemberLevelService : ApplicationService throw new BusinessException("等级名称不能为空"); } - if (string.IsNullOrWhiteSpace(priceTierKey)) - { - throw new BusinessException("价区键不能为空"); - } - if (input.ExtraDiscountRate < 0 || input.ExtraDiscountRate >= 1) { throw new BusinessException("额外折扣率须在 0~1 之间(不含 1)"); @@ -155,7 +148,6 @@ public class MarketingMemberLevelService : ApplicationService entity.LevelCode = input.LevelCode.Trim(); entity.LevelName = input.LevelName.Trim(); entity.MinPoints = input.MinPoints; - entity.PriceTierKey = input.PriceTierKey.Trim(); entity.ExtraDiscountRate = input.ExtraDiscountRate; entity.Sort = input.Sort; entity.IconUrl = string.IsNullOrWhiteSpace(input.IconUrl) ? null : input.IconUrl.Trim(); @@ -173,7 +165,6 @@ public class MarketingMemberLevelService : ApplicationService LevelCode = entity.LevelCode, LevelName = entity.LevelName, MinPoints = entity.MinPoints, - PriceTierKey = entity.PriceTierKey, ExtraDiscountRate = entity.ExtraDiscountRate, Sort = entity.Sort, IconUrl = entity.IconUrl, diff --git a/src/Oms.Application/Services/Marketing/MarketingPortalMemberService.cs b/src/Oms.Application/Services/Marketing/MarketingPortalMemberService.cs index 5b132cc..4c382be 100644 --- a/src/Oms.Application/Services/Marketing/MarketingPortalMemberService.cs +++ b/src/Oms.Application/Services/Marketing/MarketingPortalMemberService.cs @@ -57,7 +57,7 @@ public class MarketingPortalMemberService : ApplicationService { MemberLevelId = currentLevel?.Id, MemberLevelName = currentLevel?.LevelName, - PriceTierKey = currentLevel?.PriceTierKey, + PriceTierKey = MarketingCustomerHelper.NormalizePriceTierKeyOrDefault(mainCustomer.PriceTierKey), TotalPoints = account.TotalPoints, AvailablePoints = account.AvailablePoints, NextLevelId = nextLevel?.Id, diff --git a/src/Oms.Application/Shared/MarketingCustomerHelper.cs b/src/Oms.Application/Shared/MarketingCustomerHelper.cs index 66152bd..830a737 100644 --- a/src/Oms.Application/Shared/MarketingCustomerHelper.cs +++ b/src/Oms.Application/Shared/MarketingCustomerHelper.cs @@ -5,6 +5,7 @@ using Oms.Domain.CustomersAggregate.Enums; using Oms.Domain.CustomersAggregate.Repositories; using Oms.Domain.MarketingAggregate.Entities; using Oms.Domain.MarketingAggregate.Repositories; +using Oms.Domain.Shared.Enums; namespace Oms.Application.Shared; @@ -13,6 +14,15 @@ namespace Oms.Application.Shared; /// public static class MarketingCustomerHelper { + /// 缺省/空价区键回落(卖价 JSON 标准键)。 + public const string FallbackPriceTierKey = "普通客户"; + + /// 新注册客户默认价区键(与会员等级名称对齐为「普通会员」)。 + public const string DefaultNewCustomerPriceTierKey = "普通会员"; + + /// 新注册客户默认会员等级编码/名称。 + public const string DefaultMemberLevelName = "普通会员"; + public static async Task ResolveMarketingMainCustomerAsync( ICustomerRepository customerRepository, Customer sessionCustomer, @@ -46,7 +56,7 @@ public static class MarketingCustomerHelper } /// - /// 解析用于计价的价区键:主客户 MemberLevel → PriceTierKey;无等级时回退 CustomerGroup 映射。 + /// 解析用于计价的价区键:主客户.PriceTierKey;空则回落「普通客户」。与会员等级无关。 /// public static async Task ResolvePriceTierKeyAsync( ICustomerRepository customerRepository, @@ -54,15 +64,18 @@ public static class MarketingCustomerHelper int? customerId, CancellationToken cancellationToken = default) { + // memberLevelRepository 保留参数以兼容既有调用方签名;价区已与等级解耦。 + _ = memberLevelRepository; + if (!customerId.HasValue || customerId.Value <= 0) { - return null; + return FallbackPriceTierKey; } var sessionCustomer = await customerRepository.FirstOrDefaultAsync(x => x.Id == customerId.Value, cancellationToken); if (sessionCustomer == null) { - return null; + return FallbackPriceTierKey; } var mainCustomer = sessionCustomer.ParentCustomerId.HasValue @@ -70,39 +83,63 @@ public static class MarketingCustomerHelper : sessionCustomer; if (mainCustomer == null) { - return null; + return FallbackPriceTierKey; } - if (mainCustomer.MemberLevelId.HasValue) + if (!string.IsNullOrWhiteSpace(mainCustomer.PriceTierKey)) { - var level = await memberLevelRepository.FirstOrDefaultAsync( - x => x.Id == mainCustomer.MemberLevelId.Value, - cancellationToken); - if (level != null && !string.IsNullOrWhiteSpace(level.PriceTierKey)) - { - return level.PriceTierKey.Trim(); - } + return mainCustomer.PriceTierKey.Trim(); } - return MapCustomerGroupToPriceTierKey(mainCustomer.CustomerGroup); + // 兼容未回填客户:短暂回退遗留 CustomerGroup,最终仍归一到标准键 + return MapCustomerGroupToPriceTierKey(mainCustomer.CustomerGroup) ?? FallbackPriceTierKey; + } + + public static string NormalizePriceTierKeyOrDefault(string? priceTierKey) + { + return string.IsNullOrWhiteSpace(priceTierKey) ? FallbackPriceTierKey : priceTierKey.Trim(); + } + + /// 新客默认价区键。 + public static string ResolveNewCustomerPriceTierKey(string? input) => + string.IsNullOrWhiteSpace(input) ? DefaultNewCustomerPriceTierKey : input.Trim(); + + /// + /// 解析默认「普通会员」等级 Id;不存在则取启用等级中 MinPoints 最低者。 + /// + public static async Task ResolveDefaultMemberLevelIdAsync( + IMemberLevelRepository memberLevelRepository, + CancellationToken cancellationToken = default) + { + var named = await memberLevelRepository.FirstOrDefaultAsync( + x => x.Status == EnableStatus.Enabled + && (x.LevelName == DefaultMemberLevelName || x.LevelCode == DefaultMemberLevelName), + cancellationToken); + if (named != null) + { + return named.Id; + } + + var levels = await memberLevelRepository.GetAllEnabledOrderedAsync(cancellationToken); + return levels.OrderBy(x => x.MinPoints).ThenBy(x => x.Sort).ThenBy(x => x.Id).FirstOrDefault()?.Id; } public static string? MapCustomerGroupToPriceTierKey(CustomerGroup? customerGroup) { if (!customerGroup.HasValue) { - return "普通客户"; + return FallbackPriceTierKey; } return customerGroup.Value switch { - CustomerGroup.Normal => "普通客户", + CustomerGroup.Normal => FallbackPriceTierKey, CustomerGroup.Vip1 => "客户组1", CustomerGroup.Vip2 => "客户组2", CustomerGroup.Vip3 => "客户组3", CustomerGroup.Vip4 => "客户组4", CustomerGroup.Vip5 => "客户组5", - _ => "普通客户" + _ => FallbackPriceTierKey }; } } diff --git a/src/Oms.Application/Shared/MarketingOrderHelper.cs b/src/Oms.Application/Shared/MarketingOrderHelper.cs index a21a3b8..ed46a75 100644 --- a/src/Oms.Application/Shared/MarketingOrderHelper.cs +++ b/src/Oms.Application/Shared/MarketingOrderHelper.cs @@ -51,8 +51,7 @@ public class MarketingOrderHelper : IScopedDependency level = await _memberLevelRepository.FirstOrDefaultAsync(x => x.Id == mainCustomer.MemberLevelId.Value, cancellationToken); } - var priceTierKey = level?.PriceTierKey - ?? MarketingCustomerHelper.MapCustomerGroupToPriceTierKey(mainCustomer.CustomerGroup); + var priceTierKey = MarketingCustomerHelper.NormalizePriceTierKeyOrDefault(mainCustomer.PriceTierKey); var couponInput = await _couponManager.ResolveCouponDiscountInputAsync(couponInstanceId, mainCustomerId, cancellationToken); return MarketingBillingCalculator.Calculate( diff --git a/src/Oms.Domain/CustomersAggregate/Entities/Customer.cs b/src/Oms.Domain/CustomersAggregate/Entities/Customer.cs index b97281f..0f73967 100644 --- a/src/Oms.Domain/CustomersAggregate/Entities/Customer.cs +++ b/src/Oms.Domain/CustomersAggregate/Entities/Customer.cs @@ -47,6 +47,12 @@ public class Customer : OmsAuditedAggregateRoot public int? MemberLevelId { get; set; } + /// + /// 价区键,对应卖价 CustomerGroupValues JSON 键(如 普通客户、VIP1)。与会员等级无关。 + /// + [SugarColumn(Length = 64, IsNullable = true)] + public string? PriceTierKey { get; set; } + public decimal CommissionBalance { get; set; } public string? LastLoginIp { get; set; } diff --git a/src/Oms.Domain/MarketingAggregate/Entities/MemberLevel.cs b/src/Oms.Domain/MarketingAggregate/Entities/MemberLevel.cs index 28aacc9..21b51f3 100644 --- a/src/Oms.Domain/MarketingAggregate/Entities/MemberLevel.cs +++ b/src/Oms.Domain/MarketingAggregate/Entities/MemberLevel.cs @@ -13,11 +13,6 @@ public class MemberLevel : OmsAuditedAggregateRoot public int MinPoints { get; set; } - /// - /// 价区键,对应卖价 CustomerGroupValues JSON 键(如 普通客户、VIP1)。 - /// - public string PriceTierKey { get; set; } = string.Empty; - public decimal ExtraDiscountRate { get; set; } public int Sort { get; set; } diff --git a/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs b/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs index 0d76177..cb26867 100644 --- a/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs +++ b/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs @@ -15,6 +15,11 @@ public static class SystemOtherConfigCodes /// public const string WeChatMp = "wechat_mp"; + /// + /// 微信公众号关注跳转链接(BaseUrl=微信内可打开的公众号主页/名片/图文引导链接)。 + /// + public const string WeChatMpFollow = "wechat_mp_follow"; + /// /// 店铺授权公共配置(BaseUrl=前端回调根地址 FrontendBaseUrl,Remark=StateSecret 可选)。 /// diff --git a/src/SimpleAdmin.Api.IntegrationTests/WeChatRouteIntegrationTests.cs b/src/SimpleAdmin.Api.IntegrationTests/WeChatRouteIntegrationTests.cs index 25dc374..ba7a9bc 100644 --- a/src/SimpleAdmin.Api.IntegrationTests/WeChatRouteIntegrationTests.cs +++ b/src/SimpleAdmin.Api.IntegrationTests/WeChatRouteIntegrationTests.cs @@ -32,6 +32,7 @@ public class WeChatRouteIntegrationTests host.RoutePatterns.Should().Contain("apiwechat/verification"); host.RoutePatterns.Should().Contain("wechat/mp/{appId}"); host.RoutePatterns.Should().Contain("wechat/mp/login-qrcode"); + host.RoutePatterns.Should().Contain("wechat/mp/follow-qrcode"); host.RoutePatterns.Should().Contain("wechat/oauth/user-info"); host.RoutePatterns.Should().Contain("wechat/customer/register"); host.RoutePatterns.Should().Contain("wechat/customer/bind"); diff --git a/src/SimpleAdmin.Api/Auth/AuthService.WeChatMp.cs b/src/SimpleAdmin.Api/Auth/AuthService.WeChatMp.cs index 9872afd..05bc350 100644 --- a/src/SimpleAdmin.Api/Auth/AuthService.WeChatMp.cs +++ b/src/SimpleAdmin.Api/Auth/AuthService.WeChatMp.cs @@ -186,6 +186,30 @@ public partial class AuthService }; } + /// + /// 获取注册/绑定成功后跳转的公众号链接(微信内直接打开即可关注,无需长按扫码)。 + /// + [AllowAnonymous] + [HttpGet("/wechat/mp/follow-qrcode")] + [ApiExplorerSettings(GroupName = "WeChat")] + public async Task GetWeChatFollowQrcodeAsync( + CancellationToken cancellationToken = default) + { + var jumpUrl = await WeChatMpConfigProvider.GetFollowJumpUrlAsync(cancellationToken); + if (string.IsNullOrWhiteSpace(jumpUrl)) + { + throw new BusinessException( + "未配置公众号关注跳转链接。请在「其他配置」新增 wechat_mp_follow,BaseUrl 填公众号主页/名片链接。"); + } + + return new WxFollowQrcodeOutput + { + JumpUrl = jumpUrl, + QrCodeUrl = jumpUrl, + Scene = WeChatQrSceneParser.FollowScene + }; + } + /// /// 同源代理微信 showqrcode 图片,便于微信内长按识别(避免跨域直连 mp.weixin.qq.com)。 /// diff --git a/src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs b/src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs index d22a6d7..ea4808c 100644 --- a/src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs +++ b/src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs @@ -12,6 +12,19 @@ public class WxLoginQrcodeOutput public int ExpiresIn { get; set; } = 600; } +/// 引导关注公众号:优先返回可直接跳转的公众号链接。 +public class WxFollowQrcodeOutput +{ + /// 微信内 location 跳转用(公众号主页 / 名片 / 引导图文)。 + public string JumpUrl { get; set; } = string.Empty; + + /// 兼容旧字段;与 JumpUrl 相同。 + public string QrCodeUrl { get; set; } = string.Empty; + + /// scene_str,固定 oms_follow(仅当走扫码兜底时有意义)。 + public string Scene { get; set; } = "oms_follow"; +} + public class WxLoginStatusOutput { public bool Ready { get; set; } diff --git a/src/SimpleAdmin.Api/WeChat/Options/WeChatMpOptions.cs b/src/SimpleAdmin.Api/WeChat/Options/WeChatMpOptions.cs index bb7f8e5..58d8ecc 100644 --- a/src/SimpleAdmin.Api/WeChat/Options/WeChatMpOptions.cs +++ b/src/SimpleAdmin.Api/WeChat/Options/WeChatMpOptions.cs @@ -19,4 +19,10 @@ public class WeChatMpOptions /// OAuth 回调基础域名,如 https://oms.nanafa.com /// public string OAuthRedirectBase { get; set; } = string.Empty; + + /// + /// 注册/绑定成功后跳转的公众号主页或引导关注链接(微信内打开即可关注)。 + /// 可在公众号后台复制「账号详情 / 推广」相关链接;也可在其他配置 wechat_mp_follow.BaseUrl 维护。 + /// + public string FollowUrl { get; set; } = string.Empty; } diff --git a/src/SimpleAdmin.Api/WeChat/Services/WeChatEventHandler.cs b/src/SimpleAdmin.Api/WeChat/Services/WeChatEventHandler.cs index 13e191e..c763d1e 100644 --- a/src/SimpleAdmin.Api/WeChat/Services/WeChatEventHandler.cs +++ b/src/SimpleAdmin.Api/WeChat/Services/WeChatEventHandler.cs @@ -38,6 +38,13 @@ public class WeChatEventHandler( return null; } + if (WeChatQrSceneParser.IsFollowOnlyScene(message.EventKey)) + { + return BuildTextReply( + message, + "感谢关注!已绑定成功。点击底部菜单「登录注册」可进入客户中心,「我要下单」可直接下单。"); + } + var loginKey = WeChatQrSceneParser.ExtractLoginKey(message.EventKey); if (string.IsNullOrWhiteSpace(loginKey)) { diff --git a/src/SimpleAdmin.Api/WeChat/Services/WeChatMpApiClient.cs b/src/SimpleAdmin.Api/WeChat/Services/WeChatMpApiClient.cs index 155336a..a3f8760 100644 --- a/src/SimpleAdmin.Api/WeChat/Services/WeChatMpApiClient.cs +++ b/src/SimpleAdmin.Api/WeChat/Services/WeChatMpApiClient.cs @@ -10,6 +10,11 @@ public interface IWeChatMpApiClient { Task CreateLoginQrCodeUrlAsync(string loginUuid, int expireSeconds, CancellationToken cancellationToken = default); + /// + /// 获取引导关注公众号的永久二维码(scene=oms_follow),结果进程内缓存。 + /// + Task CreateFollowQrCodeUrlAsync(CancellationToken cancellationToken = default); + /// /// 代理下载公众号 showqrcode 图片,供 H5 同源展示以提升微信长按识别成功率。 /// @@ -45,6 +50,9 @@ internal sealed class WeChatMpApiClient( IWeChatMpAccessTokenStore accessTokenStore, ILogger logger) : IWeChatMpApiClient { + private readonly object _followQrLock = new(); + private string? _cachedFollowQrCodeUrl; + public Task InvalidateAccessTokenCacheAsync(CancellationToken cancellationToken = default) => accessTokenStore.ClearAsync(cancellationToken); @@ -53,12 +61,43 @@ internal sealed class WeChatMpApiClient( int expireSeconds, CancellationToken cancellationToken = default) { - return await CreateLoginQrCodeUrlCoreAsync(loginUuid, expireSeconds, retried: false, cancellationToken); + return await CreateQrCodeUrlCoreAsync( + actionName: "QR_STR_SCENE", + sceneStr: loginUuid, + expireSeconds: expireSeconds, + retried: false, + cancellationToken); } - private async Task CreateLoginQrCodeUrlCoreAsync( - string loginUuid, - int expireSeconds, + public async Task CreateFollowQrCodeUrlAsync(CancellationToken cancellationToken = default) + { + lock (_followQrLock) + { + if (!string.IsNullOrWhiteSpace(_cachedFollowQrCodeUrl)) + { + return _cachedFollowQrCodeUrl; + } + } + + var url = await CreateQrCodeUrlCoreAsync( + actionName: "QR_LIMIT_STR_SCENE", + sceneStr: WeChatQrSceneParser.FollowScene, + expireSeconds: null, + retried: false, + cancellationToken); + + lock (_followQrLock) + { + _cachedFollowQrCodeUrl = url; + } + + return url; + } + + private async Task CreateQrCodeUrlCoreAsync( + string actionName, + string sceneStr, + int? expireSeconds, bool retried, CancellationToken cancellationToken) { @@ -66,18 +105,18 @@ internal sealed class WeChatMpApiClient( var accessToken = await GetAccessTokenAsync(mp, cancellationToken); var client = httpClientFactory.CreateClient(nameof(WeChatMpApiClient)); - var payload = new - { - expire_seconds = expireSeconds, - action_name = "QR_STR_SCENE", - action_info = new + object payload = expireSeconds is > 0 + ? new { - scene = new - { - scene_str = loginUuid - } + expire_seconds = expireSeconds.Value, + action_name = actionName, + action_info = new { scene = new { scene_str = sceneStr } } } - }; + : new + { + action_name = actionName, + action_info = new { scene = new { scene_str = sceneStr } } + }; // 微信服务端要求 POST 带 Content-Length;PostAsJsonAsync 可能走 chunked 导致 HTTP 412 var json = JsonSerializer.Serialize(payload); @@ -89,7 +128,7 @@ internal sealed class WeChatMpApiClient( var result = await ReadWeChatJsonAsync( response, - "创建登录二维码", + "创建公众号二维码", cancellationToken); if (result == null || string.IsNullOrWhiteSpace(result.Ticket)) { @@ -102,10 +141,15 @@ internal sealed class WeChatMpApiClient( { logger.LogWarning("微信 access_token 失效(errcode={Errcode}),清空库内 token 后重试创建二维码", result.Errcode); await accessTokenStore.ClearAsync(cancellationToken); - return await CreateLoginQrCodeUrlCoreAsync(loginUuid, expireSeconds, retried: true, cancellationToken); + return await CreateQrCodeUrlCoreAsync( + actionName, + sceneStr, + expireSeconds, + retried: true, + cancellationToken); } - throw new InvalidOperationException(FormatWeChatError("创建登录二维码", result.Errcode, result.Errmsg)); + throw new InvalidOperationException(FormatWeChatError("创建公众号二维码", result.Errcode, result.Errmsg)); } return $"https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket={Uri.EscapeDataString(result.Ticket)}"; diff --git a/src/SimpleAdmin.Api/WeChat/Services/WeChatMpConfigProvider.cs b/src/SimpleAdmin.Api/WeChat/Services/WeChatMpConfigProvider.cs index 135994a..37f080e 100644 --- a/src/SimpleAdmin.Api/WeChat/Services/WeChatMpConfigProvider.cs +++ b/src/SimpleAdmin.Api/WeChat/Services/WeChatMpConfigProvider.cs @@ -12,6 +12,9 @@ namespace SimpleAdmin.Api.WeChat.Services; public interface IWeChatMpConfigProvider { Task GetAsync(CancellationToken cancellationToken = default); + + /// 注册成功后跳转的公众号链接(其他配置 wechat_mp_follow 优先,其次 Consul FollowUrl)。 + Task GetFollowJumpUrlAsync(CancellationToken cancellationToken = default); } internal sealed class WeChatMpConfigProvider( @@ -33,10 +36,25 @@ internal sealed class WeChatMpConfigProvider( Secret = Coalesce(entity.AppSecret, fallback.Secret), Token = Coalesce(entity.Remark, fallback.Token), OAuthRedirectBase = Coalesce(entity.BaseUrl, fallback.OAuthRedirectBase), + FollowUrl = fallback.FollowUrl, EncodingAesKey = fallback.EncodingAesKey }; } + public async Task GetFollowJumpUrlAsync(CancellationToken cancellationToken = default) + { + var followEntity = await repository.GetByConfigCodeAsync( + SystemOtherConfigCodes.WeChatMpFollow, + cancellationToken); + if (!string.IsNullOrWhiteSpace(followEntity?.BaseUrl)) + { + return followEntity.BaseUrl.Trim(); + } + + var mp = await GetAsync(cancellationToken); + return string.IsNullOrWhiteSpace(mp.FollowUrl) ? null : mp.FollowUrl.Trim(); + } + private static string Coalesce(string? primary, string fallback) => string.IsNullOrWhiteSpace(primary) ? fallback : primary.Trim(); } diff --git a/src/SimpleAdmin.Api/WeChat/Services/WeChatQrSceneParser.cs b/src/SimpleAdmin.Api/WeChat/Services/WeChatQrSceneParser.cs index cd3b9a3..60cb516 100644 --- a/src/SimpleAdmin.Api/WeChat/Services/WeChatQrSceneParser.cs +++ b/src/SimpleAdmin.Api/WeChat/Services/WeChatQrSceneParser.cs @@ -6,6 +6,16 @@ internal static class WeChatQrSceneParser { private const string QrScenePrefix = "qrscene_"; + /// 注册/绑定成功后引导关注用的永久二维码 scene。 + public const string FollowScene = "oms_follow"; + + /// 是否为「仅关注」场景(不参与扫码登录会话)。 + public static bool IsFollowOnlyScene(string? eventKey) + { + var scene = NormalizeScene(eventKey); + return string.Equals(scene, FollowScene, StringComparison.OrdinalIgnoreCase); + } + /// /// 从微信 SCAN / SUBSCRIBE 事件的 EventKey 解析 PC 端轮询用的 loginUuid(key)。 /// @@ -17,6 +27,11 @@ internal static class WeChatQrSceneParser return null; } + if (string.Equals(scene, FollowScene, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + if (scene.StartsWith('{') && scene.EndsWith('}')) { try diff --git a/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs b/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs index 8849424..21f239a 100644 --- a/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs +++ b/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs @@ -27,6 +27,7 @@ public class WeChatCustomerService( IWeChatMpApiClient weChatMpApiClient, ICustomerRepository customerRepository, ICustomerReferralCodeRepository referralCodeRepository, + IMemberLevelRepository memberLevelRepository, MarketingCouponManager couponManager, UserService userService, RoleService roleService, @@ -147,7 +148,11 @@ public class WeChatCustomerService( WechatOpenid = wechatOpenid, WechatNickname = wechatNickname, WechatAvatar = wechatAvatar, - Status = CustomerStatus.Normal + Status = CustomerStatus.Normal, + MemberLevelId = await MarketingCustomerHelper.ResolveDefaultMemberLevelIdAsync( + memberLevelRepository, + cancellationToken), + PriceTierKey = MarketingCustomerHelper.DefaultNewCustomerPriceTierKey }; await MarketingReferralBinder.BindReferrerOnCreateAsync( entity, diff --git a/src/SimpleAdmin.Api/appsettings.Development.json b/src/SimpleAdmin.Api/appsettings.Development.json index 9680e9d..a53b3df 100644 --- a/src/SimpleAdmin.Api/appsettings.Development.json +++ b/src/SimpleAdmin.Api/appsettings.Development.json @@ -50,7 +50,8 @@ "Secret": "379f51a89419dc36fbb9167feef6f89a", "Token": "c6ca74013df823c0e1da0feaaaf8bf66", "EncodingAesKey": "", - "OAuthRedirectBase": "https://oms.nanafa.com" + "OAuthRedirectBase": "https://oms.nanafa.com", + "FollowUrl": "" } }, "Oms": {