feat(wechat): 将 access_token 持久化到 t_system_other_config
改用数据库存储 token 并支持 AppId/Secret 变更时自动清空,替代内存缓存并增加无效 token 重试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
5c6c4f6e48
commit
8ef9a43f76
5
sql/migrate_t_system_other_config_wechat_token.sql
Normal file
5
sql/migrate_t_system_other_config_wechat_token.sql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
-- 微信公众号 access_token 持久化(仅 wechat_mp 使用,不对前端展示)
|
||||||
|
ALTER TABLE `t_system_other_config`
|
||||||
|
ADD COLUMN `AccessToken` VARCHAR(512) NULL COMMENT '微信 access_token(运行时缓存)' AFTER `Remark`,
|
||||||
|
ADD COLUMN `AccessTokenExpiresIn` INT NOT NULL DEFAULT 0 COMMENT '微信 access_token 有效期秒数 expires_in' AFTER `AccessToken`,
|
||||||
|
ADD COLUMN `AccessTokenUpdatedAt` DATETIME NULL COMMENT '微信 access_token 最近刷新时间' AFTER `AccessTokenExpiresIn`;
|
||||||
@ -82,6 +82,7 @@ public class SystemOtherConfigService : ApplicationService
|
|||||||
{
|
{
|
||||||
var entity = await GetConfigOrThrowAsync(input.Id);
|
var entity = await GetConfigOrThrowAsync(input.Id);
|
||||||
await ValidateInputAsync(input, entity.Id);
|
await ValidateInputAsync(input, entity.Id);
|
||||||
|
var clearWeChatAccessToken = ShouldClearWeChatAccessToken(entity, input);
|
||||||
entity.ConfigCode = input.ConfigCode.Trim();
|
entity.ConfigCode = input.ConfigCode.Trim();
|
||||||
entity.ConfigName = input.ConfigName.Trim();
|
entity.ConfigName = input.ConfigName.Trim();
|
||||||
entity.Enabled = input.Enabled;
|
entity.Enabled = input.Enabled;
|
||||||
@ -94,6 +95,13 @@ public class SystemOtherConfigService : ApplicationService
|
|||||||
|
|
||||||
entity.TimeoutSeconds = NormalizeTimeoutSeconds(input.TimeoutSeconds);
|
entity.TimeoutSeconds = NormalizeTimeoutSeconds(input.TimeoutSeconds);
|
||||||
entity.Remark = NormalizeOptional(input.Remark);
|
entity.Remark = NormalizeOptional(input.Remark);
|
||||||
|
if (clearWeChatAccessToken)
|
||||||
|
{
|
||||||
|
entity.AccessToken = null;
|
||||||
|
entity.AccessTokenExpiresIn = 0;
|
||||||
|
entity.AccessTokenUpdatedAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
await SystemOtherConfigRepository.UpdateAsync(entity);
|
await SystemOtherConfigRepository.UpdateAsync(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -206,6 +214,29 @@ public class SystemOtherConfigService : ApplicationService
|
|||||||
private static bool ShouldKeepExistingSecret(string? secret) =>
|
private static bool ShouldKeepExistingSecret(string? secret) =>
|
||||||
string.IsNullOrWhiteSpace(secret) || string.Equals(secret.Trim(), SecretMask, StringComparison.Ordinal);
|
string.IsNullOrWhiteSpace(secret) || string.Equals(secret.Trim(), SecretMask, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
private static bool ShouldClearWeChatAccessToken(SystemOtherConfig entity, SystemOtherConfigUpdateInput input)
|
||||||
|
{
|
||||||
|
if (!string.Equals(entity.ConfigCode, SystemOtherConfigCodes.WeChatMp, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.Equals(input.ConfigCode.Trim(), SystemOtherConfigCodes.WeChatMp, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nextAppKey = NormalizeOptional(input.AppKey);
|
||||||
|
if (!string.Equals(entity.AppKey, nextAppKey, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ShouldKeepExistingSecret(input.AppSecret)
|
||||||
|
&& !string.Equals(entity.AppSecret, NormalizeOptional(input.AppSecret), StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private static string? NormalizeOptional(string? value) =>
|
private static string? NormalizeOptional(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
|||||||
@ -21,4 +21,19 @@ public class SystemOtherConfig : OmsAuditedAggregateRoot
|
|||||||
public int TimeoutSeconds { get; set; } = 30;
|
public int TimeoutSeconds { get; set; } = 30;
|
||||||
|
|
||||||
public string? Remark { get; set; }
|
public string? Remark { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 微信 access_token(运行时缓存,不对前端展示)。
|
||||||
|
/// </summary>
|
||||||
|
public string? AccessToken { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 微信 access_token 有效期(expires_in,秒)。
|
||||||
|
/// </summary>
|
||||||
|
public int AccessTokenExpiresIn { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 微信 access_token 最近刷新时间。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? AccessTokenUpdatedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,78 @@
|
|||||||
|
using Fake.DependencyInjection;
|
||||||
|
using Oms.Domain.SystemAggregate.Consts;
|
||||||
|
using Oms.Domain.SystemAggregate.Entities;
|
||||||
|
using Oms.Domain.SystemAggregate.Repositories;
|
||||||
|
|
||||||
|
namespace SimpleAdmin.Api.WeChat.Services;
|
||||||
|
|
||||||
|
public interface IWeChatMpAccessTokenStore
|
||||||
|
{
|
||||||
|
Task<string?> GetValidAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task SaveAsync(string accessToken, int expiresIn, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task ClearAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class WeChatMpAccessTokenStore(
|
||||||
|
ISystemOtherConfigRepository repository,
|
||||||
|
ILogger<WeChatMpAccessTokenStore> logger) : IWeChatMpAccessTokenStore, IScopedDependency
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan RefreshSkew = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
public async Task<string?> GetValidAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var entity = await GetWeChatConfigAsync(cancellationToken);
|
||||||
|
if (entity == null
|
||||||
|
|| string.IsNullOrWhiteSpace(entity.AccessToken)
|
||||||
|
|| entity.AccessTokenExpiresIn <= 0
|
||||||
|
|| entity.AccessTokenUpdatedAt == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var expiresAt = entity.AccessTokenUpdatedAt.Value.AddSeconds(entity.AccessTokenExpiresIn);
|
||||||
|
if (expiresAt <= DateTime.Now.Add(RefreshSkew))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entity.AccessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveAsync(string accessToken, int expiresIn, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var entity = await GetWeChatConfigAsync(cancellationToken);
|
||||||
|
if (entity == null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("未找到 wechat_mp 其他配置,无法持久化 access_token");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.AccessToken = accessToken;
|
||||||
|
entity.AccessTokenExpiresIn = expiresIn;
|
||||||
|
entity.AccessTokenUpdatedAt = DateTime.Now;
|
||||||
|
await repository.UpdateAsync(entity, cancellationToken);
|
||||||
|
logger.LogInformation(
|
||||||
|
"微信 access_token 已写入 t_system_other_config,ExpiresIn={ExpiresIn}s",
|
||||||
|
expiresIn);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ClearAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var entity = await GetWeChatConfigAsync(cancellationToken);
|
||||||
|
if (entity == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.AccessToken = null;
|
||||||
|
entity.AccessTokenExpiresIn = 0;
|
||||||
|
entity.AccessTokenUpdatedAt = null;
|
||||||
|
await repository.UpdateAsync(entity, cancellationToken);
|
||||||
|
logger.LogInformation("已清空 t_system_other_config 中的微信 access_token");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<SystemOtherConfig?> GetWeChatConfigAsync(CancellationToken cancellationToken) =>
|
||||||
|
repository.GetByConfigCodeAsync(SystemOtherConfigCodes.WeChatMp, cancellationToken);
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using System.Xml.Linq;
|
using System.Xml.Linq;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
|
||||||
using SimpleAdmin.Api.WeChat.Options;
|
using SimpleAdmin.Api.WeChat.Options;
|
||||||
|
|
||||||
namespace SimpleAdmin.Api.WeChat.Services;
|
namespace SimpleAdmin.Api.WeChat.Services;
|
||||||
@ -12,6 +12,11 @@ public interface IWeChatMpApiClient
|
|||||||
Task<WeChatOAuthTokenResult?> GetOAuthAccessTokenAsync(string code, CancellationToken cancellationToken = default);
|
Task<WeChatOAuthTokenResult?> GetOAuthAccessTokenAsync(string code, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<WeChatOAuthUserResult?> GetOAuthUserInfoAsync(string accessToken, string openId, CancellationToken cancellationToken = default);
|
Task<WeChatOAuthUserResult?> GetOAuthUserInfoAsync(string accessToken, string openId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 清空数据库中缓存的公众号 access_token。
|
||||||
|
/// </summary>
|
||||||
|
Task InvalidateAccessTokenCacheAsync(CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record WeChatOAuthTokenResult(string AccessToken, string OpenId);
|
public sealed record WeChatOAuthTokenResult(string AccessToken, string OpenId);
|
||||||
@ -28,18 +33,29 @@ public sealed record WeChatOAuthUserResult(
|
|||||||
|
|
||||||
internal sealed class WeChatMpApiClient(
|
internal sealed class WeChatMpApiClient(
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
IMemoryCache memoryCache,
|
|
||||||
IWeChatMpConfigProvider configProvider,
|
IWeChatMpConfigProvider configProvider,
|
||||||
|
IWeChatMpAccessTokenStore accessTokenStore,
|
||||||
ILogger<WeChatMpApiClient> logger) : IWeChatMpApiClient
|
ILogger<WeChatMpApiClient> logger) : IWeChatMpApiClient
|
||||||
{
|
{
|
||||||
private const string AccessTokenCacheKey = "oms:wechat:mp:access_token";
|
public Task InvalidateAccessTokenCacheAsync(CancellationToken cancellationToken = default) =>
|
||||||
|
accessTokenStore.ClearAsync(cancellationToken);
|
||||||
|
|
||||||
public async Task<string> CreateLoginQrCodeUrlAsync(
|
public async Task<string> CreateLoginQrCodeUrlAsync(
|
||||||
string loginUuid,
|
string loginUuid,
|
||||||
int expireSeconds,
|
int expireSeconds,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var accessToken = await GetAccessTokenAsync(cancellationToken);
|
return await CreateLoginQrCodeUrlCoreAsync(loginUuid, expireSeconds, retried: false, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> CreateLoginQrCodeUrlCoreAsync(
|
||||||
|
string loginUuid,
|
||||||
|
int expireSeconds,
|
||||||
|
bool retried,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var mp = await configProvider.GetAsync(cancellationToken);
|
||||||
|
var accessToken = await GetAccessTokenAsync(mp, cancellationToken);
|
||||||
var client = httpClientFactory.CreateClient(nameof(WeChatMpApiClient));
|
var client = httpClientFactory.CreateClient(nameof(WeChatMpApiClient));
|
||||||
|
|
||||||
var payload = new
|
var payload = new
|
||||||
@ -60,8 +76,10 @@ internal sealed class WeChatMpApiClient(
|
|||||||
payload,
|
payload,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
response.EnsureSuccessStatusCode();
|
var result = await ReadWeChatJsonAsync<WeChatQrTicketResponse>(
|
||||||
var result = await response.Content.ReadFromJsonAsync<WeChatQrTicketResponse>(cancellationToken: cancellationToken);
|
response,
|
||||||
|
"创建登录二维码",
|
||||||
|
cancellationToken);
|
||||||
if (result == null || string.IsNullOrWhiteSpace(result.Ticket))
|
if (result == null || string.IsNullOrWhiteSpace(result.Ticket))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(result?.Errmsg ?? "创建微信二维码失败");
|
throw new InvalidOperationException(result?.Errmsg ?? "创建微信二维码失败");
|
||||||
@ -69,7 +87,14 @@ internal sealed class WeChatMpApiClient(
|
|||||||
|
|
||||||
if (result.Errcode is > 0)
|
if (result.Errcode is > 0)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(result.Errmsg ?? $"创建微信二维码失败({result.Errcode})");
|
if (!retried && IsAccessTokenInvalidError(result.Errcode))
|
||||||
|
{
|
||||||
|
logger.LogWarning("微信 access_token 失效(errcode={Errcode}),清空库内 token 后重试创建二维码", result.Errcode);
|
||||||
|
await accessTokenStore.ClearAsync(cancellationToken);
|
||||||
|
return await CreateLoginQrCodeUrlCoreAsync(loginUuid, expireSeconds, retried: true, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException(FormatWeChatError("创建登录二维码", result.Errcode, result.Errmsg));
|
||||||
}
|
}
|
||||||
|
|
||||||
return $"https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket={Uri.EscapeDataString(result.Ticket)}";
|
return $"https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket={Uri.EscapeDataString(result.Ticket)}";
|
||||||
@ -123,30 +148,100 @@ internal sealed class WeChatMpApiClient(
|
|||||||
body.Unionid);
|
body.Unionid);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> GetAccessTokenAsync(CancellationToken cancellationToken)
|
private async Task<string> GetAccessTokenAsync(WeChatMpOptions mp, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (memoryCache.TryGetValue(AccessTokenCacheKey, out string? cached) && !string.IsNullOrWhiteSpace(cached))
|
EnsureConfigured(mp);
|
||||||
|
|
||||||
|
var cached = await accessTokenStore.GetValidAsync(cancellationToken);
|
||||||
|
if (!string.IsNullOrWhiteSpace(cached))
|
||||||
{
|
{
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
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)}";
|
||||||
|
|
||||||
using var response = await client.GetAsync(url, cancellationToken);
|
using var response = await client.GetAsync(url, cancellationToken);
|
||||||
response.EnsureSuccessStatusCode();
|
var body = await ReadWeChatJsonAsync<WeChatAccessTokenResponse>(
|
||||||
var body = await response.Content.ReadFromJsonAsync<WeChatAccessTokenResponse>(cancellationToken: cancellationToken);
|
response,
|
||||||
|
"获取 access_token",
|
||||||
|
cancellationToken);
|
||||||
if (body == null || string.IsNullOrWhiteSpace(body.AccessToken))
|
if (body == null || string.IsNullOrWhiteSpace(body.AccessToken))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(body?.Errmsg ?? "获取微信 access_token 失败");
|
throw new InvalidOperationException(
|
||||||
|
FormatWeChatError("获取 access_token", body?.Errcode ?? 0, body?.Errmsg));
|
||||||
}
|
}
|
||||||
|
|
||||||
memoryCache.Set(AccessTokenCacheKey, body.AccessToken, TimeSpan.FromSeconds(Math.Max(300, body.ExpiresIn - 300)));
|
await accessTokenStore.SaveAsync(body.AccessToken, body.ExpiresIn, cancellationToken);
|
||||||
return body.AccessToken;
|
return body.AccessToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsAccessTokenInvalidError(int errcode) => errcode is 40001 or 40014 or 42001;
|
||||||
|
|
||||||
|
private static void EnsureConfigured(WeChatMpOptions mp)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(mp.AppId))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("未配置微信公众号 AppId,请在系统其他配置(wechat_mp)或 Consul 中维护");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(mp.Secret))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("未配置微信公众号 AppSecret,请在系统其他配置(wechat_mp)或 Consul 中维护");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T?> ReadWeChatJsonAsync<T>(
|
||||||
|
HttpResponseMessage response,
|
||||||
|
string action,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
var raw = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"微信{Action} HTTP 失败: {StatusCode} {Body}",
|
||||||
|
action,
|
||||||
|
(int)response.StatusCode,
|
||||||
|
raw);
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"微信{action}失败 HTTP {(int)response.StatusCode} {response.ReasonPhrase}: {raw}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<T>(raw);
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "微信{Action}响应解析失败: {Body}", action, raw);
|
||||||
|
throw new InvalidOperationException($"微信{action}响应解析失败: {raw}", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatWeChatError(string action, int errcode, string? errmsg)
|
||||||
|
{
|
||||||
|
var message = string.IsNullOrWhiteSpace(errmsg) ? $"errcode={errcode}" : errmsg;
|
||||||
|
if (errcode == 40164)
|
||||||
|
{
|
||||||
|
return $"微信{action}失败: {message}(请将 API 服务器出口 IP 加入公众号 IP 白名单)";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errcode is 40001 or 40013 or 40125 or 40014 or 42001)
|
||||||
|
{
|
||||||
|
return $"微信{action}失败: {message}(请检查 AppId / AppSecret 是否正确,或 access_token 已失效需刷新)";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"微信{action}失败: {message}";
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class WeChatAccessTokenResponse
|
private sealed class WeChatAccessTokenResponse
|
||||||
{
|
{
|
||||||
[JsonPropertyName("access_token")]
|
[JsonPropertyName("access_token")]
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user