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 不能为空");
}
}
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)

View File

@ -9,4 +9,9 @@ public static class SystemOtherConfigCodes
/// 小包轨迹服务配置。
/// </summary>
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 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<WeChatMpOptions>(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<WeChatMpOptions>(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<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)
{
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 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> WeChatMpOptions =>
LazyServiceProvider.GetRequiredService<IOptions<WeChatMpOptions>>();
private IWeChatMpConfigProvider WeChatMpConfigProvider =>
LazyServiceProvider.GetRequiredService<IWeChatMpConfigProvider>();
private IWeChatMpApiClient WeChatMpApiClient =>
LazyServiceProvider.GetRequiredService<IWeChatMpApiClient>();
@ -31,12 +29,16 @@ public partial class AuthService
[HttpGet("/apiwechat/verification")]
[Produces("text/plain")]
[ApiExplorerSettings(GroupName = "WeChat")]
public ContentResult WeChatValidateLegacyAsync(
public async Task<ContentResult> 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);
}
/// <summary>
/// 微信服务器接入验签。
@ -45,15 +47,17 @@ public partial class AuthService
[HttpGet("/wechat/mp/{appId}")]
[Produces("text/plain")]
[ApiExplorerSettings(GroupName = "WeChat")]
public ContentResult WeChatValidateAsync(
public async Task<ContentResult> 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);
}
/// <summary>
@ -64,17 +68,18 @@ public partial class AuthService
[Consumes("text/xml", "application/xml", "text/plain")]
[Produces("application/xml")]
[ApiExplorerSettings(GroupName = "WeChat")]
public Task<ContentResult> WeChatMessageLegacyAsync(
public async Task<ContentResult> 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,

View File

@ -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<WeChatMpOptions> options,
IWeChatMpConfigProvider configProvider,
ILogger<WeChatMpApiClient> logger) : IWeChatMpApiClient
{
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)
{
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)}";

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 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<WeChatMpOptions> 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<ContentResult> HandleMessageAsync(
IOptions<WeChatMpOptions> 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<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))
{
throw new BusinessException("未配置微信公众号 AppId");

View File

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