feat(wechat): 将 access_token 持久化到 t_system_other_config

改用数据库存储 token 并支持 AppId/Secret 变更时自动清空,替代内存缓存并增加无效 token 重试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-02 11:03:36 +08:00
parent 5c6c4f6e48
commit 8ef9a43f76
5 changed files with 238 additions and 14 deletions

View 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`;

View File

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

View File

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

View File

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

View File

@ -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")]