feat(wechat): 完善扫码登录缓存账号信息与轮询状态接口
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
bf81f00c91
commit
02cb06b86c
@ -26,6 +26,7 @@ public class WeChatRouteIntegrationTests
|
||||
host.RoutePatterns.Should().Contain("wechat/oauth/user-info");
|
||||
host.RoutePatterns.Should().Contain("wechat/customer/register");
|
||||
host.RoutePatterns.Should().Contain("wechat/customer/bind");
|
||||
host.RoutePatterns.Should().Contain("rbac/auth/wx-login/status");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@ -0,0 +1,107 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Moq;
|
||||
using Oms.Domain.CustomersAggregate.Entities;
|
||||
using Oms.Domain.CustomersAggregate.Enums;
|
||||
using Oms.Domain.CustomersAggregate.Repositories;
|
||||
using Oms.Domain.SystemAggregate.Consts;
|
||||
using Oms.Domain.SystemAggregate.Entities;
|
||||
using Oms.Domain.SystemAggregate.Repositories;
|
||||
using SimpleAdmin.Api.IntegrationTests.Support;
|
||||
using SimpleAdmin.Api.WeChat.Options;
|
||||
|
||||
namespace SimpleAdmin.Api.IntegrationTests;
|
||||
|
||||
public class WeChatScanLoginIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ScanEvent_ShouldWriteSession_AndStatusReturnsReady()
|
||||
{
|
||||
const string loginKey = "abc123scankey";
|
||||
const string openId = "o-test-openid";
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = 42,
|
||||
CustomerCode = "C001",
|
||||
UserId = 100,
|
||||
WechatOpenid = openId,
|
||||
Status = CustomerStatus.Normal
|
||||
};
|
||||
|
||||
var repository = new Mock<ICustomerRepository>(MockBehavior.Strict);
|
||||
repository
|
||||
.Setup(x => x.GetByWechatOpenidAsync(openId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(customer);
|
||||
|
||||
await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services =>
|
||||
{
|
||||
ConfigureWeChatFallback(services);
|
||||
services.RemoveAll<ICustomerRepository>();
|
||||
services.AddSingleton(repository.Object);
|
||||
});
|
||||
|
||||
const string timestamp = "1400000000";
|
||||
const string nonce = "nonce";
|
||||
var signature = SignWeChat("test-token", timestamp, nonce);
|
||||
|
||||
var scanXml = $"""
|
||||
<xml>
|
||||
<ToUserName><![CDATA[gh_test]]></ToUserName>
|
||||
<FromUserName><![CDATA[{openId}]]></FromUserName>
|
||||
<CreateTime>1400000000</CreateTime>
|
||||
<MsgType><![CDATA[event]]></MsgType>
|
||||
<Event><![CDATA[SCAN]]></Event>
|
||||
<EventKey><![CDATA[{loginKey}]]></EventKey>
|
||||
</xml>
|
||||
""";
|
||||
|
||||
using var postContent = new StringContent(scanXml, Encoding.UTF8, "text/xml");
|
||||
var postResponse = await host.Client.PostAsync(
|
||||
$"/apiwechat/verification?signature={signature}×tamp={timestamp}&nonce={nonce}",
|
||||
postContent);
|
||||
|
||||
postResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var statusResponse = await host.Client.GetAsync(
|
||||
$"/rbac/auth/wx-login/status?loginUuid={loginKey}");
|
||||
|
||||
statusResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var statusJson = await statusResponse.Content.ReadAsStringAsync();
|
||||
statusJson.Should().Contain("\"ready\":true");
|
||||
statusJson.Should().Contain("C001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WeChatRoutes_ShouldIncludeWxLoginStatus()
|
||||
{
|
||||
await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: ConfigureWeChatFallback);
|
||||
host.RoutePatterns.Should().Contain("rbac/auth/wx-login/status");
|
||||
}
|
||||
|
||||
private static void ConfigureWeChatFallback(IServiceCollection services)
|
||||
{
|
||||
services.PostConfigure<WeChatMpOptions>(options =>
|
||||
{
|
||||
options.Token = "test-token";
|
||||
options.AppId = "wx-test";
|
||||
});
|
||||
|
||||
var configRepository = new Mock<ISystemOtherConfigRepository>();
|
||||
configRepository
|
||||
.Setup(x => x.GetByConfigCodeAsync(SystemOtherConfigCodes.WeChatMp, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((SystemOtherConfig?)null);
|
||||
services.RemoveAll<ISystemOtherConfigRepository>();
|
||||
services.AddSingleton(configRepository.Object);
|
||||
}
|
||||
|
||||
private static string SignWeChat(string token, string timestamp, string nonce)
|
||||
{
|
||||
var parts = new[] { token, timestamp, nonce }.OrderBy(x => x, StringComparer.Ordinal).ToArray();
|
||||
var hash = SHA1.HashData(Encoding.UTF8.GetBytes(string.Concat(parts)));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@ -156,21 +156,21 @@ public partial class AuthService
|
||||
[HttpGet("/wechat/mp/login-qrcode")]
|
||||
[ApiExplorerSettings(GroupName = "WeChat")]
|
||||
public async Task<WxLoginQrcodeOutput> GetWeChatLoginQrcodeAsync(
|
||||
[FromQuery] string loginUuid,
|
||||
[FromQuery] string? loginUuid,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(loginUuid))
|
||||
{
|
||||
throw new BusinessException("缺少 loginUuid");
|
||||
}
|
||||
var loginKey = string.IsNullOrWhiteSpace(loginUuid)
|
||||
? Guid.NewGuid().ToString("N")
|
||||
: loginUuid.Trim();
|
||||
|
||||
const int expireSeconds = 600;
|
||||
var qrCodeUrl = await WeChatMpApiClient.CreateLoginQrCodeUrlAsync(
|
||||
loginUuid.Trim(),
|
||||
loginKey,
|
||||
expireSeconds,
|
||||
cancellationToken);
|
||||
return new WxLoginQrcodeOutput
|
||||
{
|
||||
LoginKey = loginKey,
|
||||
QrCodeUrl = qrCodeUrl,
|
||||
ExpiresIn = expireSeconds
|
||||
};
|
||||
|
||||
@ -18,6 +18,7 @@ using Oms.Application.Shared;
|
||||
using Oms.Domain.CustomersAggregate.Entities;
|
||||
using Oms.Domain.CustomersAggregate.Enums;
|
||||
using Oms.Domain.CustomersAggregate.Repositories;
|
||||
using SimpleAdmin.Api.WeChat.Dtos;
|
||||
using SimpleAdmin.Api.WeChat.Services;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
@ -61,6 +62,33 @@ public partial class AuthService(
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 微信扫码登录轮询状态(key 是否已有绑定账号)。
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("/rbac/auth/wx-login/status")]
|
||||
public async Task<WxLoginStatusOutput> WxLoginStatusAsync(
|
||||
[FromQuery] string loginUuid,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(loginUuid))
|
||||
{
|
||||
throw new BusinessException("缺少 loginUuid");
|
||||
}
|
||||
|
||||
var session = await weChatLoginSessionStore.GetSessionAsync(loginUuid.Trim(), cancellationToken);
|
||||
if (session == null)
|
||||
{
|
||||
return new WxLoginStatusOutput { Ready = false };
|
||||
}
|
||||
|
||||
return new WxLoginStatusOutput
|
||||
{
|
||||
Ready = true,
|
||||
Account = string.IsNullOrWhiteSpace(session.CustomerCode) ? session.OpenId : session.CustomerCode
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 微信扫码登录(轮询)。
|
||||
/// </summary>
|
||||
@ -75,14 +103,14 @@ public partial class AuthService(
|
||||
throw new BusinessException("缺少 loginUuid");
|
||||
}
|
||||
|
||||
var loginUuid = input.LoginUuid.Trim();
|
||||
var openId = await weChatLoginSessionStore.GetOpenIdAsync(loginUuid, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(openId))
|
||||
var loginKey = input.LoginUuid.Trim();
|
||||
var session = await weChatLoginSessionStore.GetSessionAsync(loginKey, cancellationToken);
|
||||
if (session == null)
|
||||
{
|
||||
throw new BusinessException("请扫码登录");
|
||||
}
|
||||
|
||||
var customer = await customerRepository.GetByWechatOpenidAsync(openId, cancellationToken);
|
||||
var customer = await ResolveCustomerFromWxSessionAsync(session, cancellationToken);
|
||||
if (customer == null)
|
||||
{
|
||||
throw new BusinessException("未绑定微信,请先注册或绑定账号");
|
||||
@ -105,12 +133,31 @@ public partial class AuthService(
|
||||
throw new BusinessException("客户未关联用户");
|
||||
}
|
||||
|
||||
await weChatLoginSessionStore.RemoveAsync(loginUuid, cancellationToken);
|
||||
await weChatLoginSessionStore.RemoveAsync(loginKey, cancellationToken);
|
||||
|
||||
var claims = await BuildClaimsAsync(userGuid.Value, OmsLoginType.Customer, cancellationToken);
|
||||
return BuildLoginResult(userGuid.Value, claims);
|
||||
}
|
||||
|
||||
private async Task<Customer?> ResolveCustomerFromWxSessionAsync(
|
||||
WeChatLoginSessionPayload session,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (session.CustomerId > 0)
|
||||
{
|
||||
return await customerRepository.FirstOrDefaultAsync(
|
||||
x => x.Id == session.CustomerId,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(session.OpenId))
|
||||
{
|
||||
return await customerRepository.GetByWechatOpenidAsync(session.OpenId, cancellationToken);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 刷新登录令牌。
|
||||
/// </summary>
|
||||
|
||||
@ -4,11 +4,21 @@ namespace SimpleAdmin.Api.WeChat.Dtos;
|
||||
|
||||
public class WxLoginQrcodeOutput
|
||||
{
|
||||
/// <summary>PC 端轮询用的扫码 key(与二维码 scene 一致)。</summary>
|
||||
public string LoginKey { get; set; } = string.Empty;
|
||||
|
||||
public string QrCodeUrl { get; set; } = string.Empty;
|
||||
|
||||
public int ExpiresIn { get; set; } = 600;
|
||||
}
|
||||
|
||||
public class WxLoginStatusOutput
|
||||
{
|
||||
public bool Ready { get; set; }
|
||||
|
||||
public string? Account { get; set; }
|
||||
}
|
||||
|
||||
public class WeChatOAuthCodeInput
|
||||
{
|
||||
[Required]
|
||||
|
||||
@ -1,13 +1,19 @@
|
||||
namespace SimpleAdmin.Api.WeChat.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 微信扫码登录会话:loginUuid → openId 桥接。
|
||||
/// 微信扫码登录会话:loginUuid(key)→ 已绑定账号信息,TTL 默认 10 分钟。
|
||||
/// </summary>
|
||||
public interface IWeChatLoginSessionStore
|
||||
{
|
||||
Task SetOpenIdAsync(string loginUuid, string openId, TimeSpan ttl, CancellationToken cancellationToken = default);
|
||||
Task SetSessionAsync(
|
||||
string loginKey,
|
||||
WeChatLoginSessionPayload payload,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<string?> GetOpenIdAsync(string loginUuid, CancellationToken cancellationToken = default);
|
||||
Task<WeChatLoginSessionPayload?> GetSessionAsync(
|
||||
string loginKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task RemoveAsync(string loginUuid, CancellationToken cancellationToken = default);
|
||||
Task RemoveAsync(string loginKey, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@ -12,8 +12,6 @@ public class WeChatEventHandler(
|
||||
IWeChatLoginSessionStore sessionStore,
|
||||
ILogger<WeChatEventHandler> logger) : IWeChatEventHandler
|
||||
{
|
||||
private const string QrScenePrefix = "qrscene_";
|
||||
|
||||
public async Task<string?> HandleAsync(WeChatIncomingMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!string.Equals(message.MsgType, "event", StringComparison.OrdinalIgnoreCase))
|
||||
@ -40,14 +38,8 @@ public class WeChatEventHandler(
|
||||
return null;
|
||||
}
|
||||
|
||||
var existing = await customerRepository.GetByWechatOpenidAsync(openId, cancellationToken);
|
||||
if (existing == null)
|
||||
{
|
||||
return BuildTextReply(message, "未绑定账号,请在公众号菜单注册账号或绑定已有账号。");
|
||||
}
|
||||
|
||||
var scene = NormalizeScene(message.EventKey);
|
||||
if (string.IsNullOrWhiteSpace(scene))
|
||||
var loginKey = WeChatQrSceneParser.ExtractLoginKey(message.EventKey);
|
||||
if (string.IsNullOrWhiteSpace(loginKey))
|
||||
{
|
||||
if (string.Equals(message.Event, "subscribe", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@ -57,8 +49,26 @@ public class WeChatEventHandler(
|
||||
return null;
|
||||
}
|
||||
|
||||
await sessionStore.SetOpenIdAsync(scene, openId, TimeSpan.FromMinutes(10), cancellationToken);
|
||||
logger.LogInformation("微信扫码登录会话已写入: loginUuid={LoginUuid}, openId={OpenId}", scene, openId);
|
||||
var customer = await customerRepository.GetByWechatOpenidAsync(openId, cancellationToken);
|
||||
if (customer == null)
|
||||
{
|
||||
return BuildTextReply(message, "未绑定账号,请在公众号菜单注册账号或绑定已有账号。");
|
||||
}
|
||||
|
||||
var payload = new WeChatLoginSessionPayload
|
||||
{
|
||||
CustomerId = customer.Id,
|
||||
CustomerCode = customer.CustomerCode,
|
||||
UserId = customer.UserId,
|
||||
OpenId = openId
|
||||
};
|
||||
|
||||
await sessionStore.SetSessionAsync(loginKey, payload, TimeSpan.FromMinutes(10), cancellationToken);
|
||||
logger.LogInformation(
|
||||
"微信扫码登录会话已写入: loginKey={LoginKey}, customerCode={CustomerCode}, openId={OpenId}",
|
||||
loginKey,
|
||||
customer.CustomerCode,
|
||||
openId);
|
||||
|
||||
if (string.Equals(message.Event, "subscribe", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@ -68,18 +78,6 @@ public class WeChatEventHandler(
|
||||
return BuildTextReply(message, "扫码成功,请返回电脑端完成登录。");
|
||||
}
|
||||
|
||||
private static string? NormalizeScene(string? eventKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(eventKey))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return eventKey.StartsWith(QrScenePrefix, StringComparison.Ordinal)
|
||||
? eventKey[QrScenePrefix.Length..]
|
||||
: eventKey;
|
||||
}
|
||||
|
||||
private static string BuildTextReply(WeChatIncomingMessage message, string content)
|
||||
{
|
||||
return WeChatXmlMessageBuilder.BuildTextReply(
|
||||
|
||||
@ -0,0 +1,15 @@
|
||||
namespace SimpleAdmin.Api.WeChat.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 扫码成功后写入缓存的会话数据(key = loginUuid)。
|
||||
/// </summary>
|
||||
public sealed class WeChatLoginSessionPayload
|
||||
{
|
||||
public int CustomerId { get; init; }
|
||||
|
||||
public string CustomerCode { get; init; } = string.Empty;
|
||||
|
||||
public int UserId { get; init; }
|
||||
|
||||
public string OpenId { get; init; } = string.Empty;
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
|
||||
namespace SimpleAdmin.Api.WeChat.Services;
|
||||
@ -6,24 +7,48 @@ public class WeChatLoginSessionStore(IDistributedCache cache) : IWeChatLoginSess
|
||||
{
|
||||
private const string KeyPrefix = "oms:wechat:login:";
|
||||
|
||||
public Task SetOpenIdAsync(string loginUuid, string openId, TimeSpan ttl, CancellationToken cancellationToken = default)
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public Task SetSessionAsync(
|
||||
string loginKey,
|
||||
WeChatLoginSessionPayload payload,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var options = new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = ttl
|
||||
};
|
||||
return cache.SetStringAsync(BuildKey(loginUuid), openId, options, cancellationToken);
|
||||
var json = JsonSerializer.Serialize(payload, JsonOptions);
|
||||
return cache.SetStringAsync(BuildKey(loginKey), json, options, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<string?> GetOpenIdAsync(string loginUuid, CancellationToken cancellationToken = default)
|
||||
public async Task<WeChatLoginSessionPayload?> GetSessionAsync(
|
||||
string loginKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return cache.GetStringAsync(BuildKey(loginUuid), cancellationToken);
|
||||
var raw = await cache.GetStringAsync(BuildKey(loginKey), cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (raw.StartsWith('{'))
|
||||
{
|
||||
return JsonSerializer.Deserialize<WeChatLoginSessionPayload>(raw, JsonOptions);
|
||||
}
|
||||
|
||||
// 兼容旧版仅缓存 openId 的会话
|
||||
return new WeChatLoginSessionPayload { OpenId = raw };
|
||||
}
|
||||
|
||||
public Task RemoveAsync(string loginUuid, CancellationToken cancellationToken = default)
|
||||
public Task RemoveAsync(string loginKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return cache.RemoveAsync(BuildKey(loginUuid), cancellationToken);
|
||||
return cache.RemoveAsync(BuildKey(loginKey), cancellationToken);
|
||||
}
|
||||
|
||||
private static string BuildKey(string loginUuid) => $"{KeyPrefix}{loginUuid}";
|
||||
private static string BuildKey(string loginKey) => $"{KeyPrefix}{loginKey}";
|
||||
}
|
||||
|
||||
57
src/SimpleAdmin.Api/WeChat/Services/WeChatQrSceneParser.cs
Normal file
57
src/SimpleAdmin.Api/WeChat/Services/WeChatQrSceneParser.cs
Normal file
@ -0,0 +1,57 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SimpleAdmin.Api.WeChat.Services;
|
||||
|
||||
internal static class WeChatQrSceneParser
|
||||
{
|
||||
private const string QrScenePrefix = "qrscene_";
|
||||
|
||||
/// <summary>
|
||||
/// 从微信 SCAN / SUBSCRIBE 事件的 EventKey 解析 PC 端轮询用的 loginUuid(key)。
|
||||
/// </summary>
|
||||
public static string? ExtractLoginKey(string? eventKey)
|
||||
{
|
||||
var scene = NormalizeScene(eventKey);
|
||||
if (string.IsNullOrWhiteSpace(scene))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (scene.StartsWith('{') && scene.EndsWith('}'))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(scene);
|
||||
var root = doc.RootElement;
|
||||
if (root.TryGetProperty("loginName", out var loginName)
|
||||
&& loginName.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return loginName.GetString();
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("key", out var key) && key.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return key.GetString();
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return scene;
|
||||
}
|
||||
|
||||
private static string? NormalizeScene(string? eventKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(eventKey))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return eventKey.StartsWith(QrScenePrefix, StringComparison.Ordinal)
|
||||
? eventKey[QrScenePrefix.Length..]
|
||||
: eventKey;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user