mirror of
				https://github.com/nsnail/NetAdmin.git
				synced 2025-11-04 21:20:50 +08:00 
			
		
		
		
	feat: ✨ 移除RedLocker,更改为自实现 (#169)
用户表增加最后登录时间字段 列表查询多字段模糊查询改为单字段精确查询 WebSocket版本更新检查 前端自定义字段筛选 暗黑模式样式调整 [skip ci] Co-authored-by: tk <fiyne1a@dingtalk.com>
This commit is contained in:
		@@ -3,11 +3,9 @@ using NetAdmin.AdmServer.Host.Extensions;
 | 
			
		||||
using NetAdmin.Host.Extensions;
 | 
			
		||||
using NetAdmin.Host.Middlewares;
 | 
			
		||||
using NetAdmin.SysComponent.Host.Extensions;
 | 
			
		||||
using NetAdmin.SysComponent.Host.Middlewares;
 | 
			
		||||
using Spectre.Console.Cli;
 | 
			
		||||
using ValidationResult = Spectre.Console.ValidationResult;
 | 
			
		||||
#if !DEBUG
 | 
			
		||||
using Prometheus;
 | 
			
		||||
#endif
 | 
			
		||||
 | 
			
		||||
NetAdmin.Host.Startup.Entry<Startup>(args);
 | 
			
		||||
 | 
			
		||||
@@ -36,7 +34,7 @@ namespace NetAdmin.AdmServer.Host
 | 
			
		||||
                .UseOpenApiSkin() // 使用OpenApiSkin中间件(仅在调试模式下),提供Swagger UI皮肤
 | 
			
		||||
                #else
 | 
			
		||||
                .UseVueAdmin()    // 托管管理后台,仅在非调试模式下
 | 
			
		||||
                .UseHttpMetrics() // 使用HttpMetrics中间件,启用HTTP性能监控
 | 
			
		||||
                .UsePrometheus()  // 使用Prometheus中间件,启用HTTP性能监控
 | 
			
		||||
                #endif
 | 
			
		||||
                .UseInject(string.Empty)                   // 使用Inject中间件,Furion脚手架的依赖注入支持
 | 
			
		||||
                .UseUnifyResultStatusCodes()               // 使用UnifyResultStatusCodes中间件,用于统一处理结果状态码
 | 
			
		||||
@@ -45,6 +43,8 @@ namespace NetAdmin.AdmServer.Host
 | 
			
		||||
                .UseAuthentication()                       // 使用Authentication中间件,启用身份验证
 | 
			
		||||
                .UseAuthorization()                        // 使用Authorization中间件,启用授权
 | 
			
		||||
                .UseMiddleware<RemoveNullNodeMiddleware>() // 使用RemoveNullNodeMiddleware中间件,删除JSON中的空节点
 | 
			
		||||
                .UseWebSockets()                           // 使用WebSockets中间件,启用WebSocket支持
 | 
			
		||||
                .UseMiddleware<VersionCheckerMiddleware>() // 使用VersionUpdaterMiddleware中间件,用于检查版本
 | 
			
		||||
                .UseEndpoints();                           // 配置端点以处理请求
 | 
			
		||||
            _ = lifeTime.ApplicationStopping.Register(SafetyShopHostMiddleware.OnStopping);
 | 
			
		||||
        }
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,6 @@
 | 
			
		||||
        <ProjectReference Include="../NetAdmin.AdmServer.Host/NetAdmin.AdmServer.Host.csproj"/>
 | 
			
		||||
    </ItemGroup>
 | 
			
		||||
    <ItemGroup>
 | 
			
		||||
        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.0-release-24352-06"/>
 | 
			
		||||
        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.0-release-24373-02"/>
 | 
			
		||||
    </ItemGroup>
 | 
			
		||||
</Project>
 | 
			
		||||
@@ -1,50 +0,0 @@
 | 
			
		||||
using NetAdmin.Application.Repositories;
 | 
			
		||||
using RedLockNet;
 | 
			
		||||
 | 
			
		||||
namespace NetAdmin.Application.Services;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
///     RedLocker Service Base
 | 
			
		||||
/// </summary>
 | 
			
		||||
