feat(platform): 添加头像URL解析服务支持预签名头像访问
- 注册IAvatarUrlResolver服务实现头像objectKey到预签名URL的实时转换 - 在GetCurrentUserQueryHandler中集成头像URL解析功能 - 在UpdateProfileCommandHandler中添加头像URL转换逻辑 - 创建IAvatarUrlResolver接口定义头像引用解析契约 - 实现InProcessAvatarUrlResolver通过MediatR委托file模块生成预签名URL - 修复Swagger UI单文档场景下的自动加载问题
This commit is contained in:
parent
ade4beacdb
commit
8e9fcb1b10
@ -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");
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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: "",
|
||||||
|
|||||||
30
src/modules/platform/Domain/Services/IAvatarUrlResolver.cs
Normal file
30
src/modules/platform/Domain/Services/IAvatarUrlResolver.cs
Normal 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);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user