feat(platform): 添加头像URL解析服务支持预签名头像访问

- 注册IAvatarUrlResolver服务实现头像objectKey到预签名URL的实时转换
- 在GetCurrentUserQueryHandler中集成头像URL解析功能
- 在UpdateProfileCommandHandler中添加头像URL转换逻辑
- 创建IAvatarUrlResolver接口定义头像引用解析契约
- 实现InProcessAvatarUrlResolver通过MediatR委托file模块生成预签名URL
- 修复Swagger UI单文档场景下的自动加载问题
This commit is contained in:
xiangning 2026-07-03 10:17:09 +08:00
parent ade4beacdb
commit 8e9fcb1b10
5 changed files with 118 additions and 6 deletions

View File

@ -100,6 +100,9 @@ public class BuildFlowHostModule : AbpModule
// 进程内文件存储:覆盖 rag 原有的 HTTP 版 FileStorageClient(后注册者胜出) // 进程内文件存储:覆盖 rag 原有的 HTTP 版 FileStorageClient(后注册者胜出)
services.AddScoped<BuildFlow.Domain.Interfaces.IFileStorageClient, InProcessFileStorageClient>(); services.AddScoped<BuildFlow.Domain.Interfaces.IFileStorageClient, InProcessFileStorageClient>();
// 头像 URL 解析:platform 模块查询用户信息时,把持久化的 objectKey 换算成预签名 URL
services.AddScoped<BuildFlow.Platform.Domain.Services.IAvatarUrlResolver, InProcessAvatarUrlResolver>();
// JWT 认证 + CORS:使用共享内核扩展方法(消除重复的认证/跨域配置) // JWT 认证 + CORS:使用共享内核扩展方法(消除重复的认证/跨域配置)
services.AddSharedJwtAuthentication(config); services.AddSharedJwtAuthentication(config);
services.AddSharedCors(); 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 = "<script>if(!location.search.includes('url='))location.replace(location.pathname+'?url='+encodeURIComponent('" + defaultDocUrl + "'));</script>";
settings.CustomHeadContent = fixup + settings.CustomHeadContent;
});
// SignalR Hub(rag 通知 + im 聊天,不同路径共存) // SignalR Hub(rag 通知 + im 聊天,不同路径共存)
context.GetEndpointRouteBuilder().MapHub<NotificationHub>("/hubs/notifications"); context.GetEndpointRouteBuilder().MapHub<NotificationHub>("/hubs/notifications");

View File

@ -0,0 +1,50 @@
using MediatR;
using File.Application.Features.Files.Queries;
using BuildFlow.Platform.Domain.Services;
namespace BuildFlow.Host.Services.Integration;
/// <summary>
/// 进程内头像 URL 解析器。把 platform 模块对「头像 objectKey → 预签名 URL」的需求,
/// 通过 MediatR 查询委托给 file 模块的 <see cref="GetFilePreviewQueryHandler"/> 完成,
/// 而非直接注入 file 的 Infrastructure 类型(<c>IS3StorageService</c>)。
/// <para>
/// 这与 <see cref="InProcessFileStorageClient"/> 同构:模块间只依赖 MediatR 消息契约,
/// 不感知 file 的存储实现细节,实现模块解耦。本类只在 BuildFlow Host 中参与 DI,
/// 不影响模块级测试或设计时工具。
/// </para>
/// </summary>
public class InProcessAvatarUrlResolver(IMediator mediator) : IAvatarUrlResolver
{
/// <summary>头像存储桶。与前端 <c>DEFAULT_BUCKET = 'build-flow'</c> 保持一致,避免读写桶不匹配。</summary>
private const string AvatarBucket = "build-flow";
public async Task<string?> 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;
}
}
}

View File