public abstract class RedLockerService<TEntity, TPrimary, TLogger>(
 | 
			
		||||
    BasicRepository<TEntity, TPrimary> rpo
 | 
			
		||||
  , RedLocker                          redLocker) : RepositoryService<TEntity, TPrimary, TLogger>(rpo)
 | 
			
		||||
    where TEntity : EntityBase<TPrimary> //
 | 
			
		||||
    where TPrimary : IEquatable<TPrimary>
 | 
			
		||||
{
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    ///     获取锁
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    protected Task<IRedLock> GetLockerAsync(string lockName)
 | 
			
		||||
    {
 | 
			
		||||
        return GetLockerAsync(lockName, TimeSpan.FromSeconds(Numbers.SECS_RED_LOCK_WAIT)
 | 
			
		||||
                  ,                     TimeSpan.FromSeconds(Numbers.SECS_RED_LOCK_RETRY_INTERVAL));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    ///     获取锁
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <exception cref="NetAdminGetLockerException">NetAdminGetLockerException</exception>
 | 
			
		||||
    protected async Task<IRedLock> GetLockerAsync(string lockName, TimeSpan waitTime, TimeSpan retryInterval)
 | 
			
		||||
    {
 | 
			
		||||
        // 加锁
 | 
			
		||||
        var lockTask = waitTime == default || retryInterval == default
 | 
			
		||||
            ? redLocker.RedLockFactory.CreateLockAsync(lockName, TimeSpan.FromSeconds(Numbers.SECS_RED_LOCK_EXPIRY))
 | 
			
		||||
            : redLocker.RedLockFactory.CreateLockAsync( //
 | 
			
		||||
                lockName, TimeSpan.FromSeconds(Numbers.SECS_RED_LOCK_EXPIRY), waitTime, retryInterval);
 | 
			
		||||
        var redLock = await lockTask.ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        return redLock.IsAcquired ? redLock : throw new NetAdminGetLockerException();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    ///     获取锁
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <remarks>
 | 
			
		||||
    ///     不重试,失败直接抛出异常
 | 
			
		||||
    /// </remarks>
 | 
			
		||||
    protected Task<IRedLock> GetLockerOnceAsync(string lockName)
 | 
			
		||||
    {
 | 
			
		||||
        return GetLockerAsync(lockName, default, default);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										47
									
								
								src/backend/NetAdmin.Application/Services/RedisService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								src/backend/NetAdmin.Application/Services/RedisService.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,47 @@
 | 
			
		||||
using NetAdmin.Application.Repositories;
 | 
			
		||||
using StackExchange.Redis;
 | 
			
		||||
 | 
			
		||||
namespace NetAdmin.Application.Services;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
///     Redis Service Base
 | 
			
		||||
/// </summary>
 | 
			
		||||
/// <remarks>
 | 
			
		||||
///     Initializes a new instance of the <see cref="RedisService{TEntity, TPrimary, TLogger}" /> class.
 | 
			
		||||
///     Redis Service Base
 | 
			
		||||
/// </remarks>
 | 
			
		||||
public abstract class RedisService<TEntity, TPrimary, TLogger>(BasicRepository<TEntity, TPrimary> rpo)
 | 
			
		||||
    : RepositoryService<TEntity, TPrimary, TLogger>(rpo)
 | 
			
		||||
    where TEntity : EntityBase<TPrimary> //
 | 
			
		||||
    where TPrimary : IEquatable<TPrimary>
 | 
			
		||||
{
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    ///     Redis Database
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    protected IDatabase RedisDatabase { get; } //
 | 
			
		||||
        = App.GetService<IConnectionMultiplexer>()
 | 
			
		||||
             .GetDatabase(App.GetOptions<RedisOptions>()
 | 
			
		||||
                             .Instances.First(x => x.Name == Chars.FLG_REDIS_INSTANCE_DATA_CACHE)
 | 
			
		||||
                             .Database);
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    ///     获取锁
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    protected Task<RedisLocker> GetLockerAsync(string lockerName)
 | 
			
		||||
    {
 | 
			
		||||
        return RedisLocker.GetLockerAsync(RedisDatabase, lockerName
 | 
			
		||||
                                        , TimeSpan.FromSeconds(Numbers.SECS_REDIS_LOCK_EXPIRY)
 | 
			
		||||
                                        , Numbers.MAX_LIMIT_RETRY_CNT_REDIS_LOCK
 | 
			
		||||
                                        , TimeSpan.FromSeconds(Numbers.SECS_REDIS_LOCK_RETRY_DELAY));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    ///     获取锁(仅获取一次)
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    protected Task<RedisLocker> GetLockerOnceAsync(string lockerName)
 | 
			
		||||
    {
 | 
			
		||||
        return RedisLocker.GetLockerAsync(RedisDatabase, lockerName
 | 
			
		||||
                                        , TimeSpan.FromSeconds(Numbers.SECS_REDIS_LOCK_EXPIRY), 1
 | 
			
		||||
                                        , TimeSpan.FromSeconds(Numbers.SECS_REDIS_LOCK_RETRY_DELAY));
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -35,7 +35,7 @@ public abstract class RepositoryService<TEntity, TPrimary, TLogger>(BasicReposit
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    protected async Task<IActionResult> ExportAsync<TQuery, TExport>( //
 | 
			
		||||
        Func<QueryReq<TQuery>, ISelectGrouping<TEntity, TEntity>> selector, QueryReq<TQuery> query, string fileName
 | 
			
		||||
      , Expression<Func<ISelectGroupingAggregate<TEntity, TEntity>, object>> listExp)
 | 
			
		||||
      , Expression<Func<ISelectGroupingAggregate<TEntity, TEntity>, object>> listExp = null)
 | 
			
		||||
        where TQuery : DataAbstraction, new()
 | 
			
		||||
    {
 | 
			
		||||
        var list = await selector(query).Take(Numbers.MAX_LIMIT_EXPORT).ToListAsync(listExp).ConfigureAwait(false);
 | 
			
		||||
@@ -95,6 +95,7 @@ public abstract class RepositoryService<TEntity, TPrimary, TLogger>(BasicReposit
 | 
			
		||||
    /// <param name="includeFields">包含的属性</param>
 | 
			
		||||
    /// <param name="excludeFields">排除的属性</param>
 | 
			
		||||
    /// <param name="whereExp">查询表达式</param>
 | 
			
		||||
    /// <param name="whereSql">查询sql</param>
 | 
			
		||||
    /// <param name="ignoreVersion">是否忽略版本锁</param>
 | 
			
		||||
    /// <returns>更新后的实体列表</returns>
 | 
			
		||||
    protected Task<List<TEntity>> UpdateReturnListAsync(     //
 | 
			
		||||
@@ -102,11 +103,15 @@ public abstract class RepositoryService<TEntity, TPrimary, TLogger>(BasicReposit
 | 
			
		||||
      , IEnumerable<string>             includeFields        //
 | 
			
		||||
      , string[]                        excludeFields = null //
 | 
			
		||||
      , Expression<Func<TEntity, bool>> whereExp = null //
 | 
			
		||||
      , string                          whereSql = null //
 | 
			
		||||
      , bool                            ignoreVersion = false)
 | 
			
		||||
    {
 | 
			
		||||
        // 默认匹配主键
 | 
			
		||||
        whereExp ??= a => a.Id.Equals(newValue.Id);
 | 
			
		||||
        return BuildUpdate(newValue, includeFields, excludeFields, ignoreVersion).Where(whereExp).ExecuteUpdatedAsync();
 | 
			
		||||
        return BuildUpdate(newValue, includeFields, excludeFields, ignoreVersion)
 | 
			
		||||
               .Where(whereExp)
 | 
			
		||||
               .Where(whereSql)
 | 
			
		||||
               .ExecuteUpdatedAsync();
 | 
			
		||||
    }
 | 
			
		||||
    #endif
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,7 @@
 | 
			
		||||
namespace NetAdmin.Domain.Attributes;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
///     危险字段标记
 | 
			
		||||
/// </summary>
 | 
			
		||||
[AttributeUsage(AttributeTargets.Property)]
 | 
			
		||||
public sealed class DangerFieldAttribute : Attribute;
 | 
			
		||||
@@ -51,6 +51,14 @@ public record Sys_User : VersionEntity, IFieldSummary, IFieldEnabled, IRegister
 | 
			
		||||
    [JsonIgnore]
 | 
			
		||||
    public virtual bool Enabled { get; init; }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    ///     最后登录时间
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    [Column]
 | 
			
		||||
    [CsvIgnore]
 | 
			
		||||
    [JsonIgnore]
 | 
			
		||||
    public virtual DateTime? LastLoginTime { get; init; }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    ///     手机号码
 | 
			
		||||
    /// </summary>
 | 
			
		||||
@@ -64,6 +72,7 @@ public record Sys_User : VersionEntity, IFieldSummary, IFieldEnabled, IRegister
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    [Column]
 | 
			
		||||
    [CsvIgnore]
 | 
			
		||||
    [DangerField]
 | 
			
		||||
    [JsonIgnore]
 | 
			
		||||
    public Guid Password { get; init; }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -36,4 +36,37 @@ public record QueryReq<T> : DataAbstraction
 | 
			
		||||
    ///     排序字段
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public string Prop { get; init; }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    ///     所需字段
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public string[] RequiredFields { get; set; }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    ///     列表表达式
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public Expression<Func<TEntity, TEntity>> GetToListExp<TEntity>()
 | 
			
		||||
    {
 | 
			
		||||
        if (RequiredFields.NullOrEmpty()) {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var expParameter = Expression.Parameter(typeof(TEntity), "a");
 | 
			
		||||
        var bindings     = new List<MemberBinding>();
 | 
			
		||||
 | 
			
		||||
        // ReSharper disable once LoopCanBeConvertedToQuery
 | 
			
		||||
        foreach (var field in RequiredFields) {
 | 
			
		||||
            var prop = typeof(TEntity).GetProperty(field);
 | 
			
		||||
            if (prop == null || prop.GetCustomAttribute<DangerFieldAttribute>() != null) {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var propExp = Expression.Property(expParameter, prop);
 | 
			
		||||
            var binding = Expression.Bind(prop, propExp);
 | 
			
		||||
            bindings.Add(binding);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var expBody = Expression.MemberInit(Expression.New(typeof(TEntity)), bindings);
 | 
			
		||||
        return Expression.Lambda<Func<TEntity, TEntity>>(expBody, expParameter);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -3,7 +3,7 @@ namespace NetAdmin.Domain.Dto.Sys.Api;
 | 
			
		||||
/// <summary>
 | 
			
		||||
///     响应:导出接口
 | 
			
		||||
/// </summary>
 | 
			
		||||
public record ExportApiRsp : QueryApiRsp
 | 
			
		||||
public sealed record ExportApiRsp : QueryApiRsp
 | 
			
		||||
{
 | 
			
		||||
    /// <inheritdoc />
 | 
			
		||||
    [CsvIgnore]
 | 
			
		||||
 
 | 
			
		||||
@@ -6,7 +6,7 @@ namespace NetAdmin.Domain.Dto.Sys.Config;
 | 
			
		||||
/// <summary>
 | 
			
		||||
///     响应:导出配置
 | 
			
		||||
/// </summary>
 | 
			
		||||
public record ExportConfigRsp : QueryConfigRsp, IRegister
 | 
			
		||||
public sealed record ExportConfigRsp : QueryConfigRsp, IRegister
 | 
			
		||||
{
 | 
			
		||||
    /// <inheritdoc />
 | 
			
		||||
    [CsvIndex(6)]
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,7 @@ namespace NetAdmin.Domain.Dto.Sys.Dept;
 | 
			
		||||
/// <summary>
 | 
			
		||||
///     响应:导出部门
 | 
			
		||||
/// </summary>
 | 
			
		||||
public record ExportDeptRsp : QueryDeptRsp
 | 
			
		||||
public sealed record ExportDeptRsp : QueryDeptRsp
 | 
			
		||||
{
 | 
			
		||||
    /// <inheritdoc />
 | 
			
		||||
    [CsvIgnore]
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,7 @@ namespace NetAdmin.Domain.Dto.Sys.Dic.Content;
 | 
			
		||||
/// <summary>
 | 
			
		||||
///     响应:导出字典内容
 | 
			
		||||
/// </summary>
 | 
			
		||||
public record ExportDicContentRsp : QueryDicContentRsp
 | 
			
		||||
public sealed record ExportDicContentRsp : QueryDicContentRsp
 | 
			
		||||
{
 | 
			
		||||
    /// <inheritdoc />
 | 
			
		||||
    [CsvIndex(2)]
 | 
			
		||||
 
 | 
			
		||||
@@ -7,7 +7,7 @@ namespace NetAdmin.Domain.Dto.Sys.Job;
 | 
			
		||||
/// <summary>
 | 
			
		||||
///     响应:导出计划作业
 | 
			
		||||
/// </summary>
 | 
			
		||||
public record ExportJobRsp : QueryJobRsp
 | 
			
		||||
public sealed record ExportJobRsp : QueryJobRsp
 | 
			
		||||
{
 | 
			
		||||
    /// <inheritdoc />
 | 
			
		||||
    [CsvIndex(5)]
 | 
			
		||||
 
 | 
			
		||||
@@ -3,4 +3,4 @@ namespace NetAdmin.Domain.Dto.Sys.JobRecord;
 | 
			
		||||
/// <summary>
 | 
			
		||||
///     请求:创建计划作业执行记录
 | 
			
		||||
/// </summary>
 | 
			
		||||
public record CreateJobRecordReq : Sys_JobRecord;
 | 
			
		||||
public sealed record CreateJobRecordReq : Sys_JobRecord;
 | 
			
		||||
@@ -5,7 +5,7 @@ namespace NetAdmin.Domain.Dto.Sys.JobRecord;
 | 
			
		||||
/// <summary>
 | 
			
		||||
///     响应:导出计划作业执行记录
 | 
			
		||||
/// </summary>
 | 
			
		||||
public record ExportJobRecordRsp : QueryJobRecordRsp, IRegister
 | 
			
		||||
public sealed record ExportJobRecordRsp : QueryJobRecordRsp, IRegister
 | 
			
		||||
{
 | 
			
		||||
    /// <inheritdoc />
 | 
			
		||||
    [CsvIndex(1)]
 | 
			
		||||
 
 | 
			
		||||
@@ -7,7 +7,7 @@ namespace NetAdmin.Domain.Dto.Sys.LoginLog;
 | 
			
		||||
/// <summary>
 | 
			
		||||
///     请求:创建登录日志
 | 
			
		||||
/// </summary>
 | 
			
		||||
public record CreateLoginLogReq : Sys_LoginLog, IRegister
 | 
			
		||||
public sealed record CreateLoginLogReq : Sys_LoginLog, IRegister
 | 
			
		||||
{
 | 
			
		||||
    /// <inheritdoc />
 | 
			
		||||
    public void Register(TypeAdapterConfig config)
 | 
			
		||||
 
 | 
			
		||||
@@ -8,7 +8,7 @@ namespace NetAdmin.Domain.Dto.Sys.RequestLog;
 | 
			
		||||
/// <summary>
 | 
			
		||||
///     响应:导出请求日志
 | 
			
		||||
/// </summary>
 | 
			
		||||
public record ExportRequestLogRsp : QueryRequestLogRsp
 | 
			
		||||
public sealed record ExportRequestLogRsp : QueryRequestLogRsp
 | 
			
		||||
{
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    ///     接口路径
 | 
			
		||||
 
 | 
			
		||||
@@ -3,4 +3,4 @@ namespace NetAdmin.Domain.Dto.Sys.RequestLogDetail;
 | 
			
		||||
/// <summary>
 | 
			
		||||
///     请求:创建请求日志明细
 | 
			
		||||
/// </summary>
 | 
			
		||||
public record CreateRequestLogDetailReq : Sys_RequestLogDetail;
 | 
			
		||||
public sealed record CreateRequestLogDetailReq : Sys_RequestLogDetail;
 | 
			
		||||
@@ -8,7 +8,7 @@ namespace NetAdmin.Domain.Dto.Sys.SiteMsg;
 | 
			
		||||
/// <summary>
 | 
			
		||||
///     响应:导出站内信
 | 
			
		||||
/// </summary>
 | 
			
		||||
public record ExportSiteMsgRsp : QuerySiteMsgRsp
 | 
			
		||||
public sealed record ExportSiteMsgRsp : QuerySiteMsgRsp
 | 
			
		||||
{
 | 
			
		||||
    /// <inheritdoc />
 | 
			
		||||
    [CsvIndex(5)]
 | 
			
		||||
 
 | 
			
		||||
@@ -3,4 +3,4 @@ namespace NetAdmin.Domain.Dto.Sys.SiteMsgDept;
 | 
			
		||||
/// <summary>
 | 
			
		||||
///     请求:创建站内信-部门映射
 | 
			
		||||
/// </summary>
 | 
			
		||||
public record CreateSiteMsgDeptReq : Sys_SiteMsgDept;
 | 
			
		||||
public sealed record CreateSiteMsgDeptReq : Sys_SiteMsgDept;
 | 
			
		||||
@@ -3,4 +3,4 @@ namespace NetAdmin.Domain.Dto.Sys.SiteMsgRole;
 | 
			
		||||
/// <summary>
 | 
			
		||||
///     请求:创建站内信-角色映射
 | 
			
		||||
/// </summary>
 | 
			
		||||
public record CreateSiteMsgRoleReq : Sys_SiteMsgRole;
 | 
			
		||||
public sealed record CreateSiteMsgRoleReq : Sys_SiteMsgRole;
 | 
			
		||||
@@ -3,4 +3,4 @@ namespace NetAdmin.Domain.Dto.Sys.SiteMsgUser;
 | 
			
		||||
/// <summary>
 | 
			
		||||
///     请求:创建站内信-用户映射
 | 
			
		||||
/// </summary>
 | 
			
		||||
public record CreateSiteMsgUserReq : Sys_SiteMsgUser;
 | 
			
		||||
public sealed record CreateSiteMsgUserReq : Sys_SiteMsgUser;
 | 
			
		||||
@@ -6,7 +6,7 @@ namespace NetAdmin.Domain.Dto.Sys.User;
 | 
			
		||||
/// <summary>
 | 
			
		||||
///     响应:导出用户
 | 
			
		||||
/// </summary>
 | 
			
		||||
public record ExportUserRsp : QueryUserRsp
 | 
			
		||||
public sealed record ExportUserRsp : QueryUserRsp
 | 
			
		||||
{
 | 
			
		||||
    /// <inheritdoc />
 | 
			
		||||
    [CsvIndex(7)]
 | 
			
		||||
@@ -43,6 +43,12 @@ public record ExportUserRsp : QueryUserRsp
 | 
			
		||||
    [CsvName(nameof(Ln.唯一编码))]
 | 
			
		||||
    public override long Id { get; init; }
 | 
			
		||||
 | 
			
		||||
    /// <inheritdoc />
 | 
			
		||||
    [CsvIndex(8)]
 | 
			
		||||
    [CsvIgnore(false)]
 | 
			
		||||
    [CsvName(nameof(Ln.最后登录时间))]
 | 
			
		||||
    public override DateTime? LastLoginTime { get; init; }
 | 
			
		||||
 | 
			
		||||
    /// <inheritdoc />
 | 
			
		||||
    [CsvIndex(2)]
 | 
			
		||||
    [CsvIgnore(false)]
 | 
			
		||||
 
 | 
			
		||||
@@ -31,6 +31,10 @@ public record QueryUserRsp : Sys_User
 | 
			
		||||
    [JsonIgnore(Condition = JsonIgnoreCondition.Never)]
 | 
			
		||||
    public override long Id { get; init; }
 | 
			
		||||
 | 
			
		||||
    /// <inheritdoc cref="Sys_User.LastLoginTime" />
 | 
			
		||||
    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
 | 
			
		||||
    public override DateTime? LastLoginTime { get; init; }
 | 
			
		||||
 | 
			
		||||
    /// <inheritdoc cref="Sys_User.Mobile" />
 | 
			
		||||
    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
 | 
			
		||||
    public override string Mobile { get; init; }
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,7 @@ namespace NetAdmin.Domain.Dto.Sys.UserProfile;
 | 
			
		||||
/// <summary>
 | 
			
		||||
///     请求:设置当前用户应用配置
 | 
			
		||||
/// </summary>
 | 
			
		||||
public record SetSessionUserAppConfigReq : Sys_UserProfile
 | 
			
		||||
public sealed record SetSessionUserAppConfigReq : Sys_UserProfile
 | 
			
		||||
{
 | 
			
		||||
    /// <inheritdoc cref="Sys_UserProfile.AppConfig" />
 | 
			
		||||
    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
 | 
			
		||||
 
 | 
			
		||||
@@ -13,7 +13,6 @@
 | 
			
		||||
        <PackageReference Include="CronExpressionDescriptor" Version="2.36.0"/>
 | 
			
		||||
        <PackageReference Include="Cronos" Version="0.8.4"/>
 | 
			
		||||
        <PackageReference Include="CsvHelper.NS" Version="33.0.2-ns2"/>
 | 
			
		||||
        <PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="9.0.0-preview.6.24328.4"/>
 | 
			
		||||
        <PackageReference Include="Yitter.IdGenerator" Version="1.0.14"/>
 | 
			
		||||
    </ItemGroup>
 | 
			
		||||
</Project>
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
using RedLockNet;
 | 
			
		||||
using StackExchange.Redis;
 | 
			
		||||
 | 
			
		||||
namespace NetAdmin.Host.BackgroundRunning;
 | 
			
		||||
 | 
			
		||||
@@ -7,8 +7,6 @@ namespace NetAdmin.Host.BackgroundRunning;
 | 
			
		||||
/// </summary>
 | 
			
		||||
public abstract class WorkBase<TLogger>
 | 
			
		||||
{
 | 
			
		||||
    private readonly RedLocker _redLocker;
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    ///     Initializes a new instance of the <see cref="WorkBase{TLogger}" /> class.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
@@ -17,7 +15,6 @@ public abstract class WorkBase<TLogger>
 | 
			
		||||
        ServiceProvider = App.GetService<IServiceScopeFactory>().CreateScope().ServiceProvider;
 | 
			
		||||
        UowManager      = ServiceProvider.GetService<UnitOfWorkManager>();
 | 
			
		||||
        Logger          = ServiceProvider.GetService<ILogger<TLogger>>();
 | 
			
		||||
        _redLocker      = ServiceProvider.GetService<RedLocker>();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
@@ -53,10 +50,8 @@ public abstract class WorkBase<TLogger>
 | 
			
		||||
    {
 | 
			
		||||
        if (singleInstance) {
 | 
			
		||||
            // 加锁
 | 
			
		||||
            await using var redLock = await GetLockerAsync(GetType().FullName).ConfigureAwait(false);
 | 
			
		||||
            if (!redLock.IsAcquired) {
 | 
			
		||||
                throw new NetAdminGetLockerException();
 | 
			
		||||
            }
 | 
			
		||||
            var             lockName    = GetType().FullName;
 | 
			
		||||
            await using var redisLocker = await GetLockerAsync(lockName).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
            await WorkflowAsync(cancelToken).ConfigureAwait(false);
 | 
			
		||||
            return;
 | 
			
		||||
@@ -68,10 +63,15 @@ public abstract class WorkBase<TLogger>
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    ///     获取锁
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    private Task<IRedLock> GetLockerAsync(string lockId)
 | 
			
		||||
    private Task<RedisLocker> GetLockerAsync(string lockId)
 | 
			
		||||
    {
 | 
			
		||||
        return _redLocker.RedLockFactory.CreateLockAsync(lockId, TimeSpan.FromSeconds(Numbers.SECS_RED_LOCK_EXPIRY)
 | 
			
		||||
                                                       , TimeSpan.FromSeconds(Numbers.SECS_RED_LOCK_WAIT)
 | 
			
		||||
                                                       , TimeSpan.FromSeconds(Numbers.SECS_RED_LOCK_RETRY_INTERVAL));
 | 
			
		||||
        var db = ServiceProvider.GetService<IConnectionMultiplexer>()
 | 
			
		||||
                                .GetDatabase(ServiceProvider.GetService<IOptions<RedisOptions>>()
 | 
			
		||||
                                                            .Value.Instances
 | 
			
		||||
                                                            .First(x => x.Name == Chars.FLG_REDIS_INSTANCE_DATA_CACHE)
 | 
			
		||||
                                                            .Database);
 | 
			
		||||
        return RedisLocker.GetLockerAsync(db, lockId, TimeSpan.FromSeconds(Numbers.SECS_REDIS_LOCK_EXPIRY)
 | 
			
		||||
                                        , Numbers.MAX_LIMIT_RETRY_CNT_REDIS_LOCK
 | 
			
		||||
                                        , TimeSpan.FromSeconds(Numbers.SECS_REDIS_LOCK_RETRY_DELAY));
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -3,6 +3,7 @@ using IGeekFan.AspNetCore.Knife4jUI;
 | 
			
		||||
 | 
			
		||||
#else
 | 
			
		||||
using Prometheus;
 | 
			
		||||
using Prometheus.HttpMetrics;
 | 
			
		||||
#endif
 | 
			
		||||
 | 
			
		||||
namespace NetAdmin.Host.Extensions;
 | 
			
		||||
@@ -40,5 +41,22 @@ public static class IApplicationBuilderExtensions
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
    #else
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    ///     使用 Prometheus
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public static IApplicationBuilder UsePrometheus(this IApplicationBuilder me)
 | 
			
		||||
    {
 | 
			
		||||
        return me.UseHttpMetrics(opt => {
 | 
			
		||||
            opt.RequestDuration.Histogram = Metrics.CreateHistogram( //
 | 
			
		||||
                "http_request_duration_seconds"
 | 
			
		||||
              , "The duration of HTTP requests processed by an ASP.NET Core application."
 | 
			
		||||
              , HttpRequestLabelNames.All
 | 
			
		||||
              , new HistogramConfiguration {
 | 
			
		||||
                                               Buckets = Histogram.PowersOfTenDividedBuckets(
 | 
			
		||||
                                                   -2, 2, 4)
 | 
			
		||||
                                           });
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
    #endif
 | 
			
		||||
}
 | 
			
		||||
@@ -28,6 +28,7 @@ public sealed class RequestLogger(ILogger<RequestLogger> logger, IEventPublisher
 | 
			
		||||
          , x => context.Request.ContentType?.Contains(x, StringComparison.OrdinalIgnoreCase) ?? false)
 | 
			
		||||
            ? await context.ReadBodyContentAsync().ConfigureAwait(false)
 | 
			
		||||
            : string.Empty;
 | 
			
		||||
        var apiId = context.Request.Path.Value!.TrimStart('/');
 | 
			
		||||
        var auditData = new CreateRequestLogReq //
 | 
			
		||||
                        {
 | 
			
		||||
                            Detail = new CreateRequestLogDetailReq //
 | 
			
		||||
@@ -47,7 +48,7 @@ public sealed class RequestLogger(ILogger<RequestLogger> logger, IEventPublisher
 | 
			
		||||
                                     }
 | 
			
		||||
                          , Duration        = (int)duration
 | 
			
		||||
                          , HttpMethod      = Enum.Parse<HttpMethods>(context.Request.Method, true)
 | 
			
		||||
                          , ApiPathCrc32    = context.Request.Path.Value!.TrimStart('/').Crc32()
 | 
			
		||||
                          , ApiPathCrc32    = apiId.Crc32()
 | 
			
		||||
                          , HttpStatusCode  = context.Response.StatusCode
 | 
			
		||||
                          , CreatedClientIp = context.GetRealIpAddress()?.MapToIPv4().ToString().IpV4ToInt32()
 | 
			
		||||
                          , OwnerId         = associatedUser?.UserId
 | 
			
		||||
@@ -56,11 +57,14 @@ public sealed class RequestLogger(ILogger<RequestLogger> logger, IEventPublisher
 | 
			
		||||
                          , TraceId         = context.GetTraceId()
 | 
			
		||||
                        };
 | 
			
		||||
 | 
			
		||||
        // 打印日志
 | 
			
		||||
        logger.Info(auditData);
 | 
			
		||||
        // ReSharper disable once InvertIf
 | 
			
		||||
        if (!GlobalStatic.LoggerIgnoreApiIds.Contains(apiId)) {
 | 
			
		||||
            // 打印日志
 | 
			
		||||
            logger.Info(auditData);
 | 
			
		||||
 | 
			
		||||
        // 发布请求日志事件
 | 
			
		||||
        await eventPublisher.PublishAsync(new RequestLogEvent(auditData)).ConfigureAwait(false);
 | 
			
		||||
            // 发布请求日志事件
 | 
			
		||||
            await eventPublisher.PublishAsync(new RequestLogEvent(auditData)).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return auditData;
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -16,20 +16,20 @@ public static class Numbers
 | 
			
		||||
    public const int  HTTP_STATUS_BIZ_FAIL    = 900;             // Http状态码-业务异常
 | 
			
		||||
    public const long ID_DIC_CATALOG_GEO_AREA = 379794295185413; // 唯一编号:字典目录-行政区划字典
 | 
			
		||||
 | 
			
		||||
    public const int MAX_LIMIT_BULK_REQ          = 100;   // 最大限制:批量请求数
 | 
			
		||||
    public const int MAX_LIMIT_EXPORT            = 10000; // 最大限制:导出为CSV文件的条数
 | 
			
		||||
    public const int MAX_LIMIT_PRINT_LEN_CONTENT = 4096;  // 最大限制:打印长度(HTTP 内容)
 | 
			
		||||
    public const int MAX_LIMIT_PRINT_LEN_SQL     = 4096;  // 最大限制:打印长度(SQL 语句)
 | 
			
		||||
    public const int MAX_LIMIT_QUERY             = 1000;  // 最大限制:非分页查询条数
 | 
			
		||||
    public const int MAX_LIMIT_QUERY_PAGE_NO     = 10000; // 最大限制:分页查询页码
 | 
			
		||||
    public const int MAX_LIMIT_QUERY_PAGE_SIZE   = 100;   // 最大限制:分页查询页容量
 | 
			
		||||
    public const int MAX_LIMIT_BULK_REQ             = 100;   // 最大限制:批量请求数
 | 
			
		||||
    public const int MAX_LIMIT_EXPORT               = 10000; // 最大限制:导出为CSV文件的条数
 | 
			
		||||
    public const int MAX_LIMIT_PRINT_LEN_CONTENT    = 4096;  // 最大限制:打印长度(HTTP 内容)
 | 
			
		||||
    public const int MAX_LIMIT_PRINT_LEN_SQL        = 4096;  // 最大限制:打印长度(SQL 语句)
 | 
			
		||||
    public const int MAX_LIMIT_QUERY                = 1000;  // 最大限制:非分页查询条数
 | 
			
		||||
    public const int MAX_LIMIT_QUERY_PAGE_NO        = 10000; // 最大限制:分页查询页码
 | 
			
		||||
    public const int MAX_LIMIT_QUERY_PAGE_SIZE      = 100;   // 最大限制:分页查询页容量
 | 
			
		||||
    public const int MAX_LIMIT_RETRY_CNT_REDIS_LOCK = 10;    // 最大限制:Redis锁重试次数
 | 
			
		||||
 | 
			
		||||
    public const int SECS_CACHE_CHART             = 300; // 秒:缓存时间-仪表
 | 
			
		||||
    public const int SECS_CACHE_DEFAULT           = 60;  // 秒:缓存时间-默认
 | 
			
		||||
    public const int SECS_CACHE_DIC_CATALOG_CODE  = 300; // 秒:缓存时间-字典配置-目录代码
 | 
			
		||||
    public const int SECS_RED_LOCK_EXPIRY         = 30;  // 秒:RedLock-锁过期时间,假如持有锁的进程挂掉,最多在此时间内锁将被释放(如持有锁的进程正常,此值不会生效)
 | 
			
		||||
    public const int SECS_RED_LOCK_RETRY_INTERVAL = 1;   // 秒:RedLock-锁等待时间内,多久尝试获取一次
 | 
			
		||||
    public const int SECS_RED_LOCK_WAIT           = 10;  // 秒:RedLock-锁等待时间,相同的 resource 如果当前的锁被其他线程占用,最多等待时间
 | 
			
		||||
    public const int SECS_TIMEOUT_HTTP_CLIENT     = 15;  // 秒:超时时间-默认HTTP客户端
 | 
			
		||||
    public const int SECS_TIMEOUT_JOB             = 600; // 秒:超时时间-作业
 | 
			
		||||
    public const int SECS_CACHE_CHART            = 300; // 秒:缓存时间-仪表
 | 
			
		||||
    public const int SECS_CACHE_DEFAULT          = 60;  // 秒:缓存时间-默认
 | 
			
		||||
    public const int SECS_CACHE_DIC_CATALOG_CODE = 300; // 秒:缓存时间-字典配置-目录代码
 | 
			
		||||
    public const int SECS_REDIS_LOCK_EXPIRY      = 60;  // 秒:Redis锁过期时间
 | 
			
		||||
    public const int SECS_REDIS_LOCK_RETRY_DELAY = 1;   // 秒:Redis锁重试间隔
 | 
			
		||||
    public const int SECS_TIMEOUT_HTTP_CLIENT    = 15;  // 秒:超时时间-默认HTTP客户端
 | 
			
		||||
    public const int SECS_TIMEOUT_JOB            = 600; // 秒:超时时间-作业
 | 
			
		||||
}
 | 
			
		||||
@@ -7,5 +7,5 @@ namespace NetAdmin.Infrastructure.Exceptions;
 | 
			
		||||
///     并发执行时锁竞争失败
 | 
			
		||||
/// </remarks>
 | 
			
		||||
#pragma warning disable RCS1194
 | 
			
		||||
public sealed class NetAdminGetLockerException() : NetAdminInvalidOperationException(null) { }
 | 
			
		||||
public sealed class NetAdminGetLockerException(string message = null) : NetAdminInvalidOperationException(message) { }
 | 
			
		||||
#pragma warning restore RCS1194
 | 
			
		||||
@@ -24,6 +24,11 @@ public static class GlobalStatic
 | 
			
		||||
    #endif
 | 
			
		||||
    ;
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    ///     日志记录器忽略的API编号
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public static string[] LoggerIgnoreApiIds => [];
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    ///     系统内部密钥
 | 
			
		||||
    /// </summary>
 | 
			
		||||
 
 | 
			
		||||
@@ -8,13 +8,13 @@
 | 
			
		||||
    <ItemGroup>
 | 
			
		||||
        <PackageReference Include="FreeSql.DbContext.NS" Version="3.2.833-preview20260627-ns1"/>
 | 
			
		||||
        <PackageReference Include="FreeSql.Provider.Sqlite.NS" Version="3.2.833-preview20260627-ns1"/>
 | 
			
		||||
        <PackageReference Include="Furion.Extras.Authentication.JwtBearer" Version="4.9.4.6"/>
 | 
			
		||||
        <PackageReference Include="Furion.Extras.ObjectMapper.Mapster.NS" Version="4.9.4.6-ns4"/>
 | 
			
		||||
        <PackageReference Include="Furion.Pure.NS" Version="4.9.4.6-ns4"/>
 | 
			
		||||
        <PackageReference Include="Furion.Extras.Authentication.JwtBearer" Version="4.9.5.2"/>
 | 
			
		||||
        <PackageReference Include="Furion.Extras.ObjectMapper.Mapster.NS" Version="4.9.5.2-ns1"/>
 | 
			
		||||
        <PackageReference Include="Furion.Pure.NS" Version="4.9.5.2-ns1"/>
 | 
			
		||||
        <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="4.10.0"/>
 | 
			
		||||
        <PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="9.0.0-preview.6.24328.4"/>
 | 
			
		||||
        <PackageReference Include="Minio" Version="6.0.3"/>
 | 
			
		||||
        <PackageReference Include="NSExt" Version="2.2.0"/>
 | 
			
		||||
        <PackageReference Include="RedLock.net" Version="2.3.2"/>
 | 
			
		||||
        <PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.4"/>
 | 
			
		||||
    </ItemGroup>
 | 
			
		||||
    <ItemGroup>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,98 +0,0 @@
 | 
			
		||||
using RedLockNet.SERedis;
 | 
			
		||||
using RedLockNet.SERedis.Configuration;
 | 
			
		||||
using StackExchange.Redis;
 | 
			
		||||
 | 
			
		||||
namespace NetAdmin.Infrastructure.Utils;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
///     Redis 分布锁
 | 
			
		||||
/// </summary>
 | 
			
		||||
#pragma warning disable DesignedForInheritance
 | 
			
		||||
public class RedLocker : IDisposable, ISingleton
 | 
			
		||||
#pragma warning restore DesignedForInheritance
 | 
			
		||||
{
 | 
			
		||||
    // Track whether Dispose has been called.
 | 
			
		||||
    private bool _disposed;
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    ///     Initializes a new instance of the <see cref="RedLocker" /> class.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public RedLocker(IOptions<RedisOptions> redisOptions)
 | 
			
		||||
    {
 | 
			
		||||
        RedLockFactory = RedLockFactory.Create( //
 | 
			
		||||
            new List<RedLockMultiplexer>        //
 | 
			
		||||
            {
 | 
			
		||||
                ConnectionMultiplexer.Connect( //
 | 
			
		||||
                    redisOptions.Value.Instances.First(x => x.Name == Chars.FLG_REDIS_INSTANCE_DATA_CACHE).ConnStr)
 | 
			
		||||
            });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    ///     Finalizes an instance of the <see cref="RedLocker" /> class.
 | 
			
		||||
    ///     Use C# finalizer syntax for finalization code.
 | 
			
		||||
    ///     This finalizer will run only if the Dispose method
 | 
			
		||||
    ///     does not get called.
 | 
			
		||||
    ///     It gives your base class the opportunity to finalize.
 | 
			
		||||
    ///     Do not provide finalizer in types derived from this class.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    ~RedLocker()
 | 
			
		||||
    {
 | 
			
		||||
        // Do not re-create Dispose clean-up code here.
 | 
			
		||||
        // Calling Dispose(disposing: false) is optimal in terms of
 | 
			
		||||
        // readability and maintainability.
 | 
			
		||||
        Dispose(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    ///     RedLockFactory
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public RedLockFactory RedLockFactory { get; }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    ///     Implement IDisposable.
 | 
			
		||||
    ///     Do not make this method virtual.
 | 
			
		||||
    ///     A derived class should not be able to override this method.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public void Dispose()
 | 
			
		||||
    {
 | 
			
		||||
        Dispose(true);
 | 
			
		||||
 | 
			
		||||
        // This object will be cleaned up by the Dispose method.
 | 
			
		||||
        // Therefore, you should call GC.SuppressFinalize to
 | 
			
		||||
        // take this object off the finalization queue
 | 
			
		||||
        // and prevent finalization code for this object
 | 
			
		||||
        // from executing a second time.
 | 
			
		||||
        GC.SuppressFinalize(this);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    ///     Dispose(bool disposing) executes in two distinct scenarios.
 | 
			
		||||
    ///     If disposing equals true, the method has been called directly
 | 
			
		||||
    ///     or indirectly by a user's code. Managed and unmanaged resources
 | 
			
		||||
    ///     can be disposed.
 | 
			
		||||
    ///     If disposing equals false, the method has been called by the
 | 
			
		||||
    ///     runtime from inside the finalizer and you should not reference
 | 
			
		||||
    ///     other objects. Only unmanaged resources can be disposed.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    protected virtual void Dispose(bool disposing)
 | 
			
		||||
    {
 | 
			
		||||
        // Check to see if Dispose has already been called.
 | 
			
		||||
        if (_disposed) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // If disposing equals true, dispose all managed
 | 
			
		||||
        // and unmanaged resources.
 | 
			
		||||
        if (disposing) {
 | 
			
		||||
            RedLockFactory.Dispose();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Call the appropriate methods to clean up
 | 
			
		||||
        // unmanaged resources here.
 | 
			
		||||
        // If disposing is false,
 | 
			
		||||
        // only the following code is executed.
 | 
			
		||||
 | 
			
		||||
        // Note disposing has been done.
 | 
			
		||||
        _disposed = true;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										70
									
								
								src/backend/NetAdmin.Infrastructure/Utils/RedisLocker.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								src/backend/NetAdmin.Infrastructure/Utils/RedisLocker.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,70 @@
 | 
			
		||||
using StackExchange.Redis;
 | 
			
		||||
 | 
			
		||||
namespace NetAdmin.Infrastructure.Utils;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
///     Redis 分布锁
 | 
			
		||||
/// </summary>
 | 
			
		||||
/// <remarks>
 | 
			
		||||
///     Initializes a new instance of the <see cref="RedisLocker" /> class.
 | 
			
		||||
/// </remarks>
 | 
			
		||||
#pragma warning disable DesignedForInheritance
 | 
			
		||||
public sealed class RedisLocker : IAsyncDisposable
 | 
			
		||||
#pragma warning restore DesignedForInheritance
 | 
			
		||||
{
 | 
			
		||||
    private readonly IDatabase _redisDatabase;
 | 
			
		||||
 | 
			
		||||
    private readonly string _redisKey;
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    ///     Initializes a new instance of the <see cref="RedisLocker" /> class.
 | 
			
		||||
    ///     Redis 分布锁
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    private RedisLocker(IDatabase redisDatabase, string redisKey)
 | 
			
		||||
    {
 | 
			
		||||
        _redisDatabase = redisDatabase;
 | 
			
		||||
        _redisKey      = redisKey;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    ///     获取锁
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <exception cref="NetAdminGetLockerException">NetAdminGetLockerException</exception>
 | 
			
		||||
    public static async Task<RedisLocker> GetLockerAsync(IDatabase redisDatabase, string lockerName
 | 
			
		||||
                                                       , TimeSpan  lockerExpire,  int retryCount, TimeSpan retryDelay)
 | 
			
		||||
    {
 | 
			
		||||
        lockerName = $"{nameof(RedisLocker)}.{lockerName}";
 | 
			
		||||
        var setOk = false;
 | 
			
		||||
        for (var i = 0; i != retryCount; ++i) {
 | 
			
		||||
            try {
 | 
			
		||||
                setOk = await redisDatabase
 | 
			
		||||
                              .StringSetAsync(lockerName, RedisValue.EmptyString, lockerExpire, When.NotExists
 | 
			
		||||
                                ,                         CommandFlags.DemandMaster)
 | 
			
		||||
                              .ConfigureAwait(false);
 | 
			
		||||
            }
 | 
			
		||||
            catch (Exception ex) {
 | 
			
		||||
                LogHelper.Get<RedisLocker>().Error(ex.Message);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (setOk) {
 | 
			
		||||
                return new RedisLocker(redisDatabase, lockerName);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            await Task.Delay(retryDelay).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        throw new NetAdminGetLockerException(lockerName);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <inheritdoc />
 | 
			
		||||
    public async ValueTask DisposeAsync()
 | 
			
		||||
    {
 | 
			
		||||
        try {
 | 
			
		||||
            _ = await _redisDatabase.KeyDeleteAsync(_redisKey, CommandFlags.DemandMaster | CommandFlags.FireAndForget)
 | 
			
		||||
                                    .ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex) {
 | 
			
		||||
            LogHelper.Get<RedisLocker>().Error(ex.Message);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -8,11 +8,10 @@ namespace NetAdmin.SysComponent.Application.Services.Sys;
 | 
			
		||||
 | 
			
		||||
/// <inheritdoc cref="IApiService" />
 | 
			
		||||
public sealed class ApiService(
 | 
			
		||||
    BasicRepository<Sys_Api, string>    rpo              //
 | 
			
		||||
  , XmlCommentReader                    xmlCommentReader //
 | 
			
		||||
  , IActionDescriptorCollectionProvider actionDescriptorCollectionProvider
 | 
			
		||||
  , RedLocker                           redLocker) //
 | 
			
		||||
    : RedLockerService<Sys_Api, string, IApiService>(rpo, redLocker), IApiService
 | 
			
		||||
    BasicRepository<Sys_Api, string>    rpo                                 //
 | 
			
		||||
  , XmlCommentReader                    xmlCommentReader                    //
 | 
			
		||||
  , IActionDescriptorCollectionProvider actionDescriptorCollectionProvider) //
 | 
			
		||||
    : RedisService<Sys_Api, string, IApiService>(rpo), IApiService
 | 
			
		||||
{
 | 
			
		||||
    /// <inheritdoc />
 | 
			
		||||
    public Task<int> BulkDeleteAsync(BulkReq<DelReq> req)
 | 
			
		||||
 
 | 
			
		||||
@@ -18,19 +18,23 @@ public sealed class UserService(
 | 
			
		||||
  , IEventPublisher                 eventPublisher)    //
 | 
			
		||||
    : RepositoryService<Sys_User, long, IUserService>(rpo), IUserService
 | 
			
		||||
{
 | 
			
		||||
    private readonly Expression<Func<Sys_User, Sys_User>> _selectUserFields = a => new Sys_User {
 | 
			
		||||
        Id          = a.Id
 | 
			
		||||
      , Avatar      = a.Avatar
 | 
			
		||||
      , Email       = a.Email
 | 
			
		||||
      , Mobile      = a.Mobile
 | 
			
		||||
      , Enabled     = a.Enabled
 | 
			
		||||
      , UserName    = a.UserName
 | 
			
		||||
      , Summary     = a.Summary
 | 
			
		||||
      , Version     = a.Version
 | 
			
		||||
      , CreatedTime = a.CreatedTime
 | 
			
		||||
      , Dept        = new Sys_Dept { Id = a.Dept.Id, Name = a.Dept.Name }
 | 
			
		||||
      , Roles       = a.Roles
 | 
			
		||||
    };
 | 
			
		||||
    private readonly Expression<Func<Sys_User, Sys_User>> _listUserExp = a => new Sys_User {
 | 
			
		||||
                                                                                  Id            = a.Id
 | 
			
		||||
                                                                                , Avatar        = a.Avatar
 | 
			
		||||
                                                                                , Email         = a.Email
 | 
			
		||||
                                                                                , Mobile        = a.Mobile
 | 
			
		||||
                                                                                , Enabled       = a.Enabled
 | 
			
		||||
                                                                                , UserName      = a.UserName
 | 
			
		||||
                                                                                , Summary       = a.Summary
 | 
			
		||||
                                                                                , Version       = a.Version
 | 
			
		||||
                                                                                , CreatedTime   = a.CreatedTime
 | 
			
		||||
                                                                                , LastLoginTime = a.LastLoginTime
 | 
			
		||||
                                                                                , Dept = new Sys_Dept {
 | 
			
		||||
                                                                                      Id   = a.Dept.Id
 | 
			
		||||
                                                                                    , Name = a.Dept.Name
 | 
			
		||||
                                                                                  }
 | 
			
		||||
                                                                                , Roles = a.Roles
 | 
			
		||||
                                                                              };
 | 
			
		||||
 | 
			
		||||
    /// <inheritdoc />
 | 
			
		||||
    public async Task<int> BulkDeleteAsync(BulkReq<DelReq> req)
 | 
			
		||||
@@ -232,14 +236,15 @@ public sealed class UserService(
 | 
			
		||||
    public async Task<PagedQueryRsp<QueryUserRsp>> PagedQueryAsync(PagedQueryReq<QueryUserReq> req)
 | 
			
		||||
    {
 | 
			
		||||
        req.ThrowIfInvalid();
 | 
			
		||||
        var list = await (await QueryInternalAsync(req).ConfigureAwait(false)).Page(req.Page, req.PageSize)
 | 
			
		||||
                                                                              #if DBTYPE_SQLSERVER
 | 
			
		||||
                                                                              .WithLock(SqlServerLock.NoLock |
 | 
			
		||||
                                                                                  SqlServerLock.NoWait)
 | 
			
		||||
                                                                              #endif
 | 
			
		||||
                                                                              .Count(out var total)
 | 
			
		||||
                                                                              .ToListAsync(_selectUserFields)
 | 
			
		||||
                                                                              .ConfigureAwait(false);
 | 
			
		||||
        var listUserExp = req.GetToListExp<Sys_User>() ?? _listUserExp;
 | 
			
		||||
        var select      = await QueryInternalAsync(req, listUserExp == _listUserExp).ConfigureAwait(false);
 | 
			
		||||
        var list = await select.Page(req.Page, req.PageSize)
 | 
			
		||||
                               #if DBTYPE_SQLSERVER
 | 
			
		||||
                               .WithLock(SqlServerLock.NoLock | SqlServerLock.NoWait)
 | 
			
		||||
                               #endif
 | 
			
		||||
                               .Count(out var total)
 | 
			
		||||
                               .ToListAsync(listUserExp)
 | 
			
		||||
                               .ConfigureAwait(false);
 | 
			
		||||
        return new PagedQueryRsp<QueryUserRsp>(req.Page, req.PageSize, total, list.Adapt<IEnumerable<QueryUserRsp>>());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -248,7 +253,7 @@ public sealed class UserService(
 | 
			
		||||
    {
 | 
			
		||||
        req.ThrowIfInvalid();
 | 
			
		||||
        var list = await (await QueryInternalAsync(req).ConfigureAwait(false)).Take(req.Count)
 | 
			
		||||
                                                                              .ToListAsync(_selectUserFields)
 | 
			
		||||
                                                                              .ToListAsync(_listUserExp)
 | 
			
		||||
                                                                              .ConfigureAwait(false);
 | 
			
		||||
        return list.Adapt<IEnumerable<QueryUserRsp>>();
 | 
			
		||||
    }
 | 
			
		||||
@@ -437,22 +442,6 @@ public sealed class UserService(
 | 
			
		||||
        return dbUser.Adapt<UserInfoRsp>();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static LoginRsp LoginInternal(Sys_User dbUser)
 | 
			
		||||
    {
 | 
			
		||||
        if (!dbUser.Enabled) {
 | 
			
		||||
            throw new NetAdminInvalidOperationException(Ln.请联系管理员激活账号);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var tokenPayload
 | 
			
		||||
            = new Dictionary<string, object> { { nameof(ContextUserToken), dbUser.Adapt<ContextUserToken>() } };
 | 
			
		||||
 | 
			
		||||
        var accessToken = JWTEncryption.Encrypt(tokenPayload);
 | 
			
		||||
        return new LoginRsp {
 | 
			
		||||
                                AccessToken  = accessToken
 | 
			
		||||
                              , RefreshToken = JWTEncryption.GenerateRefreshToken(accessToken)
 | 
			
		||||
                            };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task<Dictionary<long, string>> CreateEditCheckAsync(CreateEditUserReq req)
 | 
			
		||||
    {
 | 
			
		||||
        // 检查角色是否存在
 | 
			
		||||
@@ -475,21 +464,44 @@ public sealed class UserService(
 | 
			
		||||
        return dept.Count != 1 ? throw new NetAdminInvalidOperationException(Ln.部门不存在) : roles;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private ISelect<Sys_User> QueryInternal(QueryReq<QueryUserReq> req, IEnumerable<long> deptIds)
 | 
			
		||||
    private LoginRsp LoginInternal(Sys_User dbUser)
 | 
			
		||||
    {
 | 
			
		||||
        var ret = Rpo.Select.Include(a => a.Dept)
 | 
			
		||||
                     .IncludeMany(a => a.Roles.Select(b => new Sys_Role { Id = b.Id, Name = b.Name }))
 | 
			
		||||
                     .WhereDynamicFilter(req.DynamicFilter)
 | 
			
		||||
                     .WhereIf(deptIds != null, a => deptIds.Contains(a.DeptId))
 | 
			
		||||
                     .WhereIf( //
 | 
			
		||||
                         req.Filter?.Id > 0, a => a.Id == req.Filter.Id)
 | 
			
		||||
                     .WhereIf( //
 | 
			
		||||
                         req.Filter?.RoleId > 0, a => a.Roles.Any(b => b.Id == req.Filter.RoleId))
 | 
			
		||||
                     .WhereIf( //
 | 
			
		||||
                         req.Keywords?.Length > 0
 | 
			
		||||
                       , a => a.Id     == req.Keywords.Int64Try(0) || a.UserName == req.Keywords ||
 | 
			
		||||
                              a.Mobile == req.Keywords             ||
 | 
			
		||||
                              a.Email  == req.Keywords             || a.Summary.Contains(req.Keywords));
 | 
			
		||||
        if (!dbUser.Enabled) {
 | 
			
		||||
            throw new NetAdminInvalidOperationException(Ln.请联系管理员激活账号);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        _ = UpdateAsync(dbUser with { LastLoginTime = DateTime.Now }, [nameof(Sys_User.LastLoginTime)]
 | 
			
		||||
,                                                                     ignoreVersion: true);
 | 
			
		||||
 | 
			
		||||
        var tokenPayload
 | 
			
		||||
            = new Dictionary<string, object> { { nameof(ContextUserToken), dbUser.Adapt<ContextUserToken>() } };
 | 
			
		||||
 | 
			
		||||
        var accessToken = JWTEncryption.Encrypt(tokenPayload);
 | 
			
		||||
        return new LoginRsp {
 | 
			
		||||
                                AccessToken  = accessToken
 | 
			
		||||
                              , RefreshToken = JWTEncryption.GenerateRefreshToken(accessToken)
 | 
			
		||||
                            };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private ISelect<Sys_User> QueryInternal(QueryReq<QueryUserReq> req, IEnumerable<long> deptIds
 | 
			
		||||
                                          , bool                   includeRoles = true)
 | 
			
		||||
    {
 | 
			
		||||
        var ret = Rpo.Select.Include(a => a.Dept);
 | 
			
		||||
        if (includeRoles) {
 | 
			
		||||
            ret = ret.IncludeMany(a => a.Roles.Select(b => new Sys_Role { Id = b.Id, Name = b.Name }));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ret = ret.WhereDynamicFilter(req.DynamicFilter)
 | 
			
		||||
                 .WhereIf(deptIds != null, a => deptIds.Contains(a.DeptId))
 | 
			
		||||
                 .WhereIf( //
 | 
			
		||||
                     req.Filter?.Id > 0, a => a.Id == req.Filter.Id)
 | 
			
		||||
                 .WhereIf( //
 | 
			
		||||
                     req.Filter?.RoleId > 0, a => a.Roles.Any(b => b.Id == req.Filter.RoleId))
 | 
			
		||||
                 .WhereIf( //
 | 
			
		||||
                     req.Keywords?.Length > 0
 | 
			
		||||
                   , a => a.Id     == req.Keywords.Int64Try(0) || a.UserName == req.Keywords ||
 | 
			
		||||
                          a.Mobile == req.Keywords             ||
 | 
			
		||||
                          a.Email  == req.Keywords             || a.Summary.Contains(req.Keywords));
 | 
			
		||||
 | 
			
		||||
        // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
 | 
			
		||||
        switch (req.Order) {
 | 
			
		||||
@@ -518,7 +530,7 @@ public sealed class UserService(
 | 
			
		||||
        return QueryInternal(req, deptIds);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task<ISelect<Sys_User>> QueryInternalAsync(QueryReq<QueryUserReq> req)
 | 
			
		||||
    private async Task<ISelect<Sys_User>> QueryInternalAsync(QueryReq<QueryUserReq> req, bool includeRoles = true)
 | 
			
		||||
    {
 | 
			
		||||
        IEnumerable<long> deptIds = null;
 | 
			
		||||
        if (req.Filter?.DeptId > 0) {
 | 
			
		||||
@@ -529,6 +541,6 @@ public sealed class UserService(
 | 
			
		||||
                               .ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return QueryInternal(req, deptIds);
 | 
			
		||||
        return QueryInternal(req, deptIds, includeRoles);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,49 @@
 | 
			
		||||
using System.Net.WebSockets;
 | 
			
		||||
using NetAdmin.SysComponent.Cache.Sys.Dependency;
 | 
			
		||||
 | 
			
		||||
namespace NetAdmin.SysComponent.Host.Middlewares;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
///     版本更新检查中间件
 | 
			
		||||
/// </summary>
 | 
			
		||||
public sealed class VersionCheckerMiddleware(RequestDelegate next)
 | 
			
		||||
{
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    ///     主函数
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public async Task InvokeAsync(HttpContext context)
 | 
			
		||||
    {
 | 
			
		||||
        if (context.Request.Path == "/ws/version") {
 | 
			
		||||
            if (context.WebSockets.IsWebSocketRequest) {
 | 
			
		||||
                var webSocket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false);
 | 
			
		||||
                await ConnectionAsync(webSocket).ConfigureAwait(false);
 | 
			
		||||
            }
 | 
			
		||||
            else {
 | 
			
		||||
                context.Response.StatusCode = 400;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        else {
 | 
			
		||||
            await next(context).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static async Task ConnectionAsync(WebSocket webSocket)
 | 
			
		||||
    {
 | 
			
		||||
        var buffer = new byte[1024];
 | 
			
		||||
        while (webSocket.State == WebSocketState.Open) {
 | 
			
		||||
            var receiveResult = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None)
 | 
			
		||||
                                               .ConfigureAwait(false);
 | 
			
		||||
            if (receiveResult.MessageType != WebSocketMessageType.Text) {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var ver = await App.GetService<IToolsCache>().GetVersionAsync().ConfigureAwait(false);
 | 
			
		||||
            await webSocket.SendAsync(new ArraySegment<byte>(Encoding.UTF8.GetBytes(ver)), WebSocketMessageType.Text
 | 
			
		||||
                                    , true, CancellationToken.None)
 | 
			
		||||
                           .ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None)
 | 
			
		||||
                       .ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -5,7 +5,7 @@
 | 
			
		||||
    <ItemGroup>
 | 
			
		||||
        <PackageReference Include="xunit" Version="2.9.0"/>
 | 
			
		||||
        <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0-preview.6.24328.4"/>
 | 
			
		||||
        <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
 | 
			
		||||
        <PackageReference Include="xunit.runner.visualstudio" Version="3.0.0-pre.24">
 | 
			
		||||
            <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
 | 
			
		||||
            <PrivateAssets>all</PrivateAssets>
 | 
			
		||||
        </PackageReference>
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user