feat(wechat): 公众号配置改从 t_system_other_config 读取,支持后台其他配置维护

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-01 16:18:43 +08:00
parent d168a21e96
commit 5c6c4f6e48
9 changed files with 151 additions and 37 deletions

View File

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

View File

@ -163,6 +163,23 @@ public class SystemOtherConfigService : ApplicationService
throw new BusinessException("轨迹服务 AppSecret 不能为空"); 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) private static SystemOtherConfigOutput MapOutput(SystemOtherConfig entity, bool maskSecret)

View File

@ -9,4 +9,9 @@ public static class SystemOtherConfigCodes
/// 小包轨迹服务配置。 /// 小包轨迹服务配置。
/// </summary> /// </summary>
public const string TrackService = "track_service"; public const string TrackService = "track_service";
/// <summary>
/// 微信公众号配置(AppKey=AppId,AppSecret=Secret,Remark=Token,BaseUrl=OAuthRedirectBase)。
/// </summary>
public const string WeChatMp = "wechat_mp";
} }

View File

@ -3,6 +3,11 @@ using System.Security.Cryptography;
using System.Text; using System.Text;
using FluentAssertions; using FluentAssertions;
using Microsoft.Extensions.DependencyInjection; 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.IntegrationTests.Support;
using SimpleAdmin.Api.WeChat.Options; using SimpleAdmin.Api.WeChat.Options;
@ -13,14 +18,7 @@ public class WeChatRouteIntegrationTests
[Fact] [Fact]
public async Task WeChatRoutes_ShouldBeRegisteredOnAuthService() public async Task WeChatRoutes_ShouldBeRegisteredOnAuthService()
{ {
await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services => await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: ConfigureWeChatFallback);
{
services.PostConfigure<WeChatMpOptions>(options =>
{
options.Token = "test-token";
options.AppId = "wx-test";
});
});
host.RoutePatterns.Should().Contain("apiwechat/verification"); host.RoutePatterns.Should().Contain("apiwechat/verification");
host.RoutePatterns.Should().Contain("wechat/mp/{appId}"); host.RoutePatterns.Should().Contain("wechat/mp/{appId}");
@ -32,6 +30,7 @@ public class WeChatRouteIntegrationTests
{ {
await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services => await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services =>
{ {
ConfigureWeChatFallback(services);
services.PostConfigure<WeChatMpOptions>(options => options.Token = "test-token"); services.PostConfigure<WeChatMpOptions>(options => options.Token = "test-token");
}); });
@ -47,6 +46,22 @@ public class WeChatRouteIntegrationTests
(await response.Content.ReadAsStringAsync()).Should().Be(echostr); (await response.Content.ReadAsStringAsync()).Should().Be(echostr);
} }
private static void ConfigureWeChatFallback(IServiceCollection services)
{
services.PostConfigure<WeChatMpOptions>(options =>
{
options.Token = "test-token";
options.AppId = "wx-test";
});
var repository = new Mock<ISystemOtherConfigRepository>();
repository
.Setup(x => x.GetByConfigCodeAsync(SystemOtherConfigCodes.WeChatMp, It.IsAny<CancellationToken>()))
.ReturnsAsync((SystemOtherConfig?)null);
services.RemoveAll<ISystemOtherConfigRepository>();
services.AddSingleton(repository.Object);
}
private static string SignWeChat(string token, string timestamp, string nonce) private static string SignWeChat(string token, string timestamp, string nonce)
{ {
var parts = new[] { token, timestamp, nonce }.OrderBy(x => x, StringComparer.Ordinal).ToArray(); var parts = new[] { token, timestamp, nonce }.OrderBy(x => x, StringComparer.Ordinal).ToArray();

View File

@ -2,18 +2,16 @@ using Fake.Application;
using Fake.Domain.Exceptions; using Fake.Domain.Exceptions;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using SimpleAdmin.Api.WeChat; using SimpleAdmin.Api.WeChat;
using SimpleAdmin.Api.WeChat.Dtos; using SimpleAdmin.Api.WeChat.Dtos;
using SimpleAdmin.Api.WeChat.Options;
using SimpleAdmin.Api.WeChat.Services; using SimpleAdmin.Api.WeChat.Services;
namespace SimpleAdmin.Api.Auth; namespace SimpleAdmin.Api.Auth;
public partial class AuthService public partial class AuthService
{ {
private IOptions<WeChatMpOptions> WeChatMpOptions => private IWeChatMpConfigProvider WeChatMpConfigProvider =>
LazyServiceProvider.GetRequiredService<IOptions<WeChatMpOptions>>(); LazyServiceProvider.GetRequiredService<IWeChatMpConfigProvider>();
private IWeChatMpApiClient WeChatMpApiClient => private IWeChatMpApiClient WeChatMpApiClient =>
LazyServiceProvider.GetRequiredService<IWeChatMpApiClient>(); LazyServiceProvider.GetRequiredService<IWeChatMpApiClient>();
@ -31,12 +29,16 @@ public partial class AuthService
[HttpGet("/apiwechat/verification")] [HttpGet("/apiwechat/verification")]
[Produces("text/plain")] [Produces("text/plain")]
[ApiExplorerSettings(GroupName = "WeChat")] [ApiExplorerSettings(GroupName = "WeChat")]
public ContentResult WeChatValidateLegacyAsync( public async Task<ContentResult> WeChatValidateLegacyAsync(
[FromQuery] string? signature, [FromQuery] string? signature,
[FromQuery] string? timestamp, [FromQuery] string? timestamp,
[FromQuery] string? nonce, [FromQuery] string? nonce,
[FromQuery] string? echostr) [FromQuery] string? echostr,
=> WeChatMpCallbackSupport.Validate(WeChatMpOptions, signature, timestamp, nonce, echostr); CancellationToken cancellationToken = default)
{
var options = await WeChatMpConfigProvider.GetAsync(cancellationToken);
return WeChatMpCallbackSupport.Validate(options, signature, timestamp, nonce, echostr);
}
/// <summary> /// <summary>
/// 微信服务器接入验签。 /// 微信服务器接入验签。
@ -45,15 +47,17 @@ public partial class AuthService
[HttpGet("/wechat/mp/{appId}")] [HttpGet("/wechat/mp/{appId}")]
[Produces("text/plain")] [Produces("text/plain")]
[ApiExplorerSettings(GroupName = "WeChat")] [ApiExplorerSettings(GroupName = "WeChat")]
public ContentResult WeChatValidateAsync( public async Task<ContentResult> WeChatValidateAsync(
[FromRoute] string appId, [FromRoute] string appId,
[FromQuery] string? signature, [FromQuery] string? signature,
[FromQuery] string? timestamp, [FromQuery] string? timestamp,
[FromQuery] string? nonce, [FromQuery] string? nonce,
[FromQuery] string? echostr) [FromQuery] string? echostr,
CancellationToken cancellationToken = default)
{ {
WeChatMpCallbackSupport.EnsureAppId(WeChatMpOptions, appId); var options = await WeChatMpConfigProvider.GetAsync(cancellationToken);
return WeChatMpCallbackSupport.Validate(WeChatMpOptions, signature, timestamp, nonce, echostr); WeChatMpCallbackSupport.EnsureAppId(options, appId);
return WeChatMpCallbackSupport.Validate(options, signature, timestamp, nonce, echostr);
} }
/// <summary> /// <summary>
@ -64,17 +68,18 @@ public partial class AuthService
[Consumes("text/xml", "application/xml", "text/plain")] [Consumes("text/xml", "application/xml", "text/plain")]
[Produces("application/xml")] [Produces("application/xml")]
[ApiExplorerSettings(GroupName = "WeChat")] [ApiExplorerSettings(GroupName = "WeChat")]
public Task<ContentResult> WeChatMessageLegacyAsync( public async Task<ContentResult> WeChatMessageLegacyAsync(
[FromQuery] string signature, [FromQuery] string signature,
[FromQuery] string timestamp, [FromQuery] string timestamp,
[FromQuery] string nonce, [FromQuery] string nonce,
[FromQuery(Name = "encrypt_type")] string? encryptType, [FromQuery(Name = "encrypt_type")] string? encryptType,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var options = await WeChatMpConfigProvider.GetAsync(cancellationToken);
var request = httpContextAccessor.HttpContext?.Request var request = httpContextAccessor.HttpContext?.Request
?? throw new BusinessException("无法读取微信回调请求"); ?? throw new BusinessException("无法读取微信回调请求");
return WeChatMpCallbackSupport.HandleMessageAsync( return await WeChatMpCallbackSupport.HandleMessageAsync(
WeChatMpOptions, options,
WeChatEventHandler, WeChatEventHandler,
request, request,
WeChatLogger, WeChatLogger,
@ -101,11 +106,12 @@ public partial class AuthService
[FromQuery(Name = "encrypt_type")] string? encryptType, [FromQuery(Name = "encrypt_type")] string? encryptType,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
WeChatMpCallbackSupport.EnsureAppId(WeChatMpOptions, appId); var options = await WeChatMpConfigProvider.GetAsync(cancellationToken);
WeChatMpCallbackSupport.EnsureAppId(options, appId);
var request = httpContextAccessor.HttpContext?.Request var request = httpContextAccessor.HttpContext?.Request
?? throw new BusinessException("无法读取微信回调请求"); ?? throw new BusinessException("无法读取微信回调请求");
return await WeChatMpCallbackSupport.HandleMessageAsync( return await WeChatMpCallbackSupport.HandleMessageAsync(
WeChatMpOptions, options,
WeChatEventHandler, WeChatEventHandler,
request, request,
WeChatLogger, WeChatLogger,

View File

@ -1,7 +1,6 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Xml.Linq; using System.Xml.Linq;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using SimpleAdmin.Api.WeChat.Options; using SimpleAdmin.Api.WeChat.Options;
namespace SimpleAdmin.Api.WeChat.Services; namespace SimpleAdmin.Api.WeChat.Services;
@ -30,7 +29,7 @@ public sealed record WeChatOAuthUserResult(
internal sealed class WeChatMpApiClient( internal sealed class WeChatMpApiClient(
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IMemoryCache memoryCache, IMemoryCache memoryCache,
IOptions<WeChatMpOptions> options, IWeChatMpConfigProvider configProvider,
ILogger<WeChatMpApiClient> logger) : IWeChatMpApiClient ILogger<WeChatMpApiClient> logger) : IWeChatMpApiClient
{ {
private const string AccessTokenCacheKey = "oms:wechat:mp:access_token"; private const string AccessTokenCacheKey = "oms:wechat:mp:access_token";
@ -78,7 +77,7 @@ internal sealed class WeChatMpApiClient(
public async Task<WeChatOAuthTokenResult?> GetOAuthAccessTokenAsync(string code, CancellationToken cancellationToken = default) public async Task<WeChatOAuthTokenResult?> GetOAuthAccessTokenAsync(string code, CancellationToken cancellationToken = default)
{ {
var mp = options.Value; var mp = await configProvider.GetAsync(cancellationToken);
var client = httpClientFactory.CreateClient(nameof(WeChatMpApiClient)); var client = httpClientFactory.CreateClient(nameof(WeChatMpApiClient));
var url = 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"; $"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; return cached;
} }
var mp = options.Value; var mp = await configProvider.GetAsync(cancellationToken);
var client = httpClientFactory.CreateClient(nameof(WeChatMpApiClient)); var client = httpClientFactory.CreateClient(nameof(WeChatMpApiClient));
var url = var url =
$"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={Uri.EscapeDataString(mp.AppId)}&secret={Uri.EscapeDataString(mp.Secret)}"; $"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={Uri.EscapeDataString(mp.AppId)}&secret={Uri.EscapeDataString(mp.Secret)}";

View File

@ -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;
/// <summary>
/// 微信公众号运行时配置:优先读 t_system_other_config(wechat_mp),缺省回退 Consul/appsettings。
/// </summary>
public interface IWeChatMpConfigProvider
{
Task<WeChatMpOptions> GetAsync(CancellationToken cancellationToken = default);
}
internal sealed class WeChatMpConfigProvider(
ISystemOtherConfigRepository repository,
IOptions<WeChatMpOptions> options) : IWeChatMpConfigProvider, IScopedDependency
{
public async Task<WeChatMpOptions> 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();
}

View File

@ -1,6 +1,5 @@
using Fake.Application; using Fake.Application;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using SimpleAdmin.Api.WeChat.Options; using SimpleAdmin.Api.WeChat.Options;
using SimpleAdmin.Api.WeChat.Services; using SimpleAdmin.Api.WeChat.Services;
@ -9,14 +8,14 @@ namespace SimpleAdmin.Api.WeChat;
internal static class WeChatMpCallbackSupport internal static class WeChatMpCallbackSupport
{ {
public static ContentResult Validate( public static ContentResult Validate(
IOptions<WeChatMpOptions> options, WeChatMpOptions options,
string? signature, string? signature,
string? timestamp, string? timestamp,
string? nonce, string? nonce,
string? echostr) string? echostr)
{ {
// 微信服务器要求 HTTP 200 + 纯文本 echostr,不可抛 BusinessException(会变成 JSON 非 200) // 微信服务器要求 HTTP 200 + 纯文本 echostr,不可抛 BusinessException(会变成 JSON 非 200)
if (string.IsNullOrWhiteSpace(options.Value.Token)) if (string.IsNullOrWhiteSpace(options.Token))
{ {
return PlainText("error"); return PlainText("error");
} }
@ -29,12 +28,12 @@ internal static class WeChatMpCallbackSupport
return PlainText("error"); 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"); return PlainText(valid ? echostr : "error");
} }
public static async Task<ContentResult> HandleMessageAsync( public static async Task<ContentResult> HandleMessageAsync(
IOptions<WeChatMpOptions> options, WeChatMpOptions options,
IWeChatEventHandler eventHandler, IWeChatEventHandler eventHandler,
HttpRequest request, HttpRequest request,
ILogger logger, ILogger logger,
@ -44,7 +43,7 @@ internal static class WeChatMpCallbackSupport
string? encryptType, string? encryptType,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (!WeChatSignatureHelper.Validate(options.Value.Token, timestamp, nonce, signature)) if (!WeChatSignatureHelper.Validate(options.Token, timestamp, nonce, signature))
{ {
logger.LogWarning("微信消息验签失败"); logger.LogWarning("微信消息验签失败");
return PlainText("error"); return PlainText("error");
@ -76,9 +75,9 @@ internal static class WeChatMpCallbackSupport
return Xml(reply ?? string.Empty); return Xml(reply ?? string.Empty);
} }
public static void EnsureAppId(IOptions<WeChatMpOptions> options, string appId) public static void EnsureAppId(WeChatMpOptions options, string appId)
{ {
var configured = options.Value.AppId; var configured = options.AppId;
if (string.IsNullOrWhiteSpace(configured)) if (string.IsNullOrWhiteSpace(configured))
{ {
throw new BusinessException("未配置微信公众号 AppId"); throw new BusinessException("未配置微信公众号 AppId");

View File

@ -23,7 +23,7 @@ public static class WeChatServiceExtensions
services.AddDistributedMemoryCache(); services.AddDistributedMemoryCache();
} }
services.AddSingleton<IWeChatMpApiClient, WeChatMpApiClient>(); services.AddScoped<IWeChatMpApiClient, WeChatMpApiClient>();
services.AddSingleton<IWeChatLoginSessionStore, WeChatLoginSessionStore>(); services.AddSingleton<IWeChatLoginSessionStore, WeChatLoginSessionStore>();
services.AddTransient<IWeChatEventHandler, WeChatEventHandler>(); services.AddTransient<IWeChatEventHandler, WeChatEventHandler>();