feat: gRPC auth service, validators, middleware fixes

- Add AuthGrpcService for inter-service token validation
- Add FluentValidation validators for auth/menus/roles/users
- Fix middleware Stream lifecycle issues
- Register ABP ObjectAccessor for container compatibility
- Update connection strings for NUC Box infrastructure
- Add JWT claim mapping fixes
This commit is contained in:
向宁 2026-05-17 19:43:43 +08:00
parent 819eac0edc
commit 54db985fa5
27 changed files with 484 additions and 86 deletions

View File

@ -1,16 +1,12 @@
using RAG.Domain.Common;
using RAG.Domain.Exceptions;
using System.Security.Claims;
using FastEndpoints;
using MediatR;
using RAG.Application.Auth.Queries;
namespace RAG.Api.Endpoints.Auth;
/// <summary>
/// 获取当前用户的权限码列表。
/// 前端调 GET /auth/codes解包后得到 string[],用于 v-access:code 指令的按钮级权限控制。
/// </summary>
public class GetAccessCodesEndpoint(IMediator mediator) : EndpointWithoutRequest<List<string>>
public class GetAccessCodesEndpoint(IMediator mediator, ICurrentUserContext userContext) : EndpointWithoutRequest<List<string>>
{
public override void Configure()
{
@ -19,9 +15,7 @@ public class GetAccessCodesEndpoint(IMediator mediator) : EndpointWithoutRequest
public override async Task HandleAsync(CancellationToken ct)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier)
?? throw new UnauthorizedException("未授权,请先登录");
var userId = userContext.GetRequiredUserId();
var codes = await mediator.Send(new GetAccessCodesQuery(userId), ct);
await Send.OkAsync(codes, ct);
}

View File

@ -1,28 +1,21 @@
using RAG.Domain.Common;
using RAG.Domain.Exceptions;
using System.Security.Claims;
using FastEndpoints;
using MediatR;
using RAG.Application.Auth.Queries;
namespace RAG.Api.Endpoints.Auth;
/// <summary>
/// 获取当前登录用户信息:从 JWT claims 中取 userId查库返回用户资料+角色。
/// 前端登录后调 GET /user/info解包后得到 { userId, username, realName, roles, ... }。
/// </summary>
public class GetCurrentUserEndpoint(IMediator mediator) : EndpointWithoutRequest<CurrentUserInfo>
public class GetCurrentUserEndpoint(IMediator mediator, ICurrentUserContext userContext) : EndpointWithoutRequest<CurrentUserInfo>
{
public override void Configure()
{
Get("/user/info");
// 所有已认证用户都可调用,不需要特定权限码
}
public override async Task HandleAsync(CancellationToken ct)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier)
?? throw new UnauthorizedException("未授权,请先登录");
var userId = userContext.GetRequiredUserId();
var result = await mediator.Send(new GetCurrentUserQuery(userId), ct);
await Send.OkAsync(result, ct);
}

View File

