Refactor project files for improved readability and structure
CI / Backend (build + test) (push) Successful in 1m18s
CI / Frontend (lint + typecheck + build) (push) Successful in 31s

- 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:
Leonid Pershin
2026-07-14 07:24:13 +03:00
parent 9d5424bb9c
commit df137ca5a7
285 changed files with 6911 additions and 2063 deletions
@@ -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
);
@@ -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>>>;
@@ -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)
);
}
}
@@ -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
)
);
}
}
@@ -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>;
@@ -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,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
);
@@ -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&lt;T&gt; 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