From 8ef9a43f7633ce3848a225372aa346a5f47ca201 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Thu, 2 Jul 2026 11:03:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(wechat):=20=E5=B0=86=20access=5Ftoken=20?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96=E5=88=B0=20t=5Fsystem=5Fother=5Fcon?= =?UTF-8?q?fig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改用数据库存储 token 并支持 AppId/Secret 变更时自动清空,替代内存缓存并增加无效 token 重试。 Co-authored-by: Cursor --- ...ate_t_system_other_config_wechat_token.sql | 5 + .../System/SystemOtherConfigService.cs | 31 +++++ .../Entities/SystemOtherConfig.cs | 15 +++ .../Services/WeChatMpAccessTokenStore.cs | 78 +++++++++++ .../WeChat/Services/WeChatMpApiClient.cs | 123 ++++++++++++++++-- 5 files changed, 238 insertions(+), 14 deletions(-) create mode 100644 sql/migrate_t_system_other_config_wechat_token.sql create mode 100644 src/SimpleAdmin.Api/WeChat/Services/WeChatMpAccessTokenStore.cs diff --git a/sql/migrate_t_system_other_config_wechat_token.sql b/sql/migrate_t_system_other_config_wechat_token.sql new file mode 100644 index 0000000..1c86eec --- /dev/null +++ b/sql/migrate_t_system_other_config_wechat_token.sql @@ -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`; diff --git a/src/Oms.Application/Services/System/SystemOtherConfigService.cs b/src/Oms.Application/Services/System/SystemOtherConfigService.cs index 1535b23..0be5154 100644 --- a/src/Oms.Application/Services/System/SystemOtherConfigService.cs +++ b/src/Oms.Application/Services/System/SystemOtherConfigService.cs @@ -82,6 +82,7 @@ public class SystemOtherConfigService : ApplicationService { var entity = await GetConfigOrThrowAsync(input.Id); await ValidateInputAsync(input, entity.Id); + var clearWeChatAccessToken = ShouldClearWeChatAccessToken(entity, input); entity.ConfigCode = input.ConfigCode.Trim(); entity.ConfigName = input.ConfigName.Trim(); entity.Enabled = input.Enabled; @@ -94,6 +95,13 @@ public class SystemOtherConfigService : ApplicationService entity.TimeoutSeconds = NormalizeTimeoutSeconds(input.TimeoutSeconds); entity.Remark = NormalizeOptional(input.Remark); + if (clearWeChatAccessToken) + { + entity.AccessToken = null; + entity.AccessTokenExpiresIn = 0; + entity.AccessTokenUpdatedAt = null; + } + await SystemOtherConfigRepository.UpdateAsync(entity); } @@ -206,6 +214,29 @@ public class SystemOtherConfigService : ApplicationService private static bool ShouldKeepExistingSecret(string? secret) => 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) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); diff --git a/src/Oms.Domain/SystemAggregate/Entities/SystemOtherConfig.cs b/src/Oms.Domain/SystemAggregate/Entities/SystemOtherConfig.cs index 4b994ca..fdb9fb0 100644 --- a/src/Oms.Domain/SystemAggregate/Entities/SystemOtherConfig.cs +++ b/src/Oms.Domain/SystemAggregate/Entities/SystemOtherConfig.cs @@ -21,4 +21,19 @@ public class SystemOtherConfig : OmsAuditedAggregateRoot public int TimeoutSeconds { get; set; } = 30; public string? Remark { get; set; } + + /// + /// 微信 access_token(运行时缓存,不对前端展示)。 + /// + public string? AccessToken { get; set; } + + /// + /// 微信 access_token 有效期(expires_in,秒)。 + /// + public int AccessTokenExpiresIn { get; set; } + + /// + /// 微信 access_token 最近刷新时间。 + /// + public DateTime? AccessTokenUpdatedAt { get; set; } } diff --git a/src/SimpleAdmin.Api/WeChat/Services/WeChatMpAccessTokenStore.cs b/src/SimpleAdmin.Api/WeChat/Services/WeChatMpAccessTokenStore.cs new file mode 100644 index 0000000..7b43ed3 --- /dev/null +++ b/src/SimpleAdmin.Api/WeChat/Services/WeChatMpAccessTokenStore.cs @@ -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 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 logger) : IWeChatMpAccessTokenStore, IScopedDependency +{ + private static readonly TimeSpan RefreshSkew = TimeSpan.FromMinutes(5); + + public async Task 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 GetWeChatConfigAsync(CancellationToken cancellationToken) => + repository.GetByConfigCodeAsync(SystemOtherConfigCodes.WeChatMp, cancellationToken); +} diff --git a/src/SimpleAdmin.Api/WeChat/Services/WeChatMpApiClient.cs b/src/SimpleAdmin.Api/WeChat/Services/WeChatMpApiClient.cs index 60fbccb..bd86575 100644 --- a/src/SimpleAdmin.Api/WeChat/Services/WeChatMpApiClient.cs +++ b/src/SimpleAdmin.Api/WeChat/Services/WeChatMpApiClient.cs @@ -1,6 +1,6 @@ +using System.Text.Json; using System.Text.Json.Serialization; using System.Xml.Linq; -using Microsoft.Extensions.Caching.Memory; using SimpleAdmin.Api.WeChat.Options; namespace SimpleAdmin.Api.WeChat.Services; @@ -12,6 +12,11 @@ public interface IWeChatMpApiClient Task GetOAuthAccessTokenAsync(string code, CancellationToken cancellationToken = default); Task GetOAuthUserInfoAsync(string accessToken, string openId, CancellationToken cancellationToken = default); + + /// + /// 清空数据库中缓存的公众号 access_token。 + /// + Task InvalidateAccessTokenCacheAsync(CancellationToken cancellationToken = default); } public sealed record WeChatOAuthTokenResult(string AccessToken, string OpenId); @@ -28,18 +33,29 @@ public sealed record WeChatOAuthUserResult( internal sealed class WeChatMpApiClient( IHttpClientFactory httpClientFactory, - IMemoryCache memoryCache, IWeChatMpConfigProvider configProvider, + IWeChatMpAccessTokenStore accessTokenStore, ILogger logger) : IWeChatMpApiClient { - private const string AccessTokenCacheKey = "oms:wechat:mp:access_token"; + public Task InvalidateAccessTokenCacheAsync(CancellationToken cancellationToken = default) => + accessTokenStore.ClearAsync(cancellationToken); public async Task CreateLoginQrCodeUrlAsync( string loginUuid, int expireSeconds, CancellationToken cancellationToken = default) { - var accessToken = await GetAccessTokenAsync(cancellationToken); + return await CreateLoginQrCodeUrlCoreAsync(loginUuid, expireSeconds, retried: false, cancellationToken); + } + + private async Task 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 payload = new @@ -60,8 +76,10 @@ internal sealed class WeChatMpApiClient( payload, cancellationToken); - response.EnsureSuccessStatusCode(); - var result = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + var result = await ReadWeChatJsonAsync( + response, + "创建登录二维码", + cancellationToken); if (result == null || string.IsNullOrWhiteSpace(result.Ticket)) { throw new InvalidOperationException(result?.Errmsg ?? "创建微信二维码失败"); @@ -69,7 +87,14 @@ internal sealed class WeChatMpApiClient( 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)}"; @@ -123,30 +148,100 @@ internal sealed class WeChatMpApiClient( body.Unionid); } - private async Task GetAccessTokenAsync(CancellationToken cancellationToken) + private async Task 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; } - 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)}"; using var response = await client.GetAsync(url, cancellationToken); - response.EnsureSuccessStatusCode(); - var body = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + var body = await ReadWeChatJsonAsync( + response, + "获取 access_token", + cancellationToken); 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; } + 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 ReadWeChatJsonAsync( + 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(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 { [JsonPropertyName("access_token")]