From 02cb06b86cef17116ee7bf656424ec6684ee6c7c Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Thu, 2 Jul 2026 15:51:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(wechat):=20=E5=AE=8C=E5=96=84=E6=89=AB?= =?UTF-8?q?=E7=A0=81=E7=99=BB=E5=BD=95=E7=BC=93=E5=AD=98=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E4=B8=8E=E8=BD=AE=E8=AF=A2=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../WeChatRouteIntegrationTests.cs | 1 + .../WeChatScanLoginIntegrationTests.cs | 107 ++++++++++++++++++ .../Auth/AuthService.WeChatMp.cs | 12 +- src/SimpleAdmin.Api/Auth/AuthService.cs | 57 +++++++++- src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs | 10 ++ .../Services/IWeChatLoginSessionStore.cs | 14 ++- .../WeChat/Services/WeChatEventHandler.cs | 46 ++++---- .../Services/WeChatLoginSessionPayload.cs | 15 +++ .../Services/WeChatLoginSessionStore.cs | 39 +++++-- .../WeChat/Services/WeChatQrSceneParser.cs | 57 ++++++++++ 10 files changed, 312 insertions(+), 46 deletions(-) create mode 100644 src/SimpleAdmin.Api.IntegrationTests/WeChatScanLoginIntegrationTests.cs create mode 100644 src/SimpleAdmin.Api/WeChat/Services/WeChatLoginSessionPayload.cs create mode 100644 src/SimpleAdmin.Api/WeChat/Services/WeChatQrSceneParser.cs diff --git a/src/SimpleAdmin.Api.IntegrationTests/WeChatRouteIntegrationTests.cs b/src/SimpleAdmin.Api.IntegrationTests/WeChatRouteIntegrationTests.cs index 96c6611..122347f 100644 --- a/src/SimpleAdmin.Api.IntegrationTests/WeChatRouteIntegrationTests.cs +++ b/src/SimpleAdmin.Api.IntegrationTests/WeChatRouteIntegrationTests.cs @@ -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] diff --git a/src/SimpleAdmin.Api.IntegrationTests/WeChatScanLoginIntegrationTests.cs b/src/SimpleAdmin.Api.IntegrationTests/WeChatScanLoginIntegrationTests.cs new file mode 100644 index 0000000..eb5f2a0 --- /dev/null +++ b/src/SimpleAdmin.Api.IntegrationTests/WeChatScanLoginIntegrationTests.cs @@ -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(MockBehavior.Strict); + repository + .Setup(x => x.GetByWechatOpenidAsync(openId, It.IsAny())) + .ReturnsAsync(customer); + + await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services => + { + ConfigureWeChatFallback(services); + services.RemoveAll(); + services.AddSingleton(repository.Object); + }); + + const string timestamp = "1400000000"; + const string nonce = "nonce"; + var signature = SignWeChat("test-token", timestamp, nonce); + + var scanXml = $""" + + + + 1400000000 + + + + + """; + + 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(options => + { + options.Token = "test-token"; + options.AppId = "wx-test"; + }); + + var configRepository = new Mock(); + configRepository + .Setup(x => x.GetByConfigCodeAsync(SystemOtherConfigCodes.WeChatMp, It.IsAny())) + .ReturnsAsync((SystemOtherConfig?)null); + services.RemoveAll(); + 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(); + } +} diff --git a/src/SimpleAdmin.Api/Auth/AuthService.WeChatMp.cs b/src/SimpleAdmin.Api/Auth/AuthService.WeChatMp.cs index 91b67f0..da17f66 100644 --- a/src/SimpleAdmin.Api/Auth/AuthService.WeChatMp.cs +++ b/src/SimpleAdmin.Api/Auth/AuthService.WeChatMp.cs @@ -156,21 +156,21 @@ public partial class AuthService [HttpGet("/wechat/mp/login-qrcode")] [ApiExplorerSettings(GroupName = "WeChat")] public async Task 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 }; diff --git a/src/SimpleAdmin.Api/Auth/AuthService.cs b/src/SimpleAdmin.Api/Auth/AuthService.cs index 0510082..80f7b03 100644 --- a/src/SimpleAdmin.Api/Auth/AuthService.cs +++ b/src/SimpleAdmin.Api/Auth/AuthService.cs @@ -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( }; } + /// + /// 微信扫码登录轮询状态(key 是否已有绑定账号)。 + /// + [AllowAnonymous] + [HttpGet("/rbac/auth/wx-login/status")] + public async Task 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 + }; + } + /// /// 微信扫码登录(轮询)。 /// @@ -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 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; + } + /// /// 刷新登录令牌。 /// diff --git a/src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs b/src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs index 3e5be5c..20d4685 100644 --- a/src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs +++ b/src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs @@ -4,11 +4,21 @@ namespace SimpleAdmin.Api.WeChat.Dtos; public class WxLoginQrcodeOutput { + /// PC 端轮询用的扫码 key(与二维码 scene 一致)。 + 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] diff --git a/src/SimpleAdmin.Api/WeChat/Services/IWeChatLoginSessionStore.cs b/src/SimpleAdmin.Api/WeChat/Services/IWeChatLoginSessionStore.cs index 9c43681..ee71eaf 100644 --- a/src/SimpleAdmin.Api/WeChat/Services/IWeChatLoginSessionStore.cs +++ b/src/SimpleAdmin.Api/WeChat/Services/IWeChatLoginSessionStore.cs @@ -1,13 +1,19 @@ namespace SimpleAdmin.Api.WeChat.Services; /// -/// 微信扫码登录会话:loginUuid → openId 桥接。 +/// 微信扫码登录会话:loginUuid(key)→ 已绑定账号信息,TTL 默认 10 分钟。 /// 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 GetOpenIdAsync(string loginUuid, CancellationToken cancellationToken = default); + Task GetSessionAsync( + string loginKey, + CancellationToken cancellationToken = default); - Task RemoveAsync(string loginUuid, CancellationToken cancellationToken = default); + Task RemoveAsync(string loginKey, CancellationToken cancellationToken = default); } diff --git a/src/SimpleAdmin.Api/WeChat/Services/WeChatEventHandler.cs b/src/SimpleAdmin.Api/WeChat/Services/WeChatEventHandler.cs index 2fc01b9..e82a94c 100644 --- a/src/SimpleAdmin.Api/WeChat/Services/WeChatEventHandler.cs +++ b/src/SimpleAdmin.Api/WeChat/Services/WeChatEventHandler.cs @@ -12,8 +12,6 @@ public class WeChatEventHandler( IWeChatLoginSessionStore sessionStore, ILogger logger) : IWeChatEventHandler { - private const string QrScenePrefix = "qrscene_"; - public async Task 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( diff --git a/src/SimpleAdmin.Api/WeChat/Services/WeChatLoginSessionPayload.cs b/src/SimpleAdmin.Api/WeChat/Services/WeChatLoginSessionPayload.cs new file mode 100644 index 0000000..4365536 --- /dev/null +++ b/src/SimpleAdmin.Api/WeChat/Services/WeChatLoginSessionPayload.cs @@ -0,0 +1,15 @@ +namespace SimpleAdmin.Api.WeChat.Services; + +/// +/// 扫码成功后写入缓存的会话数据(key = loginUuid)。 +/// +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; +} diff --git a/src/SimpleAdmin.Api/WeChat/Services/WeChatLoginSessionStore.cs b/src/SimpleAdmin.Api/WeChat/Services/WeChatLoginSessionStore.cs index 20b09a3..81e8a04 100644 --- a/src/SimpleAdmin.Api/WeChat/Services/WeChatLoginSessionStore.cs +++ b/src/SimpleAdmin.Api/WeChat/Services/WeChatLoginSessionStore.cs @@ -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 GetOpenIdAsync(string loginUuid, CancellationToken cancellationToken = default) + public async Task 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(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}"; } diff --git a/src/SimpleAdmin.Api/WeChat/Services/WeChatQrSceneParser.cs b/src/SimpleAdmin.Api/WeChat/Services/WeChatQrSceneParser.cs new file mode 100644 index 0000000..cd3b9a3 --- /dev/null +++ b/src/SimpleAdmin.Api/WeChat/Services/WeChatQrSceneParser.cs @@ -0,0 +1,57 @@ +using System.Text.Json; + +namespace SimpleAdmin.Api.WeChat.Services; + +internal static class WeChatQrSceneParser +{ + private const string QrScenePrefix = "qrscene_"; + + /// + /// 从微信 SCAN / SUBSCRIBE 事件的 EventKey 解析 PC 端轮询用的 loginUuid(key)。 + /// + 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; + } +}