Compare commits
91
Commits
1a8d33efa3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b34c37ce3 | ||
|
|
c2ed3240bd | ||
|
|
cc7e2a7f8f | ||
|
|
a8358b930d | ||
|
|
9925968e22 | ||
|
|
b05b76f32f | ||
|
|
4e1b63645f | ||
|
|
694683455b | ||
|
|
fad03c2834 | ||
|
|
2c5b730500 | ||
|
|
fb97093bd6 | ||
|
|
2f6bb26e97 | ||
|
|
c4bc6ff04b | ||
|
|
99451a425a | ||
|
|
ee6b8a35b8 | ||
|
|
6b449dbfb5 | ||
|
|
fb320fbb31 | ||
|
|
29291b5dec | ||
|
|
33ad98cf62 | ||
|
|
e19860ba46 | ||
|
|
b32756d5bc | ||
|
|
979eddf72e | ||
|
|
5ff5224935 | ||
|
|
0dcaf1203f | ||
|
|
6a2d2d2318 | ||
|
|
1c13695556 | ||
|
|
24cee9bb78 | ||
|
|
e088e302e9 | ||
|
|
450ad1ea1f | ||
|
|
b2ae358250 | ||
|
|
b980dc6cef | ||
|
|
c196e0c322 | ||
|
|
3304eed4b3 | ||
|
|
32221af503 | ||
|
|
6dfd51ae7c | ||
|
|
ae379f8e0f | ||
|
|
285d8180c8 | ||
|
|
68781ef183 | ||
|
|
d30c958dc4 | ||
|
|
f14daa3df1 | ||
|
|
940577d8cd | ||
|
|
b6bffcc302 | ||
|
|
8f6807a456 | ||
|
|
bef3880593 | ||
|
|
8c53fcded2 | ||
|
|
701c3a1d51 | ||
|
|
94ba514b8e | ||
|
|
8dfeb05912 | ||
|
|
9a6540a266 | ||
|
|
d26723dce0 | ||
|
|
df137ca5a7 | ||
|
|
9d5424bb9c | ||
|
|
a833d9aa5b | ||
|
|
6bd34441fb | ||
|
|
eb806a263f | ||
|
|
b5630b2685 | ||
|
|
14b64a3140 | ||
|
|
7f9a441050 | ||
|
|
39a8b30b03 | ||
|
|
7fce5ef181 | ||
|
|
24d9ea1099 | ||
|
|
48e8d06a41 | ||
|
|
b6637a1c03 | ||
|
|
bea2b5fcf7 | ||
|
|
05d49a8cbd | ||
|
|
a0cb1b03be | ||
|
|
10e50eb8c7 | ||
|
|
ad94c6ef22 | ||
|
|
012d08e737 | ||
|
|
66d6584835 | ||
|
|
cfc5433ff3 | ||
|
|
5b398f9c59 | ||
|
|
cf3d8fcad8 | ||
|
|
1452e5c4af | ||
|
|
85becacea5 | ||
|
|
ff523d3d5a | ||
|
|
7dcc8889a0 | ||
|
|
0d0fc2c86b | ||
|
|
ff13583a62 | ||
|
|
0d05ff52e9 | ||
|
|
f731249e78 | ||
|
|
5f005b6496 | ||
|
|
48f620b964 | ||
|
|
3cdd3adf5e | ||
|
|
c04e0d7261 | ||
|
|
cdd67f8e2b | ||
|
|
7e8435ee76 | ||
|
|
8b92204733 | ||
|
|
8067be3c35 | ||
|
|
ed07221ca5 | ||
|
|
7b6fe9ad78 |
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"runtimeExecutable": "pnpm",
|
||||||
|
"runtimeArgs": ["--dir", "frontend", "dev"],
|
||||||
|
"port": 5173,
|
||||||
|
"autoPort": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+40
-16
@@ -21,33 +21,57 @@ Jwt__RefreshTokenDays=30
|
|||||||
# Путь к тому с key-ring (должен быть примонтирован и переживать перезапуск контейнера)
|
# Путь к тому с key-ring (должен быть примонтирован и переживать перезапуск контейнера)
|
||||||
DataProtection__KeyRingPath=/app/keys
|
DataProtection__KeyRingPath=/app/keys
|
||||||
|
|
||||||
|
# ── Файловое хранилище (вложения тикетов поддержки) ─────────────────────────
|
||||||
|
# Путь на диске для скриншотов к тикетам — должен быть на постоянном томе (см. docker-compose.yml,
|
||||||
|
# FileStorage__RootPath там уже задан явно под volume 'ticket_uploads', эту строку трогать не нужно).
|
||||||
|
# FileStorage__RootPath=/app/uploads
|
||||||
|
|
||||||
# ── Сид администратора (создаётся при первом старте, если не существует) ───
|
# ── Сид администратора (создаётся при первом старте, если не существует) ───
|
||||||
# Логин в систему — по username. Email в системе не используется.
|
# Логин в систему — по username. Email в системе не используется.
|
||||||
AdminSeed__Username=admin
|
AdminSeed__Username=admin
|
||||||
AdminSeed__Password=change-me-strong-admin-password
|
AdminSeed__Password=change-me-strong-admin-password
|
||||||
# Telegram id(ы) администраторов (через запятую). Дают права админа в боте
|
|
||||||
# и получают уведомления о запросах на активацию. Узнать id: @userinfobot.
|
|
||||||
AdminSeed__TelegramUserIds=123456789
|
|
||||||
|
|
||||||
# ── Роли по умолчанию ─────────────────────────────────────────────────────
|
# ── Роли по умолчанию ─────────────────────────────────────────────────────
|
||||||
# Квота конфигов для системной роли "user" (выдаётся при регистрации).
|
# Стартовая квота конфигов нового пользователя (AppUser.ConfigQuota при регистрации; далее меняется
|
||||||
|
# самостоятельно через страницу тарифа /plan, не привязана к роли).
|
||||||
Roles__DefaultUserMaxConfigs=3
|
Roles__DefaultUserMaxConfigs=3
|
||||||
|
# Лимит одновременных IP на клиента (limitIp в 3x-ui) для системной роли "user"; -1 = без лимита.
|
||||||
|
Roles__DefaultUserMaxIpLimit=2
|
||||||
|
|
||||||
|
# ── Тарифы (самообслуживание) ────────────────────────────────────────────
|
||||||
|
# Верхняя граница ручного ввода количества конфигов при смене тарифа — защита от абьюза.
|
||||||
|
Plans__MaxCustomConfigCount=50
|
||||||
|
# Нижняя граница ручного ввода — не даёт занизить квоту ниже разумного минимума.
|
||||||
|
Plans__MinCustomConfigCount=3
|
||||||
|
|
||||||
|
# ── Rate limiting ────────────────────────────────────────────────────────
|
||||||
|
# Лимит запросов/мин на auth-эндпоинты (login/register/refresh/telegram/subscription). По умолчанию 20.
|
||||||
|
# RateLimiting__AuthPermitLimit=20
|
||||||
|
|
||||||
# ── Telegram-бот ──────────────────────────────────────────────────────────
|
# ── Telegram-бот ──────────────────────────────────────────────────────────
|
||||||
# Если BotToken пуст — бот не стартует, панель работает без него.
|
# Если BotToken пуст — бот не стартует, панель работает без него. Транспорт — только long polling
|
||||||
|
# (webhook не реализован, отдельного режима/URL для него нет). Username бота для диплинков
|
||||||
|
# (кнопка «Привязать Telegram»/QR) панель узнаёт сама через Bot API (getMe) — задавать его отдельно
|
||||||
|
# не нужно и негде (раньше был Telegram__BotUsername — убран, чтобы не ломать диплинк опечаткой/пробелом).
|
||||||
Telegram__BotToken=
|
Telegram__BotToken=
|
||||||
Telegram__BotUsername=PnvPanelBot
|
# Telegram id(ы) администраторов (через запятую). Дают права админа в боте (кнопки активации)
|
||||||
Telegram__Mode=LongPolling
|
# и получают уведомления о запросах на активацию. Узнать id: @userinfobot.
|
||||||
# Для Mode=Webhook:
|
# Не связано с сид-админом выше — привязка Telegram к сид-админу делается вручную в UI.
|
||||||
# Telegram__WebhookUrl=https://panel.example.com/tg/webhook
|
Telegram__AdminTelegramUserIds=123456789
|
||||||
# Telegram__WebhookSecret=change-me-webhook-secret
|
# Прокси для запросов к Bot API (обычно socks5://[user:pass@]host:port) — на случай, если Telegram
|
||||||
|
# заблокирован напрямую с сети сервера. Пусто (по умолчанию) — без прокси, прямое подключение.
|
||||||
# ── Приложение ────────────────────────────────────────────────────────────
|
# Telegram__ProxyUrl=socks5://127.0.0.1:39372
|
||||||
# Публичный URL сайта (для deep-link'ов бота и ссылок).
|
# Ссылка на сайт панели — кнопка «🌐 Сайт панели» в главном меню бота. Пусто — кнопки не будет.
|
||||||
App__PublicSiteUrl=https://panel.example.com
|
# Telegram__PublicSiteUrl=https://dashboard.example.com
|
||||||
# CORS-источники (для dev; в проде фронт и бек — один origin).
|
|
||||||
App__CorsOrigins=http://localhost:5173
|
|
||||||
|
|
||||||
# ── ASP.NET Core ──────────────────────────────────────────────────────────
|
# ── ASP.NET Core ──────────────────────────────────────────────────────────
|
||||||
ASPNETCORE_ENVIRONMENT=Production
|
ASPNETCORE_ENVIRONMENT=Production
|
||||||
ASPNETCORE_HTTP_PORTS=8080
|
ASPNETCORE_HTTP_PORTS=8080
|
||||||
|
|
||||||
|
# ── Доверенные прокси (X-Forwarded-For/Proto) ──────────────────────────────
|
||||||
|
# TLS терминируется вне compose внешним прокси/шлюзом. Чтобы клиент не мог подделать свой IP/схему
|
||||||
|
# напрямую (в обход прокси), по умолчанию доверяется только loopback (дефолт ASP.NET Core). Если
|
||||||
|
# прокси стоит не на loopback (отдельный контейнер/хост), перечисли его через запятую — конкретные
|
||||||
|
# IP через KnownProxies и/или сети в формате CIDR через KnownNetworks.
|
||||||
|
# ForwardedHeaders__KnownProxies=203.0.113.10
|
||||||
|
# ForwardedHeaders__KnownNetworks=172.18.0.0/16
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend:
|
||||||
|
name: Backend (build + test)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: backend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '10.0.x'
|
||||||
|
|
||||||
|
- name: Restore
|
||||||
|
run: dotnet restore PnvPanel.slnx
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: dotnet build PnvPanel.slnx -c Release --no-restore
|
||||||
|
|
||||||
|
# Docker доступен на ubuntu-latest — интеграционные тесты (Testcontainers.PostgreSql) реально
|
||||||
|
# поднимают Postgres и проверяют HTTP-контракт + pg_advisory_xact_lock под нагрузкой.
|
||||||
|
- name: Test
|
||||||
|
run: dotnet test PnvPanel.slnx -c Release --no-build --logger "console;verbosity=normal"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
name: Frontend (lint + typecheck + build)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Enable corepack
|
||||||
|
run: corepack enable
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
run: corepack prepare pnpm@11.9.0 --activate
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: pnpm lint
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: pnpm typecheck
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
@@ -4,137 +4,151 @@
|
|||||||
|
|
||||||
## Что это
|
## Что это
|
||||||
|
|
||||||
**PnvPanel** — self-service портал для VPN-конфигураций. Пользователи сами создают себе конфиги
|
**PnvPanel** — self-service портал для VPN-конфигураций (VLESS/VMess/Trojan/Shadowsocks): пользователи
|
||||||
(VLESS/VMess/Trojan/Shadowsocks), админ управляет серверами и пользователями. Есть **Telegram-бот**
|
сами создают конфиги, админ управляет серверами и пользователями. Есть **Telegram-бот** (ссылка на
|
||||||
(ссылка на сайт, просмотр конфигов, passwordless-вход через привязку Telegram). Бэкенд оркестрирует
|
сайт, просмотр конфигов, passwordless-вход). Бэкенд оркестрирует панели **3x-ui** через
|
||||||
панели **3x-ui** через библиотеку [`ThreeXui.Net`](https://github.com/mrleo1nid/ThreeXui.Net) и
|
[`ThreeXui.Net`](https://github.com/mrleo1nid/ThreeXui.Net), CQRS — через собственную библиотеку
|
||||||
хранит свою проекцию домена в PostgreSQL. Живые обновления — по SignalR. Приложение (фронт + бек +
|
[`LiteCqrs.Net`](https://github.com/mrleo1nid/LiteCqrs.Net) (NuGet-пакет), и хранит проекцию домена
|
||||||
бот) поставляется **единым Docker-образом**; PostgreSQL — отдельным контейнером в compose.
|
в PostgreSQL.
|
||||||
|
Живые обновления — SignalR. Поставка — **единый Docker-образ** (фронт+бек+бот) + PostgreSQL в compose.
|
||||||
|
|
||||||
> **Статус: проектирование.** Код ещё не написан. Актуальны только документация и этот файл.
|
> Собрано и покрыто тестами, единый образ и compose-стек проверены живьём. Есть опциональный биллинг
|
||||||
> При старте реализации следуй [`docs/roadmap.md`](docs/roadmap.md) (этапы M0…M6).
|
> (подписка по сроку, per-роль). Осознанно не реализовано: лимиты трафика на конфиг, полное
|
||||||
|
> самообслуживание в боте — см. [tech-stack.md](docs/tech-stack.md).
|
||||||
|
|
||||||
## Документация (single source of truth)
|
## Документация (single source of truth)
|
||||||
|
|
||||||
Прежде чем менять архитектуру или добавлять фичу — свериться с [`docs/`](docs/README.md):
|
Прежде чем менять архитектуру или добавлять фичу — свериться с [`docs/`](docs/README.md):
|
||||||
|
[Vision](docs/vision.md) · [Architecture](docs/architecture.md) · [Domain Model](docs/domain-model.md) ·
|
||||||
- [Vision](docs/vision.md) · [Architecture](docs/architecture.md) · [Domain Model](docs/domain-model.md)
|
[Tech Stack](docs/tech-stack.md) · [Backend Conventions](docs/backend-conventions.md) ·
|
||||||
- [Tech Stack (ADR)](docs/tech-stack.md) · [Backend Conventions](docs/backend-conventions.md)
|
[Frontend](docs/frontend.md) · [Telegram Bot](docs/telegram-bot.md) · [API Design](docs/api-design.md)
|
||||||
- [Frontend](docs/frontend.md) · [Telegram Bot](docs/telegram-bot.md) · [API Design](docs/api-design.md) · [Roadmap](docs/roadmap.md)
|
|
||||||
|
|
||||||
**Держи доки в синхроне с кодом.** Меняешь контракт/архитектуру — обнови соответствующий док в том же изменении.
|
**Держи доки в синхроне с кодом.** Меняешь контракт/архитектуру — обнови соответствующий док в том же изменении.
|
||||||
|
|
||||||
## Стек
|
## Стек
|
||||||
|
|
||||||
- **Backend**: C# / .NET 10, ASP.NET Core Web API, Clean Architecture, CQRS (**собственный тонкий
|
- **Backend**: C# / .NET 10, ASP.NET Core Web API, Clean Architecture, CQRS (через
|
||||||
диспетчер**, без MediatR), EF Core 10 + Npgsql (PostgreSQL), ASP.NET Core Identity + JWT, SignalR,
|
[`LiteCqrs.Net`](https://github.com/mrleo1nid/LiteCqrs.Net) — собственная лёгкая
|
||||||
FluentValidation, Mapster, **Serilog** (логирование).
|
CQRS-библиотека, альтернатива MediatR с явным разделением Command/Query), EF Core 10 + Npgsql,
|
||||||
- **Frontend**: React 19 + Vite + TypeScript, TanStack Query/Router, shadcn/ui + Tailwind CSS v4,
|
ASP.NET Core Identity + JWT, SignalR, FluentValidation,
|
||||||
Zustand, react-hook-form + zod, @microsoft/signalr, Recharts. Пакетный менеджер — pnpm.
|
Serilog. Маппинг DTO вручную (`FromDomain(...)`, без Mapster). OpenAPI — нативный
|
||||||
- **Telegram**: Telegram.Bot, бот как `BackgroundService` **в процессе Api** (long polling).
|
`Microsoft.AspNetCore.OpenApi` + Scalar UI (без Swashbuckle).
|
||||||
- **Инфра**: единый Docker-образ (API + бот + статика SPA) + PostgreSQL в docker-compose.
|
- **Frontend**: React 19 + Vite + TS, TanStack Query/Router, shadcn-стиль поверх Radix + Tailwind v4,
|
||||||
|
Zustand (только auth), react-hook-form + zod, @microsoft/signalr. pnpm, oxlint.
|
||||||
|
- **Telegram**: Telegram.Bot как `BackgroundService` **в процессе Api** (long polling).
|
||||||
|
- **Инфра**: единый Docker-образ (API+бот+статика SPA) + PostgreSQL в docker-compose.
|
||||||
|
|
||||||
## Архитектура — жёсткие правила
|
## Архитектура — жёсткие правила
|
||||||
|
|
||||||
Слои и направление зависимостей: **Api → Infrastructure → Application → Domain** (внутрь).
|
Слои и направление зависимостей: **Api → Infrastructure → Application → Domain** (внутрь).
|
||||||
|
|
||||||
- **Domain** — без внешних зависимостей. Rich model: инварианты в сущностях (приватные сеттеры,
|
- **Domain** — без внешних зависимостей. Rich model: инварианты в сущностях (приватные сеттеры,
|
||||||
фабричные методы, поведенческие методы). Никаких EF/HTTP/Identity здесь.
|
фабричные/поведенческие методы). Никаких EF/HTTP/Identity.
|
||||||
- **Application** — CQRS-хендлеры, DTO, валидаторы, **порты** (интерфейсы). Зависит только от Domain.
|
- **Application** — CQRS-хендлеры, DTO, валидаторы, **порты** (интерфейсы). Зависит только от Domain —
|
||||||
Никаких `Npgsql`/`SignalR`/`ThreeXui.Net` — только их интерфейсы (`IAppDbContext`, `IXuiPanelGateway`,
|
никаких `Npgsql`/`SignalR`/`ThreeXui.Net`, только их интерфейсы (`IAppDbContext`, `IXuiPanelGateway`,
|
||||||
`IRealtimeNotifier`, `ISecretProtector`, `ICurrentUser`, ...).
|
`IRealtimeNotifier`, `ISecretProtector`, `ICurrentUser`, ...).
|
||||||
- **Infrastructure** — реализации портов: EF Core, Identity/JWT, `XuiPanelGateway`, SignalR-пуш,
|
- **Infrastructure** — реализации портов: EF Core, Identity/JWT, `XuiPanelGateway`, SignalR-пуш,
|
||||||
фоновые сервисы, шифрование секретов.
|
фоновые сервисы, шифрование секретов.
|
||||||
- **Api** — Minimal API эндпоинты (по фичам), SignalR-хабы, middleware, DI composition root.
|
- **Api** — Minimal API эндпоинты (по фичам), SignalR-хабы, middleware, DI composition root.
|
||||||
|
|
||||||
Обязательно:
|
Обязательно:
|
||||||
- CQRS: команды меняют состояние и идут в транзакции (UnitOfWorkBehavior); запросы только читают
|
- Команды меняют состояние в транзакции (`UnitOfWorkBehavior`); запросы только читают (`AsNoTracking` +
|
||||||
(`AsNoTracking` + проекция в DTO). Диспетчер — **собственный** (`ISender`/`ICommandHandler`/
|
проекция в DTO). Диспетчер — **собственный** (`ISender`/`ICommandHandler`/`IQueryHandler`, DI-регистрация).
|
||||||
`IQueryHandler`, регистрация хендлеров через DI), без внешних CQRS-библиотек.
|
- Управляемые ошибки — через `Result<T>`, не исключениями (исключения только для исключительного).
|
||||||
- Управляемые ошибки — через `Result<T>`, не исключениями. Исключения — только для исключительного.
|
|
||||||
- Валидация — FluentValidation через `ValidationBehavior`; хендлер не перепроверяет формат ввода.
|
- Валидация — FluentValidation через `ValidationBehavior`; хендлер не перепроверяет формат ввода.
|
||||||
- Всё I/O асинхронно, `CancellationToken` пробрасывается до EF/HTTP. Никаких `.Result`/`.Wait()`.
|
- Всё I/O асинхронно, `CancellationToken` пробрасывается до EF/HTTP. Никаких `.Result`/`.Wait()`.
|
||||||
- Nullable reference types включены; предупреждения анализаторов не игнорировать.
|
- Nullable reference types включены; предупреждения анализаторов не игнорировать.
|
||||||
|
|
||||||
## Интеграция с 3x-ui
|
## Интеграция с 3x-ui
|
||||||
|
|
||||||
- Только через порт `IXuiPanelGateway`. `ThreeXui.Net` регистрируется на один `BaseAddress`, а нод
|
- Только через порт `IXuiPanelGateway`. Один `BaseAddress` в `ThreeXui.Net`, а нод много → гейтвей
|
||||||
много → гейтвей держит **клиента per-node** (кэш по `NodeId`), создавая его из расшифрованных
|
держит **клиента per-node** (кэш по `NodeId`) из расшифрованных `NodeCredentials`. Детали —
|
||||||
`NodeCredentials`. Детали — в [architecture.md](docs/architecture.md#интеграция-с-3x-ui-threexuinet).
|
[architecture.md](docs/architecture.md#интеграция-с-3x-ui-threexuinet).
|
||||||
- Пароли нод **шифруются at-rest** (`ISecretProtector`), расшифровка только внутри Infrastructure,
|
- Пароли нод **шифруются at-rest** (`ISecretProtector`), расшифровка только в Infrastructure, никогда в логах/ответах.
|
||||||
никогда не в логах/ответах API.
|
|
||||||
- Недоступность ноды → `Result.Failure`/`NodeStatus.Offline`, не 500 наружу.
|
- Недоступность ноды → `Result.Failure`/`NodeStatus.Offline`, не 500 наружу.
|
||||||
- Операции с 3x-ui идемпотентны; при частичном сбое (клиент создан в панели, но упала БД) — компенсация.
|
- Операции идемпотентны; при частичном сбое (клиент создан в панели, упала БД) — компенсация.
|
||||||
|
|
||||||
## Роли, активация, сидинг
|
## Домен: роли, активация, конфиги
|
||||||
|
|
||||||
- **Роли динамические**: `AppRole : IdentityRole<Guid>` + поле `MaxConfigs` (квота на число конфигов).
|
Полная модель — [domain-model.md](docs/domain-model.md). Ключевые инварианты:
|
||||||
Квота — **на роли, а не на `Plan`**. **У пользователя ровно одна роль**; квота = `MaxConfigs` его
|
|
||||||
роли (`admin` — без лимита). Системные роли (`admin`/`user`) не удалять/переименовывать.
|
- **Роли динамические** (`AppRole`), квоты на роли (не на `Plan`): `MaxConfigs` (число конфигов),
|
||||||
- **Активация**: новый пользователь `IsActivated = false`, роль `user`. Конфиги может создавать
|
`MaxIpLimit` (лимит одновременных IP клиента в 3x-ui, `limitIp`); -1 = без лимита. У пользователя
|
||||||
только активированный. `ActivationRequest` (с комментарием заявителя) одобряет админ на сайте
|
ровно одна роль; `admin` — без лимитов. Системные роли `admin`/`user` не удалять/переименовывать.
|
||||||
**или** в Telegram — одними и теми же командами (`ApproveActivationCommand`/`RejectActivationCommand`).
|
Понижение роли — грандфазеринг (лишние конфиги не отзываются, новые блокируются до входа в квоту).
|
||||||
- **Инбаунды по ролям**: `Inbound.AllowedRoles` (M:N). При создании конфига доменный инвариант
|
- **`limitIp`** выставляется автоматически по `MaxIpLimit` роли при создании клиента (`Create`/`Rotate`);
|
||||||
проверяет: активирован + под квотой роли + роль входит в `AllowedRoles` инбаунда + нода включена.
|
панель не даёт настраивать его per-конфиг и не трогает уже созданных клиентов при смене роли/квоты.
|
||||||
Проверку квоты делать **в транзакции** (гонки параллельных созданий).
|
- **Активация**: новый пользователь `IsActivated=false`, роль `user`; неактивированному недоступны
|
||||||
- **Понижение роли — грандфазеринг**: смена на меньшую квоту разрешена; лишние конфиги не отзываем,
|
конфиги (создание/просмотр/редактирование/ротация/отзыв/ссылка/подписка), новости и каталог
|
||||||
но новые нельзя до входа в квоту.
|
приложений — единая проверка `RequireActivationBehavior` по маркеру `IRequiresActivation` (не
|
||||||
- **Блокировка** (`AppUser.IsBlocked`): вход запрещён + все конфиги `Disabled` (отключить клиентов
|
разбросанные `if` в хендлерах). На фронте до активации доступны только дашборд (форма запроса
|
||||||
в 3x-ui); разблокировка — обратно. Действие в `AuditLog`.
|
активации) и настройки аккаунта. `ActivationRequest` одобряет админ на сайте или в Telegram —
|
||||||
- **Аудит**: значимые действия (активация, блок, смена роли, отзыв, ноды/инбаунды) писать в `AuditLog`
|
одними командами.
|
||||||
|
- **Инбаунды по ролям** (`Inbound.AllowedRoles`, M:N): создание конфига проверяет активацию + квоту роли
|
||||||
|
(в транзакции — гонки параллельных созданий) + `AllowedRoles` + включённость ноды.
|
||||||
|
- **Поддержка** (`SupportTicket`, доступна только активированным): баг-репорт/предложение (свободная
|
||||||
|
форма + вложения-картинки, диск-хранилище `IFileStorage`) либо заявка на роль (существующая роль,
|
||||||
|
кроме `admin`, либо параметры новой). `Open → Resolved → [Reopen]`, `Closed` — финал без возврата.
|
||||||
|
Одобрение заявки на роль создаёт/назначает роль автоматически; полностью решается и в Telegram
|
||||||
|
(инлайн-кнопки), баг-репорты — только уведомление-ссылка на сайт.
|
||||||
|
- **Блокировка** (`AppUser.IsBlocked`): вход запрещён + все конфиги `Disabled` в 3x-ui; в `AuditLog`.
|
||||||
|
- **Удаление пользователя** — свой аккаунт (`DELETE /api/auth/me`) или админом
|
||||||
|
(`DELETE /api/admin/users/{id}`, себя удалить нельзя): отзыв всех конфигов в 3x-ui, затем `AppUser`;
|
||||||
|
админский путь дополнительно пишет `AuditLog` (`UserDeleted`) и шлёт Telegram-DM.
|
||||||
|
- **Аудит**: значимые действия (активация/блок/роль/отзыв/ноды/инбаунды/удаление) — `AuditLog`
|
||||||
(append-only, источник Web/Telegram/System).
|
(append-only, источник Web/Telegram/System).
|
||||||
- **Подписка**: агрегированная на юзера (`AppUser.SubscriptionToken`, все активные конфиги) + по конфигу.
|
- **Биллинг** (`AppRole.BillingEnabled`, недоступен для `admin`): пользователь оформляет
|
||||||
- **Ротация конфига** (`Rotate()`): новый UUID/ссылка, квоту не тратит. **Бот в MVP — read-only** по конфигам.
|
`PaymentRequest` на 3/6/12 мес (сумма — по `PricingSettings`, заморожена на заявке), админ
|
||||||
- **Конфиг**: пользователь задаёт метку (`Label`) и лимит устройств (`DeviceLimit` → `limitIp` в 3x-ui, 0=без лимита), может редактировать.
|
подтверждает/отклоняет на сайте или в Telegram (`pay:*`). Пока заявка `AwaitingConfirmation` —
|
||||||
- **Самоудаление аккаунта** (`DELETE /api/auth/me`): отзыв всех конфигов + удаление данных, аудит анонимизируется.
|
конфиги не гасятся, даже если срок истёк (не по вине пользователя, что админ не успел). Просрочка
|
||||||
- **API без версионирования** в MVP (`/api` без `v1`). Подписка отдаёт `Subscription-Userinfo`.
|
без заявки → `VpnConfig.Suspend()` (статус `Expired`, отдельно от `Disable()`/блокировки админом) —
|
||||||
- **Тема**: светлая/тёмная/системная (Tailwind `dark`, выбор в localStorage).
|
см. [domain-model.md](docs/domain-model.md#billing--подписка-по-сроку).
|
||||||
- **Инструкции + приложения**: отдельная страница инструкций; каталог `ClientApp` (админ CRUD:
|
- **Ротация конфига** (`Rotate()`) — новый UUID/ссылка, квоту не тратит. **Бот read-only** по конфигам.
|
||||||
название/ссылка/ОС/порядок/вкл), пользователю `GET /api/apps` отдаётся сгруппированным по ОС.
|
- **Подписка**: агрегированная (`AppUser.SubscriptionToken`) + по конфигу. API без версионирования
|
||||||
- **Вход — по `UserName`** (email в системе не используется вовсе; SMTP не нужен).
|
(`/api`, без `v1`); подписка отдаёт `Subscription-Userinfo`.
|
||||||
Восстановление пароля: через привязанный Telegram (self-service), без привязки — сброс админом
|
- **Вход по `UserName`** (email не используется). Восстановление пароля — через привязанный Telegram
|
||||||
(`ResetUserPasswordCommand`). Пока Telegram не привязан — UI настойчиво предлагает его привязать.
|
(self-service) либо сбросом админом (`ResetUserPasswordCommand`); без привязки UI настойчиво предлагает привязать.
|
||||||
- **Сидинг из env**: идемпотентный `DbInitializer` на старте создаёт системные роли и учётку админа
|
- **Сидинг**: идемпотентный `DbInitializer` создаёт системные роли + админа из env
|
||||||
(username/пароль/Telegram id) из переменных окружения; каталог приложений `ClientApp` (если пуст) —
|
(`AdminSeed__Username`/`Password`); каталог `ClientApp` — из [`seed/client-apps.json`](seed/client-apps.json).
|
||||||
из [`seed/client-apps.json`](seed/client-apps.json). Единый источник примера env — [`.env.example`](.env.example);
|
Источник примера env — [`.env.example`](.env.example), обновляй при новых настройках. Секреты — только через env.
|
||||||
при добавлении новой настройки обновляй и его. Секреты (пароль админа, JWT-ключ, BotToken) — только через env/secret-store.
|
`Telegram__AdminTelegramUserIds` — отдельно от сидинга, не пишется в БД, читается напрямую из `TelegramOptions`.
|
||||||
- Telegram id админов (`AdminSeed__TelegramUserIds`) авторизуют админ-действия в боте и получают
|
|
||||||
уведомления о запросах активации.
|
|
||||||
|
|
||||||
## Telegram-бот
|
## Telegram-бот
|
||||||
|
|
||||||
- Бот — **presentation-адаптер**, не бизнес-слой. Хостится в процессе Api (`TelegramBotHostedService`,
|
Детали флоу — [telegram-bot.md](docs/telegram-bot.md).
|
||||||
long polling). Хендлеры апдейтов вызывают **те же** CQRS-команды/запросы через собственный `ISender`
|
|
||||||
(`GetMyConfigsQuery`, `LinkTelegramCommand`, `ApproveTelegramLoginCommand`, ...).
|
- Presentation-адаптер, не бизнес-слой: `TelegramBotHostedService` (long polling) вызывает **те же**
|
||||||
- `Telegram.Bot` не проникает в Application/Domain — только в `Api/Telegram/`.
|
CQRS-команды через `ISender`. `Telegram.Bot` не проникает в Application/Domain — только `Api/Telegram/`.
|
||||||
- Passwordless-вход выпускает **те же** JWT/refresh, что и обычный логин. Требует привязки Telegram
|
- Passwordless-вход выпускает те же JWT/refresh, что и обычный логин; требует привязки Telegram (с сайта)
|
||||||
(в MVP — только привязка существующего аккаунта, регистрация из бота — backlog).
|
либо регистрации прямо из бота (`RegisterViaTelegramCommand` — логин `@username`/id, пароль генерируется
|
||||||
- Токены привязки/входа: короткоживущие, одноразовые, высокоэнтропийные. `Telegram:BotToken` — секрет,
|
и приходит в чат один раз). Новый аккаунт — роль `user`, `IsActivated=false`, активация как обычно.
|
||||||
не логировать. Панель должна работать и без бота (если токен не задан — бот просто не стартует).
|
- Токены привязки/входа — короткоживущие одноразовые. `Telegram:BotToken` — секрет, не логировать.
|
||||||
- Детали флоу — [telegram-bot.md](docs/telegram-bot.md).
|
Без токена бот просто не стартует — панель работает и без него.
|
||||||
|
|
||||||
## Единый контейнер
|
## Единый контейнер
|
||||||
|
|
||||||
- Один образ приложения: Api раздаёт REST (`/api`), SignalR (`/hubs`), хостит бота **и** статику SPA
|
- Один образ: Api раздаёт REST (`/api`), SignalR (`/hubs`), хостит бота и статику SPA из `wwwroot`
|
||||||
из `wwwroot` (fallback на `index.html`). Фронт и бек — один origin, база API — относительный `/api`.
|
(fallback на `index.html`). Один origin, база API — относительный `/api`.
|
||||||
- Multi-stage Dockerfile: node (сборка фронта) → dotnet sdk (publish + копирование в `wwwroot`) → aspnet runtime.
|
- Multi-stage Dockerfile: node (фронт) → dotnet sdk (publish + копирование в `wwwroot`) → aspnet runtime.
|
||||||
- docker-compose: `app` (единый образ) + `db` (PostgreSQL). В dev — Vite-прокси `/api`,`/hubs` на бэк.
|
- docker-compose: `app` + `db` (PostgreSQL); в dev — Vite-прокси `/api`,`/hubs` на бэк.
|
||||||
- Не вводи отдельный nginx-контейнер для статики без явной просьбы — это ломает требование единого контейнера.
|
- Не вводи отдельный nginx-контейнер для статики без явной просьбы — ломает требование единого контейнера.
|
||||||
- **TLS — внешний** (прокси/шлюз вне compose); `app` отдаёт HTTP + доверяет `X-Forwarded-*` через
|
- **TLS — внешний**; `app` отдаёт HTTP + доверяет `X-Forwarded-*` (`ForwardedHeaders`). Свой nginx/Caddy не добавляй.
|
||||||
`ForwardedHeaders` (иначе Secure-cookie/схема за прокси сломаются). Свой nginx/Caddy не добавляй.
|
- Миграции применяются авто на старте. CI (GitHub Actions) — только build/test, без деплоя.
|
||||||
- **Миграции** применяются авто на старте (MVP). **CI** (GitHub Actions) — только build/test, без деплоя.
|
|
||||||
|
|
||||||
## Соглашения по коду
|
## Соглашения по коду
|
||||||
|
|
||||||
Полный список — в [backend-conventions.md](docs/backend-conventions.md). Кратко:
|
Полный список — [backend-conventions.md](docs/backend-conventions.md). Кратко:
|
||||||
|
|
||||||
- Команды `<Verb><Noun>Command`, запросы `<Get/List><Noun>Query`, + `Handler`/`Validator`. DTO — суффикс `Dto`.
|
- `<Verb><Noun>Command`/`<Get|List><Noun>Query` + `Handler`/`Validator` (валидатор — где есть что
|
||||||
- Application организована **по фичам** (feature folders) внутри слоёв.
|
проверить). DTO — суффикс `Dto`; тела запросов Api — `Body`; тела ответов без Application DTO —
|
||||||
- Один публичный тип на файл, имя файла = имя типа. Async-методы — суффикс `Async` + `CancellationToken`.
|
`ResponseDto`. Application — по фичам (feature folders).
|
||||||
- Секреты не логировать; логи структурные (Serilog) с `UserId`/`NodeId`/`ConfigId`/`CorrelationId`.
|
- Один публичный тип на файл = имя файла (кроме `Body`/`ResponseDto` в файле эндпоинтов).
|
||||||
- Ошибки API — единый `ProblemDetails`.
|
Async-методы — суффикс `Async` + `CancellationToken`.
|
||||||
|
- Секреты не логировать; логи — Serilog. Явного обогащения `UserId`/`NodeId`/`ConfigId`/`CorrelationId`
|
||||||
|
пока нет — не полагайся на него при расследовании.
|
||||||
|
- Ошибки API — единый `application/problem+json`.
|
||||||
|
|
||||||
## Команды (ожидаемые — появятся по мере создания проектов)
|
## Команды
|
||||||
|
|
||||||
Backend (из `backend/`):
|
Backend (из `backend/`):
|
||||||
```bash
|
```bash
|
||||||
@@ -161,19 +175,17 @@ docker compose up -d # api + postgres (+ web)
|
|||||||
```
|
```
|
||||||
|
|
||||||
> Окружение: Windows, основная оболочка — **PowerShell**. Для POSIX-скриптов есть Bash-инструмент.
|
> Окружение: Windows, основная оболочка — **PowerShell**. Для POSIX-скриптов есть Bash-инструмент.
|
||||||
> Пути — с учётом Windows.
|
|
||||||
|
|
||||||
## Принятые решения (зафиксированы)
|
## Ключевые решения
|
||||||
|
|
||||||
Ключевые развилки закрыты — см. [tech-stack.md](docs/tech-stack.md#принятые-решения-по-открытым-вопросам):
|
См. [tech-stack.md](docs/tech-stack.md#ключевые-решения-по-домену-и-поведению): CQRS — через
|
||||||
CQRS — **собственный диспетчер** (не MediatR); **одна роль** на пользователя; секреты нод —
|
LiteCqrs.Net (собственная библиотека, не MediatR); одна роль на пользователя; секреты нод — ASP.NET Data Protection; биллинг
|
||||||
**ASP.NET Data Protection**; тарифы `Plan` — **backlog** (в MVP без лимитов трафика/срока);
|
опционален per-роль (недоступен для `admin`); i18n — RU+EN (react-i18next); Telegram — long polling,
|
||||||
i18n — **RU+EN** (react-i18next); Telegram — **long polling**, только **привязка** (не signup);
|
только привязка (не signup); история трафика — простая таблица + TTL; логирование — Serilog.
|
||||||
история трафика — **простая таблица + TTL**; логирование — **Serilog**.
|
|
||||||
|
|
||||||
## Рабочие принципы
|
## Рабочие принципы
|
||||||
|
|
||||||
- Не начинай крупную реализацию без сверки с доками и, при неоднозначности, без вопроса пользователю.
|
- Не начинай крупную реализацию без сверки с доками и, при неоднозначности, без вопроса пользователю.
|
||||||
- Соблюдай границы слоёв — это главный инвариант проекта. Нарушение = ошибка ревью.
|
- Соблюдай границы слоёв — главный инвариант проекта. Нарушение = ошибка ревью.
|
||||||
- Обновляй документацию вместе с кодом. Не коммить и не пуши без явной просьбы.
|
- Обновляй документацию вместе с кодом. Не коммить и не пуши без явной просьбы.
|
||||||
- Отвечай пользователю на русском (язык общения в проекте — русский).
|
- Отвечай пользователю на русском.
|
||||||
|
|||||||
+14
-4
@@ -12,13 +12,13 @@ RUN pnpm build
|
|||||||
# ── Stage 2: publish бэкенда, статика фронта в wwwroot ────────────────────
|
# ── Stage 2: publish бэкенда, статика фронта в wwwroot ────────────────────
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS backend
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS backend
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
# Сначала манифесты для кэша restore
|
# Сначала манифесты для кэша restore (только цепочка Api → ... → Domain, без tests/)
|
||||||
COPY backend/PnvPanel.sln backend/Directory.Build.props backend/Directory.Packages.props ./backend/
|
COPY backend/Directory.Build.props backend/Directory.Packages.props ./backend/
|
||||||
COPY backend/src/PnvPanel.Domain/PnvPanel.Domain.csproj ./backend/src/PnvPanel.Domain/
|
COPY backend/src/PnvPanel.Domain/PnvPanel.Domain.csproj ./backend/src/PnvPanel.Domain/
|
||||||
COPY backend/src/PnvPanel.Application/PnvPanel.Application.csproj ./backend/src/PnvPanel.Application/
|
COPY backend/src/PnvPanel.Application/PnvPanel.Application.csproj ./backend/src/PnvPanel.Application/
|
||||||
COPY backend/src/PnvPanel.Infrastructure/PnvPanel.Infrastructure.csproj ./backend/src/PnvPanel.Infrastructure/
|
COPY backend/src/PnvPanel.Infrastructure/PnvPanel.Infrastructure.csproj ./backend/src/PnvPanel.Infrastructure/
|
||||||
COPY backend/src/PnvPanel.Api/PnvPanel.Api.csproj ./backend/src/PnvPanel.Api/
|
COPY backend/src/PnvPanel.Api/PnvPanel.Api.csproj ./backend/src/PnvPanel.Api/
|
||||||
RUN dotnet restore backend/PnvPanel.sln
|
RUN dotnet restore backend/src/PnvPanel.Api/PnvPanel.Api.csproj
|
||||||
# Исходники бэкенда
|
# Исходники бэкенда
|
||||||
COPY backend/ ./backend/
|
COPY backend/ ./backend/
|
||||||
# Сид каталога приложений (PnvPanel.Api.csproj ссылается на него через ../../../seed/)
|
# Сид каталога приложений (PnvPanel.Api.csproj ссылается на него через ../../../seed/)
|
||||||
@@ -30,8 +30,18 @@ RUN dotnet publish backend/src/PnvPanel.Api/PnvPanel.Api.csproj -c Release -o /a
|
|||||||
# ── Stage 3: runtime ──────────────────────────────────────────────────────
|
# ── Stage 3: runtime ──────────────────────────────────────────────────────
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
# curl — HEALTHCHECK (GET /health). gosu — безопасный drop-privileges из entrypoint: контейнер
|
||||||
|
# стартует под root, чтобы почистить владельца смонтированных volume'ов, и тут же передаёт
|
||||||
|
# управление непривилегированному пользователю (см. docker-entrypoint.sh).
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends curl gosu \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
ENV ASPNETCORE_ENVIRONMENT=Production \
|
ENV ASPNETCORE_ENVIRONMENT=Production \
|
||||||
ASPNETCORE_HTTP_PORTS=8080
|
ASPNETCORE_HTTP_PORTS=8080
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
COPY --from=backend /app/publish ./
|
COPY --from=backend /app/publish ./
|
||||||
ENTRYPOINT ["dotnet", "PnvPanel.Api.dll"]
|
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh && mkdir -p /app/keys /app/uploads
|
||||||
|
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=5 \
|
||||||
|
CMD curl -f http://localhost:8080/health || exit 1
|
||||||
|
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||||
|
CMD ["dotnet", "PnvPanel.Api.dll"]
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.5.2.0
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "backend", "backend", "{1AE8ACA6-933B-BF2A-3671-3E2EAC007D16}"
|
||||||
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{0F9113EE-888A-26D2-68B0-4A7D0A2A8745}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PnvPanel.Api", "backend\src\PnvPanel.Api\PnvPanel.Api.csproj", "{3B6A930E-4799-6F42-1E94-163F6773FBBC}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PnvPanel.Application", "backend\src\PnvPanel.Application\PnvPanel.Application.csproj", "{25F9AF36-7508-0DC8-2469-D065D078DBF3}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PnvPanel.Domain", "backend\src\PnvPanel.Domain\PnvPanel.Domain.csproj", "{7511C433-2BCA-BF01-BEF4-DBC6A5DB8CF3}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PnvPanel.Infrastructure", "backend\src\PnvPanel.Infrastructure\PnvPanel.Infrastructure.csproj", "{470155DC-9172-CAAF-7AA4-D642ECCFD2D2}"
|
||||||
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{F57642F3-C37C-D174-720E-6A6AAD5BEE22}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PnvPanel.Application.Tests", "backend\tests\PnvPanel.Application.Tests\PnvPanel.Application.Tests.csproj", "{4A683703-6702-96CD-5AB6-56199C1C1C7E}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PnvPanel.Domain.Tests", "backend\tests\PnvPanel.Domain.Tests\PnvPanel.Domain.Tests.csproj", "{F3793E3C-AC9E-D8DA-704A-E67DC4FA790F}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PnvPanel.IntegrationTests", "backend\tests\PnvPanel.IntegrationTests\PnvPanel.IntegrationTests.csproj", "{8A44D601-6F27-4D86-63F7-25C42FF67414}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{3B6A930E-4799-6F42-1E94-163F6773FBBC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{3B6A930E-4799-6F42-1E94-163F6773FBBC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{3B6A930E-4799-6F42-1E94-163F6773FBBC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{3B6A930E-4799-6F42-1E94-163F6773FBBC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{25F9AF36-7508-0DC8-2469-D065D078DBF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{25F9AF36-7508-0DC8-2469-D065D078DBF3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{25F9AF36-7508-0DC8-2469-D065D078DBF3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{25F9AF36-7508-0DC8-2469-D065D078DBF3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{7511C433-2BCA-BF01-BEF4-DBC6A5DB8CF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{7511C433-2BCA-BF01-BEF4-DBC6A5DB8CF3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{7511C433-2BCA-BF01-BEF4-DBC6A5DB8CF3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{7511C433-2BCA-BF01-BEF4-DBC6A5DB8CF3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{470155DC-9172-CAAF-7AA4-D642ECCFD2D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{470155DC-9172-CAAF-7AA4-D642ECCFD2D2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{470155DC-9172-CAAF-7AA4-D642ECCFD2D2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{470155DC-9172-CAAF-7AA4-D642ECCFD2D2}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{4A683703-6702-96CD-5AB6-56199C1C1C7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{4A683703-6702-96CD-5AB6-56199C1C1C7E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{4A683703-6702-96CD-5AB6-56199C1C1C7E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{4A683703-6702-96CD-5AB6-56199C1C1C7E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{F3793E3C-AC9E-D8DA-704A-E67DC4FA790F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{F3793E3C-AC9E-D8DA-704A-E67DC4FA790F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{F3793E3C-AC9E-D8DA-704A-E67DC4FA790F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{F3793E3C-AC9E-D8DA-704A-E67DC4FA790F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{8A44D601-6F27-4D86-63F7-25C42FF67414}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{8A44D601-6F27-4D86-63F7-25C42FF67414}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{8A44D601-6F27-4D86-63F7-25C42FF67414}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{8A44D601-6F27-4D86-63F7-25C42FF67414}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(NestedProjects) = preSolution
|
||||||
|
{0F9113EE-888A-26D2-68B0-4A7D0A2A8745} = {1AE8ACA6-933B-BF2A-3671-3E2EAC007D16}
|
||||||
|
{3B6A930E-4799-6F42-1E94-163F6773FBBC} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745}
|
||||||
|
{25F9AF36-7508-0DC8-2469-D065D078DBF3} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745}
|
||||||
|
{7511C433-2BCA-BF01-BEF4-DBC6A5DB8CF3} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745}
|
||||||
|
{470155DC-9172-CAAF-7AA4-D642ECCFD2D2} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745}
|
||||||
|
{F57642F3-C37C-D174-720E-6A6AAD5BEE22} = {1AE8ACA6-933B-BF2A-3671-3E2EAC007D16}
|
||||||
|
{4A683703-6702-96CD-5AB6-56199C1C1C7E} = {F57642F3-C37C-D174-720E-6A6AAD5BEE22}
|
||||||
|
{F3793E3C-AC9E-D8DA-704A-E67DC4FA790F} = {F57642F3-C37C-D174-720E-6A6AAD5BEE22}
|
||||||
|
{8A44D601-6F27-4D86-63F7-25C42FF67414} = {F57642F3-C37C-D174-720E-6A6AAD5BEE22}
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
|
SolutionGuid = {B0DAEC4D-A6CA-40D5-94C1-F0ED1F9EBB44}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
@@ -21,6 +21,28 @@
|
|||||||
| Frontend | React 19 + Vite + TypeScript, TanStack Query/Router, shadcn/ui + Tailwind |
|
| Frontend | React 19 + Vite + TypeScript, TanStack Query/Router, shadcn/ui + Tailwind |
|
||||||
| Упаковка | Единый Docker-образ (API + бот + статика SPA) + PostgreSQL в docker-compose |
|
| Упаковка | Единый Docker-образ (API + бот + статика SPA) + PostgreSQL в docker-compose |
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
Единый Docker-образ (API + бот + статика SPA) + PostgreSQL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # заполнить AdminSeed__Password, Jwt__SigningKey и т.д.
|
||||||
|
docker compose up -d --build
|
||||||
|
# → http://localhost:8080 (админ — логин/пароль из .env, AdminSeed__Username/Password)
|
||||||
|
```
|
||||||
|
|
||||||
|
Локальная разработка (без Docker для приложения — только `db`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# backend, из backend/
|
||||||
|
dotnet build && dotnet test
|
||||||
|
dotnet run --project src/PnvPanel.Api
|
||||||
|
|
||||||
|
# frontend, из frontend/
|
||||||
|
pnpm install
|
||||||
|
pnpm dev # проксирует /api, /hubs на localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
## Документация
|
## Документация
|
||||||
|
|
||||||
Проектная документация лежит в [`docs/`](docs/README.md):
|
Проектная документация лежит в [`docs/`](docs/README.md):
|
||||||
@@ -28,20 +50,15 @@
|
|||||||
- [Product Vision & Scope](docs/vision.md) — что мы строим и для кого
|
- [Product Vision & Scope](docs/vision.md) — что мы строим и для кого
|
||||||
- [Architecture](docs/architecture.md) — Clean Architecture, CQRS, интеграция, realtime, безопасность
|
- [Architecture](docs/architecture.md) — Clean Architecture, CQRS, интеграция, realtime, безопасность
|
||||||
- [Domain Model](docs/domain-model.md) — сущности, связи, инварианты
|
- [Domain Model](docs/domain-model.md) — сущности, связи, инварианты
|
||||||
- [Tech Stack (ADR)](docs/tech-stack.md) — решения по стеку и их обоснование
|
- [Tech Stack](docs/tech-stack.md) — используемые технологии
|
||||||
- [Backend Conventions](docs/backend-conventions.md) — структура проекта, паттерны, стиль кода
|
- [Backend Conventions](docs/backend-conventions.md) — структура проекта, паттерны, стиль кода
|
||||||
- [Frontend](docs/frontend.md) — стек фронтенда и структура
|
- [Frontend](docs/frontend.md) — стек фронтенда и структура
|
||||||
- [Telegram Bot](docs/telegram-bot.md) — бот, привязка Telegram и passwordless-вход
|
- [Telegram Bot](docs/telegram-bot.md) — бот, привязка Telegram и passwordless-вход
|
||||||
- [API Design](docs/api-design.md) — REST-эндпоинты и SignalR-контракты
|
- [API Design](docs/api-design.md) — REST-эндпоинты и SignalR-контракты
|
||||||
- [Roadmap](docs/roadmap.md) — этапы разработки
|
|
||||||
|
|
||||||
Пример переменных окружения (сид админа, БД, JWT, Telegram) — [`.env.example`](.env.example).
|
Пример переменных окружения (сид админа, БД, JWT, Telegram) — [`.env.example`](.env.example).
|
||||||
Инструкции для AI-ассистента (Claude Code) — в [`CLAUDE.md`](CLAUDE.md).
|
Инструкции для AI-ассистента (Claude Code) — в [`CLAUDE.md`](CLAUDE.md).
|
||||||
|
|
||||||
## Статус
|
|
||||||
|
|
||||||
🚧 Проектирование. Кодовая база ещё не создана — на этом этапе зафиксированы архитектура и план.
|
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
[MIT](LICENSE)
|
[MIT](LICENSE)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<Project>
|
<Project>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
@@ -18,5 +17,4 @@
|
|||||||
-->
|
-->
|
||||||
<NoWarn>$(NoWarn);CA1711;CA1716;CA1848;CA1873</NoWarn>
|
<NoWarn>$(NoWarn);CA1711;CA1716;CA1848;CA1873</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -4,26 +4,41 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="FluentValidation" Version="12.1.1" />
|
<PackageVersion Include="FluentValidation" Version="12.1.1" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.Extensions" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.Extensions" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.11" />
|
<PackageVersion Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.11" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.OpenApi" Version="2.9.0" />
|
<!-- Держим 2.x: Microsoft.AspNetCore.OpenApi 10.0.10 сам зависит от Microsoft.OpenApi >=2.0.0
|
||||||
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.7" />
|
(nuspec) и его Roslyn source generator (XmlCommentGenerator) скомпилирован под 2.x API —
|
||||||
|
3.x меняет IOpenApiMediaType.Example на read-only и ломает генерацию (CS0200). -->
|
||||||
|
<PackageVersion Include="Microsoft.OpenApi" Version="2.11.0" />
|
||||||
|
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
|
||||||
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageVersion>
|
</PackageVersion>
|
||||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.10" />
|
||||||
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.19.1" />
|
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.21.0" />
|
||||||
<PackageVersion Include="ThreeXui.Net" Version="1.0.0" />
|
<PackageVersion Include="Telegram.Bot" Version="22.10.2" />
|
||||||
|
<PackageVersion Include="ThreeXui.Net" Version="1.0.4" />
|
||||||
|
<PackageVersion Include="LiteCqrs.Net" Version="1.0.1" />
|
||||||
|
<!-- Тестирование (M8) -->
|
||||||
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||||
|
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||||
|
<PackageVersion Include="NSubstitute" Version="6.0.0" />
|
||||||
|
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" />
|
||||||
|
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.13.0" />
|
||||||
|
<!-- InMemory, не Sqlite/Npgsql — модель использует Postgres-специфичные типы (uuid[], jsonb),
|
||||||
|
не имеющие реляционных аналогов; InMemory игнорирует HasColumnType и не требует их маппинга. -->
|
||||||
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.10" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -5,4 +5,9 @@
|
|||||||
<Project Path="src/PnvPanel.Domain/PnvPanel.Domain.csproj" />
|
<Project Path="src/PnvPanel.Domain/PnvPanel.Domain.csproj" />
|
||||||
<Project Path="src/PnvPanel.Infrastructure/PnvPanel.Infrastructure.csproj" />
|
<Project Path="src/PnvPanel.Infrastructure/PnvPanel.Infrastructure.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/PnvPanel.Domain.Tests/PnvPanel.Domain.Tests.csproj" />
|
||||||
|
<Project Path="tests/PnvPanel.Application.Tests/PnvPanel.Application.Tests.csproj" />
|
||||||
|
<Project Path="tests/PnvPanel.IntegrationTests/PnvPanel.IntegrationTests.csproj" />
|
||||||
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ namespace PnvPanel.Api.Common;
|
|||||||
|
|
||||||
public static class ResultExtensions
|
public static class ResultExtensions
|
||||||
{
|
{
|
||||||
public static IResult ToHttpResult(this Result result)
|
public static IResult ToHttpResult(this Result result) =>
|
||||||
=> result.IsSuccess ? Results.NoContent() : ToProblem(result.Error);
|
result.IsSuccess ? Results.NoContent() : ToProblem(result.Error);
|
||||||
|
|
||||||
public static IResult ToHttpResult<T>(this Result<T> result)
|
public static IResult ToHttpResult<T>(this Result<T> result) =>
|
||||||
=> result.IsSuccess ? Results.Ok(result.Value) : ToProblem(result.Error);
|
result.IsSuccess ? Results.Ok(result.Value) : ToProblem(result.Error);
|
||||||
|
|
||||||
private static IResult ToProblem(Error error)
|
private static IResult ToProblem(Error error)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
using PnvPanel.Api.Common;
|
using PnvPanel.Api.Common;
|
||||||
using PnvPanel.Application.Activation;
|
using PnvPanel.Application.Activation;
|
||||||
using PnvPanel.Application.Admin.Activation;
|
using PnvPanel.Application.Admin.Activation;
|
||||||
using PnvPanel.Application.Common.Messaging;
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
using PnvPanel.Domain.Activation;
|
using PnvPanel.Domain.Activation;
|
||||||
using PnvPanel.Infrastructure.Identity;
|
using PnvPanel.Infrastructure.Identity;
|
||||||
|
|
||||||
@@ -12,54 +13,83 @@ public static class ActivationEndpoints
|
|||||||
public static IEndpointRouteBuilder MapActivationEndpoints(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapActivationEndpoints(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
var user = app.MapGroup("/api/activation").WithTags("Activation").RequireAuthorization();
|
var user = app.MapGroup("/api/activation").WithTags("Activation").RequireAuthorization();
|
||||||
user.MapGet("/status", GetStatus);
|
user.MapGet("/status", GetStatus).Produces<ActivationStatusDto>();
|
||||||
user.MapPost("/request", RequestActivation);
|
user.MapPost("/request", RequestActivation).Produces<ActivationRequestDto>();
|
||||||
|
|
||||||
var admin = app.MapGroup("/api/admin/activation-requests")
|
var admin = app.MapGroup("/api/admin/activation-requests")
|
||||||
.WithTags("Admin.Activation")
|
.WithTags("Admin.Activation")
|
||||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
|
|
||||||
admin.MapGet("", ListRequests);
|
admin.MapGet("", ListRequests).Produces<PagedList<ActivationRequestAdminDto>>();
|
||||||
admin.MapPost("/{id:guid}/approve", Approve);
|
admin.MapPost("/{id:guid}/approve", Approve).Produces(StatusCodes.Status204NoContent);
|
||||||
admin.MapPost("/{id:guid}/reject", Reject);
|
admin.MapPost("/{id:guid}/reject", Reject).Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> GetStatus(ISender sender, CancellationToken cancellationToken)
|
private static async Task<IResult> GetStatus(
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new GetActivationStatusQuery(), cancellationToken);
|
var result = await sender.Send(new GetActivationStatusQuery(), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> RequestActivation(RequestActivationCommand command, ISender sender, CancellationToken cancellationToken)
|
private static async Task<IResult> RequestActivation(
|
||||||
|
RequestActivationCommand command,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(command, cancellationToken);
|
var result = await sender.Send(command, cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ListRequests(
|
private static async Task<IResult> ListRequests(
|
||||||
[AsParameters] ListActivationRequestsRequest request, ISender sender, CancellationToken cancellationToken)
|
[AsParameters] ListActivationRequestsRequest request,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var query = new ListActivationRequestsQuery(request.StatusFilter, request.Page, request.PageSize);
|
var query = new ListActivationRequestsQuery(
|
||||||
|
request.StatusFilter,
|
||||||
|
request.Page,
|
||||||
|
request.PageSize
|
||||||
|
);
|
||||||
var result = await sender.Send(query, cancellationToken);
|
var result = await sender.Send(query, cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> Approve(Guid id, ISender sender, CancellationToken cancellationToken)
|
private static async Task<IResult> Approve(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new ApproveActivationCommand(id), cancellationToken);
|
var result = await sender.Send(new ApproveActivationCommand(id), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> Reject(
|
private static async Task<IResult> Reject(
|
||||||
Guid id, RejectActivationBody body, ISender sender, CancellationToken cancellationToken)
|
Guid id,
|
||||||
|
RejectActivationBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new RejectActivationCommand(id, body.Reason), cancellationToken);
|
var result = await sender.Send(
|
||||||
|
new RejectActivationCommand(id, body.Reason),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record ListActivationRequestsRequest(ActivationStatus? StatusFilter, int Page = 1, int PageSize = 20);
|
public sealed record ListActivationRequestsRequest(
|
||||||
|
ActivationStatus? StatusFilter,
|
||||||
|
int Page = 1,
|
||||||
|
int PageSize = 20
|
||||||
|
);
|
||||||
|
|
||||||
public sealed record RejectActivationBody(string? Reason);
|
public sealed record RejectActivationBody(string? Reason);
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using PnvPanel.Api.Common;
|
||||||
|
using PnvPanel.Application.Admin.Apps;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Domain.Apps;
|
||||||
|
using PnvPanel.Infrastructure.Identity;
|
||||||
|
|
||||||
|
namespace PnvPanel.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class AdminAppEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapAdminAppEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var admin = app.MapGroup("/api/admin/apps")
|
||||||
|
.WithTags("Admin.Apps")
|
||||||
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
|
|
||||||
|
admin.MapGet("", ListApps).Produces<IReadOnlyList<AdminAppDto>>();
|
||||||
|
admin.MapPost("", CreateApp).Produces<AdminAppDto>();
|
||||||
|
admin.MapPut("/{id:guid}", UpdateApp).Produces<AdminAppDto>();
|
||||||
|
admin.MapDelete("/{id:guid}", DeleteApp).Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ListApps(ISender sender, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new ListAdminAppsQuery(), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CreateApp(
|
||||||
|
CreateAppCommand command,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(command, cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateApp(
|
||||||
|
Guid id,
|
||||||
|
UpdateAppBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var command = new UpdateAppCommand(
|
||||||
|
id,
|
||||||
|
body.Name,
|
||||||
|
body.DownloadUrl,
|
||||||
|
body.OperatingSystem,
|
||||||
|
body.Description,
|
||||||
|
body.IconUrl,
|
||||||
|
body.SortOrder,
|
||||||
|
body.IsEnabled,
|
||||||
|
body.IsRecommended
|
||||||
|
);
|
||||||
|
var result = await sender.Send(command, cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeleteApp(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new DeleteAppCommand(id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record UpdateAppBody(
|
||||||
|
string Name,
|
||||||
|
string DownloadUrl,
|
||||||
|
OsPlatform OperatingSystem,
|
||||||
|
string? Description,
|
||||||
|
string? IconUrl,
|
||||||
|
int SortOrder,
|
||||||
|
bool IsEnabled,
|
||||||
|
bool IsRecommended
|
||||||
|
);
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using PnvPanel.Api.Common;
|
||||||
|
using PnvPanel.Application.Admin.Billing;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
using PnvPanel.Infrastructure.Identity;
|
||||||
|
|
||||||
|
namespace PnvPanel.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class AdminBillingEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapAdminBillingEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var admin = app.MapGroup("/api/admin/billing")
|
||||||
|
.WithTags("Admin.Billing")
|
||||||
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
|
|
||||||
|
admin.MapGet("/settings", GetSettings).Produces<BillingSettingsDto>();
|
||||||
|
admin.MapPut("/settings", UpdateSettings).Produces<BillingSettingsDto>();
|
||||||
|
admin
|
||||||
|
.MapGet("/requests", ListRequests)
|
||||||
|
.Produces<PagedList<AdminPaymentRequestDto>>();
|
||||||
|
admin
|
||||||
|
.MapPost("/requests/{id:guid}/confirm", ConfirmRequest)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
admin
|
||||||
|
.MapPost("/requests/{id:guid}/reject", RejectRequest)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
admin.MapPost("/gift", GrantGift).Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetSettings(ISender sender, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new GetBillingSettingsQuery(), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateSettings(
|
||||||
|
UpdateBillingSettingsCommand command,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(command, cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ListRequests(
|
||||||
|
[AsParameters] ListPaymentRequestsRequest request,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var query = new ListPaymentRequestsQuery(
|
||||||
|
request.Status,
|
||||||
|
request.Kind,
|
||||||
|
request.Search,
|
||||||
|
request.Page,
|
||||||
|
request.PageSize
|
||||||
|
);
|
||||||
|
var result = await sender.Send(query, cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ConfirmRequest(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new ConfirmPaymentRequestCommand(id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> RejectRequest(
|
||||||
|
Guid id,
|
||||||
|
RejectPaymentRequestBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new RejectPaymentRequestCommand(id, body.Reason),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GrantGift(
|
||||||
|
GrantGiftBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new GrantBillingGiftCommand(body.UserId, body.Days),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ListPaymentRequestsRequest(
|
||||||
|
PaymentRequestStatus? Status,
|
||||||
|
PaymentRequestKind? Kind,
|
||||||
|
string? Search,
|
||||||
|
int Page = 1,
|
||||||
|
int PageSize = 20
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record RejectPaymentRequestBody(string? Reason);
|
||||||
|
|
||||||
|
public sealed record GrantGiftBody(Guid UserId, int Days);
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using PnvPanel.Api.Common;
|
||||||
|
using PnvPanel.Application.Admin.Instructions;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Instructions;
|
||||||
|
using PnvPanel.Infrastructure.Identity;
|
||||||
|
|
||||||
|
namespace PnvPanel.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class AdminInstructionEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapAdminInstructionEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var admin = app.MapGroup("/api/admin/instructions")
|
||||||
|
.WithTags("Admin.Instructions")
|
||||||
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
|
|
||||||
|
admin.MapPut("/intro", UpdateIntro).Produces<InstructionIntroDto>();
|
||||||
|
admin.MapPost("/tabs", CreateTab).Produces<InstructionTabDto>();
|
||||||
|
admin.MapPut("/tabs/{id:guid}", UpdateTab).Produces<InstructionTabDto>();
|
||||||
|
admin.MapDelete("/tabs/{id:guid}", DeleteTab).Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateIntro(
|
||||||
|
UpdateInstructionIntroCommand command,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(command, cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CreateTab(
|
||||||
|
CreateInstructionTabCommand command,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(command, cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateTab(
|
||||||
|
Guid id,
|
||||||
|
UpdateInstructionTabBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var command = new UpdateInstructionTabCommand(id, body.Title, body.Body, body.SortOrder);
|
||||||
|
var result = await sender.Send(command, cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeleteTab(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new DeleteInstructionTabCommand(id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record UpdateInstructionTabBody(string Title, string Body, int SortOrder);
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using PnvPanel.Api.Common;
|
||||||
|
using PnvPanel.Application.Admin.Maintenance;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Infrastructure.Identity;
|
||||||
|
|
||||||
|
namespace PnvPanel.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class AdminMaintenanceEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapAdminMaintenanceEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var admin = app.MapGroup("/api/admin/maintenance")
|
||||||
|
.WithTags("Admin.Maintenance")
|
||||||
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
|
|
||||||
|
admin
|
||||||
|
.MapDelete("/tickets/closed", DeleteClosedTickets)
|
||||||
|
.Produces<MaintenanceCleanupResponseDto>();
|
||||||
|
admin
|
||||||
|
.MapDelete("/audit-logs", DeleteOldAuditLogs)
|
||||||
|
.Produces<MaintenanceCleanupResponseDto>();
|
||||||
|
admin
|
||||||
|
.MapDelete("/apps/disabled", DeleteDisabledApps)
|
||||||
|
.Produces<MaintenanceCleanupResponseDto>();
|
||||||
|
admin.MapDelete("/factory-reset", FactoryReset).Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeleteClosedTickets(
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new DeleteClosedTicketsCommand(), cancellationToken);
|
||||||
|
return ToResponse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeleteOldAuditLogs(
|
||||||
|
int olderThanDays,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new DeleteOldAuditLogsCommand(olderThanDays),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return ToResponse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeleteDisabledApps(
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new DeleteDisabledAppsCommand(), cancellationToken);
|
||||||
|
return ToResponse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> FactoryReset(
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new FactoryResetCommand(), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult ToResponse(Result<int> result) =>
|
||||||
|
result.IsSuccess
|
||||||
|
? Results.Ok(new MaintenanceCleanupResponseDto(result.Value))
|
||||||
|
: result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record MaintenanceCleanupResponseDto(int DeletedCount);
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using PnvPanel.Api.Common;
|
||||||
|
using PnvPanel.Application.Admin.News;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Application.News;
|
||||||
|
using PnvPanel.Infrastructure.Identity;
|
||||||
|
|
||||||
|
namespace PnvPanel.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class AdminNewsEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapAdminNewsEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var admin = app.MapGroup("/api/admin/news")
|
||||||
|
.WithTags("Admin.News")
|
||||||
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
|
|
||||||
|
admin.MapGet("", ListAdminNews).Produces<PagedList<NewsPostDto>>();
|
||||||
|
admin.MapPost("", CreatePost).Produces<NewsPostDto>();
|
||||||
|
admin.MapPut("/{id:guid}", UpdatePost).Produces<NewsPostDto>();
|
||||||
|
admin.MapDelete("/{id:guid}", DeletePost).Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ListAdminNews(
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new ListAdminNewsQuery(page, pageSize), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CreatePost(
|
||||||
|
CreatePostCommand command,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(command, cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdatePost(
|
||||||
|
Guid id,
|
||||||
|
UpdatePostBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var command = new UpdatePostCommand(id, body.Title, body.Body);
|
||||||
|
var result = await sender.Send(command, cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeletePost(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new DeletePostCommand(id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record UpdatePostBody(string Title, string Body);
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using PnvPanel.Api.Common;
|
||||||
|
using PnvPanel.Application.Admin.Plans;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Infrastructure.Identity;
|
||||||
|
|
||||||
|
namespace PnvPanel.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class AdminPlanEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapAdminPlanEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var admin = app.MapGroup("/api/admin/plans")
|
||||||
|
.WithTags("Admin.Plans")
|
||||||
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
|
|
||||||
|
admin.MapGet("", ListPlans).Produces<IReadOnlyList<AdminPlanDto>>();
|
||||||
|
admin.MapPost("", CreatePlan).Produces<AdminPlanDto>();
|
||||||
|
admin.MapPut("/{id:guid}", UpdatePlan).Produces<AdminPlanDto>();
|
||||||
|
admin.MapDelete("/{id:guid}", DeletePlan).Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ListPlans(ISender sender, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new ListAdminPlansQuery(), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CreatePlan(
|
||||||
|
CreatePlanCommand command,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(command, cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdatePlan(
|
||||||
|
Guid id,
|
||||||
|
UpdatePlanBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var command = new UpdatePlanCommand(
|
||||||
|
id,
|
||||||
|
body.Name,
|
||||||
|
body.ConfigCount,
|
||||||
|
body.SortOrder,
|
||||||
|
body.IsEnabled
|
||||||
|
);
|
||||||
|
var result = await sender.Send(command, cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeletePlan(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new DeletePlanCommand(id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record UpdatePlanBody(string Name, int ConfigCount, int SortOrder, bool IsEnabled);
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using PnvPanel.Api.Common;
|
||||||
|
using PnvPanel.Application.Admin.Pricing;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Infrastructure.Identity;
|
||||||
|
|
||||||
|
namespace PnvPanel.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class AdminPricingEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapAdminPricingEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var admin = app.MapGroup("/api/admin/pricing")
|
||||||
|
.WithTags("Admin.Pricing")
|
||||||
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
|
|
||||||
|
admin.MapGet("", GetPricing).Produces<PricingSettingsDto>();
|
||||||
|
admin.MapPut("", UpdatePricing).Produces<PricingSettingsDto>();
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetPricing(ISender sender, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new GetPricingSettingsQuery(), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdatePricing(
|
||||||
|
UpdatePricingSettingsCommand command,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(command, cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using PnvPanel.Api.Common;
|
||||||
|
using PnvPanel.Application.Admin.Audit;
|
||||||
|
using PnvPanel.Application.Admin.Stats;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Audit;
|
||||||
|
using PnvPanel.Infrastructure.Identity;
|
||||||
|
|
||||||
|
namespace PnvPanel.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class AdminStatsEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapAdminStatsEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var admin = app.MapGroup("/api/admin")
|
||||||
|
.WithTags("Admin.Stats")
|
||||||
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
|
|
||||||
|
admin.MapGet("/stats", GetStats).Produces<StatsDto>();
|
||||||
|
admin.MapGet("/audit", GetAudit).Produces<PagedList<AuditLogDto>>();
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetStats(ISender sender, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new GetStatsQuery(), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetAudit(
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
AuditSource? source,
|
||||||
|
string? targetType,
|
||||||
|
string? action,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var query = new ListAuditLogsQuery(
|
||||||
|
page <= 0 ? 1 : page,
|
||||||
|
pageSize <= 0 ? 50 : pageSize,
|
||||||
|
source,
|
||||||
|
targetType,
|
||||||
|
action
|
||||||
|
);
|
||||||
|
var result = await sender.Send(query, cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using PnvPanel.Api.Common;
|
||||||
|
using PnvPanel.Application.Admin.Support;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Application.Support;
|
||||||
|
using PnvPanel.Application.Support.AddComment;
|
||||||
|
using PnvPanel.Infrastructure.Identity;
|
||||||
|
|
||||||
|
namespace PnvPanel.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class AdminSupportEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapAdminSupportEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var admin = app.MapGroup("/api/admin/support")
|
||||||
|
.WithTags("Admin.Support")
|
||||||
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
|
|
||||||
|
admin.MapGet("/tickets", ListTickets).Produces<PagedList<TicketSummaryDto>>();
|
||||||
|
admin.MapGet("/tickets/{id:guid}", GetTicket).Produces<TicketDetailDto>();
|
||||||
|
admin
|
||||||
|
.MapPost("/tickets/{id:guid}/comments", AddComment)
|
||||||
|
.DisableAntiforgery()
|
||||||
|
.Produces<TicketCommentDto>();
|
||||||
|
admin
|
||||||
|
.MapPost("/tickets/{id:guid}/resolve", Resolve)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
admin.MapPost("/tickets/{id:guid}/close", Close).Produces(StatusCodes.Status204NoContent);
|
||||||
|
admin
|
||||||
|
.MapPost("/tickets/{id:guid}/approve-extension", ApproveExtensionRequest)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
admin
|
||||||
|
.MapPost("/tickets/{id:guid}/reject-extension", RejectExtensionRequest)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ListTickets(
|
||||||
|
[AsParameters] ListTicketsRequest request,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var query = new ListAllTicketsQuery(
|
||||||
|
request.Type,
|
||||||
|
request.Status,
|
||||||
|
request.Page,
|
||||||
|
request.PageSize
|
||||||
|
);
|
||||||
|
var result = await sender.Send(query, cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetTicket(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new GetTicketAdminQuery(id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> AddComment(
|
||||||
|
Guid id,
|
||||||
|
[FromForm] string body,
|
||||||
|
IFormFileCollection? files,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var command = new AddTicketCommentCommand(id, body, SupportEndpoints.ToUploads(files));
|
||||||
|
var result = await sender.Send(command, cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> Resolve(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new ResolveTicketCommand(id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> Close(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new CloseTicketCommand(id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ApproveExtensionRequest(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new ApproveExtensionRequestCommand(id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> RejectExtensionRequest(
|
||||||
|
Guid id,
|
||||||
|
RejectExtensionRequestBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new RejectExtensionRequestCommand(id, body.Reason),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record RejectExtensionRequestBody(string? Reason);
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
using PnvPanel.Api.Common;
|
||||||
|
using PnvPanel.Application.Admin.Configs;
|
||||||
|
using PnvPanel.Application.Admin.Users;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Application.Configs;
|
||||||
|
using PnvPanel.Domain.Configs;
|
||||||
|
using PnvPanel.Domain.Inbounds;
|
||||||
|
using PnvPanel.Infrastructure.Identity;
|
||||||
|
|
||||||
|
namespace PnvPanel.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class AdminUserEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapAdminUserEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var admin = app.MapGroup("/api/admin")
|
||||||
|
.WithTags("Admin.Users")
|
||||||
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
|
|
||||||
|
admin.MapGet("/users", ListUsers).Produces<PagedList<UserSummaryDto>>();
|
||||||
|
admin
|
||||||
|
.MapPatch("/users/{id:guid}/block", BlockUser)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
admin
|
||||||
|
.MapPatch("/users/{id:guid}/unblock", UnblockUser)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
admin
|
||||||
|
.MapPost("/users/{id:guid}/reset-password", ResetPassword)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
admin.MapDelete("/users/{id:guid}", DeleteUser).Produces(StatusCodes.Status204NoContent);
|
||||||
|
admin
|
||||||
|
.MapGet("/users/{id:guid}/configs", GetUserConfigs)
|
||||||
|
.Produces<IReadOnlyList<VpnConfigDto>>();
|
||||||
|
admin.MapGet("/configs", ListAllConfigs).Produces<PagedList<AdminVpnConfigDto>>();
|
||||||
|
admin
|
||||||
|
.MapDelete("/configs/{id:guid}", ForceRevokeConfig)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ListUsers(
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
string? search,
|
||||||
|
Guid? roleId,
|
||||||
|
bool? isActivated,
|
||||||
|
bool? isBlocked,
|
||||||
|
bool? billingExpired,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var query = new ListUsersQuery(
|
||||||
|
page <= 0 ? 1 : page,
|
||||||
|
pageSize <= 0 ? 20 : pageSize,
|
||||||
|
search,
|
||||||
|
roleId,
|
||||||
|
isActivated,
|
||||||
|
isBlocked,
|
||||||
|
billingExpired
|
||||||
|
);
|
||||||
|
var result = await sender.Send(query, cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> BlockUser(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new BlockUserCommand(id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UnblockUser(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new UnblockUserCommand(id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ResetPassword(
|
||||||
|
Guid id,
|
||||||
|
ResetPasswordBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new ResetUserPasswordCommand(id, body.NewPassword),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeleteUser(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new DeleteUserCommand(id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetUserConfigs(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new GetUserConfigsQuery(id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ListAllConfigs(
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
string? search,
|
||||||
|
ConfigStatus? status,
|
||||||
|
VpnProtocol? protocol,
|
||||||
|
Guid? nodeId,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var query = new ListAllConfigsQuery(
|
||||||
|
page <= 0 ? 1 : page,
|
||||||
|
pageSize <= 0 ? 20 : pageSize,
|
||||||
|
search,
|
||||||
|
status,
|
||||||
|
protocol,
|
||||||
|
nodeId
|
||||||
|
);
|
||||||
|
var result = await sender.Send(query, cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ForceRevokeConfig(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new ForceRevokeConfigCommand(id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ResetPasswordBody(string NewPassword);
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using PnvPanel.Api.Common;
|
using PnvPanel.Api.Common;
|
||||||
using PnvPanel.Application.Apps;
|
using PnvPanel.Application.Apps;
|
||||||
using PnvPanel.Application.Common.Messaging;
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Domain.Apps;
|
||||||
|
|
||||||
namespace PnvPanel.Api.Endpoints;
|
namespace PnvPanel.Api.Endpoints;
|
||||||
|
|
||||||
@@ -8,7 +9,10 @@ public static class AppEndpoints
|
|||||||
{
|
{
|
||||||
public static IEndpointRouteBuilder MapAppEndpoints(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapAppEndpoints(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
app.MapGet("/api/apps", ListApps).WithTags("Apps").RequireAuthorization();
|
app.MapGet("/api/apps", ListApps)
|
||||||
|
.WithTags("Apps")
|
||||||
|
.RequireAuthorization()
|
||||||
|
.Produces<IReadOnlyDictionary<OsPlatform, IReadOnlyList<ClientAppDto>>>();
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
using PnvPanel.Api.Common;
|
using PnvPanel.Api.Common;
|
||||||
using PnvPanel.Application.Auth;
|
using PnvPanel.Application.Auth;
|
||||||
using PnvPanel.Application.Auth.ChangePassword;
|
using PnvPanel.Application.Auth.ChangePassword;
|
||||||
|
using PnvPanel.Application.Auth.ChangeUserName;
|
||||||
using PnvPanel.Application.Auth.DeleteMyAccount;
|
using PnvPanel.Application.Auth.DeleteMyAccount;
|
||||||
using PnvPanel.Application.Auth.Login;
|
using PnvPanel.Application.Auth.Login;
|
||||||
using PnvPanel.Application.Auth.Logout;
|
using PnvPanel.Application.Auth.Logout;
|
||||||
using PnvPanel.Application.Auth.Me;
|
using PnvPanel.Application.Auth.Me;
|
||||||
using PnvPanel.Application.Auth.Refresh;
|
using PnvPanel.Application.Auth.Refresh;
|
||||||
using PnvPanel.Application.Auth.Register;
|
using PnvPanel.Application.Auth.Register;
|
||||||
using PnvPanel.Application.Common.Messaging;
|
using LiteCqrs;
|
||||||
|
|
||||||
namespace PnvPanel.Api.Endpoints;
|
namespace PnvPanel.Api.Endpoints;
|
||||||
|
|
||||||
@@ -21,59 +22,125 @@ public static class AuthEndpoints
|
|||||||
.WithTags("Auth")
|
.WithTags("Auth")
|
||||||
.RequireRateLimiting(RateLimiting.AuthPolicy);
|
.RequireRateLimiting(RateLimiting.AuthPolicy);
|
||||||
|
|
||||||
group.MapPost("/register", Register);
|
group.MapPost("/register", Register).Produces<RegisterResult>();
|
||||||
group.MapPost("/login", Login);
|
group.MapPost("/login", Login).Produces<AuthResponseDto>();
|
||||||
group.MapPost("/refresh", Refresh);
|
group.MapPost("/refresh", Refresh).Produces<AuthResponseDto>();
|
||||||
group.MapPost("/logout", Logout).RequireAuthorization();
|
group
|
||||||
group.MapPost("/change-password", ChangePassword).RequireAuthorization();
|
.MapPost("/logout", Logout)
|
||||||
group.MapGet("/me", Me).RequireAuthorization();
|
.RequireAuthorization()
|
||||||
group.MapDelete("/me", DeleteMe).RequireAuthorization();
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
group
|
||||||
|
.MapPost("/change-password", ChangePassword)
|
||||||
|
.RequireAuthorization()
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
group
|
||||||
|
.MapPost("/change-username", ChangeUserName)
|
||||||
|
.RequireAuthorization()
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
group.MapGet("/me", Me).RequireAuthorization().Produces<CurrentUserDto>();
|
||||||
|
group
|
||||||
|
.MapDelete("/me", DeleteMe)
|
||||||
|
.RequireAuthorization()
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> Register(RegisterCommand command, ISender sender, CancellationToken cancellationToken)
|
private static async Task<IResult> Register(
|
||||||
|
RegisterCommand command,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(command, cancellationToken);
|
var result = await sender.Send(command, cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> Login(LoginCommand command, ISender sender, HttpResponse response, CancellationToken cancellationToken)
|
private static async Task<IResult> Login(
|
||||||
|
LoginCommand command,
|
||||||
|
ISender sender,
|
||||||
|
HttpRequest request,
|
||||||
|
HttpResponse response,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(command, cancellationToken);
|
var result = await sender.Send(command, cancellationToken);
|
||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
|
|
||||||
SetRefreshCookie(response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt);
|
SetRefreshCookie(
|
||||||
|
request,
|
||||||
|
response,
|
||||||
|
result.Value.RefreshToken,
|
||||||
|
result.Value.RefreshTokenExpiresAt
|
||||||
|
);
|
||||||
return Results.Ok(ToLoginResponse(result.Value));
|
return Results.Ok(ToLoginResponse(result.Value));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> Refresh(HttpRequest request, HttpResponse response, ISender sender, CancellationToken cancellationToken)
|
private static async Task<IResult> Refresh(
|
||||||
|
HttpRequest request,
|
||||||
|
HttpResponse response,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
if (!request.Cookies.TryGetValue(RefreshCookieName, out var rawToken) || string.IsNullOrEmpty(rawToken))
|
if (
|
||||||
|
!request.Cookies.TryGetValue(RefreshCookieName, out var rawToken)
|
||||||
|
|| string.IsNullOrEmpty(rawToken)
|
||||||
|
)
|
||||||
return Results.Unauthorized();
|
return Results.Unauthorized();
|
||||||
|
|
||||||
var result = await sender.Send(new RefreshCommand(rawToken), cancellationToken);
|
var result = await sender.Send(new RefreshCommand(rawToken), cancellationToken);
|
||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
{
|
{
|
||||||
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions());
|
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
SetRefreshCookie(response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt);
|
SetRefreshCookie(
|
||||||
|
request,
|
||||||
|
response,
|
||||||
|
result.Value.RefreshToken,
|
||||||
|
result.Value.RefreshTokenExpiresAt
|
||||||
|
);
|
||||||
return Results.Ok(ToLoginResponse(result.Value));
|
return Results.Ok(ToLoginResponse(result.Value));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> Logout(HttpRequest request, HttpResponse response, ISender sender, CancellationToken cancellationToken)
|
internal static AuthResponseDto ToLoginResponse(AuthResult auth) =>
|
||||||
|
new(auth.AccessToken, auth.AccessTokenExpiresAt, auth.User);
|
||||||
|
|
||||||
|
private static async Task<IResult> Logout(
|
||||||
|
HttpRequest request,
|
||||||
|
HttpResponse response,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
if (request.Cookies.TryGetValue(RefreshCookieName, out var rawToken) && !string.IsNullOrEmpty(rawToken))
|
if (
|
||||||
|
request.Cookies.TryGetValue(RefreshCookieName, out var rawToken)
|
||||||
|
&& !string.IsNullOrEmpty(rawToken)
|
||||||
|
)
|
||||||
await sender.Send(new LogoutCommand(rawToken), cancellationToken);
|
await sender.Send(new LogoutCommand(rawToken), cancellationToken);
|
||||||
|
|
||||||
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions());
|
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
|
||||||
return Results.NoContent();
|
return Results.NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ChangePassword(ChangePasswordCommand command, ISender sender, CancellationToken cancellationToken)
|
private static async Task<IResult> ChangePassword(
|
||||||
|
ChangePasswordCommand command,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(command, cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ChangeUserName(
|
||||||
|
ChangeUserNameCommand command,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(command, cancellationToken);
|
var result = await sender.Send(command, cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
@@ -85,32 +152,44 @@ public static class AuthEndpoints
|
|||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> DeleteMe(HttpResponse response, ISender sender, CancellationToken cancellationToken)
|
private static async Task<IResult> DeleteMe(
|
||||||
|
HttpRequest request,
|
||||||
|
HttpResponse response,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new DeleteMyAccountCommand(), cancellationToken);
|
var result = await sender.Send(new DeleteMyAccountCommand(), cancellationToken);
|
||||||
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions());
|
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static object ToLoginResponse(AuthResult auth) => new
|
private static void SetRefreshCookie(
|
||||||
|
HttpRequest request,
|
||||||
|
HttpResponse response,
|
||||||
|
string rawToken,
|
||||||
|
DateTimeOffset expiresAt
|
||||||
|
)
|
||||||
{
|
{
|
||||||
accessToken = auth.AccessToken,
|
var options = BuildCookieOptions(request);
|
||||||
expiresAt = auth.AccessTokenExpiresAt,
|
|
||||||
user = auth.User,
|
|
||||||
};
|
|
||||||
|
|
||||||
private static void SetRefreshCookie(HttpResponse response, string rawToken, DateTimeOffset expiresAt)
|
|
||||||
{
|
|
||||||
var options = BuildCookieOptions();
|
|
||||||
options.Expires = expiresAt;
|
options.Expires = expiresAt;
|
||||||
response.Cookies.Append(RefreshCookieName, rawToken, options);
|
response.Cookies.Append(RefreshCookieName, rawToken, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CookieOptions BuildCookieOptions() => new()
|
// Secure = IsHttps запроса (учитывает ForwardedHeaders за внешним TLS-прокси, см. CLAUDE.md) —
|
||||||
|
// иначе браузер/HttpClient не пришлёт cookie обратно на plain-http (локальный dev, TestServer).
|
||||||
|
private static CookieOptions BuildCookieOptions(HttpRequest request) =>
|
||||||
|
new()
|
||||||
{
|
{
|
||||||
HttpOnly = true,
|
HttpOnly = true,
|
||||||
Secure = true,
|
Secure = request.IsHttps,
|
||||||
SameSite = SameSiteMode.Strict,
|
SameSite = SameSiteMode.Strict,
|
||||||
Path = "/api/auth",
|
Path = "/api/auth",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record AuthResponseDto(
|
||||||
|
string AccessToken,
|
||||||
|
DateTimeOffset ExpiresAt,
|
||||||
|
CurrentUserDto User
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using PnvPanel.Api.Common;
|
||||||
|
using PnvPanel.Application.Billing;
|
||||||
|
using PnvPanel.Application.Billing.CancelPaymentRequest;
|
||||||
|
using PnvPanel.Application.Billing.CreatePaymentRequest;
|
||||||
|
using PnvPanel.Application.Billing.GetMyBillingStatus;
|
||||||
|
using PnvPanel.Application.Billing.MarkPaymentSent;
|
||||||
|
using PnvPanel.Application.Billing.SendRequisitesToTelegram;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
|
||||||
|
namespace PnvPanel.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class BillingEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapBillingEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/billing").WithTags("Billing").RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapGet("/status", GetStatus).Produces<BillingStatusDto>();
|
||||||
|
group.MapPost("/requests", CreateRequest).Produces<PaymentRequestDto>();
|
||||||
|
group
|
||||||
|
.MapPost("/requests/{id:guid}/cancel", CancelRequest)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
group
|
||||||
|
.MapPost("/requests/{id:guid}/mark-paid", MarkPaid)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
group
|
||||||
|
.MapPost("/requests/{id:guid}/send-requisites", SendRequisites)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetStatus(ISender sender, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new GetMyBillingStatusQuery(), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CreateRequest(
|
||||||
|
CreatePaymentRequestBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new CreatePaymentRequestCommand(body.Period),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CancelRequest(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new CancelPaymentRequestCommand(id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> MarkPaid(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new MarkPaymentSentCommand(id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> SendRequisites(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new SendRequisitesToTelegramCommand(id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record CreatePaymentRequestBody(PaymentPeriod Period);
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
using PnvPanel.Api.Common;
|
using PnvPanel.Api.Common;
|
||||||
using PnvPanel.Application.Common.Messaging;
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Configs;
|
||||||
using PnvPanel.Application.Configs.Create;
|
using PnvPanel.Application.Configs.Create;
|
||||||
using PnvPanel.Application.Configs.Edit;
|
using PnvPanel.Application.Configs.Edit;
|
||||||
using PnvPanel.Application.Configs.GetConfigLink;
|
using PnvPanel.Application.Configs.GetConfigLink;
|
||||||
using PnvPanel.Application.Configs.GetMyConfigs;
|
using PnvPanel.Application.Configs.GetMyConfigs;
|
||||||
|
using PnvPanel.Application.Configs.GetMySubscription;
|
||||||
using PnvPanel.Application.Configs.ListAvailableInbounds;
|
using PnvPanel.Application.Configs.ListAvailableInbounds;
|
||||||
using PnvPanel.Application.Configs.Revoke;
|
using PnvPanel.Application.Configs.Revoke;
|
||||||
using PnvPanel.Application.Configs.Rotate;
|
using PnvPanel.Application.Configs.Rotate;
|
||||||
@@ -16,66 +18,121 @@ public static class ConfigEndpoints
|
|||||||
{
|
{
|
||||||
var group = app.MapGroup("/api").WithTags("Configs").RequireAuthorization();
|
var group = app.MapGroup("/api").WithTags("Configs").RequireAuthorization();
|
||||||
|
|
||||||
group.MapGet("/inbounds/available", ListAvailableInbounds);
|
group
|
||||||
group.MapGet("/configs", GetMyConfigs);
|
.MapGet("/inbounds/available", ListAvailableInbounds)
|
||||||
group.MapPost("/configs", CreateConfig);
|
.Produces<IReadOnlyList<AvailableInboundDto>>();
|
||||||
group.MapPatch("/configs/{id:guid}", EditConfig);
|
group.MapGet("/configs", GetMyConfigs).Produces<GetMyConfigsResult>();
|
||||||
group.MapPost("/configs/{id:guid}/rotate", RotateConfig);
|
group.MapPost("/configs", CreateConfig).Produces<VpnConfigDto>();
|
||||||
group.MapDelete("/configs/{id:guid}", RevokeConfig);
|
group.MapPatch("/configs/{id:guid}", EditConfig).Produces<VpnConfigDto>();
|
||||||
group.MapGet("/configs/{id:guid}/link", GetConfigLink);
|
group.MapPost("/configs/{id:guid}/rotate", RotateConfig).Produces<VpnConfigDto>();
|
||||||
|
group
|
||||||
|
.MapDelete("/configs/{id:guid}", RevokeConfig)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
group.MapGet("/configs/{id:guid}/link", GetConfigLink).Produces<ConfigLinkResponseDto>();
|
||||||
|
group.MapGet("/subscription", GetMySubscription).Produces<MySubscriptionResponseDto>();
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ListAvailableInbounds(ISender sender, CancellationToken cancellationToken)
|
private static async Task<IResult> ListAvailableInbounds(
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new ListAvailableInboundsQuery(), cancellationToken);
|
var result = await sender.Send(new ListAvailableInboundsQuery(), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> GetMyConfigs(ISender sender, CancellationToken cancellationToken)
|
private static async Task<IResult> GetMyConfigs(
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new GetMyConfigsQuery(), cancellationToken);
|
var result = await sender.Send(new GetMyConfigsQuery(), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> CreateConfig(CreateConfigBody body, ISender sender, CancellationToken cancellationToken)
|
private static async Task<IResult> CreateConfig(
|
||||||
|
CreateConfigBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var command = new CreateVpnConfigCommand(body.InboundId, body.Label, body.DeviceLimit);
|
var command = new CreateVpnConfigCommand(body.InboundId, body.Label);
|
||||||
var result = await sender.Send(command, cancellationToken);
|
var result = await sender.Send(command, cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> EditConfig(Guid id, EditConfigBody body, ISender sender, CancellationToken cancellationToken)
|
private static async Task<IResult> EditConfig(
|
||||||
|
Guid id,
|
||||||
|
EditConfigBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var command = new EditVpnConfigCommand(id, body.Label, body.DeviceLimit);
|
var command = new EditVpnConfigCommand(id, body.Label);
|
||||||
var result = await sender.Send(command, cancellationToken);
|
var result = await sender.Send(command, cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> RotateConfig(Guid id, ISender sender, CancellationToken cancellationToken)
|
private static async Task<IResult> RotateConfig(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new RotateVpnConfigCommand(id), cancellationToken);
|
var result = await sender.Send(new RotateVpnConfigCommand(id), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> RevokeConfig(Guid id, ISender sender, CancellationToken cancellationToken)
|
private static async Task<IResult> RevokeConfig(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new RevokeVpnConfigCommand(id), cancellationToken);
|
var result = await sender.Send(new RevokeVpnConfigCommand(id), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> GetConfigLink(Guid id, HttpRequest request, ISender sender, CancellationToken cancellationToken)
|
private static async Task<IResult> GetConfigLink(
|
||||||
|
Guid id,
|
||||||
|
HttpRequest request,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new GetConfigLinkQuery(id), cancellationToken);
|
var result = await sender.Send(new GetConfigLinkQuery(id), cancellationToken);
|
||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
|
|
||||||
var subscriptionUrl = $"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}";
|
var subscriptionUrl =
|
||||||
return Results.Ok(new { connectionString = result.Value.ConnectionString, subscriptionUrl });
|
$"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}";
|
||||||
|
return Results.Ok(
|
||||||
|
new ConfigLinkResponseDto(result.Value.ConnectionString, subscriptionUrl)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetMySubscription(
|
||||||
|
HttpRequest request,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new GetMySubscriptionQuery(), cancellationToken);
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
return result.ToHttpResult();
|
||||||
|
|
||||||
|
var subscriptionUrl =
|
||||||
|
$"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}";
|
||||||
|
return Results.Ok(new MySubscriptionResponseDto(subscriptionUrl));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record CreateConfigBody(Guid InboundId, string? Label, int? DeviceLimit);
|
public sealed record CreateConfigBody(Guid InboundId, string? Label);
|
||||||
|
|
||||||
public sealed record EditConfigBody(string? Label, int? DeviceLimit);
|
public sealed record EditConfigBody(string? Label);
|
||||||
|
|
||||||
|
public sealed record ConfigLinkResponseDto(string ConnectionString, string SubscriptionUrl);
|
||||||
|
|
||||||
|
public sealed record MySubscriptionResponseDto(string SubscriptionUrl);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
using PnvPanel.Api.Common;
|
using PnvPanel.Api.Common;
|
||||||
using PnvPanel.Application.Admin.Inbounds;
|
using PnvPanel.Application.Admin.Inbounds;
|
||||||
using PnvPanel.Application.Common.Messaging;
|
using LiteCqrs;
|
||||||
using PnvPanel.Infrastructure.Identity;
|
using PnvPanel.Infrastructure.Identity;
|
||||||
|
|
||||||
namespace PnvPanel.Api.Endpoints;
|
namespace PnvPanel.Api.Endpoints;
|
||||||
@@ -13,26 +13,53 @@ public static class InboundEndpoints
|
|||||||
.WithTags("Admin.Inbounds")
|
.WithTags("Admin.Inbounds")
|
||||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
|
|
||||||
admin.MapGet("", ListInbounds);
|
admin.MapGet("", ListInbounds).Produces<IReadOnlyList<InboundDto>>();
|
||||||
admin.MapPut("/{id:guid}/publish", PublishInbound);
|
admin.MapPut("/{id:guid}/publish", PublishInbound).Produces<InboundDto>();
|
||||||
|
admin.MapDelete("/{id:guid}", DeleteInbound);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ListInbounds(Guid? nodeId, ISender sender, CancellationToken cancellationToken)
|
private static async Task<IResult> ListInbounds(
|
||||||
|
Guid? nodeId,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new ListInboundsQuery(nodeId), cancellationToken);
|
var result = await sender.Send(new ListInboundsQuery(nodeId), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> PublishInbound(
|
private static async Task<IResult> PublishInbound(
|
||||||
Guid id, PublishInboundBody body, ISender sender, CancellationToken cancellationToken)
|
Guid id,
|
||||||
|
PublishInboundBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var command = new PublishInboundCommand(
|
var command = new PublishInboundCommand(
|
||||||
id, body.IsPublished, body.DisplayName, body.AllowedRoleIds ?? [], body.MaxClients);
|
id,
|
||||||
|
body.IsPublished,
|
||||||
|
body.DisplayName,
|
||||||
|
body.AllowedRoleIds ?? []
|
||||||
|
);
|
||||||
var result = await sender.Send(command, cancellationToken);
|
var result = await sender.Send(command, cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeleteInbound(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new DeleteInboundCommand(id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record PublishInboundBody(bool IsPublished, string? DisplayName, IReadOnlyList<Guid>? AllowedRoleIds, int? MaxClients);
|
public sealed record PublishInboundBody(
|
||||||
|
bool IsPublished,
|
||||||
|
string? DisplayName,
|
||||||
|
IReadOnlyList<Guid>? AllowedRoleIds
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using PnvPanel.Api.Common;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Instructions;
|
||||||
|
|
||||||
|
namespace PnvPanel.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class InstructionEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapInstructionEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/instructions")
|
||||||
|
.WithTags("Instructions")
|
||||||
|
.RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapGet("/intro", GetIntro).Produces<InstructionIntroDto>();
|
||||||
|
group.MapGet("/tabs", ListTabs).Produces<IReadOnlyList<InstructionTabDto>>();
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetIntro(ISender sender, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new GetInstructionIntroQuery(), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ListTabs(ISender sender, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new ListInstructionTabsQuery(), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Api.Common;
|
||||||
|
using PnvPanel.Application.Admin.Media;
|
||||||
|
using PnvPanel.Application.Media;
|
||||||
|
using PnvPanel.Application.Media.GetImage;
|
||||||
|
using PnvPanel.Infrastructure.Identity;
|
||||||
|
|
||||||
|
namespace PnvPanel.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class MediaEndpoints
|
||||||
|
{
|
||||||
|
/// <summary>Год: содержимое картинки по Id неизменно (перезалив даёт новый Id).</summary>
|
||||||
|
private const string ImageCacheControl = "public, max-age=31536000, immutable";
|
||||||
|
|
||||||
|
public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
// Отдача — анонимная: markdown рендерится обычным <img>, который не шлёт Authorization.
|
||||||
|
// Защита — непрозрачный Guid в ссылке; в картинках инструкций/новостей нет персональных данных.
|
||||||
|
var group = app.MapGroup("/api/media").WithTags("Media");
|
||||||
|
group.MapGet("/images/{id:guid}", GetImage);
|
||||||
|
|
||||||
|
var admin = app.MapGroup("/api/admin/media")
|
||||||
|
.WithTags("Admin.Media")
|
||||||
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
|
|
||||||
|
admin.MapPost("/images", UploadImage).DisableAntiforgery().Produces<MediaImageDto>();
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetImage(
|
||||||
|
Guid id,
|
||||||
|
HttpContext httpContext,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new GetMediaImageQuery(id), cancellationToken);
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
return result.ToHttpResult();
|
||||||
|
|
||||||
|
httpContext.Response.Headers.CacheControl = ImageCacheControl;
|
||||||
|
return Results.File(result.Value.Content, result.Value.ContentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UploadImage(
|
||||||
|
IFormFile? file,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (file is null)
|
||||||
|
return Results.Problem(
|
||||||
|
title: MediaErrors.EmptyImage.Message,
|
||||||
|
statusCode: StatusCodes.Status400BadRequest
|
||||||
|
);
|
||||||
|
|
||||||
|
var upload = new MediaImageUpload(
|
||||||
|
file.OpenReadStream(),
|
||||||
|
file.FileName,
|
||||||
|
file.ContentType,
|
||||||
|
file.Length
|
||||||
|
);
|
||||||
|
var result = await sender.Send(new UploadMediaImageCommand(upload), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using PnvPanel.Api.Common;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Application.News;
|
||||||
|
|
||||||
|
namespace PnvPanel.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class NewsEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapNewsEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
app.MapGet("/api/news", ListNews)
|
||||||
|
.WithTags("News")
|
||||||
|
.RequireAuthorization()
|
||||||
|
.Produces<PagedList<NewsPostDto>>();
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ListNews(
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new ListNewsQuery(page, pageSize), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using PnvPanel.Api.Common;
|
using PnvPanel.Api.Common;
|
||||||
using PnvPanel.Application.Admin.Nodes;
|
using PnvPanel.Application.Admin.Nodes;
|
||||||
using PnvPanel.Application.Common.Messaging;
|
using LiteCqrs;
|
||||||
using PnvPanel.Infrastructure.Identity;
|
using PnvPanel.Infrastructure.Identity;
|
||||||
|
|
||||||
namespace PnvPanel.Api.Endpoints;
|
namespace PnvPanel.Api.Endpoints;
|
||||||
@@ -13,52 +13,93 @@ public static class NodeEndpoints
|
|||||||
.WithTags("Admin.Nodes")
|
.WithTags("Admin.Nodes")
|
||||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
|
|
||||||
admin.MapGet("", ListNodes);
|
admin.MapGet("", ListNodes).Produces<IReadOnlyList<NodeDto>>();
|
||||||
admin.MapPost("", RegisterNode);
|
admin.MapPost("", RegisterNode).Produces<NodeDto>();
|
||||||
admin.MapPut("/{id:guid}", UpdateNode);
|
admin.MapPut("/{id:guid}", UpdateNode).Produces<NodeDto>();
|
||||||
admin.MapDelete("/{id:guid}", DeleteNode);
|
admin.MapDelete("/{id:guid}", DeleteNode).Produces(StatusCodes.Status204NoContent);
|
||||||
admin.MapPost("/{id:guid}/sync", SyncNode);
|
admin.MapPost("/{id:guid}/sync", SyncNode).Produces<SyncNodeResultDto>();
|
||||||
admin.MapPost("/{id:guid}/probe", ProbeNode);
|
admin.MapPost("/{id:guid}/probe", ProbeNode).Produces<NodeProbeResultDto>();
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ListNodes(ISender sender, CancellationToken cancellationToken)
|
private static async Task<IResult> ListNodes(
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new ListNodesQuery(), cancellationToken);
|
var result = await sender.Send(new ListNodesQuery(), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> RegisterNode(RegisterNodeCommand command, ISender sender, CancellationToken cancellationToken)
|
private static async Task<IResult> RegisterNode(
|
||||||
|
RegisterNodeCommand command,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(command, cancellationToken);
|
var result = await sender.Send(command, cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> UpdateNode(Guid id, UpdateNodeBody body, ISender sender, CancellationToken cancellationToken)
|
private static async Task<IResult> UpdateNode(
|
||||||
|
Guid id,
|
||||||
|
UpdateNodeBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var command = new UpdateNodeCommand(id, body.Name, body.Location, body.IsEnabled, body.Username, body.Password);
|
var command = new UpdateNodeCommand(
|
||||||
|
id,
|
||||||
|
body.Name,
|
||||||
|
body.BaseAddress,
|
||||||
|
body.Location,
|
||||||
|
body.IsEnabled,
|
||||||
|
body.NotifyOnStatusChange,
|
||||||
|
body.Username,
|
||||||
|
body.Password
|
||||||
|
);
|
||||||
var result = await sender.Send(command, cancellationToken);
|
var result = await sender.Send(command, cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> DeleteNode(Guid id, ISender sender, CancellationToken cancellationToken)
|
private static async Task<IResult> DeleteNode(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new DeleteNodeCommand(id), cancellationToken);
|
var result = await sender.Send(new DeleteNodeCommand(id), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> SyncNode(Guid id, ISender sender, CancellationToken cancellationToken)
|
private static async Task<IResult> SyncNode(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new SyncNodeCommand(id), cancellationToken);
|
var result = await sender.Send(new SyncNodeCommand(id), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ProbeNode(Guid id, ISender sender, CancellationToken cancellationToken)
|
private static async Task<IResult> ProbeNode(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new ProbeNodeCommand(id), cancellationToken);
|
var result = await sender.Send(new ProbeNodeCommand(id), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record UpdateNodeBody(string Name, string? Location, bool IsEnabled, string? Username, string? Password);
|
public sealed record UpdateNodeBody(
|
||||||
|
string Name,
|
||||||
|
string BaseAddress,
|
||||||
|
string? Location,
|
||||||
|
bool IsEnabled,
|
||||||
|
bool NotifyOnStatusChange,
|
||||||
|
string? Username,
|
||||||
|
string? Password
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using PnvPanel.Api.Common;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Plans;
|
||||||
|
|
||||||
|
namespace PnvPanel.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class PlanEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapPlanEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/plans").WithTags("Plans").RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapGet("", ListPlans).Produces<IReadOnlyList<PlanDto>>();
|
||||||
|
group.MapGet("/status", GetMyPlanStatus).Produces<MyPlanStatusDto>();
|
||||||
|
group.MapPost("/change", ChangePlan).Produces<ChangePlanResultDto>();
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ListPlans(ISender sender, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new ListPlansQuery(), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetMyPlanStatus(
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new GetMyPlanStatusQuery(), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ChangePlan(
|
||||||
|
ChangePlanBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var command = new ChangePlanCommand(
|
||||||
|
body.PlanId,
|
||||||
|
body.CustomConfigCount,
|
||||||
|
body.ConfigIdsToRevoke ?? []
|
||||||
|
);
|
||||||
|
var result = await sender.Send(command, cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ChangePlanBody(
|
||||||
|
Guid? PlanId,
|
||||||
|
int? CustomConfigCount,
|
||||||
|
IReadOnlyList<Guid>? ConfigIdsToRevoke
|
||||||
|
);
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
using PnvPanel.Api.Common;
|
using PnvPanel.Api.Common;
|
||||||
using PnvPanel.Application.Admin.Roles;
|
using PnvPanel.Application.Admin.Roles;
|
||||||
using PnvPanel.Application.Admin.Users;
|
using PnvPanel.Application.Admin.Users;
|
||||||
using PnvPanel.Application.Common.Messaging;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using LiteCqrs;
|
||||||
using PnvPanel.Infrastructure.Identity;
|
using PnvPanel.Infrastructure.Identity;
|
||||||
|
|
||||||
namespace PnvPanel.Api.Endpoints;
|
namespace PnvPanel.Api.Endpoints;
|
||||||
@@ -14,46 +15,94 @@ public static class RoleEndpoints
|
|||||||
.WithTags("Admin.Roles")
|
.WithTags("Admin.Roles")
|
||||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
|
|
||||||
admin.MapGet("/roles", ListRoles);
|
admin.MapGet("/roles", ListRoles).Produces<IReadOnlyList<RoleDto>>();
|
||||||
admin.MapPost("/roles", CreateRole);
|
admin.MapPost("/roles", CreateRole).Produces<RoleDto>();
|
||||||
admin.MapPut("/roles/{id:guid}", UpdateRole);
|
admin.MapPut("/roles/{id:guid}", UpdateRole).Produces<RoleDto>();
|
||||||
admin.MapDelete("/roles/{id:guid}", DeleteRole);
|
admin.MapDelete("/roles/{id:guid}", DeleteRole).Produces(StatusCodes.Status204NoContent);
|
||||||
admin.MapPatch("/users/{id:guid}/role", ChangeUserRole);
|
admin
|
||||||
|
.MapPatch("/users/{id:guid}/role", ChangeUserRole)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
admin
|
||||||
|
.MapPatch("/users/{id:guid}/plan", SetUserPlan)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ListRoles(ISender sender, CancellationToken cancellationToken)
|
private static async Task<IResult> ListRoles(
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new ListRolesQuery(), cancellationToken);
|
var result = await sender.Send(new ListRolesQuery(), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> CreateRole(CreateRoleCommand command, ISender sender, CancellationToken cancellationToken)
|
private static async Task<IResult> CreateRole(
|
||||||
|
CreateRoleCommand command,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(command, cancellationToken);
|
var result = await sender.Send(command, cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> UpdateRole(Guid id, UpdateRoleBody body, ISender sender, CancellationToken cancellationToken)
|
private static async Task<IResult> UpdateRole(
|
||||||
|
Guid id,
|
||||||
|
UpdateRoleBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new UpdateRoleCommand(id, body.MaxConfigs), cancellationToken);
|
var result = await sender.Send(
|
||||||
|
new UpdateRoleCommand(id, body.MaxIpLimit, body.BillingEnabled),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> DeleteRole(Guid id, ISender sender, CancellationToken cancellationToken)
|
private static async Task<IResult> DeleteRole(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new DeleteRoleCommand(id), cancellationToken);
|
var result = await sender.Send(new DeleteRoleCommand(id), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ChangeUserRole(Guid id, ChangeUserRoleBody body, ISender sender, CancellationToken cancellationToken)
|
private static async Task<IResult> ChangeUserRole(
|
||||||
|
Guid id,
|
||||||
|
ChangeUserRoleBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new ChangeUserRoleCommand(id, body.RoleId), cancellationToken);
|
var result = await sender.Send(
|
||||||
|
new ChangeUserRoleCommand(id, body.RoleId),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> SetUserPlan(
|
||||||
|
Guid id,
|
||||||
|
SetUserPlanBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new AdminSetUserPlanCommand(id, body.PlanId, body.CustomConfigCount),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record UpdateRoleBody(int MaxConfigs);
|
public sealed record UpdateRoleBody(int MaxIpLimit, bool BillingEnabled);
|
||||||
|
|
||||||
public sealed record ChangeUserRoleBody(Guid RoleId);
|
public sealed record ChangeUserRoleBody(Guid RoleId);
|
||||||
|
|
||||||
|
public sealed record SetUserPlanBody(Guid? PlanId, int? CustomConfigCount);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using PnvPanel.Api.Common;
|
using PnvPanel.Api.Common;
|
||||||
using PnvPanel.Application.Common.Messaging;
|
using LiteCqrs;
|
||||||
using PnvPanel.Application.Subscriptions;
|
using PnvPanel.Application.Subscriptions;
|
||||||
|
|
||||||
namespace PnvPanel.Api.Endpoints;
|
namespace PnvPanel.Api.Endpoints;
|
||||||
@@ -12,17 +12,26 @@ public static class SubscriptionEndpoints
|
|||||||
// Вне /api по дизайну (api-design.md) — публичный эндпоинт для VPN-клиентов.
|
// Вне /api по дизайну (api-design.md) — публичный эндпоинт для VPN-клиентов.
|
||||||
app.MapGet("/sub/{token}", GetSubscription)
|
app.MapGet("/sub/{token}", GetSubscription)
|
||||||
.WithTags("Subscription")
|
.WithTags("Subscription")
|
||||||
.RequireRateLimiting(RateLimiting.AuthPolicy);
|
.RequireRateLimiting(RateLimiting.AuthPolicy)
|
||||||
|
.Produces<string>(StatusCodes.Status200OK, "text/plain")
|
||||||
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> GetSubscription(string token, HttpResponse response, ISender sender, CancellationToken cancellationToken)
|
private static async Task<IResult> GetSubscription(
|
||||||
|
string token,
|
||||||
|
HttpResponse response,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
// Токен — либо AppUser.SubscriptionToken (агрегированная подписка), либо VpnConfig.SubscriptionToken
|
// Токен — либо AppUser.SubscriptionToken (агрегированная подписка), либо VpnConfig.SubscriptionToken
|
||||||
// (один конфиг). Пробуем пользовательский токен первым.
|
// (один конфиг). Пробуем пользовательский токен первым.
|
||||||
var userResult = await sender.Send(new GetUserSubscriptionQuery(token), cancellationToken);
|
var userResult = await sender.Send(new GetUserSubscriptionQuery(token), cancellationToken);
|
||||||
var result = userResult.IsSuccess ? userResult : await sender.Send(new GetConfigSubscriptionQuery(token), cancellationToken);
|
var result = userResult.IsSuccess
|
||||||
|
? userResult
|
||||||
|
: await sender.Send(new GetConfigSubscriptionQuery(token), cancellationToken);
|
||||||
|
|
||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
@@ -34,7 +43,8 @@ public static class SubscriptionEndpoints
|
|||||||
var expire = result.Value.ExpiresAt is { } exp ? exp.ToUnixTimeSeconds().ToString() : "0";
|
var expire = result.Value.ExpiresAt is { } exp ? exp.ToUnixTimeSeconds().ToString() : "0";
|
||||||
response.Headers.Append(
|
response.Headers.Append(
|
||||||
"Subscription-Userinfo",
|
"Subscription-Userinfo",
|
||||||
$"upload={result.Value.UsedUpBytes}; download={result.Value.UsedDownBytes}; total={total}; expire={expire}");
|
$"upload={result.Value.UsedUpBytes}; download={result.Value.UsedDownBytes}; total={total}; expire={expire}"
|
||||||
|
);
|
||||||
response.Headers.Append("Profile-Update-Interval", "12");
|
response.Headers.Append("Profile-Update-Interval", "12");
|
||||||
|
|
||||||
return Results.Text(base64Body, "text/plain; charset=utf-8");
|
return Results.Text(base64Body, "text/plain; charset=utf-8");
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using PnvPanel.Api.Common;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Application.Support;
|
||||||
|
using PnvPanel.Application.Support.AddComment;
|
||||||
|
using PnvPanel.Application.Support.CreateBugReport;
|
||||||
|
using PnvPanel.Application.Support.CreateExtensionRequest;
|
||||||
|
using PnvPanel.Application.Support.GetAttachment;
|
||||||
|
using PnvPanel.Application.Support.GetSupportPricing;
|
||||||
|
using PnvPanel.Application.Support.GetTicket;
|
||||||
|
using PnvPanel.Application.Support.ListMyTickets;
|
||||||
|
using PnvPanel.Application.Support.Reopen;
|
||||||
|
using PnvPanel.Domain.Support;
|
||||||
|
|
||||||
|
namespace PnvPanel.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class SupportEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapSupportEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/support").WithTags("Support").RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapGet("/pricing", GetSupportPricing).Produces<PricingSettingsDto>();
|
||||||
|
group
|
||||||
|
.MapPost("/tickets/bug-reports", CreateBugReport)
|
||||||
|
.DisableAntiforgery()
|
||||||
|
.Produces<TicketDetailDto>();
|
||||||
|
group
|
||||||
|
.MapPost("/tickets/extension-requests", CreateExtensionRequest)
|
||||||
|
.Produces<TicketDetailDto>();
|
||||||
|
group.MapGet("/tickets", ListMyTickets).Produces<PagedList<TicketSummaryDto>>();
|
||||||
|
group.MapGet("/tickets/{id:guid}", GetTicket).Produces<TicketDetailDto>();
|
||||||
|
group
|
||||||
|
.MapPost("/tickets/{id:guid}/comments", AddComment)
|
||||||
|
.DisableAntiforgery()
|
||||||
|
.Produces<TicketCommentDto>();
|
||||||
|
group.MapPost("/tickets/{id:guid}/reopen", Reopen).Produces(StatusCodes.Status204NoContent);
|
||||||
|
group.MapGet("/attachments/{id:guid}", GetAttachment);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetSupportPricing(
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new GetSupportPricingQuery(), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CreateBugReport(
|
||||||
|
[FromForm] string message,
|
||||||
|
IFormFileCollection? files,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var command = new CreateBugReportTicketCommand(message, ToUploads(files));
|
||||||
|
var result = await sender.Send(command, cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CreateExtensionRequest(
|
||||||
|
CreateExtensionRequestBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var command = new CreateExtensionRequestTicketCommand(body.RequestedDays, body.Justification);
|
||||||
|
var result = await sender.Send(command, cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ListMyTickets(
|
||||||
|
[AsParameters] ListTicketsRequest request,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var query = new ListMyTicketsQuery(
|
||||||
|
request.Type,
|
||||||
|
request.Status,
|
||||||
|
request.Page,
|
||||||
|
request.PageSize
|
||||||
|
);
|
||||||
|
var result = await sender.Send(query, cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetTicket(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new GetTicketQuery(id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> AddComment(
|
||||||
|
Guid id,
|
||||||
|
[FromForm] string body,
|
||||||
|
IFormFileCollection? files,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var command = new AddTicketCommentCommand(id, body, ToUploads(files));
|
||||||
|
var result = await sender.Send(command, cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> Reopen(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new ReopenTicketCommand(id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetAttachment(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new GetTicketAttachmentQuery(id), cancellationToken);
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
return result.ToHttpResult();
|
||||||
|
|
||||||
|
return Results.File(result.Value.Content, result.Value.ContentType, result.Value.FileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static IReadOnlyList<TicketAttachmentUpload> ToUploads(IFormFileCollection? files)
|
||||||
|
{
|
||||||
|
if (files is null || files.Count == 0)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
return files
|
||||||
|
.Select(f => new TicketAttachmentUpload(
|
||||||
|
f.OpenReadStream(),
|
||||||
|
f.FileName,
|
||||||
|
f.ContentType,
|
||||||
|
f.Length
|
||||||
|
))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record CreateExtensionRequestBody(int RequestedDays, string Justification);
|
||||||
|
|
||||||
|
public sealed record ListTicketsRequest(
|
||||||
|
TicketType? Type,
|
||||||
|
TicketStatus? Status,
|
||||||
|
int Page = 1,
|
||||||
|
int PageSize = 20
|
||||||
|
);
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
using PnvPanel.Api.Common;
|
||||||
|
using PnvPanel.Application.Auth;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Telegram;
|
||||||
|
using PnvPanel.Domain.Telegram;
|
||||||
|
|
||||||
|
namespace PnvPanel.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class TelegramEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapTelegramEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/auth/telegram")
|
||||||
|
.WithTags("Auth.Telegram")
|
||||||
|
.RequireRateLimiting(RateLimiting.AuthPolicy);
|
||||||
|
|
||||||
|
group
|
||||||
|
.MapPost("/link-token", CreateLinkToken)
|
||||||
|
.RequireAuthorization()
|
||||||
|
.Produces<LinkTokenResponseDto>();
|
||||||
|
group
|
||||||
|
.MapPost("/unlink", Unlink)
|
||||||
|
.RequireAuthorization()
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
group
|
||||||
|
.MapPost("/login-request", CreateLoginRequest)
|
||||||
|
.Produces<TelegramLoginRequestResponseDto>();
|
||||||
|
group
|
||||||
|
.MapGet("/login-request/{id:guid}", GetLoginRequestStatus)
|
||||||
|
.Produces<TelegramLoginStatusResponseDto>();
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CreateLinkToken(
|
||||||
|
ISender sender,
|
||||||
|
ITelegramBotInfo botInfo,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new CreateLinkTokenCommand(), cancellationToken);
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
return result.ToHttpResult();
|
||||||
|
|
||||||
|
var botUsername = await botInfo.GetUsernameAsync(cancellationToken);
|
||||||
|
var deepLink = botUsername is null
|
||||||
|
? null
|
||||||
|
: $"https://t.me/{botUsername}?start=link_{result.Value.Token}";
|
||||||
|
|
||||||
|
return Results.Ok(new LinkTokenResponseDto(deepLink, result.Value.ExpiresAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> Unlink(ISender sender, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new UnlinkTelegramCommand(), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CreateLoginRequest(
|
||||||
|
HttpRequest request,
|
||||||
|
ISender sender,
|
||||||
|
ITelegramBotInfo botInfo,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var context = request.HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
|
var result = await sender.Send(new CreateLoginRequestCommand(context), cancellationToken);
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
return result.ToHttpResult();
|
||||||
|
|
||||||
|
var botUsername = await botInfo.GetUsernameAsync(cancellationToken);
|
||||||
|
var deepLink = botUsername is null
|
||||||
|
? null
|
||||||
|
: $"https://t.me/{botUsername}?start=login_{result.Value.RequestId}";
|
||||||
|
|
||||||
|
return Results.Ok(
|
||||||
|
new TelegramLoginRequestResponseDto(
|
||||||
|
result.Value.RequestId,
|
||||||
|
deepLink,
|
||||||
|
result.Value.ExpiresAt
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetLoginRequestStatus(
|
||||||
|
Guid id,
|
||||||
|
HttpRequest request,
|
||||||
|
HttpResponse response,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new GetLoginRequestStatusQuery(id), cancellationToken);
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
return result.ToHttpResult();
|
||||||
|
|
||||||
|
if (result.Value.Auth is { } auth)
|
||||||
|
{
|
||||||
|
// Secure = IsHttps запроса (учитывает ForwardedHeaders за внешним TLS-прокси) —
|
||||||
|
// иначе браузер/HttpClient не пришлёт cookie обратно на plain-http (см. AuthEndpoints).
|
||||||
|
var cookieOptions = new CookieOptions
|
||||||
|
{
|
||||||
|
HttpOnly = true,
|
||||||
|
Secure = request.IsHttps,
|
||||||
|
SameSite = SameSiteMode.Strict,
|
||||||
|
Path = "/api/auth",
|
||||||
|
Expires = auth.RefreshTokenExpiresAt,
|
||||||
|
};
|
||||||
|
response.Cookies.Append("pnv_refresh_token", auth.RefreshToken, cookieOptions);
|
||||||
|
|
||||||
|
return Results.Ok(
|
||||||
|
new TelegramLoginStatusResponseDto(
|
||||||
|
result.Value.Status,
|
||||||
|
auth.AccessToken,
|
||||||
|
auth.AccessTokenExpiresAt,
|
||||||
|
auth.User
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(
|
||||||
|
new TelegramLoginStatusResponseDto(result.Value.Status, null, null, null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record LinkTokenResponseDto(string? DeepLink, DateTimeOffset ExpiresAt);
|
||||||
|
|
||||||
|
public sealed record TelegramLoginRequestResponseDto(
|
||||||
|
Guid RequestId,
|
||||||
|
string? DeepLink,
|
||||||
|
DateTimeOffset ExpiresAt
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record TelegramLoginStatusResponseDto(
|
||||||
|
TelegramLoginStatus Status,
|
||||||
|
string? AccessToken,
|
||||||
|
DateTimeOffset? ExpiresAt,
|
||||||
|
CurrentUserDto? User
|
||||||
|
);
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using PnvPanel.Infrastructure.Identity;
|
||||||
|
|
||||||
|
namespace PnvPanel.Api.Hubs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Группы: user:{userId} (личные события — трафик/статус конфига), admins (статусы нод,
|
||||||
|
/// запросы активации). UserIdentifier берётся из claim NameIdentifier — того же, что кладём в JWT.
|
||||||
|
/// </summary>
|
||||||
|
[Authorize]
|
||||||
|
public sealed class PanelHub : Hub
|
||||||
|
{
|
||||||
|
public override async Task OnConnectedAsync()
|
||||||
|
{
|
||||||
|
if (Context.UserIdentifier is { } userId)
|
||||||
|
await Groups.AddToGroupAsync(Context.ConnectionId, GroupNames.User(userId));
|
||||||
|
|
||||||
|
if (Context.User?.IsInRole(RoleNames.Admin) == true)
|
||||||
|
await Groups.AddToGroupAsync(Context.ConnectionId, GroupNames.Admins);
|
||||||
|
|
||||||
|
await base.OnConnectedAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class GroupNames
|
||||||
|
{
|
||||||
|
public const string Admins = "admins";
|
||||||
|
|
||||||
|
public static string User(string userId) => $"user:{userId}";
|
||||||
|
|
||||||
|
public static string User(Guid userId) => User(userId.ToString());
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Domain.Configs;
|
||||||
|
using PnvPanel.Domain.Nodes;
|
||||||
|
using PnvPanel.Domain.Support;
|
||||||
|
|
||||||
|
namespace PnvPanel.Api.Hubs;
|
||||||
|
|
||||||
|
internal sealed class SignalRRealtimeNotifier(IHubContext<PanelHub> hubContext) : IRealtimeNotifier
|
||||||
|
{
|
||||||
|
public Task NotifyConfigTrafficUpdatedAsync(
|
||||||
|
Guid userId,
|
||||||
|
Guid configId,
|
||||||
|
long usedUpBytes,
|
||||||
|
long usedDownBytes,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return hubContext
|
||||||
|
.Clients.Group(GroupNames.User(userId))
|
||||||
|
.SendAsync(
|
||||||
|
"configTrafficUpdated",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
configId,
|
||||||
|
usedUpBytes,
|
||||||
|
usedDownBytes,
|
||||||
|
},
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task NotifyConfigStatusChangedAsync(
|
||||||
|
Guid userId,
|
||||||
|
Guid configId,
|
||||||
|
ConfigStatus status,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return hubContext
|
||||||
|
.Clients.Group(GroupNames.User(userId))
|
||||||
|
.SendAsync(
|
||||||
|
"configStatusChanged",
|
||||||
|
new { configId, status = status.ToString() },
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task NotifyNodeStatusChangedAsync(
|
||||||
|
Guid nodeId,
|
||||||
|
NodeStatus status,
|
||||||
|
DateTimeOffset? lastSyncAt,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return hubContext
|
||||||
|
.Clients.Group(GroupNames.Admins)
|
||||||
|
.SendAsync(
|
||||||
|
"nodeStatusChanged",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
nodeId,
|
||||||
|
status = status.ToString(),
|
||||||
|
lastSyncAt,
|
||||||
|
},
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task NotifyActivationRequestedAsync(
|
||||||
|
Guid requestId,
|
||||||
|
Guid userId,
|
||||||
|
string userName,
|
||||||
|
string? comment,
|
||||||
|
DateTimeOffset createdAt,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return hubContext
|
||||||
|
.Clients.Group(GroupNames.Admins)
|
||||||
|
.SendAsync(
|
||||||
|
"activationRequested",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
requestId,
|
||||||
|
userId,
|
||||||
|
userName,
|
||||||
|
comment,
|
||||||
|
createdAt,
|
||||||
|
},
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task NotifyUserActivatedAsync(Guid userId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return hubContext
|
||||||
|
.Clients.Group(GroupNames.User(userId))
|
||||||
|
.SendAsync("userActivated", new { userId }, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task NotifyNewsPublishedAsync(
|
||||||
|
Guid postId,
|
||||||
|
string title,
|
||||||
|
DateTimeOffset createdAt,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return hubContext.Clients.All.SendAsync(
|
||||||
|
"newsPublished",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
id = postId,
|
||||||
|
title,
|
||||||
|
createdAt,
|
||||||
|
},
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task NotifyTicketCreatedAsync(
|
||||||
|
Guid ticketId,
|
||||||
|
Guid userId,
|
||||||
|
string userName,
|
||||||
|
TicketType type,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return hubContext
|
||||||
|
.Clients.Group(GroupNames.Admins)
|
||||||
|
.SendAsync(
|
||||||
|
"ticketCreated",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
ticketId,
|
||||||
|
userId,
|
||||||
|
userName,
|
||||||
|
type = type.ToString(),
|
||||||
|
},
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task NotifyTicketUpdatedAsync(
|
||||||
|
Guid ticketId,
|
||||||
|
Guid userId,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return hubContext
|
||||||
|
.Clients.Group(GroupNames.User(userId))
|
||||||
|
.SendAsync("ticketUpdated", new { ticketId }, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task NotifyBillingStatusChangedAsync(Guid userId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return hubContext
|
||||||
|
.Clients.Group(GroupNames.User(userId))
|
||||||
|
.SendAsync("billingStatusChanged", new { userId }, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\PnvPanel.Infrastructure\PnvPanel.Infrastructure.csproj" />
|
<ProjectReference Include="..\PnvPanel.Infrastructure\PnvPanel.Infrastructure.csproj" />
|
||||||
<ProjectReference Include="..\PnvPanel.Application\PnvPanel.Application.csproj" />
|
<ProjectReference Include="..\PnvPanel.Application\PnvPanel.Application.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Content Include="..\..\..\seed\client-apps.json" Link="seed\client-apps.json" CopyToOutputDirectory="PreserveNewest" />
|
<Content
|
||||||
|
Include="..\..\..\seed\client-apps.json"
|
||||||
|
Link="seed\client-apps.json"
|
||||||
|
CopyToOutputDirectory="PreserveNewest"
|
||||||
|
/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -19,6 +22,7 @@
|
|||||||
<PackageReference Include="Microsoft.OpenApi" />
|
<PackageReference Include="Microsoft.OpenApi" />
|
||||||
<PackageReference Include="Scalar.AspNetCore" />
|
<PackageReference Include="Scalar.AspNetCore" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" />
|
<PackageReference Include="Serilog.AspNetCore" />
|
||||||
|
<PackageReference Include="Telegram.Bot" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
@@ -26,5 +30,4 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,52 +1,162 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using PnvPanel.Api.Common;
|
using PnvPanel.Api.Common;
|
||||||
using PnvPanel.Api.Endpoints;
|
using PnvPanel.Api.Endpoints;
|
||||||
|
using PnvPanel.Api.Hubs;
|
||||||
|
using PnvPanel.Api.Telegram;
|
||||||
using PnvPanel.Application;
|
using PnvPanel.Application;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
using PnvPanel.Infrastructure;
|
using PnvPanel.Infrastructure;
|
||||||
using PnvPanel.Infrastructure.Identity;
|
using PnvPanel.Infrastructure.Identity;
|
||||||
using PnvPanel.Infrastructure.Persistence;
|
using PnvPanel.Infrastructure.Persistence;
|
||||||
|
using PnvPanel.Infrastructure.Telegram;
|
||||||
using Scalar.AspNetCore;
|
using Scalar.AspNetCore;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
using Telegram.Bot;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// Структурное логирование (Serilog), конфигурация из appsettings/env.
|
// Структурное логирование (Serilog), конфигурация из appsettings/env.
|
||||||
builder.Services.AddSerilog((services, configuration) => configuration
|
builder.Services.AddSerilog(
|
||||||
|
(services, configuration) =>
|
||||||
|
configuration
|
||||||
.ReadFrom.Configuration(builder.Configuration)
|
.ReadFrom.Configuration(builder.Configuration)
|
||||||
.ReadFrom.Services(services)
|
.ReadFrom.Services(services)
|
||||||
.Enrich.FromLogContext());
|
.Enrich.FromLogContext()
|
||||||
|
);
|
||||||
|
|
||||||
// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера).
|
// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно
|
||||||
|
// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя
|
||||||
|
// прокси (спуфинг rate-limiting по IP, аудит-лога, Secure-cookie). По умолчанию (без конфигурации)
|
||||||
|
// остаётся дефолт ASP.NET Core — доверие только loopback; для прод-топологии прокси задаётся через
|
||||||
|
// ForwardedHeaders__KnownProxies / ForwardedHeaders__KnownNetworks (см. .env.example).
|
||||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||||
{
|
{
|
||||||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||||
options.KnownIPNetworks.Clear();
|
|
||||||
options.KnownProxies.Clear();
|
foreach (
|
||||||
|
var proxy in builder
|
||||||
|
.Configuration.GetSection("ForwardedHeaders:KnownProxies")
|
||||||
|
.Get<string[]>()
|
||||||
|
?? []
|
||||||
|
)
|
||||||
|
options.KnownProxies.Add(IPAddress.Parse(proxy));
|
||||||
|
|
||||||
|
foreach (
|
||||||
|
var network in builder
|
||||||
|
.Configuration.GetSection("ForwardedHeaders:KnownNetworks")
|
||||||
|
.Get<string[]>()
|
||||||
|
?? []
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var parts = network.Split('/');
|
||||||
|
options.KnownIPNetworks.Add(
|
||||||
|
new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1]))
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
builder.Services.AddApplication();
|
builder.Services.AddApplication();
|
||||||
builder.Services.AddInfrastructure(builder.Configuration);
|
builder.Services.AddInfrastructure(builder.Configuration);
|
||||||
|
|
||||||
|
builder.Services.AddSignalR();
|
||||||
|
|
||||||
|
// В Api, не в Infrastructure — реализации нужен IHubContext<PanelHub>, а Hub определён здесь же.
|
||||||
|
builder.Services.AddSingleton<IRealtimeNotifier, SignalRRealtimeNotifier>();
|
||||||
|
|
||||||
|
// Telegram-бот: presentation-адаптер, хостится в процессе Api (long polling). Клиент регистрируем
|
||||||
|
// всегда (даже с пустым токеном) — TelegramBotHostedService сам решает не стартовать без токена,
|
||||||
|
// а TelegramNotifier — не слать сообщения. TelegramBotClient(...) при этом валидирует формат токена
|
||||||
|
// и падает на пустой строке, поэтому при пустом BotToken подставляем синтаксически валидную заглушку —
|
||||||
|
// реальный HTTP-вызов через неё никогда не происходит (все вызывающие места сами проверяют BotToken).
|
||||||
|
builder.Services.AddSingleton<ITelegramBotClient>(sp =>
|
||||||
|
{
|
||||||
|
var options = sp.GetRequiredService<IOptions<TelegramOptions>>().Value;
|
||||||
|
var token = string.IsNullOrWhiteSpace(options.BotToken)
|
||||||
|
? "0:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||||
|
: options.BotToken;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(options.ProxyUrl))
|
||||||
|
return new TelegramBotClient(token);
|
||||||
|
|
||||||
|
// Прокси (обычно socks5://) для запросов к Bot API — на случай, если Telegram недоступен напрямую
|
||||||
|
// с сети сервера. Пусто (по умолчанию) — без прокси, прямое подключение (см. .env.example).
|
||||||
|
var proxyUri = new Uri(options.ProxyUrl);
|
||||||
|
var proxy = new WebProxy(proxyUri);
|
||||||
|
if (!string.IsNullOrEmpty(proxyUri.UserInfo))
|
||||||
|
{
|
||||||
|
var credentials = proxyUri.UserInfo.Split(':', 2);
|
||||||
|
proxy.Credentials = new NetworkCredential(
|
||||||
|
credentials[0],
|
||||||
|
credentials.Length > 1 ? credentials[1] : string.Empty
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
sp.GetRequiredService<ILogger<Program>>()
|
||||||
|
.LogInformation(
|
||||||
|
"Telegram bot using proxy {Scheme}://{Host}:{Port}",
|
||||||
|
proxyUri.Scheme,
|
||||||
|
proxyUri.Host,
|
||||||
|
proxyUri.Port
|
||||||
|
);
|
||||||
|
|
||||||
|
var handler = new SocketsHttpHandler { Proxy = proxy, UseProxy = true };
|
||||||
|
return new TelegramBotClient(token, new HttpClient(handler));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scoped — зависит от IIdentityService (scoped), не Singleton.
|
||||||
|
builder.Services.AddScoped<ITelegramNotifier, TelegramNotifier>();
|
||||||
|
|
||||||
|
// Singleton — кэширует username бота (getMe) на весь процесс, не из ручного env (см. TelegramBotInfo).
|
||||||
|
builder.Services.AddSingleton<ITelegramBotInfo, TelegramBotInfo>();
|
||||||
|
builder.Services.AddSingleton<PnvBotUpdateHandler>();
|
||||||
|
builder.Services.AddHostedService<TelegramBotHostedService>();
|
||||||
|
|
||||||
builder.Services.AddRateLimiter(options =>
|
builder.Services.AddRateLimiter(options =>
|
||||||
{
|
{
|
||||||
options.AddFixedWindowLimiter(RateLimiting.AuthPolicy, limiterOptions =>
|
options.AddFixedWindowLimiter(
|
||||||
|
RateLimiting.AuthPolicy,
|
||||||
|
limiterOptions =>
|
||||||
{
|
{
|
||||||
limiterOptions.PermitLimit = 20;
|
// Настраиваемо через конфиг, чтобы интеграционные тесты (общий TestServer/host на весь
|
||||||
|
// collection, все запросы — от одного "клиента") могли поднять лимит и не ловить 429.
|
||||||
|
limiterOptions.PermitLimit = builder.Configuration.GetValue(
|
||||||
|
"RateLimiting:AuthPermitLimit",
|
||||||
|
20
|
||||||
|
);
|
||||||
limiterOptions.Window = TimeSpan.FromMinutes(1);
|
limiterOptions.Window = TimeSpan.FromMinutes(1);
|
||||||
limiterOptions.QueueLimit = 0;
|
limiterOptions.QueueLimit = 0;
|
||||||
});
|
}
|
||||||
|
);
|
||||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Энумы сериализуются строками ("Vless", "Active", ...), не числами — самодокументируемый JSON,
|
||||||
|
// корректные строковые литералы при генерации TS-типов из OpenAPI-схемы (см. docs/frontend.md).
|
||||||
|
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||||
|
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())
|
||||||
|
);
|
||||||
|
|
||||||
builder.Services.AddProblemDetails();
|
builder.Services.AddProblemDetails();
|
||||||
builder.Services.AddOpenApi();
|
builder.Services.AddOpenApi();
|
||||||
builder.Services.AddHealthChecks()
|
builder.Services.AddHealthChecks().AddDbContextCheck<AppDbContext>();
|
||||||
.AddDbContextCheck<AppDbContext>();
|
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// Без персистентного пути key-ring живёт только в памяти контейнера — после пересоздания
|
||||||
|
// расшифровать уже сохранённые пароли нод будет невозможно. Предупреждаем громко, не молчим.
|
||||||
|
if (string.IsNullOrWhiteSpace(builder.Configuration["DataProtection:KeyRingPath"]))
|
||||||
|
{
|
||||||
|
app.Logger.LogWarning(
|
||||||
|
"DataProtection:KeyRingPath is not set — node secret encryption keys are not persistent "
|
||||||
|
+ "and will be lost when the container is recreated. Mount a volume and set the path in production."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
|
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
|
||||||
await app.Services.ApplyMigrationsAsync();
|
await app.Services.ApplyMigrationsAsync();
|
||||||
await app.Services.SeedDataAsync();
|
await app.Services.SeedDataAsync();
|
||||||
@@ -74,6 +184,25 @@ app.MapInboundEndpoints();
|
|||||||
app.MapConfigEndpoints();
|
app.MapConfigEndpoints();
|
||||||
app.MapSubscriptionEndpoints();
|
app.MapSubscriptionEndpoints();
|
||||||
app.MapAppEndpoints();
|
app.MapAppEndpoints();
|
||||||
|
app.MapPlanEndpoints();
|
||||||
|
app.MapNewsEndpoints();
|
||||||
|
app.MapInstructionEndpoints();
|
||||||
|
app.MapMediaEndpoints();
|
||||||
|
app.MapAdminUserEndpoints();
|
||||||
|
app.MapAdminStatsEndpoints();
|
||||||
|
app.MapAdminAppEndpoints();
|
||||||
|
app.MapAdminPlanEndpoints();
|
||||||
|
app.MapAdminNewsEndpoints();
|
||||||
|
app.MapAdminInstructionEndpoints();
|
||||||
|
app.MapAdminPricingEndpoints();
|
||||||
|
app.MapBillingEndpoints();
|
||||||
|
app.MapAdminBillingEndpoints();
|
||||||
|
app.MapSupportEndpoints();
|
||||||
|
app.MapAdminSupportEndpoints();
|
||||||
|
app.MapAdminMaintenanceEndpoints();
|
||||||
|
app.MapTelegramEndpoints();
|
||||||
|
|
||||||
|
app.MapHub<PanelHub>("/hubs/panel");
|
||||||
|
|
||||||
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
|
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
|
||||||
app.UseDefaultFiles();
|
app.UseDefaultFiles();
|
||||||
@@ -81,3 +210,6 @@ app.UseStaticFiles();
|
|||||||
app.MapFallbackToFile("index.html");
|
app.MapFallbackToFile("index.html");
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
/// <summary>Делает неявный класс Program доступным для WebApplicationFactory<Program> в интеграционных тестах.</summary>
|
||||||
|
public partial class Program;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using PnvPanel.Infrastructure.Telegram;
|
||||||
|
using Telegram.Bot;
|
||||||
|
using Telegram.Bot.Polling;
|
||||||
|
using Telegram.Bot.Types;
|
||||||
|
using Telegram.Bot.Types.Enums;
|
||||||
|
|
||||||
|
namespace PnvPanel.Api.Telegram;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Бот — presentation-адаптер, хостится в процессе Api (long polling). Если BotToken не задан,
|
||||||
|
/// не стартует — панель работает без бота. Апдейты обрабатывает PnvBotUpdateHandler, который
|
||||||
|
/// вызывает те же CQRS-команды, что и веб, через собственный ISender.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TelegramBotHostedService(
|
||||||
|
ITelegramBotClient botClient,
|
||||||
|
PnvBotUpdateHandler updateHandler,
|
||||||
|
IOptions<TelegramOptions> options,
|
||||||
|
ILogger<TelegramBotHostedService> logger
|
||||||
|
) : BackgroundService
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||||||
|
{
|
||||||
|
logger.LogWarning("Telegram__BotToken is not set — bot will not start.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var receiverOptions = new ReceiverOptions
|
||||||
|
{
|
||||||
|
AllowedUpdates = [UpdateType.Message, UpdateType.CallbackQuery],
|
||||||
|
DropPendingUpdates = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.LogInformation("Telegram bot starting (long polling)...");
|
||||||
|
|
||||||
|
// ReceiveAsync может упасть с сетевой ошибкой (Telegram API недоступен/заблокирован) —
|
||||||
|
// это НЕ должно ронять весь хост (BackgroundServiceExceptionBehavior.StopHost по умолчанию
|
||||||
|
// убивает всё приложение при необработанном исключении в BackgroundService). Панель обязана
|
||||||
|
// работать без бота, поэтому ловим, логируем и переподключаемся с паузой (см. CLAUDE.md).
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await botClient.ReceiveAsync(updateHandler, receiverOptions, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Штатная остановка вместе с приложением.
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Telegram bot polling failed, retrying in {Delay}", RetryDelay);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(RetryDelay, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Остановка приложения во время паузы перед повтором.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Infrastructure.Telegram;
|
||||||
|
using Telegram.Bot;
|
||||||
|
|
||||||
|
namespace PnvPanel.Api.Telegram;
|
||||||
|
|
||||||
|
/// <summary>Кэширует username бота на время жизни процесса (getMe не меняется, повторный запрос не нужен).</summary>
|
||||||
|
internal sealed class TelegramBotInfo(
|
||||||
|
ITelegramBotClient botClient,
|
||||||
|
IOptions<TelegramOptions> options
|
||||||
|
) : ITelegramBotInfo
|
||||||
|
{
|
||||||
|
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||||
|
private string? _cachedUsername;
|
||||||
|
|
||||||
|
public async Task<string?> GetUsernameAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_cachedUsername is not null)
|
||||||
|
return _cachedUsername;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
await _lock.WaitAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_cachedUsername is not null)
|
||||||
|
return _cachedUsername;
|
||||||
|
|
||||||
|
var me = await botClient.GetMe(cancellationToken);
|
||||||
|
_cachedUsername = me.Username;
|
||||||
|
return _cachedUsername;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Telegram недоступен/бот не отвечает — деплинк просто не покажем вызывающей стороне.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
using PnvPanel.Domain.Nodes;
|
||||||
|
using PnvPanel.Domain.Support;
|
||||||
|
using PnvPanel.Infrastructure.Telegram;
|
||||||
|
using Telegram.Bot;
|
||||||
|
using Telegram.Bot.Types.Enums;
|
||||||
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
|
||||||
|
namespace PnvPanel.Api.Telegram;
|
||||||
|
|
||||||
|
internal sealed class TelegramNotifier(
|
||||||
|
ITelegramBotClient botClient,
|
||||||
|
IIdentityService identityService,
|
||||||
|
IOptions<TelegramOptions> options
|
||||||
|
) : ITelegramNotifier
|
||||||
|
{
|
||||||
|
public async Task NotifyAdminsActivationRequestedAsync(
|
||||||
|
Guid requestId,
|
||||||
|
string userName,
|
||||||
|
string? comment,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var text =
|
||||||
|
$"🆕 Запрос на активацию от <b>{Escape(userName)}</b>"
|
||||||
|
+ (
|
||||||
|
string.IsNullOrWhiteSpace(comment)
|
||||||
|
? string.Empty
|
||||||
|
: $"\nКомментарий: {Escape(comment)}"
|
||||||
|
);
|
||||||
|
|
||||||
|
var keyboard = new InlineKeyboardMarkup(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
InlineKeyboardButton.WithCallbackData("✅ Активировать", $"act:approve:{requestId}"),
|
||||||
|
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"act:reject:{requestId}"),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await botClient.SendMessage(
|
||||||
|
adminId,
|
||||||
|
text,
|
||||||
|
parseMode: ParseMode.Html,
|
||||||
|
replyMarkup: keyboard,
|
||||||
|
cancellationToken: cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task NotifyAdminsBugReportCreatedAsync(
|
||||||
|
Guid ticketId,
|
||||||
|
string userName,
|
||||||
|
string message,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var preview = message.Length > 300 ? message[..300] + "…" : message;
|
||||||
|
var text =
|
||||||
|
$"🐞 Новый тикет (баг/предложение) от <b>{Escape(userName)}</b>\n{Escape(preview)}";
|
||||||
|
|
||||||
|
// Только ссылка на сайт — переписка и картинки удобнее там, инлайн-действий для баг-тикетов нет.
|
||||||
|
InlineKeyboardMarkup? keyboard = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
|
||||||
|
{
|
||||||
|
var url = $"{options.Value.PublicSiteUrl.TrimEnd('/')}/admin/support?ticket={ticketId}";
|
||||||
|
keyboard = new InlineKeyboardMarkup(
|
||||||
|
new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await botClient.SendMessage(
|
||||||
|
adminId,
|
||||||
|
text,
|
||||||
|
parseMode: ParseMode.Html,
|
||||||
|
replyMarkup: keyboard,
|
||||||
|
cancellationToken: cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task NotifyAdminsExtensionRequestCreatedAsync(
|
||||||
|
Guid ticketId,
|
||||||
|
string userName,
|
||||||
|
int requestedDays,
|
||||||
|
string justification,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var text =
|
||||||
|
$"🆕 Заявка на продление от <b>{Escape(userName)}</b> — {requestedDays} дн.\nОбоснование: {Escape(justification)}";
|
||||||
|
|
||||||
|
var keyboard = new InlineKeyboardMarkup(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
InlineKeyboardButton.WithCallbackData("✅ Одобрить", $"erq:approve:{ticketId}"),
|
||||||
|
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"erq:reject:{ticketId}"),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await botClient.SendMessage(
|
||||||
|
adminId,
|
||||||
|
text,
|
||||||
|
parseMode: ParseMode.Html,
|
||||||
|
replyMarkup: keyboard,
|
||||||
|
cancellationToken: cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task NotifyAdminsTicketReopenedAsync(
|
||||||
|
Guid ticketId,
|
||||||
|
string userName,
|
||||||
|
TicketType type,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var text = $"🔓 Тикет от <b>{Escape(userName)}</b> переоткрыт (баг/предложение)";
|
||||||
|
|
||||||
|
InlineKeyboardMarkup? keyboard = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
|
||||||
|
{
|
||||||
|
var url = $"{options.Value.PublicSiteUrl.TrimEnd('/')}/admin/support?ticket={ticketId}";
|
||||||
|
keyboard = new InlineKeyboardMarkup(
|
||||||
|
new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await botClient.SendMessage(
|
||||||
|
adminId,
|
||||||
|
text,
|
||||||
|
parseMode: ParseMode.Html,
|
||||||
|
replyMarkup: keyboard,
|
||||||
|
cancellationToken: cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task NotifyUsersNewsPublishedAsync(
|
||||||
|
string title,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var text = $"📰 Новая новость: <b>{Escape(title)}</b>";
|
||||||
|
|
||||||
|
InlineKeyboardMarkup? keyboard = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
|
||||||
|
{
|
||||||
|
var url = $"{options.Value.PublicSiteUrl.TrimEnd('/')}/news";
|
||||||
|
keyboard = new InlineKeyboardMarkup(
|
||||||
|
new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var telegramUserIds = await identityService.GetActivatedLinkedTelegramUserIdsAsync(
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
foreach (var telegramUserId in telegramUserIds)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await botClient.SendMessage(
|
||||||
|
telegramUserId,
|
||||||
|
text,
|
||||||
|
parseMode: ParseMode.Html,
|
||||||
|
replyMarkup: keyboard,
|
||||||
|
cancellationToken: cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Пользователь мог заблокировать бота — не критично, продолжаем рассылку остальным.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task NotifyAdminsPaymentRequestedAsync(
|
||||||
|
Guid requestId,
|
||||||
|
string userName,
|
||||||
|
PaymentRequestKind kind,
|
||||||
|
PaymentPeriod? period,
|
||||||
|
int amount,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var reason = kind == PaymentRequestKind.PlanChangeTopUp ? "доплате за смену тарифа" : $"оплате за {PeriodLabel(period!.Value)}";
|
||||||
|
var text = $"💰 <b>{Escape(userName)}</b> заявляет о {reason} — {amount} ₽\nПроверьте поступление и подтвердите.";
|
||||||
|
|
||||||
|
var keyboard = new InlineKeyboardMarkup(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
InlineKeyboardButton.WithCallbackData("✅ Подтвердить", $"pay:approve:{requestId}"),
|
||||||
|
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"pay:reject:{requestId}"),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await botClient.SendMessage(
|
||||||
|
adminId,
|
||||||
|
text,
|
||||||
|
parseMode: ParseMode.Html,
|
||||||
|
replyMarkup: keyboard,
|
||||||
|
cancellationToken: cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string PeriodLabel(PaymentPeriod period) =>
|
||||||
|
period switch
|
||||||
|
{
|
||||||
|
PaymentPeriod.Quarter => "3 месяца",
|
||||||
|
PaymentPeriod.HalfYear => "полгода",
|
||||||
|
PaymentPeriod.Year => "год",
|
||||||
|
_ => period.ToString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task NotifyUserAsync(
|
||||||
|
Guid userId,
|
||||||
|
string message,
|
||||||
|
string? linkPath,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var link = await identityService.GetTelegramLinkInfoAsync(userId, cancellationToken);
|
||||||
|
if (!link.IsLinked || link.TelegramUserId is not { } telegramUserId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
InlineKeyboardMarkup? keyboard = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
|
||||||
|
{
|
||||||
|
var url = string.IsNullOrWhiteSpace(linkPath)
|
||||||
|
? options.Value.PublicSiteUrl
|
||||||
|
: $"{options.Value.PublicSiteUrl.TrimEnd('/')}{linkPath}";
|
||||||
|
keyboard = new InlineKeyboardMarkup(
|
||||||
|
new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await botClient.SendMessage(
|
||||||
|
telegramUserId,
|
||||||
|
message,
|
||||||
|
replyMarkup: keyboard,
|
||||||
|
cancellationToken: cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Пользователь мог заблокировать бота — не критично для основной операции.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task NotifyAdminsNodeStatusChangedAsync(
|
||||||
|
Guid nodeId,
|
||||||
|
string nodeName,
|
||||||
|
NodeStatus status,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var emoji = status == NodeStatus.Online ? "✅" : "🔴";
|
||||||
|
var statusLabel = status == NodeStatus.Online ? "снова в сети" : "недоступна";
|
||||||
|
var text = $"{emoji} Нода <b>{Escape(nodeName)}</b> {statusLabel}.";
|
||||||
|
|
||||||
|
InlineKeyboardMarkup? keyboard = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
|
||||||
|
{
|
||||||
|
var url = $"{options.Value.PublicSiteUrl.TrimEnd('/')}/admin/nodes";
|
||||||
|
keyboard = new InlineKeyboardMarkup(
|
||||||
|
new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await botClient.SendMessage(
|
||||||
|
adminId,
|
||||||
|
text,
|
||||||
|
parseMode: ParseMode.Html,
|
||||||
|
replyMarkup: keyboard,
|
||||||
|
cancellationToken: cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Escape(string text) =>
|
||||||
|
text.Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
||||||
|
}
|
||||||
@@ -4,12 +4,18 @@ namespace PnvPanel.Application.Activation;
|
|||||||
|
|
||||||
public static class ActivationErrors
|
public static class ActivationErrors
|
||||||
{
|
{
|
||||||
public static readonly Error AlreadyPending =
|
public static readonly Error AlreadyPending = Error.Conflict(
|
||||||
Error.Conflict("Activation.AlreadyPending", "У вас уже есть необработанный запрос на активацию.");
|
"Activation.AlreadyPending",
|
||||||
|
"У вас уже есть необработанный запрос на активацию."
|
||||||
|
);
|
||||||
|
|
||||||
public static readonly Error NotFound =
|
public static readonly Error NotFound = Error.NotFound(
|
||||||
Error.NotFound("Activation.NotFound", "Запрос на активацию не найден.");
|
"Activation.NotFound",
|
||||||
|
"Запрос на активацию не найден."
|
||||||
|
);
|
||||||
|
|
||||||
public static readonly Error AlreadyDecided =
|
public static readonly Error AlreadyDecided = Error.Conflict(
|
||||||
Error.Conflict("Activation.AlreadyDecided", "Запрос на активацию уже обработан.");
|
"Activation.AlreadyDecided",
|
||||||
|
"Запрос на активацию уже обработан."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using PnvPanel.Application.Common.Messaging;
|
using LiteCqrs;
|
||||||
using PnvPanel.Application.Common.Models;
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
namespace PnvPanel.Application.Activation;
|
namespace PnvPanel.Application.Activation;
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using PnvPanel.Application.Auth;
|
using PnvPanel.Application.Auth;
|
||||||
using PnvPanel.Application.Common.Interfaces;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
using PnvPanel.Application.Common.Messaging;
|
using LiteCqrs;
|
||||||
using PnvPanel.Application.Common.Models;
|
using PnvPanel.Application.Common.Models;
|
||||||
using PnvPanel.Domain.Activation;
|
using PnvPanel.Domain.Activation;
|
||||||
|
|
||||||
namespace PnvPanel.Application.Activation;
|
namespace PnvPanel.Application.Activation;
|
||||||
|
|
||||||
public sealed class GetActivationStatusQueryHandler(IIdentityService identityService, IAppDbContext dbContext, ICurrentUser currentUser)
|
public sealed class GetActivationStatusQueryHandler(
|
||||||
: IQueryHandler<GetActivationStatusQuery, Result<ActivationStatusDto>>
|
IIdentityService identityService,
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
ICurrentUser currentUser
|
||||||
|
) : IQueryHandler<GetActivationStatusQuery, Result<ActivationStatusDto>>
|
||||||
{
|
{
|
||||||
public async Task<Result<ActivationStatusDto>> Handle(GetActivationStatusQuery query, CancellationToken cancellationToken)
|
public async Task<Result<ActivationStatusDto>> Handle(
|
||||||
|
GetActivationStatusQuery query,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
if (currentUser.UserId is not { } userId)
|
if (currentUser.UserId is not { } userId)
|
||||||
return Result.Failure<ActivationStatusDto>(AuthErrors.Unauthorized);
|
return Result.Failure<ActivationStatusDto>(AuthErrors.Unauthorized);
|
||||||
@@ -19,8 +25,10 @@ public sealed class GetActivationStatusQueryHandler(IIdentityService identitySer
|
|||||||
if (profile is null)
|
if (profile is null)
|
||||||
return Result.Failure<ActivationStatusDto>(AuthErrors.Unauthorized);
|
return Result.Failure<ActivationStatusDto>(AuthErrors.Unauthorized);
|
||||||
|
|
||||||
var pending = await dbContext.ActivationRequests
|
var pending = await dbContext
|
||||||
.Where(r => r.UserId == userId && r.Status == ActivationStatus.Pending)
|
.ActivationRequests.Where(r =>
|
||||||
|
r.UserId == userId && r.Status == ActivationStatus.Pending
|
||||||
|
)
|
||||||
.Select(r => new ActivationRequestDto(r.Id, r.Comment, r.CreatedAt))
|
.Select(r => new ActivationRequestDto(r.Id, r.Comment, r.CreatedAt))
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using PnvPanel.Application.Common.Messaging;
|
using LiteCqrs;
|
||||||
using PnvPanel.Application.Common.Models;
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
namespace PnvPanel.Application.Activation;
|
namespace PnvPanel.Application.Activation;
|
||||||
|
|
||||||
public sealed record RequestActivationCommand(string? Comment) : ICommand<Result<ActivationRequestDto>>;
|
public sealed record RequestActivationCommand(string? Comment)
|
||||||
|
: ICommand<Result<ActivationRequestDto>>;
|
||||||
|
|||||||
@@ -1,22 +1,31 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using PnvPanel.Application.Auth;
|
using PnvPanel.Application.Auth;
|
||||||
using PnvPanel.Application.Common.Interfaces;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
using PnvPanel.Application.Common.Messaging;
|
using LiteCqrs;
|
||||||
using PnvPanel.Application.Common.Models;
|
using PnvPanel.Application.Common.Models;
|
||||||
using PnvPanel.Domain.Activation;
|
using PnvPanel.Domain.Activation;
|
||||||
|
|
||||||
namespace PnvPanel.Application.Activation;
|
namespace PnvPanel.Application.Activation;
|
||||||
|
|
||||||
public sealed class RequestActivationCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser)
|
public sealed class RequestActivationCommandHandler(
|
||||||
: ICommandHandler<RequestActivationCommand, Result<ActivationRequestDto>>
|
IAppDbContext dbContext,
|
||||||
|
IRealtimeNotifier notifier,
|
||||||
|
ITelegramNotifier telegramNotifier,
|
||||||
|
ICurrentUser currentUser
|
||||||
|
) : ICommandHandler<RequestActivationCommand, Result<ActivationRequestDto>>
|
||||||
{
|
{
|
||||||
public async Task<Result<ActivationRequestDto>> Handle(RequestActivationCommand command, CancellationToken cancellationToken)
|
public async Task<Result<ActivationRequestDto>> Handle(
|
||||||
|
RequestActivationCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
if (currentUser.UserId is not { } userId)
|
if (currentUser.UserId is not { } userId)
|
||||||
return Result.Failure<ActivationRequestDto>(AuthErrors.Unauthorized);
|
return Result.Failure<ActivationRequestDto>(AuthErrors.Unauthorized);
|
||||||
|
|
||||||
var hasPending = await dbContext.ActivationRequests
|
var hasPending = await dbContext.ActivationRequests.AnyAsync(
|
||||||
.AnyAsync(r => r.UserId == userId && r.Status == ActivationStatus.Pending, cancellationToken);
|
r => r.UserId == userId && r.Status == ActivationStatus.Pending,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
if (hasPending)
|
if (hasPending)
|
||||||
return Result.Failure<ActivationRequestDto>(ActivationErrors.AlreadyPending);
|
return Result.Failure<ActivationRequestDto>(ActivationErrors.AlreadyPending);
|
||||||
@@ -24,6 +33,25 @@ public sealed class RequestActivationCommandHandler(IAppDbContext dbContext, ICu
|
|||||||
var request = ActivationRequest.Create(userId, command.Comment);
|
var request = ActivationRequest.Create(userId, command.Comment);
|
||||||
dbContext.ActivationRequests.Add(request);
|
dbContext.ActivationRequests.Add(request);
|
||||||
|
|
||||||
return Result.Success(new ActivationRequestDto(request.Id, request.Comment, request.CreatedAt));
|
var userName = currentUser.UserName ?? userId.ToString();
|
||||||
|
|
||||||
|
await notifier.NotifyActivationRequestedAsync(
|
||||||
|
request.Id,
|
||||||
|
userId,
|
||||||
|
userName,
|
||||||
|
request.Comment,
|
||||||
|
request.CreatedAt,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
await telegramNotifier.NotifyAdminsActivationRequestedAsync(
|
||||||
|
request.Id,
|
||||||
|
userName,
|
||||||
|
request.Comment,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
return Result.Success(
|
||||||
|
new ActivationRequestDto(request.Id, request.Comment, request.CreatedAt)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ public sealed class RequestActivationCommandValidator : AbstractValidator<Reques
|
|||||||
{
|
{
|
||||||
public RequestActivationCommandValidator()
|
public RequestActivationCommandValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Comment).MaximumLength(500);
|
// Комментарий обязателен: по нему админ понимает, кто заявитель и откуда.
|
||||||
|
RuleFor(x => x.Comment).NotEmpty().MaximumLength(500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ public sealed record ActivationRequestAdminDto(
|
|||||||
string UserName,
|
string UserName,
|
||||||
string? Comment,
|
string? Comment,
|
||||||
ActivationStatus Status,
|
ActivationStatus Status,
|
||||||
DateTimeOffset CreatedAt);
|
DateTimeOffset CreatedAt
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using PnvPanel.Application.Common.Messaging;
|
using LiteCqrs;
|
||||||
using PnvPanel.Application.Common.Models;
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
namespace PnvPanel.Application.Admin.Activation;
|
namespace PnvPanel.Application.Admin.Activation;
|
||||||
|
|||||||
+44
-7
@@ -2,22 +2,33 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using PnvPanel.Application.Activation;
|
using PnvPanel.Application.Activation;
|
||||||
using PnvPanel.Application.Auth;
|
using PnvPanel.Application.Auth;
|
||||||
using PnvPanel.Application.Common.Interfaces;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
using PnvPanel.Application.Common.Messaging;
|
using LiteCqrs;
|
||||||
using PnvPanel.Application.Common.Models;
|
using PnvPanel.Application.Common.Models;
|
||||||
using PnvPanel.Domain.Activation;
|
using PnvPanel.Domain.Activation;
|
||||||
|
using PnvPanel.Domain.Audit;
|
||||||
|
|
||||||
namespace PnvPanel.Application.Admin.Activation;
|
namespace PnvPanel.Application.Admin.Activation;
|
||||||
|
|
||||||
public sealed class ApproveActivationCommandHandler(IAppDbContext dbContext, IIdentityService identityService, ICurrentUser currentUser)
|
public sealed class ApproveActivationCommandHandler(
|
||||||
: ICommandHandler<ApproveActivationCommand, Result>
|
IAppDbContext dbContext,
|
||||||
|
IIdentityService identityService,
|
||||||
|
IRealtimeNotifier notifier,
|
||||||
|
ITelegramNotifier telegramNotifier,
|
||||||
|
ICurrentUser currentUser
|
||||||
|
) : ICommandHandler<ApproveActivationCommand, Result>
|
||||||
{
|
{
|
||||||
public async Task<Result> Handle(ApproveActivationCommand command, CancellationToken cancellationToken)
|
public async Task<Result> Handle(
|
||||||
|
ApproveActivationCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
if (currentUser.UserId is not { } adminId)
|
if (currentUser.UserId is not { } adminId)
|
||||||
return Result.Failure(AuthErrors.Unauthorized);
|
return Result.Failure(AuthErrors.Unauthorized);
|
||||||
|
|
||||||
var request = await dbContext.ActivationRequests
|
var request = await dbContext.ActivationRequests.FirstOrDefaultAsync(
|
||||||
.FirstOrDefaultAsync(r => r.Id == command.RequestId, cancellationToken);
|
r => r.Id == command.RequestId,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
if (request is null)
|
if (request is null)
|
||||||
return Result.Failure(ActivationErrors.NotFound);
|
return Result.Failure(ActivationErrors.NotFound);
|
||||||
@@ -27,6 +38,32 @@ public sealed class ApproveActivationCommandHandler(IAppDbContext dbContext, IId
|
|||||||
|
|
||||||
request.Approve(adminId);
|
request.Approve(adminId);
|
||||||
|
|
||||||
return await identityService.ActivateUserAsync(request.UserId, adminId, cancellationToken);
|
var activateResult = await identityService.ActivateUserAsync(
|
||||||
|
request.UserId,
|
||||||
|
adminId,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (!activateResult.IsSuccess)
|
||||||
|
return activateResult;
|
||||||
|
|
||||||
|
dbContext.AuditLogs.Add(
|
||||||
|
AuditLog.Create(
|
||||||
|
adminId,
|
||||||
|
"ActivationApproved",
|
||||||
|
"User",
|
||||||
|
request.UserId.ToString(),
|
||||||
|
metadata: null,
|
||||||
|
AuditSource.Web
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await notifier.NotifyUserActivatedAsync(request.UserId, cancellationToken);
|
||||||
|
await telegramNotifier.NotifyUserAsync(
|
||||||
|
request.UserId,
|
||||||
|
"✅ Ваш аккаунт активирован администратором.",
|
||||||
|
null,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return Result.Success();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
using PnvPanel.Application.Common.Messaging;
|
using LiteCqrs;
|
||||||
using PnvPanel.Application.Common.Models;
|
using PnvPanel.Application.Common.Models;
|
||||||
using PnvPanel.Domain.Activation;
|
using PnvPanel.Domain.Activation;
|
||||||
|
|
||||||
namespace PnvPanel.Application.Admin.Activation;
|
namespace PnvPanel.Application.Admin.Activation;
|
||||||
|
|
||||||
public sealed record ListActivationRequestsQuery(ActivationStatus? StatusFilter, int Page, int PageSize)
|
public sealed record ListActivationRequestsQuery(
|
||||||
: IQuery<Result<PagedList<ActivationRequestAdminDto>>>;
|
ActivationStatus? StatusFilter,
|
||||||
|
int Page,
|
||||||
|
int PageSize
|
||||||
|
) : IQuery<Result<PagedList<ActivationRequestAdminDto>>>;
|
||||||
|
|||||||
+23
-9
@@ -1,14 +1,19 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using PnvPanel.Application.Common.Interfaces;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
using PnvPanel.Application.Common.Messaging;
|
using LiteCqrs;
|
||||||
using PnvPanel.Application.Common.Models;
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
namespace PnvPanel.Application.Admin.Activation;
|
namespace PnvPanel.Application.Admin.Activation;
|
||||||
|
|
||||||
public sealed class ListActivationRequestsQueryHandler(IAppDbContext dbContext, IIdentityService identityService)
|
public sealed class ListActivationRequestsQueryHandler(
|
||||||
: IQueryHandler<ListActivationRequestsQuery, Result<PagedList<ActivationRequestAdminDto>>>
|
IAppDbContext dbContext,
|
||||||
|
IIdentityService identityService
|
||||||
|
) : IQueryHandler<ListActivationRequestsQuery, Result<PagedList<ActivationRequestAdminDto>>>
|
||||||
{
|
{
|
||||||
public async Task<Result<PagedList<ActivationRequestAdminDto>>> Handle(ListActivationRequestsQuery query, CancellationToken cancellationToken)
|
public async Task<Result<PagedList<ActivationRequestAdminDto>>> Handle(
|
||||||
|
ListActivationRequestsQuery query,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var page = query.Page <= 0 ? 1 : query.Page;
|
var page = query.Page <= 0 ? 1 : query.Page;
|
||||||
var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize;
|
var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize;
|
||||||
@@ -23,13 +28,22 @@ public sealed class ListActivationRequestsQueryHandler(IAppDbContext dbContext,
|
|||||||
|
|
||||||
var userNames = await identityService.GetUserNamesAsync(
|
var userNames = await identityService.GetUserNamesAsync(
|
||||||
page1.Items.Select(r => r.UserId).Distinct().ToList(),
|
page1.Items.Select(r => r.UserId).Distinct().ToList(),
|
||||||
cancellationToken);
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
var items = page1.Items
|
var items = page1
|
||||||
.Select(r => new ActivationRequestAdminDto(
|
.Items.Select(r => new ActivationRequestAdminDto(
|
||||||
r.Id, r.UserId, userNames.GetValueOrDefault(r.UserId, "?"), r.Comment, r.Status, r.CreatedAt))
|
r.Id,
|
||||||
|
r.UserId,
|
||||||
|
userNames.GetValueOrDefault(r.UserId, "?"),
|
||||||
|
r.Comment,
|
||||||
|
r.Status,
|
||||||
|
r.CreatedAt
|
||||||
|
))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return Result.Success(new PagedList<ActivationRequestAdminDto>(items, page1.Total, page1.Page, page1.PageSize));
|
return Result.Success(
|
||||||
|
new PagedList<ActivationRequestAdminDto>(items, page1.Total, page1.Page, page1.PageSize)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using PnvPanel.Application.Common.Messaging;
|
using LiteCqrs;
|
||||||
using PnvPanel.Application.Common.Models;
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
namespace PnvPanel.Application.Admin.Activation;
|
namespace PnvPanel.Application.Admin.Activation;
|
||||||
|
|||||||
+26
-6
@@ -2,22 +2,30 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using PnvPanel.Application.Activation;
|
using PnvPanel.Application.Activation;
|
||||||
using PnvPanel.Application.Auth;
|
using PnvPanel.Application.Auth;
|
||||||
using PnvPanel.Application.Common.Interfaces;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
using PnvPanel.Application.Common.Messaging;
|
using LiteCqrs;
|
||||||
using PnvPanel.Application.Common.Models;
|
using PnvPanel.Application.Common.Models;
|
||||||
using PnvPanel.Domain.Activation;
|
using PnvPanel.Domain.Activation;
|
||||||
|
using PnvPanel.Domain.Audit;
|
||||||
|
|
||||||
namespace PnvPanel.Application.Admin.Activation;
|
namespace PnvPanel.Application.Admin.Activation;
|
||||||
|
|
||||||
public sealed class RejectActivationCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser)
|
public sealed class RejectActivationCommandHandler(
|
||||||
: ICommandHandler<RejectActivationCommand, Result>
|
IAppDbContext dbContext,
|
||||||
|
ICurrentUser currentUser
|
||||||
|
) : ICommandHandler<RejectActivationCommand, Result>
|
||||||
{
|
{
|
||||||
public async Task<Result> Handle(RejectActivationCommand command, CancellationToken cancellationToken)
|
public async Task<Result> Handle(
|
||||||
|
RejectActivationCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
if (currentUser.UserId is not { } adminId)
|
if (currentUser.UserId is not { } adminId)
|
||||||
return Result.Failure(AuthErrors.Unauthorized);
|
return Result.Failure(AuthErrors.Unauthorized);
|
||||||
|
|
||||||
var request = await dbContext.ActivationRequests
|
var request = await dbContext.ActivationRequests.FirstOrDefaultAsync(
|
||||||
.FirstOrDefaultAsync(r => r.Id == command.RequestId, cancellationToken);
|
r => r.Id == command.RequestId,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
if (request is null)
|
if (request is null)
|
||||||
return Result.Failure(ActivationErrors.NotFound);
|
return Result.Failure(ActivationErrors.NotFound);
|
||||||
@@ -26,6 +34,18 @@ public sealed class RejectActivationCommandHandler(IAppDbContext dbContext, ICur
|
|||||||
return Result.Failure(ActivationErrors.AlreadyDecided);
|
return Result.Failure(ActivationErrors.AlreadyDecided);
|
||||||
|
|
||||||
request.Reject(adminId, command.Reason);
|
request.Reject(adminId, command.Reason);
|
||||||
|
|
||||||
|
dbContext.AuditLogs.Add(
|
||||||
|
AuditLog.Create(
|
||||||
|
adminId,
|
||||||
|
"ActivationRejected",
|
||||||
|
"User",
|
||||||
|
request.UserId.ToString(),
|
||||||
|
metadata: null,
|
||||||
|
AuditSource.Web
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using PnvPanel.Domain.Apps;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Apps;
|
||||||
|
|
||||||
|
public sealed record AdminAppDto(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
string DownloadUrl,
|
||||||
|
OsPlatform OperatingSystem,
|
||||||
|
string? Description,
|
||||||
|
string? IconUrl,
|
||||||
|
int SortOrder,
|
||||||
|
bool IsEnabled,
|
||||||
|
bool IsRecommended
|
||||||
|
)
|
||||||
|
{
|
||||||
|
public static AdminAppDto FromDomain(ClientApp app) =>
|
||||||
|
new(
|
||||||
|
app.Id,
|
||||||
|
app.Name,
|
||||||
|
app.DownloadUrl.ToString(),
|
||||||
|
app.OperatingSystem,
|
||||||
|
app.Description,
|
||||||
|
app.IconUrl,
|
||||||
|
app.SortOrder,
|
||||||
|
app.IsEnabled,
|
||||||
|
app.IsRecommended
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Apps;
|
||||||
|
|
||||||
|
public static class AppErrors
|
||||||
|
{
|
||||||
|
public static readonly Error NotFound = Error.NotFound(
|
||||||
|
"Apps.NotFound",
|
||||||
|
"Приложение не найдено."
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Apps;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Apps;
|
||||||
|
|
||||||
|
public sealed record CreateAppCommand(
|
||||||
|
string Name,
|
||||||
|
string DownloadUrl,
|
||||||
|
OsPlatform OperatingSystem,
|
||||||
|
string? Description,
|
||||||
|
string? IconUrl,
|
||||||
|
int SortOrder,
|
||||||
|
bool IsRecommended
|
||||||
|
) : ICommand<Result<AdminAppDto>>;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Apps;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Apps;
|
||||||
|
|
||||||
|
public sealed class CreateAppCommandHandler(IAppDbContext dbContext)
|
||||||
|
: ICommandHandler<CreateAppCommand, Result<AdminAppDto>>
|
||||||
|
{
|
||||||
|
public Task<Result<AdminAppDto>> Handle(
|
||||||
|
CreateAppCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var app = ClientApp.Create(
|
||||||
|
command.Name,
|
||||||
|
new Uri(command.DownloadUrl, UriKind.Absolute),
|
||||||
|
command.OperatingSystem,
|
||||||
|
command.Description,
|
||||||
|
command.IconUrl,
|
||||||
|
command.SortOrder,
|
||||||
|
command.IsRecommended
|
||||||
|
);
|
||||||
|
|
||||||
|
dbContext.ClientApps.Add(app);
|
||||||
|
|
||||||
|
return Task.FromResult(Result.Success(AdminAppDto.FromDomain(app)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Apps;
|
||||||
|
|
||||||
|
public sealed class CreateAppCommandValidator : AbstractValidator<CreateAppCommand>
|
||||||
|
{
|
||||||
|
public CreateAppCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
|
||||||
|
RuleFor(x => x.DownloadUrl).NotEmpty().MaximumLength(500);
|
||||||
|
RuleFor(x => x.OperatingSystem).IsInEnum();
|
||||||
|
RuleFor(x => x.Description).MaximumLength(300);
|
||||||
|
RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Apps;
|
||||||
|
|
||||||
|
public sealed record DeleteAppCommand(Guid AppId) : ICommand<Result>;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Apps;
|
||||||
|
|
||||||
|
public sealed class DeleteAppCommandHandler(IAppDbContext dbContext)
|
||||||
|
: ICommandHandler<DeleteAppCommand, Result>
|
||||||
|
{
|
||||||
|
public async Task<Result> Handle(DeleteAppCommand command, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var app = await dbContext.ClientApps.FirstOrDefaultAsync(
|
||||||
|
a => a.Id == command.AppId,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (app is null)
|
||||||
|
return Result.Failure(AppErrors.NotFound);
|
||||||
|
|
||||||
|
dbContext.ClientApps.Remove(app);
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Apps;
|
||||||
|
|
||||||
|
public sealed record ListAdminAppsQuery : IQuery<Result<IReadOnlyList<AdminAppDto>>>;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Apps;
|
||||||
|
|
||||||
|
public sealed class ListAdminAppsQueryHandler(IAppDbContext dbContext)
|
||||||
|
: IQueryHandler<ListAdminAppsQuery, Result<IReadOnlyList<AdminAppDto>>>
|
||||||
|
{
|
||||||
|
public async Task<Result<IReadOnlyList<AdminAppDto>>> Handle(
|
||||||
|
ListAdminAppsQuery query,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var apps = await dbContext
|
||||||
|
.ClientApps.AsNoTracking()
|
||||||
|
.OrderBy(a => a.OperatingSystem)
|
||||||
|
.ThenBy(a => a.SortOrder)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Success<IReadOnlyList<AdminAppDto>>(
|
||||||
|
apps.Select(AdminAppDto.FromDomain).ToList()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Apps;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Apps;
|
||||||
|
|
||||||
|
public sealed record UpdateAppCommand(
|
||||||
|
Guid AppId,
|
||||||
|
string Name,
|
||||||
|
string DownloadUrl,
|
||||||
|
OsPlatform OperatingSystem,
|
||||||
|
string? Description,
|
||||||
|
string? IconUrl,
|
||||||
|
int SortOrder,
|
||||||
|
bool IsEnabled,
|
||||||
|
bool IsRecommended
|
||||||
|
) : ICommand<Result<AdminAppDto>>;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Apps;
|
||||||
|
|
||||||
|
public sealed class UpdateAppCommandHandler(IAppDbContext dbContext)
|
||||||
|
: ICommandHandler<UpdateAppCommand, Result<AdminAppDto>>
|
||||||
|
{
|
||||||
|
public async Task<Result<AdminAppDto>> Handle(
|
||||||
|
UpdateAppCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var app = await dbContext.ClientApps.FirstOrDefaultAsync(
|
||||||
|
a => a.Id == command.AppId,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (app is null)
|
||||||
|
return Result.Failure<AdminAppDto>(AppErrors.NotFound);
|
||||||
|
|
||||||
|
app.Update(
|
||||||
|
command.Name,
|
||||||
|
new Uri(command.DownloadUrl, UriKind.Absolute),
|
||||||
|
command.OperatingSystem,
|
||||||
|
command.Description,
|
||||||
|
command.IconUrl,
|
||||||
|
command.SortOrder,
|
||||||
|
command.IsEnabled,
|
||||||
|
command.IsRecommended
|
||||||
|
);
|
||||||
|
|
||||||
|
return Result.Success(AdminAppDto.FromDomain(app));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Apps;
|
||||||
|
|
||||||
|
public sealed class UpdateAppCommandValidator : AbstractValidator<UpdateAppCommand>
|
||||||
|
{
|
||||||
|
public UpdateAppCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
|
||||||
|
RuleFor(x => x.DownloadUrl).NotEmpty().MaximumLength(500);
|
||||||
|
RuleFor(x => x.OperatingSystem).IsInEnum();
|
||||||
|
RuleFor(x => x.Description).MaximumLength(300);
|
||||||
|
RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Audit;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Audit;
|
||||||
|
|
||||||
|
public sealed record ListAuditLogsQuery(
|
||||||
|
int Page,
|
||||||
|
int PageSize,
|
||||||
|
AuditSource? Source,
|
||||||
|
string? TargetType,
|
||||||
|
string? Action
|
||||||
|
) : IQuery<Result<PagedList<AuditLogDto>>>;
|
||||||
|
|
||||||
|
public sealed record AuditLogDto(
|
||||||
|
long Id,
|
||||||
|
Guid? ActorId,
|
||||||
|
string Action,
|
||||||
|
string TargetType,
|
||||||
|
string TargetId,
|
||||||
|
string? Metadata,
|
||||||
|
AuditSource Source,
|
||||||
|
DateTimeOffset CreatedAt
|
||||||
|
);
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Audit;
|
||||||
|
|
||||||
|
public sealed class ListAuditLogsQueryHandler(IAppDbContext dbContext)
|
||||||
|
: IQueryHandler<ListAuditLogsQuery, Result<PagedList<AuditLogDto>>>
|
||||||
|
{
|
||||||
|
public async Task<Result<PagedList<AuditLogDto>>> Handle(
|
||||||
|
ListAuditLogsQuery query,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var page = query.Page <= 0 ? 1 : query.Page;
|
||||||
|
var pageSize = query.PageSize is <= 0 or > 200 ? 50 : query.PageSize;
|
||||||
|
|
||||||
|
var logsQuery = dbContext.AuditLogs.AsNoTracking();
|
||||||
|
|
||||||
|
if (query.Source is { } source)
|
||||||
|
logsQuery = logsQuery.Where(a => a.Source == source);
|
||||||
|
if (!string.IsNullOrWhiteSpace(query.TargetType))
|
||||||
|
logsQuery = logsQuery.Where(a => a.TargetType == query.TargetType);
|
||||||
|
if (!string.IsNullOrWhiteSpace(query.Action))
|
||||||
|
logsQuery = logsQuery.Where(a => a.Action.Contains(query.Action));
|
||||||
|
|
||||||
|
var result = await logsQuery
|
||||||
|
.OrderByDescending(a => a.CreatedAt)
|
||||||
|
.Select(a => new AuditLogDto(
|
||||||
|
a.Id,
|
||||||
|
a.ActorId,
|
||||||
|
a.Action,
|
||||||
|
a.TargetType,
|
||||||
|
a.TargetId,
|
||||||
|
a.Metadata,
|
||||||
|
a.Source,
|
||||||
|
a.CreatedAt
|
||||||
|
))
|
||||||
|
.ToPagedListAsync(page, pageSize, cancellationToken);
|
||||||
|
|
||||||
|
return Result.Success(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Billing;
|
||||||
|
|
||||||
|
public sealed record AdminPaymentRequestDto(
|
||||||
|
Guid Id,
|
||||||
|
Guid UserId,
|
||||||
|
string UserName,
|
||||||
|
PaymentRequestKind Kind,
|
||||||
|
PaymentPeriod? Period,
|
||||||
|
int AmountSnapshot,
|
||||||
|
PaymentRequestStatus Status,
|
||||||
|
DateTimeOffset CreatedAt
|
||||||
|
);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Billing;
|
||||||
|
|
||||||
|
public sealed record BillingSettingsDto(
|
||||||
|
string RequisitesText,
|
||||||
|
int GraceDays,
|
||||||
|
bool DefaultBillingEnabledForNewRoles
|
||||||
|
)
|
||||||
|
{
|
||||||
|
public static BillingSettingsDto FromDomain(BillingSettings settings) =>
|
||||||
|
new(settings.RequisitesText, settings.GraceDays, settings.DefaultBillingEnabledForNewRoles);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Billing;
|
||||||
|
|
||||||
|
public sealed record ConfirmPaymentRequestCommand(Guid RequestId) : ICommand<Result>;
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using PnvPanel.Application.Auth;
|
||||||
|
using PnvPanel.Application.Billing;
|
||||||
|
using PnvPanel.Application.Common.Concurrency;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Audit;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Billing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Продлевает оплату (max(текущий PaidUntil, сейчас) + период), возвращает в Active конфиги,
|
||||||
|
/// приостановленные за неуплату (Expired), и синхронизирует ExpiresAt на все конфиги пользователя —
|
||||||
|
/// зеркало UnblockUserCommandHandler, но по статусу Expired (биллинг), а не Disabled (блокировка).
|
||||||
|
/// Проверка статуса + продление PaidUntil + Confirm() — под AdvisoryLock (по Id заявки): без неё
|
||||||
|
/// конфирм с сайта, гонящийся с конфирмом из Telegram по одной и той же заявке, могли бы оба пройти
|
||||||
|
/// проверку "ещё не решена" и оба продлить PaidUntil — двойное начисление за одну оплату.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ConfirmPaymentRequestCommandHandler(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
IIdentityService identityService,
|
||||||
|
IXuiPanelGateway gateway,
|
||||||
|
IRealtimeNotifier notifier,
|
||||||
|
ITelegramNotifier telegramNotifier,
|
||||||
|
ICurrentUser currentUser,
|
||||||
|
ILogger<ConfirmPaymentRequestCommandHandler> logger
|
||||||
|
) : ICommandHandler<ConfirmPaymentRequestCommand, Result>
|
||||||
|
{
|
||||||
|
public async Task<Result> Handle(
|
||||||
|
ConfirmPaymentRequestCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } adminId)
|
||||||
|
return Result.Failure(AuthErrors.Unauthorized);
|
||||||
|
|
||||||
|
var claimed = await AdvisoryLock.RunAsync(
|
||||||
|
dbContext,
|
||||||
|
command.RequestId,
|
||||||
|
lockedCancellationToken => ClaimAndConfirmAsync(command.RequestId, adminId, lockedCancellationToken),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (!claimed.IsSuccess)
|
||||||
|
return Result.Failure(claimed.Error);
|
||||||
|
|
||||||
|
var (request, newPaidUntil) = claimed.Value;
|
||||||
|
|
||||||
|
// PlanChangeTopUp — доплата разницы в цене при увеличении тарифа, не покупка времени: подтверждение
|
||||||
|
// не возвращает Expired-конфиги (это делает обычная Subscription-оплата/продление).
|
||||||
|
if (request.Kind == PaymentRequestKind.Subscription)
|
||||||
|
{
|
||||||
|
await BillingConfigResumer.ResumeConfigsAsync(
|
||||||
|
dbContext,
|
||||||
|
gateway,
|
||||||
|
notifier,
|
||||||
|
logger,
|
||||||
|
request.UserId,
|
||||||
|
newPaidUntil!.Value,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
dbContext.AuditLogs.Add(
|
||||||
|
AuditLog.Create(
|
||||||
|
adminId,
|
||||||
|
"PaymentConfirmed",
|
||||||
|
"PaymentRequest",
|
||||||
|
request.Id.ToString(),
|
||||||
|
metadata: null,
|
||||||
|
AuditSource.Web
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
var message =
|
||||||
|
request.Kind == PaymentRequestKind.PlanChangeTopUp
|
||||||
|
? "✅ Доплата за смену тарифа подтверждена."
|
||||||
|
: $"✅ Оплата подтверждена. Доступ продлён до {newPaidUntil:dd.MM.yyyy}.";
|
||||||
|
await telegramNotifier.NotifyUserAsync(request.UserId, message, null, cancellationToken);
|
||||||
|
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Критическая секция под локом: проверка статуса, продление PaidUntil (для Subscription)
|
||||||
|
/// и сам Confirm() — всё атомарно вместе, чтобы гонка не могла продлить PaidUntil дважды. Внешний
|
||||||
|
/// I/O (гейтвей, Telegram) сюда намеренно не входит — см. AdvisoryLock.</summary>
|
||||||
|
private async Task<Result<(PaymentRequest Request, DateTimeOffset? NewPaidUntil)>> ClaimAndConfirmAsync(
|
||||||
|
Guid requestId,
|
||||||
|
Guid adminId,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var request = await dbContext.PaymentRequests.FirstOrDefaultAsync(
|
||||||
|
r => r.Id == requestId,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (request is null)
|
||||||
|
return Result.Failure<(PaymentRequest, DateTimeOffset?)>(BillingErrors.RequestNotFound);
|
||||||
|
|
||||||
|
if (
|
||||||
|
request.Status
|
||||||
|
is not (PaymentRequestStatus.AwaitingPayment or PaymentRequestStatus.AwaitingConfirmation)
|
||||||
|
)
|
||||||
|
return Result.Failure<(PaymentRequest, DateTimeOffset?)>(BillingErrors.RequestNotDecidable);
|
||||||
|
|
||||||
|
if (request.Kind == PaymentRequestKind.PlanChangeTopUp)
|
||||||
|
{
|
||||||
|
request.Confirm(adminId);
|
||||||
|
return Result.Success<(PaymentRequest, DateTimeOffset?)>((request, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
var profile = await identityService.GetProfileAsync(request.UserId, cancellationToken);
|
||||||
|
if (profile is null)
|
||||||
|
return Result.Failure<(PaymentRequest, DateTimeOffset?)>(AuthErrors.Unauthorized);
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var baseline = profile.BillingPaidUntil is { } paidUntil && paidUntil > now ? paidUntil : now;
|
||||||
|
var newPaidUntil = baseline.AddMonths(request.Period!.Value.ToMonths());
|
||||||
|
|
||||||
|
var extendResult = await identityService.ExtendBillingPaidUntilAsync(
|
||||||
|
request.UserId,
|
||||||
|
newPaidUntil,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (!extendResult.IsSuccess)
|
||||||
|
return Result.Failure<(PaymentRequest, DateTimeOffset?)>(extendResult.Error);
|
||||||
|
|
||||||
|
request.Confirm(adminId);
|
||||||
|
return Result.Success<(PaymentRequest, DateTimeOffset?)>((request, newPaidUntil));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Billing;
|
||||||
|
|
||||||
|
public sealed record GetBillingSettingsQuery : IQuery<Result<BillingSettingsDto>>;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Billing;
|
||||||
|
|
||||||
|
public sealed class GetBillingSettingsQueryHandler(IAppDbContext dbContext)
|
||||||
|
: IQueryHandler<GetBillingSettingsQuery, Result<BillingSettingsDto>>
|
||||||
|
{
|
||||||
|
public async Task<Result<BillingSettingsDto>> Handle(
|
||||||
|
GetBillingSettingsQuery query,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var settings = await dbContext
|
||||||
|
.BillingSettings.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Ещё не сохранялось ни разу — отдаём дефолты, а не ошибку (см. PricingSettings).
|
||||||
|
return Result.Success(
|
||||||
|
settings is null
|
||||||
|
? new BillingSettingsDto(string.Empty, BillingSettings.DefaultGraceDays, false)
|
||||||
|
: BillingSettingsDto.FromDomain(settings)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Billing;
|
||||||
|
|
||||||
|
/// <summary>Админ дарит пользователю N дней подписки — продлевает BillingPaidUntil от
|
||||||
|
/// max(текущий, сейчас), возвращает приостановленные конфиги, шлёт уведомление пользователю.</summary>
|
||||||
|
public sealed record GrantBillingGiftCommand(Guid UserId, int Days) : ICommand<Result>;
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using PnvPanel.Application.Admin.Users;
|
||||||
|
using PnvPanel.Application.Auth;
|
||||||
|
using PnvPanel.Application.Billing;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Audit;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Billing;
|
||||||
|
|
||||||
|
public sealed class GrantBillingGiftCommandHandler(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
IIdentityService identityService,
|
||||||
|
IXuiPanelGateway gateway,
|
||||||
|
IRealtimeNotifier notifier,
|
||||||
|
ITelegramNotifier telegramNotifier,
|
||||||
|
ICurrentUser currentUser,
|
||||||
|
ILogger<GrantBillingGiftCommandHandler> logger
|
||||||
|
) : ICommandHandler<GrantBillingGiftCommand, Result>
|
||||||
|
{
|
||||||
|
public async Task<Result> Handle(GrantBillingGiftCommand command, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } adminId)
|
||||||
|
return Result.Failure(AuthErrors.Unauthorized);
|
||||||
|
|
||||||
|
var profile = await identityService.GetProfileAsync(command.UserId, cancellationToken);
|
||||||
|
if (profile is null)
|
||||||
|
return Result.Failure(UserErrors.NotFound);
|
||||||
|
|
||||||
|
if (!profile.BillingEnabled)
|
||||||
|
return Result.Failure(BillingErrors.NotEnabled);
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var baseline = profile.BillingPaidUntil is { } paidUntil && paidUntil > now ? paidUntil : now;
|
||||||
|
var newPaidUntil = baseline.AddDays(command.Days);
|
||||||
|
|
||||||
|
var extendResult = await identityService.ExtendBillingPaidUntilAsync(
|
||||||
|
command.UserId,
|
||||||
|
newPaidUntil,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (!extendResult.IsSuccess)
|
||||||
|
return extendResult;
|
||||||
|
|
||||||
|
await BillingConfigResumer.ResumeConfigsAsync(
|
||||||
|
dbContext,
|
||||||
|
gateway,
|
||||||
|
notifier,
|
||||||
|
logger,
|
||||||
|
command.UserId,
|
||||||
|
newPaidUntil,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
dbContext.AuditLogs.Add(
|
||||||
|
AuditLog.Create(
|
||||||
|
adminId,
|
||||||
|
"BillingGiftGranted",
|
||||||
|
"User",
|
||||||
|
command.UserId.ToString(),
|
||||||
|
metadata: $"{{\"days\":{command.Days}}}",
|
||||||
|
AuditSource.Web
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await telegramNotifier.NotifyUserAsync(
|
||||||
|
command.UserId,
|
||||||
|
$"🎁 Вам подарено {command.Days} дн. подписки! Доступ продлён до {newPaidUntil:dd.MM.yyyy}.",
|
||||||
|
"/billing",
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Billing;
|
||||||
|
|
||||||
|
public sealed class GrantBillingGiftCommandValidator : AbstractValidator<GrantBillingGiftCommand>
|
||||||
|
{
|
||||||
|
public GrantBillingGiftCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Days).GreaterThan(0).LessThanOrEqualTo(365);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Billing;
|
||||||
|
|
||||||
|
public sealed record ListPaymentRequestsQuery(
|
||||||
|
PaymentRequestStatus? StatusFilter,
|
||||||
|
PaymentRequestKind? KindFilter,
|
||||||
|
string? Search,
|
||||||
|
int Page,
|
||||||
|
int PageSize
|
||||||
|
) : IQuery<Result<PagedList<AdminPaymentRequestDto>>>;
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Billing;
|
||||||
|
|
||||||
|
public sealed class ListPaymentRequestsQueryHandler(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
IIdentityService identityService
|
||||||
|
) : IQueryHandler<ListPaymentRequestsQuery, Result<PagedList<AdminPaymentRequestDto>>>
|
||||||
|
{
|
||||||
|
public async Task<Result<PagedList<AdminPaymentRequestDto>>> Handle(
|
||||||
|
ListPaymentRequestsQuery query,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var page = query.Page <= 0 ? 1 : query.Page;
|
||||||
|
var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize;
|
||||||
|
|
||||||
|
var requestsQuery = dbContext.PaymentRequests.AsNoTracking();
|
||||||
|
if (query.StatusFilter is { } status)
|
||||||
|
requestsQuery = requestsQuery.Where(r => r.Status == status);
|
||||||
|
if (query.KindFilter is { } kind)
|
||||||
|
requestsQuery = requestsQuery.Where(r => r.Kind == kind);
|
||||||
|
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||||
|
{
|
||||||
|
// PaymentRequest хранит только UserId — резолвим совпадающих пользователей ДО пагинации
|
||||||
|
// (иначе "поиск по имени" фильтровал бы уже отобранную страницу, а не весь набор).
|
||||||
|
var matchingUserIds = await identityService.FindUserIdsByUserNameAsync(
|
||||||
|
query.Search.Trim(),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
requestsQuery = requestsQuery.Where(r => matchingUserIds.Contains(r.UserId));
|
||||||
|
}
|
||||||
|
|
||||||
|
var page1 = await requestsQuery
|
||||||
|
.OrderByDescending(r => r.CreatedAt)
|
||||||
|
.ToPagedListAsync(page, pageSize, cancellationToken);
|
||||||
|
|
||||||
|
var userNames = await identityService.GetUserNamesAsync(
|
||||||
|
page1.Items.Select(r => r.UserId).Distinct().ToList(),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
var items = page1
|
||||||
|
.Items.Select(r => new AdminPaymentRequestDto(
|
||||||
|
r.Id,
|
||||||
|
r.UserId,
|
||||||
|
userNames.GetValueOrDefault(r.UserId, "?"),
|
||||||
|
r.Kind,
|
||||||
|
r.Period,
|
||||||
|
r.AmountSnapshot,
|
||||||
|
r.Status,
|
||||||
|
r.CreatedAt
|
||||||
|
))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Result.Success(
|
||||||
|
new PagedList<AdminPaymentRequestDto>(items, page1.Total, page1.Page, page1.PageSize)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Billing;
|
||||||
|
|
||||||
|
public sealed record RejectPaymentRequestCommand(Guid RequestId, string? Reason) : ICommand<Result>;
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using PnvPanel.Application.Auth;
|
||||||
|
using PnvPanel.Application.Billing;
|
||||||
|
using PnvPanel.Application.Common.Concurrency;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Audit;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Billing;
|
||||||
|
|
||||||
|
/// <summary>Проверка статуса + Reject() — под AdvisoryLock (по Id заявки), тот же приём, что и в
|
||||||
|
/// ConfirmPaymentRequestCommandHandler — без него отклонение с сайта, гонящееся с отклонением из
|
||||||
|
/// Telegram, могли бы оба пройти проверку "ещё не решена".</summary>
|
||||||
|
public sealed class RejectPaymentRequestCommandHandler(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
IIdentityService identityService,
|
||||||
|
IXuiPanelGateway gateway,
|
||||||
|
IRealtimeNotifier notifier,
|
||||||
|
ITelegramNotifier telegramNotifier,
|
||||||
|
ICurrentUser currentUser,
|
||||||
|
ILogger<RejectPaymentRequestCommandHandler> logger
|
||||||
|
) : ICommandHandler<RejectPaymentRequestCommand, Result>
|
||||||
|
{
|
||||||
|
public async Task<Result> Handle(
|
||||||
|
RejectPaymentRequestCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } adminId)
|
||||||
|
return Result.Failure(AuthErrors.Unauthorized);
|
||||||
|
|
||||||
|
var claimed = await AdvisoryLock.RunAsync(
|
||||||
|
dbContext,
|
||||||
|
command.RequestId,
|
||||||
|
async lockedCancellationToken =>
|
||||||
|
{
|
||||||
|
var fresh = await dbContext.PaymentRequests.FirstOrDefaultAsync(
|
||||||
|
r => r.Id == command.RequestId,
|
||||||
|
lockedCancellationToken
|
||||||
|
);
|
||||||
|
if (fresh is null)
|
||||||
|
return Result.Failure<PaymentRequest>(BillingErrors.RequestNotFound);
|
||||||
|
|
||||||
|
if (
|
||||||
|
fresh.Status
|
||||||
|
is not (PaymentRequestStatus.AwaitingPayment or PaymentRequestStatus.AwaitingConfirmation)
|
||||||
|
)
|
||||||
|
return Result.Failure<PaymentRequest>(BillingErrors.RequestNotDecidable);
|
||||||
|
|
||||||
|
fresh.Reject(adminId, command.Reason);
|
||||||
|
return Result.Success(fresh);
|
||||||
|
},
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (!claimed.IsSuccess)
|
||||||
|
return Result.Failure(claimed.Error);
|
||||||
|
|
||||||
|
var request = claimed.Value;
|
||||||
|
|
||||||
|
// Пока заявка висела на проверке, конфиги могли быть временно "защищены" на панели
|
||||||
|
// (ProtectPendingConfigsAsync — enable/expiresAt подвинуты вперёд без изменения локального
|
||||||
|
// статуса). Раз оплату отклонили и период всё ещё просрочен, а других Subscription-заявок на
|
||||||
|
// проверке нет — снимаем защиту немедленно, не дожидаясь часового тика BillingService.
|
||||||
|
if (request.Kind == PaymentRequestKind.Subscription)
|
||||||
|
await SuspendIfStillUnpaidAsync(request.Id, request.UserId, cancellationToken);
|
||||||
|
|
||||||
|
dbContext.AuditLogs.Add(
|
||||||
|
AuditLog.Create(
|
||||||
|
adminId,
|
||||||
|
"PaymentRejected",
|
||||||
|
"PaymentRequest",
|
||||||
|
request.Id.ToString(),
|
||||||
|
metadata: null,
|
||||||
|
AuditSource.Web
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
var reasonSuffix = string.IsNullOrWhiteSpace(command.Reason)
|
||||||
|
? string.Empty
|
||||||
|
: $"\nПричина: {command.Reason}";
|
||||||
|
await telegramNotifier.NotifyUserAsync(
|
||||||
|
request.UserId,
|
||||||
|
$"❌ Заявка на оплату отклонена.{reasonSuffix}",
|
||||||
|
null,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SuspendIfStillUnpaidAsync(
|
||||||
|
Guid rejectedRequestId,
|
||||||
|
Guid userId,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
|
||||||
|
if (profile is not { BillingEnabled: true })
|
||||||
|
return;
|
||||||
|
if (profile.BillingPaidUntil is { } paidUntil && paidUntil > DateTimeOffset.UtcNow)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Саму отклоняемую заявку исключаем явно: request.Reject(...) уже поменял её статус на
|
||||||
|
// Rejected в трекере EF, но до SaveChangesAsync (в конце пайплайна) в БД всё ещё лежит старое
|
||||||
|
// значение AwaitingConfirmation — без Id-исключения запрос ниже ложно принял бы её за "ещё
|
||||||
|
// одну" висящую заявку и никогда бы не приостанавливал конфиги.
|
||||||
|
var hasOtherPending = await dbContext.PaymentRequests.AnyAsync(
|
||||||
|
r =>
|
||||||
|
r.Id != rejectedRequestId
|
||||||
|
&& r.UserId == userId
|
||||||
|
&& r.Kind == PaymentRequestKind.Subscription
|
||||||
|
&& r.Status == PaymentRequestStatus.AwaitingConfirmation,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (hasOtherPending)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await BillingConfigResumer.SuspendConfigsAsync(
|
||||||
|
dbContext,
|
||||||
|
gateway,
|
||||||
|
notifier,
|
||||||
|
logger,
|
||||||
|
userId,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Billing;
|
||||||
|
|
||||||
|
public sealed record UpdateBillingSettingsCommand(
|
||||||
|
string RequisitesText,
|
||||||
|
int GraceDays,
|
||||||
|
bool DefaultBillingEnabledForNewRoles
|
||||||
|
) : ICommand<Result<BillingSettingsDto>>;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Billing;
|
||||||
|
|
||||||
|
public sealed class UpdateBillingSettingsCommandHandler(IAppDbContext dbContext)
|
||||||
|
: ICommandHandler<UpdateBillingSettingsCommand, Result<BillingSettingsDto>>
|
||||||
|
{
|
||||||
|
public async Task<Result<BillingSettingsDto>> Handle(
|
||||||
|
UpdateBillingSettingsCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var settings = await dbContext.BillingSettings.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (settings is null)
|
||||||
|
{
|
||||||
|
settings = BillingSettings.CreateDefault();
|
||||||
|
dbContext.BillingSettings.Add(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.Update(
|
||||||
|
command.RequisitesText,
|
||||||
|
command.GraceDays,
|
||||||
|
command.DefaultBillingEnabledForNewRoles
|
||||||
|
);
|
||||||
|
|
||||||
|
return Result.Success(BillingSettingsDto.FromDomain(settings));
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Billing;
|
||||||
|
|
||||||
|
public sealed class UpdateBillingSettingsCommandValidator
|
||||||
|
: AbstractValidator<UpdateBillingSettingsCommand>
|
||||||
|
{
|
||||||
|
public UpdateBillingSettingsCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.RequisitesText).NotEmpty().MaximumLength(4000);
|
||||||
|
RuleFor(x => x.GraceDays).GreaterThanOrEqualTo(0).LessThanOrEqualTo(365);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using PnvPanel.Domain.Configs;
|
||||||
|
using PnvPanel.Domain.Inbounds;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Configs;
|
||||||
|
|
||||||
|
/// <summary>Строка глобального списка конфигов для админа — в отличие от VpnConfigDto (self-service)
|
||||||
|
/// содержит владельца и ноду, т.к. список не скоупится одним пользователем.</summary>
|
||||||
|
public sealed record AdminVpnConfigDto(
|
||||||
|
Guid Id,
|
||||||
|
Guid UserId,
|
||||||
|
string UserName,
|
||||||
|
string? Label,
|
||||||
|
string ClientEmail,
|
||||||
|
VpnProtocol Protocol,
|
||||||
|
string Location,
|
||||||
|
string NodeName,
|
||||||
|
long UsedUpBytes,
|
||||||
|
long UsedDownBytes,
|
||||||
|
DateTimeOffset? ExpiresAt,
|
||||||
|
ConfigStatus Status,
|
||||||
|
DateTimeOffset CreatedAt
|
||||||
|
);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Configs;
|
||||||
|
using PnvPanel.Domain.Inbounds;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Configs;
|
||||||
|
|
||||||
|
/// <summary><paramref name="Search"/> матчится по ClientEmail/Label — это то, по чему админ сверяет
|
||||||
|
/// конфиг с записью в 3x-ui, а не по владельцу (для поиска по пользователю есть /admin/users).
|
||||||
|
/// <paramref name="NodeId"/> фильтрует по ноде инбаунда конфига (JOIN Inbounds).</summary>
|
||||||
|
public sealed record ListAllConfigsQuery(
|
||||||
|
int Page,
|
||||||
|
int PageSize,
|
||||||
|
string? Search,
|
||||||
|
ConfigStatus? Status,
|
||||||
|
VpnProtocol? Protocol,
|
||||||
|
Guid? NodeId
|
||||||
|
) : IQuery<Result<PagedList<AdminVpnConfigDto>>>;
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Configs;
|
||||||
|
|
||||||
|
public sealed class ListAllConfigsQueryHandler(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
IIdentityService identityService
|
||||||
|
) : IQueryHandler<ListAllConfigsQuery, Result<PagedList<AdminVpnConfigDto>>>
|
||||||
|
{
|
||||||
|
public async Task<Result<PagedList<AdminVpnConfigDto>>> Handle(
|
||||||
|
ListAllConfigsQuery query,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var page = query.Page <= 0 ? 1 : query.Page;
|
||||||
|
var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize;
|
||||||
|
|
||||||
|
var configsQuery = dbContext.VpnConfigs.AsNoTracking();
|
||||||
|
|
||||||
|
if (query.Status is { } status)
|
||||||
|
configsQuery = configsQuery.Where(c => c.Status == status);
|
||||||
|
|
||||||
|
if (query.Protocol is { } protocol)
|
||||||
|
configsQuery = configsQuery.Where(c => c.Protocol == protocol);
|
||||||
|
|
||||||
|
if (query.NodeId is { } nodeId)
|
||||||
|
{
|
||||||
|
var nodeInboundIds = await dbContext
|
||||||
|
.Inbounds.AsNoTracking()
|
||||||
|
.Where(i => i.NodeId == nodeId)
|
||||||
|
.Select(i => i.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
configsQuery = configsQuery.Where(c => nodeInboundIds.Contains(c.InboundId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||||
|
{
|
||||||
|
var search = query.Search.Trim();
|
||||||
|
configsQuery = configsQuery.Where(c =>
|
||||||
|
c.ClientEmail.Contains(search) || (c.Label != null && c.Label.Contains(search))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var pageResult = await configsQuery
|
||||||
|
.OrderByDescending(c => c.CreatedAt)
|
||||||
|
.ToPagedListAsync(page, pageSize, cancellationToken);
|
||||||
|
|
||||||
|
var inboundIds = pageResult.Items.Select(c => c.InboundId).Distinct().ToList();
|
||||||
|
var inbounds = (
|
||||||
|
await dbContext
|
||||||
|
.Inbounds.AsNoTracking()
|
||||||
|
.Where(i => inboundIds.Contains(i.Id))
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
).ToDictionary(i => i.Id);
|
||||||
|
|
||||||
|
var nodeIds = inbounds.Values.Select(i => i.NodeId).Distinct().ToList();
|
||||||
|
var nodes = (
|
||||||
|
await dbContext
|
||||||
|
.Nodes.AsNoTracking()
|
||||||
|
.Where(n => nodeIds.Contains(n.Id))
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
).ToDictionary(n => n.Id);
|
||||||
|
|
||||||
|
var userNames = await identityService.GetUserNamesAsync(
|
||||||
|
pageResult.Items.Select(c => c.UserId).Distinct().ToList(),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
var items = pageResult
|
||||||
|
.Items.Select(c =>
|
||||||
|
{
|
||||||
|
var inbound = inbounds.GetValueOrDefault(c.InboundId);
|
||||||
|
var node = inbound is null ? null : nodes.GetValueOrDefault(inbound.NodeId);
|
||||||
|
return new AdminVpnConfigDto(
|
||||||
|
c.Id,
|
||||||
|
c.UserId,
|
||||||
|
userNames.GetValueOrDefault(c.UserId, "?"),
|
||||||
|
c.Label,
|
||||||
|
c.ClientEmail,
|
||||||
|
c.Protocol,
|
||||||
|
inbound?.DisplayName ?? inbound?.Remark ?? "?",
|
||||||
|
node?.Name ?? "?",
|
||||||
|
c.UsedUpBytes,
|
||||||
|
c.UsedDownBytes,
|
||||||
|
c.ExpiresAt,
|
||||||
|
c.Status,
|
||||||
|
c.CreatedAt
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Result.Success(
|
||||||
|
new PagedList<AdminVpnConfigDto>(
|
||||||
|
items,
|
||||||
|
pageResult.Total,
|
||||||
|
pageResult.Page,
|
||||||
|
pageResult.PageSize
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Inbounds;
|
||||||
|
|
||||||
|
/// <summary>Force-удаление недоступного на панели инбаунда вместе с каскадным отзывом ещё живых
|
||||||
|
/// конфигов на нём — см. DeleteInboundCommandHandler.</summary>
|
||||||
|
public sealed record DeleteInboundCommand(Guid InboundId) : ICommand<Result>;
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using LiteCqrs;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Audit;
|
||||||
|
using PnvPanel.Domain.Configs;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Inbounds;
|
||||||
|
|
||||||
|
/// <summary>Удаляет инбаунд, которого больше нет на панели (см. SyncNodeCommandHandler —
|
||||||
|
/// автоматически он не удаляется, пока по нему есть VpnConfig, чтобы не потерять ссылку на историю).
|
||||||
|
/// Разрешено только для `IsAvailable == false` — живой, всё ещё синхронизируемый инбаунд через этот
|
||||||
|
/// путь не удалить. Перед удалением каскадно отзывает все ещё не-Revoked конфиги на нём: панельный
|
||||||
|
/// клиент всё равно недостижим (инбаунда для него на 3x-ui уже нет), поэтому Revoke — чисто локальная
|
||||||
|
/// операция, без вызова гейтвея (см. RevokeVpnConfigCommandHandler для того же паттерна). Уже
|
||||||
|
/// Revoked-конфиги при этом остаются в БД с InboundId, указывающим на удалённую запись — это
|
||||||
|
/// осознанный компромисс (см. domain-model.md): нигде в пользовательских списках Revoked-конфиги не
|
||||||
|
/// показываются, а в админском списке отсутствующий инбаунд отображается как "?".</summary>
|
||||||
|
public sealed class DeleteInboundCommandHandler(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
IRealtimeNotifier notifier,
|
||||||
|
ICurrentUser currentUser
|
||||||
|
) : ICommandHandler<DeleteInboundCommand, Result>
|
||||||
|
{
|
||||||
|
public async Task<Result> Handle(DeleteInboundCommand command, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var inbound = await dbContext.Inbounds.FirstOrDefaultAsync(
|
||||||
|
i => i.Id == command.InboundId,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (inbound is null)
|
||||||
|
return Result.Failure(InboundErrors.NotFound);
|
||||||
|
|
||||||
|
if (inbound.IsAvailable)
|
||||||
|
return Result.Failure(InboundErrors.StillAvailable);
|
||||||
|
|
||||||
|
var configs = await dbContext
|
||||||
|
.VpnConfigs.Where(c => c.InboundId == inbound.Id && c.Status != ConfigStatus.Revoked)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var config in configs)
|
||||||
|
{
|
||||||
|
config.Revoke();
|
||||||
|
await notifier.NotifyConfigStatusChangedAsync(
|
||||||
|
config.UserId,
|
||||||
|
config.Id,
|
||||||
|
config.Status,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
dbContext.Inbounds.Remove(inbound);
|
||||||
|
|
||||||
|
dbContext.AuditLogs.Add(
|
||||||
|
AuditLog.Create(
|
||||||
|
currentUser.UserId,
|
||||||
|
"InboundDeleted",
|
||||||
|
"Inbound",
|
||||||
|
inbound.Id.ToString(),
|
||||||
|
metadata: configs.Count > 0 ? $"{{\"revokedConfigs\":{configs.Count}}}" : null,
|
||||||
|
AuditSource.Web
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,31 @@ using PnvPanel.Domain.Inbounds;
|
|||||||
namespace PnvPanel.Application.Admin.Inbounds;
|
namespace PnvPanel.Application.Admin.Inbounds;
|
||||||
|
|
||||||
public sealed record InboundDto(
|
public sealed record InboundDto(
|
||||||
Guid Id, Guid NodeId, string RemoteInboundId, VpnProtocol Protocol, string Remark, int Port,
|
Guid Id,
|
||||||
bool IsPublished, string? DisplayName, int? MaxClients, IReadOnlyList<Guid> AllowedRoleIds,
|
Guid NodeId,
|
||||||
DateTimeOffset? LastSyncAt)
|
string RemoteInboundId,
|
||||||
|
VpnProtocol Protocol,
|
||||||
|
string Remark,
|
||||||
|
int Port,
|
||||||
|
bool IsPublished,
|
||||||
|
bool IsAvailable,
|
||||||
|
string? DisplayName,
|
||||||
|
IReadOnlyList<Guid> AllowedRoleIds,
|
||||||
|
DateTimeOffset? LastSyncAt
|
||||||
|
)
|
||||||
{
|
{
|
||||||
public static InboundDto FromDomain(Inbound inbound) => new(
|
public static InboundDto FromDomain(Inbound inbound) =>
|
||||||
inbound.Id, inbound.NodeId, inbound.RemoteInboundId, inbound.Protocol, inbound.Remark, inbound.Port,
|
new(
|
||||||
inbound.IsPublished, inbound.DisplayName, inbound.MaxClients, inbound.AllowedRoleIds, inbound.LastSyncAt);
|
inbound.Id,
|
||||||
|
inbound.NodeId,
|
||||||
|
inbound.RemoteInboundId,
|
||||||
|
inbound.Protocol,
|
||||||
|
inbound.Remark,
|
||||||
|
inbound.Port,
|
||||||
|
inbound.IsPublished,
|
||||||
|
inbound.IsAvailable,
|
||||||
|
inbound.DisplayName,
|
||||||
|
inbound.AllowedRoleIds,
|
||||||
|
inbound.LastSyncAt
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,13 @@ namespace PnvPanel.Application.Admin.Inbounds;
|
|||||||
|
|
||||||
public static class InboundErrors
|
public static class InboundErrors
|
||||||
{
|
{
|
||||||
public static readonly Error NotFound = Error.NotFound("Inbounds.NotFound", "Inbound не найден.");
|
public static readonly Error NotFound = Error.NotFound(
|
||||||
|
"Inbounds.NotFound",
|
||||||
|
"Inbound не найден."
|
||||||
|
);
|
||||||
|
|
||||||
|
public static readonly Error StillAvailable = Error.Validation(
|
||||||
|
"Inbounds.StillAvailable",
|
||||||
|
"Inbound всё ещё существует на панели — удалить можно только недоступные (IsAvailable=false)."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using PnvPanel.Application.Common.Messaging;
|
using LiteCqrs;
|
||||||
using PnvPanel.Application.Common.Models;
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
namespace PnvPanel.Application.Admin.Inbounds;
|
namespace PnvPanel.Application.Admin.Inbounds;
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using PnvPanel.Application.Common.Interfaces;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
using PnvPanel.Application.Common.Messaging;
|
using LiteCqrs;
|
||||||
using PnvPanel.Application.Common.Models;
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
namespace PnvPanel.Application.Admin.Inbounds;
|
namespace PnvPanel.Application.Admin.Inbounds;
|
||||||
|
|
||||||
public sealed class ListInboundsQueryHandler(IAppDbContext dbContext) : IQueryHandler<ListInboundsQuery, Result<IReadOnlyList<InboundDto>>>
|
public sealed class ListInboundsQueryHandler(IAppDbContext dbContext)
|
||||||
|
: IQueryHandler<ListInboundsQuery, Result<IReadOnlyList<InboundDto>>>
|
||||||
{
|
{
|
||||||
public async Task<Result<IReadOnlyList<InboundDto>>> Handle(ListInboundsQuery query, CancellationToken cancellationToken)
|
public async Task<Result<IReadOnlyList<InboundDto>>> Handle(
|
||||||
|
ListInboundsQuery query,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var inboundsQuery = dbContext.Inbounds.AsNoTracking();
|
var inboundsQuery = dbContext.Inbounds.AsNoTracking();
|
||||||
if (query.NodeId is { } nodeId)
|
if (query.NodeId is { } nodeId)
|
||||||
inboundsQuery = inboundsQuery.Where(i => i.NodeId == nodeId);
|
inboundsQuery = inboundsQuery.Where(i => i.NodeId == nodeId);
|
||||||
|
|
||||||
var inbounds = await inboundsQuery.OrderBy(i => i.Remark).ToListAsync(cancellationToken);
|
var inbounds = await inboundsQuery.OrderBy(i => i.Remark).ToListAsync(cancellationToken);
|
||||||
return Result.Success<IReadOnlyList<InboundDto>>(inbounds.Select(InboundDto.FromDomain).ToList());
|
return Result.Success<IReadOnlyList<InboundDto>>(
|
||||||
|
inbounds.Select(InboundDto.FromDomain).ToList()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
using PnvPanel.Application.Common.Messaging;
|
using LiteCqrs;
|
||||||
using PnvPanel.Application.Common.Models;
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
namespace PnvPanel.Application.Admin.Inbounds;
|
namespace PnvPanel.Application.Admin.Inbounds;
|
||||||
|
|
||||||
public sealed record PublishInboundCommand(
|
public sealed record PublishInboundCommand(
|
||||||
Guid InboundId, bool IsPublished, string? DisplayName, IReadOnlyList<Guid> AllowedRoleIds, int? MaxClients)
|
Guid InboundId,
|
||||||
: ICommand<Result<InboundDto>>;
|
bool IsPublished,
|
||||||
|
string? DisplayName,
|
||||||
|
IReadOnlyList<Guid> AllowedRoleIds
|
||||||
|
) : ICommand<Result<InboundDto>>;
|
||||||
|
|||||||
@@ -1,24 +1,42 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using PnvPanel.Application.Common.Interfaces;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
using PnvPanel.Application.Common.Messaging;
|
using LiteCqrs;
|
||||||
using PnvPanel.Application.Common.Models;
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Audit;
|
||||||
|
|
||||||
namespace PnvPanel.Application.Admin.Inbounds;
|
namespace PnvPanel.Application.Admin.Inbounds;
|
||||||
|
|
||||||
public sealed class PublishInboundCommandHandler(IAppDbContext dbContext)
|
public sealed class PublishInboundCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser)
|
||||||
: ICommandHandler<PublishInboundCommand, Result<InboundDto>>
|
: ICommandHandler<PublishInboundCommand, Result<InboundDto>>
|
||||||
{
|
{
|
||||||
public async Task<Result<InboundDto>> Handle(PublishInboundCommand command, CancellationToken cancellationToken)
|
public async Task<Result<InboundDto>> Handle(
|
||||||
|
PublishInboundCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var inbound = await dbContext.Inbounds.FirstOrDefaultAsync(i => i.Id == command.InboundId, cancellationToken);
|
var inbound = await dbContext.Inbounds.FirstOrDefaultAsync(
|
||||||
|
i => i.Id == command.InboundId,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
if (inbound is null)
|
if (inbound is null)
|
||||||
return Result.Failure<InboundDto>(InboundErrors.NotFound);
|
return Result.Failure<InboundDto>(InboundErrors.NotFound);
|
||||||
|
|
||||||
if (command.IsPublished)
|
if (command.IsPublished)
|
||||||
inbound.Publish(command.DisplayName, command.AllowedRoleIds, command.MaxClients);
|
inbound.Publish(command.DisplayName, command.AllowedRoleIds);
|
||||||
else
|
else
|
||||||
inbound.Unpublish();
|
inbound.Unpublish();
|
||||||
|
|
||||||
|
dbContext.AuditLogs.Add(
|
||||||
|
AuditLog.Create(
|
||||||
|
currentUser.UserId,
|
||||||
|
command.IsPublished ? "InboundPublished" : "InboundUnpublished",
|
||||||
|
"Inbound",
|
||||||
|
inbound.Id.ToString(),
|
||||||
|
metadata: null,
|
||||||
|
AuditSource.Web
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
return Result.Success(InboundDto.FromDomain(inbound));
|
return Result.Success(InboundDto.FromDomain(inbound));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,5 @@ public sealed class PublishInboundCommandValidator : AbstractValidator<PublishIn
|
|||||||
public PublishInboundCommandValidator()
|
public PublishInboundCommandValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.DisplayName).MaximumLength(100);
|
RuleFor(x => x.DisplayName).MaximumLength(100);
|
||||||
RuleFor(x => x.MaxClients).GreaterThan(0).When(x => x.MaxClients.HasValue);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user