Enhance admin endpoints and queries for improved filtering and management
- Updated `ListPaymentRequestsQuery` to include `Kind` and `Search` parameters for better filtering of payment requests. - Enhanced `ListAuditLogsQuery` to support additional filters: `Source`, `TargetType`, and `Action`, improving audit log retrieval. - Modified `ListUsersQuery` to accept new filters: `RoleId`, `IsActivated`, `IsBlocked`, and `BillingExpired`, allowing for more granular user management. - Introduced `DeleteInbound` endpoint to allow deletion of inbounds that are not currently available, enhancing inbound management capabilities. - Updated frontend API calls to reflect new query parameters and support for additional filtering options in the admin interface. - Revised API documentation to include new parameters and endpoint functionalities for better clarity and usage guidance.
This commit is contained in:
@@ -53,7 +53,13 @@ public static class AdminBillingEndpoints
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var query = new ListPaymentRequestsQuery(request.Status, request.Page, request.PageSize);
|
||||
var query = new ListPaymentRequestsQuery(
|
||||
request.Status,
|
||||
request.Kind,
|
||||
request.Search,
|
||||
request.Page,
|
||||
request.PageSize
|
||||
);
|
||||
var result = await sender.Send(query, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
@@ -98,6 +104,8 @@ public static class AdminBillingEndpoints
|
||||
|
||||
public sealed record ListPaymentRequestsRequest(
|
||||
PaymentRequestStatus? Status,
|
||||
PaymentRequestKind? Kind,
|
||||
string? Search,
|
||||
int Page = 1,
|
||||
int PageSize = 20
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ using PnvPanel.Application.Admin.Audit;
|
||||
using PnvPanel.Application.Admin.Stats;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Domain.Audit;
|
||||
using PnvPanel.Infrastructure.Identity;
|
||||
|
||||
namespace PnvPanel.Api.Endpoints;
|
||||
@@ -30,11 +31,20 @@ public static class AdminStatsEndpoints
|
||||
private static async Task<IResult> GetAudit(
|
||||
int page,
|
||||
int pageSize,
|
||||
AuditSource? source,
|
||||
string? targetType,
|
||||
string? action,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var query = new ListAuditLogsQuery(page <= 0 ? 1 : page, pageSize <= 0 ? 50 : pageSize);
|
||||
var query = new ListAuditLogsQuery(
|
||||
page <= 0 ? 1 : page,
|
||||
pageSize <= 0 ? 50 : pageSize,
|
||||
source,
|
||||
targetType,
|
||||
action
|
||||
);
|
||||
var result = await sender.Send(query, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Configs;
|
||||
using PnvPanel.Domain.Configs;
|
||||
using PnvPanel.Domain.Inbounds;
|
||||
using PnvPanel.Infrastructure.Identity;
|
||||
|
||||
namespace PnvPanel.Api.Endpoints;
|
||||
@@ -44,11 +45,23 @@ public static class AdminUserEndpoints
|
||||
int page,
|
||||
int pageSize,
|
||||
string? search,
|
||||
Guid? roleId,
|
||||
bool? isActivated,
|
||||
bool? isBlocked,
|
||||
bool? billingExpired,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var query = new ListUsersQuery(page <= 0 ? 1 : page, pageSize <= 0 ? 20 : pageSize, search);
|
||||
var query = new ListUsersQuery(
|
||||
page <= 0 ? 1 : page,
|
||||
pageSize <= 0 ? 20 : pageSize,
|
||||
search,
|
||||
roleId,
|
||||
isActivated,
|
||||
isBlocked,
|
||||
billingExpired
|
||||
);
|
||||
var result = await sender.Send(query, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
@@ -112,6 +125,8 @@ public static class AdminUserEndpoints
|
||||
int pageSize,
|
||||
string? search,
|
||||
ConfigStatus? status,
|
||||
VpnProtocol? protocol,
|
||||
Guid? nodeId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
@@ -120,7 +135,9 @@ public static class AdminUserEndpoints
|
||||
page <= 0 ? 1 : page,
|
||||
pageSize <= 0 ? 20 : pageSize,
|
||||
search,
|
||||
status
|
||||
status,
|
||||
protocol,
|
||||
nodeId
|
||||
);
|
||||
var result = await sender.Send(query, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
|
||||
@@ -15,6 +15,7 @@ public static class InboundEndpoints
|
||||
|
||||
admin.MapGet("", ListInbounds).Produces<IReadOnlyList<InboundDto>>();
|
||||
admin.MapPut("/{id:guid}/publish", PublishInbound).Produces<InboundDto>();
|
||||
admin.MapDelete("/{id:guid}", DeleteInbound);
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -45,6 +46,16 @@ public static class InboundEndpoints
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteInbound(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new DeleteInboundCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record PublishInboundBody(
|
||||
|
||||
@@ -4,8 +4,13 @@ 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,
|
||||
AuditSource? Source,
|
||||
string? TargetType,
|
||||
string? Action
|
||||
) : IQuery<Result<PagedList<AuditLogDto>>>;
|
||||
|
||||
public sealed record AuditLogDto(
|
||||
long Id,
|
||||
|
||||
@@ -16,8 +16,16 @@ public sealed class ListAuditLogsQueryHandler(IAppDbContext dbContext)
|
||||
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 logsQuery = dbContext.AuditLogs.AsNoTracking();
|
||||
|
||||
if (query.Source is { } source)
|
||||
logsQuery = logsQuery.Where(a => a.Source == source);
|
||||
if (!string.IsNullOrWhiteSpace(query.TargetType))
|
||||
logsQuery = logsQuery.Where(a => a.TargetType == query.TargetType);
|
||||
if (!string.IsNullOrWhiteSpace(query.Action))
|
||||
logsQuery = logsQuery.Where(a => a.Action.Contains(query.Action));
|
||||
|
||||
var result = await logsQuery
|
||||
.OrderByDescending(a => a.CreatedAt)
|
||||
.Select(a => new AuditLogDto(
|
||||
a.Id,
|
||||
|
||||
@@ -4,5 +4,10 @@ using PnvPanel.Domain.Billing;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Billing;
|
||||
|
||||
public sealed record ListPaymentRequestsQuery(PaymentRequestStatus? StatusFilter, int Page, int PageSize)
|
||||
: IQuery<Result<PagedList<AdminPaymentRequestDto>>>;
|
||||
public sealed record ListPaymentRequestsQuery(
|
||||
PaymentRequestStatus? StatusFilter,
|
||||
PaymentRequestKind? KindFilter,
|
||||
string? Search,
|
||||
int Page,
|
||||
int PageSize
|
||||
) : IQuery<Result<PagedList<AdminPaymentRequestDto>>>;
|
||||
|
||||
@@ -21,6 +21,18 @@ public sealed class ListPaymentRequestsQueryHandler(
|
||||
var requestsQuery = dbContext.PaymentRequests.AsNoTracking();
|
||||
if (query.StatusFilter is { } status)
|
||||
requestsQuery = requestsQuery.Where(r => r.Status == status);
|
||||
if (query.KindFilter is { } kind)
|
||||
requestsQuery = requestsQuery.Where(r => r.Kind == kind);
|
||||
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||
{
|
||||
// PaymentRequest хранит только UserId — резолвим совпадающих пользователей ДО пагинации
|
||||
// (иначе "поиск по имени" фильтровал бы уже отобранную страницу, а не весь набор).
|
||||
var matchingUserIds = await identityService.FindUserIdsByUserNameAsync(
|
||||
query.Search.Trim(),
|
||||
cancellationToken
|
||||
);
|
||||
requestsQuery = requestsQuery.Where(r => matchingUserIds.Contains(r.UserId));
|
||||
}
|
||||
|
||||
var page1 = await requestsQuery
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
|
||||
@@ -2,14 +2,18 @@ using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Domain.Configs;
|
||||
using PnvPanel.Domain.Inbounds;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Configs;
|
||||
|
||||
/// <summary><paramref name="Search"/> матчится по ClientEmail/Label — это то, по чему админ сверяет
|
||||
/// конфиг с записью в 3x-ui, а не по владельцу (для поиска по пользователю есть /admin/users).</summary>
|
||||
/// конфиг с записью в 3x-ui, а не по владельцу (для поиска по пользователю есть /admin/users).
|
||||
/// <paramref name="NodeId"/> фильтрует по ноде инбаунда конфига (JOIN Inbounds).</summary>
|
||||
public sealed record ListAllConfigsQuery(
|
||||
int Page,
|
||||
int PageSize,
|
||||
string? Search,
|
||||
ConfigStatus? Status
|
||||
ConfigStatus? Status,
|
||||
VpnProtocol? Protocol,
|
||||
Guid? NodeId
|
||||
) : IQuery<Result<PagedList<AdminVpnConfigDto>>>;
|
||||
|
||||
@@ -23,6 +23,19 @@ public sealed class ListAllConfigsQueryHandler(
|
||||
if (query.Status is { } status)
|
||||
configsQuery = configsQuery.Where(c => c.Status == status);
|
||||
|
||||
if (query.Protocol is { } protocol)
|
||||
configsQuery = configsQuery.Where(c => c.Protocol == protocol);
|
||||
|
||||
if (query.NodeId is { } nodeId)
|
||||
{
|
||||
var nodeInboundIds = await dbContext
|
||||
.Inbounds.AsNoTracking()
|
||||
.Where(i => i.NodeId == nodeId)
|
||||
.Select(i => i.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
configsQuery = configsQuery.Where(c => nodeInboundIds.Contains(c.InboundId));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||
{
|
||||
var search = query.Search.Trim();
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Inbounds;
|
||||
|
||||
/// <summary>Force-удаление недоступного на панели инбаунда вместе с каскадным отзывом ещё живых
|
||||
/// конфигов на нём — см. DeleteInboundCommandHandler.</summary>
|
||||
public sealed record DeleteInboundCommand(Guid InboundId) : ICommand<Result>;
|
||||
@@ -0,0 +1,67 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Domain.Audit;
|
||||
using PnvPanel.Domain.Configs;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Inbounds;
|
||||
|
||||
/// <summary>Удаляет инбаунд, которого больше нет на панели (см. SyncNodeCommandHandler —
|
||||
/// автоматически он не удаляется, пока по нему есть VpnConfig, чтобы не потерять ссылку на историю).
|
||||
/// Разрешено только для `IsAvailable == false` — живой, всё ещё синхронизируемый инбаунд через этот
|
||||
/// путь не удалить. Перед удалением каскадно отзывает все ещё не-Revoked конфиги на нём: панельный
|
||||
/// клиент всё равно недостижим (инбаунда для него на 3x-ui уже нет), поэтому Revoke — чисто локальная
|
||||
/// операция, без вызова гейтвея (см. RevokeVpnConfigCommandHandler для того же паттерна). Уже
|
||||
/// Revoked-конфиги при этом остаются в БД с InboundId, указывающим на удалённую запись — это
|
||||
/// осознанный компромисс (см. domain-model.md): нигде в пользовательских списках Revoked-конфиги не
|
||||
/// показываются, а в админском списке отсутствующий инбаунд отображается как "?".</summary>
|
||||
public sealed class DeleteInboundCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IRealtimeNotifier notifier,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<DeleteInboundCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(DeleteInboundCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var inbound = await dbContext.Inbounds.FirstOrDefaultAsync(
|
||||
i => i.Id == command.InboundId,
|
||||
cancellationToken
|
||||
);
|
||||
if (inbound is null)
|
||||
return Result.Failure(InboundErrors.NotFound);
|
||||
|
||||
if (inbound.IsAvailable)
|
||||
return Result.Failure(InboundErrors.StillAvailable);
|
||||
|
||||
var configs = await dbContext
|
||||
.VpnConfigs.Where(c => c.InboundId == inbound.Id && c.Status != ConfigStatus.Revoked)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var config in configs)
|
||||
{
|
||||
config.Revoke();
|
||||
await notifier.NotifyConfigStatusChangedAsync(
|
||||
config.UserId,
|
||||
config.Id,
|
||||
config.Status,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
dbContext.Inbounds.Remove(inbound);
|
||||
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
currentUser.UserId,
|
||||
"InboundDeleted",
|
||||
"Inbound",
|
||||
inbound.Id.ToString(),
|
||||
metadata: configs.Count > 0 ? $"{{\"revokedConfigs\":{configs.Count}}}" : null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -8,4 +8,9 @@ public static class InboundErrors
|
||||
"Inbounds.NotFound",
|
||||
"Inbound не найден."
|
||||
);
|
||||
|
||||
public static readonly Error StillAvailable = Error.Validation(
|
||||
"Inbounds.StillAvailable",
|
||||
"Inbound всё ещё существует на панели — удалить можно только недоступные (IsAvailable=false)."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,5 +4,12 @@ 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,
|
||||
Guid? RoleId,
|
||||
bool? IsActivated,
|
||||
bool? IsBlocked,
|
||||
bool? BillingExpired
|
||||
) : IQuery<Result<PagedList<UserSummaryDto>>>;
|
||||
|
||||
@@ -21,6 +21,10 @@ public sealed class ListUsersQueryHandler(IIdentityService identityService, IApp
|
||||
page,
|
||||
pageSize,
|
||||
query.Search,
|
||||
query.RoleId,
|
||||
query.IsActivated,
|
||||
query.IsBlocked,
|
||||
query.BillingExpired,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
|
||||
@@ -92,6 +92,14 @@ public interface IIdentityService
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
/// <summary>Для фильтрации списков по владельцу там, где сущность хранит только UserId (см.
|
||||
/// ListPaymentRequestsQueryHandler) — резолвится ДО пагинации, а не после (в отличие от
|
||||
/// GetUserNamesAsync, который просто подставляет имена в уже отобранную страницу).</summary>
|
||||
Task<IReadOnlyList<Guid>> FindUserIdsByUserNameAsync(
|
||||
string search,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
/// <summary>Удаляет аккаунт (самоудаление). Конфиги должны быть отозваны заранее вызывающей стороной.</summary>
|
||||
Task<Result> DeleteUserAsync(Guid userId, CancellationToken cancellationToken);
|
||||
|
||||
@@ -117,6 +125,10 @@ public interface IIdentityService
|
||||
int page,
|
||||
int pageSize,
|
||||
string? search,
|
||||
Guid? roleId,
|
||||
bool? isActivated,
|
||||
bool? isBlocked,
|
||||
bool? billingExpired,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
|
||||
@@ -4,13 +4,15 @@ using Microsoft.EntityFrameworkCore;
|
||||
using PnvPanel.Application.Auth;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Infrastructure.Persistence;
|
||||
|
||||
namespace PnvPanel.Infrastructure.Identity;
|
||||
|
||||
internal sealed class IdentityService(
|
||||
UserManager<AppUser> userManager,
|
||||
SignInManager<AppUser> signInManager,
|
||||
RoleManager<AppRole> roleManager
|
||||
RoleManager<AppRole> roleManager,
|
||||
AppDbContext dbContext
|
||||
) : IIdentityService
|
||||
{
|
||||
public async Task<Result<Guid>> CreateUserAsync(
|
||||
@@ -176,6 +178,17 @@ internal sealed class IdentityService(
|
||||
.ToDictionaryAsync(u => u.Id, u => u.UserName!, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Guid>> FindUserIdsByUserNameAsync(
|
||||
string search,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
return await userManager
|
||||
.Users.Where(u => u.UserName!.Contains(search))
|
||||
.Select(u => u.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Result> DeleteUserAsync(Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await userManager.FindByIdAsync(userId.ToString());
|
||||
@@ -252,12 +265,36 @@ internal sealed class IdentityService(
|
||||
int page,
|
||||
int pageSize,
|
||||
string? search,
|
||||
Guid? roleId,
|
||||
bool? isActivated,
|
||||
bool? isBlocked,
|
||||
bool? billingExpired,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var query = userManager.Users.AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
query = query.Where(u => u.UserName!.Contains(search));
|
||||
if (isActivated is { } activated)
|
||||
query = query.Where(u => u.IsActivated == activated);
|
||||
if (isBlocked is { } blocked)
|
||||
query = query.Where(u => u.IsBlocked == blocked);
|
||||
if (roleId is { } roleIdFilter)
|
||||
query = query.Where(u => dbContext.UserRoles.Any(ur => ur.UserId == u.Id && ur.RoleId == roleIdFilter));
|
||||
if (billingExpired is { } expired)
|
||||
{
|
||||
// Только среди пользователей с billing-ролью — иначе сюда попали бы и те, у кого
|
||||
// биллинг вообще не включён (BillingPaidUntil у них тоже null, но это не "просрочено").
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
query = query.Where(u =>
|
||||
dbContext.UserRoles.Any(ur =>
|
||||
ur.UserId == u.Id && dbContext.Roles.Any(r => r.Id == ur.RoleId && r.BillingEnabled)
|
||||
)
|
||||
);
|
||||
query = expired
|
||||
? query.Where(u => u.BillingPaidUntil == null || u.BillingPaidUntil < now)
|
||||
: query.Where(u => u.BillingPaidUntil != null && u.BillingPaidUntil >= now);
|
||||
}
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var users = await query
|
||||
|
||||
Reference in New Issue
Block a user