feat(file): 添加根级文件查询功能

- 新增 GetRootFilesEndpoint 端点,支持 GET /api/files/root 请求
- 添加 GetRootFilesQuery 查询对象用于获取根级文件
- 实现 GetRootFilesQueryHandler 处理器,查询 FolderId 为 null 的文件
- 统一根目录与子文件夹的数据源,均使用数据库而非 S3 列表
- 修复集成测试中的 MongoDB 访问方式,改用 MongoDB.Entities 静态 DB.Default
- 更新权限配置,使 Admin 角色拥有全部权限而非仅部分权限
This commit is contained in:
xiangning 2026-07-03 12:14:36 +08:00
parent 8e3878b9ff
commit e6b65b6f8f
8 changed files with 92 additions and 38 deletions

View File

@ -0,0 +1,22 @@
using File.Application.Features.Folders.DTOs;
using File.Application.Features.Folders.Queries;
using MediatR;
namespace BuildFlow.Host.Endpoints.file.Folders;
/// <summary>
/// 查询根级文件端点。GET /api/files/root,
/// 返回当前用户 FolderId=null 的所有 FileMeta,使根目录与子文件夹共享同一数据源(DB)。
/// 注意:路径用 /files/root 而非 /folders/root/files,避免与 POST /folders/{FolderId}/files 模板冲突。
/// </summary>
[Authorize]
public class GetRootFilesEndpoint(IMediator mediator) : Endpoint<EmptyRequest, List<FileMetaDto>>
{
public override void Configure() => Get("/files/root");
public override async Task HandleAsync(EmptyRequest req, CancellationToken ct)
{
var result = await mediator.Send(new GetRootFilesQuery(), ct);
await Send.OkAsync(result, ct);
}
}

View File

@ -0,0 +1,28 @@
using BuildFlow.Shared.Domain.Common;
using File.Application.Features.Folders.DTOs;
using File.Application.Features.Folders.Queries;
using File.Infrastructure.Persistence;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace File.Application.Features.Folders.Handlers;
/// <summary>
/// 查询根级文件处理器。返回当前用户名下 FolderId=null 的所有 FileMeta(按名称排序)。
/// 与 GetFolderQueryHandler 配合,使根目录与子文件夹共享同一数据源(DB),而非 S3 列表。
/// </summary>
public class GetRootFilesQueryHandler(FileDbContext db, ICurrentUserContext currentUser)
: IRequestHandler<GetRootFilesQuery, List<FileMetaDto>>
{
public async Task<List<FileMetaDto>> Handle(GetRootFilesQuery req, CancellationToken ct)
{
var owner = currentUser.GetRequiredUserId();
var files = await db.Files
.Where(f => f.FolderId == null && f.OwnerId == owner)
.OrderBy(f => f.Name)
.ToListAsync(ct);
return files.Select(f => new FileMetaDto(f.Id, f.FolderId, f.Name, f.S3Key, f.S3Bucket,
f.Size, f.ContentType, f.OwnerId, f.CreatedAt, f.UpdatedAt)).ToList();
}
}

View File

@ -0,0 +1,10 @@
using File.Application.Features.Folders.DTOs;
using MediatR;
namespace File.Application.Features.Folders.Queries;
/// <summary>
/// 查询根级文件(FolderId=null 的 FileMeta)。
/// 根目录和子文件夹统一从 DB 查,避免与 S3 扁平列表数据源冲突。
/// </summary>
public record GetRootFilesQuery : IRequest<List<FileMetaDto>>;

View File

