diff --git a/src/BuildFlow.Host/BuildFlowHostModule.cs b/src/BuildFlow.Host/BuildFlowHostModule.cs index 351b635..0302489 100644 --- a/src/BuildFlow.Host/BuildFlowHostModule.cs +++ b/src/BuildFlow.Host/BuildFlowHostModule.cs @@ -100,6 +100,9 @@ public class BuildFlowHostModule : AbpModule // 进程内文件存储:覆盖 rag 原有的 HTTP 版 FileStorageClient(后注册者胜出) services.AddScoped(); + // 头像 URL 解析:platform 模块查询用户信息时,把持久化的 objectKey 换算成预签名 URL + services.AddScoped(); + // JWT 认证 + CORS:使用共享内核扩展方法(消除重复的认证/跨域配置) services.AddSharedJwtAuthentication(config); services.AddSharedCors(); @@ -153,7 +156,15 @@ public class BuildFlowHostModule : AbpModule }; }); - app.UseSwaggerGen(); + // Swagger UI:FastEndpoints 8.1.0 生成的 index.html 用了 urls(复数)+ url:undefined 的多文档模板, + // 单文档场景下 Swagger UI 不会自动加载任何文档(报 "no valid version field")。 + // 通过 uiConfig 注入一段前置脚本:访问 /swagger/index.html 不带 ?url= 时自动补上默认文档 url。 + app.UseSwaggerGen(uiConfig: settings => + { + const string defaultDocUrl = "/swagger/v1/swagger.json"; + var fixup = ""; + settings.CustomHeadContent = fixup + settings.CustomHeadContent; + }); // SignalR Hub(rag 通知 + im 聊天,不同路径共存) context.GetEndpointRouteBuilder().MapHub("/hubs/notifications"); diff --git a/src/BuildFlow.Host/Services/Integration/InProcessAvatarUrlResolver.cs b/src/BuildFlow.Host/Services/Integration/InProcessAvatarUrlResolver.cs new file mode 100644 index 0000000..0056cb8 --- /dev/null +++ b/src/BuildFlow.Host/Services/Integration/InProcessAvatarUrlResolver.cs @@ -0,0 +1,50 @@ +using MediatR; +using File.Application.Features.Files.Queries; +using BuildFlow.Platform.Domain.Services; + +namespace BuildFlow.Host.Services.Integration; + +/// +/// 进程内头像 URL 解析器。把 platform 模块对「头像 objectKey → 预签名 URL」的需求, +/// 通过 MediatR 查询委托给 file 模块的 完成, +/// 而非直接注入 file 的 Infrastructure 类型(IS3StorageService)。 +/// +/// 这与 同构:模块间只依赖 MediatR 消息契约, +/// 不感知 file 的存储实现细节,实现模块解耦。本类只在 BuildFlow Host 中参与 DI, +/// 不影响模块级测试或设计时工具。 +/// +/// +public class InProcessAvatarUrlResolver(IMediator mediator) : IAvatarUrlResolver +{ + /// 头像存储桶。与前端 DEFAULT_BUCKET = 'build-flow' 保持一致,避免读写桶不匹配。 + private const string AvatarBucket = "build-flow"; + + public async Task ResolveAsync(string? avatarRef, CancellationToken ct = default) + { + // null/空:无头像,直接返回 null(前端会回退到默认头像) + if (string.IsNullOrWhiteSpace(avatarRef)) + { + return null; + } + + // http(s) 开头:兼容历史外链头像或已是可用 URL,原样返回,不二次签名 + if (avatarRef.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + avatarRef.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + return avatarRef; + } + + // 其余视为 objectKey:通过 file 模块生成 24 小时有效的预签名 URL + try + { + var result = await mediator.Send(new GetFilePreviewQuery(AvatarBucket, avatarRef), ct); + return result.PresignedUrl; + } + catch + { + // 兜底:预签名失败(对象不存在、存储异常等)不阻断登录/查询,返回原引用, + // 前端加载失败会回退到默认头像。 + return avatarRef; + } + } +} diff --git a/src/modules/platform/Application/Auth/Handlers/GetCurrentUserQueryHandler.cs b/src/modules/platform/Application/Auth/Handlers/GetCurrentUserQueryHandler.cs index 0ddd6e6..eb0f7ae 100644 --- a/src/modules/platform/Application/Auth/Handlers/GetCurrentUserQueryHandler.cs +++ b/src/modules/platform/Application/Auth/Handlers/GetCurrentUserQueryHandler.cs @@ -2,13 +2,20 @@ using BuildFlow.Shared.Domain.Exceptions; using MediatR; using BuildFlow.Platform.Infrastructure.Persistence; using BuildFlow.Platform.Application.Auth.Queries; +using BuildFlow.Platform.Domain.Services; namespace BuildFlow.Platform.Application.Auth.Handlers; /// /// GetCurrent用户查询处理器。执行业务校验后持久化。 /// -public class GetCurrentUserQueryHandler(PlatformDbContext db) : IRequestHandler +/// +/// 头像字段持久化的是 S3 objectKey,这里通过 实时换算成 +/// 预签名 URL 再返回前端,保证头像长期有效。 +/// +public class GetCurrentUserQueryHandler( + PlatformDbContext db, + IAvatarUrlResolver avatarResolver) : IRequestHandler { public async Task Handle(GetCurrentUserQuery request, CancellationToken ct) { @@ -19,11 +26,14 @@ public class GetCurrentUserQueryHandler(PlatformDbContext db) : IRequestHandler< var roles = user.UserRoles.Select(ur => ur.Role.Name).ToList(); + // objectKey → 预签名 URL;失败时 resolver 兜底返回原值,不阻断登录链路 + var avatarUrl = await avatarResolver.ResolveAsync(user.Avatar, ct); + return new CurrentUserInfo( UserId: user.Id.ToString(), Username: user.Username, RealName: user.RealName ?? user.Username, - Avatar: user.Avatar, + Avatar: avatarUrl, Desc: user.Description ?? user.Email, HomePath: "/workspace", Roles: roles, diff --git a/src/modules/platform/Application/Auth/Handlers/UpdateProfileCommandHandler.cs b/src/modules/platform/Application/Auth/Handlers/UpdateProfileCommandHandler.cs index f13d7b0..6408b88 100644 --- a/src/modules/platform/Application/Auth/Handlers/UpdateProfileCommandHandler.cs +++ b/src/modules/platform/Application/Auth/Handlers/UpdateProfileCommandHandler.cs @@ -3,13 +3,20 @@ using BuildFlow.Platform.Application.Auth.Queries; using BuildFlow.Platform.Infrastructure.Persistence; using BuildFlow.Shared.Domain.Exceptions; using BuildFlow.Platform.Application.Auth.Commands; +using BuildFlow.Platform.Domain.Services; namespace BuildFlow.Platform.Application.Auth.Handlers; /// /// 更新个人资料处理器:只更新 RealName/Avatar/Description,其他字段不动。 /// -public class UpdateProfileCommandHandler(PlatformDbContext db) +/// +/// Avatar 字段持久化的是 S3 objectKey(前端上传后回传),返回给前端的则是经 +/// 换算后的预签名 URL。 +/// +public class UpdateProfileCommandHandler( + PlatformDbContext db, + IAvatarUrlResolver avatarResolver) : IRequestHandler { public async Task Handle(UpdateProfileCommand request, CancellationToken ct) @@ -19,7 +26,7 @@ public class UpdateProfileCommandHandler(PlatformDbContext db) .FirstOrDefaultAsync(u => u.Id.ToString() == request.UserId, ct) ?? throw new UnauthorizedException("用户不存在"); - // 空值不更新(保持原值),非空才覆盖 + // 空值不更新(保持原值),非空才覆盖。Avatar 存的是 objectKey。 if (request.RealName is not null) user.RealName = request.RealName.Trim(); if (request.Avatar is not null) @@ -30,11 +37,15 @@ public class UpdateProfileCommandHandler(PlatformDbContext db) await db.SaveChangesAsync(ct); var roles = user.UserRoles.Select(ur => ur.Role.Name).ToList(); + + // 返回前把 objectKey 换算成预签名 URL,前端可直接用于展示 + var avatarUrl = await avatarResolver.ResolveAsync(user.Avatar, ct); + return new CurrentUserInfo( UserId: user.Id.ToString(), Username: user.Username, RealName: user.RealName ?? user.Username, - Avatar: user.Avatar, + Avatar: avatarUrl, Email: user.Email, Desc: user.Description ?? "", HomePath: "", diff --git a/src/modules/platform/Domain/Services/IAvatarUrlResolver.cs b/src/modules/platform/Domain/Services/IAvatarUrlResolver.cs new file mode 100644 index 0000000..2d950db --- /dev/null +++ b/src/modules/platform/Domain/Services/IAvatarUrlResolver.cs @@ -0,0 +1,30 @@ +namespace BuildFlow.Platform.Domain.Services; + +/// +/// 头像 URL 解析器。将持久化的头像引用解析为前端可直接访问的 URL。 +/// +/// +/// 字段持久化的是 S3 objectKey(如 avatars/{userId}/{ts}-{name}), +/// 而非可直接访问的 URL——因为 RustFS 对象私有,预签名 URL 最多 24 小时过期。 +/// 此接口在查询用户信息时把 objectKey 实时换算成有效的预签名 URL,保证头像永不失效。 +/// 实现需走 MediatR 调用 file 模块(见 Host 中的进程内实现),严禁跨模块直接注入 file 的 Infrastructure 类型。 +/// +public interface IAvatarUrlResolver +{ + /// + /// 解析头像引用。 + /// + /// 约定: + /// + /// 引用为 null/空字符串 → 返回 null(无头像)。 + /// 引用以 http/https 开头 → 视为已是可用 URL(兼容历史/外链头像),原样返回。 + /// 其余视为 objectKey → 生成预签名 URL 返回。 + /// + /// 解析过程若发生异常(如对象不存在),实现应兜底返回原引用,避免登录/查询链路因头像异常中断。 + /// + /// + /// 头像引用(objectKey 或外链 URL)。 + /// 取消令牌。 + /// 可访问的头像 URL;无头像时为 null。 + Task ResolveAsync(string? avatarRef, CancellationToken ct = default); +}