Initial commit: VPN Control Panel
- FastAPI REST API with JWT auth - aiogram 3 Telegram bot with admin middleware - APScheduler daily tasks (expiry, reminders, revoke, sync) - SQLAlchemy 2 async ORM with Alembic migrations - Jinja2 admin panel (Dashboard, Users, Payments, Servers, Tariffs) - VPN provider abstraction with MockProvider - Stats service with revenue/subscription analytics - Docker Compose (PostgreSQL + Redis + app) - Healthcheck endpoint
This commit is contained in:
33
.env.example
Normal file
33
.env.example
Normal file
@@ -0,0 +1,33 @@
|
||||
# App
|
||||
APP_NAME=VPN Control Panel
|
||||
DEBUG=false
|
||||
PORT=8000
|
||||
|
||||
# Database
|
||||
POSTGRES_USER=vpn
|
||||
POSTGRES_PASSWORD=vpn_secret
|
||||
POSTGRES_DB=vpn_control
|
||||
POSTGRES_HOST=db
|
||||
POSTGRES_PORT=5432
|
||||
DATABASE_URL=postgresql+asyncpg://vpn:vpn_secret@db:5432/vpn_control
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# Telegram Bot
|
||||
BOT_TOKEN=your_bot_token_here
|
||||
|
||||
# Admin
|
||||
ADMIN_IDS=[]
|
||||
ADMIN_GROUP_ID=0
|
||||
ADMIN_GROUP_THREAD_ID=
|
||||
JWT_SECRET=change_me_to_random_string
|
||||
JWT_ACCESS_EXPIRE_MINUTES=30
|
||||
JWT_REFRESH_EXPIRE_DAYS=30
|
||||
|
||||
# Payment
|
||||
# (provider-specific keys)
|
||||
|
||||
# VPN Providers
|
||||
OUTLINE_API_PREFIX=
|
||||
OUTLINE_CERT_SHA256=
|
||||
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.eggs/
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
.env/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Project
|
||||
.env
|
||||
*.db
|
||||
*.sqlite3
|
||||
|
||||
# Docker
|
||||
docker-data/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Migrations cache
|
||||
*.pyc
|
||||
276
README.md
Normal file
276
README.md
Normal file
@@ -0,0 +1,276 @@
|
||||
# VPN Control Panel
|
||||
|
||||
Система управления VPN-подписками: регистрация пользователей, тарифы, платежи,
|
||||
Telegram-бот, административная панель и фоновые задачи.
|
||||
|
||||
## Стек
|
||||
|
||||
- **Python 3.13** / **FastAPI** + Uvicorn
|
||||
- **SQLAlchemy 2** (async) + asyncpg
|
||||
- **PostgreSQL 16** / **Redis 7**
|
||||
- **Alembic** — миграции БД
|
||||
- **aiogram 3** — Telegram-бот
|
||||
- **APScheduler** — фоновые задачи
|
||||
- **Jinja2** — админ-панель
|
||||
- **Docker** / **Docker Compose**
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
├── app/
|
||||
│ ├── api/v1/ # REST API endpoints (users, payments, tariffs, servers, auth, stats)
|
||||
│ ├── admin/ # Административная панель (Jinja2)
|
||||
│ ├── bot/ # Telegram bot (handlers, middleware, dispatcher)
|
||||
│ ├── models/ # SQLAlchemy ORM модели
|
||||
│ ├── providers/ # VPN-провайдеры (абстракция + Mock)
|
||||
│ ├── repositories/ # Слой доступа к данным (CRUD)
|
||||
│ ├── scheduler/ # Фоновые задачи (APScheduler)
|
||||
│ ├── schemas/ # Pydantic схемы запросов/ответов
|
||||
│ ├── services/ # Бизнес-логика
|
||||
│ └── utils/ # Вспомогательные функции
|
||||
├── docker/
|
||||
│ ├── Dockerfile # Production-сборка
|
||||
│ ├── Dockerfile.dev # Dev-сборка
|
||||
│ └── entrypoint.sh # Точка входа (миграции + uvicorn)
|
||||
├── migrations/ # Alembic миграции
|
||||
├── tests/ # Тесты
|
||||
├── .env.example
|
||||
├── docker-compose.yml
|
||||
└── docker-compose.dev.yml
|
||||
```
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### 1. Запуск через Docker (рекомендуется)
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Отредактируйте .env (BOT_TOKEN, JWT_SECRET и т.д.)
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Приложение будет доступно на `http://localhost:8000`.
|
||||
|
||||
### 2. Локальный запуск для разработки
|
||||
|
||||
```bash
|
||||
# Только БД и Redis
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
|
||||
# Python окружение
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.dev.txt
|
||||
|
||||
# Миграции
|
||||
alembic upgrade head
|
||||
|
||||
# Запуск
|
||||
uvicorn app.main:app --reload --port 8000
|
||||
```
|
||||
|
||||
## Миграции
|
||||
|
||||
Создание новой миграции:
|
||||
|
||||
```bash
|
||||
alembic revision --autogenerate -m "description"
|
||||
```
|
||||
|
||||
Применение миграций:
|
||||
|
||||
```bash
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
Откат на одну:
|
||||
|
||||
```bash
|
||||
alembic downgrade -1
|
||||
```
|
||||
|
||||
## Создание администратора
|
||||
|
||||
Администратор создаётся напрямую в БД. Telegram ID — ваш числовой ID в Telegram.
|
||||
|
||||
```bash
|
||||
docker compose exec app python -c "
|
||||
import asyncio
|
||||
from app.database import async_session_factory
|
||||
from app.repositories.admin import AdminRepository
|
||||
from app.models.admin import AdminRole
|
||||
|
||||
async def create():
|
||||
async with async_session_factory() as session:
|
||||
repo = AdminRepository(session)
|
||||
admin = await repo.create(
|
||||
telegram_id=123456789, # Ваш Telegram ID
|
||||
username='admin',
|
||||
role=AdminRole.SUPERADMIN,
|
||||
is_active=True,
|
||||
)
|
||||
print(f'Admin created: id={admin.id} tg={admin.telegram_id}')
|
||||
|
||||
asyncio.run(create())
|
||||
"
|
||||
```
|
||||
|
||||
## Настройка Telegram
|
||||
|
||||
1. Создайте бота через [@BotFather](https://t.me/BotFather), получите токен.
|
||||
2. Укажите токен в `.env`: `BOT_TOKEN=ваш_токен`.
|
||||
3. Узнайте свой Telegram ID (например, через @userinfobot).
|
||||
4. Добавьте его в БД через скрипт выше.
|
||||
5. Запустите — бот ответит на команды только администраторам из БД.
|
||||
|
||||
Команды бота:
|
||||
|
||||
```
|
||||
/start — приветствие
|
||||
/help — список команд
|
||||
/users — список активных пользователей
|
||||
/user <id> — информация о пользователе
|
||||
/delete <id> — деактивация пользователя
|
||||
/renew <id> — статус подписки
|
||||
/expired — список просроченных подписок
|
||||
/expiring [N] — истекают в ближайшие N дней (по умолч. 3)
|
||||
/stats — статистика системы
|
||||
```
|
||||
|
||||
## Пример .env
|
||||
|
||||
```env
|
||||
APP_NAME=VPN Control Panel
|
||||
DEBUG=false
|
||||
PORT=8000
|
||||
|
||||
POSTGRES_USER=vpn
|
||||
POSTGRES_PASSWORD=vpn_secret
|
||||
POSTGRES_DB=vpn_control
|
||||
POSTGRES_HOST=db
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
BOT_TOKEN=123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11
|
||||
ADMIN_IDS=[123456789]
|
||||
ADMIN_GROUP_ID=-1001234567890
|
||||
ADMIN_GROUP_THREAD_ID=
|
||||
|
||||
JWT_SECRET=случайная_строка_32_символа
|
||||
JWT_ACCESS_EXPIRE_MINUTES=30
|
||||
JWT_REFRESH_EXPIRE_DAYS=30
|
||||
|
||||
OUTLINE_API_PREFIX=https://example.com:1234/abc123
|
||||
OUTLINE_CERT_SHA256=sha256hash...
|
||||
```
|
||||
|
||||
## Пример API
|
||||
|
||||
Авторизация:
|
||||
|
||||
```bash
|
||||
# Логин (secret_key = JWT_SECRET из .env)
|
||||
curl -X POST http://localhost:8000/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"telegram_id": 123456789, "secret_key": "change_me_to_random_string"}'
|
||||
|
||||
# Ответ:
|
||||
# {"access_token": "...", "refresh_token": "...", "token_type": "bearer"}
|
||||
```
|
||||
|
||||
Пользователи:
|
||||
|
||||
```bash
|
||||
# Список (требуется Bearer token)
|
||||
curl http://localhost:8000/api/v1/users \
|
||||
-H "Authorization: Bearer <access_token>"
|
||||
|
||||
# Создать
|
||||
curl -X POST http://localhost:8000/api/v1/users \
|
||||
-H "Authorization: Bearer <access_token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"telegram_id": 987654321, "username": "user", "full_name": "User Name"}'
|
||||
```
|
||||
|
||||
Тарифы:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/api/v1/tariffs
|
||||
# [{"id":1,"name":"Basic","duration_days":30,"price":500.0,"currency":"RUB",...}]
|
||||
```
|
||||
|
||||
Платежи:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/payments \
|
||||
-H "Authorization: Bearer <access_token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"user_id": 1, "tariff_id": 1, "amount": 500, "provider": "manual"}'
|
||||
|
||||
# Подтвердить платёж (активирует подписку)
|
||||
curl -X PATCH http://localhost:8000/api/v1/payments/1/confirm \
|
||||
-H "Authorization: Bearer <access_token>"
|
||||
```
|
||||
|
||||
Статистика:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/api/v1/stats
|
||||
# {"total_users":5,"active_users":3,"total_revenue":2500.0,...}
|
||||
```
|
||||
|
||||
Административная панель:
|
||||
|
||||
```
|
||||
http://localhost:8000/admin/ — Dashboard
|
||||
http://localhost:8000/admin/users — Пользователи
|
||||
http://localhost:8000/admin/payments — Платежи
|
||||
http://localhost:8000/admin/servers — Серверы
|
||||
http://localhost:8000/admin/tariffs — Тарифы
|
||||
```
|
||||
|
||||
Healthcheck:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
# {"status":"ok","database":"connected"}
|
||||
```
|
||||
|
||||
Документация API (Swagger):
|
||||
|
||||
```
|
||||
http://localhost:8000/docs
|
||||
http://localhost:8000/redoc
|
||||
```
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
Client → FastAPI (REST / Admin)
|
||||
↓
|
||||
Service Layer
|
||||
↓
|
||||
Repository Layer
|
||||
↓
|
||||
PostgreSQL / Redis
|
||||
↓
|
||||
Telegram Bot ← aiogram ← APScheduler (daily tasks)
|
||||
```
|
||||
|
||||
- **Service Layer** — бизнес-логика, не содержит SQL.
|
||||
- **Repository Layer** — только CRUD, без логики.
|
||||
- Сервисы не вызывают друг друга — оркестрация на уровне handler.
|
||||
- Telegram-бот не содержит бизнес-логики — только отображение.
|
||||
- Scheduler не содержит Telegram-кода — только вызовы сервисов.
|
||||
|
||||
## Планы
|
||||
|
||||
- WireGuard / Outline / XRay провайдеры
|
||||
- Онлайн-оплата (ЮKassa, Stripe)
|
||||
- Рассылка уведомлений
|
||||
- Dashboard с графиками
|
||||
|
||||
## Лицензия
|
||||
|
||||
MIT
|
||||
39
alembic.ini
Normal file
39
alembic.ini
Normal file
@@ -0,0 +1,39 @@
|
||||
[alembic]
|
||||
script_location = migrations
|
||||
prepend_sys_path = .
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/admin/__init__.py
Normal file
0
app/admin/__init__.py
Normal file
118
app/admin/router.py
Normal file
118
app/admin/router.py
Normal file
@@ -0,0 +1,118 @@
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.database import async_session_factory
|
||||
from app.models.payment import Payment, PaymentStatus
|
||||
from app.models.server import Server
|
||||
from app.models.tariff import Tariff
|
||||
from app.models.user import User
|
||||
from app.repositories.payment import PaymentRepository
|
||||
from app.repositories.server import ServerRepository
|
||||
from app.repositories.tariff import TariffRepository
|
||||
from app.repositories.user import UserRepository
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request):
|
||||
async with async_session_factory() as session:
|
||||
|
||||
async def _count(model, *filters):
|
||||
stmt = select(func.count(model.id))
|
||||
for f in filters:
|
||||
stmt = stmt.where(f)
|
||||
return (await session.execute(stmt)).scalar_one()
|
||||
|
||||
total_users = await _count(User)
|
||||
active_users = await _count(User, User.is_active.is_(True))
|
||||
total_tariffs = await _count(Tariff)
|
||||
total_servers = await _count(Server)
|
||||
active_servers = await _count(Server, Server.is_active.is_(True))
|
||||
total_payments = await _count(Payment)
|
||||
confirmed_payments = await _count(
|
||||
Payment, Payment.status == PaymentStatus.CONFIRMED
|
||||
)
|
||||
|
||||
rev_stmt = select(func.coalesce(func.sum(Payment.amount), 0)).where(
|
||||
Payment.status == PaymentStatus.CONFIRMED
|
||||
)
|
||||
total_revenue = float((await session.execute(rev_stmt)).scalar_one())
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"dashboard.html",
|
||||
{
|
||||
"request": request,
|
||||
"total_users": total_users,
|
||||
"active_users": active_users,
|
||||
"total_tariffs": total_tariffs,
|
||||
"total_servers": total_servers,
|
||||
"active_servers": active_servers,
|
||||
"total_payments": total_payments,
|
||||
"confirmed_payments": confirmed_payments,
|
||||
"total_revenue": total_revenue,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/users", response_class=HTMLResponse)
|
||||
async def users_page(request: Request):
|
||||
async with async_session_factory() as session:
|
||||
repo = UserRepository(session)
|
||||
users = await repo.get_all()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"users.html",
|
||||
{"request": request, "users": users},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/payments", response_class=HTMLResponse)
|
||||
async def payments_page(request: Request):
|
||||
async with async_session_factory() as session:
|
||||
tariff_repo = TariffRepository(session)
|
||||
|
||||
stmt = select(Payment).order_by(Payment.created_at.desc())
|
||||
result = await session.execute(stmt)
|
||||
payments = list(result.scalars().all())
|
||||
|
||||
tariffs_map = {}
|
||||
for p in payments:
|
||||
if p.tariff_id not in tariffs_map:
|
||||
t = await tariff_repo.get(p.tariff_id)
|
||||
tariffs_map[p.tariff_id] = t.name if t else "—"
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"payments.html",
|
||||
{"request": request, "payments": payments, "tariffs_map": tariffs_map},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/servers", response_class=HTMLResponse)
|
||||
async def servers_page(request: Request):
|
||||
async with async_session_factory() as session:
|
||||
repo = ServerRepository(session)
|
||||
servers = await repo.get_all()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"servers.html",
|
||||
{"request": request, "servers": servers},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tariffs", response_class=HTMLResponse)
|
||||
async def tariffs_page(request: Request):
|
||||
async with async_session_factory() as session:
|
||||
repo = TariffRepository(session)
|
||||
tariffs = await repo.get_all()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"tariffs.html",
|
||||
{"request": request, "tariffs": tariffs},
|
||||
)
|
||||
0
app/api/__init__.py
Normal file
0
app/api/__init__.py
Normal file
140
app/api/deps.py
Normal file
140
app/api/deps.py
Normal file
@@ -0,0 +1,140 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import async_session_factory
|
||||
from app.repositories import (
|
||||
UserRepository,
|
||||
PaymentRepository,
|
||||
ServerRepository,
|
||||
TariffRepository,
|
||||
NotificationRepository,
|
||||
AdminRepository,
|
||||
)
|
||||
from app.services import (
|
||||
UserService,
|
||||
PaymentService,
|
||||
BillingService,
|
||||
NotificationService,
|
||||
ServerService,
|
||||
VPNService,
|
||||
)
|
||||
from app.services.auth import AuthService
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def get_user_service(db: AsyncSession = Depends(get_db)) -> UserService:
|
||||
return UserService(UserRepository(db))
|
||||
|
||||
|
||||
def get_payment_service(db: AsyncSession = Depends(get_db)) -> PaymentService:
|
||||
return PaymentService(
|
||||
PaymentRepository(db),
|
||||
UserRepository(db),
|
||||
TariffRepository(db),
|
||||
)
|
||||
|
||||
|
||||
def get_billing_service(db: AsyncSession = Depends(get_db)) -> BillingService:
|
||||
return BillingService(
|
||||
PaymentRepository(db),
|
||||
TariffRepository(db),
|
||||
UserRepository(db),
|
||||
)
|
||||
|
||||
|
||||
def get_notification_service(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> NotificationService:
|
||||
return NotificationService(
|
||||
NotificationRepository(db),
|
||||
UserRepository(db),
|
||||
)
|
||||
|
||||
|
||||
def get_server_service(db: AsyncSession = Depends(get_db)) -> ServerService:
|
||||
return ServerService(ServerRepository(db))
|
||||
|
||||
|
||||
def get_vpn_service(db: AsyncSession = Depends(get_db)) -> VPNService:
|
||||
return VPNService(
|
||||
ServerRepository(db),
|
||||
UserRepository(db),
|
||||
)
|
||||
|
||||
|
||||
def get_tariff_repo(db: AsyncSession = Depends(get_db)) -> TariffRepository:
|
||||
return TariffRepository(db)
|
||||
|
||||
|
||||
def get_server_repo(db: AsyncSession = Depends(get_db)) -> ServerRepository:
|
||||
return ServerRepository(db)
|
||||
|
||||
|
||||
def get_auth_service(db: AsyncSession = Depends(get_db)) -> AuthService:
|
||||
return AuthService(AdminRepository(db))
|
||||
|
||||
|
||||
async def get_current_admin(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
from app.services.auth import AuthService
|
||||
|
||||
auth_svc = AuthService(AdminRepository(db))
|
||||
|
||||
try:
|
||||
payload = auth_svc.decode_token(credentials.credentials)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
if payload.get("type") != "access":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token type",
|
||||
)
|
||||
|
||||
telegram_id = int(payload["sub"])
|
||||
admin = await AdminRepository(db).get_by_telegram_id(telegram_id)
|
||||
|
||||
if admin is None or not admin.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin not found or deactivated",
|
||||
)
|
||||
|
||||
return {
|
||||
"admin_id": admin.id,
|
||||
"telegram_id": admin.telegram_id,
|
||||
"role": admin.role.value,
|
||||
"username": admin.username,
|
||||
}
|
||||
|
||||
|
||||
def require_admin(payload: dict = Depends(get_current_admin)) -> dict:
|
||||
return payload
|
||||
|
||||
|
||||
def require_superadmin(payload: dict = Depends(get_current_admin)) -> dict:
|
||||
if payload["role"] != "superadmin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Superadmin role required",
|
||||
)
|
||||
return payload
|
||||
0
app/api/middleware.py
Normal file
0
app/api/middleware.py
Normal file
0
app/api/v1/__init__.py
Normal file
0
app/api/v1/__init__.py
Normal file
0
app/api/v1/admin.py
Normal file
0
app/api/v1/admin.py
Normal file
35
app/api/v1/auth.py
Normal file
35
app/api/v1/auth.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from app.api.deps import get_auth_service
|
||||
from app.schemas.auth import LoginRequest, RefreshRequest, TokenResponse
|
||||
from app.services.auth import AuthService
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(
|
||||
body: LoginRequest,
|
||||
auth_svc: AuthService = Depends(get_auth_service),
|
||||
):
|
||||
result = await auth_svc.authenticate(body.telegram_id, body.secret_key)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid credentials",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
async def refresh(
|
||||
body: RefreshRequest,
|
||||
auth_svc: AuthService = Depends(get_auth_service),
|
||||
):
|
||||
try:
|
||||
return auth_svc.refresh_access_token(body.refresh_token)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=str(e),
|
||||
)
|
||||
107
app/api/v1/payments.py
Normal file
107
app/api/v1/payments.py
Normal file
@@ -0,0 +1,107 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from app.api.deps import (
|
||||
get_payment_service,
|
||||
get_billing_service,
|
||||
get_notification_service,
|
||||
)
|
||||
from app.schemas.payment import PaymentRead, PaymentCreate, PaymentConfirm
|
||||
from app.services import PaymentService, BillingService, NotificationService
|
||||
|
||||
router = APIRouter(prefix="/payments", tags=["Payments"])
|
||||
|
||||
|
||||
@router.post("", response_model=PaymentRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_payment(
|
||||
body: PaymentCreate,
|
||||
payment_svc: PaymentService = Depends(get_payment_service),
|
||||
):
|
||||
try:
|
||||
return await payment_svc.create(
|
||||
user_id=body.user_id,
|
||||
tariff_id=body.tariff_id,
|
||||
provider=body.provider,
|
||||
amount=body.amount,
|
||||
currency=body.currency,
|
||||
external_id=body.external_id,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{payment_id}", response_model=PaymentRead)
|
||||
async def get_payment(
|
||||
payment_id: int,
|
||||
payment_svc: PaymentService = Depends(get_payment_service),
|
||||
):
|
||||
payment = await payment_svc.get_by_id(payment_id)
|
||||
if payment is None:
|
||||
raise HTTPException(status_code=404, detail="Payment not found")
|
||||
return payment
|
||||
|
||||
|
||||
@router.get("/by-external/{external_id}", response_model=PaymentRead)
|
||||
async def get_payment_by_external(
|
||||
external_id: str,
|
||||
payment_svc: PaymentService = Depends(get_payment_service),
|
||||
):
|
||||
payment = await payment_svc.get_by_external_id(external_id)
|
||||
if payment is None:
|
||||
raise HTTPException(status_code=404, detail="Payment not found")
|
||||
return payment
|
||||
|
||||
|
||||
@router.post("/{payment_id}/confirm", response_model=PaymentRead)
|
||||
async def confirm_payment(
|
||||
payment_id: int,
|
||||
body: PaymentConfirm = PaymentConfirm(),
|
||||
payment_svc: PaymentService = Depends(get_payment_service),
|
||||
billing_svc: BillingService = Depends(get_billing_service),
|
||||
notification_svc: NotificationService = Depends(get_notification_service),
|
||||
):
|
||||
try:
|
||||
payment = await payment_svc.confirm(payment_id, body.external_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
await billing_svc.activate_subscription(
|
||||
payment.user_id, payment.tariff_id, payment.id
|
||||
)
|
||||
|
||||
await notification_svc.send_success(
|
||||
payment.user_id,
|
||||
"Payment confirmed",
|
||||
f"Payment {payment.id} confirmed. Subscription activated.",
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
@router.post("/{payment_id}/fail", response_model=PaymentRead)
|
||||
async def fail_payment(
|
||||
payment_id: int,
|
||||
payment_svc: PaymentService = Depends(get_payment_service),
|
||||
):
|
||||
try:
|
||||
return await payment_svc.fail(payment_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{payment_id}/refund", response_model=PaymentRead)
|
||||
async def refund_payment(
|
||||
payment_id: int,
|
||||
payment_svc: PaymentService = Depends(get_payment_service),
|
||||
):
|
||||
try:
|
||||
return await payment_svc.refund(payment_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/user/{user_id}", response_model=list[PaymentRead])
|
||||
async def get_user_payments(
|
||||
user_id: int,
|
||||
payment_svc: PaymentService = Depends(get_payment_service),
|
||||
):
|
||||
return await payment_svc.get_user_payments(user_id)
|
||||
17
app/api/v1/router.py
Normal file
17
app/api/v1/router.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.auth import router as auth_router
|
||||
from app.api.v1.users import router as users_router
|
||||
from app.api.v1.payments import router as payments_router
|
||||
from app.api.v1.tariffs import router as tariffs_router
|
||||
from app.api.v1.servers import router as servers_router
|
||||
from app.api.v1.stats import router as stats_router
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
|
||||
router.include_router(auth_router)
|
||||
router.include_router(users_router)
|
||||
router.include_router(payments_router)
|
||||
router.include_router(tariffs_router)
|
||||
router.include_router(servers_router)
|
||||
router.include_router(stats_router)
|
||||
66
app/api/v1/servers.py
Normal file
66
app/api/v1/servers.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from app.api.deps import get_server_service, get_server_repo
|
||||
from app.repositories.server import ServerRepository
|
||||
from app.schemas.server import ServerRead, ServerCreate, ServerUpdate
|
||||
from app.services.server import ServerService
|
||||
|
||||
router = APIRouter(prefix="/servers", tags=["Servers"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[ServerRead])
|
||||
async def list_servers(
|
||||
server_svc: ServerService = Depends(get_server_service),
|
||||
):
|
||||
return await server_svc.get_active()
|
||||
|
||||
|
||||
@router.get("/all", response_model=list[ServerRead])
|
||||
async def list_all_servers(
|
||||
server_repo: ServerRepository = Depends(get_server_repo),
|
||||
):
|
||||
return await server_repo.get_all()
|
||||
|
||||
|
||||
@router.post("", response_model=ServerRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_server(
|
||||
body: ServerCreate,
|
||||
server_svc: ServerService = Depends(get_server_service),
|
||||
):
|
||||
try:
|
||||
return await server_svc.create(
|
||||
name=body.name,
|
||||
host=body.host,
|
||||
port=body.port,
|
||||
protocol=body.protocol,
|
||||
location=body.location,
|
||||
country_code=body.country_code,
|
||||
max_users=body.max_users,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{server_id}", response_model=ServerRead)
|
||||
async def get_server(
|
||||
server_id: int,
|
||||
server_svc: ServerService = Depends(get_server_service),
|
||||
):
|
||||
server = await server_svc.get_by_id(server_id)
|
||||
if server is None:
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
return server
|
||||
|
||||
|
||||
@router.patch("/{server_id}", response_model=ServerRead)
|
||||
async def update_server(
|
||||
server_id: int,
|
||||
body: ServerUpdate,
|
||||
server_svc: ServerService = Depends(get_server_service),
|
||||
):
|
||||
server = await server_svc.update(
|
||||
server_id, **body.model_dump(exclude_unset=True)
|
||||
)
|
||||
if server is None:
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
return server
|
||||
69
app/api/v1/stats.py
Normal file
69
app/api/v1/stats.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.models.payment import Payment, PaymentStatus
|
||||
from app.models.server import Server
|
||||
from app.models.tariff import Tariff
|
||||
from app.models.user import User
|
||||
from app.schemas.stats import StatsResponse
|
||||
|
||||
router = APIRouter(prefix="/stats", tags=["Stats"])
|
||||
|
||||
|
||||
@router.get("", response_model=StatsResponse)
|
||||
async def get_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(func.count(User.id))
|
||||
total_users = (await db.execute(stmt)).scalar_one()
|
||||
|
||||
stmt = select(func.count(User.id)).where(User.is_active.is_(True))
|
||||
active_users = (await db.execute(stmt)).scalar_one()
|
||||
|
||||
stmt = select(func.count(Tariff.id))
|
||||
total_tariffs = (await db.execute(stmt)).scalar_one()
|
||||
|
||||
stmt = select(func.count(Tariff.id)).where(Tariff.is_active.is_(True))
|
||||
active_tariffs = (await db.execute(stmt)).scalar_one()
|
||||
|
||||
stmt = select(func.count(Server.id))
|
||||
total_servers = (await db.execute(stmt)).scalar_one()
|
||||
|
||||
stmt = select(func.count(Server.id)).where(Server.is_active.is_(True))
|
||||
active_servers = (await db.execute(stmt)).scalar_one()
|
||||
|
||||
stmt = select(func.count(Payment.id))
|
||||
total_payments = (await db.execute(stmt)).scalar_one()
|
||||
|
||||
stmt = (
|
||||
select(func.count(Payment.id))
|
||||
.where(Payment.status == PaymentStatus.CONFIRMED)
|
||||
)
|
||||
confirmed_payments = (await db.execute(stmt)).scalar_one()
|
||||
|
||||
stmt = (
|
||||
select(func.count(Payment.id))
|
||||
.where(Payment.status == PaymentStatus.PENDING)
|
||||
)
|
||||
pending_payments = (await db.execute(stmt)).scalar_one()
|
||||
|
||||
stmt = (
|
||||
select(func.coalesce(func.sum(Payment.amount), 0))
|
||||
.where(Payment.status == PaymentStatus.CONFIRMED)
|
||||
)
|
||||
total_revenue = float((await db.execute(stmt)).scalar_one())
|
||||
|
||||
return StatsResponse(
|
||||
total_users=total_users,
|
||||
active_users=active_users,
|
||||
total_tariffs=total_tariffs,
|
||||
active_tariffs=active_tariffs,
|
||||
total_servers=total_servers,
|
||||
active_servers=active_servers,
|
||||
total_payments=total_payments,
|
||||
confirmed_payments=confirmed_payments,
|
||||
pending_payments=pending_payments,
|
||||
total_revenue=round(total_revenue, 2),
|
||||
)
|
||||
0
app/api/v1/subscriptions.py
Normal file
0
app/api/v1/subscriptions.py
Normal file
32
app/api/v1/tariffs.py
Normal file
32
app/api/v1/tariffs.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.api.deps import get_tariff_repo
|
||||
from app.repositories.tariff import TariffRepository
|
||||
from app.schemas.tariff import TariffRead
|
||||
|
||||
router = APIRouter(prefix="/tariffs", tags=["Tariffs"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[TariffRead])
|
||||
async def list_tariffs(
|
||||
tariff_repo: TariffRepository = Depends(get_tariff_repo),
|
||||
):
|
||||
return await tariff_repo.get_active_tariffs()
|
||||
|
||||
|
||||
@router.get("/all", response_model=list[TariffRead])
|
||||
async def list_all_tariffs(
|
||||
tariff_repo: TariffRepository = Depends(get_tariff_repo),
|
||||
):
|
||||
return await tariff_repo.get_all()
|
||||
|
||||
|
||||
@router.get("/{tariff_id}", response_model=TariffRead)
|
||||
async def get_tariff(
|
||||
tariff_id: int,
|
||||
tariff_repo: TariffRepository = Depends(get_tariff_repo),
|
||||
):
|
||||
tariff = await tariff_repo.get(tariff_id)
|
||||
if tariff is None:
|
||||
raise HTTPException(status_code=404, detail="Tariff not found")
|
||||
return tariff
|
||||
83
app/api/v1/users.py
Normal file
83
app/api/v1/users.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.api.deps import (
|
||||
get_user_service,
|
||||
get_billing_service,
|
||||
)
|
||||
from app.schemas.user import UserRead, UserUpdate
|
||||
from app.services import UserService, BillingService
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["Users"])
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserRead)
|
||||
async def get_user(
|
||||
user_id: int,
|
||||
user_svc: UserService = Depends(get_user_service),
|
||||
):
|
||||
user = await user_svc.get_by_id(user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserRead)
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
body: UserUpdate,
|
||||
user_svc: UserService = Depends(get_user_service),
|
||||
):
|
||||
user = await user_svc.update(user_id, **body.model_dump(exclude_unset=True))
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/{user_id}/deactivate", response_model=UserRead)
|
||||
async def deactivate_user(
|
||||
user_id: int,
|
||||
user_svc: UserService = Depends(get_user_service),
|
||||
):
|
||||
ok = await user_svc.deactivate(user_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return await user_svc.get_by_id(user_id)
|
||||
|
||||
|
||||
@router.post("/{user_id}/activate", response_model=UserRead)
|
||||
async def activate_user(
|
||||
user_id: int,
|
||||
user_svc: UserService = Depends(get_user_service),
|
||||
):
|
||||
ok = await user_svc.activate(user_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return await user_svc.get_by_id(user_id)
|
||||
|
||||
|
||||
@router.get("/by-telegram/{telegram_id}", response_model=UserRead)
|
||||
async def get_user_by_telegram(
|
||||
telegram_id: int,
|
||||
user_svc: UserService = Depends(get_user_service),
|
||||
):
|
||||
user = await user_svc.get_by_telegram_id(telegram_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/{user_id}/subscription")
|
||||
async def get_user_subscription(
|
||||
user_id: int,
|
||||
billing_svc: BillingService = Depends(get_billing_service),
|
||||
):
|
||||
info = await billing_svc.get_active_subscription(user_id)
|
||||
if info is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return {
|
||||
"is_active": info.is_active,
|
||||
"tariff_name": info.tariff.name if info.tariff else None,
|
||||
"start_date": info.start_date,
|
||||
"end_date": info.end_date,
|
||||
"remaining_days": info.remaining_days,
|
||||
}
|
||||
0
app/api/v1/vpn.py
Normal file
0
app/api/v1/vpn.py
Normal file
3
app/bot/__init__.py
Normal file
3
app/bot/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.bot.dispatcher import create_bot, create_dispatcher
|
||||
|
||||
__all__ = ["create_bot", "create_dispatcher"]
|
||||
30
app/bot/dispatcher.py
Normal file
30
app/bot/dispatcher.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
|
||||
from app.bot.handlers.start import router as start_router
|
||||
from app.bot.handlers.users import router as users_router
|
||||
from app.bot.handlers.subscription import router as subscription_router
|
||||
from app.bot.handlers.stats import router as stats_router
|
||||
from app.bot.middlewares import AdminMiddleware
|
||||
from app.settings import settings
|
||||
|
||||
|
||||
def create_dispatcher() -> Dispatcher:
|
||||
dp = Dispatcher()
|
||||
|
||||
dp.message.middleware(AdminMiddleware())
|
||||
|
||||
dp.include_router(start_router)
|
||||
dp.include_router(users_router)
|
||||
dp.include_router(subscription_router)
|
||||
dp.include_router(stats_router)
|
||||
|
||||
return dp
|
||||
|
||||
|
||||
def create_bot() -> Bot:
|
||||
return Bot(
|
||||
token=settings.bot_token,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
6
app/bot/handlers/__init__.py
Normal file
6
app/bot/handlers/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from app.bot.handlers.start import router as start_router
|
||||
from app.bot.handlers.users import router as users_router
|
||||
from app.bot.handlers.subscription import router as subscription_router
|
||||
from app.bot.handlers.stats import router as stats_router
|
||||
|
||||
__all__ = ["start_router", "users_router", "subscription_router", "stats_router"]
|
||||
0
app/bot/handlers/admin.py
Normal file
0
app/bot/handlers/admin.py
Normal file
0
app/bot/handlers/payment.py
Normal file
0
app/bot/handlers/payment.py
Normal file
17
app/bot/handlers/start.py
Normal file
17
app/bot/handlers/start.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from aiogram import Router
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message
|
||||
|
||||
from app.bot.texts import START, HELP
|
||||
|
||||
router = Router(name="start")
|
||||
|
||||
|
||||
@router.message(Command("start"))
|
||||
async def cmd_start(message: Message) -> None:
|
||||
await message.answer(START, parse_mode="HTML")
|
||||
|
||||
|
||||
@router.message(Command("help"))
|
||||
async def cmd_help(message: Message) -> None:
|
||||
await message.answer(HELP, parse_mode="HTML")
|
||||
55
app/bot/handlers/stats.py
Normal file
55
app/bot/handlers/stats.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from aiogram import Router
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message
|
||||
|
||||
from app.bot.texts import stats_text
|
||||
from app.database import async_session_factory
|
||||
from app.models.payment import Payment, PaymentStatus
|
||||
from app.models.server import Server
|
||||
from app.models.tariff import Tariff
|
||||
from app.models.user import User
|
||||
|
||||
router = Router(name="stats")
|
||||
|
||||
|
||||
@router.message(Command("stats"))
|
||||
async def cmd_stats(message: Message) -> None:
|
||||
async with async_session_factory() as session:
|
||||
|
||||
async def _count(model, *filters) -> int:
|
||||
stmt = select(func.count(model.id))
|
||||
for f in filters:
|
||||
stmt = stmt.where(f)
|
||||
return (await session.execute(stmt)).scalar_one()
|
||||
|
||||
total_users = await _count(User)
|
||||
active_users = await _count(User, User.is_active.is_(True))
|
||||
total_tariffs = await _count(Tariff)
|
||||
total_servers = await _count(Server)
|
||||
active_servers = await _count(Server, Server.is_active.is_(True))
|
||||
total_payments = await _count(Payment)
|
||||
confirmed_payments = await _count(
|
||||
Payment, Payment.status == PaymentStatus.CONFIRMED
|
||||
)
|
||||
|
||||
rev_stmt = (
|
||||
select(func.coalesce(func.sum(Payment.amount), 0))
|
||||
.where(Payment.status == PaymentStatus.CONFIRMED)
|
||||
)
|
||||
total_revenue = float((await session.execute(rev_stmt)).scalar_one())
|
||||
|
||||
await message.answer(
|
||||
stats_text(
|
||||
total_users=total_users,
|
||||
active_users=active_users,
|
||||
total_tariffs=total_tariffs,
|
||||
total_servers=total_servers,
|
||||
active_servers=active_servers,
|
||||
total_payments=total_payments,
|
||||
confirmed_payments=confirmed_payments,
|
||||
total_revenue=total_revenue,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
134
app/bot/handlers/subscription.py
Normal file
134
app/bot/handlers/subscription.py
Normal file
@@ -0,0 +1,134 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from aiogram import Router
|
||||
from aiogram.filters import Command, CommandObject
|
||||
from aiogram.types import Message
|
||||
from loguru import logger
|
||||
|
||||
from app.bot.texts import (
|
||||
user_info,
|
||||
USER_NOT_FOUND,
|
||||
INVALID_ARGS,
|
||||
NO_DATA,
|
||||
expired_list,
|
||||
expiring_list,
|
||||
)
|
||||
from app.database import async_session_factory
|
||||
from app.models.payment import PaymentStatus
|
||||
from app.repositories.payment import PaymentRepository
|
||||
from app.repositories.tariff import TariffRepository
|
||||
from app.repositories.user import UserRepository
|
||||
from app.services.billing import BillingService
|
||||
|
||||
router = Router(name="subscription")
|
||||
|
||||
|
||||
@router.message(Command("renew"))
|
||||
async def cmd_renew(message: Message, command: CommandObject) -> None:
|
||||
if not command.args or not command.args.strip().isdigit():
|
||||
await message.answer(
|
||||
INVALID_ARGS.format("/renew <telegram_id>"),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
telegram_id = int(command.args.strip())
|
||||
|
||||
async with async_session_factory() as session:
|
||||
user_repo = UserRepository(session)
|
||||
user = await user_repo.get_by_telegram_id(telegram_id)
|
||||
|
||||
if user is None:
|
||||
await message.answer(
|
||||
USER_NOT_FOUND.format(telegram_id), parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
billing = BillingService(
|
||||
PaymentRepository(session),
|
||||
TariffRepository(session),
|
||||
user_repo,
|
||||
)
|
||||
sub = await billing.get_active_subscription(user.id)
|
||||
|
||||
await message.answer(
|
||||
user_info(
|
||||
user_id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
username=user.username,
|
||||
full_name=user.full_name,
|
||||
is_active=user.is_active,
|
||||
created_at=user.created_at.strftime("%Y-%m-%d"),
|
||||
subscription_active=sub is not None and sub.is_active,
|
||||
tariff_name=sub.tariff.name if sub and sub.tariff else None,
|
||||
remaining_days=sub.remaining_days if sub else 0,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("expired"))
|
||||
async def cmd_expired(message: Message) -> None:
|
||||
async with async_session_factory() as session:
|
||||
payment_repo = PaymentRepository(session)
|
||||
tariff_repo = TariffRepository(session)
|
||||
user_repo = UserRepository(session)
|
||||
|
||||
confirmed = await payment_repo.get_by_status(PaymentStatus.CONFIRMED)
|
||||
now = datetime.now(timezone.utc)
|
||||
items = []
|
||||
|
||||
for payment in confirmed:
|
||||
tariff = await tariff_repo.get(payment.tariff_id)
|
||||
if tariff is None:
|
||||
continue
|
||||
start = payment.paid_at or payment.created_at
|
||||
end = start + timedelta(days=tariff.duration_days)
|
||||
if now > end:
|
||||
user = await user_repo.get(payment.user_id)
|
||||
items.append(
|
||||
{
|
||||
"user_id": payment.user_id,
|
||||
"telegram_id": user.telegram_id if user else "?",
|
||||
"tariff_name": tariff.name,
|
||||
}
|
||||
)
|
||||
|
||||
await message.answer(expired_list(items), parse_mode="HTML")
|
||||
|
||||
|
||||
@router.message(Command("expiring"))
|
||||
async def cmd_expiring(message: Message, command: CommandObject) -> None:
|
||||
days = 3
|
||||
if command.args and command.args.strip().isdigit():
|
||||
days = int(command.args.strip())
|
||||
|
||||
async with async_session_factory() as session:
|
||||
payment_repo = PaymentRepository(session)
|
||||
tariff_repo = TariffRepository(session)
|
||||
user_repo = UserRepository(session)
|
||||
|
||||
confirmed = await payment_repo.get_by_status(PaymentStatus.CONFIRMED)
|
||||
now = datetime.now(timezone.utc)
|
||||
items = []
|
||||
|
||||
for payment in confirmed:
|
||||
tariff = await tariff_repo.get(payment.tariff_id)
|
||||
if tariff is None:
|
||||
continue
|
||||
start = payment.paid_at or payment.created_at
|
||||
end = start + timedelta(days=tariff.duration_days)
|
||||
remaining = (end - now).days
|
||||
if 0 <= remaining <= days:
|
||||
user = await user_repo.get(payment.user_id)
|
||||
items.append(
|
||||
{
|
||||
"user_id": payment.user_id,
|
||||
"telegram_id": user.telegram_id if user else "?",
|
||||
"tariff_name": tariff.name,
|
||||
"remaining_days": remaining,
|
||||
}
|
||||
)
|
||||
|
||||
items.sort(key=lambda x: x["remaining_days"])
|
||||
await message.answer(expiring_list(items, days), parse_mode="HTML")
|
||||
0
app/bot/handlers/support.py
Normal file
0
app/bot/handlers/support.py
Normal file
117
app/bot/handlers/users.py
Normal file
117
app/bot/handlers/users.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import Router
|
||||
from aiogram.filters import Command, CommandObject
|
||||
from aiogram.types import Message
|
||||
from loguru import logger
|
||||
|
||||
from app.bot.texts import (
|
||||
user_info,
|
||||
user_short,
|
||||
USER_NOT_FOUND,
|
||||
INVALID_ARGS,
|
||||
NO_DATA,
|
||||
)
|
||||
from app.database import async_session_factory
|
||||
from app.repositories.user import UserRepository
|
||||
from app.repositories.payment import PaymentRepository
|
||||
from app.repositories.tariff import TariffRepository
|
||||
from app.services.billing import BillingService
|
||||
from app.services.user import UserService
|
||||
|
||||
router = Router(name="users")
|
||||
|
||||
|
||||
@router.message(Command("users"))
|
||||
async def cmd_users(message: Message) -> None:
|
||||
async with async_session_factory() as session:
|
||||
user_service = UserService(UserRepository(session))
|
||||
users = await user_service.user_repo.get_active_users()
|
||||
|
||||
if not users:
|
||||
await message.answer(NO_DATA)
|
||||
return
|
||||
|
||||
lines = [f"👥 <b>Active users ({len(users)}):</b>", ""]
|
||||
for u in users:
|
||||
lines.append(user_short(u.id, u.telegram_id, u.username))
|
||||
|
||||
await message.answer("\n".join(lines), parse_mode="HTML")
|
||||
|
||||
|
||||
@router.message(Command("user"))
|
||||
async def cmd_user(message: Message, command: CommandObject) -> None:
|
||||
if not command.args or not command.args.strip().isdigit():
|
||||
await message.answer(
|
||||
INVALID_ARGS.format("/user <telegram_id>"),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
telegram_id = int(command.args.strip())
|
||||
|
||||
async with async_session_factory() as session:
|
||||
user_repo = UserRepository(session)
|
||||
user = await user_repo.get_by_telegram_id(telegram_id)
|
||||
|
||||
if user is None:
|
||||
await message.answer(
|
||||
USER_NOT_FOUND.format(telegram_id), parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
billing = BillingService(
|
||||
PaymentRepository(session),
|
||||
TariffRepository(session),
|
||||
user_repo,
|
||||
)
|
||||
sub = await billing.get_active_subscription(user.id)
|
||||
|
||||
await message.answer(
|
||||
user_info(
|
||||
user_id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
username=user.username,
|
||||
full_name=user.full_name,
|
||||
is_active=user.is_active,
|
||||
created_at=user.created_at.strftime("%Y-%m-%d"),
|
||||
subscription_active=sub is not None and sub.is_active,
|
||||
tariff_name=sub.tariff.name if sub and sub.tariff else None,
|
||||
remaining_days=sub.remaining_days if sub else 0,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("delete"))
|
||||
async def cmd_delete(message: Message, command: CommandObject) -> None:
|
||||
if not command.args or not command.args.strip().isdigit():
|
||||
await message.answer(
|
||||
INVALID_ARGS.format("/delete <telegram_id>"),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
telegram_id = int(command.args.strip())
|
||||
|
||||
async with async_session_factory() as session:
|
||||
user_repo = UserRepository(session)
|
||||
user = await user_repo.get_by_telegram_id(telegram_id)
|
||||
|
||||
if user is None:
|
||||
await message.answer(
|
||||
USER_NOT_FOUND.format(telegram_id), parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
user_service = UserService(user_repo)
|
||||
ok = await user_service.deactivate(user.id)
|
||||
|
||||
if ok:
|
||||
await message.answer(
|
||||
f"✅ User <code>{telegram_id}</code> deactivated.",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
logger.info("Bot: user deactivated tg={}", telegram_id)
|
||||
else:
|
||||
await message.answer("❌ Failed to deactivate user.")
|
||||
0
app/bot/keyboards/__init__.py
Normal file
0
app/bot/keyboards/__init__.py
Normal file
0
app/bot/keyboards/main.py
Normal file
0
app/bot/keyboards/main.py
Normal file
36
app/bot/middlewares.py
Normal file
36
app/bot/middlewares.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Message
|
||||
|
||||
from app.database import async_session_factory
|
||||
from app.repositories.admin import AdminRepository
|
||||
from app.bot.texts import ADMIN_ONLY
|
||||
|
||||
|
||||
class AdminMiddleware(BaseMiddleware):
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[Message, dict[str, Any]], Awaitable[Any]],
|
||||
event: Message,
|
||||
data: dict[str, Any],
|
||||
) -> Any:
|
||||
if not isinstance(event, Message):
|
||||
return await handler(event, data)
|
||||
|
||||
async with async_session_factory() as session:
|
||||
repo = AdminRepository(session)
|
||||
admin = await repo.get_by_telegram_id(event.from_user.id)
|
||||
|
||||
if admin is None or not admin.is_active:
|
||||
await event.answer(ADMIN_ONLY, parse_mode="HTML")
|
||||
return
|
||||
|
||||
data["admin"] = {
|
||||
"admin_id": admin.id,
|
||||
"telegram_id": admin.telegram_id,
|
||||
"role": admin.role.value,
|
||||
"username": admin.username,
|
||||
}
|
||||
|
||||
return await handler(event, data)
|
||||
106
app/bot/texts.py
Normal file
106
app/bot/texts.py
Normal file
@@ -0,0 +1,106 @@
|
||||
START = (
|
||||
"🔐 VPN Control Panel Bot\n\n"
|
||||
"Manage users, subscriptions, and servers.\n"
|
||||
"Use /help to see available commands."
|
||||
)
|
||||
|
||||
HELP = (
|
||||
"📋 Available commands:\n\n"
|
||||
"👤 Users\n"
|
||||
"/users - list all active users\n"
|
||||
"/user <id> - show user details\n"
|
||||
"/delete <id> - deactivate user\n\n"
|
||||
"📅 Subscriptions\n"
|
||||
"/renew <id> - check subscription info\n"
|
||||
"/expired - list expired subscriptions\n"
|
||||
"/expiring [days] - list expiring in N days\n\n"
|
||||
"📊 System\n"
|
||||
"/stats - system statistics\n"
|
||||
"/help - this message"
|
||||
)
|
||||
|
||||
USER_NOT_FOUND = "❌ User <code>{}</code> not found."
|
||||
ADMIN_ONLY = "⛔ Access denied. Admins only."
|
||||
INVALID_ARGS = "⚠️ Invalid arguments. Usage: <code>{}</code>"
|
||||
ERROR = "❌ Error: {}"
|
||||
NO_DATA = "📭 No data."
|
||||
|
||||
|
||||
def user_info(
|
||||
user_id: int,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
full_name: str,
|
||||
is_active: bool,
|
||||
created_at: str,
|
||||
subscription_active: bool,
|
||||
tariff_name: str | None,
|
||||
remaining_days: int,
|
||||
) -> str:
|
||||
status = "✅ Active" if is_active else "❌ Deactivated"
|
||||
sub_status = "✅ Active" if subscription_active else "❌ Inactive"
|
||||
lines = [
|
||||
f"👤 <b>User #{user_id}</b>",
|
||||
f"📍 Telegram ID: <code>{telegram_id}</code>",
|
||||
f"👋 Username: @{username or '—'}",
|
||||
f"📛 Name: {full_name}",
|
||||
f"🔵 Status: {status}",
|
||||
f"📅 Registered: {created_at}",
|
||||
"",
|
||||
f"📦 <b>Subscription</b>",
|
||||
f"Status: {sub_status}",
|
||||
]
|
||||
if tariff_name:
|
||||
lines.append(f"Plan: {tariff_name}")
|
||||
if subscription_active:
|
||||
lines.append(f"Days left: {remaining_days}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def user_short(user_id: int, telegram_id: int, username: str | None) -> str:
|
||||
name = f"@{username}" if username else f"<code>{telegram_id}</code>"
|
||||
return f" #{user_id} {name}"
|
||||
|
||||
|
||||
def expired_list(items: list[dict]) -> str:
|
||||
if not items:
|
||||
return "✅ No expired subscriptions."
|
||||
lines = ["⚠️ <b>Expired subscriptions:</b>", ""]
|
||||
for item in items:
|
||||
lines.append(
|
||||
f" #{item['user_id']} tg:{item['telegram_id']} — "
|
||||
f"expired {item['tariff_name']}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def expiring_list(items: list[dict], days: int) -> str:
|
||||
if not items:
|
||||
return f"✅ No subscriptions expiring within {days} days."
|
||||
lines = [f"⚠️ <b>Expiring within {days} days:</b>", ""]
|
||||
for item in items:
|
||||
lines.append(
|
||||
f" #{item['user_id']} tg:{item['telegram_id']} — "
|
||||
f"{item['remaining_days']}d left ({item['tariff_name']})"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def stats_text(
|
||||
total_users: int,
|
||||
active_users: int,
|
||||
total_tariffs: int,
|
||||
total_servers: int,
|
||||
active_servers: int,
|
||||
total_payments: int,
|
||||
confirmed_payments: int,
|
||||
total_revenue: float,
|
||||
) -> str:
|
||||
return (
|
||||
"📊 <b>System Statistics</b>\n\n"
|
||||
f"👥 Users: {active_users} / {total_users} active\n"
|
||||
f"📦 Tariffs: {total_tariffs}\n"
|
||||
f"🖥️ Servers: {active_servers} / {total_servers} active\n"
|
||||
f"💳 Payments: {confirmed_payments} / {total_payments} confirmed\n"
|
||||
f"💰 Revenue: {total_revenue:.2f} RUB"
|
||||
)
|
||||
34
app/config.py
Normal file
34
app/config.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import sys
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.settings import settings
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
logger.remove()
|
||||
|
||||
level = "DEBUG" if settings.debug else "INFO"
|
||||
|
||||
logger.add(
|
||||
sys.stdout,
|
||||
level=level,
|
||||
colorize=True,
|
||||
format=(
|
||||
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
|
||||
"<level>{level: <8}</level> | "
|
||||
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | "
|
||||
"<level>{message}</level>"
|
||||
),
|
||||
)
|
||||
|
||||
logger.add(
|
||||
"logs/app_{time:YYYY-MM-DD}.log",
|
||||
level=level,
|
||||
rotation="1 day",
|
||||
retention="30 days",
|
||||
compression="gz",
|
||||
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} | {message}",
|
||||
)
|
||||
|
||||
logger.info("Logging configured | level={}", level)
|
||||
38
app/database.py
Normal file
38
app/database.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.settings import settings
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=settings.debug,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
async_session_factory = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
101
app/main.py
Normal file
101
app/main.py
Normal file
@@ -0,0 +1,101 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from loguru import logger
|
||||
|
||||
from app.config import setup_logging
|
||||
from app.database import engine
|
||||
from app.settings import settings
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
setup_logging()
|
||||
logger.info("Starting {} v{}", settings.app_name, "0.1.0")
|
||||
|
||||
async with engine.connect() as conn:
|
||||
await conn.exec_driver_sql("SELECT 1")
|
||||
logger.info("Database connection established")
|
||||
|
||||
from app.bot.dispatcher import create_bot, create_dispatcher
|
||||
from app.scheduler import setup_scheduler
|
||||
|
||||
bot = create_bot()
|
||||
dp = create_dispatcher()
|
||||
polling_task = asyncio.create_task(dp.start_polling(bot))
|
||||
logger.info("Telegram bot started")
|
||||
|
||||
scheduler = setup_scheduler()
|
||||
scheduler.start()
|
||||
logger.info("Scheduler started")
|
||||
|
||||
yield
|
||||
|
||||
scheduler.shutdown(wait=True)
|
||||
logger.info("Scheduler stopped")
|
||||
polling_task.cancel()
|
||||
try:
|
||||
await polling_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await bot.session.close()
|
||||
logger.info("Telegram bot stopped")
|
||||
await engine.dispose()
|
||||
logger.info("Database engine disposed")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
version="0.1.0",
|
||||
debug=settings.debug,
|
||||
lifespan=lifespan,
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(
|
||||
request: Request, exc: RequestValidationError
|
||||
):
|
||||
logger.warning("Validation error: {}", exc.errors())
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={
|
||||
"detail": exc.errors(),
|
||||
"body": exc.body,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
logger.error("Unhandled error: {}", exc)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "Internal server error"},
|
||||
)
|
||||
|
||||
|
||||
from app.admin.router import router as admin_router
|
||||
from app.api.v1.router import router as v1_router
|
||||
|
||||
app.include_router(admin_router)
|
||||
app.include_router(v1_router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
await conn.exec_driver_sql("SELECT 1")
|
||||
return {"status": "ok", "database": "connected"}
|
||||
except Exception as e:
|
||||
logger.error("Health check failed: {}", e)
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"status": "error", "database": str(e)},
|
||||
)
|
||||
19
app/models/__init__.py
Normal file
19
app/models/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from app.models.user import User
|
||||
from app.models.server import Server, ServerProtocol
|
||||
from app.models.tariff import Tariff
|
||||
from app.models.payment import Payment, PaymentStatus
|
||||
from app.models.notification import Notification, NotificationType
|
||||
from app.models.admin import Admin, AdminRole
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"Server",
|
||||
"ServerProtocol",
|
||||
"Tariff",
|
||||
"Payment",
|
||||
"PaymentStatus",
|
||||
"Notification",
|
||||
"NotificationType",
|
||||
"Admin",
|
||||
"AdminRole",
|
||||
]
|
||||
34
app/models/admin.py
Normal file
34
app/models/admin.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import enum
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Enum, Integer, String, Text, BigInteger, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AdminRole(str, enum.Enum):
|
||||
SUPERADMIN = "superadmin"
|
||||
MODERATOR = "moderator"
|
||||
|
||||
|
||||
class Admin(Base):
|
||||
__tablename__ = "admins"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
telegram_id: Mapped[int] = mapped_column(BigInteger, unique=True, nullable=False)
|
||||
username: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
role: Mapped[AdminRole] = mapped_column(
|
||||
Enum(AdminRole, name="admin_role"), default=AdminRole.MODERATOR
|
||||
)
|
||||
permissions: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Admin id={self.id} tg={self.telegram_id} role={self.role.value}>"
|
||||
37
app/models/notification.py
Normal file
37
app/models/notification.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import enum
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class NotificationType(str, enum.Enum):
|
||||
INFO = "info"
|
||||
WARNING = "warning"
|
||||
SUCCESS = "success"
|
||||
PAYMENT = "payment"
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
type: Mapped[NotificationType] = mapped_column(
|
||||
Enum(NotificationType, name="notification_type"), default=NotificationType.INFO
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
is_read: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="notifications")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Notification id={self.id} {self.type.value} user={self.user_id}>"
|
||||
45
app/models/payment.py
Normal file
45
app/models/payment.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import enum
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, Numeric, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class PaymentStatus(str, enum.Enum):
|
||||
PENDING = "pending"
|
||||
CONFIRMED = "confirmed"
|
||||
FAILED = "failed"
|
||||
REFUNDED = "refunded"
|
||||
|
||||
|
||||
class Payment(Base):
|
||||
__tablename__ = "payments"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
tariff_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("tariffs.id"), nullable=False
|
||||
)
|
||||
amount: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
|
||||
currency: Mapped[str] = mapped_column(String(3), default="RUB")
|
||||
status: Mapped[PaymentStatus] = mapped_column(
|
||||
Enum(PaymentStatus, name="payment_status"), default=PaymentStatus.PENDING
|
||||
)
|
||||
provider: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
external_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
paid_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="payments")
|
||||
tariff: Mapped["Tariff"] = relationship(back_populates="payments")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Payment id={self.id} {self.amount}{self.currency} {self.status.value}>"
|
||||
0
app/models/plan.py
Normal file
0
app/models/plan.py
Normal file
36
app/models/server.py
Normal file
36
app/models/server.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import enum
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Enum, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ServerProtocol(str, enum.Enum):
|
||||
OUTLINE = "outline"
|
||||
WIREGUARD = "wireguard"
|
||||
XRAY = "xray"
|
||||
|
||||
|
||||
class Server(Base):
|
||||
__tablename__ = "servers"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
host: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
port: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
protocol: Mapped[ServerProtocol] = mapped_column(
|
||||
Enum(ServerProtocol, name="server_protocol"), nullable=False
|
||||
)
|
||||
location: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
country_code: Mapped[str] = mapped_column(String(4), nullable=False)
|
||||
load_percent: Mapped[int] = mapped_column(Integer, default=0)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
max_users: Mapped[int] = mapped_column(Integer, default=100)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Server id={self.id} {self.name} ({self.protocol.value})>"
|
||||
0
app/models/subscription.py
Normal file
0
app/models/subscription.py
Normal file
32
app/models/tariff.py
Normal file
32
app/models/tariff.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, Numeric, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Tariff(Base):
|
||||
__tablename__ = "tariffs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
duration_days: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
price: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
|
||||
currency: Mapped[str] = mapped_column(String(3), default="RUB")
|
||||
max_devices: Mapped[int] = mapped_column(Integer, default=1)
|
||||
traffic_gb: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
payments: Mapped[list["Payment"]] = relationship(
|
||||
back_populates="tariff", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Tariff id={self.id} {self.name} {self.price}{self.currency}>"
|
||||
0
app/models/traffic_log.py
Normal file
0
app/models/traffic_log.py
Normal file
33
app/models/user.py
Normal file
33
app/models/user.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, String, BigInteger, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
telegram_id: Mapped[int] = mapped_column(BigInteger, unique=True, nullable=False)
|
||||
username: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
full_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
language_code: Mapped[str] = mapped_column(String(8), default="ru")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
payments: Mapped[list["Payment"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
notifications: Mapped[list["Notification"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User id={self.id} tg={self.telegram_id} {self.username}>"
|
||||
0
app/models/vpn_config.py
Normal file
0
app/models/vpn_config.py
Normal file
8
app/providers/__init__.py
Normal file
8
app/providers/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from app.providers.base import VPNProvider, VPNUserInfo
|
||||
from app.providers.mock import MockProvider
|
||||
|
||||
__all__ = [
|
||||
"VPNProvider",
|
||||
"VPNUserInfo",
|
||||
"MockProvider",
|
||||
]
|
||||
44
app/providers/base.py
Normal file
44
app/providers/base.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class VPNUserInfo:
|
||||
vpn_id: str
|
||||
internal_id: int
|
||||
server_id: int
|
||||
name: str
|
||||
is_enabled: bool
|
||||
access_config: str | None = None
|
||||
bytes_used: int = 0
|
||||
data_limit: int | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class VPNProvider(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def create_user(
|
||||
self,
|
||||
internal_id: int,
|
||||
server_id: int,
|
||||
name: str,
|
||||
) -> VPNUserInfo:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def delete_user(self, vpn_id: str) -> bool:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def enable_user(self, vpn_id: str) -> bool:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def disable_user(self, vpn_id: str) -> bool:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_user(self, vpn_id: str) -> VPNUserInfo | None:
|
||||
...
|
||||
71
app/providers/mock.py
Normal file
71
app/providers/mock.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.providers.base import VPNProvider, VPNUserInfo
|
||||
|
||||
|
||||
class MockProvider(VPNProvider):
|
||||
def __init__(self) -> None:
|
||||
self._store: dict[str, VPNUserInfo] = {}
|
||||
|
||||
async def create_user(
|
||||
self,
|
||||
internal_id: int,
|
||||
server_id: int,
|
||||
name: str,
|
||||
) -> VPNUserInfo:
|
||||
vpn_id = str(uuid.uuid4())
|
||||
info = VPNUserInfo(
|
||||
vpn_id=vpn_id,
|
||||
internal_id=internal_id,
|
||||
server_id=server_id,
|
||||
name=name,
|
||||
is_enabled=True,
|
||||
access_config=(
|
||||
f"mock://{vpn_id}/server/{server_id}/user/{internal_id}"
|
||||
),
|
||||
bytes_used=0,
|
||||
data_limit=None,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
self._store[vpn_id] = info
|
||||
logger.debug(
|
||||
"[MockProvider] User created: vpn_id={} internal_id={} server={}",
|
||||
vpn_id, internal_id, server_id,
|
||||
)
|
||||
return info
|
||||
|
||||
async def delete_user(self, vpn_id: str) -> bool:
|
||||
if vpn_id in self._store:
|
||||
del self._store[vpn_id]
|
||||
logger.debug("[MockProvider] User deleted: vpn_id={}", vpn_id)
|
||||
return True
|
||||
logger.warning("[MockProvider] User not found for delete: {}", vpn_id)
|
||||
return False
|
||||
|
||||
async def enable_user(self, vpn_id: str) -> bool:
|
||||
info = self._store.get(vpn_id)
|
||||
if info is None:
|
||||
logger.warning("[MockProvider] User not found for enable: {}", vpn_id)
|
||||
return False
|
||||
info.is_enabled = True
|
||||
logger.debug("[MockProvider] User enabled: vpn_id={}", vpn_id)
|
||||
return True
|
||||
|
||||
async def disable_user(self, vpn_id: str) -> bool:
|
||||
info = self._store.get(vpn_id)
|
||||
if info is None:
|
||||
logger.warning("[MockProvider] User not found for disable: {}", vpn_id)
|
||||
return False
|
||||
info.is_enabled = False
|
||||
logger.debug("[MockProvider] User disabled: vpn_id={}", vpn_id)
|
||||
return True
|
||||
|
||||
async def get_user(self, vpn_id: str) -> VPNUserInfo | None:
|
||||
info = self._store.get(vpn_id)
|
||||
if info is None:
|
||||
logger.debug("[MockProvider] User not found: vpn_id={}", vpn_id)
|
||||
return None
|
||||
return info
|
||||
0
app/providers/outline.py
Normal file
0
app/providers/outline.py
Normal file
0
app/providers/wireguard.py
Normal file
0
app/providers/wireguard.py
Normal file
0
app/providers/xray.py
Normal file
0
app/providers/xray.py
Normal file
17
app/repositories/__init__.py
Normal file
17
app/repositories/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from app.repositories.base import BaseRepository
|
||||
from app.repositories.user import UserRepository
|
||||
from app.repositories.payment import PaymentRepository
|
||||
from app.repositories.server import ServerRepository
|
||||
from app.repositories.tariff import TariffRepository
|
||||
from app.repositories.notification import NotificationRepository
|
||||
from app.repositories.admin import AdminRepository
|
||||
|
||||
__all__ = [
|
||||
"BaseRepository",
|
||||
"UserRepository",
|
||||
"PaymentRepository",
|
||||
"ServerRepository",
|
||||
"TariffRepository",
|
||||
"NotificationRepository",
|
||||
"AdminRepository",
|
||||
]
|
||||
19
app/repositories/admin.py
Normal file
19
app/repositories/admin.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.admin import Admin
|
||||
from app.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class AdminRepository(BaseRepository[Admin]):
|
||||
def __init__(self, session):
|
||||
super().__init__(session, Admin)
|
||||
|
||||
async def get_by_telegram_id(self, telegram_id: int) -> Admin | None:
|
||||
stmt = select(Admin).where(Admin.telegram_id == telegram_id)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_active_admins(self) -> list[Admin]:
|
||||
stmt = select(Admin).where(Admin.is_active.is_(True))
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
71
app/repositories/base.py
Normal file
71
app/repositories/base.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import Base
|
||||
|
||||
ModelType = TypeVar("ModelType", bound=Base)
|
||||
|
||||
|
||||
class BaseRepository(Generic[ModelType]):
|
||||
def __init__(self, session: AsyncSession, model: type[ModelType]):
|
||||
self.session = session
|
||||
self.model = model
|
||||
|
||||
async def create(self, **kwargs: Any) -> ModelType:
|
||||
instance = self.model(**kwargs)
|
||||
self.session.add(instance)
|
||||
await self.session.flush()
|
||||
return instance
|
||||
|
||||
async def get(self, id: int) -> ModelType | None:
|
||||
return await self.session.get(self.model, id)
|
||||
|
||||
async def get_all(
|
||||
self,
|
||||
*,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
**filters: Any,
|
||||
) -> list[ModelType]:
|
||||
stmt = select(self.model)
|
||||
|
||||
for field_name, value in filters.items():
|
||||
if value is not None:
|
||||
column = getattr(self.model, field_name, None)
|
||||
if column is not None:
|
||||
stmt = stmt.where(column == value)
|
||||
|
||||
stmt = stmt.offset(skip).limit(limit)
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def update(self, id: int, **kwargs: Any) -> ModelType | None:
|
||||
instance = await self.get(id)
|
||||
if instance is None:
|
||||
return None
|
||||
for field, value in kwargs.items():
|
||||
setattr(instance, field, value)
|
||||
await self.session.flush()
|
||||
return instance
|
||||
|
||||
async def delete(self, id: int) -> bool:
|
||||
instance = await self.get(id)
|
||||
if instance is None:
|
||||
return False
|
||||
await self.session.delete(instance)
|
||||
await self.session.flush()
|
||||
return True
|
||||
|
||||
async def count(self, **filters: Any) -> int:
|
||||
stmt = select(func.count(self.model.id))
|
||||
|
||||
for field_name, value in filters.items():
|
||||
if value is not None:
|
||||
column = getattr(self.model, field_name, None)
|
||||
if column is not None:
|
||||
stmt = stmt.where(column == value)
|
||||
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one()
|
||||
70
app/repositories/notification.py
Normal file
70
app/repositories/notification.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app.models.notification import Notification, NotificationType
|
||||
from app.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class NotificationRepository(BaseRepository[Notification]):
|
||||
def __init__(self, session):
|
||||
super().__init__(session, Notification)
|
||||
|
||||
async def get_by_user_id(self, user_id: int) -> list[Notification]:
|
||||
stmt = (
|
||||
select(Notification)
|
||||
.where(Notification.user_id == user_id)
|
||||
.order_by(Notification.created_at.desc())
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_unread(self, user_id: int) -> list[Notification]:
|
||||
stmt = (
|
||||
select(Notification)
|
||||
.where(Notification.user_id == user_id)
|
||||
.where(Notification.is_read.is_(False))
|
||||
.order_by(Notification.created_at.desc())
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_by_type(
|
||||
self, user_id: int, type: NotificationType
|
||||
) -> list[Notification]:
|
||||
stmt = (
|
||||
select(Notification)
|
||||
.where(Notification.user_id == user_id)
|
||||
.where(Notification.type == type)
|
||||
.order_by(Notification.created_at.desc())
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def mark_as_read(self, notification_id: int) -> bool:
|
||||
stmt = (
|
||||
update(Notification)
|
||||
.where(Notification.id == notification_id)
|
||||
.values(is_read=True)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
await self.session.flush()
|
||||
return result.rowcount > 0
|
||||
|
||||
async def mark_all_as_read(self, user_id: int) -> int:
|
||||
stmt = (
|
||||
update(Notification)
|
||||
.where(Notification.user_id == user_id)
|
||||
.where(Notification.is_read.is_(False))
|
||||
.values(is_read=True)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
await self.session.flush()
|
||||
return result.rowcount
|
||||
|
||||
async def count_unread(self, user_id: int) -> int:
|
||||
stmt = (
|
||||
select(Notification)
|
||||
.where(Notification.user_id == user_id)
|
||||
.where(Notification.is_read.is_(False))
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return len(result.scalars().all())
|
||||
67
app/repositories/payment.py
Normal file
67
app/repositories/payment.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.payment import Payment, PaymentStatus
|
||||
from app.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class PaymentRepository(BaseRepository[Payment]):
|
||||
def __init__(self, session):
|
||||
super().__init__(session, Payment)
|
||||
|
||||
async def get_by_user_id(self, user_id: int) -> list[Payment]:
|
||||
stmt = (
|
||||
select(Payment)
|
||||
.where(Payment.user_id == user_id)
|
||||
.order_by(Payment.created_at.desc())
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_by_status(self, status: PaymentStatus) -> list[Payment]:
|
||||
stmt = (
|
||||
select(Payment)
|
||||
.where(Payment.status == status)
|
||||
.order_by(Payment.created_at.desc())
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_by_external_id(self, external_id: str) -> Payment | None:
|
||||
stmt = select(Payment).where(Payment.external_id == external_id)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_date_range(
|
||||
self, start: datetime, end: datetime
|
||||
) -> list[Payment]:
|
||||
stmt = (
|
||||
select(Payment)
|
||||
.where(Payment.created_at >= start)
|
||||
.where(Payment.created_at <= end)
|
||||
.order_by(Payment.created_at.desc())
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_pending_payments(self) -> list[Payment]:
|
||||
stmt = (
|
||||
select(Payment)
|
||||
.where(Payment.status == PaymentStatus.PENDING)
|
||||
.order_by(Payment.created_at.asc())
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_by_user_and_status(
|
||||
self, user_id: int, status: PaymentStatus
|
||||
) -> list[Payment]:
|
||||
stmt = (
|
||||
select(Payment)
|
||||
.where(Payment.user_id == user_id)
|
||||
.where(Payment.status == status)
|
||||
.order_by(Payment.created_at.desc())
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
53
app/repositories/server.py
Normal file
53
app/repositories/server.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.server import Server, ServerProtocol
|
||||
from app.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class ServerRepository(BaseRepository[Server]):
|
||||
def __init__(self, session):
|
||||
super().__init__(session, Server)
|
||||
|
||||
async def get_active(self) -> list[Server]:
|
||||
stmt = select(Server).where(Server.is_active.is_(True))
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_by_protocol(self, protocol: ServerProtocol) -> list[Server]:
|
||||
stmt = (
|
||||
select(Server)
|
||||
.where(Server.protocol == protocol)
|
||||
.where(Server.is_active.is_(True))
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_by_location(self, country_code: str) -> list[Server]:
|
||||
stmt = (
|
||||
select(Server)
|
||||
.where(Server.country_code == country_code.upper())
|
||||
.where(Server.is_active.is_(True))
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_by_name(self, name: str) -> Server | None:
|
||||
stmt = select(Server).where(Server.name == name)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_least_loaded(self, protocol: ServerProtocol) -> Server | None:
|
||||
stmt = (
|
||||
select(Server)
|
||||
.where(Server.protocol == protocol)
|
||||
.where(Server.is_active.is_(True))
|
||||
.order_by(Server.load_percent.asc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def count_active(self) -> int:
|
||||
stmt = select(Server).where(Server.is_active.is_(True))
|
||||
result = await self.session.execute(stmt)
|
||||
return len(result.scalars().all())
|
||||
46
app/repositories/tariff.py
Normal file
46
app/repositories/tariff.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.tariff import Tariff
|
||||
from app.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class TariffRepository(BaseRepository[Tariff]):
|
||||
def __init__(self, session):
|
||||
super().__init__(session, Tariff)
|
||||
|
||||
async def get_active_tariffs(self) -> list[Tariff]:
|
||||
stmt = (
|
||||
select(Tariff)
|
||||
.where(Tariff.is_active.is_(True))
|
||||
.order_by(Tariff.price.asc())
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_by_name(self, name: str) -> Tariff | None:
|
||||
stmt = select(Tariff).where(Tariff.name == name)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_price_range(
|
||||
self, min_price: float, max_price: float
|
||||
) -> list[Tariff]:
|
||||
stmt = (
|
||||
select(Tariff)
|
||||
.where(Tariff.price >= min_price)
|
||||
.where(Tariff.price <= max_price)
|
||||
.where(Tariff.is_active.is_(True))
|
||||
.order_by(Tariff.price.asc())
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_cheapest(self) -> Tariff | None:
|
||||
stmt = (
|
||||
select(Tariff)
|
||||
.where(Tariff.is_active.is_(True))
|
||||
.order_by(Tariff.price.asc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
37
app/repositories/user.py
Normal file
37
app/repositories/user.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.user import User
|
||||
from app.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class UserRepository(BaseRepository[User]):
|
||||
def __init__(self, session):
|
||||
super().__init__(session, User)
|
||||
|
||||
async def get_by_telegram_id(self, telegram_id: int) -> User | None:
|
||||
stmt = select(User).where(User.telegram_id == telegram_id)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_active_users(self) -> list[User]:
|
||||
stmt = select(User).where(User.is_active.is_(True))
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_by_username(self, username: str) -> list[User]:
|
||||
stmt = select(User).where(User.username.ilike(username))
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_by_created_range(
|
||||
self, start: datetime, end: datetime
|
||||
) -> list[User]:
|
||||
stmt = (
|
||||
select(User)
|
||||
.where(User.created_at >= start)
|
||||
.where(User.created_at <= end)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
3
app/scheduler/__init__.py
Normal file
3
app/scheduler/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.scheduler.scheduler import setup_scheduler
|
||||
|
||||
__all__ = ["setup_scheduler"]
|
||||
49
app/scheduler/scheduler.py
Normal file
49
app/scheduler/scheduler.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from loguru import logger
|
||||
|
||||
from app.scheduler.tasks.expire_subscriptions import expire_subscriptions
|
||||
from app.scheduler.tasks.send_reminders import send_reminders
|
||||
from app.scheduler.tasks.revoke_expired import revoke_expired
|
||||
from app.scheduler.tasks.sync_servers import sync_servers
|
||||
|
||||
|
||||
def setup_scheduler() -> AsyncIOScheduler:
|
||||
scheduler = AsyncIOScheduler(timezone="UTC")
|
||||
|
||||
daily = CronTrigger(hour=3, minute=0)
|
||||
|
||||
scheduler.add_job(
|
||||
expire_subscriptions,
|
||||
trigger=daily,
|
||||
id="expire_subscriptions",
|
||||
name="Check and expire subscriptions",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
scheduler.add_job(
|
||||
send_reminders,
|
||||
trigger=daily,
|
||||
id="send_reminders",
|
||||
name="Send subscription expiry reminders",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
scheduler.add_job(
|
||||
revoke_expired,
|
||||
trigger=daily,
|
||||
id="revoke_expired",
|
||||
name="Revoke VPN access for expired users",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
scheduler.add_job(
|
||||
sync_servers,
|
||||
trigger=daily,
|
||||
id="sync_servers",
|
||||
name="Sync server status and health",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
logger.info("Scheduler configured with {} daily jobs", len(scheduler.get_jobs()))
|
||||
return scheduler
|
||||
11
app/scheduler/tasks/__init__.py
Normal file
11
app/scheduler/tasks/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from app.scheduler.tasks.expire_subscriptions import expire_subscriptions
|
||||
from app.scheduler.tasks.send_reminders import send_reminders
|
||||
from app.scheduler.tasks.revoke_expired import revoke_expired
|
||||
from app.scheduler.tasks.sync_servers import sync_servers
|
||||
|
||||
__all__ = [
|
||||
"expire_subscriptions",
|
||||
"send_reminders",
|
||||
"revoke_expired",
|
||||
"sync_servers",
|
||||
]
|
||||
61
app/scheduler/tasks/expire_subscriptions.py
Normal file
61
app/scheduler/tasks/expire_subscriptions.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from loguru import logger
|
||||
|
||||
from app.database import async_session_factory
|
||||
from app.models.payment import PaymentStatus
|
||||
from app.repositories.notification import NotificationRepository
|
||||
from app.repositories.payment import PaymentRepository
|
||||
from app.repositories.tariff import TariffRepository
|
||||
from app.repositories.user import UserRepository
|
||||
from app.services.billing import BillingService
|
||||
from app.services.notification import NotificationService
|
||||
from app.services.user import UserService
|
||||
|
||||
|
||||
async def expire_subscriptions() -> None:
|
||||
logger.info("[Scheduler] Starting subscription expiry check")
|
||||
|
||||
async with async_session_factory() as session:
|
||||
payment_repo = PaymentRepository(session)
|
||||
tariff_repo = TariffRepository(session)
|
||||
user_repo = UserRepository(session)
|
||||
|
||||
billing = BillingService(payment_repo, tariff_repo, user_repo)
|
||||
user_service = UserService(user_repo)
|
||||
notification_service = NotificationService(
|
||||
NotificationRepository(session), user_repo
|
||||
)
|
||||
|
||||
expired_ids = await billing.expire_subscriptions()
|
||||
|
||||
if not expired_ids:
|
||||
logger.info("[Scheduler] No expired subscriptions found")
|
||||
return
|
||||
|
||||
for user_id in set(expired_ids):
|
||||
user = await user_service.get_by_id(user_id)
|
||||
if user is None:
|
||||
continue
|
||||
|
||||
tariff = None
|
||||
expired_payments = await payment_repo.get_by_user_and_status(
|
||||
user_id=user_id, status=PaymentStatus.CONFIRMED
|
||||
)
|
||||
if expired_payments:
|
||||
tariff = await tariff_repo.get(expired_payments[0].tariff_id)
|
||||
|
||||
await user_service.deactivate(user_id)
|
||||
await notification_service.send_subscription_expired(
|
||||
user_id=user_id,
|
||||
tariff_name=tariff.name if tariff else None,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[Scheduler] User {} deactivated, expired notification sent",
|
||||
user_id,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[Scheduler] Expired {} subscriptions, deactivated {} users",
|
||||
len(expired_ids),
|
||||
len(set(expired_ids)),
|
||||
)
|
||||
48
app/scheduler/tasks/revoke_expired.py
Normal file
48
app/scheduler/tasks/revoke_expired.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from loguru import logger
|
||||
|
||||
from app.database import async_session_factory
|
||||
from app.repositories.payment import PaymentRepository
|
||||
from app.repositories.server import ServerRepository
|
||||
from app.repositories.tariff import TariffRepository
|
||||
from app.repositories.user import UserRepository
|
||||
from app.services.billing import BillingService
|
||||
from app.services.vpn import VPNService
|
||||
|
||||
|
||||
async def revoke_expired() -> None:
|
||||
logger.info("[Scheduler] Revoking VPN access for expired users")
|
||||
|
||||
async with async_session_factory() as session:
|
||||
payment_repo = PaymentRepository(session)
|
||||
tariff_repo = TariffRepository(session)
|
||||
user_repo = UserRepository(session)
|
||||
server_repo = ServerRepository(session)
|
||||
|
||||
billing = BillingService(payment_repo, tariff_repo, user_repo)
|
||||
vpn = VPNService(server_repo, user_repo)
|
||||
|
||||
expired_ids = await billing.expire_subscriptions()
|
||||
revoked_count = 0
|
||||
|
||||
for user_id in set(expired_ids):
|
||||
user = await user_repo.get(user_id)
|
||||
if user is None:
|
||||
continue
|
||||
|
||||
server = await vpn.get_user_server(user_id)
|
||||
if server is not None:
|
||||
logger.info(
|
||||
"[Scheduler] VPN access revoked: user={} server={}",
|
||||
user_id,
|
||||
server.name,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"[Scheduler] No VPN server assigned for user={}", user_id
|
||||
)
|
||||
revoked_count += 1
|
||||
|
||||
logger.info(
|
||||
"[Scheduler] Processed {} expired users for VPN revocation",
|
||||
revoked_count,
|
||||
)
|
||||
48
app/scheduler/tasks/send_reminders.py
Normal file
48
app/scheduler/tasks/send_reminders.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.database import async_session_factory
|
||||
from app.models.payment import PaymentStatus
|
||||
from app.repositories.notification import NotificationRepository
|
||||
from app.repositories.payment import PaymentRepository
|
||||
from app.repositories.tariff import TariffRepository
|
||||
from app.repositories.user import UserRepository
|
||||
from app.services.notification import NotificationService
|
||||
|
||||
|
||||
async def send_reminders() -> None:
|
||||
logger.info("[Scheduler] Sending subscription expiry reminders")
|
||||
|
||||
async with async_session_factory() as session:
|
||||
payment_repo = PaymentRepository(session)
|
||||
tariff_repo = TariffRepository(session)
|
||||
user_repo = UserRepository(session)
|
||||
notification_service = NotificationService(
|
||||
NotificationRepository(session), user_repo
|
||||
)
|
||||
|
||||
confirmed = await payment_repo.get_by_status(PaymentStatus.CONFIRMED)
|
||||
now = datetime.now(timezone.utc)
|
||||
reminders_sent = 0
|
||||
|
||||
for payment in confirmed:
|
||||
tariff = await tariff_repo.get(payment.tariff_id)
|
||||
if tariff is None:
|
||||
continue
|
||||
|
||||
start = payment.paid_at or payment.created_at
|
||||
end = start + timedelta(days=tariff.duration_days)
|
||||
remaining = (end - now).days
|
||||
|
||||
result = await notification_service.send_expiry_reminder(
|
||||
user_id=payment.user_id,
|
||||
remaining_days=remaining,
|
||||
tariff_name=tariff.name,
|
||||
)
|
||||
if result is not None:
|
||||
reminders_sent += 1
|
||||
|
||||
logger.info(
|
||||
"[Scheduler] Sent {} expiry reminders", reminders_sent
|
||||
)
|
||||
33
app/scheduler/tasks/sync_servers.py
Normal file
33
app/scheduler/tasks/sync_servers.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from loguru import logger
|
||||
|
||||
from app.database import async_session_factory
|
||||
from app.repositories.server import ServerRepository
|
||||
from app.services.server import ServerService
|
||||
|
||||
|
||||
async def sync_servers() -> None:
|
||||
logger.info("[Scheduler] Syncing server status")
|
||||
|
||||
async with async_session_factory() as session:
|
||||
server_repo = ServerRepository(session)
|
||||
server_service = ServerService(server_repo)
|
||||
|
||||
active = await server_service.get_active()
|
||||
|
||||
if not active:
|
||||
logger.info("[Scheduler] No active servers found")
|
||||
return
|
||||
|
||||
for server in active:
|
||||
logger.info(
|
||||
"[Scheduler] Server {}: {}:{}, protocol={}, load={}%",
|
||||
server.name,
|
||||
server.host,
|
||||
server.port,
|
||||
server.protocol.value,
|
||||
server.load_percent,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[Scheduler] Synced {} active servers", len(active)
|
||||
)
|
||||
22
app/schemas/__init__.py
Normal file
22
app/schemas/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from app.schemas.user import UserRead, UserUpdate
|
||||
from app.schemas.payment import PaymentRead, PaymentCreate, PaymentConfirm
|
||||
from app.schemas.tariff import TariffRead
|
||||
from app.schemas.server import ServerRead, ServerCreate, ServerUpdate
|
||||
from app.schemas.stats import StatsResponse
|
||||
from app.schemas.auth import LoginRequest, RefreshRequest, TokenResponse
|
||||
|
||||
__all__ = [
|
||||
"UserRead",
|
||||
"UserUpdate",
|
||||
"PaymentRead",
|
||||
"PaymentCreate",
|
||||
"PaymentConfirm",
|
||||
"TariffRead",
|
||||
"ServerRead",
|
||||
"ServerCreate",
|
||||
"ServerUpdate",
|
||||
"StatsResponse",
|
||||
"LoginRequest",
|
||||
"RefreshRequest",
|
||||
"TokenResponse",
|
||||
]
|
||||
16
app/schemas/auth.py
Normal file
16
app/schemas/auth.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
telegram_id: int
|
||||
secret_key: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
31
app/schemas/payment.py
Normal file
31
app/schemas/payment.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class PaymentRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
tariff_id: int
|
||||
amount: float
|
||||
currency: str = "RUB"
|
||||
status: str
|
||||
provider: str
|
||||
external_id: str | None = None
|
||||
created_at: datetime
|
||||
paid_at: datetime | None = None
|
||||
|
||||
|
||||
class PaymentCreate(BaseModel):
|
||||
user_id: int
|
||||
tariff_id: int
|
||||
provider: str
|
||||
amount: float
|
||||
currency: str = "RUB"
|
||||
external_id: str | None = None
|
||||
|
||||
|
||||
class PaymentConfirm(BaseModel):
|
||||
external_id: str | None = None
|
||||
41
app/schemas/server.py
Normal file
41
app/schemas/server.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class ServerRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
host: str
|
||||
port: int
|
||||
protocol: str
|
||||
location: str
|
||||
country_code: str
|
||||
load_percent: int = 0
|
||||
is_active: bool = True
|
||||
max_users: int = 100
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ServerCreate(BaseModel):
|
||||
name: str
|
||||
host: str
|
||||
port: int
|
||||
protocol: str
|
||||
location: str
|
||||
country_code: str
|
||||
max_users: int = 100
|
||||
|
||||
|
||||
class ServerUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
host: str | None = None
|
||||
port: int | None = None
|
||||
protocol: str | None = None
|
||||
location: str | None = None
|
||||
country_code: str | None = None
|
||||
load_percent: int | None = None
|
||||
is_active: bool | None = None
|
||||
max_users: int | None = None
|
||||
14
app/schemas/stats.py
Normal file
14
app/schemas/stats.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class StatsResponse(BaseModel):
|
||||
total_users: int = 0
|
||||
active_users: int = 0
|
||||
total_tariffs: int = 0
|
||||
active_tariffs: int = 0
|
||||
total_servers: int = 0
|
||||
active_servers: int = 0
|
||||
total_payments: int = 0
|
||||
confirmed_payments: int = 0
|
||||
pending_payments: int = 0
|
||||
total_revenue: float = 0.0
|
||||
0
app/schemas/subscription.py
Normal file
0
app/schemas/subscription.py
Normal file
17
app/schemas/tariff.py
Normal file
17
app/schemas/tariff.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class TariffRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
duration_days: int
|
||||
price: float
|
||||
currency: str = "RUB"
|
||||
max_devices: int = 1
|
||||
traffic_gb: int | None = None
|
||||
is_active: bool = True
|
||||
created_at: datetime
|
||||
22
app/schemas/user.py
Normal file
22
app/schemas/user.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class UserRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
telegram_id: int
|
||||
username: str | None = None
|
||||
full_name: str
|
||||
language_code: str = "ru"
|
||||
is_active: bool = True
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
username: str | None = None
|
||||
full_name: str | None = None
|
||||
language_code: str | None = None
|
||||
0
app/schemas/vpn.py
Normal file
0
app/schemas/vpn.py
Normal file
18
app/services/__init__.py
Normal file
18
app/services/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from app.services.user import UserService
|
||||
from app.services.billing import BillingService
|
||||
from app.services.payment import PaymentService
|
||||
from app.services.notification import NotificationService
|
||||
from app.services.server import ServerService
|
||||
from app.services.vpn import VPNService
|
||||
from app.services.stats import StatsService, SystemStats
|
||||
|
||||
__all__ = [
|
||||
"UserService",
|
||||
"BillingService",
|
||||
"PaymentService",
|
||||
"NotificationService",
|
||||
"ServerService",
|
||||
"VPNService",
|
||||
"StatsService",
|
||||
"SystemStats",
|
||||
]
|
||||
90
app/services/auth.py
Normal file
90
app/services/auth.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from loguru import logger
|
||||
|
||||
from app.models.admin import Admin
|
||||
from app.repositories.admin import AdminRepository
|
||||
from app.settings import settings
|
||||
|
||||
|
||||
class AuthService:
|
||||
def __init__(self, admin_repo: AdminRepository):
|
||||
self.admin_repo = admin_repo
|
||||
|
||||
def create_access_token(self, telegram_id: int, role: str) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": str(telegram_id),
|
||||
"role": role,
|
||||
"type": "access",
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int(
|
||||
(now + timedelta(minutes=settings.jwt_access_expire_minutes)).timestamp()
|
||||
),
|
||||
}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||
|
||||
def create_refresh_token(self, telegram_id: int) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": str(telegram_id),
|
||||
"type": "refresh",
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int(
|
||||
(now + timedelta(days=settings.jwt_refresh_expire_days)).timestamp()
|
||||
),
|
||||
}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||
|
||||
def decode_token(self, token: str) -> dict:
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]
|
||||
)
|
||||
return payload
|
||||
except JWTError as e:
|
||||
logger.warning("JWT decode failed: {}", e)
|
||||
raise ValueError("Invalid or expired token") from e
|
||||
|
||||
async def authenticate(self, telegram_id: int, secret_key: str) -> dict | None:
|
||||
if secret_key != settings.jwt_secret:
|
||||
logger.warning("Auth failed: invalid secret key for tg={}", telegram_id)
|
||||
return None
|
||||
|
||||
admin = await self.admin_repo.get_by_telegram_id(telegram_id)
|
||||
if admin is None:
|
||||
logger.warning("Auth failed: admin not found tg={}", telegram_id)
|
||||
return None
|
||||
|
||||
if not admin.is_active:
|
||||
logger.warning("Auth failed: admin deactivated tg={}", telegram_id)
|
||||
return None
|
||||
|
||||
access_token = self.create_access_token(telegram_id, admin.role.value)
|
||||
refresh_token = self.create_refresh_token(telegram_id)
|
||||
|
||||
logger.info("Admin logged in: tg={} role={}", telegram_id, admin.role.value)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "bearer",
|
||||
}
|
||||
|
||||
def refresh_access_token(self, refresh_token: str) -> dict:
|
||||
payload = self.decode_token(refresh_token)
|
||||
|
||||
if payload.get("type") != "refresh":
|
||||
raise ValueError("Invalid token type")
|
||||
|
||||
telegram_id = int(payload["sub"])
|
||||
role = payload.get("role", "moderator")
|
||||
|
||||
new_access = self.create_access_token(telegram_id, role)
|
||||
|
||||
return {
|
||||
"access_token": new_access,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "bearer",
|
||||
}
|
||||
149
app/services/billing.py
Normal file
149
app/services/billing.py
Normal file
@@ -0,0 +1,149 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.models.payment import PaymentStatus
|
||||
from app.models.tariff import Tariff
|
||||
from app.models.user import User
|
||||
from app.repositories.payment import PaymentRepository
|
||||
from app.repositories.tariff import TariffRepository
|
||||
from app.repositories.user import UserRepository
|
||||
|
||||
|
||||
class SubscriptionInfo:
|
||||
def __init__(
|
||||
self,
|
||||
is_active: bool,
|
||||
tariff: Tariff | None = None,
|
||||
start_date: datetime | None = None,
|
||||
end_date: datetime | None = None,
|
||||
remaining_days: int = 0,
|
||||
auto_renew: bool = False,
|
||||
):
|
||||
self.is_active = is_active
|
||||
self.tariff = tariff
|
||||
self.start_date = start_date
|
||||
self.end_date = end_date
|
||||
self.remaining_days = remaining_days
|
||||
self.auto_renew = auto_renew
|
||||
|
||||
|
||||
class BillingService:
|
||||
def __init__(
|
||||
self,
|
||||
payment_repo: PaymentRepository,
|
||||
tariff_repo: TariffRepository,
|
||||
user_repo: UserRepository,
|
||||
):
|
||||
self.payment_repo = payment_repo
|
||||
self.tariff_repo = tariff_repo
|
||||
self.user_repo = user_repo
|
||||
|
||||
async def activate_subscription(
|
||||
self, user_id: int, tariff_id: int, payment_id: int
|
||||
) -> SubscriptionInfo:
|
||||
user = await self.user_repo.get(user_id)
|
||||
if user is None:
|
||||
raise ValueError(f"User not found: {user_id}")
|
||||
|
||||
tariff = await self.tariff_repo.get(tariff_id)
|
||||
if tariff is None:
|
||||
raise ValueError(f"Tariff not found: {tariff_id}")
|
||||
|
||||
payment = await self.payment_repo.get(payment_id)
|
||||
if payment is None:
|
||||
raise ValueError(f"Payment not found: {payment_id}")
|
||||
|
||||
if payment.status != PaymentStatus.CONFIRMED:
|
||||
raise ValueError(f"Payment {payment_id} is not confirmed")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
end_date = now + timedelta(days=tariff.duration_days)
|
||||
|
||||
logger.info(
|
||||
"Subscription activated: user={} tariff={} until={}",
|
||||
user_id,
|
||||
tariff.name,
|
||||
end_date.date(),
|
||||
)
|
||||
|
||||
return self._build_subscription_info(tariff, now, end_date)
|
||||
|
||||
async def get_active_subscription(self, user_id: int) -> SubscriptionInfo | None:
|
||||
user = await self.user_repo.get(user_id)
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
confirmed = await self.payment_repo.get_by_user_and_status(
|
||||
user_id, PaymentStatus.CONFIRMED
|
||||
)
|
||||
if not confirmed:
|
||||
return SubscriptionInfo(is_active=False)
|
||||
|
||||
latest = confirmed[0]
|
||||
tariff = await self.tariff_repo.get(latest.tariff_id)
|
||||
if tariff is None:
|
||||
return SubscriptionInfo(is_active=False)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
start = latest.paid_at or latest.created_at
|
||||
end = start + timedelta(days=tariff.duration_days)
|
||||
|
||||
if now > end:
|
||||
return SubscriptionInfo(
|
||||
is_active=False,
|
||||
tariff=tariff,
|
||||
start_date=start,
|
||||
end_date=end,
|
||||
remaining_days=0,
|
||||
)
|
||||
|
||||
remaining = (end - now).days
|
||||
return SubscriptionInfo(
|
||||
is_active=True,
|
||||
tariff=tariff,
|
||||
start_date=start,
|
||||
end_date=end,
|
||||
remaining_days=remaining,
|
||||
)
|
||||
|
||||
async def has_active_subscription(self, user_id: int) -> bool:
|
||||
info = await self.get_active_subscription(user_id)
|
||||
return info is not None and info.is_active
|
||||
|
||||
async def expire_subscriptions(self) -> list[int]:
|
||||
expired_user_ids: list[int] = []
|
||||
|
||||
confirmed = await self.payment_repo.get_by_status(PaymentStatus.CONFIRMED)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for payment in confirmed:
|
||||
tariff = await self.tariff_repo.get(payment.tariff_id)
|
||||
if tariff is None:
|
||||
continue
|
||||
|
||||
start = payment.paid_at or payment.created_at
|
||||
end = start + timedelta(days=tariff.duration_days)
|
||||
|
||||
if now > end:
|
||||
expired_user_ids.append(payment.user_id)
|
||||
logger.info(
|
||||
"Subscription expired: user={} payment={}",
|
||||
payment.user_id,
|
||||
payment.id,
|
||||
)
|
||||
|
||||
return expired_user_ids
|
||||
|
||||
@staticmethod
|
||||
def _build_subscription_info(
|
||||
tariff: Tariff, start: datetime, end: datetime
|
||||
) -> SubscriptionInfo:
|
||||
remaining = max(0, (end - datetime.now(timezone.utc)).days)
|
||||
return SubscriptionInfo(
|
||||
is_active=True,
|
||||
tariff=tariff,
|
||||
start_date=start,
|
||||
end_date=end,
|
||||
remaining_days=remaining,
|
||||
)
|
||||
202
app/services/notification.py
Normal file
202
app/services/notification.py
Normal file
@@ -0,0 +1,202 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramAPIError
|
||||
from loguru import logger
|
||||
|
||||
from app.models.notification import Notification, NotificationType
|
||||
from app.repositories.notification import NotificationRepository
|
||||
from app.repositories.user import UserRepository
|
||||
from app.settings import settings
|
||||
|
||||
|
||||
_REMINDER_DAYS = (7, 3, 1)
|
||||
|
||||
_DAYS_LABEL = {7: "7 days", 3: "3 days", 1: "1 day"}
|
||||
|
||||
|
||||
class NotificationService:
|
||||
def __init__(
|
||||
self,
|
||||
notification_repo: NotificationRepository,
|
||||
user_repo: UserRepository,
|
||||
bot: Bot | None = None,
|
||||
):
|
||||
self.notification_repo = notification_repo
|
||||
self.user_repo = user_repo
|
||||
self._bot = bot
|
||||
|
||||
def _get_bot(self) -> Bot | None:
|
||||
if self._bot is None and settings.bot_token:
|
||||
self._bot = Bot(token=settings.bot_token)
|
||||
return self._bot
|
||||
|
||||
async def send(
|
||||
self,
|
||||
user_id: int,
|
||||
title: str,
|
||||
text: str,
|
||||
type: NotificationType = NotificationType.INFO,
|
||||
) -> Notification:
|
||||
user = await self.user_repo.get(user_id)
|
||||
if user is None:
|
||||
raise ValueError(f"User not found: {user_id}")
|
||||
|
||||
notification = await self.notification_repo.create(
|
||||
user_id=user_id,
|
||||
type=type,
|
||||
title=title,
|
||||
text=text,
|
||||
)
|
||||
|
||||
await self._deliver(user, text)
|
||||
|
||||
logger.debug(
|
||||
"Notification sent: user={} type={} title={}",
|
||||
user_id,
|
||||
type.value,
|
||||
title,
|
||||
)
|
||||
return notification
|
||||
|
||||
async def send_expiry_reminder(
|
||||
self, user_id: int, remaining_days: int, tariff_name: str
|
||||
) -> Notification | None:
|
||||
if remaining_days not in _REMINDER_DAYS:
|
||||
return None
|
||||
|
||||
user = await self.user_repo.get(user_id)
|
||||
if user is None:
|
||||
raise ValueError(f"User not found: {user_id}")
|
||||
|
||||
label = _DAYS_LABEL.get(remaining_days, f"{remaining_days} days")
|
||||
|
||||
notification = await self.notification_repo.create(
|
||||
user_id=user_id,
|
||||
type=NotificationType.WARNING,
|
||||
title="Subscription Expiring Soon",
|
||||
text=(
|
||||
f"Your subscription ({tariff_name}) expires in {label}.\n"
|
||||
"Please renew to avoid service interruption."
|
||||
),
|
||||
)
|
||||
|
||||
await self._deliver(
|
||||
user,
|
||||
f"⚠️ <b>Your {tariff_name} subscription expires in {label}.</b>\n"
|
||||
"Please renew to keep using the service.",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Expiry reminder sent: user={} tariff={} expires_in={}d",
|
||||
user_id,
|
||||
tariff_name,
|
||||
remaining_days,
|
||||
)
|
||||
return notification
|
||||
|
||||
async def send_subscription_expired(
|
||||
self, user_id: int, tariff_name: str | None = None
|
||||
) -> Notification:
|
||||
user = await self.user_repo.get(user_id)
|
||||
if user is None:
|
||||
raise ValueError(f"User not found: {user_id}")
|
||||
|
||||
plan = tariff_name or "VPN"
|
||||
notification = await self.notification_repo.create(
|
||||
user_id=user_id,
|
||||
type=NotificationType.WARNING,
|
||||
title="Subscription Expired",
|
||||
text=(
|
||||
f"Your {plan} subscription has expired.\n"
|
||||
"Please renew to restore access."
|
||||
),
|
||||
)
|
||||
|
||||
await self._deliver(
|
||||
user,
|
||||
f"❌ <b>Your {plan} subscription has expired.</b>\n"
|
||||
"Please renew to restore access.",
|
||||
)
|
||||
|
||||
logger.info("Expired notification sent: user={} tariff={}", user_id, plan)
|
||||
return notification
|
||||
|
||||
async def _deliver(self, user, text: str) -> None:
|
||||
bot = self._get_bot()
|
||||
if bot is None:
|
||||
logger.debug("No bot configured, notification not delivered")
|
||||
return
|
||||
|
||||
try:
|
||||
await bot.send_message(chat_id=user.telegram_id, text=text)
|
||||
except TelegramAPIError:
|
||||
logger.warning(
|
||||
"Failed to deliver to user {} (tg={}), fallback to admin group",
|
||||
user.id,
|
||||
user.telegram_id,
|
||||
)
|
||||
await self._deliver_to_admin(text, user)
|
||||
|
||||
async def _deliver_to_admin(self, text: str, user=None) -> None:
|
||||
bot = self._get_bot()
|
||||
if bot is None:
|
||||
return
|
||||
|
||||
chat_id = settings.admin_group_id
|
||||
if not chat_id:
|
||||
logger.debug("No admin group configured, notification dropped")
|
||||
return
|
||||
|
||||
prefix = (
|
||||
f"👤 User #{user.id} (@{user.username or '—'})\n\n"
|
||||
if user
|
||||
else ""
|
||||
)
|
||||
message_id = settings.admin_group_thread_id
|
||||
|
||||
try:
|
||||
await bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=prefix + text,
|
||||
message_thread_id=message_id,
|
||||
)
|
||||
except TelegramAPIError as e:
|
||||
logger.error("Failed to deliver to admin group: {}", e)
|
||||
|
||||
async def send_info(self, user_id: int, title: str, text: str) -> Notification:
|
||||
return await self.send(user_id, title, text, NotificationType.INFO)
|
||||
|
||||
async def send_warning(self, user_id: int, title: str, text: str) -> Notification:
|
||||
return await self.send(user_id, title, text, NotificationType.WARNING)
|
||||
|
||||
async def send_success(self, user_id: int, title: str, text: str) -> Notification:
|
||||
return await self.send(user_id, title, text, NotificationType.SUCCESS)
|
||||
|
||||
async def send_payment(
|
||||
self, user_id: int, title: str, text: str
|
||||
) -> Notification:
|
||||
return await self.send(user_id, title, text, NotificationType.PAYMENT)
|
||||
|
||||
async def get_user_notifications(
|
||||
self, user_id: int
|
||||
) -> list[Notification]:
|
||||
return await self.notification_repo.get_by_user_id(user_id)
|
||||
|
||||
async def get_unread(self, user_id: int) -> list[Notification]:
|
||||
return await self.notification_repo.get_unread(user_id)
|
||||
|
||||
async def get_unread_count(self, user_id: int) -> int:
|
||||
return await self.notification_repo.count_unread(user_id)
|
||||
|
||||
async def mark_read(self, notification_id: int) -> bool:
|
||||
result = await self.notification_repo.mark_as_read(notification_id)
|
||||
if result:
|
||||
logger.debug("Notification marked read: id={}", notification_id)
|
||||
return result
|
||||
|
||||
async def mark_all_read(self, user_id: int) -> int:
|
||||
count = await self.notification_repo.mark_all_as_read(user_id)
|
||||
if count:
|
||||
logger.debug("Marked {} notifications as read: user={}", count, user_id)
|
||||
return count
|
||||
109
app/services/payment.py
Normal file
109
app/services/payment.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.models.payment import Payment, PaymentStatus
|
||||
from app.repositories.payment import PaymentRepository
|
||||
from app.repositories.tariff import TariffRepository
|
||||
from app.repositories.user import UserRepository
|
||||
|
||||
|
||||
class PaymentService:
|
||||
def __init__(
|
||||
self,
|
||||
payment_repo: PaymentRepository,
|
||||
user_repo: UserRepository,
|
||||
tariff_repo: TariffRepository,
|
||||
):
|
||||
self.payment_repo = payment_repo
|
||||
self.user_repo = user_repo
|
||||
self.tariff_repo = tariff_repo
|
||||
|
||||
async def create(
|
||||
self,
|
||||
user_id: int,
|
||||
tariff_id: int,
|
||||
provider: str,
|
||||
amount: float,
|
||||
currency: str = "RUB",
|
||||
external_id: str | None = None,
|
||||
) -> Payment:
|
||||
user = await self.user_repo.get(user_id)
|
||||
if user is None:
|
||||
raise ValueError(f"User not found: {user_id}")
|
||||
|
||||
tariff = await self.tariff_repo.get(tariff_id)
|
||||
if tariff is None:
|
||||
raise ValueError(f"Tariff not found: {tariff_id}")
|
||||
|
||||
payment = await self.payment_repo.create(
|
||||
user_id=user_id,
|
||||
tariff_id=tariff_id,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
status=PaymentStatus.PENDING,
|
||||
provider=provider,
|
||||
external_id=external_id,
|
||||
)
|
||||
|
||||
logger.info("Payment created: id={} user={} amount={}", payment.id, user_id, amount)
|
||||
return payment
|
||||
|
||||
async def confirm(
|
||||
self, payment_id: int, external_id: str | None = None
|
||||
) -> Payment:
|
||||
payment = await self.payment_repo.get(payment_id)
|
||||
if payment is None:
|
||||
raise ValueError(f"Payment not found: {payment_id}")
|
||||
|
||||
if payment.status != PaymentStatus.PENDING:
|
||||
raise ValueError(
|
||||
f"Cannot confirm payment {payment_id}: "
|
||||
f"current status is {payment.status.value}"
|
||||
)
|
||||
|
||||
payment = await self.payment_repo.update(
|
||||
payment_id,
|
||||
status=PaymentStatus.CONFIRMED,
|
||||
paid_at=datetime.now(timezone.utc),
|
||||
external_id=external_id or payment.external_id,
|
||||
)
|
||||
|
||||
logger.info("Payment confirmed: id={} user={}", payment_id, payment.user_id)
|
||||
return payment
|
||||
|
||||
async def fail(self, payment_id: int) -> Payment:
|
||||
payment = await self.payment_repo.get(payment_id)
|
||||
if payment is None:
|
||||
raise ValueError(f"Payment not found: {payment_id}")
|
||||
|
||||
payment = await self.payment_repo.update(
|
||||
payment_id, status=PaymentStatus.FAILED
|
||||
)
|
||||
|
||||
logger.info("Payment failed: id={} user={}", payment_id, payment.user_id)
|
||||
return payment
|
||||
|
||||
async def refund(self, payment_id: int) -> Payment:
|
||||
payment = await self.payment_repo.get(payment_id)
|
||||
if payment is None:
|
||||
raise ValueError(f"Payment not found: {payment_id}")
|
||||
|
||||
payment = await self.payment_repo.update(
|
||||
payment_id, status=PaymentStatus.REFUNDED
|
||||
)
|
||||
|
||||
logger.info("Payment refunded: id={} user={}", payment_id, payment.user_id)
|
||||
return payment
|
||||
|
||||
async def get_by_id(self, payment_id: int) -> Payment | None:
|
||||
return await self.payment_repo.get(payment_id)
|
||||
|
||||
async def get_by_external_id(self, external_id: str) -> Payment | None:
|
||||
return await self.payment_repo.get_by_external_id(external_id)
|
||||
|
||||
async def get_user_payments(self, user_id: int) -> list[Payment]:
|
||||
return await self.payment_repo.get_by_user_id(user_id)
|
||||
|
||||
async def get_pending(self) -> list[Payment]:
|
||||
return await self.payment_repo.get_pending_payments()
|
||||
71
app/services/server.py
Normal file
71
app/services/server.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.models.server import Server, ServerProtocol
|
||||
from app.repositories.server import ServerRepository
|
||||
|
||||
|
||||
class ServerService:
|
||||
def __init__(self, server_repo: ServerRepository):
|
||||
self.server_repo = server_repo
|
||||
|
||||
async def create(
|
||||
self,
|
||||
name: str,
|
||||
host: str,
|
||||
port: int,
|
||||
protocol: ServerProtocol,
|
||||
location: str,
|
||||
country_code: str,
|
||||
max_users: int = 100,
|
||||
) -> Server:
|
||||
existing = await self.server_repo.get_by_name(name)
|
||||
if existing is not None:
|
||||
raise ValueError(f"Server already exists: {name}")
|
||||
|
||||
server = await self.server_repo.create(
|
||||
name=name,
|
||||
host=host,
|
||||
port=port,
|
||||
protocol=protocol,
|
||||
location=location,
|
||||
country_code=country_code.upper(),
|
||||
max_users=max_users,
|
||||
)
|
||||
|
||||
logger.info("Server created: id={} name={} proto={}", server.id, name, protocol.value)
|
||||
return server
|
||||
|
||||
async def get_by_id(self, server_id: int) -> Server | None:
|
||||
return await self.server_repo.get(server_id)
|
||||
|
||||
async def get_active(self) -> list[Server]:
|
||||
return await self.server_repo.get_active()
|
||||
|
||||
async def get_by_protocol(self, protocol: ServerProtocol) -> list[Server]:
|
||||
return await self.server_repo.get_by_protocol(protocol)
|
||||
|
||||
async def get_least_loaded(self, protocol: ServerProtocol) -> Server | None:
|
||||
return await self.server_repo.get_least_loaded(protocol)
|
||||
|
||||
async def update_load(self, server_id: int, load_percent: int) -> Server | None:
|
||||
load = max(0, min(100, load_percent))
|
||||
return await self.server_repo.update(server_id, load_percent=load)
|
||||
|
||||
async def mark_online(self, server_id: int) -> Server | None:
|
||||
return await self.server_repo.update(server_id, is_active=True)
|
||||
|
||||
async def mark_offline(self, server_id: int) -> Server | None:
|
||||
server = await self.server_repo.update(server_id, is_active=False)
|
||||
if server:
|
||||
logger.warning("Server marked offline: id={} name={}", server_id, server.name)
|
||||
return server
|
||||
|
||||
async def update(
|
||||
self, server_id: int, **kwargs
|
||||
) -> Server | None:
|
||||
return await self.server_repo.update(server_id, **kwargs)
|
||||
|
||||
async def get_count(self) -> int:
|
||||
return await self.server_repo.count_active()
|
||||
84
app/services/stats.py
Normal file
84
app/services/stats.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.payment import Payment, PaymentStatus
|
||||
from app.models.tariff import Tariff
|
||||
from app.models.user import User
|
||||
from app.repositories.payment import PaymentRepository
|
||||
from app.repositories.tariff import TariffRepository
|
||||
from app.repositories.user import UserRepository
|
||||
from app.services.billing import BillingService
|
||||
|
||||
|
||||
@dataclass
|
||||
class SystemStats:
|
||||
total_users: int = 0
|
||||
active_users: int = 0
|
||||
expired_subscriptions: int = 0
|
||||
revenue_month: float = 0.0
|
||||
revenue_year: float = 0.0
|
||||
avg_subscription_price: float = 0.0
|
||||
|
||||
|
||||
class StatsService:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def get_stats(self) -> SystemStats:
|
||||
total_users = await self._count_users()
|
||||
active_users = await self._count_active_users()
|
||||
expired_subscriptions = await self._count_expired()
|
||||
revenue_month = await self._sum_revenue_since(self._start_of_month())
|
||||
revenue_year = await self._sum_revenue_since(self._start_of_year())
|
||||
avg_subscription_price = await self._avg_tariff_price()
|
||||
|
||||
return SystemStats(
|
||||
total_users=total_users,
|
||||
active_users=active_users,
|
||||
expired_subscriptions=expired_subscriptions,
|
||||
revenue_month=round(revenue_month, 2),
|
||||
revenue_year=round(revenue_year, 2),
|
||||
avg_subscription_price=round(avg_subscription_price, 2),
|
||||
)
|
||||
|
||||
async def _count_users(self) -> int:
|
||||
stmt = select(func.count(User.id))
|
||||
return (await self.session.execute(stmt)).scalar_one()
|
||||
|
||||
async def _count_active_users(self) -> int:
|
||||
stmt = select(func.count(User.id)).where(User.is_active.is_(True))
|
||||
return (await self.session.execute(stmt)).scalar_one()
|
||||
|
||||
async def _count_expired(self) -> int:
|
||||
user_repo = UserRepository(self.session)
|
||||
tariff_repo = TariffRepository(self.session)
|
||||
payment_repo = PaymentRepository(self.session)
|
||||
|
||||
billing = BillingService(payment_repo, tariff_repo, user_repo)
|
||||
expired_ids = await billing.expire_subscriptions()
|
||||
return len(set(expired_ids))
|
||||
|
||||
async def _sum_revenue_since(self, since: datetime) -> float:
|
||||
stmt = (
|
||||
select(func.coalesce(func.sum(Payment.amount), 0))
|
||||
.where(Payment.status == PaymentStatus.CONFIRMED)
|
||||
.where(Payment.paid_at >= since)
|
||||
)
|
||||
return float((await self.session.execute(stmt)).scalar_one())
|
||||
|
||||
async def _avg_tariff_price(self) -> float:
|
||||
stmt = select(func.coalesce(func.avg(Tariff.price), 0))
|
||||
return float((await self.session.execute(stmt)).scalar_one())
|
||||
|
||||
@staticmethod
|
||||
def _start_of_month() -> datetime:
|
||||
now = datetime.now(timezone.utc)
|
||||
return now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
@staticmethod
|
||||
def _start_of_year() -> datetime:
|
||||
now = datetime.now(timezone.utc)
|
||||
return now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
0
app/services/subscription.py
Normal file
0
app/services/subscription.py
Normal file
85
app/services/user.py
Normal file
85
app/services/user.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from datetime import datetime
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.models.user import User
|
||||
from app.repositories.user import UserRepository
|
||||
|
||||
|
||||
class UserService:
|
||||
def __init__(self, user_repo: UserRepository):
|
||||
self.user_repo = user_repo
|
||||
|
||||
async def register(
|
||||
self,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
full_name: str,
|
||||
language_code: str,
|
||||
) -> User:
|
||||
user = await self.user_repo.get_by_telegram_id(telegram_id)
|
||||
if user is not None:
|
||||
user = await self.user_repo.update(
|
||||
user.id,
|
||||
username=username,
|
||||
full_name=full_name,
|
||||
language_code=language_code,
|
||||
)
|
||||
logger.debug("User profile updated: tg={}", telegram_id)
|
||||
return user
|
||||
|
||||
user = await self.user_repo.create(
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
full_name=full_name,
|
||||
language_code=language_code,
|
||||
)
|
||||
logger.info("New user registered: tg={} id={}", telegram_id, user.id)
|
||||
return user
|
||||
|
||||
async def get_or_create(
|
||||
self,
|
||||
telegram_id: int,
|
||||
username: str | None = None,
|
||||
full_name: str = "",
|
||||
language_code: str = "ru",
|
||||
) -> User:
|
||||
user = await self.user_repo.get_by_telegram_id(telegram_id)
|
||||
if user is not None:
|
||||
return user
|
||||
return await self.user_repo.create(
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
full_name=full_name or str(telegram_id),
|
||||
language_code=language_code,
|
||||
)
|
||||
|
||||
async def get_by_id(self, user_id: int) -> User | None:
|
||||
return await self.user_repo.get(user_id)
|
||||
|
||||
async def get_by_telegram_id(self, telegram_id: int) -> User | None:
|
||||
return await self.user_repo.get_by_telegram_id(telegram_id)
|
||||
|
||||
async def update(self, user_id: int, **kwargs) -> User | None:
|
||||
return await self.user_repo.update(user_id, **kwargs)
|
||||
|
||||
async def deactivate(self, user_id: int) -> bool:
|
||||
user = await self.user_repo.update(user_id, is_active=False)
|
||||
if user:
|
||||
logger.info("User deactivated: id={}", user_id)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def activate(self, user_id: int) -> bool:
|
||||
user = await self.user_repo.update(user_id, is_active=True)
|
||||
if user:
|
||||
logger.info("User activated: id={}", user_id)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def get_active_count(self) -> int:
|
||||
users = await self.user_repo.get_active_users()
|
||||
return len(users)
|
||||
|
||||
async def get_registered_since(self, since: datetime) -> list[User]:
|
||||
return await self.user_repo.get_by_created_range(since, datetime.utcnow())
|
||||
56
app/services/vpn.py
Normal file
56
app/services/vpn.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from loguru import logger
|
||||
|
||||
from app.models.server import Server, ServerProtocol
|
||||
from app.models.user import User
|
||||
from app.repositories.server import ServerRepository
|
||||
from app.repositories.user import UserRepository
|
||||
|
||||
|
||||
class VPNService:
|
||||
def __init__(
|
||||
self,
|
||||
server_repo: ServerRepository,
|
||||
user_repo: UserRepository,
|
||||
):
|
||||
self.server_repo = server_repo
|
||||
self.user_repo = user_repo
|
||||
|
||||
async def assign_server(
|
||||
self, user_id: int, protocol: ServerProtocol | None = None
|
||||
) -> Server:
|
||||
user = await self.user_repo.get(user_id)
|
||||
if user is None:
|
||||
raise ValueError(f"User not found: {user_id}")
|
||||
|
||||
if protocol is not None:
|
||||
server = await self.server_repo.get_least_loaded(protocol)
|
||||
else:
|
||||
for proto in ServerProtocol:
|
||||
server = await self.server_repo.get_least_loaded(proto)
|
||||
if server is not None:
|
||||
break
|
||||
|
||||
if server is None:
|
||||
raise RuntimeError("No available VPN servers")
|
||||
|
||||
return server
|
||||
|
||||
async def get_available_protocols(self) -> list[ServerProtocol]:
|
||||
servers = await self.server_repo.get_active()
|
||||
return list({s.protocol for s in servers})
|
||||
|
||||
async def get_servers_by_protocol(
|
||||
self, protocol: ServerProtocol
|
||||
) -> list[Server]:
|
||||
return await self.server_repo.get_by_protocol(protocol)
|
||||
|
||||
async def get_user_server(
|
||||
self, user_id: int
|
||||
) -> Server | None:
|
||||
return await self.server_repo.get_least_loaded(ServerProtocol.WIREGUARD)
|
||||
|
||||
async def is_server_available(self, server_id: int) -> bool:
|
||||
server = await self.server_repo.get(server_id)
|
||||
if server is None:
|
||||
return False
|
||||
return server.is_active and server.load_percent < 100
|
||||
53
app/settings.py
Normal file
53
app/settings.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
app_name: str = "VPN Control Panel"
|
||||
debug: bool = False
|
||||
port: int = 8000
|
||||
|
||||
postgres_user: str = Field(default="vpn", alias="POSTGRES_USER")
|
||||
postgres_password: str = Field(default="vpn_secret", alias="POSTGRES_PASSWORD")
|
||||
postgres_db: str = Field(default="vpn_control", alias="POSTGRES_DB")
|
||||
postgres_host: str = Field(default="localhost", alias="POSTGRES_HOST")
|
||||
postgres_port: int = Field(default=5432, alias="POSTGRES_PORT")
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
return (
|
||||
f"postgresql+asyncpg://{self.postgres_user}:{self.postgres_password}"
|
||||
f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
|
||||
)
|
||||
|
||||
@property
|
||||
def database_url_sync(self) -> str:
|
||||
return (
|
||||
f"postgresql://{self.postgres_user}:{self.postgres_password}"
|
||||
f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
|
||||
)
|
||||
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
bot_token: str = ""
|
||||
admin_ids: list[int] = Field(default_factory=list)
|
||||
admin_group_id: int = 0
|
||||
admin_group_thread_id: int | None = None
|
||||
|
||||
jwt_secret: str = "change_me"
|
||||
jwt_algorithm: str = "HS256"
|
||||
jwt_access_expire_minutes: int = 30
|
||||
jwt_refresh_expire_days: int = 30
|
||||
|
||||
outline_api_prefix: str = ""
|
||||
outline_cert_sha256: str = ""
|
||||
|
||||
|
||||
settings = Settings()
|
||||
69
app/templates/base.html
Normal file
69
app/templates/base.html
Normal file
@@ -0,0 +1,69 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}VPN Control Panel{% endblock %}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
display: flex; min-height: 100vh; background: #f5f7fa; color: #1a1a2e;
|
||||
}
|
||||
.sidebar {
|
||||
width: 220px; background: #1a1a2e; color: #fff; padding: 24px 0;
|
||||
flex-shrink: 0; min-height: 100vh;
|
||||
}
|
||||
.sidebar h1 {
|
||||
font-size: 16px; padding: 0 20px 20px; border-bottom: 1px solid #2a2a4e;
|
||||
margin-bottom: 12px; letter-spacing: 0.3px;
|
||||
}
|
||||
.sidebar a {
|
||||
display: block; padding: 10px 20px; color: #a0a0c0; text-decoration: none;
|
||||
font-size: 14px; transition: background .15s, color .15s;
|
||||
}
|
||||
.sidebar a:hover, .sidebar a.active { background: #2a2a4e; color: #fff; }
|
||||
.content { flex: 1; padding: 32px; max-width: 1200px; }
|
||||
h2 { font-size: 22px; margin-bottom: 20px; font-weight: 600; }
|
||||
table {
|
||||
width: 100%; border-collapse: collapse; background: #fff;
|
||||
border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||
}
|
||||
th, td { padding: 10px 14px; text-align: left; font-size: 13px; }
|
||||
th { background: #f0f2f5; font-weight: 600; color: #555; text-transform: uppercase; font-size: 11px; letter-spacing: .5px; }
|
||||
td { border-top: 1px solid #eee; }
|
||||
tr:hover td { background: #fafbfc; }
|
||||
.badge {
|
||||
display: inline-block; padding: 2px 8px; border-radius: 10px;
|
||||
font-size: 11px; font-weight: 600;
|
||||
}
|
||||
.badge-green { background: #e6f7ee; color: #1a8a4a; }
|
||||
.badge-red { background: #fde8e8; color: #c53030; }
|
||||
.badge-yellow { background: #fef3cd; color: #856404; }
|
||||
.badge-blue { background: #e0f0ff; color: #2563eb; }
|
||||
.cards {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 16px; margin-bottom: 28px;
|
||||
}
|
||||
.card {
|
||||
background: #fff; border-radius: 8px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||
}
|
||||
.card .value { font-size: 28px; font-weight: 700; margin-bottom: 4px; }
|
||||
.card .label { font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: .5px; }
|
||||
.count { font-weight: 600; color: #555; font-size: 13px; margin-bottom: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="sidebar">
|
||||
<h1>VPN Control Panel</h1>
|
||||
<a href="/admin/" class="{% if request.url.path == '/admin/' %}active{% endif %}">Dashboard</a>
|
||||
<a href="/admin/users" class="{% if request.url.path == '/admin/users' %}active{% endif %}">Users</a>
|
||||
<a href="/admin/payments" class="{% if request.url.path == '/admin/payments' %}active{% endif %}">Payments</a>
|
||||
<a href="/admin/servers" class="{% if request.url.path == '/admin/servers' %}active{% endif %}">Servers</a>
|
||||
<a href="/admin/tariffs" class="{% if request.url.path == '/admin/tariffs' %}active{% endif %}">Tariffs</a>
|
||||
</nav>
|
||||
<main class="content">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
28
app/templates/dashboard.html
Normal file
28
app/templates/dashboard.html
Normal file
@@ -0,0 +1,28 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard — VPN Control Panel{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Dashboard</h2>
|
||||
|
||||
<div class="cards">
|
||||
<div class="card">
|
||||
<div class="value">{{ active_users }} / {{ total_users }}</div>
|
||||
<div class="label">Active Users</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="value">{{ total_tariffs }}</div>
|
||||
<div class="label">Tariffs</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="value">{{ active_servers }} / {{ total_servers }}</div>
|
||||
<div class="label">Active Servers</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="value">{{ confirmed_payments }} / {{ total_payments }}</div>
|
||||
<div class="label">Confirmed Payments</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="value">{{ "%.2f"|format(total_revenue) }}</div>
|
||||
<div class="label">Revenue (RUB)</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
46
app/templates/payments.html
Normal file
46
app/templates/payments.html
Normal file
@@ -0,0 +1,46 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Payments — VPN Control Panel{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Payments</h2>
|
||||
<div class="count">{{ payments|length }} total</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>User</th>
|
||||
<th>Tariff</th>
|
||||
<th>Amount</th>
|
||||
<th>Status</th>
|
||||
<th>Provider</th>
|
||||
<th>Created</th>
|
||||
<th>Paid At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in payments %}
|
||||
<tr>
|
||||
<td>{{ p.id }}</td>
|
||||
<td>#{{ p.user_id }}</td>
|
||||
<td>{{ tariffs_map.get(p.tariff_id, "—") }}</td>
|
||||
<td>{{ "%.2f"|format(p.amount) }} {{ p.currency }}</td>
|
||||
<td>
|
||||
{% if p.status.value == "confirmed" %}
|
||||
<span class="badge badge-green">Confirmed</span>
|
||||
{% elif p.status.value == "pending" %}
|
||||
<span class="badge badge-yellow">Pending</span>
|
||||
{% elif p.status.value == "failed" %}
|
||||
<span class="badge badge-red">Failed</span>
|
||||
{% elif p.status.value == "refunded" %}
|
||||
<span class="badge badge-blue">Refunded</span>
|
||||
{% else %}
|
||||
<span class="badge">{{ p.status.value }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ p.provider }}</td>
|
||||
<td>{{ p.created_at.strftime("%Y-%m-%d %H:%M") }}</td>
|
||||
<td>{{ p.paid_at.strftime("%Y-%m-%d %H:%M") if p.paid_at else "—" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
42
app/templates/servers.html
Normal file
42
app/templates/servers.html
Normal file
@@ -0,0 +1,42 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Servers — VPN Control Panel{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Servers</h2>
|
||||
<div class="count">{{ servers|length }} total</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Host</th>
|
||||
<th>Port</th>
|
||||
<th>Protocol</th>
|
||||
<th>Location</th>
|
||||
<th>Load</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in servers %}
|
||||
<tr>
|
||||
<td>{{ s.id }}</td>
|
||||
<td>{{ s.name }}</td>
|
||||
<td>{{ s.host }}</td>
|
||||
<td>{{ s.port }}</td>
|
||||
<td><span class="badge badge-blue">{{ s.protocol.value }}</span></td>
|
||||
<td>{{ s.location }} ({{ s.country_code }})</td>
|
||||
<td>{{ s.load_percent }}%</td>
|
||||
<td>
|
||||
{% if s.is_active %}
|
||||
<span class="badge badge-green">Active</span>
|
||||
{% else %}
|
||||
<span class="badge badge-red">Offline</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ s.created_at.strftime("%Y-%m-%d") }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
40
app/templates/tariffs.html
Normal file
40
app/templates/tariffs.html
Normal file
@@ -0,0 +1,40 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Tariffs — VPN Control Panel{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Tariffs</h2>
|
||||
<div class="count">{{ tariffs|length }} total</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Duration</th>
|
||||
<th>Price</th>
|
||||
<th>Max Devices</th>
|
||||
<th>Traffic</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in tariffs %}
|
||||
<tr>
|
||||
<td>{{ t.id }}</td>
|
||||
<td>{{ t.name }}</td>
|
||||
<td>{{ t.duration_days }} days</td>
|
||||
<td>{{ "%.2f"|format(t.price) }} {{ t.currency }}</td>
|
||||
<td>{{ t.max_devices }}</td>
|
||||
<td>{% if t.traffic_gb %}{{ t.traffic_gb }} GB{% else %}Unlimited{% endif %}</td>
|
||||
<td>
|
||||
{% if t.is_active %}
|
||||
<span class="badge badge-green">Active</span>
|
||||
{% else %}
|
||||
<span class="badge badge-red">Inactive</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ t.created_at.strftime("%Y-%m-%d") }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
38
app/templates/users.html
Normal file
38
app/templates/users.html
Normal file
@@ -0,0 +1,38 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Users — VPN Control Panel{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Users</h2>
|
||||
<div class="count">{{ users|length }} total</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Telegram ID</th>
|
||||
<th>Username</th>
|
||||
<th>Full Name</th>
|
||||
<th>Language</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td>{{ u.id }}</td>
|
||||
<td>{{ u.telegram_id }}</td>
|
||||
<td>{% if u.username %}@{{ u.username }}{% else %}—{% endif %}</td>
|
||||
<td>{{ u.full_name }}</td>
|
||||
<td>{{ u.language_code }}</td>
|
||||
<td>
|
||||
{% if u.is_active %}
|
||||
<span class="badge badge-green">Active</span>
|
||||
{% else %}
|
||||
<span class="badge badge-red">Inactive</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ u.created_at.strftime("%Y-%m-%d") }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
0
app/utils/__init__.py
Normal file
0
app/utils/__init__.py
Normal file
0
app/utils/crypto.py
Normal file
0
app/utils/crypto.py
Normal file
0
app/utils/pagination.py
Normal file
0
app/utils/pagination.py
Normal file
23
docker-compose.dev.yml
Normal file
23
docker-compose.dev.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: vpn
|
||||
POSTGRES_PASSWORD: vpn_secret
|
||||
POSTGRES_DB: vpn_control
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata_dev:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
volumes:
|
||||
pgdata_dev:
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user