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