feat(wechat): 完善扫码登录缓存账号信息与轮询状态接口

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-02 15:51:59 +08:00
parent bf81f00c91
commit 02cb06b86c
10 changed files with 312 additions and 46 deletions

View File

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

View File

@ -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}&timestamp={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();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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