refactor: 重构待办事项模块结构与命名

This commit is contained in:
ShaoHua
2026-04-08 19:59:50 +08:00
parent 7a4c516a20
commit 04263dff4e
30 changed files with 888 additions and 320 deletions
@@ -0,0 +1,400 @@
using System.Reflection;
using Hua.Todo.Application.Interfaces;
using Hua.Todo.Application.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using Microsoft.OpenApi;
namespace Hua.Todo.Application.DynamicApi.Swagger;
/// <summary>
/// 为 Dynamic API<c>/api/{service}/...</c>)补齐 OpenAPI 文档的过滤器。
/// 说明:Dynamic API 通过中间件反射分发,不会被 ASP.NET Core 的 ApiExplorer 自动发现;
/// 因此需要在 Swagger 生成阶段手动把这些端点补充到 OpenAPI Paths 中。
/// </summary>
public sealed class DynamicApiSwaggerDocumentFilter : IDocumentFilter
{
/// <summary>
/// 将 Dynamic API 端点追加到 <paramref name="swaggerDoc"/>。
/// </summary>
/// <param name="swaggerDoc">待输出的 OpenAPI 文档。</param>
/// <param name="context">Swagger 生成上下文(用于 Schema 生成与引用管理)。</param>
public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
{
var dynamicApiAssembly = typeof(IDynamicApiService).Assembly;
var serviceInterfaces = dynamicApiAssembly
.GetTypes()
.Where(t => t.IsInterface && typeof(IDynamicApiService).IsAssignableFrom(t))
.Where(IsRemoteServiceEnabled)
.ToList();
foreach (var serviceInterface in serviceInterfaces)
{
var serviceSegment = GetServiceRouteSegment(serviceInterface);
if (string.IsNullOrWhiteSpace(serviceSegment))
{
continue;
}
AddServiceOperations(swaggerDoc, context, serviceInterface, serviceSegment);
}
}
private static void AddServiceOperations(
OpenApiDocument swaggerDoc,
DocumentFilterContext context,
Type serviceInterface,
string serviceSegment)
{
var methods = serviceInterface
.GetMethods(BindingFlags.Public | BindingFlags.Instance)
.Where(IsRemoteServiceEnabled)
.ToList();
foreach (var method in methods)
{
var httpMethod = GetHttpMethod(method);
if (string.IsNullOrWhiteSpace(httpMethod))
{
continue;
}
if (!TryGetRouteSuffix(serviceInterface, method, out var routeSuffix))
{
continue;
}
var fullPath = BuildFullPath(serviceSegment, routeSuffix);
if (!swaggerDoc.Paths.TryGetValue(fullPath, out var pathItem))
{
pathItem = new OpenApiPathItem();
swaggerDoc.Paths[fullPath] = pathItem;
}
var operation = BuildOperation(context, serviceSegment, method, httpMethod, routeSuffix);
TrySetOperation(pathItem, httpMethod, operation);
}
}
private static OpenApiOperation BuildOperation(
DocumentFilterContext context,
string serviceSegment,
MethodInfo method,
string httpMethod,
string routeSuffix)
{
var operation = new OpenApiOperation
{
OperationId = $"DynamicApi_{serviceSegment}_{httpMethod}_{method.Name}",
Summary = $"{serviceSegment} - {method.Name}",
Tags = new HashSet<OpenApiTagReference> { new OpenApiTagReference(serviceSegment, null, serviceSegment) },
Responses = BuildResponses(context, method)
};
AddParametersAndBody(context, operation, method, routeSuffix, httpMethod);
return operation;
}
private static OpenApiResponses BuildResponses(DocumentFilterContext context, MethodInfo method)
{
var returnDataType = UnwrapReturnDataType(method.ReturnType) ?? typeof(object);
var responseType = typeof(ApiResponse<>).MakeGenericType(returnDataType);
var responseSchema = context.SchemaGenerator.GenerateSchema(responseType, context.SchemaRepository);
return new OpenApiResponses
{
["200"] = new OpenApiResponse
{
Description = "OK",
Content = new Dictionary<string, OpenApiMediaType>
{
["application/json"] = new OpenApiMediaType { Schema = responseSchema }
}
}
};
}
private static void AddParametersAndBody(
DocumentFilterContext context,
OpenApiOperation operation,
MethodInfo method,
string routeSuffix,
string httpMethod)
{
var routeParams = ExtractRouteParameters(routeSuffix);
if (routeParams.Count > 0)
{
operation.Parameters ??= new List<IOpenApiParameter>();
foreach (var routeParamName in routeParams)
{
var paramInfo = method.GetParameters()
.FirstOrDefault(p => string.Equals(p.Name, routeParamName, StringComparison.OrdinalIgnoreCase));
var paramType = paramInfo?.ParameterType ?? typeof(string);
var schema = context.SchemaGenerator.GenerateSchema(paramType, context.SchemaRepository);
operation.Parameters.Add(new OpenApiParameter
{
Name = routeParamName,
In = ParameterLocation.Path,
Required = true,
Schema = schema
});
}
}
var requestBodyParameter = GetRequestBodyParameter(method, httpMethod);
if (requestBodyParameter == null)
{
return;
}
var bodySchema = context.SchemaGenerator.GenerateSchema(requestBodyParameter.ParameterType, context.SchemaRepository);
operation.RequestBody = new OpenApiRequestBody
{
Required = true,
Content = new Dictionary<string, OpenApiMediaType>
{
["application/json"] = new OpenApiMediaType { Schema = bodySchema }
}
};
}
private static ParameterInfo? GetRequestBodyParameter(MethodInfo method, string httpMethod)
{
if (string.Equals(httpMethod, "GET", StringComparison.OrdinalIgnoreCase) ||
string.Equals(httpMethod, "DELETE", StringComparison.OrdinalIgnoreCase))
{
return null;
}
var parameters = method.GetParameters();
if (parameters.Length == 0)
{
return null;
}
var bodyParam = parameters.FirstOrDefault(p => p.GetCustomAttribute<FromBodyAttribute>() != null);
if (bodyParam != null)
{
return bodyParam;
}
return parameters.FirstOrDefault(p => !IsSimpleType(p.ParameterType));
}
private static Type? UnwrapReturnDataType(Type returnType)
{
if (returnType == typeof(void))
{
return null;
}
if (returnType == typeof(Task))
{
return null;
}
if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(Task<>))
{
return returnType.GetGenericArguments()[0];
}
return returnType;
}
private static string BuildFullPath(string serviceSegment, string routeSuffix)
{
var normalizedSuffix = string.IsNullOrWhiteSpace(routeSuffix)
? string.Empty
: routeSuffix.StartsWith("/", StringComparison.Ordinal) ? routeSuffix : $"/{routeSuffix}";
return $"/api/{serviceSegment}{normalizedSuffix}";
}
private static List<string> ExtractRouteParameters(string routeSuffix)
{
var list = new List<string>();
var suffix = routeSuffix ?? string.Empty;
var startIndex = 0;
while (startIndex < suffix.Length)
{
var open = suffix.IndexOf('{', startIndex);
if (open < 0)
{
break;
}
var close = suffix.IndexOf('}', open + 1);
if (close < 0)
{
break;
}
var name = suffix.Substring(open + 1, close - open - 1).Trim();
if (!string.IsNullOrWhiteSpace(name))
{
list.Add(name);
}
startIndex = close + 1;
}
return list;
}
private static bool TryGetRouteSuffix(Type serviceInterface, MethodInfo method, out string routeSuffix)
{
routeSuffix = string.Empty;
var explicitRoute = method.GetCustomAttribute<HttpGetAttribute>()?.Route
?? method.GetCustomAttribute<HttpPostAttribute>()?.Route
?? method.GetCustomAttribute<HttpPutAttribute>()?.Route
?? method.GetCustomAttribute<HttpDeleteAttribute>()?.Route
?? method.GetCustomAttribute<HttpPatchAttribute>()?.Route;
if (!string.IsNullOrWhiteSpace(explicitRoute))
{
routeSuffix = explicitRoute.Trim();
return true;
}
if (string.Equals(serviceInterface.Name, "ITaskService", StringComparison.OrdinalIgnoreCase))
{
return TryGetTaskServiceRouteSuffix(method, out routeSuffix);
}
return false;
}
private static bool TryGetTaskServiceRouteSuffix(MethodInfo method, out string routeSuffix)
{
routeSuffix = string.Empty;
return method.Name switch
{
"GetAllTasksAsync" => Return(string.Empty, out routeSuffix),
"GetTaskByIdAsync" => Return("/{id}", out routeSuffix),
"GetActiveTasksAsync" => Return("/active", out routeSuffix),
"GetCompletedTasksAsync" => Return("/completed", out routeSuffix),
"CreateTaskAsync" => Return(string.Empty, out routeSuffix),
"UpdateTaskAsync" => Return(string.Empty, out routeSuffix),
"ToggleCompleteAsync" => Return("/{id}/toggle", out routeSuffix),
"DeleteTaskAsync" => Return("/{id}", out routeSuffix),
"GetSubTasksAsync" => Return("/{parentTaskId}/subtasks", out routeSuffix),
_ => false
};
}
private static bool Return(string value, out string routeSuffix)
{
routeSuffix = value;
return true;
}
private static void TrySetOperation(IOpenApiPathItem pathItem, string httpMethod, OpenApiOperation operation)
{
var operationsObject = pathItem.GetType().GetProperty("Operations")?.GetValue(pathItem);
if (operationsObject == null)
{
return;
}
if (operationsObject is System.Collections.IDictionary dict)
{
var dictType = operationsObject.GetType();
var keyType = dictType.IsGenericType ? dictType.GetGenericArguments()[0] : typeof(object);
dict[CreateOperationKey(keyType, httpMethod)] = operation;
return;
}
var indexer = operationsObject.GetType().GetProperty("Item");
var keyTypeFromIndexer = indexer?.GetIndexParameters().FirstOrDefault()?.ParameterType ?? typeof(object);
indexer?.SetValue(operationsObject, operation, new[] { CreateOperationKey(keyTypeFromIndexer, httpMethod) });
}
private static object CreateOperationKey(Type keyType, string httpMethod)
{
if (keyType == typeof(string))
{
return httpMethod.ToLowerInvariant();
}
if (keyType.IsEnum)
{
var name = httpMethod.ToUpperInvariant() switch
{
"GET" => "Get",
"POST" => "Post",
"PUT" => "Put",
"DELETE" => "Delete",
"PATCH" => "Patch",
_ => httpMethod
};
return Enum.Parse(keyType, name, ignoreCase: true);
}
return httpMethod;
}
private static bool IsRemoteServiceEnabled(MemberInfo memberInfo)
{
var attribute = memberInfo.GetCustomAttribute<RemoteServiceAttribute>();
return attribute == null || attribute.IsEnabled;
}
private static string GetServiceRouteSegment(Type serviceInterface)
{
var name = serviceInterface.Name;
if (name.EndsWith("Service", StringComparison.OrdinalIgnoreCase))
{
name = name.Substring(0, name.Length - "Service".Length);
}
if (name.StartsWith("I", StringComparison.Ordinal) &&
name.Length > 1 &&
char.IsUpper(name[1]))
{
name = name.Substring(1);
}
if (name.EndsWith("App", StringComparison.OrdinalIgnoreCase))
{
name = name.Substring(0, name.Length - "App".Length);
}
return name.ToLowerInvariant();
}
private static string GetHttpMethod(MethodInfo method)
{
if (method.GetCustomAttribute<HttpGetAttribute>() != null) return "GET";
if (method.GetCustomAttribute<HttpPostAttribute>() != null) return "POST";
if (method.GetCustomAttribute<HttpPutAttribute>() != null) return "PUT";
if (method.GetCustomAttribute<HttpDeleteAttribute>() != null) return "DELETE";
if (method.GetCustomAttribute<HttpPatchAttribute>() != null) return "PATCH";
if (method.Name.StartsWith("Toggle", StringComparison.OrdinalIgnoreCase)) return "PATCH";
if (method.Name.StartsWith("Get", StringComparison.OrdinalIgnoreCase)) return "GET";
if (method.Name.StartsWith("Put", StringComparison.OrdinalIgnoreCase) ||
method.Name.StartsWith("Update", StringComparison.OrdinalIgnoreCase)) return "PUT";
if (method.Name.StartsWith("Delete", StringComparison.OrdinalIgnoreCase) ||
method.Name.StartsWith("Remove", StringComparison.OrdinalIgnoreCase)) return "DELETE";
if (method.Name.StartsWith("Patch", StringComparison.OrdinalIgnoreCase)) return "PATCH";
return "POST";
}
private static bool IsSimpleType(Type type)
{
return type.IsPrimitive ||
type == typeof(string) ||
type == typeof(decimal) ||
type == typeof(DateTime) ||
type == typeof(Guid) ||
Nullable.GetUnderlyingType(type) != null;
}
}
@@ -9,6 +9,7 @@
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' != 'net10.0'">