Compare commits
5 Commits
ljw_branch
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd9c2c85bb | ||
|
|
5b67551fee | ||
|
|
d742ed93ce | ||
|
|
ca7463d42b | ||
|
|
67b030c3c5 |
141
CLAUDE.md
Normal file
141
CLAUDE.md
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
> **Cross-repo rules** — see `/Users/wen/project/rag/CLAUDE.md` for full workspace conventions. Key shared rules:
|
||||||
|
> - File-scoped namespaces, primary constructor DI, `record` for DTOs/Commands/Queries
|
||||||
|
> - Entity: `BaseEntity + IAuditable/ISoftDelete/IFullAudit` marker interfaces
|
||||||
|
> - EF Config: snake_case table, `ValueGeneratedNever()`, `IEntityTypeConfiguration<T>`
|
||||||
|
> - Endpoint: `FastEndpoint<TReq, TRes>` + `Permissions("resource:action")`
|
||||||
|
> - Middleware: `Cors → GlobalException → ApiResponse → Auth → AuthZ → FastEndpoints`
|
||||||
|
> - Response: `{ code: 0, data, message: "ok" }` (auto-wrapped)
|
||||||
|
> - JWT shared key: `RagJwtSecretKey2026MustBeAtLeast32CharsLong!`
|
||||||
|
> - Other repos: `rag-backend` (5211), `im-system` (5212), `work-flow`, `file-system` (8080 Go), `rag-frontend` (5666 Vue)
|
||||||
|
|
||||||
|
## Build & Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build
|
||||||
|
cd src/RAG.Api && dotnet run # HTTP :5211, gRPC :50051
|
||||||
|
cd src/RAG.Api && dotnet run -- --seed # Migrate + seed, then exit
|
||||||
|
cd src/RAG.Infrastructure && dotnet ef migrations add <Name> --startup-project ../RAG.Api
|
||||||
|
cd src/RAG.Infrastructure && dotnet ef database update --startup-project ../RAG.Api
|
||||||
|
docker compose up -d # PostgreSQL 17 + pgvector, Redis 7, RabbitMQ 3
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
.NET 10, ABP modular. Four projects: `Api → Application → Infrastructure → Domain`.
|
||||||
|
|
||||||
|
### ABP Module Chain
|
||||||
|
|
||||||
|
```
|
||||||
|
RAGApiModule [DependsOn(RAGApplicationModule, RAGInfrastructureModule)]
|
||||||
|
→ RAGApplicationModule [DependsOn(RAGInfrastructureModule)]
|
||||||
|
→ RAGInfrastructureModule [DependsOn(RAGDomainModule)]
|
||||||
|
```
|
||||||
|
|
||||||
|
DI in `ConfigureServices()`, middleware in `OnApplicationInitialization()`. `Program.cs` bootstraps via `builder.AddApplicationAsync<RAGApiModule>()`.
|
||||||
|
|
||||||
|
### Middleware Order
|
||||||
|
|
||||||
|
```
|
||||||
|
Cors → GlobalExceptionMiddleware → ApiResponseMiddleware → Authentication → Authorization → FastEndpoints → SwaggerGen → MapGrpcService
|
||||||
|
```
|
||||||
|
|
||||||
|
### Feature Folder Convention
|
||||||
|
|
||||||
|
```
|
||||||
|
Application/{Feature}/
|
||||||
|
Commands/{Action}{Entity}Command.cs — record + Handler(DbContext) in same file
|
||||||
|
Queries/{Action}{Entity}Query.cs — record + Handler(DbContext) in same file
|
||||||
|
DTOs/{Entity}DTOs.cs — all DTOs for feature in one file
|
||||||
|
Validators/{Entity}CommandValidators.cs — FluentValidation rules
|
||||||
|
|
||||||
|
Api/Endpoints/{Feature}/
|
||||||
|
{Action}{Entity}Endpoint.cs — FastEndpoint<TReq, TRes>
|
||||||
|
```
|
||||||
|
|
||||||
|
### gRPC Auth Service
|
||||||
|
|
||||||
|
`AuthGrpcService` (code-first via protobuf-net.Grpc) exposes `ValidateToken` and `CheckPermission`. Other services (file-system, work-flow, im-system) call these RPCs.
|
||||||
|
|
||||||
|
### Seed Data
|
||||||
|
|
||||||
|
`SeedData.cs` creates: 11 permissions, 3 roles (SuperAdmin/Admin/User), admin user (`admin/admin123`), ~13 demo menus.
|
||||||
|
|
||||||
|
## Code Patterns
|
||||||
|
|
||||||
|
### Entity
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class User : BaseEntity, IFullAudit
|
||||||
|
{
|
||||||
|
public string Username { get; set; } = default!;
|
||||||
|
public ICollection<UserRole> UserRoles { get; set; } = [];
|
||||||
|
// IAuditable, ISoftDelete, IHasOperatorIP fields with = default!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Join tables: composite key + `IAuditable` only (no BaseEntity, no soft delete).
|
||||||
|
|
||||||
|
### EF Configuration
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class UserConfiguration : IEntityTypeConfiguration<User>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<User> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("users"); // snake_case
|
||||||
|
builder.HasKey(e => e.Id);
|
||||||
|
builder.Property(e => e.Id).ValueGeneratedNever(); // Client-generated Guid
|
||||||
|
builder.HasIndex(e => e.Username).IsUnique();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Command + Handler (same file)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public record CreateUserCommand(string Username, string Email, string Password) : IRequest<UserDto>;
|
||||||
|
|
||||||
|
public class CreateUserCommandHandler(RagDbContext db) : IRequestHandler<CreateUserCommand, UserDto>
|
||||||
|
{
|
||||||
|
public async Task<UserDto> Handle(CreateUserCommand request, CancellationToken ct) { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class CreateUserEndpoint(IMediator mediator) : Endpoint<CreateUserRequest, UserDto>
|
||||||
|
{
|
||||||
|
public override void Configure() { Post("/users"); Permissions("user:create"); }
|
||||||
|
public override async Task HandleAsync(CreateUserRequest req, CancellationToken ct) { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
|
||||||
|
{
|
||||||
|
public CreateUserCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Username).NotEmpty().Length(3, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Auto-registered + `ValidationBehavior` pipeline. Chinese error messages.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- File-scoped namespaces everywhere
|
||||||
|
- Primary constructors for DI (endpoints, handlers, middleware)
|
||||||
|
- `record` for DTOs/Commands/Queries, `class` for entities/handlers/endpoints
|
||||||
|
- Nullable reference types ON, `= default!` for required strings
|
||||||
|
- `TreatWarningsAsErrors` ON
|
||||||
|
- Permission codes: `{resource}:{action}` (e.g., `user:create`)
|
||||||
|
- Response: `{ code: 0, data, message: "ok" }` (auto-wrapped)
|
||||||
|
- Route prefix: `/api/`
|
||||||
3
RAG.slnx
3
RAG.slnx
@ -5,4 +5,7 @@
|
|||||||
<Project Path="src/RAG.Domain/RAG.Domain.csproj" />
|
<Project Path="src/RAG.Domain/RAG.Domain.csproj" />
|
||||||
<Project Path="src/RAG.Infrastructure/RAG.Infrastructure.csproj" />
|
<Project Path="src/RAG.Infrastructure/RAG.Infrastructure.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/RAG.Application.Tests/RAG.Application.Tests.csproj" />
|
||||||
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
services:
|
services:
|
||||||
|
# --- PostgreSQL (pgvector) ---
|
||||||
postgres:
|
postgres:
|
||||||
image: pgvector/pgvector:pg17
|
image: pgvector/pgvector:pg17
|
||||||
container_name: rag-postgres
|
container_name: rag-postgres
|
||||||
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: rag
|
POSTGRES_DB: rag
|
||||||
POSTGRES_USER: rag
|
POSTGRES_USER: rag
|
||||||
@ -19,9 +21,11 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
# --- Redis ---
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: rag-redis
|
container_name: rag-redis
|
||||||
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "6379:6379"
|
||||||
volumes:
|
volumes:
|
||||||
@ -34,9 +38,11 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
# --- RabbitMQ ---
|
||||||
rabbitmq:
|
rabbitmq:
|
||||||
image: rabbitmq:3-management
|
image: rabbitmq:3-management
|
||||||
container_name: rag-rabbitmq
|
container_name: rag-rabbitmq
|
||||||
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
RABBITMQ_DEFAULT_USER: rag
|
RABBITMQ_DEFAULT_USER: rag
|
||||||
RABBITMQ_DEFAULT_PASS: rag123
|
RABBITMQ_DEFAULT_PASS: rag123
|
||||||
@ -53,10 +59,55 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
# --- MongoDB (im-system) ---
|
||||||
|
mongo:
|
||||||
|
image: mongo:7
|
||||||
|
container_name: rag-mongo
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MONGO_INITDB_ROOT_USERNAME: mongo
|
||||||
|
MONGO_INITDB_ROOT_PASSWORD: mongo123
|
||||||
|
ports:
|
||||||
|
- "27017:27017"
|
||||||
|
volumes:
|
||||||
|
- mongodata:/data/db
|
||||||
|
networks:
|
||||||
|
- rag-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# --- RustFS (S3 compatible, file-system) ---
|
||||||
|
rustfs:
|
||||||
|
image: rustfs/rustfs:latest
|
||||||
|
container_name: rag-rustfs
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
RUSTFS_ROOT_USER: minioadmin
|
||||||
|
RUSTFS_ROOT_PASSWORD: minioadmin
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
- "9001:9001"
|
||||||
|
volumes:
|
||||||
|
- rustfsdata:/data
|
||||||
|
networks:
|
||||||
|
- rag-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "sh", "-c", "nc -z localhost 9000"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
redisdata:
|
redisdata:
|
||||||
rabbitdata:
|
rabbitdata:
|
||||||
|
mongodata:
|
||||||
|
rustfsdata:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
rag-network:
|
rag-network:
|
||||||
|
|||||||
@ -1 +1,22 @@
|
|||||||
|
-- pgvector for rag database
|
||||||
|
\c rag;
|
||||||
CREATE EXTENSION IF NOT EXISTS vector;
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
|
||||||
|
-- Create databases for other services (idempotent)
|
||||||
|
\c postgres;
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT FROM pg_database WHERE datname = 'im_system') THEN
|
||||||
|
PERFORM dblink_exec('dbname=postgres', 'CREATE DATABASE im_system');
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT FROM pg_database WHERE datname = 'workflow') THEN
|
||||||
|
PERFORM dblink_exec('dbname=postgres', 'CREATE DATABASE workflow');
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT FROM pg_database WHERE datname = 'file_system') THEN
|
||||||
|
PERFORM dblink_exec('dbname=postgres', 'CREATE DATABASE file_system');
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT FROM pg_database WHERE datname = 'order_service') THEN
|
||||||
|
PERFORM dblink_exec('dbname=postgres', 'CREATE DATABASE order_service');
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
using RAG.Domain.Common;
|
using RAG.Domain.Common;
|
||||||
using RAG.Domain.Exceptions;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using RAG.Application.Auth.Queries;
|
using RAG.Application.Auth.Queries;
|
||||||
|
|
||||||
namespace RAG.Api.Endpoints.Auth;
|
namespace RAG.Api.Endpoints.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取当前用户的权限码列表。前端据此渲染按钮/菜单的可见性(细粒度前端权限控制)。
|
||||||
|
/// 鉴权通过 GetRequiredUserId 强制——未登录直接抛 401。
|
||||||
|
/// </summary>
|
||||||
public class GetAccessCodesEndpoint(IMediator mediator, ICurrentUserContext userContext) : EndpointWithoutRequest<List<string>>
|
public class GetAccessCodesEndpoint(IMediator mediator, ICurrentUserContext userContext) : EndpointWithoutRequest<List<string>>
|
||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
using RAG.Domain.Common;
|
using RAG.Domain.Common;
|
||||||
using RAG.Domain.Exceptions;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using RAG.Application.Auth.Queries;
|
using RAG.Application.Auth.Queries;
|
||||||
|
|
||||||
namespace RAG.Api.Endpoints.Auth;
|
namespace RAG.Api.Endpoints.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取当前登录用户信息端点。前端登录后调用以填充用户菜单、权限、头像。
|
||||||
|
/// GetRequiredUserId 在未登录时抛 UnauthorizedException,等同强制鉴权(无需显式 Permissions)。
|
||||||
|
/// </summary>
|
||||||
public class GetCurrentUserEndpoint(IMediator mediator, ICurrentUserContext userContext) : EndpointWithoutRequest<CurrentUserInfo>
|
public class GetCurrentUserEndpoint(IMediator mediator, ICurrentUserContext userContext) : EndpointWithoutRequest<CurrentUserInfo>
|
||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
|
|||||||
@ -1,11 +1,15 @@
|
|||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using RAG.Application.Auth.Commands;
|
using RAG.Application.Auth.Commands;
|
||||||
using RAG.Application.Auth.DTOs;
|
using RAG.Application.Auth.DTOs;
|
||||||
|
|
||||||
namespace RAG.Api.Endpoints.Auth;
|
namespace RAG.Api.Endpoints.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 登录端点。返回 access_token(响应体)+ refresh_token(HttpOnly Cookie)。
|
||||||
|
/// 安全设计:refresh_token 仅通过 HttpOnly Cookie 下发,前端 JS 无法读取,可防 XSS 偷取;
|
||||||
|
/// SameSite=Lax 防御 CSRF 跨站提交;Secure 由配置控制(生产强制 HTTPS)。
|
||||||
|
/// </summary>
|
||||||
public class LoginEndpoint(IMediator mediator, IConfiguration config) : Endpoint<LoginRequest, TokenResponse>
|
public class LoginEndpoint(IMediator mediator, IConfiguration config) : Endpoint<LoginRequest, TokenResponse>
|
||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
@ -18,6 +22,7 @@ public class LoginEndpoint(IMediator mediator, IConfiguration config) : Endpoint
|
|||||||
{
|
{
|
||||||
var result = await mediator.Send(new LoginCommand(req.Username, req.Password), ct);
|
var result = await mediator.Send(new LoginCommand(req.Username, req.Password), ct);
|
||||||
|
|
||||||
|
// refresh_token 写入 HttpOnly Cookie:浏览器自动随请求带上,前端 JS 不可读,防 XSS
|
||||||
HttpContext.Response.Cookies.Append("jwt", result.RefreshToken, new CookieOptions
|
HttpContext.Response.Cookies.Append("jwt", result.RefreshToken, new CookieOptions
|
||||||
{
|
{
|
||||||
HttpOnly = true,
|
HttpOnly = true,
|
||||||
@ -30,3 +35,19 @@ public class LoginEndpoint(IMediator mediator, IConfiguration config) : Endpoint
|
|||||||
await Send.OkAsync(result, ct);
|
await Send.OkAsync(result, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>登录请求校验。规则与 LoginCommandValidator 一致,HTTP 入口层提前拦截非法输入。</summary>
|
||||||
|
public class LoginRequestValidator : Validator<LoginRequest>
|
||||||
|
{
|
||||||
|
public LoginRequestValidator()
|
||||||
|
{
|
||||||
|
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 个字符");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
using RAG.Domain.Exceptions;
|
using RAG.Domain.Exceptions;
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using RAG.Application.Auth.Commands;
|
using RAG.Application.Auth.Commands;
|
||||||
using RAG.Application.Auth.DTOs;
|
|
||||||
|
|
||||||
namespace RAG.Api.Endpoints.Auth;
|
namespace RAG.Api.Endpoints.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 刷新令牌端点。从 HttpOnly Cookie 读 refresh_token(前端无法读取,只能由浏览器自动随请求带上),
|
||||||
|
/// 换取新 access_token 以纯文本响应(被 ApiResponseMiddleware 列入 ExcludedPaths 不包裹)。
|
||||||
|
/// ExcludeFromDescription 避免 Swagger 暴露此内部接口。
|
||||||
|
/// </summary>
|
||||||
public class RefreshTokenEndpoint(IMediator mediator, IConfiguration config) : EndpointWithoutRequest<string>
|
public class RefreshTokenEndpoint(IMediator mediator, IConfiguration config) : EndpointWithoutRequest<string>
|
||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
@ -23,6 +26,7 @@ public class RefreshTokenEndpoint(IMediator mediator, IConfiguration config) : E
|
|||||||
|
|
||||||
var result = await mediator.Send(new RefreshTokenCommand(refreshToken), ct);
|
var result = await mediator.Send(new RefreshTokenCommand(refreshToken), ct);
|
||||||
|
|
||||||
|
// 新 refresh_token 写回 Cookie(Rotation:旧 token 已在 Handler 中作废)
|
||||||
HttpContext.Response.Cookies.Append("jwt", result.RefreshToken, new CookieOptions
|
HttpContext.Response.Cookies.Append("jwt", result.RefreshToken, new CookieOptions
|
||||||
{
|
{
|
||||||
HttpOnly = true,
|
HttpOnly = true,
|
||||||
|
|||||||
@ -5,6 +5,10 @@ using RAG.Application.Auth.DTOs;
|
|||||||
|
|
||||||
namespace RAG.Api.Endpoints.Auth;
|
namespace RAG.Api.Endpoints.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 自助注册端点。开放访问(AllowAnonymous),新用户一律授予 "User" 角色。
|
||||||
|
/// 注意:与 CreateUser(需 user:create 权限)区分——后者是管理员创建账号,可指定角色。
|
||||||
|
/// </summary>
|
||||||
public class RegisterEndpoint(IMediator mediator) : Endpoint<RegisterRequest>
|
public class RegisterEndpoint(IMediator mediator) : Endpoint<RegisterRequest>
|
||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
@ -16,6 +20,25 @@ public class RegisterEndpoint(IMediator mediator) : Endpoint<RegisterRequest>
|
|||||||
public override async Task HandleAsync(RegisterRequest req, CancellationToken ct)
|
public override async Task HandleAsync(RegisterRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await mediator.Send(new RegisterCommand(req.Username, req.Email, req.Password), ct);
|
await mediator.Send(new RegisterCommand(req.Username, req.Email, req.Password), ct);
|
||||||
HttpContext.Response.StatusCode = 204;
|
await Send.OkAsync(true, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>自助注册请求校验。</summary>
|
||||||
|
public class RegisterRequestValidator : Validator<RegisterRequest>
|
||||||
|
{
|
||||||
|
public RegisterRequestValidator()
|
||||||
|
{
|
||||||
|
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 个字符");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,10 @@ using RAG.Application.Auth.DTOs;
|
|||||||
|
|
||||||
namespace RAG.Api.Endpoints.Auth;
|
namespace RAG.Api.Endpoints.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 显式吊销刷新令牌("踢下线"语义)。需登录鉴权(未 AllowAnonymous),
|
||||||
|
/// 用于用户主动登出或管理员远程失效他人会话,使旧 refresh_token 立即不可用。
|
||||||
|
/// </summary>
|
||||||
public class RevokeTokenEndpoint(IMediator mediator) : Endpoint<RevokeTokenRequest>
|
public class RevokeTokenEndpoint(IMediator mediator) : Endpoint<RevokeTokenRequest>
|
||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
@ -15,6 +19,16 @@ public class RevokeTokenEndpoint(IMediator mediator) : Endpoint<RevokeTokenReque
|
|||||||
public override async Task HandleAsync(RevokeTokenRequest req, CancellationToken ct)
|
public override async Task HandleAsync(RevokeTokenRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await mediator.Send(new RevokeTokenCommand(req.RefreshToken), ct);
|
await mediator.Send(new RevokeTokenCommand(req.RefreshToken), ct);
|
||||||
HttpContext.Response.StatusCode = 204;
|
await Send.OkAsync(true, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>吊销刷新令牌请求校验。</summary>
|
||||||
|
public class RevokeTokenRequestValidator : Validator<RevokeTokenRequest>
|
||||||
|
{
|
||||||
|
public RevokeTokenRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.RefreshToken)
|
||||||
|
.NotEmpty().WithMessage("RefreshToken 不能为空");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
34
src/RAG.Api/Endpoints/Chat/CreateConversationEndpoint.cs
Normal file
34
src/RAG.Api/Endpoints/Chat/CreateConversationEndpoint.cs
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using MediatR;
|
||||||
|
using RAG.Application.Chat.Commands;
|
||||||
|
using RAG.Application.Chat.DTOs;
|
||||||
|
|
||||||
|
namespace RAG.Api.Endpoints.Chat;
|
||||||
|
|
||||||
|
public class CreateConversationEndpoint(IMediator mediator) : Endpoint<CreateConversationRequest, ConversationDto>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/chat/conversations");
|
||||||
|
AllowAnonymous();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(CreateConversationRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new CreateConversationCommand(req.Title), ct);
|
||||||
|
await Send.ResponseAsync(result, 201, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CreateConversationRequest(string Title);
|
||||||
|
|
||||||
|
/// <summary>创建会话请求校验。</summary>
|
||||||
|
public class CreateConversationRequestValidator : Validator<CreateConversationRequest>
|
||||||
|
{
|
||||||
|
public CreateConversationRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Title)
|
||||||
|
.NotEmpty().WithMessage("会话标题不能为空")
|
||||||
|
.MaximumLength(200).WithMessage("会话标题不能超过 200 个字符");
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/RAG.Api/Endpoints/Chat/DeleteConversationEndpoint.cs
Normal file
34
src/RAG.Api/Endpoints/Chat/DeleteConversationEndpoint.cs
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using MediatR;
|
||||||
|
using RAG.Application.Chat.Commands;
|
||||||
|
|
||||||
|
namespace RAG.Api.Endpoints.Chat;
|
||||||
|
|
||||||
|
public class DeleteConversationEndpoint(IMediator mediator) : Endpoint<DeleteConversationEndpointRequest, bool>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Delete("/chat/conversations/{Id}");
|
||||||
|
AllowAnonymous();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(DeleteConversationEndpointRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await mediator.Send(new DeleteConversationCommand(req.Id), ct);
|
||||||
|
// 返回 200 + 非空 JSON(true),让 ApiResponseMiddleware 包裹为标准
|
||||||
|
// { code:0, data:true, message:"ok" }。不能用 204/空 body——前端
|
||||||
|
// responseReturn:'data' 拦截器会把空响应当错误抛出,导致"删除失败"。
|
||||||
|
await Send.OkAsync(true, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record DeleteConversationEndpointRequest(Guid Id);
|
||||||
|
|
||||||
|
/// <summary>删除会话请求校验(Id 由路由提供)。</summary>
|
||||||
|
public class DeleteConversationEndpointRequestValidator : Validator<DeleteConversationEndpointRequest>
|
||||||
|
{
|
||||||
|
public DeleteConversationEndpointRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Id).NotEmpty().WithMessage("会话 ID 不能为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/RAG.Api/Endpoints/Chat/GetConversationDetailEndpoint.cs
Normal file
32
src/RAG.Api/Endpoints/Chat/GetConversationDetailEndpoint.cs
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using MediatR;
|
||||||
|
using RAG.Application.Chat.DTOs;
|
||||||
|
using RAG.Application.Chat.Queries;
|
||||||
|
|
||||||
|
namespace RAG.Api.Endpoints.Chat;
|
||||||
|
|
||||||
|
public class GetConversationDetailEndpoint(IMediator mediator) : Endpoint<GetConversationDetailRequest, ConversationDetailDto>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Get("/chat/conversations/{Id}");
|
||||||
|
AllowAnonymous();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(GetConversationDetailRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new GetConversationDetailQuery(req.Id), ct);
|
||||||
|
await Send.OkAsync(result, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record GetConversationDetailRequest(Guid Id);
|
||||||
|
|
||||||
|
/// <summary>查询会话详情请求校验(Id 由路由提供)。</summary>
|
||||||
|
public class GetConversationDetailRequestValidator : Validator<GetConversationDetailRequest>
|
||||||
|
{
|
||||||
|
public GetConversationDetailRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Id).NotEmpty().WithMessage("会话 ID 不能为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/RAG.Api/Endpoints/Chat/GetConversationsEndpoint.cs
Normal file
21
src/RAG.Api/Endpoints/Chat/GetConversationsEndpoint.cs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using MediatR;
|
||||||
|
using RAG.Application.Chat.DTOs;
|
||||||
|
using RAG.Application.Chat.Queries;
|
||||||
|
|
||||||
|
namespace RAG.Api.Endpoints.Chat;
|
||||||
|
|
||||||
|
public class GetConversationsEndpoint(IMediator mediator) : EndpointWithoutRequest<List<ConversationDto>>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Get("/chat/conversations");
|
||||||
|
AllowAnonymous();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new GetConversationsQuery(), ct);
|
||||||
|
await Send.OkAsync(result, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/RAG.Api/Endpoints/Chat/SendMessageEndpoint.cs
Normal file
35
src/RAG.Api/Endpoints/Chat/SendMessageEndpoint.cs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using MediatR;
|
||||||
|
using RAG.Application.Chat.Commands;
|
||||||
|
using RAG.Application.Chat.DTOs;
|
||||||
|
|
||||||
|
namespace RAG.Api.Endpoints.Chat;
|
||||||
|
|
||||||
|
public class SendMessageEndpoint(IMediator mediator) : Endpoint<SendMessageRequest, SendMessageResponse>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/chat/conversations/{ConversationId}/messages");
|
||||||
|
AllowAnonymous();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(SendMessageRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var conversationId = Route<Guid>("ConversationId");
|
||||||
|
var result = await mediator.Send(new SendMessageCommand(conversationId, req.Content), ct);
|
||||||
|
await Send.OkAsync(result, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SendMessageRequest(string Content);
|
||||||
|
|
||||||
|
/// <summary>发送消息请求校验。</summary>
|
||||||
|
public class SendMessageRequestValidator : Validator<SendMessageRequest>
|
||||||
|
{
|
||||||
|
public SendMessageRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Content)
|
||||||
|
.NotEmpty().WithMessage("消息内容不能为空")
|
||||||
|
.MaximumLength(10000).WithMessage("消息内容不能超过 10000 个字符");
|
||||||
|
}
|
||||||
|
}
|
||||||
134
src/RAG.Api/Endpoints/Chat/StreamMessageEndpoint.cs
Normal file
134
src/RAG.Api/Endpoints/Chat/StreamMessageEndpoint.cs
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FastEndpoints;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using RAG.Domain.Entities;
|
||||||
|
using RAG.Domain.Enums;
|
||||||
|
using RAG.Domain.Interfaces;
|
||||||
|
using RAG.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace RAG.Api.Endpoints.Chat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 普通对话流式端点(SSE)。流程:保存用户消息 → 读历史拼 prompt → 流式生成 → 累积完整回复落库 →
|
||||||
|
/// 推送 event:done 携带消息 Id。历史优先取 Redis 热缓存,未命中回退 DB 并异步回填,减少首屏延迟。
|
||||||
|
/// </summary>
|
||||||
|
public class StreamMessageEndpoint(RagDbContext db, IAIChatAgent chatAgent, IChatMessageCache cache)
|
||||||
|
: Endpoint<StreamMessageRequest>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/chat/conversations/{ConversationId}/stream");
|
||||||
|
AllowAnonymous();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(StreamMessageRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var conversationId = Route<Guid>("ConversationId");
|
||||||
|
|
||||||
|
var conversation = await db.Conversations.FindAsync([conversationId], ct);
|
||||||
|
if (conversation is null)
|
||||||
|
{
|
||||||
|
HttpContext.Response.StatusCode = 404;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存用户消息到 DB + Redis
|
||||||
|
var userMessage = new ChatMessage
|
||||||
|
{
|
||||||
|
ConversationId = conversationId,
|
||||||
|
Role = ChatRole.User,
|
||||||
|
Content = req.Content
|
||||||
|
};
|
||||||
|
await db.ChatMessages.AddAsync(userMessage, ct);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
await cache.AppendMessageAsync(conversationId,
|
||||||
|
new CachedChatMessage(userMessage.Id, ChatRole.User.ToString(), userMessage.Content, null, userMessage.CreatedAt), ct);
|
||||||
|
|
||||||
|
// 加载历史消息构建上下文
|
||||||
|
// 缓存优先策略:Redis 命中则跳过 DB 查询(高频会话性能优化);未命中则回源 DB 并异步回填
|
||||||
|
var cached = await cache.GetMessagesAsync(conversationId, ct);
|
||||||
|
List<(string Role, string Content)> history;
|
||||||
|
if (cached is { Count: > 0 })
|
||||||
|
{
|
||||||
|
history = cached
|
||||||
|
.OrderBy(m => m.CreatedAt)
|
||||||
|
.Select(m => (m.Role, m.Content))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var dbMessages = await db.ChatMessages
|
||||||
|
.Where(m => m.ConversationId == conversationId)
|
||||||
|
.OrderBy(m => m.CreatedAt)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
history = dbMessages.Select(m => (m.Role.ToString(), m.Content)).ToList();
|
||||||
|
|
||||||
|
// 回填 Redis 缓存
|
||||||
|
// 回源后立刻回填,使后续请求能命中缓存;时间戳用 i 递增保证回填后的顺序稳定
|
||||||
|
if (history.Count > 0)
|
||||||
|
{
|
||||||
|
await cache.SetMessagesAsync(conversationId,
|
||||||
|
history.Select((h, i) => new CachedChatMessage(
|
||||||
|
Guid.NewGuid(), h.Item1, h.Item2, null,
|
||||||
|
DateTime.UtcNow.AddSeconds(i))).ToList(), ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var promptBuilder = new StringBuilder();
|
||||||
|
foreach (var (role, content) in history)
|
||||||
|
{
|
||||||
|
promptBuilder.AppendLine($"{role}: {content}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var prompt = promptBuilder.ToString();
|
||||||
|
|
||||||
|
// SSE 响应
|
||||||
|
HttpContext.Response.ContentType = "text/event-stream";
|
||||||
|
HttpContext.Response.Headers.CacheControl = "no-cache";
|
||||||
|
HttpContext.Response.Headers.Connection = "keep-alive";
|
||||||
|
|
||||||
|
var fullReply = new StringBuilder();
|
||||||
|
|
||||||
|
await foreach (var chunk in chatAgent.RunStreamingAsync(prompt, ct))
|
||||||
|
{
|
||||||
|
fullReply.Append(chunk);
|
||||||
|
var sseData = JsonSerializer.Serialize(new { content = chunk });
|
||||||
|
await HttpContext.Response.WriteAsync($"data: {sseData}\n\n", ct);
|
||||||
|
await HttpContext.Response.Body.FlushAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
var replyText = fullReply.ToString();
|
||||||
|
|
||||||
|
// 保存助手回复到 DB + Redis
|
||||||
|
var assistantMessage = new ChatMessage
|
||||||
|
{
|
||||||
|
ConversationId = conversationId,
|
||||||
|
Role = ChatRole.Assistant,
|
||||||
|
Content = replyText
|
||||||
|
};
|
||||||
|
await db.ChatMessages.AddAsync(assistantMessage, ct);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
await cache.AppendMessageAsync(conversationId,
|
||||||
|
new CachedChatMessage(assistantMessage.Id, ChatRole.Assistant.ToString(), assistantMessage.Content, null, assistantMessage.CreatedAt), ct);
|
||||||
|
|
||||||
|
// 发送结束标记
|
||||||
|
var doneData = JsonSerializer.Serialize(new { messageId = assistantMessage.Id });
|
||||||
|
await HttpContext.Response.WriteAsync($"event: done\ndata: {doneData}\n\n", ct);
|
||||||
|
await HttpContext.Response.Body.FlushAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record StreamMessageRequest(string Content);
|
||||||
|
|
||||||
|
/// <summary>流式发送消息请求校验。</summary>
|
||||||
|
public class StreamMessageRequestValidator : Validator<StreamMessageRequest>
|
||||||
|
{
|
||||||
|
public StreamMessageRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Content)
|
||||||
|
.NotEmpty().WithMessage("消息内容不能为空")
|
||||||
|
.MaximumLength(10000).WithMessage("消息内容不能超过 10000 个字符");
|
||||||
|
}
|
||||||
|
}
|
||||||
201
src/RAG.Api/Endpoints/Document/ChunkUploadEndpoint.cs
Normal file
201
src/RAG.Api/Endpoints/Document/ChunkUploadEndpoint.cs
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using FastEndpoints;
|
||||||
|
using MediatR;
|
||||||
|
using RAG.Application.Document.Commands;
|
||||||
|
using RAG.Application.Document.DTOs;
|
||||||
|
|
||||||
|
namespace RAG.Api.Endpoints.Document;
|
||||||
|
|
||||||
|
// ===== 数据模型 =====
|
||||||
|
// 大文件分片上传:把单个大文件切成多个分片独立上传,服务端按 SessionId 聚合后合并。
|
||||||
|
// 解决单次 multipart 上传的大小限制(默认 ~30MB)与断点续传需求。
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分片上传会话状态。一个会话对应一次大文件上传任务,记录目标知识库、文件元信息、临时目录与已收分片索引。
|
||||||
|
/// </summary>
|
||||||
|
public class ChunkUploadSession(string sessionId, Guid knowledgeBaseId, string fileName, long fileSize, int chunkCount, string tempDir)
|
||||||
|
{
|
||||||
|
public string SessionId { get; } = sessionId;
|
||||||
|
public Guid KnowledgeBaseId { get; } = knowledgeBaseId;
|
||||||
|
public string FileName { get; } = fileName;
|
||||||
|
public long FileSize { get; } = fileSize;
|
||||||
|
public int ChunkCount { get; } = chunkCount;
|
||||||
|
public string TempDir { get; } = tempDir;
|
||||||
|
public ConcurrentDictionary<int, string> UploadedChunks { get; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分片上传会话的进程内存储。单机内存方案(ConcurrentDictionary),不适用于多实例部署——
|
||||||
|
/// 横向扩展时需替换为 Redis 等共享存储。会话无 TTL 清理,依赖客户端调用 complete 触发清理。
|
||||||
|
/// </summary>
|
||||||
|
public static class ChunkUploadStore
|
||||||
|
{
|
||||||
|
public static readonly ConcurrentDictionary<string, ChunkUploadSession> Sessions = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 初始化 =====
|
||||||
|
|
||||||
|
public class InitChunkUploadRequest
|
||||||
|
{
|
||||||
|
public string FileName { get; set; } = default!;
|
||||||
|
public long FileSize { get; set; }
|
||||||
|
public int ChunkCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>分片上传初始化请求校验。</summary>
|
||||||
|
public class InitChunkUploadRequestValidator : Validator<InitChunkUploadRequest>
|
||||||
|
{
|
||||||
|
public InitChunkUploadRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.FileName)
|
||||||
|
.NotEmpty().WithMessage("文件名不能为空")
|
||||||
|
.MaximumLength(500).WithMessage("文件名不能超过 500 个字符");
|
||||||
|
|
||||||
|
RuleFor(x => x.FileSize)
|
||||||
|
.GreaterThan(0).WithMessage("文件大小必须大于 0");
|
||||||
|
|
||||||
|
RuleFor(x => x.ChunkCount)
|
||||||
|
.InclusiveBetween(1, 10000).WithMessage("分片数量 ChunkCount 取值范围 1-10000");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class InitChunkUploadEndpoint : Endpoint<InitChunkUploadRequest, ChunkUploadSession>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/knowledge-bases/{KnowledgeBaseId}/documents/chunk-upload/init");
|
||||||
|
AllowAnonymous();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(InitChunkUploadRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var kbId = Route<Guid>("KnowledgeBaseId");
|
||||||
|
var sessionId = Guid.NewGuid().ToString("N");
|
||||||
|
var tempDir = Path.Combine(Path.GetTempPath(), "rag_chunks", sessionId);
|
||||||
|
Directory.CreateDirectory(tempDir);
|
||||||
|
|
||||||
|
var session = new ChunkUploadSession(sessionId, kbId, req.FileName, req.FileSize, req.ChunkCount, tempDir);
|
||||||
|
ChunkUploadStore.Sessions[sessionId] = session;
|
||||||
|
|
||||||
|
await Send.OkAsync(session, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 上传分片 =====
|
||||||
|
|
||||||
|
public class UploadChunkEndpoint : EndpointWithoutRequest<EmptyResponse>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/documents/chunk-upload/{SessionId}/chunks/{ChunkIndex}");
|
||||||
|
AllowAnonymous();
|
||||||
|
AllowFileUploads();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var sessionId = Route<string>("SessionId");
|
||||||
|
var chunkIndex = Route<int>("ChunkIndex");
|
||||||
|
|
||||||
|
if (!ChunkUploadStore.Sessions.TryGetValue(sessionId!, out var session))
|
||||||
|
{
|
||||||
|
HttpContext.Response.StatusCode = 404;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var file = Files.FirstOrDefault();
|
||||||
|
if (file is null)
|
||||||
|
{
|
||||||
|
ThrowError("请上传分片文件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunkPath = Path.Combine(session.TempDir, $"chunk_{chunkIndex}");
|
||||||
|
await using (var stream = System.IO.File.Create(chunkPath))
|
||||||
|
{
|
||||||
|
await file.CopyToAsync(stream, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
session.UploadedChunks.TryAdd(chunkIndex, chunkPath);
|
||||||
|
HttpContext.Response.StatusCode = 200;
|
||||||
|
await HttpContext.Response.WriteAsJsonAsync(new { uploaded = session.UploadedChunks.Count, total = session.ChunkCount }, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 合并完成 =====
|
||||||
|
// 客户端所有分片上传完毕后调用:按分片索引顺序合并为完整文件,再走与单文件上传相同的入库流程。
|
||||||
|
|
||||||
|
public class CompleteChunkUploadRequest
|
||||||
|
{
|
||||||
|
public string? Title { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>分片上传合并完成请求校验。Title 可选(不传则用原文件名)。</summary>
|
||||||
|
public class CompleteChunkUploadRequestValidator : Validator<CompleteChunkUploadRequest>
|
||||||
|
{
|
||||||
|
public CompleteChunkUploadRequestValidator()
|
||||||
|
{
|
||||||
|
When(x => x.Title is not null, () =>
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Title!)
|
||||||
|
.MaximumLength(500).WithMessage("文档标题不能超过 500 个字符");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CompleteChunkUploadEndpoint(IMediator mediator) : Endpoint<CompleteChunkUploadRequest, DocumentDto>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/documents/chunk-upload/{SessionId}/complete");
|
||||||
|
AllowAnonymous();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(CompleteChunkUploadRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var sessionId = Route<string>("SessionId");
|
||||||
|
|
||||||
|
if (!ChunkUploadStore.Sessions.TryGetValue(sessionId!, out var session))
|
||||||
|
{
|
||||||
|
HttpContext.Response.StatusCode = 404;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完整性校验:分片数必须与声明一致,否则拒绝合并避免产出残缺文件
|
||||||
|
if (session.UploadedChunks.Count != session.ChunkCount)
|
||||||
|
{
|
||||||
|
ThrowError($"分片不完整:已上传 {session.UploadedChunks.Count}/{session.ChunkCount}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ext = Path.GetExtension(session.FileName);
|
||||||
|
var mergedPath = Path.Combine(Path.GetTempPath(), $"rag_{Guid.NewGuid()}{ext}");
|
||||||
|
// 按索引顺序拼接分片:分片上传到达顺序不确定,必须按 ChunkIndex 重建原文件
|
||||||
|
await using (var mergedStream = System.IO.File.Create(mergedPath))
|
||||||
|
{
|
||||||
|
for (var i = 0; i < session.ChunkCount; i++)
|
||||||
|
{
|
||||||
|
if (!session.UploadedChunks.TryGetValue(i, out var chunkPath)) continue;
|
||||||
|
await using var chunkStream = System.IO.File.OpenRead(chunkPath);
|
||||||
|
await chunkStream.CopyToAsync(mergedStream, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command 需要 Stream(对接 file-system),打开合并后的文件传入。
|
||||||
|
// 注意:文件流在 Command handler 内会被上传到 file-system,此处不提前释放。
|
||||||
|
var fileStream = System.IO.File.OpenRead(mergedPath);
|
||||||
|
var result = await mediator.Send(new UploadDocumentCommand(
|
||||||
|
session.KnowledgeBaseId,
|
||||||
|
req.Title ?? session.FileName,
|
||||||
|
session.FileName,
|
||||||
|
fileStream,
|
||||||
|
session.FileSize,
|
||||||
|
"application/octet-stream"
|
||||||
|
), ct);
|
||||||
|
|
||||||
|
try { Directory.Delete(session.TempDir, true); } catch { /* ignore */ }
|
||||||
|
ChunkUploadStore.Sessions.TryRemove(sessionId!, out _);
|
||||||
|
|
||||||
|
await Send.OkAsync(result, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/RAG.Api/Endpoints/Document/GetDocumentChunksEndpoint.cs
Normal file
22
src/RAG.Api/Endpoints/Document/GetDocumentChunksEndpoint.cs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using MediatR;
|
||||||
|
using RAG.Application.Document.DTOs;
|
||||||
|
using RAG.Application.Document.Queries;
|
||||||
|
|
||||||
|
namespace RAG.Api.Endpoints.Document;
|
||||||
|
|
||||||
|
public class GetDocumentChunksEndpoint(IMediator mediator) : EndpointWithoutRequest<List<ChunkPreviewDto>>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Get("/documents/{DocumentId}/chunks");
|
||||||
|
AllowAnonymous();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var docId = Route<Guid>("DocumentId");
|
||||||
|
var result = await mediator.Send(new GetDocumentChunksQuery(docId), ct);
|
||||||
|
await Send.OkAsync(result, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/RAG.Api/Endpoints/Document/ListDocumentsEndpoint.cs
Normal file
22
src/RAG.Api/Endpoints/Document/ListDocumentsEndpoint.cs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using MediatR;
|
||||||
|
using RAG.Application.Document.DTOs;
|
||||||
|
using RAG.Application.Document.Queries;
|
||||||
|
|
||||||
|
namespace RAG.Api.Endpoints.Document;
|
||||||
|
|
||||||
|
public class ListDocumentsEndpoint(IMediator mediator) : EndpointWithoutRequest<List<DocumentDto>>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Get("/knowledge-bases/{KnowledgeBaseId}/documents");
|
||||||
|
AllowAnonymous();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var kbId = Route<Guid>("KnowledgeBaseId");
|
||||||
|
var result = await mediator.Send(new GetDocumentsQuery(kbId), ct);
|
||||||
|
await Send.OkAsync(result, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/RAG.Api/Endpoints/Document/ProcessDocumentEndpoint.cs
Normal file
26
src/RAG.Api/Endpoints/Document/ProcessDocumentEndpoint.cs
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using MediatR;
|
||||||
|
using RAG.Application.Document.Commands;
|
||||||
|
using RAG.Application.Document.DTOs;
|
||||||
|
|
||||||
|
namespace RAG.Api.Endpoints.Document;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 触发单文档处理流水线(提取→分块→向量化→入库)。
|
||||||
|
/// 上传后须调用此端点才能让文档进入可检索状态;失败文档也可重复调用重试。
|
||||||
|
/// </summary>
|
||||||
|
public class ProcessDocumentEndpoint(IMediator mediator) : Endpoint<EmptyRequest, ProcessDocumentResponse>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/documents/{DocumentId}/process");
|
||||||
|
AllowAnonymous();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(EmptyRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var docId = Route<Guid>("DocumentId");
|
||||||
|
var result = await mediator.Send(new ProcessDocumentCommand(docId), ct);
|
||||||
|
await Send.OkAsync(result, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using MediatR;
|
||||||
|
using RAG.Application.Document.Commands;
|
||||||
|
using RAG.Application.Document.DTOs;
|
||||||
|
|
||||||
|
namespace RAG.Api.Endpoints.Document;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 批量处理知识库内所有待处理/失败的文档。
|
||||||
|
/// POST /api/knowledge-bases/{KnowledgeBaseId}/process-all
|
||||||
|
/// 返回处理统计(成功/失败/错误明细)。Obsidian 同步后可调用此接口一键入库。
|
||||||
|
/// </summary>
|
||||||
|
public class ProcessKnowledgeBaseEndpoint(IMediator mediator)
|
||||||
|
: Endpoint<EmptyRequest, BatchProcessResponse>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/knowledge-bases/{KnowledgeBaseId}/process-all");
|
||||||
|
AllowAnonymous();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(EmptyRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var kbId = Route<Guid>("KnowledgeBaseId");
|
||||||
|
var result = await mediator.Send(new ProcessKnowledgeBaseCommand(kbId), ct);
|
||||||
|
await Send.OkAsync(result, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/RAG.Api/Endpoints/Document/UploadDocumentEndpoint.cs
Normal file
63
src/RAG.Api/Endpoints/Document/UploadDocumentEndpoint.cs
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using MediatR;
|
||||||
|
using RAG.Application.Document.Commands;
|
||||||
|
using RAG.Application.Document.DTOs;
|
||||||
|
using RAG.Domain.Enums;
|
||||||
|
using DomainExceptions = RAG.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace RAG.Api.Endpoints.Document;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 单文件上传端点(multipart)。文件通过 UploadDocumentCommand 存到文件服务(file-system)的 S3,
|
||||||
|
/// 同时记录元数据。上传后文档处于 Pending,需再调 /documents/{id}/process 才会进入可检索状态。
|
||||||
|
/// </summary>
|
||||||
|
public class UploadDocumentEndpoint(IMediator mediator) : Endpoint<UploadDocumentRequest, DocumentDto>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/knowledge-bases/{KnowledgeBaseId}/documents");
|
||||||
|
AllowAnonymous();
|
||||||
|
AllowFileUploads();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(UploadDocumentRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var kbId = Route<Guid>("KnowledgeBaseId");
|
||||||
|
var file = Files.FirstOrDefault();
|
||||||
|
if (file is null)
|
||||||
|
throw new DomainExceptions.BusinessException("请上传文件");
|
||||||
|
|
||||||
|
// 分块策略覆盖:0=General,1=Heading,2=ParentChild;null=用知识库默认
|
||||||
|
ChunkingMode? overrideMode = req.ChunkingMode switch
|
||||||
|
{
|
||||||
|
>= 0 and <= 2 => (ChunkingMode)req.ChunkingMode.Value,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
// 直接把文件流传给 command,由 command 上传到文件服务 S3(不再存临时目录)
|
||||||
|
await using var stream = file.OpenReadStream();
|
||||||
|
var result = await mediator.Send(new UploadDocumentCommand(kbId, req.Title ?? file.FileName,
|
||||||
|
file.FileName, stream, file.Length, file.ContentType, overrideMode), ct);
|
||||||
|
|
||||||
|
await Send.ResponseAsync(result, 201, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record UploadDocumentRequest(string? Title, int? ChunkingMode = null);
|
||||||
|
|
||||||
|
/// <summary>单文件上传请求校验。Title 可选(不传则用文件名),ChunkingMode 仅当提供时校验取值。</summary>
|
||||||
|
public class UploadDocumentRequestValidator : Validator<UploadDocumentRequest>
|
||||||
|
{
|
||||||
|
public UploadDocumentRequestValidator()
|
||||||
|
{
|
||||||
|
When(x => x.Title is not null, () =>
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Title!)
|
||||||
|
.MaximumLength(500).WithMessage("文档标题不能超过 500 个字符");
|
||||||
|
});
|
||||||
|
|
||||||
|
RuleFor(x => x.ChunkingMode)
|
||||||
|
.InclusiveBetween(0, 2).When(x => x.ChunkingMode.HasValue)
|
||||||
|
.WithMessage("分块模式 ChunkingMode 取值 0=General,1=Heading,2=ParentChild");
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/RAG.Api/Endpoints/Embedding/EmbedBatchEndpoint.cs
Normal file
38
src/RAG.Api/Endpoints/Embedding/EmbedBatchEndpoint.cs
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using MediatR;
|
||||||
|
using RAG.Application.Embedding.Commands;
|
||||||
|
using RAG.Application.Embedding.DTOs;
|
||||||
|
|
||||||
|
namespace RAG.Api.Endpoints.Embedding;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 批量文本向量化端点。供外部脚本/工具把文本批量灌入向量库时使用,
|
||||||
|
/// 比逐条调用 EmbedText 效率高(单次请求复用模型会话)。
|
||||||
|
/// </summary>
|
||||||
|
public class EmbedBatchEndpoint(IMediator mediator) : Endpoint<EmbedBatchRequest, EmbeddingBatchResponse>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/embeddings/batch");
|
||||||
|
AllowAnonymous();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(EmbedBatchRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new EmbedBatchCommand(req.Texts), ct);
|
||||||
|
await Send.OkAsync(result, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record EmbedBatchRequest(List<string> Texts);
|
||||||
|
|
||||||
|
/// <summary>批量文本向量化请求校验。</summary>
|
||||||
|
public class EmbedBatchRequestValidator : Validator<EmbedBatchRequest>
|
||||||
|
{
|
||||||
|
public EmbedBatchRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Texts)
|
||||||
|
.NotEmpty().WithMessage("文本列表不能为空")
|
||||||
|
.Must(t => t.Count <= 100).WithMessage("批量文本不能超过 100 条");
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/RAG.Api/Endpoints/Embedding/EmbedTextEndpoint.cs
Normal file
37
src/RAG.Api/Endpoints/Embedding/EmbedTextEndpoint.cs
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using MediatR;
|
||||||
|
using RAG.Application.Embedding.Commands;
|
||||||
|
using RAG.Application.Embedding.DTOs;
|
||||||
|
|
||||||
|
namespace RAG.Api.Endpoints.Embedding;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 单条文本向量化调试端点。返回向量与维度,主要用于验证 embedding 模型配置与维度一致性。
|
||||||
|
/// </summary>
|
||||||
|
public class EmbedTextEndpoint(IMediator mediator) : Endpoint<EmbedTextRequest, EmbeddingResponse>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/embeddings");
|
||||||
|
AllowAnonymous();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(EmbedTextRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new EmbedTextCommand(req.Text), ct);
|
||||||
|
await Send.OkAsync(result, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record EmbedTextRequest(string Text);
|
||||||
|
|
||||||
|
/// <summary>单条文本向量化请求校验。</summary>
|
||||||
|
public class EmbedTextRequestValidator : Validator<EmbedTextRequest>
|
||||||
|
{
|
||||||
|
public EmbedTextRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Text)
|
||||||
|
.NotEmpty().WithMessage("文本内容不能为空")
|
||||||
|
.MaximumLength(10000).WithMessage("单条文本不能超过 10000 个字符");
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/RAG.Api/Endpoints/KnowledgeBase/CreateKBEndpoint.cs
Normal file
82
src/RAG.Api/Endpoints/KnowledgeBase/CreateKBEndpoint.cs
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using MediatR;
|
||||||
|
using RAG.Application.KnowledgeBase.Commands;
|
||||||
|
using RAG.Application.KnowledgeBase.DTOs;
|
||||||
|
|
||||||
|
namespace RAG.Api.Endpoints.KnowledgeBase;
|
||||||
|
|
||||||
|
public class CreateKBEndpoint(IMediator mediator) : Endpoint<CreateKnowledgeBaseRequest, KnowledgeBaseDto>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/knowledge-bases");
|
||||||
|
AllowAnonymous();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(CreateKnowledgeBaseRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(
|
||||||
|
new CreateKnowledgeBaseCommand(
|
||||||
|
req.Name, req.Description, req.EmbeddingModel, req.ChunkSize, req.ChunkOverlap,
|
||||||
|
req.ChunkingMode, req.Separator, req.RetrievalMode, req.RerankStrategy,
|
||||||
|
req.RetrievalTopK, req.ContextTopK, req.SimilarityThreshold, req.VectorWeight),
|
||||||
|
ct);
|
||||||
|
await Send.ResponseAsync(result, 201, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建知识库请求校验。
|
||||||
|
/// 名称必填;数值参数(分块/检索配置)限定合理取值范围,避免传入非法值导致下游处理异常。
|
||||||
|
/// </summary>
|
||||||
|
public class CreateKnowledgeBaseRequestValidator : Validator<CreateKnowledgeBaseRequest>
|
||||||
|
{
|
||||||
|
public CreateKnowledgeBaseRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Name)
|
||||||
|
.NotEmpty().WithMessage("知识库名称不能为空")
|
||||||
|
.MaximumLength(200).WithMessage("名称不能超过 200 个字符");
|
||||||
|
|
||||||
|
When(x => x.Description is not null, () =>
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Description!)
|
||||||
|
.MaximumLength(1000).WithMessage("描述不能超过 1000 个字符");
|
||||||
|
});
|
||||||
|
|
||||||
|
RuleFor(x => x.ChunkSize)
|
||||||
|
.InclusiveBetween(100, 8000).When(x => x.ChunkSize.HasValue)
|
||||||
|
.WithMessage("分块大小 ChunkSize 取值范围 100-8000");
|
||||||
|
|
||||||
|
RuleFor(x => x.ChunkOverlap)
|
||||||
|
.InclusiveBetween(0, 1000).When(x => x.ChunkOverlap.HasValue)
|
||||||
|
.WithMessage("分块重叠 ChunkOverlap 取值范围 0-1000");
|
||||||
|
|
||||||
|
RuleFor(x => x.ChunkingMode)
|
||||||
|
.InclusiveBetween(0, 2).When(x => x.ChunkingMode.HasValue)
|
||||||
|
.WithMessage("分块模式 ChunkingMode 取值 0=General,1=Heading,2=ParentChild");
|
||||||
|
|
||||||
|
RuleFor(x => x.RetrievalMode)
|
||||||
|
.InclusiveBetween(0, 2).When(x => x.RetrievalMode.HasValue)
|
||||||
|
.WithMessage("检索模式 RetrievalMode 取值范围 0-2");
|
||||||
|
|
||||||
|
RuleFor(x => x.RerankStrategy)
|
||||||
|
.InclusiveBetween(0, 2).When(x => x.RerankStrategy.HasValue)
|
||||||
|
.WithMessage("重排策略 RerankStrategy 取值范围 0-2");
|
||||||
|
|
||||||
|
RuleFor(x => x.RetrievalTopK)
|
||||||
|
.InclusiveBetween(1, 50).When(x => x.RetrievalTopK.HasValue)
|
||||||
|
.WithMessage("检索数量 RetrievalTopK 取值范围 1-50");
|
||||||
|
|
||||||
|
RuleFor(x => x.ContextTopK)
|
||||||
|
.InclusiveBetween(1, 50).When(x => x.ContextTopK.HasValue)
|
||||||
|
.WithMessage("上下文数量 ContextTopK 取值范围 1-50");
|
||||||
|
|
||||||
|
RuleFor(x => x.SimilarityThreshold)
|
||||||
|
.InclusiveBetween(0, 1).When(x => x.SimilarityThreshold.HasValue)
|
||||||
|
.WithMessage("相似度阈值 SimilarityThreshold 取值范围 0-1");
|
||||||
|
|
||||||
|
RuleFor(x => x.VectorWeight)
|
||||||
|
.InclusiveBetween(0, 1).When(x => x.VectorWeight.HasValue)
|
||||||
|
.WithMessage("向量权重 VectorWeight 取值范围 0-1");
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/RAG.Api/Endpoints/KnowledgeBase/DeleteKBEndpoint.cs
Normal file
31
src/RAG.Api/Endpoints/KnowledgeBase/DeleteKBEndpoint.cs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using MediatR;
|
||||||
|
using RAG.Application.KnowledgeBase.Commands;
|
||||||
|
|
||||||
|
namespace RAG.Api.Endpoints.KnowledgeBase;
|
||||||
|
|
||||||
|
public class DeleteKBEndpoint(IMediator mediator) : Endpoint<DeleteKBEndpointRequest>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Delete("/knowledge-bases/{Id}");
|
||||||
|
AllowAnonymous();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(DeleteKBEndpointRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await mediator.Send(new DeleteKnowledgeBaseCommand(req.Id), ct);
|
||||||
|
await Send.OkAsync(true, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record DeleteKBEndpointRequest(Guid Id);
|
||||||
|
|
||||||
|
/// <summary>删除知识库请求校验(Id 由路由提供)。</summary>
|
||||||
|
public class DeleteKBEndpointRequestValidator : Validator<DeleteKBEndpointRequest>
|
||||||
|
{
|
||||||
|
public DeleteKBEndpointRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Id).NotEmpty().WithMessage("知识库 ID 不能为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/RAG.Api/Endpoints/KnowledgeBase/GetKBsEndpoint.cs
Normal file
21
src/RAG.Api/Endpoints/KnowledgeBase/GetKBsEndpoint.cs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using MediatR;
|
||||||
|
using RAG.Application.KnowledgeBase.DTOs;
|
||||||
|
using RAG.Application.KnowledgeBase.Queries;
|
||||||
|
|
||||||
|
namespace RAG.Api.Endpoints.KnowledgeBase;
|
||||||
|
|
||||||
|
public class GetKBsEndpoint(IMediator mediator) : EndpointWithoutRequest<List<KnowledgeBaseDto>>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Get("/knowledge-bases");
|
||||||
|
AllowAnonymous();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new GetKnowledgeBasesQuery(), ct);
|
||||||
|
await Send.OkAsync(result, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/RAG.Api/Endpoints/KnowledgeBase/UpdateKBEndpoint.cs
Normal file
89
src/RAG.Api/Endpoints/KnowledgeBase/UpdateKBEndpoint.cs
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using MediatR;
|
||||||
|
using RAG.Application.KnowledgeBase.Commands;
|
||||||
|
using RAG.Application.KnowledgeBase.DTOs;
|
||||||
|
|
||||||
|
namespace RAG.Api.Endpoints.KnowledgeBase;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新知识库配置。
|
||||||
|
/// PUT /api/knowledge-bases/{KnowledgeBaseId}
|
||||||
|
/// 仅更新请求体中非 null 的字段。修改分块/检索参数后需手动调用 process-all 重新处理文档。
|
||||||
|
/// </summary>
|
||||||
|
public class UpdateKBEndpoint(IMediator mediator)
|
||||||
|
: Endpoint<UpdateKnowledgeBaseRequest, KnowledgeBaseDto>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Put("/knowledge-bases/{KnowledgeBaseId}");
|
||||||
|
AllowAnonymous();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(UpdateKnowledgeBaseRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var kbId = Route<Guid>("KnowledgeBaseId");
|
||||||
|
var result = await mediator.Send(
|
||||||
|
new UpdateKnowledgeBaseCommand(
|
||||||
|
kbId, req.Name, req.Description, req.ChunkSize, req.ChunkOverlap,
|
||||||
|
req.ChunkingMode, req.Separator, req.RetrievalMode, req.RerankStrategy,
|
||||||
|
req.RetrievalTopK, req.ContextTopK, req.SimilarityThreshold, req.VectorWeight),
|
||||||
|
ct);
|
||||||
|
await Send.OkAsync(result, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>更新知识库请求校验。仅校验请求体中提供的字段(非 null 时才校验)。</summary>
|
||||||
|
public class UpdateKnowledgeBaseRequestValidator : Validator<UpdateKnowledgeBaseRequest>
|
||||||
|
{
|
||||||
|
public UpdateKnowledgeBaseRequestValidator()
|
||||||
|
{
|
||||||
|
When(x => x.Name is not null, () =>
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Name!)
|
||||||
|
.NotEmpty().WithMessage("知识库名称不能为空")
|
||||||
|
.MaximumLength(200).WithMessage("名称不能超过 200 个字符");
|
||||||
|
});
|
||||||
|
|
||||||
|
When(x => x.Description is not null, () =>
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Description!)
|
||||||
|
.MaximumLength(1000).WithMessage("描述不能超过 1000 个字符");
|
||||||
|
});
|
||||||
|
|
||||||
|
RuleFor(x => x.ChunkSize)
|
||||||
|
.InclusiveBetween(100, 8000).When(x => x.ChunkSize.HasValue)
|
||||||
|
.WithMessage("分块大小 ChunkSize 取值范围 100-8000");
|
||||||
|
|
||||||
|
RuleFor(x => x.ChunkOverlap)
|
||||||
|
.InclusiveBetween(0, 1000).When(x => x.ChunkOverlap.HasValue)
|
||||||
|
.WithMessage("分块重叠 ChunkOverlap 取值范围 0-1000");
|
||||||
|
|
||||||
|
RuleFor(x => x.ChunkingMode)
|
||||||
|
.InclusiveBetween(0, 2).When(x => x.ChunkingMode.HasValue)
|
||||||
|
.WithMessage("分块模式 ChunkingMode 取值 0=General,1=Heading,2=ParentChild");
|
||||||
|
|
||||||
|
RuleFor(x => x.RetrievalMode)
|
||||||
|
.InclusiveBetween(0, 2).When(x => x.RetrievalMode.HasValue)
|
||||||
|
.WithMessage("检索模式 RetrievalMode 取值范围 0-2");
|
||||||
|
|
||||||
|
RuleFor(x => x.RerankStrategy)
|
||||||
|
.InclusiveBetween(0, 2).When(x => x.RerankStrategy.HasValue)
|
||||||
|
.WithMessage("重排策略 RerankStrategy 取值范围 0-2");
|
||||||
|
|
||||||
|
RuleFor(x => x.RetrievalTopK)
|
||||||
|
.InclusiveBetween(1, 50).When(x => x.RetrievalTopK.HasValue)
|
||||||
|
.WithMessage("检索数量 RetrievalTopK 取值范围 1-50");
|
||||||
|
|
||||||
|
RuleFor(x => x.ContextTopK)
|
||||||
|
.InclusiveBetween(1, 50).When(x => x.ContextTopK.HasValue)
|
||||||
|
.WithMessage("上下文数量 ContextTopK 取值范围 1-50");
|
||||||
|
|
||||||
|
RuleFor(x => x.SimilarityThreshold)
|
||||||
|
.InclusiveBetween(0, 1).When(x => x.SimilarityThreshold.HasValue)
|
||||||
|
.WithMessage("相似度阈值 SimilarityThreshold 取值范围 0-1");
|
||||||
|
|
||||||
|
RuleFor(x => x.VectorWeight)
|
||||||
|
.InclusiveBetween(0, 1).When(x => x.VectorWeight.HasValue)
|
||||||
|
.WithMessage("向量权重 VectorWeight 取值范围 0-1");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,4 @@
|
|||||||
using RAG.Domain.Common;
|
using RAG.Domain.Common;
|
||||||
using RAG.Domain.Exceptions;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using RAG.Application.Menus.DTOs;
|
using RAG.Application.Menus.DTOs;
|
||||||
|
|||||||
180
src/RAG.Api/Endpoints/Notifications/NotificationEndpoints.cs
Normal file
180
src/RAG.Api/Endpoints/Notifications/NotificationEndpoints.cs
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using MediatR;
|
||||||
|
using RAG.Application.Notifications.Commands;
|
||||||
|
using RAG.Application.Notifications.DTOs;
|
||||||
|
using RAG.Application.Notifications.Queries;
|
||||||
|
using RAG.Domain.Common;
|
||||||
|
using RAG.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace RAG.Api.Endpoints.Notifications;
|
||||||
|
|
||||||
|
/// <summary>从当前用户上下文解析接收人 Guid(sub claim 即用户 Id)。</summary>
|
||||||
|
internal static class NotificationEndpointsExtensions
|
||||||
|
{
|
||||||
|
public static Guid GetRecipientId(this ICurrentUserContext ctx)
|
||||||
|
{
|
||||||
|
var id = ctx.GetRequiredUserId();
|
||||||
|
return Guid.Parse(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 发布通知(业务系统 / 管理员推送) ============
|
||||||
|
|
||||||
|
public record PublishNotificationRequest
|
||||||
|
{
|
||||||
|
public string Type { get; init; } = "system";
|
||||||
|
public string Title { get; init; } = default!;
|
||||||
|
public string Content { get; init; } = default!;
|
||||||
|
public string Source { get; init; } = "system";
|
||||||
|
public string? RelatedId { get; init; }
|
||||||
|
public string? RelatedType { get; init; }
|
||||||
|
/// <summary>精确接收人用户 ID 列表(与 RecipientRoles 二选一或并存)。</summary>
|
||||||
|
public List<Guid>? RecipientUserIds { get; init; }
|
||||||
|
/// <summary>接收角色名列表(中心按角色展开为用户)。</summary>
|
||||||
|
public List<string>? RecipientRoles { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发布通知请求校验。
|
||||||
|
/// 接收人范围(RecipientUserIds / RecipientRoles)可同时为空——表示全量广播,
|
||||||
|
/// 具体扇出语义由 PublishNotificationCommand 决定,此处仅校验必填字段与长度。
|
||||||
|
/// </summary>
|
||||||
|
public class PublishNotificationRequestValidator : Validator<PublishNotificationRequest>
|
||||||
|
{
|
||||||
|
public PublishNotificationRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Title)
|
||||||
|
.NotEmpty().WithMessage("通知标题不能为空")
|
||||||
|
.MaximumLength(200).WithMessage("通知标题不能超过 200 个字符");
|
||||||
|
|
||||||
|
RuleFor(x => x.Content)
|
||||||
|
.NotEmpty().WithMessage("通知内容不能为空")
|
||||||
|
.MaximumLength(5000).WithMessage("通知内容不能超过 5000 个字符");
|
||||||
|
|
||||||
|
RuleFor(x => x.Type)
|
||||||
|
.MaximumLength(50).WithMessage("通知类型 Type 长度不能超过 50 个字符");
|
||||||
|
|
||||||
|
RuleFor(x => x.Source)
|
||||||
|
.MaximumLength(50).WithMessage("通知来源 Source 长度不能超过 50 个字符");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PublishNotificationEndpoint(IMediator mediator) : Endpoint<PublishNotificationRequest, int>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/notifications/publish");
|
||||||
|
Permissions("notification:publish");
|
||||||
|
Summary(s => s.Summary = "发布通知(Fanout 扇出,业务系统只需推一条消息)");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(PublishNotificationRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// 标题/内容非空由 PublishNotificationRequestValidator 拦截,此处直接扇出
|
||||||
|
var count = await mediator.Send(new PublishNotificationCommand(
|
||||||
|
req.Type, req.Title, req.Content, req.Source,
|
||||||
|
req.RelatedId, req.RelatedType,
|
||||||
|
req.RecipientUserIds, req.RecipientRoles), ct);
|
||||||
|
await Send.OkAsync(count, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 查询当前用户通知列表 ============
|
||||||
|
|
||||||
|
public record GetNotificationsRequest
|
||||||
|
{
|
||||||
|
public bool UnreadOnly { get; init; }
|
||||||
|
public int PageIndex { get; init; } = 1;
|
||||||
|
public int PageSize { get; init; } = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>查询通知列表请求校验(分页参数取值范围)。</summary>
|
||||||
|
public class GetNotificationsRequestValidator : Validator<GetNotificationsRequest>
|
||||||
|
{
|
||||||
|
public GetNotificationsRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.PageIndex)
|
||||||
|
.InclusiveBetween(1, 1000).WithMessage("页码 PageIndex 取值范围 1-1000");
|
||||||
|
|
||||||
|
RuleFor(x => x.PageSize)
|
||||||
|
.InclusiveBetween(1, 100).WithMessage("每页数量 PageSize 取值范围 1-100");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GetNotificationsEndpoint(IMediator mediator, ICurrentUserContext userContext)
|
||||||
|
: Endpoint<GetNotificationsRequest, PagedResult<NotificationDto>>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Get("/notifications");
|
||||||
|
Summary(s => s.Summary = "获取当前用户的通知列表(分页,可只看未读)");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(GetNotificationsRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = userContext.GetRecipientId();
|
||||||
|
var result = await mediator.Send(
|
||||||
|
new GetNotificationsQuery(userId, req.UnreadOnly, req.PageIndex, req.PageSize), ct);
|
||||||
|
await Send.OkAsync(result, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 未读数(铃铛角标) ============
|
||||||
|
|
||||||
|
public class GetUnreadNotificationCountEndpoint(IMediator mediator, ICurrentUserContext userContext)
|
||||||
|
: EndpointWithoutRequest<int>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Get("/notifications/unread-count");
|
||||||
|
Summary(s => s.Summary = "获取当前用户未读通知数(前端铃铛角标)");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = userContext.GetRecipientId();
|
||||||
|
var count = await mediator.Send(new GetUnreadNotificationCountQuery(userId), ct);
|
||||||
|
await Send.OkAsync(count, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 标记单条已读 ============
|
||||||
|
|
||||||
|
public class MarkNotificationReadEndpoint(IMediator mediator, ICurrentUserContext userContext)
|
||||||
|
: EndpointWithoutRequest
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/notifications/{Id}/read");
|
||||||
|
Summary(s => s.Summary = "标记单条通知为已读");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var id = Route<Guid>("Id");
|
||||||
|
var userId = userContext.GetRecipientId();
|
||||||
|
var ok = await mediator.Send(new MarkNotificationReadCommand(id, userId), ct);
|
||||||
|
if (!ok)
|
||||||
|
throw new NotFoundException("通知不存在");
|
||||||
|
await Send.OkAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 标记全部已读 ============
|
||||||
|
|
||||||
|
public class MarkAllNotificationsReadEndpoint(IMediator mediator, ICurrentUserContext userContext)
|
||||||
|
: EndpointWithoutRequest<int>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/notifications/read-all");
|
||||||
|
Summary(s => s.Summary = "标记当前用户所有通知为已读");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = userContext.GetRecipientId();
|
||||||
|
var count = await mediator.Send(new MarkAllNotificationsReadCommand(userId), ct);
|
||||||
|
await Send.OkAsync(count, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/RAG.Api/Endpoints/Obsidian/SyncObsidianVaultEndpoint.cs
Normal file
53
src/RAG.Api/Endpoints/Obsidian/SyncObsidianVaultEndpoint.cs
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using MediatR;
|
||||||
|
using RAG.Application.Obsidian.Commands;
|
||||||
|
using RAG.Application.Obsidian.DTOs;
|
||||||
|
using RAG.Domain.Enums;
|
||||||
|
|
||||||
|
namespace RAG.Api.Endpoints.Obsidian;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 同步本地 Obsidian vault 目录到知识库。
|
||||||
|
/// POST /api/knowledge-bases/{KnowledgeBaseId}/obsidian/sync
|
||||||
|
/// 请求体:{ "vaultDirectory": "/path/to/vault", "chunkingMode": 1 }
|
||||||
|
/// chunkingMode: 0=General,1=Heading,2=ParentChild;不传=用知识库默认。
|
||||||
|
/// 返回同步统计(新增/更新/跳过)。同步后的文档需调用处理接口完成切分与向量化。
|
||||||
|
/// </summary>
|
||||||
|
public class SyncObsidianVaultEndpoint(IMediator mediator)
|
||||||
|
: Endpoint<ObsidianSyncRequest, ObsidianSyncResult>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/knowledge-bases/{KnowledgeBaseId}/obsidian/sync");
|
||||||
|
AllowAnonymous();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(ObsidianSyncRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var kbId = Route<Guid>("KnowledgeBaseId");
|
||||||
|
ChunkingMode? overrideMode = req.ChunkingMode is >= 0 and <= 2
|
||||||
|
? (ChunkingMode)req.ChunkingMode.Value
|
||||||
|
: null;
|
||||||
|
var result = await mediator.Send(new SyncObsidianVaultCommand(kbId, req.VaultDirectory, overrideMode), ct);
|
||||||
|
await Send.OkAsync(result, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Obsidian vault 同步请求校验。
|
||||||
|
/// 注意:目录存在性属于 IO 层校验,留给 handler 处理(避免 validator 内做磁盘 IO);
|
||||||
|
/// 此处仅校验非空、长度与枚举取值。
|
||||||
|
/// </summary>
|
||||||
|
public class ObsidianSyncRequestValidator : Validator<ObsidianSyncRequest>
|
||||||
|
{
|
||||||
|
public ObsidianSyncRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.VaultDirectory)
|
||||||
|
.NotEmpty().WithMessage("Vault 目录不能为空")
|
||||||
|
.MaximumLength(1000).WithMessage("Vault 目录路径不能超过 1000 个字符");
|
||||||
|
|
||||||
|
RuleFor(x => x.ChunkingMode)
|
||||||
|
.InclusiveBetween(0, 2).When(x => x.ChunkingMode.HasValue)
|
||||||
|
.WithMessage("分块模式 ChunkingMode 取值 0=General,1=Heading,2=ParentChild");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,6 +5,10 @@ using RAG.Application.Permissions.Queries;
|
|||||||
|
|
||||||
namespace RAG.Api.Endpoints.Permissions;
|
namespace RAG.Api.Endpoints.Permissions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取全部权限列表(供角色分配权限页选择)。
|
||||||
|
/// 需 permission:read 权限——权限码本身是敏感信息,仅授权用户可见。
|
||||||
|
/// </summary>
|
||||||
public class GetPermissionListEndpoint(IMediator mediator) : EndpointWithoutRequest<List<PermissionDto>>
|
public class GetPermissionListEndpoint(IMediator mediator) : EndpointWithoutRequest<List<PermissionDto>>
|
||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
|
|||||||
40
src/RAG.Api/Endpoints/RAG/RAGQueryEndpoint.cs
Normal file
40
src/RAG.Api/Endpoints/RAG/RAGQueryEndpoint.cs
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using MediatR;
|
||||||
|
using RAG.Application.RagQA.Commands;
|
||||||
|
using RAG.Application.RagQA.DTOs;
|
||||||
|
|
||||||
|
namespace RAG.Api.Endpoints.RAG;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RAG 一次性问答端点(非流式)。返回完整答案 + 命中的来源片段,
|
||||||
|
/// 适合轻量查询或非交互场景;交互式前端优先用 /rag/stream 获得流式体验。
|
||||||
|
/// </summary>
|
||||||
|
public class RAGQueryEndpoint(IMediator mediator) : Endpoint<RAGQueryRequest, RAGQueryResponse>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/rag/query");
|
||||||
|
AllowAnonymous();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(RAGQueryRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new RAGQueryCommand(req.KnowledgeBaseId, req.Question), ct);
|
||||||
|
await Send.OkAsync(result, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RAG 问答请求校验。/rag/query 与 /rag/stream 共用同一请求 DTO,故此校验对两个端点同时生效。
|
||||||
|
/// </summary>
|
||||||
|
public class RAGQueryRequestValidator : Validator<RAGQueryRequest>
|
||||||
|
{
|
||||||
|
public RAGQueryRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.KnowledgeBaseId).NotEmpty().WithMessage("知识库 ID 不能为空");
|
||||||
|
|
||||||
|
RuleFor(x => x.Question)
|
||||||
|
.NotEmpty().WithMessage("问题不能为空")
|
||||||
|
.MaximumLength(1000).WithMessage("问题不能超过 1000 个字符");
|
||||||
|
}
|
||||||
|
}
|
||||||
85
src/RAG.Api/Endpoints/RAG/RAGStreamEndpoint.cs
Normal file
85
src/RAG.Api/Endpoints/RAG/RAGStreamEndpoint.cs
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FastEndpoints;
|
||||||
|
using RAG.Application.RagQA.DTOs;
|
||||||
|
using RAG.Domain.Exceptions;
|
||||||
|
using RAG.Domain.Interfaces;
|
||||||
|
using RAG.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace RAG.Api.Endpoints.RAG;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RAG 流式问答端点,以 Server-Sent Events 推送。
|
||||||
|
/// 协议分三段:1) event:sources 推送命中的来源片段(前端即时展示引用);2) data: 流式推送 LLM 生成内容;
|
||||||
|
/// 3) data:[DONE] 标记结束。被 ApiResponseMiddleware 通过 IsStreamEndpoint 排除包裹,直接透传字节流。
|
||||||
|
/// </summary>
|
||||||
|
public class RAGStreamEndpoint(
|
||||||
|
RagDbContext db,
|
||||||
|
IRagRetrievalService retrievalService,
|
||||||
|
IAIChatAgent chatAgent)
|
||||||
|
: Endpoint<RAGQueryRequest>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/rag/stream");
|
||||||
|
AllowAnonymous();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(RAGQueryRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var kb = await db.KnowledgeBases.FindAsync([req.KnowledgeBaseId], ct)
|
||||||
|
?? throw new NotFoundException("知识库不存在");
|
||||||
|
|
||||||
|
// 检索流水线(策略由知识库配置决定)
|
||||||
|
var hits = await retrievalService.RetrieveAsync(kb, req.Question, ct);
|
||||||
|
|
||||||
|
// SSE:声明流式响应头,必须先于任何 body 写入
|
||||||
|
HttpContext.Response.ContentType = "text/event-stream";
|
||||||
|
HttpContext.Response.Headers.CacheControl = "no-cache";
|
||||||
|
HttpContext.Response.Headers.Connection = "keep-alive";
|
||||||
|
|
||||||
|
if (hits.Count == 0)
|
||||||
|
{
|
||||||
|
var noData = JsonSerializer.Serialize(new { content = "未在知识库中找到相关内容。" });
|
||||||
|
await HttpContext.Response.WriteAsync($"data: {noData}\n\n", ct);
|
||||||
|
await HttpContext.Response.WriteAsync("data: [DONE]\n\n", ct);
|
||||||
|
await HttpContext.Response.Body.FlushAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先推送命中的来源片段
|
||||||
|
var sources = hits.Select(h => new SourceChunk(
|
||||||
|
h.DocumentId,
|
||||||
|
h.DocumentTitle,
|
||||||
|
h.ContextContent.Length > 200 ? h.ContextContent[..200] + "..." : h.ContextContent,
|
||||||
|
Math.Round(h.Score, 4)
|
||||||
|
)).ToList();
|
||||||
|
var sourcesData = JsonSerializer.Serialize(new { sources });
|
||||||
|
await HttpContext.Response.WriteAsync($"event: sources\ndata: {sourcesData}\n\n", ct);
|
||||||
|
await HttpContext.Response.Body.FlushAsync(ct);
|
||||||
|
|
||||||
|
// 构建 prompt 并流式生成
|
||||||
|
var contextBuilder = new StringBuilder();
|
||||||
|
contextBuilder.AppendLine("以下是检索到的相关上下文:");
|
||||||
|
for (var i = 0; i < hits.Count; i++)
|
||||||
|
contextBuilder.AppendLine($"\n--- 上下文 {i + 1} ---\n{hits[i].ContextContent}");
|
||||||
|
|
||||||
|
var prompt = $"""
|
||||||
|
{contextBuilder}
|
||||||
|
|
||||||
|
基于以上上下文,请回答以下问题。如果上下文中没有相关信息,请如实说明。
|
||||||
|
|
||||||
|
问题:{req.Question}
|
||||||
|
""";
|
||||||
|
|
||||||
|
await foreach (var chunk in chatAgent.RunStreamingAsync(prompt, ct))
|
||||||
|
{
|
||||||
|
var sseData = JsonSerializer.Serialize(new { content = chunk });
|
||||||
|
await HttpContext.Response.WriteAsync($"data: {sseData}\n\n", ct);
|
||||||
|
await HttpContext.Response.Body.FlushAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
await HttpContext.Response.WriteAsync("data: [DONE]\n\n", ct);
|
||||||
|
await HttpContext.Response.Body.FlushAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,6 +5,10 @@ using RAG.Application.Roles.DTOs;
|
|||||||
|
|
||||||
namespace RAG.Api.Endpoints.Roles;
|
namespace RAG.Api.Endpoints.Roles;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 给角色分配权限(敏感操作)。需 role:assign-permission 权限,全量覆盖语义。
|
||||||
|
/// 直接影响登录后下发的 permissions claim,故所有持有该角色的用户都受影响。
|
||||||
|
/// </summary>
|
||||||
public class AssignPermissionsEndpoint(IMediator mediator) : Endpoint<AssignPermissionsEndpointRequest, RoleDto>
|
public class AssignPermissionsEndpoint(IMediator mediator) : Endpoint<AssignPermissionsEndpointRequest, RoleDto>
|
||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
@ -21,3 +25,14 @@ public class AssignPermissionsEndpoint(IMediator mediator) : Endpoint<AssignPerm
|
|||||||
}
|
}
|
||||||
|
|
||||||
public record AssignPermissionsEndpointRequest(Guid RoleId, List<Guid> PermissionIds);
|
public record AssignPermissionsEndpointRequest(Guid RoleId, List<Guid> PermissionIds);
|
||||||
|
|
||||||
|
/// <summary>给角色分配权限请求校验。</summary>
|
||||||
|
public class AssignPermissionsEndpointRequestValidator : Validator<AssignPermissionsEndpointRequest>
|
||||||
|
{
|
||||||
|
public AssignPermissionsEndpointRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.RoleId).NotEmpty().WithMessage("角色 ID 不能为空");
|
||||||
|
RuleFor(x => x.PermissionIds)
|
||||||
|
.NotEmpty().WithMessage("权限列表不能为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -19,3 +19,20 @@ public class CreateRoleEndpoint(IMediator mediator) : Endpoint<CreateRoleRequest
|
|||||||
await Send.ResponseAsync(result, 201, ct);
|
await Send.ResponseAsync(result, 201, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>创建角色请求校验。</summary>
|
||||||
|
public class CreateRoleRequestValidator : Validator<CreateRoleRequest>
|
||||||
|
{
|
||||||
|
public CreateRoleRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Name)
|
||||||
|
.NotEmpty().WithMessage("角色名称不能为空")
|
||||||
|
.Length(2, 50).WithMessage("角色名称长度 2-50 个字符");
|
||||||
|
|
||||||
|
When(x => x.Description is not null, () =>
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Description!)
|
||||||
|
.MaximumLength(500).WithMessage("角色描述长度不能超过 500 个字符");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -15,8 +15,17 @@ public class DeleteRoleEndpoint(IMediator mediator) : Endpoint<DeleteRoleEndpoin
|
|||||||
public override async Task HandleAsync(DeleteRoleEndpointRequest req, CancellationToken ct)
|
public override async Task HandleAsync(DeleteRoleEndpointRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await mediator.Send(new DeleteRoleCommand(req.Id), ct);
|
await mediator.Send(new DeleteRoleCommand(req.Id), ct);
|
||||||
HttpContext.Response.StatusCode = 204;
|
await Send.OkAsync(true, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public record DeleteRoleEndpointRequest(Guid Id);
|
public record DeleteRoleEndpointRequest(Guid Id);
|
||||||
|
|
||||||
|
/// <summary>删除角色请求校验(Id 由路由提供)。</summary>
|
||||||
|
public class DeleteRoleEndpointRequestValidator : Validator<DeleteRoleEndpointRequest>
|
||||||
|
{
|
||||||
|
public DeleteRoleEndpointRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Id).NotEmpty().WithMessage("角色 ID 不能为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -21,3 +21,12 @@ public class GetRoleByIdEndpoint(IMediator mediator) : Endpoint<GetRoleByIdReque
|
|||||||
}
|
}
|
||||||
|
|
||||||
public record GetRoleByIdRequest(Guid Id);
|
public record GetRoleByIdRequest(Guid Id);
|
||||||
|
|
||||||
|
/// <summary>根据角色 ID 查询请求校验(Id 由路由提供)。</summary>
|
||||||
|
public class GetRoleByIdRequestValidator : Validator<GetRoleByIdRequest>
|
||||||
|
{
|
||||||
|
public GetRoleByIdRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Id).NotEmpty().WithMessage("角色 ID 不能为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -21,3 +21,24 @@ public class UpdateRoleEndpoint(IMediator mediator) : Endpoint<UpdateRoleEndpoin
|
|||||||
}
|
}
|
||||||
|
|
||||||
public record UpdateRoleEndpointRequest(Guid Id, string? Name, string? Description);
|
public record UpdateRoleEndpointRequest(Guid Id, string? Name, string? Description);
|
||||||
|
|
||||||
|
/// <summary>更新角色请求校验。Name 为可选更新字段,仅当提供时校验长度。</summary>
|
||||||
|
public class UpdateRoleEndpointRequestValidator : Validator<UpdateRoleEndpointRequest>
|
||||||
|
{
|
||||||
|
public UpdateRoleEndpointRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Id).NotEmpty().WithMessage("角色 ID 不能为空");
|
||||||
|
|
||||||
|
When(x => x.Name is not null, () =>
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Name!)
|
||||||
|
.Length(2, 50).WithMessage("角色名称长度 2-50 个字符");
|
||||||
|
});
|
||||||
|
|
||||||
|
When(x => x.Description is not null, () =>
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Description!)
|
||||||
|
.MaximumLength(500).WithMessage("角色描述长度不能超过 500 个字符");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -5,6 +5,10 @@ using RAG.Application.Users.DTOs;
|
|||||||
|
|
||||||
namespace RAG.Api.Endpoints.Users;
|
namespace RAG.Api.Endpoints.Users;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 给用户分配角色(敏感操作)。需 user:assign-role 权限,全量覆盖语义。
|
||||||
|
/// 注意:已登录用户的旧 token 仍携带旧角色,须重新登录或 refresh 后才生效。
|
||||||
|
/// </summary>
|
||||||
public class AssignRolesEndpoint(IMediator mediator) : Endpoint<AssignRolesEndpointRequest, UserDto>
|
public class AssignRolesEndpoint(IMediator mediator) : Endpoint<AssignRolesEndpointRequest, UserDto>
|
||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
@ -21,3 +25,14 @@ public class AssignRolesEndpoint(IMediator mediator) : Endpoint<AssignRolesEndpo
|
|||||||
}
|
}
|
||||||
|
|
||||||
public record AssignRolesEndpointRequest(Guid UserId, List<Guid> RoleIds);
|
public record AssignRolesEndpointRequest(Guid UserId, List<Guid> RoleIds);
|
||||||
|
|
||||||
|
/// <summary>给用户分配角色请求校验。</summary>
|
||||||
|
public class AssignRolesEndpointRequestValidator : Validator<AssignRolesEndpointRequest>
|
||||||
|
{
|
||||||
|
public AssignRolesEndpointRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.UserId).NotEmpty().WithMessage("用户 ID 不能为空");
|
||||||
|
RuleFor(x => x.RoleIds)
|
||||||
|
.NotEmpty().WithMessage("角色列表不能为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -5,6 +5,10 @@ using RAG.Application.Users.DTOs;
|
|||||||
|
|
||||||
namespace RAG.Api.Endpoints.Users;
|
namespace RAG.Api.Endpoints.Users;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 管理员创建用户端点。需 user:create 权限(区别于公开的 /auth/register)。
|
||||||
|
/// 创建后管理员可通过 AssignRoles 进一步分配角色。
|
||||||
|
/// </summary>
|
||||||
public class CreateUserEndpoint(IMediator mediator) : Endpoint<CreateUserRequest, UserDto>
|
public class CreateUserEndpoint(IMediator mediator) : Endpoint<CreateUserRequest, UserDto>
|
||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
@ -19,3 +23,22 @@ public class CreateUserEndpoint(IMediator mediator) : Endpoint<CreateUserRequest
|
|||||||
await Send.ResponseAsync(result, 201, ct);
|
await Send.ResponseAsync(result, 201, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>管理员创建用户请求校验。</summary>
|
||||||
|
public class CreateUserRequestValidator : Validator<CreateUserRequest>
|
||||||
|
{
|
||||||
|
public CreateUserRequestValidator()
|
||||||
|
{
|
||||||
|
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 个字符");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -15,8 +15,17 @@ public class DeleteUserEndpoint(IMediator mediator) : Endpoint<DeleteUserEndpoin
|
|||||||
public override async Task HandleAsync(DeleteUserEndpointRequest req, CancellationToken ct)
|
public override async Task HandleAsync(DeleteUserEndpointRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await mediator.Send(new DeleteUserCommand(req.Id), ct);
|
await mediator.Send(new DeleteUserCommand(req.Id), ct);
|
||||||
HttpContext.Response.StatusCode = 204;
|
await Send.OkAsync(true, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public record DeleteUserEndpointRequest(Guid Id);
|
public record DeleteUserEndpointRequest(Guid Id);
|
||||||
|
|
||||||
|
/// <summary>删除用户请求校验(Id 由路由提供)。</summary>
|
||||||
|
public class DeleteUserEndpointRequestValidator : Validator<DeleteUserEndpointRequest>
|
||||||
|
{
|
||||||
|
public DeleteUserEndpointRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Id).NotEmpty().WithMessage("用户 ID 不能为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -21,3 +21,12 @@ public class GetUserByIdEndpoint(IMediator mediator) : Endpoint<GetUserByIdReque
|
|||||||
}
|
}
|
||||||
|
|
||||||
public record GetUserByIdRequest(Guid Id);
|
public record GetUserByIdRequest(Guid Id);
|
||||||
|
|
||||||
|
/// <summary>根据用户 ID 查询请求校验(Id 由路由提供)。</summary>
|
||||||
|
public class GetUserByIdRequestValidator : Validator<GetUserByIdRequest>
|
||||||
|
{
|
||||||
|
public GetUserByIdRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Id).NotEmpty().WithMessage("用户 ID 不能为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -21,3 +21,24 @@ public class UpdateUserEndpoint(IMediator mediator) : Endpoint<UpdateUserEndpoin
|
|||||||
}
|
}
|
||||||
|
|
||||||
public record UpdateUserEndpointRequest(Guid Id, string? Email, string? Password, bool? IsActive);
|
public record UpdateUserEndpointRequest(Guid Id, string? Email, string? Password, bool? IsActive);
|
||||||
|
|
||||||
|
/// <summary>更新用户请求校验。Id 由路由提供;Email/Password 为可选更新字段,仅当提供时校验格式。</summary>
|
||||||
|
public class UpdateUserEndpointRequestValidator : Validator<UpdateUserEndpointRequest>
|
||||||
|
{
|
||||||
|
public UpdateUserEndpointRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Id).NotEmpty().WithMessage("用户 ID 不能为空");
|
||||||
|
|
||||||
|
When(x => x.Email is not null, () =>
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Email!)
|
||||||
|
.EmailAddress().WithMessage("邮箱格式不正确");
|
||||||
|
});
|
||||||
|
|
||||||
|
When(x => x.Password is not null, () =>
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Password!)
|
||||||
|
.Length(6, 100).WithMessage("密码长度 6-100 个字符");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
4
src/RAG.Api/GlobalUsings.cs
Normal file
4
src/RAG.Api/GlobalUsings.cs
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
// 全局 using:endpoint 验证器直接继承 Validator<TRequest>,并使用 FluentValidation 规则,
|
||||||
|
// 无需在每个 endpoint 文件重复声明 using。FastEndpoints 反射扫描自动发现 Validator<T> 子类。
|
||||||
|
global using FastEndpoints;
|
||||||
|
global using FluentValidation;
|
||||||
@ -1,14 +1,21 @@
|
|||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using RAG.Infrastructure.Persistence;
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace RAG.Api.Grpc;
|
namespace RAG.Api.Grpc;
|
||||||
|
|
||||||
public class AuthGrpcService(IConfiguration config) : AuthService.AuthServiceBase
|
/// <summary>
|
||||||
|
/// gRPC 鉴权服务,向其它微服务(file-system/work-flow/im-system)暴露 token 校验与权限判定能力。
|
||||||
|
/// 是微服务间"以 rag-backend 为单一身份源"的关键纽带:所有跨服务调用都带着 access_token,
|
||||||
|
/// 由本服务用共享密钥(RagJwtSecretKey2026...)独立验签——避免每个服务各自持有密钥扩散攻击面。
|
||||||
|
/// </summary>
|
||||||
|
public class AuthGrpcService(IConfiguration config, IServiceScopeFactory scopeFactory) : AuthService.AuthServiceBase
|
||||||
{
|
{
|
||||||
|
// 严格校验参数:签发者、受众、签名密钥、有效期全部强校验,ClockSkew=0 杜绝宽限窗口
|
||||||
private readonly TokenValidationParameters _validationParams = new()
|
private readonly TokenValidationParameters _validationParams = new()
|
||||||
{
|
{
|
||||||
ValidateIssuer = true,
|
ValidateIssuer = true,
|
||||||
@ -21,6 +28,10 @@ public class AuthGrpcService(IConfiguration config) : AuthService.AuthServiceBas
|
|||||||
ClockSkew = TimeSpan.Zero
|
ClockSkew = TimeSpan.Zero
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证 access_token 并解包 claims。任何校验失败都静默返回 Valid=false,
|
||||||
|
/// 调用方据此拒绝未认证请求;不抛异常以保持 gRPC 协议的稳定性。
|
||||||
|
/// </summary>
|
||||||
public override Task<ValidateTokenResponse> ValidateToken(ValidateTokenRequest request, ServerCallContext context)
|
public override Task<ValidateTokenResponse> ValidateToken(ValidateTokenRequest request, ServerCallContext context)
|
||||||
{
|
{
|
||||||
var response = new ValidateTokenResponse();
|
var response = new ValidateTokenResponse();
|
||||||
@ -38,7 +49,7 @@ public class AuthGrpcService(IConfiguration config) : AuthService.AuthServiceBas
|
|||||||
?? principal.FindFirstValue(JwtRegisteredClaimNames.Sub) ?? "";
|
?? principal.FindFirstValue(JwtRegisteredClaimNames.Sub) ?? "";
|
||||||
response.Username = principal.FindFirstValue(ClaimTypes.Name)
|
response.Username = principal.FindFirstValue(ClaimTypes.Name)
|
||||||
?? principal.FindFirstValue(JwtRegisteredClaimNames.UniqueName) ?? "";
|
?? principal.FindFirstValue(JwtRegisteredClaimNames.UniqueName) ?? "";
|
||||||
response.Email = principal.FindFirstValue(JwtRegisteredClaimNames.Email)
|
response.Email = principal.FindFirstValue(ClaimTypes.Email)
|
||||||
?? principal.FindFirstValue(ClaimTypes.Email) ?? "";
|
?? principal.FindFirstValue(ClaimTypes.Email) ?? "";
|
||||||
response.Roles.AddRange(principal.FindAll(ClaimTypes.Role).Select(c => c.Value));
|
response.Roles.AddRange(principal.FindAll(ClaimTypes.Role).Select(c => c.Value));
|
||||||
response.Permissions.AddRange(principal.FindAll("permissions").Select(c => c.Value));
|
response.Permissions.AddRange(principal.FindAll("permissions").Select(c => c.Value));
|
||||||
@ -50,6 +61,10 @@ public class AuthGrpcService(IConfiguration config) : AuthService.AuthServiceBas
|
|||||||
return Task.FromResult(response);
|
return Task.FromResult(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 单次权限判定 RPC。供其他服务在不持有权限码全表的情况下,
|
||||||
|
/// 把"用户是否有 X 权限"的决策委托给中心化鉴权服务,避免权限规则散落多处。
|
||||||
|
/// </summary>
|
||||||
public override Task<CheckPermissionResponse> CheckPermission(CheckPermissionRequest request, ServerCallContext context)
|
public override Task<CheckPermissionResponse> CheckPermission(CheckPermissionRequest request, ServerCallContext context)
|
||||||
{
|
{
|
||||||
var response = new CheckPermissionResponse();
|
var response = new CheckPermissionResponse();
|
||||||
@ -70,4 +85,45 @@ public class AuthGrpcService(IConfiguration config) : AuthService.AuthServiceBas
|
|||||||
|
|
||||||
return Task.FromResult(response);
|
return Task.FromResult(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 批量反查用户基础信息(仅 Id/Username),供其他服务把内部 userId 还原为可展示的用户名。
|
||||||
|
/// gRPC 单例服务需用 IServiceScopeFactory 显式开 scope 才能取 Scoped 的 DbContext。
|
||||||
|
/// </summary>
|
||||||
|
public override async Task<GetUsersResponse> GetUsers(GetUsersRequest request, ServerCallContext context)
|
||||||
|
{
|
||||||
|
var response = new GetUsersResponse();
|
||||||
|
|
||||||
|
if (request.UserIds.Count == 0)
|
||||||
|
return response;
|
||||||
|
|
||||||
|
// 防御性解析:非法 Id 全部滤除,避免 Guid.Empty 命中真实记录或污染查询
|
||||||
|
var userIds = request.UserIds
|
||||||
|
.Select(id => Guid.TryParse(id, out var guid) ? guid : Guid.Empty)
|
||||||
|
.Where(id => id != Guid.Empty)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (userIds.Count == 0)
|
||||||
|
return response;
|
||||||
|
|
||||||
|
using var scope = scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<RagDbContext>();
|
||||||
|
|
||||||
|
var users = await db.Users
|
||||||
|
.Where(u => userIds.Contains(u.Id))
|
||||||
|
.Select(u => new { u.Id, u.Username })
|
||||||
|
.ToListAsync(context.CancellationToken);
|
||||||
|
|
||||||
|
foreach (var user in users)
|
||||||
|
{
|
||||||
|
response.Users.Add(new UserInfo
|
||||||
|
{
|
||||||
|
UserId = user.Id.ToString(),
|
||||||
|
Username = user.Username,
|
||||||
|
Avatar = ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
src/RAG.Api/Hubs/NotificationHub.cs
Normal file
35
src/RAG.Api/Hubs/NotificationHub.cs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
namespace RAG.Api.Hubs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 通知中心 SignalR Hub。前端登录后建立连接,按用户 Id 加入个人分组 user:{id},
|
||||||
|
/// 实时接收新通知(铃铛角标更新 + 下拉列表刷新)。
|
||||||
|
/// 认证:JWT Bearer(与 REST 接口一致),sub claim 即用户 Id。
|
||||||
|
/// </summary>
|
||||||
|
[Authorize]
|
||||||
|
public class NotificationHub : Hub
|
||||||
|
{
|
||||||
|
public override async Task OnConnectedAsync()
|
||||||
|
{
|
||||||
|
var userId = ResolveUserId(Context.User);
|
||||||
|
if (userId is not null)
|
||||||
|
await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(userId.Value), Context.ConnectionAborted);
|
||||||
|
await base.OnConnectedAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>用户个人通知分组名:user:{userId}。</summary>
|
||||||
|
public static string UserGroup(Guid userId) => $"user:{userId}";
|
||||||
|
|
||||||
|
/// <summary>从 ClaimsPrincipal 解析用户 Id(sub claim)。</summary>
|
||||||
|
internal static Guid? ResolveUserId(ClaimsPrincipal? user)
|
||||||
|
{
|
||||||
|
if (user?.Identity?.IsAuthenticated != true) return null;
|
||||||
|
var sub = user.FindFirstValue(JwtRegisteredClaimNames.Sub)
|
||||||
|
?? user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
return Guid.TryParse(sub, out var id) ? id : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using RAG.Api.Serialization;
|
||||||
|
|
||||||
namespace RAG.Api.Middleware;
|
namespace RAG.Api.Middleware;
|
||||||
|
|
||||||
@ -9,14 +10,23 @@ namespace RAG.Api.Middleware;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class ApiResponseMiddleware(RequestDelegate next)
|
public class ApiResponseMiddleware(RequestDelegate next)
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
// 复用与 FastEndpoints 一致的序列化选项:驼峰 + 时间毫秒时间戳,确保信封包裹时时间格式不回退
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
Converters = { new TimestampDateTimeConverter(), new TimestampDateTimeOffsetConverter() }
|
||||||
|
};
|
||||||
|
|
||||||
// refresh 接口用 baseRequestClient 调用,不解包,需要返回纯字符串
|
// refresh 接口用 baseRequestClient 调用,不解包,需要返回纯字符串
|
||||||
|
// refresh 直接返回新的 access_token 字符串(非 JSON 对象),故排除以免被二次包裹
|
||||||
private static readonly HashSet<string> ExcludedPaths = ["/api/auth/refresh"];
|
private static readonly HashSet<string> ExcludedPaths = ["/api/auth/refresh"];
|
||||||
|
|
||||||
public async Task InvokeAsync(HttpContext context)
|
public async Task InvokeAsync(HttpContext context)
|
||||||
{
|
{
|
||||||
if (context.Request.ContentType?.StartsWith("application/grpc") == true || IsExcludedPath(context.Request.Path))
|
// 三类请求绕过包裹:gRPC 二进制帧、显式排除路径、SSE 流式接口
|
||||||
|
if (context.Request.ContentType?.StartsWith("application/grpc") == true
|
||||||
|
|| IsExcludedPath(context.Request.Path)
|
||||||
|
|| IsStreamEndpoint(context.Request.Path))
|
||||||
{
|
{
|
||||||
await next(context);
|
await next(context);
|
||||||
return;
|
return;
|
||||||
@ -24,6 +34,7 @@ public class ApiResponseMiddleware(RequestDelegate next)
|
|||||||
|
|
||||||
var originalBody = context.Response.Body;
|
var originalBody = context.Response.Body;
|
||||||
var buffer = new MemoryStream();
|
var buffer = new MemoryStream();
|
||||||
|
// 用内存流接管响应体,以便下游写完后整体读取再决定是否包裹
|
||||||
context.Response.Body = buffer;
|
context.Response.Body = buffer;
|
||||||
|
|
||||||
try
|
try
|
||||||
@ -32,6 +43,7 @@ public class ApiResponseMiddleware(RequestDelegate next)
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
// 异常路径下必须先还原原始响应流,避免后续 GlobalExceptionMiddleware 写不出字节
|
||||||
context.Response.Body = originalBody;
|
context.Response.Body = originalBody;
|
||||||
await buffer.DisposeAsync();
|
await buffer.DisposeAsync();
|
||||||
throw;
|
throw;
|
||||||
@ -43,6 +55,7 @@ public class ApiResponseMiddleware(RequestDelegate next)
|
|||||||
var contentType = context.Response.ContentType ?? "";
|
var contentType = context.Response.ContentType ?? "";
|
||||||
|
|
||||||
// 非 JSON 响应或非成功状态码或排除路径,直接透传原始响应
|
// 非 JSON 响应或非成功状态码或排除路径,直接透传原始响应
|
||||||
|
// 错误响应已由 GlobalExceptionMiddleware 包装为错误信封,此处不可再包一层
|
||||||
if (statusCode is < 200 or >= 300
|
if (statusCode is < 200 or >= 300
|
||||||
|| !contentType.Contains("json", StringComparison.OrdinalIgnoreCase)
|
|| !contentType.Contains("json", StringComparison.OrdinalIgnoreCase)
|
||||||
|| IsExcludedPath(context.Request.Path))
|
|| IsExcludedPath(context.Request.Path))
|
||||||
@ -56,6 +69,7 @@ public class ApiResponseMiddleware(RequestDelegate next)
|
|||||||
var bodyText = await new StreamReader(buffer).ReadToEndAsync(context.RequestAborted);
|
var bodyText = await new StreamReader(buffer).ReadToEndAsync(context.RequestAborted);
|
||||||
|
|
||||||
// 204 或空 body 不包裹
|
// 204 或空 body 不包裹
|
||||||
|
// DELETE 等无返回值场景,204 NoContent 或 null,直接透传,避免把 null 包成 data:null 的冗余信封
|
||||||
if (statusCode == 204 || string.IsNullOrEmpty(bodyText) || bodyText == "null")
|
if (statusCode == 204 || string.IsNullOrEmpty(bodyText) || bodyText == "null")
|
||||||
{
|
{
|
||||||
buffer.Seek(0, SeekOrigin.Begin);
|
buffer.Seek(0, SeekOrigin.Begin);
|
||||||
@ -68,6 +82,7 @@ public class ApiResponseMiddleware(RequestDelegate next)
|
|||||||
var json = JsonSerializer.Serialize(wrapped, JsonOptions);
|
var json = JsonSerializer.Serialize(wrapped, JsonOptions);
|
||||||
var bytes = Encoding.UTF8.GetBytes(json);
|
var bytes = Encoding.UTF8.GetBytes(json);
|
||||||
|
|
||||||
|
// ContentLength 必须按包裹后字节重算,否则前端按原长度截断会破坏 JSON
|
||||||
context.Response.ContentType = "application/json; charset=utf-8";
|
context.Response.ContentType = "application/json; charset=utf-8";
|
||||||
context.Response.ContentLength = bytes.Length;
|
context.Response.ContentLength = bytes.Length;
|
||||||
await originalBody.WriteAsync(bytes, context.RequestAborted);
|
await originalBody.WriteAsync(bytes, context.RequestAborted);
|
||||||
@ -77,5 +92,12 @@ public class ApiResponseMiddleware(RequestDelegate next)
|
|||||||
private static bool IsExcludedPath(PathString path) =>
|
private static bool IsExcludedPath(PathString path) =>
|
||||||
ExcludedPaths.Any(p => path.Value?.StartsWith(p, StringComparison.OrdinalIgnoreCase) == true);
|
ExcludedPaths.Any(p => path.Value?.StartsWith(p, StringComparison.OrdinalIgnoreCase) == true);
|
||||||
|
|
||||||
|
// 流式接口路径前缀(SSE/分块输出,必须透传不可缓冲)
|
||||||
|
private static readonly string[] StreamPaths = ["/api/rag/stream", "/api/chat/"];
|
||||||
|
// 流式端点判定:路径命中前缀且以 /stream 结尾(避免误伤 /api/chat/ 普通对话接口)
|
||||||
|
private static bool IsStreamEndpoint(PathString path) =>
|
||||||
|
StreamPaths.Any(p => path.Value?.StartsWith(p, StringComparison.OrdinalIgnoreCase) == true
|
||||||
|
&& path.Value?.EndsWith("/stream", StringComparison.OrdinalIgnoreCase) == true);
|
||||||
|
|
||||||
private record ApiResponse(JsonElement Data, int Code = 0, string Message = "ok");
|
private record ApiResponse(JsonElement Data, int Code = 0, string Message = "ok");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,13 +2,15 @@ using System.Net;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
|
using Namotion.Reflection;
|
||||||
using RAG.Domain.Exceptions;
|
using RAG.Domain.Exceptions;
|
||||||
|
|
||||||
namespace RAG.Api.Middleware;
|
namespace RAG.Api.Middleware;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 全局异常中间件,捕获所有未处理异常并统一返回 { code, message, data } 格式。
|
/// 全局异常中间件,位于管线最前端(仅次于 CORS),捕获所有未处理异常并统一包装为
|
||||||
/// 必须注册在 ApiResponseMiddleware 之前,确保异常不会穿透到 ASP.NET Core 默认错误页。
|
/// 错误响应信封 { code, message, data: null }。负责把领域层抛出的语义异常映射为 HTTP 状态码。
|
||||||
|
/// gRPC 请求走二进制协议,不经此中间件包装(直接透传)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class GlobalExceptionMiddleware(RequestDelegate next)
|
public class GlobalExceptionMiddleware(RequestDelegate next)
|
||||||
{
|
{
|
||||||
@ -16,6 +18,7 @@ public class GlobalExceptionMiddleware(RequestDelegate next)
|
|||||||
|
|
||||||
public async Task InvokeAsync(HttpContext context)
|
public async Task InvokeAsync(HttpContext context)
|
||||||
{
|
{
|
||||||
|
// gRPC 请求是二进制帧协议,不能用 JSON 异常信封响应,直接放行交给 gRPC 管线处理
|
||||||
if (context.Request.ContentType?.StartsWith("application/grpc") == true)
|
if (context.Request.ContentType?.StartsWith("application/grpc") == true)
|
||||||
{
|
{
|
||||||
await next(context);
|
await next(context);
|
||||||
@ -28,17 +31,21 @@ public class GlobalExceptionMiddleware(RequestDelegate next)
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
// 若响应头已发出(如流式输出已写出一部分字节),无法再改写状态码,只能丢弃
|
||||||
|
if (context.Response.HasStarted) return;
|
||||||
await HandleExceptionAsync(context, ex);
|
await HandleExceptionAsync(context, ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task HandleExceptionAsync(HttpContext context, Exception ex)
|
private static async Task HandleExceptionAsync(HttpContext context, Exception ex)
|
||||||
{
|
{
|
||||||
|
// 领域异常 → HTTP 状态码的统一映射;未识别异常一律视为 500,避免内部细节泄露给客户端
|
||||||
var (statusCode, code, message) = ex switch
|
var (statusCode, code, message) = ex switch
|
||||||
{
|
{
|
||||||
UnauthorizedException => (HttpStatusCode.Unauthorized, 401, ex.Message),
|
UnauthorizedException => (HttpStatusCode.Unauthorized, 401, ex.Message),
|
||||||
NotFoundException => (HttpStatusCode.NotFound, 404, ex.Message),
|
NotFoundException => (HttpStatusCode.NotFound, 404, ex.Message),
|
||||||
BusinessException => (HttpStatusCode.BadRequest, 400, ex.Message),
|
BusinessException => (HttpStatusCode.BadRequest, 400, ex.Message),
|
||||||
|
// 多条校验错误用分号拼接,便于前端一次性展示
|
||||||
ValidationException vex => (HttpStatusCode.BadRequest, 400,
|
ValidationException vex => (HttpStatusCode.BadRequest, 400,
|
||||||
string.Join("; ", vex.Errors.Select(e => e.ErrorMessage))),
|
string.Join("; ", vex.Errors.Select(e => e.ErrorMessage))),
|
||||||
_ => (HttpStatusCode.InternalServerError, 500,
|
_ => (HttpStatusCode.InternalServerError, 500,
|
||||||
|
|||||||
@ -7,14 +7,18 @@ using Volo.Abp.DependencyInjection;
|
|||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// OTel 日志导出
|
// OTel 日志导出
|
||||||
|
var otlpEndpoint = builder.Configuration["OpenTelemetry:OtlpEndpoint"] ?? "http://localhost:4316";
|
||||||
builder.Logging.AddOpenTelemetry(logging =>
|
builder.Logging.AddOpenTelemetry(logging =>
|
||||||
{
|
{
|
||||||
logging.AddOtlpExporter(options =>
|
logging.AddOtlpExporter(options =>
|
||||||
{
|
{
|
||||||
options.Endpoint = new Uri("http://192.168.1.154:4316");
|
options.Endpoint = new Uri(otlpEndpoint);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ABP 模块系统需要在 DI 容器中预先注册 ObjectAccessor,
|
||||||
|
// 使各模块在初始化阶段能取到ApplicationBuilder/WebApplication/Host/EndpointRouteBuilder 的"占位引用",
|
||||||
|
// 主程序构建完成后再回填实际实例,从而打通 ABP 模块化与 ASP.NET Core 管线的双向访问。
|
||||||
// ABP 需要同时注册 ObjectAccessor 的具体类型和接口类型
|
// ABP 需要同时注册 ObjectAccessor 的具体类型和接口类型
|
||||||
var appBuilderAccessor = new ObjectAccessor<Microsoft.AspNetCore.Builder.IApplicationBuilder>();
|
var appBuilderAccessor = new ObjectAccessor<Microsoft.AspNetCore.Builder.IApplicationBuilder>();
|
||||||
var webAppAccessor = new ObjectAccessor<Microsoft.AspNetCore.Builder.WebApplication>();
|
var webAppAccessor = new ObjectAccessor<Microsoft.AspNetCore.Builder.WebApplication>();
|
||||||
|
|||||||
@ -7,6 +7,7 @@ option csharp_namespace = "RAG.Api.Grpc";
|
|||||||
service AuthService {
|
service AuthService {
|
||||||
rpc ValidateToken (ValidateTokenRequest) returns (ValidateTokenResponse);
|
rpc ValidateToken (ValidateTokenRequest) returns (ValidateTokenResponse);
|
||||||
rpc CheckPermission (CheckPermissionRequest) returns (CheckPermissionResponse);
|
rpc CheckPermission (CheckPermissionRequest) returns (CheckPermissionResponse);
|
||||||
|
rpc GetUsers (GetUsersRequest) returns (GetUsersResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
message ValidateTokenRequest {
|
message ValidateTokenRequest {
|
||||||
@ -33,3 +34,17 @@ message CheckPermissionResponse {
|
|||||||
string user_id = 2;
|
string user_id = 2;
|
||||||
repeated string roles = 3;
|
repeated string roles = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message GetUsersRequest {
|
||||||
|
repeated string user_ids = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetUsersResponse {
|
||||||
|
repeated UserInfo users = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UserInfo {
|
||||||
|
string user_id = 1;
|
||||||
|
string username = 2;
|
||||||
|
string avatar = 3;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using FastEndpoints.Security;
|
|
||||||
using FastEndpoints.Swagger;
|
using FastEndpoints.Swagger;
|
||||||
using OpenTelemetry.Metrics;
|
using OpenTelemetry.Metrics;
|
||||||
using OpenTelemetry.Resources;
|
using OpenTelemetry.Resources;
|
||||||
using OpenTelemetry.Trace;
|
using OpenTelemetry.Trace;
|
||||||
using RAG.Api.Grpc;
|
using RAG.Api.Grpc;
|
||||||
using RAG.Api.Middleware;
|
using RAG.Api.Middleware;
|
||||||
|
using RAG.Api.Serialization;
|
||||||
using RAG.Api.Services;
|
using RAG.Api.Services;
|
||||||
using RAG.Application;
|
using RAG.Application;
|
||||||
|
using RAG.Application.Notifications;
|
||||||
using RAG.Domain.Common;
|
using RAG.Domain.Common;
|
||||||
using RAG.Infrastructure;
|
using RAG.Infrastructure;
|
||||||
using Volo.Abp;
|
using Volo.Abp;
|
||||||
@ -26,7 +27,8 @@ public class RAGApiModule : AbpModule
|
|||||||
var services = context.Services;
|
var services = context.Services;
|
||||||
var config = services.GetConfiguration();
|
var config = services.GetConfiguration();
|
||||||
|
|
||||||
// OpenTelemetry
|
// OpenTelemetry:统一导出 Traces + Metrics 到 OTLP Collector,供可观测性后端(Jaeger/Prometheus)消费
|
||||||
|
var otlpEndpoint = config["OpenTelemetry:OtlpEndpoint"] ?? "http://localhost:4316";
|
||||||
services.AddOpenTelemetry()
|
services.AddOpenTelemetry()
|
||||||
.ConfigureResource(r => r
|
.ConfigureResource(r => r
|
||||||
.AddService(serviceName: "rag-backend", serviceVersion: "1.0.0"))
|
.AddService(serviceName: "rag-backend", serviceVersion: "1.0.0"))
|
||||||
@ -36,7 +38,7 @@ public class RAGApiModule : AbpModule
|
|||||||
.AddEntityFrameworkCoreInstrumentation()
|
.AddEntityFrameworkCoreInstrumentation()
|
||||||
.AddOtlpExporter(options =>
|
.AddOtlpExporter(options =>
|
||||||
{
|
{
|
||||||
options.Endpoint = new Uri("http://192.168.1.154:4316");
|
options.Endpoint = new Uri(otlpEndpoint);
|
||||||
}))
|
}))
|
||||||
.WithMetrics(metrics => metrics
|
.WithMetrics(metrics => metrics
|
||||||
.AddAspNetCoreInstrumentation()
|
.AddAspNetCoreInstrumentation()
|
||||||
@ -44,13 +46,16 @@ public class RAGApiModule : AbpModule
|
|||||||
.AddRuntimeInstrumentation()
|
.AddRuntimeInstrumentation()
|
||||||
.AddOtlpExporter((exporterOptions, readerOptions) =>
|
.AddOtlpExporter((exporterOptions, readerOptions) =>
|
||||||
{
|
{
|
||||||
exporterOptions.Endpoint = new Uri("http://192.168.1.154:4316");
|
exporterOptions.Endpoint = new Uri(otlpEndpoint);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// FastEndpoints + Swagger
|
// FastEndpoints + Swagger
|
||||||
services.AddFastEndpoints();
|
services.AddFastEndpoints();
|
||||||
services.SwaggerDocument();
|
services.SwaggerDocument();
|
||||||
|
|
||||||
|
// SignalR:消息中心通知实时推送(前端铃铛)
|
||||||
|
services.AddSignalR();
|
||||||
|
|
||||||
// gRPC
|
// gRPC
|
||||||
services.AddGrpc();
|
services.AddGrpc();
|
||||||
|
|
||||||
@ -58,13 +63,46 @@ public class RAGApiModule : AbpModule
|
|||||||
services.AddHttpContextAccessor();
|
services.AddHttpContextAccessor();
|
||||||
services.AddScoped<ICurrentUserContext, CurrentUserContext>();
|
services.AddScoped<ICurrentUserContext, CurrentUserContext>();
|
||||||
|
|
||||||
// JWT 认证
|
// 消息中心:通知分发器(SignalR 实现),业务层依赖 INotificationDispatcher 抽象
|
||||||
services.AddAuthenticationJwtBearer(
|
services.AddScoped<INotificationDispatcher, SignalRNotificationDispatcher>();
|
||||||
s => { s.SigningKey = config["Jwt:SigningKey"]!; },
|
|
||||||
o =>
|
// JWT 认证:切换为标准 OIDC(JWKS 自动从 SSO 拉取,RS256 验签)。
|
||||||
|
// SSO 是唯一身份源;rag 不再自签 token,本地不持有对称密钥。
|
||||||
|
// 注意:SSO 签发的 access_token 不绑定特定 audience(多服务共享),
|
||||||
|
// 故仅校验 issuer + 签名,不校验 audience。
|
||||||
|
// 同时支持本地账号密码登录签发的 token(对称密钥签名),无需经过 SSO。
|
||||||
|
services.AddAuthentication("Bearer").AddJwtBearer(options =>
|
||||||
{
|
{
|
||||||
o.TokenValidationParameters.ValidIssuer = config["Jwt:Issuer"];
|
options.Authority = config["Jwt:Authority"]; // SSO 地址,如 http://localhost:5215
|
||||||
o.TokenValidationParameters.ValidAudience = config["Jwt:Audience"];
|
options.MapInboundClaims = false; // 保留短名 claim(sub),统一读取方式
|
||||||
|
options.RequireHttpsMetadata = config.GetValue<bool>("Jwt:RequireHttps", false); // 开发期允许 HTTP
|
||||||
|
options.TokenValidationParameters.ValidateAudience = false; // SSO token 无固定 audience
|
||||||
|
options.TokenValidationParameters.ValidateIssuer = false; // 本地签发的 issuer 与 SSO 不同,放宽校验
|
||||||
|
|
||||||
|
// 本地账号密码登录用对称密钥签名,SSO 走 JWKS。
|
||||||
|
// 同时注册两种密钥,让认证中间件能验证两类 token。
|
||||||
|
var localSigningKey = config["Jwt:SigningKey"];
|
||||||
|
if (!string.IsNullOrEmpty(localSigningKey))
|
||||||
|
{
|
||||||
|
options.TokenValidationParameters.IssuerSigningKey =
|
||||||
|
new Microsoft.IdentityModel.Tokens.SymmetricSecurityKey(
|
||||||
|
System.Text.Encoding.UTF8.GetBytes(localSigningKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignalR WebSocket 握手无法携带 Authorization 头,对 /hubs/* 路径从 query 取 access_token。
|
||||||
|
options.Events = new Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerEvents
|
||||||
|
{
|
||||||
|
OnMessageReceived = context =>
|
||||||
|
{
|
||||||
|
var token = context.Request.Query["access_token"];
|
||||||
|
if (!string.IsNullOrEmpty(token) &&
|
||||||
|
context.Request.Path.StartsWithSegments("/hubs"))
|
||||||
|
{
|
||||||
|
context.Token = token;
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
};
|
||||||
});
|
});
|
||||||
services.AddAuthorization();
|
services.AddAuthorization();
|
||||||
|
|
||||||
@ -98,21 +136,36 @@ public class RAGApiModule : AbpModule
|
|||||||
app.UseMiddleware<ApiResponseMiddleware>();
|
app.UseMiddleware<ApiResponseMiddleware>();
|
||||||
|
|
||||||
// 中间件管道(顺序不可变)
|
// 中间件管道(顺序不可变)
|
||||||
|
// 认证(解析 token → ClaimsPrincipal)必须在授权之前,否则授权拿不到用户身份
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
app.UseFastEndpoints(config =>
|
app.UseFastEndpoints(config =>
|
||||||
{
|
{
|
||||||
config.Endpoints.RoutePrefix = "api";
|
config.Endpoints.RoutePrefix = "api";
|
||||||
|
// 统一数据规范:时间字段输出 UTC 毫秒时间戳
|
||||||
|
config.Serializer.Options.Converters.Add(new TimestampDateTimeConverter());
|
||||||
|
config.Serializer.Options.Converters.Add(new TimestampDateTimeOffsetConverter());
|
||||||
|
// 参数校验失败统一输出 { code, message, data },与 GlobalExceptionMiddleware 保持一致:
|
||||||
|
// ApiResponseMiddleware 对非 2xx 响应原样透传,前端 errorMessageResponseInterceptor 读取
|
||||||
|
// response.data.message 即可展示中文校验提示。多条错误用 "; " 拼接。
|
||||||
|
config.Errors.StatusCode = 400;
|
||||||
config.Errors.ResponseBuilder = (failures, ctx, statusCode) =>
|
config.Errors.ResponseBuilder = (failures, ctx, statusCode) =>
|
||||||
{
|
{
|
||||||
return new ErrorResponse(failures.Select(f => new Error(f.ErrorMessage)).ToList());
|
var message = string.Join("; ", failures.Select(f => f.ErrorMessage));
|
||||||
|
return new ValidationErrorResponse(400, message);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
app.UseSwaggerGen();
|
app.UseSwaggerGen();
|
||||||
|
|
||||||
|
// 消息中心通知 Hub:路由 /hubs/notifications,前端铃铛实时推送
|
||||||
|
context.GetEndpointRouteBuilder().MapHub<Hubs.NotificationHub>("/hubs/notifications");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public record ErrorResponse(List<Error> Errors);
|
/// <summary>
|
||||||
public record Error(string Message);
|
/// 参数校验失败响应信封,与 GlobalExceptionMiddleware 的 ErrorResponse 结构一致:
|
||||||
|
/// { code: 400, message: "...", data: null }。
|
||||||
|
/// </summary>
|
||||||
|
public record ValidationErrorResponse(int Code, string Message, object? Data = null);
|
||||||
|
|||||||
63
src/RAG.Api/Serialization/TimestampJsonConverter.cs
Normal file
63
src/RAG.Api/Serialization/TimestampJsonConverter.cs
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace RAG.Api.Serialization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 统一数据规范:所有时间字段以 UTC 毫秒时间戳(long)形式在 JSON 中传输。
|
||||||
|
/// 后端只负责按标准输出毫秒时间戳,时区/格式化统一由前端处理。
|
||||||
|
/// 读:JSON 数字(毫秒)→ DateTime(Utc);写入:DateTime → 毫秒 long。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TimestampDateTimeConverter : JsonConverter<DateTime>
|
||||||
|
{
|
||||||
|
public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
// 兼容数字(毫秒时间戳)与字符串(ISO 8601,向后兼容旧客户端)
|
||||||
|
if (reader.TokenType == JsonTokenType.Number)
|
||||||
|
{
|
||||||
|
return DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64()).UtcDateTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.TokenType == JsonTokenType.String)
|
||||||
|
{
|
||||||
|
return DateTime.Parse(reader.GetString()!, null, System.Globalization.DateTimeStyles.RoundtripKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new JsonException("Expected a number (ms timestamp) or string (ISO date) for DateTime field.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
// 统一转 UTC 毫秒时间戳;Unspecified 按当前时区推算(极少见,审计时间均为 Utc)
|
||||||
|
var utc = value.Kind == DateTimeKind.Unspecified
|
||||||
|
? DateTime.SpecifyKind(value, DateTimeKind.Utc)
|
||||||
|
: value.ToUniversalTime();
|
||||||
|
writer.WriteNumberValue(new DateTimeOffset(utc, TimeSpan.Zero).ToUnixTimeMilliseconds());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DateTimeOffset 版本:同样输出 UTC 毫秒时间戳。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TimestampDateTimeOffsetConverter : JsonConverter<DateTimeOffset>
|
||||||
|
{
|
||||||
|
public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
if (reader.TokenType == JsonTokenType.Number)
|
||||||
|
{
|
||||||
|
return DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.TokenType == JsonTokenType.String)
|
||||||
|
{
|
||||||
|
return DateTimeOffset.Parse(reader.GetString()!);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new JsonException("Expected a number (ms timestamp) or string (ISO date) for DateTimeOffset field.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
writer.WriteNumberValue(value.ToUnixTimeMilliseconds());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,11 @@ using RAG.Domain.Common;
|
|||||||
|
|
||||||
namespace RAG.Api.Services;
|
namespace RAG.Api.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 当前操作者上下文。从 HttpContext 的已认证 ClaimsPrincipal 中提取用户 Id 与来源 IP,
|
||||||
|
/// 供 AuditInterceptor 在 SaveChanges 时自动填充审计字段(CreatedBy/OperatorIP 等)。
|
||||||
|
/// 未认证场景(如种子数据、内部任务)回落为 "system",确保审计字段非空。
|
||||||
|
/// </summary>
|
||||||
public class CurrentUserContext : ICurrentUserContext
|
public class CurrentUserContext : ICurrentUserContext
|
||||||
{
|
{
|
||||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||||
@ -20,8 +25,9 @@ public class CurrentUserContext : ICurrentUserContext
|
|||||||
var httpContext = _httpContextAccessor.HttpContext;
|
var httpContext = _httpContextAccessor.HttpContext;
|
||||||
if (httpContext?.User?.Identity?.IsAuthenticated == true)
|
if (httpContext?.User?.Identity?.IsAuthenticated == true)
|
||||||
{
|
{
|
||||||
return httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier)
|
// MapInboundClaims=false,sub 保持短名;优先读 sub,兼容旧 NameIdentifier
|
||||||
?? httpContext.User.FindFirstValue(JwtRegisteredClaimNames.Sub)
|
return httpContext.User.FindFirstValue(JwtRegisteredClaimNames.Sub)
|
||||||
|
?? httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||||
?? "system";
|
?? "system";
|
||||||
}
|
}
|
||||||
return "system";
|
return "system";
|
||||||
|
|||||||
37
src/RAG.Api/Services/SignalRNotificationDispatcher.cs
Normal file
37
src/RAG.Api/Services/SignalRNotificationDispatcher.cs
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using RAG.Api.Hubs;
|
||||||
|
using RAG.Application.Notifications;
|
||||||
|
using RAG.Domain.Entities;
|
||||||
|
|
||||||
|
namespace RAG.Api.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 基于 SignalR 的通知分发实现。把已落库的通知按接收人推送到对应分组(user:{id}),
|
||||||
|
/// 前端铃铛据此实时更新。推送失败不影响已落库的通知(消息可靠性以数据库为准)。
|
||||||
|
/// </summary>
|
||||||
|
public class SignalRNotificationDispatcher(IHubContext<NotificationHub> hubContext)
|
||||||
|
: INotificationDispatcher
|
||||||
|
{
|
||||||
|
public async Task DispatchAsync(IReadOnlyList<Notification> notifications, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// 按接收人分组推送,推送载荷为通知摘要(前端据此刷新铃铛与列表)
|
||||||
|
var byRecipient = notifications.GroupBy(n => n.RecipientUserId);
|
||||||
|
foreach (var group in byRecipient)
|
||||||
|
{
|
||||||
|
var payload = group.Select(n => new
|
||||||
|
{
|
||||||
|
id = n.Id,
|
||||||
|
type = n.Type,
|
||||||
|
title = n.Title,
|
||||||
|
content = n.Content,
|
||||||
|
source = n.Source,
|
||||||
|
relatedId = n.RelatedId,
|
||||||
|
relatedType = n.RelatedType,
|
||||||
|
createdAt = n.CreatedAt
|
||||||
|
});
|
||||||
|
await hubContext.Clients
|
||||||
|
.Group(NotificationHub.UserGroup(group.Key))
|
||||||
|
.SendAsync("notification", payload, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,21 +19,39 @@
|
|||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"Default": "Host=192.168.1.154;Port=5432;Database=rag;Username=auto_agent;Password=auto_agent",
|
"Default": "Host=localhost;Port=5432;Database=rag;Username=rag;Password=rag123",
|
||||||
"Redis": "192.168.1.154:31040,password=xn001624."
|
"Redis": "localhost:6379,abortConnect=false"
|
||||||
},
|
},
|
||||||
"RabbitMq": {
|
"RabbitMq": {
|
||||||
"Host": "192.168.1.154",
|
"Host": "localhost",
|
||||||
"Port": 31020,
|
"Port": 5672,
|
||||||
"Username": "guest",
|
"Username": "rag",
|
||||||
"Password": "xn001624."
|
"Password": "rag123"
|
||||||
},
|
},
|
||||||
"Jwt": {
|
"Jwt": {
|
||||||
|
"Authority": "http://localhost:5215",
|
||||||
|
"RequireHttps": false,
|
||||||
"SigningKey": "RagJwtSecretKey2026MustBeAtLeast32CharsLong!",
|
"SigningKey": "RagJwtSecretKey2026MustBeAtLeast32CharsLong!",
|
||||||
"Issuer": "rag-api",
|
"Issuer": "http://localhost:5211",
|
||||||
"Audience": "rag-client"
|
"Audience": "rag-frontend"
|
||||||
},
|
},
|
||||||
"Cookie": {
|
"Cookie": {
|
||||||
"Secure": false
|
"Secure": false
|
||||||
|
},
|
||||||
|
"FileService": {
|
||||||
|
"BaseUrl": "http://localhost:8080",
|
||||||
|
"Bucket": "rag-documents"
|
||||||
|
},
|
||||||
|
"OpenTelemetry": {
|
||||||
|
"OtlpEndpoint": "http://localhost:4316"
|
||||||
|
},
|
||||||
|
"Ai": {
|
||||||
|
"BaseUrl": "http://localhost:1234/v1",
|
||||||
|
"ApiKey": "lm-studio",
|
||||||
|
"ChatModel": "qwen/qwen3.6-27b",
|
||||||
|
"EmbeddingModel": "text-embedding-embeddinggemma-300m",
|
||||||
|
"DefaultInstructions": "你是一个RAG知识库助手,基于提供的上下文准确回答用户问题。如果上下文中没有相关信息,请如实说明。",
|
||||||
|
"MaxTokens": 2000,
|
||||||
|
"Temperature": 0.7
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,24 +13,32 @@ using RAG.Infrastructure.Persistence;
|
|||||||
|
|
||||||
namespace RAG.Application.Auth.Commands;
|
namespace RAG.Application.Auth.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 账号密码登录命令。校验通过后签发短时 access_token(15 分钟)与长时 refresh_token(7 天),
|
||||||
|
/// 并将角色与权限码塞入 JWT claims,供后续权限判定使用。
|
||||||
|
/// </summary>
|
||||||
public record LoginCommand(string Username, string Password) : IRequest<TokenResponse>;
|
public record LoginCommand(string Username, string Password) : IRequest<TokenResponse>;
|
||||||
|
|
||||||
public class LoginCommandHandler(RagDbContext db, IConfiguration config) : IRequestHandler<LoginCommand, TokenResponse>
|
public class LoginCommandHandler(RagDbContext db, IConfiguration config) : IRequestHandler<LoginCommand, TokenResponse>
|
||||||
{
|
{
|
||||||
|
// 进程内登录失败计数器(未持久化,重启即清零)。键统一小写化,避免大小写差异绕过锁定。
|
||||||
private static readonly Dictionary<string, (int Count, DateTime LockedUntil)> LoginAttempts = new();
|
private static readonly Dictionary<string, (int Count, DateTime LockedUntil)> LoginAttempts = new();
|
||||||
|
|
||||||
public async Task<TokenResponse> Handle(LoginCommand request, CancellationToken ct)
|
public async Task<TokenResponse> Handle(LoginCommand request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var key = request.Username.Trim().ToLowerInvariant();
|
var key = request.Username.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
// 账号已被锁定:直接拒绝,不消耗数据库查询
|
||||||
if (LoginAttempts.TryGetValue(key, out var attempt) && attempt.LockedUntil > DateTime.UtcNow)
|
if (LoginAttempts.TryGetValue(key, out var attempt) && attempt.LockedUntil > DateTime.UtcNow)
|
||||||
throw new BusinessException($"登录失败次数过多,请在 {(attempt.LockedUntil - DateTime.UtcNow).Minutes + 1} 分钟后重试");
|
throw new BusinessException($"登录失败次数过多,请在 {(attempt.LockedUntil - DateTime.UtcNow).Minutes + 1} 分钟后重试");
|
||||||
|
|
||||||
|
// 一次 Include 取齐角色→权限,避免后续多次查询
|
||||||
var user = await db.Users
|
var user = await db.Users
|
||||||
.Include(u => u.UserRoles).ThenInclude(ur => ur.Role).ThenInclude(r => r.RolePermissions).ThenInclude(rp => rp.Permission)
|
.Include(u => u.UserRoles).ThenInclude(ur => ur.Role).ThenInclude(r => r.RolePermissions).ThenInclude(rp => rp.Permission)
|
||||||
.FirstOrDefaultAsync(u => u.Username == request.Username, ct)
|
.FirstOrDefaultAsync(u => u.Username == request.Username, ct)
|
||||||
?? throw FailLogin(key, "用户名或密码错误");
|
?? throw FailLogin(key, "用户名或密码错误");
|
||||||
|
|
||||||
|
// 用恒定时间的 BCrypt 校验,避免基于响应耗时的时序攻击
|
||||||
if (!BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
|
if (!BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
|
||||||
throw FailLogin(key, "用户名或密码错误");
|
throw FailLogin(key, "用户名或密码错误");
|
||||||
|
|
||||||
@ -58,6 +66,10 @@ public class LoginCommandHandler(RagDbContext db, IConfiguration config) : IRequ
|
|||||||
return new TokenResponse(accessToken, refreshToken, DateTime.UtcNow.AddMinutes(15));
|
return new TokenResponse(accessToken, refreshToken, DateTime.UtcNow.AddMinutes(15));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 累加失败次数并按需触发账号锁定。统一返回"用户名或密码错误",避免泄露用户是否存在。
|
||||||
|
/// 达到 5 次后锁定 15 分钟(暴力破解防护阈值)。
|
||||||
|
/// </summary>
|
||||||
private static BusinessException FailLogin(string key, string message)
|
private static BusinessException FailLogin(string key, string message)
|
||||||
{
|
{
|
||||||
if (!LoginAttempts.TryGetValue(key, out var attempt))
|
if (!LoginAttempts.TryGetValue(key, out var attempt))
|
||||||
@ -76,6 +88,10 @@ public class LoginCommandHandler(RagDbContext db, IConfiguration config) : IRequ
|
|||||||
return new BusinessException(message);
|
return new BusinessException(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用配置中的对称密钥(共享密钥 RagJwtSecretKey2026...)HS256 签发 access_token。
|
||||||
|
/// permissions claim 携带权限码列表,FastEndpoints 的 Permissions() 据此判定。
|
||||||
|
/// </summary>
|
||||||
private string GenerateAccessToken(User user, List<string> roles, List<string> permissions)
|
private string GenerateAccessToken(User user, List<string> roles, List<string> permissions)
|
||||||
{
|
{
|
||||||
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:SigningKey"]!));
|
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:SigningKey"]!));
|
||||||
@ -101,6 +117,7 @@ public class LoginCommandHandler(RagDbContext db, IConfiguration config) : IRequ
|
|||||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 用加密强随机数生成器产生 64 字节熵,再 Base64 编码为不透明 refresh_token(不可猜测、不可推导)
|
||||||
private static string GenerateRefreshToken()
|
private static string GenerateRefreshToken()
|
||||||
{
|
{
|
||||||
var bytes = new byte[64];
|
var bytes = new byte[64];
|
||||||
|
|||||||
@ -11,6 +11,11 @@ using RAG.Infrastructure.Persistence;
|
|||||||
|
|
||||||
namespace RAG.Application.Auth.Commands;
|
namespace RAG.Application.Auth.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 刷新令牌命令。用未过期的 refresh_token 换取一对新令牌,
|
||||||
|
/// 同时将旧 refresh_token 置为已吊销——实现 Refresh Token Rotation(一次性使用),
|
||||||
|
/// 一旦检测到旧 token 被再次使用即可识别盗用。
|
||||||
|
/// </summary>
|
||||||
public record RefreshTokenCommand(string RefreshToken) : IRequest<TokenResponse>;
|
public record RefreshTokenCommand(string RefreshToken) : IRequest<TokenResponse>;
|
||||||
|
|
||||||
public class RefreshTokenCommandHandler(RagDbContext db, IConfiguration config) : IRequestHandler<RefreshTokenCommand, TokenResponse>
|
public class RefreshTokenCommandHandler(RagDbContext db, IConfiguration config) : IRequestHandler<RefreshTokenCommand, TokenResponse>
|
||||||
@ -22,9 +27,11 @@ public class RefreshTokenCommandHandler(RagDbContext db, IConfiguration config)
|
|||||||
.FirstOrDefaultAsync(rt => rt.Token == request.RefreshToken, ct)
|
.FirstOrDefaultAsync(rt => rt.Token == request.RefreshToken, ct)
|
||||||
?? throw new UnauthorizedException("无效的刷新令牌");
|
?? throw new UnauthorizedException("无效的刷新令牌");
|
||||||
|
|
||||||
|
// 已吊销或已过期均视为不可用,防止 refresh token 被复用
|
||||||
if (stored.IsRevoked || stored.ExpiresAt < DateTime.UtcNow)
|
if (stored.IsRevoked || stored.ExpiresAt < DateTime.UtcNow)
|
||||||
throw new UnauthorizedException("刷新令牌已过期或已吊销");
|
throw new UnauthorizedException("刷新令牌已过期或已吊销");
|
||||||
|
|
||||||
|
// 旧 token 立即作废,强制下一次刷新必须用新 token(Rotation 防盗用)
|
||||||
stored.IsRevoked = true;
|
stored.IsRevoked = true;
|
||||||
|
|
||||||
var user = stored.User;
|
var user = stored.User;
|
||||||
|
|||||||
@ -12,6 +12,7 @@ public class RegisterCommandHandler(RagDbContext db) : IRequestHandler<RegisterC
|
|||||||
{
|
{
|
||||||
public async Task<Unit> Handle(RegisterCommand request, CancellationToken ct)
|
public async Task<Unit> Handle(RegisterCommand request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
// 用户名与邮箱任一重复即拒绝,保证唯一性约束在业务层先行拦截
|
||||||
if (await db.Users.AnyAsync<User>(u => u.Username == request.Username || u.Email == request.Email, ct))
|
if (await db.Users.AnyAsync<User>(u => u.Username == request.Username || u.Email == request.Email, ct))
|
||||||
throw new BusinessException("用户名或邮箱已存在");
|
throw new BusinessException("用户名或邮箱已存在");
|
||||||
|
|
||||||
@ -19,9 +20,11 @@ public class RegisterCommandHandler(RagDbContext db) : IRequestHandler<RegisterC
|
|||||||
{
|
{
|
||||||
Username = request.Username,
|
Username = request.Username,
|
||||||
Email = request.Email,
|
Email = request.Email,
|
||||||
|
// 用 BCrypt 自带盐哈希密码,永不落地明文
|
||||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password)
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 自助注册一律授予最低权限的 "User" 角色,避免越权
|
||||||
var userRole = await db.Roles.FirstAsync<Role>(r => r.Name == "User", ct);
|
var userRole = await db.Roles.FirstAsync<Role>(r => r.Name == "User", ct);
|
||||||
await db.Users.AddAsync(user, ct);
|
await db.Users.AddAsync(user, ct);
|
||||||
await db.UserRoles.AddAsync(new UserRole { UserId = user.Id, RoleId = userRole.Id }, ct);
|
await db.UserRoles.AddAsync(new UserRole { UserId = user.Id, RoleId = userRole.Id }, ct);
|
||||||
|
|||||||
@ -0,0 +1,27 @@
|
|||||||
|
using MediatR;
|
||||||
|
using RAG.Application.Chat.DTOs;
|
||||||
|
using RAG.Domain.Common;
|
||||||
|
using RAG.Domain.Entities;
|
||||||
|
using RAG.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace RAG.Application.Chat.Commands;
|
||||||
|
|
||||||
|
public record CreateConversationCommand(string Title) : IRequest<ConversationDto>;
|
||||||
|
|
||||||
|
public class CreateConversationCommandHandler(RagDbContext db, ICurrentUserContext userContext) : IRequestHandler<CreateConversationCommand, ConversationDto>
|
||||||
|
{
|
||||||
|
public async Task<ConversationDto> Handle(CreateConversationCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var conversation = new Conversation
|
||||||
|
{
|
||||||
|
Title = request.Title,
|
||||||
|
UserId = Guid.Parse(userContext.GetRequiredUserId())
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.Conversations.AddAsync(conversation, ct);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return new ConversationDto(conversation.Id, conversation.Title, conversation.UserId,
|
||||||
|
conversation.KnowledgeBaseId, conversation.CreatedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
using MediatR;
|
||||||
|
using RAG.Domain.Exceptions;
|
||||||
|
using RAG.Domain.Interfaces;
|
||||||
|
using RAG.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace RAG.Application.Chat.Commands;
|
||||||
|
|
||||||
|
public record DeleteConversationCommand(Guid Id) : IRequest<Unit>;
|
||||||
|
|
||||||
|
public class DeleteConversationCommandHandler(RagDbContext db, IChatMessageCache cache)
|
||||||
|
: IRequestHandler<DeleteConversationCommand, Unit>
|
||||||
|
{
|
||||||
|
public async Task<Unit> Handle(DeleteConversationCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var conversation = await db.Conversations.FindAsync([request.Id], ct)
|
||||||
|
?? throw new NotFoundException("会话不存在");
|
||||||
|
|
||||||
|
db.Conversations.Remove(conversation);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
await cache.RemoveAsync(request.Id, ct);
|
||||||
|
|
||||||
|
return Unit.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/RAG.Application/Chat/Commands/SendMessageCommand.cs
Normal file
68
src/RAG.Application/Chat/Commands/SendMessageCommand.cs
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using RAG.Application.Chat.DTOs;
|
||||||
|
using RAG.Domain.Entities;
|
||||||
|
using RAG.Domain.Enums;
|
||||||
|
using RAG.Domain.Exceptions;
|
||||||
|
using RAG.Domain.Interfaces;
|
||||||
|
using RAG.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace RAG.Application.Chat.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 普通对话发送消息命令(非 RAG)。维护多轮上下文:双写 PostgreSQL(持久)+ Redis(热读缓存),
|
||||||
|
/// 取出历史拼成 prompt 后调用 LLM 生成回复,回复同样双写并落库。
|
||||||
|
/// </summary>
|
||||||
|
public record SendMessageCommand(Guid ConversationId, string Content) : IRequest<SendMessageResponse>;
|
||||||
|
|
||||||
|
public class SendMessageCommandHandler(RagDbContext db, IAIChatAgent chatAgent, IChatMessageCache cache)
|
||||||
|
: IRequestHandler<SendMessageCommand, SendMessageResponse>
|
||||||
|
{
|
||||||
|
public async Task<SendMessageResponse> Handle(SendMessageCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var conversation = await db.Conversations.FindAsync([request.ConversationId], ct)
|
||||||
|
?? throw new NotFoundException("会话不存在");
|
||||||
|
|
||||||
|
// 保存用户消息
|
||||||
|
var userMessage = new ChatMessage
|
||||||
|
{
|
||||||
|
ConversationId = request.ConversationId,
|
||||||
|
Role = ChatRole.User,
|
||||||
|
Content = request.Content
|
||||||
|
};
|
||||||
|
await db.ChatMessages.AddAsync(userMessage, ct);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
// 追加到 Redis
|
||||||
|
// 双写:DB 为持久真源,Redis 供流式接口与最近消息高频读取,最终一致即可
|
||||||
|
await cache.AppendMessageAsync(request.ConversationId,
|
||||||
|
new CachedChatMessage(userMessage.Id, ChatRole.User.ToString(), userMessage.Content, null, userMessage.CreatedAt), ct);
|
||||||
|
|
||||||
|
// 获取历史消息构建上下文
|
||||||
|
// 从 DB 取全量历史而非 Redis:保证上下文完整且时序正确,避免缓存与库不一致
|
||||||
|
var history = await db.ChatMessages
|
||||||
|
.Where(m => m.ConversationId == request.ConversationId)
|
||||||
|
.OrderBy(m => m.CreatedAt)
|
||||||
|
.Select(m => $"{m.Role}: {m.Content}")
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var prompt = string.Join("\n", history);
|
||||||
|
var reply = await chatAgent.RunAsync(prompt, ct);
|
||||||
|
|
||||||
|
// 保存助手回复
|
||||||
|
var assistantMessage = new ChatMessage
|
||||||
|
{
|
||||||
|
ConversationId = request.ConversationId,
|
||||||
|
Role = ChatRole.Assistant,
|
||||||
|
Content = reply
|
||||||
|
};
|
||||||
|
await db.ChatMessages.AddAsync(assistantMessage, ct);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
// 追加到 Redis
|
||||||
|
await cache.AppendMessageAsync(request.ConversationId,
|
||||||
|
new CachedChatMessage(assistantMessage.Id, ChatRole.Assistant.ToString(), assistantMessage.Content, null, assistantMessage.CreatedAt), ct);
|
||||||
|
|
||||||
|
return new SendMessageResponse(assistantMessage.Id, reply);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/RAG.Application/Chat/DTOs/ChatDTOs.cs
Normal file
10
src/RAG.Application/Chat/DTOs/ChatDTOs.cs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
namespace RAG.Application.Chat.DTOs;
|
||||||
|
|
||||||
|
public record ConversationDto(Guid Id, string Title, Guid UserId, Guid? KnowledgeBaseId, DateTime CreatedAt);
|
||||||
|
|
||||||
|
public record ConversationDetailDto(Guid Id, string Title, Guid UserId, Guid? KnowledgeBaseId,
|
||||||
|
DateTime CreatedAt, List<ChatMessageDto> Messages);
|
||||||
|
|
||||||
|
public record ChatMessageDto(Guid Id, string Role, string Content, int? TokenUsage, DateTime CreatedAt);
|
||||||
|
|
||||||
|
public record SendMessageResponse(Guid MessageId, string Content);
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using RAG.Application.Chat.DTOs;
|
||||||
|
using RAG.Domain.Exceptions;
|
||||||
|
using RAG.Domain.Interfaces;
|
||||||
|
using RAG.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace RAG.Application.Chat.Queries;
|
||||||
|
|
||||||
|
public record GetConversationDetailQuery(Guid Id) : IRequest<ConversationDetailDto>;
|
||||||
|
|
||||||
|
public class GetConversationDetailQueryHandler(RagDbContext db, IChatMessageCache cache)
|
||||||
|
: IRequestHandler<GetConversationDetailQuery, ConversationDetailDto>
|
||||||
|
{
|
||||||
|
public async Task<ConversationDetailDto> Handle(GetConversationDetailQuery request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var conversation = await db.Conversations.FindAsync([request.Id], ct)
|
||||||
|
?? throw new NotFoundException("会话不存在");
|
||||||
|
|
||||||
|
// 先查 Redis 缓存
|
||||||
|
var cached = await cache.GetMessagesAsync(request.Id, ct);
|
||||||
|
if (cached is not null)
|
||||||
|
{
|
||||||
|
var messages = cached.Select(m => new ChatMessageDto(m.Id, m.Role, m.Content, m.TokenUsage, m.CreatedAt)).ToList();
|
||||||
|
return new ConversationDetailDto(conversation.Id, conversation.Title, conversation.UserId,
|
||||||
|
conversation.KnowledgeBaseId, conversation.CreatedAt, messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存 miss,查数据库
|
||||||
|
var dbMessages = await db.ChatMessages
|
||||||
|
.Where(m => m.ConversationId == request.Id)
|
||||||
|
.OrderBy(m => m.CreatedAt)
|
||||||
|
.Select(m => new ChatMessageDto(m.Id, m.Role.ToString(), m.Content, m.TokenUsage, m.CreatedAt))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
// 回填 Redis
|
||||||
|
var cachedMessages = dbMessages.Select(m => new CachedChatMessage(m.Id, m.Role, m.Content, m.TokenUsage, m.CreatedAt)).ToList();
|
||||||
|
await cache.SetMessagesAsync(request.Id, cachedMessages, ct);
|
||||||
|
|
||||||
|
return new ConversationDetailDto(conversation.Id, conversation.Title, conversation.UserId,
|
||||||
|
conversation.KnowledgeBaseId, conversation.CreatedAt, dbMessages);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/RAG.Application/Chat/Queries/GetConversationsQuery.cs
Normal file
22
src/RAG.Application/Chat/Queries/GetConversationsQuery.cs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using RAG.Application.Chat.DTOs;
|
||||||
|
using RAG.Domain.Common;
|
||||||
|
using RAG.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace RAG.Application.Chat.Queries;
|
||||||
|
|
||||||
|
public record GetConversationsQuery : IRequest<List<ConversationDto>>;
|
||||||
|
|
||||||
|
public class GetConversationsQueryHandler(RagDbContext db, ICurrentUserContext userContext) : IRequestHandler<GetConversationsQuery, List<ConversationDto>>
|
||||||
|
{
|
||||||
|
public async Task<List<ConversationDto>> Handle(GetConversationsQuery request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = Guid.Parse(userContext.GetRequiredUserId());
|
||||||
|
return await db.Conversations
|
||||||
|
.Where(c => c.UserId == userId)
|
||||||
|
.OrderByDescending(c => c.CreatedAt)
|
||||||
|
.Select(c => new ConversationDto(c.Id, c.Title, c.UserId, c.KnowledgeBaseId, c.CreatedAt))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/RAG.Application/Chat/Validators/ChatValidators.cs
Normal file
22
src/RAG.Application/Chat/Validators/ChatValidators.cs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace RAG.Application.Chat.Validators;
|
||||||
|
|
||||||
|
public class CreateConversationCommandValidator : AbstractValidator<Commands.CreateConversationCommand>
|
||||||
|
{
|
||||||
|
public CreateConversationCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Title).NotEmpty().WithMessage("会话标题不能为空")
|
||||||
|
.MaximumLength(200).WithMessage("会话标题不能超过200个字符");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SendMessageCommandValidator : AbstractValidator<Commands.SendMessageCommand>
|
||||||
|
{
|
||||||
|
public SendMessageCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.ConversationId).NotEmpty().WithMessage("会话ID不能为空");
|
||||||
|
RuleFor(x => x.Content).NotEmpty().WithMessage("消息内容不能为空")
|
||||||
|
.MaximumLength(10000).WithMessage("消息内容不能超过10000个字符");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,11 @@ using MediatR;
|
|||||||
|
|
||||||
namespace RAG.Application.Common;
|
namespace RAG.Application.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MediatR 管线行为:在 Handler 执行前自动运行所有匹配的 FluentValidation 校验器。
|
||||||
|
/// 任一校验失败即抛 ValidationException(被 GlobalExceptionMiddleware 映射为 400),
|
||||||
|
/// 使业务 Handler 内无需重复写校验代码,统一在管线层拦截非法请求。
|
||||||
|
/// </summary>
|
||||||
public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
|
public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
|
||||||
: IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse>
|
: IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse>
|
||||||
{
|
{
|
||||||
|
|||||||
229
src/RAG.Application/Document/Commands/ProcessDocumentCommand.cs
Normal file
229
src/RAG.Application/Document/Commands/ProcessDocumentCommand.cs
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using RAG.Application.Document.DTOs;
|
||||||
|
using RAG.Domain.Interfaces;
|
||||||
|
using RAG.Domain.Entities;
|
||||||
|
using RAG.Domain.Enums;
|
||||||
|
using RAG.Domain.Exceptions;
|
||||||
|
using RAG.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace RAG.Application.Document.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 文档处理命令——RAG 离线流水线核心。四阶段串联执行:
|
||||||
|
/// 1) 提取:按扩展名分发到对应 Extractor(PDF/DOCX/MD/HTML/TXT),把二进制还原为纯文本+元数据;
|
||||||
|
/// 2) 分块:按知识库配置的策略(General/Heading/ParentChild)切成可向量化的小块;
|
||||||
|
/// 3) 向量化:批量调 embedding 模型,为每块生成向量;
|
||||||
|
/// 4) 入库:分块元数据走 EF,向量字段走 raw SQL(EF 不识别 pgvector 类型)。
|
||||||
|
/// 任何阶段失败都把文档状态置为 Failed 并保留错误信息,支持后续重试。
|
||||||
|
/// </summary>
|
||||||
|
public record ProcessDocumentCommand(Guid DocumentId) : IRequest<ProcessDocumentResponse>;
|
||||||
|
|
||||||
|
public class ProcessDocumentCommandHandler(
|
||||||
|
RagDbContext db,
|
||||||
|
IDocumentExtractorFactory extractorFactory,
|
||||||
|
ITextChunker chunker,
|
||||||
|
IEmbeddingService embeddingService,
|
||||||
|
IFileStorageClient fileStorage,
|
||||||
|
IHttpContextAccessor httpContextAccessor)
|
||||||
|
: IRequestHandler<ProcessDocumentCommand, ProcessDocumentResponse>
|
||||||
|
{
|
||||||
|
public async Task<ProcessDocumentResponse> Handle(ProcessDocumentCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var doc = await db.Documents.FindAsync([request.DocumentId], ct)
|
||||||
|
?? throw new NotFoundException("文档不存在");
|
||||||
|
|
||||||
|
if (doc.Status == DocumentStatus.Processing)
|
||||||
|
throw new BusinessException("文档正在处理中");
|
||||||
|
// 并发互斥:同一文档正在处理则拒绝,避免重复入库与向量覆盖竞态(上)
|
||||||
|
var kb = await db.KnowledgeBases.FindAsync([doc.KnowledgeBaseId], ct)
|
||||||
|
?? throw new NotFoundException("知识库不存在");
|
||||||
|
|
||||||
|
// 抢先置为 Processing 并落库,作为"软锁"挡住 UI 层重复点击(无强一致性,仅尽力而为)
|
||||||
|
doc.Status = DocumentStatus.Processing;
|
||||||
|
doc.ErrorMessage = null;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
string? tempDownloadPath = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 解析文件来源:本地路径直接用;文件服务路径(bucket/objectKey)下载到临时文件
|
||||||
|
var extractPath = await ResolveFilePathAsync(doc, ct);
|
||||||
|
tempDownloadPath = extractPath == doc.FilePath ? null : extractPath;
|
||||||
|
|
||||||
|
// ===== 阶段 1:提取(按文件类型选择提取器,取代原先的 File.ReadAllTextAsync)=====
|
||||||
|
var extractor = extractorFactory.GetExtractor(doc.FileName);
|
||||||
|
ExtractedDocument extracted;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
extracted = await extractor.ExtractAsync(extractPath, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not BusinessException)
|
||||||
|
{
|
||||||
|
// 文件损坏/格式错误等提取失败,转化为用户可理解的错误
|
||||||
|
throw new BusinessException($"无法解析文件「{doc.FileName}」:文件可能已损坏或格式不被支持({GetRootMessage(ex)})");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空内容直接标记完成(避免产生空分块)
|
||||||
|
if (string.IsNullOrWhiteSpace(extracted.Content))
|
||||||
|
throw new BusinessException($"文件「{doc.FileName}」提取到的内容为空,无法处理");
|
||||||
|
|
||||||
|
// 回填提取得到的标题/元数据
|
||||||
|
if (!string.IsNullOrWhiteSpace(extracted.Title) && doc.Title == doc.FileName)
|
||||||
|
doc.Title = extracted.Title;
|
||||||
|
doc.MetadataJson = extracted.MetadataJson ?? doc.MetadataJson;
|
||||||
|
doc.ContentHash = ComputeHash(extracted.Content);
|
||||||
|
|
||||||
|
// ===== 阶段 2:分块(优先用文档级覆盖策略,否则用知识库默认策略)=====
|
||||||
|
var effectiveMode = doc.ChunkingModeOverride ?? kb.ChunkingMode;
|
||||||
|
// 走带 document 的重载,支持 ByPage(页信息)和 Separator(自定义分隔符)
|
||||||
|
var chunks = chunker.Chunk(extracted, effectiveMode, kb.ChunkSize, kb.ChunkOverlap, kb.Separator);
|
||||||
|
|
||||||
|
// 批量向量化所有块(父块和子块都向量化,召回阶段已排除父块重复召回)
|
||||||
|
var texts = chunks.Select(c => c.Content).ToList();
|
||||||
|
var vectors = texts.Count > 0
|
||||||
|
? await embeddingService.EmbedBatchAsync(texts, ct)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// 删除旧分块
|
||||||
|
var existingChunks = await db.DocumentChunks
|
||||||
|
.Where(c => c.DocumentId == doc.Id)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
db.DocumentChunks.RemoveRange(existingChunks);
|
||||||
|
|
||||||
|
// ===== 阶段 3:保存新分块 =====
|
||||||
|
var chunkEntities = new List<DocumentChunk>();
|
||||||
|
for (var i = 0; i < chunks.Count; i++)
|
||||||
|
{
|
||||||
|
var chunk = new DocumentChunk
|
||||||
|
{
|
||||||
|
DocumentId = doc.Id,
|
||||||
|
Content = chunks[i].Content,
|
||||||
|
ChunkIndex = chunks[i].Index,
|
||||||
|
TokenCount = chunks[i].Content.Length / 4, // 粗略估算
|
||||||
|
HeadingPath = chunks[i].HeadingPath,
|
||||||
|
MetadataJson = doc.MetadataJson
|
||||||
|
};
|
||||||
|
chunkEntities.Add(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.DocumentChunks.AddRangeAsync(chunkEntities, ct);
|
||||||
|
|
||||||
|
doc.ChunkCount = chunks.Count;
|
||||||
|
doc.Status = DocumentStatus.Completed;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
// 二次遍历回填 ParentChunkId(父块先保存拿到 Id 后再关联)
|
||||||
|
var parentIdMap = chunks
|
||||||
|
.Select((c, idx) => (c, idx))
|
||||||
|
.Where(x => x.c.IsParent)
|
||||||
|
.ToDictionary(x => x.c.Index, x => chunkEntities[x.idx].Id);
|
||||||
|
var needsParentLink = false;
|
||||||
|
for (var i = 0; i < chunks.Count; i++)
|
||||||
|
{
|
||||||
|
if (chunks[i].ParentChunkIndex is int pIdx && parentIdMap.TryGetValue(pIdx, out var parentId))
|
||||||
|
{
|
||||||
|
chunkEntities[i].ParentChunkId = parentId;
|
||||||
|
needsParentLink = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (needsParentLink)
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
// ===== 阶段 4:写向量(EF 忽略 Embedding 属性,用 raw SQL 写 pgvector)=====
|
||||||
|
// EF Core 不识别 pgvector 的 vector 列类型,无法通过实体属性写入,
|
||||||
|
// 故用插值 raw SQL(ExecuteSqlAsync 自动参数化,避免注入)逐块回填 embedding 字段
|
||||||
|
for (var i = 0; i < chunkEntities.Count; i++)
|
||||||
|
{
|
||||||
|
var vectorStr = $"[{string.Join(",", vectors[i])}]";
|
||||||
|
var chunkId = chunkEntities[i].Id;
|
||||||
|
await db.Database.ExecuteSqlAsync(
|
||||||
|
$"UPDATE document_chunks SET embedding = {vectorStr}::vector WHERE \"Id\" = {chunkId}", ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ProcessDocumentResponse(doc.Id, doc.ChunkCount, doc.Status.ToString());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// 记录失败状态。若失败发生在 SaveChanges(如 DB 约束),
|
||||||
|
// 已追踪的 chunk 实体会导致再次 SaveChanges 同样失败,需先清除变更。
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var entry in db.ChangeTracker.Entries<DocumentChunk>().ToList())
|
||||||
|
entry.State = EntityState.Detached;
|
||||||
|
|
||||||
|
doc.Status = DocumentStatus.Failed;
|
||||||
|
var errMsg = ex is BusinessException ? ex.Message : GetRootMessage(ex);
|
||||||
|
doc.ErrorMessage = errMsg.Length > 500 ? errMsg[..500] : errMsg;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 错误记录本身失败时不影响主错误抛出
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取阶段的错误(文件损坏/格式不支持)属于用户可理解的业务错误,
|
||||||
|
// 应返回 400 而非 500。内部错误(DB/模型)同样转为业务错误。
|
||||||
|
if (ex is BusinessException or Domain.Exceptions.NotFoundException)
|
||||||
|
throw;
|
||||||
|
|
||||||
|
throw new BusinessException($"文档「{doc.FileName}」处理失败:{GetRootMessage(ex)}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// 清理从文件服务下载的临时文件(本地路径/Obsidian 不清理)
|
||||||
|
if (tempDownloadPath is not null && File.Exists(tempDownloadPath))
|
||||||
|
{
|
||||||
|
try { File.Delete(tempDownloadPath); } catch { /* 忽略清理失败 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 解析文档文件路径,返回提取器可用的本地文件路径。
|
||||||
|
/// 文件服务路径(bucket/objectKey)→ 下载到临时文件;本地路径(旧数据/Obsidian)→ 直接返回。
|
||||||
|
/// </summary>
|
||||||
|
private async Task<string> ResolveFilePathAsync(RAG.Domain.Entities.Document doc, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// 本地路径判断:绝对路径且文件存在(兼容旧数据 + Obsidian vault 文件)
|
||||||
|
if (File.Exists(doc.FilePath))
|
||||||
|
return doc.FilePath;
|
||||||
|
|
||||||
|
// 文件服务路径格式:"bucket/objectKey"(可能含多级,如 "rag-documents/docs/kb/{guid}/file.md")
|
||||||
|
var slashIdx = doc.FilePath.IndexOf('/');
|
||||||
|
if (slashIdx <= 0 || slashIdx >= doc.FilePath.Length - 1)
|
||||||
|
throw new BusinessException($"文档路径格式无效:{doc.FilePath}");
|
||||||
|
|
||||||
|
var bucket = doc.FilePath[..slashIdx];
|
||||||
|
var objectKey = doc.FilePath[(slashIdx + 1)..];
|
||||||
|
var authToken = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString().Replace("Bearer ", "");
|
||||||
|
|
||||||
|
// 下载到临时文件(保留扩展名,提取器按扩展名分发)
|
||||||
|
var ext = Path.GetExtension(doc.FileName);
|
||||||
|
var tempPath = Path.Combine(Path.GetTempPath(), $"rag_extract_{Guid.NewGuid()}{ext}");
|
||||||
|
await using (var stream = await fileStorage.DownloadAsync(bucket, objectKey, authToken, ct))
|
||||||
|
await using (var fs = File.Create(tempPath))
|
||||||
|
{
|
||||||
|
await stream.CopyToAsync(fs, ct);
|
||||||
|
}
|
||||||
|
return tempPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>提取异常的最内层消息,避免把堆栈包装暴露给前端。</summary>
|
||||||
|
private static string GetRootMessage(Exception ex)
|
||||||
|
{
|
||||||
|
var msg = ex.Message;
|
||||||
|
// 取第一行、截断
|
||||||
|
var nl = msg.IndexOfAny(['\r', '\n']);
|
||||||
|
if (nl > 0) msg = msg[..nl];
|
||||||
|
return msg.Length > 150 ? msg[..150] : msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ComputeHash(string content)
|
||||||
|
{
|
||||||
|
var bytes = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(content));
|
||||||
|
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using RAG.Application.Document.DTOs;
|
||||||
|
using RAG.Domain.Enums;
|
||||||
|
using RAG.Domain.Exceptions;
|
||||||
|
using RAG.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace RAG.Application.Document.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 批量处理知识库内所有 Pending/Failed 状态的文档(提取->分块->向量化)。
|
||||||
|
/// 用于 Obsidian 同步后一键入库,或失败文档批量重试。
|
||||||
|
/// </summary>
|
||||||
|
public record ProcessKnowledgeBaseCommand(Guid KnowledgeBaseId) : IRequest<BatchProcessResponse>;
|
||||||
|
|
||||||
|
public class ProcessKnowledgeBaseCommandHandler(
|
||||||
|
RagDbContext db,
|
||||||
|
IMediator mediator)
|
||||||
|
: IRequestHandler<ProcessKnowledgeBaseCommand, BatchProcessResponse>
|
||||||
|
{
|
||||||
|
public async Task<BatchProcessResponse> Handle(ProcessKnowledgeBaseCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var kbExists = await db.KnowledgeBases.AnyAsync(k => k.Id == request.KnowledgeBaseId, ct);
|
||||||
|
if (!kbExists) throw new NotFoundException("知识库不存在");
|
||||||
|
|
||||||
|
var docIds = await db.Documents
|
||||||
|
.Where(d => d.KnowledgeBaseId == request.KnowledgeBaseId
|
||||||
|
&& (d.Status == DocumentStatus.Pending || d.Status == DocumentStatus.Failed))
|
||||||
|
.Select(d => d.Id)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var succeeded = 0;
|
||||||
|
var failed = 0;
|
||||||
|
var errors = new List<BatchProcessError>();
|
||||||
|
|
||||||
|
foreach (var docId in docIds)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await mediator.Send(new ProcessDocumentCommand(docId), ct);
|
||||||
|
succeeded++;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
failed++;
|
||||||
|
errors.Add(new BatchProcessError(docId, ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BatchProcessResponse(request.KnowledgeBaseId, docIds.Count, succeeded, failed, errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using RAG.Application.Document.DTOs;
|
||||||
|
using RAG.Domain.Interfaces;
|
||||||
|
using RAG.Domain.Enums;
|
||||||
|
using RAG.Domain.Exceptions;
|
||||||
|
using RAG.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace RAG.Application.Document.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 上传文档命令。文件通过 IFileStorageClient 存到文件服务(file-system)的 S3,
|
||||||
|
/// 此处记录元数据 + objectKey(格式 "bucket/objectKey"),状态置 Pending,等待后续处理。
|
||||||
|
/// 可选 ChunkingModeOverride 允许单文档覆盖知识库默认分块策略。
|
||||||
|
/// </summary>
|
||||||
|
public record UploadDocumentCommand(
|
||||||
|
Guid KnowledgeBaseId,
|
||||||
|
string Title,
|
||||||
|
string FileName,
|
||||||
|
Stream FileStream,
|
||||||
|
long FileSize,
|
||||||
|
string ContentType,
|
||||||
|
ChunkingMode? ChunkingModeOverride = null
|
||||||
|
) : IRequest<DocumentDto>;
|
||||||
|
|
||||||
|
public class UploadDocumentCommandHandler(
|
||||||
|
RagDbContext db,
|
||||||
|
IFileStorageClient fileStorage,
|
||||||
|
IConfiguration config,
|
||||||
|
IHttpContextAccessor httpContextAccessor) : IRequestHandler<UploadDocumentCommand, DocumentDto>
|
||||||
|
{
|
||||||
|
public async Task<DocumentDto> Handle(UploadDocumentCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var kb = await db.KnowledgeBases.FindAsync([request.KnowledgeBaseId], ct)
|
||||||
|
?? throw new NotFoundException("知识库不存在");
|
||||||
|
|
||||||
|
// 上传到文件服务 S3。objectKey 用文档 GUID 防冲突
|
||||||
|
var bucket = config["FileService:Bucket"] ?? "rag-documents";
|
||||||
|
var docId = Guid.NewGuid();
|
||||||
|
var objectKey = $"docs/{request.KnowledgeBaseId}/{docId}/{request.FileName}";
|
||||||
|
var authToken = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString().Replace("Bearer ", "");
|
||||||
|
|
||||||
|
await fileStorage.UploadAsync(bucket, objectKey, request.FileStream, request.ContentType, authToken, ct);
|
||||||
|
|
||||||
|
var doc = new RAG.Domain.Entities.Document
|
||||||
|
{
|
||||||
|
Id = docId,
|
||||||
|
KnowledgeBaseId = request.KnowledgeBaseId,
|
||||||
|
Title = request.Title,
|
||||||
|
FileName = request.FileName,
|
||||||
|
FilePath = $"{bucket}/{objectKey}", // 存 "bucket/objectKey" 格式
|
||||||
|
FileSize = request.FileSize,
|
||||||
|
ContentType = request.ContentType,
|
||||||
|
Status = DocumentStatus.Pending,
|
||||||
|
ChunkingModeOverride = request.ChunkingModeOverride
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.Documents.AddAsync(doc, ct);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return new DocumentDto(doc.Id, doc.KnowledgeBaseId, doc.Title, doc.FileName,
|
||||||
|
doc.FileSize, doc.ContentType, doc.ChunkCount, doc.Status.ToString(), doc.CreatedAt,
|
||||||
|
(int)doc.SourceType, doc.VaultPath, doc.ErrorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/RAG.Application/Document/DTOs/DocumentDTOs.cs
Normal file
38
src/RAG.Application/Document/DTOs/DocumentDTOs.cs
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
namespace RAG.Application.Document.DTOs;
|
||||||
|
|
||||||
|
public record DocumentDto(
|
||||||
|
Guid Id,
|
||||||
|
Guid KnowledgeBaseId,
|
||||||
|
string Title,
|
||||||
|
string FileName,
|
||||||
|
long FileSize,
|
||||||
|
string ContentType,
|
||||||
|
int ChunkCount,
|
||||||
|
string Status,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
int SourceType = 0,
|
||||||
|
string? VaultPath = null,
|
||||||
|
string? ErrorMessage = null);
|
||||||
|
|
||||||
|
public record ProcessDocumentResponse(Guid DocumentId, int ChunkCount, string Status);
|
||||||
|
|
||||||
|
public record ChunkPreviewDto(
|
||||||
|
Guid Id,
|
||||||
|
int ChunkIndex,
|
||||||
|
string Content,
|
||||||
|
int TokenCount,
|
||||||
|
bool HasEmbedding,
|
||||||
|
int? EmbeddingDimension,
|
||||||
|
List<float>? EmbeddingPreview,
|
||||||
|
string? HeadingPath = null,
|
||||||
|
Guid? ParentChunkId = null
|
||||||
|
);
|
||||||
|
|
||||||
|
public record BatchProcessResponse(
|
||||||
|
Guid KnowledgeBaseId,
|
||||||
|
int Total,
|
||||||
|
int Succeeded,
|
||||||
|
int Failed,
|
||||||
|
List<BatchProcessError> Errors);
|
||||||
|
|
||||||
|
public record BatchProcessError(Guid DocumentId, string Message);
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using RAG.Application.Document.DTOs;
|
||||||
|
using RAG.Domain.Exceptions;
|
||||||
|
using RAG.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace RAG.Application.Document.Queries;
|
||||||
|
|
||||||
|
public record GetDocumentChunksQuery(Guid DocumentId) : IRequest<List<ChunkPreviewDto>>;
|
||||||
|
|
||||||
|
public class GetDocumentChunksQueryHandler(RagDbContext db)
|
||||||
|
: IRequestHandler<GetDocumentChunksQuery, List<ChunkPreviewDto>>
|
||||||
|
{
|
||||||
|
public async Task<List<ChunkPreviewDto>> Handle(GetDocumentChunksQuery request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var doc = await db.Documents.FindAsync([request.DocumentId], ct)
|
||||||
|
?? throw new NotFoundException("文档不存在");
|
||||||
|
|
||||||
|
var chunks = await db.DocumentChunks
|
||||||
|
.Where(c => c.DocumentId == request.DocumentId)
|
||||||
|
.OrderBy(c => c.ChunkIndex)
|
||||||
|
.Select(c => new { c.Id, c.ChunkIndex, c.Content, c.TokenCount, c.HeadingPath, c.ParentChunkId })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
// 查询 embedding 状态和预览(EF Ignore 了 Embedding,用 raw SQL)
|
||||||
|
var conn = db.Database.GetDbConnection();
|
||||||
|
await conn.OpenAsync(ct);
|
||||||
|
var embeddingInfo = new Dictionary<Guid, (bool Has, int? Dim, List<float>? Preview)>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ids = string.Join("','", chunks.Select(c => c.Id.ToString()));
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = $"""
|
||||||
|
SELECT "Id",
|
||||||
|
embedding IS NOT NULL as has_embedding,
|
||||||
|
CASE WHEN embedding IS NOT NULL THEN vector_dims(embedding) ELSE NULL END as dim,
|
||||||
|
embedding::text as embedding_text
|
||||||
|
FROM document_chunks
|
||||||
|
WHERE "DocumentId" = '{request.DocumentId}'
|
||||||
|
ORDER BY "ChunkIndex"
|
||||||
|
""";
|
||||||
|
using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||||
|
while (await reader.ReadAsync(ct))
|
||||||
|
{
|
||||||
|
var id = (Guid)reader["Id"];
|
||||||
|
var hasEmbedding = reader["has_embedding"] is bool b && b;
|
||||||
|
var dimObj = reader["dim"];
|
||||||
|
int? dim = dimObj is int d ? d : (dimObj is string s && int.TryParse(s, out var parsed) ? parsed : (int?)null);
|
||||||
|
List<float>? preview = null;
|
||||||
|
|
||||||
|
if (hasEmbedding)
|
||||||
|
{
|
||||||
|
var text = reader["embedding_text"]?.ToString() ?? "";
|
||||||
|
// 解析 [0.1, 0.2, ...] 取前10个
|
||||||
|
var cleaned = text.Trim('[', ']');
|
||||||
|
var parts = cleaned.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
preview = parts.Take(10).Select(p => float.TryParse(p.Trim(), out var v) ? v : 0f).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
embeddingInfo[id] = (hasEmbedding, dim, preview);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await conn.CloseAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks.Select(c =>
|
||||||
|
{
|
||||||
|
var info = embeddingInfo.GetValueOrDefault(c.Id);
|
||||||
|
return new ChunkPreviewDto(c.Id, c.ChunkIndex, c.Content, c.TokenCount, info.Has, info.Dim, info.Preview, c.HeadingPath, c.ParentChunkId);
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/RAG.Application/Document/Queries/GetDocumentsQuery.cs
Normal file
22
src/RAG.Application/Document/Queries/GetDocumentsQuery.cs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using RAG.Application.Document.DTOs;
|
||||||
|
using RAG.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace RAG.Application.Document.Queries;
|
||||||
|
|
||||||
|
public record GetDocumentsQuery(Guid KnowledgeBaseId) : IRequest<List<DocumentDto>>;
|
||||||
|
|
||||||
|
public class GetDocumentsQueryHandler(RagDbContext db) : IRequestHandler<GetDocumentsQuery, List<DocumentDto>>
|
||||||
|
{
|
||||||
|
public async Task<List<DocumentDto>> Handle(GetDocumentsQuery request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return await db.Documents
|
||||||
|
.Where(d => d.KnowledgeBaseId == request.KnowledgeBaseId)
|
||||||
|
.OrderByDescending(d => d.CreatedAt)
|
||||||
|
.Select(d => new DocumentDto(d.Id, d.KnowledgeBaseId, d.Title, d.FileName,
|
||||||
|
d.FileSize, d.ContentType, d.ChunkCount, d.Status.ToString(), d.CreatedAt,
|
||||||
|
(int)d.SourceType, d.VaultPath, d.ErrorMessage))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace RAG.Application.Document.Validators;
|
||||||
|
|
||||||
|
public class UploadDocumentCommandValidator : AbstractValidator<Commands.UploadDocumentCommand>
|
||||||
|
{
|
||||||
|
public UploadDocumentCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.KnowledgeBaseId).NotEmpty().WithMessage("知识库ID不能为空");
|
||||||
|
RuleFor(x => x.Title).NotEmpty().WithMessage("文档标题不能为空").MaximumLength(500);
|
||||||
|
RuleFor(x => x.FileName).NotEmpty().MaximumLength(500);
|
||||||
|
RuleFor(x => x.FileStream).NotNull().WithMessage("文件流不能为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ProcessDocumentCommandValidator : AbstractValidator<Commands.ProcessDocumentCommand>
|
||||||
|
{
|
||||||
|
public ProcessDocumentCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.DocumentId).NotEmpty().WithMessage("文档ID不能为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/RAG.Application/Embedding/Commands/EmbedBatchCommand.cs
Normal file
18
src/RAG.Application/Embedding/Commands/EmbedBatchCommand.cs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
using MediatR;
|
||||||
|
using RAG.Application.Embedding.DTOs;
|
||||||
|
using RAG.Domain.Interfaces;
|
||||||
|
|
||||||
|
namespace RAG.Application.Embedding.Commands;
|
||||||
|
|
||||||
|
public record EmbedBatchCommand(List<string> Texts) : IRequest<EmbeddingBatchResponse>;
|
||||||
|
|
||||||
|
public class EmbedBatchCommandHandler(IEmbeddingService embeddingService)
|
||||||
|
: IRequestHandler<EmbedBatchCommand, EmbeddingBatchResponse>
|
||||||
|
{
|
||||||
|
public async Task<EmbeddingBatchResponse> Handle(EmbedBatchCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var vectors = await embeddingService.EmbedBatchAsync(request.Texts, ct);
|
||||||
|
var dimensions = vectors.FirstOrDefault()?.Length ?? 0;
|
||||||
|
return new EmbeddingBatchResponse(vectors.Select(v => v.ToList()).ToList(), dimensions);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/RAG.Application/Embedding/Commands/EmbedTextCommand.cs
Normal file
17
src/RAG.Application/Embedding/Commands/EmbedTextCommand.cs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
using MediatR;
|
||||||
|
using RAG.Application.Embedding.DTOs;
|
||||||
|
using RAG.Domain.Interfaces;
|
||||||
|
|
||||||
|
namespace RAG.Application.Embedding.Commands;
|
||||||
|
|
||||||
|
public record EmbedTextCommand(string Text) : IRequest<EmbeddingResponse>;
|
||||||
|
|
||||||
|
public class EmbedTextCommandHandler(IEmbeddingService embeddingService)
|
||||||
|
: IRequestHandler<EmbedTextCommand, EmbeddingResponse>
|
||||||
|
{
|
||||||
|
public async Task<EmbeddingResponse> Handle(EmbedTextCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var vector = await embeddingService.EmbedAsync(request.Text, ct);
|
||||||
|
return new EmbeddingResponse(vector.ToList(), vector.Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/RAG.Application/Embedding/DTOs/EmbeddingDTOs.cs
Normal file
5
src/RAG.Application/Embedding/DTOs/EmbeddingDTOs.cs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
namespace RAG.Application.Embedding.DTOs;
|
||||||
|
|
||||||
|
public record EmbeddingResponse(List<float> Vector, int Dimensions);
|
||||||
|
|
||||||
|
public record EmbeddingBatchResponse(List<List<float>> Vectors, int Dimensions);
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace RAG.Application.Embedding.Validators;
|
||||||
|
|
||||||
|
public class EmbedTextCommandValidator : AbstractValidator<Commands.EmbedTextCommand>
|
||||||
|
{
|
||||||
|
public EmbedTextCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Text).NotEmpty().WithMessage("文本内容不能为空")
|
||||||
|
.MaximumLength(10000).WithMessage("单条文本不能超过10000个字符");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EmbedBatchCommandValidator : AbstractValidator<Commands.EmbedBatchCommand>
|
||||||
|
{
|
||||||
|
public EmbedBatchCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Texts).NotEmpty().WithMessage("文本列表不能为空")
|
||||||
|
.Must(t => t.Count <= 100).WithMessage("批量文本不能超过100条");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,95 @@
|
|||||||
|
using MediatR;
|
||||||
|
using RAG.Application.KnowledgeBase.DTOs;
|
||||||
|
using RAG.Domain.Enums;
|
||||||
|
using RAG.Domain.Exceptions;
|
||||||
|
using RAG.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace RAG.Application.KnowledgeBase.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建知识库命令。检索/分块参数全部可选(缺省取经验值),由 Validate 做范围与枚举校验,
|
||||||
|
/// 拒绝非法值(如 TopK 越界、阈值不在 0-1、上下文片段数大于召回数等),避免坏配置导致后续检索异常。
|
||||||
|
/// </summary>
|
||||||
|
public record CreateKnowledgeBaseCommand(
|
||||||
|
string Name,
|
||||||
|
string? Description,
|
||||||
|
string? EmbeddingModel,
|
||||||
|
int? ChunkSize,
|
||||||
|
int? ChunkOverlap,
|
||||||
|
int? ChunkingMode,
|
||||||
|
string? Separator,
|
||||||
|
int? RetrievalMode,
|
||||||
|
int? RerankStrategy,
|
||||||
|
int? RetrievalTopK,
|
||||||
|
int? ContextTopK,
|
||||||
|
double? SimilarityThreshold,
|
||||||
|
double? VectorWeight) : IRequest<KnowledgeBaseDto>;
|
||||||
|
|
||||||
|
public class CreateKnowledgeBaseCommandHandler(RagDbContext db) : IRequestHandler<CreateKnowledgeBaseCommand, KnowledgeBaseDto>
|
||||||
|
{
|
||||||
|
public async Task<KnowledgeBaseDto> Handle(CreateKnowledgeBaseCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// 参数校验:拒绝非法枚举值与不合理范围,防止坏配置污染后续检索
|
||||||
|
Validate(request);
|
||||||
|
|
||||||
|
var kb = new RAG.Domain.Entities.KnowledgeBase
|
||||||
|
{
|
||||||
|
Name = request.Name,
|
||||||
|
Description = request.Description,
|
||||||
|
EmbeddingModel = request.EmbeddingModel ?? "text-embedding-embeddinggemma-300m",
|
||||||
|
ChunkSize = request.ChunkSize ?? 500,
|
||||||
|
ChunkOverlap = request.ChunkOverlap ?? 50,
|
||||||
|
ChunkingMode = (ChunkingMode)(request.ChunkingMode ?? (int)ChunkingMode.General),
|
||||||
|
Separator = request.Separator,
|
||||||
|
RetrievalMode = (RetrievalMode)(request.RetrievalMode ?? (int)RetrievalMode.Hybrid),
|
||||||
|
RerankStrategy = (RerankStrategy)(request.RerankStrategy ?? (int)RerankStrategy.Keyword),
|
||||||
|
RetrievalTopK = request.RetrievalTopK ?? 20,
|
||||||
|
ContextTopK = request.ContextTopK ?? 5,
|
||||||
|
SimilarityThreshold = request.SimilarityThreshold ?? 0.3,
|
||||||
|
VectorWeight = request.VectorWeight ?? 0.7
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.KnowledgeBases.AddAsync(kb, ct);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return MapToDto(kb);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Validate(CreateKnowledgeBaseCommand r)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(r.Name))
|
||||||
|
throw new BusinessException("知识库名称不能为空");
|
||||||
|
|
||||||
|
ValidateEnum(r.ChunkingMode, 0, 5, "分块策略");
|
||||||
|
ValidateEnum(r.RetrievalMode, 0, 2, "检索模式");
|
||||||
|
ValidateEnum(r.RerankStrategy, 0, 2, "重排序策略");
|
||||||
|
|
||||||
|
if (r.ChunkSize is < 50 or > 5000)
|
||||||
|
throw new BusinessException("分块大小应在 50-5000 之间");
|
||||||
|
if (r.ChunkOverlap is < 0 or > 1000)
|
||||||
|
throw new BusinessException("分块重叠应在 0-1000 之间");
|
||||||
|
if (r.RetrievalTopK is < 1 or > 100)
|
||||||
|
throw new BusinessException("向量召回 TopK 应在 1-100 之间");
|
||||||
|
if (r.ContextTopK is < 1 or > 50)
|
||||||
|
throw new BusinessException("上下文片段数应在 1-50 之间");
|
||||||
|
if (r.SimilarityThreshold is < 0 or > 1)
|
||||||
|
throw new BusinessException("相似度阈值应在 0-1 之间");
|
||||||
|
if (r.VectorWeight is < 0 or > 1)
|
||||||
|
throw new BusinessException("向量权重应在 0-1 之间");
|
||||||
|
if (r.ContextTopK > r.RetrievalTopK)
|
||||||
|
throw new BusinessException("上下文片段数不应大于向量召回 TopK");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateEnum(int? value, int min, int max, string label)
|
||||||
|
{
|
||||||
|
if (value is not null && (value < min || value > max))
|
||||||
|
throw new BusinessException($"{label}的值无效,应为 {min}-{max}");
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static KnowledgeBaseDto MapToDto(RAG.Domain.Entities.KnowledgeBase kb) => new(
|
||||||
|
kb.Id, kb.Name, kb.Description, kb.EmbeddingModel,
|
||||||
|
kb.ChunkSize, kb.ChunkOverlap,
|
||||||
|
(int)kb.ChunkingMode, kb.Separator, (int)kb.RetrievalMode, (int)kb.RerankStrategy,
|
||||||
|
kb.RetrievalTopK, kb.ContextTopK, kb.SimilarityThreshold, kb.VectorWeight,
|
||||||
|
kb.Status.ToString(), kb.CreatedAt);
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
using MediatR;
|
||||||
|
using RAG.Domain.Exceptions;
|
||||||
|
using RAG.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace RAG.Application.KnowledgeBase.Commands;
|
||||||
|
|
||||||
|
public record DeleteKnowledgeBaseCommand(Guid Id) : IRequest<Unit>;
|
||||||
|
|
||||||
|
public class DeleteKnowledgeBaseCommandHandler(RagDbContext db) : IRequestHandler<DeleteKnowledgeBaseCommand, Unit>
|
||||||
|
{
|
||||||
|
public async Task<Unit> Handle(DeleteKnowledgeBaseCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var kb = await db.KnowledgeBases.FindAsync([request.Id], ct)
|
||||||
|
?? throw new NotFoundException("知识库不存在");
|
||||||
|
|
||||||
|
db.KnowledgeBases.Remove(kb);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Unit.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
using MediatR;
|
||||||
|
using RAG.Application.KnowledgeBase.DTOs;
|
||||||
|
using RAG.Domain.Enums;
|
||||||
|
using RAG.Domain.Exceptions;
|
||||||
|
using RAG.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace RAG.Application.KnowledgeBase.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新知识库配置(名称、描述、分块与检索参数)。仅更新非 null 字段。
|
||||||
|
/// 注意:修改分块/嵌入参数不会自动重新处理已有文档,需手动调用 process-all。
|
||||||
|
/// </summary>
|
||||||
|
public record UpdateKnowledgeBaseCommand(
|
||||||
|
Guid Id,
|
||||||
|
string? Name,
|
||||||
|
string? Description,
|
||||||
|
int? ChunkSize,
|
||||||
|
int? ChunkOverlap,
|
||||||
|
int? ChunkingMode,
|
||||||
|
string? Separator,
|
||||||
|
int? RetrievalMode,
|
||||||
|
int? RerankStrategy,
|
||||||
|
int? RetrievalTopK,
|
||||||
|
int? ContextTopK,
|
||||||
|
double? SimilarityThreshold,
|
||||||
|
double? VectorWeight) : IRequest<KnowledgeBaseDto>;
|
||||||
|
|
||||||
|
public class UpdateKnowledgeBaseCommandHandler(RagDbContext db)
|
||||||
|
: IRequestHandler<UpdateKnowledgeBaseCommand, KnowledgeBaseDto>
|
||||||
|
{
|
||||||
|
public async Task<KnowledgeBaseDto> Handle(UpdateKnowledgeBaseCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var kb = await db.KnowledgeBases.FindAsync([request.Id], ct)
|
||||||
|
?? throw new NotFoundException("知识库不存在");
|
||||||
|
|
||||||
|
// 校验(与创建保持一致)
|
||||||
|
ValidateEnum(request.ChunkingMode, 0, 5, "分块策略");
|
||||||
|
ValidateEnum(request.RetrievalMode, 0, 2, "检索模式");
|
||||||
|
ValidateEnum(request.RerankStrategy, 0, 2, "重排序策略");
|
||||||
|
if (request.ChunkSize is < 50 or > 5000)
|
||||||
|
throw new BusinessException("分块大小应在 50-5000 之间");
|
||||||
|
if (request.ChunkOverlap is < 0 or > 1000)
|
||||||
|
throw new BusinessException("分块重叠应在 0-1000 之间");
|
||||||
|
if (request.RetrievalTopK is < 1 or > 100)
|
||||||
|
throw new BusinessException("向量召回 TopK 应在 1-100 之间");
|
||||||
|
if (request.ContextTopK is < 1 or > 50)
|
||||||
|
throw new BusinessException("上下文片段数应在 1-50 之间");
|
||||||
|
if (request.SimilarityThreshold is < 0 or > 1)
|
||||||
|
throw new BusinessException("相似度阈值应在 0-1 之间");
|
||||||
|
if (request.VectorWeight is < 0 or > 1)
|
||||||
|
throw new BusinessException("向量权重应在 0-1 之间");
|
||||||
|
|
||||||
|
// 仅更新非 null 字段
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Name)) kb.Name = request.Name;
|
||||||
|
if (request.Description is not null) kb.Description = request.Description;
|
||||||
|
if (request.ChunkSize is not null) kb.ChunkSize = request.ChunkSize.Value;
|
||||||
|
if (request.ChunkOverlap is not null) kb.ChunkOverlap = request.ChunkOverlap.Value;
|
||||||
|
if (request.ChunkingMode is not null) kb.ChunkingMode = (ChunkingMode)request.ChunkingMode.Value;
|
||||||
|
if (request.Separator is not null) kb.Separator = request.Separator;
|
||||||
|
if (request.RetrievalMode is not null) kb.RetrievalMode = (RetrievalMode)request.RetrievalMode.Value;
|
||||||
|
if (request.RerankStrategy is not null) kb.RerankStrategy = (RerankStrategy)request.RerankStrategy.Value;
|
||||||
|
if (request.RetrievalTopK is not null) kb.RetrievalTopK = request.RetrievalTopK.Value;
|
||||||
|
if (request.ContextTopK is not null) kb.ContextTopK = request.ContextTopK.Value;
|
||||||
|
if (request.SimilarityThreshold is not null) kb.SimilarityThreshold = request.SimilarityThreshold.Value;
|
||||||
|
if (request.VectorWeight is not null) kb.VectorWeight = request.VectorWeight.Value;
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return CreateKnowledgeBaseCommandHandler.MapToDto(kb);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateEnum(int? value, int min, int max, string label)
|
||||||
|
{
|
||||||
|
if (value is not null && (value < min || value > max))
|
||||||
|
throw new BusinessException($"{label}的值无效,应为 {min}-{max}");
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/RAG.Application/KnowledgeBase/DTOs/KnowledgeBaseDTOs.cs
Normal file
48
src/RAG.Application/KnowledgeBase/DTOs/KnowledgeBaseDTOs.cs
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
namespace RAG.Application.KnowledgeBase.DTOs;
|
||||||
|
|
||||||
|
public record KnowledgeBaseDto(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
string? Description,
|
||||||
|
string EmbeddingModel,
|
||||||
|
int ChunkSize,
|
||||||
|
int ChunkOverlap,
|
||||||
|
int ChunkingMode,
|
||||||
|
string? Separator,
|
||||||
|
int RetrievalMode,
|
||||||
|
int RerankStrategy,
|
||||||
|
int RetrievalTopK,
|
||||||
|
int ContextTopK,
|
||||||
|
double SimilarityThreshold,
|
||||||
|
double VectorWeight,
|
||||||
|
string Status,
|
||||||
|
DateTime CreatedAt);
|
||||||
|
|
||||||
|
public record CreateKnowledgeBaseRequest(
|
||||||
|
string Name,
|
||||||
|
string? Description,
|
||||||
|
string? EmbeddingModel,
|
||||||
|
int? ChunkSize,
|
||||||
|
int? ChunkOverlap,
|
||||||
|
int? ChunkingMode,
|
||||||
|
string? Separator,
|
||||||
|
int? RetrievalMode,
|
||||||
|
int? RerankStrategy,
|
||||||
|
int? RetrievalTopK,
|
||||||
|
int? ContextTopK,
|
||||||
|
double? SimilarityThreshold,
|
||||||
|
double? VectorWeight);
|
||||||
|
|
||||||
|
public record UpdateKnowledgeBaseRequest(
|
||||||
|
string? Name,
|
||||||
|
string? Description,
|
||||||
|
int? ChunkSize,
|
||||||
|
int? ChunkOverlap,
|
||||||
|
int? ChunkingMode,
|
||||||
|
string? Separator,
|
||||||
|
int? RetrievalMode,
|
||||||
|
int? RerankStrategy,
|
||||||
|
int? RetrievalTopK,
|
||||||
|
int? ContextTopK,
|
||||||
|
double? SimilarityThreshold,
|
||||||
|
double? VectorWeight);
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
using MediatR;
|
||||||
|
using RAG.Application.KnowledgeBase.Commands;
|
||||||
|
using RAG.Application.KnowledgeBase.DTOs;
|
||||||
|
using RAG.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace RAG.Application.KnowledgeBase.Queries;
|
||||||
|
|
||||||
|
public record GetKnowledgeBasesQuery : IRequest<List<KnowledgeBaseDto>>;
|
||||||
|
|
||||||
|
public class GetKnowledgeBasesQueryHandler(RagDbContext db) : IRequestHandler<GetKnowledgeBasesQuery, List<KnowledgeBaseDto>>
|
||||||
|
{
|
||||||
|
public async Task<List<KnowledgeBaseDto>> Handle(GetKnowledgeBasesQuery request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var kbs = await db.KnowledgeBases
|
||||||
|
.OrderByDescending(k => k.CreatedAt)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return kbs.Select(CreateKnowledgeBaseCommandHandler.MapToDto).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/RAG.Application/KnowledgeBase/Validators/KBValidators.cs
Normal file
13
src/RAG.Application/KnowledgeBase/Validators/KBValidators.cs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace RAG.Application.KnowledgeBase.Validators;
|
||||||
|
|
||||||
|
public class CreateKnowledgeBaseCommandValidator : AbstractValidator<Commands.CreateKnowledgeBaseCommand>
|
||||||
|
{
|
||||||
|
public CreateKnowledgeBaseCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Name).NotEmpty().WithMessage("知识库名称不能为空")
|
||||||
|
.MaximumLength(200).WithMessage("名称不能超过200个字符");
|
||||||
|
RuleFor(x => x.Description).MaximumLength(1000).When(x => x.Description != null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using RAG.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace RAG.Application.Notifications.Commands;
|
||||||
|
|
||||||
|
/// <summary>标记单条通知为已读(仅限接收人本人操作)。</summary>
|
||||||
|
public record MarkNotificationReadCommand(Guid NotificationId, Guid RecipientUserId) : IRequest<bool>;
|
||||||
|
|
||||||
|
public class MarkNotificationReadCommandHandler(RagDbContext db)
|
||||||
|
: IRequestHandler<MarkNotificationReadCommand, bool>
|
||||||
|
{
|
||||||
|
public async Task<bool> Handle(MarkNotificationReadCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var notification = await db.Notifications
|
||||||
|
.FirstOrDefaultAsync(n => n.Id == request.NotificationId
|
||||||
|
&& n.RecipientUserId == request.RecipientUserId, ct);
|
||||||
|
if (notification is null || notification.IsRead)
|
||||||
|
return notification is not null;
|
||||||
|
|
||||||
|
notification.IsRead = true;
|
||||||
|
notification.ReadAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>标记当前用户所有通知为已读。</summary>
|
||||||
|
public record MarkAllNotificationsReadCommand(Guid RecipientUserId) : IRequest<int>;
|
||||||
|
|
||||||
|
public class MarkAllNotificationsReadCommandHandler(RagDbContext db)
|
||||||
|
: IRequestHandler<MarkAllNotificationsReadCommand, int>
|
||||||
|
{
|
||||||
|
public async Task<int> Handle(MarkAllNotificationsReadCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var unread = await db.Notifications
|
||||||
|
.Where(n => n.RecipientUserId == request.RecipientUserId && !n.IsRead)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
foreach (var n in unread)
|
||||||
|
{
|
||||||
|
n.IsRead = true;
|
||||||
|
n.ReadAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return unread.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using RAG.Domain.Entities;
|
||||||
|
using RAG.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace RAG.Application.Notifications.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发布通知(Fanout 扇出)。业务系统只需"推一条消息":
|
||||||
|
/// 提供 recipientUserIds(精确)或 recipientRoles(按角色展开为用户列表),
|
||||||
|
/// 中心为每个接收人生成一条独立 Notification 记录(空间换时间,读多写少)。
|
||||||
|
/// </summary>
|
||||||
|
public record PublishNotificationCommand(
|
||||||
|
string Type,
|
||||||
|
string Title,
|
||||||
|
string Content,
|
||||||
|
string Source,
|
||||||
|
string? RelatedId,
|
||||||
|
string? RelatedType,
|
||||||
|
IReadOnlyCollection<Guid>? RecipientUserIds,
|
||||||
|
IReadOnlyCollection<string>? RecipientRoles) : IRequest<int>;
|
||||||
|
|
||||||
|
public class PublishNotificationCommandHandler(
|
||||||
|
RagDbContext db,
|
||||||
|
INotificationDispatcher dispatcher) : IRequestHandler<PublishNotificationCommand, int>
|
||||||
|
{
|
||||||
|
public async Task<int> Handle(PublishNotificationCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// 解析最终接收人列表:直接指定 + 角色展开(去重)
|
||||||
|
var recipients = new HashSet<Guid>();
|
||||||
|
if (request.RecipientUserIds is { Count: > 0 } userIds)
|
||||||
|
{
|
||||||
|
recipients.UnionWith(userIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.RecipientRoles is { Count: > 0 } roles)
|
||||||
|
{
|
||||||
|
// 按角色展开:查询拥有任一指定角色且未软删的活跃用户
|
||||||
|
var userIdsByRole = await db.UserRoles
|
||||||
|
.Where(ur => roles.Contains(ur.Role.Name))
|
||||||
|
.Join(db.Users.Where(u => !u.IsDeleted && u.IsActive),
|
||||||
|
ur => ur.UserId, u => u.Id,
|
||||||
|
(ur, u) => u.Id)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(ct);
|
||||||
|
recipients.UnionWith(userIdsByRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipients.Count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
// Fanout:每个接收人一条记录
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var notifications = recipients.Select(userId => new Notification
|
||||||
|
{
|
||||||
|
RecipientUserId = userId,
|
||||||
|
Type = request.Type,
|
||||||
|
Title = request.Title,
|
||||||
|
Content = request.Content,
|
||||||
|
Source = request.Source,
|
||||||
|
RelatedId = request.RelatedId,
|
||||||
|
RelatedType = request.RelatedType,
|
||||||
|
IsRead = false,
|
||||||
|
CreatedAt = now,
|
||||||
|
UpdatedAt = now
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
await db.Notifications.AddRangeAsync(notifications, ct);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
// 实时推送:通知 dispatcher(SignalR / RabbitMQ 实现可插拔)
|
||||||
|
await dispatcher.DispatchAsync(notifications, ct);
|
||||||
|
|
||||||
|
return notifications.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/RAG.Application/Notifications/DTOs/NotificationDtos.cs
Normal file
18
src/RAG.Application/Notifications/DTOs/NotificationDtos.cs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
namespace RAG.Application.Notifications.DTOs;
|
||||||
|
|
||||||
|
/// <summary>通知 DTO。时间为毫秒时间戳(由统一序列化转换器输出)。</summary>
|
||||||
|
public record NotificationDto(
|
||||||
|
Guid Id,
|
||||||
|
Guid RecipientUserId,
|
||||||
|
string Type,
|
||||||
|
string Title,
|
||||||
|
string Content,
|
||||||
|
string Source,
|
||||||
|
string? RelatedId,
|
||||||
|
string? RelatedType,
|
||||||
|
bool IsRead,
|
||||||
|
DateTime? ReadAt,
|
||||||
|
DateTime CreatedAt);
|
||||||
|
|
||||||
|
/// <summary>分页结果。</summary>
|
||||||
|
public record PagedResult<T>(List<T> Items, int Total, int PageIndex, int PageSize);
|
||||||
14
src/RAG.Application/Notifications/INotificationDispatcher.cs
Normal file
14
src/RAG.Application/Notifications/INotificationDispatcher.cs
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
using RAG.Domain.Entities;
|
||||||
|
|
||||||
|
namespace RAG.Application.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 通知分发器抽象。职责:把已落库的通知实时推送给接收人(如 SignalR)。
|
||||||
|
/// 业务层只依赖此抽象,具体实现(SignalR / RabbitMQ / no-op)在 Infrastructure 注册,
|
||||||
|
/// 便于在不改业务代码的前提下替换推送通道。
|
||||||
|
/// </summary>
|
||||||
|
public interface INotificationDispatcher
|
||||||
|
{
|
||||||
|
/// <summary>把已持久化的通知推送给各自的接收人。</summary>
|
||||||
|
Task DispatchAsync(IReadOnlyList<Notification> notifications, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using RAG.Application.Notifications.DTOs;
|
||||||
|
using RAG.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace RAG.Application.Notifications.Queries;
|
||||||
|
|
||||||
|
/// <summary>查询当前用户的通知列表(支持只看未读,按创建时间倒序分页)。</summary>
|
||||||
|
public record GetNotificationsQuery(
|
||||||
|
Guid RecipientUserId,
|
||||||
|
bool UnreadOnly,
|
||||||
|
int PageIndex,
|
||||||
|
int PageSize) : IRequest<PagedResult<NotificationDto>>;
|
||||||
|
|
||||||
|
public class GetNotificationsQueryHandler(RagDbContext db)
|
||||||
|
: IRequestHandler<GetNotificationsQuery, PagedResult<NotificationDto>>
|
||||||
|
{
|
||||||
|
public async Task<PagedResult<NotificationDto>> Handle(GetNotificationsQuery request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var query = db.Notifications
|
||||||
|
.Where(n => n.RecipientUserId == request.RecipientUserId);
|
||||||
|
|
||||||
|
if (request.UnreadOnly)
|
||||||
|
query = query.Where(n => !n.IsRead);
|
||||||
|
|
||||||
|
var total = await query.CountAsync(ct);
|
||||||
|
|
||||||
|
var items = await query
|
||||||
|
.OrderByDescending(n => n.CreatedAt)
|
||||||
|
.Skip((request.PageIndex - 1) * request.PageSize)
|
||||||
|
.Take(request.PageSize)
|
||||||
|
.Select(n => new NotificationDto(
|
||||||
|
n.Id, n.RecipientUserId, n.Type, n.Title, n.Content, n.Source,
|
||||||
|
n.RelatedId, n.RelatedType, n.IsRead, n.ReadAt, n.CreatedAt))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return new PagedResult<NotificationDto>(items, total, request.PageIndex, request.PageSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>查询当前用户未读通知数(前端铃铛角标用)。</summary>
|
||||||
|
public record GetUnreadNotificationCountQuery(Guid RecipientUserId) : IRequest<int>;
|
||||||
|
|
||||||
|
public class GetUnreadNotificationCountQueryHandler(RagDbContext db)
|
||||||
|
: IRequestHandler<GetUnreadNotificationCountQuery, int>
|
||||||
|
{
|
||||||
|
public async Task<int> Handle(GetUnreadNotificationCountQuery request, CancellationToken ct)
|
||||||
|
=> await db.Notifications
|
||||||
|
.CountAsync(n => n.RecipientUserId == request.RecipientUserId && !n.IsRead, ct);
|
||||||
|
}
|
||||||
@ -0,0 +1,109 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using RAG.Application.Obsidian.DTOs;
|
||||||
|
using RAG.Domain.Enums;
|
||||||
|
using RAG.Domain.Exceptions;
|
||||||
|
using RAG.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace RAG.Application.Obsidian.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 同步 Obsidian vault 到知识库。扫描指定目录下全部 .md 文件,
|
||||||
|
/// 按内容哈希增量导入(新增/变更入库,未变更跳过),保留 vault 内相对路径与 frontmatter 元数据。
|
||||||
|
/// 参考 Dify 的 Obsidian 接入:把一个笔记目录作为知识源,逐文件入库。
|
||||||
|
/// </summary>
|
||||||
|
public record SyncObsidianVaultCommand(
|
||||||
|
Guid KnowledgeBaseId,
|
||||||
|
string VaultDirectory,
|
||||||
|
ChunkingMode? ChunkingModeOverride = null
|
||||||
|
) : IRequest<ObsidianSyncResult>;
|
||||||
|
|
||||||
|
public class SyncObsidianVaultCommandHandler(RagDbContext db)
|
||||||
|
: IRequestHandler<SyncObsidianVaultCommand, ObsidianSyncResult>
|
||||||
|
{
|
||||||
|
private static readonly string[] ObsidianIgnoreDirs = [".obsidian", ".trash", ".git", "node_modules"];
|
||||||
|
|
||||||
|
public async Task<ObsidianSyncResult> Handle(SyncObsidianVaultCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(request.VaultDirectory))
|
||||||
|
throw new BusinessException($"Vault 目录不存在:{request.VaultDirectory}");
|
||||||
|
|
||||||
|
var kb = await db.KnowledgeBases.FindAsync([request.KnowledgeBaseId], ct)
|
||||||
|
?? throw new NotFoundException("知识库不存在");
|
||||||
|
|
||||||
|
var vaultRoot = Path.GetFullPath(request.VaultDirectory);
|
||||||
|
var mdFiles = Directory.EnumerateFiles(vaultRoot, "*.md", SearchOption.AllDirectories)
|
||||||
|
.Where(f => !ObsidianIgnoreDirs.Any(d => f.Contains(Path.DirectorySeparatorChar + d, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// 加载该知识库已有的 Obsidian 文档(VaultPath -> 实体)
|
||||||
|
var existing = await db.Documents
|
||||||
|
.Where(d => d.KnowledgeBaseId == kb.Id && d.SourceType == DocumentSourceType.Obsidian)
|
||||||
|
.ToDictionaryAsync(d => d.VaultPath ?? d.FileName, ct);
|
||||||
|
|
||||||
|
var added = 0;
|
||||||
|
var updated = 0;
|
||||||
|
var skipped = 0;
|
||||||
|
|
||||||
|
foreach (var file in mdFiles)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var vaultPath = Path.GetRelativePath(vaultRoot, file).Replace('\\', '/');
|
||||||
|
var fileName = Path.GetFileName(file);
|
||||||
|
var fileBytes = await File.ReadAllBytesAsync(file, ct);
|
||||||
|
var hash = Convert.ToHexString(SHA256.HashData(fileBytes)).ToLowerInvariant();
|
||||||
|
var fileSize = fileBytes.LongLength;
|
||||||
|
|
||||||
|
if (existing.TryGetValue(vaultPath, out var doc))
|
||||||
|
{
|
||||||
|
// 增量:哈希未变则跳过
|
||||||
|
if (doc.ContentHash == hash)
|
||||||
|
{
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 变更:更新文件路径与哈希,标记为待重新处理
|
||||||
|
doc.FilePath = file;
|
||||||
|
doc.FileName = fileName;
|
||||||
|
doc.FileSize = fileSize;
|
||||||
|
doc.ContentHash = hash;
|
||||||
|
doc.Status = DocumentStatus.Pending;
|
||||||
|
doc.VaultPath = vaultPath;
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 新增
|
||||||
|
db.Documents.Add(new RAG.Domain.Entities.Document
|
||||||
|
{
|
||||||
|
KnowledgeBaseId = kb.Id,
|
||||||
|
Title = fileName,
|
||||||
|
FileName = fileName,
|
||||||
|
FilePath = file,
|
||||||
|
FileSize = fileSize,
|
||||||
|
ContentType = "text/markdown",
|
||||||
|
Status = DocumentStatus.Pending,
|
||||||
|
SourceType = DocumentSourceType.Obsidian,
|
||||||
|
VaultPath = vaultPath,
|
||||||
|
ContentHash = hash,
|
||||||
|
ChunkingModeOverride = request.ChunkingModeOverride
|
||||||
|
});
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return new ObsidianSyncResult(
|
||||||
|
KnowledgeBaseId: kb.Id,
|
||||||
|
VaultDirectory: vaultRoot,
|
||||||
|
TotalFiles: mdFiles.Count,
|
||||||
|
Added: added,
|
||||||
|
Updated: updated,
|
||||||
|
Skipped: skipped,
|
||||||
|
Message: $"同步完成:新增 {added},更新 {updated},跳过 {skipped}(未变更)。");
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/RAG.Application/Obsidian/DTOs/ObsidianDTOs.cs
Normal file
12
src/RAG.Application/Obsidian/DTOs/ObsidianDTOs.cs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
namespace RAG.Application.Obsidian.DTOs;
|
||||||
|
|
||||||
|
public record ObsidianSyncRequest(string VaultDirectory, int? ChunkingMode = null);
|
||||||
|
|
||||||
|
public record ObsidianSyncResult(
|
||||||
|
Guid KnowledgeBaseId,
|
||||||
|
string VaultDirectory,
|
||||||
|
int TotalFiles,
|
||||||
|
int Added,
|
||||||
|
int Updated,
|
||||||
|
int Skipped,
|
||||||
|
string Message);
|
||||||
@ -22,5 +22,9 @@ public class RAGApplicationModule : AbpModule
|
|||||||
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
|
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
|
||||||
services.AddValidatorsFromAssembly(assembly);
|
services.AddValidatorsFromAssembly(assembly);
|
||||||
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
|
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
|
||||||
|
|
||||||
|
// 文件存储客户端:file-system Go 微服务未就绪,先用本地桩实现(存到系统临时目录)。
|
||||||
|
// 微服务就绪后换回 Infrastructure 的 FileStorageClient(HTTP 调用 S3)。
|
||||||
|
services.AddHttpContextAccessor();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
60
src/RAG.Application/RagQA/Commands/RAGQueryCommand.cs
Normal file
60
src/RAG.Application/RagQA/Commands/RAGQueryCommand.cs
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
using System.Text;
|
||||||
|
using MediatR;
|
||||||
|
using RAG.Application.RagQA.DTOs;
|
||||||
|
using RAG.Domain.Exceptions;
|
||||||
|
using RAG.Domain.Interfaces;
|
||||||
|
using RAG.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace RAG.Application.RagQA.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RAG 检索增强问答命令。完整流水线:检索召回 → 上下文拼接 → LLM 生成 → 返回答案与来源片段。
|
||||||
|
/// 是 RAG 系统的"用户可见"入口,回答必须严格基于检索结果,无命中时拒绝编造(避免幻觉)。
|
||||||
|
/// </summary>
|
||||||
|
public record RAGQueryCommand(Guid KnowledgeBaseId, string Question) : IRequest<RAGQueryResponse>;
|
||||||
|
|
||||||
|
public class RAGQueryCommandHandler(
|
||||||
|
RagDbContext db,
|
||||||
|
IRagRetrievalService retrievalService,
|
||||||
|
IAIChatAgent chatAgent)
|
||||||
|
: IRequestHandler<RAGQueryCommand, RAGQueryResponse>
|
||||||
|
{
|
||||||
|
public async Task<RAGQueryResponse> Handle(RAGQueryCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var kb = await db.KnowledgeBases.FindAsync([request.KnowledgeBaseId], ct)
|
||||||
|
?? throw new NotFoundException("知识库不存在");
|
||||||
|
|
||||||
|
// 检索流水线:向量化 -> 混合召回 -> 重排 -> 回填上下文(策略由知识库配置决定)
|
||||||
|
var hits = await retrievalService.RetrieveAsync(kb, request.Question, ct);
|
||||||
|
|
||||||
|
// 关键反幻觉策略:未命中任何片段时直接拒绝回答,避免 LLM 凭空编造
|
||||||
|
if (hits.Count == 0)
|
||||||
|
return new RAGQueryResponse("未在知识库中找到相关内容,无法回答该问题。", []);
|
||||||
|
|
||||||
|
var sources = hits.Select(h => new SourceChunk(
|
||||||
|
h.DocumentId,
|
||||||
|
h.DocumentTitle,
|
||||||
|
// 来源片段截断到 200 字符,前端仅作展示用,避免传输整段上下文
|
||||||
|
h.ContextContent.Length > 200 ? h.ContextContent[..200] + "..." : h.ContextContent,
|
||||||
|
Math.Round(h.Score, 4)
|
||||||
|
)).ToList();
|
||||||
|
|
||||||
|
// 构建 RAG prompt(用回填后的上下文内容,而非原始命中片段)
|
||||||
|
// 显式约束"上下文无关则如实说明",进一步抑制幻觉
|
||||||
|
var contextBuilder = new StringBuilder();
|
||||||
|
contextBuilder.AppendLine("以下是检索到的相关上下文:");
|
||||||
|
for (var i = 0; i < hits.Count; i++)
|
||||||
|
contextBuilder.AppendLine($"\n--- 上下文 {i + 1} ---\n{hits[i].ContextContent}");
|
||||||
|
|
||||||
|
var prompt = $"""
|
||||||
|
{contextBuilder}
|
||||||
|
|
||||||
|
基于以上上下文,请回答以下问题。如果上下文中没有相关信息,请如实说明。
|
||||||
|
|
||||||
|
问题:{request.Question}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var answer = await chatAgent.RunAsync(prompt, ct);
|
||||||
|
return new RAGQueryResponse(answer, sources);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/RAG.Application/RagQA/DTOs/RAGDTOs.cs
Normal file
7
src/RAG.Application/RagQA/DTOs/RAGDTOs.cs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
namespace RAG.Application.RagQA.DTOs;
|
||||||
|
|
||||||
|
public record RAGQueryRequest(Guid KnowledgeBaseId, string Question);
|
||||||
|
|
||||||
|
public record RAGQueryResponse(string Answer, List<SourceChunk> Sources);
|
||||||
|
|
||||||
|
public record SourceChunk(Guid DocumentId, string DocumentTitle, string Content, double Similarity);
|
||||||
13
src/RAG.Application/RagQA/Validators/RAGValidators.cs
Normal file
13
src/RAG.Application/RagQA/Validators/RAGValidators.cs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace RAG.Application.RagQA.Validators;
|
||||||
|
|
||||||
|
public class RAGQueryCommandValidator : AbstractValidator<Commands.RAGQueryCommand>
|
||||||
|
{
|
||||||
|
public RAGQueryCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.KnowledgeBaseId).NotEmpty().WithMessage("知识库ID不能为空");
|
||||||
|
RuleFor(x => x.Question).NotEmpty().WithMessage("问题不能为空")
|
||||||
|
.MaximumLength(1000).WithMessage("问题不能超过1000个字符");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,11 @@ using RAG.Infrastructure.Persistence;
|
|||||||
|
|
||||||
namespace RAG.Application.Roles.Commands;
|
namespace RAG.Application.Roles.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 给角色分配权限(全量覆盖语义)。安全敏感操作:直接影响登录后下发的 permissions claim。
|
||||||
|
/// 采用"先删全部再插入选中"实现全量覆盖,避免逐条 diff 的复杂度;计数校验确保传入的权限 Id 全部合法,
|
||||||
|
/// 防止因部分无效 Id 导致权限静默丢失。
|
||||||
|
/// </summary>
|
||||||
public record AssignPermissionsCommand(Guid RoleId, List<Guid> PermissionIds) : IRequest<RoleDto>;
|
public record AssignPermissionsCommand(Guid RoleId, List<Guid> PermissionIds) : IRequest<RoleDto>;
|
||||||
|
|
||||||
public class AssignPermissionsCommandHandler(RagDbContext db) : IRequestHandler<AssignPermissionsCommand, RoleDto>
|
public class AssignPermissionsCommandHandler(RagDbContext db) : IRequestHandler<AssignPermissionsCommand, RoleDto>
|
||||||
@ -18,6 +23,7 @@ public class AssignPermissionsCommandHandler(RagDbContext db) : IRequestHandler<
|
|||||||
?? throw new NotFoundException("角色不存在");
|
?? throw new NotFoundException("角色不存在");
|
||||||
|
|
||||||
var permissions = await db.Permissions.Where(p => request.PermissionIds.Contains(p.Id)).ToListAsync(ct);
|
var permissions = await db.Permissions.Where(p => request.PermissionIds.Contains(p.Id)).ToListAsync(ct);
|
||||||
|
// 计数校验:查回的数量必须与请求数量一致,否则说明传入含无效 Id,拒绝执行以免权限静默丢失
|
||||||
if (permissions.Count != request.PermissionIds.Count)
|
if (permissions.Count != request.PermissionIds.Count)
|
||||||
throw new NotFoundException("部分权限不存在");
|
throw new NotFoundException("部分权限不存在");
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,11 @@ using RAG.Infrastructure.Persistence;
|
|||||||
|
|
||||||
namespace RAG.Application.Users.Commands;
|
namespace RAG.Application.Users.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 给用户分配角色(全量覆盖语义)。安全敏感操作:决定用户登录后的角色与衍生权限。
|
||||||
|
/// 与 AssignPermissionsCommand 同样采用"先删后插"的全量覆盖策略,计数校验防止无效 Id 导致角色静默丢失。
|
||||||
|
/// 注意:变更不会立即生效,需用户重新登录签发新 token 才会更新 claims。
|
||||||
|
/// </summary>
|
||||||
public record AssignRolesCommand(Guid UserId, List<Guid> RoleIds) : IRequest<UserDto>;
|
public record AssignRolesCommand(Guid UserId, List<Guid> RoleIds) : IRequest<UserDto>;
|
||||||
|
|
||||||
public class AssignRolesCommandHandler(RagDbContext db) : IRequestHandler<AssignRolesCommand, UserDto>
|
public class AssignRolesCommandHandler(RagDbContext db) : IRequestHandler<AssignRolesCommand, UserDto>
|
||||||
|
|||||||
@ -7,6 +7,11 @@ using RAG.Infrastructure.Persistence;
|
|||||||
|
|
||||||
namespace RAG.Application.Users.Commands;
|
namespace RAG.Application.Users.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 管理员创建用户命令(对应 /users POST,需 user:create 权限)。
|
||||||
|
/// 与 RegisterCommand 区别:本命令由后台调用,仍只授予 "User" 角色——
|
||||||
|
/// 角色提升须走 AssignRolesCommand,避免创建即越权。
|
||||||
|
/// </summary>
|
||||||
public record CreateUserCommand(string Username, string Email, string Password) : IRequest<UserDto>;
|
public record CreateUserCommand(string Username, string Email, string Password) : IRequest<UserDto>;
|
||||||
|
|
||||||
public class CreateUserCommandHandler(RagDbContext db) : IRequestHandler<CreateUserCommand, UserDto>
|
public class CreateUserCommandHandler(RagDbContext db) : IRequestHandler<CreateUserCommand, UserDto>
|
||||||
@ -24,6 +29,7 @@ public class CreateUserCommandHandler(RagDbContext db) : IRequestHandler<CreateU
|
|||||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password)
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 管理员新建用户也仅授 "User" 角色,更高权限须显式 AssignRoles 分配
|
||||||
var userRole = await db.Roles.FirstAsync(r => r.Name == "User", ct);
|
var userRole = await db.Roles.FirstAsync(r => r.Name == "User", ct);
|
||||||
await db.Users.AddAsync(user, ct);
|
await db.Users.AddAsync(user, ct);
|
||||||
await db.UserRoles.AddAsync(new UserRole { UserId = user.Id, RoleId = userRole.Id }, ct);
|
await db.UserRoles.AddAsync(new UserRole { UserId = user.Id, RoleId = userRole.Id }, ct);
|
||||||
|
|||||||
@ -12,4 +12,7 @@ public abstract class BaseEntity<TId> where TId : struct
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 实体基类,默认使用 Guid 作为主键。项目中大部分实体继承此类。
|
/// 实体基类,默认使用 Guid 作为主键。项目中大部分实体继承此类。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public abstract class BaseEntity : BaseEntity<Guid> { }
|
public abstract class BaseEntity : BaseEntity<Guid>
|
||||||
|
{
|
||||||
|
public BaseEntity() => Id = Guid.NewGuid();
|
||||||
|
}
|
||||||
|
|||||||
21
src/RAG.Domain/Entities/ChatMessage.cs
Normal file
21
src/RAG.Domain/Entities/ChatMessage.cs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
using RAG.Domain.Common;
|
||||||
|
using RAG.Domain.Enums;
|
||||||
|
|
||||||
|
namespace RAG.Domain.Entities;
|
||||||
|
|
||||||
|
public class ChatMessage : BaseEntity, IAuditable
|
||||||
|
{
|
||||||
|
public Guid ConversationId { get; set; }
|
||||||
|
public ChatRole Role { get; set; }
|
||||||
|
public string Content { get; set; } = default!;
|
||||||
|
public int? TokenUsage { get; set; }
|
||||||
|
|
||||||
|
// IAuditable
|
||||||
|
public string CreatedBy { get; set; } = default!;
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public string UpdatedBy { get; set; } = default!;
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
public Conversation Conversation { get; set; } = default!;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user