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