@ -36,8 +36,9 @@ public static class SeedData
var userRoleId = Guid.Parse("20000000-0000-0000-0000-000000000003");
var allPerms = await db.Permissions.ToListAsync();
// SuperAdmin 与 Admin 均拥有全部权限;User 仅拥有只读权限。
await db.RolePermissions.AddRangeAsync(allPerms.Select(p => new RolePermission { RoleId = superAdminRoleId, PermissionId = p.Id }));
await db.RolePermissions.AddRangeAsync(allPerms.Where(p => p.Code is "user:read" or "user:create" or "user:update" or "role:read" or "permission:read").Select(p => new RolePermission { RoleId = adminRoleId, PermissionId = p.Id }));
await db.RolePermissions.AddRangeAsync(allPerms.Select(p => new RolePermission { RoleId = adminRoleId, PermissionId = p.Id }));
await db.RolePermissions.AddRangeAsync(allPerms.Where(p => p.Code is "user:read" or "role:read" or "permission:read").Select(p => new RolePermission { RoleId = userRoleId, PermissionId = p.Id }));
await db.SaveChangesAsync();
}
@ -160,16 +161,10 @@ public static class SeedData
var adminRoleId = Guid.Parse("20000000-0000-0000-0000-000000000002");
var userRoleId = Guid.Parse("20000000-0000-0000-0000-000000000003");
// SuperAdmin 与 Admin 拥有全部权限(动态取全库权限码,避免后续新增权限时遗漏)。
var allPermissionCodes = await db.Permissions.Select(p => p.Code).ToListAsync();
await EnsureRolePermissionsAsync(db, superAdminRoleId, allPermissionCodes);
await EnsureRolePermissionsAsync(db, adminRoleId, [
"user:read",
"user:create",
"user:update",
"role:read",
"permission:read",
"menu:read"
]);
await EnsureRolePermissionsAsync(db, adminRoleId, allPermissionCodes);
await EnsureRolePermissionsAsync(db, userRoleId, [
"user:read",
"role:read",

View File

@ -1,6 +1,7 @@
using IM.Application.Chat.Queries;
using IM.Domain.Enums;
using IM.Infrastructure.Mongo;
using MongoDB.Entities;
using Xunit;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.AspNetCore.SignalR;
@ -13,7 +14,7 @@ namespace BuildFlow.IM.Integration.Tests;
/// 权限隔离与消息历史分页用例。
/// 通过 MediatR 直接驱动查询/命令处理器,并验证 ChatHub 的非法访问会被 query 的鉴权副作用拦截。
/// </summary>
[Collection(ImServerCollection.Name)]
[Xunit.Collection(ImServerCollection.Name)]
public class ChatHubAuthAndHistoryTests : IAsyncLifetime
{
private readonly ImServerFixture _fixture;
@ -116,8 +117,6 @@ public class ChatHubAuthAndHistoryTests : IAsyncLifetime
/// <summary>向指定会话连续写入 N 条消息,返回按插入顺序的 ObjectId 列表。</summary>
private async Task<List<string>> SeedMessagesAsync(Guid conversationId, int count)
{
await using var scope = _fixture.Services.CreateAsyncScope();
var mongo = scope.ServiceProvider.GetRequiredService<MongoDbContext>();
var docs = new List<MessageDocument>();
var baseTime = DateTime.UtcNow.AddMinutes(-count);
for (var i = 0; i < count; i++)
@ -132,8 +131,8 @@ public class ChatHubAuthAndHistoryTests : IAsyncLifetime
CreatedAt = baseTime.AddSeconds(i),
});
}
await mongo.Messages.InsertManyAsync(docs);
return docs.Select(d => d.Id).ToList();
await DB.Default.SaveAsync(docs);
return docs.Select(d => d.ID).ToList();
}
private static async Task WaitForAsync(Func<bool> condition, TimeSpan? timeout = null)

View File

