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:
2026-07-05 17:50:11 +07:00
commit 7c7c88621d
117 changed files with 4456 additions and 0 deletions

33
.env.example Normal file
View 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
View 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
View 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
View 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
View File

0
app/admin/__init__.py Normal file
View File

118
app/admin/router.py Normal file
View 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
View File

140
app/api/deps.py Normal file
View 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
View File

0
app/api/v1/__init__.py Normal file
View File

0
app/api/v1/admin.py Normal file
View File

35
app/api/v1/auth.py Normal file
View 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
View 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
View 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
View 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
View 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),
)

View File

32
app/api/v1/tariffs.py Normal file
View 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
View 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
View File

3
app/bot/__init__.py Normal file
View 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
View 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),
)

View 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"]

View File

View File

17
app/bot/handlers/start.py Normal file
View 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
View 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",
)

View 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")

View File

117
app/bot/handlers/users.py Normal file
View 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.")

View File

View File

36
app/bot/middlewares.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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}>"

View 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
View 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
View File

36
app/models/server.py Normal file
View 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})>"

View File

32
app/models/tariff.py Normal file
View 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}>"

View File

33
app/models/user.py Normal file
View 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
View File

View 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
View 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
View 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
View File

View File

0
app/providers/xray.py Normal file
View File

View 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
View 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
View 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()

View 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())

View 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())

View 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())

View 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
View 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())

View File

@@ -0,0 +1,3 @@
from app.scheduler.scheduler import setup_scheduler
__all__ = ["setup_scheduler"]

View 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

View 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",
]

View 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)),
)

View 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,
)

View 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
)

View 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
View 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
View 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
View 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
View 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
View 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

View File

17
app/schemas/tariff.py Normal file
View 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
View 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
View File

18
app/services/__init__.py Normal file
View 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
View 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
View 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,
)

View 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
View 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
View 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
View 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)

View File

85
app/services/user.py Normal file
View 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
View 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
View 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
View 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>

View 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 %}

View 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 %}

View 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 %}

View 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
View 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
View File

0
app/utils/crypto.py Normal file
View File

0
app/utils/pagination.py Normal file
View File

23
docker-compose.dev.yml Normal file
View 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