Refactor project files for improved readability and structure
- Cleaned up whitespace in Directory.Build.props and Directory.Packages.props for consistency. - Reformatted project file references in PnvPanel.Api.csproj for better clarity. - Enhanced code readability in various endpoint files by adjusting line breaks and indentation. - Standardized method signatures and improved formatting in ResultExtensions and multiple endpoint classes for better maintainability.
This commit is contained in:
@@ -4,12 +4,18 @@ namespace PnvPanel.Application.Activation;
|
||||
|
||||
public static class ActivationErrors
|
||||
{
|
||||
public static readonly Error AlreadyPending =
|
||||
Error.Conflict("Activation.AlreadyPending", "У вас уже есть необработанный запрос на активацию.");
|
||||
public static readonly Error AlreadyPending = Error.Conflict(
|
||||
"Activation.AlreadyPending",
|
||||
"У вас уже есть необработанный запрос на активацию."
|
||||
);
|
||||
|
||||
public static readonly Error NotFound =
|
||||
Error.NotFound("Activation.NotFound", "Запрос на активацию не найден.");
|
||||
public static readonly Error NotFound = Error.NotFound(
|
||||
"Activation.NotFound",
|
||||
"Запрос на активацию не найден."
|
||||
);
|
||||
|
||||
public static readonly Error AlreadyDecided =
|
||||
Error.Conflict("Activation.AlreadyDecided", "Запрос на активацию уже обработан.");
|
||||
public static readonly Error AlreadyDecided = Error.Conflict(
|
||||
"Activation.AlreadyDecided",
|
||||
"Запрос на активацию уже обработан."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,16 @@ using PnvPanel.Domain.Activation;
|
||||
|
||||
namespace PnvPanel.Application.Activation;
|
||||
|
||||
public sealed class GetActivationStatusQueryHandler(IIdentityService identityService, IAppDbContext dbContext, ICurrentUser currentUser)
|
||||
: IQueryHandler<GetActivationStatusQuery, Result<ActivationStatusDto>>
|
||||
public sealed class GetActivationStatusQueryHandler(
|
||||
IIdentityService identityService,
|
||||
IAppDbContext dbContext,
|
||||
ICurrentUser currentUser
|
||||
) : IQueryHandler<GetActivationStatusQuery, Result<ActivationStatusDto>>
|
||||
{
|
||||
public async Task<Result<ActivationStatusDto>> Handle(GetActivationStatusQuery query, CancellationToken cancellationToken)
|
||||
public async Task<Result<ActivationStatusDto>> Handle(
|
||||
GetActivationStatusQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return Result.Failure<ActivationStatusDto>(AuthErrors.Unauthorized);
|
||||
@@ -19,8 +25,10 @@ public sealed class GetActivationStatusQueryHandler(IIdentityService identitySer
|
||||
if (profile is null)
|
||||
return Result.Failure<ActivationStatusDto>(AuthErrors.Unauthorized);
|
||||
|
||||
var pending = await dbContext.ActivationRequests
|
||||
.Where(r => r.UserId == userId && r.Status == ActivationStatus.Pending)
|
||||
var pending = await dbContext
|
||||
.ActivationRequests.Where(r =>
|
||||
r.UserId == userId && r.Status == ActivationStatus.Pending
|
||||
)
|
||||
.Select(r => new ActivationRequestDto(r.Id, r.Comment, r.CreatedAt))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
|
||||
@@ -3,4 +3,5 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Activation;
|
||||
|
||||
public sealed record RequestActivationCommand(string? Comment) : ICommand<Result<ActivationRequestDto>>;
|
||||
public sealed record RequestActivationCommand(string? Comment)
|
||||
: ICommand<Result<ActivationRequestDto>>;
|
||||
|
||||
@@ -8,16 +8,24 @@ using PnvPanel.Domain.Activation;
|
||||
namespace PnvPanel.Application.Activation;
|
||||
|
||||
public sealed class RequestActivationCommandHandler(
|
||||
IAppDbContext dbContext, IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
|
||||
: ICommandHandler<RequestActivationCommand, Result<ActivationRequestDto>>
|
||||
IAppDbContext dbContext,
|
||||
IRealtimeNotifier notifier,
|
||||
ITelegramNotifier telegramNotifier,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<RequestActivationCommand, Result<ActivationRequestDto>>
|
||||
{
|
||||
public async Task<Result<ActivationRequestDto>> Handle(RequestActivationCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result<ActivationRequestDto>> Handle(
|
||||
RequestActivationCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return Result.Failure<ActivationRequestDto>(AuthErrors.Unauthorized);
|
||||
|
||||
var hasPending = await dbContext.ActivationRequests
|
||||
.AnyAsync(r => r.UserId == userId && r.Status == ActivationStatus.Pending, cancellationToken);
|
||||
var hasPending = await dbContext.ActivationRequests.AnyAsync(
|
||||
r => r.UserId == userId && r.Status == ActivationStatus.Pending,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (hasPending)
|
||||
return Result.Failure<ActivationRequestDto>(ActivationErrors.AlreadyPending);
|
||||
@@ -27,9 +35,23 @@ public sealed class RequestActivationCommandHandler(
|
||||
|
||||
var userName = currentUser.UserName ?? userId.ToString();
|
||||
|
||||
await notifier.NotifyActivationRequestedAsync(request.Id, userId, userName, request.Comment, request.CreatedAt, cancellationToken);
|
||||
await telegramNotifier.NotifyAdminsActivationRequestedAsync(request.Id, userName, request.Comment, cancellationToken);
|
||||
await notifier.NotifyActivationRequestedAsync(
|
||||
request.Id,
|
||||
userId,
|
||||
userName,
|
||||
request.Comment,
|
||||
request.CreatedAt,
|
||||
cancellationToken
|
||||
);
|
||||
await telegramNotifier.NotifyAdminsActivationRequestedAsync(
|
||||
request.Id,
|
||||
userName,
|
||||
request.Comment,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Result.Success(new ActivationRequestDto(request.Id, request.Comment, request.CreatedAt));
|
||||
return Result.Success(
|
||||
new ActivationRequestDto(request.Id, request.Comment, request.CreatedAt)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,4 +8,5 @@ public sealed record ActivationRequestAdminDto(
|
||||
string UserName,
|
||||
string? Comment,
|
||||
ActivationStatus Status,
|
||||
DateTimeOffset CreatedAt);
|
||||
DateTimeOffset CreatedAt
|
||||
);
|
||||
|
||||
+34
-10
@@ -10,17 +10,25 @@ using PnvPanel.Domain.Audit;
|
||||
namespace PnvPanel.Application.Admin.Activation;
|
||||
|
||||
public sealed class ApproveActivationCommandHandler(
|
||||
IAppDbContext dbContext, IIdentityService identityService, IRealtimeNotifier notifier,
|
||||
ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
|
||||
: ICommandHandler<ApproveActivationCommand, Result>
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService,
|
||||
IRealtimeNotifier notifier,
|
||||
ITelegramNotifier telegramNotifier,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<ApproveActivationCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(ApproveActivationCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result> Handle(
|
||||
ApproveActivationCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } adminId)
|
||||
return Result.Failure(AuthErrors.Unauthorized);
|
||||
|
||||
var request = await dbContext.ActivationRequests
|
||||
.FirstOrDefaultAsync(r => r.Id == command.RequestId, cancellationToken);
|
||||
var request = await dbContext.ActivationRequests.FirstOrDefaultAsync(
|
||||
r => r.Id == command.RequestId,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (request is null)
|
||||
return Result.Failure(ActivationErrors.NotFound);
|
||||
@@ -30,15 +38,31 @@ public sealed class ApproveActivationCommandHandler(
|
||||
|
||||
request.Approve(adminId);
|
||||
|
||||
var activateResult = await identityService.ActivateUserAsync(request.UserId, adminId, cancellationToken);
|
||||
var activateResult = await identityService.ActivateUserAsync(
|
||||
request.UserId,
|
||||
adminId,
|
||||
cancellationToken
|
||||
);
|
||||
if (!activateResult.IsSuccess)
|
||||
return activateResult;
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
adminId, "ActivationApproved", "User", request.UserId.ToString(), metadata: null, AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
adminId,
|
||||
"ActivationApproved",
|
||||
"User",
|
||||
request.UserId.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
await notifier.NotifyUserActivatedAsync(request.UserId, cancellationToken);
|
||||
await telegramNotifier.NotifyUserAsync(request.UserId, "✅ Ваш аккаунт активирован администратором.", cancellationToken);
|
||||
await telegramNotifier.NotifyUserAsync(
|
||||
request.UserId,
|
||||
"✅ Ваш аккаунт активирован администратором.",
|
||||
cancellationToken
|
||||
);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,8 @@ using PnvPanel.Domain.Activation;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Activation;
|
||||
|
||||
public sealed record ListActivationRequestsQuery(ActivationStatus? StatusFilter, int Page, int PageSize)
|
||||
: IQuery<Result<PagedList<ActivationRequestAdminDto>>>;
|
||||
public sealed record ListActivationRequestsQuery(
|
||||
ActivationStatus? StatusFilter,
|
||||
int Page,
|
||||
int PageSize
|
||||
) : IQuery<Result<PagedList<ActivationRequestAdminDto>>>;
|
||||
|
||||
+22
-8
@@ -5,10 +5,15 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Activation;
|
||||
|
||||
public sealed class ListActivationRequestsQueryHandler(IAppDbContext dbContext, IIdentityService identityService)
|
||||
: IQueryHandler<ListActivationRequestsQuery, Result<PagedList<ActivationRequestAdminDto>>>
|
||||
public sealed class ListActivationRequestsQueryHandler(
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService
|
||||
) : IQueryHandler<ListActivationRequestsQuery, Result<PagedList<ActivationRequestAdminDto>>>
|
||||
{
|
||||
public async Task<Result<PagedList<ActivationRequestAdminDto>>> Handle(ListActivationRequestsQuery query, CancellationToken cancellationToken)
|
||||
public async Task<Result<PagedList<ActivationRequestAdminDto>>> Handle(
|
||||
ListActivationRequestsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var page = query.Page <= 0 ? 1 : query.Page;
|
||||
var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize;
|
||||
@@ -23,13 +28,22 @@ public sealed class ListActivationRequestsQueryHandler(IAppDbContext dbContext,
|
||||
|
||||
var userNames = await identityService.GetUserNamesAsync(
|
||||
page1.Items.Select(r => r.UserId).Distinct().ToList(),
|
||||
cancellationToken);
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var items = page1.Items
|
||||
.Select(r => new ActivationRequestAdminDto(
|
||||
r.Id, r.UserId, userNames.GetValueOrDefault(r.UserId, "?"), r.Comment, r.Status, r.CreatedAt))
|
||||
var items = page1
|
||||
.Items.Select(r => new ActivationRequestAdminDto(
|
||||
r.Id,
|
||||
r.UserId,
|
||||
userNames.GetValueOrDefault(r.UserId, "?"),
|
||||
r.Comment,
|
||||
r.Status,
|
||||
r.CreatedAt
|
||||
))
|
||||
.ToList();
|
||||
|
||||
return Result.Success(new PagedList<ActivationRequestAdminDto>(items, page1.Total, page1.Page, page1.PageSize));
|
||||
return Result.Success(
|
||||
new PagedList<ActivationRequestAdminDto>(items, page1.Total, page1.Page, page1.PageSize)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+22
-7
@@ -9,16 +9,23 @@ using PnvPanel.Domain.Audit;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Activation;
|
||||
|
||||
public sealed class RejectActivationCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser)
|
||||
: ICommandHandler<RejectActivationCommand, Result>
|
||||
public sealed class RejectActivationCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<RejectActivationCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(RejectActivationCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result> Handle(
|
||||
RejectActivationCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } adminId)
|
||||
return Result.Failure(AuthErrors.Unauthorized);
|
||||
|
||||
var request = await dbContext.ActivationRequests
|
||||
.FirstOrDefaultAsync(r => r.Id == command.RequestId, cancellationToken);
|
||||
var request = await dbContext.ActivationRequests.FirstOrDefaultAsync(
|
||||
r => r.Id == command.RequestId,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (request is null)
|
||||
return Result.Failure(ActivationErrors.NotFound);
|
||||
@@ -28,8 +35,16 @@ public sealed class RejectActivationCommandHandler(IAppDbContext dbContext, ICur
|
||||
|
||||
request.Reject(adminId, command.Reason);
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
adminId, "ActivationRejected", "User", request.UserId.ToString(), metadata: null, AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
adminId,
|
||||
"ActivationRejected",
|
||||
"User",
|
||||
request.UserId.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
@@ -3,10 +3,25 @@ using PnvPanel.Domain.Apps;
|
||||
namespace PnvPanel.Application.Admin.Apps;
|
||||
|
||||
public sealed record AdminAppDto(
|
||||
Guid Id, string Name, string DownloadUrl, OsPlatform OperatingSystem, string? Description,
|
||||
string? IconUrl, int SortOrder, bool IsEnabled)
|
||||
Guid Id,
|
||||
string Name,
|
||||
string DownloadUrl,
|
||||
OsPlatform OperatingSystem,
|
||||
string? Description,
|
||||
string? IconUrl,
|
||||
int SortOrder,
|
||||
bool IsEnabled
|
||||
)
|
||||
{
|
||||
public static AdminAppDto FromDomain(ClientApp app) => new(
|
||||
app.Id, app.Name, app.DownloadUrl.ToString(), app.OperatingSystem, app.Description,
|
||||
app.IconUrl, app.SortOrder, app.IsEnabled);
|
||||
public static AdminAppDto FromDomain(ClientApp app) =>
|
||||
new(
|
||||
app.Id,
|
||||
app.Name,
|
||||
app.DownloadUrl.ToString(),
|
||||
app.OperatingSystem,
|
||||
app.Description,
|
||||
app.IconUrl,
|
||||
app.SortOrder,
|
||||
app.IsEnabled
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,5 +4,8 @@ namespace PnvPanel.Application.Admin.Apps;
|
||||
|
||||
public static class AppErrors
|
||||
{
|
||||
public static readonly Error NotFound = Error.NotFound("Apps.NotFound", "Приложение не найдено.");
|
||||
public static readonly Error NotFound = Error.NotFound(
|
||||
"Apps.NotFound",
|
||||
"Приложение не найдено."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,5 +5,10 @@ using PnvPanel.Domain.Apps;
|
||||
namespace PnvPanel.Application.Admin.Apps;
|
||||
|
||||
public sealed record CreateAppCommand(
|
||||
string Name, string DownloadUrl, OsPlatform OperatingSystem, string? Description, string? IconUrl, int SortOrder)
|
||||
: ICommand<Result<AdminAppDto>>;
|
||||
string Name,
|
||||
string DownloadUrl,
|
||||
OsPlatform OperatingSystem,
|
||||
string? Description,
|
||||
string? IconUrl,
|
||||
int SortOrder
|
||||
) : ICommand<Result<AdminAppDto>>;
|
||||
|
||||
@@ -5,13 +5,22 @@ using PnvPanel.Domain.Apps;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Apps;
|
||||
|
||||
public sealed class CreateAppCommandHandler(IAppDbContext dbContext) : ICommandHandler<CreateAppCommand, Result<AdminAppDto>>
|
||||
public sealed class CreateAppCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<CreateAppCommand, Result<AdminAppDto>>
|
||||
{
|
||||
public Task<Result<AdminAppDto>> Handle(CreateAppCommand command, CancellationToken cancellationToken)
|
||||
public Task<Result<AdminAppDto>> Handle(
|
||||
CreateAppCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var app = ClientApp.Create(
|
||||
command.Name, new Uri(command.DownloadUrl, UriKind.Absolute), command.OperatingSystem,
|
||||
command.Description, command.IconUrl, command.SortOrder);
|
||||
command.Name,
|
||||
new Uri(command.DownloadUrl, UriKind.Absolute),
|
||||
command.OperatingSystem,
|
||||
command.Description,
|
||||
command.IconUrl,
|
||||
command.SortOrder
|
||||
);
|
||||
|
||||
dbContext.ClientApps.Add(app);
|
||||
|
||||
|
||||
@@ -5,11 +5,15 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Apps;
|
||||
|
||||
public sealed class DeleteAppCommandHandler(IAppDbContext dbContext) : ICommandHandler<DeleteAppCommand, Result>
|
||||
public sealed class DeleteAppCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<DeleteAppCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(DeleteAppCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var app = await dbContext.ClientApps.FirstOrDefaultAsync(a => a.Id == command.AppId, cancellationToken);
|
||||
var app = await dbContext.ClientApps.FirstOrDefaultAsync(
|
||||
a => a.Id == command.AppId,
|
||||
cancellationToken
|
||||
);
|
||||
if (app is null)
|
||||
return Result.Failure(AppErrors.NotFound);
|
||||
|
||||
|
||||
@@ -5,14 +5,22 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Apps;
|
||||
|
||||
public sealed class ListAdminAppsQueryHandler(IAppDbContext dbContext) : IQueryHandler<ListAdminAppsQuery, Result<IReadOnlyList<AdminAppDto>>>
|
||||
public sealed class ListAdminAppsQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ListAdminAppsQuery, Result<IReadOnlyList<AdminAppDto>>>
|
||||
{
|
||||
public async Task<Result<IReadOnlyList<AdminAppDto>>> Handle(ListAdminAppsQuery query, CancellationToken cancellationToken)
|
||||
public async Task<Result<IReadOnlyList<AdminAppDto>>> Handle(
|
||||
ListAdminAppsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var apps = await dbContext.ClientApps.AsNoTracking()
|
||||
.OrderBy(a => a.OperatingSystem).ThenBy(a => a.SortOrder)
|
||||
var apps = await dbContext
|
||||
.ClientApps.AsNoTracking()
|
||||
.OrderBy(a => a.OperatingSystem)
|
||||
.ThenBy(a => a.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Success<IReadOnlyList<AdminAppDto>>(apps.Select(AdminAppDto.FromDomain).ToList());
|
||||
return Result.Success<IReadOnlyList<AdminAppDto>>(
|
||||
apps.Select(AdminAppDto.FromDomain).ToList()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,12 @@ using PnvPanel.Domain.Apps;
|
||||
namespace PnvPanel.Application.Admin.Apps;
|
||||
|
||||
public sealed record UpdateAppCommand(
|
||||
Guid AppId, string Name, string DownloadUrl, OsPlatform OperatingSystem, string? Description,
|
||||
string? IconUrl, int SortOrder, bool IsEnabled)
|
||||
: ICommand<Result<AdminAppDto>>;
|
||||
Guid AppId,
|
||||
string Name,
|
||||
string DownloadUrl,
|
||||
OsPlatform OperatingSystem,
|
||||
string? Description,
|
||||
string? IconUrl,
|
||||
int SortOrder,
|
||||
bool IsEnabled
|
||||
) : ICommand<Result<AdminAppDto>>;
|
||||
|
||||
@@ -5,17 +5,30 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Apps;
|
||||
|
||||
public sealed class UpdateAppCommandHandler(IAppDbContext dbContext) : ICommandHandler<UpdateAppCommand, Result<AdminAppDto>>
|
||||
public sealed class UpdateAppCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<UpdateAppCommand, Result<AdminAppDto>>
|
||||
{
|
||||
public async Task<Result<AdminAppDto>> Handle(UpdateAppCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result<AdminAppDto>> Handle(
|
||||
UpdateAppCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var app = await dbContext.ClientApps.FirstOrDefaultAsync(a => a.Id == command.AppId, cancellationToken);
|
||||
var app = await dbContext.ClientApps.FirstOrDefaultAsync(
|
||||
a => a.Id == command.AppId,
|
||||
cancellationToken
|
||||
);
|
||||
if (app is null)
|
||||
return Result.Failure<AdminAppDto>(AppErrors.NotFound);
|
||||
|
||||
app.Update(
|
||||
command.Name, new Uri(command.DownloadUrl, UriKind.Absolute), command.OperatingSystem,
|
||||
command.Description, command.IconUrl, command.SortOrder, command.IsEnabled);
|
||||
command.Name,
|
||||
new Uri(command.DownloadUrl, UriKind.Absolute),
|
||||
command.OperatingSystem,
|
||||
command.Description,
|
||||
command.IconUrl,
|
||||
command.SortOrder,
|
||||
command.IsEnabled
|
||||
);
|
||||
|
||||
return Result.Success(AdminAppDto.FromDomain(app));
|
||||
}
|
||||
|
||||
@@ -4,8 +4,16 @@ using PnvPanel.Domain.Audit;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Audit;
|
||||
|
||||
public sealed record ListAuditLogsQuery(int Page, int PageSize) : IQuery<Result<PagedList<AuditLogDto>>>;
|
||||
public sealed record ListAuditLogsQuery(int Page, int PageSize)
|
||||
: IQuery<Result<PagedList<AuditLogDto>>>;
|
||||
|
||||
public sealed record AuditLogDto(
|
||||
long Id, Guid? ActorId, string Action, string TargetType, string TargetId, string? Metadata,
|
||||
AuditSource Source, DateTimeOffset CreatedAt);
|
||||
long Id,
|
||||
Guid? ActorId,
|
||||
string Action,
|
||||
string TargetType,
|
||||
string TargetId,
|
||||
string? Metadata,
|
||||
AuditSource Source,
|
||||
DateTimeOffset CreatedAt
|
||||
);
|
||||
|
||||
@@ -5,16 +5,30 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Audit;
|
||||
|
||||
public sealed class ListAuditLogsQueryHandler(IAppDbContext dbContext) : IQueryHandler<ListAuditLogsQuery, Result<PagedList<AuditLogDto>>>
|
||||
public sealed class ListAuditLogsQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ListAuditLogsQuery, Result<PagedList<AuditLogDto>>>
|
||||
{
|
||||
public async Task<Result<PagedList<AuditLogDto>>> Handle(ListAuditLogsQuery query, CancellationToken cancellationToken)
|
||||
public async Task<Result<PagedList<AuditLogDto>>> Handle(
|
||||
ListAuditLogsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var page = query.Page <= 0 ? 1 : query.Page;
|
||||
var pageSize = query.PageSize is <= 0 or > 200 ? 50 : query.PageSize;
|
||||
|
||||
var result = await dbContext.AuditLogs.AsNoTracking()
|
||||
var result = await dbContext
|
||||
.AuditLogs.AsNoTracking()
|
||||
.OrderByDescending(a => a.CreatedAt)
|
||||
.Select(a => new AuditLogDto(a.Id, a.ActorId, a.Action, a.TargetType, a.TargetId, a.Metadata, a.Source, a.CreatedAt))
|
||||
.Select(a => new AuditLogDto(
|
||||
a.Id,
|
||||
a.ActorId,
|
||||
a.Action,
|
||||
a.TargetType,
|
||||
a.TargetId,
|
||||
a.Metadata,
|
||||
a.Source,
|
||||
a.CreatedAt
|
||||
))
|
||||
.ToPagedListAsync(page, pageSize, cancellationToken);
|
||||
|
||||
return Result.Success(result);
|
||||
|
||||
@@ -6,6 +6,17 @@ namespace PnvPanel.Application.Admin.Configs;
|
||||
/// <summary>Строка глобального списка конфигов для админа — в отличие от VpnConfigDto (self-service)
|
||||
/// содержит владельца и ноду, т.к. список не скоупится одним пользователем.</summary>
|
||||
public sealed record AdminVpnConfigDto(
|
||||
Guid Id, Guid UserId, string UserName, string? Label, string ClientEmail, VpnProtocol Protocol,
|
||||
string Location, string NodeName, long UsedUpBytes, long UsedDownBytes, DateTimeOffset? ExpiresAt,
|
||||
ConfigStatus Status, DateTimeOffset CreatedAt);
|
||||
Guid Id,
|
||||
Guid UserId,
|
||||
string UserName,
|
||||
string? Label,
|
||||
string ClientEmail,
|
||||
VpnProtocol Protocol,
|
||||
string Location,
|
||||
string NodeName,
|
||||
long UsedUpBytes,
|
||||
long UsedDownBytes,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
ConfigStatus Status,
|
||||
DateTimeOffset CreatedAt
|
||||
);
|
||||
|
||||
@@ -7,5 +7,9 @@ namespace PnvPanel.Application.Admin.Configs;
|
||||
|
||||
/// <summary><paramref name="Search"/> матчится по ClientEmail/Label — это то, по чему админ сверяет
|
||||
/// конфиг с записью в 3x-ui, а не по владельцу (для поиска по пользователю есть /admin/users).</summary>
|
||||
public sealed record ListAllConfigsQuery(int Page, int PageSize, string? Search, ConfigStatus? Status)
|
||||
: IQuery<Result<PagedList<AdminVpnConfigDto>>>;
|
||||
public sealed record ListAllConfigsQuery(
|
||||
int Page,
|
||||
int PageSize,
|
||||
string? Search,
|
||||
ConfigStatus? Status
|
||||
) : IQuery<Result<PagedList<AdminVpnConfigDto>>>;
|
||||
|
||||
@@ -5,10 +5,15 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Configs;
|
||||
|
||||
public sealed class ListAllConfigsQueryHandler(IAppDbContext dbContext, IIdentityService identityService)
|
||||
: IQueryHandler<ListAllConfigsQuery, Result<PagedList<AdminVpnConfigDto>>>
|
||||
public sealed class ListAllConfigsQueryHandler(
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService
|
||||
) : IQueryHandler<ListAllConfigsQuery, Result<PagedList<AdminVpnConfigDto>>>
|
||||
{
|
||||
public async Task<Result<PagedList<AdminVpnConfigDto>>> Handle(ListAllConfigsQuery query, CancellationToken cancellationToken)
|
||||
public async Task<Result<PagedList<AdminVpnConfigDto>>> Handle(
|
||||
ListAllConfigsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var page = query.Page <= 0 ? 1 : query.Page;
|
||||
var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize;
|
||||
@@ -21,7 +26,9 @@ public sealed class ListAllConfigsQueryHandler(IAppDbContext dbContext, IIdentit
|
||||
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||
{
|
||||
var search = query.Search.Trim();
|
||||
configsQuery = configsQuery.Where(c => c.ClientEmail.Contains(search) || (c.Label != null && c.Label.Contains(search)));
|
||||
configsQuery = configsQuery.Where(c =>
|
||||
c.ClientEmail.Contains(search) || (c.Label != null && c.Label.Contains(search))
|
||||
);
|
||||
}
|
||||
|
||||
var pageResult = await configsQuery
|
||||
@@ -29,30 +36,56 @@ public sealed class ListAllConfigsQueryHandler(IAppDbContext dbContext, IIdentit
|
||||
.ToPagedListAsync(page, pageSize, cancellationToken);
|
||||
|
||||
var inboundIds = pageResult.Items.Select(c => c.InboundId).Distinct().ToList();
|
||||
var inbounds = (await dbContext.Inbounds.AsNoTracking()
|
||||
var inbounds = (
|
||||
await dbContext
|
||||
.Inbounds.AsNoTracking()
|
||||
.Where(i => inboundIds.Contains(i.Id))
|
||||
.ToListAsync(cancellationToken))
|
||||
.ToDictionary(i => i.Id);
|
||||
.ToListAsync(cancellationToken)
|
||||
).ToDictionary(i => i.Id);
|
||||
|
||||
var nodeIds = inbounds.Values.Select(i => i.NodeId).Distinct().ToList();
|
||||
var nodes = (await dbContext.Nodes.AsNoTracking()
|
||||
var nodes = (
|
||||
await dbContext
|
||||
.Nodes.AsNoTracking()
|
||||
.Where(n => nodeIds.Contains(n.Id))
|
||||
.ToListAsync(cancellationToken))
|
||||
.ToDictionary(n => n.Id);
|
||||
.ToListAsync(cancellationToken)
|
||||
).ToDictionary(n => n.Id);
|
||||
|
||||
var userNames = await identityService.GetUserNamesAsync(
|
||||
pageResult.Items.Select(c => c.UserId).Distinct().ToList(), cancellationToken);
|
||||
pageResult.Items.Select(c => c.UserId).Distinct().ToList(),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var items = pageResult.Items.Select(c =>
|
||||
{
|
||||
var inbound = inbounds.GetValueOrDefault(c.InboundId);
|
||||
var node = inbound is null ? null : nodes.GetValueOrDefault(inbound.NodeId);
|
||||
return new AdminVpnConfigDto(
|
||||
c.Id, c.UserId, userNames.GetValueOrDefault(c.UserId, "?"), c.Label, c.ClientEmail, c.Protocol,
|
||||
inbound?.DisplayName ?? inbound?.Remark ?? "?", node?.Name ?? "?",
|
||||
c.UsedUpBytes, c.UsedDownBytes, c.ExpiresAt, c.Status, c.CreatedAt);
|
||||
}).ToList();
|
||||
var items = pageResult
|
||||
.Items.Select(c =>
|
||||
{
|
||||
var inbound = inbounds.GetValueOrDefault(c.InboundId);
|
||||
var node = inbound is null ? null : nodes.GetValueOrDefault(inbound.NodeId);
|
||||
return new AdminVpnConfigDto(
|
||||
c.Id,
|
||||
c.UserId,
|
||||
userNames.GetValueOrDefault(c.UserId, "?"),
|
||||
c.Label,
|
||||
c.ClientEmail,
|
||||
c.Protocol,
|
||||
inbound?.DisplayName ?? inbound?.Remark ?? "?",
|
||||
node?.Name ?? "?",
|
||||
c.UsedUpBytes,
|
||||
c.UsedDownBytes,
|
||||
c.ExpiresAt,
|
||||
c.Status,
|
||||
c.CreatedAt
|
||||
);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Result.Success(new PagedList<AdminVpnConfigDto>(items, pageResult.Total, pageResult.Page, pageResult.PageSize));
|
||||
return Result.Success(
|
||||
new PagedList<AdminVpnConfigDto>(
|
||||
items,
|
||||
pageResult.Total,
|
||||
pageResult.Page,
|
||||
pageResult.PageSize
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,31 @@ using PnvPanel.Domain.Inbounds;
|
||||
namespace PnvPanel.Application.Admin.Inbounds;
|
||||
|
||||
public sealed record InboundDto(
|
||||
Guid Id, Guid NodeId, string RemoteInboundId, VpnProtocol Protocol, string Remark, int Port,
|
||||
bool IsPublished, string? DisplayName, int? MaxClients, IReadOnlyList<Guid> AllowedRoleIds,
|
||||
DateTimeOffset? LastSyncAt)
|
||||
Guid Id,
|
||||
Guid NodeId,
|
||||
string RemoteInboundId,
|
||||
VpnProtocol Protocol,
|
||||
string Remark,
|
||||
int Port,
|
||||
bool IsPublished,
|
||||
string? DisplayName,
|
||||
int? MaxClients,
|
||||
IReadOnlyList<Guid> AllowedRoleIds,
|
||||
DateTimeOffset? LastSyncAt
|
||||
)
|
||||
{
|
||||
public static InboundDto FromDomain(Inbound inbound) => new(
|
||||
inbound.Id, inbound.NodeId, inbound.RemoteInboundId, inbound.Protocol, inbound.Remark, inbound.Port,
|
||||
inbound.IsPublished, inbound.DisplayName, inbound.MaxClients, inbound.AllowedRoleIds, inbound.LastSyncAt);
|
||||
public static InboundDto FromDomain(Inbound inbound) =>
|
||||
new(
|
||||
inbound.Id,
|
||||
inbound.NodeId,
|
||||
inbound.RemoteInboundId,
|
||||
inbound.Protocol,
|
||||
inbound.Remark,
|
||||
inbound.Port,
|
||||
inbound.IsPublished,
|
||||
inbound.DisplayName,
|
||||
inbound.MaxClients,
|
||||
inbound.AllowedRoleIds,
|
||||
inbound.LastSyncAt
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,5 +4,8 @@ namespace PnvPanel.Application.Admin.Inbounds;
|
||||
|
||||
public static class InboundErrors
|
||||
{
|
||||
public static readonly Error NotFound = Error.NotFound("Inbounds.NotFound", "Inbound не найден.");
|
||||
public static readonly Error NotFound = Error.NotFound(
|
||||
"Inbounds.NotFound",
|
||||
"Inbound не найден."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,15 +5,21 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Inbounds;
|
||||
|
||||
public sealed class ListInboundsQueryHandler(IAppDbContext dbContext) : IQueryHandler<ListInboundsQuery, Result<IReadOnlyList<InboundDto>>>
|
||||
public sealed class ListInboundsQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ListInboundsQuery, Result<IReadOnlyList<InboundDto>>>
|
||||
{
|
||||
public async Task<Result<IReadOnlyList<InboundDto>>> Handle(ListInboundsQuery query, CancellationToken cancellationToken)
|
||||
public async Task<Result<IReadOnlyList<InboundDto>>> Handle(
|
||||
ListInboundsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var inboundsQuery = dbContext.Inbounds.AsNoTracking();
|
||||
if (query.NodeId is { } nodeId)
|
||||
inboundsQuery = inboundsQuery.Where(i => i.NodeId == nodeId);
|
||||
|
||||
var inbounds = await inboundsQuery.OrderBy(i => i.Remark).ToListAsync(cancellationToken);
|
||||
return Result.Success<IReadOnlyList<InboundDto>>(inbounds.Select(InboundDto.FromDomain).ToList());
|
||||
return Result.Success<IReadOnlyList<InboundDto>>(
|
||||
inbounds.Select(InboundDto.FromDomain).ToList()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,9 @@ using PnvPanel.Application.Common.Models;
|
||||
namespace PnvPanel.Application.Admin.Inbounds;
|
||||
|
||||
public sealed record PublishInboundCommand(
|
||||
Guid InboundId, bool IsPublished, string? DisplayName, IReadOnlyList<Guid> AllowedRoleIds, int? MaxClients)
|
||||
: ICommand<Result<InboundDto>>;
|
||||
Guid InboundId,
|
||||
bool IsPublished,
|
||||
string? DisplayName,
|
||||
IReadOnlyList<Guid> AllowedRoleIds,
|
||||
int? MaxClients
|
||||
) : ICommand<Result<InboundDto>>;
|
||||
|
||||
@@ -9,9 +9,15 @@ namespace PnvPanel.Application.Admin.Inbounds;
|
||||
public sealed class PublishInboundCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser)
|
||||
: ICommandHandler<PublishInboundCommand, Result<InboundDto>>
|
||||
{
|
||||
public async Task<Result<InboundDto>> Handle(PublishInboundCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result<InboundDto>> Handle(
|
||||
PublishInboundCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var inbound = await dbContext.Inbounds.FirstOrDefaultAsync(i => i.Id == command.InboundId, cancellationToken);
|
||||
var inbound = await dbContext.Inbounds.FirstOrDefaultAsync(
|
||||
i => i.Id == command.InboundId,
|
||||
cancellationToken
|
||||
);
|
||||
if (inbound is null)
|
||||
return Result.Failure<InboundDto>(InboundErrors.NotFound);
|
||||
|
||||
@@ -20,9 +26,16 @@ public sealed class PublishInboundCommandHandler(IAppDbContext dbContext, ICurre
|
||||
else
|
||||
inbound.Unpublish();
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
currentUser.UserId, command.IsPublished ? "InboundPublished" : "InboundUnpublished",
|
||||
"Inbound", inbound.Id.ToString(), metadata: null, AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
currentUser.UserId,
|
||||
command.IsPublished ? "InboundPublished" : "InboundUnpublished",
|
||||
"Inbound",
|
||||
inbound.Id.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
return Result.Success(InboundDto.FromDomain(inbound));
|
||||
}
|
||||
|
||||
@@ -6,15 +6,26 @@ using PnvPanel.Domain.News;
|
||||
|
||||
namespace PnvPanel.Application.Admin.News;
|
||||
|
||||
public sealed class CreatePostCommandHandler(IAppDbContext dbContext, IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier)
|
||||
: ICommandHandler<CreatePostCommand, Result<NewsPostDto>>
|
||||
public sealed class CreatePostCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IRealtimeNotifier notifier,
|
||||
ITelegramNotifier telegramNotifier
|
||||
) : ICommandHandler<CreatePostCommand, Result<NewsPostDto>>
|
||||
{
|
||||
public async Task<Result<NewsPostDto>> Handle(CreatePostCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result<NewsPostDto>> Handle(
|
||||
CreatePostCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var post = NewsPost.Create(command.Title, command.Body);
|
||||
dbContext.NewsPosts.Add(post);
|
||||
|
||||
await notifier.NotifyNewsPublishedAsync(post.Id, post.Title, post.CreatedAt, cancellationToken);
|
||||
await notifier.NotifyNewsPublishedAsync(
|
||||
post.Id,
|
||||
post.Title,
|
||||
post.CreatedAt,
|
||||
cancellationToken
|
||||
);
|
||||
await telegramNotifier.NotifyUsersNewsPublishedAsync(post.Title, cancellationToken);
|
||||
|
||||
return Result.Success(NewsPostDto.FromDomain(post));
|
||||
|
||||
@@ -5,11 +5,15 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.News;
|
||||
|
||||
public sealed class DeletePostCommandHandler(IAppDbContext dbContext) : ICommandHandler<DeletePostCommand, Result>
|
||||
public sealed class DeletePostCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<DeletePostCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(DeletePostCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var post = await dbContext.NewsPosts.FirstOrDefaultAsync(p => p.Id == command.PostId, cancellationToken);
|
||||
var post = await dbContext.NewsPosts.FirstOrDefaultAsync(
|
||||
p => p.Id == command.PostId,
|
||||
cancellationToken
|
||||
);
|
||||
if (post is null)
|
||||
return Result.Failure(NewsErrors.NotFound);
|
||||
|
||||
|
||||
@@ -4,4 +4,5 @@ using PnvPanel.Application.News;
|
||||
|
||||
namespace PnvPanel.Application.Admin.News;
|
||||
|
||||
public sealed record ListAdminNewsQuery(int Page, int PageSize) : IQuery<Result<PagedList<NewsPostDto>>>;
|
||||
public sealed record ListAdminNewsQuery(int Page, int PageSize)
|
||||
: IQuery<Result<PagedList<NewsPostDto>>>;
|
||||
|
||||
@@ -6,14 +6,19 @@ using PnvPanel.Application.News;
|
||||
|
||||
namespace PnvPanel.Application.Admin.News;
|
||||
|
||||
public sealed class ListAdminNewsQueryHandler(IAppDbContext dbContext) : IQueryHandler<ListAdminNewsQuery, Result<PagedList<NewsPostDto>>>
|
||||
public sealed class ListAdminNewsQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ListAdminNewsQuery, Result<PagedList<NewsPostDto>>>
|
||||
{
|
||||
public async Task<Result<PagedList<NewsPostDto>>> Handle(ListAdminNewsQuery query, CancellationToken cancellationToken)
|
||||
public async Task<Result<PagedList<NewsPostDto>>> Handle(
|
||||
ListAdminNewsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var page = query.Page <= 0 ? 1 : query.Page;
|
||||
var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize;
|
||||
|
||||
var result = await dbContext.NewsPosts.AsNoTracking()
|
||||
var result = await dbContext
|
||||
.NewsPosts.AsNoTracking()
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.Select(p => new NewsPostDto(p.Id, p.Title, p.Body, p.CreatedAt, p.UpdatedAt))
|
||||
.ToPagedListAsync(page, pageSize, cancellationToken);
|
||||
|
||||
@@ -4,4 +4,5 @@ using PnvPanel.Application.News;
|
||||
|
||||
namespace PnvPanel.Application.Admin.News;
|
||||
|
||||
public sealed record UpdatePostCommand(Guid PostId, string Title, string Body) : ICommand<Result<NewsPostDto>>;
|
||||
public sealed record UpdatePostCommand(Guid PostId, string Title, string Body)
|
||||
: ICommand<Result<NewsPostDto>>;
|
||||
|
||||
@@ -6,11 +6,18 @@ using PnvPanel.Application.News;
|
||||
|
||||
namespace PnvPanel.Application.Admin.News;
|
||||
|
||||
public sealed class UpdatePostCommandHandler(IAppDbContext dbContext) : ICommandHandler<UpdatePostCommand, Result<NewsPostDto>>
|
||||
public sealed class UpdatePostCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<UpdatePostCommand, Result<NewsPostDto>>
|
||||
{
|
||||
public async Task<Result<NewsPostDto>> Handle(UpdatePostCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result<NewsPostDto>> Handle(
|
||||
UpdatePostCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var post = await dbContext.NewsPosts.FirstOrDefaultAsync(p => p.Id == command.PostId, cancellationToken);
|
||||
var post = await dbContext.NewsPosts.FirstOrDefaultAsync(
|
||||
p => p.Id == command.PostId,
|
||||
cancellationToken
|
||||
);
|
||||
if (post is null)
|
||||
return Result.Failure<NewsPostDto>(NewsErrors.NotFound);
|
||||
|
||||
|
||||
@@ -6,22 +6,38 @@ using PnvPanel.Domain.Audit;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Nodes;
|
||||
|
||||
public sealed class DeleteNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway, ICurrentUser currentUser)
|
||||
: ICommandHandler<DeleteNodeCommand, Result>
|
||||
public sealed class DeleteNodeCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IXuiPanelGateway gateway,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<DeleteNodeCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(DeleteNodeCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var node = await dbContext.Nodes.FirstOrDefaultAsync(n => n.Id == command.NodeId, cancellationToken);
|
||||
var node = await dbContext.Nodes.FirstOrDefaultAsync(
|
||||
n => n.Id == command.NodeId,
|
||||
cancellationToken
|
||||
);
|
||||
if (node is null)
|
||||
return Result.Failure(NodeErrors.NotFound);
|
||||
|
||||
var inbounds = await dbContext.Inbounds.Where(i => i.NodeId == node.Id).ToListAsync(cancellationToken);
|
||||
var inbounds = await dbContext
|
||||
.Inbounds.Where(i => i.NodeId == node.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
dbContext.Inbounds.RemoveRange(inbounds);
|
||||
dbContext.Nodes.Remove(node);
|
||||
gateway.InvalidateClient(node.Id);
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
currentUser.UserId, "NodeDeleted", "Node", node.Id.ToString(), metadata: null, AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
currentUser.UserId,
|
||||
"NodeDeleted",
|
||||
"Node",
|
||||
node.Id.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
@@ -5,11 +5,18 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Nodes;
|
||||
|
||||
public sealed class ListNodesQueryHandler(IAppDbContext dbContext) : IQueryHandler<ListNodesQuery, Result<IReadOnlyList<NodeDto>>>
|
||||
public sealed class ListNodesQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ListNodesQuery, Result<IReadOnlyList<NodeDto>>>
|
||||
{
|
||||
public async Task<Result<IReadOnlyList<NodeDto>>> Handle(ListNodesQuery query, CancellationToken cancellationToken)
|
||||
public async Task<Result<IReadOnlyList<NodeDto>>> Handle(
|
||||
ListNodesQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var nodes = await dbContext.Nodes.AsNoTracking().OrderBy(n => n.Name).ToListAsync(cancellationToken);
|
||||
var nodes = await dbContext
|
||||
.Nodes.AsNoTracking()
|
||||
.OrderBy(n => n.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
return Result.Success<IReadOnlyList<NodeDto>>(nodes.Select(NodeDto.FromDomain).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,25 @@ namespace PnvPanel.Application.Admin.Nodes;
|
||||
|
||||
/// <summary>Админский DTO ноды. Пароль никогда не попадает в ответ API.</summary>
|
||||
public sealed record NodeDto(
|
||||
Guid Id, string Name, string BaseAddress, string Username, string? Location,
|
||||
NodeStatus Status, bool IsEnabled, DateTimeOffset? LastSyncAt)
|
||||
Guid Id,
|
||||
string Name,
|
||||
string BaseAddress,
|
||||
string Username,
|
||||
string? Location,
|
||||
NodeStatus Status,
|
||||
bool IsEnabled,
|
||||
DateTimeOffset? LastSyncAt
|
||||
)
|
||||
{
|
||||
public static NodeDto FromDomain(Node node) => new(
|
||||
node.Id, node.Name, node.BaseAddress.ToString(), node.Credentials.Username, node.Location,
|
||||
node.Status, node.IsEnabled, node.LastSyncAt);
|
||||
public static NodeDto FromDomain(Node node) =>
|
||||
new(
|
||||
node.Id,
|
||||
node.Name,
|
||||
node.BaseAddress.ToString(),
|
||||
node.Credentials.Username,
|
||||
node.Location,
|
||||
node.Status,
|
||||
node.IsEnabled,
|
||||
node.LastSyncAt
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,5 +5,8 @@ namespace PnvPanel.Application.Admin.Nodes;
|
||||
public static class NodeErrors
|
||||
{
|
||||
public static readonly Error NotFound = Error.NotFound("Nodes.NotFound", "Нода не найдена.");
|
||||
public static readonly Error InvalidBaseAddress = Error.Validation("Nodes.InvalidBaseAddress", "Некорректный адрес панели.");
|
||||
public static readonly Error InvalidBaseAddress = Error.Validation(
|
||||
"Nodes.InvalidBaseAddress",
|
||||
"Некорректный адрес панели."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,15 +9,23 @@ namespace PnvPanel.Application.Admin.Nodes;
|
||||
public sealed class ProbeNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway)
|
||||
: ICommandHandler<ProbeNodeCommand, Result<NodeProbeResultDto>>
|
||||
{
|
||||
public async Task<Result<NodeProbeResultDto>> Handle(ProbeNodeCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result<NodeProbeResultDto>> Handle(
|
||||
ProbeNodeCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var node = await dbContext.Nodes.FirstOrDefaultAsync(n => n.Id == command.NodeId, cancellationToken);
|
||||
var node = await dbContext.Nodes.FirstOrDefaultAsync(
|
||||
n => n.Id == command.NodeId,
|
||||
cancellationToken
|
||||
);
|
||||
if (node is null)
|
||||
return Result.Failure<NodeProbeResultDto>(NodeErrors.NotFound);
|
||||
|
||||
var probe = await gateway.ProbeAsync(node, cancellationToken);
|
||||
node.UpdateStatus(probe.IsReachable ? NodeStatus.Online : NodeStatus.Offline);
|
||||
|
||||
return Result.Success(new NodeProbeResultDto(probe.IsReachable, probe.ErrorMessage, node.Status));
|
||||
return Result.Success(
|
||||
new NodeProbeResultDto(probe.IsReachable, probe.ErrorMessage, node.Status)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,5 +3,10 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Nodes;
|
||||
|
||||
public sealed record RegisterNodeCommand(string Name, string BaseAddress, string Username, string Password, string? Location)
|
||||
: ICommand<Result<NodeDto>>;
|
||||
public sealed record RegisterNodeCommand(
|
||||
string Name,
|
||||
string BaseAddress,
|
||||
string Username,
|
||||
string Password,
|
||||
string? Location
|
||||
) : ICommand<Result<NodeDto>>;
|
||||
|
||||
@@ -7,10 +7,16 @@ using PnvPanel.Domain.Nodes;
|
||||
namespace PnvPanel.Application.Admin.Nodes;
|
||||
|
||||
public sealed class RegisterNodeCommandHandler(
|
||||
IAppDbContext dbContext, IXuiPanelGateway gateway, ISecretProtector secretProtector, ICurrentUser currentUser)
|
||||
: ICommandHandler<RegisterNodeCommand, Result<NodeDto>>
|
||||
IAppDbContext dbContext,
|
||||
IXuiPanelGateway gateway,
|
||||
ISecretProtector secretProtector,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<RegisterNodeCommand, Result<NodeDto>>
|
||||
{
|
||||
public Task<Result<NodeDto>> Handle(RegisterNodeCommand command, CancellationToken cancellationToken)
|
||||
public Task<Result<NodeDto>> Handle(
|
||||
RegisterNodeCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (!Uri.TryCreate(command.BaseAddress, UriKind.Absolute, out var baseAddress))
|
||||
return Task.FromResult(Result.Failure<NodeDto>(NodeErrors.InvalidBaseAddress));
|
||||
@@ -19,12 +25,23 @@ public sealed class RegisterNodeCommandHandler(
|
||||
if (!validation.IsSuccess)
|
||||
return Task.FromResult(Result.Failure<NodeDto>(validation.Error));
|
||||
|
||||
var credentials = new NodeCredentials(command.Username, secretProtector.Protect(command.Password));
|
||||
var credentials = new NodeCredentials(
|
||||
command.Username,
|
||||
secretProtector.Protect(command.Password)
|
||||
);
|
||||
var node = Node.Register(command.Name, baseAddress, credentials, command.Location);
|
||||
|
||||
dbContext.Nodes.Add(node);
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
currentUser.UserId, "NodeRegistered", "Node", node.Id.ToString(), metadata: null, AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
currentUser.UserId,
|
||||
"NodeRegistered",
|
||||
"Node",
|
||||
node.Id.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
return Task.FromResult(Result.Success(NodeDto.FromDomain(node)));
|
||||
}
|
||||
|
||||
@@ -10,9 +10,15 @@ namespace PnvPanel.Application.Admin.Nodes;
|
||||
public sealed class SyncNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway)
|
||||
: ICommandHandler<SyncNodeCommand, Result<SyncNodeResultDto>>
|
||||
{
|
||||
public async Task<Result<SyncNodeResultDto>> Handle(SyncNodeCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result<SyncNodeResultDto>> Handle(
|
||||
SyncNodeCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var node = await dbContext.Nodes.FirstOrDefaultAsync(n => n.Id == command.NodeId, cancellationToken);
|
||||
var node = await dbContext.Nodes.FirstOrDefaultAsync(
|
||||
n => n.Id == command.NodeId,
|
||||
cancellationToken
|
||||
);
|
||||
if (node is null)
|
||||
return Result.Failure<SyncNodeResultDto>(NodeErrors.NotFound);
|
||||
|
||||
@@ -23,7 +29,9 @@ public sealed class SyncNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGat
|
||||
return Result.Failure<SyncNodeResultDto>(remoteResult.Error);
|
||||
}
|
||||
|
||||
var existing = await dbContext.Inbounds.Where(i => i.NodeId == node.Id).ToListAsync(cancellationToken);
|
||||
var existing = await dbContext
|
||||
.Inbounds.Where(i => i.NodeId == node.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var existingByRemoteId = existing.ToDictionary(i => i.RemoteInboundId);
|
||||
|
||||
foreach (var remote in remoteResult.Value)
|
||||
@@ -31,13 +39,25 @@ public sealed class SyncNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGat
|
||||
if (existingByRemoteId.TryGetValue(remote.RemoteInboundId, out var inbound))
|
||||
inbound.UpdateFromRemote(remote.Protocol, remote.Remark, remote.Port);
|
||||
else
|
||||
dbContext.Inbounds.Add(Inbound.FromRemote(node.Id, remote.RemoteInboundId, remote.Protocol, remote.Remark, remote.Port));
|
||||
dbContext.Inbounds.Add(
|
||||
Inbound.FromRemote(
|
||||
node.Id,
|
||||
remote.RemoteInboundId,
|
||||
remote.Protocol,
|
||||
remote.Remark,
|
||||
remote.Port
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Inbound, пропавший на панели, снимаем с публикации (не удаляем — реконсиляция дрейфа,
|
||||
// см. architecture.md); новые конфиги на нём создать будет нельзя, старые не трогаем.
|
||||
var remoteIds = remoteResult.Value.Select(r => r.RemoteInboundId).ToHashSet();
|
||||
foreach (var stale in existing.Where(i => i.IsPublished && !remoteIds.Contains(i.RemoteInboundId)))
|
||||
foreach (
|
||||
var stale in existing.Where(i =>
|
||||
i.IsPublished && !remoteIds.Contains(i.RemoteInboundId)
|
||||
)
|
||||
)
|
||||
stale.Unpublish();
|
||||
|
||||
node.UpdateStatus(NodeStatus.Online);
|
||||
|
||||
@@ -4,5 +4,10 @@ using PnvPanel.Application.Common.Models;
|
||||
namespace PnvPanel.Application.Admin.Nodes;
|
||||
|
||||
public sealed record UpdateNodeCommand(
|
||||
Guid NodeId, string Name, string? Location, bool IsEnabled, string? Username, string? Password)
|
||||
: ICommand<Result<NodeDto>>;
|
||||
Guid NodeId,
|
||||
string Name,
|
||||
string? Location,
|
||||
bool IsEnabled,
|
||||
string? Username,
|
||||
string? Password
|
||||
) : ICommand<Result<NodeDto>>;
|
||||
|
||||
@@ -8,12 +8,21 @@ using PnvPanel.Domain.Nodes;
|
||||
namespace PnvPanel.Application.Admin.Nodes;
|
||||
|
||||
public sealed class UpdateNodeCommandHandler(
|
||||
IAppDbContext dbContext, IXuiPanelGateway gateway, ISecretProtector secretProtector, ICurrentUser currentUser)
|
||||
: ICommandHandler<UpdateNodeCommand, Result<NodeDto>>
|
||||
IAppDbContext dbContext,
|
||||
IXuiPanelGateway gateway,
|
||||
ISecretProtector secretProtector,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<UpdateNodeCommand, Result<NodeDto>>
|
||||
{
|
||||
public async Task<Result<NodeDto>> Handle(UpdateNodeCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result<NodeDto>> Handle(
|
||||
UpdateNodeCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var node = await dbContext.Nodes.FirstOrDefaultAsync(n => n.Id == command.NodeId, cancellationToken);
|
||||
var node = await dbContext.Nodes.FirstOrDefaultAsync(
|
||||
n => n.Id == command.NodeId,
|
||||
cancellationToken
|
||||
);
|
||||
if (node is null)
|
||||
return Result.Failure<NodeDto>(NodeErrors.NotFound);
|
||||
|
||||
@@ -24,14 +33,27 @@ public sealed class UpdateNodeCommandHandler(
|
||||
else
|
||||
node.Disable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(command.Username) && !string.IsNullOrWhiteSpace(command.Password))
|
||||
if (
|
||||
!string.IsNullOrWhiteSpace(command.Username)
|
||||
&& !string.IsNullOrWhiteSpace(command.Password)
|
||||
)
|
||||
{
|
||||
node.UpdateCredentials(new NodeCredentials(command.Username, secretProtector.Protect(command.Password)));
|
||||
node.UpdateCredentials(
|
||||
new NodeCredentials(command.Username, secretProtector.Protect(command.Password))
|
||||
);
|
||||
gateway.InvalidateClient(node.Id);
|
||||
}
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
currentUser.UserId, "NodeUpdated", "Node", node.Id.ToString(), metadata: null, AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
currentUser.UserId,
|
||||
"NodeUpdated",
|
||||
"Node",
|
||||
node.Id.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
return Result.Success(NodeDto.FromDomain(node));
|
||||
}
|
||||
|
||||
@@ -4,4 +4,5 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Roles;
|
||||
|
||||
public sealed record CreateRoleCommand(string Name, int MaxConfigs, int MaxIpLimit) : ICommand<Result<RoleDto>>;
|
||||
public sealed record CreateRoleCommand(string Name, int MaxConfigs, int MaxIpLimit)
|
||||
: ICommand<Result<RoleDto>>;
|
||||
|
||||
@@ -4,8 +4,17 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Roles;
|
||||
|
||||
public sealed class CreateRoleCommandHandler(IRoleService roleService) : ICommandHandler<CreateRoleCommand, Result<RoleDto>>
|
||||
public sealed class CreateRoleCommandHandler(IRoleService roleService)
|
||||
: ICommandHandler<CreateRoleCommand, Result<RoleDto>>
|
||||
{
|
||||
public Task<Result<RoleDto>> Handle(CreateRoleCommand command, CancellationToken cancellationToken)
|
||||
=> roleService.CreateRoleAsync(command.Name, command.MaxConfigs, command.MaxIpLimit, cancellationToken);
|
||||
public Task<Result<RoleDto>> Handle(
|
||||
CreateRoleCommand command,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
roleService.CreateRoleAsync(
|
||||
command.Name,
|
||||
command.MaxConfigs,
|
||||
command.MaxIpLimit,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,10 +6,7 @@ public sealed class CreateRoleCommandValidator : AbstractValidator<CreateRoleCom
|
||||
{
|
||||
public CreateRoleCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty()
|
||||
.Length(2, 32)
|
||||
.Matches("^[a-zA-Z0-9_-]+$");
|
||||
RuleFor(x => x.Name).NotEmpty().Length(2, 32).Matches("^[a-zA-Z0-9_-]+$");
|
||||
|
||||
RuleFor(x => x.MaxConfigs).GreaterThanOrEqualTo(-1);
|
||||
RuleFor(x => x.MaxIpLimit).GreaterThanOrEqualTo(-1);
|
||||
|
||||
@@ -4,8 +4,9 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Roles;
|
||||
|
||||
public sealed class DeleteRoleCommandHandler(IRoleService roleService) : ICommandHandler<DeleteRoleCommand, Result>
|
||||
public sealed class DeleteRoleCommandHandler(IRoleService roleService)
|
||||
: ICommandHandler<DeleteRoleCommand, Result>
|
||||
{
|
||||
public Task<Result> Handle(DeleteRoleCommand command, CancellationToken cancellationToken)
|
||||
=> roleService.DeleteRoleAsync(command.RoleId, cancellationToken);
|
||||
public Task<Result> Handle(DeleteRoleCommand command, CancellationToken cancellationToken) =>
|
||||
roleService.DeleteRoleAsync(command.RoleId, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ namespace PnvPanel.Application.Admin.Roles;
|
||||
public sealed class ListRolesQueryHandler(IRoleService roleService)
|
||||
: IQueryHandler<ListRolesQuery, Result<IReadOnlyList<RoleDto>>>
|
||||
{
|
||||
public async Task<Result<IReadOnlyList<RoleDto>>> Handle(ListRolesQuery query, CancellationToken cancellationToken)
|
||||
=> Result.Success(await roleService.ListRolesAsync(cancellationToken));
|
||||
public async Task<Result<IReadOnlyList<RoleDto>>> Handle(
|
||||
ListRolesQuery query,
|
||||
CancellationToken cancellationToken
|
||||
) => Result.Success(await roleService.ListRolesAsync(cancellationToken));
|
||||
}
|
||||
|
||||
@@ -5,7 +5,16 @@ namespace PnvPanel.Application.Admin.Roles;
|
||||
public static class RoleErrors
|
||||
{
|
||||
public static readonly Error NotFound = Error.NotFound("Roles.NotFound", "Роль не найдена.");
|
||||
public static readonly Error DuplicateName = Error.Conflict("Roles.DuplicateName", "Роль с таким именем уже существует.");
|
||||
public static readonly Error CannotModifySystemRole = Error.Forbidden("Roles.CannotModifySystemRole", "Системную роль нельзя удалить.");
|
||||
public static readonly Error RoleInUse = Error.Conflict("Roles.RoleInUse", "Роль назначена пользователям — сначала переназначьте их.");
|
||||
public static readonly Error DuplicateName = Error.Conflict(
|
||||
"Roles.DuplicateName",
|
||||
"Роль с таким именем уже существует."
|
||||
);
|
||||
public static readonly Error CannotModifySystemRole = Error.Forbidden(
|
||||
"Roles.CannotModifySystemRole",
|
||||
"Системную роль нельзя удалить."
|
||||
);
|
||||
public static readonly Error RoleInUse = Error.Conflict(
|
||||
"Roles.RoleInUse",
|
||||
"Роль назначена пользователям — сначала переназначьте их."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,4 +4,5 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Roles;
|
||||
|
||||
public sealed record UpdateRoleCommand(Guid RoleId, int MaxConfigs, int MaxIpLimit) : ICommand<Result<RoleDto>>;
|
||||
public sealed record UpdateRoleCommand(Guid RoleId, int MaxConfigs, int MaxIpLimit)
|
||||
: ICommand<Result<RoleDto>>;
|
||||
|
||||
@@ -4,8 +4,17 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Roles;
|
||||
|
||||
public sealed class UpdateRoleCommandHandler(IRoleService roleService) : ICommandHandler<UpdateRoleCommand, Result<RoleDto>>
|
||||
public sealed class UpdateRoleCommandHandler(IRoleService roleService)
|
||||
: ICommandHandler<UpdateRoleCommand, Result<RoleDto>>
|
||||
{
|
||||
public Task<Result<RoleDto>> Handle(UpdateRoleCommand command, CancellationToken cancellationToken)
|
||||
=> roleService.UpdateRoleAsync(command.RoleId, command.MaxConfigs, command.MaxIpLimit, cancellationToken);
|
||||
public Task<Result<RoleDto>> Handle(
|
||||
UpdateRoleCommand command,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
roleService.UpdateRoleAsync(
|
||||
command.RoleId,
|
||||
command.MaxConfigs,
|
||||
command.MaxIpLimit,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,13 @@ namespace PnvPanel.Application.Admin.Stats;
|
||||
public sealed record GetStatsQuery : IQuery<Result<StatsDto>>;
|
||||
|
||||
public sealed record StatsDto(
|
||||
int TotalUsers, int ActivatedUsers, int PendingActivationRequests,
|
||||
int TotalNodes, int OnlineNodes, int TotalConfigs, int ActiveConfigs,
|
||||
long TotalUsedUpBytes, long TotalUsedDownBytes);
|
||||
int TotalUsers,
|
||||
int ActivatedUsers,
|
||||
int PendingActivationRequests,
|
||||
int TotalNodes,
|
||||
int OnlineNodes,
|
||||
int TotalConfigs,
|
||||
int ActiveConfigs,
|
||||
long TotalUsedUpBytes,
|
||||
long TotalUsedDownBytes
|
||||
);
|
||||
|
||||
@@ -11,27 +11,47 @@ namespace PnvPanel.Application.Admin.Stats;
|
||||
public sealed class GetStatsQueryHandler(IAppDbContext dbContext, IIdentityService identityService)
|
||||
: IQueryHandler<GetStatsQuery, Result<StatsDto>>
|
||||
{
|
||||
public async Task<Result<StatsDto>> Handle(GetStatsQuery query, CancellationToken cancellationToken)
|
||||
public async Task<Result<StatsDto>> Handle(
|
||||
GetStatsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var userStats = await identityService.GetUserStatsAsync(cancellationToken);
|
||||
|
||||
var pendingActivations = await dbContext.ActivationRequests
|
||||
.CountAsync(r => r.Status == ActivationStatus.Pending, cancellationToken);
|
||||
var pendingActivations = await dbContext.ActivationRequests.CountAsync(
|
||||
r => r.Status == ActivationStatus.Pending,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var totalNodes = await dbContext.Nodes.CountAsync(cancellationToken);
|
||||
var onlineNodes = await dbContext.Nodes.CountAsync(n => n.Status == NodeStatus.Online, cancellationToken);
|
||||
var onlineNodes = await dbContext.Nodes.CountAsync(
|
||||
n => n.Status == NodeStatus.Online,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var totalConfigs = await dbContext.VpnConfigs.CountAsync(cancellationToken);
|
||||
var activeConfigs = await dbContext.VpnConfigs.CountAsync(c => c.Status == ConfigStatus.Active, cancellationToken);
|
||||
var activeConfigs = await dbContext.VpnConfigs.CountAsync(
|
||||
c => c.Status == ConfigStatus.Active,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var trafficTotals = await dbContext.VpnConfigs
|
||||
.GroupBy(_ => 1)
|
||||
var trafficTotals = await dbContext
|
||||
.VpnConfigs.GroupBy(_ => 1)
|
||||
.Select(g => new { Up = g.Sum(c => c.UsedUpBytes), Down = g.Sum(c => c.UsedDownBytes) })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return Result.Success(new StatsDto(
|
||||
userStats.Total, userStats.Activated, pendingActivations,
|
||||
totalNodes, onlineNodes, totalConfigs, activeConfigs,
|
||||
trafficTotals?.Up ?? 0, trafficTotals?.Down ?? 0));
|
||||
return Result.Success(
|
||||
new StatsDto(
|
||||
userStats.Total,
|
||||
userStats.Activated,
|
||||
pendingActivations,
|
||||
totalNodes,
|
||||
onlineNodes,
|
||||
totalConfigs,
|
||||
activeConfigs,
|
||||
trafficTotals?.Up ?? 0,
|
||||
trafficTotals?.Down ?? 0
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+39
-10
@@ -10,16 +10,25 @@ using PnvPanel.Domain.Support;
|
||||
namespace PnvPanel.Application.Admin.Support;
|
||||
|
||||
public sealed class ApproveRoleRequestCommandHandler(
|
||||
IAppDbContext dbContext, IRoleService roleService, IRealtimeNotifier notifier,
|
||||
ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
|
||||
: ICommandHandler<ApproveRoleRequestCommand, Result>
|
||||
IAppDbContext dbContext,
|
||||
IRoleService roleService,
|
||||
IRealtimeNotifier notifier,
|
||||
ITelegramNotifier telegramNotifier,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<ApproveRoleRequestCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(ApproveRoleRequestCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result> Handle(
|
||||
ApproveRoleRequestCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } adminId)
|
||||
return Result.Failure(AuthErrors.Unauthorized);
|
||||
|
||||
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken);
|
||||
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(
|
||||
t => t.Id == command.TicketId,
|
||||
cancellationToken
|
||||
);
|
||||
if (ticket is null)
|
||||
return Result.Failure(SupportErrors.NotFound);
|
||||
|
||||
@@ -37,24 +46,44 @@ public sealed class ApproveRoleRequestCommandHandler(
|
||||
else
|
||||
{
|
||||
var createResult = await roleService.CreateRoleAsync(
|
||||
ticket.ProposedRoleName!, ticket.ProposedMaxConfigs!.Value, ticket.ProposedMaxIpLimit!.Value, cancellationToken);
|
||||
ticket.ProposedRoleName!,
|
||||
ticket.ProposedMaxConfigs!.Value,
|
||||
ticket.ProposedMaxIpLimit!.Value,
|
||||
cancellationToken
|
||||
);
|
||||
if (!createResult.IsSuccess)
|
||||
return Result.Failure(createResult.Error);
|
||||
|
||||
roleId = createResult.Value.Id;
|
||||
}
|
||||
|
||||
var assignResult = await roleService.ChangeUserRoleAsync(ticket.UserId, roleId, cancellationToken);
|
||||
var assignResult = await roleService.ChangeUserRoleAsync(
|
||||
ticket.UserId,
|
||||
roleId,
|
||||
cancellationToken
|
||||
);
|
||||
if (!assignResult.IsSuccess)
|
||||
return assignResult;
|
||||
|
||||
ticket.Resolve();
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
adminId, "RoleRequestApproved", "SupportTicket", ticket.Id.ToString(), metadata: null, AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
adminId,
|
||||
"RoleRequestApproved",
|
||||
"SupportTicket",
|
||||
ticket.Id.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken);
|
||||
await telegramNotifier.NotifyUserAsync(ticket.UserId, "✅ Ваша заявка на роль одобрена.", cancellationToken);
|
||||
await telegramNotifier.NotifyUserAsync(
|
||||
ticket.UserId,
|
||||
"✅ Ваша заявка на роль одобрена.",
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
@@ -10,15 +10,24 @@ using PnvPanel.Domain.Support;
|
||||
namespace PnvPanel.Application.Admin.Support;
|
||||
|
||||
public sealed class CloseTicketCommandHandler(
|
||||
IAppDbContext dbContext, IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
|
||||
: ICommandHandler<CloseTicketCommand, Result>
|
||||
IAppDbContext dbContext,
|
||||
IRealtimeNotifier notifier,
|
||||
ITelegramNotifier telegramNotifier,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<CloseTicketCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(CloseTicketCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result> Handle(
|
||||
CloseTicketCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } adminId)
|
||||
return Result.Failure(AuthErrors.Unauthorized);
|
||||
|
||||
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken);
|
||||
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(
|
||||
t => t.Id == command.TicketId,
|
||||
cancellationToken
|
||||
);
|
||||
if (ticket is null)
|
||||
return Result.Failure(SupportErrors.NotFound);
|
||||
|
||||
@@ -27,11 +36,23 @@ public sealed class CloseTicketCommandHandler(
|
||||
|
||||
ticket.Close();
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
adminId, "TicketClosed", "SupportTicket", ticket.Id.ToString(), metadata: null, AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
adminId,
|
||||
"TicketClosed",
|
||||
"SupportTicket",
|
||||
ticket.Id.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken);
|
||||
await telegramNotifier.NotifyUserAsync(ticket.UserId, "🔒 Ваше обращение закрыто.", cancellationToken);
|
||||
await telegramNotifier.NotifyUserAsync(
|
||||
ticket.UserId,
|
||||
"🔒 Ваше обращение закрыто.",
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
@@ -6,17 +6,30 @@ using PnvPanel.Application.Support;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Support;
|
||||
|
||||
public sealed class GetTicketAdminQueryHandler(IAppDbContext dbContext, IIdentityService identityService, IRoleService roleService)
|
||||
: IQueryHandler<GetTicketAdminQuery, Result<TicketDetailDto>>
|
||||
public sealed class GetTicketAdminQueryHandler(
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService,
|
||||
IRoleService roleService
|
||||
) : IQueryHandler<GetTicketAdminQuery, Result<TicketDetailDto>>
|
||||
{
|
||||
public async Task<Result<TicketDetailDto>> Handle(GetTicketAdminQuery query, CancellationToken cancellationToken)
|
||||
public async Task<Result<TicketDetailDto>> Handle(
|
||||
GetTicketAdminQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var ticket = await dbContext.SupportTickets.AsNoTracking()
|
||||
var ticket = await dbContext
|
||||
.SupportTickets.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.Id == query.TicketId, cancellationToken);
|
||||
if (ticket is null)
|
||||
return Result.Failure<TicketDetailDto>(SupportErrors.NotFound);
|
||||
|
||||
var dto = await TicketMapping.ToDetailDtoAsync(dbContext, identityService, roleService, ticket, cancellationToken);
|
||||
var dto = await TicketMapping.ToDetailDtoAsync(
|
||||
dbContext,
|
||||
identityService,
|
||||
roleService,
|
||||
ticket,
|
||||
cancellationToken
|
||||
);
|
||||
return Result.Success(dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,9 @@ using PnvPanel.Domain.Support;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Support;
|
||||
|
||||
public sealed record ListAllTicketsQuery(TicketType? TypeFilter, TicketStatus? StatusFilter, int Page, int PageSize)
|
||||
: IQuery<Result<PagedList<TicketSummaryDto>>>;
|
||||
public sealed record ListAllTicketsQuery(
|
||||
TicketType? TypeFilter,
|
||||
TicketStatus? StatusFilter,
|
||||
int Page,
|
||||
int PageSize
|
||||
) : IQuery<Result<PagedList<TicketSummaryDto>>>;
|
||||
|
||||
@@ -6,10 +6,15 @@ using PnvPanel.Application.Support;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Support;
|
||||
|
||||
public sealed class ListAllTicketsQueryHandler(IAppDbContext dbContext, IIdentityService identityService)
|
||||
: IQueryHandler<ListAllTicketsQuery, Result<PagedList<TicketSummaryDto>>>
|
||||
public sealed class ListAllTicketsQueryHandler(
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService
|
||||
) : IQueryHandler<ListAllTicketsQuery, Result<PagedList<TicketSummaryDto>>>
|
||||
{
|
||||
public async Task<Result<PagedList<TicketSummaryDto>>> Handle(ListAllTicketsQuery query, CancellationToken cancellationToken)
|
||||
public async Task<Result<PagedList<TicketSummaryDto>>> Handle(
|
||||
ListAllTicketsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var page = query.Page <= 0 ? 1 : query.Page;
|
||||
var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize;
|
||||
@@ -20,9 +25,18 @@ public sealed class ListAllTicketsQueryHandler(IAppDbContext dbContext, IIdentit
|
||||
if (query.StatusFilter is { } status)
|
||||
ticketsQuery = ticketsQuery.Where(t => t.Status == status);
|
||||
|
||||
var page1 = await ticketsQuery.OrderByDescending(t => t.CreatedAt).ToPagedListAsync(page, pageSize, cancellationToken);
|
||||
var items = await TicketMapping.ToSummaryDtosAsync(dbContext, identityService, page1.Items, cancellationToken);
|
||||
var page1 = await ticketsQuery
|
||||
.OrderByDescending(t => t.CreatedAt)
|
||||
.ToPagedListAsync(page, pageSize, cancellationToken);
|
||||
var items = await TicketMapping.ToSummaryDtosAsync(
|
||||
dbContext,
|
||||
identityService,
|
||||
page1.Items,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Result.Success(new PagedList<TicketSummaryDto>(items, page1.Total, page1.Page, page1.PageSize));
|
||||
return Result.Success(
|
||||
new PagedList<TicketSummaryDto>(items, page1.Total, page1.Page, page1.PageSize)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,15 +10,24 @@ using PnvPanel.Domain.Support;
|
||||
namespace PnvPanel.Application.Admin.Support;
|
||||
|
||||
public sealed class RejectRoleRequestCommandHandler(
|
||||
IAppDbContext dbContext, IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
|
||||
: ICommandHandler<RejectRoleRequestCommand, Result>
|
||||
IAppDbContext dbContext,
|
||||
IRealtimeNotifier notifier,
|
||||
ITelegramNotifier telegramNotifier,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<RejectRoleRequestCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(RejectRoleRequestCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result> Handle(
|
||||
RejectRoleRequestCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } adminId)
|
||||
return Result.Failure(AuthErrors.Unauthorized);
|
||||
|
||||
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken);
|
||||
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(
|
||||
t => t.Id == command.TicketId,
|
||||
cancellationToken
|
||||
);
|
||||
if (ticket is null)
|
||||
return Result.Failure(SupportErrors.NotFound);
|
||||
|
||||
@@ -33,11 +42,23 @@ public sealed class RejectRoleRequestCommandHandler(
|
||||
|
||||
ticket.Close();
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
adminId, "RoleRequestRejected", "SupportTicket", ticket.Id.ToString(), metadata: null, AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
adminId,
|
||||
"RoleRequestRejected",
|
||||
"SupportTicket",
|
||||
ticket.Id.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken);
|
||||
await telegramNotifier.NotifyUserAsync(ticket.UserId, "❌ Ваша заявка на роль отклонена.", cancellationToken);
|
||||
await telegramNotifier.NotifyUserAsync(
|
||||
ticket.UserId,
|
||||
"❌ Ваша заявка на роль отклонена.",
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
@@ -10,15 +10,24 @@ using PnvPanel.Domain.Support;
|
||||
namespace PnvPanel.Application.Admin.Support;
|
||||
|
||||
public sealed class ResolveTicketCommandHandler(
|
||||
IAppDbContext dbContext, IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
|
||||
: ICommandHandler<ResolveTicketCommand, Result>
|
||||
IAppDbContext dbContext,
|
||||
IRealtimeNotifier notifier,
|
||||
ITelegramNotifier telegramNotifier,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<ResolveTicketCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(ResolveTicketCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result> Handle(
|
||||
ResolveTicketCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } adminId)
|
||||
return Result.Failure(AuthErrors.Unauthorized);
|
||||
|
||||
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken);
|
||||
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(
|
||||
t => t.Id == command.TicketId,
|
||||
cancellationToken
|
||||
);
|
||||
if (ticket is null)
|
||||
return Result.Failure(SupportErrors.NotFound);
|
||||
|
||||
@@ -27,11 +36,23 @@ public sealed class ResolveTicketCommandHandler(
|
||||
|
||||
ticket.Resolve();
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
adminId, "TicketResolved", "SupportTicket", ticket.Id.ToString(), metadata: null, AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
adminId,
|
||||
"TicketResolved",
|
||||
"SupportTicket",
|
||||
ticket.Id.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken);
|
||||
await telegramNotifier.NotifyUserAsync(ticket.UserId, "✅ Ваше обращение решено.", cancellationToken);
|
||||
await telegramNotifier.NotifyUserAsync(
|
||||
ticket.UserId,
|
||||
"✅ Ваше обращение решено.",
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
@@ -10,10 +10,14 @@ namespace PnvPanel.Application.Admin.Users;
|
||||
|
||||
/// <summary>Блокировка гасит все активные конфиги в 3x-ui (см. architecture.md).</summary>
|
||||
public sealed class BlockUserCommandHandler(
|
||||
IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway,
|
||||
IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser,
|
||||
ILogger<BlockUserCommandHandler> logger)
|
||||
: ICommandHandler<BlockUserCommand, Result>
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService,
|
||||
IXuiPanelGateway gateway,
|
||||
IRealtimeNotifier notifier,
|
||||
ITelegramNotifier telegramNotifier,
|
||||
ICurrentUser currentUser,
|
||||
ILogger<BlockUserCommandHandler> logger
|
||||
) : ICommandHandler<BlockUserCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(BlockUserCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -21,22 +25,32 @@ public sealed class BlockUserCommandHandler(
|
||||
if (!blockResult.IsSuccess)
|
||||
return blockResult;
|
||||
|
||||
var configs = await dbContext.VpnConfigs
|
||||
.Where(c => c.UserId == command.UserId && c.Status == ConfigStatus.Active)
|
||||
var configs = await dbContext
|
||||
.VpnConfigs.Where(c => c.UserId == command.UserId && c.Status == ConfigStatus.Active)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var config in configs)
|
||||
{
|
||||
var inbound = await dbContext.Inbounds.AsNoTracking().FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
|
||||
var inbound = await dbContext
|
||||
.Inbounds.AsNoTracking()
|
||||
.FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
|
||||
var node = inbound is null
|
||||
? null
|
||||
: await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
|
||||
: await dbContext
|
||||
.Nodes.AsNoTracking()
|
||||
.FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
|
||||
|
||||
if (inbound is not null && node is not null)
|
||||
{
|
||||
var updateResult = await gateway.UpdateClientAsync(
|
||||
node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol,
|
||||
config.Label ?? config.ClientEmail, enable: false, cancellationToken);
|
||||
node,
|
||||
inbound.RemoteInboundId,
|
||||
config.ClientExternalId,
|
||||
config.Protocol,
|
||||
config.Label ?? config.ClientEmail,
|
||||
enable: false,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (!updateResult.IsSuccess)
|
||||
{
|
||||
@@ -45,19 +59,40 @@ public sealed class BlockUserCommandHandler(
|
||||
// Конфиг останется Active и будет подхвачен повторным BlockUserCommand (идемпотентен).
|
||||
logger.LogWarning(
|
||||
"Failed to disable client for config {ConfigId} on node {NodeId} while blocking user {UserId}: {Error}",
|
||||
config.Id, node.Id, command.UserId, updateResult.Error);
|
||||
config.Id,
|
||||
node.Id,
|
||||
command.UserId,
|
||||
updateResult.Error
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
config.Disable();
|
||||
await notifier.NotifyConfigStatusChangedAsync(config.UserId, config.Id, config.Status, cancellationToken);
|
||||
await notifier.NotifyConfigStatusChangedAsync(
|
||||
config.UserId,
|
||||
config.Id,
|
||||
config.Status,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
currentUser.UserId, "UserBlocked", "User", command.UserId.ToString(), metadata: null, AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
currentUser.UserId,
|
||||
"UserBlocked",
|
||||
"User",
|
||||
command.UserId.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
await telegramNotifier.NotifyUserAsync(command.UserId, "⛔ Ваш аккаунт заблокирован администратором.", cancellationToken);
|
||||
await telegramNotifier.NotifyUserAsync(
|
||||
command.UserId,
|
||||
"⛔ Ваш аккаунт заблокирован администратором.",
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
@@ -5,18 +5,35 @@ using PnvPanel.Domain.Audit;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Users;
|
||||
|
||||
public sealed class ChangeUserRoleCommandHandler(IRoleService roleService, IAppDbContext dbContext, ICurrentUser currentUser)
|
||||
: ICommandHandler<ChangeUserRoleCommand, Result>
|
||||
public sealed class ChangeUserRoleCommandHandler(
|
||||
IRoleService roleService,
|
||||
IAppDbContext dbContext,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<ChangeUserRoleCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(ChangeUserRoleCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result> Handle(
|
||||
ChangeUserRoleCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await roleService.ChangeUserRoleAsync(command.UserId, command.RoleId, cancellationToken);
|
||||
var result = await roleService.ChangeUserRoleAsync(
|
||||
command.UserId,
|
||||
command.RoleId,
|
||||
cancellationToken
|
||||
);
|
||||
if (!result.IsSuccess)
|
||||
return result;
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
currentUser.UserId, "UserRoleChanged", "User", command.UserId.ToString(),
|
||||
metadata: $"{{\"roleId\":\"{command.RoleId}\"}}", AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
currentUser.UserId,
|
||||
"UserRoleChanged",
|
||||
"User",
|
||||
command.UserId.ToString(),
|
||||
metadata: $"{{\"roleId\":\"{command.RoleId}\"}}",
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -9,40 +9,65 @@ namespace PnvPanel.Application.Admin.Users;
|
||||
|
||||
/// <summary>Удаление пользователя админом: отзывает все его конфиги в 3x-ui, затем удаляет учётку.</summary>
|
||||
public sealed class DeleteUserCommandHandler(
|
||||
IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway,
|
||||
ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
|
||||
: ICommandHandler<DeleteUserCommand, Result>
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService,
|
||||
IXuiPanelGateway gateway,
|
||||
ITelegramNotifier telegramNotifier,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<DeleteUserCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(DeleteUserCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId == command.UserId)
|
||||
return Result.Failure(UserErrors.CannotDeleteSelf);
|
||||
|
||||
var configs = await dbContext.VpnConfigs
|
||||
.Where(c => c.UserId == command.UserId && c.Status != ConfigStatus.Revoked)
|
||||
var configs = await dbContext
|
||||
.VpnConfigs.Where(c => c.UserId == command.UserId && c.Status != ConfigStatus.Revoked)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var config in configs)
|
||||
{
|
||||
var inbound = await dbContext.Inbounds.AsNoTracking().FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
|
||||
var inbound = await dbContext
|
||||
.Inbounds.AsNoTracking()
|
||||
.FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
|
||||
var node = inbound is null
|
||||
? null
|
||||
: await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
|
||||
: await dbContext
|
||||
.Nodes.AsNoTracking()
|
||||
.FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
|
||||
|
||||
if (inbound is not null && node is not null)
|
||||
await gateway.RemoveClientAsync(node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, cancellationToken);
|
||||
await gateway.RemoveClientAsync(
|
||||
node,
|
||||
inbound.RemoteInboundId,
|
||||
config.ClientExternalId,
|
||||
config.Protocol,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
config.Revoke();
|
||||
}
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
currentUser.UserId, "UserDeleted", "User", command.UserId.ToString(), metadata: null, AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
currentUser.UserId,
|
||||
"UserDeleted",
|
||||
"User",
|
||||
command.UserId.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
// Коммитим отзыв конфигов + аудит ДО удаления учётки: UserManager.DeleteAsync ниже удаляет
|
||||
// AppUser отдельным путём (Identity store), после чего NotifyUserAsync уже не найдёт Telegram-привязку.
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await telegramNotifier.NotifyUserAsync(command.UserId, "🗑 Ваш аккаунт удалён администратором.", cancellationToken);
|
||||
await telegramNotifier.NotifyUserAsync(
|
||||
command.UserId,
|
||||
"🗑 Ваш аккаунт удалён администратором.",
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return await identityService.DeleteUserAsync(command.UserId, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -9,35 +9,70 @@ using PnvPanel.Domain.Configs;
|
||||
namespace PnvPanel.Application.Admin.Users;
|
||||
|
||||
public sealed class ForceRevokeConfigCommandHandler(
|
||||
IAppDbContext dbContext, IXuiPanelGateway gateway, IRealtimeNotifier notifier,
|
||||
ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
|
||||
: ICommandHandler<ForceRevokeConfigCommand, Result>
|
||||
IAppDbContext dbContext,
|
||||
IXuiPanelGateway gateway,
|
||||
IRealtimeNotifier notifier,
|
||||
ITelegramNotifier telegramNotifier,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<ForceRevokeConfigCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(ForceRevokeConfigCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result> Handle(
|
||||
ForceRevokeConfigCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var config = await dbContext.VpnConfigs.FirstOrDefaultAsync(c => c.Id == command.ConfigId, cancellationToken);
|
||||
var config = await dbContext.VpnConfigs.FirstOrDefaultAsync(
|
||||
c => c.Id == command.ConfigId,
|
||||
cancellationToken
|
||||
);
|
||||
if (config is null)
|
||||
return Result.Failure(ConfigErrors.NotFound);
|
||||
|
||||
if (config.Status == ConfigStatus.Revoked)
|
||||
return Result.Success();
|
||||
|
||||
var inbound = await dbContext.Inbounds.AsNoTracking().FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
|
||||
var inbound = await dbContext
|
||||
.Inbounds.AsNoTracking()
|
||||
.FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
|
||||
var node = inbound is null
|
||||
? null
|
||||
: await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
|
||||
: await dbContext
|
||||
.Nodes.AsNoTracking()
|
||||
.FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
|
||||
|
||||
if (inbound is not null && node is not null)
|
||||
await gateway.RemoveClientAsync(node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, cancellationToken);
|
||||
await gateway.RemoveClientAsync(
|
||||
node,
|
||||
inbound.RemoteInboundId,
|
||||
config.ClientExternalId,
|
||||
config.Protocol,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
config.Revoke();
|
||||
await notifier.NotifyConfigStatusChangedAsync(config.UserId, config.Id, config.Status, cancellationToken);
|
||||
await notifier.NotifyConfigStatusChangedAsync(
|
||||
config.UserId,
|
||||
config.Id,
|
||||
config.Status,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
currentUser.UserId, "ConfigForceRevoked", "VpnConfig", config.Id.ToString(), metadata: null, AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
currentUser.UserId,
|
||||
"ConfigForceRevoked",
|
||||
"VpnConfig",
|
||||
config.Id.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
await telegramNotifier.NotifyUserAsync(
|
||||
config.UserId, $"⚠️ Администратор отозвал ваш конфиг «{config.Label ?? config.ClientEmail}».", cancellationToken);
|
||||
config.UserId,
|
||||
$"⚠️ Администратор отозвал ваш конфиг «{config.Label ?? config.ClientEmail}».",
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
@@ -10,11 +10,20 @@ namespace PnvPanel.Application.Admin.Users;
|
||||
public sealed class GetUserConfigsQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<GetUserConfigsQuery, Result<IReadOnlyList<VpnConfigDto>>>
|
||||
{
|
||||
public async Task<Result<IReadOnlyList<VpnConfigDto>>> Handle(GetUserConfigsQuery query, CancellationToken cancellationToken)
|
||||
public async Task<Result<IReadOnlyList<VpnConfigDto>>> Handle(
|
||||
GetUserConfigsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var rows = await dbContext.VpnConfigs.AsNoTracking()
|
||||
var rows = await dbContext
|
||||
.VpnConfigs.AsNoTracking()
|
||||
.Where(c => c.UserId == query.UserId && c.Status != ConfigStatus.Revoked)
|
||||
.Join(dbContext.Inbounds.AsNoTracking(), c => c.InboundId, i => i.Id, (c, i) => new { Config = c, Inbound = i })
|
||||
.Join(
|
||||
dbContext.Inbounds.AsNoTracking(),
|
||||
c => c.InboundId,
|
||||
i => i.Id,
|
||||
(c, i) => new { Config = c, Inbound = i }
|
||||
)
|
||||
.OrderByDescending(x => x.Config.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
|
||||
@@ -4,4 +4,5 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Users;
|
||||
|
||||
public sealed record ListUsersQuery(int Page, int PageSize, string? Search) : IQuery<Result<PagedList<UserSummaryDto>>>;
|
||||
public sealed record ListUsersQuery(int Page, int PageSize, string? Search)
|
||||
: IQuery<Result<PagedList<UserSummaryDto>>>;
|
||||
|
||||
@@ -7,12 +7,20 @@ namespace PnvPanel.Application.Admin.Users;
|
||||
public sealed class ListUsersQueryHandler(IIdentityService identityService)
|
||||
: IQueryHandler<ListUsersQuery, Result<PagedList<UserSummaryDto>>>
|
||||
{
|
||||
public async Task<Result<PagedList<UserSummaryDto>>> Handle(ListUsersQuery query, CancellationToken cancellationToken)
|
||||
public async Task<Result<PagedList<UserSummaryDto>>> Handle(
|
||||
ListUsersQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var page = query.Page <= 0 ? 1 : query.Page;
|
||||
var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize;
|
||||
|
||||
var result = await identityService.ListUsersAsync(page, pageSize, query.Search, cancellationToken);
|
||||
var result = await identityService.ListUsersAsync(
|
||||
page,
|
||||
pageSize,
|
||||
query.Search,
|
||||
cancellationToken
|
||||
);
|
||||
return Result.Success(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,17 +5,35 @@ using PnvPanel.Domain.Audit;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Users;
|
||||
|
||||
public sealed class ResetUserPasswordCommandHandler(IAppDbContext dbContext, IIdentityService identityService, ICurrentUser currentUser)
|
||||
: ICommandHandler<ResetUserPasswordCommand, Result>
|
||||
public sealed class ResetUserPasswordCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<ResetUserPasswordCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(ResetUserPasswordCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result> Handle(
|
||||
ResetUserPasswordCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await identityService.ResetPasswordAsync(command.UserId, command.NewPassword, cancellationToken);
|
||||
var result = await identityService.ResetPasswordAsync(
|
||||
command.UserId,
|
||||
command.NewPassword,
|
||||
cancellationToken
|
||||
);
|
||||
if (!result.IsSuccess)
|
||||
return result;
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
currentUser.UserId, "UserPasswordReset", "User", command.UserId.ToString(), metadata: null, AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
currentUser.UserId,
|
||||
"UserPasswordReset",
|
||||
"User",
|
||||
command.UserId.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success();
|
||||
|
||||
@@ -10,32 +10,52 @@ namespace PnvPanel.Application.Admin.Users;
|
||||
|
||||
/// <summary>Разблокировка возвращает в 3x-ui только конфиги, погашенные блокировкой (Disabled).</summary>
|
||||
public sealed class UnblockUserCommandHandler(
|
||||
IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway,
|
||||
IRealtimeNotifier notifier, ICurrentUser currentUser, ILogger<UnblockUserCommandHandler> logger)
|
||||
: ICommandHandler<UnblockUserCommand, Result>
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService,
|
||||
IXuiPanelGateway gateway,
|
||||
IRealtimeNotifier notifier,
|
||||
ICurrentUser currentUser,
|
||||
ILogger<UnblockUserCommandHandler> logger
|
||||
) : ICommandHandler<UnblockUserCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(UnblockUserCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result> Handle(
|
||||
UnblockUserCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var unblockResult = await identityService.UnblockUserAsync(command.UserId, cancellationToken);
|
||||
var unblockResult = await identityService.UnblockUserAsync(
|
||||
command.UserId,
|
||||
cancellationToken
|
||||
);
|
||||
if (!unblockResult.IsSuccess)
|
||||
return unblockResult;
|
||||
|
||||
var configs = await dbContext.VpnConfigs
|
||||
.Where(c => c.UserId == command.UserId && c.Status == ConfigStatus.Disabled)
|
||||
var configs = await dbContext
|
||||
.VpnConfigs.Where(c => c.UserId == command.UserId && c.Status == ConfigStatus.Disabled)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var config in configs)
|
||||
{
|
||||
var inbound = await dbContext.Inbounds.AsNoTracking().FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
|
||||
var inbound = await dbContext
|
||||
.Inbounds.AsNoTracking()
|
||||
.FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
|
||||
var node = inbound is null
|
||||
? null
|
||||
: await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
|
||||
: await dbContext
|
||||
.Nodes.AsNoTracking()
|
||||
.FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
|
||||
|
||||
if (inbound is not null && node is not null)
|
||||
{
|
||||
var updateResult = await gateway.UpdateClientAsync(
|
||||
node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol,
|
||||
config.Label ?? config.ClientEmail, enable: true, cancellationToken);
|
||||
node,
|
||||
inbound.RemoteInboundId,
|
||||
config.ClientExternalId,
|
||||
config.Protocol,
|
||||
config.Label ?? config.ClientEmail,
|
||||
enable: true,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (!updateResult.IsSuccess)
|
||||
{
|
||||
@@ -44,17 +64,34 @@ public sealed class UnblockUserCommandHandler(
|
||||
// повторным UnblockUserCommand (идемпотентен).
|
||||
logger.LogWarning(
|
||||
"Failed to enable client for config {ConfigId} on node {NodeId} while unblocking user {UserId}: {Error}",
|
||||
config.Id, node.Id, command.UserId, updateResult.Error);
|
||||
config.Id,
|
||||
node.Id,
|
||||
command.UserId,
|
||||
updateResult.Error
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
config.Enable();
|
||||
await notifier.NotifyConfigStatusChangedAsync(config.UserId, config.Id, config.Status, cancellationToken);
|
||||
await notifier.NotifyConfigStatusChangedAsync(
|
||||
config.UserId,
|
||||
config.Id,
|
||||
config.Status,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
currentUser.UserId, "UserUnblocked", "User", command.UserId.ToString(), metadata: null, AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
currentUser.UserId,
|
||||
"UserUnblocked",
|
||||
"User",
|
||||
command.UserId.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
@@ -4,8 +4,13 @@ namespace PnvPanel.Application.Admin.Users;
|
||||
|
||||
public static class UserErrors
|
||||
{
|
||||
public static readonly Error NotFound = Error.NotFound("Users.NotFound", "Пользователь не найден.");
|
||||
public static readonly Error NotFound = Error.NotFound(
|
||||
"Users.NotFound",
|
||||
"Пользователь не найден."
|
||||
);
|
||||
|
||||
public static readonly Error CannotDeleteSelf = Error.Validation(
|
||||
"Users.CannotDeleteSelf", "Нельзя удалить свою учётную запись здесь — используйте удаление аккаунта в Настройках.");
|
||||
"Users.CannotDeleteSelf",
|
||||
"Нельзя удалить свою учётную запись здесь — используйте удаление аккаунта в Настройках."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,13 @@ using PnvPanel.Domain.Apps;
|
||||
|
||||
namespace PnvPanel.Application.Apps;
|
||||
|
||||
public sealed record ClientAppDto(Guid Id, string Name, string DownloadUrl, string? Description, string? IconUrl)
|
||||
public sealed record ClientAppDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string DownloadUrl,
|
||||
string? Description,
|
||||
string? IconUrl
|
||||
)
|
||||
{
|
||||
public static ClientAppDto FromDomain(ClientApp app) =>
|
||||
new(app.Id, app.Name, app.DownloadUrl.ToString(), app.Description, app.IconUrl);
|
||||
|
||||
@@ -4,4 +4,6 @@ using PnvPanel.Domain.Apps;
|
||||
|
||||
namespace PnvPanel.Application.Apps;
|
||||
|
||||
public sealed record ListAppsQuery : IQuery<Result<IReadOnlyDictionary<OsPlatform, IReadOnlyList<ClientAppDto>>>>, IRequiresActivation;
|
||||
public sealed record ListAppsQuery
|
||||
: IQuery<Result<IReadOnlyDictionary<OsPlatform, IReadOnlyList<ClientAppDto>>>>,
|
||||
IRequiresActivation;
|
||||
|
||||
@@ -7,22 +7,30 @@ using PnvPanel.Domain.Apps;
|
||||
namespace PnvPanel.Application.Apps;
|
||||
|
||||
public sealed class ListAppsQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ListAppsQuery, Result<IReadOnlyDictionary<OsPlatform, IReadOnlyList<ClientAppDto>>>>
|
||||
: IQueryHandler<
|
||||
ListAppsQuery,
|
||||
Result<IReadOnlyDictionary<OsPlatform, IReadOnlyList<ClientAppDto>>>
|
||||
>
|
||||
{
|
||||
public async Task<Result<IReadOnlyDictionary<OsPlatform, IReadOnlyList<ClientAppDto>>>> Handle(
|
||||
ListAppsQuery query, CancellationToken cancellationToken)
|
||||
ListAppsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var apps = await dbContext.ClientApps.AsNoTracking()
|
||||
var apps = await dbContext
|
||||
.ClientApps.AsNoTracking()
|
||||
.Where(a => a.IsEnabled)
|
||||
.OrderBy(a => a.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var grouped = apps
|
||||
.GroupBy(a => a.OperatingSystem)
|
||||
var grouped = apps.GroupBy(a => a.OperatingSystem)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => (IReadOnlyList<ClientAppDto>)g.Select(ClientAppDto.FromDomain).ToList());
|
||||
g => (IReadOnlyList<ClientAppDto>)g.Select(ClientAppDto.FromDomain).ToList()
|
||||
);
|
||||
|
||||
return Result.Success<IReadOnlyDictionary<OsPlatform, IReadOnlyList<ClientAppDto>>>(grouped);
|
||||
return Result.Success<IReadOnlyDictionary<OsPlatform, IReadOnlyList<ClientAppDto>>>(
|
||||
grouped
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,24 +4,38 @@ namespace PnvPanel.Application.Auth;
|
||||
|
||||
public static class AuthErrors
|
||||
{
|
||||
public static readonly Error DuplicateUserName =
|
||||
Error.Conflict("Auth.DuplicateUserName", "Пользователь с таким именем уже существует.");
|
||||
public static readonly Error DuplicateUserName = Error.Conflict(
|
||||
"Auth.DuplicateUserName",
|
||||
"Пользователь с таким именем уже существует."
|
||||
);
|
||||
|
||||
public static readonly Error InvalidCredentials =
|
||||
Error.Unauthorized("Auth.InvalidCredentials", "Неверное имя пользователя или пароль.");
|
||||
public static readonly Error InvalidCredentials = Error.Unauthorized(
|
||||
"Auth.InvalidCredentials",
|
||||
"Неверное имя пользователя или пароль."
|
||||
);
|
||||
|
||||
public static readonly Error LockedOut =
|
||||
Error.Unauthorized("Auth.LockedOut", "Слишком много неудачных попыток входа. Попробуйте позже.");
|
||||
public static readonly Error LockedOut = Error.Unauthorized(
|
||||
"Auth.LockedOut",
|
||||
"Слишком много неудачных попыток входа. Попробуйте позже."
|
||||
);
|
||||
|
||||
public static readonly Error InvalidRefreshToken =
|
||||
Error.Unauthorized("Auth.InvalidRefreshToken", "Недействительный refresh-токен.");
|
||||
public static readonly Error InvalidRefreshToken = Error.Unauthorized(
|
||||
"Auth.InvalidRefreshToken",
|
||||
"Недействительный refresh-токен."
|
||||
);
|
||||
|
||||
public static readonly Error Unauthorized =
|
||||
Error.Unauthorized("Auth.Unauthorized", "Требуется аутентификация.");
|
||||
public static readonly Error Unauthorized = Error.Unauthorized(
|
||||
"Auth.Unauthorized",
|
||||
"Требуется аутентификация."
|
||||
);
|
||||
|
||||
public static readonly Error UserBlocked =
|
||||
Error.Forbidden("Auth.UserBlocked", "Аккаунт заблокирован администратором.");
|
||||
public static readonly Error UserBlocked = Error.Forbidden(
|
||||
"Auth.UserBlocked",
|
||||
"Аккаунт заблокирован администратором."
|
||||
);
|
||||
|
||||
public static readonly Error NotActivated =
|
||||
Error.Forbidden("Auth.NotActivated", "Аккаунт не активирован — обратитесь к администратору.");
|
||||
public static readonly Error NotActivated = Error.Forbidden(
|
||||
"Auth.NotActivated",
|
||||
"Аккаунт не активирован — обратитесь к администратору."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,4 +5,5 @@ public sealed record AuthResult(
|
||||
DateTimeOffset AccessTokenExpiresAt,
|
||||
string RefreshToken,
|
||||
DateTimeOffset RefreshTokenExpiresAt,
|
||||
CurrentUserDto User);
|
||||
CurrentUserDto User
|
||||
);
|
||||
|
||||
@@ -3,4 +3,5 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Auth.ChangePassword;
|
||||
|
||||
public sealed record ChangePasswordCommand(string CurrentPassword, string NewPassword) : ICommand<Result>;
|
||||
public sealed record ChangePasswordCommand(string CurrentPassword, string NewPassword)
|
||||
: ICommand<Result>;
|
||||
|
||||
+10
-3
@@ -4,14 +4,21 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Auth.ChangePassword;
|
||||
|
||||
public sealed class ChangePasswordCommandHandler(IIdentityService identityService, ICurrentUser currentUser)
|
||||
: ICommandHandler<ChangePasswordCommand, Result>
|
||||
public sealed class ChangePasswordCommandHandler(
|
||||
IIdentityService identityService,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<ChangePasswordCommand, Result>
|
||||
{
|
||||
public Task<Result> Handle(ChangePasswordCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return Task.FromResult(Result.Failure(AuthErrors.Unauthorized));
|
||||
|
||||
return identityService.ChangePasswordAsync(userId, command.CurrentPassword, command.NewPassword, cancellationToken);
|
||||
return identityService.ChangePasswordAsync(
|
||||
userId,
|
||||
command.CurrentPassword,
|
||||
command.NewPassword,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -4,8 +4,10 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Auth.ChangeUserName;
|
||||
|
||||
public sealed class ChangeUserNameCommandHandler(IIdentityService identityService, ICurrentUser currentUser)
|
||||
: ICommandHandler<ChangeUserNameCommand, Result>
|
||||
public sealed class ChangeUserNameCommandHandler(
|
||||
IIdentityService identityService,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<ChangeUserNameCommand, Result>
|
||||
{
|
||||
public Task<Result> Handle(ChangeUserNameCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
namespace PnvPanel.Application.Auth;
|
||||
|
||||
public sealed record CurrentUserDto(Guid Id, string UserName, string Role, bool IsActivated, bool TelegramLinked);
|
||||
public sealed record CurrentUserDto(
|
||||
Guid Id,
|
||||
string UserName,
|
||||
string Role,
|
||||
bool IsActivated,
|
||||
bool TelegramLinked
|
||||
);
|
||||
|
||||
+25
-8
@@ -6,27 +6,44 @@ using PnvPanel.Domain.Configs;
|
||||
|
||||
namespace PnvPanel.Application.Auth.DeleteMyAccount;
|
||||
|
||||
public sealed class DeleteMyAccountCommandHandler(IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway, ICurrentUser currentUser)
|
||||
: ICommandHandler<DeleteMyAccountCommand, Result>
|
||||
public sealed class DeleteMyAccountCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService,
|
||||
IXuiPanelGateway gateway,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<DeleteMyAccountCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(DeleteMyAccountCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result> Handle(
|
||||
DeleteMyAccountCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return Result.Failure(AuthErrors.Unauthorized);
|
||||
|
||||
var configs = await dbContext.VpnConfigs
|
||||
.Where(c => c.UserId == userId && c.Status != ConfigStatus.Revoked)
|
||||
var configs = await dbContext
|
||||
.VpnConfigs.Where(c => c.UserId == userId && c.Status != ConfigStatus.Revoked)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var config in configs)
|
||||
{
|
||||
var inbound = await dbContext.Inbounds.AsNoTracking().FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
|
||||
var inbound = await dbContext
|
||||
.Inbounds.AsNoTracking()
|
||||
.FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
|
||||
var node = inbound is null
|
||||
? null
|
||||
: await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
|
||||
: await dbContext
|
||||
.Nodes.AsNoTracking()
|
||||
.FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
|
||||
|
||||
if (inbound is not null && node is not null)
|
||||
await gateway.RemoveClientAsync(node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, cancellationToken);
|
||||
await gateway.RemoveClientAsync(
|
||||
node,
|
||||
inbound.RemoteInboundId,
|
||||
config.ClientExternalId,
|
||||
config.Protocol,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
config.Revoke();
|
||||
}
|
||||
|
||||
@@ -7,11 +7,19 @@ namespace PnvPanel.Application.Auth.Login;
|
||||
public sealed class LoginCommandHandler(
|
||||
IIdentityService identityService,
|
||||
IJwtTokenService jwtTokenService,
|
||||
IRefreshTokenService refreshTokenService) : ICommandHandler<LoginCommand, Result<AuthResult>>
|
||||
IRefreshTokenService refreshTokenService
|
||||
) : ICommandHandler<LoginCommand, Result<AuthResult>>
|
||||
{
|
||||
public async Task<Result<AuthResult>> Handle(LoginCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result<AuthResult>> Handle(
|
||||
LoginCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var credentialsResult = await identityService.ValidateCredentialsAsync(command.UserName, command.Password, cancellationToken);
|
||||
var credentialsResult = await identityService.ValidateCredentialsAsync(
|
||||
command.UserName,
|
||||
command.Password,
|
||||
cancellationToken
|
||||
);
|
||||
if (!credentialsResult.IsSuccess)
|
||||
return Result.Failure<AuthResult>(credentialsResult.Error);
|
||||
|
||||
@@ -23,14 +31,26 @@ public sealed class LoginCommandHandler(
|
||||
var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(user);
|
||||
var refreshToken = await refreshTokenService.IssueAsync(user.Id, cancellationToken);
|
||||
|
||||
var telegramInfo = await identityService.GetTelegramLinkInfoAsync(profile.Id, cancellationToken);
|
||||
var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated, telegramInfo.IsLinked);
|
||||
var telegramInfo = await identityService.GetTelegramLinkInfoAsync(
|
||||
profile.Id,
|
||||
cancellationToken
|
||||
);
|
||||
var dto = new CurrentUserDto(
|
||||
profile.Id,
|
||||
profile.UserName,
|
||||
profile.Role,
|
||||
profile.IsActivated,
|
||||
telegramInfo.IsLinked
|
||||
);
|
||||
|
||||
return Result.Success(new AuthResult(
|
||||
accessToken,
|
||||
accessExpiresAt,
|
||||
refreshToken.RawToken,
|
||||
refreshToken.ExpiresAt,
|
||||
dto));
|
||||
return Result.Success(
|
||||
new AuthResult(
|
||||
accessToken,
|
||||
accessExpiresAt,
|
||||
refreshToken.RawToken,
|
||||
refreshToken.ExpiresAt,
|
||||
dto
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,15 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Auth.Me;
|
||||
|
||||
public sealed class GetCurrentUserQueryHandler(IIdentityService identityService, ICurrentUser currentUser)
|
||||
: IQueryHandler<GetCurrentUserQuery, Result<CurrentUserDto>>
|
||||
public sealed class GetCurrentUserQueryHandler(
|
||||
IIdentityService identityService,
|
||||
ICurrentUser currentUser
|
||||
) : IQueryHandler<GetCurrentUserQuery, Result<CurrentUserDto>>
|
||||
{
|
||||
public async Task<Result<CurrentUserDto>> Handle(GetCurrentUserQuery query, CancellationToken cancellationToken)
|
||||
public async Task<Result<CurrentUserDto>> Handle(
|
||||
GetCurrentUserQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return Result.Failure<CurrentUserDto>(AuthErrors.Unauthorized);
|
||||
@@ -16,8 +21,19 @@ public sealed class GetCurrentUserQueryHandler(IIdentityService identityService,
|
||||
if (profile is null)
|
||||
return Result.Failure<CurrentUserDto>(AuthErrors.Unauthorized);
|
||||
|
||||
var telegramInfo = await identityService.GetTelegramLinkInfoAsync(userId, cancellationToken);
|
||||
var telegramInfo = await identityService.GetTelegramLinkInfoAsync(
|
||||
userId,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Result.Success(new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated, telegramInfo.IsLinked));
|
||||
return Result.Success(
|
||||
new CurrentUserDto(
|
||||
profile.Id,
|
||||
profile.UserName,
|
||||
profile.Role,
|
||||
profile.IsActivated,
|
||||
telegramInfo.IsLinked
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,29 +7,51 @@ namespace PnvPanel.Application.Auth.Refresh;
|
||||
public sealed class RefreshCommandHandler(
|
||||
IIdentityService identityService,
|
||||
IJwtTokenService jwtTokenService,
|
||||
IRefreshTokenService refreshTokenService) : ICommandHandler<RefreshCommand, Result<AuthResult>>
|
||||
IRefreshTokenService refreshTokenService
|
||||
) : ICommandHandler<RefreshCommand, Result<AuthResult>>
|
||||
{
|
||||
public async Task<Result<AuthResult>> Handle(RefreshCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result<AuthResult>> Handle(
|
||||
RefreshCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var rotated = await refreshTokenService.RotateAsync(command.RawRefreshToken, cancellationToken);
|
||||
var rotated = await refreshTokenService.RotateAsync(
|
||||
command.RawRefreshToken,
|
||||
cancellationToken
|
||||
);
|
||||
if (!rotated.IsSuccess)
|
||||
return Result.Failure<AuthResult>(rotated.Error);
|
||||
|
||||
var profile = await identityService.GetProfileAsync(rotated.Value.UserId, cancellationToken);
|
||||
var profile = await identityService.GetProfileAsync(
|
||||
rotated.Value.UserId,
|
||||
cancellationToken
|
||||
);
|
||||
if (profile is null)
|
||||
return Result.Failure<AuthResult>(AuthErrors.InvalidRefreshToken);
|
||||
|
||||
var authUser = new AuthenticatedUser(profile.Id, profile.UserName, profile.Role);
|
||||
var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(authUser);
|
||||
|
||||
var telegramInfo = await identityService.GetTelegramLinkInfoAsync(profile.Id, cancellationToken);
|
||||
var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated, telegramInfo.IsLinked);
|
||||
var telegramInfo = await identityService.GetTelegramLinkInfoAsync(
|
||||
profile.Id,
|
||||
cancellationToken
|
||||
);
|
||||
var dto = new CurrentUserDto(
|
||||
profile.Id,
|
||||
profile.UserName,
|
||||
profile.Role,
|
||||
profile.IsActivated,
|
||||
telegramInfo.IsLinked
|
||||
);
|
||||
|
||||
return Result.Success(new AuthResult(
|
||||
accessToken,
|
||||
accessExpiresAt,
|
||||
rotated.Value.RawToken,
|
||||
rotated.Value.ExpiresAt,
|
||||
dto));
|
||||
return Result.Success(
|
||||
new AuthResult(
|
||||
accessToken,
|
||||
accessExpiresAt,
|
||||
rotated.Value.RawToken,
|
||||
rotated.Value.ExpiresAt,
|
||||
dto
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Auth.Register;
|
||||
|
||||
public sealed record RegisterCommand(string UserName, string Password) : ICommand<Result<RegisterResult>>;
|
||||
public sealed record RegisterCommand(string UserName, string Password)
|
||||
: ICommand<Result<RegisterResult>>;
|
||||
|
||||
public sealed record RegisterResult(Guid Id, string UserName);
|
||||
|
||||
@@ -7,9 +7,16 @@ namespace PnvPanel.Application.Auth.Register;
|
||||
public sealed class RegisterCommandHandler(IIdentityService identityService)
|
||||
: ICommandHandler<RegisterCommand, Result<RegisterResult>>
|
||||
{
|
||||
public async Task<Result<RegisterResult>> Handle(RegisterCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result<RegisterResult>> Handle(
|
||||
RegisterCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await identityService.CreateUserAsync(command.UserName, command.Password, cancellationToken);
|
||||
var result = await identityService.CreateUserAsync(
|
||||
command.UserName,
|
||||
command.Password,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return result.IsSuccess
|
||||
? Result.Success(new RegisterResult(result.Value, command.UserName))
|
||||
|
||||
@@ -3,11 +3,16 @@ using PnvPanel.Application.Common.Messaging;
|
||||
|
||||
namespace PnvPanel.Application.Common.Behaviors;
|
||||
|
||||
public sealed class LoggingBehavior<TRequest, TResponse>(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
|
||||
: IPipelineBehavior<TRequest, TResponse>
|
||||
public sealed class LoggingBehavior<TRequest, TResponse>(
|
||||
ILogger<LoggingBehavior<TRequest, TResponse>> logger
|
||||
) : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : notnull
|
||||
{
|
||||
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var requestName = typeof(TRequest).Name;
|
||||
logger.LogInformation("Handling {RequestName}", requestName);
|
||||
|
||||
@@ -10,12 +10,18 @@ namespace PnvPanel.Application.Common.Behaviors;
|
||||
/// вместо разбросанных if(!profile.IsActivated) по хендлерам. Применяется только к запросам с этим
|
||||
/// маркером (generic-ограничение), остальные проходят мимо.
|
||||
/// </summary>
|
||||
public sealed class RequireActivationBehavior<TRequest, TResponse>(ICurrentUser currentUser, IIdentityService identityService)
|
||||
: IPipelineBehavior<TRequest, TResponse>
|
||||
public sealed class RequireActivationBehavior<TRequest, TResponse>(
|
||||
ICurrentUser currentUser,
|
||||
IIdentityService identityService
|
||||
) : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IRequiresActivation
|
||||
where TResponse : Result
|
||||
{
|
||||
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return ResultFailureFactory.Create<TResponse>(AuthErrors.Unauthorized);
|
||||
|
||||
@@ -5,7 +5,8 @@ namespace PnvPanel.Application.Common.Behaviors;
|
||||
/// <summary>Строит Result/Result<T> failure-ответ через reflection — общий хелпер для generic pipeline behaviors.</summary>
|
||||
internal static class ResultFailureFactory
|
||||
{
|
||||
public static TResponse Create<TResponse>(Error error) where TResponse : Result
|
||||
public static TResponse Create<TResponse>(Error error)
|
||||
where TResponse : Result
|
||||
{
|
||||
if (typeof(TResponse) == typeof(Result))
|
||||
return (TResponse)(object)Result.Failure(error);
|
||||
|
||||
@@ -12,7 +12,11 @@ public sealed class UnitOfWorkBehavior<TRequest, TResponse>(IAppDbContext dbCont
|
||||
: IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : ICommand<TResponse>
|
||||
{
|
||||
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var response = await next();
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
@@ -4,12 +4,17 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Common.Behaviors;
|
||||
|
||||
public sealed class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
|
||||
: IPipelineBehavior<TRequest, TResponse>
|
||||
public sealed class ValidationBehavior<TRequest, TResponse>(
|
||||
IEnumerable<IValidator<TRequest>> validators
|
||||
) : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : notnull
|
||||
where TResponse : Result
|
||||
{
|
||||
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (!validators.Any())
|
||||
return await next();
|
||||
@@ -25,7 +30,8 @@ public sealed class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidat
|
||||
|
||||
var error = Error.Validation(
|
||||
"Validation.Failed",
|
||||
string.Join("; ", failures.Select(f => f.ErrorMessage)));
|
||||
string.Join("; ", failures.Select(f => f.ErrorMessage))
|
||||
);
|
||||
|
||||
return ResultFailureFactory.Create<TResponse>(error);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,25 @@ namespace PnvPanel.Application.Common.Interfaces;
|
||||
public sealed record AuthenticatedUser(Guid Id, string UserName, string Role);
|
||||
|
||||
public sealed record CurrentUserProfile(
|
||||
Guid Id, string UserName, Guid RoleId, string Role, bool IsActivated, bool IsBlocked, int MaxConfigs, int MaxIpLimit,
|
||||
string SubscriptionToken);
|
||||
Guid Id,
|
||||
string UserName,
|
||||
Guid RoleId,
|
||||
string Role,
|
||||
bool IsActivated,
|
||||
bool IsBlocked,
|
||||
int MaxConfigs,
|
||||
int MaxIpLimit,
|
||||
string SubscriptionToken
|
||||
);
|
||||
|
||||
public sealed record UserSummaryDto(Guid Id, string UserName, string Role, bool IsActivated, bool IsBlocked, DateTimeOffset? ActivatedAt);
|
||||
public sealed record UserSummaryDto(
|
||||
Guid Id,
|
||||
string UserName,
|
||||
string Role,
|
||||
bool IsActivated,
|
||||
bool IsBlocked,
|
||||
DateTimeOffset? ActivatedAt
|
||||
);
|
||||
|
||||
public sealed record UserStatsDto(int Total, int Activated);
|
||||
|
||||
@@ -16,30 +31,57 @@ public sealed record TelegramLinkInfo(bool IsLinked, long? TelegramUserId, strin
|
||||
|
||||
public interface IIdentityService
|
||||
{
|
||||
Task<Result<Guid>> CreateUserAsync(string userName, string password, CancellationToken cancellationToken);
|
||||
Task<Result<Guid>> CreateUserAsync(
|
||||
string userName,
|
||||
string password,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task<Result<AuthenticatedUser>> ValidateCredentialsAsync(string userName, string password, CancellationToken cancellationToken);
|
||||
Task<Result<AuthenticatedUser>> ValidateCredentialsAsync(
|
||||
string userName,
|
||||
string password,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task<CurrentUserProfile?> GetProfileAsync(Guid userId, CancellationToken cancellationToken);
|
||||
|
||||
Task<Result> ChangePasswordAsync(Guid userId, string currentPassword, string newPassword, CancellationToken cancellationToken);
|
||||
Task<Result> ChangePasswordAsync(
|
||||
Guid userId,
|
||||
string currentPassword,
|
||||
string newPassword,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task<Result> ChangeUserNameAsync(Guid userId, string newUserName, CancellationToken cancellationToken);
|
||||
Task<Result> ChangeUserNameAsync(
|
||||
Guid userId,
|
||||
string newUserName,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Помечает пользователя активированным. Изменение не коммитится немедленно (в отличие от
|
||||
/// CreateUserAsync/ChangePasswordAsync) — оно попадает в трекер того же DbContext и сохраняется
|
||||
/// вместе с изменением ActivationRequest одной транзакцией через UnitOfWorkBehavior.
|
||||
/// </summary>
|
||||
Task<Result> ActivateUserAsync(Guid userId, Guid activatedBy, CancellationToken cancellationToken);
|
||||
Task<Result> ActivateUserAsync(
|
||||
Guid userId,
|
||||
Guid activatedBy,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task<IReadOnlyDictionary<Guid, string>> GetUserNamesAsync(IReadOnlyCollection<Guid> userIds, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyDictionary<Guid, string>> GetUserNamesAsync(
|
||||
IReadOnlyCollection<Guid> userIds,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
/// <summary>Удаляет аккаунт (самоудаление). Конфиги должны быть отозваны заранее вызывающей стороной.</summary>
|
||||
Task<Result> DeleteUserAsync(Guid userId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Для агрегированной подписки /sub/{userToken} (все активные конфиги пользователя).</summary>
|
||||
Task<Guid?> FindUserIdBySubscriptionTokenAsync(string token, CancellationToken cancellationToken);
|
||||
Task<Guid?> FindUserIdBySubscriptionTokenAsync(
|
||||
string token,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
/// <summary>Блокировка: вход запрещён (см. ValidateCredentialsAsync). Конфиги гасит вызывающая сторона.</summary>
|
||||
Task<Result> BlockUserAsync(Guid userId, CancellationToken cancellationToken);
|
||||
@@ -47,21 +89,43 @@ public interface IIdentityService
|
||||
Task<Result> UnblockUserAsync(Guid userId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Сброс пароля админом — для пользователей без привязанного Telegram (M7).</summary>
|
||||
Task<Result> ResetPasswordAsync(Guid userId, string newPassword, CancellationToken cancellationToken);
|
||||
Task<Result> ResetPasswordAsync(
|
||||
Guid userId,
|
||||
string newPassword,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task<PagedList<UserSummaryDto>> ListUsersAsync(int page, int pageSize, string? search, CancellationToken cancellationToken);
|
||||
Task<PagedList<UserSummaryDto>> ListUsersAsync(
|
||||
int page,
|
||||
int pageSize,
|
||||
string? search,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task<UserStatsDto> GetUserStatsAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task<Result> LinkTelegramAsync(Guid userId, long telegramUserId, string? telegramUsername, CancellationToken cancellationToken);
|
||||
Task<Result> LinkTelegramAsync(
|
||||
Guid userId,
|
||||
long telegramUserId,
|
||||
string? telegramUsername,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task<Result> UnlinkTelegramAsync(Guid userId, CancellationToken cancellationToken);
|
||||
|
||||
Task<Guid?> FindUserIdByTelegramUserIdAsync(long telegramUserId, CancellationToken cancellationToken);
|
||||
Task<Guid?> FindUserIdByTelegramUserIdAsync(
|
||||
long telegramUserId,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task<TelegramLinkInfo> GetTelegramLinkInfoAsync(Guid userId, CancellationToken cancellationToken);
|
||||
Task<TelegramLinkInfo> GetTelegramLinkInfoAsync(
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
/// <summary>Telegram ID активных (активированных, не заблокированных) пользователей с привязкой —
|
||||
/// для рассылки уведомлений вроде публикации новости.</summary>
|
||||
Task<IReadOnlyCollection<long>> GetActivatedLinkedTelegramUserIdsAsync(CancellationToken cancellationToken);
|
||||
Task<IReadOnlyCollection<long>> GetActivatedLinkedTelegramUserIdsAsync(
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,21 +12,53 @@ namespace PnvPanel.Application.Common.Interfaces;
|
||||
public interface IRealtimeNotifier
|
||||
{
|
||||
Task NotifyConfigTrafficUpdatedAsync(
|
||||
Guid userId, Guid configId, long usedUpBytes, long usedDownBytes, CancellationToken cancellationToken);
|
||||
Guid userId,
|
||||
Guid configId,
|
||||
long usedUpBytes,
|
||||
long usedDownBytes,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task NotifyConfigStatusChangedAsync(Guid userId, Guid configId, ConfigStatus status, CancellationToken cancellationToken);
|
||||
Task NotifyConfigStatusChangedAsync(
|
||||
Guid userId,
|
||||
Guid configId,
|
||||
ConfigStatus status,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task NotifyNodeStatusChangedAsync(Guid nodeId, NodeStatus status, DateTimeOffset? lastSyncAt, CancellationToken cancellationToken);
|
||||
Task NotifyNodeStatusChangedAsync(
|
||||
Guid nodeId,
|
||||
NodeStatus status,
|
||||
DateTimeOffset? lastSyncAt,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task NotifyActivationRequestedAsync(
|
||||
Guid requestId, Guid userId, string userName, string? comment, DateTimeOffset createdAt, CancellationToken cancellationToken);
|
||||
Guid requestId,
|
||||
Guid userId,
|
||||
string userName,
|
||||
string? comment,
|
||||
DateTimeOffset createdAt,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task NotifyUserActivatedAsync(Guid userId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Единственное широковещательное событие (всем подключенным клиентам), а не по группе.</summary>
|
||||
Task NotifyNewsPublishedAsync(Guid postId, string title, DateTimeOffset createdAt, CancellationToken cancellationToken);
|
||||
Task NotifyNewsPublishedAsync(
|
||||
Guid postId,
|
||||
string title,
|
||||
DateTimeOffset createdAt,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task NotifyTicketCreatedAsync(Guid ticketId, Guid userId, string userName, TicketType type, CancellationToken cancellationToken);
|
||||
Task NotifyTicketCreatedAsync(
|
||||
Guid ticketId,
|
||||
Guid userId,
|
||||
string userName,
|
||||
TicketType type,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
/// <summary>Новый комментарий или смена статуса — пушится автору тикета (не всем участникам треда).</summary>
|
||||
Task NotifyTicketUpdatedAsync(Guid ticketId, Guid userId, CancellationToken cancellationToken);
|
||||
|
||||
@@ -10,7 +10,10 @@ public interface IRefreshTokenService
|
||||
{
|
||||
Task<IssuedRefreshToken> IssueAsync(Guid userId, CancellationToken cancellationToken);
|
||||
|
||||
Task<Result<RotatedRefreshToken>> RotateAsync(string rawToken, CancellationToken cancellationToken);
|
||||
Task<Result<RotatedRefreshToken>> RotateAsync(
|
||||
string rawToken,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task RevokeAsync(string rawToken, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -6,9 +6,19 @@ public sealed record RoleDto(Guid Id, string Name, int MaxConfigs, int MaxIpLimi
|
||||
|
||||
public interface IRoleService
|
||||
{
|
||||
Task<Result<RoleDto>> CreateRoleAsync(string name, int maxConfigs, int maxIpLimit, CancellationToken cancellationToken);
|
||||
Task<Result<RoleDto>> CreateRoleAsync(
|
||||
string name,
|
||||
int maxConfigs,
|
||||
int maxIpLimit,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task<Result<RoleDto>> UpdateRoleAsync(Guid roleId, int maxConfigs, int maxIpLimit, CancellationToken cancellationToken);
|
||||
Task<Result<RoleDto>> UpdateRoleAsync(
|
||||
Guid roleId,
|
||||
int maxConfigs,
|
||||
int maxIpLimit,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task<Result> DeleteRoleAsync(Guid roleId, CancellationToken cancellationToken);
|
||||
|
||||
|
||||
@@ -10,7 +10,11 @@ namespace PnvPanel.Application.Common.Interfaces;
|
||||
public interface ITelegramNotifier
|
||||
{
|
||||
Task NotifyAdminsActivationRequestedAsync(
|
||||
Guid requestId, string userName, string? comment, CancellationToken cancellationToken);
|
||||
Guid requestId,
|
||||
string userName,
|
||||
string? comment,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
/// <summary>Личное сообщение пользователю, если у него привязан Telegram (иначе no-op).
|
||||
/// Если задан PublicSiteUrl — добавляет кнопку-ссылку на сайт.</summary>
|
||||
@@ -18,11 +22,21 @@ public interface ITelegramNotifier
|
||||
|
||||
/// <summary>Баг-репорт/предложение — только кнопка-ссылка на сайт (переписка и картинки — там),
|
||||
/// без инлайн-действий.</summary>
|
||||
Task NotifyAdminsBugReportCreatedAsync(Guid ticketId, string userName, string message, CancellationToken cancellationToken);
|
||||
Task NotifyAdminsBugReportCreatedAsync(
|
||||
Guid ticketId,
|
||||
string userName,
|
||||
string message,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
/// <summary>Заявка на роль — инлайн-кнопки «Одобрить/Отклонить», решается полностью в Telegram.</summary>
|
||||
Task NotifyAdminsRoleRequestCreatedAsync(
|
||||
Guid ticketId, string userName, string roleDescription, string justification, CancellationToken cancellationToken);
|
||||
Guid ticketId,
|
||||
string userName,
|
||||
string roleDescription,
|
||||
string justification,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
/// <summary>Рассылка о публикации новости всем активированным пользователям с привязанным Telegram
|
||||
/// (кнопка-ссылка на сайт, где новость показана целиком).</summary>
|
||||
@@ -30,5 +44,10 @@ public interface ITelegramNotifier
|
||||
|
||||
/// <summary>Пользователь переоткрыл решённый тикет — только кнопка-ссылка на сайт, без
|
||||
/// инлайн-действий (аналогично баг-репортам).</summary>
|
||||
Task NotifyAdminsTicketReopenedAsync(Guid ticketId, string userName, TicketType type, CancellationToken cancellationToken);
|
||||
Task NotifyAdminsTicketReopenedAsync(
|
||||
Guid ticketId,
|
||||
string userName,
|
||||
TicketType type,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,12 @@ using PnvPanel.Domain.Nodes;
|
||||
|
||||
namespace PnvPanel.Application.Common.Interfaces;
|
||||
|
||||
public sealed record RemoteInboundInfo(string RemoteInboundId, VpnProtocol Protocol, string Remark, int Port);
|
||||
public sealed record RemoteInboundInfo(
|
||||
string RemoteInboundId,
|
||||
VpnProtocol Protocol,
|
||||
string Remark,
|
||||
int Port
|
||||
);
|
||||
|
||||
public sealed record NodeProbeResult(bool IsReachable, string? ErrorMessage);
|
||||
|
||||
@@ -20,7 +25,10 @@ public interface IXuiPanelGateway
|
||||
|
||||
Task<NodeProbeResult> ProbeAsync(Node node, CancellationToken cancellationToken);
|
||||
|
||||
Task<Result<IReadOnlyList<RemoteInboundInfo>>> ListInboundsAsync(Node node, CancellationToken cancellationToken);
|
||||
Task<Result<IReadOnlyList<RemoteInboundInfo>>> ListInboundsAsync(
|
||||
Node node,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
void InvalidateClient(Guid nodeId);
|
||||
|
||||
@@ -30,25 +38,49 @@ public interface IXuiPanelGateway
|
||||
/// -1 (RoleQuota.Unlimited) означает без лимита — гейтвей сам переводит его в нативное значение 3x-ui.
|
||||
/// </summary>
|
||||
Task<Result<string>> AddClientAsync(
|
||||
Node node, string inboundRemoteId, VpnProtocol protocol, string clientEmail, string clientName, int limitIp,
|
||||
CancellationToken cancellationToken);
|
||||
Node node,
|
||||
string inboundRemoteId,
|
||||
VpnProtocol protocol,
|
||||
string clientEmail,
|
||||
string clientName,
|
||||
int limitIp,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task<Result> RemoveClientAsync(
|
||||
Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol,
|
||||
CancellationToken cancellationToken);
|
||||
Node node,
|
||||
string inboundRemoteId,
|
||||
string clientExternalId,
|
||||
VpnProtocol protocol,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task<Result> UpdateClientAsync(
|
||||
Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol,
|
||||
string name, bool enable, CancellationToken cancellationToken);
|
||||
Node node,
|
||||
string inboundRemoteId,
|
||||
string clientExternalId,
|
||||
VpnProtocol protocol,
|
||||
string name,
|
||||
bool enable,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task<Result<string>> BuildConnectionStringAsync(
|
||||
Node node, Inbound inbound, string clientExternalId, string clientName, string publicHost,
|
||||
CancellationToken cancellationToken);
|
||||
Node node,
|
||||
Inbound inbound,
|
||||
string clientExternalId,
|
||||
string clientName,
|
||||
string publicHost,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Трафик по клиентам инбаунда, ключ — ClientEmail. ThreeXui.Net не даёт типизированного метода
|
||||
/// для этого — извлекается из сырого clientStats[] в RawInboundJson (стандартное поле 3x-ui API).
|
||||
/// </summary>
|
||||
Task<Result<IReadOnlyDictionary<string, ClientTrafficInfo>>> GetClientTrafficAsync(
|
||||
Node node, string inboundRemoteId, CancellationToken cancellationToken);
|
||||
Node node,
|
||||
string inboundRemoteId,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
namespace PnvPanel.Application.Common.Messaging;
|
||||
|
||||
public interface ICommandHandler<in TCommand, TResponse> where TCommand : ICommand<TResponse>
|
||||
public interface ICommandHandler<in TCommand, TResponse>
|
||||
where TCommand : ICommand<TResponse>
|
||||
{
|
||||
Task<TResponse> Handle(TCommand command, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -4,5 +4,9 @@ public delegate Task<TResponse> RequestHandlerDelegate<TResponse>();
|
||||
|
||||
public interface IPipelineBehavior<TRequest, TResponse>
|
||||
{
|
||||
Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken);
|
||||
Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
namespace PnvPanel.Application.Common.Messaging;
|
||||
|
||||
public interface IQueryHandler<in TQuery, TResponse> where TQuery : IQuery<TResponse>
|
||||
public interface IQueryHandler<in TQuery, TResponse>
|
||||
where TQuery : IQuery<TResponse>
|
||||
{
|
||||
Task<TResponse> Handle(TQuery query, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@ namespace PnvPanel.Application.Common.Messaging;
|
||||
/// <summary>Собственный тонкий CQRS-диспетчер (без MediatR).</summary>
|
||||
public interface ISender
|
||||
{
|
||||
Task<TResponse> Send<TResponse>(ICommand<TResponse> command, CancellationToken cancellationToken = default);
|
||||
Task<TResponse> Send<TResponse>(IQuery<TResponse> query, CancellationToken cancellationToken = default);
|
||||
Task<TResponse> Send<TResponse>(
|
||||
ICommand<TResponse> command,
|
||||
CancellationToken cancellationToken = default
|
||||
);
|
||||
Task<TResponse> Send<TResponse>(
|
||||
IQuery<TResponse> query,
|
||||
CancellationToken cancellationToken = default
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user