diff --git a/Attributes/SimApiDocAttribute.cs b/Attributes/SimApiDocAttribute.cs
index 12e3ec7..f96357e 100644
--- a/Attributes/SimApiDocAttribute.cs
+++ b/Attributes/SimApiDocAttribute.cs
@@ -1,39 +1,105 @@
-using System;
+using System;
using Swashbuckle.AspNetCore.Annotations;
namespace SimApi.Attributes;
///
-/// 快捷自定义接口文档类
+/// 快捷自定义接口文档类(所有参数均可省略, 支持命名参数)。
+/// 对应仓颉版 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 等)
+/// 属性命名参数优先于构造命名参数。
///
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public class SimApiDocAttribute : SwaggerOperationAttribute
{
///
- /// 定义接口说明
+ /// 出现在所有文档组中的通配符
///
- /// 接口分组列表
+ public const string AllGroups = "*";
+
+ ///
+ /// 接口所属的文档组(逗号分隔, 如 "api,admin"); "*" 表示所有文档; null/空 表示未分组(仅默认 "api" 文档)
+ ///
+ public string? GroupNames { get; set; }
+
+ ///
+ /// 为 true 时该接口不出现在任何文档中(不影响路由)
+ ///
+ public bool Ignore { get; set; }
+
+ ///
+ /// 定义接口说明(全部可选)
+ ///
+ /// 接口标签, 逗号分隔, 如 "认证,用户"
/// 接口名称
/// 接口描述
- public SimApiDocAttribute(string[] tags, string name, string? description = null)
+ /// 所属文档组, 逗号分隔; "*" 表示所有文档
+ /// true 时不出现在任何文档
+ public SimApiDocAttribute(string? tags = null, string? name = null, string? description = null,
+ string? groupNames = null, bool ignore = false)
{
- Tags = tags;
- Summary = name;
- if (description != null)
- {
- Description = description;
- }
- // Consumes = new[] {"application/json"};
- // Produces = new[] {"application/json"};
+ Apply(tags, name, description, groupNames, ignore);
}
///
- /// 定义接口说明
+ /// 定义接口说明(标签以数组传入)
///
- /// 接口分组
+ /// 接口标签列表
/// 接口名称
/// 接口描述
- public SimApiDocAttribute(string tag, string name, string? description = null) : this([tag], name, description)
+ /// 所属文档组, 逗号分隔; "*" 表示所有文档
+ /// true 时不出现在任何文档
+ public SimApiDocAttribute(string[] tags, string? name = null, string? description = null,
+ string? groupNames = null, bool ignore = false)
{
+ Apply(tags, name, description, groupNames, ignore);
}
-}
\ No newline at end of file
+
+ 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;
+ }
+}
diff --git a/Configurations/SimApiDocOptions.cs b/Configurations/SimApiDocOptions.cs
index 2271ad2..8003ce1 100644
--- a/Configurations/SimApiDocOptions.cs
+++ b/Configurations/SimApiDocOptions.cs
@@ -6,7 +6,7 @@ namespace SimApi.Configurations;
///
/// 文档组配置
///
-public class SimApiDocGroupOption(string id, string name, string description = "")
+public class SimApiDocGroupOption(string id, string name, string description = "", bool isDefault = false)
{
///
/// 文档标识
@@ -22,6 +22,11 @@ public class SimApiDocGroupOption(string id, string name, string description = "
/// 文档描述
///
public string Description { get; set; } = description!;
+
+ ///
+ /// 是否为默认文档组: 未标注分组的接口全部归入此文档组
+ ///
+ public bool IsDefault { get; set; } = isDefault;
}
///
@@ -50,7 +55,7 @@ public class SimApiDocOptions
///
public SimApiDocGroupOption[] ApiGroups { get; set; } =
[
- new("api", "Api", "Api接口文档")
+ new("api", "Api", "Api接口文档", isDefault: true)
];
///
@@ -58,6 +63,12 @@ public class SimApiDocOptions
///
public SimApiAuthOption ApiAuth { get; set; } = new();
+ ///
+ /// 接口文档页面访问前缀, 默认 "docs"。
+ /// 访问 /docs 即打开文档页面, JSON 地址为 /docs/{文档组Id}.json
+ ///
+ public string UrlPrefix { get; set; } = "docs";
+
///
/// 文档页面标题
///
diff --git a/Controllers/SimApiAuthController.cs b/Controllers/SimApiAuthController.cs
index bc8e481..135f126 100644
--- a/Controllers/SimApiAuthController.cs
+++ b/Controllers/SimApiAuthController.cs
@@ -11,7 +11,7 @@ public class SimApiAuthController(SimApiAuth auth) : SimApiBaseController
/// 退出登陆
///
///
- [HttpPost, SimApiDoc("认证", "退出登陆")]
+ [HttpPost, SimApiDoc("认证", "退出登陆",groupNames: "*")]
public void Logout()
{
if (Request.Headers.TryGetValue("Token", out var value))
diff --git a/Controllers/SimApiBuiltInRoutes.cs b/Controllers/SimApiBuiltInRoutes.cs
new file mode 100644
index 0000000..d1d87a1
--- /dev/null
+++ b/Controllers/SimApiBuiltInRoutes.cs
@@ -0,0 +1,48 @@
+using System;
+using System.Collections.Generic;
+using SimApi.Configurations;
+
+namespace SimApi.Controllers;
+
+///
+/// SimApi 内置端点路由表中的一项。
+///
+public sealed record SimApiBuiltInRoute(
+ string RouteName,
+ string Controller,
+ string Action,
+ string[] HttpMethods,
+ string Path);
+
+///
+/// SimApi 内置端点路由表 —— 路径的唯一配置处。
+/// UseSimApi 用它注册约定路由(动态路由),
+/// SimApiBuiltInRoutesDescriptionProvider 用它生成文档条目。
+/// 某项路径为 null / 空时该端点既不注册路由、也不出现在文档中。
+///
+public static class SimApiBuiltInRoutes
+{
+ public static IEnumerable 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);
+ }
+ }
+}
diff --git a/Controllers/SimApiBuiltInRoutesDescriptionProvider.cs b/Controllers/SimApiBuiltInRoutesDescriptionProvider.cs
new file mode 100644
index 0000000..fb4ce97
--- /dev/null
+++ b/Controllers/SimApiBuiltInRoutesDescriptionProvider.cs
@@ -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;
+
+///
+/// 将 SimApiRouteOptions 动态配置的内置约定路由(UserInfo / Logout / WebConfig)
+/// 翻译成 ApiDescription, 使它们能像属性路由接口一样出现在 Swagger 文档中:
+/// 请求/响应 schema 由 Swashbuckle 自动分析, 方法上已有的 [SimApiDoc] 注解自动生效。
+/// 路径为 null / 空 的端点在此一并跳过(与路由注册保持一致)。
+///
+public class SimApiBuiltInRoutesDescriptionProvider(
+ IServiceProvider services,
+ SimApiOptions options) : IApiDescriptionProvider
+{
+ ///
+ /// 默认 provider(DefaultApiDescriptionProvider)的 Order 为 -1000, 位于其后执行。
+ ///
+ public int Order => -900;
+
+ public void OnProvidersExecuting(ApiDescriptionProviderContext context)
+ {
+ }
+
+ public void OnProvidersExecuted(ApiDescriptionProviderContext context)
+ {
+ var modelMetadata = services.GetService();
+
+ foreach (var route in SimApiBuiltInRoutes.Get(options.SimApiRouteOptions))
+ {
+ var action = context.Actions.OfType()
+ .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;
+}
diff --git a/Controllers/SimApiCommonController.cs b/Controllers/SimApiCommonController.cs
index ffb6192..c1bbf0e 100644
--- a/Controllers/SimApiCommonController.cs
+++ b/Controllers/SimApiCommonController.cs
@@ -18,7 +18,7 @@ public class SimApiCommonController(SimApiOptions simApiOptions) : SimApiBaseCon
/// 错误代码
///
[HttpGet("exception/{code:int}")]
- [ApiExplorerSettings(IgnoreApi = true)]
+ [SimApiDoc(ignore: true)]
public void ExceptionHandler(int code)
{
Error(code);
@@ -29,6 +29,7 @@ public class SimApiCommonController(SimApiOptions simApiOptions) : SimApiBaseCon
///
///
[HttpPost, HttpGet]
+ [HttpPost, SimApiDoc("公共", "获取自定义配置",groupNames: "*")]
public Dictionary WebConfig()
{
var resp = simApiOptions.WebConfig!.ToDictionary();
@@ -49,6 +50,6 @@ public class SimApiCommonController(SimApiOptions simApiOptions) : SimApiBaseCon
/// 获取已登录用户信息
///
///
- [HttpPost, SimApiAuth, SimApiDoc("认证", "获取已登录用户信息")]
+ [HttpPost, SimApiAuth, SimApiDoc("认证", "获取已登录用户信息",groupNames: "*")]
public SimApiLoginItem UserInfo() => LoginInfo;
}
\ No newline at end of file
diff --git a/Helpers/SimApiUtil.cs b/Helpers/SimApiUtil.cs
index e5543a0..0186b39 100644
--- a/Helpers/SimApiUtil.cs
+++ b/Helpers/SimApiUtil.cs
@@ -28,7 +28,7 @@ public static class SimApiUtil
{
// ReferenceHandler = ReferenceHandler.Preserve,
Encoder = JavaScriptEncoder.Create(UnicodeRanges.All),
- PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
// DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
diff --git a/Middlewares/SimApiRequestLogMiddleware.cs b/Middlewares/SimApiRequestLogMiddleware.cs
index 4f114d6..f123080 100644
--- a/Middlewares/SimApiRequestLogMiddleware.cs
+++ b/Middlewares/SimApiRequestLogMiddleware.cs
@@ -60,18 +60,36 @@ public class SimApiRequestLogMiddleware(
}
else
{
- var reqLogs = SimApiUtil.FromJson>(requestBodyText);
- foreach (var reqLog in reqLogs)
+ // GET/DELETE 等无 body 的请求, 或表单/文件上传等非 JSON body:
+ // 不进行长度裁剪, 原样记录, 避免空 JSON 解析抛异常
+ Dictionary? reqLogs = null;
+ try
{
- if (reqLog.Value is not JsonElement { ValueKind: JsonValueKind.String } je) continue;
- var str = je.GetString();
- if (str?.Length > options.RequestStringLogLength)
- {
- reqLogs[reqLog.Key] = str[..options.RequestStringLogLength] + $"...({str.Length})";
- }
+ reqLogs = SimApiUtil.FromJson>(requestBodyText);
+ }
+ catch (JsonException)
+ {
+ reqLogs = null;
}
- logMessage.AppendLine(SimApiUtil.Json(reqLogs));
+ if (reqLogs == null)
+ {
+ logMessage.AppendLine(requestBodyText);
+ }
+ else
+ {
+ foreach (var reqLog in reqLogs)
+ {
+ if (reqLog.Value is not JsonElement { ValueKind: JsonValueKind.String } je) continue;
+ var str = je.GetString();
+ if (str?.Length > options.RequestStringLogLength)
+ {
+ reqLogs[reqLog.Key] = str[..options.RequestStringLogLength] + $"...({str.Length})";
+ }
+ }
+
+ logMessage.AppendLine(SimApiUtil.Json(reqLogs));
+ }
}
var originalBodyStream = context.Response.Body;
diff --git a/SimApiExtensions.cs b/SimApiExtensions.cs
index 96a4f07..ee0bd68 100644
--- a/SimApiExtensions.cs
+++ b/SimApiExtensions.cs
@@ -9,15 +9,18 @@ using Hangfire.Redis.StackExchange;
using SimApi.Helpers;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.OpenApi;
using SimApi.Middlewares;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using SimApi.Attributes;
using SimApi.AuthSDK;
using SimApi.Configurations;
+using SimApi.Controllers;
using SimApi.Interfaces;
using SimApi.Logger;
using SimApi.SwaggerFilters;
@@ -229,15 +232,42 @@ public static class SimApiExtensions
x.DocInclusionPredicate((docName, apiDesc) =>
{
- // 获取接口标记的 GroupName(未标记则为 null)
- var actionGroupName = apiDesc.ActionDescriptor.EndpointMetadata
+ var metadata = apiDesc.ActionDescriptor.EndpointMetadata;
+
+ // 类/方法上的 [SimApiDoc] 标注统一控制文档归属
+ var docAttrs = metadata.OfType().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()
.FirstOrDefault()?.GroupName;
- // 情况1:接口未标记任何 GroupName(actionGroupName 为 null)
+ // 未标注任何分组 → 放入配置为 IsDefault 的文档组;
+ // 未配置 IsDefault 时依次回退 id 为 "api" 的组 / 第一个组
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;
@@ -308,6 +338,13 @@ public static class SimApiExtensions
});
}
});
+
+ // 让 SimApiRouteOptions 配置的内置约定路由(UserInfo/Logout/WebConfig)
+ // 也能出现在文档中: 通过 IApiDescriptionProvider 手工产出 ApiDescription,
+ // 请求/响应 schema 由 Swashbuckle 自动分析, [SimApiDoc] 注解自动生效。
+ builder.TryAddEnumerable(
+ ServiceDescriptor.Transient());
}
// 使用Header转发,应对代理后获取真实ip
@@ -316,7 +353,7 @@ public static class SimApiExtensions
builder.Configure(fwOptions =>
{
fwOptions.ForwardedHeaders = ForwardedHeaders.All;
- fwOptions.KnownNetworks.Clear();
+ fwOptions.KnownIPNetworks.Clear();
fwOptions.KnownProxies.Clear();
});
}
@@ -462,36 +499,19 @@ public static class SimApiExtensions
builder.UseMiddleware();
}
- if (options.SimApiRouteOptions.UserInfoRoute != null)
+ // 注册内置Route(UserInfo/Logout/WebConfig)。
+ // 路径统一由 SimApiBuiltInRoutes 路由表提供: 配置为 null/空 的端点不注册。
+ foreach (var route in SimApiBuiltInRoutes.Get(options.SimApiRouteOptions))
{
- logger.LogInformation("注册内置Route: UserInfo => {}", options.SimApiRouteOptions.UserInfoRoute);
- builder.MapControllerRoute(name: "UserInfo", pattern: options.SimApiRouteOptions.UserInfoRoute,
+ logger.LogInformation("注册内置Route: {RouteName} => {RoutePath} [{HttpMethods}]", route.RouteName,
+ route.Path, string.Join(", ", route.HttpMethods));
+ builder.MapControllerRoute(
+ name: route.RouteName,
+ pattern: route.Path,
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"
- });
- }
-
- if (options.SimApiRouteOptions.WebConfigRoute != null)
- {
- logger.LogInformation("注册内置Route: Logout => {}", options.SimApiRouteOptions.WebConfigRoute);
- builder.MapControllerRoute(name: "WebConfig", pattern: options.SimApiRouteOptions.WebConfigRoute,
- defaults: new
- {
- controller = "SimApiCommon",
- action = "WebConfig"
+ controller = route.Controller,
+ action = route.Action
});
}
@@ -499,12 +519,14 @@ public static class SimApiExtensions
{
logger.LogInformation("开始配置SimApiDoc...");
var docOptions = options.SimApiDocOptions;
- builder.UseSwagger(x => x.RouteTemplate = "/swagger/{documentName}.json")
+ builder.UseSwagger(x => x.RouteTemplate = $"/{docOptions.UrlPrefix}/{{documentName}}.json")
.UseSwaggerUI(x =>
{
+ x.RoutePrefix = docOptions.UrlPrefix;
x.DocumentTitle = docOptions.DocumentTitle;
foreach (var group in docOptions.ApiGroups)
{
+ // 相对端点: 页面位于 /{UrlPrefix}/, 自动解析为 /{UrlPrefix}/{group.Id}.json
x.SwaggerEndpoint($"{group.Id}.json", name: group.Name);
}