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:
@@ -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>
|
||||
|
||||
+198
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+203
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user