@ -1,15 +1,12 @@
using FastEndpoints;
using MediatR;
using Microsoft.Extensions.Configuration;
using RAG.Application.Auth.Commands;
using RAG.Application.Auth.DTOs;
namespace RAG.Api.Endpoints.Auth;
/// <summary>
/// 登录接口:验证凭据后返回 TokenResponse同时将 refreshToken 写入 httpOnly Cookie。
/// 前端 requestClient 只从响应体取 accessTokenrefreshToken 通过 Cookie 自动管理。
/// </summary>
public class LoginEndpoint(IMediator mediator) : Endpoint<LoginRequest, TokenResponse>
public class LoginEndpoint(IMediator mediator, IConfiguration config) : Endpoint<LoginRequest, TokenResponse>
{
public override void Configure()
{
@ -21,11 +18,10 @@ public class LoginEndpoint(IMediator mediator) : Endpoint<LoginRequest, TokenRes
{
var result = await mediator.Send(new LoginCommand(req.Username, req.Password), ct);
// 将 refreshToken 写入 httpOnly Cookie前端通过 withCredentials 自动携带
HttpContext.Response.Cookies.Append("jwt", result.RefreshToken, new CookieOptions
{
HttpOnly = true,
Secure = false,
Secure = config.GetValue<bool>("Cookie:Secure"),
SameSite = SameSiteMode.Lax,
Expires = DateTimeOffset.UtcNow.AddDays(7),
Path = "/"

View File

@ -1,23 +1,19 @@
using RAG.Domain.Exceptions;
using System.Text;
using FastEndpoints;
using MediatR;
using Microsoft.Extensions.Configuration;
using RAG.Application.Auth.Commands;
using RAG.Application.Auth.DTOs;
namespace RAG.Api.Endpoints.Auth;
/// <summary>
/// 刷新令牌接口:从 httpOnly Cookie 读取 refreshToken颁发新的 token 对。
/// 前端通过 baseRequestClient不解包调用返回纯字符串新 accessToken
/// 注意:此接口被 ApiResponseMiddleware 排除,不会包裹为 { code, data, message }。
/// </summary>
public class RefreshTokenEndpoint(IMediator mediator) : EndpointWithoutRequest<string>
public class RefreshTokenEndpoint(IMediator mediator, IConfiguration config) : EndpointWithoutRequest<string>
{
public override void Configure()
{
Post("/auth/refresh");
AllowAnonymous();
Options(b => b.ExcludeFromDescription());
}
public override async Task HandleAsync(CancellationToken ct)
@ -27,19 +23,17 @@ public class RefreshTokenEndpoint(IMediator mediator) : EndpointWithoutRequest<s
var result = await mediator.Send(new RefreshTokenCommand(refreshToken), ct);
// 轮换 Cookie
HttpContext.Response.Cookies.Append("jwt", result.RefreshToken, new CookieOptions
{
HttpOnly = true,
Secure = false,
Secure = config.GetValue<bool>("Cookie:Secure"),
SameSite = SameSiteMode.Lax,
Expires = DateTimeOffset.UtcNow.AddDays(7),
Path = "/"
});
// 前端 baseRequestClient 期望 resp.data 为纯字符串(新 accessToken
HttpContext.Response.StatusCode = 200;
HttpContext.Response.ContentType = "text/plain; charset=utf-8";
var bytes = Encoding.UTF8.GetBytes(result.AccessToken);
await HttpContext.Response.Body.WriteAsync(bytes, ct);
await HttpContext.Response.WriteAsync(result.AccessToken, ct);
}
}

View File

@ -1,39 +1,23 @@
using RAG.Domain.Common;
using RAG.Domain.Exceptions;
using System.Security.Claims;
using FastEndpoints;
using MediatR;
using Microsoft.EntityFrameworkCore;
using RAG.Application.Menus.DTOs;
using RAG.Application.Menus.Queries;
using RAG.Infrastructure.Persistence;
namespace RAG.Api.Endpoints.Menus;
/// <summary>
/// 获取当前用户可见的动态菜单树。
/// 前端在 backend 模式下调 GET /menu/all解包后得到 RouteRecordStringComponent[]。
/// 根据 JWT 中的用户 ID 查出角色,再按角色过滤菜单。
/// </summary>
public class GetAllMenusEndpoint(IMediator mediator, RagDbContext db) : EndpointWithoutRequest<List<MenuRouteDto>>
public class GetAllMenusEndpoint(IMediator mediator, ICurrentUserContext userContext) : EndpointWithoutRequest<List<MenuRouteDto>>
{
public override void Configure()
{
Get("/menu/all");
// 所有已认证用户可调用
}
public override async Task HandleAsync(CancellationToken ct)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier)
?? throw new UnauthorizedException("未授权,请先登录");
// 查出当前用户角色
var userRoles = await db.Users
.Where(u => u.Id.ToString() == userId)
.SelectMany(u => u.UserRoles.Select(ur => ur.Role.Name))
.ToListAsync(ct);
var menus = await mediator.Send(new GetAllMenusQuery(userRoles), ct);
var userId = userContext.GetRequiredUserId();
var menus = await mediator.Send(new GetAllMenusQuery(userId), ct);
await Send.OkAsync(menus, ct);
}
}

View File

@ -0,0 +1,73 @@
using Grpc.Core;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
namespace RAG.Api.Grpc;
public class AuthGrpcService(IConfiguration config) : AuthService.AuthServiceBase
{
private readonly TokenValidationParameters _validationParams = new()
{
ValidateIssuer = true,
ValidIssuer = config["Jwt:Issuer"],
ValidateAudience = true,
ValidAudience = config["Jwt:Audience"],
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:SigningKey"]!)),
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero
};
public override Task<ValidateTokenResponse> ValidateToken(ValidateTokenRequest request, ServerCallContext context)
{
var response = new ValidateTokenResponse();
try
{
var handler = new JwtSecurityTokenHandler();
var principal = handler.ValidateToken(request.Token, _validationParams, out var validatedToken);
if (validatedToken is not JwtSecurityToken jwtToken)
return Task.FromResult(response);
response.Valid = true;
response.UserId = principal.FindFirstValue(ClaimTypes.NameIdentifier)
?? principal.FindFirstValue(JwtRegisteredClaimNames.Sub) ?? "";
response.Username = principal.FindFirstValue(ClaimTypes.Name)
?? principal.FindFirstValue(JwtRegisteredClaimNames.UniqueName) ?? "";
response.Email = principal.FindFirstValue(JwtRegisteredClaimNames.Email)
?? principal.FindFirstValue(ClaimTypes.Email) ?? "";
response.Roles.AddRange(principal.FindAll(ClaimTypes.Role).Select(c => c.Value));
response.Permissions.AddRange(principal.FindAll("permissions").Select(c => c.Value));
response.ExpiresAt = new DateTimeOffset(jwtToken.ValidTo).ToUnixTimeSeconds();
}
catch (SecurityTokenException) { }
catch (ArgumentException) { }
return Task.FromResult(response);
}
public override Task<CheckPermissionResponse> CheckPermission(CheckPermissionRequest request, ServerCallContext context)
{
var response = new CheckPermissionResponse();
try
{
var handler = new JwtSecurityTokenHandler();
var principal = handler.ValidateToken(request.Token, _validationParams, out _);
response.UserId = principal.FindFirstValue(ClaimTypes.NameIdentifier)
?? principal.FindFirstValue(JwtRegisteredClaimNames.Sub) ?? "";
var permissions = principal.FindAll("permissions").Select(c => c.Value).ToList();
response.Allowed = permissions.Contains(request.Permission);
response.Roles.AddRange(principal.FindAll(ClaimTypes.Role).Select(c => c.Value));
}
catch (SecurityTokenException) { }
catch (ArgumentException) { }
return Task.FromResult(response);
}
}

View File

@ -16,11 +16,26 @@ public class ApiResponseMiddleware(RequestDelegate next)
public async Task InvokeAsync(HttpContext context)
{
if (context.Request.ContentType?.StartsWith("application/grpc") == true || IsExcludedPath(context.Request.Path))
{
await next(context);
return;
}
var originalBody = context.Response.Body;
using var buffer = new MemoryStream();
var buffer = new MemoryStream();
context.Response.Body = buffer;
await next(context);
try
{
await next(context);
}
catch
{
context.Response.Body = originalBody;
await buffer.DisposeAsync();
throw;
}
context.Response.Body = originalBody;
@ -56,6 +71,7 @@ public class ApiResponseMiddleware(RequestDelegate next)
context.Response.ContentType = "application/json; charset=utf-8";
context.Response.ContentLength = bytes.Length;
await originalBody.WriteAsync(bytes, context.RequestAborted);
await buffer.DisposeAsync();
}
private static bool IsExcludedPath(PathString path) =>

View File

@ -1,6 +1,7 @@
using System.Net;
using System.Text;
using System.Text.Json;
using FluentValidation;
using RAG.Domain.Exceptions;
namespace RAG.Api.Middleware;
@ -15,6 +16,12 @@ public class GlobalExceptionMiddleware(RequestDelegate next)
public async Task InvokeAsync(HttpContext context)
{
if (context.Request.ContentType?.StartsWith("application/grpc") == true)
{
await next(context);
return;
}
try
{
await next(context);
@ -32,7 +39,8 @@ public class GlobalExceptionMiddleware(RequestDelegate next)
UnauthorizedException => (HttpStatusCode.Unauthorized, 401, ex.Message),
NotFoundException => (HttpStatusCode.NotFound, 404, ex.Message),
BusinessException => (HttpStatusCode.BadRequest, 400, ex.Message),
// 未知异常:生产环境隐藏详情,开发环境暴露
ValidationException vex => (HttpStatusCode.BadRequest, 400,
string.Join("; ", vex.Errors.Select(e => e.ErrorMessage))),
_ => (HttpStatusCode.InternalServerError, 500,
"服务内部错误,请稍后重试")
};

View File

@ -1,9 +1,25 @@
using Microsoft.EntityFrameworkCore;
using RAG.Api;
using RAG.Infrastructure.Persistence;
using Volo.Abp.DependencyInjection;
var builder = WebApplication.CreateBuilder(args);
// ABP 需要同时注册 ObjectAccessor 的具体类型和接口类型
var appBuilderAccessor = new ObjectAccessor<Microsoft.AspNetCore.Builder.IApplicationBuilder>();
var webAppAccessor = new ObjectAccessor<Microsoft.AspNetCore.Builder.WebApplication>();
var hostAccessor = new ObjectAccessor<Microsoft.Extensions.Hosting.IHost>();
var endpointAccessor = new ObjectAccessor<Microsoft.AspNetCore.Routing.IEndpointRouteBuilder>();
builder.Services.AddSingleton(appBuilderAccessor);
builder.Services.AddSingleton<IObjectAccessor<Microsoft.AspNetCore.Builder.IApplicationBuilder>>(appBuilderAccessor);
builder.Services.AddSingleton(webAppAccessor);
builder.Services.AddSingleton<IObjectAccessor<Microsoft.AspNetCore.Builder.WebApplication>>(webAppAccessor);
builder.Services.AddSingleton(hostAccessor);
builder.Services.AddSingleton<IObjectAccessor<Microsoft.Extensions.Hosting.IHost>>(hostAccessor);
builder.Services.AddSingleton(endpointAccessor);
builder.Services.AddSingleton<IObjectAccessor<Microsoft.AspNetCore.Routing.IEndpointRouteBuilder>>(endpointAccessor);
// ABP 模块化引导:按 DependsOn 依赖顺序调用各模块的 ConfigureServices
await builder.AddApplicationAsync<RAGApiModule>();

View File

@ -0,0 +1,35 @@
syntax = "proto3";
package auth;
option csharp_namespace = "RAG.Api.Grpc";
service AuthService {
rpc ValidateToken (ValidateTokenRequest) returns (ValidateTokenResponse);
rpc CheckPermission (CheckPermissionRequest) returns (CheckPermissionResponse);
}
message ValidateTokenRequest {
string token = 1;
}
message ValidateTokenResponse {
bool valid = 1;
string user_id = 2;
string username = 3;
string email = 4;
repeated string roles = 5;
repeated string permissions = 6;
int64 expires_at = 7;
}
message CheckPermissionRequest {
string token = 1;
string permission = 2;
}
message CheckPermissionResponse {
bool allowed = 1;
string user_id = 2;
repeated string roles = 3;
}

View File

@ -10,18 +10,23 @@
<PackageReference Include="FastEndpoints" Version="8.1.0" />
<PackageReference Include="FastEndpoints.Security" Version="8.1.0" />
<PackageReference Include="FastEndpoints.Swagger" Version="8.1.0" />
<PackageReference Include="Grpc.AspNetCore" Version="2.71.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Volo.Abp.AspNetCore" Version="10.3.0" />
<PackageReference Include="Volo.Abp.Core" Version="10.3.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\RAG.Application\RAG.Application.csproj" />
<ProjectReference Include="..\RAG.Infrastructure\RAG.Infrastructure.csproj" />
<ItemGroup>
<Protobuf Include="Protos\auth.proto" GrpcServices="Server" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\RAG.Application\RAG.Application.csproj" />
<ProjectReference Include="..\RAG.Infrastructure\RAG.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@ -1,6 +1,7 @@
using FastEndpoints;
using FastEndpoints.Security;
using FastEndpoints.Swagger;
using RAG.Api.Grpc;
using RAG.Api.Middleware;
using RAG.Api.Services;
using RAG.Application;
@ -26,6 +27,9 @@ public class RAGApiModule : AbpModule
services.AddFastEndpoints();
services.SwaggerDocument();
// gRPC
services.AddGrpc();
// 当前用户上下文,供 AuditInterceptor 获取操作者 ID 和 IP
services.AddHttpContextAccessor();
services.AddScoped<ICurrentUserContext, CurrentUserContext>();
@ -63,6 +67,9 @@ public class RAGApiModule : AbpModule
// 全局异常中间件:捕获所有未处理异常,统一返回 { code, message, data }
app.UseMiddleware<GlobalExceptionMiddleware>();
// gRPC endpoint mapping必须在 ApiResponseMiddleware 之前)
((IEndpointRouteBuilder)app).MapGrpcService<AuthGrpcService>();
// 响应包裹中间件:统一 { code: 0, data, message } 格式
app.UseMiddleware<ApiResponseMiddleware>();

View File

@ -1,12 +1,9 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using RAG.Domain.Common;
namespace RAG.Api.Services;
/// <summary>
/// 当前用户上下文实现,从 HttpContext 中提取当前认证用户的 ID 和请求 IP。
/// 供 AuditInterceptor 使用,实现审计字段的自动填充。
/// </summary>
public class CurrentUserContext : ICurrentUserContext
{
private readonly IHttpContextAccessor _httpContextAccessor;
@ -23,10 +20,10 @@ public class CurrentUserContext : ICurrentUserContext
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext?.User?.Identity?.IsAuthenticated == true)
{
// JWT 令牌中 NameIdentifier claim 对应登录时写入的 user.Id
return httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system";
return httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? httpContext.User.FindFirstValue(JwtRegisteredClaimNames.Sub)
?? "system";
}
// 未认证场景(种子数据、后台任务等)使用 "system" 作为操作者
return "system";
}
}

View File

@ -1,4 +1,16 @@
{
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:5211",
"Protocols": "Http1"
},
"Grpc": {
"Url": "http://localhost:50051",
"Protocols": "Http2"
}
}
},
"Logging": {
"LogLevel": {
"Default": "Information",
@ -20,5 +32,8 @@
"SigningKey": "RagJwtSecretKey2026MustBeAtLeast32CharsLong!",
"Issuer": "rag-api",
"Audience": "rag-client"
},
"Cookie": {
"Secure": false
}
}

