Compare commits

..

5 Commits

Author SHA1 Message Date
向宁
dd9c2c85bb feat(validation): 添加请求参数校验器并优化依赖注入配置
- 为 AssignPermissionsEndpoint 添加请求校验器
- 为 AssignRolesEndpoint 添加请求校验器
- 为分片上传相关端点添加请求校验器
- 为聊天功能相关端点添加请求校验器
- 为知识库管理相关端点添加请求校验器
- 为用户和角色管理相关端点添加请求校验器
- 为嵌入向量化相关端点添加请求校验器
- 为认证授权相关端点添加请求校验器
- 为通知系统相关端点添加请求校验器
- 为 Obsidian 同步功能添加请求校验器
- 移除不必要的 using 语句和依赖注入配置
- 统一参数校验失败响应格式为 ValidationErrorResponse
2026-06-14 15:59:57 +08:00
向宁
5b67551fee feat: add notification center, Obsidian integration, RAG retrieval service and SignalR
- Add Notification entity, SignalR hub and NotificationDispatcher
- Add Obsidian document endpoints and document extractors
- Add RagRetrievalService with chunking/retrieval config
- Add ProcessKnowledgeBase and UpdateKnowledgeBase endpoints
- Add EF migrations for RAG enhancements, chunking modes and notification center
- Add unit/integration tests project
2026-06-14 15:01:07 +08:00
向宁
d742ed93ce feat: add EF column comments for all entities, docker-compose with init SQL 2026-05-25 20:33:20 +08:00
向宁
ca7463d42b fix: AI 流式回复过滤思考标签 + 加载历史上下文
- ChatAgentService.RunStreamingAsync 增加状态机过滤 qwen3 <think/> 标签
- RunAsync 同步方法也增加过滤
- StreamMessageEndpoint 从 Redis/DB 加载历史消息构建上下文
2026-05-20 21:07:47 +08:00
向宁
67b030c3c5 feat: add AI chat, RAG Q&A, knowledge base, embeddings, document processing
- AI chat with SSE streaming (Microsoft Agent Framework + Qwen)
- RAG Q&A with hybrid retrieval (vector + keyword RRF fusion)
- Knowledge base CRUD with semantic text chunking
- Embedding generation via Azure.AI.OpenAI / LM Studio
- Document upload with chunked upload support
- Redis caching for chat messages
- Chunk/vector preview endpoints
- gRPC auth service improvements
- Removed demo menus, cleaned up seed data
2026-05-20 20:28:15 +08:00
165 changed files with 16543 additions and 364 deletions

141
CLAUDE.md Normal file
View 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/`

View File

@ -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>

View File

@ -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:

View File

@ -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
$$;

View File

@ -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()

View File

@ -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()

View File

@ -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_tokenHttpOnly 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 个字符");
}
}

View File

@ -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 写回 CookieRotation旧 token 已在 Handler 中作废)
HttpContext.Response.Cookies.Append("jwt", result.RefreshToken, new CookieOptions
{
HttpOnly = true,

View File

@ -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 个字符");
}
}

View File

@ -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 不能为空");
}
}

View 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 个字符");
}
}

View 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 + 非空 JSONtrue让 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 不能为空");
}
}

View 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 不能为空");
}
}

View 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);
}
}

View 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 个字符");
}
}

View 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 个字符");
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View File

@ -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);
}
}

View 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=ParentChildnull=用知识库默认
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");
}
}

View 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 条");
}
}

View 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 个字符");
}
}

View 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");
}
}

View 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 不能为空");
}
}

View 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);
}
}

View 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");
}
}

View File

@ -1,5 +1,4 @@
using RAG.Domain.Common;
using RAG.Domain.Exceptions;
using FastEndpoints;
using MediatR;
using RAG.Application.Menus.DTOs;

View 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>从当前用户上下文解析接收人 Guidsub 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);
}
}

View 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");
}
}

View File

@ -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()

View 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 个字符");
}
}

View 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);
}
}

View File

@ -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("权限列表不能为空");
}
}

View File

@ -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 个字符");
});
}
}

View File

@ -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 不能为空");
}
}

View File

@ -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 不能为空");
}
}

View File

@ -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 个字符");
});
}
}

View File

@ -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("角色列表不能为空");
}
}

View File

@ -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 个字符");
}
}

View File

@ -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 不能为空");
}
}

View File

@ -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 不能为空");
}
}

View File

@ -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 个字符");
});
}
}

View File

@ -0,0 +1,4 @@
// 全局 usingendpoint 验证器直接继承 Validator<TRequest>,并使用 FluentValidation 规则,
// 无需在每个 endpoint 文件重复声明 using。FastEndpoints 反射扫描自动发现 Validator<T> 子类。
global using FastEndpoints;
global using FluentValidation;

View File

@ -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;
}
}

View 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 解析用户 Idsub 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;
}
}

View File

@ -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");
}

View File

@ -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,

View File

@ -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>();

View File

@ -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;
}

View File

@ -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 认证:切换为标准 OIDCJWKS 自动从 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; // 保留短名 claimsub统一读取方式
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);

View 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());
}
}

View File

@ -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=falsesub 保持短名;优先读 sub兼容旧 NameIdentifier
return httpContext.User.FindFirstValue(JwtRegisteredClaimNames.Sub)
?? httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? "system";
}
return "system";

View 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);
}
}
}

View File

@ -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
}
}

View File

@ -13,24 +13,32 @@ using RAG.Infrastructure.Persistence;
namespace RAG.Application.Auth.Commands;
/// <summary>
/// 账号密码登录命令。校验通过后签发短时 access_token15 分钟)与长时 refresh_token7 天),
/// 并将角色与权限码塞入 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];

View File

@ -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 立即作废,强制下一次刷新必须用新 tokenRotation 防盗用)
stored.IsRevoked = true;
var user = stored.User;

View File

@ -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);

View File

@ -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);
}
}

View File

@ -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;
}
}

View 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);
}
}

View 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);

View File

@ -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);
}
}

View 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);
}
}

View 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个字符");
}
}

View File

@ -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>
{

View 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) 提取:按扩展名分发到对应 ExtractorPDF/DOCX/MD/HTML/TXT把二进制还原为纯文本+元数据;
/// 2) 分块按知识库配置的策略General/Heading/ParentChild切成可向量化的小块
/// 3) 向量化:批量调 embedding 模型,为每块生成向量;
/// 4) 入库:分块元数据走 EF向量字段走 raw SQLEF 不识别 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 SQLExecuteSqlAsync 自动参数化,避免注入)逐块回填 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();
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View 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);

View File

@ -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();
}
}

View 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);
}
}

View File

@ -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不能为空");
}
}

View 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);
}
}

View 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);
}
}

View 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);

View File

@ -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条");
}
}

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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}");
}
}

View 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);

View File

@ -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();
}
}

View 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);
}
}

View File

@ -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;
}
}

View File

@ -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);
// 实时推送:通知 dispatcherSignalR / RabbitMQ 实现可插拔)
await dispatcher.DispatchAsync(notifications, ct);
return notifications.Count;
}
}

View 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);

View 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);
}

View File

@ -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);
}

View File

@ -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}(未变更)。");
}
}

View 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);

View File

@ -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 的 FileStorageClientHTTP 调用 S3
services.AddHttpContextAccessor();
}
}

View 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);
}
}

View 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);

View 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个字符");
}
}

View File

@ -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("部分权限不存在");

View File

@ -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>

View File

@ -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);

View File

@ -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();
}

View 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