diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml index 57c616d..424bb5f 100644 --- a/.github/workflows/nuget-publish.yml +++ b/.github/workflows/nuget-publish.yml @@ -18,7 +18,7 @@ jobs: - name: Publish run: | version=`git describe --tags` - dotnet build --configuration release -p:PackageVersion=$version + dotnet build --configuration release -p:Version=$version dotnet nuget push bin/release/Simcu.SimApi.$version.nupkg -k ${NUGET_APIKEY} -s https://www.nuget.org/api/v2/package env: NUGET_APIKEY: ${{ secrets.NUGET_APIKEY }} diff --git a/Attributes/SimApiDocAttribute.cs b/Attributes/SimApiDocAttribute.cs index 84a1171..c6d1999 100644 --- a/Attributes/SimApiDocAttribute.cs +++ b/Attributes/SimApiDocAttribute.cs @@ -16,7 +16,7 @@ public class SimApiDocAttribute : SwaggerOperationAttribute /// 接口名称 public SimApiDocAttribute(string tag, string name) { - Tags = new[] { tag }; + Tags = [tag]; Summary = name; // Consumes = new[] {"application/json"}; // Produces = new[] {"application/json"}; diff --git a/Communications/SimApiBaseResponse.cs b/Communications/SimApiBaseResponse.cs index ea0ff66..f55b7d7 100644 --- a/Communications/SimApiBaseResponse.cs +++ b/Communications/SimApiBaseResponse.cs @@ -13,6 +13,10 @@ public class SimApiBaseResponse(int code = 200, string message = "成功") public int Code { get; set; } = code; public string Message { get; set; } = message; + public SimApiBaseResponse() : this(200, "成功") + { + } + /// /// 默认错误代码对应提示信息 /// diff --git a/Configurations/SimApiOptions.cs b/Configurations/SimApiOptions.cs index 9bd90d2..6bbf3cb 100644 --- a/Configurations/SimApiOptions.cs +++ b/Configurations/SimApiOptions.cs @@ -74,6 +74,14 @@ public class SimApiOptions public bool EnableLowerUrl { get; set; } = true; + /// + /// 应用可以通过 /versions 显示出应用版本和SimApi包版本 + /// default: true + /// + public bool EnableVersionUrl { get; set; } = true; + + + /// /// 启用格式化的 Console Logger /// default: false diff --git a/Controllers/SimApiBaseController.cs b/Controllers/SimApiBaseController.cs index 37e4e30..6fc7093 100644 --- a/Controllers/SimApiBaseController.cs +++ b/Controllers/SimApiBaseController.cs @@ -14,6 +14,9 @@ namespace SimApi.Controllers; /// 2. 报错返回 /// 3. 错误回馈页面 /// +/// +[Consumes("application/json")] +[Produces("application/json")] public class SimApiBaseController : Controller { /// diff --git a/Controllers/SimApiCommonController.cs b/Controllers/SimApiCommonController.cs index 2dfbb4b..b74af01 100644 --- a/Controllers/SimApiCommonController.cs +++ b/Controllers/SimApiCommonController.cs @@ -1,9 +1,11 @@ -using Microsoft.AspNetCore.Mvc; +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc; using SimApi.Attributes; using SimApi.Communications; using SimApi.Helpers; namespace SimApi.Controllers; + public class SimApiCommonController(SimApiAuth auth) : SimApiBaseController { /// @@ -25,7 +27,7 @@ public class SimApiCommonController(SimApiAuth auth) : SimApiBaseController [HttpPost, SimApiDoc("认证", "检测登陆")] public SimApiBaseResponse CheckLogin() { - ErrorWhenNull(LoginInfo, 401,"未登录"); + ErrorWhenNull(LoginInfo, 401, "未登录"); return new SimApiBaseResponse { Data = LoginInfo.Id @@ -50,7 +52,20 @@ public class SimApiCommonController(SimApiAuth auth) : SimApiBaseController return new SimApiBaseResponse(); } - [HttpPost,SimApiAuth] + [HttpPost, HttpGet] + public SimApiBaseResponse> Versions() + { + return new SimApiBaseResponse>() + { + Data = new Dictionary + { + { "SimApi", SimApiUtil.SimApiVersion }, + { "App", SimApiUtil.AppVersion } + } + }; + } + + [HttpPost, SimApiAuth] public SimApiBaseResponse UserInfo() { return new SimApiBaseResponse(LoginInfo); diff --git a/Helpers/SimApiAesUtil.cs b/Helpers/SimApiAesUtil.cs new file mode 100644 index 0000000..f989b56 --- /dev/null +++ b/Helpers/SimApiAesUtil.cs @@ -0,0 +1,119 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; + +namespace SimApi.Helpers; + +public static class SimApiAesUtil +{ + // 密钥长度:256位 (32字节) + private const int KeySize = 256; + + // 块大小:128位 (16字节) + private const int BlockSize = 128; + + // 加密模式 - 重命名以避免与枚举类型冲突 + private const CipherMode AesCipherMode = CipherMode.CBC; + + // 填充模式 - 重命名以避免与枚举类型冲突 + private const PaddingMode AesPaddingMode = PaddingMode.PKCS7; + + /// + /// AES 加密 + /// + /// 明文 + /// 字符串密钥(将被处理为256位) + /// 加密后的Base64字符串(包含IV) + public static string Encrypt(string plainText, string key) + { + if (string.IsNullOrEmpty(plainText)) + throw new ArgumentNullException(nameof(plainText)); + if (string.IsNullOrEmpty(key)) + throw new ArgumentNullException(nameof(key)); + + // 处理密钥为指定长度 + var keyBytes = ProcessKey(key); + // 生成随机IV + var iv = GenerateRandomIv(); + + using var aes = CreateAesProvider(keyBytes, iv); + using var encryptor = aes.CreateEncryptor(aes.Key, aes.IV); + using var ms = new MemoryStream(); + // 先写入IV,解密时需要用到 + ms.Write(iv, 0, iv.Length); + + using (var cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write)) + using (var sw = new StreamWriter(cs)) + { + sw.Write(plainText); + } + + return Convert.ToBase64String(ms.ToArray()); + } + + /// + /// AES 解密 + /// + /// 加密后的Base64字符串 + /// 字符串密钥(与加密时相同) + /// 解密后的明文 + public static string Decrypt(string cipherText, string key) + { + if (string.IsNullOrEmpty(cipherText)) + throw new ArgumentNullException(nameof(cipherText)); + if (string.IsNullOrEmpty(key)) + throw new ArgumentNullException(nameof(key)); + + var cipherBytes = Convert.FromBase64String(cipherText); + + // 从加密数据中提取IV + var iv = new byte[BlockSize / 8]; + Array.Copy(cipherBytes, 0, iv, 0, iv.Length); + + // 处理密钥为指定长度 + var keyBytes = ProcessKey(key); + + using var aes = CreateAesProvider(keyBytes, iv); + using var decryptor = aes.CreateDecryptor(aes.Key, aes.IV); + using var ms = new MemoryStream(cipherBytes, iv.Length, cipherBytes.Length - iv.Length); + using var cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read); + using var sr = new StreamReader(cs); + return sr.ReadToEnd(); + } + + /// + /// 处理密钥为指定长度(256位) + /// + private static byte[] ProcessKey(string key) + { + // 使用SHA256哈希处理密钥,确保得到32字节(256位)的密钥 + return SHA256.HashData(Encoding.UTF8.GetBytes(key)); + } + + /// + /// 生成随机初始化向量 + /// + private static byte[] GenerateRandomIv() + { + using var aes = Aes.Create(); + aes.BlockSize = BlockSize; + aes.GenerateIV(); + return aes.IV; + } + + /// + /// 创建并配置AES加密服务提供器 + /// + private static Aes CreateAesProvider(byte[] key, byte[] iv) + { + var aes = Aes.Create(); + aes.KeySize = KeySize; + aes.BlockSize = BlockSize; + aes.Mode = AesCipherMode; + aes.Padding = AesPaddingMode; + aes.Key = key; + aes.IV = iv; + return aes; + } +} \ No newline at end of file diff --git a/Helpers/SimApiAuthOperationFilter.cs b/Helpers/SimApiAuthOperationFilter.cs new file mode 100644 index 0000000..362c21b --- /dev/null +++ b/Helpers/SimApiAuthOperationFilter.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using SimApi.Attributes; + +namespace SimApi.Helpers +{ + public class SimApiAuthOperationFilter : IOperationFilter + { + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + // 检查接口或控制器是否标记了 [SimApiAuth] 特性 + var requiresAuth = + // 方法上有 [SimApiAuth] + context.MethodInfo.GetCustomAttributes(true).Any() + || + // 控制器上有 [SimApiAuth](继承到所有方法) + context.MethodInfo.DeclaringType?.GetCustomAttributes(true).Any() == true; + if (requiresAuth) + { + // 添加授权要求:关联步骤 2 中定义的 "SimApiAuth" 安全方案 + operation.Security = new List + { + new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "SimApiAuth" // 必须与 AddSecurityDefinition 的第一个参数一致 + } + }, + [] // 无需指定作用域(scope)时留空 + } + } + }; + } + // 未标记 [SimApiAuth] 的接口:不添加安全要求,Swagger 不显示锁图标 + } + } +} \ No newline at end of file diff --git a/Helpers/SimApiUtil.cs b/Helpers/SimApiUtil.cs index af35990..4ad7298 100644 --- a/Helpers/SimApiUtil.cs +++ b/Helpers/SimApiUtil.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Linq; +using System.Reflection; using System.Security.Cryptography; using System.Text; using System.Text.Encodings.Web; @@ -30,6 +31,53 @@ public static class SimApiUtil // DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; + public static string SimApiVersion + { + get + { + // 这里使用当前类(属于 NuGet 包)的程序集 + var assembly = typeof(SimApiUtil).Assembly; + + // 优先获取 AssemblyInformationalVersion(通常对应 NuGet 包版本,可能包含预发布标签) + var informationalVersion = + assembly.GetCustomAttribute()?.InformationalVersion; + if (!string.IsNullOrEmpty(informationalVersion)) + { + return informationalVersion; + } + + // 若不存在,则获取 AssemblyVersion(编译时版本) + var version = assembly.GetName().Version?.ToString(); + return version ?? "Unknown"; + } + } + + public static string AppVersion + { + get + { + // 获取外层应用的入口程序集(通常是启动项目的程序集) + var entryAssembly = Assembly.GetEntryAssembly(); + if (entryAssembly == null) + { + // 特殊场景(如单元测试、某些宿主环境)下,入口程序集可能为 null,可尝试获取调用栈中的上层程序集 + entryAssembly = Assembly.GetCallingAssembly(); // 或 Assembly.GetExecutingAssembly() 视场景调整 + } + + // 优先获取应用的 AssemblyInformationalVersion + var informationalVersion = entryAssembly.GetCustomAttribute() + ?.InformationalVersion; + if (!string.IsNullOrEmpty(informationalVersion)) + { + return informationalVersion; + } + + // 若不存在,则获取 AssemblyVersion + var version = entryAssembly.GetName().Version?.ToString(); + return version ?? "Unknown"; + } + } + /// /// 当前秒级时间戳 /// diff --git a/Helpers/WrapResponseSchemaFilter.cs b/Helpers/WrapResponseSchemaFilter.cs new file mode 100644 index 0000000..d6aa856 --- /dev/null +++ b/Helpers/WrapResponseSchemaFilter.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using SimApi.Attributes; +using SimApi.Communications; + +namespace SimApi.Helpers; + +public class WrapResponseSchemaFilter : IOperationFilter +{ + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + // 1. 跳过标记了 [OriginResponseAttribute] 的接口(不包装) + if (context.MethodInfo.DeclaringType?.GetCustomAttributes(true).Any() == true + || context.MethodInfo.GetCustomAttributes(true).Any()) + { + return; + } + + // 2. 获取控制器方法声明的“原始返回类型”(如 Task → UserDto) + var returnType = GetUnwrappedReturnType(context.MethodInfo.ReturnType, context); + if (IsAlreadyWrappedType(returnType)) + { + return; // 保留原始返回类型的文档描述 + } + + // 3. 定义包装后的目标类型(泛型/非泛型) + Type wrappedType; + if (returnType == typeof(void) || returnType == typeof(EmptyResult)) + { + // 无数据:用非泛型 SimApiBaseResponse + wrappedType = typeof(SimApiBaseResponse); + } + else + { + // 有数据:用泛型 SimApiBaseResponse(T 为原始返回类型) + wrappedType = typeof(SimApiBaseResponse<>).MakeGenericType(returnType); + } + + // 4. 让 Swagger 生成包装类型的 Schema + var schema = context.SchemaGenerator.GenerateSchema(wrappedType, context.SchemaRepository); + + // 5. 替换 Swagger 文档中的响应类型(只保留 200 OK 的响应,匹配过滤器逻辑) + operation.Responses.Clear(); // 清除默认响应(如 200 返回原始类型) + operation.Responses.Add("200", new OpenApiResponse + { + Description = "请求成功", + Content = new Dictionary + { + { + "application/json", // 只保留 JSON 格式(配合之前的全局配置) + new OpenApiMediaType + { + Schema = schema + } + } + } + }); + } + + private static Type GetUnwrappedReturnType(Type returnType, OperationFilterContext context) + { + // 处理 Task(异步方法) + if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(Task<>)) + { + returnType = returnType.GetGenericArguments()[0]; + } + + // 处理 IActionResult/ObjectResult(如 return Ok(userDto)) + if (!typeof(IActionResult).IsAssignableFrom(returnType)) return returnType; + // 从方法返回语句中提取原始类型(需结合特性,或默认用 object) + // 更精准的方式:让控制器方法用 [ProducesResponseType(typeof(UserDto), 200)] 声明原始类型 + var producesAttr = context.MethodInfo.GetCustomAttributes() + .FirstOrDefault(a => a.StatusCode == 200); + if (producesAttr?.Type != null && producesAttr.Type != typeof(void)) + { + return producesAttr.Type; + } + + return typeof(object); // 无法识别时默认用 object + } + + // 辅助方法:判断类型是否已经是 SimApiBaseResponse 及其泛型 + private static bool IsAlreadyWrappedType(Type type) + { + // 情况1:类型本身是 SimApiBaseResponse 或其泛型 + if (type == typeof(SimApiBaseResponse) || + (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(SimApiBaseResponse<>))) + { + return true; + } + + // 情况2:类型是 SimApiBaseResponse 的子类(非泛型) + if (type.IsSubclassOf(typeof(SimApiBaseResponse))) + { + return true; + } + + // 情况3:类型是 SimApiBaseResponse 的子类(泛型) + // 检查是否继承自泛型父类 SimApiBaseResponse<>(任意 T) + if (type.BaseType is { IsGenericType: true }) + { + var genericBaseType = type.BaseType.GetGenericTypeDefinition(); + if (genericBaseType == typeof(SimApiBaseResponse<>)) + { + return true; + } + } + + // 其他情况:不是包装类型或其子类 + return false; + } +} \ No newline at end of file diff --git a/SimApi.csproj b/SimApi.csproj index f515b53..e5c9769 100644 --- a/SimApi.csproj +++ b/SimApi.csproj @@ -3,17 +3,10 @@ Library true - 0.2.3 + 0.0.0 xRain@SimcuTeam AspNetCore一个方便的API文档,捕获异常,统一输入输出的API类库 Simcu.SimApi - true - snupkg - true - 5.0.0 - false - true - 5.0.2 enable net8.0;net9.0 @@ -23,15 +16,15 @@ - - - - - - - - - + + + + + + + + + diff --git a/SimApiExtensions.cs b/SimApiExtensions.cs index 7a1a22f..2ea8db1 100644 --- a/SimApiExtensions.cs +++ b/SimApiExtensions.cs @@ -121,30 +121,27 @@ public static class SimApiExtensions }); } + x.CustomSchemaIds(type => type.FullName?.Replace("+", ".")); + x.OperationFilter(); + if (simApiOptions.EnableSimApiAuth) + { + x.OperationFilter(); + } + x.EnableAnnotations(); var haveOauth = false; - var haveSimApiAuth = false; var oauthFlows = new OpenApiOAuthFlows(); foreach (var auth in docOptions.ApiAuth.Type) { switch (auth) { case "SimApiAuth": - x.AddSecurityRequirement(new OpenApiSecurityRequirement - { + x.AddSecurityDefinition("HeaderToken", + new OpenApiSecurityScheme { - new OpenApiSecurityScheme - { - Reference = new OpenApiReference - { - Type = ReferenceType.SecurityScheme, - Id = "HeaderToken" - } - }, - new[] { "readAccess", "writeAccess" } - } - }); - haveSimApiAuth = true; + Name = "Token", + In = ParameterLocation.Header + }); break; case "ClientCredentials": oauthFlows.ClientCredentials = new OpenApiOAuthFlow @@ -193,30 +190,6 @@ public static class SimApiExtensions Description = docOptions.ApiAuth.Description, In = ParameterLocation.Header }); - x.AddSecurityRequirement(new OpenApiSecurityRequirement - { - { - new OpenApiSecurityScheme - { - Reference = new OpenApiReference - { - Type = ReferenceType.SecurityScheme, - Id = "oauth2" - } - }, - ["SimApiAuth"] - } - }); - } - - if (haveSimApiAuth) - { - x.AddSecurityDefinition("HeaderToken", - new OpenApiSecurityScheme - { - Name = "Token", - In = ParameterLocation.Header - }); } }); } @@ -264,6 +237,8 @@ public static class SimApiExtensions var logger = builder.Services.GetRequiredService>(); logger.LogInformation("当前时区: {LocalId}", TimeZoneInfo.Local.Id); + logger.LogInformation("主应用版本: {AppVersion}\nSimApi版本: {SimApiVersion}", SimApiUtil.AppVersion, + SimApiUtil.SimApiVersion); if (options.RedisConfiguration != null) { @@ -374,22 +349,33 @@ public static class SimApiExtensions } } + if (options.EnableVersionUrl) + { + builder.MapControllerRoute(name: "Versions", pattern: "/versions", + defaults: new + { + controller = "SimApiCommon", + action = "Versions" + }); + } + if (options.EnableSimApiDoc) { logger.LogInformation("开始配置SimApiDoc..."); var docOptions = options.SimApiDocOptions; - builder.UseSwagger(x => x.RouteTemplate = "/swagger/{documentName}.json").UseSwaggerUI(x => - { - x.DocumentTitle = docOptions.DocumentTitle; - foreach (var group in docOptions.ApiGroups) + builder.UseSwagger(x => x.RouteTemplate = "/swagger/{documentName}.json") + .UseSwaggerUI(x => { - x.SwaggerEndpoint($"/swagger/{group.Id}.json", name: group.Name); - } + x.DocumentTitle = docOptions.DocumentTitle; + foreach (var group in docOptions.ApiGroups) + { + x.SwaggerEndpoint($"/swagger/{group.Id}.json", name: group.Name); + } - x.EnableValidator(); - x.SupportedSubmitMethods(docOptions.SupportedMethod); - x.DisplayRequestDuration(); - }); + x.EnableValidator(); + x.SupportedSubmitMethods(docOptions.SupportedMethod); + x.DisplayRequestDuration(); + }); } if (options.EnableSimApiException)