Enhance documentation with new features: added dark/light/system theme support, instructions page, and application catalog. Updated API and domain model for app management and automatic migrations on startup. Improved frontend structure with new routes and features for user instructions and app management.

This commit is contained in:
Leonid Pershin
2026-07-01 22:38:01 +03:00
parent d8930409fe
commit 1a8d33efa3
229 changed files with 9226 additions and 20 deletions
@@ -0,0 +1,58 @@
using PnvPanel.Domain.Common;
using PnvPanel.Domain.Exceptions;
namespace PnvPanel.Domain.Activation;
/// <summary>
/// Запрос пользователя на активацию (с комментарием), решение принимает админ на сайте или в Telegram.
/// Одновременно не более одного Pending-запроса на пользователя (инвариант проверяется на уровне Application).
/// </summary>
public sealed class ActivationRequest : Entity
{
public Guid UserId { get; private set; }
public string? Comment { get; private set; }
public ActivationStatus Status { get; private set; }
public Guid? DecidedBy { get; private set; }
public DateTimeOffset? DecidedAt { get; private set; }
public string? RejectionReason { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
private ActivationRequest()
{
}
public static ActivationRequest Create(Guid userId, string? comment)
{
return new ActivationRequest
{
Id = Guid.NewGuid(),
UserId = userId,
Comment = comment,
Status = ActivationStatus.Pending,
CreatedAt = DateTimeOffset.UtcNow,
};
}
public void Approve(Guid decidedBy)
{
EnsurePending();
Status = ActivationStatus.Approved;
DecidedBy = decidedBy;
DecidedAt = DateTimeOffset.UtcNow;
}
public void Reject(Guid decidedBy, string? reason)
{
EnsurePending();
Status = ActivationStatus.Rejected;
DecidedBy = decidedBy;
DecidedAt = DateTimeOffset.UtcNow;
RejectionReason = reason;
}
private void EnsurePending()
{
if (Status != ActivationStatus.Pending)
throw new DomainException("Запрос на активацию уже обработан.");
}
}
@@ -0,0 +1,8 @@
namespace PnvPanel.Domain.Activation;
public enum ActivationStatus
{
Pending,
Approved,
Rejected,
}
@@ -0,0 +1,45 @@
using PnvPanel.Domain.Common;
namespace PnvPanel.Domain.Apps;
/// <summary>Каталог рекомендуемых приложений-клиентов для подключения, ведёт админ.</summary>
public sealed class ClientApp : Entity
{
public string Name { get; private set; } = string.Empty;
public Uri DownloadUrl { get; private set; } = null!;
public OsPlatform OperatingSystem { get; private set; }
public string? Description { get; private set; }
public string? IconUrl { get; private set; }
public int SortOrder { get; private set; }
public bool IsEnabled { get; private set; }
private ClientApp()
{
}
public static ClientApp Create(string name, Uri downloadUrl, OsPlatform operatingSystem, string? description, string? iconUrl, int sortOrder)
{
return new ClientApp
{
Id = Guid.NewGuid(),
Name = name,
DownloadUrl = downloadUrl,
OperatingSystem = operatingSystem,
Description = description,
IconUrl = iconUrl,
SortOrder = sortOrder,
IsEnabled = true,
};
}
public void Update(string name, Uri downloadUrl, OsPlatform operatingSystem, string? description, string? iconUrl, int sortOrder, bool isEnabled)
{
Name = name;
DownloadUrl = downloadUrl;
OperatingSystem = operatingSystem;
Description = description;
IconUrl = iconUrl;
SortOrder = sortOrder;
IsEnabled = isEnabled;
}
}
@@ -0,0 +1,10 @@
namespace PnvPanel.Domain.Apps;
public enum OsPlatform
{
IOS,
Android,
Windows,
MacOS,
Linux,
}
@@ -0,0 +1,14 @@
namespace PnvPanel.Domain.Common;
public abstract class Entity
{
public Guid Id { get; protected init; }
public override bool Equals(object? obj) => obj is Entity other && other.GetType() == GetType() && other.Id == Id;
public override int GetHashCode() => HashCode.Combine(GetType(), Id);
public static bool operator ==(Entity? left, Entity? right) => Equals(left, right);
public static bool operator !=(Entity? left, Entity? right) => !Equals(left, right);
}
@@ -0,0 +1,10 @@
namespace PnvPanel.Domain.Configs;
public enum ConfigStatus
{
Active,
Disabled,
Expired,
LimitReached,
Revoked,
}
@@ -0,0 +1,89 @@
using System.Security.Cryptography;
using PnvPanel.Domain.Common;
using PnvPanel.Domain.Exceptions;
using PnvPanel.Domain.Inbounds;
namespace PnvPanel.Domain.Configs;
/// <summary>
/// Один конфиг = один клиент в 3x-ui, привязанный к пользователю. ClientExternalId — то, что
/// вернула панель при создании клиента (ThreeXui.Net отдаёт его как string — формат зависит от
/// протокола: UUID для VLESS/VMess, пароль для Trojan/Shadowsocks).
/// </summary>
public sealed class VpnConfig : Entity
{
public Guid UserId { get; private set; }
public Guid InboundId { get; private set; }
public string? Label { get; private set; }
public string ClientEmail { get; private set; } = string.Empty;
public string ClientExternalId { get; private set; } = string.Empty;
public VpnProtocol Protocol { get; private set; }
public int DeviceLimit { get; private set; }
public long UsedUpBytes { get; private set; }
public long UsedDownBytes { get; private set; }
public DateTimeOffset? ExpiresAt { get; private set; }
public ConfigStatus Status { get; private set; }
public string SubscriptionToken { get; private set; } = string.Empty;
public DateTimeOffset? LastSyncAt { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
private VpnConfig()
{
}
public static VpnConfig Create(Guid userId, Guid inboundId, VpnProtocol protocol, string? label, int deviceLimit)
{
return new VpnConfig
{
Id = Guid.NewGuid(),
UserId = userId,
InboundId = inboundId,
Protocol = protocol,
ClientEmail = GenerateClientEmail(userId),
ClientExternalId = string.Empty,
Label = label,
DeviceLimit = deviceLimit,
Status = ConfigStatus.Active,
SubscriptionToken = GenerateToken(),
CreatedAt = DateTimeOffset.UtcNow,
};
}
/// <summary>Проставляется после успешного ответа от 3x-ui (см. IXuiPanelGateway.AddClientAsync).</summary>
public void AssignRemoteClient(string clientExternalId) => ClientExternalId = clientExternalId;
public void Rename(string? label) => Label = label;
public void SetDeviceLimit(int deviceLimit) => DeviceLimit = deviceLimit;
public void Rotate(string newClientEmail, string newClientExternalId)
{
EnsureActive("перевыпустить");
ClientEmail = newClientEmail;
ClientExternalId = newClientExternalId;
SubscriptionToken = GenerateToken();
}
public void Revoke()
{
if (Status == ConfigStatus.Revoked)
throw new DomainException("Конфиг уже отозван.");
Status = ConfigStatus.Revoked;
}
private void EnsureActive(string action)
{
if (Status != ConfigStatus.Active)
throw new DomainException($"Нельзя {action} конфиг в статусе {Status}.");
}
public static string GenerateClientEmail(Guid userId)
{
var shortId = userId.ToString("N")[..8];
var rand = Convert.ToHexString(RandomNumberGenerator.GetBytes(4)).ToLowerInvariant();
return $"pnv_{shortId}_{rand}";
}
private static string GenerateToken() => Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
}
@@ -0,0 +1,4 @@
namespace PnvPanel.Domain.Exceptions;
/// <summary>Нарушение инварианта домена. На границе Application транслируется в Result-ошибку.</summary>
public class DomainException(string message) : Exception(message);
@@ -0,0 +1,60 @@
using PnvPanel.Domain.Common;
namespace PnvPanel.Domain.Inbounds;
/// <summary>
/// Проекция inbound из 3x-ui. <see cref="RemoteInboundId"/> — идентификатор на стороне панели
/// (в ThreeXui.Net это string, а не число — так отдаёт API 3x-ui).
/// AllowedRoleIds хранит только Guid ролей (не навигацию на AppRole — тот живёт в Infrastructure/Identity,
/// Domain не должен на него ссылаться).
/// </summary>
public sealed class Inbound : Entity
{
public Guid NodeId { get; private set; }
public string RemoteInboundId { get; private set; } = string.Empty;
public VpnProtocol Protocol { get; private set; }
public string Remark { get; private set; } = string.Empty;
public int Port { get; private set; }
public bool IsPublished { get; private set; }
public string? DisplayName { get; private set; }
public int? MaxClients { get; private set; }
public IReadOnlyList<Guid> AllowedRoleIds { get; private set; } = [];
public DateTimeOffset? LastSyncAt { get; private set; }
private Inbound()
{
}
public static Inbound FromRemote(Guid nodeId, string remoteInboundId, VpnProtocol protocol, string remark, int port)
{
return new Inbound
{
Id = Guid.NewGuid(),
NodeId = nodeId,
RemoteInboundId = remoteInboundId,
Protocol = protocol,
Remark = remark,
Port = port,
IsPublished = false,
LastSyncAt = DateTimeOffset.UtcNow,
};
}
public void UpdateFromRemote(VpnProtocol protocol, string remark, int port)
{
Protocol = protocol;
Remark = remark;
Port = port;
LastSyncAt = DateTimeOffset.UtcNow;
}
public void Publish(string? displayName, IReadOnlyCollection<Guid> allowedRoleIds, int? maxClients)
{
IsPublished = true;
DisplayName = displayName;
AllowedRoleIds = allowedRoleIds.Distinct().ToList();
MaxClients = maxClients;
}
public void Unpublish() => IsPublished = false;
}
@@ -0,0 +1,9 @@
namespace PnvPanel.Domain.Inbounds;
public enum VpnProtocol
{
Vless,
Vmess,
Trojan,
Shadowsocks,
}
+58
View File
@@ -0,0 +1,58 @@
using PnvPanel.Domain.Common;
using PnvPanel.Domain.Exceptions;
namespace PnvPanel.Domain.Nodes;
/// <summary>
/// Подключённая администратором панель 3x-ui. Недоступность/выключение ноды блокирует только
/// новые конфиги — существующие клиенты в 3x-ui не трогаем.
/// </summary>
public sealed class Node : Entity
{
public string Name { get; private set; } = string.Empty;
public Uri BaseAddress { get; private set; } = null!;
public NodeCredentials Credentials { get; private set; } = null!;
public string? Location { get; private set; }
public NodeStatus Status { get; private set; }
public bool IsEnabled { get; private set; }
public DateTimeOffset? LastSyncAt { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
private Node()
{
}
public static Node Register(string name, Uri baseAddress, NodeCredentials credentials, string? location)
{
if (!baseAddress.IsAbsoluteUri)
throw new DomainException("Адрес ноды должен быть абсолютным URI.");
return new Node
{
Id = Guid.NewGuid(),
Name = name,
BaseAddress = baseAddress,
Credentials = credentials,
Location = location,
Status = NodeStatus.Unknown,
IsEnabled = true,
CreatedAt = DateTimeOffset.UtcNow,
};
}
public void UpdateDetails(string name, string? location)
{
Name = name;
Location = location;
}
public void UpdateCredentials(NodeCredentials credentials) => Credentials = credentials;
public void Enable() => IsEnabled = true;
public void Disable() => IsEnabled = false;
public void UpdateStatus(NodeStatus status) => Status = status;
public void MarkSynced() => LastSyncAt = DateTimeOffset.UtcNow;
}
@@ -0,0 +1,10 @@
namespace PnvPanel.Domain.Nodes;
/// <summary>
/// Логин + зашифрованный пароль панели. Domain не знает о механизме шифрования (ISecretProtector —
/// порт Infrastructure); здесь хранится уже готовый шифротекст.
/// </summary>
public sealed record NodeCredentials(string Username, string ProtectedPassword)
{
public override string ToString() => $"NodeCredentials {{ Username = {Username}, ProtectedPassword = [REDACTED] }}";
}
@@ -0,0 +1,8 @@
namespace PnvPanel.Domain.Nodes;
public enum NodeStatus
{
Unknown,
Online,
Offline,
}
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>