Add inbound country flags and node load in location picker
- Inbound.CountryCode (ISO alpha-2), set on publish; flag shown before the location name (SVG via country-flag-icons on the site, emoji in bot) - Node.Capacity (optional, not a limit); available inbounds report load percent of capacity or active config count, sorted least loaded first Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d7743977a1
commit
bb5e59904f
51 files changed
+3160
-284
No files matched your search
@@ -41,6 +41,7 @@ public static class InboundEndpoints
|
||||
id,
|
||||
body.IsPublished,
|
||||
body.DisplayName,
|
||||
body.CountryCode,
|
||||
body.AllowedRoleIds ?? []
|
||||
);
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
@@ -61,5 +62,6 @@ public static class InboundEndpoints
|
||||
public sealed record PublishInboundBody(
|
||||
bool IsPublished,
|
||||
string? DisplayName,
|
||||
IReadOnlyList<Guid>? AllowedRoleIds
|
||||
IReadOnlyList<Guid>? AllowedRoleIds,
|
||||
string? CountryCode = null
|
||||
);
|
||||
@@ -57,7 +57,8 @@ public static class NodeEndpoints
|
||||
body.IsEnabled,
|
||||
body.NotifyOnStatusChange,
|
||||
body.Username,
|
||||
body.Password
|
||||
body.Password,
|
||||
body.Capacity
|
||||
);
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
@@ -101,5 +102,6 @@ public sealed record UpdateNodeBody(
|
||||
bool IsEnabled,
|
||||
bool NotifyOnStatusChange,
|
||||
string? Username,
|
||||
string? Password
|
||||
string? Password,
|
||||
int? Capacity = null
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace PnvPanel.Api.Telegram;
|
||||
|
||||
/// <summary>Emoji-флаг страны по ISO 3166-1 alpha-2 (пара regional indicator symbols). В Telegram
|
||||
/// флаги рисуются на всех платформах, поэтому здесь emoji, а на сайте — SVG (Windows emoji-флаги
|
||||
/// не рисует).</summary>
|
||||
internal static class CountryFlag
|
||||
{
|
||||
/// <summary>"DE" → "🇩🇪 " (с пробелом, для префикса к имени); пусто, если кода нет или он кривой.</summary>
|
||||
public static string Prefix(string? countryCode)
|
||||
{
|
||||
if (countryCode is not { Length: 2 } || !countryCode.All(char.IsAsciiLetter))
|
||||
return string.Empty;
|
||||
|
||||
var upper = countryCode.ToUpperInvariant();
|
||||
return char.ConvertFromUtf32(0x1F1E6 + upper[0] - 'A')
|
||||
+ char.ConvertFromUtf32(0x1F1E6 + upper[1] - 'A')
|
||||
+ " ";
|
||||
}
|
||||
}
|
||||
@@ -353,246 +353,246 @@ public sealed class PnvBotUpdateHandler(
|
||||
|
||||
switch (parts[0])
|
||||
{
|
||||
case "login":
|
||||
{
|
||||
var result =
|
||||
parts[1] == "approve"
|
||||
? await sender.Send(
|
||||
new ApproveTelegramLoginCommand(requestId, fromId),
|
||||
cancellationToken
|
||||
)
|
||||
: await sender.Send(
|
||||
new RejectTelegramLoginCommand(requestId, fromId),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
result.IsSuccess ? "Готово" : result.Error.Message,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
if (callback.Message is not null)
|
||||
{
|
||||
var statusText = result.IsSuccess
|
||||
? (parts[1] == "approve" ? "✅ Вход подтверждён." : "❌ Вход отклонён.")
|
||||
: $"⚠️ {result.Error.Message}";
|
||||
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
|
||||
await botClient.EditMessageText(
|
||||
chatId.Value,
|
||||
callback.Message.Id,
|
||||
text,
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
case "login":
|
||||
{
|
||||
var result =
|
||||
parts[1] == "approve"
|
||||
? await sender.Send(
|
||||
new ApproveTelegramLoginCommand(requestId, fromId),
|
||||
cancellationToken
|
||||
)
|
||||
: await sender.Send(
|
||||
new RejectTelegramLoginCommand(requestId, fromId),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
result.IsSuccess ? "Готово" : result.Error.Message,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
if (callback.Message is not null)
|
||||
{
|
||||
var statusText = result.IsSuccess
|
||||
? (parts[1] == "approve" ? "✅ Вход подтверждён." : "❌ Вход отклонён.")
|
||||
: $"⚠️ {result.Error.Message}";
|
||||
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
|
||||
await botClient.EditMessageText(
|
||||
chatId.Value,
|
||||
callback.Message.Id,
|
||||
text,
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "act":
|
||||
{
|
||||
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
|
||||
{
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
"Недостаточно прав.",
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var result =
|
||||
parts[1] == "approve"
|
||||
? await sender.Send(
|
||||
new ApproveActivationCommand(requestId),
|
||||
cancellationToken
|
||||
)
|
||||
: await sender.Send(
|
||||
new RejectActivationCommand(requestId, Reason: null),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
result.IsSuccess ? "Готово" : result.Error.Message,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
if (callback.Message is not null)
|
||||
{
|
||||
// Редактируем исходное сообщение с запросом вместо отдельного — иначе кнопки
|
||||
// «Активировать/Отклонить» остаются висеть под уже обработанным запросом (в т.ч.
|
||||
// если его обработали в другом месте — на сайте или из другого чата).
|
||||
var statusText = result.IsSuccess
|
||||
? (
|
||||
parts[1] == "approve"
|
||||
? "✅ Пользователь активирован."
|
||||
: "❌ Запрос отклонён."
|
||||
)
|
||||
: $"⚠️ {result.Error.Message}";
|
||||
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
|
||||
await botClient.EditMessageText(
|
||||
chatId.Value,
|
||||
callback.Message.Id,
|
||||
text,
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
case "act":
|
||||
{
|
||||
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
|
||||
{
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
"Недостаточно прав.",
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var result =
|
||||
parts[1] == "approve"
|
||||
? await sender.Send(
|
||||
new ApproveActivationCommand(requestId),
|
||||
cancellationToken
|
||||
)
|
||||
: await sender.Send(
|
||||
new RejectActivationCommand(requestId, Reason: null),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
result.IsSuccess ? "Готово" : result.Error.Message,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
if (callback.Message is not null)
|
||||
{
|
||||
// Редактируем исходное сообщение с запросом вместо отдельного — иначе кнопки
|
||||
// «Активировать/Отклонить» остаются висеть под уже обработанным запросом (в т.ч.
|
||||
// если его обработали в другом месте — на сайте или из другого чата).
|
||||
var statusText = result.IsSuccess
|
||||
? (
|
||||
parts[1] == "approve"
|
||||
? "✅ Пользователь активирован."
|
||||
: "❌ Запрос отклонён."
|
||||
)
|
||||
: $"⚠️ {result.Error.Message}";
|
||||
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
|
||||
await botClient.EditMessageText(
|
||||
chatId.Value,
|
||||
callback.Message.Id,
|
||||
text,
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "erq":
|
||||
{
|
||||
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
|
||||
{
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
"Недостаточно прав.",
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var result =
|
||||
parts[1] == "approve"
|
||||
? await sender.Send(
|
||||
new ApproveExtensionRequestCommand(requestId),
|
||||
cancellationToken
|
||||
)
|
||||
: await sender.Send(
|
||||
new RejectExtensionRequestCommand(requestId, Reason: null),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
result.IsSuccess ? "Готово" : result.Error.Message,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
if (callback.Message is not null)
|
||||
{
|
||||
var statusText = result.IsSuccess
|
||||
? (
|
||||
parts[1] == "approve"
|
||||
? "✅ Заявка на продление одобрена."
|
||||
: "❌ Заявка отклонена."
|
||||
)
|
||||
: $"⚠️ {result.Error.Message}";
|
||||
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
|
||||
await botClient.EditMessageText(
|
||||
chatId.Value,
|
||||
callback.Message.Id,
|
||||
text,
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
case "erq":
|
||||
{
|
||||
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
|
||||
{
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
"Недостаточно прав.",
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var result =
|
||||
parts[1] == "approve"
|
||||
? await sender.Send(
|
||||
new ApproveExtensionRequestCommand(requestId),
|
||||
cancellationToken
|
||||
)
|
||||
: await sender.Send(
|
||||
new RejectExtensionRequestCommand(requestId, Reason: null),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
result.IsSuccess ? "Готово" : result.Error.Message,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
if (callback.Message is not null)
|
||||
{
|
||||
var statusText = result.IsSuccess
|
||||
? (
|
||||
parts[1] == "approve"
|
||||
? "✅ Заявка на продление одобрена."
|
||||
: "❌ Заявка отклонена."
|
||||
)
|
||||
: $"⚠️ {result.Error.Message}";
|
||||
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
|
||||
await botClient.EditMessageText(
|
||||
chatId.Value,
|
||||
callback.Message.Id,
|
||||
text,
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "pay":
|
||||
{
|
||||
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
|
||||
{
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
"Недостаточно прав.",
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var result =
|
||||
parts[1] == "approve"
|
||||
? await sender.Send(
|
||||
new ConfirmPaymentRequestCommand(requestId),
|
||||
cancellationToken
|
||||
)
|
||||
: await sender.Send(
|
||||
new RejectPaymentRequestCommand(requestId, Reason: null),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
result.IsSuccess ? "Готово" : result.Error.Message,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
if (callback.Message is not null)
|
||||
{
|
||||
var statusText = result.IsSuccess
|
||||
? (
|
||||
parts[1] == "approve"
|
||||
? "✅ Оплата подтверждена."
|
||||
: "❌ Оплата отклонена."
|
||||
)
|
||||
: $"⚠️ {result.Error.Message}";
|
||||
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
|
||||
await botClient.EditMessageText(
|
||||
chatId.Value,
|
||||
callback.Message.Id,
|
||||
text,
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
case "pay":
|
||||
{
|
||||
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
|
||||
{
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
"Недостаточно прав.",
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var result =
|
||||
parts[1] == "approve"
|
||||
? await sender.Send(
|
||||
new ConfirmPaymentRequestCommand(requestId),
|
||||
cancellationToken
|
||||
)
|
||||
: await sender.Send(
|
||||
new RejectPaymentRequestCommand(requestId, Reason: null),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
result.IsSuccess ? "Готово" : result.Error.Message,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
if (callback.Message is not null)
|
||||
{
|
||||
var statusText = result.IsSuccess
|
||||
? (
|
||||
parts[1] == "approve"
|
||||
? "✅ Оплата подтверждена."
|
||||
: "❌ Оплата отклонена."
|
||||
)
|
||||
: $"⚠️ {result.Error.Message}";
|
||||
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
|
||||
await botClient.EditMessageText(
|
||||
chatId.Value,
|
||||
callback.Message.Id,
|
||||
text,
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "cfg":
|
||||
{
|
||||
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
|
||||
{
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
"Telegram не привязан.",
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var linkResult = await sender.Send(
|
||||
new GetConfigLinkQuery(requestId),
|
||||
cancellationToken
|
||||
);
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
|
||||
if (!linkResult.IsSuccess)
|
||||
{
|
||||
await botClient.SendMessage(
|
||||
chatId.Value,
|
||||
$"Не удалось получить ссылку: {linkResult.Error.Message}",
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (callback.Message is null)
|
||||
break;
|
||||
|
||||
// Редактируем то же сообщение (не плодим отдельное с сырым URL) — ссылка моноширинным
|
||||
// блоком, по нему в Telegram можно тапнуть и скопировать целиком одним движением.
|
||||
// Убираем именно эту кнопку из клавиатуры — остальные конфиги и «В меню» остаются на месте.
|
||||
var text =
|
||||
$"{Escape(callback.Message.Text ?? "")}\n\n<code>{Escape(linkResult.Value.ConnectionString)}</code>";
|
||||
var remainingRows = (callback.Message.ReplyMarkup?.InlineKeyboard ?? [])
|
||||
.Where(row => row.All(b => b.CallbackData != data))
|
||||
.ToArray();
|
||||
|
||||
await botClient.EditMessageText(
|
||||
chatId.Value,
|
||||
callback.Message.Id,
|
||||
text,
|
||||
parseMode: ParseMode.Html,
|
||||
replyMarkup: remainingRows.Length > 0
|
||||
? new InlineKeyboardMarkup(remainingRows)
|
||||
: null,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
|
||||
break;
|
||||
case "cfg":
|
||||
{
|
||||
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
|
||||
{
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
"Telegram не привязан.",
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var linkResult = await sender.Send(
|
||||
new GetConfigLinkQuery(requestId),
|
||||
cancellationToken
|
||||
);
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
|
||||
if (!linkResult.IsSuccess)
|
||||
{
|
||||
await botClient.SendMessage(
|
||||
chatId.Value,
|
||||
$"Не удалось получить ссылку: {linkResult.Error.Message}",
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (callback.Message is null)
|
||||
break;
|
||||
|
||||
// Редактируем то же сообщение (не плодим отдельное с сырым URL) — ссылка моноширинным
|
||||
// блоком, по нему в Telegram можно тапнуть и скопировать целиком одним движением.
|
||||
// Убираем именно эту кнопку из клавиатуры — остальные конфиги и «В меню» остаются на месте.
|
||||
var text =
|
||||
$"{Escape(callback.Message.Text ?? "")}\n\n<code>{Escape(linkResult.Value.ConnectionString)}</code>";
|
||||
var remainingRows = (callback.Message.ReplyMarkup?.InlineKeyboard ?? [])
|
||||
.Where(row => row.All(b => b.CallbackData != data))
|
||||
.ToArray();
|
||||
|
||||
await botClient.EditMessageText(
|
||||
chatId.Value,
|
||||
callback.Message.Id,
|
||||
text,
|
||||
parseMode: ParseMode.Html,
|
||||
replyMarkup: remainingRows.Length > 0
|
||||
? new InlineKeyboardMarkup(remainingRows)
|
||||
: null,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -730,7 +730,7 @@ public sealed class PnvBotUpdateHandler(
|
||||
+ string.Join(
|
||||
'\n',
|
||||
result.Value.Configs.Select(c =>
|
||||
$"• {c.Label ?? c.Location} ({c.Protocol}) — {c.Status}"
|
||||
$"• {CountryFlag.Prefix(c.CountryCode)}{c.Label ?? c.Location} ({c.Protocol}) — {c.Status}"
|
||||
)
|
||||
);
|
||||
|
||||
@@ -741,7 +741,7 @@ public sealed class PnvBotUpdateHandler(
|
||||
new[]
|
||||
{
|
||||
InlineKeyboardButton.WithCallbackData(
|
||||
$"🔗 {c.Label ?? c.Location}",
|
||||
$"🔗 {CountryFlag.Prefix(c.CountryCode)}{c.Label ?? c.Location}",
|
||||
$"cfg:link:{c.Id}"
|
||||
),
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ public sealed record AdminVpnConfigDto(
|
||||
string ClientEmail,
|
||||
VpnProtocol Protocol,
|
||||
string Location,
|
||||
string? CountryCode,
|
||||
string NodeName,
|
||||
long UsedUpBytes,
|
||||
long UsedDownBytes,
|
||||
|
||||
@@ -82,6 +82,7 @@ public sealed class ListAllConfigsQueryHandler(
|
||||
c.ClientEmail,
|
||||
c.Protocol,
|
||||
inbound?.DisplayName ?? inbound?.Remark ?? "?",
|
||||
inbound?.CountryCode,
|
||||
node?.Name ?? "?",
|
||||
c.UsedUpBytes,
|
||||
c.UsedDownBytes,
|
||||
|
||||
@@ -12,6 +12,7 @@ public sealed record InboundDto(
|
||||
bool IsPublished,
|
||||
bool IsAvailable,
|
||||
string? DisplayName,
|
||||
string? CountryCode,
|
||||
IReadOnlyList<Guid> AllowedRoleIds,
|
||||
DateTimeOffset? LastSyncAt
|
||||
)
|
||||
@@ -27,6 +28,7 @@ public sealed record InboundDto(
|
||||
inbound.IsPublished,
|
||||
inbound.IsAvailable,
|
||||
inbound.DisplayName,
|
||||
inbound.CountryCode,
|
||||
inbound.AllowedRoleIds,
|
||||
inbound.LastSyncAt
|
||||
);
|
||||
|
||||
@@ -7,5 +7,6 @@ public sealed record PublishInboundCommand(
|
||||
Guid InboundId,
|
||||
bool IsPublished,
|
||||
string? DisplayName,
|
||||
string? CountryCode,
|
||||
IReadOnlyList<Guid> AllowedRoleIds
|
||||
) : ICommand<Result<InboundDto>>;
|
||||
@@ -22,7 +22,7 @@ public sealed class PublishInboundCommandHandler(IAppDbContext dbContext, ICurre
|
||||
return Result.Failure<InboundDto>(InboundErrors.NotFound);
|
||||
|
||||
if (command.IsPublished)
|
||||
inbound.Publish(command.DisplayName, command.AllowedRoleIds);
|
||||
inbound.Publish(command.DisplayName, command.CountryCode, command.AllowedRoleIds);
|
||||
else
|
||||
inbound.Unpublish();
|
||||
|
||||
|
||||
@@ -7,5 +7,9 @@ public sealed class PublishInboundCommandValidator : AbstractValidator<PublishIn
|
||||
public PublishInboundCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.DisplayName).MaximumLength(100);
|
||||
RuleFor(x => x.CountryCode)
|
||||
.Matches("^[A-Za-z]{2}$")
|
||||
.When(x => !string.IsNullOrWhiteSpace(x.CountryCode))
|
||||
.WithMessage("CountryCode must be an ISO 3166-1 alpha-2 code.");
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ public sealed record NodeDto(
|
||||
NodeStatus Status,
|
||||
bool IsEnabled,
|
||||
bool NotifyOnStatusChange,
|
||||
int? Capacity,
|
||||
DateTimeOffset? LastSyncAt
|
||||
)
|
||||
{
|
||||
@@ -25,6 +26,7 @@ public sealed record NodeDto(
|
||||
node.Status,
|
||||
node.IsEnabled,
|
||||
node.NotifyOnStatusChange,
|
||||
node.Capacity,
|
||||
node.LastSyncAt
|
||||
);
|
||||
}
|
||||
@@ -11,5 +11,6 @@ public sealed record UpdateNodeCommand(
|
||||
bool IsEnabled,
|
||||
bool NotifyOnStatusChange,
|
||||
string? Username,
|
||||
string? Password
|
||||
string? Password,
|
||||
int? Capacity
|
||||
) : ICommand<Result<NodeDto>>;
|
||||
@@ -48,6 +48,7 @@ public sealed class UpdateNodeCommandHandler(
|
||||
node.Disable();
|
||||
|
||||
node.SetNotifyOnStatusChange(command.NotifyOnStatusChange);
|
||||
node.SetCapacity(command.Capacity);
|
||||
|
||||
var credentialsChanged =
|
||||
!string.IsNullOrWhiteSpace(command.Username)
|
||||
|
||||
@@ -10,5 +10,6 @@ public sealed class UpdateNodeCommandValidator : AbstractValidator<UpdateNodeCom
|
||||
RuleFor(x => x.BaseAddress).NotEmpty().MaximumLength(500);
|
||||
RuleFor(x => x.Location).MaximumLength(100);
|
||||
RuleFor(x => x.Username).MaximumLength(200);
|
||||
RuleFor(x => x.Capacity).GreaterThan(0).When(x => x.Capacity is not null);
|
||||
}
|
||||
}
|
||||
+12
-2
@@ -9,5 +9,15 @@ public sealed record ListAvailableInboundsQuery
|
||||
: IQuery<Result<IReadOnlyList<AvailableInboundDto>>>,
|
||||
IRequiresActivation;
|
||||
|
||||
/// <summary>Витринная карточка инбаунда для выбора при создании конфига — без деталей 3x-ui.</summary>
|
||||
public sealed record AvailableInboundDto(Guid InboundId, string DisplayName, VpnProtocol Protocol);
|
||||
/// <summary>Витринная карточка инбаунда для выбора при создании конфига — без деталей 3x-ui.
|
||||
/// Загрузка — по ноде целиком (все её инбаунды делят один сервер), по активным конфигам. Если у
|
||||
/// ноды задана ёмкость — отдаём только процент (<see cref="LoadPercent"/>, может быть больше 100:
|
||||
/// ёмкость не лимит), абсолютное число не раскрываем; иначе — только <see cref="NodeConfigCount"/>.</summary>
|
||||
public sealed record AvailableInboundDto(
|
||||
Guid InboundId,
|
||||
string DisplayName,
|
||||
VpnProtocol Protocol,
|
||||
string? CountryCode,
|
||||
int? LoadPercent,
|
||||
int? NodeConfigCount
|
||||
);
|
||||
+52
-11
@@ -3,6 +3,7 @@ using PnvPanel.Application.Auth;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using LiteCqrs;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Domain.Configs;
|
||||
|
||||
namespace PnvPanel.Application.Configs.ListAvailableInbounds;
|
||||
|
||||
@@ -24,22 +25,62 @@ public sealed class ListAvailableInboundsQueryHandler(
|
||||
if (profile is null)
|
||||
return Result.Failure<IReadOnlyList<AvailableInboundDto>>(AuthErrors.Unauthorized);
|
||||
|
||||
var enabledNodeIds = dbContext
|
||||
.Nodes.AsNoTracking()
|
||||
.Where(n => n.IsEnabled)
|
||||
.Select(n => n.Id);
|
||||
var enabledNodes = dbContext.Nodes.AsNoTracking().Where(n => n.IsEnabled);
|
||||
|
||||
var inbounds = await dbContext
|
||||
.Inbounds.AsNoTracking()
|
||||
.Where(i =>
|
||||
i.IsPublished
|
||||
&& enabledNodeIds.Contains(i.NodeId)
|
||||
&& i.AllowedRoleIds.Contains(profile.RoleId)
|
||||
.Where(i => i.IsPublished && i.AllowedRoleIds.Contains(profile.RoleId))
|
||||
.Join(
|
||||
enabledNodes,
|
||||
i => i.NodeId,
|
||||
n => n.Id,
|
||||
(i, n) => new
|
||||
{
|
||||
i.Id,
|
||||
i.NodeId,
|
||||
DisplayName = i.DisplayName ?? i.Remark,
|
||||
i.Protocol,
|
||||
i.CountryCode,
|
||||
n.Capacity,
|
||||
}
|
||||
)
|
||||
.OrderBy(i => i.DisplayName ?? i.Remark)
|
||||
.Select(i => new AvailableInboundDto(i.Id, i.DisplayName ?? i.Remark, i.Protocol))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Success<IReadOnlyList<AvailableInboundDto>>(inbounds);
|
||||
var nodeIds = inbounds.Select(i => i.NodeId).Distinct().ToList();
|
||||
// Считаем по всем инбаундам ноды (в т.ч. неопубликованным и недоступным этой роли) — сервер
|
||||
// грузят все его клиенты, а не только видимые этому пользователю.
|
||||
var activeCountByNode = await dbContext
|
||||
.VpnConfigs.AsNoTracking()
|
||||
.Where(c => c.Status == ConfigStatus.Active)
|
||||
.Join(dbContext.Inbounds.AsNoTracking(), c => c.InboundId, i => i.Id, (c, i) => i.NodeId)
|
||||
.Where(nodeId => nodeIds.Contains(nodeId))
|
||||
.GroupBy(nodeId => nodeId)
|
||||
.Select(g => new { NodeId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.NodeId, x => x.Count, cancellationToken);
|
||||
|
||||
var result = inbounds
|
||||
.Select(i =>
|
||||
{
|
||||
var count = activeCountByNode.GetValueOrDefault(i.NodeId);
|
||||
var loadPercent = i.Capacity is { } capacity
|
||||
? (int)Math.Round(count * 100.0 / capacity)
|
||||
: (int?)null;
|
||||
return new AvailableInboundDto(
|
||||
i.Id,
|
||||
i.DisplayName,
|
||||
i.Protocol,
|
||||
i.CountryCode,
|
||||
loadPercent,
|
||||
loadPercent is null ? count : null
|
||||
);
|
||||
})
|
||||
// Менее загруженные — первыми. Ноды с ёмкостью (сравнимый процент) идут раньше нод без
|
||||
// неё: сырое число конфигов с процентом не сравнить.
|
||||
.OrderBy(i => i.LoadPercent is null)
|
||||
.ThenBy(i => i.LoadPercent ?? i.NodeConfigCount)
|
||||
.ThenBy(i => i.DisplayName, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return Result.Success<IReadOnlyList<AvailableInboundDto>>(result);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ public sealed record VpnConfigDto(
|
||||
string ClientEmail,
|
||||
VpnProtocol Protocol,
|
||||
string Location,
|
||||
string? CountryCode,
|
||||
long UsedUpBytes,
|
||||
long UsedDownBytes,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
@@ -29,6 +30,7 @@ public sealed record VpnConfigDto(
|
||||
config.ClientEmail,
|
||||
config.Protocol,
|
||||
inbound.DisplayName ?? inbound.Remark,
|
||||
inbound.CountryCode,
|
||||
config.UsedUpBytes,
|
||||
config.UsedDownBytes,
|
||||
config.ExpiresAt,
|
||||
|
||||
@@ -18,6 +18,10 @@ public sealed class Inbound : Entity
|
||||
public bool IsPublished { get; private set; }
|
||||
public bool IsAvailable { get; private set; } = true;
|
||||
public string? DisplayName { get; private set; }
|
||||
|
||||
/// <summary>ISO 3166-1 alpha-2 (верхний регистр) — страна, флаг которой пользователь видит перед
|
||||
/// именем инбаунда. Только для отображения, на подключение не влияет.</summary>
|
||||
public string? CountryCode { get; private set; }
|
||||
public IReadOnlyList<Guid> AllowedRoleIds { get; private set; } = [];
|
||||
public DateTimeOffset? LastSyncAt { get; private set; }
|
||||
|
||||
@@ -57,11 +61,18 @@ public sealed class Inbound : Entity
|
||||
IsAvailable = true;
|
||||
}
|
||||
|
||||
public void Publish(string? displayName, IReadOnlyCollection<Guid> allowedRoleIds)
|
||||
public void Publish(
|
||||
string? displayName,
|
||||
string? countryCode,
|
||||
IReadOnlyCollection<Guid> allowedRoleIds
|
||||
)
|
||||
{
|
||||
IsPublished = true;
|
||||
IsAvailable = true;
|
||||
DisplayName = displayName;
|
||||
CountryCode = string.IsNullOrWhiteSpace(countryCode)
|
||||
? null
|
||||
: countryCode.Trim().ToUpperInvariant();
|
||||
AllowedRoleIds = allowedRoleIds.Distinct().ToList();
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,11 @@ public sealed class Node : Entity
|
||||
/// (таймаут/реавторизация) не должна ронять ноду в Offline и спамить уведомлениями.</summary>
|
||||
public int ConsecutiveProbeFailures { get; private set; }
|
||||
|
||||
/// <summary>Ёмкость ноды — сколько активных конфигов она нормально тянет, задаёт админ. Не лимит:
|
||||
/// только знаменатель для загрузки, которую пользователь видит при выборе локации
|
||||
/// (ListAvailableInbounds). null — не задана, тогда пользователю показывается число конфигов.</summary>
|
||||
public int? Capacity { get; private set; }
|
||||
|
||||
public bool IsEnabled { get; private set; }
|
||||
public bool NotifyOnStatusChange { get; private set; }
|
||||
public DateTimeOffset? LastSyncAt { get; private set; }
|
||||
@@ -73,6 +78,14 @@ public sealed class Node : Entity
|
||||
? baseAddress
|
||||
: new UriBuilder(baseAddress) { Path = baseAddress.AbsolutePath + "/" }.Uri;
|
||||
|
||||
public void SetCapacity(int? capacity)
|
||||
{
|
||||
if (capacity is <= 0)
|
||||
throw new DomainException("Ёмкость ноды должна быть положительной.");
|
||||
|
||||
Capacity = capacity;
|
||||
}
|
||||
|
||||
public void UpdateCredentials(NodeCredentials credentials) => Credentials = credentials;
|
||||
|
||||
/// <summary>Слать админам в Telegram уведомление при каждом переходе Online↔Offline (см.
|
||||
|
||||
@@ -15,6 +15,7 @@ public class InboundConfiguration : IEntityTypeConfiguration<Inbound>
|
||||
builder.Property(x => x.Protocol).HasConversion<string>().HasMaxLength(32);
|
||||
builder.Property(x => x.Remark).IsRequired().HasMaxLength(200);
|
||||
builder.Property(x => x.DisplayName).HasMaxLength(100);
|
||||
builder.Property(x => x.CountryCode).HasMaxLength(2);
|
||||
|
||||
builder.Property(x => x.AllowedRoleIds).HasColumnType("uuid[]");
|
||||
|
||||
|
||||
+1138
File diff suppressed because it is too large.
Load diff
+29
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddInboundCountryCode : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CountryCode",
|
||||
table: "Inbounds",
|
||||
type: "character varying(2)",
|
||||
maxLength: 2,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CountryCode",
|
||||
table: "Inbounds");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1141
File diff suppressed because it is too large.
Load diff
+28
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddNodeCapacity : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Capacity",
|
||||
table: "Nodes",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Capacity",
|
||||
table: "Nodes");
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -426,6 +426,10 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("uuid[]");
|
||||
|
||||
b.Property<string>("CountryCode")
|
||||
.HasMaxLength(2)
|
||||
.HasColumnType("character varying(2)");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
@@ -597,6 +601,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<int?>("Capacity")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ConsecutiveProbeFailures")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ public class ListAllConfigsQueryHandlerTests
|
||||
null
|
||||
);
|
||||
var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443);
|
||||
inbound.Publish("Germany", []);
|
||||
inbound.Publish("Germany", null, []);
|
||||
var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "my-config");
|
||||
|
||||
dbContext.Nodes.Add(node);
|
||||
|
||||
+6
-4
@@ -22,7 +22,7 @@ public class PublishInboundCommandHandlerTests
|
||||
var roleId = Guid.NewGuid();
|
||||
var handler = new PublishInboundCommandHandler(dbContext, _currentUser);
|
||||
|
||||
var command = new PublishInboundCommand(inbound.Id, true, "EU Fast", [roleId]);
|
||||
var command = new PublishInboundCommand(inbound.Id, true, "EU Fast", "de", [roleId]);
|
||||
|
||||
var result = await handler.Handle(command, CancellationToken.None);
|
||||
|
||||
@@ -31,6 +31,8 @@ public class PublishInboundCommandHandlerTests
|
||||
Assert.Equal("EU Fast", inbound.DisplayName);
|
||||
Assert.Contains(roleId, inbound.AllowedRoleIds);
|
||||
Assert.Equal("EU Fast", result.Value.DisplayName);
|
||||
Assert.Equal("DE", inbound.CountryCode);
|
||||
Assert.Equal("DE", result.Value.CountryCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -38,13 +40,13 @@ public class PublishInboundCommandHandlerTests
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var inbound = Inbound.FromRemote(Guid.NewGuid(), "1", VpnProtocol.Vless, "remark", 443);
|
||||
inbound.Publish("EU Fast", [Guid.NewGuid()]);
|
||||
inbound.Publish("EU Fast", null, [Guid.NewGuid()]);
|
||||
dbContext.Inbounds.Add(inbound);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var handler = new PublishInboundCommandHandler(dbContext, _currentUser);
|
||||
|
||||
var command = new PublishInboundCommand(inbound.Id, false, null, []);
|
||||
var command = new PublishInboundCommand(inbound.Id, false, null, null, []);
|
||||
|
||||
var result = await handler.Handle(command, CancellationToken.None);
|
||||
|
||||
@@ -59,7 +61,7 @@ public class PublishInboundCommandHandlerTests
|
||||
|
||||
var handler = new PublishInboundCommandHandler(dbContext, _currentUser);
|
||||
|
||||
var command = new PublishInboundCommand(Guid.NewGuid(), true, "EU Fast", []);
|
||||
var command = new PublishInboundCommand(Guid.NewGuid(), true, "EU Fast", null, []);
|
||||
|
||||
var result = await handler.Handle(command, CancellationToken.None);
|
||||
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
using PnvPanel.Application.Admin.Inbounds;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Admin.Inbounds;
|
||||
|
||||
public class PublishInboundCommandValidatorTests
|
||||
{
|
||||
private readonly PublishInboundCommandValidator _validator = new();
|
||||
|
||||
private static PublishInboundCommand Command(string? countryCode) =>
|
||||
new(Guid.NewGuid(), true, "EU Fast", countryCode, []);
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("DE")]
|
||||
[InlineData("nl")]
|
||||
public void Validate_WithEmptyOrAlpha2CountryCode_IsValid(string? countryCode)
|
||||
{
|
||||
var result = _validator.Validate(Command(countryCode));
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("DEU")]
|
||||
[InlineData("D")]
|
||||
[InlineData("1E")]
|
||||
public void Validate_WithInvalidCountryCode_IsInvalid(string countryCode)
|
||||
{
|
||||
var result = _validator.Validate(Command(countryCode));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ public class SyncNodeCommandHandlerTests
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var node = CreateNode();
|
||||
var inbound = Inbound.FromRemote(node.Id, "old-id", VpnProtocol.Vless, "old", 443);
|
||||
inbound.Publish("Old", [Guid.NewGuid()]);
|
||||
inbound.Publish("Old", null, [Guid.NewGuid()]);
|
||||
var config = VpnConfig.Create(Guid.NewGuid(), inbound.Id, VpnProtocol.Vless, null);
|
||||
config.AssignRemoteClient("external-id");
|
||||
|
||||
|
||||
+9
-2
@@ -40,6 +40,7 @@ public class UpdateNodeCommandHandlerTests
|
||||
true,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
@@ -69,6 +70,7 @@ public class UpdateNodeCommandHandlerTests
|
||||
true,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
@@ -90,7 +92,7 @@ public class UpdateNodeCommandHandlerTests
|
||||
|
||||
var handler = new UpdateNodeCommandHandler(dbContext, _gateway, _secretProtector, _currentUser);
|
||||
|
||||
var command = new UpdateNodeCommand(node.Id, "node-1", "not-a-uri", null, true, false, null, null);
|
||||
var command = new UpdateNodeCommand(node.Id, "node-1", "not-a-uri", null, true, false, null, null, null);
|
||||
|
||||
var result = await handler.Handle(command, CancellationToken.None);
|
||||
|
||||
@@ -121,6 +123,7 @@ public class UpdateNodeCommandHandlerTests
|
||||
true,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
@@ -152,7 +155,8 @@ public class UpdateNodeCommandHandlerTests
|
||||
true,
|
||||
false,
|
||||
"new-admin",
|
||||
"new-password"
|
||||
"new-password",
|
||||
null
|
||||
);
|
||||
|
||||
var result = await handler.Handle(command, CancellationToken.None);
|
||||
@@ -181,6 +185,7 @@ public class UpdateNodeCommandHandlerTests
|
||||
true,
|
||||
false,
|
||||
"new-admin",
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
@@ -211,6 +216,7 @@ public class UpdateNodeCommandHandlerTests
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
@@ -235,6 +241,7 @@ public class UpdateNodeCommandHandlerTests
|
||||
true,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ public class CreateVpnConfigCommandHandlerTests
|
||||
null
|
||||
);
|
||||
var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443);
|
||||
inbound.Publish(null, [roleId]);
|
||||
inbound.Publish(null, null, [roleId]);
|
||||
|
||||
dbContext.Nodes.Add(node);
|
||||
dbContext.Inbounds.Add(inbound);
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ public class GetMyConfigsQueryHandlerTests
|
||||
var otherUserId = Guid.NewGuid();
|
||||
|
||||
var inbound = Inbound.FromRemote(Guid.NewGuid(), "1", VpnProtocol.Vless, "remark", 443);
|
||||
inbound.Publish("My inbound", []);
|
||||
inbound.Publish("My inbound", null, []);
|
||||
|
||||
var activeConfig = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "Active");
|
||||
var revokedConfig = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "Revoked");
|
||||
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Configs.ListAvailableInbounds;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using PnvPanel.Domain.Configs;
|
||||
using PnvPanel.Domain.Inbounds;
|
||||
using PnvPanel.Domain.Nodes;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Configs.ListAvailableInbounds;
|
||||
|
||||
public class ListAvailableInboundsQueryHandlerTests
|
||||
{
|
||||
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
||||
private readonly Guid _userId = Guid.NewGuid();
|
||||
private readonly Guid _roleId = Guid.NewGuid();
|
||||
|
||||
public ListAvailableInboundsQueryHandlerTests()
|
||||
{
|
||||
var profile = new CurrentUserProfile(
|
||||
_userId,
|
||||
"alice",
|
||||
_roleId,
|
||||
"user",
|
||||
true,
|
||||
false,
|
||||
5,
|
||||
null,
|
||||
RoleQuota.Unlimited,
|
||||
"sub-token",
|
||||
false,
|
||||
null,
|
||||
false
|
||||
);
|
||||
_identityService.GetProfileAsync(_userId, Arg.Any<CancellationToken>()).Returns(profile);
|
||||
}
|
||||
|
||||
private static Node CreateNode(string name, int? capacity)
|
||||
{
|
||||
var node = Node.Register(
|
||||
name,
|
||||
new Uri($"https://{name}.example.com"),
|
||||
new NodeCredentials("admin", "protected"),
|
||||
null
|
||||
);
|
||||
node.SetCapacity(capacity);
|
||||
return node;
|
||||
}
|
||||
|
||||
private Inbound CreatePublishedInbound(Node node, string remoteId, string displayName)
|
||||
{
|
||||
var inbound = Inbound.FromRemote(node.Id, remoteId, VpnProtocol.Vless, "remark", 443);
|
||||
inbound.Publish(displayName, "DE", [_roleId]);
|
||||
return inbound;
|
||||
}
|
||||
|
||||
private static IEnumerable<VpnConfig> ActiveConfigs(Inbound inbound, int count) =>
|
||||
Enumerable
|
||||
.Range(0, count)
|
||||
.Select(_ => VpnConfig.Create(Guid.NewGuid(), inbound.Id, VpnProtocol.Vless, null))
|
||||
.ToList();
|
||||
|
||||
private ListAvailableInboundsQueryHandler CreateHandler(IAppDbContext dbContext) =>
|
||||
new(dbContext, _identityService, FakeCurrentUser.Authenticated(_userId));
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithCapacity_ReturnsPercentOfAllActiveConfigsOnNode()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var node = CreateNode("node-1", capacity: 10);
|
||||
var published = CreatePublishedInbound(node, "1", "Germany");
|
||||
// Неопубликованный инбаунд той же ноды тоже грузит сервер.
|
||||
var hidden = Inbound.FromRemote(node.Id, "2", VpnProtocol.Vless, "hidden", 444);
|
||||
var revoked = VpnConfig.Create(Guid.NewGuid(), published.Id, VpnProtocol.Vless, null);
|
||||
revoked.Revoke();
|
||||
|
||||
dbContext.Nodes.Add(node);
|
||||
dbContext.Inbounds.AddRange(published, hidden);
|
||||
dbContext.VpnConfigs.AddRange(ActiveConfigs(published, 2));
|
||||
dbContext.VpnConfigs.AddRange(ActiveConfigs(hidden, 1));
|
||||
dbContext.VpnConfigs.Add(revoked);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var result = await CreateHandler(dbContext)
|
||||
.Handle(new ListAvailableInboundsQuery(), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var item = Assert.Single(result.Value);
|
||||
Assert.Equal(30, item.LoadPercent);
|
||||
Assert.Null(item.NodeConfigCount);
|
||||
Assert.Equal("DE", item.CountryCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithoutCapacity_ReturnsConfigCount()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var node = CreateNode("node-1", capacity: null);
|
||||
var inbound = CreatePublishedInbound(node, "1", "Germany");
|
||||
|
||||
dbContext.Nodes.Add(node);
|
||||
dbContext.Inbounds.Add(inbound);
|
||||
dbContext.VpnConfigs.AddRange(ActiveConfigs(inbound, 4));
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var result = await CreateHandler(dbContext)
|
||||
.Handle(new ListAvailableInboundsQuery(), CancellationToken.None);
|
||||
|
||||
var item = Assert.Single(result.Value);
|
||||
Assert.Null(item.LoadPercent);
|
||||
Assert.Equal(4, item.NodeConfigCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_SortsByLoadAscending_NodesWithCapacityFirst()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var busy = CreateNode("busy", capacity: 10);
|
||||
var idle = CreateNode("idle", capacity: 100);
|
||||
var noCapacity = CreateNode("no-capacity", capacity: null);
|
||||
var busyInbound = CreatePublishedInbound(busy, "1", "A busy");
|
||||
var idleInbound = CreatePublishedInbound(idle, "1", "B idle");
|
||||
var noCapacityInbound = CreatePublishedInbound(noCapacity, "1", "C unknown");
|
||||
|
||||
dbContext.Nodes.AddRange(busy, idle, noCapacity);
|
||||
dbContext.Inbounds.AddRange(busyInbound, idleInbound, noCapacityInbound);
|
||||
dbContext.VpnConfigs.AddRange(ActiveConfigs(busyInbound, 8));
|
||||
dbContext.VpnConfigs.AddRange(ActiveConfigs(idleInbound, 8));
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var result = await CreateHandler(dbContext)
|
||||
.Handle(new ListAvailableInboundsQuery(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(
|
||||
["B idle", "A busy", "C unknown"],
|
||||
result.Value.Select(i => i.DisplayName).ToArray()
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ExcludesInboundsOfDisabledNodes()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var node = CreateNode("node-1", capacity: null);
|
||||
node.Disable();
|
||||
var inbound = CreatePublishedInbound(node, "1", "Germany");
|
||||
|
||||
dbContext.Nodes.Add(node);
|
||||
dbContext.Inbounds.Add(inbound);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var result = await CreateHandler(dbContext)
|
||||
.Handle(new ListAvailableInboundsQuery(), CancellationToken.None);
|
||||
|
||||
Assert.Empty(result.Value);
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ public class InboundTests
|
||||
var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443);
|
||||
var roleId = Guid.NewGuid();
|
||||
|
||||
inbound.Publish("Germany (VLESS)", [roleId, roleId]);
|
||||
inbound.Publish("Germany (VLESS)", null, [roleId, roleId]);
|
||||
|
||||
Assert.True(inbound.IsPublished);
|
||||
Assert.Equal("Germany (VLESS)", inbound.DisplayName);
|
||||
@@ -48,12 +48,26 @@ public class InboundTests
|
||||
Assert.Contains(roleId, inbound.AllowedRoleIds);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("de", "DE")]
|
||||
[InlineData(" NL ", "NL")]
|
||||
[InlineData("", null)]
|
||||
[InlineData(null, null)]
|
||||
public void Publish_NormalizesCountryCode(string? input, string? expected)
|
||||
{
|
||||
var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443);
|
||||
|
||||
inbound.Publish("Germany", input, []);
|
||||
|
||||
Assert.Equal(expected, inbound.CountryCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unpublish_SetsIsPublishedFalseButKeepsRoles()
|
||||
{
|
||||
var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443);
|
||||
var roleId = Guid.NewGuid();
|
||||
inbound.Publish("Germany", [roleId]);
|
||||
inbound.Publish("Germany", null, [roleId]);
|
||||
|
||||
inbound.Unpublish();
|
||||
|
||||
@@ -73,7 +87,7 @@ public class InboundTests
|
||||
public void MarkUnavailable_SetsIsAvailableAndIsPublishedFalse()
|
||||
{
|
||||
var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443);
|
||||
inbound.Publish("Germany", [Guid.NewGuid()]);
|
||||
inbound.Publish("Germany", null, [Guid.NewGuid()]);
|
||||
|
||||
inbound.MarkUnavailable();
|
||||
|
||||
@@ -100,7 +114,7 @@ public class InboundTests
|
||||
var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443);
|
||||
inbound.MarkUnavailable();
|
||||
|
||||
inbound.Publish("Germany", [Guid.NewGuid()]);
|
||||
inbound.Publish("Germany", null, [Guid.NewGuid()]);
|
||||
|
||||
Assert.True(inbound.IsAvailable);
|
||||
}
|
||||
@@ -111,7 +125,7 @@ public class InboundTests
|
||||
var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443);
|
||||
var roleId = Guid.NewGuid();
|
||||
var otherRoleId = Guid.NewGuid();
|
||||
inbound.Publish("Germany", [roleId, otherRoleId]);
|
||||
inbound.Publish("Germany", null, [roleId, otherRoleId]);
|
||||
|
||||
inbound.RemoveAllowedRole(roleId);
|
||||
|
||||
|
||||
@@ -212,4 +212,26 @@ public class NodeTests
|
||||
Assert.DoesNotContain("protected-secret", text);
|
||||
Assert.Contains("REDACTED", text);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(50)]
|
||||
[InlineData(null)]
|
||||
public void SetCapacity_WithPositiveOrNull_SetsCapacity(int? capacity)
|
||||
{
|
||||
var node = Node.Register("Germany-1", new Uri("https://de1.example.com"), Credentials, null);
|
||||
|
||||
node.SetCapacity(capacity);
|
||||
|
||||
Assert.Equal(capacity, node.Capacity);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
public void SetCapacity_WithNonPositive_Throws(int capacity)
|
||||
{
|
||||
var node = Node.Register("Germany-1", new Uri("https://de1.example.com"), Credentials, null);
|
||||
|
||||
Assert.Throws<DomainException>(() => node.SetCapacity(capacity));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user