feat(wechat): 公众号配置改从 t_system_other_config 读取,支持后台其他配置维护
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
d168a21e96
commit
5c6c4f6e48
31
sql/migrate_wechat_mp_other_config.sql
Normal file
31
sql/migrate_wechat_mp_other_config.sql
Normal 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
|
||||
);
|
||||
@ -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)
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)}";
|
||||
|
||||
@ -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();
|
||||
}
|
||||
@ -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");
|
||||
|
||||
@ -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>();
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user