Compare commits

..
6 Commits
10 changed files with 392 additions and 68 deletions
+1
View File
@@ -437,3 +437,4 @@ MigrationBackup/
# BitFun snapshot data - auto managed
.bitfun/
/.bitfun/search/flashgrep-index/
+33
View File
@@ -1,11 +1,25 @@
using System;
using System.Collections.Generic;
namespace SimApi.Configurations;
public class SimApiOptions
{
/// <summary>
/// Redis配置
/// </summary>
public string? RedisConfiguration { get; set; }
/// <summary>
/// 原样返回给前端的配置信息
/// </summary>
public Dictionary<string, object>? WebConfig { get; set; }
/// <summary>
/// WebConfig返回是否包含版本信息
/// </summary>
public bool WebConfigIncludeVersion { get; set; } = true;
/// <summary>
/// 是否启用后台任务系统 *基于Hangfire
/// </summary>
@@ -17,6 +31,12 @@ public class SimApiOptions
/// </summary>
public bool EnableSimApiAuth { get; set; }
/// <summary>
/// 启用Cache功能
/// </summary>
public bool EnableSimApiCache { get; set; } = true;
/// <summary>
/// 启用SimApi网关授权, 基于上层网关透传的身份令牌验证
/// </summary>
@@ -56,6 +76,12 @@ public class SimApiOptions
/// </summary>
public bool EnableSimApiResponseFilter { get; set; } = true;
/// <summary>
/// 启用请求日志中间件
/// default: false
/// </summary>
public bool EnableRequestLog { get; set; }
/// <summary>
/// 开启ForwardHeaders,开启后可以透传负载均衡的Headers
/// default: true
@@ -104,6 +130,8 @@ public class SimApiOptions
public SimApiRouteOptions SimApiRouteOptions { get; set; } = new();
public SimApiRequestLogOptions SimApiRequestLogOptions { get; set; } = new();
public void ConfigureSimApiRoute(Action<SimApiRouteOptions>? options = null)
{
options?.Invoke(SimApiRouteOptions);
@@ -143,4 +171,9 @@ public class SimApiOptions
{
options?.Invoke(SimApiAuthCenterOptions);
}
public void ConfigureSimApiRequestLog(Action<SimApiRequestLogOptions>? options = null)
{
options?.Invoke(SimApiRequestLogOptions);
}
}
+19
View File
@@ -0,0 +1,19 @@
namespace SimApi.Configurations;
public class SimApiRequestLogOptions
{
/// <summary>
/// 是否打印完整的请求Header
/// </summary>
public bool ShowFullHeader { get; set; }
/// <summary>
/// 是否打印完整的响应体
/// </summary>
public bool ShowFullResponse { get; set; }
/// <summary>
/// 请求字段显示最长长度
/// </summary>
public int RequestStringLogLength { get; set; } = 0;
}
+1 -1
View File
@@ -2,7 +2,7 @@
public class SimApiRouteOptions
{
public string? VersionRoute = "/versions";
public string? LogoutRoute = "/auth/logout";
public string? UserInfoRoute = "/user/info";
public string? WebConfigRoute = "/config";
}
+21 -9
View File
@@ -1,13 +1,16 @@
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using SimApi.Attributes;
using SimApi.Communications;
using SimApi.Configurations;
using SimApi.Helpers;
using static SimApi.Helpers.SimApiError;
namespace SimApi.Controllers;
public class SimApiCommonController : SimApiBaseController
public class SimApiCommonController(SimApiOptions simApiOptions) : SimApiBaseController
{
/// <summary>
/// 错误回馈页面
@@ -21,18 +24,27 @@ public class SimApiCommonController : SimApiBaseController
Error(code);
}
/// <summary>
/// 给前端的自定义信息
/// </summary>
/// <returns></returns>
[HttpPost, HttpGet]
public Dictionary<string, string> Versions()
public Dictionary<string, object> WebConfig()
{
return new Dictionary<string, string>
var resp = simApiOptions.WebConfig!.ToDictionary();
if (simApiOptions.WebConfigIncludeVersion)
{
{ "SimApi", SimApiUtil.SimApiVersion },
{ "App", SimApiUtil.AppVersion }
};
resp.Add("Versions", new Dictionary<string, string>
{
{ "SimApi", SimApiUtil.SimApiVersion },
{ "App", SimApiUtil.AppVersion }
});
}
return resp;
}
/// <summary>
/// 获取已登录用户信息
/// </summary>
+108 -43
View File
@@ -1,19 +1,32 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection;
using SimApi.Communications;
using StackExchange.Redis;
namespace SimApi.Helpers;
/// <summary>
/// 认证助手
/// 认证助手(支持 Redis 和 InMemory 两种模式)
/// </summary>
public class SimApiAuth(IDistributedCache cache, IConnectionMultiplexer redis)
public class SimApiAuth
{
private const string TokenCacheKey = "SimApi:Auth:Token:{token}";
private const string TokenSetCacheKey = "SimApi:Auth:User:{userId}";
private readonly IDatabase _redisDb = redis.GetDatabase();
private readonly IDistributedCache _cache;
private readonly IDatabase? _redisDb;
// InMemory 模式:用户 → Token集合
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> _userTokens = new();
public SimApiAuth(IDistributedCache cache, IServiceProvider sp)
{
_cache = cache;
_redisDb = sp.GetService<IConnectionMultiplexer>()?.GetDatabase();
}
/// <summary>
/// 登录信息
@@ -28,13 +41,20 @@ public class SimApiAuth(IDistributedCache cache, IConnectionMultiplexer redis)
token ??= Guid.NewGuid().ToString();
var cacheKey = TokenCacheKey.Replace("{token}", token);
var setCacheKey = TokenSetCacheKey.Replace("{userId}", loginItem.Id);
_redisDb.SetAdd(setCacheKey, token);
cache.SetString(cacheKey, SimApiUtil.Json(loginItem),
new DistributedCacheEntryOptions
{
SlidingExpiration = expireTime
});
_redisDb.KeyExpire(setCacheKey, expireTime.Value);
_cache.SetString(cacheKey, SimApiUtil.Json(loginItem),
new DistributedCacheEntryOptions { SlidingExpiration = expireTime });
if (_redisDb != null)
{
_redisDb.SetAdd(setCacheKey, token);
_redisDb.KeyExpire(setCacheKey, expireTime.Value);
}
else
{
var tokens = _userTokens.GetOrAdd(loginItem.Id, _ => new ConcurrentDictionary<string, byte>());
tokens.TryAdd(token, 0);
}
return token;
}
@@ -48,10 +68,15 @@ public class SimApiAuth(IDistributedCache cache, IConnectionMultiplexer redis)
public string Update(SimApiLoginItem loginItem, string token)
{
var cacheKey = TokenCacheKey.Replace("{token}", token);
cache.SetString(cacheKey, SimApiUtil.Json(loginItem));
var ttl = _redisDb.KeyTimeToLive(cacheKey);
var setCacheKey = TokenSetCacheKey.Replace("{userId}", loginItem.Id);
_redisDb.KeyExpire(setCacheKey, ttl);
_cache.SetString(cacheKey, SimApiUtil.Json(loginItem));
if (_redisDb != null)
{
var ttl = _redisDb.KeyTimeToLive(cacheKey);
var setCacheKey = TokenSetCacheKey.Replace("{userId}", loginItem.Id);
_redisDb.KeyExpire(setCacheKey, ttl);
}
return token;
}
@@ -64,9 +89,10 @@ public class SimApiAuth(IDistributedCache cache, IConnectionMultiplexer redis)
public SimApiLoginItem? GetLogin(string token)
{
var cacheKey = TokenCacheKey.Replace("{token}", token);
var login = cache.GetString(cacheKey);
var login = _cache.GetString(cacheKey);
var resp = login != null ? SimApiUtil.FromJson<SimApiLoginItem>(login) : null;
if (resp != null)
if (resp != null && _redisDb != null)
{
var ttl = _redisDb.KeyTimeToLive(cacheKey);
var setCacheKey = TokenSetCacheKey.Replace("{userId}", resp.Id);
@@ -83,26 +109,42 @@ public class SimApiAuth(IDistributedCache cache, IConnectionMultiplexer redis)
/// <returns></returns>
public SimApiLoginItem[] GetAllLogins(string userId)
{
var setCacheKey = TokenSetCacheKey.Replace("{userId}", userId);
var allLogins = _redisDb.SetMembers(setCacheKey).ToStringArray();
var resp = new List<SimApiLoginItem>();
foreach (var login in allLogins)
if (_redisDb != null)
{
if (login != null)
var setCacheKey = TokenSetCacheKey.Replace("{userId}", userId);
var allLogins = _redisDb.SetMembers(setCacheKey).ToStringArray();
var resp = new List<SimApiLoginItem>();
foreach (var login in allLogins)
{
var item = GetLogin(login);
if (item != null)
if (login != null)
{
resp.Add(item);
}
else
{
_redisDb.SetRemove(setCacheKey, login);
var item = GetLogin(login);
if (item != null)
resp.Add(item);
else
_redisDb.SetRemove(setCacheKey, login);
}
}
}
return resp.ToArray();
return resp.ToArray();
}
else
{
if (!_userTokens.TryGetValue(userId, out var tokens))
return [];
var resp = new List<SimApiLoginItem>();
foreach (var token in tokens.Keys)
{
var item = GetLogin(token);
if (item != null)
resp.Add(item);
else
tokens.TryRemove(token, out _);
}
return resp.ToArray();
}
}
/// <summary>
@@ -111,18 +153,32 @@ public class SimApiAuth(IDistributedCache cache, IConnectionMultiplexer redis)
/// <param name="userId"></param>
public void LogoutAll(string userId)
{
var setCacheKey = TokenSetCacheKey.Replace("{userId}", userId);
var allLogins = _redisDb.SetMembers(setCacheKey).ToStringArray();
foreach (var login in allLogins)
if (_redisDb != null)
{
if (login != null)
var setCacheKey = TokenSetCacheKey.Replace("{userId}", userId);
var allLogins = _redisDb.SetMembers(setCacheKey).ToStringArray();
foreach (var login in allLogins)
{
var cacheKey = TokenCacheKey.Replace("{token}", login);
cache.Remove(cacheKey);
if (login != null)
{
var cacheKey = TokenCacheKey.Replace("{token}", login);
_cache.Remove(cacheKey);
}
}
_redisDb.KeyDelete(setCacheKey);
}
else
{
if (_userTokens.TryRemove(userId, out var tokens))
{
foreach (var token in tokens.Keys)
{
var cacheKey = TokenCacheKey.Replace("{token}", token);
_cache.Remove(cacheKey);
}
}
}
_redisDb.KeyDelete(setCacheKey);
}
/// <summary>
@@ -132,13 +188,22 @@ public class SimApiAuth(IDistributedCache cache, IConnectionMultiplexer redis)
public void Logout(string token)
{
var item = GetLogin(token);
if (item != null)
if (_redisDb != null)
{
var setCacheKey = TokenSetCacheKey.Replace("{userId}", item.Id);
_redisDb.SetRemove(setCacheKey, token);
if (item != null)
{
var setCacheKey = TokenSetCacheKey.Replace("{userId}", item.Id);
_redisDb.SetRemove(setCacheKey, token);
}
}
else
{
if (item != null && _userTokens.TryGetValue(item.Id, out var tokens))
tokens.TryRemove(token, out _);
}
var cacheKey = TokenCacheKey.Replace("{token}", token);
cache.Remove(cacheKey);
_cache.Remove(cacheKey);
}
}
}
+124
View File
@@ -0,0 +1,124 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.ExceptionServices;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using SimApi.Configurations;
using SimApi.Helpers;
namespace SimApi.Middlewares;
/// <summary>
/// 请求日志中间件
/// </summary>
public class SimApiRequestLogMiddleware(
RequestDelegate next,
ILogger<SimApiRequestLogMiddleware> log,
SimApiRequestLogOptions options)
{
public async Task InvokeAsync(HttpContext context)
{
var sw = Stopwatch.StartNew();
var fullUrl =
$"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}{context.Request.QueryString}";
var logMessage = new StringBuilder();
logMessage.AppendLine($"[{context.Request.Method}] {fullUrl}");
if (options.ShowFullHeader)
{
logMessage.AppendLine("*( RequestHeaders [Full] ) =>");
var headersDict = new Dictionary<string, string>();
foreach (var header in context.Request.Headers)
{
headersDict[header.Key] = header.Value.ToString();
}
logMessage.AppendLine(JsonSerializer.Serialize(headersDict));
}
else
{
logMessage.AppendLine("*( RequestHeaders ) =>");
var token = context.Request.Headers["Token"].FirstOrDefault() ?? "";
var queryId = context.Request.Headers["Query-Id"].FirstOrDefault() ?? "";
logMessage.AppendLine($"Token: {token} QueryId: {queryId}");
}
context.Request.EnableBuffering();
var requestBodyText = await new StreamReader(context.Request.Body).ReadToEndAsync();
context.Request.Body.Seek(0, SeekOrigin.Begin);
logMessage.AppendLine("*( RequestBody ) =>");
if (options.RequestStringLogLength == 0)
{
logMessage.AppendLine(requestBodyText);
}
else
{
var reqLogs = SimApiUtil.FromJson<Dictionary<string, object>>(requestBodyText);
foreach (var reqLog in reqLogs)
{
if (reqLog.Value is not JsonElement { ValueKind: JsonValueKind.String } je) continue;
var str = je.GetString();
if (str?.Length > options.RequestStringLogLength)
{
reqLogs[reqLog.Key] = str[..options.RequestStringLogLength] + $"...({str.Length})";
}
}
logMessage.AppendLine(SimApiUtil.Json(reqLogs));
}
var originalBodyStream = context.Response.Body;
using var responseBody = new MemoryStream();
context.Response.Body = responseBody;
ExceptionDispatchInfo? edi = null;
try
{
await next(context);
}
catch (Exception ex)
{
edi = ExceptionDispatchInfo.Capture(ex);
}
finally
{
sw.Stop();
responseBody.Seek(0, SeekOrigin.Begin);
var responseText = await new StreamReader(responseBody).ReadToEndAsync();
logMessage.AppendLine($"*( Response [{context.Response.StatusCode}] ) =>");
if (options.ShowFullResponse)
{
logMessage.Append(responseText);
}
else
{
var truncated = responseText.Length > 200 ? responseText[..200] : responseText;
logMessage.Append(truncated);
}
if (edi != null)
{
logMessage.Append(Environment.NewLine);
logMessage.Append($"Exception: {edi.SourceException}");
}
responseBody.Seek(0, SeekOrigin.Begin);
await responseBody.CopyToAsync(originalBodyStream);
context.Response.Body = originalBodyStream;
log.LogInformation(logMessage.ToString());
}
edi?.Throw();
}
}
+26 -2
View File
@@ -13,6 +13,7 @@ ASP.NET Core API 基础框架库,提供统一异常拦截、响应封装、Tok
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSimApi(options =>
{
// RedisConfiguration 可选:配置则使用 Redis,不配则自动使用 InMemory
options.RedisConfiguration = "localhost:6379";
options.EnableSimApiAuth = true;
options.EnableSimApiDoc = true;
@@ -191,6 +192,26 @@ public class SimApiLoginItem {
**Token 传参**: Header `Token: <value>`
### 存储模式
`SimApiAuth` 内部自动判断,无需手动配置:
| 条件 | 存储后端 | 用户↔Token 映射 |
|------|---------|-----------------|
| 配置了 `RedisConfiguration` | Redis`IDistributedCache` + Set | Redis Set |
| 未配置 `RedisConfiguration` | InMemory`DistributedMemoryCache` | `ConcurrentDictionary` |
- **Redis 模式**:支持多实例共享,Token 持久化,适合生产环境
- **InMemory 模式**:零配置即可启用 `EnableSimApiAuth`,适合开发/测试/单实例场景。注意重启后所有登录态丢失
```csharp
// 不配 Redis 也能用 Auth
builder.Services.AddSimApi(options =>
{
options.EnableSimApiAuth = true; // 自动使用 InMemory
});
```
### 认证后处理 Hook — ISimApiAuthChecker
```csharp
@@ -406,9 +427,11 @@ IMinioClient Client { get; } // 底层 MinIO 客户端
---
## 8. Redis 缓存 — SimApiCache
## 8. 缓存 — SimApiCache
依赖 `RedisConfiguration`Key 自动加前缀 `SimApi:Cache:`
通过 `EnableSimApiCache`(默认 `true`)控制。Key 自动加前缀 `SimApi:Cache:`
存储后端与 `SimApiAuth` 一致:配了 `RedisConfiguration` 就用 Redis,否则用 InMemory。
```csharp
void Set(string key, object value, DistributedCacheEntryOptions? options = null);
@@ -617,6 +640,7 @@ builder.Services.AddSimApi(options =>
// 功能开关
options.EnableSimApiAuth = false; // Token 认证
options.EnableSimApiCache = true; // 缓存(Redis 或 InMemory
options.EnableSimApiAuthGate = false; // Auth Center 网关鉴权
options.EnableSimApiDoc = false; // Swagger 文档
options.EnableSimApiStorage = false; // S3 存储
+35 -13
View File
@@ -36,11 +36,22 @@ public static class SimApiExtensions
{
var simApiOptions = new SimApiOptions();
options?.Invoke(simApiOptions);
simApiOptions.WebConfig ??= new();
builder.AddSingleton(simApiOptions);
if (simApiOptions.RedisConfiguration != null)
{
builder.AddStackExchangeRedisCache(x => x.Configuration = simApiOptions.RedisConfiguration);
builder.AddSingleton<IConnectionMultiplexer>(_ =>
ConnectionMultiplexer.Connect(simApiOptions.RedisConfiguration));
}
else if (simApiOptions.EnableSimApiAuth || simApiOptions.EnableSimApiCache)
{
builder.AddDistributedMemoryCache();
}
if (simApiOptions.EnableSimApiCache)
{
builder.AddSingleton<SimApiCache>();
}
@@ -209,6 +220,7 @@ public static class SimApiExtensions
x.OperationFilter<SimApiSignOperationFilter>();
x.OperationFilter<AesBodyOperationFilter>();
x.SchemaFilter<GlobalDynamicObjectSchemaFilter>();
x.SchemaFilter<DictionarySchemaFilter>();
x.DocumentFilter<RemoveEmptyTagsFilter>();
if (simApiOptions.EnableSimApiAuth)
{
@@ -339,7 +351,7 @@ public static class SimApiExtensions
}
}
builder.AddSingleton(simApiOptions);
builder.AddSingleton(simApiOptions.SimApiRequestLogOptions);
return builder;
}
@@ -358,6 +370,11 @@ public static class SimApiExtensions
logger.LogInformation("开始配置 RedisCache ...");
}
if (options.EnableSimApiCache)
{
logger.LogInformation("开始配置SimApiCache...");
}
//请求一下检测存储错误
if (options.EnableSimApiStorage)
{
@@ -445,18 +462,6 @@ public static class SimApiExtensions
builder.UseMiddleware<SimApiAuthMiddleware>();
}
if (options.SimApiRouteOptions.VersionRoute != null)
{
logger.LogInformation("注册内置Route: Versions => {}", options.SimApiRouteOptions.LogoutRoute);
builder.MapControllerRoute(name: "Versions", pattern: options.SimApiRouteOptions.VersionRoute,
defaults: new
{
controller = "SimApiCommon",
action = "Versions"
});
}
if (options.SimApiRouteOptions.UserInfoRoute != null)
{
logger.LogInformation("注册内置Route: UserInfo => {}", options.SimApiRouteOptions.UserInfoRoute);
@@ -479,6 +484,17 @@ public static class SimApiExtensions
});
}
if (options.SimApiRouteOptions.WebConfigRoute != null)
{
logger.LogInformation("注册内置Route: Logout => {}", options.SimApiRouteOptions.WebConfigRoute);
builder.MapControllerRoute(name: "WebConfig", pattern: options.SimApiRouteOptions.WebConfigRoute,
defaults: new
{
controller = "SimApiCommon",
action = "WebConfig"
});
}
if (options.EnableSimApiDoc)
{
logger.LogInformation("开始配置SimApiDoc...");
@@ -497,6 +513,12 @@ public static class SimApiExtensions
});
}
if (options.EnableRequestLog)
{
logger.LogInformation("开始配置SimApiRequestLog...");
builder.UseMiddleware<SimApiRequestLogMiddleware>();
}
if (options.EnableSimApiException)
{
logger.LogInformation("开始配置SimApiException...");
+24
View File
@@ -0,0 +1,24 @@
using System.Collections.Generic;
using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace SimApi.SwaggerFilters;
public class DictionarySchemaFilter : ISchemaFilter
{
public void Apply(IOpenApiSchema schema, SchemaFilterContext context)
{
if (!context.Type.IsGenericType || context.Type.GetGenericTypeDefinition() != typeof(Dictionary<,>))
return;
if (schema is not OpenApiSchema concrete) return;
var valueType = context.Type.GetGenericArguments()[1];
concrete.Type = JsonSchemaType.Object;
concrete.AdditionalPropertiesAllowed = true;
concrete.AdditionalProperties = context.SchemaGenerator.GenerateSchema(valueType, context.SchemaRepository);
concrete.Properties?.Clear();
concrete.Example = null;
concrete.Examples?.Clear();
}
}