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": {