feat: 新增微信公众号扫码注册功能及幂等处理

- 新增 `FuwuhaoConst` 常量类,统一缓存 Key 前缀管理
- `FuwuhaoOptions` 增加 FromUser、RedirectUri、PicUrl 配置项
- `FuwuhaoManager` 新增 `BuildRegisterMessage` 方法,构建注册引导图文消息
- `FuwuhaoService`
  - 增加 OpenId 与 Scene 绑定缓存,支持扫码注册有效期管理
  - 回调处理支持注册场景,返回图文消息引导用户注册
  - 新增注册接口 `RegisterByCodeAsync`,根据微信授权信息自动注册账号并更新场景状态
- `AccountManager` 注册方法增加分布式锁,防止重复注册,并校验用户名唯一性
This commit is contained in:
chenchun
2025-08-29 11:01:09 +08:00
parent d2c6238df1
commit 6bd561b094
5 changed files with 146 additions and 31 deletions

View File

@@ -11,6 +11,7 @@ using Volo.Abp.Caching;
using Volo.Abp.Users; using Volo.Abp.Users;
using Yi.Framework.AiHub.Application.Contracts.Dtos.Fuwuhao; using Yi.Framework.AiHub.Application.Contracts.Dtos.Fuwuhao;
using Yi.Framework.AiHub.Domain.Managers.Fuwuhao; using Yi.Framework.AiHub.Domain.Managers.Fuwuhao;
using Yi.Framework.AiHub.Domain.Shared.Consts;
using Yi.Framework.AiHub.Domain.Shared.Enums.Fuwuhao; using Yi.Framework.AiHub.Domain.Shared.Enums.Fuwuhao;
using Yi.Framework.Rbac.Application.Contracts.Dtos.Account; using Yi.Framework.Rbac.Application.Contracts.Dtos.Account;
using Yi.Framework.Rbac.Application.Contracts.IServices; using Yi.Framework.Rbac.Application.Contracts.IServices;
@@ -26,12 +27,15 @@ public class FuwuhaoService : ApplicationService
private readonly IHttpContextAccessor _accessor; private readonly IHttpContextAccessor _accessor;
private readonly FuwuhaoManager _fuwuhaoManager; private readonly FuwuhaoManager _fuwuhaoManager;
private IDistributedCache<SceneCacheDto> _sceneCache; private IDistributedCache<SceneCacheDto> _sceneCache;
private IDistributedCache<string> _openIdToSceneCache;
private IAccountService _accountService; private IAccountService _accountService;
private IFileService _fileService; private IFileService _fileService;
public IDistributedLockProvider DistributedLock => LazyServiceProvider.LazyGetService<IDistributedLockProvider>(); public IDistributedLockProvider DistributedLock => LazyServiceProvider.LazyGetService<IDistributedLockProvider>();
public FuwuhaoService(ILogger<FuwuhaoService> logger, IHttpContextAccessor accessor, FuwuhaoManager fuwuhaoManager, public FuwuhaoService(ILogger<FuwuhaoService> logger, IHttpContextAccessor accessor, FuwuhaoManager fuwuhaoManager,
IDistributedCache<SceneCacheDto> sceneCache, IAccountService accountService, IFileService fileService) IDistributedCache<SceneCacheDto> sceneCache, IAccountService accountService, IFileService fileService,
IDistributedCache<string> openIdToSceneCache)
{ {
_logger = logger; _logger = logger;
_accessor = accessor; _accessor = accessor;
@@ -39,6 +43,7 @@ public class FuwuhaoService : ApplicationService
_sceneCache = sceneCache; _sceneCache = sceneCache;
_accountService = accountService; _accountService = accountService;
_fileService = fileService; _fileService = fileService;
_openIdToSceneCache = openIdToSceneCache;
} }
/// <summary> /// <summary>
@@ -63,7 +68,7 @@ public class FuwuhaoService : ApplicationService
public async Task<string> PostCallbackAsync([FromQuery] string signature, [FromQuery] string timestamp, public async Task<string> PostCallbackAsync([FromQuery] string signature, [FromQuery] string timestamp,
[FromQuery] string nonce) [FromQuery] string nonce)
{ {
_fuwuhaoManager.ValidateCallback(signature,timestamp,nonce); _fuwuhaoManager.ValidateCallback(signature, timestamp, nonce);
var request = _accessor.HttpContext.Request; var request = _accessor.HttpContext.Request;
// 1. 读取原始 XML 内容 // 1. 读取原始 XML 内容
using var reader = new StreamReader(request.Body, Encoding.UTF8); using var reader = new StreamReader(request.Body, Encoding.UTF8);
@@ -74,11 +79,12 @@ public class FuwuhaoService : ApplicationService
var body = (FuwuhaoCallModel)serializer.Deserialize(stringReader); var body = (FuwuhaoCallModel)serializer.Deserialize(stringReader);
//获取场景值,后续通过场景值设置缓存状态,前端轮询这个场景值用户是否已操作即可 //获取场景值,后续通过场景值设置缓存状态,前端轮询这个场景值用户是否已操作即可
var scene = body.EventKey.Replace("qrscene_",""); var scene = body.EventKey.Replace("qrscene_", "");
if (!(body.Event is "SCAN" or "subscribe")) if (!(body.Event is "SCAN" or "subscribe"))
{ {
throw new UserFriendlyException("当前回调只处理扫码 与 关注"); throw new UserFriendlyException("当前回调只处理扫码 与 关注");
} }
if (scene is null) if (scene is null)
{ {
throw new UserFriendlyException("服务号返回无场景值"); throw new UserFriendlyException("服务号返回无场景值");
@@ -86,22 +92,35 @@ public class FuwuhaoService : ApplicationService
//制作幂等 //制作幂等
await using (var handle = await using (var handle =
await DistributedLock.TryAcquireLockAsync($"Yi:fuwuhao:callbacklock:{scene}", TimeSpan.FromSeconds(60))) await DistributedLock.TryAcquireLockAsync($"Yi:fuwuhao:callbacklock:{scene}",
TimeSpan.FromSeconds(60)))
{ {
if (handle == null) if (handle == null)
{ {
return "success"; // 跳过直接返回成功 return "success"; // 跳过直接返回成功
} }
var cache = await _sceneCache.GetAsync($"fuwuhao:{scene}"); var cache = await _sceneCache.GetAsync($"{FuwuhaoConst.SceneCacheKey}{scene}");
//根据操作类型,进行业务处理,返回处理结果,再写入缓存,10s过去相当于用户10s扫完app后轮询要在10秒内完成 //根据操作类型,进行业务处理,返回处理结果,再写入缓存,10s过去相当于用户10s扫完app后轮询要在10秒内完成
var scenResult = var scenResult =
await _fuwuhaoManager.CallBackHandlerAsync(cache.SceneType, body.FromUserName, cache.UserId); await _fuwuhaoManager.CallBackHandlerAsync(cache.SceneType, body.FromUserName, cache.UserId);
cache.SceneResult = scenResult; cache.SceneResult = scenResult;
await _sceneCache.SetAsync($"fuwuhao:{scene}", cache, await _sceneCache.SetAsync($"{FuwuhaoConst.SceneCacheKey}{scene}", cache,
new DistributedCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(50) }); new DistributedCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(50) });
//如果是注册将OpenId与Scene进行绑定代表用户有30分钟进行注册
if (scenResult == SceneResultEnum.Register)
{
await _openIdToSceneCache.SetAsync($"{FuwuhaoConst.OpenIdToSceneCacheKey}{body.FromUserName}", scene,
new DistributedCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) });
var replyMessage =
_fuwuhaoManager.BuildRegisterMessage(body.FromUserName);
return replyMessage;
}
} }
return "success"; return "success";
@@ -118,7 +137,7 @@ public class FuwuhaoService : ApplicationService
//生成一个随机场景值 //生成一个随机场景值
var scene = Guid.NewGuid().ToString("N"); var scene = Guid.NewGuid().ToString("N");
var qrCodeUrl = await _fuwuhaoManager.CreateQrCodeAsync(scene); var qrCodeUrl = await _fuwuhaoManager.CreateQrCodeAsync(scene);
await _sceneCache.SetAsync($"fuwuhao:{scene}", new SceneCacheDto() await _sceneCache.SetAsync($"{FuwuhaoConst.SceneCacheKey}{scene}", new SceneCacheDto()
{ {
UserId = CurrentUser.IsAuthenticated ? CurrentUser.GetId() : null, UserId = CurrentUser.IsAuthenticated ? CurrentUser.GetId() : null,
SceneType = sceneType SceneType = sceneType
@@ -139,7 +158,7 @@ public class FuwuhaoService : ApplicationService
public async Task<QrCodeResultOutput> GetQrCodeResultAsync([FromQuery] string scene) public async Task<QrCodeResultOutput> GetQrCodeResultAsync([FromQuery] string scene)
{ {
var output = new QrCodeResultOutput(); var output = new QrCodeResultOutput();
var cache = await _sceneCache.GetAsync($"fuwuhao:{scene}"); var cache = await _sceneCache.GetAsync($"{FuwuhaoConst.SceneCacheKey}{scene}");
if (cache is null) if (cache is null)
{ {
output.SceneResult = SceneResultEnum.Expired; output.SceneResult = SceneResultEnum.Expired;
@@ -164,13 +183,23 @@ public class FuwuhaoService : ApplicationService
/// </summary> /// </summary>
/// <param name="code"></param> /// <param name="code"></param>
/// <returns></returns> /// <returns></returns>
[HttpPost("fuwuhao/register")] [HttpGet("fuwuhao/register")]
public async Task<LoginOutputDto> RegisterByCodeAsync([FromQuery] string code) public async Task<string> RegisterByCodeAsync([FromQuery] string code)
{ {
//根据code获取到openid、微信用户昵称、头像 //根据code获取到openid、微信用户昵称、头像
var userInfo = await _fuwuhaoManager.GetUserInfoByCodeAsync(code); var userInfo = await _fuwuhaoManager.GetUserInfoByCodeAsync(code);
var files = new FormFileCollection(); if (userInfo is null)
{
return "当前注册已经失效,请重新扫码注册";
}
var scene = await _openIdToSceneCache.GetAsync($"{FuwuhaoConst.OpenIdToSceneCacheKey}{userInfo.OpenId}");
if (scene is null)
{
return "当前注册已经过期,请重新扫码注册";
}
var files = new FormFileCollection();
// 下载头像并添加到系统文件中 // 下载头像并添加到系统文件中
if (!string.IsNullOrEmpty(userInfo.HeadImgUrl)) if (!string.IsNullOrEmpty(userInfo.HeadImgUrl))
{ {
@@ -190,16 +219,22 @@ public class FuwuhaoService : ApplicationService
var result = await _fileService.Post(files); var result = await _fileService.Post(files);
var userId = await _accountService.PostSystemRegisterAsync(new RegisterDto await _accountService.PostSystemRegisterAsync(new RegisterDto
{ {
UserName = userInfo.Nickname, UserName = $"wx{Random.Shared.Next(100000, 999999)}",
Password = Guid.NewGuid().ToString("N"), Password = Guid.NewGuid().ToString("N"),
Phone = null, Phone = null,
Email = null, Email = null,
Nick = userInfo.Nickname, Nick = userInfo.Nickname,
Icon = result.FirstOrDefault()?.Id.ToString() Icon = result.FirstOrDefault()?.Id.ToString()
}); });
return await _accountService.PostLoginAsync(userId);
var sceneCache = await _sceneCache.GetAsync($"{FuwuhaoConst.SceneCacheKey}{scene}");
sceneCache.SceneResult = SceneResultEnum.Login;
await _sceneCache.SetAsync($"{FuwuhaoConst.SceneCacheKey}:{scene}", sceneCache,
new DistributedCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(50) });
return "恭喜你已注册成功意社区账号!";
} }
} }

View File

@@ -0,0 +1,7 @@
namespace Yi.Framework.AiHub.Domain.Shared.Consts;
public class FuwuhaoConst
{
public const string SceneCacheKey = "fuwuhao:scene:";
public const string OpenIdToSceneCacheKey = "fuwuhao:OpenIdToScene:";
}

View File

@@ -19,7 +19,8 @@ public class FuwuhaoManager : DomainService
private ISqlSugarRepository<AiUserExtraInfoEntity> _userRepository; private ISqlSugarRepository<AiUserExtraInfoEntity> _userRepository;
public FuwuhaoManager(IOptions<FuwuhaoOptions> options, IHttpClientFactory httpClientFactory, public FuwuhaoManager(IOptions<FuwuhaoOptions> options, IHttpClientFactory httpClientFactory,
ISqlSugarRepository<AiUserExtraInfoEntity> userRepository, IDistributedCache<AccessTokenResponse> accessTokenCache) ISqlSugarRepository<AiUserExtraInfoEntity> userRepository,
IDistributedCache<AccessTokenResponse> accessTokenCache)
{ {
_options = options.Value; _options = options.Value;
_httpClientFactory = httpClientFactory; _httpClientFactory = httpClientFactory;
@@ -97,7 +98,8 @@ public class FuwuhaoManager : DomainService
/// <returns>用户基础信息响应对象</returns> /// <returns>用户基础信息响应对象</returns>
private async Task<UserBaseInfoResponse> GetBaseUserInfoByCodeAsync(string code) private async Task<UserBaseInfoResponse> GetBaseUserInfoByCodeAsync(string code)
{ {
var url = $"https://api.weixin.qq.com/sns/oauth2/access_token?appid={_options.AppId}&secret={_options.Secret}&code={code}&grant_type=authorization_code"; var url =
$"https://api.weixin.qq.com/sns/oauth2/access_token?appid={_options.AppId}&secret={_options.Secret}&code={code}&grant_type=authorization_code";
var response = await _httpClientFactory.CreateClient().GetAsync(url); var response = await _httpClientFactory.CreateClient().GetAsync(url);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
@@ -116,10 +118,11 @@ public class FuwuhaoManager : DomainService
/// </summary> /// </summary>
/// <param name="code">授权码</param> /// <param name="code">授权码</param>
/// <returns>用户信息响应对象</returns> /// <returns>用户信息响应对象</returns>
public async Task<UserInfoResponse> GetUserInfoByCodeAsync(string code) public async Task<UserInfoResponse?> GetUserInfoByCodeAsync(string code)
{ {
var baseUserInfo = await GetBaseUserInfoByCodeAsync(code); var baseUserInfo = await GetBaseUserInfoByCodeAsync(code);
var url = $"https://api.weixin.qq.com/sns/userinfo?access_token={baseUserInfo.AccessToken}&openid={baseUserInfo.OpenId}&lang=zh_CN"; var url =
$"https://api.weixin.qq.com/sns/userinfo?access_token={baseUserInfo.AccessToken}&openid={baseUserInfo.OpenId}&lang=zh_CN";
var response = await _httpClientFactory.CreateClient().GetAsync(url); var response = await _httpClientFactory.CreateClient().GetAsync(url);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
@@ -166,6 +169,38 @@ public class FuwuhaoManager : DomainService
} }
/// <summary>
/// 构建引导注册图文消息体
/// </summary>
/// <param name="toUser">接收用户的OpenID</param>
/// <param name="title">图文消息标题</param>
/// <param name="description">图文消息描述</param>
/// <returns>XML格式的图文消息体</returns>
public string BuildRegisterMessage(string toUser, string title="意社区点击一键注册账号", string description="来自意社区SSO统一注册安全中心")
{
var createTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var fromUser = _options.FromUser;
var url =
$"https://open.weixin.qq.com/connect/oauth2/authorize?appid={_options.AppId}&redirect_uri={_options.RedirectUri}&response_type=code&scope=snsapi_userinfo&state={createTime}#wechat_redirect";
var xml = $@"<xml>
<ToUserName><![CDATA[{toUser}]]></ToUserName>
<FromUserName><![CDATA[{fromUser}]]></FromUserName>
<CreateTime>{createTime}</CreateTime>
<MsgType><![CDATA[news]]></MsgType>
<ArticleCount>1</ArticleCount>
<Articles>
<item>
<Title><![CDATA[{title}]]></Title>
<Description><![CDATA[{description}]]></Description>
<PicUrl><![CDATA[{_options.PicUrl}]]></PicUrl>
<Url><![CDATA[{url}]]></Url>
</item>
</Articles>
</xml>";
return xml;
}
/// <summary> /// <summary>
/// 处理回调逻辑 /// 处理回调逻辑
/// </summary> /// </summary>

View File

@@ -16,4 +16,19 @@ public class FuwuhaoOptions
/// 回调token /// 回调token
/// </summary> /// </summary>
public string CallbackToken { get; set; } public string CallbackToken { get; set; }
/// <summary>
/// 微信公众号原始ID用于FromUser
/// </summary>
public string FromUser { get; set; }
/// <summary>
/// 微信网页授权跳转地址
/// </summary>
public string RedirectUri { get; set; }
/// <summary>
/// 图片地址
/// </summary>
public string PicUrl { get; set; }
} }

View File

@@ -2,6 +2,7 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text; using System.Text;
using Mapster; using Mapster;
using Medallion.Threading;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
@@ -36,6 +37,13 @@ namespace Yi.Framework.Rbac.Domain.Managers
private UserManager _userManager; private UserManager _userManager;
private ISqlSugarRepository<RoleAggregateRoot> _roleRepository; private ISqlSugarRepository<RoleAggregateRoot> _roleRepository;
private RefreshJwtOptions _refreshJwtOptions; private RefreshJwtOptions _refreshJwtOptions;
/// <summary>
/// 缓存前缀
/// </summary>
private string CacheKeyPrefix => LazyServiceProvider.LazyGetRequiredService<IOptions<AbpDistributedCacheOptions>>()
.Value.KeyPrefix;
public IDistributedLockProvider DistributedLock =>
LazyServiceProvider.LazyGetService<IDistributedLockProvider>();
public AccountManager(IUserRepository repository public AccountManager(IUserRepository repository
, IOptions<JwtOptions> jwtOptions , IOptions<JwtOptions> jwtOptions
@@ -288,17 +296,32 @@ namespace Yi.Framework.Rbac.Domain.Managers
/// <param name="icon"></param> /// <param name="icon"></param>
/// <returns></returns> /// <returns></returns>
public async Task<Guid> RegisterAsync(string userName, string password, long? phone, string? email, public async Task<Guid> RegisterAsync(string userName, string password, long? phone, string? email,
string? nick,string? icon) string? nick, string? icon)
{ {
if (phone is null && string.IsNullOrWhiteSpace(email)) if (userName is null)
{ {
throw new UserFriendlyException("注册时,电话与邮箱不能同时为空"); throw new UserFriendlyException("注册时,用户名不能为空");
} }
var user = new UserAggregateRoot(userName, password, phone, email, nick); //制作幂等
var userId = await _userManager.CreateAsync(user); await using (var handle = await DistributedLock.TryAcquireLockAsync($"{CacheKeyPrefix}Register:Lock:{userName}", TimeSpan.FromSeconds(60)))
await _userManager.SetDefautRoleAsync(user.Id); {
return userId; if (handle is null)
{
throw new UserFriendlyException($"{userName}用户正在注册中,请稍等");
}
var userUpName = userName.ToUpper();
if (await _userManager._repository._DbQueryable.Where(x => x.UserName.ToUpper() == userUpName).AnyAsync())
{
throw new UserFriendlyException($"{userName}用户已注册");
}
var user = new UserAggregateRoot(userName, password, phone, email, nick);
var userId = await _userManager.CreateAsync(user);
await _userManager.SetDefautRoleAsync(user.Id);
return userId;
}
} }
} }
} }