View File

@ -1,6 +1,7 @@
using RAG.Domain.Exceptions;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using MediatR;
using Microsoft.EntityFrameworkCore;
@ -16,19 +17,29 @@ public record LoginCommand(string Username, string Password) : IRequest<TokenRes
public class LoginCommandHandler(RagDbContext db, IConfiguration config) : IRequestHandler<LoginCommand, TokenResponse>
{
private static readonly Dictionary<string, (int Count, DateTime LockedUntil)> LoginAttempts = new();
public async Task<TokenResponse> Handle(LoginCommand request, CancellationToken ct)
{
var key = request.Username.Trim().ToLowerInvariant();
if (LoginAttempts.TryGetValue(key, out var attempt) && attempt.LockedUntil > DateTime.UtcNow)
throw new BusinessException($"登录失败次数过多,请在 {(attempt.LockedUntil - DateTime.UtcNow).Minutes + 1} 分钟后重试");
var user = await db.Users
.Include(u => u.UserRoles).ThenInclude(ur => ur.Role).ThenInclude(r => r.RolePermissions).ThenInclude(rp => rp.Permission)
.FirstOrDefaultAsync(u => u.Username == request.Username, ct)
?? throw new UnauthorizedException("用户名或密码错误");
?? throw FailLogin(key, "用户名或密码错误");
if (!BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
throw new UnauthorizedException("用户名或密码错误");
throw FailLogin(key, "用户名或密码错误");
if (!user.IsActive)
throw new BusinessException("用户已被停用");
// 登录成功,清除失败计数
LoginAttempts.Remove(key);
var roles = user.UserRoles.Select(ur => ur.Role.Name).ToList();
var permissions = user.UserRoles.SelectMany(ur => ur.Role.RolePermissions).Select(rp => rp.Permission.Code).Distinct().ToList();
@ -37,6 +48,7 @@ public class LoginCommandHandler(RagDbContext db, IConfiguration config) : IRequ
await db.RefreshTokens.AddAsync(new RefreshToken
{
Id = Guid.NewGuid(),
UserId = user.Id,
Token = refreshToken,
ExpiresAt = DateTime.UtcNow.AddDays(7),
@ -46,10 +58,28 @@ public class LoginCommandHandler(RagDbContext db, IConfiguration config) : IRequ
return new TokenResponse(accessToken, refreshToken, DateTime.UtcNow.AddMinutes(15));
}
private static BusinessException FailLogin(string key, string message)
{
if (!LoginAttempts.TryGetValue(key, out var attempt))
attempt = (0, DateTime.MinValue);
attempt.Count++;
if (attempt.Count >= 5)
{
var lockedUntil = DateTime.UtcNow.AddMinutes(15);
LoginAttempts[key] = (attempt.Count, lockedUntil);
return new BusinessException($"登录失败次数过多,请 15 分钟后重试");
}
LoginAttempts[key] = attempt;
return new BusinessException(message);
}
private string GenerateAccessToken(User user, List<string> roles, List<string> permissions)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:SigningKey"]!));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:SigningKey"]!));
var creds = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256);
var claims = new List<Claim>
{
@ -71,5 +101,11 @@ public class LoginCommandHandler(RagDbContext db, IConfiguration config) : IRequ
return new JwtSecurityTokenHandler().WriteToken(token);
}
private static string GenerateRefreshToken() => Convert.ToBase64String(Guid.NewGuid().ToByteArray()) + Guid.NewGuid().ToString("N");
private static string GenerateRefreshToken()
{
var bytes = new byte[64];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(bytes);
return Convert.ToBase64String(bytes);
}
}

