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.Infrastructure/RAG.Infrastructure.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/RAG.Application.Tests/RAG.Application.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
services:
|
||||
# --- PostgreSQL (pgvector) ---
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg17
|
||||
container_name: rag-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: rag
|
||||
POSTGRES_USER: rag
|
||||
@ -19,9 +21,11 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# --- Redis ---
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: rag-redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
@ -34,9 +38,11 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# --- RabbitMQ ---
|
||||
rabbitmq:
|
||||
image: rabbitmq:3-management
|
||||
container_name: rag-rabbitmq
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
RABBITMQ_DEFAULT_USER: rag
|
||||
RABBITMQ_DEFAULT_PASS: rag123
|
||||
@ -53,10 +59,55 @@ services:
|
||||
timeout: 10s
|
||||
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:
|
||||
pgdata:
|
||||
redisdata:
|
||||
rabbitdata:
|
||||
mongodata:
|
||||
rustfsdata:
|
||||
|
||||
networks:
|
||||
rag-network:
|
||||
|
||||
@ -1 +1,22 @@
|
||||
-- pgvector for rag database
|
||||
\c rag;
|
||||
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.Exceptions;
|
||||
using FastEndpoints;
|
||||
using MediatR;
|
||||
using RAG.Application.Auth.Queries;
|
||||
|
||||
namespace RAG.Api.Endpoints.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前用户的权限码列表。前端据此渲染按钮/菜单的可见性(细粒度前端权限控制)。
|
||||
/// 鉴权通过 GetRequiredUserId 强制——未登录直接抛 401。
|
||||
/// </summary>
|
||||
public class GetAccessCodesEndpoint(IMediator mediator, ICurrentUserContext userContext) : EndpointWithoutRequest<List<string>>
|
||||
{
|
||||
public override void Configure()
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
using RAG.Domain.Common;
|
||||
using RAG.Domain.Exceptions;
|
||||
using FastEndpoints;
|
||||
using MediatR;
|
||||
using RAG.Application.Auth.Queries;
|
||||
|
||||
namespace RAG.Api.Endpoints.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前登录用户信息端点。前端登录后调用以填充用户菜单、权限、头像。
|
||||
/// GetRequiredUserId 在未登录时抛 UnauthorizedException,等同强制鉴权(无需显式 Permissions)。
|
||||
/// </summary>
|
||||
public class GetCurrentUserEndpoint(IMediator mediator, ICurrentUserContext userContext) : EndpointWithoutRequest<CurrentUserInfo>
|
||||
{
|
||||
public override void Configure()
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
using FastEndpoints;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using RAG.Application.Auth.Commands;
|
||||
using RAG.Application.Auth.DTOs;
|
||||
|
||||
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 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);
|
||||
|
||||
// refresh_token 写入 HttpOnly Cookie:浏览器自动随请求带上,前端 JS 不可读,防 XSS
|
||||
HttpContext.Response.Cookies.Append("jwt", result.RefreshToken, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
@ -30,3 +35,19 @@ public class LoginEndpoint(IMediator mediator, IConfiguration config) : Endpoint
|
||||
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 FastEndpoints;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using RAG.Application.Auth.Commands;
|
||||
using RAG.Application.Auth.DTOs;
|
||||
|
||||
namespace RAG.Api.Endpoints.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// 刷新令牌端点。从 HttpOnly Cookie 读 refresh_token(前端无法读取,只能由浏览器自动随请求带上),
|
||||
/// 换取新 access_token 以纯文本响应(被 ApiResponseMiddleware 列入 ExcludedPaths 不包裹)。
|
||||
/// ExcludeFromDescription 避免 Swagger 暴露此内部接口。
|
||||
/// </summary>
|
||||
public class RefreshTokenEndpoint(IMediator mediator, IConfiguration config) : EndpointWithoutRequest<string>
|
||||
{
|
||||
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);
|
||||
|
||||
// 新 refresh_token 写回 Cookie(Rotation:旧 token 已在 Handler 中作废)
|
||||
HttpContext.Response.Cookies.Append("jwt", result.RefreshToken, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
|
||||
@ -5,6 +5,10 @@ using RAG.Application.Auth.DTOs;
|
||||
|
||||
namespace RAG.Api.Endpoints.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// 自助注册端点。开放访问(AllowAnonymous),新用户一律授予 "User" 角色。
|
||||
/// 注意:与 CreateUser(需 user:create 权限)区分——后者是管理员创建账号,可指定角色。
|
||||
/// </summary>
|
||||
public class RegisterEndpoint(IMediator mediator) : Endpoint<RegisterRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
@ -16,6 +20,25 @@ public class RegisterEndpoint(IMediator mediator) : Endpoint<RegisterRequest>
|
||||
public override async Task HandleAsync(RegisterRequest req, CancellationToken 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;
|
||||
|
||||
/// <summary>
|
||||
/// 显式吊销刷新令牌("踢下线"语义)。需登录鉴权(未 AllowAnonymous),
|
||||
/// 用于用户主动登出或管理员远程失效他人会话,使旧 refresh_token 立即不可用。
|
||||
/// </summary>
|
||||
public class RevokeTokenEndpoint(IMediator mediator) : Endpoint<RevokeTokenRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
@ -15,6 +19,16 @@ public class RevokeTokenEndpoint(IMediator mediator) : Endpoint<RevokeTokenReque
|
||||
public override async Task HandleAsync(RevokeTokenRequest req, CancellationToken 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.Exceptions;
|
||||
using FastEndpoints;
|
||||
using MediatR;
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 获取全部权限列表(供角色分配权限页选择)。
|
||||
/// 需 permission:read 权限——权限码本身是敏感信息,仅授权用户可见。
|
||||
/// </summary>
|
||||
public class GetPermissionListEndpoint(IMediator mediator) : EndpointWithoutRequest<List<PermissionDto>>
|
||||
{
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 给角色分配权限(敏感操作)。需 role:assign-permission 权限,全量覆盖语义。
|
||||
/// 直接影响登录后下发的 permissions claim,故所有持有该角色的用户都受影响。
|
||||
/// </summary>
|
||||
public class AssignPermissionsEndpoint(IMediator mediator) : Endpoint<AssignPermissionsEndpointRequest, RoleDto>
|
||||
{
|
||||
public override void Configure()
|
||||
@ -21,3 +25,14 @@ public class AssignPermissionsEndpoint(IMediator mediator) : Endpoint<AssignPerm
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
await mediator.Send(new DeleteRoleCommand(req.Id), ct);
|
||||
HttpContext.Response.StatusCode = 204;
|
||||
await Send.OkAsync(true, ct);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
/// <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);
|
||||
|
||||
/// <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;
|
||||
|
||||
/// <summary>
|
||||
/// 给用户分配角色(敏感操作)。需 user:assign-role 权限,全量覆盖语义。
|
||||
/// 注意:已登录用户的旧 token 仍携带旧角色,须重新登录或 refresh 后才生效。
|
||||
/// </summary>
|
||||
public class AssignRolesEndpoint(IMediator mediator) : Endpoint<AssignRolesEndpointRequest, UserDto>
|
||||
{
|
||||
public override void Configure()
|
||||
@ -21,3 +25,14 @@ public class AssignRolesEndpoint(IMediator mediator) : Endpoint<AssignRolesEndpo
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 管理员创建用户端点。需 user:create 权限(区别于公开的 /auth/register)。
|
||||
/// 创建后管理员可通过 AssignRoles 进一步分配角色。
|
||||
/// </summary>
|
||||
public class CreateUserEndpoint(IMediator mediator) : Endpoint<CreateUserRequest, UserDto>
|
||||
{
|
||||
public override void Configure()
|
||||
@ -19,3 +23,22 @@ public class CreateUserEndpoint(IMediator mediator) : Endpoint<CreateUserRequest
|
||||
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)
|
||||
{
|
||||
await mediator.Send(new DeleteUserCommand(req.Id), ct);
|
||||
HttpContext.Response.StatusCode = 204;
|
||||
await Send.OkAsync(true, ct);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
/// <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);
|
||||
|
||||
/// <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 Microsoft.Extensions.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using RAG.Infrastructure.Persistence;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
|
||||
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()
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
@ -21,6 +28,10 @@ public class AuthGrpcService(IConfiguration config) : AuthService.AuthServiceBas
|
||||
ClockSkew = TimeSpan.Zero
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 验证 access_token 并解包 claims。任何校验失败都静默返回 Valid=false,
|
||||
/// 调用方据此拒绝未认证请求;不抛异常以保持 gRPC 协议的稳定性。
|
||||
/// </summary>
|
||||
public override Task<ValidateTokenResponse> ValidateToken(ValidateTokenRequest request, ServerCallContext context)
|
||||
{
|
||||
var response = new ValidateTokenResponse();
|
||||
@ -38,7 +49,7 @@ public class AuthGrpcService(IConfiguration config) : AuthService.AuthServiceBas
|
||||
?? principal.FindFirstValue(JwtRegisteredClaimNames.Sub) ?? "";
|
||||
response.Username = principal.FindFirstValue(ClaimTypes.Name)
|
||||
?? principal.FindFirstValue(JwtRegisteredClaimNames.UniqueName) ?? "";
|
||||
response.Email = principal.FindFirstValue(JwtRegisteredClaimNames.Email)
|
||||
response.Email = principal.FindFirstValue(ClaimTypes.Email)
|
||||
?? principal.FindFirstValue(ClaimTypes.Email) ?? "";
|
||||
response.Roles.AddRange(principal.FindAll(ClaimTypes.Role).Select(c => c.Value));
|
||||
response.Permissions.AddRange(principal.FindAll("permissions").Select(c => c.Value));
|
||||
@ -50,6 +61,10 @@ public class AuthGrpcService(IConfiguration config) : AuthService.AuthServiceBas
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 单次权限判定 RPC。供其他服务在不持有权限码全表的情况下,
|
||||
/// 把"用户是否有 X 权限"的决策委托给中心化鉴权服务,避免权限规则散落多处。
|
||||
/// </summary>
|
||||
public override Task<CheckPermissionResponse> CheckPermission(CheckPermissionRequest request, ServerCallContext context)
|
||||
{
|
||||
var response = new CheckPermissionResponse();
|
||||
@ -70,4 +85,45 @@ public class AuthGrpcService(IConfiguration config) : AuthService.AuthServiceBas
|
||||
|
||||
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.Json;
|
||||
using RAG.Api.Serialization;
|
||||
|
||||
namespace RAG.Api.Middleware;
|
||||
|
||||
@ -9,14 +10,23 @@ namespace RAG.Api.Middleware;
|
||||
/// </summary>
|
||||
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 直接返回新的 access_token 字符串(非 JSON 对象),故排除以免被二次包裹
|
||||
private static readonly HashSet<string> ExcludedPaths = ["/api/auth/refresh"];
|
||||
|
||||
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);
|
||||
return;
|
||||
@ -24,6 +34,7 @@ public class ApiResponseMiddleware(RequestDelegate next)
|
||||
|
||||
var originalBody = context.Response.Body;
|
||||
var buffer = new MemoryStream();
|
||||
// 用内存流接管响应体,以便下游写完后整体读取再决定是否包裹
|
||||
context.Response.Body = buffer;
|
||||
|
||||
try
|
||||
@ -32,6 +43,7 @@ public class ApiResponseMiddleware(RequestDelegate next)
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 异常路径下必须先还原原始响应流,避免后续 GlobalExceptionMiddleware 写不出字节
|
||||
context.Response.Body = originalBody;
|
||||
await buffer.DisposeAsync();
|
||||
throw;
|
||||
@ -43,6 +55,7 @@ public class ApiResponseMiddleware(RequestDelegate next)
|
||||
var contentType = context.Response.ContentType ?? "";
|
||||
|
||||
// 非 JSON 响应或非成功状态码或排除路径,直接透传原始响应
|
||||
// 错误响应已由 GlobalExceptionMiddleware 包装为错误信封,此处不可再包一层
|
||||
if (statusCode is < 200 or >= 300
|
||||
|| !contentType.Contains("json", StringComparison.OrdinalIgnoreCase)
|
||||
|| IsExcludedPath(context.Request.Path))
|
||||
@ -56,6 +69,7 @@ public class ApiResponseMiddleware(RequestDelegate next)
|
||||
var bodyText = await new StreamReader(buffer).ReadToEndAsync(context.RequestAborted);
|
||||
|
||||
// 204 或空 body 不包裹
|
||||
// DELETE 等无返回值场景,204 NoContent 或 null,直接透传,避免把 null 包成 data:null 的冗余信封
|
||||
if (statusCode == 204 || string.IsNullOrEmpty(bodyText) || bodyText == "null")
|
||||
{
|
||||
buffer.Seek(0, SeekOrigin.Begin);
|
||||
@ -68,6 +82,7 @@ public class ApiResponseMiddleware(RequestDelegate next)
|
||||
var json = JsonSerializer.Serialize(wrapped, JsonOptions);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
|
||||
// ContentLength 必须按包裹后字节重算,否则前端按原长度截断会破坏 JSON
|
||||
context.Response.ContentType = "application/json; charset=utf-8";
|
||||
context.Response.ContentLength = bytes.Length;
|
||||
await originalBody.WriteAsync(bytes, context.RequestAborted);
|
||||
@ -77,5 +92,12 @@ public class ApiResponseMiddleware(RequestDelegate next)
|
||||
private static bool IsExcludedPath(PathString path) =>
|
||||
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");
|
||||
}
|
||||
|
||||
@ -2,13 +2,15 @@ using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentValidation;
|
||||
using Namotion.Reflection;
|
||||
using RAG.Domain.Exceptions;
|
||||
|
||||
namespace RAG.Api.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// 全局异常中间件,捕获所有未处理异常并统一返回 { code, message, data } 格式。
|
||||
/// 必须注册在 ApiResponseMiddleware 之前,确保异常不会穿透到 ASP.NET Core 默认错误页。
|
||||
/// 全局异常中间件,位于管线最前端(仅次于 CORS),捕获所有未处理异常并统一包装为
|
||||
/// 错误响应信封 { code, message, data: null }。负责把领域层抛出的语义异常映射为 HTTP 状态码。
|
||||
/// gRPC 请求走二进制协议,不经此中间件包装(直接透传)。
|
||||
/// </summary>
|
||||
public class GlobalExceptionMiddleware(RequestDelegate next)
|
||||
{
|
||||
@ -16,6 +18,7 @@ public class GlobalExceptionMiddleware(RequestDelegate next)
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
// gRPC 请求是二进制帧协议,不能用 JSON 异常信封响应,直接放行交给 gRPC 管线处理
|
||||
if (context.Request.ContentType?.StartsWith("application/grpc") == true)
|
||||
{
|
||||
await next(context);
|
||||
@ -28,17 +31,21 @@ public class GlobalExceptionMiddleware(RequestDelegate next)
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 若响应头已发出(如流式输出已写出一部分字节),无法再改写状态码,只能丢弃
|
||||
if (context.Response.HasStarted) return;
|
||||
await HandleExceptionAsync(context, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task HandleExceptionAsync(HttpContext context, Exception ex)
|
||||
{
|
||||
// 领域异常 → HTTP 状态码的统一映射;未识别异常一律视为 500,避免内部细节泄露给客户端
|
||||
var (statusCode, code, message) = ex switch
|
||||
{
|
||||
UnauthorizedException => (HttpStatusCode.Unauthorized, 401, ex.Message),
|
||||
NotFoundException => (HttpStatusCode.NotFound, 404, ex.Message),
|
||||
BusinessException => (HttpStatusCode.BadRequest, 400, ex.Message),
|
||||
// 多条校验错误用分号拼接,便于前端一次性展示
|
||||
ValidationException vex => (HttpStatusCode.BadRequest, 400,
|
||||
string.Join("; ", vex.Errors.Select(e => e.ErrorMessage))),
|
||||
_ => (HttpStatusCode.InternalServerError, 500,
|
||||
|
||||
@ -7,14 +7,18 @@ using Volo.Abp.DependencyInjection;
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// OTel 日志导出
|
||||
var otlpEndpoint = builder.Configuration["OpenTelemetry:OtlpEndpoint"] ?? "http://localhost:4316";
|
||||
builder.Logging.AddOpenTelemetry(logging =>
|
||||
{
|
||||
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 的具体类型和接口类型
|
||||
var appBuilderAccessor = new ObjectAccessor<Microsoft.AspNetCore.Builder.IApplicationBuilder>();
|
||||
var webAppAccessor = new ObjectAccessor<Microsoft.AspNetCore.Builder.WebApplication>();
|
||||
|
||||
@ -7,6 +7,7 @@ option csharp_namespace = "RAG.Api.Grpc";
|
||||
service AuthService {
|
||||
rpc ValidateToken (ValidateTokenRequest) returns (ValidateTokenResponse);
|
||||
rpc CheckPermission (CheckPermissionRequest) returns (CheckPermissionResponse);
|
||||
rpc GetUsers (GetUsersRequest) returns (GetUsersResponse);
|
||||
}
|
||||
|
||||
message ValidateTokenRequest {
|
||||
@ -33,3 +34,17 @@ message CheckPermissionResponse {
|
||||
string user_id = 2;
|
||||
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.Security;
|
||||
using FastEndpoints.Swagger;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
using RAG.Api.Grpc;
|
||||
using RAG.Api.Middleware;
|
||||
using RAG.Api.Serialization;
|
||||
using RAG.Api.Services;
|
||||
using RAG.Application;
|
||||
using RAG.Application.Notifications;
|
||||
using RAG.Domain.Common;
|
||||
using RAG.Infrastructure;
|
||||
using Volo.Abp;
|
||||
@ -26,7 +27,8 @@ public class RAGApiModule : AbpModule
|
||||
var services = context.Services;
|
||||
var config = services.GetConfiguration();
|
||||
|
||||
// OpenTelemetry
|
||||
// OpenTelemetry:统一导出 Traces + Metrics 到 OTLP Collector,供可观测性后端(Jaeger/Prometheus)消费
|
||||
var otlpEndpoint = config["OpenTelemetry:OtlpEndpoint"] ?? "http://localhost:4316";
|
||||
services.AddOpenTelemetry()
|
||||
.ConfigureResource(r => r
|
||||
.AddService(serviceName: "rag-backend", serviceVersion: "1.0.0"))
|
||||
@ -36,7 +38,7 @@ public class RAGApiModule : AbpModule
|
||||
.AddEntityFrameworkCoreInstrumentation()
|
||||
.AddOtlpExporter(options =>
|
||||
{
|
||||
options.Endpoint = new Uri("http://192.168.1.154:4316");
|
||||
options.Endpoint = new Uri(otlpEndpoint);
|
||||
}))
|
||||
.WithMetrics(metrics => metrics
|
||||
.AddAspNetCoreInstrumentation()
|
||||
@ -44,13 +46,16 @@ public class RAGApiModule : AbpModule
|
||||
.AddRuntimeInstrumentation()
|
||||
.AddOtlpExporter((exporterOptions, readerOptions) =>
|
||||
{
|
||||
exporterOptions.Endpoint = new Uri("http://192.168.1.154:4316");
|
||||
exporterOptions.Endpoint = new Uri(otlpEndpoint);
|
||||
}));
|
||||
|
||||
// FastEndpoints + Swagger
|
||||
services.AddFastEndpoints();
|
||||
services.SwaggerDocument();
|
||||
|
||||
// SignalR:消息中心通知实时推送(前端铃铛)
|
||||
services.AddSignalR();
|
||||
|
||||
// gRPC
|
||||
services.AddGrpc();
|
||||
|
||||
@ -58,13 +63,46 @@ public class RAGApiModule : AbpModule
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddScoped<ICurrentUserContext, CurrentUserContext>();
|
||||
|
||||
// JWT 认证
|
||||
services.AddAuthenticationJwtBearer(
|
||||
s => { s.SigningKey = config["Jwt:SigningKey"]!; },
|
||||
o =>
|
||||
// 消息中心:通知分发器(SignalR 实现),业务层依赖 INotificationDispatcher 抽象
|
||||
services.AddScoped<INotificationDispatcher, SignalRNotificationDispatcher>();
|
||||
|
||||
// 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"];
|
||||
o.TokenValidationParameters.ValidAudience = config["Jwt:Audience"];
|
||||
options.Authority = config["Jwt:Authority"]; // SSO 地址,如 http://localhost:5215
|
||||
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();
|
||||
|
||||
@ -98,21 +136,36 @@ public class RAGApiModule : AbpModule
|
||||
app.UseMiddleware<ApiResponseMiddleware>();
|
||||
|
||||
// 中间件管道(顺序不可变)
|
||||
// 认证(解析 token → ClaimsPrincipal)必须在授权之前,否则授权拿不到用户身份
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.UseFastEndpoints(config =>
|
||||
{
|
||||
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) =>
|
||||
{
|
||||
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();
|
||||
|
||||
// 消息中心通知 Hub:路由 /hubs/notifications,前端铃铛实时推送
|
||||
context.GetEndpointRouteBuilder().MapHub<Hubs.NotificationHub>("/hubs/notifications");
|
||||
}
|
||||
}
|
||||
|
||||
public record ErrorResponse(List<Error> Errors);
|
||||
public record Error(string Message);
|
||||
/// <summary>
|
||||
/// 参数校验失败响应信封,与 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;
|
||||
|
||||
/// <summary>
|
||||
/// 当前操作者上下文。从 HttpContext 的已认证 ClaimsPrincipal 中提取用户 Id 与来源 IP,
|
||||
/// 供 AuditInterceptor 在 SaveChanges 时自动填充审计字段(CreatedBy/OperatorIP 等)。
|
||||
/// 未认证场景(如种子数据、内部任务)回落为 "system",确保审计字段非空。
|
||||
/// </summary>
|
||||
public class CurrentUserContext : ICurrentUserContext
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
@ -20,8 +25,9 @@ public class CurrentUserContext : ICurrentUserContext
|
||||
var httpContext = _httpContextAccessor.HttpContext;
|
||||
if (httpContext?.User?.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
return httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? httpContext.User.FindFirstValue(JwtRegisteredClaimNames.Sub)
|
||||
// MapInboundClaims=false,sub 保持短名;优先读 sub,兼容旧 NameIdentifier
|
||||
return httpContext.User.FindFirstValue(JwtRegisteredClaimNames.Sub)
|
||||
?? httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? "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": "*",
|
||||
"ConnectionStrings": {
|
||||
"Default": "Host=192.168.1.154;Port=5432;Database=rag;Username=auto_agent;Password=auto_agent",
|
||||
"Redis": "192.168.1.154:31040,password=xn001624."
|
||||
"Default": "Host=localhost;Port=5432;Database=rag;Username=rag;Password=rag123",
|
||||
"Redis": "localhost:6379,abortConnect=false"
|
||||
},
|
||||
"RabbitMq": {
|
||||
"Host": "192.168.1.154",
|
||||
"Port": 31020,
|
||||
"Username": "guest",
|
||||
"Password": "xn001624."
|
||||
"Host": "localhost",
|
||||
"Port": 5672,
|
||||
"Username": "rag",
|
||||
"Password": "rag123"
|
||||
},
|
||||
"Jwt": {
|
||||
"Authority": "http://localhost:5215",
|
||||
"RequireHttps": false,
|
||||
"SigningKey": "RagJwtSecretKey2026MustBeAtLeast32CharsLong!",
|
||||
"Issuer": "rag-api",
|
||||
"Audience": "rag-client"
|
||||
"Issuer": "http://localhost:5211",
|
||||
"Audience": "rag-frontend"
|
||||
},
|
||||
"Cookie": {
|
||||
"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;
|
||||
|
||||
/// <summary>
|
||||
/// 账号密码登录命令。校验通过后签发短时 access_token(15 分钟)与长时 refresh_token(7 天),
|
||||
/// 并将角色与权限码塞入 JWT claims,供后续权限判定使用。
|
||||
/// </summary>
|
||||
public record LoginCommand(string Username, string Password) : IRequest<TokenResponse>;
|
||||
|
||||
public class LoginCommandHandler(RagDbContext db, IConfiguration config) : IRequestHandler<LoginCommand, TokenResponse>
|
||||
{
|
||||
// 进程内登录失败计数器(未持久化,重启即清零)。键统一小写化,避免大小写差异绕过锁定。
|
||||
private static readonly Dictionary<string, (int Count, DateTime LockedUntil)> LoginAttempts = new();
|
||||
|
||||
public async Task<TokenResponse> Handle(LoginCommand request, CancellationToken ct)
|
||||
{
|
||||
var key = request.Username.Trim().ToLowerInvariant();
|
||||
|
||||
// 账号已被锁定:直接拒绝,不消耗数据库查询
|
||||
if (LoginAttempts.TryGetValue(key, out var attempt) && attempt.LockedUntil > DateTime.UtcNow)
|
||||
throw new BusinessException($"登录失败次数过多,请在 {(attempt.LockedUntil - DateTime.UtcNow).Minutes + 1} 分钟后重试");
|
||||
|
||||
// 一次 Include 取齐角色→权限,避免后续多次查询
|
||||
var user = await db.Users
|
||||
.Include(u => u.UserRoles).ThenInclude(ur => ur.Role).ThenInclude(r => r.RolePermissions).ThenInclude(rp => rp.Permission)
|
||||
.FirstOrDefaultAsync(u => u.Username == request.Username, ct)
|
||||
?? throw FailLogin(key, "用户名或密码错误");
|
||||
|
||||
// 用恒定时间的 BCrypt 校验,避免基于响应耗时的时序攻击
|
||||
if (!BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
|
||||
throw FailLogin(key, "用户名或密码错误");
|
||||
|
||||
@ -58,6 +66,10 @@ public class LoginCommandHandler(RagDbContext db, IConfiguration config) : IRequ
|
||||
return new TokenResponse(accessToken, refreshToken, DateTime.UtcNow.AddMinutes(15));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 累加失败次数并按需触发账号锁定。统一返回"用户名或密码错误",避免泄露用户是否存在。
|
||||
/// 达到 5 次后锁定 15 分钟(暴力破解防护阈值)。
|
||||
/// </summary>
|
||||
private static BusinessException FailLogin(string key, string message)
|
||||
{
|
||||
if (!LoginAttempts.TryGetValue(key, out var attempt))
|
||||
@ -76,6 +88,10 @@ public class LoginCommandHandler(RagDbContext db, IConfiguration config) : IRequ
|
||||
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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
// 用加密强随机数生成器产生 64 字节熵,再 Base64 编码为不透明 refresh_token(不可猜测、不可推导)
|
||||
private static string GenerateRefreshToken()
|
||||
{
|
||||
var bytes = new byte[64];
|
||||
|
||||
@ -11,6 +11,11 @@ using RAG.Infrastructure.Persistence;
|
||||
|
||||
namespace RAG.Application.Auth.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 刷新令牌命令。用未过期的 refresh_token 换取一对新令牌,
|
||||
/// 同时将旧 refresh_token 置为已吊销——实现 Refresh Token Rotation(一次性使用),
|
||||
/// 一旦检测到旧 token 被再次使用即可识别盗用。
|
||||
/// </summary>
|
||||
public record RefreshTokenCommand(string RefreshToken) : IRequest<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)
|
||||
?? throw new UnauthorizedException("无效的刷新令牌");
|
||||
|
||||
// 已吊销或已过期均视为不可用,防止 refresh token 被复用
|
||||
if (stored.IsRevoked || stored.ExpiresAt < DateTime.UtcNow)
|
||||
throw new UnauthorizedException("刷新令牌已过期或已吊销");
|
||||
|
||||
// 旧 token 立即作废,强制下一次刷新必须用新 token(Rotation 防盗用)
|
||||
stored.IsRevoked = true;
|
||||
|
||||
var user = stored.User;
|
||||
|
||||
@ -12,6 +12,7 @@ public class RegisterCommandHandler(RagDbContext db) : IRequestHandler<RegisterC
|
||||
{
|
||||
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))
|
||||
throw new BusinessException("用户名或邮箱已存在");
|
||||
|
||||
@ -19,9 +20,11 @@ public class RegisterCommandHandler(RagDbContext db) : IRequestHandler<RegisterC
|
||||
{
|
||||
Username = request.Username,
|
||||
Email = request.Email,
|
||||
// 用 BCrypt 自带盐哈希密码,永不落地明文
|
||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password)
|
||||
};
|
||||
|
||||
// 自助注册一律授予最低权限的 "User" 角色,避免越权
|
||||
var userRole = await db.Roles.FirstAsync<Role>(r => r.Name == "User", ct);
|
||||
await db.Users.AddAsync(user, 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;
|
||||
|
||||
/// <summary>
|
||||
/// MediatR 管线行为:在 Handler 执行前自动运行所有匹配的 FluentValidation 校验器。
|
||||
/// 任一校验失败即抛 ValidationException(被 GlobalExceptionMiddleware 映射为 400),
|
||||
/// 使业务 Handler 内无需重复写校验代码,统一在管线层拦截非法请求。
|
||||
/// </summary>
|
||||
public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
|
||||
: 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.AddValidatorsFromAssembly(assembly);
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 给角色分配权限(全量覆盖语义)。安全敏感操作:直接影响登录后下发的 permissions claim。
|
||||
/// 采用"先删全部再插入选中"实现全量覆盖,避免逐条 diff 的复杂度;计数校验确保传入的权限 Id 全部合法,
|
||||
/// 防止因部分无效 Id 导致权限静默丢失。
|
||||
/// </summary>
|
||||
public record AssignPermissionsCommand(Guid RoleId, List<Guid> PermissionIds) : IRequest<RoleDto>;
|
||||
|
||||
public class AssignPermissionsCommandHandler(RagDbContext db) : IRequestHandler<AssignPermissionsCommand, RoleDto>
|
||||
@ -18,6 +23,7 @@ public class AssignPermissionsCommandHandler(RagDbContext db) : IRequestHandler<
|
||||
?? throw new NotFoundException("角色不存在");
|
||||
|
||||
var permissions = await db.Permissions.Where(p => request.PermissionIds.Contains(p.Id)).ToListAsync(ct);
|
||||
// 计数校验:查回的数量必须与请求数量一致,否则说明传入含无效 Id,拒绝执行以免权限静默丢失
|
||||
if (permissions.Count != request.PermissionIds.Count)
|
||||
throw new NotFoundException("部分权限不存在");
|
||||
|
||||
|
||||
@ -7,6 +7,11 @@ using RAG.Infrastructure.Persistence;
|
||||
|
||||
namespace RAG.Application.Users.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 给用户分配角色(全量覆盖语义)。安全敏感操作:决定用户登录后的角色与衍生权限。
|
||||
/// 与 AssignPermissionsCommand 同样采用"先删后插"的全量覆盖策略,计数校验防止无效 Id 导致角色静默丢失。
|
||||
/// 注意:变更不会立即生效,需用户重新登录签发新 token 才会更新 claims。
|
||||
/// </summary>
|
||||
public record AssignRolesCommand(Guid UserId, List<Guid> RoleIds) : IRequest<UserDto>;
|
||||
|
||||
public class AssignRolesCommandHandler(RagDbContext db) : IRequestHandler<AssignRolesCommand, UserDto>
|
||||
|
||||
@ -7,6 +7,11 @@ using RAG.Infrastructure.Persistence;
|
||||
|
||||
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 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)
|
||||
};
|
||||
|
||||
// 管理员新建用户也仅授 "User" 角色,更高权限须显式 AssignRoles 分配
|
||||
var userRole = await db.Roles.FirstAsync(r => r.Name == "User", ct);
|
||||
await db.Users.AddAsync(user, 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>
|
||||
/// 实体基类,默认使用 Guid 作为主键。项目中大部分实体继承此类。
|
||||
/// </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