Compare commits
7
Commits
10.26.8.8
...
e4f051cb61
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4f051cb61 | ||
|
|
7b34991ea0 | ||
|
|
1623343748 | ||
|
|
d8a32f3f07 | ||
|
|
e17b9d9eed | ||
|
|
80a05ea20b | ||
|
|
33f7cff11c |
@@ -1,39 +1,105 @@
|
|||||||
using System;
|
using System;
|
||||||
using Swashbuckle.AspNetCore.Annotations;
|
using Swashbuckle.AspNetCore.Annotations;
|
||||||
|
|
||||||
namespace SimApi.Attributes;
|
namespace SimApi.Attributes;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 快捷自定义接口文档类
|
/// 快捷自定义接口文档类(所有参数均可省略, 支持命名参数)。
|
||||||
|
/// 对应仓颉版 SimApiDoc:
|
||||||
|
/// tags → 接口标签(逗号分隔, 如 "认证,用户")
|
||||||
|
/// name → API 名称(映射到 Summary, 作为文档接口标题)
|
||||||
|
/// description → API 详细描述
|
||||||
|
/// groupNames → 所属文档组(逗号分隔, 如 "api,admin"; "*" 表示所有文档; null/空 → 仅默认 "api" 文档)
|
||||||
|
/// ignore → true 时不出现在任何文档中(路由不受影响)
|
||||||
|
/// 写法示例:
|
||||||
|
/// [SimApiDoc("认证", "登录")] 位置参数(保持旧版兼容)
|
||||||
|
/// [SimApiDoc(tags: "认证", name: "登录", description: "...")]
|
||||||
|
/// [SimApiDoc(groupNames: "api,admin")] 仅指定文档组
|
||||||
|
/// [SimApiDoc(GroupNames = "*", Ignore = false)] 属性命名参数
|
||||||
|
/// [SimApiDoc] 全部默认(可标在类上, 仅用 Ignore/GroupNames 等)
|
||||||
|
/// 属性命名参数优先于构造命名参数。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
|
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
|
||||||
public class SimApiDocAttribute : SwaggerOperationAttribute
|
public class SimApiDocAttribute : SwaggerOperationAttribute
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 定义接口说明
|
/// 出现在所有文档组中的通配符
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="tags">接口分组列表</param>
|
public const string AllGroups = "*";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 接口所属的文档组(逗号分隔, 如 "api,admin"); "*" 表示所有文档; null/空 表示未分组(仅默认 "api" 文档)
|
||||||
|
/// </summary>
|
||||||
|
public string? GroupNames { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 为 true 时该接口不出现在任何文档中(不影响路由)
|
||||||
|
/// </summary>
|
||||||
|
public bool Ignore { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 定义接口说明(全部可选)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tags">接口标签, 逗号分隔, 如 "认证,用户"</param>
|
||||||
/// <param name="name">接口名称</param>
|
/// <param name="name">接口名称</param>
|
||||||
/// <param name="description">接口描述</param>
|
/// <param name="description">接口描述</param>
|
||||||
public SimApiDocAttribute(string[] tags, string name, string? description = null)
|
/// <param name="groupNames">所属文档组, 逗号分隔; "*" 表示所有文档</param>
|
||||||
|
/// <param name="ignore">true 时不出现在任何文档</param>
|
||||||
|
public SimApiDocAttribute(string? tags = null, string? name = null, string? description = null,
|
||||||
|
string? groupNames = null, bool ignore = false)
|
||||||
{
|
{
|
||||||
Tags = tags;
|
Apply(tags, name, description, groupNames, ignore);
|
||||||
Summary = name;
|
|
||||||
if (description != null)
|
|
||||||
{
|
|
||||||
Description = description;
|
|
||||||
}
|
|
||||||
// Consumes = new[] {"application/json"};
|
|
||||||
// Produces = new[] {"application/json"};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 定义接口说明
|
/// 定义接口说明(标签以数组传入)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="tag">接口分组</param>
|
/// <param name="tags">接口标签列表</param>
|
||||||
/// <param name="name">接口名称</param>
|
/// <param name="name">接口名称</param>
|
||||||
/// <param name="description">接口描述</param>
|
/// <param name="description">接口描述</param>
|
||||||
public SimApiDocAttribute(string tag, string name, string? description = null) : this([tag], name, description)
|
/// <param name="groupNames">所属文档组, 逗号分隔; "*" 表示所有文档</param>
|
||||||
|
/// <param name="ignore">true 时不出现在任何文档</param>
|
||||||
|
public SimApiDocAttribute(string[] tags, string? name = null, string? description = null,
|
||||||
|
string? groupNames = null, bool ignore = false)
|
||||||
{
|
{
|
||||||
|
Apply(tags, name, description, groupNames, ignore);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Apply(string? tags, string? name, string? description,
|
||||||
|
string? groupNames, bool ignore)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(tags))
|
||||||
|
{
|
||||||
|
Tags = tags.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyCore(name, description, groupNames, ignore);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Apply(string[] tags, string? name, string? description,
|
||||||
|
string? groupNames, bool ignore)
|
||||||
|
{
|
||||||
|
if (tags is { Length: > 0 })
|
||||||
|
{
|
||||||
|
Tags = tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyCore(name, description, groupNames, ignore);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyCore(string? name, string? description, string? groupNames, bool ignore)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(name))
|
||||||
|
{
|
||||||
|
Summary = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(description))
|
||||||
|
{
|
||||||
|
Description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
GroupNames = groupNames;
|
||||||
|
Ignore = ignore;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,7 @@ namespace SimApi.Configurations;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 文档组配置
|
/// 文档组配置
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SimApiDocGroupOption(string id, string name, string description = "")
|
public class SimApiDocGroupOption(string id, string name, string description = "", bool isDefault = false)
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 文档标识
|
/// 文档标识
|
||||||
@@ -22,6 +22,11 @@ public class SimApiDocGroupOption(string id, string name, string description = "
|
|||||||
/// 文档描述
|
/// 文档描述
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Description { get; set; } = description!;
|
public string Description { get; set; } = description!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否为默认文档组: 未标注分组的接口全部归入此文档组
|
||||||
|
/// </summary>
|
||||||
|
public bool IsDefault { get; set; } = isDefault;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -50,7 +55,7 @@ public class SimApiDocOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public SimApiDocGroupOption[] ApiGroups { get; set; } =
|
public SimApiDocGroupOption[] ApiGroups { get; set; } =
|
||||||
[
|
[
|
||||||
new("api", "Api", "Api接口文档")
|
new("api", "Api", "Api接口文档", isDefault: true)
|
||||||
];
|
];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -58,6 +63,12 @@ public class SimApiDocOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public SimApiAuthOption ApiAuth { get; set; } = new();
|
public SimApiAuthOption ApiAuth { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 接口文档页面访问前缀, 默认 "docs"。
|
||||||
|
/// 访问 /docs 即打开文档页面, JSON 地址为 /docs/{文档组Id}.json
|
||||||
|
/// </summary>
|
||||||
|
public string UrlPrefix { get; set; } = "docs";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 文档页面标题
|
/// 文档页面标题
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,11 +1,25 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace SimApi.Configurations;
|
namespace SimApi.Configurations;
|
||||||
|
|
||||||
public class SimApiOptions
|
public class SimApiOptions
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Redis配置
|
||||||
|
/// </summary>
|
||||||
public string? RedisConfiguration { get; set; }
|
public string? RedisConfiguration { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 原样返回给前端的配置信息
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, object>? WebConfig { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WebConfig返回是否包含版本信息
|
||||||
|
/// </summary>
|
||||||
|
public bool WebConfigIncludeVersion { get; set; } = true;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 是否启用后台任务系统 *基于Hangfire
|
/// 是否启用后台任务系统 *基于Hangfire
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -11,4 +11,14 @@ public class SimApiRequestLogOptions
|
|||||||
/// 是否打印完整的响应体
|
/// 是否打印完整的响应体
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool ShowFullResponse { get; set; }
|
public bool ShowFullResponse { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 请求字段显示最长长度
|
||||||
|
/// </summary>
|
||||||
|
public int RequestStringLogLength { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 响应字段显示最长长度
|
||||||
|
/// </summary>
|
||||||
|
public int ResponseStringLogLength { get; set; } = 0;
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
public class SimApiRouteOptions
|
public class SimApiRouteOptions
|
||||||
{
|
{
|
||||||
public string? VersionRoute = "/versions";
|
|
||||||
public string? LogoutRoute = "/auth/logout";
|
public string? LogoutRoute = "/auth/logout";
|
||||||
public string? UserInfoRoute = "/user/info";
|
public string? UserInfoRoute = "/user/info";
|
||||||
|
public string? WebConfigRoute = "/config";
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,7 @@ public class SimApiAuthController(SimApiAuth auth) : SimApiBaseController
|
|||||||
/// 退出登陆
|
/// 退出登陆
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpPost, SimApiDoc("认证", "退出登陆")]
|
[HttpPost, SimApiDoc("认证", "退出登陆",groupNames: "*")]
|
||||||
public void Logout()
|
public void Logout()
|
||||||
{
|
{
|
||||||
if (Request.Headers.TryGetValue("Token", out var value))
|
if (Request.Headers.TryGetValue("Token", out var value))
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using SimApi.Configurations;
|
||||||
|
|
||||||
|
namespace SimApi.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SimApi 内置端点路由表中的一项。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SimApiBuiltInRoute(
|
||||||
|
string RouteName,
|
||||||
|
string Controller,
|
||||||
|
string Action,
|
||||||
|
string[] HttpMethods,
|
||||||
|
string Path);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SimApi 内置端点路由表 —— 路径的唯一配置处。
|
||||||
|
/// UseSimApi 用它注册约定路由(动态路由),
|
||||||
|
/// SimApiBuiltInRoutesDescriptionProvider 用它生成文档条目。
|
||||||
|
/// 某项路径为 null / 空时该端点既不注册路由、也不出现在文档中。
|
||||||
|
/// </summary>
|
||||||
|
public static class SimApiBuiltInRoutes
|
||||||
|
{
|
||||||
|
public static IEnumerable<SimApiBuiltInRoute> Get(SimApiRouteOptions options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(options.UserInfoRoute))
|
||||||
|
{
|
||||||
|
yield return new SimApiBuiltInRoute(
|
||||||
|
"UserInfo", "SimApiCommon", "UserInfo", ["POST"], options.UserInfoRoute);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(options.LogoutRoute))
|
||||||
|
{
|
||||||
|
yield return new SimApiBuiltInRoute(
|
||||||
|
"Logout", "SimApiAuth", "Logout", ["POST"], options.LogoutRoute);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(options.WebConfigRoute))
|
||||||
|
{
|
||||||
|
// 控制器动作同时标注了 HttpGet/HttpPost
|
||||||
|
yield return new SimApiBuiltInRoute(
|
||||||
|
"WebConfig", "SimApiCommon", "WebConfig", ["GET", "POST"], options.WebConfigRoute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ApiExplorer;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SimApi.Configurations;
|
||||||
|
|
||||||
|
namespace SimApi.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将 SimApiRouteOptions 动态配置的内置约定路由(UserInfo / Logout / WebConfig)
|
||||||
|
/// 翻译成 ApiDescription, 使它们能像属性路由接口一样出现在 Swagger 文档中:
|
||||||
|
/// 请求/响应 schema 由 Swashbuckle 自动分析, 方法上已有的 [SimApiDoc] 注解自动生效。
|
||||||
|
/// 路径为 null / 空 的端点在此一并跳过(与路由注册保持一致)。
|
||||||
|
/// </summary>
|
||||||
|
public class SimApiBuiltInRoutesDescriptionProvider(
|
||||||
|
IServiceProvider services,
|
||||||
|
SimApiOptions options) : IApiDescriptionProvider
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 默认 provider(DefaultApiDescriptionProvider)的 Order 为 -1000, 位于其后执行。
|
||||||
|
/// </summary>
|
||||||
|
public int Order => -900;
|
||||||
|
|
||||||
|
public void OnProvidersExecuting(ApiDescriptionProviderContext context)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnProvidersExecuted(ApiDescriptionProviderContext context)
|
||||||
|
{
|
||||||
|
var modelMetadata = services.GetService<IModelMetadataProvider>();
|
||||||
|
|
||||||
|
foreach (var route in SimApiBuiltInRoutes.Get(options.SimApiRouteOptions))
|
||||||
|
{
|
||||||
|
var action = context.Actions.OfType<ControllerActionDescriptor>()
|
||||||
|
.FirstOrDefault(a =>
|
||||||
|
string.Equals(a.ControllerName, route.Controller, StringComparison.Ordinal) &&
|
||||||
|
string.Equals(a.ActionName, route.Action, StringComparison.Ordinal));
|
||||||
|
if (action is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若该动作已被属性路由 / 其他 provider 收录, 跳过避免重复
|
||||||
|
if (context.Results.Any(d => ReferenceEquals(d.ActionDescriptor, action)))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var httpMethod in route.HttpMethods)
|
||||||
|
{
|
||||||
|
context.Results.Add(CreateDescription(action, route, httpMethod, modelMetadata));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ApiDescription CreateDescription(
|
||||||
|
ControllerActionDescriptor action,
|
||||||
|
SimApiBuiltInRoute route,
|
||||||
|
string httpMethod,
|
||||||
|
IModelMetadataProvider? modelMetadata)
|
||||||
|
{
|
||||||
|
var apiDescription = new ApiDescription
|
||||||
|
{
|
||||||
|
ActionDescriptor = action,
|
||||||
|
HttpMethod = httpMethod,
|
||||||
|
RelativePath = route.Path.TrimStart('/')
|
||||||
|
};
|
||||||
|
|
||||||
|
// 输入: 按真实方法参数翻译(内置端点均为零参, 此处保证将来加参也可分析)
|
||||||
|
foreach (var parameter in action.Parameters)
|
||||||
|
{
|
||||||
|
var parameterDescription = new ApiParameterDescription
|
||||||
|
{
|
||||||
|
Name = parameter.Name,
|
||||||
|
Type = parameter.ParameterType,
|
||||||
|
ParameterDescriptor = parameter,
|
||||||
|
Source = parameter.BindingInfo?.BindingSource ?? BindingSource.Body,
|
||||||
|
IsRequired = true
|
||||||
|
};
|
||||||
|
if (modelMetadata is not null)
|
||||||
|
{
|
||||||
|
parameterDescription.ModelMetadata = modelMetadata.GetMetadataForType(parameter.ParameterType);
|
||||||
|
}
|
||||||
|
|
||||||
|
apiDescription.ParameterDescriptions.Add(parameterDescription);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输出: 方法返回类型 => 200 + json
|
||||||
|
var returnType = Unwrap(action.MethodInfo.ReturnType);
|
||||||
|
if (returnType != typeof(void))
|
||||||
|
{
|
||||||
|
var responseType = new ApiResponseType
|
||||||
|
{
|
||||||
|
StatusCode = 200,
|
||||||
|
Type = returnType,
|
||||||
|
IsDefaultResponse = true
|
||||||
|
};
|
||||||
|
if (modelMetadata is not null)
|
||||||
|
{
|
||||||
|
responseType.ModelMetadata = modelMetadata.GetMetadataForType(returnType);
|
||||||
|
}
|
||||||
|
|
||||||
|
responseType.ApiResponseFormats.Add(new ApiResponseFormat { MediaType = "application/json" });
|
||||||
|
apiDescription.SupportedResponseTypes.Add(responseType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiDescription;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Type Unwrap(Type type) =>
|
||||||
|
type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Task<>)
|
||||||
|
? type.GetGenericArguments()[0]
|
||||||
|
: type;
|
||||||
|
}
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using SimApi.Attributes;
|
using SimApi.Attributes;
|
||||||
using SimApi.Communications;
|
using SimApi.Communications;
|
||||||
|
using SimApi.Configurations;
|
||||||
using SimApi.Helpers;
|
using SimApi.Helpers;
|
||||||
using static SimApi.Helpers.SimApiError;
|
using static SimApi.Helpers.SimApiError;
|
||||||
|
|
||||||
namespace SimApi.Controllers;
|
namespace SimApi.Controllers;
|
||||||
|
|
||||||
public class SimApiCommonController : SimApiBaseController
|
public class SimApiCommonController(SimApiOptions simApiOptions) : SimApiBaseController
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 错误回馈页面
|
/// 错误回馈页面
|
||||||
@@ -15,21 +18,31 @@ public class SimApiCommonController : SimApiBaseController
|
|||||||
/// <param name="code">错误代码</param>
|
/// <param name="code">错误代码</param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpGet("exception/{code:int}")]
|
[HttpGet("exception/{code:int}")]
|
||||||
[ApiExplorerSettings(IgnoreApi = true)]
|
[SimApiDoc(ignore: true)]
|
||||||
public void ExceptionHandler(int code)
|
public void ExceptionHandler(int code)
|
||||||
{
|
{
|
||||||
Error(code);
|
Error(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 给前端的自定义信息
|
||||||
|
/// </summary>
|
||||||
|
/// <returns></returns>
|
||||||
[HttpPost, HttpGet]
|
[HttpPost, HttpGet]
|
||||||
public Dictionary<string, string> Versions()
|
[HttpPost, SimApiDoc("公共", "获取自定义配置",groupNames: "*")]
|
||||||
|
public Dictionary<string, object> WebConfig()
|
||||||
{
|
{
|
||||||
return new Dictionary<string, string>
|
var resp = simApiOptions.WebConfig!.ToDictionary();
|
||||||
|
if (simApiOptions.WebConfigIncludeVersion)
|
||||||
{
|
{
|
||||||
{ "SimApi", SimApiUtil.SimApiVersion },
|
resp.Add("Versions", new Dictionary<string, string>
|
||||||
{ "App", SimApiUtil.AppVersion }
|
{
|
||||||
};
|
{ "SimApi", SimApiUtil.SimApiVersion },
|
||||||
|
{ "App", SimApiUtil.AppVersion }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -37,6 +50,6 @@ public class SimApiCommonController : SimApiBaseController
|
|||||||
/// 获取已登录用户信息
|
/// 获取已登录用户信息
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpPost, SimApiAuth, SimApiDoc("认证", "获取已登录用户信息")]
|
[HttpPost, SimApiAuth, SimApiDoc("认证", "获取已登录用户信息",groupNames: "*")]
|
||||||
public SimApiLoginItem UserInfo() => LoginInfo;
|
public SimApiLoginItem UserInfo() => LoginInfo;
|
||||||
}
|
}
|
||||||
@@ -28,7 +28,7 @@ public static class SimApiUtil
|
|||||||
{
|
{
|
||||||
// ReferenceHandler = ReferenceHandler.Preserve,
|
// ReferenceHandler = ReferenceHandler.Preserve,
|
||||||
Encoder = JavaScriptEncoder.Create(UnicodeRanges.All),
|
Encoder = JavaScriptEncoder.Create(UnicodeRanges.All),
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
// DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
// DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ using System.Linq;
|
|||||||
using System.Runtime.ExceptionServices;
|
using System.Runtime.ExceptionServices;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using SimApi.Configurations;
|
using SimApi.Configurations;
|
||||||
|
using SimApi.Helpers;
|
||||||
|
|
||||||
namespace SimApi.Middlewares;
|
namespace SimApi.Middlewares;
|
||||||
|
|
||||||
@@ -53,7 +55,16 @@ public class SimApiRequestLogMiddleware(
|
|||||||
var requestBodyText = await new StreamReader(context.Request.Body).ReadToEndAsync();
|
var requestBodyText = await new StreamReader(context.Request.Body).ReadToEndAsync();
|
||||||
context.Request.Body.Seek(0, SeekOrigin.Begin);
|
context.Request.Body.Seek(0, SeekOrigin.Begin);
|
||||||
logMessage.AppendLine("*( RequestBody ) =>");
|
logMessage.AppendLine("*( RequestBody ) =>");
|
||||||
logMessage.AppendLine(requestBodyText);
|
if (options.RequestStringLogLength == 0)
|
||||||
|
{
|
||||||
|
logMessage.AppendLine(requestBodyText);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// GET/DELETE 等无 body 的请求, 或表单/文件上传等非 JSON body:
|
||||||
|
// 不做长度裁剪, 原样记录, 避免空 JSON 解析抛异常
|
||||||
|
logMessage.AppendLine(TrimJsonStringFields(requestBodyText, options.RequestStringLogLength));
|
||||||
|
}
|
||||||
|
|
||||||
var originalBodyStream = context.Response.Body;
|
var originalBodyStream = context.Response.Body;
|
||||||
using var responseBody = new MemoryStream();
|
using var responseBody = new MemoryStream();
|
||||||
@@ -77,7 +88,12 @@ public class SimApiRequestLogMiddleware(
|
|||||||
var responseText = await new StreamReader(responseBody).ReadToEndAsync();
|
var responseText = await new StreamReader(responseBody).ReadToEndAsync();
|
||||||
|
|
||||||
logMessage.AppendLine($"*( Response [{context.Response.StatusCode}] ) =>");
|
logMessage.AppendLine($"*( Response [{context.Response.StatusCode}] ) =>");
|
||||||
if (options.ShowFullResponse)
|
if (options.ResponseStringLogLength > 0)
|
||||||
|
{
|
||||||
|
// 与请求体相同的字段级裁剪: JSON 按字段截断超长字符串, 非 JSON/空响应原样记录
|
||||||
|
logMessage.Append(TrimJsonStringFields(responseText, options.ResponseStringLogLength));
|
||||||
|
}
|
||||||
|
else if (options.ShowFullResponse)
|
||||||
{
|
{
|
||||||
logMessage.Append(responseText);
|
logMessage.Append(responseText);
|
||||||
}
|
}
|
||||||
@@ -102,4 +118,79 @@ public class SimApiRequestLogMiddleware(
|
|||||||
|
|
||||||
edi?.Throw();
|
edi?.Throw();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 与请求体相同的字段级裁剪(递归):
|
||||||
|
/// 遍历 JSON 对象/数组的所有层级, 把长度超过 maxLength 的字符串字段截断为前 maxLength 字符并追加 ...(原长度);
|
||||||
|
/// 空 body 或非 JSON body(表单/文件上传等)原样返回, 不裁剪
|
||||||
|
/// </summary>
|
||||||
|
private static string TrimJsonStringFields(string text, int maxLength)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text))
|
||||||
|
{
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode? root;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
root = JsonNode.Parse(text);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root == null)
|
||||||
|
{
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
TrimNode(root, maxLength);
|
||||||
|
return SimApiUtil.Json(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 递归裁剪节点下所有层级的字符串值:
|
||||||
|
/// 纯字符串字面量超长时截断, 数字/布尔/嵌套对象/数组继续向下递归
|
||||||
|
/// </summary>
|
||||||
|
private static void TrimNode(JsonNode? node, int maxLength)
|
||||||
|
{
|
||||||
|
switch (node)
|
||||||
|
{
|
||||||
|
case JsonObject obj:
|
||||||
|
// 键快照遍历后再赋值, 避免枚举过程中修改集合引发异常
|
||||||
|
foreach (var prop in obj.ToList())
|
||||||
|
{
|
||||||
|
if (prop.Value is JsonValue value &&
|
||||||
|
value.TryGetValue<string>(out var str) &&
|
||||||
|
str.Length > maxLength)
|
||||||
|
{
|
||||||
|
obj[prop.Key] = JsonValue.Create(str[..maxLength] + $"...({str.Length})");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
TrimNode(prop.Value, maxLength);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case JsonArray arr:
|
||||||
|
for (var i = 0; i < arr.Count; i++)
|
||||||
|
{
|
||||||
|
if (arr[i] is JsonValue value &&
|
||||||
|
value.TryGetValue<string>(out var str) &&
|
||||||
|
str.Length > maxLength)
|
||||||
|
{
|
||||||
|
arr[i] = JsonValue.Create(str[..maxLength] + $"...({str.Length})");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
TrimNode(arr[i], maxLength);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+56
-35
@@ -9,15 +9,18 @@ using Hangfire.Redis.StackExchange;
|
|||||||
using SimApi.Helpers;
|
using SimApi.Helpers;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using Microsoft.OpenApi;
|
using Microsoft.OpenApi;
|
||||||
using SimApi.Middlewares;
|
using SimApi.Middlewares;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ApiExplorer;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using SimApi.Attributes;
|
using SimApi.Attributes;
|
||||||
using SimApi.AuthSDK;
|
using SimApi.AuthSDK;
|
||||||
using SimApi.Configurations;
|
using SimApi.Configurations;
|
||||||
|
using SimApi.Controllers;
|
||||||
using SimApi.Interfaces;
|
using SimApi.Interfaces;
|
||||||
using SimApi.Logger;
|
using SimApi.Logger;
|
||||||
using SimApi.SwaggerFilters;
|
using SimApi.SwaggerFilters;
|
||||||
@@ -36,6 +39,8 @@ public static class SimApiExtensions
|
|||||||
{
|
{
|
||||||
var simApiOptions = new SimApiOptions();
|
var simApiOptions = new SimApiOptions();
|
||||||
options?.Invoke(simApiOptions);
|
options?.Invoke(simApiOptions);
|
||||||
|
simApiOptions.WebConfig ??= new();
|
||||||
|
builder.AddSingleton(simApiOptions);
|
||||||
|
|
||||||
if (simApiOptions.RedisConfiguration != null)
|
if (simApiOptions.RedisConfiguration != null)
|
||||||
{
|
{
|
||||||
@@ -227,15 +232,42 @@ public static class SimApiExtensions
|
|||||||
|
|
||||||
x.DocInclusionPredicate((docName, apiDesc) =>
|
x.DocInclusionPredicate((docName, apiDesc) =>
|
||||||
{
|
{
|
||||||
// 获取接口标记的 GroupName(未标记则为 null)
|
var metadata = apiDesc.ActionDescriptor.EndpointMetadata;
|
||||||
var actionGroupName = apiDesc.ActionDescriptor.EndpointMetadata
|
|
||||||
|
// 类/方法上的 [SimApiDoc] 标注统一控制文档归属
|
||||||
|
var docAttrs = metadata.OfType<SimApiDocAttribute>().ToArray();
|
||||||
|
|
||||||
|
// Ignore: 不出现在任何文档(路由不受影响)
|
||||||
|
if (docAttrs.Any(a => a.Ignore))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupNames: 逗号分隔的文档组列表; "*" 表示所有文档;
|
||||||
|
// 标注在类上则整类生效, 方法级标注可覆盖
|
||||||
|
var groupNames = docAttrs
|
||||||
|
.Select(a => a.GroupNames)
|
||||||
|
.FirstOrDefault(g => !string.IsNullOrWhiteSpace(g));
|
||||||
|
if (groupNames != null)
|
||||||
|
{
|
||||||
|
var groups = groupNames.Split(',',
|
||||||
|
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
return groups.Contains(SimApiDocAttribute.AllGroups) || groups.Contains(docName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容: ApiExplorerSettings.GroupName 单组标注
|
||||||
|
var actionGroupName = metadata
|
||||||
.OfType<ApiExplorerSettingsAttribute>()
|
.OfType<ApiExplorerSettingsAttribute>()
|
||||||
.FirstOrDefault()?.GroupName;
|
.FirstOrDefault()?.GroupName;
|
||||||
|
|
||||||
// 情况1:接口未标记任何 GroupName(actionGroupName 为 null)
|
// 未标注任何分组 → 放入配置为 IsDefault 的文档组;
|
||||||
|
// 未配置 IsDefault 时依次回退 id 为 "api" 的组 / 第一个组
|
||||||
if (actionGroupName == null)
|
if (actionGroupName == null)
|
||||||
{
|
{
|
||||||
return docName == "api"; // 未分组接口只属于默认分组v1
|
var defaultGroup = docOptions.ApiGroups.FirstOrDefault(g => g.IsDefault)
|
||||||
|
?? docOptions.ApiGroups.FirstOrDefault(g => g.Id == "api")
|
||||||
|
?? docOptions.ApiGroups.FirstOrDefault();
|
||||||
|
return defaultGroup != null && docName == defaultGroup.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
return docName == actionGroupName;
|
return docName == actionGroupName;
|
||||||
@@ -306,6 +338,13 @@ public static class SimApiExtensions
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 让 SimApiRouteOptions 配置的内置约定路由(UserInfo/Logout/WebConfig)
|
||||||
|
// 也能出现在文档中: 通过 IApiDescriptionProvider 手工产出 ApiDescription,
|
||||||
|
// 请求/响应 schema 由 Swashbuckle 自动分析, [SimApiDoc] 注解自动生效。
|
||||||
|
builder.TryAddEnumerable(
|
||||||
|
ServiceDescriptor.Transient<IApiDescriptionProvider,
|
||||||
|
SimApiBuiltInRoutesDescriptionProvider>());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用Header转发,应对代理后获取真实ip
|
// 使用Header转发,应对代理后获取真实ip
|
||||||
@@ -315,7 +354,6 @@ public static class SimApiExtensions
|
|||||||
{
|
{
|
||||||
fwOptions.ForwardedHeaders = ForwardedHeaders.All;
|
fwOptions.ForwardedHeaders = ForwardedHeaders.All;
|
||||||
fwOptions.KnownNetworks.Clear();
|
fwOptions.KnownNetworks.Clear();
|
||||||
fwOptions.KnownProxies.Clear();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,7 +388,6 @@ public static class SimApiExtensions
|
|||||||
}
|
}
|
||||||
|
|
||||||
builder.AddSingleton(simApiOptions.SimApiRequestLogOptions);
|
builder.AddSingleton(simApiOptions.SimApiRequestLogOptions);
|
||||||
builder.AddSingleton(simApiOptions);
|
|
||||||
return builder;
|
return builder;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,37 +498,19 @@ public static class SimApiExtensions
|
|||||||
builder.UseMiddleware<SimApiAuthMiddleware>();
|
builder.UseMiddleware<SimApiAuthMiddleware>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 注册内置Route(UserInfo/Logout/WebConfig)。
|
||||||
if (options.SimApiRouteOptions.VersionRoute != null)
|
// 路径统一由 SimApiBuiltInRoutes 路由表提供: 配置为 null/空 的端点不注册。
|
||||||
|
foreach (var route in SimApiBuiltInRoutes.Get(options.SimApiRouteOptions))
|
||||||
{
|
{
|
||||||
logger.LogInformation("注册内置Route: Versions => {}", options.SimApiRouteOptions.LogoutRoute);
|
logger.LogInformation("注册内置Route: {RouteName} => {RoutePath} [{HttpMethods}]", route.RouteName,
|
||||||
builder.MapControllerRoute(name: "Versions", pattern: options.SimApiRouteOptions.VersionRoute,
|
route.Path, string.Join(", ", route.HttpMethods));
|
||||||
|
builder.MapControllerRoute(
|
||||||
|
name: route.RouteName,
|
||||||
|
pattern: route.Path,
|
||||||
defaults: new
|
defaults: new
|
||||||
{
|
{
|
||||||
controller = "SimApiCommon",
|
controller = route.Controller,
|
||||||
action = "Versions"
|
action = route.Action
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.SimApiRouteOptions.UserInfoRoute != null)
|
|
||||||
{
|
|
||||||
logger.LogInformation("注册内置Route: UserInfo => {}", options.SimApiRouteOptions.UserInfoRoute);
|
|
||||||
builder.MapControllerRoute(name: "UserInfo", pattern: options.SimApiRouteOptions.UserInfoRoute,
|
|
||||||
defaults: new
|
|
||||||
{
|
|
||||||
controller = "SimApiCommon",
|
|
||||||
action = "UserInfo"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.SimApiRouteOptions.LogoutRoute != null)
|
|
||||||
{
|
|
||||||
logger.LogInformation("注册内置Route: Logout => {}", options.SimApiRouteOptions.LogoutRoute);
|
|
||||||
builder.MapControllerRoute(name: "Logout", pattern: options.SimApiRouteOptions.LogoutRoute,
|
|
||||||
defaults: new
|
|
||||||
{
|
|
||||||
controller = "SimApiAuth",
|
|
||||||
action = "Logout"
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,12 +518,14 @@ public static class SimApiExtensions
|
|||||||
{
|
{
|
||||||
logger.LogInformation("开始配置SimApiDoc...");
|
logger.LogInformation("开始配置SimApiDoc...");
|
||||||
var docOptions = options.SimApiDocOptions;
|
var docOptions = options.SimApiDocOptions;
|
||||||
builder.UseSwagger(x => x.RouteTemplate = "/swagger/{documentName}.json")
|
builder.UseSwagger(x => x.RouteTemplate = $"/{docOptions.UrlPrefix}/{{documentName}}.json")
|
||||||
.UseSwaggerUI(x =>
|
.UseSwaggerUI(x =>
|
||||||
{
|
{
|
||||||
|
x.RoutePrefix = docOptions.UrlPrefix;
|
||||||
x.DocumentTitle = docOptions.DocumentTitle;
|
x.DocumentTitle = docOptions.DocumentTitle;
|
||||||
foreach (var group in docOptions.ApiGroups)
|
foreach (var group in docOptions.ApiGroups)
|
||||||
{
|
{
|
||||||
|
// 相对端点: 页面位于 /{UrlPrefix}/, 自动解析为 /{UrlPrefix}/{group.Id}.json
|
||||||
x.SwaggerEndpoint($"{group.Id}.json", name: group.Name);
|
x.SwaggerEndpoint($"{group.Id}.json", name: group.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user