Implement billing status notification and enhance user management integration
CI / Backend (build + test) (push) Successful in 1m30s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s

- Added `NotifyBillingStatusChangedAsync` method to `IRealtimeNotifier` for notifying clients about changes in billing status.
- Updated `BillingConfigResumer` to call the new notification method after modifying billing configurations, ensuring users receive real-time updates.
- Enhanced `ListUsersQueryHandler` to include a `BillingPendingReview` property in `UserSummaryDto`, indicating if a user has a pending payment request awaiting confirmation.
- Refactored various command handlers to utilize `AdvisoryLock` for managing concurrent requests, preventing race conditions in billing operations.
- Updated tests to cover new notification behaviors and ensure proper functionality in billing status management.
This commit is contained in:
Leonid Pershin
2026-07-19 23:22:57 +03:00
parent b32756d5bc
commit e19860ba46
49 changed files with 1195 additions and 233 deletions
+57 -2
View File
@@ -83,7 +83,10 @@ AppUser
`RemoteInboundId` — 3x-ui не переиспользует id — накапливался бы визуальный дубль). Если конфиги
есть — удалить нельзя (FK), инбаунд помечается `MarkUnavailable()` (`IsAvailable=false`,
`IsPublished=false`): новые конфиги на нём не создать, а `Revoke` для существующих конфигов не бьёт
в панель повторно (см. `RevokeVpnConfigCommandHandler`), а отзывает локально.
в панель повторно (см. `RevokeVpnConfigCommandHandler`), а отзывает локально. Если инбаунд позже
снова появляется в ответе 3x-ui (`UpdateFromRemote`) или переопубликовывается (`Publish`) —
`IsAvailable` сбрасывается обратно в `true`: без этого разово пропавший инбаунд оставался бы
недоступным навсегда, даже вернувшись на панель.
> `Node.Status` (health-check раз в 2 минуты, см. `NodeHealthCheckService`) — это диагностический
> индикатор для админа, не гейт для создания конфига: он кэшированный и может ложно показывать
@@ -119,10 +122,22 @@ AppUser
`IXuiPanelGateway.AddClientAsync`, затем `AssignRemoteClient(id)` и сохраняет — при сбое БД после
успешного создания в панели хендлер удаляет клиента в 3x-ui (компенсация).
- **Проверка квоты выполняется под `pg_advisory_xact_lock(hashtext(userId))`** в транзакции создания
(`CreateVpnConfigCommandHandler`) — иначе два параллельных запроса могли бы пробить лимит роли.
(`CreateVpnConfigCommandHandler`) — иначе два параллельных запроса могли бы пробить лимит роли. Тот
же паттерн обобщён в `Application/Common/Concurrency/AdvisoryLock.cs` (`AdvisoryLock.RunAsync`) и
используется во всех "проверил статус — потом изменил" хендлерах одобрения/отклонения заявок и
оплат (`Confirm`/`RejectPaymentRequestCommandHandler`, `Approve`/`RejectRoleRequestCommandHandler`,
`Approve`/`RejectExtensionRequestCommandHandler`, `Create`/`GetLoginRequestStatusQueryHandler`,
`Create...RequestTicket`/`CreatePaymentRequestCommandHandler`) — без него параллельное одобрение той
же заявки с сайта и из Telegram могло бы оба пройти проверку статуса и оба начислить дни/роль/оплату.
На нерелационном EF-провайдере (InMemory в `PnvPanel.Application.Tests`) лок автоматически
пропускается — сериализующее поведение проверяется только в `PnvPanel.IntegrationTests` (реальный
Postgres).
- `Revoke()` → статус `Revoked` (запись остаётся для истории/аудита); хендлер отдельно удаляет клиента в 3x-ui.
- `Rotate(newClientEmail, newClientExternalId)` → перевыпуск: хендлер создаёт нового клиента в 3x-ui,
удаляет старого, генерирует новый `SubscriptionToken`; квоту **не тратит**. Для случая утечки ссылки.
Если после успешного создания нового клиента в панели `SaveChangesAsync` падает (сбой БД) —
хендлер откатывает созданного клиента через `RemoveClientAsync` и возвращает `RotateFailed`, не
оставляя осиротевшего рабочего клиента, ни к одному конфигу не привязанного.
- `Disable()`/`Enable()` → меняют только статус записи (`Active ↔ Disabled`); отключение/включение
самого клиента в 3x-ui делает хендлер отдельным вызовом гейтвея (используется при блокировке юзера).
- `Suspend()`/`Resume()` → меняют статус (`Active ↔ Expired`), отдельно от `Disable()`/`Enable()`
@@ -135,6 +150,14 @@ AppUser
трогает уже созданных клиентов в 3x-ui (см. `IXuiPanelGateway.UpdateClientAsync`, где `LimitIp`
всегда `null` — «не менять»).
- `UpdateTraffic(up, down)` → пишет `TrafficSyncService` при периодической синхронизации, только для отображения.
- **Shadowsocks не поддерживает обновление клиента после создания** — ограничение `ThreeXui.Net`
(`XuiClient.UpdateClient` тихо не выполняет изменение для SS, но раньше сама библиотека всё равно
отдавала «успех»). `XuiPanelGateway.UpdateClientAsync` теперь явно возвращает `Result.Failure`
для `VpnProtocol.Shadowsocks` — не пытается вызвать панель зря и не врёт об успехе PnvPanel. На
практике это значит: `Disable`/`Enable` (блокировка админом), приостановка/возврат за неуплату
(`BillingConfigResumer`) и продление `expiresAt` **не долетают до панели** для SS-конфигов — только
в локальную БД. До появления реальной поддержки в `ThreeXui.Net` SS — известное ограничение, не
скрытое молчаливым сбоем.
- **Доступ разрешён только активированному пользователю** (`AppUser.IsActivated == true`): создание,
просмотр списка, редактирование, ротация, отзыв, получение ссылки/подписки на свои конфиги, а также
чтение новостей и каталога приложений — единая проверка в `RequireActivationBehavior` (pipeline
@@ -314,6 +337,12 @@ UI **настойчиво напоминает** привязать его (ед
одобряет заявку на понижение самому себе — этот путь специально не блокируется отдельно, чтобы не
плодить тикеты, которые некому обработать, если админ единственный.
**Удаление роли** (`IRoleService.DeleteRoleAsync`): запрещено для системных ролей и пока есть живые
пользователи с этой ролью (`RoleErrors.RoleInUse`). Живых пользователей нет — но `Inbound.AllowedRoleIds`
(plain `uuid[]`, без FK) мог всё ещё указывать удаляемую роль; перед `RoleManager.DeleteAsync`
хендлер подчищает такие ссылки (`Inbound.RemoveAllowedRole`), иначе "мёртвый" Id молча оставался бы
висеть в массиве — не пуская никого нового, но и не давая понять почему.
### PricingSettings — глобальная справочная цена конфига
Единственная строка в таблице (singleton) — цена за один конфиг, редактируется админом. Не привязана
к роли: одна цена на весь сервис. Не биллинг — без статусов оплаты, дат окончания, интеграций с
@@ -538,6 +567,16 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты
всё ещё лежит старое `AwaitingConfirmation` — без исключения по Id проверка ложно приняла бы её за ещё
одну висящую заявку).
Каждая из трёх операций также шлёт `IRealtimeNotifier.NotifyBillingStatusChangedAsync(userId, ...)` —
безадресный SignalR-пинг (`billingStatusChanged`, группа `user:{id}`) без пейлоада данных: фронт
(`/billing`) в ответ инвалидирует свой запрос статуса, а не ждёт следующего ручного рефреша/поллинга.
Админский список пользователей (`ListUsersQueryHandler`, `UserSummaryDto.BillingPendingReview`)
отдельно подмешивает "есть Subscription-заявка на `AwaitingConfirmation`" тем же способом, что и
`PaidUntilBadge.pendingReview` на `/billing` у самого пользователя — `IIdentityService` ничего не
знает про `PaymentRequest` (граница Identity/биллинг), поэтому джойн с `PaymentRequests` сделан в
Application-хендлере поверх результата `IIdentityService.ListUsersAsync`, а не внутри Identity.
Прочие точки, не входящие в `BillingConfigResumer`:
- **Создание** (`CreateVpnConfigCommandHandler`) пушит `expiresAt = profile.BillingPaidUntil` при
`BillingEnabled` через `AddClientAsync` — свежий конфиг сразу несёт правильный срок.
@@ -605,6 +644,14 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты
Переходы: `Pending → Approved/Rejected/Expired`; `Approved → Consumed` (после выпуска JWT сайту).
После `Consumed`/`Expired` — не переиспользуется.
`GetLoginRequestStatusQueryHandler` (поллинг статуса с сайта) при первом наблюдении `Approved`
атомарно "забирает" вход: под `AdvisoryLock` (по Id запроса) заново проверяет статус, `Consume()`-ит
и только потом выпускает JWT — без лока два одновременных поллинга (два открытых окна той же вкладки)
могли бы оба увидеть `Approved` до того, как первый допишет `Consume()`, и оба выпустить валидную пару
токенов из одного подтверждения. Как и обычный логин — отказывает **заблокированному** пользователю
(`AppUser.IsBlocked`) до выпуска токенов; так же поступает `RefreshCommandHandler` при ротации
refresh-токена — иначе блокировка обходилась бы passwordless-входом/уже выданным refresh-токеном.
### SupportTicket — обращение в поддержку
Три вида: `BugReport` (свободная форма, с вложениями), `RoleRequest` (запрос существующей роли —
кроме `admin` — либо параметров новой) и `ExtensionRequest` (продление оплаченного периода на N
@@ -638,6 +685,14 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты
- `Close()` — из `Open` или `Resolved`, **финал** (обратного пути нет). Для `RoleRequest`/
`ExtensionRequest` — отклонение.
- `Reopen()` — только из `Resolved` (владелец тикета); `Closed` не переоткрывается.
- **`approve`/`reject` — единственный путь решить `RoleRequest`/`ExtensionRequest`.** Общие
`/admin/support/tickets/{id}/resolve|close` (для произвольного `BugReport`) на этих двух типах
возвращают `OnlyBugReportCanBeResolvedDirectly`/`OnlyBugReportCanBeClosedDirectly` без изменения
статуса — иначе `resolve` обходил бы `ApproveRoleRequestCommandHandler`/
`ApproveExtensionRequestCommandHandler` и переводил тикет в `Resolved`, так и не выдав роль/дни.
По той же причине `Reopen()` на `RoleRequest`/`ExtensionRequest` тоже запрещён
(`OnlyBugReportCanBeReopened`) — переоткрытие уже решённой заявки на роль не имеет осмысленного
действия (роль/дни уже выданы, откатывать их не пытаемся).
- Одновременно не более одной **открытой** заявки на роль (`Type == RoleRequest && Status == Open`)
и отдельно не более одной открытой заявки на продление (`Type == ExtensionRequest && Status == Open`)
на пользователя — проверяется в Application, аналогично `ActivationRequest.AlreadyPending`.