View File

@ -54,6 +54,7 @@ public class RefreshTokenCommandHandler(RagDbContext db, IConfiguration config)
var newRefresh = Convert.ToBase64String(Guid.NewGuid().ToByteArray()) + Guid.NewGuid().ToString("N");
await db.RefreshTokens.AddAsync(new Domain.Entities.RefreshToken
{
Id = Guid.NewGuid(),
UserId = user.Id,
Token = newRefresh,
ExpiresAt = DateTime.UtcNow.AddDays(7),

View File

@ -0,0 +1,53 @@
using FluentValidation;
using RAG.Application.Auth.Commands;
namespace RAG.Application.Auth.Validators;
public class LoginCommandValidator : AbstractValidator<LoginCommand>
{
public LoginCommandValidator()
{
RuleFor(x => x.Username).NotEmpty().WithMessage("用户名不能为空")
.MaximumLength(50).WithMessage("用户名长度不能超过 50 个字符")
.Must(u => u == u.Trim()).WithMessage("用户名前后不能有空格");
RuleFor(x => x.Password).NotEmpty().WithMessage("密码不能为空")
.MaximumLength(100).WithMessage("密码长度不能超过 100 个字符");
}
}
public class RegisterCommandValidator : AbstractValidator<RegisterCommand>
{
public RegisterCommandValidator()
{
RuleFor(x => x.Username).NotEmpty().WithMessage("用户名不能为空")
.Length(3, 50).WithMessage("用户名长度 3-50 个字符");
RuleFor(x => x.Email).NotEmpty().WithMessage("邮箱不能为空")
.EmailAddress().WithMessage("邮箱格式不正确");
RuleFor(x => x.Password).NotEmpty().WithMessage("密码不能为空")
.Length(6, 100).WithMessage("密码长度 6-100 个字符");
}
}
public class RefreshTokenCommandValidator : AbstractValidator<RefreshTokenCommand>
{
public RefreshTokenCommandValidator()
{
RuleFor(x => x.RefreshToken).NotEmpty().WithMessage("RefreshToken 不能为空");
}
}
public class RevokeTokenCommandValidator : AbstractValidator<RevokeTokenCommand>
{
public RevokeTokenCommandValidator()
{
RuleFor(x => x.RefreshToken).NotEmpty().WithMessage("RefreshToken 不能为空");
}
}
public class LogoutCommandValidator : AbstractValidator<LogoutCommand>
{
public LogoutCommandValidator()
{
RuleFor(x => x.RefreshToken).NotEmpty().WithMessage("RefreshToken 不能为空");
}
}

View File

@ -0,0 +1,23 @@
using FluentValidation;
using MediatR;
namespace RAG.Application.Common;
public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
: IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse>
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
if (validators.Any())
{
var context = new ValidationContext<TRequest>(request);
var results = await Task.WhenAll(validators.Select(v => v.ValidateAsync(context, cancellationToken)));
var failures = results.SelectMany(r => r.Errors).Where(f => f != null).ToList();
if (failures.Count != 0)
throw new ValidationException(failures);
}
return await next();
}
}

