diff --git a/YuanXuan.IM.Api.slnx b/YuanXuan.IM.Api.slnx new file mode 100644 index 0000000..80282f2 --- /dev/null +++ b/YuanXuan.IM.Api.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/YuanXuan.IM.Api/CollectionExtensions/ApiVersioningServiceCollectionExtensions.cs b/YuanXuan.IM.Api/CollectionExtensions/ApiVersioningServiceCollectionExtensions.cs new file mode 100644 index 0000000..eee0262 --- /dev/null +++ b/YuanXuan.IM.Api/CollectionExtensions/ApiVersioningServiceCollectionExtensions.cs @@ -0,0 +1,37 @@ +using Asp.Versioning; + +namespace YuanXuan.IM.Api.CollectionExtensions +{ + /// + /// Api版本控制服务扩展类 + /// + public static class ApiVersioningServiceCollectionExtensions + { + /// + /// 添加Api版本控制服务 + /// + /// + /// + public static IServiceCollection AddApiVersion(this IServiceCollection services) + { + services.AddApiVersioning(config => + { + // 默认的Api版号 + config.DefaultApiVersion = new ApiVersion(1, 0); + // 未指定Api版号的时候,使用默认版号 + config.AssumeDefaultVersionWhenUnspecified = true; + // 是否在返回响应头中返回Api版本信息 + config.ReportApiVersions = true; + + }).AddApiExplorer(options => + { + // Api 版本分组名称 + options.GroupNameFormat = "'v'VVV"; + // 未指定Api版号的时候,使用默认版号 + options.SubstituteApiVersionInUrl = true; + }); + + return services; + } + } +} diff --git a/YuanXuan.IM.Api/CollectionExtensions/BatchRegisterServiceCollectionExtension.cs b/YuanXuan.IM.Api/CollectionExtensions/BatchRegisterServiceCollectionExtension.cs new file mode 100644 index 0000000..0fbe022 --- /dev/null +++ b/YuanXuan.IM.Api/CollectionExtensions/BatchRegisterServiceCollectionExtension.cs @@ -0,0 +1,198 @@ +using Masuit.Tools; +using Microsoft.Extensions.DependencyModel; +using Serilog; +using System.Reflection; +using System.Runtime.Loader; +using YuanXuan.IM.Common.Attributes; + +namespace YuanXuan.IM.Api.CollectionExtensions +{ + /// + /// 批量注册服务 + /// + public static class BatchRegisterServiceCollectionExtension + { + /// + /// 批量注册带属性的服务和后台服务 + /// + /// + public static void BatchRegisterServices(this IServiceCollection services) + { + var allAssembly = GetAllAssembly(); + + services.RegisterServiceByAttribute(ServiceLifetime.Singleton, allAssembly); + services.RegisterServiceByAttribute(ServiceLifetime.Scoped, allAssembly); + services.RegisterServiceByAttribute(ServiceLifetime.Transient, allAssembly); + + services.RegisterBackgroundService(allAssembly); + } + /// + /// 通过 InjectAttribute 批量注册服务 + /// + /// + /// + private static void RegisterServiceByAttribute(this IServiceCollection services, ServiceLifetime serviceLifetime, List allAssembly) + { + var types = allAssembly.SelectMany(t => t.GetTypes()) + .Where(t => t.GetCustomAttributes(typeof(InjectAttribute), false).Length > 0 && + t.GetCustomAttribute()?.Lifetime == serviceLifetime && + t.IsClass && !t.IsAbstract).ToList(); + + foreach (var type in types) + { + var baseInterfaces = type.BaseType?.GetInterfaces(); + Type? typeInterface = null; + + // 获取所有接口,然后过滤掉系统接口和常见的不适合DI的接口 + var allInterfaces = type.GetInterfaces(); + var filteredInterfaces = allInterfaces.Where(IsCustomInterface).ToList(); + + // 如果没有自定义接口,则使用直接注入 + if (filteredInterfaces.Count == 0) + { + //服务非继承自接口的直接注入 + switch (serviceLifetime) + { + case ServiceLifetime.Singleton: services.AddSingleton(type); break; + case ServiceLifetime.Scoped: services.AddScoped(type); break; + case ServiceLifetime.Transient: services.AddTransient(type); break; + } + Log.Information("直接注入服务: {TypeName} (生命周期: {Lifetime})", type.Name, serviceLifetime); + } + else + { + // 如果有多个自定义接口,选择最合适的接口 + typeInterface = ChooseBestInterface(filteredInterfaces, type); + + //服务继承自接口的和接口一起注入 + switch (serviceLifetime) + { + case ServiceLifetime.Singleton: services.AddSingleton(typeInterface, type); break; + case ServiceLifetime.Scoped: services.AddScoped(typeInterface, type); break; + case ServiceLifetime.Transient: services.AddTransient(typeInterface, type); break; + } + Log.Information("接口注入服务: {TypeName} -> {InterfaceName} (生命周期: {Lifetime})", + type.Name, typeInterface.Name, serviceLifetime); + } + } + } + + + /// + /// 注册后台服务 + /// + /// + /// + private static void RegisterBackgroundService(this IServiceCollection services, List allAssembly) + { + List types = allAssembly.SelectMany(t => t.GetTypes()).Where(t => typeof(BackgroundService).IsAssignableFrom(t) && t.IsClass && !t.IsAbstract).ToList(); + + foreach (var type in types) + { + services.AddSingleton(typeof(IHostedService), type); + } + } + /// + /// 判断是否为自定义接口(过滤掉系统接口和常见的不适合DI的接口) + /// + /// 接口类型 + /// + private static bool IsCustomInterface(Type interfaceType) + { + // 系统命名空间的接口(不适合DI) + var systemNamespaces = new[] + { + "System", + "Microsoft", + "Windows", + "IDisposable", + "IComparable", + "IEquatable", + "IEnumerable", + "ICollection", + "IList", + "IAsyncEnumerable", + "IAsyncDisposable" + }; + + // 如果接口名称包含系统命名空间或常见系统接口名称,则过滤掉 + if (systemNamespaces.Any(ns => interfaceType.Namespace?.StartsWith(ns) == true || + interfaceType.Name.StartsWith(ns))) + { + return false; + } + + // 检查接口是否有自定义项目的命名空间 + var customNamespaces = new[] + { + "LearningOfficer.OA", + "LearningOfficer" + }; + + // 如果接口有自定义项目的命名空间,则认为是自定义接口 + return customNamespaces.Any(ns => interfaceType.Namespace?.StartsWith(ns) == true); + } + + /// + /// 从多个自定义接口中选择最合适的接口 + /// + /// 接口列表 + /// 实现类型 + /// + private static Type ChooseBestInterface(List interfaces, Type implementationType) + { + // 如果只有一个接口,直接返回 + if (interfaces.Count == 1) + return interfaces[0]; + + // 优先选择名称匹配的接口(例如:MyService -> IMyService) + var serviceName = implementationType.Name; + var preferredInterface = interfaces.FirstOrDefault(i => + i.Name == "I" + serviceName || + i.Name == serviceName.Replace("Service", "").Replace("Impl", "") + "Service"); + + if (preferredInterface != null) + return preferredInterface; + + // 按接口名称排序,选择最合适的 + return interfaces + .OrderBy(i => i.Name.StartsWith("I") ? 0 : 1) // 优先选择 I 开头的接口 + .ThenBy(i => i.Name.Length) // 选择名称较短的接口 + .First(); + } + /// + /// 获取全部 Assembly + /// + /// + private static List GetAllAssembly() + { + var allAssemblies = new List(); + var loadedAssemblies = new HashSet(); + + var dependencyContext = DependencyContext.Default; + var libraries = dependencyContext.RuntimeLibraries + .Where(lib => !lib.Serviceable && lib.Type != "package") + .ToList(); + + foreach (var library in libraries) + { + try + { + var assembly = AssemblyLoadContext.Default.LoadFromAssemblyName(new AssemblyName(library.Name)); + if (loadedAssemblies.Add(assembly.FullName)) + { + allAssemblies.Add(assembly); + } + } + catch (Exception) + { + Log.Fatal("加载程序集失败:{0}", library.Name); + // Log or handle the exception if necessary + } + } + + return allAssemblies; + } + + } +} diff --git a/YuanXuan.IM.Api/CollectionExtensions/ConfigureOptionServiceCollectionExtension.cs b/YuanXuan.IM.Api/CollectionExtensions/ConfigureOptionServiceCollectionExtension.cs new file mode 100644 index 0000000..941ef91 --- /dev/null +++ b/YuanXuan.IM.Api/CollectionExtensions/ConfigureOptionServiceCollectionExtension.cs @@ -0,0 +1,28 @@ +using YuanXuan.IM.Common.Configs; +using YuanXuan.IM.Common.Dtos.ALiYun; + +namespace YuanXuan.IM.Api.CollectionExtensions +{ + /// + /// 强类型配置服务 + /// + public static class ConfigureOptionServiceCollectionExtension + { + + /// + /// 添加强类型配置服务 + /// + /// + public static void AddConfigureOptions(this IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection("nacos")); + services.AddOptions(); + services.Configure(configuration.GetSection("OSSConfig")); + services.Configure(configuration.GetSection("ImConfig")); + services.Configure(configuration.GetSection("HangFireSettings")); + services.Configure(configuration.GetSection("UpAppVersion")); + + services.Configure(configuration.GetSection("RabbitMQ")); + } + } +} diff --git a/YuanXuan.IM.Api/CollectionExtensions/JWTAuthServiceCollectionExtensions.cs b/YuanXuan.IM.Api/CollectionExtensions/JWTAuthServiceCollectionExtensions.cs new file mode 100644 index 0000000..1468e56 --- /dev/null +++ b/YuanXuan.IM.Api/CollectionExtensions/JWTAuthServiceCollectionExtensions.cs @@ -0,0 +1,72 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using System.Text; +using YuanXuan.IM.Common.Configs; + +namespace YuanXuan.IM.Api.CollectionExtensions +{ + public static class JWTAuthServiceCollectionExtensions + { + /// + /// Jwt认证服务 + /// + /// + /// + /// + public static IServiceCollection AddJwtAuth(this IServiceCollection services, IConfiguration configuration) + { + //将配置文件中的相关内容反序列化 + var jwtOption = configuration.GetSection("Jwt").Get(); + + services.AddAuthentication(options => + { + options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }).AddJwtBearer(options => + { + options.Events = new JwtBearerEvents + { + //验证失败时的处理 + OnAuthenticationFailed = context => + { + //若失败类型为过期,则返回特定Header,便于客户端判断 + if (context.Exception.GetType() == typeof(SecurityTokenExpiredException)) + context.Response.Headers.Add("tokenErr", "expired"); + return Task.CompletedTask; + } + //// 配置 SignalR 使用 JWT + //,OnMessageReceived = context => + //{ + // var accessToken = context.Request.Query["access_token"]; + // var path = context.HttpContext.Request.Path; + // if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/signalr")) + // { + // context.Token = accessToken; + // } + // return Task.CompletedTask; + //} + }; + + options.RequireHttpsMetadata = false; + options.SaveToken = true; + + options.TokenValidationParameters = new TokenValidationParameters() + { + ValidateIssuer = true, //是否验证Issuer + ValidIssuer = jwtOption.Issuer, //发行人Issuer + ValidateAudience = true, //是否验证Audience + ValidAudience = jwtOption.Audience, //订阅人Audience + ValidateIssuerSigningKey = true, //是否验证SecurityKey + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOption.AccessSecret)), //SecurityKey + ValidateLifetime = true, //是否验证失效时间 + ClockSkew = TimeSpan.FromSeconds(jwtOption.ClockSkew), //过期时间容错值,解决服务器端时间不同步问题(秒) + RequireExpirationTime = true, + }; + }); + + services.AddAuthorization(); + + return services; + } + } +} diff --git a/YuanXuan.IM.Api/CollectionExtensions/NacosServiceCollectionExtension.cs b/YuanXuan.IM.Api/CollectionExtensions/NacosServiceCollectionExtension.cs new file mode 100644 index 0000000..4838fbe --- /dev/null +++ b/YuanXuan.IM.Api/CollectionExtensions/NacosServiceCollectionExtension.cs @@ -0,0 +1,23 @@ +using Nacos.V2.DependencyInjection; + +namespace YuanXuan.IM.Api.CollectionExtensions +{ + /// + /// 强类型配置服务 + /// + public static class NacosServiceCollectionExtension + { + + /// + /// 添加强类型配置服务 + /// + /// + public static void AddNacos(this IServiceCollection services, IConfiguration configuration, IHostBuilder host) + { + services.AddNacosV2Config(configuration); + services.AddNacosV2Naming(configuration); + host.UseNacosConfig("nacos"); + //services.AddNacosAspNet(configuration, section: "nacos"); + } + } +} diff --git a/YuanXuan.IM.Api/CollectionExtensions/PollyServiceCollectionExtensions.cs b/YuanXuan.IM.Api/CollectionExtensions/PollyServiceCollectionExtensions.cs new file mode 100644 index 0000000..5f8546f --- /dev/null +++ b/YuanXuan.IM.Api/CollectionExtensions/PollyServiceCollectionExtensions.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace YuanXuan.IM.Api.CollectionExtensions +{ + public static class PollyServiceCollectionExtensions + { + /// + /// 添加Polly策略 + /// + /// + /// + public static IServiceCollection AddPollyPolicies(this IServiceCollection services) + { + // 添加HTTP客户端工厂 + services.AddHttpClient("YarpClient") + .ConfigurePrimaryHttpMessageHandler(() => new System.Net.Http.HttpClientHandler()); + + return services; + } + } +} diff --git a/YuanXuan.IM.Api/CollectionExtensions/RedisServiceCollectionExtensions.cs b/YuanXuan.IM.Api/CollectionExtensions/RedisServiceCollectionExtensions.cs new file mode 100644 index 0000000..d03f86e --- /dev/null +++ b/YuanXuan.IM.Api/CollectionExtensions/RedisServiceCollectionExtensions.cs @@ -0,0 +1,22 @@ +using YuanXuan.IM.Common.Configs; +using YuanXuan.IM.Infrastructure.Redis; + +namespace YuanXuan.IM.Api.CollectionExtensions +{ + public static class RedisServiceCollectionExtensions + { + /// + /// 添加Redis服务 + /// + /// + /// + /// + public static IServiceCollection AddRedis(this IServiceCollection services, IConfiguration configuration) + { + var connectionStrings = configuration.GetSection("ConnectionStrings").Get(); + + RedisHelper.Initialization(connectionStrings.Redis); + return services; + } + } +} diff --git a/YuanXuan.IM.Api/CollectionExtensions/SerilogServiceCollectionExtensions.cs b/YuanXuan.IM.Api/CollectionExtensions/SerilogServiceCollectionExtensions.cs new file mode 100644 index 0000000..6295a09 --- /dev/null +++ b/YuanXuan.IM.Api/CollectionExtensions/SerilogServiceCollectionExtensions.cs @@ -0,0 +1,57 @@ +using Serilog; +using Serilog.Sinks.Grafana.Loki; +using System; +using YuanXuan.IM.Api.CollectionExtensions; + +namespace YuanXuan.IM.Api.CollectionExtensions +{ + public static class SerilogServiceCollectionExtensions + { + /// + /// 添加Serilog + /// + /// + /// + /// + public static IServiceCollection AddSerilog(this IServiceCollection services, IConfiguration configuration, IHostBuilder hostBuilder, IHostEnvironment environment) + { + var labels = new List + { + new LokiLabel + { + Key="app", + Value=environment.ApplicationName + }, + new LokiLabel + { + Key="env", + Value=environment.EnvironmentName + }, + }; + + // 配置 Serilog + var logger = new LoggerConfiguration() + .WriteTo.Console() + .Enrich.FromLogContext(); + + //if (environment.IsDevelopment()) + //{ + logger.MinimumLevel.Information() + .WriteTo.GrafanaLoki(configuration["GrafanaLoki:LokiUri"], labels, new List() + { + //"RequestId","Path" + }, tenant: configuration["GrafanaLoki:TenantId"]); + + //} + //else + //{ + // logger.MinimumLevel.Information() + // .WriteTo.GrafanaLoki(configuration["GrafanaLoki:LokiUri"], labels); + //} + + Log.Logger = logger.CreateLogger(); + services.AddSerilog(Log.Logger); + return services; + } + } +} diff --git a/YuanXuan.IM.Api/CollectionExtensions/SqlSugarServiceCollectionExtensions.cs b/YuanXuan.IM.Api/CollectionExtensions/SqlSugarServiceCollectionExtensions.cs new file mode 100644 index 0000000..fe5abf6 --- /dev/null +++ b/YuanXuan.IM.Api/CollectionExtensions/SqlSugarServiceCollectionExtensions.cs @@ -0,0 +1,117 @@ +using Serilog; +using SqlSugar; +using System.Text.RegularExpressions; +using Yitter.IdGenerator; +using YuanXuan.IM.Common.Configs; +using YuanXuan.IM.Infrastructure.DBContext; + +namespace YuanXuan.IM.Api.CollectionExtensions +{ + public static class SqlSugarServiceCollectionExtensions + { + /// + /// 添加SqlSugar + /// + /// + /// + /// + public static IServiceCollection AddSqlSugar(this IServiceCollection services, IConfiguration configuration, IWebHostEnvironment env) + { + var connectionStrings = configuration.GetSection("ConnectionStrings").Get(); + + //YitIdHelper.SetIdGenerator(new IdGeneratorOptions(configuration.GetValue("SnowFlakeWorkId"))); + + // 自定义雪花ID算法 程序启动时执行一次就行 + StaticConfig.CustomSnowFlakeFunc = () => + { + return YitIdHelper.NextId(); + }; + + services.AddScoped(provider => + { + var serviceProvider = provider; // 获取 IServiceProvider + List configs = new() + { + new ConnectionConfig + { + DbType = DbType.MySql, + ConfigId = nameof(connectionStrings.Db), + ConnectionString = connectionStrings?.Db ?? throw new InvalidOperationException("Connection string cannot be null."), + IsAutoCloseConnection = true, + AopEvents = SetAopEvents(connectionStrings.Db, env.EnvironmentName, serviceProvider), + }, + + new ConnectionConfig + { + DbType = DbType.MySql, + ConfigId = nameof(connectionStrings.UserCenterDb), + ConnectionString = connectionStrings?.UserCenterDb ?? throw new InvalidOperationException("Connection string cannot be null."), + IsAutoCloseConnection = true, + AopEvents = SetAopEvents(connectionStrings.UserCenterDb, env.EnvironmentName, serviceProvider), + } + }; + + return new SqlSugarClient(configs); + }); + + services.AddScoped(typeof(SugarRepository<>)); + + return services; + } + + private static AopEvents SetAopEvents(string connectionString, string environmentName, IServiceProvider serviceProvider) + { + var aopEvents = new AopEvents(); + + if (environmentName == Environments.Development) + { + // 正则表达式匹配Ip + var ipMatch = Regex.Match(connectionString, @"((25[0-5]|2[0-4]\d|((1\d{2})|([1-9]?\d)))\.){3}(25[0-5]|2[0-4]\d|((1\d{2})|([1-9]?\d)))"); + + var connections = connectionString.Split(';'); + string dbNamne = string.Empty; + foreach (var item in connections) + { + if (item.Contains("Database")) + { + dbNamne = item.Split('=')[1]; + } + } + + aopEvents.OnLogExecuting = (sql, pars) => + { + // 打印 Sql 语句 + Console.WriteLine($"执行Sql:{Environment.NewLine}{UtilMethods.GetNativeSql(sql, pars)}"); + Log.Logger.Debug($"执行Sql:{Environment.NewLine}{UtilMethods.GetNativeSql(sql, pars)}"); + }; + } + + //aopEvents.DataExecuting += (oldValue, entityInfo) => + //{ + // // 获取 ICurrentUserService + // var currentUserService = serviceProvider.GetService(); + + + // //if (entityInfo.EntityColumnInfo.IsPrimarykey && entityInfo.EntityValue is BaseEntity baseEntity) + // //{ + // // if (entityInfo.OperationType == DataFilterType.InsertByObject) + // // { + // // // 插入时填充创建信息 + // // baseEntity.CreatedUserId = currentUserService.GetUserId(); + // // baseEntity.CreatedUserName = currentUserService.GetUserName(); + // // baseEntity.CreatedUserRealname = currentUserService.GetUserName(); + // // } + // // else if (entityInfo.OperationType == DataFilterType.UpdateByObject) + // // { + // // // 更新时填充修改信息 + // // baseEntity.ModifiedUserId = currentUserService.GetUserId(); + // // baseEntity.ModifiedUserName = currentUserService.GetUserName(); + // // baseEntity.ModifiedUserRealname = currentUserService.GetUserName(); + // // } + // //} + //}; + + return aopEvents; + } + } +} diff --git a/YuanXuan.IM.Api/CollectionExtensions/SwaggerServiceCollectionExtensions.cs b/YuanXuan.IM.Api/CollectionExtensions/SwaggerServiceCollectionExtensions.cs new file mode 100644 index 0000000..0c29dc0 --- /dev/null +++ b/YuanXuan.IM.Api/CollectionExtensions/SwaggerServiceCollectionExtensions.cs @@ -0,0 +1,87 @@ +using LearningOfficer.OA.Mobile.Api.Filters; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi.Models; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +namespace YuanXuan.IM.Api.CollectionExtensions +{ + public static class SwaggerServiceCollectionExtensions + { + /// + /// Swagger注入 + /// + /// + public static void AddSwagger(this IServiceCollection services) + { + services.AddSwaggerGen(c => + { + c.SwaggerDoc("v1", new OpenApiInfo + { + Title = "OA移动端Api", + Version = "v1" + + }); + // v2 文档 + c.SwaggerDoc("v2", new OpenApiInfo + { + Title = "OA移动端Api", + Version = "v2" + }); + // v3 文档 + c.SwaggerDoc("v3", new OpenApiInfo + { + Title = "OA移动端Api", + Version = "v3" + }); + c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme() + { + Description = "在下框中输入请求头中需要添加Jwt授权Token:Bearer {Token},注意中间有空格", + Name = "Authorization", + In = ParameterLocation.Header, + Type = SecuritySchemeType.ApiKey, + BearerFormat = "JWT", + Scheme = "Bearer" + }); + c.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }, + new string[] { } + } + }); + c.SupportNonNullableReferenceTypes(); + c.ParameterFilter(); + + // 获取主项目生成的 XML 文件路径 + var mainXmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; + var mainXmlPath = Path.Combine(AppContext.BaseDirectory, mainXmlFile); + c.IncludeXmlComments(mainXmlPath, includeControllerXmlComments: true); + + // 获取所有引用的类库 XML 文件路径 + var referencedAssemblies = Assembly.GetExecutingAssembly().GetReferencedAssemblies(); + foreach (var assemblyName in referencedAssemblies) + { + var libraryXmlPath = Path.Combine(AppContext.BaseDirectory, $"{assemblyName.Name}.xml"); + if (File.Exists(libraryXmlPath)) + { + c.IncludeXmlComments(libraryXmlPath); + } + } + + + }); + } + } +} diff --git a/YuanXuan.IM.Api/CollectionExtensions/YarpServiceCollectionExtensions.cs b/YuanXuan.IM.Api/CollectionExtensions/YarpServiceCollectionExtensions.cs new file mode 100644 index 0000000..9e436ac --- /dev/null +++ b/YuanXuan.IM.Api/CollectionExtensions/YarpServiceCollectionExtensions.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Primitives; +using Nacos.V2; +using Nacos.V2.Naming; +using Nacos.V2.Naming.Dtos; +using Newtonsoft.Json; +using System.Threading; +using Yarp.ReverseProxy.Configuration; +using YuanXuan.IM.Api.Proxy; + +namespace YuanXuan.IM.Api.CollectionExtensions +{ + public static class YarpServiceCollectionExtensions + { + /// + /// 添加YARP反向代理服务 + /// + /// + /// + /// + public static IServiceCollection AddYarpWithNacos(this IServiceCollection services, IConfiguration configuration) + { + // 添加YARP服务 + services.AddReverseProxy(); + + // 注册Nacos服务发现提供者 + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + + return services; + } + + } + +} diff --git a/YuanXuan.IM.Api/Controllers/BaseApiController.cs b/YuanXuan.IM.Api/Controllers/BaseApiController.cs new file mode 100644 index 0000000..55eed45 --- /dev/null +++ b/YuanXuan.IM.Api/Controllers/BaseApiController.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using YuanXuan.IM.Common.Dtos.LoginMobile; +using YuanXuan.IM.Common.Response; + +namespace YuanXuan.IM.Api.Controllers +{ + /// + /// 自定义基础Api控制器 + /// + [Authorize] + [ApiController] + public class BaseApiController : ControllerBase + { + /// + /// Api版本前缀 + /// + protected const string RoutePrefix = "api/v{version:apiVersion}"; + public MobileTokenInfo ZereUser => GetTokenInfo(); + private MobileTokenInfo GetTokenInfo() + { + return new MobileTokenInfo + { + UserName = HttpContext?.User?.FindFirst("UserName")?.Value, + UserId = HttpContext?.User?.FindFirst("UserId")?.Value, + Jwt_id = HttpContext?.User?.FindFirst("Jwt_id")?.Value, + }; + } + + /// + /// 返回成功结果 + /// + /// 数据 + /// + protected IActionResult Success(object data = null) + { + return Ok(new BaseResponse(200, "success", data)); + } + + /// + /// 返回失败结果 + /// + /// 错误信息 + /// + protected IActionResult Fail(string message) + { + return BadRequest(new BaseResponse(400, message)); + } + } +} diff --git a/YuanXuan.IM.Api/Controllers/HealthController.cs b/YuanXuan.IM.Api/Controllers/HealthController.cs new file mode 100644 index 0000000..66d1415 --- /dev/null +++ b/YuanXuan.IM.Api/Controllers/HealthController.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace YuanXuan.IM.Api.Controllers +{ + /// + /// 健康检查控制器 + /// + [Route("[controller]")] + [ApiController] + public class HealthController : ControllerBase + { + /// + /// 健康检查 + /// + /// + [HttpGet] + [AllowAnonymous] + public IActionResult Check() + { + return Ok(new { Status = "Healthy", Timestamp = DateTime.UtcNow }); + } + } +} diff --git a/YuanXuan.IM.Api/Controllers/LoginAuthorController.cs b/YuanXuan.IM.Api/Controllers/LoginAuthorController.cs new file mode 100644 index 0000000..618e091 --- /dev/null +++ b/YuanXuan.IM.Api/Controllers/LoginAuthorController.cs @@ -0,0 +1,120 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using YuanXuan.IM.Common.Dtos.LoginMobile; +using YuanXuan.IM.Common.Helpers; +using YuanXuan.IM.Infrastructure.Redis; + +namespace YuanXuan.IM.Api.Controllers +{ + /// + /// 登录授权控制器 + /// + [Route($@"{RoutePrefix}/[controller]/[action]")] + [ApiVersion(1.0)] + public class LoginAuthorController : BaseApiController + { + /// + /// 登录 + /// + /// + /// + [AllowAnonymous] + [HttpPost] + public async Task Login([FromBody] LoginRequest request) + { + // 这里应该添加实际的登录验证逻辑 + // 暂时模拟登录成功 + var userId = "123456"; + var userName = "testuser"; + + // 生成JWT token + var token = JwtHelper.GenerateToken(userId, userName); + var refreshToken = JwtHelper.GenerateRefreshToken(); + + // 存储token到Redis,用于后续的验证和登出 + await RedisHelper.SetAsync($"user:token:{userId}", token, TimeSpan.FromHours(24)); + await RedisHelper.SetAsync($"user:refreshToken:{userId}", refreshToken, TimeSpan.FromDays(7)); + + return Success(new { Token = token, RefreshToken = refreshToken, UserId = userId, UserName = userName }); + } + + /// + /// 刷新Token + /// + /// + /// + [AllowAnonymous] + [HttpPost] + public async Task RefreshToken([FromBody] RefreshTokenRequest request) + { + // 验证refreshToken + var principal = JwtHelper.GetPrincipalFromExpiredToken(request.Token); + var userId = principal?.Claims.FirstOrDefault(c => c.Type == "sub")?.Value; + + if (userId == null) + { + return Fail("无效的token"); + } + + var storedRefreshToken = await RedisHelper.GetStringAsync($"user:refreshToken:{userId}"); + if (storedRefreshToken != request.RefreshToken) + { + return Fail("无效的refreshToken"); + } + + var userName = principal?.Claims.FirstOrDefault(c => c.Type == "name")?.Value; + var newToken = JwtHelper.GenerateToken(userId, userName); + var newRefreshToken = JwtHelper.GenerateRefreshToken(); + + await RedisHelper.SetAsync($"user:token:{userId}", newToken, TimeSpan.FromHours(24)); + await RedisHelper.SetAsync($"user:refreshToken:{userId}", newRefreshToken, TimeSpan.FromDays(7)); + + return Success(new { Token = newToken, RefreshToken = newRefreshToken }); + } + + /// + /// 登出 + /// + /// + [Authorize] + [HttpPost] + public async Task Logout() + { + var userId = User.FindFirst("sub")?.Value; + if (userId == null) + { + return Fail("用户未登录"); + } + + // 从Redis中删除token + await RedisHelper.DeleteAsync($"user:token:{userId}"); + await RedisHelper.DeleteAsync($"user:refreshToken:{userId}"); + + return Success("登出成功"); + } + + /// + /// 全局登出(所有设备) + /// + /// + [Authorize] + [HttpPost] + public async Task GlobalLogout() + { + var userId = User.FindFirst("sub")?.Value; + if (userId == null) + { + return Fail("用户未登录"); + } + + // 删除所有相关的token + await RedisHelper.DeleteAsync($"user:token:{userId}"); + await RedisHelper.DeleteAsync($"user:refreshToken:{userId}"); + // 这里可以添加更多的清理逻辑,比如删除所有设备的登录记录 + + return Success("全局登出成功"); + } + } +} diff --git a/YuanXuan.IM.Api/Filters/CustomContractResolver.cs b/YuanXuan.IM.Api/Filters/CustomContractResolver.cs new file mode 100644 index 0000000..bf586ea --- /dev/null +++ b/YuanXuan.IM.Api/Filters/CustomContractResolver.cs @@ -0,0 +1,56 @@ +using Newtonsoft.Json; +using System.Reflection; +using Newtonsoft.Json.Serialization; +using System.Text.RegularExpressions; +using System.Text; + +namespace LearningOfficer.OA.Mobile.Api.Filters +{ + public class CustomContractResolver : DefaultContractResolver + { + protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) + { + var property = base.CreateProperty(member, memberSerialization); + + if (!string.IsNullOrEmpty(member.Name)) + { + if (member.Name.Length >= 1 && char.IsUpper(member.Name[0])) + { + property.PropertyName = char.ToLower(member.Name[0]) + member.Name.Substring(1); + } + property.PropertyName = ChangeName(property.PropertyName); + } + return property; + } + /// + /// 将下划线命名转换为小驼峰命名 + /// + /// 变量名 + /// + private string ChangeName(string name) + { + Match mt = Regex.Match(name, @"_(\w*)*"); + if (mt.Success) { + var sb = new StringBuilder(); + bool toUpper = false; + for (int i = 0; i < name.Length; i++) + { + char c = name[i]; + if (c == '_') + { + toUpper = true; + } + else + { + sb.Append(toUpper ? char.ToUpperInvariant(c) : c); + toUpper = false; + } + } + if (sb.Length > 0) + sb[0] = char.ToLowerInvariant(sb[0]); + return sb.ToString(); + } + return name; + } + } +} diff --git a/YuanXuan.IM.Api/Filters/CustomJsonNamingPolicy.cs b/YuanXuan.IM.Api/Filters/CustomJsonNamingPolicy.cs new file mode 100644 index 0000000..3798b6c --- /dev/null +++ b/YuanXuan.IM.Api/Filters/CustomJsonNamingPolicy.cs @@ -0,0 +1,54 @@ +using System.Runtime.Intrinsics.X86; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace LearningOfficer.OA.Mobile.Api.Filters +{ + public class CustomJsonNamingPolicy : JsonNamingPolicy + { + public override string ConvertName(string name) + { + //第一个字母小写 + if (string.IsNullOrEmpty(name)) + { + return name; + } + if (name.Length > 1 && char.IsUpper(name[0])) + { + name = char.ToLower(name[0]) + name.Substring(1); + } + + return ChangeName(name); + } + private string ChangeName(string name) + { + Match mt = Regex.Match(name, @"_(\w*)*"); + if (mt.Success) + { + var sb = new StringBuilder(); + bool toUpper = false; + for (int i = 0; i < name.Length; i++) + { + char c = name[i]; + if (c == '_') + { + toUpper = true; + } + else + { + sb.Append(toUpper ? char.ToUpperInvariant(c) : c); + toUpper = false; + } + } + if (sb.Length > 0) + { + + sb[0] = char.ToLowerInvariant(sb[0]); + } + return sb.ToString(); + } + return name; + } + } +} diff --git a/YuanXuan.IM.Api/Filters/GlobalExceptionCatchFilter.cs b/YuanXuan.IM.Api/Filters/GlobalExceptionCatchFilter.cs new file mode 100644 index 0000000..264e07c --- /dev/null +++ b/YuanXuan.IM.Api/Filters/GlobalExceptionCatchFilter.cs @@ -0,0 +1,135 @@ +using System.Text.RegularExpressions; +using System.Text; +using Mapster; +using Masuit.Tools; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using YuanXuan.IM.Common.Response; +using YuanXuan.IM.Common.Exceptions; + +namespace LearningOfficer.OA.Mobile.Api.Filters +{ + /// + /// 全局异常捕获过滤器 + /// + public class GlobalExceptionCatchFilter : IAsyncExceptionFilter + { + private readonly ILogger _logger; + + public GlobalExceptionCatchFilter(ILogger logger) + { + _logger = logger; //在构造函数中注入日志处理实例 + } + + public async Task OnExceptionAsync(ExceptionContext context) + { + // 如果异常没有被处理则进行处理 + if (context.ExceptionHandled == false) + { + // 定义返回类型 + BaseResponse result; + + // 如果为业务逻辑抛出的内部异常 + if (context.Exception is BusinessException ex) + { + if (ex.BussinessExceptionData != null) + { + result = new BaseResponse((int)ex.ErrorCode, context.Exception.Message, ex.BussinessExceptionData); + //将result的data转换为Dictionary + var data = result.data.ToDictionary(); + + ConvertKeysToCamelCase(data); + result.data = data; + } + else + { + result = new BaseResponse((int)ex.ErrorCode, context.Exception.Message); + } + } + else + { + // 程序异常,不对外暴露程序异常细节 + result = new BaseResponse((int)BusinessExceptionCode.AppError, "服务器好像出错了!" + // ,new + //{ + // RequestId = context.HttpContext.TraceIdentifier + //} + ); + + //使用日志对象 _logger 的 LogError() 方法将异常信息写入日志文件 + _logger.LogError(context.Exception, context.Exception.Message); + } + + context.Result = new ContentResult + { + // 返回状态码设置为200,表示成功 + StatusCode = StatusCodes.Status200OK, + // 设置返回格式 + ContentType = "application/json;charset=utf-8", + Content = result.ToJsonString() + }; + + + } + + // 设置为true,表示异常已经被处理了 + context.ExceptionHandled = true; + } + + public static void ConvertKeysToCamelCase(Dictionary dictionary) + { + var keys = dictionary.Keys.ToList(); + foreach (var key in keys) + { + string ItemKey = key; + if (ItemKey.Length >= 1 && char.IsUpper(ItemKey[0])) + { + ItemKey = char.ToLower(ItemKey[0]) + ItemKey.Substring(1); + } + var camelCaseKey = ChangeName(ItemKey); + if (dictionary.ContainsKey(camelCaseKey)) + { + dictionary[camelCaseKey] = dictionary[key]; + } + else + { + dictionary.Add(camelCaseKey, dictionary[key]); + } + dictionary.Remove(key); + } + } + + /// + /// 将下划线命名转换为小驼峰命名 + /// + /// 变量名 + /// + private static string ChangeName(string name) + { + Match mt = Regex.Match(name, @"_(\w*)*"); + if (mt.Success) + { + var sb = new StringBuilder(); + bool toUpper = false; + for (int i = 0; i < name.Length; i++) + { + char c = name[i]; + if (c == '_') + { + toUpper = true; + } + else + { + sb.Append(toUpper ? char.ToUpperInvariant(c) : c); + toUpper = false; + } + } + if (sb.Length > 0) + sb[0] = char.ToLowerInvariant(sb[0]); + return sb.ToString(); + } + return name; + } + + } +} diff --git a/YuanXuan.IM.Api/Filters/GlobalOperationLogFilter.cs b/YuanXuan.IM.Api/Filters/GlobalOperationLogFilter.cs new file mode 100644 index 0000000..0b2fce0 --- /dev/null +++ b/YuanXuan.IM.Api/Filters/GlobalOperationLogFilter.cs @@ -0,0 +1,60 @@ +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Filters; +using Newtonsoft.Json; +using System.Text; + +namespace LearningOfficer.OA.Mobile.Api.Filters +{ + public class GlobalOperationLogFilter : IAsyncActionFilter + { + private readonly ILogger _logger; + + public GlobalOperationLogFilter(ILogger logger ) + { + _logger = logger; + } + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + //// 获取请求信息 + var paramss = context.ActionDescriptor.Parameters; + var ActionName = ((ControllerActionDescriptor)context.ActionDescriptor)?.ActionName; + var Method = context.HttpContext.Request.Method; + var queryString = context.HttpContext.Request.QueryString; + var RequestUrl = context.HttpContext.Request.Host + context.HttpContext.Request.Path; + string token = context.HttpContext.Request.Headers["Authorization"].FirstOrDefault(); + string body = ""; + var request = context.HttpContext.Request; + var stream = request.Body; + if (request.ContentLength != null && request.ContentLength > 0) + { + request.EnableBuffering(); + request.Body.Position = 0;//将读取指针迻到开始位置 + using (var reader = new StreamReader(stream, Encoding.UTF8, true, 1024, true)) + { + body = await reader.ReadToEndAsync(); + } + } + + _logger.LogInformation(context.HttpContext.TraceIdentifier + ": {RequestParams}", body); + await next(); + } + + private async Task GetRequestBody(HttpRequest request) + { + request.EnableBuffering(); + var body = request.Body; + var buffer = new byte[Convert.ToInt32(request.ContentLength)]; + await request.Body.ReadAsync(buffer, 0, buffer.Length); + request.Body.Position = 0; // 重置流位置 + return Encoding.UTF8.GetString(buffer); + } + + private async Task GetResponseBody(HttpResponse response) + { + response.Body.Seek(0, SeekOrigin.Begin); + var body = await new StreamReader(response.Body).ReadToEndAsync(); + response.Body.Seek(0, SeekOrigin.Begin); + return body; + } + } +} diff --git a/YuanXuan.IM.Api/Filters/NullableParameterFilter.cs b/YuanXuan.IM.Api/Filters/NullableParameterFilter.cs new file mode 100644 index 0000000..fe3fe00 --- /dev/null +++ b/YuanXuan.IM.Api/Filters/NullableParameterFilter.cs @@ -0,0 +1,29 @@ +using System.Reflection; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace LearningOfficer.OA.Mobile.Api.Filters +{ + public class NullableParameterFilter : IParameterFilter + { + public void Apply(OpenApiParameter parameter, ParameterFilterContext context) + { + if (context.ApiParameterDescription.Type is null) + return; + + if (Nullable.GetUnderlyingType(context.ApiParameterDescription.Type) != null) + { + parameter.Schema.Nullable = true; + } + else if (context.ApiParameterDescription.Type.IsClass || context.ApiParameterDescription.Type.IsInterface) + { + + var nullableAttribute = context.ParameterInfo?.GetCustomAttribute(); + if (nullableAttribute != null || context.ApiParameterDescription.Type.FullName?.Contains("System.Nullable") == true) + { + parameter.Schema.Nullable = true; + } + } + } + } +} diff --git a/YuanXuan.IM.Api/Filters/UniformResultActionFilter.cs b/YuanXuan.IM.Api/Filters/UniformResultActionFilter.cs new file mode 100644 index 0000000..aeb1cc7 --- /dev/null +++ b/YuanXuan.IM.Api/Filters/UniformResultActionFilter.cs @@ -0,0 +1,199 @@ +using Dm.util; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Options; +using NetTaste; +using Newtonsoft.Json; +using System.Text; +using System.Threading.Tasks; +using YuanXuan.IM.Common.Configs; +using YuanXuan.IM.Common.Dtos.LoginMobile; +using YuanXuan.IM.Common.Response; +using YuanXuan.IM.Infrastructure.Redis; + +namespace LearningOfficer.OA.Mobile.Api.Filters +{ + /// + /// 统一返回结果过滤器 + /// + public class UniformResultActionFilter : ActionFilterAttribute + { + private readonly NacosConfig _nacosConfig; + private readonly ILogger _logger; + public UniformResultActionFilter(ILogger logger,IOptions nacosConfig) + { + _logger = logger; + _nacosConfig = nacosConfig.Value; + } + /// + /// 在Controller的Action执行后执行 + /// + /// + public override void OnActionExecuted(ActionExecutedContext context) + { + //特殊处理:对有ApiResultIgnoreAttribute标签的,不进行返回结果包装,原样输出 + //var controllerActionDescriptor = context.ActionDescriptor as ControllerActionDescriptor; + //if (controllerActionDescriptor != null) + //{ + // var isDefined = controllerActionDescriptor.EndpointMetadata.Any(a => a.GetType().Equals(typeof(ApiResultIgnoreAttribute))); + // if (isDefined) + // { + // return; + // } + //} + // 返回结果为JsonResult的请求进行Result包装 + if (context.Result != null) + { + switch (context.Result) + { + case ObjectResult: + { + var result = context.Result as ObjectResult; + context.Result = new JsonResult(new BaseResponse(200, "请求成功", result.Value)); + break; + } + case EmptyResult: + context.Result = new JsonResult(new BaseResponse(200, "请求成功")); + break; + case ContentResult: + { + var result = context.Result as ContentResult; + context.Result = new JsonResult(new BaseResponse(200, "请求成功", result.Content)); + break; + } + case OkResult: + { + context.Result = new JsonResult(new BaseResponse(200, "请求成功")); + } + break; + } + } + + //_logger.LogInformation(""); + + base.OnActionExecuted(context); + } + + /// + /// 在Controller的Action执行前执行 + /// + /// + public override async void OnActionExecuting(ActionExecutingContext context) + { + var actionDescriptor = context.ActionDescriptor as ControllerActionDescriptor; + if (actionDescriptor != null) + { + var methodInfo = actionDescriptor.MethodInfo; + var hasAllowAnonymous = methodInfo.GetCustomAttributes(true) + .OfType() + .Any(); + //获取action名称 + var actionName = actionDescriptor.ActionName; + //获取控制器名称 + var controllerName = actionDescriptor.ControllerName; + //判断是否是刷新Token的请求 + if (controllerName == "Login" && actionName == "RefreshToken") + { + //获取body参数对象中token + var requestBody = context.ActionArguments.Values.FirstOrDefault(); + if (requestBody != null) + { + var token = context.HttpContext.Request.Headers["Authorization"].FirstOrDefault(); + if (!string.IsNullOrEmpty(token)) + { + token = token.Replace("Bearer ", ""); + } + else + { + context.Result = new JsonResult(new BaseResponse(1000, "刷新token需要请求头同时带上原token")); + return; + } + var parms = requestBody as RefreshTokenRequest; + if (parms.Token != token) + { + context.Result = new JsonResult(new BaseResponse(1000, "参数token与头部token不一致")); + return; + } + if ((RefreshTokenExpToken(context, token))) + { + context.HttpContext.Response.StatusCode = 402; + context.Result = new JsonResult(new BaseResponse(402, "账号被其他人登录,请重新登录")); + return; + } + } + + } + if (!hasAllowAnonymous) + { + var request = context.HttpContext.Request; + var token = request.Headers["Authorization"].FirstOrDefault(); + if (!string.IsNullOrEmpty(token)) + { + token = token.Replace("Bearer ", ""); + } + if (IsExpRedisToken(context, token)) + { + context.HttpContext.Response.StatusCode = 402; + context.Result = new JsonResult(new BaseResponse(402, "账号被其他人登录,请重新登录")); + return; + } + + } + } + base.OnActionExecuting(context); + } + /// + /// 是否返回过期 + /// + /// + /// + /// + private bool IsExpRedisToken(ActionExecutingContext context, string token) + { + if (_nacosConfig.Namespace == "dev") + { + return false; + } + var userId = context.HttpContext.User.Claims.FirstOrDefault(c => c.Type == "UserId")?.Value; + if (string.IsNullOrEmpty(token) || string.IsNullOrEmpty(userId)) + { + return true; + } + // 验证Token是否存在于Redis中 + var redisToken = RedisHelper.Instance.HGet("Login_Token", userId); + if (string.IsNullOrEmpty(redisToken) || token != redisToken) + { + return true; + } + return false; + } + /// + /// 是否返回过期 + /// + /// + /// + /// + private bool RefreshTokenExpToken(ActionExecutingContext context, string token) + { + if (_nacosConfig.Namespace == "dev") + { + return false; + } + var userId = context.HttpContext.User.Claims.FirstOrDefault(c => c.Type == "UserId")?.Value; + if (string.IsNullOrEmpty(token) || string.IsNullOrEmpty(userId)) + { + return true; + } + // 验证Token是否存在于Redis中 + var redisToken = RedisHelper.Instance.HGet("Login_Token", userId); + // + if (string.IsNullOrEmpty(redisToken) || token != redisToken) + { + return true; + } + return false; + } + } +} \ No newline at end of file diff --git a/YuanXuan.IM.Api/Hangfire/HangfireJobs.cs b/YuanXuan.IM.Api/Hangfire/HangfireJobs.cs new file mode 100644 index 0000000..7a20db2 --- /dev/null +++ b/YuanXuan.IM.Api/Hangfire/HangfireJobs.cs @@ -0,0 +1,20 @@ + +using Microsoft.AspNetCore.SignalR; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using Yitter.IdGenerator; +using YuanXuan.IM.Common.Attributes; + +namespace YuanXuan.IM.Api.Hangfire +{ + [Inject] + public class HangfireJobs : IAdminHangfireJobs + { + private readonly ILogger _logger; + public HangfireJobs(ILogger logger) + { + _logger = logger; + } + } +} diff --git a/YuanXuan.IM.Api/Hangfire/IAdminHangfireJobs.cs b/YuanXuan.IM.Api/Hangfire/IAdminHangfireJobs.cs new file mode 100644 index 0000000..6a115f2 --- /dev/null +++ b/YuanXuan.IM.Api/Hangfire/IAdminHangfireJobs.cs @@ -0,0 +1,6 @@ +namespace YuanXuan.IM.Api.Hangfire +{ + public interface IAdminHangfireJobs + { + } +} diff --git a/YuanXuan.IM.Api/Hangfire/InitialHangfireService.cs b/YuanXuan.IM.Api/Hangfire/InitialHangfireService.cs new file mode 100644 index 0000000..1145fd0 --- /dev/null +++ b/YuanXuan.IM.Api/Hangfire/InitialHangfireService.cs @@ -0,0 +1,26 @@ +using Hangfire.Redis.StackExchange; + +namespace YuanXuan.IM.Api.Hangfire +{ + public class InitialHangfireService : IHostedService + { + private readonly IConfiguration _configuration; + public InitialHangfireService(IConfiguration configuration) + { + _configuration = configuration; + } + public Task StartAsync(CancellationToken cancellationToken) + { + + + return Task.CompletedTask; + } + public Task StopAsync(CancellationToken cancellationToken) + { + + return Task.CompletedTask; + } + public void Dispose() { + } + } +} diff --git a/YuanXuan.IM.Api/Hangfire/MyHangfireFilter.cs b/YuanXuan.IM.Api/Hangfire/MyHangfireFilter.cs new file mode 100644 index 0000000..f1966d3 --- /dev/null +++ b/YuanXuan.IM.Api/Hangfire/MyHangfireFilter.cs @@ -0,0 +1,14 @@ +using Hangfire.Dashboard; + +namespace YuanXuan.IM.Api.Hangfire +{ + public class MyHangfireFilter : IDashboardAuthorizationFilter + { + public bool Authorize(DashboardContext context) + { + var httpContext = context.GetHttpContext(); + + return true; // 允许远程无限制访问 + } + } +} diff --git a/YuanXuan.IM.Api/Hangfire/WskService.cs b/YuanXuan.IM.Api/Hangfire/WskService.cs new file mode 100644 index 0000000..da7d2bf --- /dev/null +++ b/YuanXuan.IM.Api/Hangfire/WskService.cs @@ -0,0 +1,28 @@ +using Hangfire; +using Hangfire.Redis.StackExchange; +using YuanXuan.IM.Common.Configs; + +namespace YuanXuan.IM.Api.Hangfire +{ + public static class WskService + { + public static void AddWskService(this IServiceCollection services, IConfiguration configuration) + { + if (services==null) + { + throw new ArgumentNullException(nameof(services)); + } + services.AddHostedService(); + + var HangfireConfig = configuration.GetSection("Hangfire").Get(); + HangFireSettings.hangfireStorage = new RedisStorage(HangfireConfig.ConnectionString, new RedisStorageOptions + { + + Db = HangfireConfig.Db, + FetchTimeout = TimeSpan.FromSeconds(30), + + }); + services.AddHangfire(config => config.UseStorage(HangFireSettings.hangfireStorage)); + } + } +} diff --git a/YuanXuan.IM.Api/Helper/MobileToH5UserSign.cs b/YuanXuan.IM.Api/Helper/MobileToH5UserSign.cs new file mode 100644 index 0000000..8d8fc50 --- /dev/null +++ b/YuanXuan.IM.Api/Helper/MobileToH5UserSign.cs @@ -0,0 +1,87 @@ +using System; +using System.Buffers.Text; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using YuanXuan.IM.Common.Exceptions; +using YuanXuan.IM.Common.Helpers; +using YuanXuan.IM.Infrastructure.Redis; + +namespace YuanXuan.IM.Api.Helper +{ + /// + /// 手机跳H5用户签名帮助类 + /// + public static class MobileToH5UserSign + { + /// + /// 获取用户跳转H5的签名,并缓存sign 1小时 + /// + /// + /// + /// + public static string GetUserSign(string UserId, long? DataId) + { + //生成sign,规则为:Id + "&" + userId + var sign = $"{UserId}"; + if (DataId != null) + { + sign = $"{DataId}&" + sign; + } + //aes加密sign + sign = AesEncryptHelper.EncryptToShortString(sign); + //sign进行base64 + // 将字符串转换为字节数组 + byte[] byteArray = Encoding.UTF8.GetBytes(sign); + // 将字节数组转换为Base64字符串 + string base64String = Convert.ToBase64String(byteArray); + //存储到redis中,有效期1小时 + RedisHelper.Instance.Set(base64String, $"{DataId}&{UserId}", TimeSpan.FromHours(1)); + return base64String; + } + /// + /// 解密用户跳转H5的签名,获取Id(有可能为null)和userId + /// + /// + /// 正确参数个数 + /// + /// + public static (long? DataId, long UserId) DecryptUserSign(string sign, int parCount) + { + //判断redis中是否存在sign + var redisSign = RedisHelper.Instance.Get(sign); + if (string.IsNullOrEmpty(redisSign)) + { + throw new BusinessException("链接已过期,请重新获取"); + } + //sign的base64解码 + byte[] decodedBytes = Convert.FromBase64String(sign); + // 转换为UTF8字符串 + string decodedString = Encoding.UTF8.GetString(decodedBytes); + //解密sign + sign = AesEncryptHelper.DecryptFromShortString(decodedString); + var arr = sign.Split('&'); + if (parCount!=arr.Length) + { + throw new BusinessException("参数错误"); + } + if (arr.Length == 1) + { + var userId = Convert.ToInt64(arr[0]); + + return (null, userId); + } + else if (true) + { + var id = Convert.ToInt64(arr[0]); + var userId = Convert.ToInt64(arr[1]); + return (id, userId); + } + else + { + throw new BusinessException("参数错误"); + } + } + } +} diff --git a/YuanXuan.IM.Api/Helper/WorkerIdAutoRegisterHelper.cs b/YuanXuan.IM.Api/Helper/WorkerIdAutoRegisterHelper.cs new file mode 100644 index 0000000..efff8bc --- /dev/null +++ b/YuanXuan.IM.Api/Helper/WorkerIdAutoRegisterHelper.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Timers; +using Yitter.IdGenerator; +using YuanXuan.IM.Infrastructure.Redis; + +namespace YuanXuan.IM.Api.Helper +{ + /// + /// IdWorker自动注册帮助类 需要先注册Redis + /// + public class WorkerIdAutoRegisterHelper + { + + private static System.Timers.Timer _timer; + + public static IdGeneratorOptions GetIdGeneratorOptions() + { + byte workerIdBitLength = 6; + var maxWorkId = Math.Pow(2, workerIdBitLength) - 1; + var workIdKey = "idgen:workid"; + + var workId = GetNextWorkId(); + + while (!RedisHelper.Instance.SetNx($"{workIdKey}:{workId}", 1)) + { + // workId 已被占用,获取下一个workId + workId = GetNextWorkId(); + }; + + // 设置5分钟过期 + RedisHelper.Instance.Expire($"{workIdKey}:{workId}", 60 * 5); + + // 设置定时器,每4分钟更新一次过期时间 + SetTimer(4, (s, e) => + { + RedisHelper.Instance.Expire($"{workIdKey}:{workId}", 60 * 5); + }); + + // WorkerIdBitLength + SeqBitLength 不超过 22 + return new IdGeneratorOptions + { + WorkerIdBitLength = workerIdBitLength, + SeqBitLength = 6, // 数值越高,性能越好,但是Id也越长 + WorkerId = (ushort)workId + }; + + long GetNextWorkId() + { + var workId = RedisHelper.Instance.IncrBy(workIdKey,1); + if (workId > maxWorkId) + { + // 大于了最大可用WorkId,重置workId,并获取 + RedisHelper.Instance.Set(workIdKey, 0); + workId = RedisHelper.Instance.IncrBy(workIdKey, 1); + } + Console.WriteLine($"================================================分配到的workId:{workId}==============================================================================="); + return workId; + } + } + + private static void SetTimer(int mins, ElapsedEventHandler eh) + { + // 创建一个 Timer 实例,并设置其相关属性 + _timer = new System.Timers.Timer(TimeSpan.FromMinutes(mins).TotalMilliseconds); // 4 分钟 + _timer.Elapsed += eh; + _timer.AutoReset = true; // 设置 Timer 实例能否多次触发 + _timer.Enabled = true; // 启动 Timer 实例 + } + } +} diff --git a/YuanXuan.IM.Api/Program.cs b/YuanXuan.IM.Api/Program.cs new file mode 100644 index 0000000..6cee9d0 --- /dev/null +++ b/YuanXuan.IM.Api/Program.cs @@ -0,0 +1,150 @@ + +using Hangfire; +using Hangfire.Dashboard; +using LearningOfficer.OA.Mobile.Api.Filters; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Serialization; +using System.Text; +using Yitter.IdGenerator; +using YuanXuan.IM.Api.CollectionExtensions; +using YuanXuan.IM.Api.Hangfire; +using YuanXuan.IM.Api.Helper; +using YuanXuan.IM.Api.Proxy; + +namespace YuanXuan.IM.Api +{ + public class Program + { + public static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + + var Configuration = builder.Configuration; + var services = builder.Services; + //Services============================= + services.AddControllers(); + services.AddEndpointsApiExplorer(); + services.AddSwaggerGen(); + services.AddNacos(Configuration, builder.Host); + services.AddYarpWithNacos(Configuration); + services.AddPollyPolicies(); + services.AddControllers().AddJsonOptions(option => + { + option.JsonSerializerOptions.PropertyNamingPolicy = new CustomJsonNamingPolicy(); + }); + services.AddControllers(opt => + { + opt.Filters.Add(); + opt.Filters.Add(); + opt.Filters.Add(); + //// ������֤�����������ƽ��ɵ��豸��¼ + //opt.Filters.Add(); + }).AddNewtonsoftJson(opt => + { + //����ѭ������ + opt.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; + //���ı��ֶδ�С + opt.SerializerSettings.ContractResolver = new DefaultContractResolver(); + + opt.SerializerSettings.ContractResolver = new CustomContractResolver(); + //��������Ĭ�ϸ�ʽ������ + opt.SerializerSettings.Converters.Add(new IsoDateTimeConverter() { DateTimeFormat = "yyyy-MM-dd HH:mm:ss" }); + }); + + // ���ӿ������ + services.AddCors(options => + { + options.AddDefaultPolicy(builder => + { + builder.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); + }); + + services.BatchRegisterServices(); + services.AddApiVersion(); + services.AddJwtAuth(Configuration); + services.AddSwagger(); + services.AddHttpClient(); + services.AddSerilog(Configuration, builder.Host, builder.Environment); + services.AddRedis(Configuration); + services.AddSqlSugar(Configuration, builder.Environment); + services.AddConfigureOptions(Configuration); + services.AddHttpContextAccessor(); + services.AddWskService(Configuration); + services.AddHangfireServer(); + YitIdHelper.SetIdGenerator(WorkerIdAutoRegisterHelper.GetIdGeneratorOptions()); + + var app = builder.Build(); + if (app.Environment.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "YuanXuan-IM v1"); + }); + app.UseCors(); + } + app.UseAuthentication(); + app.UseAuthorization(); + app.Use(async (context, next) => + { + if (context.Request.Method.Equals("POST", StringComparison.OrdinalIgnoreCase)) + { + context.Request.EnableBuffering(); + using (var reader = new StreamReader(context.Request.Body, encoding: Encoding.UTF8 + , detectEncodingFromByteOrderMarks: false, leaveOpen: true)) + { + var body = await reader.ReadToEndAsync(); + context.Items.Add("body", body); + context.Request.Body.Position = 0; + } + } + if (context.Request.Method.Equals("Put", StringComparison.OrdinalIgnoreCase)) + { + context.Request.EnableBuffering(); + + using (var reader = new StreamReader(context.Request.Body, encoding: Encoding.UTF8 + , detectEncodingFromByteOrderMarks: false, leaveOpen: true)) + { + var body = await reader.ReadToEndAsync(); + context.Items.Add("body", body); + context.Request.Body.Position = 0; + } + } + await next.Invoke(); + }); + app.UseHangfireDashboard("/hang", new DashboardOptions + { + IgnoreAntiforgeryToken = true, + DashboardTitle = "Hangfire���", + Authorization = new[] { new MyHangfireFilter() }, + IsReadOnlyFunc = (DashboardContext context) => false + }); + var LocalTimeZone = new RecurringJobOptions + { + TimeZone = TimeZoneInfo.Local + }; + //RecurringJob.AddOrUpdate("OverdueTasks", x => x.OverdueTasks(), "0 5 0 * * ?", LocalTimeZone); + + + app.UseHttpsRedirection(); + + app.UseAuthorization(); + + + app.MapControllers(); + + // 映射YARP反向代理 + app.MapReverseProxy(); + + + + app.Run(); + } + } +} diff --git a/YuanXuan.IM.Api/Properties/launchSettings.json b/YuanXuan.IM.Api/Properties/launchSettings.json new file mode 100644 index 0000000..440e541 --- /dev/null +++ b/YuanXuan.IM.Api/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:34020", + "sslPort": 44342 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5070", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7045;http://localhost:5070", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/YuanXuan.IM.Api/Proxy/NacosProxyConfigProvider.cs b/YuanXuan.IM.Api/Proxy/NacosProxyConfigProvider.cs new file mode 100644 index 0000000..11c94bb --- /dev/null +++ b/YuanXuan.IM.Api/Proxy/NacosProxyConfigProvider.cs @@ -0,0 +1,269 @@ +using Nacos.V2; +using Newtonsoft.Json; +using System.Threading.Tasks; +using Yarp.ReverseProxy.Configuration; + +namespace YuanXuan.IM.Api.Proxy +{ + /// + /// Nacos代理配置提供者 + /// + public class NacosProxyConfigProvider : IProxyConfigProvider + { + private readonly INacosNamingService _nacosNamingService; + private readonly IConfiguration _configuration; + private volatile IProxyConfig _config; + private CancellationTokenSource _cts; + + public NacosProxyConfigProvider(INacosNamingService nacosNamingService, IConfiguration configuration) + { + _nacosNamingService = nacosNamingService; + _configuration = configuration; + + // 立即从Nacos获取服务实例 + _config = CreateConfig(); + // 通知YARP配置已变更 + ProxyConfig.SignalChange(); + _cts = new CancellationTokenSource(); + + // 启动后台任务,定期从Nacos更新服务实例 + Task.Run(async () => await UpdateConfigPeriodically(), _cts.Token); + } + + /// + /// 创建配置 + /// + /// + private IProxyConfig CreateConfig() + { + var routes = new List + { + new RouteConfig + { + RouteId = "api_route", + ClusterId = "api_cluster", + Match = new RouteMatch + { + Path = "/api/{**catch-all}" + } + }, + new RouteConfig + { + RouteId = "user_route", + ClusterId = "user_cluster", + Match = new RouteMatch + { + Path = "/user/{**catch-all}" + } + }, + new RouteConfig + { + RouteId = "testdemo1", + ClusterId = "test_cluster", + Match = new RouteMatch + { + Path = "/test/{**catch-all}" + } + } + }; + + var clusters = new List(); + + try + { + // 从Nacos获取YuanXuan.Api服务实例,指定分组为qx-im + var apiServiceInstances = _nacosNamingService.GetAllInstances("YuanXuan.Api", "qx-im").Result; + + if (apiServiceInstances.Any()) + { + // 创建api_cluster集群配置 + var apiDestinations = new Dictionary(); + + foreach (var instance in apiServiceInstances) + { + if (instance.Healthy) + { + var destinationId = $"{instance.Ip}:{instance.Port}"; + apiDestinations.Add(destinationId, new DestinationConfig + { + Address = $"http://{instance.Ip}:{instance.Port}" + }); + } + } + + clusters.Add(new ClusterConfig + { + ClusterId = "api_cluster", + Destinations = apiDestinations + }); + } + + // 从Nacos获取User.Service服务实例,指定分组为qx-im + try + { + var userServiceInstances = _nacosNamingService.GetAllInstances("User.Service", "qx-im").Result; + + if (userServiceInstances.Any()) + { + // 创建user_cluster集群配置 + var userDestinations = new Dictionary(); + + foreach (var instance in userServiceInstances) + { + if (instance.Healthy) + { + var destinationId = $"{instance.Ip}:{instance.Port}"; + userDestinations.Add(destinationId, new DestinationConfig + { + Address = $"http://{instance.Ip}:{instance.Port}" + }); + } + } + + clusters.Add(new ClusterConfig + { + ClusterId = "user_cluster", + Destinations = userDestinations + }); + } + } + catch (Exception ex) + { + Console.WriteLine($"从Nacos获取User.Service服务实例失败: {ex.Message}"); + } + + // 从Nacos获取Test.Service服务实例,指定分组为qx-im + try + { + var testServiceInstances = _nacosNamingService.GetAllInstances("Test.Service", "qx-im").Result; + + if (testServiceInstances.Any()) + { + // 创建test_cluster集群配置 + var testDestinations = new Dictionary(); + + foreach (var instance in testServiceInstances) + { + if (instance.Healthy) + { + var destinationId = $"{instance.Ip}:{instance.Port}"; + testDestinations.Add(destinationId, new DestinationConfig + { + Address = $"http://{instance.Ip}:{instance.Port}" + }); + } + } + + clusters.Add(new ClusterConfig + { + ClusterId = "test_cluster", + Destinations = testDestinations + }); + } + } + catch (Exception ex) + { + Console.WriteLine($"从Nacos获取Test.Service服务实例失败: {ex.Message}"); + } + + Console.WriteLine($"{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")}:" + JsonConvert.SerializeObject(clusters)); + } + catch (Exception ex) + { + Console.WriteLine($"从Nacos获取服务实例失败: {ex.Message}"); + } + + // 如果没有从Nacos获取到api_cluster集群,使用默认配置 + if (!clusters.Any(c => c.ClusterId == "api_cluster")) + { + clusters.Add(new ClusterConfig + { + ClusterId = "api_cluster", + Destinations = new Dictionary + { + { + "default", new DestinationConfig { Address = "http://localhost:5001" } + } + } + }); + } + + // 如果没有从Nacos获取到user_cluster集群,使用默认配置 + if (!clusters.Any(c => c.ClusterId == "user_cluster")) + { + clusters.Add(new ClusterConfig + { + ClusterId = "user_cluster", + Destinations = new Dictionary + { + { + "default", new DestinationConfig { Address = "http://localhost:5181" } + } + } + }); + } + + // 如果没有从Nacos获取到test_cluster集群,使用默认配置 + if (!clusters.Any(c => c.ClusterId == "test_cluster")) + { + clusters.Add(new ClusterConfig + { + ClusterId = "test_cluster", + Destinations = new Dictionary + { + { + "default", new DestinationConfig { Address = "http://localhost:5252" } + } + } + }); + } + + return new ProxyConfig(routes, clusters, DateTime.UtcNow); + } + + /// + /// 获取代理配置 + /// + /// + public IProxyConfig GetConfig() => _config; + + /// + /// 定期更新配置 + /// + /// + private async Task UpdateConfigPeriodically() + { + while (!_cts.Token.IsCancellationRequested) + { + try + { + await Task.Delay(TimeSpan.FromSeconds(30), _cts.Token); // 每30秒更新一次 + await UpdateConfig(); + } + catch (TaskCanceledException) + { + // 任务被取消,退出循环 + break; + } + catch (Exception ex) + { + Console.WriteLine($"更新Nacos代理配置失败: {ex.Message}"); + } + } + } + + /// + /// 更新配置 + /// + /// + private async Task UpdateConfig() + { + // 重新从Nacos获取服务实例并更新配置 + _config = CreateConfig(); + // 通知YARP配置已变更 + ProxyConfig.SignalChange(); + } + + } + +} diff --git a/YuanXuan.IM.Api/Proxy/ProxyConfig.cs b/YuanXuan.IM.Api/Proxy/ProxyConfig.cs new file mode 100644 index 0000000..2046680 --- /dev/null +++ b/YuanXuan.IM.Api/Proxy/ProxyConfig.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.Primitives; +using System.Threading; +using Yarp.ReverseProxy.Configuration; + +namespace YuanXuan.IM.Api.Proxy +{ + /// + /// 代理配置 + /// + public class ProxyConfig : IProxyConfig + { + private static CancellationTokenSource _cts = new CancellationTokenSource(); + + public ProxyConfig(IReadOnlyList routes, IReadOnlyList clusters, DateTime timestamp) + { + Routes = routes; + Clusters = clusters; + Timestamp = timestamp; + } + + public IReadOnlyList Routes { get; } + public IReadOnlyList Clusters { get; } + public DateTime Timestamp { get; } + public IChangeToken ChangeToken => new CancellationChangeToken(_cts.Token); + + /// + /// 通知配置变更 + /// + public static void SignalChange() + { + var oldCts = Interlocked.Exchange(ref _cts, new CancellationTokenSource()); + oldCts.Cancel(); + } + } +} diff --git a/YuanXuan.IM.Api/YuanXuan.IM.Api.csproj b/YuanXuan.IM.Api/YuanXuan.IM.Api.csproj new file mode 100644 index 0000000..f48e103 --- /dev/null +++ b/YuanXuan.IM.Api/YuanXuan.IM.Api.csproj @@ -0,0 +1,36 @@ + + + + net8.0 + enable + enable + True + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/YuanXuan.IM.Api/YuanXuan.IM.Api.http b/YuanXuan.IM.Api/YuanXuan.IM.Api.http new file mode 100644 index 0000000..20f0493 --- /dev/null +++ b/YuanXuan.IM.Api/YuanXuan.IM.Api.http @@ -0,0 +1,6 @@ +@YuanXuan.IM.Api_HostAddress = http://localhost:5070 + +GET {{YuanXuan.IM.Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/YuanXuan.IM.Api/appsettings.Development.json b/YuanXuan.IM.Api/appsettings.Development.json new file mode 100644 index 0000000..ea8fb2e --- /dev/null +++ b/YuanXuan.IM.Api/appsettings.Development.json @@ -0,0 +1,28 @@ +{ + + "nacos": { + "Namespace": "public", //对应的是命名空间的Id + "ServerAddresses": [ "http://192.168.2.9:8848" ], //是Nacos的服务器地址,可以添加多个 + "UserName": "nacos", // 你的Nacos用户名 + "Password": "qwe123!@#", // 你的Nacos密码 + "Group": "qx-im", // 默认配置分组,可按需修改 + "Listeners": [ //对应配置文件 + { + "Optional": false, + "DataId": "qx-im-api-dev", //配置名称 + "Group": "qx-im" //组名 + } + ], + "ServiceName": "YuanXuan.Api", //服务名称 + // Ncaos 注册中心的配置 + "DefaultTimeOut": 15000, + "ListenInterval": 1000, + "GroupName": "qx-im", + "RegisterEnabled": true, + "InstanceEnabled": true, + "Ephemeral": true, + "ConfigUseRpc": true, + "NamingUseRpc": true, + "LBStrategy": "WeightRandom" //WeightRandom WeightRoundRobin + } +} diff --git a/YuanXuan.IM.Api/appsettings.json b/YuanXuan.IM.Api/appsettings.json new file mode 100644 index 0000000..fac20ae --- /dev/null +++ b/YuanXuan.IM.Api/appsettings.json @@ -0,0 +1,21 @@ +{ + + "nacos": { + "Listeners": [ //对应配置文件 + { + "Optional": false, + "DataId": "qx-im-api-dev", //配置名称 + "Group": "qx-im" //组名 + } + ], + "DefaultTimeOut": 15, + "ListenInterval": 1000, + "ServiceName": "qx-im-api-dev", //服务名称 + "Namespace": "public", //对应的是命名空间的Id + "ServerAddresses": [ "http://192.168.2.9:8848" ], //是Nacos的服务器地址,可以添加多个 + "UserName": "nacos", + "Password": "qwe123!@#", + "ConfigUseRpc": false, //false-http协议,true-grpc + "NamingUseRpc": false //false-http协议,true-grpc + } +} diff --git a/YuanXuan.IM.Common/Attributes/InjectServiceAttribute.cs b/YuanXuan.IM.Common/Attributes/InjectServiceAttribute.cs new file mode 100644 index 0000000..c9720ac --- /dev/null +++ b/YuanXuan.IM.Common/Attributes/InjectServiceAttribute.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace YuanXuan.IM.Common.Attributes +{ + [AttributeUsage(AttributeTargets.Class)] + public class InjectAttribute : Attribute + { + public ServiceLifetime Lifetime { get; set; } = ServiceLifetime.Scoped; + } +} diff --git a/YuanXuan.IM.Common/Configs/ConnectionStringsSettings.cs b/YuanXuan.IM.Common/Configs/ConnectionStringsSettings.cs new file mode 100644 index 0000000..cdb064d --- /dev/null +++ b/YuanXuan.IM.Common/Configs/ConnectionStringsSettings.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace YuanXuan.IM.Common.Configs +{ + public class ConnectionStringsSettings + { + public string Redis { get; set; } + public string Db { get; set; } + public string UserCenterDb { get; set; } + } +} diff --git a/YuanXuan.IM.Common/Configs/HangFireSettings.cs b/YuanXuan.IM.Common/Configs/HangFireSettings.cs new file mode 100644 index 0000000..ff7d775 --- /dev/null +++ b/YuanXuan.IM.Common/Configs/HangFireSettings.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Hangfire; + +namespace YuanXuan.IM.Common.Configs +{ + public class HangFireSettings + { + public string ConnectionString { get; set; } + public int Db { get; set; } = 0; + public string cron { get; set; } + + public static JobStorage hangfireStorage { get; set; } + } +} diff --git a/YuanXuan.IM.Common/Configs/ImConfig.cs b/YuanXuan.IM.Common/Configs/ImConfig.cs new file mode 100644 index 0000000..803da5a --- /dev/null +++ b/YuanXuan.IM.Common/Configs/ImConfig.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace YuanXuan.IM.Common.Configs +{ + public class ImConfig + { + public int SDKAppID { get; set; } + public string Key { get; set; } + public int ExpiredTime { get; set; } + /// + /// 头像默认地址 + /// + public string DefaultAvater { get; set; } + /// + /// 管理员用户 + /// + public string AdminUserId { get; set; } + /// + /// api头部地址 + /// + public string ApiHeadUrl { get; set; } + /// + /// 视频通话状态 + /// + public bool VideoCall { get; set; } + /// + /// 语音通话状态 + /// + public bool VoiceCall { get; set; } + } + public class ImConfigResult + { + + public int SDKAppID { get; set; } + /// + /// 视频通话状态 + /// + public bool VideoCall { get; set; } + /// + /// 语音通话状态 + /// + public bool VoiceCall { get; set; } + } +} diff --git a/YuanXuan.IM.Common/Configs/JwtSettings.cs b/YuanXuan.IM.Common/Configs/JwtSettings.cs new file mode 100644 index 0000000..c6d4158 --- /dev/null +++ b/YuanXuan.IM.Common/Configs/JwtSettings.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace YuanXuan.IM.Common.Configs +{ + public class JwtSettings + { + /// + /// AccessToken密钥 + /// + public string AccessSecret { get; set; } = string.Empty; + /// + /// 签发人 + /// + public string Issuer { get; set; } = string.Empty; + /// + /// 受众 + /// + public string Audience { get; set; } = string.Empty; + /// + /// AccessToken有效时长 + /// + public int AccessExpiration { get; set; } + /// + /// RefreshExpiration有效时长 + /// + public int RefreshExpiration { get; set; } + /// + /// 允许的时差 + /// + public int ClockSkew { get; set; } + } +} diff --git a/YuanXuan.IM.Common/Configs/NacosConfig.cs b/YuanXuan.IM.Common/Configs/NacosConfig.cs new file mode 100644 index 0000000..0afcac4 --- /dev/null +++ b/YuanXuan.IM.Common/Configs/NacosConfig.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace YuanXuan.IM.Common.Configs +{ + public class NacosConfig + { + /// + /// Nacos命名空间 + /// + public string Namespace { get; set; } + } +} diff --git a/YuanXuan.IM.Common/Configs/RabbitMQConfig.cs b/YuanXuan.IM.Common/Configs/RabbitMQConfig.cs new file mode 100644 index 0000000..cf36551 --- /dev/null +++ b/YuanXuan.IM.Common/Configs/RabbitMQConfig.cs @@ -0,0 +1,72 @@ +using RabbitMQ.Client; + +namespace YuanXuan.IM.Common.Configs +{ + /// + /// RabbitMQ 配置 + /// + public class RabbitMQConfig + { + /// + /// RabbitMQ 服务器地址 + /// + public string HostName { get; set; } + + /// + /// 端口号 + /// + public int Port { get; set; } + + /// + /// 用户名 + /// + public string UserName { get; set; } + + /// + /// 密码 + /// + public string Password { get; set; } + + /// + /// 虚拟主机 + /// + public string VirtualHost { get; set; } + + /// + /// im推送mq配置 + /// + public BaseMqBusinessConfig imMq { get; set; } + } + /// + /// 对应业务的mq配置 + /// + public class BaseMqBusinessConfig + { + /// + /// 队列名称 + /// + public string QueueName { get; set; } + /// + /// 交换机名称 + /// + public string ExchangeName { get; set; } + /// + /// 路由键 + /// + public string RoutingKey { get; set; } + /// + /// 是否启用消息持久化 + /// + public bool Durable { get; set; } = true; + + /// + /// 是否自动删除队列 + /// + public bool AutoDelete { get; set; } = false; + + /// + /// 预取计数 + /// + public ushort PrefetchCount { get; set; } + } +} \ No newline at end of file diff --git a/YuanXuan.IM.Common/Configs/UpAppVersionConfig.cs b/YuanXuan.IM.Common/Configs/UpAppVersionConfig.cs new file mode 100644 index 0000000..95c2d3d --- /dev/null +++ b/YuanXuan.IM.Common/Configs/UpAppVersionConfig.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace YuanXuan.IM.Common.Configs +{ + public class UpAppVersionConfig + { + /// + /// IOS链接 + /// + public string IOSUrl { get; set; } + } +} diff --git a/YuanXuan.IM.Common/Dtos/ALiYun/CodeMsg.cs b/YuanXuan.IM.Common/Dtos/ALiYun/CodeMsg.cs new file mode 100644 index 0000000..7a3a8c6 --- /dev/null +++ b/YuanXuan.IM.Common/Dtos/ALiYun/CodeMsg.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace YuanXuan.IM.Common.Dtos.ALiYun +{ + public class CodeMsg + { + public int Code { get; set; } + public string Data { get; set; } + public string Reson { get; set; } + } +} diff --git a/YuanXuan.IM.Common/Dtos/ALiYun/OSSConfigResult.cs b/YuanXuan.IM.Common/Dtos/ALiYun/OSSConfigResult.cs new file mode 100644 index 0000000..eb4cdd6 --- /dev/null +++ b/YuanXuan.IM.Common/Dtos/ALiYun/OSSConfigResult.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace YuanXuan.IM.Common.Dtos.ALiYun +{ + public class OSSConfigResult + { + public string AccessKeyId { get; set; } + public string AccessKeySecret { get; set; } + public string Endpoint { get; set; } + public string BucketName { get; set; } + public long Size { get; set; } + } +} diff --git a/YuanXuan.IM.Common/Dtos/Consul/ConsulServiceInstance.cs b/YuanXuan.IM.Common/Dtos/Consul/ConsulServiceInstance.cs new file mode 100644 index 0000000..72e9bbe --- /dev/null +++ b/YuanXuan.IM.Common/Dtos/Consul/ConsulServiceInstance.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace YuanXuan.IM.Common.Dtos.Consul +{ + public class ConsulServiceInstance + { + + /// + /// 服务ID + /// + public string Id { get; set; } + /// + /// 服务名称 + /// + public string Name { get; set; } + /// + /// 服务地址 + /// + public string Address { get; set; } + /// + /// 服务端口 + /// + public int Port { get; set; } + /// + /// 是否健康 + /// + public bool IsHealthy { get; set; } + } +} diff --git a/YuanXuan.IM.Common/Dtos/LoginMobile/LoginRequest.cs b/YuanXuan.IM.Common/Dtos/LoginMobile/LoginRequest.cs new file mode 100644 index 0000000..28043ca --- /dev/null +++ b/YuanXuan.IM.Common/Dtos/LoginMobile/LoginRequest.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace YuanXuan.IM.Common.Dtos.LoginMobile +{ + public class LoginRequest + { + /// + /// 用户名 + /// + public string username { get; set; } + /// + /// 密码 + /// + public string Pwd { get; set; } + /// + /// 登录类型 1:Android;2Ios;3扫码;4:H5 + /// + public int loginType { get; set; } = 1; + } +} diff --git a/YuanXuan.IM.Common/Dtos/LoginMobile/MobileTokenInfo.cs b/YuanXuan.IM.Common/Dtos/LoginMobile/MobileTokenInfo.cs new file mode 100644 index 0000000..e9b7f4b --- /dev/null +++ b/YuanXuan.IM.Common/Dtos/LoginMobile/MobileTokenInfo.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace YuanXuan.IM.Common.Dtos.LoginMobile +{ + public class MobileTokenInfo + { + /// + /// 用户Id + /// + public string UserId { get; set; } + /// + /// 用户名称 + /// + public string UserName { get; set; } + /// + /// Jwt_id + /// + public string Jwt_id { get; set; } + } +} diff --git a/YuanXuan.IM.Common/Dtos/LoginMobile/RefreshTokenRequest.cs b/YuanXuan.IM.Common/Dtos/LoginMobile/RefreshTokenRequest.cs new file mode 100644 index 0000000..5f133d4 --- /dev/null +++ b/YuanXuan.IM.Common/Dtos/LoginMobile/RefreshTokenRequest.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace YuanXuan.IM.Common.Dtos.LoginMobile +{ + public class RefreshTokenRequest + { + /// + /// 过期的token + /// + public string Token { get; set; } + + /// + /// 刷新token + /// + public string RefreshToken { get; set; } + } +} diff --git a/YuanXuan.IM.Common/Entities/BaseEntity.cs b/YuanXuan.IM.Common/Entities/BaseEntity.cs new file mode 100644 index 0000000..e97c702 --- /dev/null +++ b/YuanXuan.IM.Common/Entities/BaseEntity.cs @@ -0,0 +1,19 @@ +using SqlSugar; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace YuanXuan.IM.Common.Entities +{ + /// + /// 基础数据库实体类 + /// + public class BaseEntity + { + [SugarColumn(IsPrimaryKey = true)] + public long Id { get; set; } + + } +} diff --git a/YuanXuan.IM.Common/Enums/QRCodeTypeEnum.cs b/YuanXuan.IM.Common/Enums/QRCodeTypeEnum.cs new file mode 100644 index 0000000..3e91726 --- /dev/null +++ b/YuanXuan.IM.Common/Enums/QRCodeTypeEnum.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace YuanXuan.IM.Common.Enums +{ + /// + /// 二维码用途类型 + /// + public enum QRCodeTypeEnum + { + /// + /// 登录到PC + /// + LoginPC = 1, + /// + /// 登录到统计 + /// + LoginStatistic =2 + } +} diff --git a/YuanXuan.IM.Common/Enums/SysRoleEnum.cs b/YuanXuan.IM.Common/Enums/SysRoleEnum.cs new file mode 100644 index 0000000..852d5a0 --- /dev/null +++ b/YuanXuan.IM.Common/Enums/SysRoleEnum.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace YuanXuan.IM.Common.Enums +{ + /// + /// 系统角色枚举 + /// + public enum SysRoleEnum + { + /// + /// 超级管理员 + /// + [Description("超级管理员")] + SuperAdmin = 1, + /// + /// 云校管理员 + /// + [Description("云校管理员")] + CloudSchoolAdmin = 2, + /// + /// 运营管理员 + /// + OperationAdmin = 3, + /// + /// 总部长 + /// + [Description("总部长")] + GeneralMinisterAdmin = 1000, + /// + /// 部长 + /// + [Description("部长")] + MinisterAdmin = 1001, + /// + /// 组长 + /// + [Description("组长")] + TeamLeader = 1002, + /// + /// 学习官 + /// + [Description("学习官")] + LearningOfficer = 1003, + } +} diff --git a/YuanXuan.IM.Common/Exceptions/BusinessExcpetion.cs b/YuanXuan.IM.Common/Exceptions/BusinessExcpetion.cs new file mode 100644 index 0000000..a0bb4dd --- /dev/null +++ b/YuanXuan.IM.Common/Exceptions/BusinessExcpetion.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace YuanXuan.IM.Common.Exceptions +{ + /// + /// 业务异常 + /// + public class BusinessException : Exception + { + + public BusinessExceptionCode ErrorCode { get; private set; } + public object BussinessExceptionData { get; private set; } + + public BusinessException(string message, object friendlyData = null, BusinessExceptionCode errorCode = BusinessExceptionCode.BussinessError) : base(message) + { + ErrorCode = errorCode; + BussinessExceptionData = friendlyData; + } + } + + /// + /// 业务异常码 + /// + public enum BusinessExceptionCode + { + /// + /// 业务异常码 + /// + BussinessError = 1000, + + /// + /// 程序异常码 + /// + AppError = 500, + + /// + /// 访问令牌异常码 + /// + AccessTokenError = 401, + + /// + /// 访问令牌被顶号异常码 + /// + AccessTokenTopNumberError = 402, + } +} diff --git a/YuanXuan.IM.Common/Helpers/AesEncryptHelper.cs b/YuanXuan.IM.Common/Helpers/AesEncryptHelper.cs new file mode 100644 index 0000000..525603b --- /dev/null +++ b/YuanXuan.IM.Common/Helpers/AesEncryptHelper.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.IO.Compression; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; + +namespace YuanXuan.IM.Common.Helpers +{ + public static class AesEncryptHelper + { + static string key = "ThisIsAVerySecre"; + static string iv = "16ByteIV12345678"; + + // 使用CBC模式加密并返回Base64字符串 + public static string EncryptToShortString(string plainText) + { + using (Aes aesAlg = Aes.Create()) + { + aesAlg.Key = Encoding.UTF8.GetBytes(key); + aesAlg.IV = Encoding.UTF8.GetBytes(iv); + aesAlg.Mode = CipherMode.CBC; + aesAlg.Padding = PaddingMode.PKCS7; + + ICryptoTransform encryptor = aesAlg.CreateEncryptor(); + byte[] encryptedBytes = encryptor.TransformFinalBlock( + Encoding.UTF8.GetBytes(plainText), 0, plainText.Length); + + return Convert.ToBase64String(encryptedBytes); + } + } + + // 解密Base64加密字符串 + public static string DecryptFromShortString(string cipherText) + { + using (Aes aesAlg = Aes.Create()) + { + aesAlg.Key = Encoding.UTF8.GetBytes(key); + aesAlg.IV = Encoding.UTF8.GetBytes(iv); + aesAlg.Mode = CipherMode.CBC; + aesAlg.Padding = PaddingMode.PKCS7; + + ICryptoTransform decryptor = aesAlg.CreateDecryptor(); + byte[] cipherBytes = Convert.FromBase64String(cipherText); + byte[] decryptedBytes = decryptor.TransformFinalBlock( + cipherBytes, 0, cipherBytes.Length); + + return Encoding.UTF8.GetString(decryptedBytes); + } + } + } +} diff --git a/YuanXuan.IM.Common/Helpers/AliyunOssHelper.cs b/YuanXuan.IM.Common/Helpers/AliyunOssHelper.cs new file mode 100644 index 0000000..297eb02 --- /dev/null +++ b/YuanXuan.IM.Common/Helpers/AliyunOssHelper.cs @@ -0,0 +1,52 @@ +using Aliyun.OSS; +using Aliyun.OSS.Common; +using Castle.Core.Logging; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.AccessControl; +using System.Text; +using System.Threading.Tasks; +using YuanXuan.IM.Common.Attributes; +using YuanXuan.IM.Common.Dtos.ALiYun; + +namespace YuanXuan.IM.Common.Helpers +{ + [Inject(Lifetime = Microsoft.Extensions.DependencyInjection.ServiceLifetime.Transient)] + public class AliyunOssHelper + { + private readonly IOptionsMonitor ossConfig; + private readonly ILogger logger; + + public AliyunOssHelper(IOptionsMonitor ossConfig, + ILogger logger) + { + this.ossConfig = ossConfig; + this.logger = logger; + } + + public string UploadByStream(Stream stream, string fileName) + { + try + { + var client = new OssClient(ossConfig.CurrentValue.Endpoint, ossConfig.CurrentValue.AccessKeyId, ossConfig.CurrentValue.AccessKeySecret, new ClientConfiguration() + { + SignatureVersion = SignatureVersion.V4 + }); + client.SetRegion("cn-chengdu"); + client.PutObject(ossConfig.CurrentValue.BucketName, fileName, stream); + + return $"https://{ossConfig.CurrentValue.BucketName}.{ossConfig.CurrentValue.Endpoint}/{fileName.TrimStart('/')}"; + } + catch (Exception ex) + { + logger.LogError("AliyunOssHelper UploadByStream Error:{0}", ex.Message); + } + + return string.Empty; + } + } +} diff --git a/YuanXuan.IM.Common/Helpers/ExceptionNotice.cs b/YuanXuan.IM.Common/Helpers/ExceptionNotice.cs new file mode 100644 index 0000000..93f5650 --- /dev/null +++ b/YuanXuan.IM.Common/Helpers/ExceptionNotice.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http.Json; +using System.Text; +using System.Threading.Tasks; + +namespace YuanXuan.IM.Common.Helpers +{ + /// + /// 异常通知 + /// + public class ExceptionNotice + { + private static HttpClient httpClient = new HttpClient() + { + BaseAddress = new Uri("https://oapi.dingtalk.com/robot/send?access_token=339d1f43d3b2a084abaa77871ddd187b613206149962d844adf37a46a14359a1"), + }; + + /// + /// 发送异常信息 + /// + /// 异常 + /// 异常来源(用于显示) + /// + public static async Task SendAsync(Exception exp, string expSrc) + { + var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development"; + if (env == "Development") + { + Console.WriteLine("*************** Excpetion ***************"); + Console.WriteLine(exp.Message, exp); + Console.WriteLine("*************** Excpetion ***************"); + return true; + } + + var reponse = await httpClient.PostAsync(string.Empty, JsonContent.Create(new + { + msgtype = "markdown", + markdown = new + { + title = "Mobile.API抛出异常", + text = $"Mobile.API异常.描述:{exp.Message}\n详情:{exp}" + }, + })); + return reponse.IsSuccessStatusCode; + + } + + } +} diff --git a/YuanXuan.IM.Common/Helpers/JwtHelper.cs b/YuanXuan.IM.Common/Helpers/JwtHelper.cs new file mode 100644 index 0000000..b07928b --- /dev/null +++ b/YuanXuan.IM.Common/Helpers/JwtHelper.cs @@ -0,0 +1,133 @@ +using Microsoft.IdentityModel.Tokens; +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Security.Claims; +using System.Text; +using System.Threading.Tasks; +using YuanXuan.IM.Common.Configs; +using Microsoft.Extensions.Configuration; +using System.Reflection; +using System.IO; + +namespace YuanXuan.IM.Common.Helpers +{ + public class JwtHelper + { + private static JwtSettings _jwtSettings; + + static JwtHelper() + { + // 初始化JWT配置 + var configuration = new ConfigurationBuilder() + .SetBasePath(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)) + .AddJsonFile("appsettings.json", optional: true) + .AddJsonFile("appsettings.Development.json", optional: true) + .Build(); + + _jwtSettings = configuration.GetSection("Jwt").Get(); + } + + /// + /// 生成token + /// + /// + /// + /// + /// + /// + /// + /// + public static string CreateToken(string uid, string secretKey, string issuer, string audience, double expires, List claims = null) + { + if (claims.IsNullOrEmpty()) + claims = new(); + claims.AddRange(new List + { + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new Claim(ClaimTypes.NameIdentifier, uid), + }); + + // 2. 从 appsettings.json 中读取SecretKey + var secret = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)); + + // 3. 选择加密算法 + var algorithm = SecurityAlgorithms.HmacSha256; + + // 4. 生成Credentials + var signingCredentials = new SigningCredentials(secret, algorithm); + + // 5. 根据以上,生成token + var jwtSecurityToken = new JwtSecurityToken( + issuer, //Issuer + audience, //Audience + claims, //Claims, + DateTime.Now, //notBefore + DateTime.Now.AddSeconds(expires), //expires + signingCredentials //Credentials + ); + + // 6. 将token变为string + var token = new JwtSecurityTokenHandler().WriteToken(jwtSecurityToken); + + return token; + } + + /// + /// 生成JWT token + /// + /// + /// + /// + public static string GenerateToken(string userId, string userName) + { + var claims = new List + { + new Claim(JwtRegisteredClaimNames.Sub, userId), + new Claim(JwtRegisteredClaimNames.Name, userName), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + }; + + return CreateToken(userId, _jwtSettings.AccessSecret, _jwtSettings.Issuer, _jwtSettings.Audience, _jwtSettings.AccessExpiration); + } + + /// + /// 生成刷新token + /// + /// + public static string GenerateRefreshToken() + { + return Guid.NewGuid().ToString("N"); + } + + /// + /// 从过期的token中获取principal + /// + /// + /// + public static ClaimsPrincipal GetPrincipalFromExpiredToken(string token) + { + var tokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = _jwtSettings.Issuer, + ValidateAudience = true, + ValidAudience = _jwtSettings.Audience, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.AccessSecret)), + ValidateLifetime = false // 不验证过期时间 + }; + + var tokenHandler = new JwtSecurityTokenHandler(); + var principal = tokenHandler.ValidateToken(token, tokenValidationParameters, out var securityToken); + + if (!(securityToken is JwtSecurityToken jwtSecurityToken) || !jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase)) + { + throw new SecurityTokenException("无效的token"); + } + + return principal; + } + } +} diff --git a/YuanXuan.IM.Common/Helpers/MD5EncryptionHelper.cs b/YuanXuan.IM.Common/Helpers/MD5EncryptionHelper.cs new file mode 100644 index 0000000..75fd13b --- /dev/null +++ b/YuanXuan.IM.Common/Helpers/MD5EncryptionHelper.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; + +namespace YuanXuan.IM.Common.Helpers +{ + /// + /// MD5帮助类 + /// + public static class MD5EncryptionHelper + { + /// + /// MD5字符串加密 + /// + /// + /// 加密后字符串 + public static string MD5Encryption(string txt) + { + using (MD5 mi = MD5.Create()) + { + byte[] buffer = Encoding.Default.GetBytes(txt); + //开始加密 + byte[] newBuffer = mi.ComputeHash(buffer); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < newBuffer.Length; i++) + { + sb.Append(newBuffer[i].ToString("x2")); + } + return sb.ToString().ToUpper(); ; + } + } + + } +} diff --git a/YuanXuan.IM.Common/Helpers/QRCoderHelper.cs b/YuanXuan.IM.Common/Helpers/QRCoderHelper.cs new file mode 100644 index 0000000..8557b95 --- /dev/null +++ b/YuanXuan.IM.Common/Helpers/QRCoderHelper.cs @@ -0,0 +1,39 @@ +using QRCoder; + +namespace YuanXuan.IM.Common.Helpers +{ + /// + /// 二维码生成器 + /// + public class QRCoderHelper + { + /// + /// 生成二维码的 URL + /// + /// 网络路径 + /// 二维码图片的base64字符串 + public static string GenerateQRCodeUrl(string filePath) + { + + if (string.IsNullOrWhiteSpace(filePath)) + { + throw new ArgumentException("文件路径不能为空", nameof(filePath)); + } + + using (var qrGenerator = new QRCodeGenerator()) + { + // 生成二维码数据 + var qrCodeData = qrGenerator.CreateQrCode(filePath, QRCodeGenerator.ECCLevel.Q); + + // 使用 PngByteQRCode 生成二维码字节数组 + var pngByteQRCode = new PngByteQRCode(qrCodeData); + var qrCodeBytes = pngByteQRCode.GetGraphic(20); + + // 将字节数组转换为 Base64 字符串 + var base64String = Convert.ToBase64String(qrCodeBytes); + return $"data:image/png;base64,{base64String}"; + + } + } + } +} diff --git a/YuanXuan.IM.Common/Helpers/RestSharpHelper.cs b/YuanXuan.IM.Common/Helpers/RestSharpHelper.cs new file mode 100644 index 0000000..df1d9a0 --- /dev/null +++ b/YuanXuan.IM.Common/Helpers/RestSharpHelper.cs @@ -0,0 +1,123 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using RestSharp; + +namespace YuanXuan.IM.Common.Helpers +{ + /// + /// RestSharpHelper + /// + public class RestSharpHelper + { + + private readonly RestClient _client; + + /// + /// 构造函数,初始化 RestClient + /// + public RestSharpHelper() + { + _client = new RestClient(); + } + + /// + /// 发送 GET 请求 + /// + /// 完整的请求 URL + /// 请求头 + /// 响应数据 + public async Task GetAsync(string url, Dictionary? headers = null) + { + var request = new RestRequest(url, Method.Get); + AddHeaders(request, headers); + + var response = await _client.ExecuteAsync(request); + return HandleResponse(response); + } + + /// + /// 发送 POST 请求 + /// + /// 完整的请求 URL + /// 请求体 + /// 请求头 + /// 响应数据 + public async Task PostAsync(string url, object body, Dictionary? headers = null) + { + var request = new RestRequest(url, Method.Post); + AddHeaders(request, headers); + request.AddBody(body); + + var response = await _client.ExecuteAsync(request); + return HandleResponse(response); + } + + /// + /// 发送 POST 请求 + /// + /// 完整的请求 URL + /// 请求体 + /// 请求头 + /// 响应数据 + public async Task PostByJsonBodyAsync(string url, object body, Dictionary? headers = null) + { + var request = new RestRequest(url, Method.Post); + AddHeaders(request, headers); + request.AddJsonBody(body); + + var response = await _client.ExecuteAsync(request); + return HandleResponse(response); + } + + /// + /// 发送 PUT 请求 + /// + /// 完整的请求 URL + /// 请求体 + /// 请求头 + /// 响应数据 + public async Task PutAsync(string url, object body, Dictionary? headers = null) + { + var request = new RestRequest(url, Method.Put); + AddHeaders(request, headers); + request.AddJsonBody(body); + + var response = await _client.ExecuteAsync(request); + return HandleResponse(response); + } + + /// + /// 添加请求头 + /// + /// RestSharp 请求对象 + /// 请求头 + private void AddHeaders(RestRequest request, Dictionary? headers) + { + if (headers != null) + { + foreach (var header in headers) + { + request.AddHeader(header.Key, header.Value); + } + } + } + + /// + /// 处理响应 + /// + /// RestSharp 响应对象 + /// 响应数据 + /// 当响应失败时抛出异常 + private string HandleResponse(RestResponse response) + { + if (response.IsSuccessful && response.Content != null) + { + return response.Content; + } + + throw new Exception($"请求失败: {response.StatusCode}, 错误信息: {response.ErrorMessage ?? response.Content}"); + } + } +} diff --git a/YuanXuan.IM.Common/Helpers/TLSSigAPIv2.cs b/YuanXuan.IM.Common/Helpers/TLSSigAPIv2.cs new file mode 100644 index 0000000..7a159f3 --- /dev/null +++ b/YuanXuan.IM.Common/Helpers/TLSSigAPIv2.cs @@ -0,0 +1,251 @@ +using System; +using System.IO; +using System.Text; +using System.Security.Cryptography; +using ComponentAce.Compression.Libs.zlib; + + +namespace YuanXuan.IM.Common.Helpers +{ + public class TLSSigAPIv2 + { + + private readonly int sdkappid; + private readonly string key; + + public TLSSigAPIv2(int sdkappid, string key) + { + this.sdkappid = sdkappid; + this.key = key; + } + + /** + *【功能说明】用于签发 TRTC 和 IM 服务中必须要使用的 UserSig 鉴权票据 + * + *【参数说明】 + * userid - 用户id,限制长度为32字节,只允许包含大小写英文字母(a-zA-Z)、数字(0-9)及下划线和连词符。 + * expire - UserSig 票据的过期时间,单位是秒,比如 86400 代表生成的 UserSig 票据在一天后就无法再使用了。 + */ + public string genUserSig(string userid, int expire = 180 * 86400) + { + return genUserSig(userid, expire, null, false); + } + + /** + *【功能说明】 + * 用于签发 TRTC 进房参数中可选的 PrivateMapKey 权限票据。 + * PrivateMapKey 需要跟 UserSig 一起使用,但 PrivateMapKey 比 UserSig 有更强的权限控制能力: + * - UserSig 只能控制某个 UserID 有无使用 TRTC 服务的权限,只要 UserSig 正确,其对应的 UserID 可以进出任意房间。 + * - PrivateMapKey 则是将 UserID 的权限控制的更加严格,包括能不能进入某个房间,能不能在该房间里上行音视频等等。 + * 如果要开启 PrivateMapKey 严格权限位校验,需要在【实时音视频控制台】=>【应用管理】=>【应用信息】中打开“启动权限密钥”开关。 + * + *【参数说明】 + * userid - 用户id,限制长度为32字节,只允许包含大小写英文字母(a-zA-Z)、数字(0-9)及下划线和连词符。 + * roomid - 房间号,用于指定该 userid 可以进入的房间号 + * expire - PrivateMapKey 票据的过期时间,单位是秒,比如 86400 生成的 PrivateMapKey 票据在一天后就无法再使用了。 + * privilegeMap - 权限位,使用了一个字节中的 8 个比特位,分别代表八个具体的功能权限开关: + * - 第 1 位:0000 0001 = 1,创建房间的权限 + * - 第 2 位:0000 0010 = 2,加入房间的权限 + * - 第 3 位:0000 0100 = 4,发送语音的权限 + * - 第 4 位:0000 1000 = 8,接收语音的权限 + * - 第 5 位:0001 0000 = 16,发送视频的权限 + * - 第 6 位:0010 0000 = 32,接收视频的权限 + * - 第 7 位:0100 0000 = 64,发送辅路(也就是屏幕分享)视频的权限 + * - 第 8 位:1000 0000 = 200,接收辅路(也就是屏幕分享)视频的权限 + * - privilegeMap == 1111 1111 == 255 代表该 userid 在该 roomid 房间内的所有功能权限。 + * - privilegeMap == 0010 1010 == 42 代表该 userid 拥有加入房间和接收音视频数据的权限,但不具备其他权限。 + */ + + public string genPrivateMapKey(string userid, int expire, uint roomid, uint privilegeMap) + { + byte[] userbuf = genUserBuf(userid, roomid, expire, privilegeMap, 0, ""); + System.Console.WriteLine(userbuf); + return genUserSig(userid, expire, userbuf, true); + } + /** + *【功能说明】 + * 用于签发 TRTC 进房参数中可选的 PrivateMapKey 权限票据。 + * PrivateMapKey 需要跟 UserSig 一起使用,但 PrivateMapKey 比 UserSig 有更强的权限控制能力: + * - UserSig 只能控制某个 UserID 有无使用 TRTC 服务的权限,只要 UserSig 正确,其对应的 UserID 可以进出任意房间。 + * - PrivateMapKey 则是将 UserID 的权限控制的更加严格,包括能不能进入某个房间,能不能在该房间里上行音视频等等。 + * 如果要开启 PrivateMapKey 严格权限位校验,需要在【实时音视频控制台】=>【应用管理】=>【应用信息】中打开“启动权限密钥”开关。 + * + *【参数说明】 + * userid - 用户id,限制长度为32字节,只允许包含大小写英文字母(a-zA-Z)、数字(0-9)及下划线和连词符。 + * roomstr - 房间号,用于指定该 userid 可以进入的房间号 + * expire - PrivateMapKey 票据的过期时间,单位是秒,比如 86400 生成的 PrivateMapKey 票据在一天后就无法再使用了。 + * privilegeMap - 权限位,使用了一个字节中的 8 个比特位,分别代表八个具体的功能权限开关: + * - 第 1 位:0000 0001 = 1,创建房间的权限 + * - 第 2 位:0000 0010 = 2,加入房间的权限 + * - 第 3 位:0000 0100 = 4,发送语音的权限 + * - 第 4 位:0000 1000 = 8,接收语音的权限 + * - 第 5 位:0001 0000 = 16,发送视频的权限 + * - 第 6 位:0010 0000 = 32,接收视频的权限 + * - 第 7 位:0100 0000 = 64,发送辅路(也就是屏幕分享)视频的权限 + * - 第 8 位:1000 0000 = 200,接收辅路(也就是屏幕分享)视频的权限 + * - privilegeMap == 1111 1111 == 255 代表该 userid 在该 roomid 房间内的所有功能权限。 + * - privilegeMap == 0010 1010 == 42 代表该 userid 拥有加入房间和接收音视频数据的权限,但不具备其他权限。 + */ + + public string genPrivateMapKeyWithStringRoomID(string userid, int expire, string roomstr, uint privilegeMap) + { + byte[] userbuf = genUserBuf(userid, 0, expire, privilegeMap, 0, roomstr); + System.Console.WriteLine(userbuf); + return genUserSig(userid, expire, userbuf, true); + } + private string genUserSig(string userid, int expire, byte[] userbuf, bool userBufEnabled) + { + DateTime epoch = new DateTime(1970, 1, 1); // unix 时间戳 + Int64 currTime = (Int64)(DateTime.UtcNow - epoch).TotalMilliseconds / 1000; + + string base64UserBuf; + string jsonData; + if (true == userBufEnabled) + { + base64UserBuf = Convert.ToBase64String(userbuf); + string base64sig = HMACSHA256(userid, currTime, expire, base64UserBuf, userBufEnabled); + // 没有引入 json 库,所以这里手动进行组装 + jsonData = String.Format("{{" + + "\"TLS.ver\":" + "\"2.0\"," + + "\"TLS.identifier\":" + "\"{0}\"," + + "\"TLS.sdkappid\":" + "{1}," + + "\"TLS.expire\":" + "{2}," + + "\"TLS.time\":" + "{3}," + + "\"TLS.sig\":" + "\"{4}\"," + + "\"TLS.userbuf\":" + "\"{5}\"" + + "}}", userid, sdkappid, expire, currTime, base64sig, base64UserBuf); + } + else + { + // 没有引入 json 库,所以这里手动进行组装 + string base64sig = HMACSHA256(userid, currTime, expire, "", false); + jsonData = String.Format("{{" + + "\"TLS.ver\":" + "\"2.0\"," + + "\"TLS.identifier\":" + "\"{0}\"," + + "\"TLS.sdkappid\":" + "{1}," + + "\"TLS.expire\":" + "{2}," + + "\"TLS.time\":" + "{3}," + + "\"TLS.sig\":" + "\"{4}\"" + + "}}", userid, sdkappid, expire, currTime, base64sig); + } + + byte[] buffer = Encoding.UTF8.GetBytes(jsonData); + return Convert.ToBase64String(CompressBytes(buffer)) + .Replace('+', '*').Replace('/', '-').Replace('=', '_'); + } + public byte[] genUserBuf(string account, uint dwAuthID, int dwExpTime, uint dwPrivilegeMap, uint dwAccountType, string roomStr) + { + int length = 1 + 2 + account.Length + 20; + int offset = 0; + if (roomStr.Length > 0) + length = length + 2 + roomStr.Length; + byte[] userBuf = new byte[length]; + + if (roomStr.Length > 0) + userBuf[offset++] = 1; + else + userBuf[offset++] = 0; + + userBuf[offset++] = (byte)((account.Length & 0xFF00) >> 8); + userBuf[offset++] = (byte)(account.Length & 0x00FF); + + byte[] accountByte = System.Text.Encoding.UTF8.GetBytes(account); + accountByte.CopyTo(userBuf, offset); + offset += account.Length; + + //dwSdkAppid + userBuf[offset++] = (byte)((sdkappid & 0xFF000000) >> 24); + userBuf[offset++] = (byte)((sdkappid & 0x00FF0000) >> 16); + userBuf[offset++] = (byte)((sdkappid & 0x0000FF00) >> 8); + userBuf[offset++] = (byte)(sdkappid & 0x000000FF); + + //dwAuthId + userBuf[offset++] = (byte)((dwAuthID & 0xFF000000) >> 24); + userBuf[offset++] = (byte)((dwAuthID & 0x00FF0000) >> 16); + userBuf[offset++] = (byte)((dwAuthID & 0x0000FF00) >> 8); + userBuf[offset++] = (byte)(dwAuthID & 0x000000FF); + + //time_t now = time(0); + //uint32_t expire = now + dwExpTime; + long expire = dwExpTime + (long)(DateTime.UtcNow.Subtract(new DateTime(1970, 1, 1))).TotalSeconds; + userBuf[offset++] = (byte)((expire & 0xFF000000) >> 24); + userBuf[offset++] = (byte)((expire & 0x00FF0000) >> 16); + userBuf[offset++] = (byte)((expire & 0x0000FF00) >> 8); + userBuf[offset++] = (byte)(expire & 0x000000FF); + + //dwPrivilegeMap + userBuf[offset++] = (byte)((dwPrivilegeMap & 0xFF000000) >> 24); + userBuf[offset++] = (byte)((dwPrivilegeMap & 0x00FF0000) >> 16); + userBuf[offset++] = (byte)((dwPrivilegeMap & 0x0000FF00) >> 8); + userBuf[offset++] = (byte)(dwPrivilegeMap & 0x000000FF); + + //dwAccountType + userBuf[offset++] = (byte)((dwAccountType & 0xFF000000) >> 24); + userBuf[offset++] = (byte)((dwAccountType & 0x00FF0000) >> 16); + userBuf[offset++] = (byte)((dwAccountType & 0x0000FF00) >> 8); + userBuf[offset++] = (byte)(dwAccountType & 0x000000FF); + + if (roomStr.Length > 0) + { + userBuf[offset++] = (byte)((roomStr.Length & 0xFF00) >> 8); + userBuf[offset++] = (byte)(roomStr.Length & 0x00FF); + + byte[] roomStrByte = System.Text.Encoding.UTF8.GetBytes(roomStr); + roomStrByte.CopyTo(userBuf, offset); + offset += roomStr.Length; + } + return userBuf; + } + private static byte[] CompressBytes(byte[] sourceByte) + { + MemoryStream inputStream = new MemoryStream(sourceByte); + Stream outStream = CompressStream(inputStream); + byte[] outPutByteArray = new byte[outStream.Length]; + outStream.Position = 0; + outStream.Read(outPutByteArray, 0, outPutByteArray.Length); + return outPutByteArray; + } + + private static Stream CompressStream(Stream sourceStream) + { + MemoryStream streamOut = new MemoryStream(); + ZOutputStream streamZOut = new ZOutputStream(streamOut, zlibConst.Z_DEFAULT_COMPRESSION); + CopyStream(sourceStream, streamZOut); + streamZOut.finish(); + return streamOut; + } + + public static void CopyStream(System.IO.Stream input, System.IO.Stream output) + { + byte[] buffer = new byte[2000]; + int len; + while ((len = input.Read(buffer, 0, 2000)) > 0) + { + output.Write(buffer, 0, len); + } + output.Flush(); + } + + private string HMACSHA256(string identifier, long currTime, int expire, string base64UserBuf, bool userBufEnabled) + { + string rawContentToBeSigned = "TLS.identifier:" + identifier + "\n" + + "TLS.sdkappid:" + sdkappid + "\n" + + "TLS.time:" + currTime + "\n" + + "TLS.expire:" + expire + "\n"; + if (true == userBufEnabled) + { + rawContentToBeSigned += "TLS.userbuf:" + base64UserBuf + "\n"; + } + using (HMACSHA256 hmac = new HMACSHA256()) + { + UTF8Encoding encoding = new UTF8Encoding(); + Byte[] textBytes = encoding.GetBytes(rawContentToBeSigned); + Byte[] keyBytes = encoding.GetBytes(key); + Byte[] hashBytes; + using (HMACSHA256 hash = new HMACSHA256(keyBytes)) + hashBytes = hash.ComputeHash(textBytes); + return Convert.ToBase64String(hashBytes); + } + } + } +} diff --git a/YuanXuan.IM.Common/Request/PageRequest.cs b/YuanXuan.IM.Common/Request/PageRequest.cs new file mode 100644 index 0000000..1425a61 --- /dev/null +++ b/YuanXuan.IM.Common/Request/PageRequest.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace YuanXuan.IM.Common.Request +{ + + /// + /// 分页请求实体类 + /// + public class PageRequest + { + /// + /// 当前页 + /// + public int PageIndex { get; set; } = 1; + + /// + /// 一页条数 + /// + public int PageSize { get; set; } = 10; + + } +} diff --git a/YuanXuan.IM.Common/Response/BaseResponse.cs b/YuanXuan.IM.Common/Response/BaseResponse.cs new file mode 100644 index 0000000..bacfd2f --- /dev/null +++ b/YuanXuan.IM.Common/Response/BaseResponse.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace YuanXuan.IM.Common.Response +{ + public class BaseResponse + { + /// + /// 业务结果代码 + /// + public int code { get; set; } + + /// + /// 返回消息 + /// + public string msg { get; set; } = ""; + } + + + /// + /// 基础返回实体类 + /// + /// + public class BaseResponse : BaseResponse + { + public BaseResponse(int code, string msg = "", T data = default) + { + this.code = code; + this.msg = msg; + this.data = data; + } + + /// + /// 返回实体 + /// + public T data { get; set; } + } +} diff --git a/YuanXuan.IM.Common/Response/PageResponse.cs b/YuanXuan.IM.Common/Response/PageResponse.cs new file mode 100644 index 0000000..34558bc --- /dev/null +++ b/YuanXuan.IM.Common/Response/PageResponse.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace YuanXuan.IM.Common.Request +{ + /// + /// 分页响应实体类 + /// + /// + public class PageResponse + { + /// + /// 总记录条数 + /// + public int Total { get; set; } + + /// + /// 响应数据 + /// + public List Items { get; set; } + } +} diff --git a/YuanXuan.IM.Common/YuanXuan.IM.Common.csproj b/YuanXuan.IM.Common/YuanXuan.IM.Common.csproj new file mode 100644 index 0000000..410335a --- /dev/null +++ b/YuanXuan.IM.Common/YuanXuan.IM.Common.csproj @@ -0,0 +1,41 @@ + + + + net8.0 + enable + enable + True + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/YuanXuan.IM.Core/Interfaces/IBaseService.cs b/YuanXuan.IM.Core/Interfaces/IBaseService.cs new file mode 100644 index 0000000..10d7bcf --- /dev/null +++ b/YuanXuan.IM.Core/Interfaces/IBaseService.cs @@ -0,0 +1,34 @@ +using SqlSugar; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Text; +using System.Threading.Tasks; +using YuanXuan.IM.Common.Entities; +using YuanXuan.IM.Common.Request; + +namespace YuanXuan.IM.Core.Interfaces +{ + public interface IBaseService where TEntity : BaseEntity, new() + { + Task CreateAsync(TEntity entity); + Task CreateAsync(List entity); + Task InsertReturnBigIdentityAsync(TEntity entity); + Task CreateReturnSnowflakeIdAsync(TEntity entity); + Task> CreateReturnSnowflakeIdAsync(List entity); + Task DeleteByIdsAsync(params dynamic[] ids); + Task DeleteByIdsAsync(params long[] ids); + Task GetByIdAsync(long id); + Task GetFirstAsync(System.Linq.Expressions.Expression> whereExpression); + Task> GetListAsync(System.Linq.Expressions.Expression> whereExpression = null); + Task> GetPageListAsync(PageRequest pagination, Expression> whereExpression = null); + Task> GetPageListAsync(PageRequest pagination, Expression> whereExpression, Expression> orderByExpression = null, OrderByType orderByType = OrderByType.Asc); + Task> GetPageListAsync(PageRequest pagination, Expression> whereExpression = null); + Task GetSingleAsync(System.Linq.Expressions.Expression> whereExpression); + Task UpdateAsync(TEntity entity); + Task UpdateAsync(List entity); + Task UpdateColumsAsync(Expression> columns, Expression> whereExpression); + Task AnyAsync(Expression> whereExpression); + } +} diff --git a/YuanXuan.IM.Core/Services/BaseService.cs b/YuanXuan.IM.Core/Services/BaseService.cs new file mode 100644 index 0000000..55025e3 --- /dev/null +++ b/YuanXuan.IM.Core/Services/BaseService.cs @@ -0,0 +1,254 @@ +using Mapster; +using SqlSugar; +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Linq.Expressions; +using System.Text; +using System.Threading.Tasks; +using YuanXuan.IM.Common.Entities; +using YuanXuan.IM.Common.Request; +using YuanXuan.IM.Core.Interfaces; +using YuanXuan.IM.Infrastructure.DBContext; + +namespace YuanXuan.IM.Core.Services +{ + /// + /// 服务基类 + /// + //[Inject(Lifetime = Microsoft.Extensions.DependencyInjection.ServiceLifetime.Scoped)] + public class BaseService : IBaseService where TEntity : BaseEntity, new() + { + //protected readonly SugarRepository _db; + private readonly SugarRepository _db; + + + public BaseService(SugarRepository repository) + { + _db = repository; + } + + /// + /// 创建(自增ID) + /// + /// + /// + public virtual async Task CreateAsync(TEntity entity) + { + return await _db.InsertAsync(entity); + } + /// + /// 创建 + /// + /// + /// + public virtual async Task CreateAsync(List entity) + { + return await _db.InsertRangeAsync(entity); + } + /// + /// 创建(自增ID) + /// + /// + /// + public virtual async Task InsertReturnBigIdentityAsync(TEntity entity) + { + return await _db.InsertReturnBigIdentityAsync(entity); + } + + /// + /// 创建并返回雪花ID + /// + /// + /// + public virtual async Task CreateReturnSnowflakeIdAsync(TEntity entity) + { + return await _db.InsertReturnSnowflakeIdAsync(entity); + } + /// + /// 创建并返回雪花ID + /// + /// + /// + public virtual async Task> CreateReturnSnowflakeIdAsync(List entity) + { + return await _db.InsertReturnSnowflakeIdAsync(entity); + } + + /// + /// 物理删除 + /// + /// + /// + public virtual async Task DeleteByIdsAsync(params dynamic[] ids) + { + return await _db.DeleteByIdsAsync(ids); + } + + /// + /// 物理删除 + /// + /// + /// + public virtual async Task DeleteByIdsAsync(params long[] ids) + { + return await _db.Context.Deleteable().Where(x => ids.Contains(x.Id)).ExecuteCommandHasChangeAsync(); + } + + + + /// + /// 更新 + /// + /// + /// + public virtual async Task UpdateAsync(TEntity entity) + { + return await _db.UpdateAsync(entity); + } + /// + /// 批量更新 + /// + /// + /// + public virtual async Task UpdateAsync(List entity) + { + return await _db.UpdateRangeAsync(entity); + } + + /// + /// 更新指定字段 + /// + /// + /// + /// + public virtual async Task UpdateColumsAsync(Expression> columns, Expression> whereExpression) + { + return await _db.UpdateAsync(columns, whereExpression); + } + + /// + /// 根据ID获取 + /// + /// + /// + public virtual async Task GetByIdAsync(long id) + { + return await _db.GetByIdAsync(id); + } + + /// + /// 根据ID获取 + /// + /// + /// + public virtual async Task> GetListAsync(Expression> whereExpression = null) + { + if (whereExpression == null) + { + return await _db.GetListAsync(); + } + return await _db.GetListAsync(whereExpression); + } + + /// + /// 根据条件获取单个 + /// + /// + /// + public virtual async Task GetSingleAsync(Expression> whereExpression) + { + return await _db.GetSingleAsync(whereExpression); + } + + /// + /// 根据条件获取第一个 + /// + /// + /// + public virtual async Task GetFirstAsync(Expression> whereExpression) + { + return await _db.GetFirstAsync(whereExpression); + } + public virtual async Task> GetPageListAsync(PageRequest pagination, Expression> whereExpression, Expression> orderByExpression = null, OrderByType orderByType = OrderByType.Asc) + { + var pageModel = pagination.Adapt(); + List result = null; + if (whereExpression != null) + result = await _db.GetPageListAsync(whereExpression, pageModel, orderByExpression, orderByType); + else + result = await _db.GetPageListAsync(x => 1 == 1, pageModel, orderByExpression, orderByType); + return new PageResponse + { + Items = result, + Total = pageModel.TotalCount, + }; + } + /// + /// 分页查询 + /// + /// + /// + /// + public virtual async Task> GetPageListAsync(PageRequest pagination, Expression> whereExpression = null) + { + var pageModel = pagination.Adapt(); + + List result = null; + if (whereExpression != null) + result = await _db.GetPageListAsync(whereExpression, pageModel, orderByExpression: x => x.Id, orderByType: OrderByType.Desc); + else + result = await _db.GetPageListAsync(x => 1 == 1, pageModel, orderByExpression: x => x.Id, orderByType: OrderByType.Desc); + + return new PageResponse + { + Items = result, + Total = pageModel.TotalCount, + }; + } + + /// + /// 分页查询 + /// + /// + /// + /// + public virtual async Task> GetPageListAsync(PageRequest pagination, Expression> whereExpression) + { + var pageModel = pagination.Adapt(); + + List result = null; + if (whereExpression != null) + result = await _db.GetPageListAsync(whereExpression, pageModel, orderByExpression: x => x.Id, orderByType: OrderByType.Desc); + else + result = await _db.GetPageListAsync(x => 1 == 1, pageModel, orderByExpression: x => x.Id, orderByType: OrderByType.Desc); + + return new PageResponse + { + Items = result.Adapt>(), + Total = pageModel.TotalCount, + }; + } + public virtual async Task AnyAsync(Expression> whereExpression) + { + return await _db.IsAnyAsync(whereExpression); + } + //protected async Task BeginTransaction(IsolationLevel isolationLevel = IsolationLevel.ReadCommitted) + //{ + // return await _db.Context.AsTenant().UseTranAsync(isolationLevel); + //} + + //// 针对不同业务场景使用不同隔离级别 + //protected async SqlSugarTransaction BeginSerializableTransaction() + //{ + // return BeginTransaction(IsolationLevel.Serializable); + //} + + //protected async SqlSugarTransaction BeginRepeatableReadTransaction() + //{ + // return BeginTransaction(IsolationLevel.RepeatableRead); + //} + + } +} diff --git a/YuanXuan.IM.Core/YuanXuan.IM.Core.csproj b/YuanXuan.IM.Core/YuanXuan.IM.Core.csproj new file mode 100644 index 0000000..b5f146c --- /dev/null +++ b/YuanXuan.IM.Core/YuanXuan.IM.Core.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + True + + + + + + + + + + + + + + + diff --git a/YuanXuan.IM.Infrastructure/DBContext/SugarRepository.cs b/YuanXuan.IM.Infrastructure/DBContext/SugarRepository.cs new file mode 100644 index 0000000..89a07ad --- /dev/null +++ b/YuanXuan.IM.Infrastructure/DBContext/SugarRepository.cs @@ -0,0 +1,23 @@ +using SqlSugar; +using YuanXuan.IM.Common.Entities; + +namespace YuanXuan.IM.Infrastructure.DBContext +{ + /// + /// SqlSugar通用仓储类 + /// + /// + public class SugarRepository : SimpleClient where TEntity : BaseEntity, new() + { + public SugarRepository(ISqlSugarClient sugarClient) : base(sugarClient) + { + var db = sugarClient.AsTenant().GetConnectionWithAttr(); + base.Context = db; + } + + public IEnumerable Queryable(Func value) + { + throw new NotImplementedException(); + } + } +} diff --git a/YuanXuan.IM.Infrastructure/Redis/RedisHelper.cs b/YuanXuan.IM.Infrastructure/Redis/RedisHelper.cs new file mode 100644 index 0000000..8338ee1 --- /dev/null +++ b/YuanXuan.IM.Infrastructure/Redis/RedisHelper.cs @@ -0,0 +1,172 @@ +using FreeRedis; +using Masuit.Tools; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace YuanXuan.IM.Infrastructure.Redis +{ + public abstract class RedisHelper + { + + private static RedisClient _instance; + + /// + /// redis实例 + /// + public static RedisClient Instance + { + get + { + if (_instance == null) + { + throw new Exception("使用前初始化redis静态访问类 RedisHelper.Initialization(new FreeRedis.RedisClient(\"127.0.0.1:6379,password=123,defaultDatabase=13,maxpoolsize=50,prefix=key前辍\"));"); + } + + return _instance; + } + } + public static void SetLoginToken(long userId, string token) + { + var key = "Login_Token"; + Instance.HSet(key, userId.ToString(), token); + } + public static void DeleteLoginToken(long userId) + { + var key = "Login_Token"; + Instance.HDel(key, userId.ToString()); + } + + + /// + /// 初始化redis静态访问类 RedisHelper.Initialization(new FreeRedis.RedisClient(\"127.0.0.1:6379,password=123,defaultDatabase=13,maxpoolsize=50,prefix=key前辍\")) + /// + /// + public static void Initialization(string connectionString) + { + _instance = new FreeRedis.RedisClient(connectionString) + { + Serialize = (x) => x.ToJsonString(), + Deserialize = (x, t) => JsonConvert.DeserializeObject(x, t), + }; + } + + internal static ThreadLocal rnd = new ThreadLocal(() => new Random()); + /// + /// 随机秒(防止所有key同一时间过期,雪崩) + /// + /// 最小秒数 + /// 最大秒数 + /// + public static int RandomExpired(int minTimeoutSeconds, int maxTimeoutSeconds) => rnd.Value.Next(minTimeoutSeconds, maxTimeoutSeconds); + /// + /// 获取Redis分布式锁 + /// + /// 锁键 + /// 锁值(建议使用GUID) + /// 过期时间(秒) + /// 是否获取成功 + public static bool TryLock(string lockKey, string lockValue, int expireSeconds = 30) + { + return Instance.SetNx(lockKey, lockValue, expireSeconds); + } + + /// + /// 释放Redis分布式锁 + /// + /// 锁键 + /// 锁值(必须与加锁时一致) + /// 是否释放成功 + public static bool ReleaseLock(string lockKey, string lockValue) + { + // 使用Lua脚本确保原子性:只有当锁的值匹配时才删除 + const string luaScript = @" + if redis.call('GET', KEYS[1]) == ARGV[1] then + return redis.call('DEL', KEYS[1]) + else + return 0 + end"; + + var result = Instance.Eval(luaScript, new[] { lockKey }, new[] { lockValue }); + return (long)result == 1; + } + + /// + /// 延长Redis锁的过期时间 + /// + /// 锁键 + /// 锁值 + /// 新的过期时间(秒) + /// 是否续期成功 + public static bool RenewLock(string lockKey, string lockValue, int expireSeconds) + { + // 使用Lua脚本确保原子性:只有当锁的值匹配时才更新过期时间 + const string luaScript = @" + if redis.call('GET', KEYS[1]) == ARGV[1] then + return redis.call('EXPIRE', KEYS[1], ARGV[2]) + else + return 0 + end"; + + var result = Instance.Eval(luaScript, new[] { lockKey }, new[] { lockValue, expireSeconds.ToString() }); + return (long)result == 1; + } + + /// + /// 异步设置键值对 + /// + /// 键 + /// 值 + /// 过期时间 + /// + public static async Task SetAsync(string key, object value, TimeSpan expiry) + { + await Instance.SetAsync(key, value, expiry); + } + + /// + /// 异步获取字符串值 + /// + /// 键 + /// + public static async Task GetStringAsync(string key) + { + return await Instance.GetAsync(key); + } + + /// + /// 异步删除键 + /// + /// 键 + /// + public static async Task DeleteAsync(string key) + { + await Instance.DelAsync(key); + } + + /// + /// 异步获取值 + /// + /// 类型 + /// 键 + /// + public static async Task GetAsync(string key) + { + return await Instance.GetAsync(key); + } + + /// + /// 异步设置键值对(默认过期时间) + /// + /// 键 + /// 值 + /// + public static async Task SetAsync(string key, object value) + { + await Instance.SetAsync(key, value); + } + } +} diff --git a/YuanXuan.IM.Infrastructure/YuanXuan.IM.Infrastructure.csproj b/YuanXuan.IM.Infrastructure/YuanXuan.IM.Infrastructure.csproj new file mode 100644 index 0000000..6ab850b --- /dev/null +++ b/YuanXuan.IM.Infrastructure/YuanXuan.IM.Infrastructure.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + True + + + + + + + + + + + + diff --git a/YuanXuan.IM.Services/Class1.cs b/YuanXuan.IM.Services/Class1.cs new file mode 100644 index 0000000..664c331 --- /dev/null +++ b/YuanXuan.IM.Services/Class1.cs @@ -0,0 +1,7 @@ +namespace YuanXuan.IM.Services +{ + public class Class1 + { + + } +} diff --git a/YuanXuan.IM.Services/YuanXuan.IM.Services.csproj b/YuanXuan.IM.Services/YuanXuan.IM.Services.csproj new file mode 100644 index 0000000..fa71b7a --- /dev/null +++ b/YuanXuan.IM.Services/YuanXuan.IM.Services.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + +