diff --git a/WebApiClientCore.Extensions.OAuths/DependencyInjection/OAuthTokenOptionsExtensions.cs b/WebApiClientCore.Extensions.OAuths/DependencyInjection/OAuthTokenOptionsExtensions.cs
new file mode 100644
index 00000000..ad8c714e
--- /dev/null
+++ b/WebApiClientCore.Extensions.OAuths/DependencyInjection/OAuthTokenOptionsExtensions.cs
@@ -0,0 +1,41 @@
+using Microsoft.Extensions.DependencyInjection;
+using System;
+
+namespace WebApiClientCore.Extensions.OAuths.DependencyInjection
+{
+ ///
+ /// OAuthToken配置的依赖注入扩展
+ ///
+ public static class OAuthTokenOptionsExtensions
+ {
+ ///
+ /// 配置OAuth Token刷新选项
+ /// 使用独立的配置选项,支持从appsettings.json加载
+ ///
+ /// HttpClient构建器
+ /// 配置委托
+ /// HttpClient构建器
+ public static IHttpClientBuilder ConfigureOAuthTokenOptions(
+ this IHttpClientBuilder builder,
+ Action configure)
+ {
+ builder.Services.Configure(builder.Name, configure);
+ return builder;
+ }
+
+ ///
+ /// 配置OAuth Token刷新选项(通过IConfiguration)
+ /// 支持从appsettings.json等配置源加载
+ ///
+ /// HttpClient构建器
+ /// 配置节
+ /// HttpClient构建器
+ public static IHttpClientBuilder ConfigureOAuthTokenOptions(
+ this IHttpClientBuilder builder,
+ Microsoft.Extensions.Configuration.IConfiguration configuration)
+ {
+ builder.Services.Configure(builder.Name, configuration);
+ return builder;
+ }
+ }
+}
diff --git a/WebApiClientCore.Extensions.OAuths/HttpApiOptionsExtensions.cs b/WebApiClientCore.Extensions.OAuths/HttpApiOptionsExtensions.cs
new file mode 100644
index 00000000..7c81bcc6
--- /dev/null
+++ b/WebApiClientCore.Extensions.OAuths/HttpApiOptionsExtensions.cs
@@ -0,0 +1,45 @@
+using System;
+
+namespace WebApiClientCore.Extensions.OAuths
+{
+ ///
+ /// HttpApiOptions的扩展方法
+ ///
+ public static class HttpApiOptionsExtensions
+ {
+ ///
+ /// OAuthTokenOptions在Properties字典中的键
+ ///
+ private const string TokenRefreshOptionsKey = "WebApiClientCore.OAuths.TokenRefreshOptions";
+
+ ///
+ /// 获取或设置OAuth Token刷新选项
+ /// 此方法使用HttpApiOptions的Properties字典存储配置,不污染核心配置类
+ ///
+ /// HttpApi选项
+ /// OAuth Token刷新选项
+ public static OAuthTokenOptions GetOAuthTokenOptions(this HttpApiOptions options)
+ {
+ if (!options.Properties.TryGetValue(TokenRefreshOptionsKey, out var value))
+ {
+ value = new OAuthTokenOptions();
+ options.Properties[TokenRefreshOptionsKey] = value;
+ }
+ return (OAuthTokenOptions)value;
+ }
+
+ ///
+ /// 配置OAuth Token刷新选项
+ /// 此方法使用HttpApiOptions的Properties字典存储配置,不污染核心配置类
+ ///
+ /// HttpApi选项
+ /// 配置委托
+ /// HttpApi选项
+ public static HttpApiOptions ConfigureOAuthToken(this HttpApiOptions options, Action configure)
+ {
+ var tokenOptions = options.GetOAuthTokenOptions();
+ configure(tokenOptions);
+ return options;
+ }
+ }
+}
diff --git a/WebApiClientCore.Extensions.OAuths/OAuthTokenOptions.cs b/WebApiClientCore.Extensions.OAuths/OAuthTokenOptions.cs
new file mode 100644
index 00000000..d93cce4d
--- /dev/null
+++ b/WebApiClientCore.Extensions.OAuths/OAuthTokenOptions.cs
@@ -0,0 +1,34 @@
+namespace WebApiClientCore.Extensions.OAuths
+{
+ ///
+ /// 表示OAuth Token的刷新选项
+ ///
+ public class OAuthTokenOptions
+ {
+ ///
+ /// 获取或设置是否启用Token提前刷新窗口
+ /// 默认值为 true
+ ///
+ public bool UseTokenRefreshWindow { get; set; } = true;
+
+ ///
+ /// 获取或设置固定刷新窗口时长(秒)
+ /// 当剩余有效时间小于等于此值时,触发提前刷新
+ /// 默认值为 60 秒
+ ///
+ public int RefreshWindowSeconds { get; set; } = 60;
+
+ ///
+ /// 获取或设置刷新窗口百分比(0-1)
+ /// 当剩余有效时间小于等于总有效期的此百分比时,触发提前刷新
+ /// 默认值为 0.1 (10%)
+ ///
+ public double RefreshWindowPercentage { get; set; } = 0.1;
+
+ ///
+ /// 获取或设置刷新窗口计算策略
+ /// 默认值为 Auto (自动选择固定时长和百分比中的较小值)
+ ///
+ public RefreshWindowStrategy RefreshWindowStrategy { get; set; } = RefreshWindowStrategy.Auto;
+ }
+}
diff --git a/WebApiClientCore.Extensions.OAuths/RefreshWindowStrategy.cs b/WebApiClientCore.Extensions.OAuths/RefreshWindowStrategy.cs
new file mode 100644
index 00000000..48ae357c
--- /dev/null
+++ b/WebApiClientCore.Extensions.OAuths/RefreshWindowStrategy.cs
@@ -0,0 +1,23 @@
+namespace WebApiClientCore.Extensions.OAuths
+{
+ ///
+ /// 表示Token刷新窗口计算策略
+ ///
+ public enum RefreshWindowStrategy
+ {
+ ///
+ /// 仅使用固定秒数
+ ///
+ FixedSeconds = 0,
+
+ ///
+ /// 仅使用百分比
+ ///
+ Percentage = 1,
+
+ ///
+ /// 自动选择:取固定秒数和百分比的较小值
+ ///
+ Auto = 2
+ }
+}
diff --git a/WebApiClientCore.Extensions.OAuths/TokenProviders/TokenProvider.cs b/WebApiClientCore.Extensions.OAuths/TokenProviders/TokenProvider.cs
index 1ea0ec94..a89b6f4e 100644
--- a/WebApiClientCore.Extensions.OAuths/TokenProviders/TokenProvider.cs
+++ b/WebApiClientCore.Extensions.OAuths/TokenProviders/TokenProvider.cs
@@ -75,7 +75,7 @@ public async Task GetTokenAsync()
using var scope = this.services.CreateScope();
this.token = await this.RequestTokenAsync(scope.ServiceProvider).ConfigureAwait(false);
}
- else if (this.token.IsExpired() == true)
+ else if (this.ShouldRefreshToken(this.token))
{
using var scope = this.services.CreateScope();
@@ -88,6 +88,96 @@ public async Task GetTokenAsync()
}
}
+ ///
+ /// 判断是否应该刷新Token
+ /// 混合方案:优先从独立配置读取,其次从 HttpApiOptions.Properties 读取,最后使用默认行为
+ ///
+ /// token结果
+ ///
+ private bool ShouldRefreshToken(TokenResult token)
+ {
+ // 获取配置
+ var tokenOptions = this.GetTokenRefreshOptions();
+
+ if (!tokenOptions.UseTokenRefreshWindow)
+ {
+ // 如果禁用刷新窗口,使用原有行为
+ return token.IsExpired();
+ }
+
+ // 计算刷新窗口
+ var refreshWindow = this.CalculateRefreshWindow(token, tokenOptions);
+ return token.IsExpired(refreshWindow);
+ }
+
+ ///
+ /// 获取Token刷新配置
+ /// 优先级:独立配置(IOptionsMonitor<OAuthTokenOptions>) > HttpApiOptions.Properties > 默认配置
+ ///
+ /// Token刷新配置
+ private OAuthTokenOptions GetTokenRefreshOptions()
+ {
+ // 优先从独立配置读取 (方案2:独立配置类)
+ var oauthOptionsMonitor = this.services.GetService>();
+ if (oauthOptionsMonitor != null)
+ {
+ try
+ {
+ var options = oauthOptionsMonitor.Get(this.Name);
+ // 检查是否为默认配置,如果不是则使用
+ if (options != null)
+ {
+ return options;
+ }
+ }
+ catch
+ {
+ // 如果获取失败,继续尝试下一种方式
+ }
+ }
+
+ // 其次从 HttpApiOptions.Properties 读取 (方案1:Properties 字典)
+ var httpApiOptionsMonitor = this.services.GetService>();
+ if (httpApiOptionsMonitor != null)
+ {
+ try
+ {
+ var httpApiOptions = httpApiOptionsMonitor.Get(this.Name);
+ if (httpApiOptions != null)
+ {
+ return httpApiOptions.GetOAuthTokenOptions();
+ }
+ }
+ catch
+ {
+ // 如果获取失败,使用默认配置
+ }
+ }
+
+ // 最后使用默认配置
+ return new OAuthTokenOptions();
+ }
+
+ ///
+ /// 根据配置策略计算刷新窗口
+ ///
+ /// Token结果
+ /// 配置选项
+ /// 刷新窗口时间
+ private TimeSpan CalculateRefreshWindow(TokenResult token, OAuthTokenOptions options)
+ {
+ var fixedWindow = TimeSpan.FromSeconds(options.RefreshWindowSeconds);
+ var percentageWindow = TimeSpan.FromSeconds(token.Expires_in * options.RefreshWindowPercentage);
+
+ return options.RefreshWindowStrategy switch
+ {
+ RefreshWindowStrategy.FixedSeconds => fixedWindow,
+ RefreshWindowStrategy.Percentage => percentageWindow,
+ RefreshWindowStrategy.Auto => fixedWindow < percentageWindow ? fixedWindow : percentageWindow,
+ _ => fixedWindow < percentageWindow ? fixedWindow : percentageWindow // 默认使用Auto
+ };
+ }
+
///
/// 请求获取 token
diff --git a/WebApiClientCore.Extensions.OAuths/TokenResult.cs b/WebApiClientCore.Extensions.OAuths/TokenResult.cs
index 71f312e3..04b0c9e0 100644
--- a/WebApiClientCore.Extensions.OAuths/TokenResult.cs
+++ b/WebApiClientCore.Extensions.OAuths/TokenResult.cs
@@ -75,7 +75,19 @@ public virtual bool IsSuccess()
///
public virtual bool IsExpired()
{
- return DateTime.Now.Subtract(this.createTime) > TimeSpan.FromSeconds(this.Expires_in);
+ return IsExpired(TimeSpan.Zero);
+ }
+
+ ///
+ /// 返回是否已过期或在刷新窗口内
+ ///
+ /// 提前刷新时间窗口
+ /// 如果已过期或剩余有效时间小于等于刷新窗口,返回true
+ public virtual bool IsExpired(TimeSpan refreshWindow)
+ {
+ var elapsed = DateTime.Now.Subtract(this.createTime);
+ var remaining = TimeSpan.FromSeconds(this.Expires_in).Subtract(elapsed);
+ return remaining <= refreshWindow;
}
///
diff --git a/WebApiClientCore.Test/Extensions/OAuths/TokenResultTest.cs b/WebApiClientCore.Test/Extensions/OAuths/TokenResultTest.cs
new file mode 100644
index 00000000..19e0e188
--- /dev/null
+++ b/WebApiClientCore.Test/Extensions/OAuths/TokenResultTest.cs
@@ -0,0 +1,267 @@
+using System;
+using System.Threading;
+using WebApiClientCore.Extensions.OAuths;
+using Xunit;
+
+namespace WebApiClientCore.Test.Extensions.OAuths
+{
+ ///
+ /// TokenResult 过期判断测试
+ ///
+ public class TokenResultTest
+ {
+ #region 基础过期判断测试
+
+ [Fact]
+ [Trait("Category", "BasicExpiration")]
+ public void IsExpired_WithValidToken_ReturnsFalse()
+ {
+ // Arrange
+ var token = new TokenResult { Expires_in = 3600 };
+
+ // Act
+ var result = token.IsExpired();
+
+ // Assert
+ Assert.False(result);
+ }
+
+ [Fact]
+ [Trait("Category", "BasicExpiration")]
+ public void IsExpired_WithExpiredToken_ReturnsTrue()
+ {
+ // Arrange
+ var token = new TokenResult { Expires_in = 1 };
+ Thread.Sleep(1100); // 等待超过1秒
+
+ // Act
+ var result = token.IsExpired();
+
+ // Assert
+ Assert.True(result);
+ }
+
+ [Fact]
+ [Trait("Category", "BasicExpiration")]
+ public void IsExpired_AtExactExpirationTime_ReturnsTrue()
+ {
+ // Arrange
+ var token = new TokenResult { Expires_in = 0 };
+
+ // Act
+ var result = token.IsExpired();
+
+ // Assert
+ Assert.True(result, "Token with expires_in=0 should be immediately expired");
+ }
+
+ #endregion
+
+ #region 刷新窗口过期判断测试
+
+ [Fact]
+ [Trait("Category", "RefreshWindow")]
+ public void IsExpired_OutsideRefreshWindow_ReturnsFalse()
+ {
+ // Arrange
+ var token = new TokenResult { Expires_in = 300 }; // 5 minutes
+ var refreshWindow = TimeSpan.FromSeconds(60); // 1 minute
+
+ // Act
+ var result = token.IsExpired(refreshWindow);
+
+ // Assert
+ Assert.False(result, "Token with 5 minutes remaining should not trigger refresh with 1 minute window");
+ }
+
+ [Fact]
+ [Trait("Category", "RefreshWindow")]
+ public void IsExpired_JustEnteredRefreshWindow_ReturnsTrue()
+ {
+ // Arrange
+ var token = new TokenResult { Expires_in = 2 }; // 2 seconds
+ var refreshWindow = TimeSpan.FromSeconds(60); // 1 minute
+ Thread.Sleep(100); // 等待一小段时间
+
+ // Act
+ var result = token.IsExpired(refreshWindow);
+
+ // Assert
+ Assert.True(result, "Token with 2 seconds lifetime should be in refresh window immediately with 60 second window");
+ }
+
+ [Fact]
+ [Trait("Category", "RefreshWindow")]
+ public void IsExpired_AtRefreshWindowBoundary_ReturnsTrue()
+ {
+ // Arrange
+ var token = new TokenResult { Expires_in = 60 }; // Exactly 60 seconds
+ var refreshWindow = TimeSpan.FromSeconds(60);
+
+ // Act
+ var result = token.IsExpired(refreshWindow);
+
+ // Assert
+ Assert.True(result, "Token at exact refresh window boundary should trigger refresh");
+ }
+
+ [Theory]
+ [InlineData(30, false)] // 120秒有效期,30秒窗口,剩余120秒 > 30秒,不在窗口内
+ [InlineData(60, false)] // 120秒有效期,60秒窗口,剩余120秒 > 60秒,不在窗口内
+ [InlineData(90, false)] // 120秒有效期,90秒窗口,剩余120秒 > 90秒,不在窗口内
+ [InlineData(120, true)] // 120秒有效期,120秒窗口,剩余120秒 = 120秒,在窗口内(临界点)
+ [Trait("Category", "RefreshWindow")]
+ public void IsExpired_WithDifferentRefreshWindows_BehavesCorrectly(int windowSeconds, bool expectedExpired)
+ {
+ // Arrange
+ var token = new TokenResult { Expires_in = 120 }; // 2 minutes
+ var refreshWindow = TimeSpan.FromSeconds(windowSeconds);
+
+ // Act
+ var result = token.IsExpired(refreshWindow);
+
+ // Assert
+ Assert.Equal(expectedExpired, result);
+ }
+
+ [Fact]
+ [Trait("Category", "RefreshWindow")]
+ public void IsExpired_AfterActualExpiration_ReturnsTrueRegardlessOfWindow()
+ {
+ // Arrange
+ var token = new TokenResult { Expires_in = 1 };
+ Thread.Sleep(1100); // 等待过期
+ var refreshWindow = TimeSpan.Zero;
+
+ // Act
+ var result = token.IsExpired(refreshWindow);
+
+ // Assert
+ Assert.True(result, "Expired token should return true even with zero refresh window");
+ }
+
+ #endregion
+
+ #region 边界情况测试
+
+ [Fact]
+ [Trait("Category", "EdgeCase")]
+ public void IsExpired_WithZeroExpiresIn_ReturnsTrue()
+ {
+ // Arrange
+ var token = new TokenResult { Expires_in = 0 };
+
+ // Act
+ var result = token.IsExpired();
+
+ // Assert
+ Assert.True(result, "Token with expires_in=0 should be immediately expired");
+ }
+
+ [Fact]
+ [Trait("Category", "EdgeCase")]
+ public void IsExpired_WithNegativeExpiresIn_ReturnsTrue()
+ {
+ // Arrange
+ var token = new TokenResult { Expires_in = -100 };
+
+ // Act
+ var result = token.IsExpired();
+
+ // Assert
+ Assert.True(result, "Token with negative expires_in should be expired");
+ }
+
+ [Fact]
+ [Trait("Category", "EdgeCase")]
+ public void IsExpired_RefreshWindowLargerThanExpiresIn_ReturnsTrueImmediately()
+ {
+ // Arrange
+ var token = new TokenResult { Expires_in = 60 }; // 1 minute
+ var refreshWindow = TimeSpan.FromSeconds(120); // 2 minutes
+
+ // Act
+ var result = token.IsExpired(refreshWindow);
+
+ // Assert
+ Assert.True(result, "When refresh window is larger than expires_in, token should be in refresh window immediately");
+ }
+
+ [Fact]
+ [Trait("Category", "EdgeCase")]
+ public void IsExpired_VeryShortExpiresIn_BehavesCorrectly()
+ {
+ // Arrange
+ var token = new TokenResult { Expires_in = 2 }; // 2 seconds
+ var refreshWindow = TimeSpan.FromSeconds(60);
+
+ // Act
+ var result = token.IsExpired(refreshWindow);
+
+ // Assert
+ Assert.True(result, "Very short lifetime token should not cause exceptions");
+ }
+
+ #endregion
+
+ #region 时间流逝模拟测试
+
+ [Fact]
+ [Trait("Category", "TimeProgression")]
+ public void IsExpired_ProgressionFromValidToRefreshWindow_TransitionsCorrectly()
+ {
+ // Arrange
+ var token = new TokenResult { Expires_in = 3 }; // 3 seconds
+ var refreshWindow = TimeSpan.FromSeconds(2);
+
+ // Act & Assert - Initially outside refresh window
+ Assert.False(token.IsExpired(refreshWindow));
+
+ // Wait to enter refresh window
+ Thread.Sleep(1100); // After 1 second, remaining = 2 seconds
+ Assert.True(token.IsExpired(refreshWindow), "Should be in refresh window after 1 second");
+ }
+
+ [Fact]
+ [Trait("Category", "TimeProgression")]
+ public void IsExpired_ProgressionFromRefreshWindowToExpired_RemainsTrue()
+ {
+ // Arrange
+ var token = new TokenResult { Expires_in = 2 }; // 2 seconds
+ var refreshWindow = TimeSpan.FromSeconds(1);
+
+ // Act & Assert - Check at different points
+ Thread.Sleep(1100); // After 1 second, in refresh window
+ Assert.True(token.IsExpired(refreshWindow));
+
+ Thread.Sleep(1000); // After 2 seconds, actually expired
+ Assert.True(token.IsExpired(refreshWindow));
+ }
+
+ [Fact]
+ [Trait("Category", "TimeProgression")]
+ public void IsExpired_MultipleChecks_ConsistentResults()
+ {
+ // Arrange
+ var token = new TokenResult { Expires_in = 3600 };
+ bool? firstResult = null;
+
+ // Act - Multiple rapid checks
+ for (int i = 0; i < 100; i++)
+ {
+ var result = token.IsExpired();
+ if (!firstResult.HasValue)
+ {
+ firstResult = result;
+ }
+ else
+ {
+ // Assert
+ Assert.Equal(firstResult.Value, result);
+ }
+ }
+ }
+
+ #endregion
+ }
+}
diff --git a/WebApiClientCore.Test/WebApiClientCore.Test.csproj b/WebApiClientCore.Test/WebApiClientCore.Test.csproj
index ec82a1a8..9e5a4401 100644
--- a/WebApiClientCore.Test/WebApiClientCore.Test.csproj
+++ b/WebApiClientCore.Test/WebApiClientCore.Test.csproj
@@ -21,5 +21,6 @@
+
diff --git a/docs/guide/6_auth-token-extension.md b/docs/guide/6_auth-token-extension.md
index 5628c1c0..c1406551 100644
--- a/docs/guide/6_auth-token-extension.md
+++ b/docs/guide/6_auth-token-extension.md
@@ -14,6 +14,82 @@ token 的应用特性,使用 ITokenProviderFactory 创建 ITokenProvider,然
### OAuthTokenHandler
属于 http 消息处理器,功能与 OAuthTokenAttribute 一样,除此之外,如果因为意外的原因导致服务器仍然返回未授权(401 状态码),其还会丢弃旧 token,申请新 token 来重试一次请求。
+
+## Token 提前刷新
+
+为避免 token 在检查后到实际使用前过期,支持配置提前刷新时间窗口。
+
+### 默认行为
+
+默认启用提前刷新,使用 Auto 策略:
+- 固定窗口:60 秒
+- 百分比窗口:10%
+- 实际窗口:取两者较小值
+
+### 配置方式1:通过 HttpApiOptions
+
+```csharp
+services.AddHttpApi(o =>
+{
+ o.ConfigureOAuthToken(t =>
+ {
+ t.RefreshWindowSeconds = 120; // 固定窗口120秒
+ t.RefreshWindowPercentage = 0.15; // 百分比15%
+ t.RefreshWindowStrategy = RefreshWindowStrategy.Auto;
+ });
+});
+```
+
+### 配置方式2:通过独立配置
+
+```csharp
+services.AddHttpApi()
+ .AddOAuthTokenHandler()
+ .ConfigureOAuthTokenOptions(o =>
+ {
+ o.RefreshWindowSeconds = 120;
+ o.RefreshWindowStrategy = RefreshWindowStrategy.FixedSeconds;
+ });
+```
+
+### 从配置文件加载
+
+**appsettings.json:**
+```json
+{
+ "OAuthToken": {
+ "IUserApi": {
+ "UseTokenRefreshWindow": true,
+ "RefreshWindowSeconds": 120,
+ "RefreshWindowPercentage": 0.15,
+ "RefreshWindowStrategy": "Auto"
+ }
+ }
+}
+```
+
+**Program.cs:**
+```csharp
+services.AddHttpApi()
+ .ConfigureOAuthTokenOptions(configuration.GetSection("OAuthToken:IUserApi"));
+```
+
+### 刷新策略
+
+| 策略 | 说明 | 适用场景 |
+|------|------|----------|
+| `FixedSeconds` | 固定秒数 | Token 有效期固定 |
+| `Percentage` | 百分比 | Token 有效期不固定 |
+| `Auto` (默认) | 取较小值 | 通用场景 |
+
+### 禁用提前刷新
+
+```csharp
+services.AddHttpApi(o =>
+{
+ o.ConfigureOAuthToken(t => t.UseTokenRefreshWindow = false);
+});
+```
## OAuth 的 Client 模式