View File

@ -5,22 +5,24 @@ using RAG.Infrastructure.Persistence;
namespace RAG.Application.Menus.Queries;
public record GetAllMenusQuery(List<string> UserRoles) : IRequest<List<MenuRouteDto>>;
public record GetAllMenusQuery(string UserId) : IRequest<List<MenuRouteDto>>;
public class GetAllMenusQueryHandler(RagDbContext db) : IRequestHandler<GetAllMenusQuery, List<MenuRouteDto>>
{
public async Task<List<MenuRouteDto>> Handle(GetAllMenusQuery request, CancellationToken ct)
{
// 加载所有未删除的菜单,按 Order 排序
var userRoles = await db.Users
.Where(u => u.Id.ToString() == request.UserId)
.SelectMany(u => u.UserRoles.Select(ur => ur.Role.Name))
.ToListAsync(ct);
var allMenus = await db.Menus
.OrderBy(m => m.Order)
.ToListAsync(ct);
// 按角色过滤Authority 为空则所有人可见,否则用户角色匹配则可见
var userRoleSet = new HashSet<string>(request.UserRoles);
var userRoleSet = new HashSet<string>(userRoles);
var visibleMenus = allMenus.Where(m => IsMenuVisible(m, userRoleSet)).ToList();
// 构建树结构并转换为 DTO
return BuildTree(visibleMenus, null);
}
@ -59,6 +61,9 @@ public class GetAllMenusQueryHandler(RagDbContext db) : IRequestHandler<GetAllMe
HideInMenu = m.HideInMenu ? true : null,
NoBasicLayout = m.NoBasicLayout ? true : null,
MenuVisibleWithForbidden = m.MenuVisibleWithForbidden ? true : null,
Authority = string.IsNullOrWhiteSpace(m.Authority)
? null
: m.Authority.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(),
},
Children = BuildTree(menus, m.Id)
})

