更新框架,增加云库搜索模块

This commit is contained in:
chfei committed 2026-09-18 18:40:10 +08:00
1 parent 8526e5e0d2
commit f2caec6083
192 files changed
+3626 -804

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
using MAX.Core.DependencyInjection;
using MAX.Core.Infrastructure;
using MAX.Core.Interfaces;
using System;
using System.Collections.Concurrent;
using System.Linq;
@@ -1,10 +1,12 @@
namespace MAX.Core.DependencyInjection;
using MAX.Core.Interfaces;
namespace MAX.Core.DependencyInjection;
/// <summary>
/// 依赖注册器:把当前模块的服务注册到 <see cref="IServiceRegistrar"/>。
/// 底层使用哪个容器由宿主项目决定,MAX.Core 不引用任何容器。
/// </summary>
public interface IDependencyRegistrar
public interface IDependencyRegistrar: IPriority
{
/// <summary>
/// 注册服务
+33
View File
@@ -0,0 +1,33 @@
namespace MAX.Core.Interfaces;
public interface IPriority
{
/// <summary> 优先级(优先级越高越先执行) </summary>
int Priority { get; }
}
public static class IntPriority
{
public const int Highest = 5000;
public const int VeryHigh = 4000;
public const int Higher = 3000;
public const int High = 2000;
public const int AboveNormal = 1000;
public const int Normal = 0;
public const int BelowNormal = -1000;
public const int Low = -2000;
public const int Lower = -3000;
public const int VeryLow = -4000;
public const int Lowest = -5000;
}
@@ -1,6 +1,6 @@
using MAX.Core.DependencyInjection;
namespace MAX.Core.Infrastructure;
namespace MAX.Core.Interfaces;
/// <summary>
/// 启动器:实现 <see cref="IDependencyRegistrar"/>,由宿主项目统一发现并按 <see cref="Priority"/> 排序执行。
@@ -1,7 +1,7 @@
using System;
using System.Reflection;
namespace MAX.Core.Infrastructure;
namespace MAX.Core.Interfaces;
/// <summary>
/// 单例,需要在当前类中定义
+1 -2
View File
@@ -1,5 +1,4 @@
using MAX.Core.Infrastructure;
namespace MAX.Core.Logger;
/// <summary>自定义日志记录器接口</summary>
+1 -1
View File
@@ -24,7 +24,7 @@
*/
using MAX.Core.DependencyInjection;
using MAX.Core.Infrastructure;
using MAX.Core.Interfaces;
namespace MAX.Core;
@@ -0,0 +1,18 @@
using MAX.Framework.Dtos;
using System;
using System.Collections.Generic;
using System.Text;
namespace MAX.Framework.Commons
{
public class AppException : Exception
{
public int Code { get; }
public AppException(string message, int code = ResultCode.BusinessError)
: base(message)
{
Code = code;
}
}
}
@@ -0,0 +1,42 @@
using MAX.Framework.Dtos;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Text;
namespace MAX.Framework.Commons
{
public class GlobalExceptionFilter : IExceptionFilter
{
private readonly ILogger<GlobalExceptionFilter> _logger;
public GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
{
_logger = logger;
}
public void OnException(ExceptionContext context)
{
var traceId = context.HttpContext.TraceIdentifier;
var result = context.Exception switch
{
AppException be => ApiResult.Fail(be.Message, be.Code),
ValidationException ve => ApiResult.Fail(ve.Message, ResultCode.ValidationError),
UnauthorizedAccessException => ApiResult.Fail("未授权访问", ResultCode.Unauthorized),
_ => ApiResult.Fail("服务器内部错误", ResultCode.ServerError)
};
_logger.LogError(context.Exception, "请求异常 TraceId: {TraceId}", traceId);
context.Result = new ObjectResult(result)
{
StatusCode = 200 // 关键:HTTP状态码统一200,业务状态用Code表达
};
context.ExceptionHandled = true;
}
}
}
+26
View File
@@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace MAX.Framework.Commons
{
public class PageData<T>
{
public long Total { get; set; }
public int PageIndex { get; set; }
public int PageSize { get; set; }
public int TotalPages => PageSize > 0 ? (int)Math.Ceiling((double)Total / PageSize) : 0;
public List<T> List { get; set; } = new();
public PageData() { }
public PageData(List<T> list, long total, int pageIndex, int pageSize)
{
List = list;
Total = total;
PageIndex = pageIndex;
PageSize = pageSize;
}
}
}
@@ -0,0 +1,52 @@
using MAX.Framework.Dtos;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using System;
using System.Collections.Generic;
using System.Text;
namespace MAX.Framework.Commons
{
/// <summary>
/// 返回过滤
/// </summary>
public class UnifyResultFilter : IActionFilter
{
public void OnActionExecuted(ActionExecutedContext context)
{
if (context.Result is ObjectResult objResult)
{
if (!IsAlreadyWrapped(objResult.Value))
{
var apiResult = ApiResult<object>.Success(objResult.Value);
objResult.Value = apiResult;
objResult.DeclaredType = null;
}
}
else if (context.Result is EmptyResult || context.Result is NoContentResult)
{
context.Result = new ObjectResult(ApiResult.Success());
}
}
public void OnActionExecuting(ActionExecutingContext context) { }
private static bool IsAlreadyWrapped(object? value)
{
if (value is null) return false;
var type = value.GetType();
// 递归判断:只要类型链上有任何一层是 ApiResult<> 的开放泛型定义,或直接是 ApiResult,就算已包装
while (type is not null)
{
if (type == typeof(ApiResult)) return true;
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ApiResult<>)) return true;
type = type.BaseType;
}
return false;
}
}
}
@@ -0,0 +1,53 @@
using MAX.Framework.Dtos;
using Microsoft.AspNetCore.Http;
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.Json;
namespace MAX.Framework.Commons
{
public class UnsupportedMediaTypeHandlingMiddleware
{
private readonly RequestDelegate _next;
public UnsupportedMediaTypeHandlingMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
// 用一个内存流替换原始的Response.Body,方便判断状态码后决定是否要改写
var originalBodyStream = context.Response.Body;
using var memoryStream = new MemoryStream();
context.Response.Body = memoryStream;
await _next(context);
if (context.Response.StatusCode == StatusCodes.Status415UnsupportedMediaType)
{
var result = ApiResult.Fail("请求参数格式不正确", ResultCode.ValidationError);
var json = JsonSerializer.Serialize(result);
var bytes = Encoding.UTF8.GetBytes(json);
context.Response.Clear();
context.Response.StatusCode = StatusCodes.Status200OK; // 统一成200,和其他接口一致
context.Response.ContentType = "application/json; charset=utf-8";
context.Response.ContentLength = bytes.Length;
await context.Response.Body.WriteAsync(bytes);
await context.Response.Body.FlushAsync(originalBodyStream is null ? default : default);
}
else
{
// 非415的情况,把内存流内容原样写回真正的Response.Body
memoryStream.Seek(0, SeekOrigin.Begin);
await memoryStream.CopyToAsync(originalBodyStream);
}
context.Response.Body = originalBodyStream;
}
}
}
@@ -0,0 +1,27 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Collections.Generic;
using System.Text;
namespace MAX.Framework.DependencyInjection
{
public static class FreeSqlDB
{
public static IServiceCollection AddFreeSqlDB(this IServiceCollection services)
{
Func<IServiceProvider, IFreeSql> fsqlFactory = r =>
{
var config = r.GetRequiredService<IConfiguration>();
var connection = config.GetConnectionString("DefaultConnection");
IFreeSql fsql = new FreeSql.FreeSqlBuilder()
.UseConnectionString(FreeSql.DataType.MySql, connection)
.UseAdoConnectionPool(true)
.Build();
return fsql;
};
services.AddSingleton<IFreeSql>(fsqlFactory);
return services;
}
}
}
@@ -0,0 +1,127 @@
using Asp.Versioning.OpenApi;
using MAX.Framework.Security;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi;
using System.Security.Claims;
using System.Text;
namespace MAX.Framework.DependencyInjection;
/// <summary>
/// JWT 认证服务注册入口(由宿主 Program.cs 调用)。
/// </summary>
public static class JwtServiceCollectionExtensions
{
/// <summary>
/// 注册 JWT 认证:
/// <list type="number">
/// <item>绑定 <c>Jwt</c> 配置节到 <see cref="JwtOptions"/>,并在启动时校验签名密钥长度;</item>
/// <item>注册 <see cref="ITokenService"/>(令牌生成/校验)与 <see cref="ICurrentUser"/>(当前用户访问器);</item>
/// <item>注册 <see cref="JwtBearerDefaults"/> 认证,认证失败/授权失败统一返回 <see cref="Dtos.ApiResult{T}"/> 格式;</item>
/// <item>注册 <see cref="IOnlineUserStore"/>(基于 <c>IMemoryCache</c> 的在线会话存储):单点登录、在线人员查询、退出销毁;</item>
/// <item>为 OpenAPI 文档声明 Bearer(JWT) 安全方案,并对标记了 [Authorize] 的接口声明需要携带令牌。</item>
/// </list>
/// </summary>
/// <remarks>
/// 需要在请求管道中调用 <c>app.UseAuthentication()</c>(位于 <c>app.UseAuthorization()</c> 之前)。
/// </remarks>
public static IServiceCollection AddStoreHubJwt(this IServiceCollection services, IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
// JwtOptions:绑定 Jwt 配置节,启动时校验 HS256 签名密钥长度
services.AddOptions<JwtOptions>()
.Bind(configuration.GetSection(JwtOptions.SectionName))
.Validate(
o => !string.IsNullOrWhiteSpace(o.SigningKey)
&& Encoding.UTF8.GetByteCount(o.SigningKey) >= 32,
"Jwt:SigningKey 长度不足,HS256 签名密钥至少需要 32 字节(256位)。")
.ValidateOnStart();
services.AddHttpContextAccessor();
// 令牌服务与当前用户访问器
services.AddSingleton<ITokenService, TokenService>();
services.AddScoped<ICurrentUser, CurrentUser>();
// 在线会话存储(单点登录):基于 IMemoryCache 的进程内缓存,用于在线人员管理与退出销毁
services.AddMemoryCache();
services.AddSingleton<IOnlineUserStore, OnlineUserStore>();
// JwtBearer 认证
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
JwtOptions jwt = configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>() ?? new JwtOptions();
options.TokenValidationParameters = jwt.ToTokenValidationParameters();
options.Events = new JwtBearerEvents
{
OnChallenge = JwtBearerResponseHandler.OnChallenge,
OnForbidden = JwtBearerResponseHandler.OnForbidden,
// 单点登录校验:会话必须仍有效(未退出、未被新登录顶替)
OnTokenValidated = context =>
{
IOnlineUserStore onlineUserStore = context.HttpContext.RequestServices.GetRequiredService<IOnlineUserStore>();
ClaimsPrincipal? principal = context.Principal;
string? userName = principal?.FindFirst(JwtClaimTypes.UserName)?.Value;
string? jti = principal?.FindFirst(JwtClaimTypes.Jti)?.Value;
if (string.IsNullOrWhiteSpace(userName)
|| string.IsNullOrWhiteSpace(jti)
|| !onlineUserStore.IsTokenActive(userName, jti))
{
context.Fail(new SecurityTokenException("登录已失效,请重新登录"));
}
return Task.CompletedTask;
},
};
});
// OpenAPI:声明 Bearer(JWT) 安全方案(Scalar 页面即可显示 Authorize/输入 token),
// 并仅对标记了 [Authorize] 的接口声明需要携带令牌(与宿主的 AddOpenApi 配置合并生效)
services.Configure<VersionedOpenApiOptions>(options =>
{
// 在 OpenAPI 文档中声明 Bearer(JWT) 安全方案
options.Document.AddDocumentTransformer((document, _, _) =>
{
document.Components ??= new OpenApiComponents();
document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
document.Components.SecuritySchemes.TryAdd("Bearer", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
Description = "登录接口返回的 accessToken,直接粘贴令牌内容即可(无需手动加 Bearer 前缀)。",
});
return Task.CompletedTask;
});
// 仅对标记了 [Authorize] 的接口声明需要携带令牌
options.Document.AddOperationTransformer((operation, context, _) =>
{
IList<object> metadata = context.Description.ActionDescriptor.EndpointMetadata;
bool requiresAuth = metadata.OfType<IAuthorizeData>().Any()
&& !metadata.OfType<IAllowAnonymous>().Any();
if (requiresAuth)
{
operation.Security =
[
new OpenApiSecurityRequirement
{
[new OpenApiSecuritySchemeReference("Bearer", context.Document, "")] = [],
},
];
}
return Task.CompletedTask;
});
});
return services;
}
}
@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NLog.Web;
using System;
using System.Collections.Generic;
using System.Text;
namespace MAX.Framework.DependencyInjection
{
public static class LoggerExtensions
{
public static WebApplicationBuilder AddLogger(this WebApplicationBuilder builder)
{
builder.Logging.ClearProviders();
//builder.Logging.SetMinimumLevel(LogLevel.Information);
builder.Host.UseNLog();
return builder;
}
}
}
@@ -0,0 +1,163 @@
using MAX.Core.DependencyInjection;
using MAX.Core.Interfaces;
using MAX.Framework.Commons;
using MAX.Framework.Dtos;
using MAX.Framework.Interfaces;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using System.Diagnostics;
using System.Reflection;
namespace MAX.Framework.DependencyInjection
{
/// <summary>
/// IOC 注册入口(由宿主 Program.cs 调用)
/// </summary>
public static class ServiceCollectionExtensions
{
private static readonly Type[] DependencyMarkers =
[
typeof(ISingletonDependency),
typeof(IScopedDependency),
typeof(ITransientDependency),
];
/// <summary>
/// 注册应用服务:
/// <list type="number">
/// <item>自动注册所有实现 <see cref="ISingletonDependency"/> / <see cref="IScopedDependency"/> / <see cref="ITransientDependency"/> 的类型;</item>
/// <item>发现所有 <see cref="IStartup"/>,按 <see cref="IStartup.Priority"/> 降序执行 Initialize + Register。</item>
/// </list>
/// </summary>
public static IServiceCollection AddStoreHub(this IServiceCollection services)
{
IEnumerable<Assembly> assemblies = GetApplicationAssemblies();
RegisterDependencyMarkers(services, assemblies);
RunStartups(services, assemblies);
RunModules(services, assemblies);
services.AddControllers(options =>
{
options.Filters.Add<GlobalExceptionFilter>();
options.Filters.Add<UnifyResultFilter>();
}).ConfigureApiBehaviorOptions(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
// 取出所有校验错误信息,拼成一个字符串(或者只取第一个)
var errors = context.ModelState
.Where(e => e.Value?.Errors.Count > 0)
.SelectMany(e => e.Value!.Errors.Select(err => err.ErrorMessage))
.ToList();
var msg = string.Join("; ", errors);
var result = ApiResult.Fail(msg, ResultCode.ValidationError);
return new ObjectResult(result)
{
StatusCode = 200 // 和其他接口保持一致,用Code表达失败,而不是HTTP 400
};
};
});
return services;
}
/// <summary>
/// 自动注册标记接口(ISingletonDependency / IScopedDependency / ITransientDependency)的实现类型
/// </summary>
private static void RegisterDependencyMarkers(IServiceCollection services, IEnumerable<Assembly> assemblies)
{
foreach (Type type in GetConcreteTypes(assemblies))
{
Debug.WriteLine(type.Namespace);
Type? marker = DependencyMarkers.FirstOrDefault(m => m.IsAssignableFrom(type));
if (marker is null)
{
continue;
}
ServiceLifetime lifetime = marker == typeof(ISingletonDependency) ? ServiceLifetime.Singleton
: marker == typeof(IScopedDependency) ? ServiceLifetime.Scoped
: ServiceLifetime.Transient;
// 注册为自身
services.Add(new ServiceDescriptor(type, type, lifetime));
// 注册为实现的接口(排除标记接口与系统接口)
foreach (Type iface in type.GetInterfaces())
{
if (DependencyMarkers.Contains(iface) || IsSystemInterface(iface))
{
continue;
}
services.Add(new ServiceDescriptor(iface, type, lifetime));
}
}
}
/// <summary>
/// 发现所有 <see cref="IStartup"/> 实现,按 <see cref="IStartup.Priority"/> 降序执行
/// </summary>
private static void RunStartups(IServiceCollection services, IEnumerable<Assembly> assemblies)
{
IServiceRegistrar registrar = new ServiceRegistrarProxy(services);
foreach (IStartup startup in GetConcreteTypes(assemblies)
.Where(t => typeof(IStartup).IsAssignableFrom(t))
.Select(t => (IStartup)Activator.CreateInstance(t, true)!)
.OrderByDescending(s => s.Priority))
{
startup.Initialize();
startup.Register(registrar);
}
}
private static void RunModules(IServiceCollection services, IEnumerable<Assembly> assemblies)
{
IServiceRegistrar registrar = new ServiceRegistrarProxy(services);
foreach (IModule startup in GetConcreteTypes(assemblies)
.Where(t => typeof(IModule).IsAssignableFrom(t))
.Select(t => (IModule)Activator.CreateInstance(t, true)!))
{
startup.AddModule(services);
}
}
private static IEnumerable<Type> GetConcreteTypes(IEnumerable<Assembly> assemblies)
{
return assemblies
.SelectMany(SafeGetTypes)
.Where(t => t.IsClass && !t.IsAbstract && !t.IsGenericTypeDefinition);
}
private static IEnumerable<Type> SafeGetTypes(Assembly assembly)
{
try
{
return assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
return ex.Types.OfType<Type>();
}
}
private static IEnumerable<Assembly> GetApplicationAssemblies()
{
// 使用 AppTypeFinder:扫描应用输出目录 + 已加载程序集,
return StoreHubAppTypeFinder.Current.GetAssemblies();
}
private static bool IsSystemInterface(Type type)
{
return type.Namespace is null
|| type.Namespace.StartsWith("System", StringComparison.Ordinal)
|| type.Namespace.StartsWith("Microsoft", StringComparison.Ordinal);
}
}
}
@@ -0,0 +1,88 @@
using MAX.Core.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
namespace MAX.Framework.DependencyInjection
{
/// <summary>
/// 将 <see cref="IServiceRegistrar"/> 适配到 Microsoft.Extensions.DependencyInjection
/// </summary>
internal sealed class ServiceRegistrarProxy : IServiceRegistrar
{
private readonly IServiceCollection _services;
public ServiceRegistrarProxy(IServiceCollection services)
{
_services = services;
}
public void RegisterSingleton<TService, TImplementation>() where TService : class where TImplementation : class, TService
{
_services.AddSingleton<TService, TImplementation>();
}
public void RegisterSingleton<TService>(TService instance) where TService : class
{
_services.AddSingleton(instance);
}
public void RegisterSingleton<TService>(Func<IServiceProvider, TService> factory) where TService : class
{
_services.AddSingleton(factory);
}
public void RegisterSingleton<TService, TImplementation>(object? serviceKey) where TService : class where TImplementation : class, TService
{
_services.AddKeyedSingleton<TService, TImplementation>(serviceKey);
}
public void RegisterSingleton<TService>(object? serviceKey, TService instance) where TService : class
{
_services.AddKeyedSingleton(serviceKey, instance);
}
public void RegisterSingleton<TService>(object? serviceKey, Func<IServiceProvider, object?, TService> factory) where TService : class
{
_services.AddKeyedSingleton(serviceKey, factory);
}
public void RegisterScoped<TService, TImplementation>() where TService : class where TImplementation : class, TService
{
_services.AddScoped<TService, TImplementation>();
}
public void RegisterScoped<TService>(Func<IServiceProvider, TService> factory) where TService : class
{
_services.AddScoped(factory);
}
public void RegisterScoped<TService, TImplementation>(object? serviceKey) where TService : class where TImplementation : class, TService
{
_services.AddKeyedScoped<TService, TImplementation>(serviceKey);
}
public void RegisterScoped<TService>(object? serviceKey, Func<IServiceProvider, object?, TService> factory) where TService : class
{
_services.AddKeyedScoped(serviceKey, factory);
}
public void RegisterTransient<TService, TImplementation>() where TService : class where TImplementation : class, TService
{
_services.AddTransient<TService, TImplementation>();
}
public void RegisterTransient<TService>(Func<IServiceProvider, TService> factory) where TService : class
{
_services.AddTransient(factory);
}
public void RegisterTransient<TService, TImplementation>(object? serviceKey) where TService : class where TImplementation : class, TService
{
_services.AddKeyedTransient<TService, TImplementation>(serviceKey);
}
public void RegisterTransient<TService>(object? serviceKey, Func<IServiceProvider, object?, TService> factory) where TService : class
{
_services.AddKeyedTransient(serviceKey, factory);
}
}
}
@@ -0,0 +1,18 @@
using MAX.Core.Reflections;
namespace MAX.Framework.DependencyInjection
{
/// <summary>
/// 本应用的类型查找器:在 AppTypeFinder 默认前缀(MAX. / Nexus.)之外,
/// 增加 StoreHub、KJD 前缀,用于发现宿主和各业务模块的程序集。
/// </summary>
internal sealed class StoreHubAppTypeFinder : AppTypeFinder
{
public static StoreHubAppTypeFinder Current { get; } = new();
protected override List<string> AvailablePrefix()
{
return ["Store"];
}
}
}
+69
View File
@@ -0,0 +1,69 @@
using MAX.Framework.Commons;
using static System.Runtime.InteropServices.JavaScript.JSType;
namespace MAX.Framework.Dtos
{
public static class ResultCode
{
public const int Success = 200;
public const int Fail = 500;
public const int Unauthorized = 401;
public const int Forbidden = 403;
public const int NotFound = 404;
/// <summary>
/// 参数校验失败
/// </summary>
public const int ValidationError = 1001;
/// <summary>
/// 业务逻辑异常
/// </summary>
public const int BusinessError = 2001;
/// <summary>
/// 服务器内部错误
/// </summary>
public const int ServerError = 5000;
}
/// <summary>
/// 非泛型基类,用于统一处理
/// </summary>
public abstract class ApiResultBase
{
/// <summary>业务状态码</summary>
public int Code { get; set; } = ResultCode.Success;
/// <summary>提示信息</summary>
public string? Msg { get; set; }
/// <summary>请求是否成功</summary>
public bool Success => Code == ResultCode.Success;
}
/// <summary>
/// 泛型响应
/// </summary>
public class ApiResult<T> : ApiResultBase
{
public T? Data { get; set; }
public static ApiResult<T> Success(T data, string msg = "操作成功")
=> new ApiResult<T> { Code = ResultCode.Success, Data = data, Msg = msg };
public static ApiResult<T> Fail(string msg = "操作失败", int code = ResultCode.Fail, T? data = default)
=> new ApiResult<T> { Code = code, Msg = msg, Data = data };
}
/// <summary>
/// 响应
/// </summary>
public class ApiResult : ApiResult<object>
{
public static ApiResult Success(string msg = "操作成功")
=> new ApiResult { Code = ResultCode.Success, Msg = msg };
public static ApiResult Fail(string msg = "操作失败", int code = ResultCode.Fail)
=> new ApiResult { Code = code, Msg = msg };
}
}
@@ -0,0 +1,76 @@
using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
namespace MAX.Framework.Helpers;
public static class ObjectHelper
{
private static readonly ConcurrentDictionary<string, PropertyInfo> CachedObjectProperties =
new();
public static void TrySetProperty<TObject, TValue>(
TObject obj,
Expression<Func<TObject, TValue>> propertySelector,
Func<TValue> valueFactory,
params Type[] ignoreAttributeTypes
)
{
TrySetProperty(obj, propertySelector, x => valueFactory(), ignoreAttributeTypes);
}
public static void TrySetProperty<TObject, TValue>(
TObject obj,
Expression<Func<TObject, TValue>> propertySelector,
Func<TObject, TValue> valueFactory,
params Type[] ignoreAttributeTypes
)
{
string cacheKey =
$"{obj?.GetType().FullName}-"
+ $"{propertySelector}-"
+ $"{(ignoreAttributeTypes != null ? "-" + string.Join("-", ignoreAttributeTypes.Select(x => x.FullName)) : "")}";
PropertyInfo property = CachedObjectProperties.GetOrAdd(
cacheKey,
(name) =>
{
if (propertySelector.Body.NodeType != ExpressionType.MemberAccess)
{
return null;
}
MemberExpression memberExpression = (MemberExpression)propertySelector.Body;
PropertyInfo? propertyInfo = obj?.GetType()
.GetProperties()
.FirstOrDefault(
x =>
x.Name == memberExpression.Member.Name
&& x.GetSetMethod(true) != null
);
if (propertyInfo == null)
{
return null;
}
if (
ignoreAttributeTypes != null
&& ignoreAttributeTypes.Any(
ignoreAttribute => propertyInfo.IsDefined(ignoreAttribute, true)
)
)
{
return null;
}
return propertyInfo;
}
);
property?.SetValue(obj, valueFactory(obj));
}
}
+156
View File
@@ -0,0 +1,156 @@
using MAX.Core.Commons;
using System;
using System.Diagnostics;
using System.Text;
namespace MAX.Framework.Helpers;
public class WinCmdHelper
{
#region 执行Dos命令
/// <summary>
/// 执行Dos命令
/// </summary>
/// <param name="cmd">Dos命令及参数</param>
/// <param name="isShowCmdWindow">是否显示cmd窗口</param>
/// <param name="isCloseCmdProcess">执行完毕后是否关闭cmd进程</param>
/// <returns>成功返回true,失败返回false</returns>
public static Result ExcuteDosCommand(string cmd, string workdir, bool isShowCmdWindow = false, bool isWaitForExit = false, bool isCloseCmdProcess = true)
{
Result result = new();
try
{
StringBuilder errString = new();
StringBuilder outString = new();
Process p = new();
p.StartInfo.FileName = "cmd";
p.StartInfo.WorkingDirectory = workdir;
p.StartInfo.Arguments = "/c " + cmd;
p.StartInfo.StandardOutputEncoding = Encoding.UTF8;
p.StartInfo.StandardErrorEncoding = Encoding.UTF8;
p.StartInfo.UseShellExecute = false;
p.StartInfo.RedirectStandardOutput = true;
p.StartInfo.RedirectStandardError = true;
p.StartInfo.CreateNoWindow = !isShowCmdWindow;
p.OutputDataReceived += new DataReceivedEventHandler(delegate (object sender, DataReceivedEventArgs e)
{
if (!string.IsNullOrEmpty(e.Data))
{
outString.AppendLine(e.Data);
Debug.WriteLine(e.Data);
}
});
p.ErrorDataReceived += new DataReceivedEventHandler(delegate (object sender, DataReceivedEventArgs e)
{
if (!string.IsNullOrEmpty(e.Data))
{
errString.AppendLine(e.Data);
}
});
p.Start();
p.BeginOutputReadLine();
p.BeginErrorReadLine();
if (isWaitForExit)
{
p.WaitForExit();
}
if (isCloseCmdProcess)
{
p.Close();
}
if (errString.Length <= 0)
{
result.Success = true;
result.Data = outString.ToString();
}
Console.WriteLine(string.Format("成功执行Dos命令[{0}]!", cmd));
return result;
}
catch (Exception ex)
{
Console.WriteLine("执行命令失败,请检查输入的命令是否正确:" + ex.Message, ex);
result.Msg = ex.Message;
}
return result;
}
private static void P_ErrorDataReceived(object sender, DataReceivedEventArgs e)
{
throw new NotImplementedException();
}
#endregion
#region 判断指定的进程是否在运行中
/// <summary>
/// 判断指定的进程是否在运行中
/// </summary>
/// <param name="processName">要判断的进程名称,不包括扩展名exe</param>
/// <param name="processFileName">进程文件的完整路径</param>
/// <returns>存在返回true,否则返回false</returns>
public static bool CheckProcessExists(string processName, string processFileName)
{
Process[] processes = Process.GetProcessesByName(processName);
foreach (Process p in processes)
{
if (!string.IsNullOrEmpty(processFileName))
{
if (processFileName == p?.MainModule?.FileName)
{
return true;
}
}
else
{
return true;
}
}
return false;
}
#endregion
#region 结束指定的windows进程
/// <summary>
/// 结束指定的windows进程,如果进程存在
/// </summary>
/// <param name="processName">进程名称,不包含扩展名</param>
/// <param name="processFileName">进程文件完整路径,如果为空则删除所有进程名为processName参数值的进程</param>
public static bool KillProcessExists(string processName, string processFileName)
{
try
{
Process[] processes = Process.GetProcessesByName(processName);
foreach (Process p in processes)
{
if (!string.IsNullOrEmpty(processFileName))
{
if (processFileName == p.MainModule.FileName)
{
p.Kill();
p.Close();
}
}
else
{
p.Kill();
p.Close();
}
}
Console.WriteLine(string.Format("成功结束[{0}]进程!", processes));
return true;
}
catch (Exception ex)
{
Console.WriteLine("结束指定的Widnows进程异常:" + ex.Message, ex);
return false;
}
}
#endregion
}
+12
View File
@@ -0,0 +1,12 @@
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Collections.Generic;
using System.Text;
namespace MAX.Framework.Interfaces
{
public interface IModule
{
void AddModule(IServiceCollection services);
}
}
+138
View File
@@ -0,0 +1,138 @@
//using MAX.Core.Infrastructure;
//using MAX.Core.Logger;
//using NLog;
//using System;
//using LLogLevel = MAX.Core.Logger.LogLevel;
//using NLogLevel = NLog.LogLevel;
//namespace MAX.Framework.Logger;
//public class NLogger : SingleInstance<NLogger>, ILoggerFacade
//{
// private readonly ILogger _log;
// private NLogger()
// {
// _log = LogManager.GetLogger("GlobalLogger");
// }
// public bool IsDebugEnabled { get; private set; } = true;
// public void Debug(Exception ex, string message)
// {
// Execute(NLogLevel.Debug, ex, message);
// }
// public void Debug(string message)
// {
// Execute(NLogLevel.Debug, message);
// }
// public void Error(Exception ex)
// {
// Execute(NLogLevel.Error, ex);
// }
// public void Error(Exception ex, string message)
// {
// Execute(NLogLevel.Error, ex, message);
// }
// public void Error(string message)
// {
// Execute(NLogLevel.Error, message);
// }
// public void Fatal(Exception ex, string message)
// {
// Execute(NLogLevel.Fatal, ex, message);
// }
// public void Fatal(string message)
// {
// Execute(NLogLevel.Fatal, message);
// }
// public void Info(Exception ex, string message)
// {
// Execute(NLogLevel.Info, ex, message);
// }
// public void Info(string message)
// {
// Execute(NLogLevel.Info, message);
// }
// public void Warn(Exception ex, string message)
// {
// Execute(NLogLevel.Warn, ex, message);
// }
// public void Warn(string message)
// {
// Execute(NLogLevel.Warn, message);
// }
// public void Log(string message, LLogLevel level)
// {
// NLogLevel lv = NLogLevel.Info;
// switch (level)
// {
// case LLogLevel.Trace:
// lv = NLogLevel.Trace;
// break;
// case LLogLevel.Debug:
// lv = NLogLevel.Debug;
// break;
// case LLogLevel.Info:
// lv = NLogLevel.Info;
// break;
// case LLogLevel.Warn:
// lv = NLogLevel.Warn;
// break;
// case LLogLevel.Error:
// lv = NLogLevel.Error;
// break;
// case LLogLevel.Fatal:
// lv = NLogLevel.Fatal;
// break;
// case LLogLevel.None:
// lv = NLogLevel.Off;
// break;
// }
// Execute(lv, message);
// }
// /// <summary>
// /// 是否启用
// /// </summary>
// private bool Enabled(NLogLevel level)
// {
// if (level > NLogLevel.Debug)
// {
// return true;
// }
// return IsDebugEnabled;
// }
// /// <summary>
// /// 执行
// /// </summary>
// private void Execute(NLogLevel level, Exception ex, string message = "")
// {
// if (Enabled(level))
// {
// _log.Log(level, ex, message, null);
// }
// }
// /// <summary>
// /// 执行
// /// </summary>
// private void Execute(NLogLevel level, string message)
// {
// if (Enabled(level))
// {
// _log.Log(level, message);
// }
// }
//}
+30
View File
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.2.1" />
<PackageReference Include="Asp.Versioning.OpenApi" Version="10.2.1" />
<PackageReference Include="FreeSql" Version="3.5.311" />
<PackageReference Include="FreeSql.Provider.MySql" Version="3.5.311" />
<PackageReference Include="FreeSql.Repository" Version="3.5.311" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<PackageReference Include="NLog.Web.AspNetCore" Version="6.2.0" />
<PackageReference Include="Riok.Mapperly" Version="4.3.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MAX.Core\MAX.Core.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Models\" />
</ItemGroup>
</Project>
@@ -0,0 +1,66 @@
using Microsoft.AspNetCore.Http;
using System.Globalization;
using System.Security.Claims;
namespace MAX.Framework.Security;
/// <summary>
/// 从 <see cref="IHttpContextAccessor"/> 读取当前请求认证票据的默认实现。
/// </summary>
public class CurrentUser : ICurrentUser
{
private readonly IHttpContextAccessor _httpContextAccessor;
public CurrentUser(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
/// <summary>
/// 当前请求的 <see cref="ClaimsPrincipal"/>
/// </summary>
private ClaimsPrincipal? Principal => _httpContextAccessor.HttpContext?.User;
/// <inheritdoc />
public bool IsAuthenticated => Principal?.Identity?.IsAuthenticated == true;
/// <inheritdoc />
public string? UserName => GetClaimValue(JwtClaimTypes.UserName);
/// <inheritdoc />
public string? NickName => GetClaimValue(JwtClaimTypes.NickName);
/// <inheritdoc />
public IEnumerable<Claim> Claims => Principal?.Claims ?? [];
/// <inheritdoc />
public string? GetClaimValue(string claimType)
{
if (string.IsNullOrWhiteSpace(claimType))
{
return null;
}
return Principal?.FindFirst(claimType)?.Value;
}
/// <inheritdoc />
public T? GetClaimValue<T>(string claimType)
{
string? value = GetClaimValue(claimType);
if (value is null)
{
return default;
}
try
{
return (T)Convert.ChangeType(value, typeof(T), CultureInfo.InvariantCulture);
}
catch
{
return default;
}
}
}
@@ -0,0 +1,39 @@
using System.Security.Claims;
namespace MAX.Framework.Security;
/// <summary>
/// 当前登录用户访问器:从当前请求的认证票据中读取用户信息。
/// </summary>
public interface ICurrentUser
{
/// <summary>
/// 当前请求是否已认证
/// </summary>
bool IsAuthenticated { get; }
/// <summary>
/// 当前登录账户名(未登录为 null)
/// </summary>
string? UserName { get; }
/// <summary>
/// 当前用户昵称(未登录为 null)
/// </summary>
string? NickName { get; }
/// <summary>
/// 当前用户的全部声明
/// </summary>
IEnumerable<Claim> Claims { get; }
/// <summary>
/// 获取指定类型的声明值
/// </summary>
string? GetClaimValue(string claimType);
/// <summary>
/// 获取指定类型的声明值并转换为目标类型;不存在或转换失败返回默认值
/// </summary>
T? GetClaimValue<T>(string claimType);
}
@@ -0,0 +1,34 @@
namespace MAX.Framework.Security;
/// <summary>
/// 在线用户会话存储:基于 <see cref="Microsoft.Extensions.Caching.Memory.IMemoryCache"/> 的进程内实现,
/// 用于单点登录(同一用户仅保留最新会话)、在线人员管理与退出销毁。
/// </summary>
public interface IOnlineUserStore
{
/// <summary>
/// 用户登录/刷新会话:同用户的新登录会覆盖旧会话,旧令牌立即失效(单点登录)。
/// </summary>
void SignIn(OnlineUserInfo info);
/// <summary>
/// 判断指定登录账户、指定令牌会话是否仍然有效(未被退出、未被新登录顶替)。
/// </summary>
bool IsTokenActive(string userName, string jti);
/// <summary>
/// 退出登录:销毁该账户的在线会话,令牌立即失效。
/// </summary>
void SignOut(string userName);
/// <summary>
/// 获取当前在线人员列表。
/// </summary>
IReadOnlyList<OnlineUserInfo> GetOnlineUsers();
/// <summary>
/// 当前在线人数。
/// </summary>
int Count { get; }
}
@@ -0,0 +1,40 @@
using System.Security.Claims;
namespace MAX.Framework.Security;
/// <summary>
/// 令牌生成结果
/// </summary>
public class TokenResult
{
/// <summary>
/// 令牌类型(Bearer)
/// </summary>
public string TokenType { get; set; } = "Bearer";
/// <summary>
/// 访问令牌
/// </summary>
public string Token { get; set; } = string.Empty;
/// <summary>
/// 有效时间(分钟)
/// </summary>
public uint ExpiresInMinutes { get; set; }
}
/// <summary>
/// JWT 令牌服务:生成与校验访问令牌。
/// </summary>
public interface ITokenService
{
/// <summary>
/// 根据声明集合生成访问令牌
/// </summary>
TokenResult CreateToken(IEnumerable<Claim> claims);
/// <summary>
/// 校验令牌,校验失败返回 null
/// </summary>
Task<ClaimsPrincipal?> ValidateTokenAsync(string token);
}
@@ -0,0 +1,45 @@
using MAX.Framework.Dtos;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http;
using System.Text;
using System.Text.Json;
namespace MAX.Framework.Security;
/// <summary>
/// JwtBearer 认证失败/授权失败时,统一返回 <see cref="ApiResult{T}"/> 格式的 JSON 响应。
/// </summary>
internal static class JwtBearerResponseHandler
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
/// <summary>
/// 未携带令牌或令牌无效(401)
/// </summary>
public static Task OnChallenge(JwtBearerChallengeContext context)
{
context.HandleResponse();
return WriteAsync(context.HttpContext, StatusCodes.Status401Unauthorized, "未授权:请先登录或携带有效的访问令牌");
}
/// <summary>
/// 已认证但无权限(403)
/// </summary>
public static Task OnForbidden(ForbiddenContext context)
{
return WriteAsync(context.HttpContext, StatusCodes.Status403Forbidden, "禁止访问:当前账号没有权限执行该操作");
}
private static async Task WriteAsync(HttpContext httpContext, int statusCode, string message)
{
httpContext.Response.StatusCode = statusCode;
httpContext.Response.ContentType = "application/json; charset=utf-8";
string body = JsonSerializer.Serialize(ApiResult.Fail(message, statusCode), JsonOptions);
await httpContext.Response.WriteAsync(body, Encoding.UTF8);
}
}
@@ -0,0 +1,16 @@
namespace MAX.Framework.Security;
/// <summary>
/// JWT 自定义声明类型常量。
/// </summary>
public static class JwtClaimTypes
{
/// <summary>登录账户(唯一)</summary>
public const string UserName = "user_name";
/// <summary>用户昵称</summary>
public const string NickName = "nick_name";
/// <summary>令牌唯一标识(jti,用于单点登录/退出校验)</summary>
public const string Jti = "jti";
}
+74
View File
@@ -0,0 +1,74 @@
using Microsoft.IdentityModel.Tokens;
using System.Text;
namespace MAX.Framework.Security;
/// <summary>
/// JWT 配置选项,默认绑定配置节 <c>Jwt</c>。
/// </summary>
public class JwtOptions
{
/// <summary>
/// 配置节名称
/// </summary>
public const string SectionName = "Jwt";
/// <summary>
/// 签发者
/// </summary>
public string Issuer { get; set; } = "StoreHub";
/// <summary>
/// 受众
/// </summary>
public string Audience { get; set; } = "StoreHub.Client";
/// <summary>
/// 签名密钥(HS256,至少 32 字节)
/// </summary>
public string SigningKey { get; set; } = "StoreHub-Development-SigningKey-0123456789abcdef0123456789abcdef";
/// <summary>
/// 令牌有效期(分钟)
/// </summary>
public int ExpiresMinutes { get; set; } = 120;
/// <summary>
/// 时钟偏移(分钟),容忍客户端与服务器的时间偏差
/// </summary>
public int ClockSkewMinutes { get; set; } = 5;
/// <summary>
/// 是否校验签发者
/// </summary>
public bool ValidateIssuer { get; set; } = true;
/// <summary>
/// 是否校验受众
/// </summary>
public bool ValidateAudience { get; set; } = true;
/// <summary>
/// 是否校验有效期
/// </summary>
public bool ValidateLifetime { get; set; } = true;
/// <summary>
/// 根据当前选项构建 <see cref="TokenValidationParameters"/>,用于 JwtBearer 中间件与令牌校验。
/// </summary>
public TokenValidationParameters ToTokenValidationParameters()
{
byte[] keyBytes = Encoding.UTF8.GetBytes(SigningKey);
return new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(keyBytes),
ValidateIssuer = ValidateIssuer,
ValidIssuer = Issuer,
ValidateAudience = ValidateAudience,
ValidAudience = Audience,
ValidateLifetime = ValidateLifetime,
ClockSkew = TimeSpan.FromMinutes(ClockSkewMinutes),
};
}
}
@@ -0,0 +1,23 @@
namespace MAX.Framework.Security;
/// <summary>
/// 在线用户会话信息(单点登录:同一用户仅保留一个有效会话)。
/// </summary>
public class OnlineUserInfo
{
/// <summary>登录账户(唯一,作为会话键)</summary>
public string UserName { get; set; } = string.Empty;
/// <summary>用户昵称</summary>
public string NickName { get; set; } = string.Empty;
/// <summary>当前有效会话的令牌唯一标识(jti)</summary>
public string Jti { get; set; } = string.Empty;
/// <summary>登录时间(本地时间)</summary>
public DateTime LoginTime { get; set; }
/// <summary>会话过期时间(本地时间,与令牌 exp 一致)</summary>
public DateTime ExpiresAt { get; set; }
}
@@ -0,0 +1,80 @@
using Microsoft.Extensions.Caching.Memory;
using System.Collections.Concurrent;
namespace MAX.Framework.Security;
/// <summary>
/// 基于 <see cref="IMemoryCache"/> 的在线用户会话存储。
/// </summary>
public class OnlineUserStore : IOnlineUserStore
{
private const string CacheKeyPrefix = "online:user:";
private readonly IMemoryCache _cache;
// 在线人员索引:IMemoryCache 不提供公共枚举 API,这里用字典维护便于查询在线列表
private readonly ConcurrentDictionary<string, OnlineUserInfo> _onlineIndex = new(StringComparer.Ordinal);
public OnlineUserStore(IMemoryCache cache)
{
_cache = cache;
}
/// <inheritdoc />
public void SignIn(OnlineUserInfo info)
{
ArgumentNullException.ThrowIfNull(info);
// 以登录账户为键覆盖写入:旧会话(jti)被顶替,实现单点登录
_cache.Set(GetKey(info.UserName), info, new MemoryCacheEntryOptions
{
// 缓存条目与令牌同时过期,过期后自动失效
AbsoluteExpiration = info.ExpiresAt,
});
_onlineIndex[info.UserName] = info;
}
/// <inheritdoc />
public bool IsTokenActive(string userName, string jti)
{
if (string.IsNullOrWhiteSpace(userName) || string.IsNullOrWhiteSpace(jti))
{
return false;
}
return _cache.TryGetValue(GetKey(userName), out OnlineUserInfo? info)
&& info is not null
&& string.Equals(info.Jti, jti, StringComparison.Ordinal);
}
/// <inheritdoc />
public void SignOut(string userName)
{
_cache.Remove(GetKey(userName));
_onlineIndex.TryRemove(userName, out _);
}
/// <inheritdoc />
public IReadOnlyList<OnlineUserInfo> GetOnlineUsers()
{
DateTime now = DateTime.Now;
List<OnlineUserInfo> result = [];
foreach (KeyValuePair<string, OnlineUserInfo> pair in _onlineIndex)
{
if (pair.Value.ExpiresAt > now)
{
result.Add(pair.Value);
}
else
{
_onlineIndex.TryRemove(pair.Key, out _);
}
}
return result;
}
/// <inheritdoc />
public int Count => GetOnlineUsers().Count;
private static string GetKey(string userName) => $"{CacheKeyPrefix}{userName}";
}
+129
View File
@@ -0,0 +1,129 @@
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using System.Security.Claims;
using System.Text;
namespace MAX.Framework.Security;
/// <summary>
/// 基于 <see cref="JwtOptions"/> 的 JWT 令牌服务。
/// </summary>
public class TokenService : ITokenService
{
private readonly JwtOptions _options;
private readonly IOnlineUserStore _onlineUserStore;
public TokenService(IOptions<JwtOptions> options, IOnlineUserStore onlineUserStore)
{
_options = options.Value;
_onlineUserStore = onlineUserStore;
}
/// <inheritdoc />
public TokenResult CreateToken(IEnumerable<Claim> claims)
{
ArgumentNullException.ThrowIfNull(claims);
DateTime now = DateTime.Now;
DateTime expires = now.AddMinutes(_options.ExpiresMinutes);
string jti = Guid.NewGuid().ToString("N");
List<Claim> allClaims = [.. claims, new Claim(JwtClaimTypes.Jti, jti)];
SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.SigningKey));
JsonWebTokenHandler handler = new();
string token = handler.CreateToken(new SecurityTokenDescriptor
{
Issuer = _options.Issuer,
Audience = _options.Audience,
Claims = ToClaimDictionary(allClaims),
NotBefore = now,
Expires = expires,
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256),
});
// 注册在线会话(单点登录:同用户的新登录会顶替旧会话,旧令牌立即失效)
RegisterSession(allClaims, jti, now, expires);
return new TokenResult
{
Token = token,
ExpiresInMinutes = (uint)_options.ExpiresMinutes,
};
}
/// <inheritdoc />
public async Task<ClaimsPrincipal?> ValidateTokenAsync(string token)
{
if (string.IsNullOrWhiteSpace(token))
{
return null;
}
try
{
JsonWebTokenHandler handler = new();
TokenValidationResult result = await handler.ValidateTokenAsync(token, _options.ToTokenValidationParameters());
if (!result.IsValid)
{
return null;
}
ClaimsPrincipal principal = new(result.ClaimsIdentity);
// 单点登录校验:会话必须仍然有效(未被退出、未被新登录顶替)
string? userName = principal.FindFirst(JwtClaimTypes.UserName)?.Value;
string? jti = principal.FindFirst(JwtClaimTypes.Jti)?.Value;
if (string.IsNullOrWhiteSpace(userName) || string.IsNullOrWhiteSpace(jti) || !_onlineUserStore.IsTokenActive(userName, jti))
{
return null;
}
return principal;
}
catch
{
return null;
}
}
/// <summary>
/// 将令牌声明注册为在线会话。
/// </summary>
private void RegisterSession(IEnumerable<Claim> claims, string jti, DateTime loginTime, DateTime expiresAt)
{
string? userName = claims.FirstOrDefault(c => c.Type == JwtClaimTypes.UserName)?.Value;
if (string.IsNullOrWhiteSpace(userName))
{
return; // 未携带登录账户的令牌不纳入在线会话管理
}
_onlineUserStore.SignIn(new OnlineUserInfo
{
UserName = userName,
NickName = claims.FirstOrDefault(c => c.Type == JwtClaimTypes.NickName)?.Value ?? string.Empty,
Jti = jti,
LoginTime = loginTime,
ExpiresAt = expiresAt,
});
}
/// <summary>
/// 将声明集合转换为 <see cref="SecurityTokenDescriptor.Claims"/> 所需的字典;
/// 相同类型的多个声明合并为数组。
/// </summary>
private static Dictionary<string, object> ToClaimDictionary(IEnumerable<Claim> claims)
{
Dictionary<string, object> result = new(StringComparer.Ordinal);
foreach (IGrouping<string, Claim> group in claims.GroupBy(c => c.Type, StringComparer.Ordinal))
{
object[] values = group.Select(c => (object)c.Value).ToArray();
result[group.Key] = values.Length == 1 ? values[0] : values;
}
return result;
}
}
+24
View File
@@ -0,0 +1,24 @@
using FreeSql;
using MAX.Core.DependencyInjection;
using MAX.Core.Interfaces;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace MAX.Framework
{
public class Startup : IStartup
{
public int Priority => IntPriority.Highest;
public void Initialize()
{
}
public void Register(IServiceRegistrar registrar)
{
}
}
}