feat: ??????????????????????????

?????????????????????????????????? SQL?

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-30 15:16:55 +08:00
parent e0216e9692
commit 206dc3ef3c
25 changed files with 309 additions and 61 deletions

View File

@ -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` = '普通会员')
);

View File

@ -106,6 +106,12 @@ public class CreateCustomerInput
/// </summary>
public int? MemberLevelId { get; set; }
/// <summary>
/// 价区键(卖价 CustomerGroupValues JSON 键)。空则默认「普通会员」。
/// </summary>
[StringLength(64)]
public string? PriceTierKey { get; set; }
/// <summary>
/// 注册邀请码(引荐绑定,仅新客户有效)。
/// </summary>

View File

@ -102,6 +102,11 @@ public class CustomerOutput
/// </summary>
public string? MemberLevelName { get; set; }
/// <summary>
/// 价区键。
/// </summary>
public string? PriceTierKey { get; set; }
/// <summary>
/// 最后登录 IP。
/// </summary>

View File

@ -102,6 +102,11 @@ public class CustomerPaginatedOutput
/// </summary>
public string? MemberLevelName { get; set; }
/// <summary>
/// 价区键。
/// </summary>
public string? PriceTierKey { get; set; }
/// <summary>
/// 最后登录 IP。
/// </summary>

View File

@ -105,4 +105,10 @@ public class UpdateCustomerInput
/// </summary>
public int? MemberLevelId { get; set; }
/// <summary>
/// 价区键(卖价 CustomerGroupValues JSON 键)。空则回落「普通客户」。
/// </summary>
[StringLength(64)]
public string? PriceTierKey { get; set; }
}

View File

@ -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; }

View File

@ -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; }

View File

@ -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);
}
/// <summary>创建时:未指定则默认「普通会员」等级。</summary>
private async Task<int?> ResolveMemberLevelIdForCreateAsync(int? memberLevelId)
{
if (memberLevelId.HasValue)
{
return await ResolveMemberLevelIdForSaveAsync(memberLevelId);
}
return await MarketingCustomerHelper.ResolveDefaultMemberLevelIdAsync(MemberLevelRepository);
}
private async Task<int?> ResolveMemberLevelIdForSaveAsync(int? memberLevelId)
{
if (!memberLevelId.HasValue)

View File

@ -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,

View File

@ -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,

View File

@ -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;
/// </summary>
public static class MarketingCustomerHelper
{
/// <summary>缺省/空价区键回落(卖价 JSON 标准键)。</summary>
public const string FallbackPriceTierKey = "普通客户";
/// <summary>新注册客户默认价区键(与会员等级名称对齐为「普通会员」)。</summary>
public const string DefaultNewCustomerPriceTierKey = "普通会员";
/// <summary>新注册客户默认会员等级编码/名称。</summary>
public const string DefaultMemberLevelName = "普通会员";
public static async Task<Customer> ResolveMarketingMainCustomerAsync(
ICustomerRepository customerRepository,
Customer sessionCustomer,
@ -46,7 +56,7 @@ public static class MarketingCustomerHelper
}
/// <summary>
/// 解析用于计价的价区键:主客户 MemberLevel → PriceTierKey;无等级时回退 CustomerGroup 映射。
/// 解析用于计价的价区键:主客户.PriceTierKey;空则回落「普通客户」。与会员等级无关。
/// </summary>
public static async Task<string?> 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();
}
/// <summary>新客默认价区键。</summary>
public static string ResolveNewCustomerPriceTierKey(string? input) =>
string.IsNullOrWhiteSpace(input) ? DefaultNewCustomerPriceTierKey : input.Trim();
/// <summary>
/// 解析默认「普通会员」等级 Id;不存在则取启用等级中 MinPoints 最低者。
/// </summary>
public static async Task<int?> 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
};
}
}

View File

@ -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(

View File

@ -47,6 +47,12 @@ public class Customer : OmsAuditedAggregateRoot
public int? MemberLevelId { get; set; }
/// <summary>
/// 价区键,对应卖价 CustomerGroupValues JSON 键(如 普通客户、VIP1)。与会员等级无关。
/// </summary>
[SugarColumn(Length = 64, IsNullable = true)]
public string? PriceTierKey { get; set; }
public decimal CommissionBalance { get; set; }
public string? LastLoginIp { get; set; }

View File

@ -13,11 +13,6 @@ public class MemberLevel : OmsAuditedAggregateRoot
public int MinPoints { get; set; }
/// <summary>
/// 价区键,对应卖价 CustomerGroupValues JSON 键(如 普通客户、VIP1)。
/// </summary>
public string PriceTierKey { get; set; } = string.Empty;
public decimal ExtraDiscountRate { get; set; }
public int Sort { get; set; }

View File

@ -15,6 +15,11 @@ public static class SystemOtherConfigCodes
/// </summary>
public const string WeChatMp = "wechat_mp";
/// <summary>
/// 微信公众号关注跳转链接(BaseUrl=微信内可打开的公众号主页/名片/图文引导链接)。
/// </summary>
public const string WeChatMpFollow = "wechat_mp_follow";
/// <summary>
/// 店铺授权公共配置(BaseUrl=前端回调根地址 FrontendBaseUrl,Remark=StateSecret 可选)。
/// </summary>

View File

@ -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");

View File

@ -186,6 +186,30 @@ public partial class AuthService
};
}
/// <summary>
/// 获取注册/绑定成功后跳转的公众号链接(微信内直接打开即可关注,无需长按扫码)。
/// </summary>
[AllowAnonymous]
[HttpGet("/wechat/mp/follow-qrcode")]
[ApiExplorerSettings(GroupName = "WeChat")]
public async Task<WxFollowQrcodeOutput> 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
};
}
/// <summary>
/// 同源代理微信 showqrcode 图片,便于微信内长按识别(避免跨域直连 mp.weixin.qq.com)。
/// </summary>

View File

@ -12,6 +12,19 @@ public class WxLoginQrcodeOutput
public int ExpiresIn { get; set; } = 600;
}
/// <summary>引导关注公众号:优先返回可直接跳转的公众号链接。</summary>
public class WxFollowQrcodeOutput
{
/// <summary>微信内 location 跳转用(公众号主页 / 名片 / 引导图文)。</summary>
public string JumpUrl { get; set; } = string.Empty;
/// <summary>兼容旧字段;与 JumpUrl 相同。</summary>
public string QrCodeUrl { get; set; } = string.Empty;
/// <summary>scene_str,固定 oms_follow(仅当走扫码兜底时有意义)。</summary>
public string Scene { get; set; } = "oms_follow";
}
public class WxLoginStatusOutput
{
public bool Ready { get; set; }

View File

@ -19,4 +19,10 @@ public class WeChatMpOptions
/// OAuth 回调基础域名,如 https://oms.nanafa.com
/// </summary>
public string OAuthRedirectBase { get; set; } = string.Empty;
/// <summary>
/// 注册/绑定成功后跳转的公众号主页或引导关注链接(微信内打开即可关注)。
/// 可在公众号后台复制「账号详情 / 推广」相关链接;也可在其他配置 wechat_mp_follow.BaseUrl 维护。
/// </summary>
public string FollowUrl { get; set; } = string.Empty;
}

View File

@ -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))
{

View File

@ -10,6 +10,11 @@ public interface IWeChatMpApiClient
{
Task<string> CreateLoginQrCodeUrlAsync(string loginUuid, int expireSeconds, CancellationToken cancellationToken = default);
/// <summary>
/// 获取引导关注公众号的永久二维码(scene=oms_follow),结果进程内缓存。
/// </summary>
Task<string> CreateFollowQrCodeUrlAsync(CancellationToken cancellationToken = default);
/// <summary>
/// 代理下载公众号 showqrcode 图片,供 H5 同源展示以提升微信长按识别成功率。
/// </summary>
@ -45,6 +50,9 @@ internal sealed class WeChatMpApiClient(
IWeChatMpAccessTokenStore accessTokenStore,
ILogger<WeChatMpApiClient> 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<string> CreateLoginQrCodeUrlCoreAsync(
string loginUuid,
int expireSeconds,
public async Task<string> 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<string> 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<WeChatQrTicketResponse>(
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)}";

View File

@ -12,6 +12,9 @@ namespace SimpleAdmin.Api.WeChat.Services;
public interface IWeChatMpConfigProvider
{
Task<WeChatMpOptions> GetAsync(CancellationToken cancellationToken = default);
/// <summary>注册成功后跳转的公众号链接(其他配置 wechat_mp_follow 优先,其次 Consul FollowUrl)。</summary>
Task<string?> 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<string?> 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();
}

View File

@ -6,6 +6,16 @@ internal static class WeChatQrSceneParser
{
private const string QrScenePrefix = "qrscene_";
/// <summary>注册/绑定成功后引导关注用的永久二维码 scene。</summary>
public const string FollowScene = "oms_follow";
/// <summary>是否为「仅关注」场景(不参与扫码登录会话)。</summary>
public static bool IsFollowOnlyScene(string? eventKey)
{
var scene = NormalizeScene(eventKey);
return string.Equals(scene, FollowScene, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// 从微信 SCAN / SUBSCRIBE 事件的 EventKey 解析 PC 端轮询用的 loginUuid(key)。
/// </summary>
@ -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

View File

@ -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,

View File

@ -50,7 +50,8 @@
"Secret": "379f51a89419dc36fbb9167feef6f89a",
"Token": "c6ca74013df823c0e1da0feaaaf8bf66",
"EncodingAesKey": "",
"OAuthRedirectBase": "https://oms.nanafa.com"
"OAuthRedirectBase": "https://oms.nanafa.com",
"FollowUrl": ""
}
},
"Oms": {