Refactor project files for improved readability and structure
- Cleaned up whitespace in Directory.Build.props and Directory.Packages.props for consistency. - Reformatted project file references in PnvPanel.Api.csproj for better clarity. - Enhanced code readability in various endpoint files by adjusting line breaks and indentation. - Standardized method signatures and improved formatting in ResultExtensions and multiple endpoint classes for better maintainability.
This commit is contained in:
@@ -4,11 +4,11 @@ namespace PnvPanel.Api.Common;
|
||||
|
||||
public static class ResultExtensions
|
||||
{
|
||||
public static IResult ToHttpResult(this Result result)
|
||||
=> result.IsSuccess ? Results.NoContent() : ToProblem(result.Error);
|
||||
public static IResult ToHttpResult(this Result result) =>
|
||||
result.IsSuccess ? Results.NoContent() : ToProblem(result.Error);
|
||||
|
||||
public static IResult ToHttpResult<T>(this Result<T> result)
|
||||
=> result.IsSuccess ? Results.Ok(result.Value) : ToProblem(result.Error);
|
||||
public static IResult ToHttpResult<T>(this Result<T> result) =>
|
||||
result.IsSuccess ? Results.Ok(result.Value) : ToProblem(result.Error);
|
||||
|
||||
private static IResult ToProblem(Error error)
|
||||
{
|
||||
|
||||
@@ -27,40 +27,69 @@ public static class ActivationEndpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetStatus(ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> GetStatus(
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new GetActivationStatusQuery(), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RequestActivation(RequestActivationCommand command, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> RequestActivation(
|
||||
RequestActivationCommand command,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListRequests(
|
||||
[AsParameters] ListActivationRequestsRequest request, ISender sender, CancellationToken cancellationToken)
|
||||
[AsParameters] ListActivationRequestsRequest request,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var query = new ListActivationRequestsQuery(request.StatusFilter, request.Page, request.PageSize);
|
||||
var query = new ListActivationRequestsQuery(
|
||||
request.StatusFilter,
|
||||
request.Page,
|
||||
request.PageSize
|
||||
);
|
||||
var result = await sender.Send(query, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> Approve(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> Approve(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ApproveActivationCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> Reject(
|
||||
Guid id, RejectActivationBody body, ISender sender, CancellationToken cancellationToken)
|
||||
Guid id,
|
||||
RejectActivationBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new RejectActivationCommand(id, body.Reason), cancellationToken);
|
||||
var result = await sender.Send(
|
||||
new RejectActivationCommand(id, body.Reason),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ListActivationRequestsRequest(ActivationStatus? StatusFilter, int Page = 1, int PageSize = 20);
|
||||
public sealed record ListActivationRequestsRequest(
|
||||
ActivationStatus? StatusFilter,
|
||||
int Page = 1,
|
||||
int PageSize = 20
|
||||
);
|
||||
|
||||
public sealed record RejectActivationBody(string? Reason);
|
||||
|
||||
@@ -28,21 +28,42 @@ public static class AdminAppEndpoints
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateApp(CreateAppCommand command, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> CreateApp(
|
||||
CreateAppCommand command,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateApp(Guid id, UpdateAppBody body, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> UpdateApp(
|
||||
Guid id,
|
||||
UpdateAppBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var command = new UpdateAppCommand(
|
||||
id, body.Name, body.DownloadUrl, body.OperatingSystem, body.Description, body.IconUrl, body.SortOrder, body.IsEnabled);
|
||||
id,
|
||||
body.Name,
|
||||
body.DownloadUrl,
|
||||
body.OperatingSystem,
|
||||
body.Description,
|
||||
body.IconUrl,
|
||||
body.SortOrder,
|
||||
body.IsEnabled
|
||||
);
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteApp(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> DeleteApp(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new DeleteAppCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
@@ -50,5 +71,11 @@ public static class AdminAppEndpoints
|
||||
}
|
||||
|
||||
public sealed record UpdateAppBody(
|
||||
string Name, string DownloadUrl, OsPlatform OperatingSystem, string? Description, string? IconUrl,
|
||||
int SortOrder, bool IsEnabled);
|
||||
string Name,
|
||||
string DownloadUrl,
|
||||
OsPlatform OperatingSystem,
|
||||
string? Description,
|
||||
string? IconUrl,
|
||||
int SortOrder,
|
||||
bool IsEnabled
|
||||
);
|
||||
|
||||
@@ -23,26 +23,44 @@ public static class AdminNewsEndpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListAdminNews(int page, int pageSize, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> ListAdminNews(
|
||||
int page,
|
||||
int pageSize,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ListAdminNewsQuery(page, pageSize), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreatePost(CreatePostCommand command, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> CreatePost(
|
||||
CreatePostCommand command,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdatePost(Guid id, UpdatePostBody body, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> UpdatePost(
|
||||
Guid id,
|
||||
UpdatePostBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var command = new UpdatePostCommand(id, body.Title, body.Body);
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeletePost(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> DeletePost(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new DeletePostCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
|
||||
@@ -27,7 +27,12 @@ public static class AdminStatsEndpoints
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAudit(int page, int pageSize, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> GetAudit(
|
||||
int page,
|
||||
int pageSize,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var query = new ListAuditLogsQuery(page <= 0 ? 1 : page, pageSize <= 0 ? 50 : pageSize);
|
||||
var result = await sender.Send(query, cancellationToken);
|
||||
|
||||
@@ -19,59 +19,104 @@ public static class AdminSupportEndpoints
|
||||
|
||||
admin.MapGet("/tickets", ListTickets).Produces<PagedList<TicketSummaryDto>>();
|
||||
admin.MapGet("/tickets/{id:guid}", GetTicket).Produces<TicketDetailDto>();
|
||||
admin.MapPost("/tickets/{id:guid}/comments", AddComment).DisableAntiforgery().Produces<TicketCommentDto>();
|
||||
admin.MapPost("/tickets/{id:guid}/resolve", Resolve).Produces(StatusCodes.Status204NoContent);
|
||||
admin
|
||||
.MapPost("/tickets/{id:guid}/comments", AddComment)
|
||||
.DisableAntiforgery()
|
||||
.Produces<TicketCommentDto>();
|
||||
admin
|
||||
.MapPost("/tickets/{id:guid}/resolve", Resolve)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapPost("/tickets/{id:guid}/close", Close).Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapPost("/tickets/{id:guid}/approve", ApproveRoleRequest).Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapPost("/tickets/{id:guid}/reject", RejectRoleRequest).Produces(StatusCodes.Status204NoContent);
|
||||
admin
|
||||
.MapPost("/tickets/{id:guid}/approve", ApproveRoleRequest)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin
|
||||
.MapPost("/tickets/{id:guid}/reject", RejectRoleRequest)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListTickets(
|
||||
[AsParameters] ListTicketsRequest request, ISender sender, CancellationToken cancellationToken)
|
||||
[AsParameters] ListTicketsRequest request,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var query = new ListAllTicketsQuery(request.Type, request.Status, request.Page, request.PageSize);
|
||||
var query = new ListAllTicketsQuery(
|
||||
request.Type,
|
||||
request.Status,
|
||||
request.Page,
|
||||
request.PageSize
|
||||
);
|
||||
var result = await sender.Send(query, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetTicket(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> GetTicket(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new GetTicketAdminQuery(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> AddComment(
|
||||
Guid id, [FromForm] string body, IFormFileCollection? files, ISender sender, CancellationToken cancellationToken)
|
||||
Guid id,
|
||||
[FromForm] string body,
|
||||
IFormFileCollection? files,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var command = new AddTicketCommentCommand(id, body, SupportEndpoints.ToUploads(files));
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> Resolve(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> Resolve(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ResolveTicketCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> Close(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> Close(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new CloseTicketCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ApproveRoleRequest(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> ApproveRoleRequest(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ApproveRoleRequestCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RejectRoleRequest(
|
||||
Guid id, RejectRoleRequestBody body, ISender sender, CancellationToken cancellationToken)
|
||||
Guid id,
|
||||
RejectRoleRequestBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new RejectRoleRequestCommand(id, body.Reason), cancellationToken);
|
||||
var result = await sender.Send(
|
||||
new RejectRoleRequestCommand(id, body.Reason),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,64 +19,118 @@ public static class AdminUserEndpoints
|
||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||
|
||||
admin.MapGet("/users", ListUsers).Produces<PagedList<UserSummaryDto>>();
|
||||
admin.MapPatch("/users/{id:guid}/block", BlockUser).Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapPatch("/users/{id:guid}/unblock", UnblockUser).Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapPost("/users/{id:guid}/reset-password", ResetPassword).Produces(StatusCodes.Status204NoContent);
|
||||
admin
|
||||
.MapPatch("/users/{id:guid}/block", BlockUser)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin
|
||||
.MapPatch("/users/{id:guid}/unblock", UnblockUser)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin
|
||||
.MapPost("/users/{id:guid}/reset-password", ResetPassword)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapDelete("/users/{id:guid}", DeleteUser).Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapGet("/users/{id:guid}/configs", GetUserConfigs).Produces<IReadOnlyList<VpnConfigDto>>();
|
||||
admin
|
||||
.MapGet("/users/{id:guid}/configs", GetUserConfigs)
|
||||
.Produces<IReadOnlyList<VpnConfigDto>>();
|
||||
admin.MapGet("/configs", ListAllConfigs).Produces<PagedList<AdminVpnConfigDto>>();
|
||||
admin.MapDelete("/configs/{id:guid}", ForceRevokeConfig).Produces(StatusCodes.Status204NoContent);
|
||||
admin
|
||||
.MapDelete("/configs/{id:guid}", ForceRevokeConfig)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListUsers(
|
||||
int page, int pageSize, string? search, ISender sender, CancellationToken cancellationToken)
|
||||
int page,
|
||||
int pageSize,
|
||||
string? search,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var query = new ListUsersQuery(page <= 0 ? 1 : page, pageSize <= 0 ? 20 : pageSize, search);
|
||||
var result = await sender.Send(query, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> BlockUser(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> BlockUser(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new BlockUserCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> UnblockUser(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> UnblockUser(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new UnblockUserCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ResetPassword(Guid id, ResetPasswordBody body, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> ResetPassword(
|
||||
Guid id,
|
||||
ResetPasswordBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ResetUserPasswordCommand(id, body.NewPassword), cancellationToken);
|
||||
var result = await sender.Send(
|
||||
new ResetUserPasswordCommand(id, body.NewPassword),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteUser(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> DeleteUser(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new DeleteUserCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetUserConfigs(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> GetUserConfigs(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new GetUserConfigsQuery(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListAllConfigs(
|
||||
int page, int pageSize, string? search, ConfigStatus? status, ISender sender, CancellationToken cancellationToken)
|
||||
int page,
|
||||
int pageSize,
|
||||
string? search,
|
||||
ConfigStatus? status,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var query = new ListAllConfigsQuery(page <= 0 ? 1 : page, pageSize <= 0 ? 20 : pageSize, search, status);
|
||||
var query = new ListAllConfigsQuery(
|
||||
page <= 0 ? 1 : page,
|
||||
pageSize <= 0 ? 20 : pageSize,
|
||||
search,
|
||||
status
|
||||
);
|
||||
var result = await sender.Send(query, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ForceRevokeConfig(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> ForceRevokeConfig(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ForceRevokeConfigCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
|
||||
@@ -25,34 +25,69 @@ public static class AuthEndpoints
|
||||
group.MapPost("/register", Register).Produces<RegisterResult>();
|
||||
group.MapPost("/login", Login).Produces<AuthResponseDto>();
|
||||
group.MapPost("/refresh", Refresh).Produces<AuthResponseDto>();
|
||||
group.MapPost("/logout", Logout).RequireAuthorization().Produces(StatusCodes.Status204NoContent);
|
||||
group.MapPost("/change-password", ChangePassword).RequireAuthorization().Produces(StatusCodes.Status204NoContent);
|
||||
group.MapPost("/change-username", ChangeUserName).RequireAuthorization().Produces(StatusCodes.Status204NoContent);
|
||||
group
|
||||
.MapPost("/logout", Logout)
|
||||
.RequireAuthorization()
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
group
|
||||
.MapPost("/change-password", ChangePassword)
|
||||
.RequireAuthorization()
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
group
|
||||
.MapPost("/change-username", ChangeUserName)
|
||||
.RequireAuthorization()
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
group.MapGet("/me", Me).RequireAuthorization().Produces<CurrentUserDto>();
|
||||
group.MapDelete("/me", DeleteMe).RequireAuthorization().Produces(StatusCodes.Status204NoContent);
|
||||
group
|
||||
.MapDelete("/me", DeleteMe)
|
||||
.RequireAuthorization()
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> Register(RegisterCommand command, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> Register(
|
||||
RegisterCommand command,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> Login(LoginCommand command, ISender sender, HttpRequest request, HttpResponse response, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> Login(
|
||||
LoginCommand command,
|
||||
ISender sender,
|
||||
HttpRequest request,
|
||||
HttpResponse response,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
if (!result.IsSuccess)
|
||||
return result.ToHttpResult();
|
||||
|
||||
SetRefreshCookie(request, response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt);
|
||||
SetRefreshCookie(
|
||||
request,
|
||||
response,
|
||||
result.Value.RefreshToken,
|
||||
result.Value.RefreshTokenExpiresAt
|
||||
);
|
||||
return Results.Ok(ToLoginResponse(result.Value));
|
||||
}
|
||||
|
||||
private static async Task<IResult> Refresh(HttpRequest request, HttpResponse response, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> Refresh(
|
||||
HttpRequest request,
|
||||
HttpResponse response,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (!request.Cookies.TryGetValue(RefreshCookieName, out var rawToken) || string.IsNullOrEmpty(rawToken))
|
||||
if (
|
||||
!request.Cookies.TryGetValue(RefreshCookieName, out var rawToken)
|
||||
|| string.IsNullOrEmpty(rawToken)
|
||||
)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var result = await sender.Send(new RefreshCommand(rawToken), cancellationToken);
|
||||
@@ -62,29 +97,50 @@ public static class AuthEndpoints
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
SetRefreshCookie(request, response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt);
|
||||
SetRefreshCookie(
|
||||
request,
|
||||
response,
|
||||
result.Value.RefreshToken,
|
||||
result.Value.RefreshTokenExpiresAt
|
||||
);
|
||||
return Results.Ok(ToLoginResponse(result.Value));
|
||||
}
|
||||
|
||||
internal static AuthResponseDto ToLoginResponse(AuthResult auth) =>
|
||||
new(auth.AccessToken, auth.AccessTokenExpiresAt, auth.User);
|
||||
|
||||
private static async Task<IResult> Logout(HttpRequest request, HttpResponse response, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> Logout(
|
||||
HttpRequest request,
|
||||
HttpResponse response,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (request.Cookies.TryGetValue(RefreshCookieName, out var rawToken) && !string.IsNullOrEmpty(rawToken))
|
||||
if (
|
||||
request.Cookies.TryGetValue(RefreshCookieName, out var rawToken)
|
||||
&& !string.IsNullOrEmpty(rawToken)
|
||||
)
|
||||
await sender.Send(new LogoutCommand(rawToken), cancellationToken);
|
||||
|
||||
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ChangePassword(ChangePasswordCommand command, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> ChangePassword(
|
||||
ChangePasswordCommand command,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ChangeUserName(ChangeUserNameCommand command, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> ChangeUserName(
|
||||
ChangeUserNameCommand command,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
@@ -96,14 +152,24 @@ public static class AuthEndpoints
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteMe(HttpRequest request, HttpResponse response, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> DeleteMe(
|
||||
HttpRequest request,
|
||||
HttpResponse response,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new DeleteMyAccountCommand(), cancellationToken);
|
||||
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static void SetRefreshCookie(HttpRequest request, HttpResponse response, string rawToken, DateTimeOffset expiresAt)
|
||||
private static void SetRefreshCookie(
|
||||
HttpRequest request,
|
||||
HttpResponse response,
|
||||
string rawToken,
|
||||
DateTimeOffset expiresAt
|
||||
)
|
||||
{
|
||||
var options = BuildCookieOptions(request);
|
||||
options.Expires = expiresAt;
|
||||
@@ -112,13 +178,18 @@ public static class AuthEndpoints
|
||||
|
||||
// Secure = IsHttps запроса (учитывает ForwardedHeaders за внешним TLS-прокси, см. CLAUDE.md) —
|
||||
// иначе браузер/HttpClient не пришлёт cookie обратно на plain-http (локальный dev, TestServer).
|
||||
private static CookieOptions BuildCookieOptions(HttpRequest request) => new()
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = request.IsHttps,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Path = "/api/auth",
|
||||
};
|
||||
private static CookieOptions BuildCookieOptions(HttpRequest request) =>
|
||||
new()
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = request.IsHttps,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Path = "/api/auth",
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record AuthResponseDto(string AccessToken, DateTimeOffset ExpiresAt, CurrentUserDto User);
|
||||
public sealed record AuthResponseDto(
|
||||
string AccessToken,
|
||||
DateTimeOffset ExpiresAt,
|
||||
CurrentUserDto User
|
||||
);
|
||||
|
||||
@@ -18,73 +18,113 @@ public static class ConfigEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/api").WithTags("Configs").RequireAuthorization();
|
||||
|
||||
group.MapGet("/inbounds/available", ListAvailableInbounds).Produces<IReadOnlyList<AvailableInboundDto>>();
|
||||
group
|
||||
.MapGet("/inbounds/available", ListAvailableInbounds)
|
||||
.Produces<IReadOnlyList<AvailableInboundDto>>();
|
||||
group.MapGet("/configs", GetMyConfigs).Produces<GetMyConfigsResult>();
|
||||
group.MapPost("/configs", CreateConfig).Produces<VpnConfigDto>();
|
||||
group.MapPatch("/configs/{id:guid}", EditConfig).Produces<VpnConfigDto>();
|
||||
group.MapPost("/configs/{id:guid}/rotate", RotateConfig).Produces<VpnConfigDto>();
|
||||
group.MapDelete("/configs/{id:guid}", RevokeConfig).Produces(StatusCodes.Status204NoContent);
|
||||
group
|
||||
.MapDelete("/configs/{id:guid}", RevokeConfig)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
group.MapGet("/configs/{id:guid}/link", GetConfigLink).Produces<ConfigLinkResponseDto>();
|
||||
group.MapGet("/subscription", GetMySubscription).Produces<MySubscriptionResponseDto>();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListAvailableInbounds(ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> ListAvailableInbounds(
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ListAvailableInboundsQuery(), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetMyConfigs(ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> GetMyConfigs(
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new GetMyConfigsQuery(), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateConfig(CreateConfigBody body, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> CreateConfig(
|
||||
CreateConfigBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var command = new CreateVpnConfigCommand(body.InboundId, body.Label);
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> EditConfig(Guid id, EditConfigBody body, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> EditConfig(
|
||||
Guid id,
|
||||
EditConfigBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var command = new EditVpnConfigCommand(id, body.Label);
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RotateConfig(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> RotateConfig(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new RotateVpnConfigCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RevokeConfig(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> RevokeConfig(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new RevokeVpnConfigCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetConfigLink(Guid id, HttpRequest request, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> GetConfigLink(
|
||||
Guid id,
|
||||
HttpRequest request,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new GetConfigLinkQuery(id), cancellationToken);
|
||||
if (!result.IsSuccess)
|
||||
return result.ToHttpResult();
|
||||
|
||||
var subscriptionUrl = $"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}";
|
||||
return Results.Ok(new ConfigLinkResponseDto(result.Value.ConnectionString, subscriptionUrl));
|
||||
var subscriptionUrl =
|
||||
$"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}";
|
||||
return Results.Ok(
|
||||
new ConfigLinkResponseDto(result.Value.ConnectionString, subscriptionUrl)
|
||||
);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetMySubscription(HttpRequest request, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> GetMySubscription(
|
||||
HttpRequest request,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new GetMySubscriptionQuery(), cancellationToken);
|
||||
if (!result.IsSuccess)
|
||||
return result.ToHttpResult();
|
||||
|
||||
var subscriptionUrl = $"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}";
|
||||
var subscriptionUrl =
|
||||
$"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}";
|
||||
return Results.Ok(new MySubscriptionResponseDto(subscriptionUrl));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,20 +19,38 @@ public static class InboundEndpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListInbounds(Guid? nodeId, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> ListInbounds(
|
||||
Guid? nodeId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ListInboundsQuery(nodeId), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> PublishInbound(
|
||||
Guid id, PublishInboundBody body, ISender sender, CancellationToken cancellationToken)
|
||||
Guid id,
|
||||
PublishInboundBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var command = new PublishInboundCommand(
|
||||
id, body.IsPublished, body.DisplayName, body.AllowedRoleIds ?? [], body.MaxClients);
|
||||
id,
|
||||
body.IsPublished,
|
||||
body.DisplayName,
|
||||
body.AllowedRoleIds ?? [],
|
||||
body.MaxClients
|
||||
);
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record PublishInboundBody(bool IsPublished, string? DisplayName, IReadOnlyList<Guid>? AllowedRoleIds, int? MaxClients);
|
||||
public sealed record PublishInboundBody(
|
||||
bool IsPublished,
|
||||
string? DisplayName,
|
||||
IReadOnlyList<Guid>? AllowedRoleIds,
|
||||
int? MaxClients
|
||||
);
|
||||
|
||||
@@ -16,7 +16,12 @@ public static class NewsEndpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListNews(int page, int pageSize, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> ListNews(
|
||||
int page,
|
||||
int pageSize,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ListNewsQuery(page, pageSize), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
|
||||
@@ -23,42 +23,79 @@ public static class NodeEndpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListNodes(ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> ListNodes(
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ListNodesQuery(), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RegisterNode(RegisterNodeCommand command, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> RegisterNode(
|
||||
RegisterNodeCommand command,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateNode(Guid id, UpdateNodeBody body, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> UpdateNode(
|
||||
Guid id,
|
||||
UpdateNodeBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var command = new UpdateNodeCommand(id, body.Name, body.Location, body.IsEnabled, body.Username, body.Password);
|
||||
var command = new UpdateNodeCommand(
|
||||
id,
|
||||
body.Name,
|
||||
body.Location,
|
||||
body.IsEnabled,
|
||||
body.Username,
|
||||
body.Password
|
||||
);
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteNode(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> DeleteNode(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new DeleteNodeCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> SyncNode(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> SyncNode(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new SyncNodeCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ProbeNode(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> ProbeNode(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ProbeNodeCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record UpdateNodeBody(string Name, string? Location, bool IsEnabled, string? Username, string? Password);
|
||||
public sealed record UpdateNodeBody(
|
||||
string Name,
|
||||
string? Location,
|
||||
bool IsEnabled,
|
||||
string? Username,
|
||||
string? Password
|
||||
);
|
||||
|
||||
@@ -19,38 +19,67 @@ public static class RoleEndpoints
|
||||
admin.MapPost("/roles", CreateRole).Produces<RoleDto>();
|
||||
admin.MapPut("/roles/{id:guid}", UpdateRole).Produces<RoleDto>();
|
||||
admin.MapDelete("/roles/{id:guid}", DeleteRole).Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapPatch("/users/{id:guid}/role", ChangeUserRole).Produces(StatusCodes.Status204NoContent);
|
||||
admin
|
||||
.MapPatch("/users/{id:guid}/role", ChangeUserRole)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListRoles(ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> ListRoles(
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ListRolesQuery(), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateRole(CreateRoleCommand command, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> CreateRole(
|
||||
CreateRoleCommand command,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateRole(Guid id, UpdateRoleBody body, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> UpdateRole(
|
||||
Guid id,
|
||||
UpdateRoleBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new UpdateRoleCommand(id, body.MaxConfigs, body.MaxIpLimit), cancellationToken);
|
||||
var result = await sender.Send(
|
||||
new UpdateRoleCommand(id, body.MaxConfigs, body.MaxIpLimit),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteRole(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> DeleteRole(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new DeleteRoleCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ChangeUserRole(Guid id, ChangeUserRoleBody body, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> ChangeUserRole(
|
||||
Guid id,
|
||||
ChangeUserRoleBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ChangeUserRoleCommand(id, body.RoleId), cancellationToken);
|
||||
var result = await sender.Send(
|
||||
new ChangeUserRoleCommand(id, body.RoleId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,12 +19,19 @@ public static class SubscriptionEndpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetSubscription(string token, HttpResponse response, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> GetSubscription(
|
||||
string token,
|
||||
HttpResponse response,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
// Токен — либо AppUser.SubscriptionToken (агрегированная подписка), либо VpnConfig.SubscriptionToken
|
||||
// (один конфиг). Пробуем пользовательский токен первым.
|
||||
var userResult = await sender.Send(new GetUserSubscriptionQuery(token), cancellationToken);
|
||||
var result = userResult.IsSuccess ? userResult : await sender.Send(new GetConfigSubscriptionQuery(token), cancellationToken);
|
||||
var result = userResult.IsSuccess
|
||||
? userResult
|
||||
: await sender.Send(new GetConfigSubscriptionQuery(token), cancellationToken);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
return Results.NotFound();
|
||||
@@ -36,7 +43,8 @@ public static class SubscriptionEndpoints
|
||||
var expire = result.Value.ExpiresAt is { } exp ? exp.ToUnixTimeSeconds().ToString() : "0";
|
||||
response.Headers.Append(
|
||||
"Subscription-Userinfo",
|
||||
$"upload={result.Value.UsedUpBytes}; download={result.Value.UsedDownBytes}; total={total}; expire={expire}");
|
||||
$"upload={result.Value.UsedUpBytes}; download={result.Value.UsedDownBytes}; total={total}; expire={expire}"
|
||||
);
|
||||
response.Headers.Append("Profile-Update-Interval", "12");
|
||||
|
||||
return Results.Text(base64Body, "text/plain; charset=utf-8");
|
||||
|
||||
@@ -23,25 +23,38 @@ public static class SupportEndpoints
|
||||
var group = app.MapGroup("/api/support").WithTags("Support").RequireAuthorization();
|
||||
|
||||
group.MapGet("/roles", ListSelectableRoles).Produces<IReadOnlyList<RoleDto>>();
|
||||
group.MapPost("/tickets/bug-reports", CreateBugReport).DisableAntiforgery().Produces<TicketDetailDto>();
|
||||
group
|
||||
.MapPost("/tickets/bug-reports", CreateBugReport)
|
||||
.DisableAntiforgery()
|
||||
.Produces<TicketDetailDto>();
|
||||
group.MapPost("/tickets/role-requests", CreateRoleRequest).Produces<TicketDetailDto>();
|
||||
group.MapGet("/tickets", ListMyTickets).Produces<PagedList<TicketSummaryDto>>();
|
||||
group.MapGet("/tickets/{id:guid}", GetTicket).Produces<TicketDetailDto>();
|
||||
group.MapPost("/tickets/{id:guid}/comments", AddComment).DisableAntiforgery().Produces<TicketCommentDto>();
|
||||
group
|
||||
.MapPost("/tickets/{id:guid}/comments", AddComment)
|
||||
.DisableAntiforgery()
|
||||
.Produces<TicketCommentDto>();
|
||||
group.MapPost("/tickets/{id:guid}/reopen", Reopen).Produces(StatusCodes.Status204NoContent);
|
||||
group.MapGet("/attachments/{id:guid}", GetAttachment);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListSelectableRoles(ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> ListSelectableRoles(
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ListSelectableRolesQuery(), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateBugReport(
|
||||
[FromForm] string message, IFormFileCollection? files, ISender sender, CancellationToken cancellationToken)
|
||||
[FromForm] string message,
|
||||
IFormFileCollection? files,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var command = new CreateBugReportTicketCommand(message, ToUploads(files));
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
@@ -49,43 +62,76 @@ public static class SupportEndpoints
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateRoleRequest(
|
||||
CreateRoleRequestBody body, ISender sender, CancellationToken cancellationToken)
|
||||
CreateRoleRequestBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var command = new CreateRoleRequestTicketCommand(
|
||||
body.ExistingRoleId, body.NewRoleName, body.NewRoleMaxConfigs, body.NewRoleMaxIpLimit, body.Justification);
|
||||
body.ExistingRoleId,
|
||||
body.NewRoleName,
|
||||
body.NewRoleMaxConfigs,
|
||||
body.NewRoleMaxIpLimit,
|
||||
body.Justification
|
||||
);
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListMyTickets(
|
||||
[AsParameters] ListTicketsRequest request, ISender sender, CancellationToken cancellationToken)
|
||||
[AsParameters] ListTicketsRequest request,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var query = new ListMyTicketsQuery(request.Type, request.Status, request.Page, request.PageSize);
|
||||
var query = new ListMyTicketsQuery(
|
||||
request.Type,
|
||||
request.Status,
|
||||
request.Page,
|
||||
request.PageSize
|
||||
);
|
||||
var result = await sender.Send(query, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetTicket(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> GetTicket(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new GetTicketQuery(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> AddComment(
|
||||
Guid id, [FromForm] string body, IFormFileCollection? files, ISender sender, CancellationToken cancellationToken)
|
||||
Guid id,
|
||||
[FromForm] string body,
|
||||
IFormFileCollection? files,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var command = new AddTicketCommentCommand(id, body, ToUploads(files));
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> Reopen(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> Reopen(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ReopenTicketCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAttachment(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
private static async Task<IResult> GetAttachment(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new GetTicketAttachmentQuery(id), cancellationToken);
|
||||
if (!result.IsSuccess)
|
||||
@@ -100,12 +146,27 @@ public static class SupportEndpoints
|
||||
return [];
|
||||
|
||||
return files
|
||||
.Select(f => new TicketAttachmentUpload(f.OpenReadStream(), f.FileName, f.ContentType, f.Length))
|
||||
.Select(f => new TicketAttachmentUpload(
|
||||
f.OpenReadStream(),
|
||||
f.FileName,
|
||||
f.ContentType,
|
||||
f.Length
|
||||
))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record CreateRoleRequestBody(
|
||||
Guid? ExistingRoleId, string? NewRoleName, int? NewRoleMaxConfigs, int? NewRoleMaxIpLimit, string Justification);
|
||||
Guid? ExistingRoleId,
|
||||
string? NewRoleName,
|
||||
int? NewRoleMaxConfigs,
|
||||
int? NewRoleMaxIpLimit,
|
||||
string Justification
|
||||
);
|
||||
|
||||
public sealed record ListTicketsRequest(TicketType? Type, TicketStatus? Status, int Page = 1, int PageSize = 20);
|
||||
public sealed record ListTicketsRequest(
|
||||
TicketType? Type,
|
||||
TicketStatus? Status,
|
||||
int Page = 1,
|
||||
int PageSize = 20
|
||||
);
|
||||
|
||||
@@ -15,23 +15,38 @@ public static class TelegramEndpoints
|
||||
.WithTags("Auth.Telegram")
|
||||
.RequireRateLimiting(RateLimiting.AuthPolicy);
|
||||
|
||||
group.MapPost("/link-token", CreateLinkToken).RequireAuthorization().Produces<LinkTokenResponseDto>();
|
||||
group.MapPost("/unlink", Unlink).RequireAuthorization().Produces(StatusCodes.Status204NoContent);
|
||||
group.MapPost("/login-request", CreateLoginRequest).Produces<TelegramLoginRequestResponseDto>();
|
||||
group.MapGet("/login-request/{id:guid}", GetLoginRequestStatus).Produces<TelegramLoginStatusResponseDto>();
|
||||
group
|
||||
.MapPost("/link-token", CreateLinkToken)
|
||||
.RequireAuthorization()
|
||||
.Produces<LinkTokenResponseDto>();
|
||||
group
|
||||
.MapPost("/unlink", Unlink)
|
||||
.RequireAuthorization()
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
group
|
||||
.MapPost("/login-request", CreateLoginRequest)
|
||||
.Produces<TelegramLoginRequestResponseDto>();
|
||||
group
|
||||
.MapGet("/login-request/{id:guid}", GetLoginRequestStatus)
|
||||
.Produces<TelegramLoginStatusResponseDto>();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateLinkToken(
|
||||
ISender sender, ITelegramBotInfo botInfo, CancellationToken cancellationToken)
|
||||
ISender sender,
|
||||
ITelegramBotInfo botInfo,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new CreateLinkTokenCommand(), cancellationToken);
|
||||
if (!result.IsSuccess)
|
||||
return result.ToHttpResult();
|
||||
|
||||
var botUsername = await botInfo.GetUsernameAsync(cancellationToken);
|
||||
var deepLink = botUsername is null ? null : $"https://t.me/{botUsername}?start=link_{result.Value.Token}";
|
||||
var deepLink = botUsername is null
|
||||
? null
|
||||
: $"https://t.me/{botUsername}?start=link_{result.Value.Token}";
|
||||
|
||||
return Results.Ok(new LinkTokenResponseDto(deepLink, result.Value.ExpiresAt));
|
||||
}
|
||||
@@ -43,7 +58,11 @@ public static class TelegramEndpoints
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateLoginRequest(
|
||||
HttpRequest request, ISender sender, ITelegramBotInfo botInfo, CancellationToken cancellationToken)
|
||||
HttpRequest request,
|
||||
ISender sender,
|
||||
ITelegramBotInfo botInfo,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var context = request.HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
var result = await sender.Send(new CreateLoginRequestCommand(context), cancellationToken);
|
||||
@@ -51,13 +70,26 @@ public static class TelegramEndpoints
|
||||
return result.ToHttpResult();
|
||||
|
||||
var botUsername = await botInfo.GetUsernameAsync(cancellationToken);
|
||||
var deepLink = botUsername is null ? null : $"https://t.me/{botUsername}?start=login_{result.Value.RequestId}";
|
||||
var deepLink = botUsername is null
|
||||
? null
|
||||
: $"https://t.me/{botUsername}?start=login_{result.Value.RequestId}";
|
||||
|
||||
return Results.Ok(new TelegramLoginRequestResponseDto(result.Value.RequestId, deepLink, result.Value.ExpiresAt));
|
||||
return Results.Ok(
|
||||
new TelegramLoginRequestResponseDto(
|
||||
result.Value.RequestId,
|
||||
deepLink,
|
||||
result.Value.ExpiresAt
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetLoginRequestStatus(
|
||||
Guid id, HttpRequest request, HttpResponse response, ISender sender, CancellationToken cancellationToken)
|
||||
Guid id,
|
||||
HttpRequest request,
|
||||
HttpResponse response,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new GetLoginRequestStatusQuery(id), cancellationToken);
|
||||
if (!result.IsSuccess)
|
||||
@@ -77,17 +109,33 @@ public static class TelegramEndpoints
|
||||
};
|
||||
response.Cookies.Append("pnv_refresh_token", auth.RefreshToken, cookieOptions);
|
||||
|
||||
return Results.Ok(new TelegramLoginStatusResponseDto(
|
||||
result.Value.Status, auth.AccessToken, auth.AccessTokenExpiresAt, auth.User));
|
||||
return Results.Ok(
|
||||
new TelegramLoginStatusResponseDto(
|
||||
result.Value.Status,
|
||||
auth.AccessToken,
|
||||
auth.AccessTokenExpiresAt,
|
||||
auth.User
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return Results.Ok(new TelegramLoginStatusResponseDto(result.Value.Status, null, null, null));
|
||||
return Results.Ok(
|
||||
new TelegramLoginStatusResponseDto(result.Value.Status, null, null, null)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record LinkTokenResponseDto(string? DeepLink, DateTimeOffset ExpiresAt);
|
||||
|
||||
public sealed record TelegramLoginRequestResponseDto(Guid RequestId, string? DeepLink, DateTimeOffset ExpiresAt);
|
||||
public sealed record TelegramLoginRequestResponseDto(
|
||||
Guid RequestId,
|
||||
string? DeepLink,
|
||||
DateTimeOffset ExpiresAt
|
||||
);
|
||||
|
||||
public sealed record TelegramLoginStatusResponseDto(
|
||||
TelegramLoginStatus Status, string? AccessToken, DateTimeOffset? ExpiresAt, CurrentUserDto? User);
|
||||
TelegramLoginStatus Status,
|
||||
string? AccessToken,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
CurrentUserDto? User
|
||||
);
|
||||
|
||||
@@ -9,68 +9,146 @@ namespace PnvPanel.Api.Hubs;
|
||||
internal sealed class SignalRRealtimeNotifier(IHubContext<PanelHub> hubContext) : IRealtimeNotifier
|
||||
{
|
||||
public Task NotifyConfigTrafficUpdatedAsync(
|
||||
Guid userId, Guid configId, long usedUpBytes, long usedDownBytes, CancellationToken cancellationToken)
|
||||
Guid userId,
|
||||
Guid configId,
|
||||
long usedUpBytes,
|
||||
long usedDownBytes,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
return hubContext.Clients.Group(GroupNames.User(userId)).SendAsync(
|
||||
"configTrafficUpdated",
|
||||
new { configId, usedUpBytes, usedDownBytes },
|
||||
cancellationToken);
|
||||
return hubContext
|
||||
.Clients.Group(GroupNames.User(userId))
|
||||
.SendAsync(
|
||||
"configTrafficUpdated",
|
||||
new
|
||||
{
|
||||
configId,
|
||||
usedUpBytes,
|
||||
usedDownBytes,
|
||||
},
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
public Task NotifyConfigStatusChangedAsync(Guid userId, Guid configId, ConfigStatus status, CancellationToken cancellationToken)
|
||||
public Task NotifyConfigStatusChangedAsync(
|
||||
Guid userId,
|
||||
Guid configId,
|
||||
ConfigStatus status,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
return hubContext.Clients.Group(GroupNames.User(userId)).SendAsync(
|
||||
"configStatusChanged",
|
||||
new { configId, status = status.ToString() },
|
||||
cancellationToken);
|
||||
return hubContext
|
||||
.Clients.Group(GroupNames.User(userId))
|
||||
.SendAsync(
|
||||
"configStatusChanged",
|
||||
new { configId, status = status.ToString() },
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
public Task NotifyNodeStatusChangedAsync(Guid nodeId, NodeStatus status, DateTimeOffset? lastSyncAt, CancellationToken cancellationToken)
|
||||
public Task NotifyNodeStatusChangedAsync(
|
||||
Guid nodeId,
|
||||
NodeStatus status,
|
||||
DateTimeOffset? lastSyncAt,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
return hubContext.Clients.Group(GroupNames.Admins).SendAsync(
|
||||
"nodeStatusChanged",
|
||||
new { nodeId, status = status.ToString(), lastSyncAt },
|
||||
cancellationToken);
|
||||
return hubContext
|
||||
.Clients.Group(GroupNames.Admins)
|
||||
.SendAsync(
|
||||
"nodeStatusChanged",
|
||||
new
|
||||
{
|
||||
nodeId,
|
||||
status = status.ToString(),
|
||||
lastSyncAt,
|
||||
},
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
public 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
|
||||
)
|
||||
{
|
||||
return hubContext.Clients.Group(GroupNames.Admins).SendAsync(
|
||||
"activationRequested",
|
||||
new { requestId, userId, userName, comment, createdAt },
|
||||
cancellationToken);
|
||||
return hubContext
|
||||
.Clients.Group(GroupNames.Admins)
|
||||
.SendAsync(
|
||||
"activationRequested",
|
||||
new
|
||||
{
|
||||
requestId,
|
||||
userId,
|
||||
userName,
|
||||
comment,
|
||||
createdAt,
|
||||
},
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
public Task NotifyUserActivatedAsync(Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
return hubContext.Clients.Group(GroupNames.User(userId)).SendAsync(
|
||||
"userActivated",
|
||||
new { userId },
|
||||
cancellationToken);
|
||||
return hubContext
|
||||
.Clients.Group(GroupNames.User(userId))
|
||||
.SendAsync("userActivated", new { userId }, cancellationToken);
|
||||
}
|
||||
|
||||
public Task NotifyNewsPublishedAsync(Guid postId, string title, DateTimeOffset createdAt, CancellationToken cancellationToken)
|
||||
public Task NotifyNewsPublishedAsync(
|
||||
Guid postId,
|
||||
string title,
|
||||
DateTimeOffset createdAt,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
return hubContext.Clients.All.SendAsync(
|
||||
"newsPublished",
|
||||
new { id = postId, title, createdAt },
|
||||
cancellationToken);
|
||||
new
|
||||
{
|
||||
id = postId,
|
||||
title,
|
||||
createdAt,
|
||||
},
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
public Task NotifyTicketCreatedAsync(Guid ticketId, Guid userId, string userName, TicketType type, CancellationToken cancellationToken)
|
||||
public Task NotifyTicketCreatedAsync(
|
||||
Guid ticketId,
|
||||
Guid userId,
|
||||
string userName,
|
||||
TicketType type,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
return hubContext.Clients.Group(GroupNames.Admins).SendAsync(
|
||||
"ticketCreated",
|
||||
new { ticketId, userId, userName, type = type.ToString() },
|
||||
cancellationToken);
|
||||
return hubContext
|
||||
.Clients.Group(GroupNames.Admins)
|
||||
.SendAsync(
|
||||
"ticketCreated",
|
||||
new
|
||||
{
|
||||
ticketId,
|
||||
userId,
|
||||
userName,
|
||||
type = type.ToString(),
|
||||
},
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
public Task NotifyTicketUpdatedAsync(Guid ticketId, Guid userId, CancellationToken cancellationToken)
|
||||
public Task NotifyTicketUpdatedAsync(
|
||||
Guid ticketId,
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
return hubContext.Clients.Group(GroupNames.User(userId)).SendAsync(
|
||||
"ticketUpdated",
|
||||
new { ticketId },
|
||||
cancellationToken);
|
||||
return hubContext
|
||||
.Clients.Group(GroupNames.User(userId))
|
||||
.SendAsync("ticketUpdated", new { ticketId }, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PnvPanel.Infrastructure\PnvPanel.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\PnvPanel.Application\PnvPanel.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\..\seed\client-apps.json" Link="seed\client-apps.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Content
|
||||
Include="..\..\..\seed\client-apps.json"
|
||||
Link="seed\client-apps.json"
|
||||
CopyToOutputDirectory="PreserveNewest"
|
||||
/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -27,5 +30,4 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -20,10 +20,13 @@ using Telegram.Bot;
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Структурное логирование (Serilog), конфигурация из appsettings/env.
|
||||
builder.Services.AddSerilog((services, configuration) => configuration
|
||||
.ReadFrom.Configuration(builder.Configuration)
|
||||
.ReadFrom.Services(services)
|
||||
.Enrich.FromLogContext());
|
||||
builder.Services.AddSerilog(
|
||||
(services, configuration) =>
|
||||
configuration
|
||||
.ReadFrom.Configuration(builder.Configuration)
|
||||
.ReadFrom.Services(services)
|
||||
.Enrich.FromLogContext()
|
||||
);
|
||||
|
||||
// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно
|
||||
// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя
|
||||
@@ -34,13 +37,25 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||
|
||||
foreach (var proxy in builder.Configuration.GetSection("ForwardedHeaders:KnownProxies").Get<string[]>() ?? [])
|
||||
foreach (
|
||||
var proxy in builder
|
||||
.Configuration.GetSection("ForwardedHeaders:KnownProxies")
|
||||
.Get<string[]>()
|
||||
?? []
|
||||
)
|
||||
options.KnownProxies.Add(IPAddress.Parse(proxy));
|
||||
|
||||
foreach (var network in builder.Configuration.GetSection("ForwardedHeaders:KnownNetworks").Get<string[]>() ?? [])
|
||||
foreach (
|
||||
var network in builder
|
||||
.Configuration.GetSection("ForwardedHeaders:KnownNetworks")
|
||||
.Get<string[]>()
|
||||
?? []
|
||||
)
|
||||
{
|
||||
var parts = network.Split('/');
|
||||
options.KnownIPNetworks.Add(new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1])));
|
||||
options.KnownIPNetworks.Add(
|
||||
new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1]))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -49,6 +64,7 @@ builder.Services.AddApplication();
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
// В Api, не в Infrastructure — реализации нужен IHubContext<PanelHub>, а Hub определён здесь же.
|
||||
builder.Services.AddSingleton<IRealtimeNotifier, SignalRRealtimeNotifier>();
|
||||
|
||||
@@ -74,17 +90,27 @@ builder.Services.AddSingleton<ITelegramBotClient>(sp =>
|
||||
if (!string.IsNullOrEmpty(proxyUri.UserInfo))
|
||||
{
|
||||
var credentials = proxyUri.UserInfo.Split(':', 2);
|
||||
proxy.Credentials = new NetworkCredential(credentials[0], credentials.Length > 1 ? credentials[1] : string.Empty);
|
||||
proxy.Credentials = new NetworkCredential(
|
||||
credentials[0],
|
||||
credentials.Length > 1 ? credentials[1] : string.Empty
|
||||
);
|
||||
}
|
||||
|
||||
sp.GetRequiredService<ILogger<Program>>().LogInformation(
|
||||
"Telegram bot using proxy {Scheme}://{Host}:{Port}", proxyUri.Scheme, proxyUri.Host, proxyUri.Port);
|
||||
sp.GetRequiredService<ILogger<Program>>()
|
||||
.LogInformation(
|
||||
"Telegram bot using proxy {Scheme}://{Host}:{Port}",
|
||||
proxyUri.Scheme,
|
||||
proxyUri.Host,
|
||||
proxyUri.Port
|
||||
);
|
||||
|
||||
var handler = new SocketsHttpHandler { Proxy = proxy, UseProxy = true };
|
||||
return new TelegramBotClient(token, new HttpClient(handler));
|
||||
});
|
||||
|
||||
// Scoped — зависит от IIdentityService (scoped), не Singleton.
|
||||
builder.Services.AddScoped<ITelegramNotifier, TelegramNotifier>();
|
||||
|
||||
// Singleton — кэширует username бота (getMe) на весь процесс, не из ручного env (см. TelegramBotInfo).
|
||||
builder.Services.AddSingleton<ITelegramBotInfo, TelegramBotInfo>();
|
||||
builder.Services.AddSingleton<PnvBotUpdateHandler>();
|
||||
@@ -92,26 +118,32 @@ builder.Services.AddHostedService<TelegramBotHostedService>();
|
||||
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.AddFixedWindowLimiter(RateLimiting.AuthPolicy, limiterOptions =>
|
||||
{
|
||||
// Настраиваемо через конфиг, чтобы интеграционные тесты (общий TestServer/host на весь
|
||||
// collection, все запросы — от одного "клиента") могли поднять лимит и не ловить 429.
|
||||
limiterOptions.PermitLimit = builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 20);
|
||||
limiterOptions.Window = TimeSpan.FromMinutes(1);
|
||||
limiterOptions.QueueLimit = 0;
|
||||
});
|
||||
options.AddFixedWindowLimiter(
|
||||
RateLimiting.AuthPolicy,
|
||||
limiterOptions =>
|
||||
{
|
||||
// Настраиваемо через конфиг, чтобы интеграционные тесты (общий TestServer/host на весь
|
||||
// collection, все запросы — от одного "клиента") могли поднять лимит и не ловить 429.
|
||||
limiterOptions.PermitLimit = builder.Configuration.GetValue(
|
||||
"RateLimiting:AuthPermitLimit",
|
||||
20
|
||||
);
|
||||
limiterOptions.Window = TimeSpan.FromMinutes(1);
|
||||
limiterOptions.QueueLimit = 0;
|
||||
}
|
||||
);
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
});
|
||||
|
||||
// Энумы сериализуются строками ("Vless", "Active", ...), не числами — самодокументируемый JSON,
|
||||
// корректные строковые литералы при генерации TS-типов из OpenAPI-схемы (см. docs/frontend.md).
|
||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())
|
||||
);
|
||||
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddOpenApi();
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddDbContextCheck<AppDbContext>();
|
||||
builder.Services.AddHealthChecks().AddDbContextCheck<AppDbContext>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -120,8 +152,9 @@ var app = builder.Build();
|
||||
if (string.IsNullOrWhiteSpace(builder.Configuration["DataProtection:KeyRingPath"]))
|
||||
{
|
||||
app.Logger.LogWarning(
|
||||
"DataProtection:KeyRingPath is not set — node secret encryption keys are not persistent " +
|
||||
"and will be lost when the container is recreated. Mount a volume and set the path in production.");
|
||||
"DataProtection:KeyRingPath is not set — node secret encryption keys are not persistent "
|
||||
+ "and will be lost when the container is recreated. Mount a volume and set the path in production."
|
||||
);
|
||||
}
|
||||
|
||||
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
|
||||
|
||||
@@ -23,24 +23,41 @@ namespace PnvPanel.Api.Telegram;
|
||||
/// свежие scoped-сервисы (ISender, ICurrentUserSetter, ...). Бот — read-only по конфигам в MVP.
|
||||
/// </summary>
|
||||
public sealed class PnvBotUpdateHandler(
|
||||
IServiceScopeFactory scopeFactory, IOptions<TelegramOptions> options, ILogger<PnvBotUpdateHandler> logger)
|
||||
: IUpdateHandler
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<TelegramOptions> options,
|
||||
ILogger<PnvBotUpdateHandler> logger
|
||||
) : IUpdateHandler
|
||||
{
|
||||
// Показывается везде, где боту нужен привязанный аккаунт, а его нет — явно проговариваем оба шага,
|
||||
// иначе новые пользователи не понимают, что сначала нужен обычный аккаунт на сайте.
|
||||
private const string NotLinkedMessage =
|
||||
"Сначала зарегистрируйтесь и войдите на сайте, затем привяжите Telegram: Настройки → «Привязать Telegram».";
|
||||
|
||||
public async Task HandleUpdateAsync(ITelegramBotClient botClient, Update update, CancellationToken cancellationToken)
|
||||
public async Task HandleUpdateAsync(
|
||||
ITelegramBotClient botClient,
|
||||
Update update,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
|
||||
try
|
||||
{
|
||||
if (update.Message is { Text: { } text } message)
|
||||
await HandleMessageAsync(botClient, scope.ServiceProvider, message, text, cancellationToken);
|
||||
await HandleMessageAsync(
|
||||
botClient,
|
||||
scope.ServiceProvider,
|
||||
message,
|
||||
text,
|
||||
cancellationToken
|
||||
);
|
||||
else if (update.CallbackQuery is { } callback)
|
||||
await HandleCallbackAsync(botClient, scope.ServiceProvider, callback, cancellationToken);
|
||||
await HandleCallbackAsync(
|
||||
botClient,
|
||||
scope.ServiceProvider,
|
||||
callback,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -48,14 +65,24 @@ public sealed class PnvBotUpdateHandler(
|
||||
}
|
||||
}
|
||||
|
||||
public Task HandleErrorAsync(ITelegramBotClient botClient, Exception exception, HandleErrorSource source, CancellationToken cancellationToken)
|
||||
public Task HandleErrorAsync(
|
||||
ITelegramBotClient botClient,
|
||||
Exception exception,
|
||||
HandleErrorSource source,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
logger.LogError(exception, "Telegram bot error (source {Source})", source);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task HandleMessageAsync(
|
||||
ITelegramBotClient botClient, IServiceProvider services, Message message, string text, CancellationToken cancellationToken)
|
||||
ITelegramBotClient botClient,
|
||||
IServiceProvider services,
|
||||
Message message,
|
||||
string text,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var chatId = message.Chat.Id;
|
||||
var fromId = message.From?.Id;
|
||||
@@ -67,11 +94,32 @@ public sealed class PnvBotUpdateHandler(
|
||||
var payload = text.Length > 7 ? text[7..].Trim() : string.Empty;
|
||||
|
||||
if (payload.StartsWith("link_", StringComparison.Ordinal))
|
||||
await HandleLinkAsync(botClient, services, chatId, fromId.Value, message.From?.Username, payload[5..], cancellationToken);
|
||||
await HandleLinkAsync(
|
||||
botClient,
|
||||
services,
|
||||
chatId,
|
||||
fromId.Value,
|
||||
message.From?.Username,
|
||||
payload[5..],
|
||||
cancellationToken
|
||||
);
|
||||
else if (payload.StartsWith("login_", StringComparison.Ordinal))
|
||||
await HandleLoginPromptAsync(botClient, services, chatId, fromId.Value, payload[6..], cancellationToken);
|
||||
await HandleLoginPromptAsync(
|
||||
botClient,
|
||||
services,
|
||||
chatId,
|
||||
fromId.Value,
|
||||
payload[6..],
|
||||
cancellationToken
|
||||
);
|
||||
else
|
||||
await SendWelcomeAsync(botClient, services, chatId, fromId.Value, cancellationToken);
|
||||
await SendWelcomeAsync(
|
||||
botClient,
|
||||
services,
|
||||
chatId,
|
||||
fromId.Value,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -79,25 +127,57 @@ public sealed class PnvBotUpdateHandler(
|
||||
switch (text)
|
||||
{
|
||||
case "/configs":
|
||||
await HandleConfigsAsync(botClient, services, chatId, fromId.Value, cancellationToken);
|
||||
await HandleConfigsAsync(
|
||||
botClient,
|
||||
services,
|
||||
chatId,
|
||||
fromId.Value,
|
||||
cancellationToken
|
||||
);
|
||||
break;
|
||||
case "/unlink":
|
||||
await HandleUnlinkAsync(botClient, services, chatId, fromId.Value, cancellationToken);
|
||||
await HandleUnlinkAsync(
|
||||
botClient,
|
||||
services,
|
||||
chatId,
|
||||
fromId.Value,
|
||||
cancellationToken
|
||||
);
|
||||
break;
|
||||
case "/requests":
|
||||
await HandleRequestsAsync(botClient, services, chatId, fromId.Value, cancellationToken);
|
||||
await HandleRequestsAsync(
|
||||
botClient,
|
||||
services,
|
||||
chatId,
|
||||
fromId.Value,
|
||||
cancellationToken
|
||||
);
|
||||
break;
|
||||
case "/help":
|
||||
await SendWelcomeAsync(botClient, services, chatId, fromId.Value, cancellationToken);
|
||||
await SendWelcomeAsync(
|
||||
botClient,
|
||||
services,
|
||||
chatId,
|
||||
fromId.Value,
|
||||
cancellationToken
|
||||
);
|
||||
break;
|
||||
default:
|
||||
await botClient.SendMessage(chatId, "Не понимаю эту команду. /help — список команд.", cancellationToken: cancellationToken);
|
||||
await botClient.SendMessage(
|
||||
chatId,
|
||||
"Не понимаю эту команду. /help — список команд.",
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleCallbackAsync(
|
||||
ITelegramBotClient botClient, IServiceProvider services, CallbackQuery callback, CancellationToken cancellationToken)
|
||||
ITelegramBotClient botClient,
|
||||
IServiceProvider services,
|
||||
CallbackQuery callback,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var data = callback.Data;
|
||||
var chatId = callback.Message?.Chat.Id;
|
||||
@@ -107,7 +187,15 @@ public sealed class PnvBotUpdateHandler(
|
||||
|
||||
if (data == "reg:new")
|
||||
{
|
||||
await HandleRegisterCallbackAsync(botClient, services, chatId.Value, fromId, callback.From.Username, callback.Id, cancellationToken);
|
||||
await HandleRegisterCallbackAsync(
|
||||
botClient,
|
||||
services,
|
||||
chatId.Value,
|
||||
fromId,
|
||||
callback.From.Username,
|
||||
callback.Id,
|
||||
cancellationToken
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -115,15 +203,28 @@ public sealed class PnvBotUpdateHandler(
|
||||
{
|
||||
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
|
||||
{
|
||||
await botClient.AnswerCallbackQuery(callback.Id, "Telegram не привязан.", cancellationToken: cancellationToken);
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
"Telegram не привязан.",
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await botClient.AnswerCallbackQuery(callback.Id, cancellationToken: cancellationToken);
|
||||
|
||||
var (configsText, configsKeyboard) = await BuildConfigsMenuAsync(services, cancellationToken);
|
||||
var (configsText, configsKeyboard) = await BuildConfigsMenuAsync(
|
||||
services,
|
||||
cancellationToken
|
||||
);
|
||||
if (callback.Message is not null)
|
||||
await botClient.EditMessageText(chatId.Value, callback.Message.Id, configsText, replyMarkup: configsKeyboard, cancellationToken: cancellationToken);
|
||||
await botClient.EditMessageText(
|
||||
chatId.Value,
|
||||
callback.Message.Id,
|
||||
configsText,
|
||||
replyMarkup: configsKeyboard,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -135,9 +236,18 @@ public sealed class PnvBotUpdateHandler(
|
||||
return;
|
||||
|
||||
var identityService = services.GetRequiredService<IIdentityService>();
|
||||
var linkedUserId = await identityService.FindUserIdByTelegramUserIdAsync(fromId, cancellationToken);
|
||||
var linkedUserId = await identityService.FindUserIdByTelegramUserIdAsync(
|
||||
fromId,
|
||||
cancellationToken
|
||||
);
|
||||
var (menuText, menuKeyboard) = BuildMainMenu(isLinked: linkedUserId is not null);
|
||||
await botClient.EditMessageText(chatId.Value, callback.Message.Id, menuText, replyMarkup: menuKeyboard, cancellationToken: cancellationToken);
|
||||
await botClient.EditMessageText(
|
||||
chatId.Value,
|
||||
callback.Message.Id,
|
||||
menuText,
|
||||
replyMarkup: menuKeyboard,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -146,12 +256,19 @@ public sealed class PnvBotUpdateHandler(
|
||||
{
|
||||
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
|
||||
{
|
||||
await botClient.AnswerCallbackQuery(callback.Id, "Telegram не привязан.", cancellationToken: cancellationToken);
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
"Telegram не привязан.",
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var unlinkSender = services.GetRequiredService<ISender>();
|
||||
var unlinkResult = await unlinkSender.Send(new UnlinkTelegramCommand(), cancellationToken);
|
||||
var unlinkResult = await unlinkSender.Send(
|
||||
new UnlinkTelegramCommand(),
|
||||
cancellationToken
|
||||
);
|
||||
await botClient.AnswerCallbackQuery(callback.Id, cancellationToken: cancellationToken);
|
||||
|
||||
if (callback.Message is null)
|
||||
@@ -160,15 +277,23 @@ public sealed class PnvBotUpdateHandler(
|
||||
if (!unlinkResult.IsSuccess)
|
||||
{
|
||||
await botClient.EditMessageText(
|
||||
chatId.Value, callback.Message.Id, $"❌ Ошибка: {unlinkResult.Error.Message}",
|
||||
replyMarkup: BackToMenuKeyboard(), cancellationToken: cancellationToken);
|
||||
chatId.Value,
|
||||
callback.Message.Id,
|
||||
$"❌ Ошибка: {unlinkResult.Error.Message}",
|
||||
replyMarkup: BackToMenuKeyboard(),
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var (unlinkedText, unlinkedKeyboard) = BuildMainMenu(isLinked: false);
|
||||
await botClient.EditMessageText(
|
||||
chatId.Value, callback.Message.Id, "✅ Telegram отвязан от аккаунта.\n\n" + unlinkedText,
|
||||
replyMarkup: unlinkedKeyboard, cancellationToken: cancellationToken);
|
||||
chatId.Value,
|
||||
callback.Message.Id,
|
||||
"✅ Telegram отвязан от аккаунта.\n\n" + unlinkedText,
|
||||
replyMarkup: unlinkedKeyboard,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -183,11 +308,22 @@ public sealed class PnvBotUpdateHandler(
|
||||
{
|
||||
case "login":
|
||||
{
|
||||
var result = parts[1] == "approve"
|
||||
? await sender.Send(new ApproveTelegramLoginCommand(requestId, fromId), cancellationToken)
|
||||
: await sender.Send(new RejectTelegramLoginCommand(requestId, fromId), cancellationToken);
|
||||
var result =
|
||||
parts[1] == "approve"
|
||||
? await sender.Send(
|
||||
new ApproveTelegramLoginCommand(requestId, fromId),
|
||||
cancellationToken
|
||||
)
|
||||
: await sender.Send(
|
||||
new RejectTelegramLoginCommand(requestId, fromId),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
await botClient.AnswerCallbackQuery(callback.Id, result.IsSuccess ? "Готово" : result.Error.Message, cancellationToken: cancellationToken);
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
result.IsSuccess ? "Готово" : result.Error.Message,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
if (callback.Message is not null)
|
||||
{
|
||||
var statusText = result.IsSuccess
|
||||
@@ -195,7 +331,12 @@ public sealed class PnvBotUpdateHandler(
|
||||
: $"⚠️ {result.Error.Message}";
|
||||
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
|
||||
await botClient.EditMessageText(
|
||||
chatId.Value, callback.Message.Id, text, parseMode: ParseMode.Html, cancellationToken: cancellationToken);
|
||||
chatId.Value,
|
||||
callback.Message.Id,
|
||||
text,
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -204,26 +345,50 @@ public sealed class PnvBotUpdateHandler(
|
||||
{
|
||||
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
|
||||
{
|
||||
await botClient.AnswerCallbackQuery(callback.Id, "Недостаточно прав.", cancellationToken: cancellationToken);
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
"Недостаточно прав.",
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var result = parts[1] == "approve"
|
||||
? await sender.Send(new ApproveActivationCommand(requestId), cancellationToken)
|
||||
: await sender.Send(new RejectActivationCommand(requestId, Reason: null), cancellationToken);
|
||||
var result =
|
||||
parts[1] == "approve"
|
||||
? await sender.Send(
|
||||
new ApproveActivationCommand(requestId),
|
||||
cancellationToken
|
||||
)
|
||||
: await sender.Send(
|
||||
new RejectActivationCommand(requestId, Reason: null),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
await botClient.AnswerCallbackQuery(callback.Id, result.IsSuccess ? "Готово" : result.Error.Message, cancellationToken: cancellationToken);
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
result.IsSuccess ? "Готово" : result.Error.Message,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
if (callback.Message is not null)
|
||||
{
|
||||
// Редактируем исходное сообщение с запросом вместо отдельного — иначе кнопки
|
||||
// «Активировать/Отклонить» остаются висеть под уже обработанным запросом (в т.ч.
|
||||
// если его обработали в другом месте — на сайте или из другого чата).
|
||||
var statusText = result.IsSuccess
|
||||
? (parts[1] == "approve" ? "✅ Пользователь активирован." : "❌ Запрос отклонён.")
|
||||
? (
|
||||
parts[1] == "approve"
|
||||
? "✅ Пользователь активирован."
|
||||
: "❌ Запрос отклонён."
|
||||
)
|
||||
: $"⚠️ {result.Error.Message}";
|
||||
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
|
||||
await botClient.EditMessageText(
|
||||
chatId.Value, callback.Message.Id, text, parseMode: ParseMode.Html, cancellationToken: cancellationToken);
|
||||
chatId.Value,
|
||||
callback.Message.Id,
|
||||
text,
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -232,23 +397,47 @@ public sealed class PnvBotUpdateHandler(
|
||||
{
|
||||
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
|
||||
{
|
||||
await botClient.AnswerCallbackQuery(callback.Id, "Недостаточно прав.", cancellationToken: cancellationToken);
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
"Недостаточно прав.",
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var result = parts[1] == "approve"
|
||||
? await sender.Send(new ApproveRoleRequestCommand(requestId), cancellationToken)
|
||||
: await sender.Send(new RejectRoleRequestCommand(requestId, Reason: null), cancellationToken);
|
||||
var result =
|
||||
parts[1] == "approve"
|
||||
? await sender.Send(
|
||||
new ApproveRoleRequestCommand(requestId),
|
||||
cancellationToken
|
||||
)
|
||||
: await sender.Send(
|
||||
new RejectRoleRequestCommand(requestId, Reason: null),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
await botClient.AnswerCallbackQuery(callback.Id, result.IsSuccess ? "Готово" : result.Error.Message, cancellationToken: cancellationToken);
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
result.IsSuccess ? "Готово" : result.Error.Message,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
if (callback.Message is not null)
|
||||
{
|
||||
var statusText = result.IsSuccess
|
||||
? (parts[1] == "approve" ? "✅ Заявка одобрена, роль выдана." : "❌ Заявка отклонена.")
|
||||
? (
|
||||
parts[1] == "approve"
|
||||
? "✅ Заявка одобрена, роль выдана."
|
||||
: "❌ Заявка отклонена."
|
||||
)
|
||||
: $"⚠️ {result.Error.Message}";
|
||||
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
|
||||
await botClient.EditMessageText(
|
||||
chatId.Value, callback.Message.Id, text, parseMode: ParseMode.Html, cancellationToken: cancellationToken);
|
||||
chatId.Value,
|
||||
callback.Message.Id,
|
||||
text,
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -257,16 +446,30 @@ public sealed class PnvBotUpdateHandler(
|
||||
{
|
||||
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
|
||||
{
|
||||
await botClient.AnswerCallbackQuery(callback.Id, "Telegram не привязан.", cancellationToken: cancellationToken);
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
"Telegram не привязан.",
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var linkResult = await sender.Send(new GetConfigLinkQuery(requestId), cancellationToken);
|
||||
await botClient.AnswerCallbackQuery(callback.Id, cancellationToken: cancellationToken);
|
||||
var linkResult = await sender.Send(
|
||||
new GetConfigLinkQuery(requestId),
|
||||
cancellationToken
|
||||
);
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
|
||||
if (!linkResult.IsSuccess)
|
||||
{
|
||||
await botClient.SendMessage(chatId.Value, $"Не удалось получить ссылку: {linkResult.Error.Message}", cancellationToken: cancellationToken);
|
||||
await botClient.SendMessage(
|
||||
chatId.Value,
|
||||
$"Не удалось получить ссылку: {linkResult.Error.Message}",
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -276,15 +479,22 @@ public sealed class PnvBotUpdateHandler(
|
||||
// Редактируем то же сообщение (не плодим отдельное с сырым URL) — ссылка моноширинным
|
||||
// блоком, по нему в Telegram можно тапнуть и скопировать целиком одним движением.
|
||||
// Убираем именно эту кнопку из клавиатуры — остальные конфиги и «В меню» остаются на месте.
|
||||
var text = $"{Escape(callback.Message.Text ?? "")}\n\n<code>{Escape(linkResult.Value.ConnectionString)}</code>";
|
||||
var text =
|
||||
$"{Escape(callback.Message.Text ?? "")}\n\n<code>{Escape(linkResult.Value.ConnectionString)}</code>";
|
||||
var remainingRows = (callback.Message.ReplyMarkup?.InlineKeyboard ?? [])
|
||||
.Where(row => row.All(b => b.CallbackData != data))
|
||||
.ToArray();
|
||||
|
||||
await botClient.EditMessageText(
|
||||
chatId.Value, callback.Message.Id, text, parseMode: ParseMode.Html,
|
||||
replyMarkup: remainingRows.Length > 0 ? new InlineKeyboardMarkup(remainingRows) : null,
|
||||
cancellationToken: cancellationToken);
|
||||
chatId.Value,
|
||||
callback.Message.Id,
|
||||
text,
|
||||
parseMode: ParseMode.Html,
|
||||
replyMarkup: remainingRows.Length > 0
|
||||
? new InlineKeyboardMarkup(remainingRows)
|
||||
: null,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
@@ -292,11 +502,20 @@ public sealed class PnvBotUpdateHandler(
|
||||
}
|
||||
|
||||
private async Task HandleLinkAsync(
|
||||
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, string? username, string token,
|
||||
CancellationToken cancellationToken)
|
||||
ITelegramBotClient botClient,
|
||||
IServiceProvider services,
|
||||
long chatId,
|
||||
long fromId,
|
||||
string? username,
|
||||
string token,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var sender = services.GetRequiredService<ISender>();
|
||||
var result = await sender.Send(new LinkTelegramCommand(token, fromId, username), cancellationToken);
|
||||
var result = await sender.Send(
|
||||
new LinkTelegramCommand(token, fromId, username),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var text = result.IsSuccess
|
||||
? "✅ Telegram успешно привязан к вашему аккаунту."
|
||||
@@ -306,56 +525,103 @@ public sealed class PnvBotUpdateHandler(
|
||||
}
|
||||
|
||||
private async Task HandleLoginPromptAsync(
|
||||
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, string requestIdRaw, CancellationToken cancellationToken)
|
||||
ITelegramBotClient botClient,
|
||||
IServiceProvider services,
|
||||
long chatId,
|
||||
long fromId,
|
||||
string requestIdRaw,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (!Guid.TryParse(requestIdRaw, out var requestId))
|
||||
{
|
||||
await botClient.SendMessage(chatId, "Некорректная ссылка входа.", cancellationToken: cancellationToken);
|
||||
await botClient.SendMessage(
|
||||
chatId,
|
||||
"Некорректная ссылка входа.",
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var identityService = services.GetRequiredService<IIdentityService>();
|
||||
var userId = await identityService.FindUserIdByTelegramUserIdAsync(fromId, cancellationToken);
|
||||
var userId = await identityService.FindUserIdByTelegramUserIdAsync(
|
||||
fromId,
|
||||
cancellationToken
|
||||
);
|
||||
if (userId is null)
|
||||
{
|
||||
await botClient.SendMessage(
|
||||
chatId, NotLinkedMessage,
|
||||
replyMarkup: new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new") }),
|
||||
cancellationToken: cancellationToken);
|
||||
chatId,
|
||||
NotLinkedMessage,
|
||||
replyMarkup: new InlineKeyboardMarkup(
|
||||
new[]
|
||||
{
|
||||
InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new"),
|
||||
}
|
||||
),
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var keyboard = new InlineKeyboardMarkup(new[]
|
||||
{
|
||||
InlineKeyboardButton.WithCallbackData("✅ Подтвердить вход", $"login:approve:{requestId}"),
|
||||
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"login:reject:{requestId}"),
|
||||
});
|
||||
var keyboard = new InlineKeyboardMarkup(
|
||||
new[]
|
||||
{
|
||||
InlineKeyboardButton.WithCallbackData(
|
||||
"✅ Подтвердить вход",
|
||||
$"login:approve:{requestId}"
|
||||
),
|
||||
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"login:reject:{requestId}"),
|
||||
}
|
||||
);
|
||||
|
||||
await botClient.SendMessage(
|
||||
chatId, "Кто-то пытается войти в PnvPanel через ваш аккаунт. Подтвердить вход?",
|
||||
replyMarkup: keyboard, cancellationToken: cancellationToken);
|
||||
chatId,
|
||||
"Кто-то пытается войти в PnvPanel через ваш аккаунт. Подтвердить вход?",
|
||||
replyMarkup: keyboard,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
private async Task HandleConfigsAsync(
|
||||
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken)
|
||||
ITelegramBotClient botClient,
|
||||
IServiceProvider services,
|
||||
long chatId,
|
||||
long fromId,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
|
||||
{
|
||||
await botClient.SendMessage(
|
||||
chatId, NotLinkedMessage,
|
||||
replyMarkup: new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new") }),
|
||||
cancellationToken: cancellationToken);
|
||||
chatId,
|
||||
NotLinkedMessage,
|
||||
replyMarkup: new InlineKeyboardMarkup(
|
||||
new[]
|
||||
{
|
||||
InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new"),
|
||||
}
|
||||
),
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var (text, keyboard) = await BuildConfigsMenuAsync(services, cancellationToken);
|
||||
await botClient.SendMessage(chatId, text, replyMarkup: keyboard, cancellationToken: cancellationToken);
|
||||
await botClient.SendMessage(
|
||||
chatId,
|
||||
text,
|
||||
replyMarkup: keyboard,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Список конфигов одним сообщением: строка на конфиг + кнопка «🔗 {Label}» на каждый
|
||||
/// не отозванный, плюс «🔙 В меню» внизу.</summary>
|
||||
private static async Task<(string Text, InlineKeyboardMarkup Keyboard)> BuildConfigsMenuAsync(
|
||||
IServiceProvider services, CancellationToken cancellationToken)
|
||||
IServiceProvider services,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var sender = services.GetRequiredService<ISender>();
|
||||
var result = await sender.Send(new GetMyConfigsQuery(), cancellationToken);
|
||||
@@ -363,29 +629,53 @@ public sealed class PnvBotUpdateHandler(
|
||||
if (!result.IsSuccess || result.Value.Configs.Count == 0)
|
||||
return ("У вас пока нет конфигов.", BackToMenuKeyboard());
|
||||
|
||||
var text = "Ваши конфиги:\n" + string.Join('\n', result.Value.Configs.Select(c =>
|
||||
$"• {c.Label ?? c.Location} ({c.Protocol}) — {c.Status}"));
|
||||
var text =
|
||||
"Ваши конфиги:\n"
|
||||
+ string.Join(
|
||||
'\n',
|
||||
result.Value.Configs.Select(c =>
|
||||
$"• {c.Label ?? c.Location} ({c.Protocol}) — {c.Status}"
|
||||
)
|
||||
);
|
||||
|
||||
// Отозванному конфигу нечего показывать — кнопку не даём.
|
||||
var rows = result.Value.Configs
|
||||
.Where(c => c.Status != ConfigStatus.Revoked)
|
||||
.Select(c => new[] { InlineKeyboardButton.WithCallbackData($"🔗 {c.Label ?? c.Location}", $"cfg:link:{c.Id}") })
|
||||
var rows = result
|
||||
.Value.Configs.Where(c => c.Status != ConfigStatus.Revoked)
|
||||
.Select(c =>
|
||||
new[]
|
||||
{
|
||||
InlineKeyboardButton.WithCallbackData(
|
||||
$"🔗 {c.Label ?? c.Location}",
|
||||
$"cfg:link:{c.Id}"
|
||||
),
|
||||
}
|
||||
)
|
||||
.Append(BackToMenuRow())
|
||||
.ToArray();
|
||||
|
||||
return (text, new InlineKeyboardMarkup(rows));
|
||||
}
|
||||
|
||||
private static InlineKeyboardButton[] BackToMenuRow() => new[] { InlineKeyboardButton.WithCallbackData("🔙 В меню", "menu:back") };
|
||||
private static InlineKeyboardButton[] BackToMenuRow() =>
|
||||
new[] { InlineKeyboardButton.WithCallbackData("🔙 В меню", "menu:back") };
|
||||
|
||||
private static InlineKeyboardMarkup BackToMenuKeyboard() => new(new[] { BackToMenuRow() });
|
||||
|
||||
private async Task HandleUnlinkAsync(
|
||||
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken)
|
||||
ITelegramBotClient botClient,
|
||||
IServiceProvider services,
|
||||
long chatId,
|
||||
long fromId,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
|
||||
{
|
||||
await botClient.SendMessage(chatId, "Telegram не привязан.", cancellationToken: cancellationToken);
|
||||
await botClient.SendMessage(
|
||||
chatId,
|
||||
"Telegram не привязан.",
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -393,105 +683,191 @@ public sealed class PnvBotUpdateHandler(
|
||||
var result = await sender.Send(new UnlinkTelegramCommand(), cancellationToken);
|
||||
|
||||
await botClient.SendMessage(
|
||||
chatId, result.IsSuccess ? "Telegram отвязан от аккаунта." : $"Ошибка: {result.Error.Message}",
|
||||
cancellationToken: cancellationToken);
|
||||
chatId,
|
||||
result.IsSuccess ? "Telegram отвязан от аккаунта." : $"Ошибка: {result.Error.Message}",
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
private async Task HandleRequestsAsync(
|
||||
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken)
|
||||
ITelegramBotClient botClient,
|
||||
IServiceProvider services,
|
||||
long chatId,
|
||||
long fromId,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
|
||||
{
|
||||
await botClient.SendMessage(chatId, "Недостаточно прав.", cancellationToken: cancellationToken);
|
||||
await botClient.SendMessage(
|
||||
chatId,
|
||||
"Недостаточно прав.",
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var sender = services.GetRequiredService<ISender>();
|
||||
var result = await sender.Send(new ListActivationRequestsQuery(ActivationStatus.Pending, 1, 10), cancellationToken);
|
||||
var result = await sender.Send(
|
||||
new ListActivationRequestsQuery(ActivationStatus.Pending, 1, 10),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (!result.IsSuccess || result.Value.Items.Count == 0)
|
||||
{
|
||||
await botClient.SendMessage(chatId, "Нет ожидающих запросов на активацию.", cancellationToken: cancellationToken);
|
||||
await botClient.SendMessage(
|
||||
chatId,
|
||||
"Нет ожидающих запросов на активацию.",
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var item in result.Value.Items)
|
||||
{
|
||||
var text = $"Запрос от <b>{item.UserName}</b>" + (string.IsNullOrWhiteSpace(item.Comment) ? "" : $"\n{item.Comment}");
|
||||
var keyboard = new InlineKeyboardMarkup(new[]
|
||||
{
|
||||
InlineKeyboardButton.WithCallbackData("✅ Активировать", $"act:approve:{item.Id}"),
|
||||
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"act:reject:{item.Id}"),
|
||||
});
|
||||
await botClient.SendMessage(chatId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken);
|
||||
var text =
|
||||
$"Запрос от <b>{item.UserName}</b>"
|
||||
+ (string.IsNullOrWhiteSpace(item.Comment) ? "" : $"\n{item.Comment}");
|
||||
var keyboard = new InlineKeyboardMarkup(
|
||||
new[]
|
||||
{
|
||||
InlineKeyboardButton.WithCallbackData(
|
||||
"✅ Активировать",
|
||||
$"act:approve:{item.Id}"
|
||||
),
|
||||
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"act:reject:{item.Id}"),
|
||||
}
|
||||
);
|
||||
await botClient.SendMessage(
|
||||
chatId,
|
||||
text,
|
||||
parseMode: ParseMode.Html,
|
||||
replyMarkup: keyboard,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendWelcomeAsync(
|
||||
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken)
|
||||
ITelegramBotClient botClient,
|
||||
IServiceProvider services,
|
||||
long chatId,
|
||||
long fromId,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var identityService = services.GetRequiredService<IIdentityService>();
|
||||
var userId = await identityService.FindUserIdByTelegramUserIdAsync(fromId, cancellationToken);
|
||||
var userId = await identityService.FindUserIdByTelegramUserIdAsync(
|
||||
fromId,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var (text, keyboard) = BuildMainMenu(isLinked: userId is not null);
|
||||
await botClient.SendMessage(chatId, text, replyMarkup: keyboard, cancellationToken: cancellationToken);
|
||||
await botClient.SendMessage(
|
||||
chatId,
|
||||
text,
|
||||
replyMarkup: keyboard,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Привязанному аккаунту — кнопки-действия вместо текстовых команд; непривязанному —
|
||||
/// только регистрация (остальное ему всё равно недоступно). Кнопка на сайт — если задан PublicSiteUrl.</summary>
|
||||
private (string Text, InlineKeyboardMarkup Keyboard) BuildMainMenu(bool isLinked)
|
||||
{
|
||||
const string text = "Привет! Это бот PnvPanel.\n\n"
|
||||
const string text =
|
||||
"Привет! Это бот PnvPanel.\n\n"
|
||||
+ "Вход без пароля запускается кнопкой «Войти через Telegram» на сайте — бот пришлёт запрос на подтверждение.";
|
||||
|
||||
var rows = new List<InlineKeyboardButton[]>();
|
||||
if (isLinked)
|
||||
{
|
||||
rows.Add(new[] { InlineKeyboardButton.WithCallbackData("📋 Мои конфиги", "menu:configs") });
|
||||
rows.Add(new[] { InlineKeyboardButton.WithCallbackData("🔓 Отвязать Telegram", "menu:unlink") });
|
||||
rows.Add(
|
||||
new[] { InlineKeyboardButton.WithCallbackData("📋 Мои конфиги", "menu:configs") }
|
||||
);
|
||||
rows.Add(
|
||||
new[]
|
||||
{
|
||||
InlineKeyboardButton.WithCallbackData("🔓 Отвязать Telegram", "menu:unlink"),
|
||||
}
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
rows.Add(new[] { InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new") });
|
||||
rows.Add(
|
||||
new[] { InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new") }
|
||||
);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
|
||||
rows.Add(new[] { InlineKeyboardButton.WithUrl("🌐 Сайт панели", options.Value.PublicSiteUrl) });
|
||||
rows.Add(
|
||||
new[]
|
||||
{
|
||||
InlineKeyboardButton.WithUrl("🌐 Сайт панели", options.Value.PublicSiteUrl),
|
||||
}
|
||||
);
|
||||
|
||||
return (text, new InlineKeyboardMarkup(rows));
|
||||
}
|
||||
|
||||
private static async Task HandleRegisterCallbackAsync(
|
||||
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, string? username, string callbackId,
|
||||
CancellationToken cancellationToken)
|
||||
ITelegramBotClient botClient,
|
||||
IServiceProvider services,
|
||||
long chatId,
|
||||
long fromId,
|
||||
string? username,
|
||||
string callbackId,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var sender = services.GetRequiredService<ISender>();
|
||||
var result = await sender.Send(new RegisterViaTelegramCommand(fromId, username), cancellationToken);
|
||||
var result = await sender.Send(
|
||||
new RegisterViaTelegramCommand(fromId, username),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
await botClient.AnswerCallbackQuery(callbackId, cancellationToken: cancellationToken);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
await botClient.SendMessage(chatId, $"Не удалось зарегистрироваться: {result.Error.Message}", cancellationToken: cancellationToken);
|
||||
await botClient.SendMessage(
|
||||
chatId,
|
||||
$"Не удалось зарегистрироваться: {result.Error.Message}",
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var text = "✅ Аккаунт создан.\n\n"
|
||||
var text =
|
||||
"✅ Аккаунт создан.\n\n"
|
||||
+ $"Логин: <code>{result.Value.UserName}</code>\n"
|
||||
+ $"Пароль: <code>{result.Value.Password}</code>\n\n"
|
||||
+ "Сохраните пароль — он присылается только один раз. Логин можно сменить в Настройках на сайте.\n\n"
|
||||
+ "Дальше нужно дождаться активации администратором — после неё будут доступны конфиги. "
|
||||
+ "Входить можно как по паролю, так и кнопкой «Войти через Telegram».";
|
||||
|
||||
await botClient.SendMessage(chatId, text, parseMode: ParseMode.Html, cancellationToken: cancellationToken);
|
||||
await botClient.SendMessage(
|
||||
chatId,
|
||||
text,
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
private static string Escape(string text) => text.Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
||||
private static string Escape(string text) =>
|
||||
text.Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
||||
|
||||
private static async Task<bool> TrySetCurrentUserAsync(IServiceProvider services, long telegramUserId, CancellationToken cancellationToken)
|
||||
private static async Task<bool> TrySetCurrentUserAsync(
|
||||
IServiceProvider services,
|
||||
long telegramUserId,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var identityService = services.GetRequiredService<IIdentityService>();
|
||||
var userId = await identityService.FindUserIdByTelegramUserIdAsync(telegramUserId, cancellationToken);
|
||||
var userId = await identityService.FindUserIdByTelegramUserIdAsync(
|
||||
telegramUserId,
|
||||
cancellationToken
|
||||
);
|
||||
if (userId is null)
|
||||
return false;
|
||||
|
||||
@@ -503,7 +879,11 @@ public sealed class PnvBotUpdateHandler(
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<bool> TrySetAdminCurrentUserAsync(IServiceProvider services, long telegramUserId, CancellationToken cancellationToken)
|
||||
private async Task<bool> TrySetAdminCurrentUserAsync(
|
||||
IServiceProvider services,
|
||||
long telegramUserId,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (!options.Value.ParseAdminTelegramUserIds().Contains(telegramUserId))
|
||||
return false;
|
||||
|
||||
@@ -13,9 +13,11 @@ namespace PnvPanel.Api.Telegram;
|
||||
/// вызывает те же CQRS-команды, что и веб, через собственный ISender.
|
||||
/// </summary>
|
||||
public sealed class TelegramBotHostedService(
|
||||
ITelegramBotClient botClient, PnvBotUpdateHandler updateHandler, IOptions<TelegramOptions> options,
|
||||
ILogger<TelegramBotHostedService> logger)
|
||||
: BackgroundService
|
||||
ITelegramBotClient botClient,
|
||||
PnvBotUpdateHandler updateHandler,
|
||||
IOptions<TelegramOptions> options,
|
||||
ILogger<TelegramBotHostedService> logger
|
||||
) : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(30);
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@ using Telegram.Bot;
|
||||
namespace PnvPanel.Api.Telegram;
|
||||
|
||||
/// <summary>Кэширует username бота на время жизни процесса (getMe не меняется, повторный запрос не нужен).</summary>
|
||||
internal sealed class TelegramBotInfo(ITelegramBotClient botClient, IOptions<TelegramOptions> options) : ITelegramBotInfo
|
||||
internal sealed class TelegramBotInfo(
|
||||
ITelegramBotClient botClient,
|
||||
IOptions<TelegramOptions> options
|
||||
) : ITelegramBotInfo
|
||||
{
|
||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||
private string? _cachedUsername;
|
||||
|
||||
@@ -8,30 +8,49 @@ using Telegram.Bot.Types.ReplyMarkups;
|
||||
|
||||
namespace PnvPanel.Api.Telegram;
|
||||
|
||||
internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentityService identityService, IOptions<TelegramOptions> options)
|
||||
: ITelegramNotifier
|
||||
internal sealed class TelegramNotifier(
|
||||
ITelegramBotClient botClient,
|
||||
IIdentityService identityService,
|
||||
IOptions<TelegramOptions> options
|
||||
) : ITelegramNotifier
|
||||
{
|
||||
public async Task NotifyAdminsActivationRequestedAsync(
|
||||
Guid requestId, string userName, string? comment, CancellationToken cancellationToken)
|
||||
Guid requestId,
|
||||
string userName,
|
||||
string? comment,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||||
return;
|
||||
|
||||
var text = $"🆕 Запрос на активацию от <b>{Escape(userName)}</b>"
|
||||
+ (string.IsNullOrWhiteSpace(comment) ? string.Empty : $"\nКомментарий: {Escape(comment)}");
|
||||
var text =
|
||||
$"🆕 Запрос на активацию от <b>{Escape(userName)}</b>"
|
||||
+ (
|
||||
string.IsNullOrWhiteSpace(comment)
|
||||
? string.Empty
|
||||
: $"\nКомментарий: {Escape(comment)}"
|
||||
);
|
||||
|
||||
var keyboard = new InlineKeyboardMarkup(new[]
|
||||
{
|
||||
InlineKeyboardButton.WithCallbackData("✅ Активировать", $"act:approve:{requestId}"),
|
||||
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"act:reject:{requestId}"),
|
||||
});
|
||||
var keyboard = new InlineKeyboardMarkup(
|
||||
new[]
|
||||
{
|
||||
InlineKeyboardButton.WithCallbackData("✅ Активировать", $"act:approve:{requestId}"),
|
||||
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"act:reject:{requestId}"),
|
||||
}
|
||||
);
|
||||
|
||||
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
|
||||
{
|
||||
try
|
||||
{
|
||||
await botClient.SendMessage(
|
||||
adminId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken);
|
||||
adminId,
|
||||
text,
|
||||
parseMode: ParseMode.Html,
|
||||
replyMarkup: keyboard,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -40,20 +59,28 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
|
||||
}
|
||||
}
|
||||
|
||||
public async Task NotifyAdminsBugReportCreatedAsync(Guid ticketId, string userName, string message, CancellationToken cancellationToken)
|
||||
public async Task NotifyAdminsBugReportCreatedAsync(
|
||||
Guid ticketId,
|
||||
string userName,
|
||||
string message,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||||
return;
|
||||
|
||||
var preview = message.Length > 300 ? message[..300] + "…" : message;
|
||||
var text = $"🐞 Новый тикет (баг/предложение) от <b>{Escape(userName)}</b>\n{Escape(preview)}";
|
||||
var text =
|
||||
$"🐞 Новый тикет (баг/предложение) от <b>{Escape(userName)}</b>\n{Escape(preview)}";
|
||||
|
||||
// Только ссылка на сайт — переписка и картинки удобнее там, инлайн-действий для баг-тикетов нет.
|
||||
InlineKeyboardMarkup? keyboard = null;
|
||||
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
|
||||
{
|
||||
var url = $"{options.Value.PublicSiteUrl.TrimEnd('/')}/admin/support?ticket={ticketId}";
|
||||
keyboard = new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) });
|
||||
keyboard = new InlineKeyboardMarkup(
|
||||
new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) }
|
||||
);
|
||||
}
|
||||
|
||||
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
|
||||
@@ -61,7 +88,12 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
|
||||
try
|
||||
{
|
||||
await botClient.SendMessage(
|
||||
adminId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken);
|
||||
adminId,
|
||||
text,
|
||||
parseMode: ParseMode.Html,
|
||||
replyMarkup: keyboard,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -71,25 +103,38 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
|
||||
}
|
||||
|
||||
public async Task NotifyAdminsRoleRequestCreatedAsync(
|
||||
Guid ticketId, string userName, string roleDescription, string justification, CancellationToken cancellationToken)
|
||||
Guid ticketId,
|
||||
string userName,
|
||||
string roleDescription,
|
||||
string justification,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||||
return;
|
||||
|
||||
var text = $"🆕 Заявка на роль от <b>{Escape(userName)}</b>\n{Escape(roleDescription)}\nОбоснование: {Escape(justification)}";
|
||||
var text =
|
||||
$"🆕 Заявка на роль от <b>{Escape(userName)}</b>\n{Escape(roleDescription)}\nОбоснование: {Escape(justification)}";
|
||||
|
||||
var keyboard = new InlineKeyboardMarkup(new[]
|
||||
{
|
||||
InlineKeyboardButton.WithCallbackData("✅ Одобрить", $"rrq:approve:{ticketId}"),
|
||||
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"rrq:reject:{ticketId}"),
|
||||
});
|
||||
var keyboard = new InlineKeyboardMarkup(
|
||||
new[]
|
||||
{
|
||||
InlineKeyboardButton.WithCallbackData("✅ Одобрить", $"rrq:approve:{ticketId}"),
|
||||
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"rrq:reject:{ticketId}"),
|
||||
}
|
||||
);
|
||||
|
||||
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
|
||||
{
|
||||
try
|
||||
{
|
||||
await botClient.SendMessage(
|
||||
adminId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken);
|
||||
adminId,
|
||||
text,
|
||||
parseMode: ParseMode.Html,
|
||||
replyMarkup: keyboard,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -98,7 +143,12 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
|
||||
}
|
||||
}
|
||||
|
||||
public async Task NotifyAdminsTicketReopenedAsync(Guid ticketId, string userName, TicketType type, CancellationToken cancellationToken)
|
||||
public async Task NotifyAdminsTicketReopenedAsync(
|
||||
Guid ticketId,
|
||||
string userName,
|
||||
TicketType type,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||||
return;
|
||||
@@ -110,7 +160,9 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
|
||||
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
|
||||
{
|
||||
var url = $"{options.Value.PublicSiteUrl.TrimEnd('/')}/admin/support?ticket={ticketId}";
|
||||
keyboard = new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) });
|
||||
keyboard = new InlineKeyboardMarkup(
|
||||
new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) }
|
||||
);
|
||||
}
|
||||
|
||||
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
|
||||
@@ -118,7 +170,12 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
|
||||
try
|
||||
{
|
||||
await botClient.SendMessage(
|
||||
adminId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken);
|
||||
adminId,
|
||||
text,
|
||||
parseMode: ParseMode.Html,
|
||||
replyMarkup: keyboard,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -127,7 +184,10 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
|
||||
}
|
||||
}
|
||||
|
||||
public async Task NotifyUsersNewsPublishedAsync(string title, CancellationToken cancellationToken)
|
||||
public async Task NotifyUsersNewsPublishedAsync(
|
||||
string title,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||||
return;
|
||||
@@ -138,16 +198,25 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
|
||||
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
|
||||
{
|
||||
var url = $"{options.Value.PublicSiteUrl.TrimEnd('/')}/news";
|
||||
keyboard = new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) });
|
||||
keyboard = new InlineKeyboardMarkup(
|
||||
new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) }
|
||||
);
|
||||
}
|
||||
|
||||
var telegramUserIds = await identityService.GetActivatedLinkedTelegramUserIdsAsync(cancellationToken);
|
||||
var telegramUserIds = await identityService.GetActivatedLinkedTelegramUserIdsAsync(
|
||||
cancellationToken
|
||||
);
|
||||
foreach (var telegramUserId in telegramUserIds)
|
||||
{
|
||||
try
|
||||
{
|
||||
await botClient.SendMessage(
|
||||
telegramUserId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken);
|
||||
telegramUserId,
|
||||
text,
|
||||
parseMode: ParseMode.Html,
|
||||
replyMarkup: keyboard,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -156,7 +225,11 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
|
||||
}
|
||||
}
|
||||
|
||||
public async Task NotifyUserAsync(Guid userId, string message, CancellationToken cancellationToken)
|
||||
public async Task NotifyUserAsync(
|
||||
Guid userId,
|
||||
string message,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||||
return;
|
||||
@@ -167,11 +240,24 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
|
||||
|
||||
InlineKeyboardMarkup? keyboard = null;
|
||||
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
|
||||
keyboard = new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", options.Value.PublicSiteUrl) });
|
||||
keyboard = new InlineKeyboardMarkup(
|
||||
new[]
|
||||
{
|
||||
InlineKeyboardButton.WithUrl(
|
||||
"🌐 Открыть на сайте",
|
||||
options.Value.PublicSiteUrl
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
try
|
||||
{
|
||||
await botClient.SendMessage(telegramUserId, message, replyMarkup: keyboard, cancellationToken: cancellationToken);
|
||||
await botClient.SendMessage(
|
||||
telegramUserId,
|
||||
message,
|
||||
replyMarkup: keyboard,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -179,5 +265,6 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
|
||||
}
|
||||
}
|
||||
|
||||
private static string Escape(string text) => text.Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
||||
private static string Escape(string text) =>
|
||||
text.Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
||||
}
|
||||
|
||||
@@ -4,12 +4,18 @@ namespace PnvPanel.Application.Activation;
|
||||
|
||||
public static class ActivationErrors
|
||||
{
|
||||
public static readonly Error AlreadyPending =
|
||||
Error.Conflict("Activation.AlreadyPending", "У вас уже есть необработанный запрос на активацию.");
|
||||
public static readonly Error AlreadyPending = Error.Conflict(
|
||||
"Activation.AlreadyPending",
|
||||
"У вас уже есть необработанный запрос на активацию."
|
||||
);
|
||||
|
||||
public static readonly Error NotFound =
|
||||
Error.NotFound("Activation.NotFound", "Запрос на активацию не найден.");
|
||||
public static readonly Error NotFound = Error.NotFound(
|
||||
"Activation.NotFound",
|
||||
"Запрос на активацию не найден."
|
||||
);
|
||||
|
||||
public static readonly Error AlreadyDecided =
|
||||
Error.Conflict("Activation.AlreadyDecided", "Запрос на активацию уже обработан.");
|
||||
public static readonly Error AlreadyDecided = Error.Conflict(
|
||||
"Activation.AlreadyDecided",
|
||||
"Запрос на активацию уже обработан."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,16 @@ using PnvPanel.Domain.Activation;
|
||||
|
||||
namespace PnvPanel.Application.Activation;
|
||||
|
||||
public sealed class GetActivationStatusQueryHandler(IIdentityService identityService, IAppDbContext dbContext, ICurrentUser currentUser)
|
||||
: IQueryHandler<GetActivationStatusQuery, Result<ActivationStatusDto>>
|
||||
public sealed class GetActivationStatusQueryHandler(
|
||||
IIdentityService identityService,
|
||||
IAppDbContext dbContext,
|
||||
ICurrentUser currentUser
|
||||
) : IQueryHandler<GetActivationStatusQuery, Result<ActivationStatusDto>>
|
||||
{
|
||||
public async Task<Result<ActivationStatusDto>> Handle(GetActivationStatusQuery query, CancellationToken cancellationToken)
|
||||
public async Task<Result<ActivationStatusDto>> Handle(
|
||||
GetActivationStatusQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return Result.Failure<ActivationStatusDto>(AuthErrors.Unauthorized);
|
||||
@@ -19,8 +25,10 @@ public sealed class GetActivationStatusQueryHandler(IIdentityService identitySer
|
||||
if (profile is null)
|
||||
return Result.Failure<ActivationStatusDto>(AuthErrors.Unauthorized);
|
||||
|
||||
var pending = await dbContext.ActivationRequests
|
||||
.Where(r => r.UserId == userId && r.Status == ActivationStatus.Pending)
|
||||
var pending = await dbContext
|
||||
.ActivationRequests.Where(r =>
|
||||
r.UserId == userId && r.Status == ActivationStatus.Pending
|
||||
)
|
||||
.Select(r => new ActivationRequestDto(r.Id, r.Comment, r.CreatedAt))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
|
||||
@@ -3,4 +3,5 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Activation;
|
||||
|
||||
public sealed record RequestActivationCommand(string? Comment) : ICommand<Result<ActivationRequestDto>>;
|
||||
public sealed record RequestActivationCommand(string? Comment)
|
||||
: ICommand<Result<ActivationRequestDto>>;
|
||||
|
||||
@@ -8,16 +8,24 @@ using PnvPanel.Domain.Activation;
|
||||
namespace PnvPanel.Application.Activation;
|
||||
|
||||
public sealed class RequestActivationCommandHandler(
|
||||
IAppDbContext dbContext, IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
|
||||
: ICommandHandler<RequestActivationCommand, Result<ActivationRequestDto>>
|
||||
IAppDbContext dbContext,
|
||||
IRealtimeNotifier notifier,
|
||||
ITelegramNotifier telegramNotifier,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<RequestActivationCommand, Result<ActivationRequestDto>>
|
||||
{
|
||||
public async Task<Result<ActivationRequestDto>> Handle(RequestActivationCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result<ActivationRequestDto>> Handle(
|
||||
RequestActivationCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return Result.Failure<ActivationRequestDto>(AuthErrors.Unauthorized);
|
||||
|
||||
var hasPending = await dbContext.ActivationRequests
|
||||
.AnyAsync(r => r.UserId == userId && r.Status == ActivationStatus.Pending, cancellationToken);
|
||||
var hasPending = await dbContext.ActivationRequests.AnyAsync(
|
||||
r => r.UserId == userId && r.Status == ActivationStatus.Pending,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (hasPending)
|
||||
return Result.Failure<ActivationRequestDto>(ActivationErrors.AlreadyPending);
|
||||
@@ -27,9 +35,23 @@ public sealed class RequestActivationCommandHandler(
|
||||
|
||||
var userName = currentUser.UserName ?? userId.ToString();
|
||||
|
||||
await notifier.NotifyActivationRequestedAsync(request.Id, userId, userName, request.Comment, request.CreatedAt, cancellationToken);
|
||||
await telegramNotifier.NotifyAdminsActivationRequestedAsync(request.Id, userName, request.Comment, cancellationToken);
|
||||
await notifier.NotifyActivationRequestedAsync(
|
||||
request.Id,
|
||||
userId,
|
||||
userName,
|
||||
request.Comment,
|
||||
request.CreatedAt,
|
||||
cancellationToken
|
||||
);
|
||||
await telegramNotifier.NotifyAdminsActivationRequestedAsync(
|
||||
request.Id,
|
||||
userName,
|
||||
request.Comment,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Result.Success(new ActivationRequestDto(request.Id, request.Comment, request.CreatedAt));
|
||||
return Result.Success(
|
||||
new ActivationRequestDto(request.Id, request.Comment, request.CreatedAt)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,4 +8,5 @@ public sealed record ActivationRequestAdminDto(
|
||||
string UserName,
|
||||
string? Comment,
|
||||
ActivationStatus Status,
|
||||
DateTimeOffset CreatedAt);
|
||||
DateTimeOffset CreatedAt
|
||||
);
|
||||
|
||||
+34
-10
@@ -10,17 +10,25 @@ using PnvPanel.Domain.Audit;
|
||||
namespace PnvPanel.Application.Admin.Activation;
|
||||
|
||||
public sealed class ApproveActivationCommandHandler(
|
||||
IAppDbContext dbContext, IIdentityService identityService, IRealtimeNotifier notifier,
|
||||
ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
|
||||
: ICommandHandler<ApproveActivationCommand, Result>
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService,
|
||||
IRealtimeNotifier notifier,
|
||||
ITelegramNotifier telegramNotifier,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<ApproveActivationCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(ApproveActivationCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result> Handle(
|
||||
ApproveActivationCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } adminId)
|
||||
return Result.Failure(AuthErrors.Unauthorized);
|
||||
|
||||
var request = await dbContext.ActivationRequests
|
||||
.FirstOrDefaultAsync(r => r.Id == command.RequestId, cancellationToken);
|
||||
var request = await dbContext.ActivationRequests.FirstOrDefaultAsync(
|
||||
r => r.Id == command.RequestId,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (request is null)
|
||||
return Result.Failure(ActivationErrors.NotFound);
|
||||
@@ -30,15 +38,31 @@ public sealed class ApproveActivationCommandHandler(
|
||||
|
||||
request.Approve(adminId);
|
||||
|
||||
var activateResult = await identityService.ActivateUserAsync(request.UserId, adminId, cancellationToken);
|
||||
var activateResult = await identityService.ActivateUserAsync(
|
||||
request.UserId,
|
||||
adminId,
|
||||
cancellationToken
|
||||
);
|
||||
if (!activateResult.IsSuccess)
|
||||
return activateResult;
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
adminId, "ActivationApproved", "User", request.UserId.ToString(), metadata: null, AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
adminId,
|
||||
"ActivationApproved",
|
||||
"User",
|
||||
request.UserId.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
await notifier.NotifyUserActivatedAsync(request.UserId, cancellationToken);
|
||||
await telegramNotifier.NotifyUserAsync(request.UserId, "✅ Ваш аккаунт активирован администратором.", cancellationToken);
|
||||
await telegramNotifier.NotifyUserAsync(
|
||||
request.UserId,
|
||||
"✅ Ваш аккаунт активирован администратором.",
|
||||
cancellationToken
|
||||
);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,8 @@ using PnvPanel.Domain.Activation;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Activation;
|
||||
|
||||
public sealed record ListActivationRequestsQuery(ActivationStatus? StatusFilter, int Page, int PageSize)
|
||||
: IQuery<Result<PagedList<ActivationRequestAdminDto>>>;
|
||||
public sealed record ListActivationRequestsQuery(
|
||||
ActivationStatus? StatusFilter,
|
||||
int Page,
|
||||
int PageSize
|
||||
) : IQuery<Result<PagedList<ActivationRequestAdminDto>>>;
|
||||
|
||||
+22
-8
@@ -5,10 +5,15 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Activation;
|
||||
|
||||
public sealed class ListActivationRequestsQueryHandler(IAppDbContext dbContext, IIdentityService identityService)
|
||||
: IQueryHandler<ListActivationRequestsQuery, Result<PagedList<ActivationRequestAdminDto>>>
|
||||
public sealed class ListActivationRequestsQueryHandler(
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService
|
||||
) : IQueryHandler<ListActivationRequestsQuery, Result<PagedList<ActivationRequestAdminDto>>>
|
||||
{
|
||||
public async Task<Result<PagedList<ActivationRequestAdminDto>>> Handle(ListActivationRequestsQuery query, CancellationToken cancellationToken)
|
||||
public async Task<Result<PagedList<ActivationRequestAdminDto>>> Handle(
|
||||
ListActivationRequestsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var page = query.Page <= 0 ? 1 : query.Page;
|
||||
var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize;
|
||||
@@ -23,13 +28,22 @@ public sealed class ListActivationRequestsQueryHandler(IAppDbContext dbContext,
|
||||
|
||||
var userNames = await identityService.GetUserNamesAsync(
|
||||
page1.Items.Select(r => r.UserId).Distinct().ToList(),
|
||||
cancellationToken);
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var items = page1.Items
|
||||
.Select(r => new ActivationRequestAdminDto(
|
||||
r.Id, r.UserId, userNames.GetValueOrDefault(r.UserId, "?"), r.Comment, r.Status, r.CreatedAt))
|
||||
var items = page1
|
||||
.Items.Select(r => new ActivationRequestAdminDto(
|
||||
r.Id,
|
||||
r.UserId,
|
||||
userNames.GetValueOrDefault(r.UserId, "?"),
|
||||
r.Comment,
|
||||
r.Status,
|
||||
r.CreatedAt
|
||||
))
|
||||
.ToList();
|
||||
|
||||
return Result.Success(new PagedList<ActivationRequestAdminDto>(items, page1.Total, page1.Page, page1.PageSize));
|
||||
return Result.Success(
|
||||
new PagedList<ActivationRequestAdminDto>(items, page1.Total, page1.Page, page1.PageSize)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+22
-7
@@ -9,16 +9,23 @@ using PnvPanel.Domain.Audit;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Activation;
|
||||
|
||||
public sealed class RejectActivationCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser)
|
||||
: ICommandHandler<RejectActivationCommand, Result>
|
||||
public sealed class RejectActivationCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<RejectActivationCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(RejectActivationCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result> Handle(
|
||||
RejectActivationCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } adminId)
|
||||
return Result.Failure(AuthErrors.Unauthorized);
|
||||
|
||||
var request = await dbContext.ActivationRequests
|
||||
.FirstOrDefaultAsync(r => r.Id == command.RequestId, cancellationToken);
|
||||
var request = await dbContext.ActivationRequests.FirstOrDefaultAsync(
|
||||
r => r.Id == command.RequestId,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (request is null)
|
||||
return Result.Failure(ActivationErrors.NotFound);
|
||||
@@ -28,8 +35,16 @@ public sealed class RejectActivationCommandHandler(IAppDbContext dbContext, ICur
|
||||
|
||||
request.Reject(adminId, command.Reason);
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
adminId, "ActivationRejected", "User", request.UserId.ToString(), metadata: null, AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
adminId,
|
||||
"ActivationRejected",
|
||||
"User",
|
||||
request.UserId.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
@@ -3,10 +3,25 @@ using PnvPanel.Domain.Apps;
|
||||
namespace PnvPanel.Application.Admin.Apps;
|
||||
|
||||
public sealed record AdminAppDto(
|
||||
Guid Id, string Name, string DownloadUrl, OsPlatform OperatingSystem, string? Description,
|
||||
string? IconUrl, int SortOrder, bool IsEnabled)
|
||||
Guid Id,
|
||||
string Name,
|
||||
string DownloadUrl,
|
||||
OsPlatform OperatingSystem,
|
||||
string? Description,
|
||||
string? IconUrl,
|
||||
int SortOrder,
|
||||
bool IsEnabled
|
||||
)
|
||||
{
|
||||
public static AdminAppDto FromDomain(ClientApp app) => new(
|
||||
app.Id, app.Name, app.DownloadUrl.ToString(), app.OperatingSystem, app.Description,
|
||||
app.IconUrl, app.SortOrder, app.IsEnabled);
|
||||
public static AdminAppDto FromDomain(ClientApp app) =>
|
||||
new(
|
||||
app.Id,
|
||||
app.Name,
|
||||
app.DownloadUrl.ToString(),
|
||||
app.OperatingSystem,
|
||||
app.Description,
|
||||
app.IconUrl,
|
||||
app.SortOrder,
|
||||
app.IsEnabled
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,5 +4,8 @@ namespace PnvPanel.Application.Admin.Apps;
|
||||
|
||||
public static class AppErrors
|
||||
{
|
||||
public static readonly Error NotFound = Error.NotFound("Apps.NotFound", "Приложение не найдено.");
|
||||
public static readonly Error NotFound = Error.NotFound(
|
||||
"Apps.NotFound",
|
||||
"Приложение не найдено."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,5 +5,10 @@ using PnvPanel.Domain.Apps;
|
||||
namespace PnvPanel.Application.Admin.Apps;
|
||||
|
||||
public sealed record CreateAppCommand(
|
||||
string Name, string DownloadUrl, OsPlatform OperatingSystem, string? Description, string? IconUrl, int SortOrder)
|
||||
: ICommand<Result<AdminAppDto>>;
|
||||
string Name,
|
||||
string DownloadUrl,
|
||||
OsPlatform OperatingSystem,
|
||||
string? Description,
|
||||
string? IconUrl,
|
||||
int SortOrder
|
||||
) : ICommand<Result<AdminAppDto>>;
|
||||
|
||||
@@ -5,13 +5,22 @@ using PnvPanel.Domain.Apps;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Apps;
|
||||
|
||||
public sealed class CreateAppCommandHandler(IAppDbContext dbContext) : ICommandHandler<CreateAppCommand, Result<AdminAppDto>>
|
||||
public sealed class CreateAppCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<CreateAppCommand, Result<AdminAppDto>>
|
||||
{
|
||||
public Task<Result<AdminAppDto>> Handle(CreateAppCommand command, CancellationToken cancellationToken)
|
||||
public Task<Result<AdminAppDto>> Handle(
|
||||
CreateAppCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var app = ClientApp.Create(
|
||||
command.Name, new Uri(command.DownloadUrl, UriKind.Absolute), command.OperatingSystem,
|
||||
command.Description, command.IconUrl, command.SortOrder);
|
||||
command.Name,
|
||||
new Uri(command.DownloadUrl, UriKind.Absolute),
|
||||
command.OperatingSystem,
|
||||
command.Description,
|
||||
command.IconUrl,
|
||||
command.SortOrder
|
||||
);
|
||||
|
||||
dbContext.ClientApps.Add(app);
|
||||
|
||||
|
||||
@@ -5,11 +5,15 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Apps;
|
||||
|
||||
public sealed class DeleteAppCommandHandler(IAppDbContext dbContext) : ICommandHandler<DeleteAppCommand, Result>
|
||||
public sealed class DeleteAppCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<DeleteAppCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(DeleteAppCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var app = await dbContext.ClientApps.FirstOrDefaultAsync(a => a.Id == command.AppId, cancellationToken);
|
||||
var app = await dbContext.ClientApps.FirstOrDefaultAsync(
|
||||
a => a.Id == command.AppId,
|
||||
cancellationToken
|
||||
);
|
||||
if (app is null)
|
||||
return Result.Failure(AppErrors.NotFound);
|
||||
|
||||
|
||||
@@ -5,14 +5,22 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Apps;
|
||||
|
||||
public sealed class ListAdminAppsQueryHandler(IAppDbContext dbContext) : IQueryHandler<ListAdminAppsQuery, Result<IReadOnlyList<AdminAppDto>>>
|
||||
public sealed class ListAdminAppsQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ListAdminAppsQuery, Result<IReadOnlyList<AdminAppDto>>>
|
||||
{
|
||||
public async Task<Result<IReadOnlyList<AdminAppDto>>> Handle(ListAdminAppsQuery query, CancellationToken cancellationToken)
|
||||
public async Task<Result<IReadOnlyList<AdminAppDto>>> Handle(
|
||||
ListAdminAppsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var apps = await dbContext.ClientApps.AsNoTracking()
|
||||
.OrderBy(a => a.OperatingSystem).ThenBy(a => a.SortOrder)
|
||||
var apps = await dbContext
|
||||
.ClientApps.AsNoTracking()
|
||||
.OrderBy(a => a.OperatingSystem)
|
||||
.ThenBy(a => a.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Success<IReadOnlyList<AdminAppDto>>(apps.Select(AdminAppDto.FromDomain).ToList());
|
||||
return Result.Success<IReadOnlyList<AdminAppDto>>(
|
||||
apps.Select(AdminAppDto.FromDomain).ToList()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,12 @@ using PnvPanel.Domain.Apps;
|
||||
namespace PnvPanel.Application.Admin.Apps;
|
||||
|
||||
public sealed record UpdateAppCommand(
|
||||
Guid AppId, string Name, string DownloadUrl, OsPlatform OperatingSystem, string? Description,
|
||||
string? IconUrl, int SortOrder, bool IsEnabled)
|
||||
: ICommand<Result<AdminAppDto>>;
|
||||
Guid AppId,
|
||||
string Name,
|
||||
string DownloadUrl,
|
||||
OsPlatform OperatingSystem,
|
||||
string? Description,
|
||||
string? IconUrl,
|
||||
int SortOrder,
|
||||
bool IsEnabled
|
||||
) : ICommand<Result<AdminAppDto>>;
|
||||
|
||||
@@ -5,17 +5,30 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Apps;
|
||||
|
||||
public sealed class UpdateAppCommandHandler(IAppDbContext dbContext) : ICommandHandler<UpdateAppCommand, Result<AdminAppDto>>
|
||||
public sealed class UpdateAppCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<UpdateAppCommand, Result<AdminAppDto>>
|
||||
{
|
||||
public async Task<Result<AdminAppDto>> Handle(UpdateAppCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result<AdminAppDto>> Handle(
|
||||
UpdateAppCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var app = await dbContext.ClientApps.FirstOrDefaultAsync(a => a.Id == command.AppId, cancellationToken);
|
||||
var app = await dbContext.ClientApps.FirstOrDefaultAsync(
|
||||
a => a.Id == command.AppId,
|
||||
cancellationToken
|
||||
);
|
||||
if (app is null)
|
||||
return Result.Failure<AdminAppDto>(AppErrors.NotFound);
|
||||
|
||||
app.Update(
|
||||
command.Name, new Uri(command.DownloadUrl, UriKind.Absolute), command.OperatingSystem,
|
||||
command.Description, command.IconUrl, command.SortOrder, command.IsEnabled);
|
||||
command.Name,
|
||||
new Uri(command.DownloadUrl, UriKind.Absolute),
|
||||
command.OperatingSystem,
|
||||
command.Description,
|
||||
command.IconUrl,
|
||||
command.SortOrder,
|
||||
command.IsEnabled
|
||||
);
|
||||
|
||||
return Result.Success(AdminAppDto.FromDomain(app));
|
||||
}
|
||||
|
||||
@@ -4,8 +4,16 @@ using PnvPanel.Domain.Audit;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Audit;
|
||||
|
||||
public sealed record ListAuditLogsQuery(int Page, int PageSize) : IQuery<Result<PagedList<AuditLogDto>>>;
|
||||
public sealed record ListAuditLogsQuery(int Page, int PageSize)
|
||||
: IQuery<Result<PagedList<AuditLogDto>>>;
|
||||
|
||||
public sealed record AuditLogDto(
|
||||
long Id, Guid? ActorId, string Action, string TargetType, string TargetId, string? Metadata,
|
||||
AuditSource Source, DateTimeOffset CreatedAt);
|
||||
long Id,
|
||||
Guid? ActorId,
|
||||
string Action,
|
||||
string TargetType,
|
||||
string TargetId,
|
||||
string? Metadata,
|
||||
AuditSource Source,
|
||||
DateTimeOffset CreatedAt
|
||||
);
|
||||
|
||||
@@ -5,16 +5,30 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Audit;
|
||||
|
||||
public sealed class ListAuditLogsQueryHandler(IAppDbContext dbContext) : IQueryHandler<ListAuditLogsQuery, Result<PagedList<AuditLogDto>>>
|
||||
public sealed class ListAuditLogsQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ListAuditLogsQuery, Result<PagedList<AuditLogDto>>>
|
||||
{
|
||||
public async Task<Result<PagedList<AuditLogDto>>> Handle(ListAuditLogsQuery query, CancellationToken cancellationToken)
|
||||
public async Task<Result<PagedList<AuditLogDto>>> Handle(
|
||||
ListAuditLogsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var page = query.Page <= 0 ? 1 : query.Page;
|
||||
var pageSize = query.PageSize is <= 0 or > 200 ? 50 : query.PageSize;
|
||||
|
||||
var result = await dbContext.AuditLogs.AsNoTracking()
|
||||
var result = await dbContext
|
||||
.AuditLogs.AsNoTracking()
|
||||
.OrderByDescending(a => a.CreatedAt)
|
||||
.Select(a => new AuditLogDto(a.Id, a.ActorId, a.Action, a.TargetType, a.TargetId, a.Metadata, a.Source, a.CreatedAt))
|
||||
.Select(a => new AuditLogDto(
|
||||
a.Id,
|
||||
a.ActorId,
|
||||
a.Action,
|
||||
a.TargetType,
|
||||
a.TargetId,
|
||||
a.Metadata,
|
||||
a.Source,
|
||||
a.CreatedAt
|
||||
))
|
||||
.ToPagedListAsync(page, pageSize, cancellationToken);
|
||||
|
||||
return Result.Success(result);
|
||||
|
||||
@@ -6,6 +6,17 @@ namespace PnvPanel.Application.Admin.Configs;
|
||||
/// <summary>Строка глобального списка конфигов для админа — в отличие от VpnConfigDto (self-service)
|
||||
/// содержит владельца и ноду, т.к. список не скоупится одним пользователем.</summary>
|
||||
public sealed record AdminVpnConfigDto(
|
||||
Guid Id, Guid UserId, string UserName, string? Label, string ClientEmail, VpnProtocol Protocol,
|
||||
string Location, string NodeName, long UsedUpBytes, long UsedDownBytes, DateTimeOffset? ExpiresAt,
|
||||
ConfigStatus Status, DateTimeOffset CreatedAt);
|
||||
Guid Id,
|
||||
Guid UserId,
|
||||
string UserName,
|
||||
string? Label,
|
||||
string ClientEmail,
|
||||
VpnProtocol Protocol,
|
||||
string Location,
|
||||
string NodeName,
|
||||
long UsedUpBytes,
|
||||
long UsedDownBytes,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
ConfigStatus Status,
|
||||
DateTimeOffset CreatedAt
|
||||
);
|
||||
|
||||
@@ -7,5 +7,9 @@ namespace PnvPanel.Application.Admin.Configs;
|
||||
|
||||
/// <summary><paramref name="Search"/> матчится по ClientEmail/Label — это то, по чему админ сверяет
|
||||
/// конфиг с записью в 3x-ui, а не по владельцу (для поиска по пользователю есть /admin/users).</summary>
|
||||
public sealed record ListAllConfigsQuery(int Page, int PageSize, string? Search, ConfigStatus? Status)
|
||||
: IQuery<Result<PagedList<AdminVpnConfigDto>>>;
|
||||
public sealed record ListAllConfigsQuery(
|
||||
int Page,
|
||||
int PageSize,
|
||||
string? Search,
|
||||
ConfigStatus? Status
|
||||
) : IQuery<Result<PagedList<AdminVpnConfigDto>>>;
|
||||
|
||||
@@ -5,10 +5,15 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Configs;
|
||||
|
||||
public sealed class ListAllConfigsQueryHandler(IAppDbContext dbContext, IIdentityService identityService)
|
||||
: IQueryHandler<ListAllConfigsQuery, Result<PagedList<AdminVpnConfigDto>>>
|
||||
public sealed class ListAllConfigsQueryHandler(
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService
|
||||
) : IQueryHandler<ListAllConfigsQuery, Result<PagedList<AdminVpnConfigDto>>>
|
||||
{
|
||||
public async Task<Result<PagedList<AdminVpnConfigDto>>> Handle(ListAllConfigsQuery query, CancellationToken cancellationToken)
|
||||
public async Task<Result<PagedList<AdminVpnConfigDto>>> Handle(
|
||||
ListAllConfigsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var page = query.Page <= 0 ? 1 : query.Page;
|
||||
var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize;
|
||||
@@ -21,7 +26,9 @@ public sealed class ListAllConfigsQueryHandler(IAppDbContext dbContext, IIdentit
|
||||
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||
{
|
||||
var search = query.Search.Trim();
|
||||
configsQuery = configsQuery.Where(c => c.ClientEmail.Contains(search) || (c.Label != null && c.Label.Contains(search)));
|
||||
configsQuery = configsQuery.Where(c =>
|
||||
c.ClientEmail.Contains(search) || (c.Label != null && c.Label.Contains(search))
|
||||
);
|
||||
}
|
||||
|
||||
var pageResult = await configsQuery
|
||||
@@ -29,30 +36,56 @@ public sealed class ListAllConfigsQueryHandler(IAppDbContext dbContext, IIdentit
|
||||
.ToPagedListAsync(page, pageSize, cancellationToken);
|
||||
|
||||
var inboundIds = pageResult.Items.Select(c => c.InboundId).Distinct().ToList();
|
||||
var inbounds = (await dbContext.Inbounds.AsNoTracking()
|
||||
var inbounds = (
|
||||
await dbContext
|
||||
.Inbounds.AsNoTracking()
|
||||
.Where(i => inboundIds.Contains(i.Id))
|
||||
.ToListAsync(cancellationToken))
|
||||
.ToDictionary(i => i.Id);
|
||||
.ToListAsync(cancellationToken)
|
||||
).ToDictionary(i => i.Id);
|
||||
|
||||
var nodeIds = inbounds.Values.Select(i => i.NodeId).Distinct().ToList();
|
||||
var nodes = (await dbContext.Nodes.AsNoTracking()
|
||||
var nodes = (
|
||||
await dbContext
|
||||
.Nodes.AsNoTracking()
|
||||
.Where(n => nodeIds.Contains(n.Id))
|
||||
.ToListAsync(cancellationToken))
|
||||
.ToDictionary(n => n.Id);
|
||||
.ToListAsync(cancellationToken)
|
||||
).ToDictionary(n => n.Id);
|
||||
|
||||
var userNames = await identityService.GetUserNamesAsync(
|
||||
pageResult.Items.Select(c => c.UserId).Distinct().ToList(), cancellationToken);
|
||||
pageResult.Items.Select(c => c.UserId).Distinct().ToList(),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var items = pageResult.Items.Select(c =>
|
||||
{
|
||||
var inbound = inbounds.GetValueOrDefault(c.InboundId);
|
||||
var node = inbound is null ? null : nodes.GetValueOrDefault(inbound.NodeId);
|
||||
return new AdminVpnConfigDto(
|
||||
c.Id, c.UserId, userNames.GetValueOrDefault(c.UserId, "?"), c.Label, c.ClientEmail, c.Protocol,
|
||||
inbound?.DisplayName ?? inbound?.Remark ?? "?", node?.Name ?? "?",
|
||||
c.UsedUpBytes, c.UsedDownBytes, c.ExpiresAt, c.Status, c.CreatedAt);
|
||||
}).ToList();
|
||||
var items = pageResult
|
||||
.Items.Select(c =>
|
||||
{
|
||||
var inbound = inbounds.GetValueOrDefault(c.InboundId);
|
||||
var node = inbound is null ? null : nodes.GetValueOrDefault(inbound.NodeId);
|
||||
return new AdminVpnConfigDto(
|
||||
c.Id,
|
||||
c.UserId,
|
||||
userNames.GetValueOrDefault(c.UserId, "?"),
|
||||
c.Label,
|
||||
c.ClientEmail,
|
||||
c.Protocol,
|
||||
inbound?.DisplayName ?? inbound?.Remark ?? "?",
|
||||
node?.Name ?? "?",
|
||||
c.UsedUpBytes,
|
||||
c.UsedDownBytes,
|
||||
c.ExpiresAt,
|
||||
c.Status,
|
||||
c.CreatedAt
|
||||
);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Result.Success(new PagedList<AdminVpnConfigDto>(items, pageResult.Total, pageResult.Page, pageResult.PageSize));
|
||||
return Result.Success(
|
||||
new PagedList<AdminVpnConfigDto>(
|
||||
items,
|
||||
pageResult.Total,
|
||||
pageResult.Page,
|
||||
pageResult.PageSize
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,31 @@ using PnvPanel.Domain.Inbounds;
|
||||
namespace PnvPanel.Application.Admin.Inbounds;
|
||||
|
||||
public sealed record InboundDto(
|
||||
Guid Id, Guid NodeId, string RemoteInboundId, VpnProtocol Protocol, string Remark, int Port,
|
||||
bool IsPublished, string? DisplayName, int? MaxClients, IReadOnlyList<Guid> AllowedRoleIds,
|
||||
DateTimeOffset? LastSyncAt)
|
||||
Guid Id,
|
||||
Guid NodeId,
|
||||
string RemoteInboundId,
|
||||
VpnProtocol Protocol,
|
||||
string Remark,
|
||||
int Port,
|
||||
bool IsPublished,
|
||||
string? DisplayName,
|
||||
int? MaxClients,
|
||||
IReadOnlyList<Guid> AllowedRoleIds,
|
||||
DateTimeOffset? LastSyncAt
|
||||
)
|
||||
{
|
||||
public static InboundDto FromDomain(Inbound inbound) => new(
|
||||
inbound.Id, inbound.NodeId, inbound.RemoteInboundId, inbound.Protocol, inbound.Remark, inbound.Port,
|
||||
inbound.IsPublished, inbound.DisplayName, inbound.MaxClients, inbound.AllowedRoleIds, inbound.LastSyncAt);
|
||||
public static InboundDto FromDomain(Inbound inbound) =>
|
||||
new(
|
||||
inbound.Id,
|
||||
inbound.NodeId,
|
||||
inbound.RemoteInboundId,
|
||||
inbound.Protocol,
|
||||
inbound.Remark,
|
||||
inbound.Port,
|
||||
inbound.IsPublished,
|
||||
inbound.DisplayName,
|
||||
inbound.MaxClients,
|
||||
inbound.AllowedRoleIds,
|
||||
inbound.LastSyncAt
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,5 +4,8 @@ namespace PnvPanel.Application.Admin.Inbounds;
|
||||
|
||||
public static class InboundErrors
|
||||
{
|
||||
public static readonly Error NotFound = Error.NotFound("Inbounds.NotFound", "Inbound не найден.");
|
||||
public static readonly Error NotFound = Error.NotFound(
|
||||
"Inbounds.NotFound",
|
||||
"Inbound не найден."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,15 +5,21 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Inbounds;
|
||||
|
||||
public sealed class ListInboundsQueryHandler(IAppDbContext dbContext) : IQueryHandler<ListInboundsQuery, Result<IReadOnlyList<InboundDto>>>
|
||||
public sealed class ListInboundsQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ListInboundsQuery, Result<IReadOnlyList<InboundDto>>>
|
||||
{
|
||||
public async Task<Result<IReadOnlyList<InboundDto>>> Handle(ListInboundsQuery query, CancellationToken cancellationToken)
|
||||
public async Task<Result<IReadOnlyList<InboundDto>>> Handle(
|
||||
ListInboundsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var inboundsQuery = dbContext.Inbounds.AsNoTracking();
|
||||
if (query.NodeId is { } nodeId)
|
||||
inboundsQuery = inboundsQuery.Where(i => i.NodeId == nodeId);
|
||||
|
||||
var inbounds = await inboundsQuery.OrderBy(i => i.Remark).ToListAsync(cancellationToken);
|
||||
return Result.Success<IReadOnlyList<InboundDto>>(inbounds.Select(InboundDto.FromDomain).ToList());
|
||||
return Result.Success<IReadOnlyList<InboundDto>>(
|
||||
inbounds.Select(InboundDto.FromDomain).ToList()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,9 @@ using PnvPanel.Application.Common.Models;
|
||||
namespace PnvPanel.Application.Admin.Inbounds;
|
||||
|
||||
public sealed record PublishInboundCommand(
|
||||
Guid InboundId, bool IsPublished, string? DisplayName, IReadOnlyList<Guid> AllowedRoleIds, int? MaxClients)
|
||||
: ICommand<Result<InboundDto>>;
|
||||
Guid InboundId,
|
||||
bool IsPublished,
|
||||
string? DisplayName,
|
||||
IReadOnlyList<Guid> AllowedRoleIds,
|
||||
int? MaxClients
|
||||
) : ICommand<Result<InboundDto>>;
|
||||
|
||||
@@ -9,9 +9,15 @@ namespace PnvPanel.Application.Admin.Inbounds;
|
||||
public sealed class PublishInboundCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser)
|
||||
: ICommandHandler<PublishInboundCommand, Result<InboundDto>>
|
||||
{
|
||||
public async Task<Result<InboundDto>> Handle(PublishInboundCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result<InboundDto>> Handle(
|
||||
PublishInboundCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var inbound = await dbContext.Inbounds.FirstOrDefaultAsync(i => i.Id == command.InboundId, cancellationToken);
|
||||
var inbound = await dbContext.Inbounds.FirstOrDefaultAsync(
|
||||
i => i.Id == command.InboundId,
|
||||
cancellationToken
|
||||
);
|
||||
if (inbound is null)
|
||||
return Result.Failure<InboundDto>(InboundErrors.NotFound);
|
||||
|
||||
@@ -20,9 +26,16 @@ public sealed class PublishInboundCommandHandler(IAppDbContext dbContext, ICurre
|
||||
else
|
||||
inbound.Unpublish();
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
currentUser.UserId, command.IsPublished ? "InboundPublished" : "InboundUnpublished",
|
||||
"Inbound", inbound.Id.ToString(), metadata: null, AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
currentUser.UserId,
|
||||
command.IsPublished ? "InboundPublished" : "InboundUnpublished",
|
||||
"Inbound",
|
||||
inbound.Id.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
return Result.Success(InboundDto.FromDomain(inbound));
|
||||
}
|
||||
|
||||
@@ -6,15 +6,26 @@ using PnvPanel.Domain.News;
|
||||
|
||||
namespace PnvPanel.Application.Admin.News;
|
||||
|
||||
public sealed class CreatePostCommandHandler(IAppDbContext dbContext, IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier)
|
||||
: ICommandHandler<CreatePostCommand, Result<NewsPostDto>>
|
||||
public sealed class CreatePostCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IRealtimeNotifier notifier,
|
||||
ITelegramNotifier telegramNotifier
|
||||
) : ICommandHandler<CreatePostCommand, Result<NewsPostDto>>
|
||||
{
|
||||
public async Task<Result<NewsPostDto>> Handle(CreatePostCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result<NewsPostDto>> Handle(
|
||||
CreatePostCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var post = NewsPost.Create(command.Title, command.Body);
|
||||
dbContext.NewsPosts.Add(post);
|
||||
|
||||
await notifier.NotifyNewsPublishedAsync(post.Id, post.Title, post.CreatedAt, cancellationToken);
|
||||
await notifier.NotifyNewsPublishedAsync(
|
||||
post.Id,
|
||||
post.Title,
|
||||
post.CreatedAt,
|
||||
cancellationToken
|
||||
);
|
||||
await telegramNotifier.NotifyUsersNewsPublishedAsync(post.Title, cancellationToken);
|
||||
|
||||
return Result.Success(NewsPostDto.FromDomain(post));
|
||||
|
||||
@@ -5,11 +5,15 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.News;
|
||||
|
||||
public sealed class DeletePostCommandHandler(IAppDbContext dbContext) : ICommandHandler<DeletePostCommand, Result>
|
||||
public sealed class DeletePostCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<DeletePostCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(DeletePostCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var post = await dbContext.NewsPosts.FirstOrDefaultAsync(p => p.Id == command.PostId, cancellationToken);
|
||||
var post = await dbContext.NewsPosts.FirstOrDefaultAsync(
|
||||
p => p.Id == command.PostId,
|
||||
cancellationToken
|
||||
);
|
||||
if (post is null)
|
||||
return Result.Failure(NewsErrors.NotFound);
|
||||
|
||||
|
||||
@@ -4,4 +4,5 @@ using PnvPanel.Application.News;
|
||||
|
||||
namespace PnvPanel.Application.Admin.News;
|
||||
|
||||
public sealed record ListAdminNewsQuery(int Page, int PageSize) : IQuery<Result<PagedList<NewsPostDto>>>;
|
||||
public sealed record ListAdminNewsQuery(int Page, int PageSize)
|
||||
: IQuery<Result<PagedList<NewsPostDto>>>;
|
||||
|
||||
@@ -6,14 +6,19 @@ using PnvPanel.Application.News;
|
||||
|
||||
namespace PnvPanel.Application.Admin.News;
|
||||
|
||||
public sealed class ListAdminNewsQueryHandler(IAppDbContext dbContext) : IQueryHandler<ListAdminNewsQuery, Result<PagedList<NewsPostDto>>>
|
||||
public sealed class ListAdminNewsQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ListAdminNewsQuery, Result<PagedList<NewsPostDto>>>
|
||||
{
|
||||
public async Task<Result<PagedList<NewsPostDto>>> Handle(ListAdminNewsQuery query, CancellationToken cancellationToken)
|
||||
public async Task<Result<PagedList<NewsPostDto>>> Handle(
|
||||
ListAdminNewsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var page = query.Page <= 0 ? 1 : query.Page;
|
||||
var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize;
|
||||
|
||||
var result = await dbContext.NewsPosts.AsNoTracking()
|
||||
var result = await dbContext
|
||||
.NewsPosts.AsNoTracking()
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.Select(p => new NewsPostDto(p.Id, p.Title, p.Body, p.CreatedAt, p.UpdatedAt))
|
||||
.ToPagedListAsync(page, pageSize, cancellationToken);
|
||||
|
||||
@@ -4,4 +4,5 @@ using PnvPanel.Application.News;
|
||||
|
||||
namespace PnvPanel.Application.Admin.News;
|
||||
|
||||
public sealed record UpdatePostCommand(Guid PostId, string Title, string Body) : ICommand<Result<NewsPostDto>>;
|
||||
public sealed record UpdatePostCommand(Guid PostId, string Title, string Body)
|
||||
: ICommand<Result<NewsPostDto>>;
|
||||
|
||||
@@ -6,11 +6,18 @@ using PnvPanel.Application.News;
|
||||
|
||||
namespace PnvPanel.Application.Admin.News;
|
||||
|
||||
public sealed class UpdatePostCommandHandler(IAppDbContext dbContext) : ICommandHandler<UpdatePostCommand, Result<NewsPostDto>>
|
||||
public sealed class UpdatePostCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<UpdatePostCommand, Result<NewsPostDto>>
|
||||
{
|
||||
public async Task<Result<NewsPostDto>> Handle(UpdatePostCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result<NewsPostDto>> Handle(
|
||||
UpdatePostCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var post = await dbContext.NewsPosts.FirstOrDefaultAsync(p => p.Id == command.PostId, cancellationToken);
|
||||
var post = await dbContext.NewsPosts.FirstOrDefaultAsync(
|
||||
p => p.Id == command.PostId,
|
||||
cancellationToken
|
||||
);
|
||||
if (post is null)
|
||||
return Result.Failure<NewsPostDto>(NewsErrors.NotFound);
|
||||
|
||||
|
||||
@@ -6,22 +6,38 @@ using PnvPanel.Domain.Audit;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Nodes;
|
||||
|
||||
public sealed class DeleteNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway, ICurrentUser currentUser)
|
||||
: ICommandHandler<DeleteNodeCommand, Result>
|
||||
public sealed class DeleteNodeCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IXuiPanelGateway gateway,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<DeleteNodeCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(DeleteNodeCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var node = await dbContext.Nodes.FirstOrDefaultAsync(n => n.Id == command.NodeId, cancellationToken);
|
||||
var node = await dbContext.Nodes.FirstOrDefaultAsync(
|
||||
n => n.Id == command.NodeId,
|
||||
cancellationToken
|
||||
);
|
||||
if (node is null)
|
||||
return Result.Failure(NodeErrors.NotFound);
|
||||
|
||||
var inbounds = await dbContext.Inbounds.Where(i => i.NodeId == node.Id).ToListAsync(cancellationToken);
|
||||
var inbounds = await dbContext
|
||||
.Inbounds.Where(i => i.NodeId == node.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
dbContext.Inbounds.RemoveRange(inbounds);
|
||||
dbContext.Nodes.Remove(node);
|
||||
gateway.InvalidateClient(node.Id);
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
currentUser.UserId, "NodeDeleted", "Node", node.Id.ToString(), metadata: null, AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
currentUser.UserId,
|
||||
"NodeDeleted",
|
||||
"Node",
|
||||
node.Id.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
@@ -5,11 +5,18 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Nodes;
|
||||
|
||||
public sealed class ListNodesQueryHandler(IAppDbContext dbContext) : IQueryHandler<ListNodesQuery, Result<IReadOnlyList<NodeDto>>>
|
||||
public sealed class ListNodesQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ListNodesQuery, Result<IReadOnlyList<NodeDto>>>
|
||||
{
|
||||
public async Task<Result<IReadOnlyList<NodeDto>>> Handle(ListNodesQuery query, CancellationToken cancellationToken)
|
||||
public async Task<Result<IReadOnlyList<NodeDto>>> Handle(
|
||||
ListNodesQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var nodes = await dbContext.Nodes.AsNoTracking().OrderBy(n => n.Name).ToListAsync(cancellationToken);
|
||||
var nodes = await dbContext
|
||||
.Nodes.AsNoTracking()
|
||||
.OrderBy(n => n.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
return Result.Success<IReadOnlyList<NodeDto>>(nodes.Select(NodeDto.FromDomain).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,25 @@ namespace PnvPanel.Application.Admin.Nodes;
|
||||
|
||||
/// <summary>Админский DTO ноды. Пароль никогда не попадает в ответ API.</summary>
|
||||
public sealed record NodeDto(
|
||||
Guid Id, string Name, string BaseAddress, string Username, string? Location,
|
||||
NodeStatus Status, bool IsEnabled, DateTimeOffset? LastSyncAt)
|
||||
Guid Id,
|
||||
string Name,
|
||||
string BaseAddress,
|
||||
string Username,
|
||||
string? Location,
|
||||
NodeStatus Status,
|
||||
bool IsEnabled,
|
||||
DateTimeOffset? LastSyncAt
|
||||
)
|
||||
{
|
||||
public static NodeDto FromDomain(Node node) => new(
|
||||
node.Id, node.Name, node.BaseAddress.ToString(), node.Credentials.Username, node.Location,
|
||||
node.Status, node.IsEnabled, node.LastSyncAt);
|
||||
public static NodeDto FromDomain(Node node) =>
|
||||
new(
|
||||
node.Id,
|
||||
node.Name,
|
||||
node.BaseAddress.ToString(),
|
||||
node.Credentials.Username,
|
||||
node.Location,
|
||||
node.Status,
|
||||
node.IsEnabled,
|
||||
node.LastSyncAt
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,5 +5,8 @@ namespace PnvPanel.Application.Admin.Nodes;
|
||||
public static class NodeErrors
|
||||
{
|
||||
public static readonly Error NotFound = Error.NotFound("Nodes.NotFound", "Нода не найдена.");
|
||||
public static readonly Error InvalidBaseAddress = Error.Validation("Nodes.InvalidBaseAddress", "Некорректный адрес панели.");
|
||||
public static readonly Error InvalidBaseAddress = Error.Validation(
|
||||
"Nodes.InvalidBaseAddress",
|
||||
"Некорректный адрес панели."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,15 +9,23 @@ namespace PnvPanel.Application.Admin.Nodes;
|
||||
public sealed class ProbeNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway)
|
||||
: ICommandHandler<ProbeNodeCommand, Result<NodeProbeResultDto>>
|
||||
{
|
||||
public async Task<Result<NodeProbeResultDto>> Handle(ProbeNodeCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result<NodeProbeResultDto>> Handle(
|
||||
ProbeNodeCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var node = await dbContext.Nodes.FirstOrDefaultAsync(n => n.Id == command.NodeId, cancellationToken);
|
||||
var node = await dbContext.Nodes.FirstOrDefaultAsync(
|
||||
n => n.Id == command.NodeId,
|
||||
cancellationToken
|
||||
);
|
||||
if (node is null)
|
||||
return Result.Failure<NodeProbeResultDto>(NodeErrors.NotFound);
|
||||
|
||||
var probe = await gateway.ProbeAsync(node, cancellationToken);
|
||||
node.UpdateStatus(probe.IsReachable ? NodeStatus.Online : NodeStatus.Offline);
|
||||
|
||||
return Result.Success(new NodeProbeResultDto(probe.IsReachable, probe.ErrorMessage, node.Status));
|
||||
return Result.Success(
|
||||
new NodeProbeResultDto(probe.IsReachable, probe.ErrorMessage, node.Status)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,5 +3,10 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Nodes;
|
||||
|
||||
public sealed record RegisterNodeCommand(string Name, string BaseAddress, string Username, string Password, string? Location)
|
||||
: ICommand<Result<NodeDto>>;
|
||||
public sealed record RegisterNodeCommand(
|
||||
string Name,
|
||||
string BaseAddress,
|
||||
string Username,
|
||||
string Password,
|
||||
string? Location
|
||||
) : ICommand<Result<NodeDto>>;
|
||||
|
||||
@@ -7,10 +7,16 @@ using PnvPanel.Domain.Nodes;
|
||||
namespace PnvPanel.Application.Admin.Nodes;
|
||||
|
||||
public sealed class RegisterNodeCommandHandler(
|
||||
IAppDbContext dbContext, IXuiPanelGateway gateway, ISecretProtector secretProtector, ICurrentUser currentUser)
|
||||
: ICommandHandler<RegisterNodeCommand, Result<NodeDto>>
|
||||
IAppDbContext dbContext,
|
||||
IXuiPanelGateway gateway,
|
||||
ISecretProtector secretProtector,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<RegisterNodeCommand, Result<NodeDto>>
|
||||
{
|
||||
public Task<Result<NodeDto>> Handle(RegisterNodeCommand command, CancellationToken cancellationToken)
|
||||
public Task<Result<NodeDto>> Handle(
|
||||
RegisterNodeCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (!Uri.TryCreate(command.BaseAddress, UriKind.Absolute, out var baseAddress))
|
||||
return Task.FromResult(Result.Failure<NodeDto>(NodeErrors.InvalidBaseAddress));
|
||||
@@ -19,12 +25,23 @@ public sealed class RegisterNodeCommandHandler(
|
||||
if (!validation.IsSuccess)
|
||||
return Task.FromResult(Result.Failure<NodeDto>(validation.Error));
|
||||
|
||||
var credentials = new NodeCredentials(command.Username, secretProtector.Protect(command.Password));
|
||||
var credentials = new NodeCredentials(
|
||||
command.Username,
|
||||
secretProtector.Protect(command.Password)
|
||||
);
|
||||
var node = Node.Register(command.Name, baseAddress, credentials, command.Location);
|
||||
|
||||
dbContext.Nodes.Add(node);
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
currentUser.UserId, "NodeRegistered", "Node", node.Id.ToString(), metadata: null, AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
currentUser.UserId,
|
||||
"NodeRegistered",
|
||||
"Node",
|
||||
node.Id.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
return Task.FromResult(Result.Success(NodeDto.FromDomain(node)));
|
||||
}
|
||||
|
||||
@@ -10,9 +10,15 @@ namespace PnvPanel.Application.Admin.Nodes;
|
||||
public sealed class SyncNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway)
|
||||
: ICommandHandler<SyncNodeCommand, Result<SyncNodeResultDto>>
|
||||
{
|
||||
public async Task<Result<SyncNodeResultDto>> Handle(SyncNodeCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result<SyncNodeResultDto>> Handle(
|
||||
SyncNodeCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var node = await dbContext.Nodes.FirstOrDefaultAsync(n => n.Id == command.NodeId, cancellationToken);
|
||||
var node = await dbContext.Nodes.FirstOrDefaultAsync(
|
||||
n => n.Id == command.NodeId,
|
||||
cancellationToken
|
||||
);
|
||||
if (node is null)
|
||||
return Result.Failure<SyncNodeResultDto>(NodeErrors.NotFound);
|
||||
|
||||
@@ -23,7 +29,9 @@ public sealed class SyncNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGat
|
||||
return Result.Failure<SyncNodeResultDto>(remoteResult.Error);
|
||||
}
|
||||
|
||||
var existing = await dbContext.Inbounds.Where(i => i.NodeId == node.Id).ToListAsync(cancellationToken);
|
||||
var existing = await dbContext
|
||||
.Inbounds.Where(i => i.NodeId == node.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var existingByRemoteId = existing.ToDictionary(i => i.RemoteInboundId);
|
||||
|
||||
foreach (var remote in remoteResult.Value)
|
||||
@@ -31,13 +39,25 @@ public sealed class SyncNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGat
|
||||
if (existingByRemoteId.TryGetValue(remote.RemoteInboundId, out var inbound))
|
||||
inbound.UpdateFromRemote(remote.Protocol, remote.Remark, remote.Port);
|
||||
else
|
||||
dbContext.Inbounds.Add(Inbound.FromRemote(node.Id, remote.RemoteInboundId, remote.Protocol, remote.Remark, remote.Port));
|
||||
dbContext.Inbounds.Add(
|
||||
Inbound.FromRemote(
|
||||
node.Id,
|
||||
remote.RemoteInboundId,
|
||||
remote.Protocol,
|
||||
remote.Remark,
|
||||
remote.Port
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Inbound, пропавший на панели, снимаем с публикации (не удаляем — реконсиляция дрейфа,
|
||||
// см. architecture.md); новые конфиги на нём создать будет нельзя, старые не трогаем.
|
||||
var remoteIds = remoteResult.Value.Select(r => r.RemoteInboundId).ToHashSet();
|
||||
foreach (var stale in existing.Where(i => i.IsPublished && !remoteIds.Contains(i.RemoteInboundId)))
|
||||
foreach (
|
||||
var stale in existing.Where(i =>
|
||||
i.IsPublished && !remoteIds.Contains(i.RemoteInboundId)
|
||||
)
|
||||
)
|
||||
stale.Unpublish();
|
||||
|
||||
node.UpdateStatus(NodeStatus.Online);
|
||||
|
||||
@@ -4,5 +4,10 @@ using PnvPanel.Application.Common.Models;
|
||||
namespace PnvPanel.Application.Admin.Nodes;
|
||||
|
||||
public sealed record UpdateNodeCommand(
|
||||
Guid NodeId, string Name, string? Location, bool IsEnabled, string? Username, string? Password)
|
||||
: ICommand<Result<NodeDto>>;
|
||||
Guid NodeId,
|
||||
string Name,
|
||||
string? Location,
|
||||
bool IsEnabled,
|
||||
string? Username,
|
||||
string? Password
|
||||
) : ICommand<Result<NodeDto>>;
|
||||
|
||||
@@ -8,12 +8,21 @@ using PnvPanel.Domain.Nodes;
|
||||
namespace PnvPanel.Application.Admin.Nodes;
|
||||
|
||||
public sealed class UpdateNodeCommandHandler(
|
||||
IAppDbContext dbContext, IXuiPanelGateway gateway, ISecretProtector secretProtector, ICurrentUser currentUser)
|
||||
: ICommandHandler<UpdateNodeCommand, Result<NodeDto>>
|
||||
IAppDbContext dbContext,
|
||||
IXuiPanelGateway gateway,
|
||||
ISecretProtector secretProtector,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<UpdateNodeCommand, Result<NodeDto>>
|
||||
{
|
||||
public async Task<Result<NodeDto>> Handle(UpdateNodeCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result<NodeDto>> Handle(
|
||||
UpdateNodeCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var node = await dbContext.Nodes.FirstOrDefaultAsync(n => n.Id == command.NodeId, cancellationToken);
|
||||
var node = await dbContext.Nodes.FirstOrDefaultAsync(
|
||||
n => n.Id == command.NodeId,
|
||||
cancellationToken
|
||||
);
|
||||
if (node is null)
|
||||
return Result.Failure<NodeDto>(NodeErrors.NotFound);
|
||||
|
||||
@@ -24,14 +33,27 @@ public sealed class UpdateNodeCommandHandler(
|
||||
else
|
||||
node.Disable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(command.Username) && !string.IsNullOrWhiteSpace(command.Password))
|
||||
if (
|
||||
!string.IsNullOrWhiteSpace(command.Username)
|
||||
&& !string.IsNullOrWhiteSpace(command.Password)
|
||||
)
|
||||
{
|
||||
node.UpdateCredentials(new NodeCredentials(command.Username, secretProtector.Protect(command.Password)));
|
||||
node.UpdateCredentials(
|
||||
new NodeCredentials(command.Username, secretProtector.Protect(command.Password))
|
||||
);
|
||||
gateway.InvalidateClient(node.Id);
|
||||
}
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
currentUser.UserId, "NodeUpdated", "Node", node.Id.ToString(), metadata: null, AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
currentUser.UserId,
|
||||
"NodeUpdated",
|
||||
"Node",
|
||||
node.Id.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
return Result.Success(NodeDto.FromDomain(node));
|
||||
}
|
||||
|
||||
@@ -4,4 +4,5 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Roles;
|
||||
|
||||
public sealed record CreateRoleCommand(string Name, int MaxConfigs, int MaxIpLimit) : ICommand<Result<RoleDto>>;
|
||||
public sealed record CreateRoleCommand(string Name, int MaxConfigs, int MaxIpLimit)
|
||||
: ICommand<Result<RoleDto>>;
|
||||
|
||||
@@ -4,8 +4,17 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Roles;
|
||||
|
||||
public sealed class CreateRoleCommandHandler(IRoleService roleService) : ICommandHandler<CreateRoleCommand, Result<RoleDto>>
|
||||
public sealed class CreateRoleCommandHandler(IRoleService roleService)
|
||||
: ICommandHandler<CreateRoleCommand, Result<RoleDto>>
|
||||
{
|
||||
public Task<Result<RoleDto>> Handle(CreateRoleCommand command, CancellationToken cancellationToken)
|
||||
=> roleService.CreateRoleAsync(command.Name, command.MaxConfigs, command.MaxIpLimit, cancellationToken);
|
||||
public Task<Result<RoleDto>> Handle(
|
||||
CreateRoleCommand command,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
roleService.CreateRoleAsync(
|
||||
command.Name,
|
||||
command.MaxConfigs,
|
||||
command.MaxIpLimit,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,10 +6,7 @@ public sealed class CreateRoleCommandValidator : AbstractValidator<CreateRoleCom
|
||||
{
|
||||
public CreateRoleCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty()
|
||||
.Length(2, 32)
|
||||
.Matches("^[a-zA-Z0-9_-]+$");
|
||||
RuleFor(x => x.Name).NotEmpty().Length(2, 32).Matches("^[a-zA-Z0-9_-]+$");
|
||||
|
||||
RuleFor(x => x.MaxConfigs).GreaterThanOrEqualTo(-1);
|
||||
RuleFor(x => x.MaxIpLimit).GreaterThanOrEqualTo(-1);
|
||||
|
||||
@@ -4,8 +4,9 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Roles;
|
||||
|
||||
public sealed class DeleteRoleCommandHandler(IRoleService roleService) : ICommandHandler<DeleteRoleCommand, Result>
|
||||
public sealed class DeleteRoleCommandHandler(IRoleService roleService)
|
||||
: ICommandHandler<DeleteRoleCommand, Result>
|
||||
{
|
||||
public Task<Result> Handle(DeleteRoleCommand command, CancellationToken cancellationToken)
|
||||
=> roleService.DeleteRoleAsync(command.RoleId, cancellationToken);
|
||||
public Task<Result> Handle(DeleteRoleCommand command, CancellationToken cancellationToken) =>
|
||||
roleService.DeleteRoleAsync(command.RoleId, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ namespace PnvPanel.Application.Admin.Roles;
|
||||
public sealed class ListRolesQueryHandler(IRoleService roleService)
|
||||
: IQueryHandler<ListRolesQuery, Result<IReadOnlyList<RoleDto>>>
|
||||
{
|
||||
public async Task<Result<IReadOnlyList<RoleDto>>> Handle(ListRolesQuery query, CancellationToken cancellationToken)
|
||||
=> Result.Success(await roleService.ListRolesAsync(cancellationToken));
|
||||
public async Task<Result<IReadOnlyList<RoleDto>>> Handle(
|
||||
ListRolesQuery query,
|
||||
CancellationToken cancellationToken
|
||||
) => Result.Success(await roleService.ListRolesAsync(cancellationToken));
|
||||
}
|
||||
|
||||
@@ -5,7 +5,16 @@ namespace PnvPanel.Application.Admin.Roles;
|
||||
public static class RoleErrors
|
||||
{
|
||||
public static readonly Error NotFound = Error.NotFound("Roles.NotFound", "Роль не найдена.");
|
||||
public static readonly Error DuplicateName = Error.Conflict("Roles.DuplicateName", "Роль с таким именем уже существует.");
|
||||
public static readonly Error CannotModifySystemRole = Error.Forbidden("Roles.CannotModifySystemRole", "Системную роль нельзя удалить.");
|
||||
public static readonly Error RoleInUse = Error.Conflict("Roles.RoleInUse", "Роль назначена пользователям — сначала переназначьте их.");
|
||||
public static readonly Error DuplicateName = Error.Conflict(
|
||||
"Roles.DuplicateName",
|
||||
"Роль с таким именем уже существует."
|
||||
);
|
||||
public static readonly Error CannotModifySystemRole = Error.Forbidden(
|
||||
"Roles.CannotModifySystemRole",
|
||||
"Системную роль нельзя удалить."
|
||||
);
|
||||
public static readonly Error RoleInUse = Error.Conflict(
|
||||
"Roles.RoleInUse",
|
||||
"Роль назначена пользователям — сначала переназначьте их."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,4 +4,5 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Roles;
|
||||
|
||||
public sealed record UpdateRoleCommand(Guid RoleId, int MaxConfigs, int MaxIpLimit) : ICommand<Result<RoleDto>>;
|
||||
public sealed record UpdateRoleCommand(Guid RoleId, int MaxConfigs, int MaxIpLimit)
|
||||
: ICommand<Result<RoleDto>>;
|
||||
|
||||
@@ -4,8 +4,17 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Roles;
|
||||
|
||||
public sealed class UpdateRoleCommandHandler(IRoleService roleService) : ICommandHandler<UpdateRoleCommand, Result<RoleDto>>
|
||||
public sealed class UpdateRoleCommandHandler(IRoleService roleService)
|
||||
: ICommandHandler<UpdateRoleCommand, Result<RoleDto>>
|
||||
{
|
||||
public Task<Result<RoleDto>> Handle(UpdateRoleCommand command, CancellationToken cancellationToken)
|
||||
=> roleService.UpdateRoleAsync(command.RoleId, command.MaxConfigs, command.MaxIpLimit, cancellationToken);
|
||||
public Task<Result<RoleDto>> Handle(
|
||||
UpdateRoleCommand command,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
roleService.UpdateRoleAsync(
|
||||
command.RoleId,
|
||||
command.MaxConfigs,
|
||||
command.MaxIpLimit,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,13 @@ namespace PnvPanel.Application.Admin.Stats;
|
||||
public sealed record GetStatsQuery : IQuery<Result<StatsDto>>;
|
||||
|
||||
public sealed record StatsDto(
|
||||
int TotalUsers, int ActivatedUsers, int PendingActivationRequests,
|
||||
int TotalNodes, int OnlineNodes, int TotalConfigs, int ActiveConfigs,
|
||||
long TotalUsedUpBytes, long TotalUsedDownBytes);
|
||||
int TotalUsers,
|
||||
int ActivatedUsers,
|
||||
int PendingActivationRequests,
|
||||
int TotalNodes,
|
||||
int OnlineNodes,
|
||||
int TotalConfigs,
|
||||
int ActiveConfigs,
|
||||
long TotalUsedUpBytes,
|
||||
long TotalUsedDownBytes
|
||||
);
|
||||
|
||||
@@ -11,27 +11,47 @@ namespace PnvPanel.Application.Admin.Stats;
|
||||
public sealed class GetStatsQueryHandler(IAppDbContext dbContext, IIdentityService identityService)
|
||||
: IQueryHandler<GetStatsQuery, Result<StatsDto>>
|
||||
{
|
||||
public async Task<Result<StatsDto>> Handle(GetStatsQuery query, CancellationToken cancellationToken)
|
||||
public async Task<Result<StatsDto>> Handle(
|
||||
GetStatsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var userStats = await identityService.GetUserStatsAsync(cancellationToken);
|
||||
|
||||
var pendingActivations = await dbContext.ActivationRequests
|
||||
.CountAsync(r => r.Status == ActivationStatus.Pending, cancellationToken);
|
||||
var pendingActivations = await dbContext.ActivationRequests.CountAsync(
|
||||
r => r.Status == ActivationStatus.Pending,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var totalNodes = await dbContext.Nodes.CountAsync(cancellationToken);
|
||||
var onlineNodes = await dbContext.Nodes.CountAsync(n => n.Status == NodeStatus.Online, cancellationToken);
|
||||
var onlineNodes = await dbContext.Nodes.CountAsync(
|
||||
n => n.Status == NodeStatus.Online,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var totalConfigs = await dbContext.VpnConfigs.CountAsync(cancellationToken);
|
||||
var activeConfigs = await dbContext.VpnConfigs.CountAsync(c => c.Status == ConfigStatus.Active, cancellationToken);
|
||||
var activeConfigs = await dbContext.VpnConfigs.CountAsync(
|
||||
c => c.Status == ConfigStatus.Active,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var trafficTotals = await dbContext.VpnConfigs
|
||||
.GroupBy(_ => 1)
|
||||
var trafficTotals = await dbContext
|
||||
.VpnConfigs.GroupBy(_ => 1)
|
||||
.Select(g => new { Up = g.Sum(c => c.UsedUpBytes), Down = g.Sum(c => c.UsedDownBytes) })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return Result.Success(new StatsDto(
|
||||
userStats.Total, userStats.Activated, pendingActivations,
|
||||
totalNodes, onlineNodes, totalConfigs, activeConfigs,
|
||||
trafficTotals?.Up ?? 0, trafficTotals?.Down ?? 0));
|
||||
return Result.Success(
|
||||
new StatsDto(
|
||||
userStats.Total,
|
||||
userStats.Activated,
|
||||
pendingActivations,
|
||||
totalNodes,
|
||||
onlineNodes,
|
||||
totalConfigs,
|
||||
activeConfigs,
|
||||
trafficTotals?.Up ?? 0,
|
||||
trafficTotals?.Down ?? 0
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+39
-10
@@ -10,16 +10,25 @@ using PnvPanel.Domain.Support;
|
||||
namespace PnvPanel.Application.Admin.Support;
|
||||
|
||||
public sealed class ApproveRoleRequestCommandHandler(
|
||||
IAppDbContext dbContext, IRoleService roleService, IRealtimeNotifier notifier,
|
||||
ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
|
||||
: ICommandHandler<ApproveRoleRequestCommand, Result>
|
||||
IAppDbContext dbContext,
|
||||
IRoleService roleService,
|
||||
IRealtimeNotifier notifier,
|
||||
ITelegramNotifier telegramNotifier,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<ApproveRoleRequestCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(ApproveRoleRequestCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result> Handle(
|
||||
ApproveRoleRequestCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } adminId)
|
||||
return Result.Failure(AuthErrors.Unauthorized);
|
||||
|
||||
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken);
|
||||
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(
|
||||
t => t.Id == command.TicketId,
|
||||
cancellationToken
|
||||
);
|
||||
if (ticket is null)
|
||||
return Result.Failure(SupportErrors.NotFound);
|
||||
|
||||
@@ -37,24 +46,44 @@ public sealed class ApproveRoleRequestCommandHandler(
|
||||
else
|
||||
{
|
||||
var createResult = await roleService.CreateRoleAsync(
|
||||
ticket.ProposedRoleName!, ticket.ProposedMaxConfigs!.Value, ticket.ProposedMaxIpLimit!.Value, cancellationToken);
|
||||
ticket.ProposedRoleName!,
|
||||
ticket.ProposedMaxConfigs!.Value,
|
||||
ticket.ProposedMaxIpLimit!.Value,
|
||||
cancellationToken
|
||||
);
|
||||
if (!createResult.IsSuccess)
|
||||
return Result.Failure(createResult.Error);
|
||||
|
||||
roleId = createResult.Value.Id;
|
||||
}
|
||||
|
||||
var assignResult = await roleService.ChangeUserRoleAsync(ticket.UserId, roleId, cancellationToken);
|
||||
var assignResult = await roleService.ChangeUserRoleAsync(
|
||||
ticket.UserId,
|
||||
roleId,
|
||||
cancellationToken
|
||||
);
|
||||
if (!assignResult.IsSuccess)
|
||||
return assignResult;
|
||||
|
||||
ticket.Resolve();
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
adminId, "RoleRequestApproved", "SupportTicket", ticket.Id.ToString(), metadata: null, AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
adminId,
|
||||
"RoleRequestApproved",
|
||||
"SupportTicket",
|
||||
ticket.Id.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken);
|
||||
await telegramNotifier.NotifyUserAsync(ticket.UserId, "✅ Ваша заявка на роль одобрена.", cancellationToken);
|
||||
await telegramNotifier.NotifyUserAsync(
|
||||
ticket.UserId,
|
||||
"✅ Ваша заявка на роль одобрена.",
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
@@ -10,15 +10,24 @@ using PnvPanel.Domain.Support;
|
||||
namespace PnvPanel.Application.Admin.Support;
|
||||
|
||||
public sealed class CloseTicketCommandHandler(
|
||||
IAppDbContext dbContext, IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
|
||||
: ICommandHandler<CloseTicketCommand, Result>
|
||||
IAppDbContext dbContext,
|
||||
IRealtimeNotifier notifier,
|
||||
ITelegramNotifier telegramNotifier,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<CloseTicketCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(CloseTicketCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result> Handle(
|
||||
CloseTicketCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } adminId)
|
||||
return Result.Failure(AuthErrors.Unauthorized);
|
||||
|
||||
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken);
|
||||
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(
|
||||
t => t.Id == command.TicketId,
|
||||
cancellationToken
|
||||
);
|
||||
if (ticket is null)
|
||||
return Result.Failure(SupportErrors.NotFound);
|
||||
|
||||
@@ -27,11 +36,23 @@ public sealed class CloseTicketCommandHandler(
|
||||
|
||||
ticket.Close();
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
adminId, "TicketClosed", "SupportTicket", ticket.Id.ToString(), metadata: null, AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
adminId,
|
||||
"TicketClosed",
|
||||
"SupportTicket",
|
||||
ticket.Id.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken);
|
||||
await telegramNotifier.NotifyUserAsync(ticket.UserId, "🔒 Ваше обращение закрыто.", cancellationToken);
|
||||
await telegramNotifier.NotifyUserAsync(
|
||||
ticket.UserId,
|
||||
"🔒 Ваше обращение закрыто.",
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
@@ -6,17 +6,30 @@ using PnvPanel.Application.Support;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Support;
|
||||
|
||||
public sealed class GetTicketAdminQueryHandler(IAppDbContext dbContext, IIdentityService identityService, IRoleService roleService)
|
||||
: IQueryHandler<GetTicketAdminQuery, Result<TicketDetailDto>>
|
||||
public sealed class GetTicketAdminQueryHandler(
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService,
|
||||
IRoleService roleService
|
||||
) : IQueryHandler<GetTicketAdminQuery, Result<TicketDetailDto>>
|
||||
{
|
||||
public async Task<Result<TicketDetailDto>> Handle(GetTicketAdminQuery query, CancellationToken cancellationToken)
|
||||
public async Task<Result<TicketDetailDto>> Handle(
|
||||
GetTicketAdminQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var ticket = await dbContext.SupportTickets.AsNoTracking()
|
||||
var ticket = await dbContext
|
||||
.SupportTickets.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.Id == query.TicketId, cancellationToken);
|
||||
if (ticket is null)
|
||||
return Result.Failure<TicketDetailDto>(SupportErrors.NotFound);
|
||||
|
||||
var dto = await TicketMapping.ToDetailDtoAsync(dbContext, identityService, roleService, ticket, cancellationToken);
|
||||
var dto = await TicketMapping.ToDetailDtoAsync(
|
||||
dbContext,
|
||||
identityService,
|
||||
roleService,
|
||||
ticket,
|
||||
cancellationToken
|
||||
);
|
||||
return Result.Success(dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,9 @@ using PnvPanel.Domain.Support;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Support;
|
||||
|
||||
public sealed record ListAllTicketsQuery(TicketType? TypeFilter, TicketStatus? StatusFilter, int Page, int PageSize)
|
||||
: IQuery<Result<PagedList<TicketSummaryDto>>>;
|
||||
public sealed record ListAllTicketsQuery(
|
||||
TicketType? TypeFilter,
|
||||
TicketStatus? StatusFilter,
|
||||
int Page,
|
||||
int PageSize
|
||||
) : IQuery<Result<PagedList<TicketSummaryDto>>>;
|
||||
|
||||
@@ -6,10 +6,15 @@ using PnvPanel.Application.Support;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Support;
|
||||
|
||||
public sealed class ListAllTicketsQueryHandler(IAppDbContext dbContext, IIdentityService identityService)
|
||||
: IQueryHandler<ListAllTicketsQuery, Result<PagedList<TicketSummaryDto>>>
|
||||
public sealed class ListAllTicketsQueryHandler(
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService
|
||||
) : IQueryHandler<ListAllTicketsQuery, Result<PagedList<TicketSummaryDto>>>
|
||||
{
|
||||
public async Task<Result<PagedList<TicketSummaryDto>>> Handle(ListAllTicketsQuery query, CancellationToken cancellationToken)
|
||||
public async Task<Result<PagedList<TicketSummaryDto>>> Handle(
|
||||
ListAllTicketsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var page = query.Page <= 0 ? 1 : query.Page;
|
||||
var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize;
|
||||
@@ -20,9 +25,18 @@ public sealed class ListAllTicketsQueryHandler(IAppDbContext dbContext, IIdentit
|
||||
if (query.StatusFilter is { } status)
|
||||
ticketsQuery = ticketsQuery.Where(t => t.Status == status);
|
||||
|
||||
var page1 = await ticketsQuery.OrderByDescending(t => t.CreatedAt).ToPagedListAsync(page, pageSize, cancellationToken);
|
||||
var items = await TicketMapping.ToSummaryDtosAsync(dbContext, identityService, page1.Items, cancellationToken);
|
||||
var page1 = await ticketsQuery
|
||||
.OrderByDescending(t => t.CreatedAt)
|
||||
.ToPagedListAsync(page, pageSize, cancellationToken);
|
||||
var items = await TicketMapping.ToSummaryDtosAsync(
|
||||
dbContext,
|
||||
identityService,
|
||||
page1.Items,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Result.Success(new PagedList<TicketSummaryDto>(items, page1.Total, page1.Page, page1.PageSize));
|
||||
return Result.Success(
|
||||
new PagedList<TicketSummaryDto>(items, page1.Total, page1.Page, page1.PageSize)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,15 +10,24 @@ using PnvPanel.Domain.Support;
|
||||
namespace PnvPanel.Application.Admin.Support;
|
||||
|
||||
public sealed class RejectRoleRequestCommandHandler(
|
||||
IAppDbContext dbContext, IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
|
||||
: ICommandHandler<RejectRoleRequestCommand, Result>
|
||||
IAppDbContext dbContext,
|
||||
IRealtimeNotifier notifier,
|
||||
ITelegramNotifier telegramNotifier,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<RejectRoleRequestCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(RejectRoleRequestCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result> Handle(
|
||||
RejectRoleRequestCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } adminId)
|
||||
return Result.Failure(AuthErrors.Unauthorized);
|
||||
|
||||
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken);
|
||||
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(
|
||||
t => t.Id == command.TicketId,
|
||||
cancellationToken
|
||||
);
|
||||
if (ticket is null)
|
||||
return Result.Failure(SupportErrors.NotFound);
|
||||
|
||||
@@ -33,11 +42,23 @@ public sealed class RejectRoleRequestCommandHandler(
|
||||
|
||||
ticket.Close();
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
adminId, "RoleRequestRejected", "SupportTicket", ticket.Id.ToString(), metadata: null, AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
adminId,
|
||||
"RoleRequestRejected",
|
||||
"SupportTicket",
|
||||
ticket.Id.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken);
|
||||
await telegramNotifier.NotifyUserAsync(ticket.UserId, "❌ Ваша заявка на роль отклонена.", cancellationToken);
|
||||
await telegramNotifier.NotifyUserAsync(
|
||||
ticket.UserId,
|
||||
"❌ Ваша заявка на роль отклонена.",
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
@@ -10,15 +10,24 @@ using PnvPanel.Domain.Support;
|
||||
namespace PnvPanel.Application.Admin.Support;
|
||||
|
||||
public sealed class ResolveTicketCommandHandler(
|
||||
IAppDbContext dbContext, IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
|
||||
: ICommandHandler<ResolveTicketCommand, Result>
|
||||
IAppDbContext dbContext,
|
||||
IRealtimeNotifier notifier,
|
||||
ITelegramNotifier telegramNotifier,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<ResolveTicketCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(ResolveTicketCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result> Handle(
|
||||
ResolveTicketCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } adminId)
|
||||
return Result.Failure(AuthErrors.Unauthorized);
|
||||
|
||||
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken);
|
||||
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(
|
||||
t => t.Id == command.TicketId,
|
||||
cancellationToken
|
||||
);
|
||||
if (ticket is null)
|
||||
return Result.Failure(SupportErrors.NotFound);
|
||||
|
||||
@@ -27,11 +36,23 @@ public sealed class ResolveTicketCommandHandler(
|
||||
|
||||
ticket.Resolve();
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
adminId, "TicketResolved", "SupportTicket", ticket.Id.ToString(), metadata: null, AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
adminId,
|
||||
"TicketResolved",
|
||||
"SupportTicket",
|
||||
ticket.Id.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken);
|
||||
await telegramNotifier.NotifyUserAsync(ticket.UserId, "✅ Ваше обращение решено.", cancellationToken);
|
||||
await telegramNotifier.NotifyUserAsync(
|
||||
ticket.UserId,
|
||||
"✅ Ваше обращение решено.",
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
@@ -10,10 +10,14 @@ namespace PnvPanel.Application.Admin.Users;
|
||||
|
||||
/// <summary>Блокировка гасит все активные конфиги в 3x-ui (см. architecture.md).</summary>
|
||||
public sealed class BlockUserCommandHandler(
|
||||
IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway,
|
||||
IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser,
|
||||
ILogger<BlockUserCommandHandler> logger)
|
||||
: ICommandHandler<BlockUserCommand, Result>
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService,
|
||||
IXuiPanelGateway gateway,
|
||||
IRealtimeNotifier notifier,
|
||||
ITelegramNotifier telegramNotifier,
|
||||
ICurrentUser currentUser,
|
||||
ILogger<BlockUserCommandHandler> logger
|
||||
) : ICommandHandler<BlockUserCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(BlockUserCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -21,22 +25,32 @@ public sealed class BlockUserCommandHandler(
|
||||
if (!blockResult.IsSuccess)
|
||||
return blockResult;
|
||||
|
||||
var configs = await dbContext.VpnConfigs
|
||||
.Where(c => c.UserId == command.UserId && c.Status == ConfigStatus.Active)
|
||||
var configs = await dbContext
|
||||
.VpnConfigs.Where(c => c.UserId == command.UserId && c.Status == ConfigStatus.Active)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var config in configs)
|
||||
{
|
||||
var inbound = await dbContext.Inbounds.AsNoTracking().FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
|
||||
var inbound = await dbContext
|
||||
.Inbounds.AsNoTracking()
|
||||
.FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
|
||||
var node = inbound is null
|
||||
? null
|
||||
: await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
|
||||
: await dbContext
|
||||
.Nodes.AsNoTracking()
|
||||
.FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
|
||||
|
||||
if (inbound is not null && node is not null)
|
||||
{
|
||||
var updateResult = await gateway.UpdateClientAsync(
|
||||
node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol,
|
||||
config.Label ?? config.ClientEmail, enable: false, cancellationToken);
|
||||
node,
|
||||
inbound.RemoteInboundId,
|
||||
config.ClientExternalId,
|
||||
config.Protocol,
|
||||
config.Label ?? config.ClientEmail,
|
||||
enable: false,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (!updateResult.IsSuccess)
|
||||
{
|
||||
@@ -45,19 +59,40 @@ public sealed class BlockUserCommandHandler(
|
||||
// Конфиг останется Active и будет подхвачен повторным BlockUserCommand (идемпотентен).
|
||||
logger.LogWarning(
|
||||
"Failed to disable client for config {ConfigId} on node {NodeId} while blocking user {UserId}: {Error}",
|
||||
config.Id, node.Id, command.UserId, updateResult.Error);
|
||||
config.Id,
|
||||
node.Id,
|
||||
command.UserId,
|
||||
updateResult.Error
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
config.Disable();
|
||||
await notifier.NotifyConfigStatusChangedAsync(config.UserId, config.Id, config.Status, cancellationToken);
|
||||
await notifier.NotifyConfigStatusChangedAsync(
|
||||
config.UserId,
|
||||
config.Id,
|
||||
config.Status,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
currentUser.UserId, "UserBlocked", "User", command.UserId.ToString(), metadata: null, AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
currentUser.UserId,
|
||||
"UserBlocked",
|
||||
"User",
|
||||
command.UserId.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
await telegramNotifier.NotifyUserAsync(command.UserId, "⛔ Ваш аккаунт заблокирован администратором.", cancellationToken);
|
||||
await telegramNotifier.NotifyUserAsync(
|
||||
command.UserId,
|
||||
"⛔ Ваш аккаунт заблокирован администратором.",
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
@@ -5,18 +5,35 @@ using PnvPanel.Domain.Audit;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Users;
|
||||
|
||||
public sealed class ChangeUserRoleCommandHandler(IRoleService roleService, IAppDbContext dbContext, ICurrentUser currentUser)
|
||||
: ICommandHandler<ChangeUserRoleCommand, Result>
|
||||
public sealed class ChangeUserRoleCommandHandler(
|
||||
IRoleService roleService,
|
||||
IAppDbContext dbContext,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<ChangeUserRoleCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(ChangeUserRoleCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result> Handle(
|
||||
ChangeUserRoleCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await roleService.ChangeUserRoleAsync(command.UserId, command.RoleId, cancellationToken);
|
||||
var result = await roleService.ChangeUserRoleAsync(
|
||||
command.UserId,
|
||||
command.RoleId,
|
||||
cancellationToken
|
||||
);
|
||||
if (!result.IsSuccess)
|
||||
return result;
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
currentUser.UserId, "UserRoleChanged", "User", command.UserId.ToString(),
|
||||
metadata: $"{{\"roleId\":\"{command.RoleId}\"}}", AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
currentUser.UserId,
|
||||
"UserRoleChanged",
|
||||
"User",
|
||||
command.UserId.ToString(),
|
||||
metadata: $"{{\"roleId\":\"{command.RoleId}\"}}",
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -9,40 +9,65 @@ namespace PnvPanel.Application.Admin.Users;
|
||||
|
||||
/// <summary>Удаление пользователя админом: отзывает все его конфиги в 3x-ui, затем удаляет учётку.</summary>
|
||||
public sealed class DeleteUserCommandHandler(
|
||||
IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway,
|
||||
ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
|
||||
: ICommandHandler<DeleteUserCommand, Result>
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService,
|
||||
IXuiPanelGateway gateway,
|
||||
ITelegramNotifier telegramNotifier,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<DeleteUserCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(DeleteUserCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId == command.UserId)
|
||||
return Result.Failure(UserErrors.CannotDeleteSelf);
|
||||
|
||||
var configs = await dbContext.VpnConfigs
|
||||
.Where(c => c.UserId == command.UserId && c.Status != ConfigStatus.Revoked)
|
||||
var configs = await dbContext
|
||||
.VpnConfigs.Where(c => c.UserId == command.UserId && c.Status != ConfigStatus.Revoked)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var config in configs)
|
||||
{
|
||||
var inbound = await dbContext.Inbounds.AsNoTracking().FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
|
||||
var inbound = await dbContext
|
||||
.Inbounds.AsNoTracking()
|
||||
.FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
|
||||
var node = inbound is null
|
||||
? null
|
||||
: await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
|
||||
: await dbContext
|
||||
.Nodes.AsNoTracking()
|
||||
.FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
|
||||
|
||||
if (inbound is not null && node is not null)
|
||||
await gateway.RemoveClientAsync(node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, cancellationToken);
|
||||
await gateway.RemoveClientAsync(
|
||||
node,
|
||||
inbound.RemoteInboundId,
|
||||
config.ClientExternalId,
|
||||
config.Protocol,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
config.Revoke();
|
||||
}
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
currentUser.UserId, "UserDeleted", "User", command.UserId.ToString(), metadata: null, AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
currentUser.UserId,
|
||||
"UserDeleted",
|
||||
"User",
|
||||
command.UserId.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
// Коммитим отзыв конфигов + аудит ДО удаления учётки: UserManager.DeleteAsync ниже удаляет
|
||||
// AppUser отдельным путём (Identity store), после чего NotifyUserAsync уже не найдёт Telegram-привязку.
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await telegramNotifier.NotifyUserAsync(command.UserId, "🗑 Ваш аккаунт удалён администратором.", cancellationToken);
|
||||
await telegramNotifier.NotifyUserAsync(
|
||||
command.UserId,
|
||||
"🗑 Ваш аккаунт удалён администратором.",
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return await identityService.DeleteUserAsync(command.UserId, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -9,35 +9,70 @@ using PnvPanel.Domain.Configs;
|
||||
namespace PnvPanel.Application.Admin.Users;
|
||||
|
||||
public sealed class ForceRevokeConfigCommandHandler(
|
||||
IAppDbContext dbContext, IXuiPanelGateway gateway, IRealtimeNotifier notifier,
|
||||
ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
|
||||
: ICommandHandler<ForceRevokeConfigCommand, Result>
|
||||
IAppDbContext dbContext,
|
||||
IXuiPanelGateway gateway,
|
||||
IRealtimeNotifier notifier,
|
||||
ITelegramNotifier telegramNotifier,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<ForceRevokeConfigCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(ForceRevokeConfigCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result> Handle(
|
||||
ForceRevokeConfigCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var config = await dbContext.VpnConfigs.FirstOrDefaultAsync(c => c.Id == command.ConfigId, cancellationToken);
|
||||
var config = await dbContext.VpnConfigs.FirstOrDefaultAsync(
|
||||
c => c.Id == command.ConfigId,
|
||||
cancellationToken
|
||||
);
|
||||
if (config is null)
|
||||
return Result.Failure(ConfigErrors.NotFound);
|
||||
|
||||
if (config.Status == ConfigStatus.Revoked)
|
||||
return Result.Success();
|
||||
|
||||
var inbound = await dbContext.Inbounds.AsNoTracking().FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
|
||||
var inbound = await dbContext
|
||||
.Inbounds.AsNoTracking()
|
||||
.FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
|
||||
var node = inbound is null
|
||||
? null
|
||||
: await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
|
||||
: await dbContext
|
||||
.Nodes.AsNoTracking()
|
||||
.FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
|
||||
|
||||
if (inbound is not null && node is not null)
|
||||
await gateway.RemoveClientAsync(node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, cancellationToken);
|
||||
await gateway.RemoveClientAsync(
|
||||
node,
|
||||
inbound.RemoteInboundId,
|
||||
config.ClientExternalId,
|
||||
config.Protocol,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
config.Revoke();
|
||||
await notifier.NotifyConfigStatusChangedAsync(config.UserId, config.Id, config.Status, cancellationToken);
|
||||
await notifier.NotifyConfigStatusChangedAsync(
|
||||
config.UserId,
|
||||
config.Id,
|
||||
config.Status,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
currentUser.UserId, "ConfigForceRevoked", "VpnConfig", config.Id.ToString(), metadata: null, AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
currentUser.UserId,
|
||||
"ConfigForceRevoked",
|
||||
"VpnConfig",
|
||||
config.Id.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
await telegramNotifier.NotifyUserAsync(
|
||||
config.UserId, $"⚠️ Администратор отозвал ваш конфиг «{config.Label ?? config.ClientEmail}».", cancellationToken);
|
||||
config.UserId,
|
||||
$"⚠️ Администратор отозвал ваш конфиг «{config.Label ?? config.ClientEmail}».",
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
@@ -10,11 +10,20 @@ namespace PnvPanel.Application.Admin.Users;
|
||||
public sealed class GetUserConfigsQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<GetUserConfigsQuery, Result<IReadOnlyList<VpnConfigDto>>>
|
||||
{
|
||||
public async Task<Result<IReadOnlyList<VpnConfigDto>>> Handle(GetUserConfigsQuery query, CancellationToken cancellationToken)
|
||||
public async Task<Result<IReadOnlyList<VpnConfigDto>>> Handle(
|
||||
GetUserConfigsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var rows = await dbContext.VpnConfigs.AsNoTracking()
|
||||
var rows = await dbContext
|
||||
.VpnConfigs.AsNoTracking()
|
||||
.Where(c => c.UserId == query.UserId && c.Status != ConfigStatus.Revoked)
|
||||
.Join(dbContext.Inbounds.AsNoTracking(), c => c.InboundId, i => i.Id, (c, i) => new { Config = c, Inbound = i })
|
||||
.Join(
|
||||
dbContext.Inbounds.AsNoTracking(),
|
||||
c => c.InboundId,
|
||||
i => i.Id,
|
||||
(c, i) => new { Config = c, Inbound = i }
|
||||
)
|
||||
.OrderByDescending(x => x.Config.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
|
||||
@@ -4,4 +4,5 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Users;
|
||||
|
||||
public sealed record ListUsersQuery(int Page, int PageSize, string? Search) : IQuery<Result<PagedList<UserSummaryDto>>>;
|
||||
public sealed record ListUsersQuery(int Page, int PageSize, string? Search)
|
||||
: IQuery<Result<PagedList<UserSummaryDto>>>;
|
||||
|
||||
@@ -7,12 +7,20 @@ namespace PnvPanel.Application.Admin.Users;
|
||||
public sealed class ListUsersQueryHandler(IIdentityService identityService)
|
||||
: IQueryHandler<ListUsersQuery, Result<PagedList<UserSummaryDto>>>
|
||||
{
|
||||
public async Task<Result<PagedList<UserSummaryDto>>> Handle(ListUsersQuery query, CancellationToken cancellationToken)
|
||||
public async Task<Result<PagedList<UserSummaryDto>>> Handle(
|
||||
ListUsersQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var page = query.Page <= 0 ? 1 : query.Page;
|
||||
var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize;
|
||||
|
||||
var result = await identityService.ListUsersAsync(page, pageSize, query.Search, cancellationToken);
|
||||
var result = await identityService.ListUsersAsync(
|
||||
page,
|
||||
pageSize,
|
||||
query.Search,
|
||||
cancellationToken
|
||||
);
|
||||
return Result.Success(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,17 +5,35 @@ using PnvPanel.Domain.Audit;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Users;
|
||||
|
||||
public sealed class ResetUserPasswordCommandHandler(IAppDbContext dbContext, IIdentityService identityService, ICurrentUser currentUser)
|
||||
: ICommandHandler<ResetUserPasswordCommand, Result>
|
||||
public sealed class ResetUserPasswordCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<ResetUserPasswordCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(ResetUserPasswordCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result> Handle(
|
||||
ResetUserPasswordCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await identityService.ResetPasswordAsync(command.UserId, command.NewPassword, cancellationToken);
|
||||
var result = await identityService.ResetPasswordAsync(
|
||||
command.UserId,
|
||||
command.NewPassword,
|
||||
cancellationToken
|
||||
);
|
||||
if (!result.IsSuccess)
|
||||
return result;
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
currentUser.UserId, "UserPasswordReset", "User", command.UserId.ToString(), metadata: null, AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
currentUser.UserId,
|
||||
"UserPasswordReset",
|
||||
"User",
|
||||
command.UserId.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success();
|
||||
|
||||
@@ -10,32 +10,52 @@ namespace PnvPanel.Application.Admin.Users;
|
||||
|
||||
/// <summary>Разблокировка возвращает в 3x-ui только конфиги, погашенные блокировкой (Disabled).</summary>
|
||||
public sealed class UnblockUserCommandHandler(
|
||||
IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway,
|
||||
IRealtimeNotifier notifier, ICurrentUser currentUser, ILogger<UnblockUserCommandHandler> logger)
|
||||
: ICommandHandler<UnblockUserCommand, Result>
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService,
|
||||
IXuiPanelGateway gateway,
|
||||
IRealtimeNotifier notifier,
|
||||
ICurrentUser currentUser,
|
||||
ILogger<UnblockUserCommandHandler> logger
|
||||
) : ICommandHandler<UnblockUserCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(UnblockUserCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result> Handle(
|
||||
UnblockUserCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var unblockResult = await identityService.UnblockUserAsync(command.UserId, cancellationToken);
|
||||
var unblockResult = await identityService.UnblockUserAsync(
|
||||
command.UserId,
|
||||
cancellationToken
|
||||
);
|
||||
if (!unblockResult.IsSuccess)
|
||||
return unblockResult;
|
||||
|
||||
var configs = await dbContext.VpnConfigs
|
||||
.Where(c => c.UserId == command.UserId && c.Status == ConfigStatus.Disabled)
|
||||
var configs = await dbContext
|
||||
.VpnConfigs.Where(c => c.UserId == command.UserId && c.Status == ConfigStatus.Disabled)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var config in configs)
|
||||
{
|
||||
var inbound = await dbContext.Inbounds.AsNoTracking().FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
|
||||
var inbound = await dbContext
|
||||
.Inbounds.AsNoTracking()
|
||||
.FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
|
||||
var node = inbound is null
|
||||
? null
|
||||
: await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
|
||||
: await dbContext
|
||||
.Nodes.AsNoTracking()
|
||||
.FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
|
||||
|
||||
if (inbound is not null && node is not null)
|
||||
{
|
||||
var updateResult = await gateway.UpdateClientAsync(
|
||||
node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol,
|
||||
config.Label ?? config.ClientEmail, enable: true, cancellationToken);
|
||||
node,
|
||||
inbound.RemoteInboundId,
|
||||
config.ClientExternalId,
|
||||
config.Protocol,
|
||||
config.Label ?? config.ClientEmail,
|
||||
enable: true,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (!updateResult.IsSuccess)
|
||||
{
|
||||
@@ -44,17 +64,34 @@ public sealed class UnblockUserCommandHandler(
|
||||
// повторным UnblockUserCommand (идемпотентен).
|
||||
logger.LogWarning(
|
||||
"Failed to enable client for config {ConfigId} on node {NodeId} while unblocking user {UserId}: {Error}",
|
||||
config.Id, node.Id, command.UserId, updateResult.Error);
|
||||
config.Id,
|
||||
node.Id,
|
||||
command.UserId,
|
||||
updateResult.Error
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
config.Enable();
|
||||
await notifier.NotifyConfigStatusChangedAsync(config.UserId, config.Id, config.Status, cancellationToken);
|
||||
await notifier.NotifyConfigStatusChangedAsync(
|
||||
config.UserId,
|
||||
config.Id,
|
||||
config.Status,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
currentUser.UserId, "UserUnblocked", "User", command.UserId.ToString(), metadata: null, AuditSource.Web));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
currentUser.UserId,
|
||||
"UserUnblocked",
|
||||
"User",
|
||||
command.UserId.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
@@ -4,8 +4,13 @@ namespace PnvPanel.Application.Admin.Users;
|
||||
|
||||
public static class UserErrors
|
||||
{
|
||||
public static readonly Error NotFound = Error.NotFound("Users.NotFound", "Пользователь не найден.");
|
||||
public static readonly Error NotFound = Error.NotFound(
|
||||
"Users.NotFound",
|
||||
"Пользователь не найден."
|
||||
);
|
||||
|
||||
public static readonly Error CannotDeleteSelf = Error.Validation(
|
||||
"Users.CannotDeleteSelf", "Нельзя удалить свою учётную запись здесь — используйте удаление аккаунта в Настройках.");
|
||||
"Users.CannotDeleteSelf",
|
||||
"Нельзя удалить свою учётную запись здесь — используйте удаление аккаунта в Настройках."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,13 @@ using PnvPanel.Domain.Apps;
|
||||
|
||||
namespace PnvPanel.Application.Apps;
|
||||
|
||||
public sealed record ClientAppDto(Guid Id, string Name, string DownloadUrl, string? Description, string? IconUrl)
|
||||
public sealed record ClientAppDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string DownloadUrl,
|
||||
string? Description,
|
||||
string? IconUrl
|
||||
)
|
||||
{
|
||||
public static ClientAppDto FromDomain(ClientApp app) =>
|
||||
new(app.Id, app.Name, app.DownloadUrl.ToString(), app.Description, app.IconUrl);
|
||||
|
||||
@@ -4,4 +4,6 @@ using PnvPanel.Domain.Apps;
|
||||
|
||||
namespace PnvPanel.Application.Apps;
|
||||
|
||||
public sealed record ListAppsQuery : IQuery<Result<IReadOnlyDictionary<OsPlatform, IReadOnlyList<ClientAppDto>>>>, IRequiresActivation;
|
||||
public sealed record ListAppsQuery
|
||||
: IQuery<Result<IReadOnlyDictionary<OsPlatform, IReadOnlyList<ClientAppDto>>>>,
|
||||
IRequiresActivation;
|
||||
|
||||
@@ -7,22 +7,30 @@ using PnvPanel.Domain.Apps;
|
||||
namespace PnvPanel.Application.Apps;
|
||||
|
||||
public sealed class ListAppsQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ListAppsQuery, Result<IReadOnlyDictionary<OsPlatform, IReadOnlyList<ClientAppDto>>>>
|
||||
: IQueryHandler<
|
||||
ListAppsQuery,
|
||||
Result<IReadOnlyDictionary<OsPlatform, IReadOnlyList<ClientAppDto>>>
|
||||
>
|
||||
{
|
||||
public async Task<Result<IReadOnlyDictionary<OsPlatform, IReadOnlyList<ClientAppDto>>>> Handle(
|
||||
ListAppsQuery query, CancellationToken cancellationToken)
|
||||
ListAppsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var apps = await dbContext.ClientApps.AsNoTracking()
|
||||
var apps = await dbContext
|
||||
.ClientApps.AsNoTracking()
|
||||
.Where(a => a.IsEnabled)
|
||||
.OrderBy(a => a.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var grouped = apps
|
||||
.GroupBy(a => a.OperatingSystem)
|
||||
var grouped = apps.GroupBy(a => a.OperatingSystem)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => (IReadOnlyList<ClientAppDto>)g.Select(ClientAppDto.FromDomain).ToList());
|
||||
g => (IReadOnlyList<ClientAppDto>)g.Select(ClientAppDto.FromDomain).ToList()
|
||||
);
|
||||
|
||||
return Result.Success<IReadOnlyDictionary<OsPlatform, IReadOnlyList<ClientAppDto>>>(grouped);
|
||||
return Result.Success<IReadOnlyDictionary<OsPlatform, IReadOnlyList<ClientAppDto>>>(
|
||||
grouped
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,24 +4,38 @@ namespace PnvPanel.Application.Auth;
|
||||
|
||||
public static class AuthErrors
|
||||
{
|
||||
public static readonly Error DuplicateUserName =
|
||||
Error.Conflict("Auth.DuplicateUserName", "Пользователь с таким именем уже существует.");
|
||||
public static readonly Error DuplicateUserName = Error.Conflict(
|
||||
"Auth.DuplicateUserName",
|
||||
"Пользователь с таким именем уже существует."
|
||||
);
|
||||
|
||||
public static readonly Error InvalidCredentials =
|
||||
Error.Unauthorized("Auth.InvalidCredentials", "Неверное имя пользователя или пароль.");
|
||||
public static readonly Error InvalidCredentials = Error.Unauthorized(
|
||||
"Auth.InvalidCredentials",
|
||||
"Неверное имя пользователя или пароль."
|
||||
);
|
||||
|
||||
public static readonly Error LockedOut =
|
||||
Error.Unauthorized("Auth.LockedOut", "Слишком много неудачных попыток входа. Попробуйте позже.");
|
||||
public static readonly Error LockedOut = Error.Unauthorized(
|
||||
"Auth.LockedOut",
|
||||
"Слишком много неудачных попыток входа. Попробуйте позже."
|
||||
);
|
||||
|
||||
public static readonly Error InvalidRefreshToken =
|
||||
Error.Unauthorized("Auth.InvalidRefreshToken", "Недействительный refresh-токен.");
|
||||
public static readonly Error InvalidRefreshToken = Error.Unauthorized(
|
||||
"Auth.InvalidRefreshToken",
|
||||
"Недействительный refresh-токен."
|
||||
);
|
||||
|
||||
public static readonly Error Unauthorized =
|
||||
Error.Unauthorized("Auth.Unauthorized", "Требуется аутентификация.");
|
||||
public static readonly Error Unauthorized = Error.Unauthorized(
|
||||
"Auth.Unauthorized",
|
||||
"Требуется аутентификация."
|
||||
);
|
||||
|
||||
public static readonly Error UserBlocked =
|
||||
Error.Forbidden("Auth.UserBlocked", "Аккаунт заблокирован администратором.");
|
||||
public static readonly Error UserBlocked = Error.Forbidden(
|
||||
"Auth.UserBlocked",
|
||||
"Аккаунт заблокирован администратором."
|
||||
);
|
||||
|
||||
public static readonly Error NotActivated =
|
||||
Error.Forbidden("Auth.NotActivated", "Аккаунт не активирован — обратитесь к администратору.");
|
||||
public static readonly Error NotActivated = Error.Forbidden(
|
||||
"Auth.NotActivated",
|
||||
"Аккаунт не активирован — обратитесь к администратору."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,4 +5,5 @@ public sealed record AuthResult(
|
||||
DateTimeOffset AccessTokenExpiresAt,
|
||||
string RefreshToken,
|
||||
DateTimeOffset RefreshTokenExpiresAt,
|
||||
CurrentUserDto User);
|
||||
CurrentUserDto User
|
||||
);
|
||||
|
||||
@@ -3,4 +3,5 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Auth.ChangePassword;
|
||||
|
||||
public sealed record ChangePasswordCommand(string CurrentPassword, string NewPassword) : ICommand<Result>;
|
||||
public sealed record ChangePasswordCommand(string CurrentPassword, string NewPassword)
|
||||
: ICommand<Result>;
|
||||
|
||||
+10
-3
@@ -4,14 +4,21 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Auth.ChangePassword;
|
||||
|
||||
public sealed class ChangePasswordCommandHandler(IIdentityService identityService, ICurrentUser currentUser)
|
||||
: ICommandHandler<ChangePasswordCommand, Result>
|
||||
public sealed class ChangePasswordCommandHandler(
|
||||
IIdentityService identityService,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<ChangePasswordCommand, Result>
|
||||
{
|
||||
public Task<Result> Handle(ChangePasswordCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return Task.FromResult(Result.Failure(AuthErrors.Unauthorized));
|
||||
|
||||
return identityService.ChangePasswordAsync(userId, command.CurrentPassword, command.NewPassword, cancellationToken);
|
||||
return identityService.ChangePasswordAsync(
|
||||
userId,
|
||||
command.CurrentPassword,
|
||||
command.NewPassword,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user