@ -2,13 +2,20 @@ using BuildFlow.Shared.Domain.Exceptions;
using MediatR; using MediatR;
using BuildFlow.Platform.Infrastructure.Persistence; using BuildFlow.Platform.Infrastructure.Persistence;
using BuildFlow.Platform.Application.Auth.Queries; using BuildFlow.Platform.Application.Auth.Queries;
using BuildFlow.Platform.Domain.Services;
namespace BuildFlow.Platform.Application.Auth.Handlers; namespace BuildFlow.Platform.Application.Auth.Handlers;
/// <summary> /// <summary>
/// GetCurrent用户查询处理器。执行业务校验后持久化。 /// GetCurrent用户查询处理器。执行业务校验后持久化。
/// </summary> /// </summary>
public class GetCurrentUserQueryHandler(PlatformDbContext db) : IRequestHandler<GetCurrentUserQuery, CurrentUserInfo> /// <remarks>
/// 头像字段持久化的是 S3 objectKey,这里通过 <see cref="IAvatarUrlResolver"/> 实时换算成
/// 预签名 URL 再返回前端,保证头像长期有效。
/// </remarks>
public class GetCurrentUserQueryHandler(
PlatformDbContext db,
IAvatarUrlResolver avatarResolver) : IRequestHandler<GetCurrentUserQuery, CurrentUserInfo>
{ {
public async Task<CurrentUserInfo> Handle(GetCurrentUserQuery request, CancellationToken ct) public async Task<CurrentUserInfo> 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(); var roles = user.UserRoles.Select(ur => ur.Role.Name).ToList();
// objectKey → 预签名 URL;失败时 resolver 兜底返回原值,不阻断登录链路
var avatarUrl = await avatarResolver.ResolveAsync(user.Avatar, ct);
return new CurrentUserInfo( return new CurrentUserInfo(
UserId: user.Id.ToString(), UserId: user.Id.ToString(),
Username: user.Username, Username: user.Username,
RealName: user.RealName ?? user.Username, RealName: user.RealName ?? user.Username,
Avatar: user.Avatar, Avatar: avatarUrl,
Desc: user.Description ?? user.Email, Desc: user.Description ?? user.Email,
HomePath: "/workspace", HomePath: "/workspace",
Roles: roles, Roles: roles,

View File

@ -3,13 +3,20 @@ using BuildFlow.Platform.Application.Auth.Queries;
using BuildFlow.Platform.Infrastructure.Persistence; using BuildFlow.Platform.Infrastructure.Persistence;
using BuildFlow.Shared.Domain.Exceptions; using BuildFlow.Shared.Domain.Exceptions;
using BuildFlow.Platform.Application.Auth.Commands; using BuildFlow.Platform.Application.Auth.Commands;
using BuildFlow.Platform.Domain.Services;
namespace BuildFlow.Platform.Application.Auth.Handlers; namespace BuildFlow.Platform.Application.Auth.Handlers;
/// <summary> /// <summary>
/// 更新个人资料处理器:只更新 RealName/Avatar/Description,其他字段不动。 /// 更新个人资料处理器:只更新 RealName/Avatar/Description,其他字段不动。
/// </summary> /// </summary>
public class UpdateProfileCommandHandler(PlatformDbContext db) /// <remarks>
/// Avatar 字段持久化的是 S3 objectKey(前端上传后回传),返回给前端的则是经
/// <see cref="IAvatarUrlResolver"/> 换算后的预签名 URL。
/// </remarks>
public class UpdateProfileCommandHandler(
PlatformDbContext db,
IAvatarUrlResolver avatarResolver)
: IRequestHandler<UpdateProfileCommand, CurrentUserInfo> : IRequestHandler<UpdateProfileCommand, CurrentUserInfo>
{ {
public async Task<CurrentUserInfo> Handle(UpdateProfileCommand request, CancellationToken ct) public async Task<CurrentUserInfo> Handle(UpdateProfileCommand request, CancellationToken ct)
@ -19,7 +26,7 @@ public class UpdateProfileCommandHandler(PlatformDbContext db)
.FirstOrDefaultAsync(u => u.Id.ToString() == request.UserId, ct) .FirstOrDefaultAsync(u => u.Id.ToString() == request.UserId, ct)
?? throw new UnauthorizedException("用户不存在"); ?? throw new UnauthorizedException("用户不存在");
// 空值不更新(保持原值),非空才覆盖 // 空值不更新(保持原值),非空才覆盖。Avatar 存的是 objectKey。
if (request.RealName is not null) if (request.RealName is not null)
user.RealName = request.RealName.Trim(); user.RealName = request.RealName.Trim();
if (request.Avatar is not null) if (request.Avatar is not null)
@ -30,11 +37,15 @@ public class UpdateProfileCommandHandler(PlatformDbContext db)
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
var roles = user.UserRoles.Select(ur => ur.Role.Name).ToList(); var roles = user.UserRoles.Select(ur => ur.Role.Name).ToList();
// 返回前把 objectKey 换算成预签名 URL,前端可直接用于展示
var avatarUrl = await avatarResolver.ResolveAsync(user.Avatar, ct);
return new CurrentUserInfo( return new CurrentUserInfo(
UserId: user.Id.ToString(), UserId: user.Id.ToString(),
Username: user.Username, Username: user.Username,
RealName: user.RealName ?? user.Username, RealName: user.RealName ?? user.Username,
Avatar: user.Avatar, Avatar: avatarUrl,
Email: user.Email, Email: user.Email,
Desc: user.Description ?? "", Desc: user.Description ?? "",
HomePath: "", HomePath: "",

View File

@ -0,0 +1,30 @@
namespace BuildFlow.Platform.Domain.Services;
/// <summary>
/// 头像 URL 解析器。将持久化的头像引用解析为前端可直接访问的 URL。
/// </summary>
/// <remarks>
/// <see cref="User.Avatar"/> 字段持久化的是 S3 objectKey(如 <c>avatars/{userId}/{ts}-{name}</c>),
/// 而非可直接访问的 URL——因为 RustFS 对象私有,预签名 URL 最多 24 小时过期。
/// 此接口在查询用户信息时把 objectKey 实时换算成有效的预签名 URL,保证头像永不失效。
/// 实现需走 MediatR 调用 file 模块(见 Host 中的进程内实现),严禁跨模块直接注入 file 的 Infrastructure 类型。
/// </remarks>
public interface IAvatarUrlResolver
{
/// <summary>
/// 解析头像引用。
/// <para>
/// 约定:
/// <list type="bullet">
/// <item><description>引用为 null/空字符串 → 返回 null(无头像)。</description></item>
/// <item><description>引用以 http/https 开头 → 视为已是可用 URL(兼容历史/外链头像),原样返回。</description></item>
/// <item><description>其余视为 objectKey → 生成预签名 URL 返回。</description></item>
/// </list>
/// 解析过程若发生异常(如对象不存在),实现应兜底返回原引用,避免登录/查询链路因头像异常中断。
/// </para>
/// </summary>
/// <param name="avatarRef">头像引用(objectKey 或外链 URL)。</param>
/// <param name="ct">取消令牌。</param>
/// <returns>可访问的头像 URL;无头像时为 null。</returns>
Task<string?> ResolveAsync(string? avatarRef, CancellationToken ct = default);
}