diff --git a/sql/migrate_wechat_mp_other_config.sql b/sql/migrate_wechat_mp_other_config.sql new file mode 100644 index 0000000..4e4c778 --- /dev/null +++ b/sql/migrate_wechat_mp_other_config.sql @@ -0,0 +1,31 @@ +-- 微信公众号配置(t_system_other_config) +-- 字段映射:AppKey=AppId,AppSecret=Secret,Remark=Token,BaseUrl=OAuthRedirectBase +-- 请在管理端「系统 → 其他配置」填写真实 AppId / AppSecret / Token,勿将密钥提交到 Git。 + +INSERT INTO `t_system_other_config` ( + `ConfigCode`, + `ConfigName`, + `Enabled`, + `BaseUrl`, + `AppKey`, + `AppSecret`, + `Remark`, + `TimeoutSeconds`, + `IsDeleted`, + `CreateUserId` +) +SELECT + 'wechat_mp', + '微信公众号', + 1, + 'https://oms.nanafa.com', + '', + '', + '', + 30, + 0, + 0 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `t_system_other_config` WHERE `ConfigCode` = 'wechat_mp' AND `IsDeleted` = 0 +); diff --git a/src/Oms.Application/Services/System/SystemOtherConfigService.cs b/src/Oms.Application/Services/System/SystemOtherConfigService.cs index 7f88981..1535b23 100644 --- a/src/Oms.Application/Services/System/SystemOtherConfigService.cs +++ b/src/Oms.Application/Services/System/SystemOtherConfigService.cs @@ -163,6 +163,23 @@ public class SystemOtherConfigService : ApplicationService throw new BusinessException("轨迹服务 AppSecret 不能为空"); } } + else if (string.Equals(configCode, SystemOtherConfigCodes.WeChatMp, StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(input.AppKey)) + { + throw new BusinessException("微信公众号 AppId 不能为空"); + } + + if (string.IsNullOrWhiteSpace(input.Remark)) + { + throw new BusinessException("微信公众号 Token 不能为空"); + } + + if (excludeId == null && ShouldKeepExistingSecret(input.AppSecret)) + { + throw new BusinessException("微信公众号 AppSecret 不能为空"); + } + } } private static SystemOtherConfigOutput MapOutput(SystemOtherConfig entity, bool maskSecret) diff --git a/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs b/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs index f549e00..4363b2d 100644 --- a/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs +++ b/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs @@ -9,4 +9,9 @@ public static class SystemOtherConfigCodes /// 小包轨迹服务配置。 /// public const string TrackService = "track_service"; + + /// + /// 微信公众号配置(AppKey=AppId,AppSecret=Secret,Remark=Token,BaseUrl=OAuthRedirectBase)。 + /// + public const string WeChatMp = "wechat_mp"; } diff --git a/src/SimpleAdmin.Api.IntegrationTests/WeChatRouteIntegrationTests.cs b/src/SimpleAdmin.Api.IntegrationTests/WeChatRouteIntegrationTests.cs index bb07084..205ac8b 100644 --- a/src/SimpleAdmin.Api.IntegrationTests/WeChatRouteIntegrationTests.cs +++ b/src/SimpleAdmin.Api.IntegrationTests/WeChatRouteIntegrationTests.cs @@ -3,6 +3,11 @@ using System.Security.Cryptography; using System.Text; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Moq; +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; @@ -13,14 +18,7 @@ public class WeChatRouteIntegrationTests [Fact] public async Task WeChatRoutes_ShouldBeRegisteredOnAuthService() { - await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services => - { - services.PostConfigure(options => - { - options.Token = "test-token"; - options.AppId = "wx-test"; - }); - }); + await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: ConfigureWeChatFallback); host.RoutePatterns.Should().Contain("apiwechat/verification"); host.RoutePatterns.Should().Contain("wechat/mp/{appId}"); @@ -32,6 +30,7 @@ public class WeChatRouteIntegrationTests { await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services => { + ConfigureWeChatFallback(services); services.PostConfigure(options => options.Token = "test-token"); }); @@ -47,6 +46,22 @@ public class WeChatRouteIntegrationTests (await response.Content.ReadAsStringAsync()).Should().Be(echostr); } + private static void ConfigureWeChatFallback(IServiceCollection services) + { + services.PostConfigure(options => + { + options.Token = "test-token"; + options.AppId = "wx-test"; + }); + + var repository = new Mock(); + repository + .Setup(x => x.GetByConfigCodeAsync(SystemOtherConfigCodes.WeChatMp, It.IsAny())) + .ReturnsAsync((SystemOtherConfig?)null); + services.RemoveAll(); + services.AddSingleton(repository.Object); + } + private static string SignWeChat(string token, string timestamp, string nonce) { var parts = new[] { token, timestamp, nonce }.OrderBy(x => x, StringComparer.Ordinal).ToArray(); diff --git a/src/SimpleAdmin.Api/Auth/AuthService.WeChatMp.cs b/src/SimpleAdmin.Api/Auth/AuthService.WeChatMp.cs index 2265077..df57e47 100644 --- a/src/SimpleAdmin.Api/Auth/AuthService.WeChatMp.cs +++ b/src/SimpleAdmin.Api/Auth/AuthService.WeChatMp.cs @@ -2,18 +2,16 @@ using Fake.Application; using Fake.Domain.Exceptions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; using SimpleAdmin.Api.WeChat; using SimpleAdmin.Api.WeChat.Dtos; -using SimpleAdmin.Api.WeChat.Options; using SimpleAdmin.Api.WeChat.Services; namespace SimpleAdmin.Api.Auth; public partial class AuthService { - private IOptions WeChatMpOptions => - LazyServiceProvider.GetRequiredService>(); + private IWeChatMpConfigProvider WeChatMpConfigProvider => + LazyServiceProvider.GetRequiredService(); private IWeChatMpApiClient WeChatMpApiClient => LazyServiceProvider.GetRequiredService(); @@ -31,12 +29,16 @@ public partial class AuthService [HttpGet("/apiwechat/verification")] [Produces("text/plain")] [ApiExplorerSettings(GroupName = "WeChat")] - public ContentResult WeChatValidateLegacyAsync( + public async Task WeChatValidateLegacyAsync( [FromQuery] string? signature, [FromQuery] string? timestamp, [FromQuery] string? nonce, - [FromQuery] string? echostr) - => WeChatMpCallbackSupport.Validate(WeChatMpOptions, signature, timestamp, nonce, echostr); + [FromQuery] string? echostr, + CancellationToken cancellationToken = default) + { + var options = await WeChatMpConfigProvider.GetAsync(cancellationToken); + return WeChatMpCallbackSupport.Validate(options, signature, timestamp, nonce, echostr); + } /// /// 微信服务器接入验签。 @@ -45,15 +47,17 @@ public partial class AuthService [HttpGet("/wechat/mp/{appId}")] [Produces("text/plain")] [ApiExplorerSettings(GroupName = "WeChat")] - public ContentResult WeChatValidateAsync( + public async Task WeChatValidateAsync( [FromRoute] string appId, [FromQuery] string? signature, [FromQuery] string? timestamp, [FromQuery] string? nonce, - [FromQuery] string? echostr) + [FromQuery] string? echostr, + CancellationToken cancellationToken = default) { - WeChatMpCallbackSupport.EnsureAppId(WeChatMpOptions, appId); - return WeChatMpCallbackSupport.Validate(WeChatMpOptions, signature, timestamp, nonce, echostr); + var options = await WeChatMpConfigProvider.GetAsync(cancellationToken); + WeChatMpCallbackSupport.EnsureAppId(options, appId); + return WeChatMpCallbackSupport.Validate(options, signature, timestamp, nonce, echostr); } /// @@ -64,17 +68,18 @@ public partial class AuthService [Consumes("text/xml", "application/xml", "text/plain")] [Produces("application/xml")] [ApiExplorerSettings(GroupName = "WeChat")] - public Task WeChatMessageLegacyAsync( + public async Task WeChatMessageLegacyAsync( [FromQuery] string signature, [FromQuery] string timestamp, [FromQuery] string nonce, [FromQuery(Name = "encrypt_type")] string? encryptType, CancellationToken cancellationToken = default) { + var options = await WeChatMpConfigProvider.GetAsync(cancellationToken); var request = httpContextAccessor.HttpContext?.Request ?? throw new BusinessException("无法读取微信回调请求"); - return WeChatMpCallbackSupport.HandleMessageAsync( - WeChatMpOptions, + return await WeChatMpCallbackSupport.HandleMessageAsync( + options, WeChatEventHandler, request, WeChatLogger, @@ -101,11 +106,12 @@ public partial class AuthService [FromQuery(Name = "encrypt_type")] string? encryptType, CancellationToken cancellationToken = default) { - WeChatMpCallbackSupport.EnsureAppId(WeChatMpOptions, appId); + var options = await WeChatMpConfigProvider.GetAsync(cancellationToken); + WeChatMpCallbackSupport.EnsureAppId(options, appId); var request = httpContextAccessor.HttpContext?.Request ?? throw new BusinessException("无法读取微信回调请求"); return await WeChatMpCallbackSupport.HandleMessageAsync( - WeChatMpOptions, + options, WeChatEventHandler, request, WeChatLogger, diff --git a/src/SimpleAdmin.Api/WeChat/Services/WeChatMpApiClient.cs b/src/SimpleAdmin.Api/WeChat/Services/WeChatMpApiClient.cs index 126df39..60fbccb 100644 --- a/src/SimpleAdmin.Api/WeChat/Services/WeChatMpApiClient.cs +++ b/src/SimpleAdmin.Api/WeChat/Services/WeChatMpApiClient.cs @@ -1,7 +1,6 @@ using System.Text.Json.Serialization; using System.Xml.Linq; using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Options; using SimpleAdmin.Api.WeChat.Options; namespace SimpleAdmin.Api.WeChat.Services; @@ -30,7 +29,7 @@ public sealed record WeChatOAuthUserResult( internal sealed class WeChatMpApiClient( IHttpClientFactory httpClientFactory, IMemoryCache memoryCache, - IOptions options, + IWeChatMpConfigProvider configProvider, ILogger logger) : IWeChatMpApiClient { private const string AccessTokenCacheKey = "oms:wechat:mp:access_token"; @@ -78,7 +77,7 @@ internal sealed class WeChatMpApiClient( public async Task GetOAuthAccessTokenAsync(string code, CancellationToken cancellationToken = default) { - var mp = options.Value; + var mp = await configProvider.GetAsync(cancellationToken); var client = httpClientFactory.CreateClient(nameof(WeChatMpApiClient)); var url = $"https://api.weixin.qq.com/sns/oauth2/access_token?appid={Uri.EscapeDataString(mp.AppId)}&secret={Uri.EscapeDataString(mp.Secret)}&code={Uri.EscapeDataString(code)}&grant_type=authorization_code"; @@ -131,7 +130,7 @@ internal sealed class WeChatMpApiClient( return cached; } - var mp = options.Value; + var mp = await configProvider.GetAsync(cancellationToken); var client = httpClientFactory.CreateClient(nameof(WeChatMpApiClient)); var url = $"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={Uri.EscapeDataString(mp.AppId)}&secret={Uri.EscapeDataString(mp.Secret)}"; diff --git a/src/SimpleAdmin.Api/WeChat/Services/WeChatMpConfigProvider.cs b/src/SimpleAdmin.Api/WeChat/Services/WeChatMpConfigProvider.cs new file mode 100644 index 0000000..135994a --- /dev/null +++ b/src/SimpleAdmin.Api/WeChat/Services/WeChatMpConfigProvider.cs @@ -0,0 +1,42 @@ +using Fake.DependencyInjection; +using Microsoft.Extensions.Options; +using Oms.Domain.SystemAggregate.Consts; +using Oms.Domain.SystemAggregate.Repositories; +using SimpleAdmin.Api.WeChat.Options; + +namespace SimpleAdmin.Api.WeChat.Services; + +/// +/// 微信公众号运行时配置:优先读 t_system_other_config(wechat_mp),缺省回退 Consul/appsettings。 +/// +public interface IWeChatMpConfigProvider +{ + Task GetAsync(CancellationToken cancellationToken = default); +} + +internal sealed class WeChatMpConfigProvider( + ISystemOtherConfigRepository repository, + IOptions options) : IWeChatMpConfigProvider, IScopedDependency +{ + public async Task GetAsync(CancellationToken cancellationToken = default) + { + var fallback = options.Value; + var entity = await repository.GetByConfigCodeAsync(SystemOtherConfigCodes.WeChatMp, cancellationToken); + if (entity == null) + { + return fallback; + } + + return new WeChatMpOptions + { + AppId = Coalesce(entity.AppKey, fallback.AppId), + Secret = Coalesce(entity.AppSecret, fallback.Secret), + Token = Coalesce(entity.Remark, fallback.Token), + OAuthRedirectBase = Coalesce(entity.BaseUrl, fallback.OAuthRedirectBase), + EncodingAesKey = fallback.EncodingAesKey + }; + } + + private static string Coalesce(string? primary, string fallback) => + string.IsNullOrWhiteSpace(primary) ? fallback : primary.Trim(); +} diff --git a/src/SimpleAdmin.Api/WeChat/WeChatMpCallbackSupport.cs b/src/SimpleAdmin.Api/WeChat/WeChatMpCallbackSupport.cs index 98d0ef3..4d3d126 100644 --- a/src/SimpleAdmin.Api/WeChat/WeChatMpCallbackSupport.cs +++ b/src/SimpleAdmin.Api/WeChat/WeChatMpCallbackSupport.cs @@ -1,6 +1,5 @@ using Fake.Application; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; using SimpleAdmin.Api.WeChat.Options; using SimpleAdmin.Api.WeChat.Services; @@ -9,14 +8,14 @@ namespace SimpleAdmin.Api.WeChat; internal static class WeChatMpCallbackSupport { public static ContentResult Validate( - IOptions options, + WeChatMpOptions options, string? signature, string? timestamp, string? nonce, string? echostr) { // 微信服务器要求 HTTP 200 + 纯文本 echostr,不可抛 BusinessException(会变成 JSON 非 200) - if (string.IsNullOrWhiteSpace(options.Value.Token)) + if (string.IsNullOrWhiteSpace(options.Token)) { return PlainText("error"); } @@ -29,12 +28,12 @@ internal static class WeChatMpCallbackSupport return PlainText("error"); } - var valid = WeChatSignatureHelper.Validate(options.Value.Token, timestamp, nonce, signature); + var valid = WeChatSignatureHelper.Validate(options.Token, timestamp, nonce, signature); return PlainText(valid ? echostr : "error"); } public static async Task HandleMessageAsync( - IOptions options, + WeChatMpOptions options, IWeChatEventHandler eventHandler, HttpRequest request, ILogger logger, @@ -44,7 +43,7 @@ internal static class WeChatMpCallbackSupport string? encryptType, CancellationToken cancellationToken) { - if (!WeChatSignatureHelper.Validate(options.Value.Token, timestamp, nonce, signature)) + if (!WeChatSignatureHelper.Validate(options.Token, timestamp, nonce, signature)) { logger.LogWarning("微信消息验签失败"); return PlainText("error"); @@ -76,9 +75,9 @@ internal static class WeChatMpCallbackSupport return Xml(reply ?? string.Empty); } - public static void EnsureAppId(IOptions options, string appId) + public static void EnsureAppId(WeChatMpOptions options, string appId) { - var configured = options.Value.AppId; + var configured = options.AppId; if (string.IsNullOrWhiteSpace(configured)) { throw new BusinessException("未配置微信公众号 AppId"); diff --git a/src/SimpleAdmin.Api/WeChat/WeChatServiceExtensions.cs b/src/SimpleAdmin.Api/WeChat/WeChatServiceExtensions.cs index 6213e1d..0009fdd 100644 --- a/src/SimpleAdmin.Api/WeChat/WeChatServiceExtensions.cs +++ b/src/SimpleAdmin.Api/WeChat/WeChatServiceExtensions.cs @@ -23,7 +23,7 @@ public static class WeChatServiceExtensions services.AddDistributedMemoryCache(); } - services.AddSingleton(); + services.AddScoped(); services.AddSingleton(); services.AddTransient();