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,34 @@
using Microsoft.EntityFrameworkCore;
using Hua.Todo.Core.Entities;
namespace Hua.Todo.Application.Data;
public class TodoDbContext : DbContext
{
public TodoDbContext(DbContextOptions<TodoDbContext> options) : base(options)
{
}
public DbSet<TaskEntity> Tasks { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<TaskEntity>(entity =>
{
entity.ToTable("Tasks");
entity.HasKey(e => e.Id);
entity.Property(e => e.Title).IsRequired().HasMaxLength(200);
entity.Property(e => e.Priority).HasDefaultValue(TaskPriority.Medium);
entity.Property(e => e.IsCompleted).HasDefaultValue(false);
entity.Property(e => e.CreatedAt).HasDefaultValueSql("datetime('now')");
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("datetime('now')");
entity.HasOne(e => e.ParentTask)
.WithMany(e => e.SubTasks)
.HasForeignKey(e => e.ParentTaskId)
.OnDelete(DeleteBehavior.Restrict);
});
}
}
@@ -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;
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0;net10.0-android;net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<OutputType>Library</OutputType>
</PropertyGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' != 'net10.0'">
<Compile Remove="DynamicApi\\**\\*.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Hua.Todo.Core\Hua.Todo.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,5 @@
namespace Hua.Todo.Application.Interfaces;
public interface IDynamicApiService
{
}
@@ -0,0 +1,16 @@
using Hua.Todo.Application.Models;
namespace Hua.Todo.Application.Interfaces;
public interface ITaskService : IDynamicApiService
{
Task<List<TaskDto>> GetAllTasksAsync();
Task<TaskDto?> GetTaskByIdAsync(int id);
Task<List<TaskDto>> GetActiveTasksAsync();
Task<List<TaskDto>> GetCompletedTasksAsync();
Task<TaskDto> CreateTaskAsync(CreateTaskDto dto);
Task<TaskDto> UpdateTaskAsync(UpdateTaskDto dto);
Task<TaskDto> ToggleCompleteAsync(int id);
Task DeleteTaskAsync(int id);
Task<List<TaskDto>> GetSubTasksAsync(int parentTaskId);
}
@@ -0,0 +1,61 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Hua.Todo.Application.Data;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
[DbContext(typeof(TodoDbContext))]
[Migration("20260313044926_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<bool>("IsCompleted")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int>("Priority")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1);
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.HasKey("Id");
b.ToTable("Tasks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,39 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Tasks",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
Priority = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 1),
IsCompleted = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false, defaultValueSql: "datetime('now')"),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false, defaultValueSql: "datetime('now')")
},
constraints: table =>
{
table.PrimaryKey("PK_Tasks", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Tasks");
}
}
}
@@ -0,0 +1,81 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Hua.Todo.Application.Data;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
[DbContext(typeof(TodoDbContext))]
[Migration("20260313092658_AddParentTaskId")]
partial class AddParentTaskId
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<bool>("IsCompleted")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int?>("ParentTaskId")
.HasColumnType("INTEGER");
b.Property<int>("Priority")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1);
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.HasKey("Id");
b.HasIndex("ParentTaskId");
b.ToTable("Tasks");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "ParentTask")
.WithMany("SubTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("ParentTask");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.Navigation("SubTasks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
/// <inheritdoc />
public partial class AddParentTaskId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "ParentTaskId",
table: "Tasks",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Tasks_ParentTaskId",
table: "Tasks",
column: "ParentTaskId");
migrationBuilder.AddForeignKey(
name: "FK_Tasks_Tasks_ParentTaskId",
table: "Tasks",
column: "ParentTaskId",
principalTable: "Tasks",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Tasks_Tasks_ParentTaskId",
table: "Tasks");
migrationBuilder.DropIndex(
name: "IX_Tasks_ParentTaskId",
table: "Tasks");
migrationBuilder.DropColumn(
name: "ParentTaskId",
table: "Tasks");
}
}
}
@@ -0,0 +1,78 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Hua.Todo.Application.Data;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
[DbContext(typeof(TodoDbContext))]
partial class TodoDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<bool>("IsCompleted")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int?>("ParentTaskId")
.HasColumnType("INTEGER");
b.Property<int>("Priority")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1);
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.HasKey("Id");
b.HasIndex("ParentTaskId");
b.ToTable("Tasks");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "ParentTask")
.WithMany("SubTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("ParentTask");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.Navigation("SubTasks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,47 @@
using Hua.Todo.Core.Entities;
using System.Text.Json.Serialization;
namespace Hua.Todo.Application.Models;
public class CreateTaskDto
{
public string Title { get; set; } = string.Empty;
[JsonConverter(typeof(JsonStringEnumConverter))]
public TaskPriority Priority { get; set; } = TaskPriority.Medium;
public int? ParentTaskId { get; set; }
}
public class UpdateTaskDto
{
public int Id { get; set; }
public string? Title { get; set; }
[JsonConverter(typeof(JsonStringEnumConverter))]
public TaskPriority? Priority { get; set; }
}
public class TaskDto
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
[JsonConverter(typeof(JsonStringEnumConverter))]
public TaskPriority Priority { get; set; }
public bool IsCompleted { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public int? ParentTaskId { get; set; }
public List<TaskDto> SubTasks { get; set; } = new();
}
public class ApiResponse<T>
{
public bool Success { get; set; }
public T? Data { get; set; }
public string Message { get; set; } = string.Empty;
public List<string> Errors { get; set; } = new();
}
@@ -0,0 +1,12 @@
{
"profiles": {
"Hua.Todo.Application": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:53852;http://localhost:53853"
}
}
}
@@ -0,0 +1,79 @@
using Microsoft.EntityFrameworkCore;
using Hua.Todo.Application.Data;
using Hua.Todo.Core.Entities;
using Hua.Todo.Core.Interfaces;
namespace Hua.Todo.Application.Repositories;
public class TaskRepository : ITaskRepository
{
private readonly TodoDbContext _context;
public TaskRepository(TodoDbContext context)
{
_context = context;
}
public async Task<List<TaskEntity>> GetAllAsync()
{
return await _context.Tasks
.Include(t => t.SubTasks)
.ToListAsync();
}
public async Task<TaskEntity?> GetByIdAsync(int id)
{
return await _context.Tasks
.Include(t => t.SubTasks)
.FirstOrDefaultAsync(t => t.Id == id);
}
public async Task<List<TaskEntity>> GetActiveTasksAsync()
{
return await _context.Tasks
.Where(t => !t.IsCompleted)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
}
public async Task<List<TaskEntity>> GetCompletedTasksAsync()
{
return await _context.Tasks
.Where(t => t.IsCompleted)
.OrderByDescending(t => t.UpdatedAt)
.ToListAsync();
}
public async Task<TaskEntity> AddAsync(TaskEntity taskEntity)
{
_context.Tasks.Add(taskEntity);
await _context.SaveChangesAsync();
return taskEntity;
}
public async Task<TaskEntity> UpdateAsync(TaskEntity taskEntity)
{
taskEntity.UpdatedAt = DateTime.UtcNow;
_context.Tasks.Update(taskEntity);
await _context.SaveChangesAsync();
return taskEntity;
}
public async Task DeleteAsync(int id)
{
var task = await _context.Tasks.FindAsync(id);
if (task != null)
{
_context.Tasks.Remove(task);
await _context.SaveChangesAsync();
}
}
public async Task<List<TaskEntity>> GetSubTasksAsync(int parentTaskId)
{
return await _context.Tasks
.Where(t => t.ParentTaskId == parentTaskId)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
}
}
@@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Hua.Todo.Application.Data;
using Hua.Todo.Application.Interfaces;
using Hua.Todo.Application.Repositories;
using Hua.Todo.Application.Services;
using Hua.Todo.Core.Interfaces;
using ITaskService = Hua.Todo.Application.Interfaces.ITaskService;
namespace Hua.Todo.Application;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddApplicationServices(this IServiceCollection services, string connectionString)
{
services.AddDbContext<TodoDbContext>(options =>
options.UseSqlite(connectionString, b => b.MigrationsAssembly("Hua.Todo.Application")));
services.AddScoped<ITaskRepository, TaskRepository>();
services.AddScoped<ITaskService, TaskService>();
return services;
}
}
@@ -0,0 +1,127 @@
using Hua.Todo.Application.Interfaces;
using Hua.Todo.Application.Models;
using Hua.Todo.Core.Entities;
using Hua.Todo.Core.Interfaces;
namespace Hua.Todo.Application.Services;
public class TaskService : ITaskService
{
private readonly ITaskRepository _taskRepository;
public TaskService(ITaskRepository taskRepository)
{
_taskRepository = taskRepository;
}
public async Task<List<TaskDto>> GetAllTasksAsync()
{
var tasks = await _taskRepository.GetAllAsync();
return tasks.Select(MapToDto).ToList();
}
public async Task<TaskDto?> GetTaskByIdAsync(int id)
{
var task = await _taskRepository.GetByIdAsync(id);
return task != null ? MapToDto(task) : null;
}
public async Task<List<TaskDto>> GetActiveTasksAsync()
{
var allTasks = await _taskRepository.GetAllAsync();
return allTasks.Where(t => !t.IsCompleted).Select(MapToDto).ToList();
}
public async Task<List<TaskDto>> GetCompletedTasksAsync()
{
var allTasks = await _taskRepository.GetAllAsync();
return allTasks.Where(t => t.IsCompleted).Select(MapToDto).ToList();
}
public async Task<TaskDto> CreateTaskAsync(CreateTaskDto dto)
{
var task = new TaskEntity
{
Title = dto.Title,
Priority = dto.Priority,
IsCompleted = false,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
ParentTaskId = dto.ParentTaskId
};
var createdTask = await _taskRepository.AddAsync(task);
return MapToDto(createdTask);
}
public async Task<TaskDto> UpdateTaskAsync(UpdateTaskDto dto)
{
var task = await _taskRepository.GetByIdAsync(dto.Id);
if (task == null)
{
throw new KeyNotFoundException($"Task with ID {dto.Id} not found");
}
if (!string.IsNullOrEmpty(dto.Title))
{
task.Title = dto.Title;
}
if (dto.Priority.HasValue)
{
task.Priority = dto.Priority.Value;
}
task.UpdatedAt = DateTime.UtcNow;
var updatedTask = await _taskRepository.UpdateAsync(task);
return MapToDto(updatedTask);
}
public async Task<TaskDto> ToggleCompleteAsync(int id)
{
var task = await _taskRepository.GetByIdAsync(id);
if (task == null)
{
throw new KeyNotFoundException($"Task with ID {id} not found");
}
task.IsCompleted = !task.IsCompleted;
task.UpdatedAt = DateTime.UtcNow;
var updatedTask = await _taskRepository.UpdateAsync(task);
return MapToDto(updatedTask);
}
public async Task DeleteTaskAsync(int id)
{
var task = await _taskRepository.GetByIdAsync(id);
if (task == null)
{
throw new KeyNotFoundException($"Task with ID {id} not found");
}
await _taskRepository.DeleteAsync(id);
}
public async Task<List<TaskDto>> GetSubTasksAsync(int parentTaskId)
{
var allTasks = await _taskRepository.GetAllAsync();
return allTasks.Where(t => t.ParentTaskId == parentTaskId).Select(MapToDto).ToList();
}
private TaskDto MapToDto(TaskEntity task)
{
return new TaskDto
{
Id = task.Id,
Title = task.Title,
Priority = task.Priority,
IsCompleted = task.IsCompleted,
CreatedAt = task.CreatedAt,
UpdatedAt = task.UpdatedAt,
ParentTaskId = task.ParentTaskId,
SubTasks = task.SubTasks.Select(MapToDto).ToList()
};
}
}