- Introduced a new error, `CannotRemoveLastAdmin`, to handle attempts to downgrade the last admin user in the system. - Updated `RoleService` to check the number of admin users before allowing a role change that would remove the last admin. - Enhanced unit tests to verify the new behavior, ensuring that attempts to downgrade the last admin correctly propagate the failure. - Updated API documentation to reflect the new validation logic and its implications for role management.
46 KiB
Domain Model
Домен — «rich model»: инварианты и переходы состояний живут в сущностях, а не в хендлерах.
AppUser/AppRole — часть Identity (живут в Infrastructure, т.к. расширяют IdentityUser<Guid>/
IdentityRole<Guid>); чистый PnvPanel.Domain ссылается на пользователя/роль только по Guid.
Тарифы Plan и лимиты трафика на конфиг (TrafficLimit) не реализованы — единственная квота:
число активных конфигов на роль (AppRole.MaxConfigs).
Диаграмма связей
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 |
Доступен ли для самообслуживания пользователями |
AllowedRoleIds |
Guid[] |
Id ролей, которым разрешено создавать конфиги (native PostgreSQL uuid[]; не навигация на AppRole — тот в Infrastructure/Identity, Domain на него не ссылается) |
DisplayName |
string? |
Витринное имя для пользователя, напр. «Германия (Trojan)» |
LastSyncAt |
DateTimeOffset? |
Инварианты: конфиг можно создать только если IsPublished && Node.IsEnabled && Node.Status != Offline, и роль пользователя входит в AllowedRoles. Публикация инбаунда админом включает
выбор AllowedRoles (напр. «Германия (Trojan)» → роли user, vip). Лимита числа клиентов на
инбаунд нет — квота ограничивается только на уровне пользователя (AppRole.MaxConfigs).
Пользователю показываем только
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? |
Зарезервировано, сейчас ничего его не выставляет — конфиг живёт бессрочно |
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 делает хендлер отдельным вызовом гейтвея (используется при блокировке юзера).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). - Разрешено несколько конфигов в одном инбаунде (ограничение — только общая квота роли).
Лимиты трафика и автоматическое истечение срока конфига не реализованы.
ExpiresAtникогда не выставляется;ConfigStatus.LimitReachedв значении enum есть, но код в него никогда не переводит конфиг. Квота на число конфигов реализована черезAppRole.MaxConfigs(см. tech-stack.md).
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) — нельзя удалить/переименовать |
Сидируются: 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), в том числе когда админ
одобряет заявку на понижение самому себе — этот путь специально не блокируется отдельно, чтобы не
плодить тикеты, которые некому обработать, если админ единственный.
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 — либо параметров новой). Текст/обоснование не хранится отдельным полем — это первое
сообщение в переписке (TicketComment), созданное вместе с тикетом в одной операции.
| Поле | Тип | Заметки |
|---|---|---|
Id |
Guid |
PK |
UserId |
Guid |
FK → AppUser (автор) |
Type |
TicketType |
BugReport / RoleRequest |
Status |
TicketStatus |
Open / Resolved / Closed |
RequestedRoleId |
Guid? |
Заполнено для RoleRequest при выборе существующей роли |
ProposedRoleName |
string? |
Заполнено для RoleRequest при запросе новой роли |
ProposedMaxConfigs |
int? |
Параметры новой роли (см. AppRole.MaxConfigs) |
ProposedMaxIpLimit |
int? |
Параметры новой роли (см. AppRole.MaxIpLimit) |
CreatedAt |
DateTimeOffset |
Инварианты и переходы (backend/src/PnvPanel.Domain/Support/SupportTicket.cs): RequestedRoleId
и Proposed* никогда не заполнены одновременно — гарантируется отдельными фабриками
(CreateRoleRequestForExistingRole/CreateRoleRequestForNewRole), а не runtime-проверкой.
Resolve()— только изOpen. ДляRoleRequestодобрение — оркестрация в Application (ApproveRoleRequestCommandHandler): при новой роли сначалаIRoleService.CreateRoleAsync, затем в любом случаеChangeUserRoleAsyncпользователю, и только потомticket.Resolve().Close()— изOpenилиResolved, финал (обратного пути нет). ДляRoleRequest— отклонение.Reopen()— только изResolved(владелец тикета);Closedне переоткрывается.- Одновременно не более одной открытой заявки на роль (
Type == RoleRequest && Status == Open) на пользователя — проверяется в Application, аналогичноActivationRequest.AlreadyPending. Баг-репорты такого ограничения не имеют. - Доступ — только активированному пользователю (
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 }
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 владельцу |
ResolveTicketCommandHandler / CloseTicketCommandHandler |
AuditLog (TicketResolved/TicketClosed); realtime ticketUpdated владельцу |
SignalR-события и группы — см. architecture.md и api-design.md.