feat: 引入 CloudSync 核心能力并新增 Avalonia 桌面端与发布脚本

- 后端:新增 CloudSync 认证/权限/端点/服务与 DTO
- 数据:新增用户/会话/安全策略实体与 EF Core migrations
- 前端:新增云同步设置 UI、客户端与本地存储;Vite 支持 maui 构建输出到 wwwroot
- 桌面端:新增 Avalonia 项目、内置 WebServer、托盘与 Windows 全局热键
- 发布/构建:新增 Windows/Linux 发布脚本与统一入口;调整 MAUI 资源与安装包配置
- 文档:同步更新 README/docs 与协作规则
This commit is contained in:
ShaoHua
2026-04-07 03:34:34 +08:00
parent 18d37fdd24
commit 7a4c516a20
85 changed files with 5774 additions and 127 deletions
@@ -0,0 +1,53 @@
using System.Security.Claims;
namespace Hua.Todo.Application.CloudSync.Auth;
/// <summary>
/// 云同步鉴权相关的 <see cref="ClaimsPrincipal"/> 扩展方法。
/// </summary>
public static class ClaimsPrincipalExtensions
{
/// <summary>
/// 获取当前用户 ID(若未登录则返回 null)。
/// </summary>
/// <param name="user">当前用户主体。</param>
/// <returns>用户 ID。</returns>
public static Guid? GetUserId(this ClaimsPrincipal user)
{
var value = user.FindFirstValue(ClaimTypes.NameIdentifier);
return Guid.TryParse(value, out var id) ? id : null;
}
/// <summary>
/// 获取当前会话 ID(若不可用则返回 null)。
/// </summary>
/// <param name="user">当前用户主体。</param>
/// <returns>会话 ID。</returns>
public static Guid? GetSessionId(this ClaimsPrincipal user)
{
var value = user.FindFirstValue(ClaimTypes.Sid);
return Guid.TryParse(value, out var id) ? id : null;
}
/// <summary>
/// 判断是否具备指定权限。
/// </summary>
/// <param name="user">当前用户主体。</param>
/// <param name="permission">权限点。</param>
/// <returns>是否具备权限。</returns>
public static bool HasPermission(this ClaimsPrincipal user, string permission)
{
return user.Claims.Any(c => c.Type == CloudClaims.Permission && string.Equals(c.Value, permission, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// 判断是否已完成二次认证(step-up)。
/// </summary>
/// <param name="user">当前用户主体。</param>
/// <returns>是否已完成二次认证。</returns>
public static bool HasStepUp(this ClaimsPrincipal user)
{
return user.Claims.Any(c => c.Type == CloudClaims.StepUp && string.Equals(c.Value, "true", StringComparison.OrdinalIgnoreCase));
}
}
@@ -0,0 +1,18 @@
namespace Hua.Todo.Application.CloudSync.Auth;
/// <summary>
/// 云同步鉴权使用的 Claim 名称约定。
/// </summary>
public static class CloudClaims
{
/// <summary>
/// 权限 Claim(可重复出现)。
/// </summary>
public const string Permission = "perm";
/// <summary>
/// 二次认证状态 Claim。
/// </summary>
public const string StepUp = "step_up";
}
@@ -0,0 +1,38 @@
namespace Hua.Todo.Application.CloudSync.Auth;
/// <summary>
/// 云同步权限点常量。
/// </summary>
public static class CloudPermissions
{
/// <summary>
/// 读取任务数据。
/// </summary>
public const string TasksRead = "tasks:read";
/// <summary>
/// 写入任务数据。
/// </summary>
public const string TasksWrite = "tasks:write";
/// <summary>
/// 允许执行同步写入(用于区分“允许同步/禁止同步”)。
/// </summary>
public const string SyncWrite = "sync:write";
/// <summary>
/// 读取安全策略。
/// </summary>
public const string PolicyRead = "policy:read";
/// <summary>
/// 修改安全策略(高风险)。
/// </summary>
public const string PolicyWrite = "policy:write";
/// <summary>
/// 管理用户(创建/修改角色)。
/// </summary>
public const string UsersManage = "users:manage";
}
@@ -0,0 +1,43 @@
namespace Hua.Todo.Application.CloudSync.Auth;
/// <summary>
/// 默认的角色-权限映射(内置最小集合)。
/// </summary>
public class DefaultRolePermissionMapper : IRolePermissionMapper
{
/// <inheritdoc />
public IReadOnlyList<string> GetPermissions(string role)
{
return role switch
{
"admin" => new[]
{
CloudPermissions.TasksRead,
CloudPermissions.TasksWrite,
CloudPermissions.SyncWrite,
CloudPermissions.PolicyRead,
CloudPermissions.PolicyWrite,
CloudPermissions.UsersManage
},
"readonly" => new[]
{
CloudPermissions.TasksRead,
CloudPermissions.PolicyRead
},
"nosync" => new[]
{
CloudPermissions.TasksRead,
CloudPermissions.TasksWrite,
CloudPermissions.PolicyRead
},
_ => new[]
{
CloudPermissions.TasksRead,
CloudPermissions.TasksWrite,
CloudPermissions.SyncWrite,
CloudPermissions.PolicyRead
}
};
}
}
@@ -0,0 +1,15 @@
namespace Hua.Todo.Application.CloudSync.Auth;
/// <summary>
/// 角色到权限集合的映射器(RBAC 最小落地)。
/// </summary>
public interface IRolePermissionMapper
{
/// <summary>
/// 获取指定角色对应的权限集合。
/// </summary>
/// <param name="role">角色名称。</param>
/// <returns>权限列表。</returns>
IReadOnlyList<string> GetPermissions(string role);
}
@@ -0,0 +1,13 @@
namespace Hua.Todo.Application.CloudSync.Auth;
/// <summary>
/// 云同步会话鉴权默认配置。
/// </summary>
public static class SessionAuthenticationDefaults
{
/// <summary>
/// 会话鉴权 Scheme 名称。
/// </summary>
public const string Scheme = "CloudSession";
}
@@ -0,0 +1,114 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Hua.Todo.Application.Data;
namespace Hua.Todo.Application.CloudSync.Auth;
/// <summary>
/// 基于服务端会话表的 Bearer Token 鉴权处理器。
/// </summary>
public class SessionAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
private readonly TodoDbContext _dbContext;
private readonly IRolePermissionMapper _rolePermissionMapper;
/// <summary>
/// 创建 <see cref="SessionAuthenticationHandler"/>。
/// </summary>
/// <param name="options">鉴权 scheme 选项。</param>
/// <param name="logger">日志。</param>
/// <param name="encoder">编码器。</param>
/// <param name="dbContext">数据库上下文。</param>
/// <param name="rolePermissionMapper">角色权限映射器。</param>
public SessionAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
TodoDbContext dbContext,
IRolePermissionMapper rolePermissionMapper)
: base(options, logger, encoder)
{
_dbContext = dbContext;
_rolePermissionMapper = rolePermissionMapper;
}
/// <inheritdoc />
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var authorization = Request.Headers.Authorization.ToString();
if (string.IsNullOrWhiteSpace(authorization))
{
return AuthenticateResult.NoResult();
}
if (!authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
return AuthenticateResult.NoResult();
}
var token = authorization["Bearer ".Length..].Trim();
if (!Guid.TryParse(token, out var sessionId))
{
return AuthenticateResult.Fail("Invalid bearer token format.");
}
var now = DateTime.UtcNow;
var session = await _dbContext.UserSessions
.AsNoTracking()
.Include(s => s.User)
.FirstOrDefaultAsync(s => s.Id == sessionId, Context.RequestAborted);
if (session == null || session.User == null)
{
return AuthenticateResult.Fail("Session not found.");
}
if (session.ExpiresAtUtc <= now)
{
return AuthenticateResult.Fail("Session expired.");
}
var user = session.User;
var permissions = _rolePermissionMapper.GetPermissions(user.Role).ToList();
var allowSync = await _dbContext.SecurityPolicies
.AsNoTracking()
.Where(p => p.UserId == user.Id)
.Select(p => (bool?)p.AllowSync)
.FirstOrDefaultAsync(Context.RequestAborted);
if (allowSync.HasValue && !allowSync.Value)
{
permissions.RemoveAll(p => string.Equals(p, CloudPermissions.SyncWrite, StringComparison.OrdinalIgnoreCase));
}
var claims = new List<Claim>
{
new(ClaimTypes.Sid, session.Id.ToString()),
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(ClaimTypes.Name, user.UserName),
new(ClaimTypes.Role, user.Role)
};
foreach (var p in permissions)
{
claims.Add(new Claim(CloudClaims.Permission, p));
}
if (session.StepUpExpiresAtUtc.HasValue && session.StepUpExpiresAtUtc.Value > now)
{
claims.Add(new Claim(CloudClaims.StepUp, "true"));
}
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
}
@@ -0,0 +1,194 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Hua.Todo.Application.CloudSync.Auth;
using Hua.Todo.Application.CloudSync.Models;
using Hua.Todo.Application.CloudSync.Services;
namespace Hua.Todo.Application.CloudSync;
/// <summary>
/// 云同步服务端 API 路由注册扩展。
/// </summary>
public static class CloudSyncEndpointExtensions
{
/// <summary>
/// 映射云同步相关 API 端点。
/// </summary>
/// <param name="app">Web 应用。</param>
/// <returns>Web 应用。</returns>
public static WebApplication MapCloudSyncEndpoints(this WebApplication app)
{
var auth = app.MapGroup("/auth").WithTags("CloudSync - Auth");
auth.MapPost("/bootstrap", BootstrapAdminAsync).AllowAnonymous();
auth.MapPost("/login", LoginAsync).AllowAnonymous();
auth.MapPost("/step-up", StepUpAsync).AllowAnonymous();
var tasks = app.MapGroup("/tasks").WithTags("CloudSync - Tasks");
tasks.MapGet("/", GetTasksAsync).AllowAnonymous();
var sync = app.MapGroup("/sync").WithTags("CloudSync - Sync");
sync.MapPost("/", SyncAsync).AllowAnonymous();
var security = app.MapGroup("/security").WithTags("CloudSync - Security");
security.MapGet("/policy", GetPolicyAsync).AllowAnonymous();
security.MapPut("/policy", UpdatePolicyAsync).AllowAnonymous();
return app;
}
private static async Task<IResult> BootstrapAdminAsync(
BootstrapAdminRequest request,
CloudAuthService authService,
CancellationToken cancellationToken)
{
if (request == null || string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.Password))
{
return CloudApiErrors.BadRequest("UserName and Password are required.");
}
var ok = await authService.BootstrapAdminAsync(request.UserName, request.Password, cancellationToken);
if (!ok)
{
return CloudApiErrors.Forbidden("Bootstrap is not allowed (already initialized or invalid input).");
}
return Results.Ok();
}
private static async Task<IResult> LoginAsync(
LoginRequest request,
CloudAuthService authService,
CancellationToken cancellationToken)
{
if (request == null || string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.Password))
{
return CloudApiErrors.BadRequest("UserName and Password are required.");
}
var response = await authService.LoginAsync(request.UserName, request.Password, TimeSpan.FromDays(7), cancellationToken);
if (response == null)
{
return CloudApiErrors.Unauthorized("Invalid credentials.");
}
return Results.Json(response);
}
private static async Task<IResult> StepUpAsync(
StepUpRequest request,
CloudAuthService authService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var sessionId = httpContext.User.GetSessionId();
if (sessionId == null)
{
return CloudApiErrors.Unauthorized();
}
if (request == null || string.IsNullOrWhiteSpace(request.Password))
{
return CloudApiErrors.BadRequest("Password is required.");
}
var expiresAt = await authService.StepUpAsync(sessionId.Value, request.Password, TimeSpan.FromMinutes(5), cancellationToken);
if (!expiresAt.HasValue)
{
return CloudApiErrors.Unauthorized("Invalid credentials or session expired.");
}
return Results.Json(new StepUpResponse { StepUpExpiresAtUtc = expiresAt.Value });
}
private static async Task<IResult> GetTasksAsync(
CloudTaskSyncService taskService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var userId = httpContext.User.GetUserId();
if (userId == null)
{
return CloudApiErrors.Unauthorized();
}
if (!httpContext.User.HasPermission(CloudPermissions.TasksRead))
{
return CloudApiErrors.Forbidden();
}
var tasks = await taskService.GetTasksAsync(userId.Value, cancellationToken);
return Results.Json(tasks);
}
private static async Task<IResult> SyncAsync(
SyncRequest request,
CloudTaskSyncService taskService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var userId = httpContext.User.GetUserId();
if (userId == null)
{
return CloudApiErrors.Unauthorized();
}
if (!httpContext.User.HasPermission(CloudPermissions.SyncWrite))
{
return CloudApiErrors.Forbidden();
}
if (!httpContext.User.HasStepUp())
{
return CloudApiErrors.SecondFactorRequired();
}
var response = await taskService.SyncAsync(userId.Value, request, cancellationToken);
return Results.Json(response);
}
private static async Task<IResult> GetPolicyAsync(
SecurityPolicyService policyService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var userId = httpContext.User.GetUserId();
if (userId == null)
{
return CloudApiErrors.Unauthorized();
}
if (!httpContext.User.HasPermission(CloudPermissions.PolicyRead))
{
return CloudApiErrors.Forbidden();
}
var policy = await policyService.GetPolicyAsync(userId.Value, cancellationToken);
return Results.Json(policy);
}
private static async Task<IResult> UpdatePolicyAsync(
UpdateSecurityPolicyRequest request,
SecurityPolicyService policyService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var userId = httpContext.User.GetUserId();
if (userId == null)
{
return CloudApiErrors.Unauthorized();
}
if (!httpContext.User.HasPermission(CloudPermissions.PolicyWrite))
{
return CloudApiErrors.Forbidden();
}
if (!httpContext.User.HasStepUp())
{
return CloudApiErrors.SecondFactorRequired();
}
var policy = await policyService.UpdatePolicyAsync(userId.Value, request.AllowPersist, request.AllowSync, cancellationToken);
return Results.Json(policy);
}
}
@@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Hua.Todo.Application.CloudSync.Auth;
using Hua.Todo.Application.CloudSync.Services;
using Hua.Todo.Core.Entities;
namespace Hua.Todo.Application.CloudSync;
/// <summary>
/// 云同步服务端能力的依赖注入扩展。
/// </summary>
public static class CloudSyncServiceCollectionExtensions
{
/// <summary>
/// 注册云同步相关的认证、授权与业务服务。
/// </summary>
/// <param name="services">服务集合。</param>
/// <returns>服务集合。</returns>
public static IServiceCollection AddCloudSyncServer(this IServiceCollection services)
{
services.AddHttpContextAccessor();
services.AddSingleton<IRolePermissionMapper, DefaultRolePermissionMapper>();
services.AddScoped<IPasswordHasher<UserEntity>, PasswordHasher<UserEntity>>();
services.AddScoped<CloudAuthService>();
services.AddScoped<CloudTaskSyncService>();
services.AddScoped<SecurityPolicyService>();
services.AddAuthentication(SessionAuthenticationDefaults.Scheme)
.AddScheme<AuthenticationSchemeOptions, SessionAuthenticationHandler>(SessionAuthenticationDefaults.Scheme, _ => { });
services.AddAuthorization(options =>
{
options.AddPolicy("tasks:read", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.TasksRead));
options.AddPolicy("tasks:write", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.TasksWrite));
options.AddPolicy("sync:write", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.SyncWrite));
options.AddPolicy("policy:read", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.PolicyRead));
options.AddPolicy("policy:write", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.PolicyWrite));
options.AddPolicy("users:manage", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.UsersManage));
});
return services;
}
}
@@ -0,0 +1,18 @@
namespace Hua.Todo.Application.CloudSync.Models;
/// <summary>
/// 云同步 API 统一错误响应结构。
/// </summary>
public class ApiErrorResponse
{
/// <summary>
/// 错误码(用于客户端识别与提示)。
/// </summary>
public string Code { get; set; } = string.Empty;
/// <summary>
/// 错误消息(用于调试或直接展示)。
/// </summary>
public string Message { get; set; } = string.Empty;
}
@@ -0,0 +1,87 @@
namespace Hua.Todo.Application.CloudSync.Models;
/// <summary>
/// 登录请求。
/// </summary>
public class LoginRequest
{
/// <summary>
/// 用户名。
/// </summary>
public string UserName { get; set; } = string.Empty;
/// <summary>
/// 密码。
/// </summary>
public string Password { get; set; } = string.Empty;
}
/// <summary>
/// 登录响应。
/// </summary>
public class LoginResponse
{
/// <summary>
/// Bearer Token(会话 ID)。
/// </summary>
public string AccessToken { get; set; } = string.Empty;
/// <summary>
/// 会话过期时间(UTC)。
/// </summary>
public DateTime ExpiresAtUtc { get; set; }
/// <summary>
/// 用户 ID。
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// 用户角色。
/// </summary>
public string Role { get; set; } = string.Empty;
/// <summary>
/// 用户权限列表。
/// </summary>
public List<string> Permissions { get; set; } = new();
}
/// <summary>
/// 初始化管理员账号请求(仅在系统尚无云用户时可用)。
/// </summary>
public class BootstrapAdminRequest
{
/// <summary>
/// 用户名。
/// </summary>
public string UserName { get; set; } = string.Empty;
/// <summary>
/// 密码。
/// </summary>
public string Password { get; set; } = string.Empty;
}
/// <summary>
/// 二次认证(step-up)请求。
/// </summary>
public class StepUpRequest
{
/// <summary>
/// 二次口令(v1.2.0 最小实现:复用登录密码进行再认证)。
/// </summary>
public string Password { get; set; } = string.Empty;
}
/// <summary>
/// 二次认证(step-up)响应。
/// </summary>
public class StepUpResponse
{
/// <summary>
/// 二次认证有效期截止(UTC)。
/// </summary>
public DateTime StepUpExpiresAtUtc { get; set; }
}
@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Http;
namespace Hua.Todo.Application.CloudSync.Models;
/// <summary>
/// 云同步 API 错误响应生成器。
/// </summary>
public static class CloudApiErrors
{
/// <summary>
/// 生成标准错误响应。
/// </summary>
/// <param name="statusCode">HTTP 状态码。</param>
/// <param name="code">业务错误码。</param>
/// <param name="message">错误消息。</param>
/// <returns>最小 API 结果。</returns>
public static IResult Error(int statusCode, string code, string message)
{
return Results.Json(
new ApiErrorResponse { Code = code, Message = message },
statusCode: statusCode);
}
/// <summary>
/// 未认证。
/// </summary>
public static IResult Unauthorized(string message = "Unauthorized.")
=> Error(StatusCodes.Status401Unauthorized, "UNAUTHORIZED", message);
/// <summary>
/// 权限不足。
/// </summary>
public static IResult Forbidden(string message = "Forbidden.")
=> Error(StatusCodes.Status403Forbidden, "FORBIDDEN", message);
/// <summary>
/// 需要二次认证(step-up)。
/// </summary>
public static IResult SecondFactorRequired(string message = "Second factor required.")
=> Error(StatusCodes.Status403Forbidden, "SECOND_FACTOR_REQUIRED", message);
/// <summary>
/// 请求非法。
/// </summary>
public static IResult BadRequest(string message = "Bad request.")
=> Error(StatusCodes.Status400BadRequest, "BAD_REQUEST", message);
/// <summary>
/// 资源不存在。
/// </summary>
public static IResult NotFound(string message = "Not found.")
=> Error(StatusCodes.Status404NotFound, "NOT_FOUND", message);
}
@@ -0,0 +1,38 @@
namespace Hua.Todo.Application.CloudSync.Models;
/// <summary>
/// 安全策略下发 DTO。
/// </summary>
public class SecurityPolicyDto
{
/// <summary>
/// 是否允许落盘。
/// </summary>
public bool AllowPersist { get; set; }
/// <summary>
/// 是否允许同步写入。
/// </summary>
public bool AllowSync { get; set; }
/// <summary>
/// 需要二次认证的操作列表(操作码)。
/// </summary>
public List<string> RequireSecondFactorFor { get; set; } = new();
}
/// <summary>
/// 更新安全策略请求 DTO。
/// </summary>
public class UpdateSecurityPolicyRequest
{
/// <summary>
/// 是否允许落盘。
/// </summary>
public bool AllowPersist { get; set; }
/// <summary>
/// 是否允许同步写入。
/// </summary>
public bool AllowSync { get; set; }
}
@@ -0,0 +1,108 @@
using Hua.Todo.Core.Entities;
namespace Hua.Todo.Application.CloudSync.Models;
/// <summary>
/// 云同步任务条目。
/// </summary>
public class CloudTaskItem
{
/// <summary>
/// 任务 ID(服务端分配)。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 标题。
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 优先级。
/// </summary>
public TaskPriority Priority { get; set; }
/// <summary>
/// 是否完成。
/// </summary>
public bool IsCompleted { get; set; }
/// <summary>
/// 创建时间(UTC)。
/// </summary>
public DateTime CreatedAtUtc { get; set; }
/// <summary>
/// 更新时间(UTC)。
/// </summary>
public DateTime UpdatedAtUtc { get; set; }
/// <summary>
/// 父任务 ID(v1.2.0 同步可先不使用)。
/// </summary>
public int? ParentTaskId { get; set; }
}
/// <summary>
/// 同步请求(增改删)。
/// </summary>
public class SyncRequest
{
/// <summary>
/// 新增或更新的任务列表。
/// </summary>
public List<CloudTaskUpsert> Upserts { get; set; } = new();
/// <summary>
/// 需要删除的任务 ID 列表。
/// </summary>
public List<int> Deletes { get; set; } = new();
}
/// <summary>
/// 任务 Upsert DTO。
/// </summary>
public class CloudTaskUpsert
{
/// <summary>
/// 任务 ID;为空表示新建。
/// </summary>
public int? Id { get; set; }
/// <summary>
/// 标题。
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 优先级。
/// </summary>
public TaskPriority Priority { get; set; } = TaskPriority.Medium;
/// <summary>
/// 是否完成。
/// </summary>
public bool IsCompleted { get; set; }
/// <summary>
/// 父任务 ID(可选)。
/// </summary>
public int? ParentTaskId { get; set; }
}
/// <summary>
/// 同步响应。
/// </summary>
public class SyncResponse
{
/// <summary>
/// 服务端时间(UTC)。
/// </summary>
public DateTime ServerTimeUtc { get; set; }
/// <summary>
/// 当前用户的任务全量。
/// </summary>
public List<CloudTaskItem> Tasks { get; set; } = new();
}
@@ -0,0 +1,188 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Hua.Todo.Application.CloudSync.Auth;
using Hua.Todo.Application.CloudSync.Models;
using Hua.Todo.Application.Data;
using Hua.Todo.Core.Entities;
namespace Hua.Todo.Application.CloudSync.Services;
/// <summary>
/// 云同步认证服务(登录、初始化管理员、二次认证)。
/// </summary>
public class CloudAuthService
{
private readonly TodoDbContext _dbContext;
private readonly IPasswordHasher<UserEntity> _passwordHasher;
private readonly IRolePermissionMapper _rolePermissionMapper;
/// <summary>
/// 创建 <see cref="CloudAuthService"/>。
/// </summary>
/// <param name="dbContext">数据库上下文。</param>
/// <param name="passwordHasher">密码哈希器。</param>
/// <param name="rolePermissionMapper">角色权限映射器。</param>
public CloudAuthService(
TodoDbContext dbContext,
IPasswordHasher<UserEntity> passwordHasher,
IRolePermissionMapper rolePermissionMapper)
{
_dbContext = dbContext;
_passwordHasher = passwordHasher;
_rolePermissionMapper = rolePermissionMapper;
}
/// <summary>
/// 初始化系统管理员账号(仅在系统尚无云用户时可用)。
/// </summary>
/// <param name="userName">用户名。</param>
/// <param name="password">密码。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>是否初始化成功。</returns>
public async Task<bool> BootstrapAdminAsync(string userName, string password, CancellationToken cancellationToken)
{
var normalizedUserName = (userName ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalizedUserName) || string.IsNullOrWhiteSpace(password))
{
return false;
}
var hasAnyCloudUser = await _dbContext.Users
.AsNoTracking()
.AnyAsync(u => u.Role != "local", cancellationToken);
if (hasAnyCloudUser)
{
return false;
}
var exists = await _dbContext.Users
.AsNoTracking()
.AnyAsync(u => u.UserName == normalizedUserName, cancellationToken);
if (exists)
{
return false;
}
var user = new UserEntity
{
Id = Guid.NewGuid(),
UserName = normalizedUserName,
Role = "admin"
};
user.PasswordHash = _passwordHasher.HashPassword(user, password);
_dbContext.Users.Add(user);
_dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true });
await _dbContext.SaveChangesAsync(cancellationToken);
return true;
}
/// <summary>
/// 用户名密码登录并创建会话。
/// </summary>
/// <param name="userName">用户名。</param>
/// <param name="password">密码。</param>
/// <param name="sessionTtl">会话有效期。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>登录响应;失败则返回 null。</returns>
public async Task<LoginResponse?> LoginAsync(string userName, string password, TimeSpan sessionTtl, CancellationToken cancellationToken)
{
var normalizedUserName = (userName ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalizedUserName) || string.IsNullOrWhiteSpace(password))
{
return null;
}
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.UserName == normalizedUserName, cancellationToken);
if (user == null || user.Role == "local")
{
return null;
}
var verify = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
if (verify == PasswordVerificationResult.Failed)
{
return null;
}
var now = DateTime.UtcNow;
var expiresAt = now.Add(sessionTtl);
var session = new UserSessionEntity
{
Id = Guid.NewGuid(),
UserId = user.Id,
CreatedAtUtc = now,
ExpiresAtUtc = expiresAt
};
_dbContext.UserSessions.Add(session);
var hasPolicy = await _dbContext.SecurityPolicies
.AsNoTracking()
.AnyAsync(p => p.UserId == user.Id, cancellationToken);
if (!hasPolicy)
{
_dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true });
}
await _dbContext.SaveChangesAsync(cancellationToken);
return new LoginResponse
{
AccessToken = session.Id.ToString(),
ExpiresAtUtc = expiresAt,
UserId = user.Id,
Role = user.Role,
Permissions = _rolePermissionMapper.GetPermissions(user.Role).ToList()
};
}
/// <summary>
/// 通过再输入口令提升会话权限(step-up)。
/// </summary>
/// <param name="sessionId">会话 ID。</param>
/// <param name="password">口令。</param>
/// <param name="stepUpTtl">二次认证有效期。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>有效期截止时间;失败返回 null。</returns>
public async Task<DateTime?> StepUpAsync(Guid sessionId, string password, TimeSpan stepUpTtl, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(password))
{
return null;
}
var now = DateTime.UtcNow;
var session = await _dbContext.UserSessions.FirstOrDefaultAsync(s => s.Id == sessionId, cancellationToken);
if (session == null || session.ExpiresAtUtc <= now)
{
return null;
}
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == session.UserId, cancellationToken);
if (user == null)
{
return null;
}
var verify = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
if (verify == PasswordVerificationResult.Failed)
{
return null;
}
var stepUpExpiresAt = now.Add(stepUpTtl);
session.StepUpExpiresAtUtc = stepUpExpiresAt;
await _dbContext.SaveChangesAsync(cancellationToken);
return stepUpExpiresAt;
}
}
@@ -0,0 +1,128 @@
using Microsoft.EntityFrameworkCore;
using Hua.Todo.Application.CloudSync.Models;
using Hua.Todo.Application.Data;
using Hua.Todo.Core.Entities;
namespace Hua.Todo.Application.CloudSync.Services;
/// <summary>
/// 云同步任务服务(按用户隔离)。
/// </summary>
public class CloudTaskSyncService
{
private readonly TodoDbContext _dbContext;
/// <summary>
/// 创建 <see cref="CloudTaskSyncService"/>。
/// </summary>
/// <param name="dbContext">数据库上下文。</param>
public CloudTaskSyncService(TodoDbContext dbContext)
{
_dbContext = dbContext;
}
/// <summary>
/// 获取指定用户的任务全量。
/// </summary>
/// <param name="userId">用户 ID。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>任务列表。</returns>
public async Task<List<CloudTaskItem>> GetTasksAsync(Guid userId, CancellationToken cancellationToken)
{
var tasks = await _dbContext.Tasks
.AsNoTracking()
.Where(t => t.UserId == userId)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync(cancellationToken);
return tasks.Select(MapToItem).ToList();
}
/// <summary>
/// 执行同步(增改删),并返回最新全量。
/// </summary>
/// <param name="userId">用户 ID。</param>
/// <param name="request">同步请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>同步响应。</returns>
public async Task<SyncResponse> SyncAsync(Guid userId, SyncRequest request, CancellationToken cancellationToken)
{
request ??= new SyncRequest();
if (request.Deletes.Count > 0)
{
var deleteIds = request.Deletes.Distinct().ToList();
var toDelete = await _dbContext.Tasks
.Where(t => t.UserId == userId && deleteIds.Contains(t.Id))
.ToListAsync(cancellationToken);
if (toDelete.Count > 0)
{
_dbContext.Tasks.RemoveRange(toDelete);
}
}
if (request.Upserts.Count > 0)
{
foreach (var upsert in request.Upserts)
{
if (string.IsNullOrWhiteSpace(upsert.Title))
{
continue;
}
if (upsert.Id.HasValue)
{
var existing = await _dbContext.Tasks
.FirstOrDefaultAsync(t => t.UserId == userId && t.Id == upsert.Id.Value, cancellationToken);
if (existing != null)
{
existing.Title = upsert.Title.Trim();
existing.Priority = upsert.Priority;
existing.IsCompleted = upsert.IsCompleted;
existing.ParentTaskId = upsert.ParentTaskId;
existing.UpdatedAt = DateTime.UtcNow;
continue;
}
}
var entity = new TaskEntity
{
UserId = userId,
Title = upsert.Title.Trim(),
Priority = upsert.Priority,
IsCompleted = upsert.IsCompleted,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
ParentTaskId = upsert.ParentTaskId
};
_dbContext.Tasks.Add(entity);
}
}
await _dbContext.SaveChangesAsync(cancellationToken);
return new SyncResponse
{
ServerTimeUtc = DateTime.UtcNow,
Tasks = await GetTasksAsync(userId, cancellationToken)
};
}
private static CloudTaskItem MapToItem(TaskEntity task)
{
return new CloudTaskItem
{
Id = task.Id,
Title = task.Title,
Priority = task.Priority,
IsCompleted = task.IsCompleted,
CreatedAtUtc = task.CreatedAt,
UpdatedAtUtc = task.UpdatedAt,
ParentTaskId = task.ParentTaskId
};
}
}
@@ -0,0 +1,69 @@
using Microsoft.EntityFrameworkCore;
using Hua.Todo.Application.CloudSync.Models;
using Hua.Todo.Application.Data;
using Hua.Todo.Core.Entities;
namespace Hua.Todo.Application.CloudSync.Services;
/// <summary>
/// 安全策略服务(按用户隔离)。
/// </summary>
public class SecurityPolicyService
{
private readonly TodoDbContext _dbContext;
/// <summary>
/// 创建 <see cref="SecurityPolicyService"/>。
/// </summary>
/// <param name="dbContext">数据库上下文。</param>
public SecurityPolicyService(TodoDbContext dbContext)
{
_dbContext = dbContext;
}
/// <summary>
/// 获取指定用户的安全策略。
/// </summary>
/// <param name="userId">用户 ID。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>策略 DTO。</returns>
public async Task<SecurityPolicyDto> GetPolicyAsync(Guid userId, CancellationToken cancellationToken)
{
var policy = await _dbContext.SecurityPolicies
.AsNoTracking()
.FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken);
return new SecurityPolicyDto
{
AllowPersist = policy?.AllowPersist ?? true,
AllowSync = policy?.AllowSync ?? true,
RequireSecondFactorFor = new List<string> { "sync:write", "policy:write" }
};
}
/// <summary>
/// 更新指定用户的安全策略(覆盖式更新)。
/// </summary>
/// <param name="userId">用户 ID。</param>
/// <param name="allowPersist">是否允许落盘。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>更新后的策略 DTO。</returns>
public async Task<SecurityPolicyDto> UpdatePolicyAsync(Guid userId, bool allowPersist, bool allowSync, CancellationToken cancellationToken)
{
var policy = await _dbContext.SecurityPolicies.FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken);
if (policy == null)
{
policy = new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = userId, AllowPersist = allowPersist, AllowSync = allowSync };
_dbContext.SecurityPolicies.Add(policy);
}
else
{
policy.AllowPersist = allowPersist;
policy.AllowSync = allowSync;
}
await _dbContext.SaveChangesAsync(cancellationToken);
return await GetPolicyAsync(userId, cancellationToken);
}
}
@@ -21,6 +21,21 @@ public class TodoDbContext : DbContext
/// </summary>
public DbSet<TaskEntity> Tasks { get; set; }
/// <summary>
/// 用户集合。
/// </summary>
public DbSet<UserEntity> Users { get; set; }
/// <summary>
/// 用户会话集合。
/// </summary>
public DbSet<UserSessionEntity> UserSessions { get; set; }
/// <summary>
/// 安全策略集合。
/// </summary>
public DbSet<SecurityPolicyEntity> SecurityPolicies { get; set; }
/// <summary>
/// 配置实体模型映射。
/// </summary>
@@ -33,16 +48,58 @@ public class TodoDbContext : DbContext
{
entity.ToTable("Tasks");
entity.HasKey(e => e.Id);
entity.Property(e => e.UserId).IsRequired().HasDefaultValue(TodoUserIds.LocalUserId);
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.User)
.WithMany(u => u.Tasks)
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.ParentTask)
.WithMany(e => e.SubTasks)
.HasForeignKey(e => e.ParentTaskId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<UserEntity>(entity =>
{
entity.ToTable("Users");
entity.HasKey(e => e.Id);
entity.Property(e => e.UserName).IsRequired().HasMaxLength(64);
entity.HasIndex(e => e.UserName).IsUnique();
entity.Property(e => e.PasswordHash).IsRequired();
entity.Property(e => e.Role).IsRequired().HasMaxLength(32);
});
modelBuilder.Entity<UserSessionEntity>(entity =>
{
entity.ToTable("UserSessions");
entity.HasKey(e => e.Id);
entity.Property(e => e.CreatedAtUtc).IsRequired();
entity.Property(e => e.ExpiresAtUtc).IsRequired();
entity.HasIndex(e => e.UserId);
entity.HasOne(e => e.User)
.WithMany()
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<SecurityPolicyEntity>(entity =>
{
entity.ToTable("SecurityPolicies");
entity.HasKey(e => e.Id);
entity.Property(e => e.AllowPersist).HasDefaultValue(true);
entity.Property(e => e.AllowSync).HasDefaultValue(true);
entity.HasIndex(e => e.UserId).IsUnique();
entity.HasOne(e => e.User)
.WithMany()
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
}
}
@@ -13,6 +13,7 @@
<ItemGroup Condition="'$(TargetFramework)' != 'net10.0'">
<Compile Remove="DynamicApi\\**\\*.cs" />
<Compile Remove="CloudSync\\**\\*.cs" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,198 @@
// <auto-generated />
using System;
using Hua.Todo.Application.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
[DbContext(typeof(TodoDbContext))]
[Migration("20260406172936_AddCloudSyncCoreEntities")]
partial class AddCloudSyncCoreEntities
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowPersist")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId")
.IsUnique();
b.ToTable("SecurityPolicies", (string)null);
});
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.Property<Guid>("UserId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001"));
b.HasKey("Id");
b.HasIndex("ParentTaskId");
b.HasIndex("UserId");
b.ToTable("Tasks", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserName")
.IsUnique();
b.ToTable("Users", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<DateTime>("ExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<DateTime?>("StepUpExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserSessions", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "ParentTask")
.WithMany("SubTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany("Tasks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ParentTask");
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.Navigation("SubTasks");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
{
b.Navigation("Tasks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,136 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
/// <inheritdoc />
public partial class AddCloudSyncCoreEntities : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "UserId",
table: "Tasks",
type: "TEXT",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000001"));
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
UserName = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
PasswordHash = table.Column<string>(type: "TEXT", nullable: false),
Role = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.InsertData(
table: "Users",
columns: new[] { "Id", "UserName", "PasswordHash", "Role" },
values: new object[] { new Guid("00000000-0000-0000-0000-000000000001"), "local", "", "local" });
migrationBuilder.CreateTable(
name: "SecurityPolicies",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
AllowPersist = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: true)
},
constraints: table =>
{
table.PrimaryKey("PK_SecurityPolicies", x => x.Id);
table.ForeignKey(
name: "FK_SecurityPolicies_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "UserSessions",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
CreatedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
ExpiresAtUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
StepUpExpiresAtUtc = table.Column<DateTime>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_UserSessions", x => x.Id);
table.ForeignKey(
name: "FK_UserSessions_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Tasks_UserId",
table: "Tasks",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_SecurityPolicies_UserId",
table: "SecurityPolicies",
column: "UserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Users_UserName",
table: "Users",
column: "UserName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_UserSessions_UserId",
table: "UserSessions",
column: "UserId");
migrationBuilder.AddForeignKey(
name: "FK_Tasks_Users_UserId",
table: "Tasks",
column: "UserId",
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Tasks_Users_UserId",
table: "Tasks");
migrationBuilder.DropTable(
name: "SecurityPolicies");
migrationBuilder.DropTable(
name: "UserSessions");
migrationBuilder.DropTable(
name: "Users");
migrationBuilder.DropIndex(
name: "IX_Tasks_UserId",
table: "Tasks");
migrationBuilder.DropColumn(
name: "UserId",
table: "Tasks");
}
}
}
@@ -0,0 +1,203 @@
// <auto-generated />
using System;
using Hua.Todo.Application.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
[DbContext(typeof(TodoDbContext))]
[Migration("20260406173734_AddAllowSyncToSecurityPolicy")]
partial class AddAllowSyncToSecurityPolicy
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowPersist")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("AllowSync")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId")
.IsUnique();
b.ToTable("SecurityPolicies", (string)null);
});
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.Property<Guid>("UserId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001"));
b.HasKey("Id");
b.HasIndex("ParentTaskId");
b.HasIndex("UserId");
b.ToTable("Tasks", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserName")
.IsUnique();
b.ToTable("Users", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<DateTime>("ExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<DateTime?>("StepUpExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserSessions", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "ParentTask")
.WithMany("SubTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany("Tasks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ParentTask");
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.Navigation("SubTasks");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
{
b.Navigation("Tasks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
/// <inheritdoc />
public partial class AddAllowSyncToSecurityPolicy : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AllowSync",
table: "SecurityPolicies",
type: "INTEGER",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AllowSync",
table: "SecurityPolicies");
}
}
}
@@ -1,9 +1,9 @@
// <auto-generated />
// <auto-generated />
using System;
using Hua.Todo.Application.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Hua.Todo.Application.Data;
#nullable disable
@@ -17,6 +17,33 @@ namespace Hua.Todo.Application.Migrations
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowPersist")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("AllowSync")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId")
.IsUnique();
b.ToTable("SecurityPolicies", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.Property<int>("Id")
@@ -51,11 +78,82 @@ namespace Hua.Todo.Application.Migrations
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<Guid>("UserId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001"));
b.HasKey("Id");
b.HasIndex("ParentTaskId");
b.ToTable("Tasks");
b.HasIndex("UserId");
b.ToTable("Tasks", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserName")
.IsUnique();
b.ToTable("Users", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<DateTime>("ExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<DateTime?>("StepUpExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserSessions", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
@@ -65,13 +163,37 @@ namespace Hua.Todo.Application.Migrations
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany("Tasks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ParentTask");
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.Navigation("SubTasks");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
{
b.Navigation("Tasks");
});
#pragma warning restore 612, 618
}
}
@@ -28,6 +28,7 @@ public class TaskRepository : ITaskRepository
public async Task<List<TaskEntity>> GetAllAsync()
{
return await _context.Tasks
.Where(t => t.UserId == TodoUserIds.LocalUserId)
.Include(t => t.SubTasks)
.ToListAsync();
}
@@ -41,7 +42,7 @@ public class TaskRepository : ITaskRepository
{
return await _context.Tasks
.Include(t => t.SubTasks)
.FirstOrDefaultAsync(t => t.Id == id);
.FirstOrDefaultAsync(t => t.Id == id && t.UserId == TodoUserIds.LocalUserId);
}
/// <summary>
@@ -51,7 +52,7 @@ public class TaskRepository : ITaskRepository
public async Task<List<TaskEntity>> GetActiveTasksAsync()
{
return await _context.Tasks
.Where(t => !t.IsCompleted)
.Where(t => t.UserId == TodoUserIds.LocalUserId && !t.IsCompleted)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
}
@@ -63,7 +64,7 @@ public class TaskRepository : ITaskRepository
public async Task<List<TaskEntity>> GetCompletedTasksAsync()
{
return await _context.Tasks
.Where(t => t.IsCompleted)
.Where(t => t.UserId == TodoUserIds.LocalUserId && t.IsCompleted)
.OrderByDescending(t => t.UpdatedAt)
.ToListAsync();
}
@@ -75,6 +76,7 @@ public class TaskRepository : ITaskRepository
/// <returns>已持久化的任务实体(包含生成的 ID)。</returns>
public async Task<TaskEntity> AddAsync(TaskEntity taskEntity)
{
taskEntity.UserId = TodoUserIds.LocalUserId;
_context.Tasks.Add(taskEntity);
await _context.SaveChangesAsync();
return taskEntity;
@@ -100,7 +102,7 @@ public class TaskRepository : ITaskRepository
/// <returns>表示删除操作的任务。</returns>
public async Task DeleteAsync(int id)
{
var task = await _context.Tasks.FindAsync(id);
var task = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == id && t.UserId == TodoUserIds.LocalUserId);
if (task != null)
{
_context.Tasks.Remove(task);
@@ -116,7 +118,7 @@ public class TaskRepository : ITaskRepository
public async Task<List<TaskEntity>> GetSubTasksAsync(int parentTaskId)
{
return await _context.Tasks
.Where(t => t.ParentTaskId == parentTaskId)
.Where(t => t.UserId == TodoUserIds.LocalUserId && t.ParentTaskId == parentTaskId)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
}