添加项目文件。
This commit is contained in:
1 parent
6a017c9547
commit
58abbb5b4f
86 files changed
+8729
No files matched your search
@@ -0,0 +1,30 @@
|
||||
namespace StoreHub.Framework.Commons
|
||||
{
|
||||
public class ApiResult<T>
|
||||
{
|
||||
public int Code { get; set; }
|
||||
|
||||
public T? Data { get; set; }
|
||||
|
||||
public string? Msg { get; set; }
|
||||
}
|
||||
|
||||
public static class ApiResult
|
||||
{
|
||||
public static ApiResult<T> Success<T>(T data, string? msg = null)
|
||||
=> new()
|
||||
{
|
||||
Code = 200,
|
||||
Data = data,
|
||||
Msg = msg ?? "success"
|
||||
};
|
||||
|
||||
public static ApiResult<object?> Error(string msg, int code = 500)
|
||||
=> new()
|
||||
{
|
||||
Code = code,
|
||||
Data = null,
|
||||
Msg = msg
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace StoreHub.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 Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.OpenApi;
|
||||
using StoreHub.Framework.Security;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
|
||||
namespace StoreHub.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="StoreHub.Framework.Commons.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? userIdValue = principal?.FindFirst(JwtClaimTypes.UserId)?.Value;
|
||||
string? jti = principal?.FindFirst(JwtClaimTypes.Jti)?.Value;
|
||||
|
||||
if (!int.TryParse(userIdValue, out int userId)
|
||||
|| string.IsNullOrWhiteSpace(jti)
|
||||
|| !onlineUserStore.IsTokenActive(userId, 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,122 @@
|
||||
using MAX.Core.DependencyInjection;
|
||||
using MAX.Core.Infrastructure;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
|
||||
namespace StoreHub.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);
|
||||
|
||||
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 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 StoreHub.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 StoreHub.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 ["StoreHub"];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using System.Reflection;
|
||||
|
||||
namespace StoreHub.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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using MAX.Core.Commons;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
|
||||
namespace StoreHub.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
|
||||
}
|
||||
@@ -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 StoreHub.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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using FreeSql.DataAnnotations;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace StoreHub.Framework.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户实体
|
||||
/// </summary>
|
||||
//[Table(Name = "user")]
|
||||
public class User
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户ID,主键,自增
|
||||
/// </summary>
|
||||
[Column(Name = "id", IsPrimary = true, IsIdentity = true)]
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户昵称
|
||||
/// </summary>
|
||||
[Column(Name = "nick_name", DbType = "varchar(50)")]
|
||||
public string NickName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户名称(登录账户)
|
||||
/// </summary>
|
||||
[Column(Name = "user_name", DbType = "varchar(20)")]
|
||||
public string UserName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 登录密码
|
||||
/// </summary>
|
||||
[Column(Name = "password", DbType = "varchar(32)")]
|
||||
public string Password { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 身份证号
|
||||
/// </summary>
|
||||
[Column(Name = "card_id", DbType = "varchar(50)")]
|
||||
public string CardId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户分组:3主号 4客服 5加盟 8登录 9采购 10加盟 11上货,默认10
|
||||
/// </summary>
|
||||
[Column(Name = "group_id")]
|
||||
public byte GroupId { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// 父账户用户名
|
||||
/// </summary>
|
||||
[Column(Name = "parent_user_name", DbType = "varchar(20)")]
|
||||
public string ParentUserName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// OEM编号,仅OEM用户有值
|
||||
/// </summary>
|
||||
[Column(Name = "oem_id", DbType = "varchar(20)")]
|
||||
public string OemId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 手机号
|
||||
/// </summary>
|
||||
[Column(Name = "mobile", DbType = "varchar(20)")]
|
||||
public string Mobile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 添加时间
|
||||
/// </summary>
|
||||
[Column(Name = "add_time")]
|
||||
public DateTime? AddTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 修改时间
|
||||
/// </summary>
|
||||
[Column(Name = "mdy_time")]
|
||||
public DateTime? MdyTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 公司名称
|
||||
/// </summary>
|
||||
[Column(Name = "company_name", DbType = "varchar(50)")]
|
||||
public string CompanyName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注
|
||||
/// </summary>
|
||||
[Column(Name = "notes", DbType = "varchar(50)")]
|
||||
public string Notes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 超管等级:0普通用户 1超级管理员,默认0
|
||||
/// </summary>
|
||||
[Column(Name = "sa_level")]
|
||||
public short SaLevel { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// 账户权限
|
||||
/// </summary>
|
||||
[Column(Name = "authority", DbType = "varchar(255)")]
|
||||
public string Authority { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 子账号被禁止展示的功能页面
|
||||
/// </summary>
|
||||
[Column(Name = "forbidden_page", DbType = "varchar(500)")]
|
||||
public string ForbiddenPage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 账户积分,1积分=1元,默认0.00
|
||||
/// </summary>
|
||||
[Column(Name = "points", DbType = "decimal(10,2)")]
|
||||
public decimal Points { get; set; } = 0.00m;
|
||||
|
||||
/// <summary>
|
||||
/// 积分历史累计充值金额
|
||||
/// </summary>
|
||||
[Column(Name = "recharge_points_total", DbType = "decimal(10,2)")]
|
||||
public decimal? RechargePointsTotal { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 店铺每续费一个月需要的积分(单位:元),默认30
|
||||
/// </summary>
|
||||
[Column(Name = "shop_points_per_month")]
|
||||
public int ShopPointsPerMonth { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// 可用余额,默认0.00
|
||||
/// </summary>
|
||||
[Column(Name = "balance", DbType = "decimal(10,2)")]
|
||||
public decimal? Balance { get; set; } = 0.00m;
|
||||
|
||||
/// <summary>
|
||||
/// 累计充值金额,默认0.00
|
||||
/// </summary>
|
||||
[Column(Name = "recharge_amount_total", DbType = "decimal(10,2)")]
|
||||
public decimal? RechargeAmountTotal { get; set; } = 0.00m;
|
||||
|
||||
/// <summary>
|
||||
/// 每单中转仓快递费用(单位:元,两位小数),默认2.20
|
||||
/// </summary>
|
||||
[Column(Name = "postage_per_order", DbType = "decimal(10,2)")]
|
||||
public decimal? PostagePerOrder { get; set; } = 2.20m;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using FreeSql;
|
||||
using MAX.Core.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StoreHub.Framework.Models;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace StoreHub.Framework.Repositorys
|
||||
{
|
||||
public interface IUserRepository : IBaseRepository<User>
|
||||
{
|
||||
|
||||
}
|
||||
public class SqlRepository : WxRepository<User>, IUserRepository, IScopedDependency
|
||||
{
|
||||
public SqlRepository([FromKeyedServices("KJDDB")] IFreeSql fsql) : base(fsql)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
public abstract class WxRepository<TEntity> : BaseRepository<TEntity>
|
||||
where TEntity : class
|
||||
{
|
||||
protected WxRepository(IFreeSql fsql)
|
||||
: base(fsql)
|
||||
{
|
||||
fsql.CodeFirst.ConfigEntity<TEntity>(a =>
|
||||
{
|
||||
a.Name($"wxxd.{typeof(TEntity).Name}");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System.Globalization;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace StoreHub.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 int? Id
|
||||
{
|
||||
get
|
||||
{
|
||||
string? value = GetClaimValue(JwtClaimTypes.UserId);
|
||||
return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int id) ? id : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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,44 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace StoreHub.Framework.Security;
|
||||
|
||||
/// <summary>
|
||||
/// 当前登录用户访问器:从当前请求的认证票据中读取用户信息。
|
||||
/// </summary>
|
||||
public interface ICurrentUser
|
||||
{
|
||||
/// <summary>
|
||||
/// 当前请求是否已认证
|
||||
/// </summary>
|
||||
bool IsAuthenticated { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前用户ID(未登录为 null)
|
||||
/// </summary>
|
||||
int? Id { 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 StoreHub.Framework.Security;
|
||||
|
||||
/// <summary>
|
||||
/// 在线用户会话存储:基于 <see cref="Microsoft.Extensions.Caching.Memory.IMemoryCache"/> 的进程内实现,
|
||||
/// 用于单点登录(同一用户仅保留最新会话)、在线人员管理与退出销毁。
|
||||
/// </summary>
|
||||
public interface IOnlineUserStore
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户登录/刷新会话:同用户的新登录会覆盖旧会话,旧令牌立即失效(单点登录)。
|
||||
/// </summary>
|
||||
void SignIn(OnlineUserInfo info);
|
||||
|
||||
/// <summary>
|
||||
/// 判断指定用户、指定令牌会话是否仍然有效(未被退出、未被新登录顶替)。
|
||||
/// </summary>
|
||||
bool IsTokenActive(int userId, string jti);
|
||||
|
||||
/// <summary>
|
||||
/// 退出登录:销毁该用户的在线会话,令牌立即失效。
|
||||
/// </summary>
|
||||
void SignOut(int userId);
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前在线人员列表。
|
||||
/// </summary>
|
||||
IReadOnlyList<OnlineUserInfo> GetOnlineUsers();
|
||||
|
||||
/// <summary>
|
||||
/// 当前在线人数。
|
||||
/// </summary>
|
||||
int Count { get; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace StoreHub.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 DateTime ExpiresAt { 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 Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StoreHub.Framework.Commons;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StoreHub.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.Error(message, statusCode), JsonOptions);
|
||||
await httpContext.Response.WriteAsync(body, Encoding.UTF8);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace StoreHub.Framework.Security;
|
||||
|
||||
/// <summary>
|
||||
/// JWT 自定义声明类型常量。
|
||||
/// </summary>
|
||||
public static class JwtClaimTypes
|
||||
{
|
||||
/// <summary>用户ID</summary>
|
||||
public const string UserId = "uid";
|
||||
|
||||
/// <summary>登录账户</summary>
|
||||
public const string UserName = "user_name";
|
||||
|
||||
/// <summary>用户昵称</summary>
|
||||
public const string NickName = "nick_name";
|
||||
|
||||
/// <summary>令牌唯一标识(jti,用于单点登录/退出校验)</summary>
|
||||
public const string Jti = "jti";
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.Text;
|
||||
|
||||
namespace StoreHub.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,26 @@
|
||||
namespace StoreHub.Framework.Security;
|
||||
|
||||
/// <summary>
|
||||
/// 在线用户会话信息(单点登录:同一用户仅保留一个有效会话)。
|
||||
/// </summary>
|
||||
public class OnlineUserInfo
|
||||
{
|
||||
/// <summary>用户ID</summary>
|
||||
public int UserId { get; set; }
|
||||
|
||||
/// <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 StoreHub.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<int, OnlineUserInfo> _onlineIndex = new();
|
||||
|
||||
public OnlineUserStore(IMemoryCache cache)
|
||||
{
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SignIn(OnlineUserInfo info)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(info);
|
||||
|
||||
// 以 userId 为键覆盖写入:旧会话(jti)被顶替,实现单点登录
|
||||
_cache.Set(GetKey(info.UserId), info, new MemoryCacheEntryOptions
|
||||
{
|
||||
// 缓存条目与令牌同时过期,过期后自动失效
|
||||
AbsoluteExpiration = info.ExpiresAt,
|
||||
});
|
||||
_onlineIndex[info.UserId] = info;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsTokenActive(int userId, string jti)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(jti))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return _cache.TryGetValue(GetKey(userId), out OnlineUserInfo? info)
|
||||
&& info is not null
|
||||
&& string.Equals(info.Jti, jti, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SignOut(int userId)
|
||||
{
|
||||
_cache.Remove(GetKey(userId));
|
||||
_onlineIndex.TryRemove(userId, out _);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<OnlineUserInfo> GetOnlineUsers()
|
||||
{
|
||||
DateTime now = DateTime.Now;
|
||||
List<OnlineUserInfo> result = [];
|
||||
foreach (KeyValuePair<int, 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(int userId) => $"{CacheKeyPrefix}{userId}";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.JsonWebTokens;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
|
||||
namespace StoreHub.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,
|
||||
ExpiresAt = expires,
|
||||
};
|
||||
}
|
||||
|
||||
/// <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? userIdValue = principal.FindFirst(JwtClaimTypes.UserId)?.Value;
|
||||
string? jti = principal.FindFirst(JwtClaimTypes.Jti)?.Value;
|
||||
if (!int.TryParse(userIdValue, out int userId) || string.IsNullOrWhiteSpace(jti) || !_onlineUserStore.IsTokenActive(userId, jti))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return principal;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将令牌声明注册为在线会话。
|
||||
/// </summary>
|
||||
private void RegisterSession(IEnumerable<Claim> claims, string jti, DateTime loginTime, DateTime expiresAt)
|
||||
{
|
||||
string? userIdValue = claims.FirstOrDefault(c => c.Type == JwtClaimTypes.UserId)?.Value;
|
||||
if (!int.TryParse(userIdValue, out int userId))
|
||||
{
|
||||
return; // 未携带用户ID的令牌不纳入在线会话管理
|
||||
}
|
||||
|
||||
_onlineUserStore.SignIn(new OnlineUserInfo
|
||||
{
|
||||
UserId = userId,
|
||||
UserName = claims.FirstOrDefault(c => c.Type == JwtClaimTypes.UserName)?.Value ?? string.Empty,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using FreeSql;
|
||||
using MAX.Core.DependencyInjection;
|
||||
using MAX.Core.Infrastructure;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StoreHub.Framework.Repositorys;
|
||||
|
||||
namespace StoreHub.Framework
|
||||
{
|
||||
public class Startup : IStartup
|
||||
{
|
||||
public int Priority => int.MaxValue - 1;
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public void Register(IServiceRegistrar registrar)
|
||||
{
|
||||
Func<IServiceProvider, IFreeSql> userFsqlFactory = r =>
|
||||
{
|
||||
var config = r.GetRequiredService<IConfiguration>();
|
||||
var connection = config.GetConnectionString("UserConnection");
|
||||
IFreeSql fsql = new FreeSql.FreeSqlBuilder()
|
||||
.UseConnectionString(FreeSql.DataType.MySql, connection)
|
||||
.UseAdoConnectionPool(true)
|
||||
.Build();
|
||||
return fsql;
|
||||
};
|
||||
registrar.RegisterSingleton<IFreeSql>("UserDB", (s, k) =>
|
||||
{
|
||||
return userFsqlFactory(s);
|
||||
});
|
||||
|
||||
|
||||
Func<IServiceProvider, IFreeSql> KJDFsqlFactory = r =>
|
||||
{
|
||||
var config = r.GetRequiredService<IConfiguration>();
|
||||
var connection = config.GetConnectionString("KJDConnection");
|
||||
IFreeSql fsql = new FreeSql.FreeSqlBuilder()
|
||||
.UseConnectionString(FreeSql.DataType.MySql, connection)
|
||||
.UseAdoConnectionPool(true)
|
||||
.Build();
|
||||
return fsql;
|
||||
};
|
||||
registrar.RegisterSingleton<IFreeSql>("KJDDB", (s, k) =>
|
||||
{
|
||||
return KJDFsqlFactory(s);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Core\MAX.Core\MAX.Core.csproj" />
|
||||
</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" Version="6.1.4" />
|
||||
<PackageReference Include="Riok.Mapperly" Version="4.3.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in new issue
Block a user