@ -1,7 +1,9 @@
using IM.Application.Chat.DTOs;
using IM.Domain.Common;
using IM.Domain.Enums;
using IM.Infrastructure.Mongo;
using IM.Infrastructure.Persistence;
using MongoDB.Entities;
using Xunit;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.EntityFrameworkCore;
@ -12,7 +14,7 @@ namespace BuildFlow.IM.Integration.Tests;
/// <summary>
/// ChatHub 消息收发端到端用例。覆盖发送 → 广播 → 落库 → 未读累加的核心闭环。
/// </summary>
[Collection(ImServerCollection.Name)]
[Xunit.Collection(ImServerCollection.Name)]
public class ChatHubMessagingTests : IAsyncLifetime
{
private readonly ImServerFixture _fixture;
@ -69,15 +71,13 @@ public class ChatHubMessagingTests : IAsyncLifetime
Assert.Equal(convId, bobReceived!.ConversationId);
Assert.Equal(ImServerFixture.AliceId, aliceReceived.SenderId);
// Assert ②:消息正文已落 MongoDB,可按会话查到
await using (var scope = _fixture.Services.CreateAsyncScope())
{
var mongo = scope.ServiceProvider.GetRequiredService<MongoDbContext>();
var docs = await mongo.Messages.Find(m => m.ConversationId == convId).ToListAsync();
var persisted = Assert.Single(docs);
Assert.Equal("hello integration", persisted.Content);
Assert.False(persisted.IsRecalled);
}
// Assert ②:消息正文已落 MongoDB,可按会话查到(MongoDB.Entities 静态 DB.Default)
var docs = await DB.Default.Find<MessageDocument>()
.Match(m => m.ConversationId == convId)
.ExecuteAsync();
var persisted = Assert.Single(docs);
Assert.Equal("hello integration", persisted.Content);
Assert.False(persisted.IsRecalled);
// Assert ③:会话列表摘要已更新(PostgreSQL 冷数据)
await using (var scope2 = _fixture.Services.CreateAsyncScope())

View File

@ -1,7 +1,9 @@
using IM.Application.Chat.DTOs;
using IM.Domain.Common;
using IM.Domain.Enums;
using IM.Infrastructure.Mongo;
using IM.Infrastructure.Persistence;
using MongoDB.Entities;
using Xunit;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.EntityFrameworkCore;
@ -13,7 +15,7 @@ namespace BuildFlow.IM.Integration.Tests;
/// 已读回执与消息撤回的端到端用例。
/// 覆盖:已读双写清零(PG+Redis)并广播、撤回标记 Mongo 并广播、撤回会话摘要更新。
/// </summary>
[Collection(ImServerCollection.Name)]
[Xunit.Collection(ImServerCollection.Name)]
public class ChatHubReadAndRecallTests : IAsyncLifetime
{
private readonly ImServerFixture _fixture;
@ -111,15 +113,14 @@ public class ChatHubReadAndRecallTests : IAsyncLifetime
// Act:Alice 撤回自己的消息(在 2 分钟时间窗内)
await alice.InvokeAsync("RecallMessage", new RecallMessageRequest(convId, messageId!));
// Assert ①:Mongo 标记撤回
await using (var scope = _fixture.Services.CreateAsyncScope())
{
var mongo = scope.ServiceProvider.GetRequiredService<MongoDbContext>();
var doc = await mongo.Messages.Find(m => m.Id == messageId!).FirstOrDefaultAsync();
Assert.True(doc.IsRecalled);
Assert.Equal(ImServerFixture.AliceId, doc.RecalledBy);
Assert.NotNull(doc.RecalledAt);
}
// Assert ①:Mongo 标记撤回(MongoDB.Entities 静态 DB.Default)
var docs = await DB.Default.Find<MessageDocument>()
.Match(m => m.ID == messageId!)
.ExecuteAsync();
var doc = docs.First();
Assert.True(doc.IsRecalled);
Assert.Equal(ImServerFixture.AliceId, doc.RecalledBy);
Assert.NotNull(doc.RecalledAt);
// Assert ②:会话列表摘要更新为撤回提示(因为撤回的恰是最后一条消息)
await using (var scope2 = _fixture.Services.CreateAsyncScope())

View File

@ -12,6 +12,7 @@ using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using FluentValidation;
using MongoDB.Driver;
using MongoDB.Entities;
using BuildFlow.Shared.Domain.Common;
using BuildFlow.Shared.Infrastructure;
using IM.Application.Chat.Commands;
@ -94,11 +95,9 @@ public sealed class ImServerFixture : IAsyncLifetime
options.UseNpgsql(PostgresConnection)
.AddInterceptors(sp.GetRequiredService<AuditInterceptor>()));
// —— MongoDB:消息正文 ——
var mongoClient = new MongoClient(MongoConnection);
// —— MongoDB:消息正文(MongoDB.Entities 静态 DB.Default)——
var mongoDbName = MongoUrl.Create(MongoConnection).DatabaseName;
services.AddSingleton<IMongoClient>(_ => mongoClient);
services.AddScoped(_ => new MongoDbContext(mongoClient, mongoDbName));
DB.InitAsync(mongoDbName, MongoClientSettings.FromConnectionString(MongoConnection)).GetAwaiter().GetResult();
// —— Redis:在线状态与未读计数的真实实现 ——
services.AddSingleton<IConnectionMultiplexer>(_ => _redis!);