diff --git a/src/BuildFlow.Host/Endpoints/Rag/Obsidian/SyncObsidianVaultEndpoint.cs b/src/BuildFlow.Host/Endpoints/Rag/Obsidian/SyncObsidianVaultEndpoint.cs
index bf6458d..f2416a8 100644
--- a/src/BuildFlow.Host/Endpoints/Rag/Obsidian/SyncObsidianVaultEndpoint.cs
+++ b/src/BuildFlow.Host/Endpoints/Rag/Obsidian/SyncObsidianVaultEndpoint.cs
@@ -15,7 +15,7 @@ public class SyncObsidianVaultEndpoint(IMediator mediator)
public override void Configure()
{
Post("/knowledge-bases/{KnowledgeBaseId}/obsidian/sync");
- AllowAnonymous();
+ Permissions("knowledge:write");
}
public override async Task HandleAsync(ObsidianSyncRequest req, CancellationToken ct)
@@ -28,9 +28,3 @@ public class SyncObsidianVaultEndpoint(IMediator mediator)
await Send.OkAsync(result, ct);
}
}
-
-///
-/// Obsidian vault 同步请求校验。
-/// 注意:目录存在性属于 IO 层校验,留给 handler 处理(避免 validator 内做磁盘 IO);
-/// 此处仅校验非空、长度与枚举取值。
-///
diff --git a/src/modules/rag/Application/Obsidian/Handlers/SyncObsidianVaultCommandHandler.cs b/src/modules/rag/Application/Obsidian/Handlers/SyncObsidianVaultCommandHandler.cs
index 396bcc8..d3932c2 100644
--- a/src/modules/rag/Application/Obsidian/Handlers/SyncObsidianVaultCommandHandler.cs
+++ b/src/modules/rag/Application/Obsidian/Handlers/SyncObsidianVaultCommandHandler.cs
@@ -1,9 +1,10 @@
using BuildFlow.Domain.Entities.Document;
-using System.Security.Cryptography;
using MediatR;
using BuildFlow.Application.Obsidian.DTOs;
using BuildFlow.Domain.Enums;
+using BuildFlow.Infrastructure.Obsidian;
using BuildFlow.Shared.Domain.Exceptions;
+using BuildFlow.Shared.Domain.Common;
using BuildFlow.Infrastructure.Persistence;
using BuildFlow.Application.Obsidian.Commands;
@@ -12,24 +13,20 @@ namespace BuildFlow.Application.Obsidian.Handlers;
///
/// SyncObsidian同步仓库Command处理器。执行业务校验后持久化。
///
-public class SyncObsidianVaultCommandHandler(RagDbContext db)
+public class SyncObsidianVaultCommandHandler(
+ RagDbContext db,
+ ICurrentUserContext userContext,
+ ObsidianVaultScanner scanner)
: IRequestHandler
{
- private static readonly string[] ObsidianIgnoreDirs = [".obsidian", ".trash", ".git", "node_modules"];
-
public async Task 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)
+ var userId = userContext.GetRequiredUserId();
+ var kb = await db.KnowledgeBases.FirstOrDefaultAsync(
+ x => x.Id == request.KnowledgeBaseId && x.CreatedBy == userId,
+ ct)
?? throw new NotFoundException(ErrorCode.NotFound, "知识库不存在");
- 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 == DocumentSourceTypeEnum.Obsidian)
@@ -38,62 +35,55 @@ public class SyncObsidianVaultCommandHandler(RagDbContext db)
var added = 0;
var updated = 0;
var skipped = 0;
+ var totalFiles = 0;
- foreach (var file in mdFiles)
+ await foreach (var file in scanner.ScanAsync(request.VaultDirectory, ct))
{
- ct.ThrowIfCancellationRequested();
+ totalFiles++;
- 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 (existing.TryGetValue(file.RelativePath, out var doc))
{
// 增量:哈希未变则跳过
- if (doc.ContentHash == hash)
+ if (doc.ContentHash == file.ContentHash)
{
skipped++;
continue;
}
// 变更:更新文件路径与哈希,标记为待重新处理
- doc.FilePath = file;
- doc.FileName = fileName;
- doc.FileSize = fileSize;
- doc.ContentHash = hash;
+ doc.FilePath = file.FullPath;
+ doc.FileName = file.FileName;
+ doc.FileSize = file.FileSize;
+ doc.ContentHash = file.ContentHash;
doc.Status = DocumentStatusEnum.Pending;
- doc.VaultPath = vaultPath;
+ doc.VaultPath = file.RelativePath;
updated++;
+ continue;
}
- else
+
+ db.Documents.Add(new DocumentEntity
{
- // 新增
- db.Documents.Add(new DocumentEntity
- {
- KnowledgeBaseId = kb.Id,
- Title = fileName,
- FileName = fileName,
- FilePath = file,
- FileSize = fileSize,
- ContentType = "text/markdown",
- Status = DocumentStatusEnum.Pending,
- SourceType = DocumentSourceTypeEnum.Obsidian,
- VaultPath = vaultPath,
- ContentHash = hash,
- ChunkingModeOverride = request.ChunkingModeOverride
- });
- added++;
- }
+ KnowledgeBaseId = kb.Id,
+ Title = file.FileName,
+ FileName = file.FileName,
+ FilePath = file.FullPath,
+ FileSize = file.FileSize,
+ ContentType = "text/markdown",
+ Status = DocumentStatusEnum.Pending,
+ SourceType = DocumentSourceTypeEnum.Obsidian,
+ VaultPath = file.RelativePath,
+ ContentHash = file.ContentHash,
+ ChunkingModeOverride = request.ChunkingModeOverride,
+ });
+ added++;
}
await db.SaveChangesAsync(ct);
return new ObsidianSyncResult(
KnowledgeBaseId: kb.Id,
- VaultDirectory: vaultRoot,
- TotalFiles: mdFiles.Count,
+ VaultDirectory: Path.GetFullPath(request.VaultDirectory),
+ TotalFiles: totalFiles,
Added: added,
Updated: updated,
Skipped: skipped,
diff --git a/tests/BuildFlow.Architecture.Tests/RagSecurityArchitectureTests.cs b/tests/BuildFlow.Architecture.Tests/RagSecurityArchitectureTests.cs
new file mode 100644
index 0000000..235aeae
--- /dev/null
+++ b/tests/BuildFlow.Architecture.Tests/RagSecurityArchitectureTests.cs
@@ -0,0 +1,38 @@
+namespace BuildFlow.Architecture.Tests;
+
+using Xunit;
+
+///
+/// RAG 高风险入口的权限和 CQRS 架构约束。
+///
+public class RagSecurityArchitectureTests
+{
+ [Fact]
+ public void ObsidianSyncEndpointShouldRequireKnowledgeWritePermission()
+ {
+ var source = ReadSource(
+ "src/BuildFlow.Host/Endpoints/Rag/Obsidian/SyncObsidianVaultEndpoint.cs");
+
+ Assert.Contains("Permissions(\"knowledge:write\")", source);
+ Assert.DoesNotContain("AllowAnonymous()", source);
+ }
+
+ private static string ReadSource(string relativePath)
+ {
+ return File.ReadAllText(Path.Combine(
+ FindRepoRoot().FullName,
+ relativePath.Replace('/', Path.DirectorySeparatorChar)));
+ }
+
+ private static DirectoryInfo FindRepoRoot()
+ {
+ var current = new DirectoryInfo(AppContext.BaseDirectory);
+ while (current is not null)
+ {
+ if (File.Exists(Path.Combine(current.FullName, "BuildFlow.slnx"))) return current;
+ current = current.Parent;
+ }
+
+ throw new InvalidOperationException("无法定位 BuildFlow.slnx,无法验证 RAG 安全边界。");
+ }
+}
diff --git a/tests/BuildFlow.Rag.Tests/ObsidianSyncSecurityTests.cs b/tests/BuildFlow.Rag.Tests/ObsidianSyncSecurityTests.cs
new file mode 100644
index 0000000..d2f07bc
--- /dev/null
+++ b/tests/BuildFlow.Rag.Tests/ObsidianSyncSecurityTests.cs
@@ -0,0 +1,96 @@
+using BuildFlow.Application.Obsidian.Commands;
+using BuildFlow.Application.Obsidian.Handlers;
+using BuildFlow.Domain.Entities.KnowledgeBase;
+using BuildFlow.Infrastructure.Obsidian;
+using BuildFlow.Infrastructure.Persistence;
+using BuildFlow.Rag.Tests.Stub;
+using BuildFlow.Shared.Domain.Errors;
+using BuildFlow.Shared.Domain.Exceptions;
+using BuildFlow.Shared.Infrastructure;
+using FluentAssertions;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using Xunit;
+
+namespace BuildFlow.Rag.Tests;
+
+///
+/// Obsidian 同步的知识库所有权和增量行为测试。
+///
+public sealed class ObsidianSyncSecurityTests : IDisposable
+{
+ private readonly string _allowedRoot = Directory.CreateDirectory(Path.Combine(
+ Path.GetTempPath(),
+ $"buildflow-obsidian-sync-{Guid.NewGuid():N}")).FullName;
+
+ [Fact]
+ public async Task NonOwnerShouldReceiveNotFoundWithoutScanningFiles()
+ {
+ var owner = new TestCurrentUserContext { UserId = Guid.NewGuid().ToString() };
+ await using var db = CreateDb(owner);
+ var kb = new KnowledgeBaseEntity { Name = "private" };
+ db.KnowledgeBases.Add(kb);
+ await db.SaveChangesAsync();
+ var caller = new TestCurrentUserContext { UserId = Guid.NewGuid().ToString() };
+ var handler = new SyncObsidianVaultCommandHandler(db, caller, CreateScanner());
+
+ var act = () => handler.Handle(
+ new SyncObsidianVaultCommand(kb.Id, _allowedRoot),
+ CancellationToken.None);
+
+ await act.Should().ThrowAsync()
+ .Where(x => x.Code == ErrorCode.NotFound);
+ db.Documents.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task OwnerSyncShouldAddThenSkipAnUnchangedFile()
+ {
+ var caller = new TestCurrentUserContext();
+ await using var db = CreateDb(caller);
+ var kb = new KnowledgeBaseEntity { Name = "owned" };
+ db.KnowledgeBases.Add(kb);
+ await db.SaveChangesAsync();
+ await File.WriteAllTextAsync(Path.Combine(_allowedRoot, "note.md"), "hello");
+ var handler = new SyncObsidianVaultCommandHandler(db, caller, CreateScanner());
+
+ var first = await handler.Handle(
+ new SyncObsidianVaultCommand(kb.Id, _allowedRoot),
+ CancellationToken.None);
+ var second = await handler.Handle(
+ new SyncObsidianVaultCommand(kb.Id, _allowedRoot),
+ CancellationToken.None);
+
+ first.Added.Should().Be(1);
+ second.Skipped.Should().Be(1);
+ db.Documents.Should().ContainSingle();
+ }
+
+ private static RagDbContext CreateDb(TestCurrentUserContext userContext)
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase($"obsidian-sync-{Guid.NewGuid():N}")
+ .AddInterceptors(new AuditInterceptor(userContext))
+ .Options;
+ return new RagDbContext(options);
+ }
+
+ private ObsidianVaultScanner CreateScanner()
+ {
+ return new ObsidianVaultScanner(
+ Options.Create(new ObsidianOptions
+ {
+ AllowedRoots = [_allowedRoot],
+ MaxFiles = 100,
+ MaxFileBytes = 1024 * 1024,
+ MaxTotalBytes = 10 * 1024 * 1024,
+ }),
+ NullLogger.Instance);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_allowedRoot)) Directory.Delete(_allowedRoot, recursive: true);
+ }
+}