feat: 移除RedLocker,更改为自实现 (#169)

用户表增加最后登录时间字段
列表查询多字段模糊查询改为单字段精确查询
WebSocket版本更新检查
前端自定义字段筛选
暗黑模式样式调整
[skip ci]

Co-authored-by: tk <fiyne1a@dingtalk.com>
This commit is contained in:
2024-08-12 11:44:14 +08:00
committed by GitHub
parent 4733adede5
commit cd8ed674e0
58 changed files with 585 additions and 320 deletions

View File

@ -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);
}

View File

@ -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>

View File

@ -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);
}
}

View 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));
}
}

View File

@ -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

View File

@ -0,0 +1,7 @@
namespace NetAdmin.Domain.Attributes;
/// <summary>
/// 危险字段标记
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public sealed class DangerFieldAttribute : Attribute;

View File

@ -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; }

View File

@ -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);
}
}

View File

@ -3,7 +3,7 @@ namespace NetAdmin.Domain.Dto.Sys.Api;
/// <summary>
/// 响应:导出接口
/// </summary>
public record ExportApiRsp : QueryApiRsp
public sealed record ExportApiRsp : QueryApiRsp
{
/// <inheritdoc />
[CsvIgnore]

View File

@ -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)]

View File

@ -3,7 +3,7 @@ namespace NetAdmin.Domain.Dto.Sys.Dept;
/// <summary>
/// 响应:导出部门
/// </summary>
public record ExportDeptRsp : QueryDeptRsp
public sealed record ExportDeptRsp : QueryDeptRsp
{
/// <inheritdoc />
[CsvIgnore]

View File

@ -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)]

View File

@ -7,7 +7,7 @@ namespace NetAdmin.Domain.Dto.Sys.Job;
/// <summary>
/// 响应:导出计划作业
/// </summary>
public record ExportJobRsp : QueryJobRsp
public sealed record ExportJobRsp : QueryJobRsp
{
/// <inheritdoc />
[CsvIndex(5)]

View File

@ -3,4 +3,4 @@ namespace NetAdmin.Domain.Dto.Sys.JobRecord;
/// <summary>
/// 请求:创建计划作业执行记录
/// </summary>
public record CreateJobRecordReq : Sys_JobRecord;
public sealed record CreateJobRecordReq : Sys_JobRecord;

View File

@ -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)]

View File

@ -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)

View File

@ -8,7 +8,7 @@ namespace NetAdmin.Domain.Dto.Sys.RequestLog;
/// <summary>
/// 响应:导出请求日志
/// </summary>
public record ExportRequestLogRsp : QueryRequestLogRsp
public sealed record ExportRequestLogRsp : QueryRequestLogRsp
{
/// <summary>
/// 接口路径

View File

@ -3,4 +3,4 @@ namespace NetAdmin.Domain.Dto.Sys.RequestLogDetail;
/// <summary>
/// 请求:创建请求日志明细
/// </summary>
public record CreateRequestLogDetailReq : Sys_RequestLogDetail;
public sealed record CreateRequestLogDetailReq : Sys_RequestLogDetail;

View File

@ -8,7 +8,7 @@ namespace NetAdmin.Domain.Dto.Sys.SiteMsg;
/// <summary>
/// 响应:导出站内信
/// </summary>
public record ExportSiteMsgRsp : QuerySiteMsgRsp
public sealed record ExportSiteMsgRsp : QuerySiteMsgRsp
{
/// <inheritdoc />
[CsvIndex(5)]

View File

@ -3,4 +3,4 @@ namespace NetAdmin.Domain.Dto.Sys.SiteMsgDept;
/// <summary>
/// 请求:创建站内信-部门映射
/// </summary>
public record CreateSiteMsgDeptReq : Sys_SiteMsgDept;
public sealed record CreateSiteMsgDeptReq : Sys_SiteMsgDept;

View File

@ -3,4 +3,4 @@ namespace NetAdmin.Domain.Dto.Sys.SiteMsgRole;
/// <summary>
/// 请求:创建站内信-角色映射
/// </summary>
public record CreateSiteMsgRoleReq : Sys_SiteMsgRole;
public sealed record CreateSiteMsgRoleReq : Sys_SiteMsgRole;

View File

@ -3,4 +3,4 @@ namespace NetAdmin.Domain.Dto.Sys.SiteMsgUser;
/// <summary>
/// 请求:创建站内信-用户映射
/// </summary>
public record CreateSiteMsgUserReq : Sys_SiteMsgUser;
public sealed record CreateSiteMsgUserReq : Sys_SiteMsgUser;

View File

@ -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)]

View File

@ -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; }

View File

@ -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)]

View File

@ -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>

View File

@ -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));
}
}

View File

@ -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
}

View File

@ -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;
}

View File

@ -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; // 秒:超时时间-作业
}

View File

@ -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

View File

@ -24,6 +24,11 @@ public static class GlobalStatic
#endif
;
/// <summary>
/// 日志记录器忽略的API编号
/// </summary>
public static string[] LoggerIgnoreApiIds => [];
/// <summary>
/// 系统内部密钥
/// </summary>

View File

@ -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>

View File

@ -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;
}
}

View 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);
}
}
}

View File

@ -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)

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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>