Implement billing status notification and enhance user management integration
- 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:
+57
-2
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user