feat: ??????????????????????????
?????????????????????????????????? SQL? Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
e0216e9692
commit
206dc3ef3c
46
sql/20260730_customer_price_tier_key.sql
Normal file
46
sql/20260730_customer_price_tier_key.sql
Normal 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` = '普通会员')
|
||||
);
|
||||
@ -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>
|
||||
|
||||
@ -102,6 +102,11 @@ public class CustomerOutput
|
||||
/// </summary>
|
||||
public string? MemberLevelName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 价区键。
|
||||
/// </summary>
|
||||
public string? PriceTierKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最后登录 IP。
|
||||
/// </summary>
|
||||
|
||||
@ -102,6 +102,11 @@ public class CustomerPaginatedOutput
|
||||
/// </summary>
|
||||
public string? MemberLevelName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 价区键。
|
||||
/// </summary>
|
||||
public string? PriceTierKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最后登录 IP。
|
||||
/// </summary>
|
||||
|
||||
@ -105,4 +105,10 @@ public class UpdateCustomerInput
|
||||
/// </summary>
|
||||
public int? MemberLevelId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 价区键(卖价 CustomerGroupValues JSON 键)。空则回落「普通客户」。
|
||||
/// </summary>
|
||||
[StringLength(64)]
|
||||
public string? PriceTierKey { get; set; }
|
||||
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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))
|
||||
{
|
||||
|
||||
@ -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)}";
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -50,7 +50,8 @@
|
||||
"Secret": "379f51a89419dc36fbb9167feef6f89a",
|
||||
"Token": "c6ca74013df823c0e1da0feaaaf8bf66",
|
||||
"EncodingAesKey": "",
|
||||
"OAuthRedirectBase": "https://oms.nanafa.com"
|
||||
"OAuthRedirectBase": "https://oms.nanafa.com",
|
||||
"FollowUrl": ""
|
||||
}
|
||||
},
|
||||
"Oms": {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user