1.更换软件协议为AGPL

2.切换项目名称为Hua.Todo
This commit is contained in:
ShaoHua
2026-04-06 22:06:30 +08:00
parent 40a91e39b6
commit 758f6772c6
147 changed files with 1203 additions and 644 deletions
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Builder;
namespace Hua.Todo.Application.DynamicApi;
public static class DynamicApiExtensions
{
public static IApplicationBuilder UseDynamicApi(this IApplicationBuilder builder)
{
return builder.UseMiddleware<DynamicApiMiddleware>();
}
}
@@ -0,0 +1,637 @@
using System.Reflection;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Hua.Todo.Application.Interfaces;
namespace Hua.Todo.Application.DynamicApi;
public class DynamicApiMiddleware
{
private readonly RequestDelegate _next;
private readonly IServiceProvider _serviceProvider;
private static readonly Dictionary<string, Type> _serviceTypeCache = new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase);
static DynamicApiMiddleware()
{
InitializeServiceTypeCache();
}
private static void InitializeServiceTypeCache()
{
var assembly = typeof(IDynamicApiService).Assembly;
var serviceTypes = assembly.GetTypes()
.Where(t => t.IsInterface && typeof(IDynamicApiService).IsAssignableFrom(t))
.ToList();
foreach (var type in serviceTypes)
{
var cleanName = type.Name.EndsWith("Service")
? type.Name.Substring(0, type.Name.Length - "Service".Length)
: type.Name;
if (cleanName.StartsWith("I") && cleanName.Length > 1 && char.IsUpper(cleanName[1]))
{
cleanName = cleanName.Substring(1);
}
if (cleanName.EndsWith("App"))
{
cleanName = cleanName.Substring(0, cleanName.Length - "App".Length);
}
_serviceTypeCache[cleanName] = type;
}
}
public DynamicApiMiddleware(RequestDelegate next, IServiceProvider serviceProvider)
{
_next = next;
_serviceProvider = serviceProvider;
}
public async Task InvokeAsync(HttpContext context)
{
var path = context.Request.Path.Value ?? string.Empty;
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length < 2 || segments[0] != "api")
{
await _next(context);
return;
}
var serviceName = segments[1];
var serviceType = FindDynamicApiService(serviceName);
if (serviceType == null)
{
await _next(context);
return;
}
if (!IsRemoteServiceEnabled(serviceType))
{
await _next(context);
return;
}
using var scope = _serviceProvider.CreateScope();
var scopedServiceProvider = scope.ServiceProvider;
var service = scopedServiceProvider.GetService(serviceType);
if (service == null)
{
await _next(context);
return;
}
var method = FindMethod(serviceType, context.Request.Method, segments.Skip(2).ToArray(), context);
if (method == null)
{
await _next(context);
return;
}
if (!IsRemoteServiceEnabled(method))
{
await _next(context);
return;
}
try
{
var result = await InvokeMethod(service, method, context);
await WriteResponse(context, result, null, method.Name);
}
catch (Exception ex)
{
await WriteResponse(context, null, ex, method.Name);
}
}
private Type? FindDynamicApiService(string serviceName)
{
_serviceTypeCache.TryGetValue(serviceName, out var serviceType);
return serviceType;
}
private bool IsRemoteServiceEnabled(Type type)
{
var attribute = type.GetCustomAttribute<RemoteServiceAttribute>();
return attribute == null || attribute.IsEnabled;
}
private bool IsRemoteServiceEnabled(MethodInfo method)
{
var attribute = method.GetCustomAttribute<RemoteServiceAttribute>();
return attribute == null || attribute.IsEnabled;
}
private MethodInfo? FindMethod(Type serviceType, string httpMethod, string[] pathSegments, HttpContext context)
{
var methods = serviceType.GetMethods(BindingFlags.Public | BindingFlags.Instance);
var matchedMethods = methods.Where(m => IsRemoteServiceEnabled(m)).ToList();
// First, try to handle special case: /api/task/13/toggle
if (pathSegments.Length >= 2 && httpMethod == "PATCH")
{
var potentialMethodName = pathSegments[1];
var potentialParamValue = pathSegments[0];
// Try to find method with matching name and single parameter
foreach (var method in matchedMethods)
{
if (MatchesHttpMethod(method, httpMethod))
{
var parameters = method.GetParameters();
if (parameters.Length == 1 && IsSimpleType(parameters[0].ParameterType))
{
// Try to match with normalized method name
var normalizedMethodName = method.Name;
if (normalizedMethodName.EndsWith("Async", StringComparison.OrdinalIgnoreCase))
{
normalizedMethodName = normalizedMethodName.Substring(0, normalizedMethodName.Length - 5);
}
if (normalizedMethodName.Equals(potentialMethodName, StringComparison.OrdinalIgnoreCase))
{
return method;
}
// For ToggleCompleteAsync, try to match with "toggle"
if (normalizedMethodName.StartsWith("Toggle", StringComparison.OrdinalIgnoreCase) &&
potentialMethodName.Equals("toggle", StringComparison.OrdinalIgnoreCase))
{
return method;
}
}
}
}
}
// Try to match with method name from path segments
var methodName = pathSegments.Length > 0 ? pathSegments[0] : null;
if (!string.IsNullOrEmpty(methodName))
{
var exactMatch = matchedMethods.FirstOrDefault(m =>
m.Name.Equals(methodName, StringComparison.OrdinalIgnoreCase) &&
MatchesHttpMethod(m, httpMethod));
if (exactMatch != null)
return exactMatch;
// Try to match method names with different naming conventions
foreach (var method in matchedMethods)
{
if (MatchesHttpMethod(method, httpMethod))
{
// For GetSubTasksAsync, try to match with "subtasks"
var normalizedMethodName = method.Name;
if (normalizedMethodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase))
{
normalizedMethodName = normalizedMethodName.Substring(3);
}
if (normalizedMethodName.EndsWith("Async", StringComparison.OrdinalIgnoreCase))
{
normalizedMethodName = normalizedMethodName.Substring(0, normalizedMethodName.Length - 5);
}
if (normalizedMethodName.Equals(methodName, StringComparison.OrdinalIgnoreCase))
{
return method;
}
// For GetActiveTasksAsync, try to match with "active"
if (normalizedMethodName.Equals($"{methodName}Tasks", StringComparison.OrdinalIgnoreCase))
{
return method;
}
}
}
}
// First, try to handle special case: /api/task/13/subtasks
if (pathSegments.Length >= 2)
{
var potentialMethodName = pathSegments[1];
var potentialParamValue = pathSegments[0];
// Try to find method with matching name and single parameter
foreach (var method in matchedMethods)
{
if (MatchesHttpMethod(method, httpMethod))
{
var parameters = method.GetParameters();
if (parameters.Length == 1 && IsSimpleType(parameters[0].ParameterType))
{
// Try to match with normalized method name
var normalizedMethodName = method.Name;
if (normalizedMethodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase))
{
normalizedMethodName = normalizedMethodName.Substring(3);
}
if (normalizedMethodName.EndsWith("Async", StringComparison.OrdinalIgnoreCase))
{
normalizedMethodName = normalizedMethodName.Substring(0, normalizedMethodName.Length - 5);
}
if (normalizedMethodName.Equals(potentialMethodName, StringComparison.OrdinalIgnoreCase))
{
return method;
}
}
}
}
}
foreach (var method in matchedMethods)
{
if (MatchesHttpMethod(method, httpMethod) && MatchesRoute(method, pathSegments))
{
return method;
}
}
// Try to match methods with parameters from path
foreach (var method in matchedMethods)
{
if (MatchesHttpMethod(method, httpMethod))
{
var parameters = method.GetParameters();
if (parameters.Length > 0 && pathSegments.Length > 0)
{
// Check if all path segments can be mapped to parameters
bool canMapParameters = true;
for (int i = 0; i < pathSegments.Length; i++)
{
if (i >= parameters.Length)
{
canMapParameters = false;
break;
}
if (!IsSimpleType(parameters[i].ParameterType))
{
canMapParameters = false;
break;
}
}
if (canMapParameters)
{
// Try to convert the first path segment to the parameter type
// to avoid trying to convert non-numeric strings to numbers
try
{
ConvertValue(pathSegments[0], parameters[0].ParameterType);
return method;
}
catch
{
// If conversion fails, skip this method
continue;
}
}
}
}
}
return null;
}
private bool MatchesHttpMethod(MethodInfo method, string httpMethod)
{
if (method.GetCustomAttribute<HttpGetAttribute>() != null)
return httpMethod == "GET";
if (method.GetCustomAttribute<HttpPostAttribute>() != null)
return httpMethod == "POST";
if (method.GetCustomAttribute<HttpPutAttribute>() != null)
return httpMethod == "PUT";
if (method.GetCustomAttribute<HttpDeleteAttribute>() != null)
return httpMethod == "DELETE";
if (method.GetCustomAttribute<HttpPatchAttribute>() != null)
return httpMethod == "PATCH";
// For ToggleCompleteAsync, use PATCH method
if (method.Name.StartsWith("Toggle", StringComparison.OrdinalIgnoreCase))
{
return httpMethod == "PATCH";
}
return GetHttpVerbByConvention(method.Name) == httpMethod;
}
private string GetHttpVerbByConvention(string methodName)
{
if (methodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase))
return "GET";
if (methodName.StartsWith("Put", StringComparison.OrdinalIgnoreCase) ||
methodName.StartsWith("Update", StringComparison.OrdinalIgnoreCase))
return "PUT";
if (methodName.StartsWith("Delete", StringComparison.OrdinalIgnoreCase) ||
methodName.StartsWith("Remove", StringComparison.OrdinalIgnoreCase))
return "DELETE";
if (methodName.StartsWith("Post", StringComparison.OrdinalIgnoreCase) ||
methodName.StartsWith("Create", StringComparison.OrdinalIgnoreCase) ||
methodName.StartsWith("Insert", StringComparison.OrdinalIgnoreCase))
return "POST";
if (methodName.StartsWith("Patch", StringComparison.OrdinalIgnoreCase))
return "PATCH";
return "POST";
}
private bool MatchesRoute(MethodInfo method, string[] pathSegments)
{
var httpGetAttr = method.GetCustomAttribute<HttpGetAttribute>();
var httpPostAttr = method.GetCustomAttribute<HttpPostAttribute>();
var httpPutAttr = method.GetCustomAttribute<HttpPutAttribute>();
var httpDeleteAttr = method.GetCustomAttribute<HttpDeleteAttribute>();
var httpPatchAttr = method.GetCustomAttribute<HttpPatchAttribute>();
var route = httpGetAttr?.Route ?? httpPostAttr?.Route ??
httpPutAttr?.Route ?? httpDeleteAttr?.Route ?? httpPatchAttr?.Route;
if (!string.IsNullOrEmpty(route))
{
var routeSegments = route.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (routeSegments.Length > 0 && pathSegments.Length > 0)
{
return routeSegments[0].Equals(pathSegments[0], StringComparison.OrdinalIgnoreCase);
}
return routeSegments.Length == 0 && pathSegments.Length == 0;
}
if (pathSegments.Length == 0)
{
return true;
}
// Try to match with full method name
if (method.Name.Equals(pathSegments[0], StringComparison.OrdinalIgnoreCase))
{
return true;
}
// Try to match with normalized method name (without Get/Async prefix/suffix)
var normalizedMethodName = method.Name;
if (normalizedMethodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase))
{
normalizedMethodName = normalizedMethodName.Substring(3);
}
if (normalizedMethodName.EndsWith("Async", StringComparison.OrdinalIgnoreCase))
{
normalizedMethodName = normalizedMethodName.Substring(0, normalizedMethodName.Length - 5);
}
return normalizedMethodName.Equals(pathSegments[0], StringComparison.OrdinalIgnoreCase);
}
private async Task<object?> InvokeMethod(object service, MethodInfo method, HttpContext context)
{
var parameters = method.GetParameters();
var args = new List<object?>();
foreach (var param in parameters)
{
var fromBodyAttr = param.GetCustomAttribute<FromBodyAttribute>();
var fromQueryAttr = param.GetCustomAttribute<FromQueryAttribute>();
if (fromBodyAttr != null)
{
var dto = await ReadDtoFromBody(context, param.ParameterType);
args.Add(dto);
}
else if (fromQueryAttr != null || IsSimpleType(param.ParameterType))
{
var value = BindParameterFromQueryOrPath(param, context);
args.Add(value);
}
else
{
var dto = await ReadDtoFromBody(context, param.ParameterType);
args.Add(dto);
}
}
var result = method.Invoke(service, args.ToArray());
if (result is not Task task) return result;
await task;
return task.GetType().GetProperty("Result")?.GetValue(task);
}
private bool IsSimpleType(Type type)
{
return type.IsPrimitive ||
type == typeof(string) ||
type == typeof(decimal) ||
type == typeof(DateTime) ||
type == typeof(Guid) ||
Nullable.GetUnderlyingType(type) != null;
}
private object? BindParameterFromQueryOrPath(ParameterInfo param, HttpContext context)
{
var paramName = param.Name ?? string.Empty;
// Try to get value from path
var pathValue = GetPathValue(context, paramName);
if (!string.IsNullOrEmpty(pathValue))
{
return ConvertValue(pathValue, param.ParameterType);
}
// Try to get value from query string
var queryValue = context.Request.Query[paramName].FirstOrDefault();
if (!string.IsNullOrEmpty(queryValue))
{
return ConvertValue(queryValue, param.ParameterType);
}
// For methods with single parameter, try to get value from path segments
var segments = context.Request.Path.Value?.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments != null && segments.Length >= 3)
{
// For GET /api/task/13, segments = ["api", "task", "13"]
// For GET /api/task/13/subtasks, segments = ["api", "task", "13", "subtasks"]
var methodName = segments.Length > 3 ? segments[3] : null;
var paramValue = segments[2];
// Check if this is a method with single parameter
var method = param.Member as MethodInfo;
if (method != null && method.GetParameters().Length == 1)
{
return ConvertValue(paramValue, param.ParameterType);
}
// Special case for GetSubTasksAsync
if (methodName?.Equals("subtasks", StringComparison.OrdinalIgnoreCase) == true &&
paramName.Equals("parentTaskId", StringComparison.OrdinalIgnoreCase))
{
return ConvertValue(paramValue, param.ParameterType);
}
}
if (param.ParameterType.IsValueType && Nullable.GetUnderlyingType(param.ParameterType) == null)
{
throw new ArgumentException($"Parameter '{paramName}' is required");
}
return null;
}
private object? ConvertValue(string value, Type targetType)
{
try
{
if (targetType == typeof(string))
return value;
if (targetType == typeof(int) || targetType == typeof(int?))
return int.Parse(value);
if (targetType == typeof(long) || targetType == typeof(long?))
return long.Parse(value);
if (targetType == typeof(bool) || targetType == typeof(bool?))
return bool.Parse(value);
if (targetType == typeof(decimal) || targetType == typeof(decimal?))
return decimal.Parse(value);
if (targetType == typeof(DateTime) || targetType == typeof(DateTime?))
return DateTime.Parse(value);
if (targetType == typeof(Guid) || targetType == typeof(Guid?))
return Guid.Parse(value);
return Convert.ChangeType(value, targetType);
}
catch
{
throw new ArgumentException($"Cannot convert '{value}' to {targetType.Name}");
}
}
private string? GetPathValue(HttpContext context, string? paramName)
{
var segments = context.Request.Path.Value?.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments == null || segments.Length < 3)
return null;
// Try to find parameter in path segments
// For GET /api/task/13, segments = ["api", "task", "13"]
// For GET /api/task/13/subtasks, segments = ["api", "task", "13", "subtasks"]
if (segments.Length >= 3)
{
// First check if paramName is "id" (common case)
if (paramName?.Equals("id", StringComparison.OrdinalIgnoreCase) == true)
return segments[2];
// Then check if paramName is "parentTaskId" (for subtasks)
if (paramName?.Equals("parentTaskId", StringComparison.OrdinalIgnoreCase) == true && segments.Length >= 3)
return segments[2];
}
return null;
}
private async Task<object?> ReadDtoFromBody(HttpContext context, Type dtoType)
{
var body = await new StreamReader(context.Request.Body).ReadToEndAsync();
if (string.IsNullOrWhiteSpace(body))
return null;
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
Converters = {
new System.Text.Json.Serialization.JsonStringEnumConverter()
}
};
return JsonSerializer.Deserialize(body, dtoType, options);
}
private async Task WriteResponse(HttpContext context, object? result, Exception? exception, string methodName)
{
context.Response.ContentType = "application/json";
var message = methodName switch
{
_ when methodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase) => "获取成功",
_ when methodName.StartsWith("Create", StringComparison.OrdinalIgnoreCase) => "创建成功",
_ when methodName.StartsWith("Update", StringComparison.OrdinalIgnoreCase) => "更新成功",
_ when methodName.StartsWith("Delete", StringComparison.OrdinalIgnoreCase) => "删除成功",
_ when methodName.StartsWith("Toggle", StringComparison.OrdinalIgnoreCase) => "操作成功",
_ => "操作成功"
};
var errorMessage = methodName switch
{
_ when methodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase) => "获取失败",
_ when methodName.StartsWith("Create", StringComparison.OrdinalIgnoreCase) => "创建失败",
_ when methodName.StartsWith("Update", StringComparison.OrdinalIgnoreCase) => "更新失败",
_ when methodName.StartsWith("Delete", StringComparison.OrdinalIgnoreCase) => "删除失败",
_ when methodName.StartsWith("Toggle", StringComparison.OrdinalIgnoreCase) => "操作失败",
_ => "操作失败"
};
// Get friendly error message
var errors = new List<string>();
if (exception != null)
{
if (exception is Microsoft.EntityFrameworkCore.DbUpdateException dbEx)
{
var innerMessage = dbEx.InnerException?.Message ?? dbEx.Message;
if (innerMessage.Contains("no such table", StringComparison.OrdinalIgnoreCase))
{
errors.Add("数据库表不存在,请尝试重启应用或重新初始化数据库。");
}
else if (innerMessage.Contains("UNIQUE constraint failed", StringComparison.OrdinalIgnoreCase))
{
errors.Add("该项已存在,请检查是否重复。");
}
else
{
errors.Add($"数据库操作失败: {innerMessage}");
}
}
else
{
errors.Add(exception.Message);
}
}
var response = new
{
Success = exception == null,
Data = result,
Message = exception == null ? message : errorMessage,
Errors = errors.Count > 0 ? errors : null
};
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
context.Response.StatusCode = exception switch
{
KeyNotFoundException => 404,
ArgumentException => 400,
_ => 200
};
await context.Response.WriteAsync(json);
}
}
@@ -0,0 +1,76 @@
namespace Hua.Todo.Application.DynamicApi;
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class HttpGetAttribute : Attribute
{
public string? Route { get; set; }
public HttpGetAttribute()
{
}
public HttpGetAttribute(string route)
{
Route = route;
}
}
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class HttpPostAttribute : Attribute
{
public string? Route { get; set; }
public HttpPostAttribute()
{
}
public HttpPostAttribute(string route)
{
Route = route;
}
}
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class HttpPutAttribute : Attribute
{
public string? Route { get; set; }
public HttpPutAttribute()
{
}
public HttpPutAttribute(string route)
{
Route = route;
}
}
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class HttpDeleteAttribute : Attribute
{
public string? Route { get; set; }
public HttpDeleteAttribute()
{
}
public HttpDeleteAttribute(string route)
{
Route = route;
}
}
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class HttpPatchAttribute : Attribute
{
public string? Route { get; set; }
public HttpPatchAttribute()
{
}
public HttpPatchAttribute(string route)
{
Route = route;
}
}
@@ -0,0 +1,11 @@
namespace Hua.Todo.Application.DynamicApi;
[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)]
public class FromQueryAttribute : Attribute
{
}
[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)]
public class FromBodyAttribute : Attribute
{
}
@@ -0,0 +1,8 @@
namespace Hua.Todo.Application.DynamicApi;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Method, AllowMultiple = false)]
public class RemoteServiceAttribute : Attribute
{
public bool IsEnabled { get; set; } = true;
public bool IsMetadataEnabled { get; set; } = true;
}