View File

@ -0,0 +1,12 @@
using FluentValidation;
using RAG.Application.Menus.Queries;
namespace RAG.Application.Menus.Validators;
public class GetAllMenusQueryValidator : AbstractValidator<GetAllMenusQuery>
{
public GetAllMenusQueryValidator()
{
RuleFor(x => x.UserId).NotEmpty().WithMessage("用户 ID 不能为空");
}
}

View File

@ -6,6 +6,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentValidation" Version="12.1.1" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageReference Include="MediatR" Version="14.1.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.7" />

View File

@ -1,6 +1,8 @@
using FluentValidation;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using RAG.Application.Auth.Commands;
using RAG.Application.Common;
using RAG.Infrastructure;
using Volo.Abp.Modularity;
@ -14,7 +16,11 @@ public class RAGApplicationModule : AbpModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
context.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(RegisterCommand).Assembly));
var services = context.Services;
var assembly = typeof(RegisterCommand).Assembly;
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
services.AddValidatorsFromAssembly(assembly);
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
}
}

View File

@ -5,8 +5,11 @@ using RAG.Infrastructure.Persistence;
namespace RAG.Application.Roles.Commands;
public record DeleteRoleCommand(Guid Id) : IRequest<Unit>;
public class DeleteRoleCommandHandler(RagDbContext db) : IRequestHandler<DeleteRoleCommand, Unit>
{
public async Task<Unit> Handle(DeleteRoleCommand request, CancellationToken ct)

View File

@ -0,0 +1,51 @@
using FluentValidation;
using RAG.Application.Roles.Commands;
using RAG.Application.Roles.Queries;
namespace RAG.Application.Roles.Validators;
public class CreateRoleCommandValidator : AbstractValidator<CreateRoleCommand>
{
public CreateRoleCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().WithMessage("角色名称不能为空")
.Length(2, 50).WithMessage("角色名称长度 2-50 个字符");
}
}
public class UpdateRoleCommandValidator : AbstractValidator<UpdateRoleCommand>
{
public UpdateRoleCommandValidator()
{
RuleFor(x => x.Id).NotEmpty().WithMessage("角色 ID 不能为空");
When(x => x.Name != null, () =>
{
RuleFor(x => x.Name!).Length(2, 50).WithMessage("角色名称长度 2-50 个字符");
});
}
}
public class DeleteRoleCommandValidator : AbstractValidator<DeleteRoleCommand>
{
public DeleteRoleCommandValidator()
{
RuleFor(x => x.Id).NotEmpty().WithMessage("角色 ID 不能为空");
}
}
public class AssignPermissionsCommandValidator : AbstractValidator<AssignPermissionsCommand>
{
public AssignPermissionsCommandValidator()
{
RuleFor(x => x.RoleId).NotEmpty().WithMessage("角色 ID 不能为空");
RuleFor(x => x.PermissionIds).NotEmpty().WithMessage("权限列表不能为空");
}
}
public class GetRoleByIdQueryValidator : AbstractValidator<GetRoleByIdQuery>
{
public GetRoleByIdQueryValidator()
{
RuleFor(x => x.Id).NotEmpty().WithMessage("角色 ID 不能为空");
}
}

