- Added new DELETE endpoints for managing audit logs and disabled apps in the admin maintenance section. - Updated existing endpoint for closed tickets to use a unified response type, `MaintenanceCleanupResponseDto`. - Enhanced API documentation to reflect the new operations and their expected request/response formats. - Improved frontend integration with new functions for deleting old audit logs and disabled apps, including user confirmation prompts. - Added localization support for new maintenance actions in both Russian and English.
407 lines
40 KiB
Markdown
407 lines
40 KiB
Markdown
# 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)» |
|
||
| `MaxClients` | `int?` | Лимит клиентов (null = без лимита) |
|
||
| `LastSyncAt` | `DateTimeOffset?` | |
|
||
|
||
Инварианты: конфиг можно создать только если `IsPublished && Node.IsEnabled`, **роль пользователя
|
||
входит в `AllowedRoles`**, и при заданном `MaxClients` он не достигнут. Публикация инбаунда админом
|
||
включает выбор `AllowedRoles` (напр. «Германия (Trojan)» → роли `user`, `vip`).
|
||
|
||
> **Пользователю показываем только `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](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` | Показывать пользователям |
|
||
|
||
Управляется админом (CRUD). Пользователю отдаётся только `IsEnabled`, сгруппировано по `OperatingSystem`.
|
||
Стартовый набор сидируется из [`seed/client-apps.json`](../seed/client-apps.json), если таблица пуста.
|
||
Массовая очистка отключённых (`IsEnabled = false`) — вкладка «Обслуживание»,
|
||
`DELETE /api/admin/maintenance/apps/disabled`.
|
||
|
||
### NewsPost — новости для пользователей
|
||
Публикуются админом немедленно, видны всем залогиненным пользователям в хронологической ленте.
|
||
|
||
| Поле | Тип | Заметки |
|
||
| ----------- | ----------------- | ------------------------------------------------------- |
|
||
| `Id` | `Guid` | PK |
|
||
| `Title` | `string` | Заголовок |
|
||
| `Body` | `string` | Markdown-текст |
|
||
| `CreatedAt` | `DateTimeOffset` | Момент публикации (= момент создания, нет черновиков) |
|
||
| `UpdatedAt` | `DateTimeOffset?` | Момент последней правки (опц.) |
|
||
|
||
Нет статуса черновик/запланировано — публикация мгновенная. Нет видимости по ролям — доступно
|
||
всем аутентифицированным пользователям. Realtime-оповещение о новом посте — `newsPublished`
|
||
(SignalR, широковещательно всем подключенным клиентам), см. [architecture.md](architecture.md#realtime-signalr).
|
||
|
||
### 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`), доступная только
|
||
админу; полного удаления журнала осознанно нет.
|
||
|
||
### 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` → без лимита).
|
||
|
||
**Понижение роли (грандфазеринг)**: смену роли на роль с меньшей квотой разрешаем даже если текущих
|
||
конфигов больше новой квоты — существующие конфиги сохраняются, но **создание новых блокируется**,
|
||
пока число активных не станет меньше квоты. Форс-отзыв лишних не делаем.
|
||
|
||
### 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/*` с
|
||
ролевой проверкой, без завязки на активацию.
|
||
- `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
|
||
|
||
```csharp
|
||
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](architecture.md#realtime-signalr) и
|
||
[api-design.md](api-design.md#signalr--hub-hubspanel).
|