- Updated the `UpdateClientAsync` method in `IXuiPanelGateway` to accept nullable parameters for `name` and `expiresAt`, allowing for more flexible client management without unintended modifications. - Adjusted the `BlockUserCommandHandler`, `UnblockUserCommandHandler`, and other related command handlers to utilize the new nullable parameters, ensuring that client names remain unchanged during block/unblock operations and that expiration dates are managed correctly. - Enhanced the billing and configuration handling to reflect the new logic for managing client states based on expiration rather than enabling/disabling, improving reliability in client status management. - Updated tests to cover the new behavior and ensure proper functionality across the application.
77 KiB
Domain Model
Домен — «rich model»: инварианты и переходы состояний живут в сущностях, а не в хендлерах.
AppUser/AppRole — часть Identity (живут в Infrastructure, т.к. расширяют IdentityUser<Guid>/
IdentityRole<Guid>); чистый PnvPanel.Domain ссылается на пользователя/роль только по Guid.
Лимиты трафика на конфиг (TrafficLimit) не реализованы — квота на число активных конфигов —
только через AppRole.MaxConfigs. Есть глобальная справочная цена за один конфиг (PricingSettings;
редактирует только admin, но справочно видна и активированным пользователям в заявке на роль) —
используется и биллингом (см. ниже) для расчёта суммы заявки на оплату.
Биллинг (подписка по сроку) реализован, но опционален и включается per-роль
(AppRole.BillingEnabled, недоступен для admin) — см. Billing.
Роль без флага живёт как раньше, без ограничений по сроку.
Диаграмма связей
AppUser (Identity) [+ IsActivated, IsBlocked, TelegramUserId, SubscriptionToken]
├─*───1─ AppRole (ровно одна роль; роль несёт квоту MaxConfigs)
├─1───*─ VpnConfig
│ └─1─ Inbound ─*─1─ Node
│ └─*───*─ AppRole (какие роли могут создавать конфиги в инбаунде)
├─0..1─* ActivationRequest (запрос активации у админа, с комментарием)
├─1───*─ TelegramLinkToken (короткоживущие токены привязки)
└─0..1─* TelegramLoginRequest (passwordless-вход)
VpnConfig ─*─ TrafficSample (история трафика; пишется TrafficSyncService)
AuditLog (append-only журнал действий; ссылается на ActorId/TargetId)
ClientApp (каталог приложений-клиентов; группируется по OperatingSystem)
NewsPost (лента новостей; публикуется админом, видна всем аутентифицированным пользователям)
AppUser
└─0..*─ SupportTicket (баг-репорт/предложение либо заявка на роль)
└─1───*─ TicketComment (переписка; первое сообщение = описание/обоснование)
└─0..*─ TicketAttachment (изображения, диск-хранилище)
Сущности
Node — VPN-сервер (панель 3x-ui)
Подключённая администратором панель 3x-ui.
| Поле | Тип | Заметки |
|---|---|---|
Id |
Guid |
PK |
Name |
string |
Отображаемое имя |
BaseAddress |
Uri |
Напр. https://panel.example.com:2053/ |
Credentials |
NodeCredentials (VO) |
Логин + зашифрованный пароль (ISecretProtector) |
Location |
string? |
Страна/город/тег для выбора пользователем |
Status |
NodeStatus |
Online / Offline / Unknown |
IsEnabled |
bool |
Выключена админом → скрыта из самообслуживания |
LastSyncAt |
DateTimeOffset? |
Последняя успешная синхронизация |
CreatedAt |
DateTimeOffset |
Инварианты: BaseAddress абсолютный; при IsEnabled == false или Status == Offline новые
конфиги на ноде запрещены, но существующие не трогаем (клиенты остаются в 3x-ui). Статус ноды
показываем пользователю как индикатор «состояние сервера».
Inbound — прокси-inbound на ноде
Проекция inbound из 3x-ui; определяет протокол и параметры подключения.
| Поле | Тип | Заметки |
|---|---|---|
Id |
Guid |
PK (внутренний) |
NodeId |
Guid |
FK → Node |
RemoteInboundId |
string |
Id inbound в 3x-ui (ThreeXui.Net отдаёт его как string, не число) |
Protocol |
VpnProtocol |
Vless / Vmess / Trojan / Shadowsocks |
Remark |
string |
Метка из 3x-ui |
Port |
int |
|
IsPublished |
bool |
Доступен ли для самообслуживания пользователями |
IsAvailable |
bool |
Существует ли инбаунд на панели по последней синхронизации (см. ниже) |
AllowedRoleIds |
Guid[] |
Id ролей, которым разрешено создавать конфиги (native PostgreSQL uuid[]; не навигация на AppRole — тот в Infrastructure/Identity, Domain на него не ссылается) |
DisplayName |
string? |
Витринное имя для пользователя, напр. «Германия (Trojan)» |
LastSyncAt |
DateTimeOffset? |
Инварианты: конфиг можно создать только если IsPublished && Node.IsEnabled, и роль пользователя
входит в AllowedRoles. Публикация инбаунда админом включает выбор AllowedRoles (напр.
«Германия (Trojan)» → роли user, vip). Лимита числа клиентов на инбаунд нет — квота
ограничивается только на уровне пользователя (AppRole.MaxConfigs).
Синхронизация и пропажа инбаунда с панели (SyncNodeCommandHandler, кнопка «Синхронизировать»):
инбаунд, не пришедший в очередном ответе 3x-ui, считается пропавшим. Если по нему нет ни одного
VpnConfig — запись просто удаляется (иначе при пересоздании того же инбаунда на панели под новым
RemoteInboundId — 3x-ui не переиспользует id — накапливался бы визуальный дубль). Если конфиги
есть — удалить нельзя (FK), инбаунд помечается MarkUnavailable() (IsAvailable=false,
IsPublished=false): новые конфиги на нём не создать, а Revoke для существующих конфигов не бьёт
в панель повторно (см. RevokeVpnConfigCommandHandler), а отзывает локально.
Node.Status(health-check раз в 2 минуты, см.NodeHealthCheckService) — это диагностический индикатор для админа, не гейт для создания конфига: он кэшированный и может ложно показыватьOfflineиз-за временного сбоя пробника. Реальную недоступность ноды ловит вызовIXuiPanelGateway.AddClientAsyncв момент создания — с честной ошибкой и компенсацией зарезервированной квоты, а не заранее закэшированным статусом.
Пользователю показываем только
DisplayName+ протокол. Адрес/хост ноды,RemoteInboundId,Portи прочие детали 3x-ui в пользовательские DTO не попадают (только в админские).
VpnConfig — конфиг пользователя (клиент в 3x-ui)
Центральная сущность. Одна запись = один клиент внутри inbound + его привязка к пользователю.
| Поле | Тип | Заметки |
|---|---|---|
Id |
Guid |
PK |
UserId |
Guid |
FK → AppUser (владелец) |
InboundId |
Guid |
FK → Inbound |
Label |
string? |
Пользовательская метка («Мой телефон»); редактируется юзером |
ClientEmail |
string |
Уникальный ключ клиента в 3x-ui; схема pnv_{userIdShort}_{rand} (уникален в рамках панели, виден владелец) |
ClientExternalId |
string |
Идентификатор клиента, который вернула панель (UUID для VLESS/VMess, пароль для Trojan/Shadowsocks — ThreeXui.Net отдаёт его как string) |
Protocol |
VpnProtocol |
Денормализовано с inbound |
UsedUpBytes |
long |
Синхронизируется из 3x-ui (только для отображения — лимит трафика не применяется) |
UsedDownBytes |
long |
Синхронизируется из 3x-ui |
ExpiresAt |
DateTimeOffset? |
Для billing-ролей — денормализованный AppUser.BillingPaidUntil (см. Billing); иначе null, конфиг живёт бессрочно. Уходит в Subscription-Userinfo для VPN-клиента |
Status |
ConfigStatus |
Active / Disabled / Expired / LimitReached / Revoked |
SubscriptionToken |
string |
Секрет для публичного /sub/{token} |
LastSyncAt |
DateTimeOffset? |
|
CreatedAt |
DateTimeOffset |
Инварианты и переходы (методы на VpnConfig, backend/src/PnvPanel.Domain/Configs/VpnConfig.cs):
Create(...)→ статусActive,ClientExternalIdпуст до ответа от 3x-ui; хендлер вызываетIXuiPanelGateway.AddClientAsync, затемAssignRemoteClient(id)и сохраняет — при сбое БД после успешного создания в панели хендлер удаляет клиента в 3x-ui (компенсация).- Проверка квоты выполняется под
pg_advisory_xact_lock(hashtext(userId))в транзакции создания (CreateVpnConfigCommandHandler) — иначе два параллельных запроса могли бы пробить лимит роли. Revoke()→ статусRevoked(запись остаётся для истории/аудита); хендлер отдельно удаляет клиента в 3x-ui.Rotate(newClientEmail, newClientExternalId)→ перевыпуск: хендлер создаёт нового клиента в 3x-ui, удаляет старого, генерирует новыйSubscriptionToken; квоту не тратит. Для случая утечки ссылки.Disable()/Enable()→ меняют только статус записи (Active ↔ Disabled); отключение/включение самого клиента в 3x-ui делает хендлер отдельным вызовом гейтвея (используется при блокировке юзера).Suspend()/Resume()→ меняют статус (Active ↔ Expired), отдельно отDisable()/Enable()— приостановка за неуплату (биллинг) не должна конфликтовать с блокировкой админом: разблокировка возвращает вActiveтолько то, что было погашено именно блокировкой, и наоборот (см. Billing).Rename(label)→ юзер меняет метку (синкается в 3x-ui как имя клиента).- Лимит одновременных IP (
limitIpв 3x-ui) выставляется при создании клиента (Create/Rotate) по квоте роли пользователя (AppRole.MaxIpLimit; -1 = без лимита) — панель не даёт настраивать его per-конфиг. Как иMaxConfigs, лимит применяется только к новым клиентам: смена роли/квоты не трогает уже созданных клиентов в 3x-ui (см.IXuiPanelGateway.UpdateClientAsync, гдеLimitIpвсегдаnull— «не менять»). UpdateTraffic(up, down)→ пишетTrafficSyncServiceпри периодической синхронизации, только для отображения.- Доступ разрешён только активированному пользователю (
AppUser.IsActivated == true): создание, просмотр списка, редактирование, ротация, отзыв, получение ссылки/подписки на свои конфиги, а также чтение новостей и каталога приложений — единая проверка вRequireActivationBehavior(pipeline behavior, маркерIRequiresActivationна команде/запросе), а не разбросанные проверки в хендлерах. - Число активных конфигов пользователя не может превышать квоту его роли (
AppRole.MaxConfigs; рольadmin— без лимита). У пользователя ровно одна роль. См.AppRoleниже. - Инбаунд должен быть доступен роли пользователя (
Inbound.AllowedRoles). - Разрешено несколько конфигов в одном инбаунде (ограничение — только общая квота роли).
Лимиты трафика не реализованы.
ConfigStatus.LimitReachedв значении enum есть, но код в него никогда не переводит конфиг. Квота на число конфигов реализована черезAppRole.MaxConfigs(см. tech-stack.md). Истечение срока — только для billing-ролей, см. Billing ниже.
TrafficSample — история трафика (для графиков)
Точки потребления во времени; пишутся синхронизацией.
| Поле | Тип | Заметки |
|---|---|---|
Id |
long |
PK |
ConfigId |
Guid |
FK → VpnConfig |
Timestamp |
DateTimeOffset |
|
UpBytes |
long |
Накопительно или дельта |
DownBytes |
long |
Обычная таблица PostgreSQL + TTL — фоновая чистка записей старше N дней (
TrafficRetentionService).
ClientApp — каталог приложений для подключения
Приложения-клиенты, которые админ рекомендует пользователям. На странице инструкций отображаются сгруппированными по ОС; клик открывает ссылку на скачивание.
| Поле | Тип | Заметки |
|---|---|---|
Id |
Guid |
PK |
Name |
string |
Название, напр. «v2rayNG», «Hiddify», «NekoBox» |
DownloadUrl |
Uri |
Ссылка на скачивание/стор |
OperatingSystem |
OsPlatform |
IOS / Android / Windows / MacOS / Linux |
Description |
string? |
Короткая подсказка (опц.) |
IconUrl |
string? |
Иконка (опц.) |
SortOrder |
int |
Порядок внутри группы ОС |
IsEnabled |
bool |
Показывать пользователям |
IsRecommended |
bool |
Показывать первыми в группе ОС + значок на фронте |
Управляется админом (CRUD). Пользователю отдаётся только IsEnabled/IsRecommended, сгруппировано
по OperatingSystem; внутри группы IsRecommended (сначала true) → SortOrder.
Стартовый набор сидируется из seed/client-apps.json, если таблица пуста.
Массовая очистка отключённых (IsEnabled = false) — вкладка «Обслуживание»,
DELETE /api/admin/maintenance/apps/disabled.
InstructionIntro — вводный текст страницы инструкций
Единственная строка в таблице (singleton) — markdown-текст над вкладками на странице «Инструкции», редактируется админом. Никакой поддержки нескольких версий/языков нет.
| Поле | Тип | Заметки |
|---|---|---|
Id |
Guid |
PK |
Body |
string |
Markdown-текст |
UpdatedAt |
DateTimeOffset |
GET /api/instructions/intro (активированным) читает; PUT /api/admin/instructions/intro (админ)
делает get-or-create — если строки ещё нет (не сидировано), создаёт, иначе обновляет на месте.
Сидируется дефолтным текстом при старте (IInstructionIntroSeeder, если таблица пуста) и заново
после полного сброса панели (см. «Полный сброс панели» выше).
InstructionTab — дополнительные вкладки инструкций
Заголовок + markdown-текст, ведёт админ; на странице «Инструкции» отображаются вкладками рядом с
встроенной вкладкой «Приложения» (каталог ClientApp, не хранится как InstructionTab).
| Поле | Тип | Заметки |
|---|---|---|
Id |
Guid |
PK |
Title |
string |
Заголовок вкладки |
Body |
string |
Markdown-текст |
SortOrder |
int |
Порядок вкладок (та же конвенция, что у ClientApp) |
CreatedAt |
DateTimeOffset |
|
UpdatedAt |
DateTimeOffset? |
Нет статуса черновик/опубликовано — публикация мгновенная, как у NewsPost. Полный CRUD только
у админа (/api/admin/instructions/tabs); чтение — GET /api/instructions/tabs (активированным).
Не пересеивается дефолтными вкладками — при полном сбросе панели просто удаляются.
NewsPost — новости для пользователей
Публикуются админом немедленно, видны всем залогиненным пользователям в хронологической ленте.
| Поле | Тип | Заметки |
|---|---|---|
Id |
Guid |
PK |
Title |
string |
Заголовок |
Body |
string |
Markdown-текст |
CreatedAt |
DateTimeOffset |
Момент публикации (= момент создания, нет черновиков) |
UpdatedAt |
DateTimeOffset? |
Момент последней правки (опц.) |
Нет статуса черновик/запланировано — публикация мгновенная. Нет видимости по ролям — доступно
всем аутентифицированным пользователям. Realtime-оповещение о новом посте — newsPublished
(SignalR, широковещательно всем подключенным клиентам), см. architecture.md.
AuditLog — журнал действий
Аудит значимых действий (прежде всего админских) для расследований и прозрачности.
| Поле | Тип | Заметки |
|---|---|---|
Id |
long |
PK |
ActorId |
Guid? |
Кто выполнил (null — система/фон) |
Action |
string |
Напр. UserActivated, UserBlocked, RoleChanged, ConfigRevoked, NodeAdded, InboundPublished |
TargetType |
string |
Сущность (User/Config/Node/Inbound/Role) |
TargetId |
string |
Идентификатор цели |
Metadata |
jsonb |
Доп. детали (старое/новое значение, комментарий) |
Source |
AuditSource |
Web / Telegram / System |
CreatedAt |
DateTimeOffset |
Пишется из хендлеров (или обработчиков доменных событий), append-only — из приложения ничего не
удаляет и не редактирует записи. Исключения — retention-очистка по возрасту (вкладка «Обслуживание»,
DELETE /api/admin/maintenance/audit-logs?olderThanDays=N) и полный сброс панели (см. ниже), оба
доступны только админу; отдельной кнопки «удалить весь журнал» без сброса всей панели осознанно нет.
Полный сброс панели
Вкладка «Обслуживание» → «Опасная зона» (спойлер + подтверждение фразой в диалоге, не просто
confirm()) — DELETE /api/admin/maintenance/factory-reset. Возвращает панель к состоянию свежего
деплоя: удаляет всех пользователей кроме текущего админа, конфиги (сначала best-effort отзываются
на нодах 3x-ui), ноды/инбаунды, тикеты, новости, вводный текст и вкладки инструкций, весь аудит и
кастомные роли; каталог приложений и вводный текст инструкций пересеиваются дефолтными значениями,
вкладки инструкций — нет (пусто, как у новостей). Необратимо, не атомарно целиком — подробности и
полный список удаляемого см. api-design.md.
AppUser — расширения (Identity)
AppUser живёт в Identity (Infrastructure). Логин — по UserName (уникальный, обязательный).
Email в системе не используется — поле не заполняем/не требуем (стандартная колонка Identity
остаётся пустой). Помимо стандартных полей Identity:
| Поле | Тип | Заметки |
|---|---|---|
IsActivated |
bool |
По умолчанию false при регистрации; активирует админ |
ActivatedAt |
DateTimeOffset? |
Когда активирован |
ActivatedBy |
Guid? |
Какой админ активировал |
IsBlocked |
bool |
Блокировка админом: вход запрещён + все конфиги отключены в 3x-ui |
SubscriptionToken |
string |
Секрет для агрегированной подписки /sub/{token} (все активные конфиги юзера) |
TelegramUserId |
long? |
Id пользователя Telegram; уникальный; null до привязки |
TelegramUsername |
string? |
@username на момент привязки (для отображения) |
TelegramLinkedAt |
DateTimeOffset? |
Когда привязан |
Инварианты: один TelegramUserId ↔ один аккаунт (повторная привязка требует /unlink);
неактивированный пользователь не имеет доступа к конфигам, новостям и каталогу приложений (см. выше);
при регистрации выдаётся роль user.
Блокировка (IsBlocked = true) переводит все конфиги в Disabled (отключение клиентов в 3x-ui);
разблокировка включает их обратно. У пользователя ровно одна роль.
Восстановление пароля: только через привязанный Telegram (passwordless-вход → смена пароля в
настройках, либо reset-флоу в боте). Если Telegram не привязан — пароль сбрасывает админ
(ResetUserPasswordCommand). Пока Telegram не привязан,
UI настойчиво напоминает привязать его (единственный self-service способ восстановления).
AppRole — роль с квотой (Identity, динамическая)
Расширяет IdentityRole<Guid>. Роли создаёт админ и назначает пользователям; роль несёт квоту
на число конфигов и лимит одновременных IP на клиента в 3x-ui.
| Поле | Тип | Заметки |
|---|---|---|
Id |
Guid |
PK |
Name |
string |
Напр. admin, user, vip |
MaxConfigs |
int |
Квота активных конфигов (-1 = без лимита; для admin — без лимита) |
MaxIpLimit |
int |
Лимит одновременных IP на клиента (limitIp в 3x-ui; -1 = без лимита; для admin — без лимита) |
IsSystem |
bool |
Системная (admin, user) — нельзя удалить/переименовать |
BillingEnabled |
bool |
Включает биллинг для пользователей с этой ролью; нельзя включить для admin (см. Billing) |
Сидируются: admin (оба лимита без ограничения) и user (MaxConfigs = Roles__DefaultUserMaxConfigs,
по умолчанию 3; MaxIpLimit = Roles__DefaultUserMaxIpLimit, по умолчанию 2).
У пользователя ровно одна роль; его квоты = MaxConfigs/MaxIpLimit этой роли (admin → без лимита).
Понижение роли (грандфазеринг): смену роли на роль с меньшей квотой разрешаем даже если текущих конфигов больше новой квоты — существующие конфиги сохраняются, но создание новых блокируется, пока число активных не станет меньше квоты. Форс-отзыв лишних не делаем.
Нельзя снять admin с последнего администратора: IRoleService.ChangeUserRoleAsync перед сменой
роли проверяет — если у пользователя сейчас admin, а новая роль другая, и админов в системе ровно
один — RoleErrors.CannotRemoveLastAdmin (409), смены не происходит. Единая точка защиты — работает
и при прямой смене роли из /admin/users, и при одобрении заявки на роль через SupportTicket
(ApproveRoleRequestCommandHandler вызывает тот же ChangeUserRoleAsync), в том числе когда админ
одобряет заявку на понижение самому себе — этот путь специально не блокируется отдельно, чтобы не
плодить тикеты, которые некому обработать, если админ единственный.
PricingSettings — глобальная справочная цена конфига
Единственная строка в таблице (singleton) — цена за один конфиг, редактируется админом. Не привязана к роли: одна цена на весь сервис. Не биллинг — без статусов оплаты, дат окончания, интеграций с платёжными системами.
| Поле | Тип | Заметки |
|---|---|---|
Id |
Guid |
PK |
PricePerConfigPerQuarter |
int? |
Цена за конфиг в месяц при оплате раз в 3 месяца (минимальный период), руб. |
PricePerConfigPerHalfYear |
int? |
Цена за конфиг в месяц при оплате раз в полгода, руб. Может быть ниже квартальной (скидка за оплату на полгода вперёд) |
PricePerConfigPerYear |
int? |
Цена за конфиг в месяц при оплате раз в год, руб. Может быть ниже полугодовой (скидка за годовую оплату) |
UpdatedAt |
DateTimeOffset |
Все три поля — ставка за месяц, не за весь период целиком. Итог за период = ставка × число_месяцев × AppRole.MaxConfigs, считается на фронте (таблица ролей в админке), нигде не
хранится:
- 3 месяца =
PricePerConfigPerQuarter × 3 × MaxConfigs - полгода =
PricePerConfigPerHalfYear × 6 × MaxConfigs - год =
PricePerConfigPerYear × 12 × MaxConfigs
Например, user с MaxConfigs=3 и одинаковой ставкой 200₽/мес на всех трёх тарифах → 600₽/3мес,
1200₽/полгода, 2400₽/год (линейный рост, скидки за тариф нет). Для ролей с MaxConfigs = -1
(unlimited, в т.ч. admin) итог не считается — отображается как «не задано».
Инвариант: UpdatePricingSettingsCommandValidator не даёт сохранить более длинный тариф настолько
дешёвым, что его итог окажется дешевле итога более короткого — иначе выгоднее купить длинный тариф и
не продлевать, чем платить за короткий. Формально: PricePerConfigPerHalfYear × 6 ≥ PricePerConfigPerQuarter × 3 и PricePerConfigPerYear × 12 ≥ PricePerConfigPerHalfYear × 6 (если
полугодовая ставка не задана — год сверяется напрямую с кварталом: PricePerConfigPerYear × 12 ≥ PricePerConfigPerQuarter × 3).
GET/PUT /api/admin/pricing — только admin (редактирование). GET /api/support/pricing — то же
чтение, но доступно любому активированному пользователю (не admin-эндпоинт) — используется в
диалоге заявки на роль, чтобы показать ориентировочную стоимость выбранной/предлагаемой роли, с
пометкой, что цены пока ознакомительные. Это два разных Query (GetPricingSettingsQuery в
Admin/Pricing, GetSupportPricingQuery в Support) над одним и тем же общим PricingSettingsDto
(Common/Interfaces) — по аналогии с ListRolesQuery/ListSelectableRolesQuery для ролей. В отличие
от RoleDto, у PricingSettingsDto нет чувствительных per-роль данных, поэтому шарить DTO между
admin- и user-facing путями безопасно. Сидируется пустой строкой при старте (IPricingSettingsSeeder,
если таблица пуста) и заново после полного сброса панели (см. «Полный сброс панели» выше).
PricingDiscountTier — скидка за объём (лесенка порогов)
Стимул брать роль с бОльшей квотой конфигов разом: плоская таблица (не навигационная коллекция —
см. конвенцию проекта на TicketComment) с FK на PricingSettingsId, глобальная, не привязана к
конкретной роли — как и сам PricingSettings.
| Поле | Тип | Заметки |
|---|---|---|
Id |
Guid |
PK |
PricingSettingsId |
Guid |
FK → PricingSettings |
MinConfigs |
int |
Порог: скидка действует при AppRole.MaxConfigs >= MinConfigs |
DiscountPercent |
int |
Скидка в процентах от итоговой цены периода, 1–99 |
Действует наивысший подходящий порог (не суммируется с другими) — PricingDiscount.ResolvePercent
(Domain/Pricing): из тиров с MinConfigs <= MaxConfigs берётся тот, у которого MinConfigs
максимален. Например, при порогах 3+ → 5% и 6+ → 10% роль с MaxConfigs=8 получает 10%, а не 15%.
Скидка применяется к уже посчитанному итогу периода: PricingDiscount.Apply(итог, процент), округление
до целого рубля (MidpointRounding.AwayFromZero). Роли с MaxConfigs = -1 (unlimited) скидку не
получают — как и обычный расчёт цены, для них итог не считается.
Инвариант: UpdatePricingSettingsCommandValidator требует уникальности порогов и прогрессивности
лесенки — на более высоком пороге скидка не может быть меньше, чем на более низком (иначе взять роль с
бОльшей квотой может оказаться менее выгодно, что противоречит смыслу скидки за объём).
Применяется в двух местах, зеркалящих друг друга: реальная оплата (CreatePaymentRequestCommandHandler
— AmountSnapshot уже с учётом скидки) и ознакомительная оценка (PricingSettingsDto.DiscountTiers +
resolveDiscountPercent/applyDiscount на фронте, frontend/src/shared/lib/pricing.ts) — используется
и в списке ролей в админке (admin/roles.tsx), и в оценке стоимости при смене роли тикетом
(CreateRoleRequestDialog.tsx). UpdatePricingSettingsCommand при сохранении полностью заменяет набор
тиров (удаляет старые, вставляет новые) — операция редкая (правит только admin), сложность
инкрементального diff не оправдана.
Billing — подписка по сроку
Опциональная подсистема: включается per-роль (AppRole.BillingEnabled), недоступна для admin.
Роль без флага не затрагивается — конфиги живут бессрочно, как без биллинга вообще.
AppUser (доп. поля, только для billing-ролей):
| Поле | Тип | Заметки |
|---|---|---|
BillingPaidUntil |
DateTimeOffset? |
Оплачено до этой даты; null — оплата ещё ни разу не выставлялась |
BillingSuspended |
bool |
Конфиги приостановлены за неуплату (см. BillingService) |
BillingLastWarnedForPaidUntil |
DateTimeOffset? |
Для какого PaidUntil уже отправлено предупреждение «истекает через N дней» — не даёт слать повторно на каждый тик джобы |
Грейс-период: когда пользователю впервые назначается billing-роль (или роли, где он уже состоит,
включают BillingEnabled) и BillingPaidUntil == null — RoleService выставляет
BillingPaidUntil = now + BillingSettings.GraceDays автоматически (ChangeUserRoleAsync/
UpdateRoleAsync). Без этого пользователь был бы «просрочен» с первой секунды.
BillingSettings — глобальные настройки биллинга
Singleton (как PricingSettings) — реквизиты для оплаты и длина грейс-периода, редактирует admin.
| Поле | Тип | Заметки |
|---|---|---|
Id |
Guid |
PK |
RequisitesText |
string |
Произвольный текст реквизитов (карта/крипто-адрес/СБП и т.д.), показывается пользователю с заявкой |
GraceDays |
int |
По умолчанию 7 (BillingSettings.DefaultGraceDays) |
UpdatedAt |
DateTimeOffset |
PaymentRequest — заявка на оплату
Пользователь оформляет заявку на период (3/6/12 мес); решает админ на сайте или в Telegram. Не более
одной активной (AwaitingPayment/AwaitingConfirmation) заявки Kind.Subscription на
пользователя — инвариант проверяется в CreatePaymentRequestCommandHandler и не распространяется на
Kind.RoleChangeTopUp (см. ниже) — доплата не должна мешать оформить/продлить обычную подписку.
| Поле | Тип | Заметки |
|---|---|---|
Id |
Guid |
PK |
UserId |
Guid |
FK → AppUser (заявитель) |
Kind |
PaymentRequestKind |
Subscription (оплата за период) / RoleChangeTopUp (доплата за апгрейд роли, см. ниже) |
Period |
PaymentPeriod? |
Quarter (3 мес) / HalfYear (6 мес) / Year (12 мес). null для Kind.RoleChangeTopUp — доплата не привязана к тарифному периоду |
AmountSnapshot |
int |
Сумма, замороженная на момент создания. Для Subscription: ставка PricingSettings за период × MaxConfigs роли × число месяцев, затем скидка по лесенке PricingDiscountTier (см. выше), если применима. Для RoleChangeTopUp: см. RoleChangeTopUp.Compute ниже. Последующее изменение прайса/лесенки админом не меняет уже созданные заявки |
Status |
PaymentRequestStatus |
AwaitingPayment → AwaitingConfirmation → Confirmed/Rejected, либо Cancelled из AwaitingPayment |
DecidedBy/DecidedAt/RejectionReason |
Кто/когда решил, причина отказа (опционально) | |
CreatedAt |
DateTimeOffset |
Роль с MaxConfigs = -1 (unlimited) не поддерживает биллинг по формуле —
CreatePaymentRequestCommandHandler отдаёт BillingErrors.UnlimitedRoleNotSupported; та же логика в
RoleChangeTopUp.Compute (null, доплата не считается).
Переходы (backend/src/PnvPanel.Domain/Billing/PaymentRequest.cs):
Create(userId, period, amount)(Kind.Subscription) /CreateRoleChangeTopUp(userId, amount)(Kind.RoleChangeTopUp) →AwaitingPayment, показываются реквизитыBillingSettings. Пользователь можетCancel()(только изAwaitingPayment) или дождаться проверки.MarkPaymentSent()→ пользователь нажал «Я оплатил»;AwaitingPayment → AwaitingConfirmation, админам уходит Telegram-уведомление с инлайн-кнопкамиpay:approve:{id}/pay:reject:{id}(текст уведомления зависит отKind— период или «доплата за смену роли», см.TelegramNotifier).Confirm(adminId)/Reject(adminId, reason)→ допустимы из обоихAwaitingPaymentиAwaitingConfirmation(админ мог заметить оплату раньше, чем пользователь нажал кнопку). ДляKind.SubscriptionConfirmпродлеваетAppUser.BillingPaidUntil = max(текущий, сейчас) + период(не теряет уже оплаченный остаток при досрочной оплате), возвращает вActiveконфиги, приостановленные за неуплату (Suspend()/Resume()наVpnConfig, статусExpired), обновляетExpiresAtна всех конфигах пользователя. ДляKind.RoleChangeTopUpConfirmтолько переводит заявку вConfirmed—BillingPaidUntilне трогает (это не покупка времени, а закрытие долга за уже выданный апгрейд) — см.ConfirmPaymentRequestCommandHandler.
RoleChangeTopUp — доплата при апгрейде роли с активным периодом
Пользователь с активным BillingPaidUntil меняет роль (тикетом SupportTicket.RoleRequest,
ApproveRoleRequestCommandHandler) на более дорогую — по-хорошему должен доплатить разницу, а не
доиграть апгрейд бесплатно до конца уже оплаченного срока. Роль меняется сразу (не блокируется
ожиданием оплаты); доплата решается отдельно через обычный флоу PaymentRequest
(Kind.RoleChangeTopUp) — тем же путём, что и обычная оплата: сайт (billing.tsx,
PaymentRequestPanel) или Telegram (pay:approve/pay:reject).
Сумма — RoleChangeTopUp.Compute (backend/src/PnvPanel.Domain/Billing/RoleChangeTopUp.cs), чистая
функция без I/O:
- Месячная стоимость роли =
PricingSettings.PricePerConfigPerQuarter × MaxConfigs, затем скидка по лесенкеPricingDiscountTier(PricingDiscount.ResolvePercent/Apply) — та же формула и тот же базовый (квартальный/минимальный) тариф, что у обычной оплаты, независимо от того, за какой период пользователь платил на самом деле — упрощение, чтобы не вводить отдельное понятие «дневная ставка по фактическому тарифу». - Разница месячных стоимостей новой и старой роли, поделённая на 30 (условный «месяц» для
проратирования) и умноженная на число оставшихся до
BillingPaidUntilдней — округление до целого рубля (MidpointRounding.AwayFromZero). null(доплата не создаётся), если: новая роль не дороже старой (в т.ч. понижение — остаётся грандфазеринг, без доплаты и без возврата), оплаченный период уже истёк, либо старая/новая роль без лимита конфигов (MaxConfigs = -1, цена не считается).
Применяется только к самостоятельной заявке на роль (ApproveRoleRequestCommandHandler) — админская
прямая смена роли (PATCH /api/admin/users/{id}/role, UserManageDialog) доплату не создаёт: это
осознанный инструмент админа, который может быть применён как поощрение.
Известное ограничение: GetMyBillingStatusQueryHandler отдаёт только одну activeRequest —
если у пользователя одновременно есть активная Subscription-заявка и RoleChangeTopUp (редкий
случай: роль сменили, пока уже шла обычная оплата), на странице /billing будет видна только одна из
них (обе видны в админке и обе решаемы через Telegram). Не устранено — узкий edge case, не блокирует
основной сценарий.
BillingService — приостановка за неуплату (фоновая джоба)
Infrastructure/BackgroundJobs/BillingService.cs, раз в час (по образцу TrafficSyncService). Для
каждого пользователя с billing-ролью, не заблокированного (IsBlocked):
- есть
PaymentRequestв статусеAwaitingConfirmation→ пропустить — это и есть защита «заявка висит на подтверждении админом, а срок истёк» из требований: конфиги не гасятся, пока админ не решит (не по вине пользователя, что админ не успел проверить оплату); BillingPaidUntilв прошлом (илиnull) и ещё неBillingSuspended→ приостановить всеActiveконфиги (Suspend()→Expired, гейтвейUpdateClientAsync(expiresAt: вчера), идемпотентно как вBlockUserCommandHandler),AppUser.BillingSuspended = true, Telegram-уведомление пользователю,AuditLog(BillingSuspended, источникSystem). На последующих тиках (уже suspended) — только идемпотентная досуспензия «зависших»Active-конфигов (самовосстановление после недоступности ноды), без повторных уведомлений;- до истечения ≤ 3 дней и предупреждение для этого
PaidUntilещё не отправлено (BillingLastWarnedForPaidUntil != PaidUntil) → Telegram-предупреждение, отметка отправки.
Приостановка/возврат за неуплату — через expiresAt, не enable
Приостановка за неуплату (BillingService) и возврат (BillingConfigResumer) управляют панельным
клиентом через IXuiPanelGateway.UpdateClientAsync(..., expiresAt: ..., enable: null, ...), а не через
enable: false/true — по факту эксплуатации переключение enable ненадёжно останавливает уже
установленные соединения на стороне Xray/панели, а просроченный expiryTime — надёжно, и не зависит
от enable. Конкретно:
- Приостановка:
expiresAt = UtcNow.AddDays(-1)— гарантированно просроченная дата,enableне трогаем (null, «оставить как есть» — см. ThreeXui.NetUpdateClientRequest). - Возврат:
expiresAt = newPaidUntil(реальный новый срок оплаты, не «снять ограничение» на бесконечность) — так панель/Xray сама несёт актуальный срок: еслиBillingServiceвдруг пропустит тик, панель всё равно перестанет пускать по истечении этой даты независимо от приложения. - Создание (
CreateVpnConfigCommandHandler) уже пушитexpiresAt = profile.BillingPaidUntilприBillingEnabledчерезAddClientAsync— свежий конфиг сразу несёт правильный срок, не «без ограничения» до первого циклаBillingService. - Ротация (
RotateVpnConfigCommandHandler) переносит текущийconfig.ExpiresAtна нового клиента — ротация не должна ни продлевать, ни сбрасывать оплаченный период. - Блокировка/разблокировка админом (
Disable()/Enable()) — отдельная ось, управляет толькоenable,expiresAt: null(не трогает срок оплаты). Переименование (EditVpnConfigCommandHandler) —enable/expiresAt: null(раньше по ошибке форсировалоenable:true, тем самым молча снимая приостановку/блокировку простым переименованием конфига — исправлено).
CreateVpnConfigCommandHandler дополнительно не даёт создать новый конфиг, если роль billing
и оплата просрочена (ConfigErrors.BillingRequired) — иначе приостановку можно было бы обойти
созданием свежего конфига. Фронт (dashboard.tsx) зеркалит эту же проверку и скрывает кнопку создания
конфига заранее, а не только реагирует на 403 от сервера (см. CreateConfigDialog.tsx — safety-net на
случай гонки состояний).
GET/POST /api/billing/* — пользователь (статус, создание/отмена заявки, «я оплатил», отправка
реквизитов в свой Telegram). GET/PUT/POST /api/admin/billing/* — админ (настройки, список заявок,
подтверждение/отклонение, POST /gift — выдать N дней конкретному пользователю без заявки), только
admin. Продление PaidUntil попадает в панель тремя путями — подтверждённая PaymentRequest,
одобренная SupportTicket(ExtensionRequest) и прямой гифт от админа — все три используют один и тот
же BillingConfigResumer (см. Application/Billing), различается только вычисление newPaidUntil
(месяцы для оплаты, дни для продления/гифта) и триггер (пользователь vs админ).
ActivationRequest — запрос активации
Пользователь просит активацию у админа; админ одобряет/отклоняет на сайте или в Telegram.
| Поле | Тип | Заметки |
|---|---|---|
Id |
Guid |
PK |
UserId |
Guid |
FK → AppUser (заявитель) |
Comment |
string? |
Комментарий заявителя, напр. «я Никита» — чтобы админ понял, кто это |
Status |
ActivationStatus |
Pending / Approved / Rejected |
DecidedBy |
Guid? |
Админ, принявший решение |
DecidedAt |
DateTimeOffset? |
|
RejectionReason |
string? |
Комментарий админа при отклонении (опционально) |
CreatedAt |
DateTimeOffset |
Инварианты: одновременно не более одного Pending-запроса на пользователя; Approved →
AppUser.IsActivated = true. Создание запроса и решение шлют realtime/Telegram-уведомления.
TelegramLinkToken — токен привязки
Короткоживущий одноразовый токен для флоу привязки Telegram.
| Поле | Тип | Заметки |
|---|---|---|
Id |
Guid |
PK |
Token |
string |
Высокоэнтропийный секрет (в deep-link) |
UserId |
Guid |
FK → AppUser (кто привязывает) |
ExpiresAt |
DateTimeOffset |
≈2–5 минут |
ConsumedAt |
DateTimeOffset? |
Одноразовый: гасится при использовании |
TelegramLoginRequest — запрос passwordless-входа
Запрос входа на сайт без пароля, подтверждаемый в боте.
| Поле | Тип | Заметки |
|---|---|---|
Id |
Guid |
PK; nonce в deep-link |
Status |
TelegramLoginStatus |
Pending / Approved / Rejected / Expired / Consumed |
UserId |
Guid? |
Проставляется после подтверждения (по TelegramUserId) |
Context |
string? |
IP/устройство инициатора — показывается при подтверждении |
CreatedAt |
DateTimeOffset |
|
ExpiresAt |
DateTimeOffset |
≈2–5 минут |
Переходы: Pending → Approved/Rejected/Expired; Approved → Consumed (после выпуска JWT сайту).
После Consumed/Expired — не переиспользуется.
SupportTicket — обращение в поддержку
Три вида: BugReport (свободная форма, с вложениями), RoleRequest (запрос существующей роли —
кроме admin — либо параметров новой) и ExtensionRequest (продление оплаченного периода на N
дней — только для billing-ролей, см. Billing выше). Текст/обоснование не хранится отдельным полем —
это первое сообщение в переписке (TicketComment), созданное вместе с тикетом в одной операции.
| Поле | Тип | Заметки |
|---|---|---|
Id |
Guid |
PK |
UserId |
Guid |
FK → AppUser (автор) |
Type |
TicketType |
BugReport / RoleRequest / ExtensionRequest |
Status |
TicketStatus |
Open / Resolved / Closed |
RequestedRoleId |
Guid? |
Заполнено для RoleRequest при выборе существующей роли |
ProposedRoleName |
string? |
Заполнено для RoleRequest при запросе новой роли |
ProposedMaxConfigs |
int? |
Параметры новой роли (см. AppRole.MaxConfigs) |
ProposedMaxIpLimit |
int? |
Параметры новой роли (см. AppRole.MaxIpLimit) |
RequestedDays |
int? |
Заполнено для ExtensionRequest — сколько дней просит пользователь (1–365) |
CreatedAt |
DateTimeOffset |
Инварианты и переходы (backend/src/PnvPanel.Domain/Support/SupportTicket.cs): RequestedRoleId
и Proposed* никогда не заполнены одновременно — гарантируется отдельными фабриками
(CreateRoleRequestForExistingRole/CreateRoleRequestForNewRole), а не runtime-проверкой. Аналогично
RequestedDays заполняется только фабрикой CreateExtensionRequest.
Resolve()— только изOpen. ДляRoleRequestодобрение — оркестрация в Application (ApproveRoleRequestCommandHandler): при новой роли сначалаIRoleService.CreateRoleAsync, затем в любом случаеChangeUserRoleAsyncпользователю, и только потомticket.Resolve(). ДляExtensionRequest—ApproveExtensionRequestCommandHandlerпродлеваетAppUser.BillingPaidUntilнаRequestedDays(отmax(текущий, сейчас), как и уPaymentRequest) и возвращает вActiveконфиги, приостановленные за неуплату (BillingConfigResumer, тот же helper, что и у подтверждения оплаты и гифт-дней от админа).Close()— изOpenилиResolved, финал (обратного пути нет). ДляRoleRequest/ExtensionRequest— отклонение.Reopen()— только изResolved(владелец тикета);Closedне переоткрывается.- Одновременно не более одной открытой заявки на роль (
Type == RoleRequest && Status == Open) и отдельно не более одной открытой заявки на продление (Type == ExtensionRequest && Status == Open) на пользователя — проверяется в Application, аналогичноActivationRequest.AlreadyPending. Баг-репорты такого ограничения не имеют. - Создать
ExtensionRequestможет только пользователь с billing-ролью (CurrentUserProfile.BillingEnabled) — иначеBilling.NotEnabled. - Доступ — только активированному пользователю (
IRequiresActivation, как и у конфигов/новостей); админские действия (resolve/close/approve/reject) идут по отдельным/api/admin/support/*с ролевой проверкой, без завязки на активацию. - Resolve/close/reject собственного тикета админом разрешены — они не трогают роль, риска нет
(запрет ломал бы самообслуживание: тикет единственного админа застревал бы в
Openнавсегда, убрать некому). Единственное действие с реальным риском — approve заявки на роль, потому что оно меняет роль заявителя; его самостоятельная защита не нужна — она уже есть на уровень ниже, см.AppRole(RoleErrors.CannotRemoveLastAdmin), и одинаково работает что для approve своей заявки, что для прямой смены роли через/admin/users. Closed-тикеты не удаляются автоматически — админ может подчистить их вручную (вкладка «Обслуживание»,DELETE /api/admin/maintenance/tickets/closed), это удаляет иTicketComment/TicketAttachment(+ файлы на диске), необратимо.
TicketComment — сообщение в переписке
Плоская сущность (не навигационная коллекция на SupportTicket — конвенция проекта, см.
TrafficSample), одна на любое сообщение (включая первое, созданное вместе с тикетом).
| Поле | Тип | Заметки |
|---|---|---|
Id |
Guid |
PK |
TicketId |
Guid |
FK → SupportTicket |
AuthorId |
Guid |
FK → AppUser (владелец тикета либо админ) |
Body |
string |
|
CreatedAt |
DateTimeOffset |
Комментарий запрещён на Closed-тикете; на Open/Resolved — можно (для Resolved это не
переоткрывает тикет автоматически, переоткрытие — отдельное явное действие пользователя Reopen()).
TicketAttachment — вложение (изображение)
Хранится на диске контейнера (IFileStorage/DiskFileStorage, volume ticket_uploads в
docker-compose) — первая в проекте функциональность загрузки файлов. Вайтлист
image/jpeg|png|webp|gif, до 5 МБ на файл, до 5 файлов на сообщение.
| Поле | Тип | Заметки |
|---|---|---|
Id |
Guid |
PK |
CommentId |
Guid |
FK → TicketComment |
FileName |
string |
Оригинальное имя — только для отображения, не участвует в пути на диске |
StoredFileName |
string |
Серверное GUID-имя на диске (не доверяем пользовательскому вводу) |
ContentType |
string |
|
SizeBytes |
long |
|
CreatedAt |
DateTimeOffset |
Отдаётся авторизованным эндпоинтом (GET /api/support/attachments/{id}, проверка владения тикетом
или роли admin), не статикой — вложения могут быть чувствительными.
Value Objects
- NodeCredentials (
Nodes/NodeCredentials.cs) —Username+ProtectedPassword(шифротекст,ISecretProtector/ASP.NET Data Protection); пароль не сериализуется наружу.
Connection string для клиента строит IXuiPanelGateway (обёртка над ThreeXui.Net) на лету при
запросе GET /api/configs/{id}/link — отдельного value object под это не заводили. QR-код из
готовой строки генерируется на фронте (qrcode.react), сервер картинку не рендерит.
Enums
enum VpnProtocol { Vless, Vmess, Trojan, Shadowsocks }
enum NodeStatus { Unknown, Online, Offline }
enum ConfigStatus { Active, Disabled, Expired, LimitReached, Revoked }
enum TelegramLoginStatus { Pending, Approved, Rejected, Expired, Consumed }
enum ActivationStatus { Pending, Approved, Rejected }
enum AuditSource { Web, Telegram, System }
enum OsPlatform { IOS, Android, Windows, MacOS, Linux }
enum TicketType { BugReport, RoleRequest, ExtensionRequest }
enum TicketStatus { Open, Resolved, Closed }
Уведомления и аудит (без диспетчера доменных событий)
В Domain нет маркера IDomainEvent и диспетчера событий. CQRS-хендлеры сами вызывают порты
IRealtimeNotifier / ITelegramNotifier и пишут AuditLog
напрямую, после того как изменение состояния сохранено. Так проще проследить, что именно произойдёт
при вызове конкретной команды — не нужно искать обработчик события где-то ещё.
| Хендлер / фоновый сервис | Что происходит |
|---|---|
CreateVpnConfigCommandHandler |
Создаёт клиента в 3x-ui + VpnConfig |
RevokeVpnConfigCommandHandler / RotateVpnConfigCommandHandler |
Меняют клиента в 3x-ui и запись |
RequestActivationCommandHandler |
Realtime activationRequested группе admins + Telegram-уведомление админам (AdminTelegramUserIds) |
ApproveActivationCommandHandler |
AuditLog (ActivationApproved); realtime userActivated владельцу + Telegram-DM, если привязан |
RejectActivationCommandHandler |
AuditLog (ActivationRejected) |
BlockUserCommandHandler / UnblockUserCommandHandler |
Отключают/включают все активные конфиги в 3x-ui; AuditLog; Telegram-DM владельцу |
ChangeUserRoleCommandHandler |
AuditLog (UserRoleChanged) |
ForceRevokeConfigCommandHandler |
Отзывает конфиг в 3x-ui; AuditLog (ConfigForceRevoked); Telegram-DM владельцу |
ResetUserPasswordCommandHandler |
AuditLog (UserPasswordReset) |
DeleteUserCommandHandler |
Отзывает все конфиги пользователя в 3x-ui; AuditLog (UserDeleted); Telegram-DM владельцу; затем удаляет AppUser. Админ не может удалить себя |
RegisterNodeCommandHandler / UpdateNodeCommandHandler / DeleteNodeCommandHandler |
AuditLog (NodeRegistered/NodeUpdated/NodeDeleted) |
PublishInboundCommandHandler |
AuditLog (InboundPublished/InboundUnpublished) |
NodeHealthCheckService (фон) |
Обновляет NodeStatus; realtime nodeStatusChanged группе admins |
TrafficSyncService (фон) |
UpdateTraffic(...); realtime configTrafficUpdated владельцу |
CreateBugReportTicketCommandHandler |
Realtime ticketCreated группе admins; Telegram админам — превью текста + кнопка-ссылка на сайт |
CreateRoleRequestTicketCommandHandler |
Realtime ticketCreated группе admins; Telegram админам — инлайн-кнопки «Одобрить/Отклонить» |
AddTicketCommentCommandHandler |
Realtime ticketUpdated владельцу, только если комментирует не он сам |
ApproveRoleRequestCommandHandler |
Создаёт роль (если новая) + назначает пользователю; AuditLog (RoleRequestApproved); Telegram-DM владельцу |
RejectRoleRequestCommandHandler |
AuditLog (RoleRequestRejected); Telegram-DM владельцу |
CreateExtensionRequestTicketCommandHandler |
Realtime ticketCreated группе admins; Telegram админам — инлайн-кнопки «Одобрить/Отклонить» |
ApproveExtensionRequestCommandHandler |
Продлевает BillingPaidUntil, возвращает приостановленные конфиги; AuditLog (ExtensionRequestApproved); Telegram-DM владельцу |
RejectExtensionRequestCommandHandler |
AuditLog (ExtensionRequestRejected); Telegram-DM владельцу |
GrantBillingGiftCommandHandler |
Админ дарит N дней (/admin/billing/gift) — та же логика продления/возврата конфигов, что и у заявок; AuditLog (BillingGiftGranted); Telegram-DM владельцу |
ResolveTicketCommandHandler / CloseTicketCommandHandler |
AuditLog (TicketResolved/TicketClosed); realtime ticketUpdated владельцу |
SignalR-события и группы — см. architecture.md и api-design.md.