View File

@ -18,6 +18,7 @@ public class CreateUserCommandHandler(RagDbContext db) : IRequestHandler<CreateU
var user = new User
{
Id = Guid.NewGuid(),
Username = request.Username,
Email = request.Email,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password)

View File

@ -0,0 +1,59 @@
using FluentValidation;
using RAG.Application.Users.Commands;
using RAG.Application.Users.Queries;
namespace RAG.Application.Users.Validators;
public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
public CreateUserCommandValidator()
{
RuleFor(x => x.Username).NotEmpty().WithMessage("用户名不能为空")
.Length(3, 50).WithMessage("用户名长度 3-50 个字符");
RuleFor(x => x.Email).NotEmpty().WithMessage("邮箱不能为空")
.EmailAddress().WithMessage("邮箱格式不正确");
RuleFor(x => x.Password).NotEmpty().WithMessage("密码不能为空")
.Length(6, 100).WithMessage("密码长度 6-100 个字符");
}
}
public class UpdateUserCommandValidator : AbstractValidator<UpdateUserCommand>
{
public UpdateUserCommandValidator()
{
RuleFor(x => x.Id).NotEmpty().WithMessage("用户 ID 不能为空");
When(x => x.Email != null, () =>
{
RuleFor(x => x.Email!).EmailAddress().WithMessage("邮箱格式不正确");
});
When(x => x.Password != null, () =>
{
RuleFor(x => x.Password!).Length(6, 100).WithMessage("密码长度 6-100 个字符");
});
}
}
public class DeleteUserCommandValidator : AbstractValidator<DeleteUserCommand>
{
public DeleteUserCommandValidator()
{
RuleFor(x => x.Id).NotEmpty().WithMessage("用户 ID 不能为空");
}
}
public class AssignRolesCommandValidator : AbstractValidator<AssignRolesCommand>
{
public AssignRolesCommandValidator()
{
RuleFor(x => x.UserId).NotEmpty().WithMessage("用户 ID 不能为空");
RuleFor(x => x.RoleIds).NotEmpty().WithMessage("角色列表不能为空");
}
}
public class GetUserByIdQueryValidator : AbstractValidator<GetUserByIdQuery>
{
public GetUserByIdQueryValidator()
{
RuleFor(x => x.Id).NotEmpty().WithMessage("用户 ID 不能为空");
}
}

View File

@ -1,3 +1,5 @@
using RAG.Domain.Exceptions;
namespace RAG.Domain.Common;
/// <summary>
@ -9,3 +11,14 @@ public interface ICurrentUserContext
string UserId { get; }
string IPAddress { get; }
}
public static class CurrentUserContextExtensions
{
public static string GetRequiredUserId(this ICurrentUserContext context)
{
var userId = context.UserId;
if (string.IsNullOrEmpty(userId) || userId == "system")
throw new UnauthorizedException("未授权,请先登录");
return userId;
}
}