Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6288d3fb7f | |||
| d611eecece | |||
| 779cccffe6 | |||
| 04144dc7a5 | |||
| b7bfdbb485 | |||
| 21b7c039b7 | |||
| dc7d2eb02a | |||
| 6c26d5c7f8 | |||
| 06e99d5a43 | |||
| a7712c7151 | |||
| 418d329b75 | |||
| a4d5b8067c | |||
| f61ee0f64a | |||
| 841b1e4c52 | |||
| 927ec91e0e | |||
| 9cc2a285dc | |||
| f11173d5aa | |||
| 7bd5d13cc8 | |||
| c30e22e1b1 | |||
| ffff91466e | |||
| a7669d0f35 | |||
| 1371f21d17 | |||
| ec553d3334 | |||
| 638644d1e9 | |||
| 8ae6ef5cb8 | |||
| 1092301767 | |||
| 365f75447f | |||
| 2e3dbaa18c | |||
| 68df186f72 | |||
| 8d3bedefb0 | |||
| 701a4d51de | |||
| fbb45c10c1 | |||
| e4f182ffc6 | |||
| fa94042284 | |||
| b258715cc1 | |||
| 4e9f01d439 | |||
| 5dad41953a | |||
| dd9b33af83 | |||
| fbf2325a42 | |||
| 11eb27437b | |||
| 1bb5ef85aa | |||
| 35c5d78963 | |||
| 38ff15e794 | |||
| 2992dfbada | |||
| a1e5a71ad3 | |||
| 28dbe3dca7 | |||
| 49b48164fd | |||
| f688c74aee | |||
| fe42548dd7 | |||
| 7000cd5bc2 | |||
| e3901c8d39 | |||
| 23f72dc4ec | |||
| 55d817bcad | |||
| 8a9994e539 | |||
| f050a62bc6 | |||
| e26464ddad | |||
| 3263606702 | |||
| 86350424d5 | |||
| d2ade1d9ed | |||
| cc47cea268 | |||
| 6f420264f8 | |||
| 5949460572 | |||
| 4330775d01 | |||
| fa5c217dd0 | |||
| aa270c9ab4 | |||
| 8b742082a4 | |||
| 8078a4e64d | |||
| 79f2cc0da5 | |||
| 0c769ac16d | |||
| 25aba75413 | |||
| 9afe370a98 | |||
| 6202801793 | |||
| 4d48418b2c | |||
| a21dfa75f0 | |||
| e09d9b6607 | |||
| 68a734051d | |||
| 3f43371e60 | |||
| cb8d233a16 | |||
| d95aa7ca5c | |||
| f7fc7d5cb0 | |||
| 5d6d3b962b | |||
| 6bf8f85c80 | |||
| 0a254b1903 | |||
| 9f33331618 | |||
| 595ecff396 | |||
| ad96e8951d | |||
| 385e1b4287 | |||
| 6551ac1fe9 | |||
| 327ba81d25 | |||
| bf65e16d4d | |||
| 9cf24deb93 | |||
| 3791f1db5a | |||
| 0c3070a0cc | |||
| a37ec7a308 | |||
| b161a8604e | |||
| 1e93f24f78 | |||
| f8cd3076e9 | |||
| e557504309 | |||
| 4602f72030 | |||
| d811321808 | |||
| dffb637e8d | |||
| 32129bebc0 | |||
| dd5ee45ab5 | |||
| 95b7152c05 | |||
| aa093a074a |
@@ -51,6 +51,8 @@ TEST_EMAIL_PASSWORD=
|
||||
CABINET_EMAIL_VERIFICATION_EXPIRE_HOURS=24
|
||||
# Время жизни токена сброса пароля в часах
|
||||
CABINET_PASSWORD_RESET_EXPIRE_HOURS=1
|
||||
# Время жизни кода подтверждения смены email в минутах
|
||||
CABINET_EMAIL_CHANGE_CODE_EXPIRE_MINUTES=15
|
||||
|
||||
# ===== SMTP НАСТРОЙКИ (для email в личном кабинете) =====
|
||||
# SMTP сервер (например: smtp.gmail.com, smtp.yandex.ru)
|
||||
|
||||
@@ -6,6 +6,27 @@ from datetime import datetime, timedelta
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def generate_email_change_code() -> str:
|
||||
"""
|
||||
Generate a 6-digit verification code for email change.
|
||||
|
||||
Returns:
|
||||
6-digit numeric string
|
||||
"""
|
||||
return str(secrets.randbelow(900000) + 100000)
|
||||
|
||||
|
||||
def get_email_change_expires_at() -> datetime:
|
||||
"""
|
||||
Get the expiration datetime for an email change code.
|
||||
|
||||
Returns:
|
||||
Datetime when the email change code expires
|
||||
"""
|
||||
minutes = settings.get_cabinet_email_change_code_expire_minutes()
|
||||
return datetime.utcnow() + timedelta(minutes=minutes)
|
||||
|
||||
|
||||
def generate_verification_token() -> str:
|
||||
"""
|
||||
Generate a secure random verification token.
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import distinct, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import BroadcastHistory, Subscription, SubscriptionStatus, Tariff, User
|
||||
@@ -13,7 +13,9 @@ from app.keyboards.admin import BROADCAST_BUTTONS, DEFAULT_BROADCAST_BUTTONS
|
||||
from app.services.broadcast_service import (
|
||||
BroadcastConfig,
|
||||
BroadcastMediaConfig,
|
||||
EmailBroadcastConfig,
|
||||
broadcast_service,
|
||||
email_broadcast_service,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
@@ -28,6 +30,11 @@ from ..schemas.broadcasts import (
|
||||
BroadcastPreviewResponse,
|
||||
BroadcastResponse,
|
||||
BroadcastTariffsResponse,
|
||||
CombinedBroadcastCreateRequest,
|
||||
EmailFilterItem,
|
||||
EmailFiltersResponse,
|
||||
EmailPreviewRequest,
|
||||
EmailPreviewResponse,
|
||||
TariffFilter,
|
||||
TariffForBroadcast,
|
||||
)
|
||||
@@ -87,6 +94,25 @@ CUSTOM_FILTER_GROUPS = {
|
||||
}
|
||||
|
||||
|
||||
# ============ Email Filter Labels ============
|
||||
|
||||
EMAIL_FILTER_LABELS = {
|
||||
'all_email': 'Все с email',
|
||||
'email_only': 'Только email-регистрация',
|
||||
'telegram_with_email': 'Telegram с email',
|
||||
'active_email': 'С активной подпиской',
|
||||
'expired_email': 'С истекшей подпиской',
|
||||
}
|
||||
|
||||
EMAIL_FILTER_GROUPS = {
|
||||
'all_email': 'basic',
|
||||
'email_only': 'auth_type',
|
||||
'telegram_with_email': 'auth_type',
|
||||
'active_email': 'subscription',
|
||||
'expired_email': 'subscription',
|
||||
}
|
||||
|
||||
|
||||
# ============ Helper Functions ============
|
||||
|
||||
|
||||
@@ -113,9 +139,73 @@ def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
|
||||
created_at=broadcast.created_at,
|
||||
completed_at=broadcast.completed_at,
|
||||
progress_percent=progress,
|
||||
channel=getattr(broadcast, 'channel', 'telegram') or 'telegram',
|
||||
email_subject=getattr(broadcast, 'email_subject', None),
|
||||
email_html_content=getattr(broadcast, 'email_html_content', None),
|
||||
)
|
||||
|
||||
|
||||
async def _get_email_filter_count(db: AsyncSession, target: str) -> int:
|
||||
"""Get count of email users matching the filter."""
|
||||
base_conditions = [
|
||||
User.email.isnot(None),
|
||||
User.email_verified == True,
|
||||
User.status == 'active',
|
||||
]
|
||||
|
||||
if target == 'all_email':
|
||||
query = select(func.count(User.id)).where(*base_conditions)
|
||||
|
||||
elif target == 'email_only':
|
||||
query = select(func.count(User.id)).where(
|
||||
*base_conditions,
|
||||
User.auth_type == 'email',
|
||||
)
|
||||
|
||||
elif target == 'telegram_with_email':
|
||||
query = select(func.count(User.id)).where(
|
||||
*base_conditions,
|
||||
User.auth_type == 'telegram',
|
||||
User.telegram_id.isnot(None),
|
||||
)
|
||||
|
||||
elif target == 'active_email':
|
||||
query = (
|
||||
select(func.count(distinct(User.id)))
|
||||
.join(Subscription, User.id == Subscription.user_id)
|
||||
.where(
|
||||
*base_conditions,
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
|
||||
elif target == 'expired_email':
|
||||
query = (
|
||||
select(func.count(distinct(User.id)))
|
||||
.join(Subscription, User.id == Subscription.user_id)
|
||||
.where(
|
||||
*base_conditions,
|
||||
Subscription.status.in_(
|
||||
[
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
else:
|
||||
return 0
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
def _validate_email_target(target: str) -> bool:
|
||||
"""Validate email target filter."""
|
||||
return target in EMAIL_FILTER_LABELS
|
||||
|
||||
|
||||
async def _get_tariff_user_counts(db: AsyncSession) -> dict:
|
||||
"""Get count of active users per tariff."""
|
||||
result = await db.execute(
|
||||
@@ -388,6 +478,194 @@ async def list_broadcasts(
|
||||
)
|
||||
|
||||
|
||||
# ============ Email Broadcast Endpoints ============
|
||||
|
||||
|
||||
@router.get('/email-filters', response_model=EmailFiltersResponse)
|
||||
async def get_email_filters(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> EmailFiltersResponse:
|
||||
"""Get all available email filters with user counts."""
|
||||
filters = []
|
||||
total_with_email = 0
|
||||
|
||||
for key, label in EMAIL_FILTER_LABELS.items():
|
||||
try:
|
||||
count = await _get_email_filter_count(db, key)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to get count for email filter {key}: {e}')
|
||||
count = 0
|
||||
|
||||
filters.append(
|
||||
EmailFilterItem(
|
||||
key=key,
|
||||
label=label,
|
||||
count=count,
|
||||
group=EMAIL_FILTER_GROUPS.get(key),
|
||||
)
|
||||
)
|
||||
|
||||
# Track total with email (all_email filter)
|
||||
if key == 'all_email':
|
||||
total_with_email = count
|
||||
|
||||
return EmailFiltersResponse(
|
||||
filters=filters,
|
||||
total_with_email=total_with_email,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/email-preview', response_model=EmailPreviewResponse)
|
||||
async def preview_email_broadcast(
|
||||
request: EmailPreviewRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> EmailPreviewResponse:
|
||||
"""Preview email broadcast recipients count."""
|
||||
if not _validate_email_target(request.target):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid email target: {request.target}',
|
||||
)
|
||||
|
||||
try:
|
||||
count = await _get_email_filter_count(db, request.target)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to get email count for target {request.target}: {e}')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to count email recipients',
|
||||
)
|
||||
|
||||
return EmailPreviewResponse(target=request.target, count=count)
|
||||
|
||||
|
||||
@router.post('/send', response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_combined_broadcast(
|
||||
request: CombinedBroadcastCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastResponse:
|
||||
"""Create and start a combined broadcast (telegram/email/both)."""
|
||||
# Get tariff IDs for target validation
|
||||
result = await db.execute(select(Tariff.id))
|
||||
tariff_ids = {row[0] for row in result.all()}
|
||||
|
||||
admin_name = admin.username or f'Admin #{admin.id}'
|
||||
|
||||
# Validate based on channel
|
||||
if request.channel in ('telegram', 'both'):
|
||||
# Validate telegram target
|
||||
if not _validate_target(request.target, tariff_ids):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid target: {request.target}',
|
||||
)
|
||||
|
||||
# Validate telegram message
|
||||
if not request.message_text or not request.message_text.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Message text is required for Telegram broadcast',
|
||||
)
|
||||
|
||||
# Validate buttons
|
||||
if not _validate_buttons(request.selected_buttons):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid button key',
|
||||
)
|
||||
|
||||
if request.channel in ('email', 'both'):
|
||||
# For email channel, target must be email filter or we use telegram target for 'both'
|
||||
if request.channel == 'email' and not _validate_email_target(request.target):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid email target: {request.target}',
|
||||
)
|
||||
|
||||
# Validate email fields
|
||||
if not request.email_subject or not request.email_subject.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Email subject is required for email broadcast',
|
||||
)
|
||||
|
||||
if not request.email_html_content or not request.email_html_content.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Email HTML content is required for email broadcast',
|
||||
)
|
||||
|
||||
media_payload = request.media
|
||||
|
||||
# Create broadcast record
|
||||
broadcast = BroadcastHistory(
|
||||
target_type=request.target,
|
||||
message_text=request.message_text.strip() if request.message_text else None,
|
||||
has_media=media_payload is not None,
|
||||
media_type=media_payload.type if media_payload else None,
|
||||
media_file_id=media_payload.file_id if media_payload else None,
|
||||
media_caption=media_payload.caption if media_payload else None,
|
||||
total_count=0,
|
||||
sent_count=0,
|
||||
failed_count=0,
|
||||
status='queued',
|
||||
admin_id=admin.id,
|
||||
admin_name=admin_name,
|
||||
channel=request.channel,
|
||||
email_subject=request.email_subject.strip() if request.email_subject else None,
|
||||
email_html_content=request.email_html_content.strip() if request.email_html_content else None,
|
||||
)
|
||||
db.add(broadcast)
|
||||
await db.commit()
|
||||
await db.refresh(broadcast)
|
||||
|
||||
# Start broadcasts based on channel
|
||||
if request.channel in ('telegram', 'both'):
|
||||
# Prepare media config
|
||||
media_config = None
|
||||
if media_payload:
|
||||
media_config = BroadcastMediaConfig(
|
||||
type=media_payload.type,
|
||||
file_id=media_payload.file_id,
|
||||
caption=media_payload.caption or request.message_text,
|
||||
)
|
||||
|
||||
# Create telegram broadcast config
|
||||
telegram_config = BroadcastConfig(
|
||||
target=request.target,
|
||||
message_text=request.message_text.strip(),
|
||||
selected_buttons=request.selected_buttons,
|
||||
media=media_config,
|
||||
initiator_name=admin_name,
|
||||
)
|
||||
|
||||
await broadcast_service.start_broadcast(broadcast.id, telegram_config)
|
||||
|
||||
if request.channel in ('email', 'both'):
|
||||
# For 'both' channel, we use 'all_email' as default email target
|
||||
# since telegram target won't match email filters
|
||||
email_target = request.target if request.channel == 'email' else 'all_email'
|
||||
|
||||
# Create email broadcast config
|
||||
email_config = EmailBroadcastConfig(
|
||||
target=email_target,
|
||||
email_subject=request.email_subject.strip(),
|
||||
email_html_content=request.email_html_content.strip(),
|
||||
initiator_name=admin_name,
|
||||
)
|
||||
|
||||
await email_broadcast_service.start_broadcast(broadcast.id, email_config)
|
||||
|
||||
await db.refresh(broadcast)
|
||||
|
||||
logger.info(f"Admin {admin.id} created {request.channel} broadcast {broadcast.id} for target '{request.target}'")
|
||||
|
||||
return _serialize_broadcast(broadcast)
|
||||
|
||||
|
||||
@router.get('/{broadcast_id}', response_model=BroadcastResponse)
|
||||
async def get_broadcast(
|
||||
broadcast_id: int,
|
||||
@@ -410,7 +688,7 @@ async def stop_broadcast(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastResponse:
|
||||
"""Stop a running broadcast."""
|
||||
"""Stop a running broadcast (telegram or email)."""
|
||||
broadcast = await db.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
raise HTTPException(
|
||||
@@ -424,7 +702,15 @@ async def stop_broadcast(
|
||||
detail='Broadcast is not running',
|
||||
)
|
||||
|
||||
is_running = await broadcast_service.request_stop(broadcast_id)
|
||||
# Try to stop both telegram and email broadcasts (one or both may be running)
|
||||
channel = getattr(broadcast, 'channel', 'telegram') or 'telegram'
|
||||
|
||||
is_running = False
|
||||
if channel in ('telegram', 'both'):
|
||||
is_running = await broadcast_service.request_stop(broadcast_id) or is_running
|
||||
|
||||
if channel in ('email', 'both'):
|
||||
is_running = await email_broadcast_service.request_stop(broadcast_id) or is_running
|
||||
|
||||
if is_running:
|
||||
broadcast.status = 'cancelling'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Admin routes for managing users in cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import Integer, and_, func, or_, select
|
||||
@@ -32,6 +32,7 @@ from app.database.models import (
|
||||
User,
|
||||
UserStatus,
|
||||
)
|
||||
from app.utils.timezone import panel_datetime_to_naive_utc
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.users import (
|
||||
@@ -275,6 +276,7 @@ async def list_users(
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
search: str | None = Query(None, max_length=255),
|
||||
email: str | None = Query(None, max_length=255),
|
||||
status: UserStatusEnum | None = Query(None),
|
||||
sort_by: SortByEnum = Query(SortByEnum.CREATED_AT),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -286,6 +288,7 @@ async def list_users(
|
||||
- **offset**: Pagination offset
|
||||
- **limit**: Number of users per page (max 200)
|
||||
- **search**: Search by telegram_id, username, first_name, last_name
|
||||
- **email**: Search by email
|
||||
- **status**: Filter by user status (active, blocked, deleted)
|
||||
- **sort_by**: Sort field (created_at, balance, traffic, last_activity, total_spent, purchase_count)
|
||||
"""
|
||||
@@ -306,6 +309,7 @@ async def list_users(
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
search=search,
|
||||
email=email,
|
||||
status=user_status,
|
||||
order_by_balance=order_by_balance,
|
||||
order_by_traffic=order_by_traffic,
|
||||
@@ -314,7 +318,7 @@ async def list_users(
|
||||
order_by_purchase_count=order_by_purchase_count,
|
||||
)
|
||||
|
||||
total = await get_users_count(db=db, status=user_status, search=search)
|
||||
total = await get_users_count(db=db, status=user_status, search=search, email=email)
|
||||
|
||||
# Get spending stats for all users
|
||||
user_ids = [u.id for u in users]
|
||||
@@ -1372,12 +1376,8 @@ async def get_user_sync_status(
|
||||
bot_end_utc = (
|
||||
bot_sub_end_date.replace(tzinfo=None) if bot_sub_end_date.tzinfo else bot_sub_end_date
|
||||
)
|
||||
# Panel dates might be timezone-aware, convert to UTC first
|
||||
if panel_expire_at.tzinfo:
|
||||
panel_end_utc = panel_expire_at.astimezone(UTC).replace(tzinfo=None)
|
||||
else:
|
||||
# Panel might return naive datetime in MSK (UTC+3), try both interpretations
|
||||
panel_end_utc = panel_expire_at
|
||||
# Panel returns local time with misleading +00:00 offset
|
||||
panel_end_utc = panel_datetime_to_naive_utc(panel_expire_at)
|
||||
|
||||
diff_seconds = abs((bot_end_utc - panel_end_utc).total_seconds())
|
||||
# Allow for timezone offset (3 hours = MSK) and small sync delays
|
||||
@@ -1508,7 +1508,7 @@ async def sync_user_from_panel(
|
||||
short_uuid=panel_user.short_uuid,
|
||||
username=panel_user.username,
|
||||
status=panel_user.status.value if panel_user.status else None,
|
||||
expire_at=panel_user.expire_at,
|
||||
expire_at=panel_datetime_to_naive_utc(panel_user.expire_at) if panel_user.expire_at else None,
|
||||
traffic_limit_gb=panel_user.traffic_limit_bytes / (1024**3) if panel_user.traffic_limit_bytes else 0,
|
||||
traffic_used_gb=panel_user.used_traffic_bytes / (1024**3) if panel_user.used_traffic_bytes else 0,
|
||||
device_limit=panel_user.hwid_device_limit or 1,
|
||||
@@ -1527,11 +1527,8 @@ async def sync_user_from_panel(
|
||||
|
||||
# Update end date (normalize timezone)
|
||||
if panel_user.expire_at:
|
||||
# Convert panel expire_at to naive UTC for storage
|
||||
if panel_user.expire_at.tzinfo:
|
||||
panel_expire_utc = panel_user.expire_at.astimezone(UTC).replace(tzinfo=None)
|
||||
else:
|
||||
panel_expire_utc = panel_user.expire_at
|
||||
# Panel returns local time with misleading +00:00 offset
|
||||
panel_expire_utc = panel_datetime_to_naive_utc(panel_user.expire_at)
|
||||
|
||||
sub_end_naive = (
|
||||
sub.end_date.replace(tzinfo=None) if sub.end_date and sub.end_date.tzinfo else sub.end_date
|
||||
@@ -1602,11 +1599,8 @@ async def sync_user_from_panel(
|
||||
panel_traffic_limit = (
|
||||
int(panel_user.traffic_limit_bytes / (1024**3)) if panel_user.traffic_limit_bytes else 100
|
||||
)
|
||||
# Normalize panel expire date for calculation
|
||||
if panel_user.expire_at.tzinfo:
|
||||
panel_expire_naive = panel_user.expire_at.astimezone(UTC).replace(tzinfo=None)
|
||||
else:
|
||||
panel_expire_naive = panel_user.expire_at
|
||||
# Panel returns local time with misleading +00:00 offset
|
||||
panel_expire_naive = panel_datetime_to_naive_utc(panel_user.expire_at)
|
||||
days_remaining = max(1, (panel_expire_naive - datetime.utcnow()).days)
|
||||
|
||||
new_sub = await create_paid_subscription(
|
||||
|
||||
+168
-17
@@ -11,14 +11,19 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.user import (
|
||||
clear_email_change_pending,
|
||||
create_user,
|
||||
create_user_by_email,
|
||||
get_user_by_id,
|
||||
get_user_by_referral_code,
|
||||
get_user_by_telegram_id,
|
||||
is_email_taken,
|
||||
set_email_change_pending,
|
||||
verify_and_apply_email_change,
|
||||
)
|
||||
from app.database.models import CabinetRefreshToken, User
|
||||
from app.services.referral_service import process_referral_registration
|
||||
from app.utils.timezone import panel_datetime_to_naive_utc
|
||||
|
||||
from ..auth import (
|
||||
create_access_token,
|
||||
@@ -30,8 +35,10 @@ from ..auth import (
|
||||
verify_password,
|
||||
)
|
||||
from ..auth.email_verification import (
|
||||
generate_email_change_code,
|
||||
generate_password_reset_token,
|
||||
generate_verification_token,
|
||||
get_email_change_expires_at,
|
||||
get_password_reset_expires_at,
|
||||
get_verification_expires_at,
|
||||
is_token_expired,
|
||||
@@ -40,6 +47,9 @@ from ..auth.jwt_handler import get_refresh_token_expires_at
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.auth import (
|
||||
AuthResponse,
|
||||
EmailChangeRequest,
|
||||
EmailChangeResponse,
|
||||
EmailChangeVerifyRequest,
|
||||
EmailLoginRequest,
|
||||
EmailRegisterRequest,
|
||||
EmailRegisterStandaloneRequest,
|
||||
@@ -162,8 +172,8 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
|
||||
|
||||
existing_sub = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
# Parse panel data
|
||||
expire_at = panel_user.expire_at
|
||||
# Parse panel data — panel returns local time with misleading +00:00 offset
|
||||
expire_at = panel_datetime_to_naive_utc(panel_user.expire_at)
|
||||
traffic_limit_gb = panel_user.traffic_limit_bytes // (1024**3) if panel_user.traffic_limit_bytes > 0 else 0
|
||||
traffic_used_gb = panel_user.used_traffic_bytes / (1024**3) if panel_user.used_traffic_bytes > 0 else 0
|
||||
|
||||
@@ -173,11 +183,8 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
|
||||
# Device limit from panel
|
||||
device_limit = panel_user.hwid_device_limit or 1
|
||||
|
||||
# Determine status - use timezone-aware datetime for comparison
|
||||
current_time = datetime.now(UTC)
|
||||
# Make expire_at timezone-aware if it's naive
|
||||
if expire_at.tzinfo is None:
|
||||
expire_at = expire_at.replace(tzinfo=UTC)
|
||||
# Determine status — expire_at is now naive UTC
|
||||
current_time = datetime.now(UTC).replace(tzinfo=None)
|
||||
|
||||
if panel_user.status.value == 'ACTIVE' and expire_at > current_time:
|
||||
sub_status = SubscriptionStatus.ACTIVE
|
||||
@@ -187,10 +194,8 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
|
||||
sub_status = SubscriptionStatus.DISABLED
|
||||
|
||||
if existing_sub:
|
||||
# Update existing subscription
|
||||
# Convert to naive datetime for database storage
|
||||
end_date_naive = expire_at.replace(tzinfo=None) if expire_at.tzinfo else expire_at
|
||||
existing_sub.end_date = end_date_naive
|
||||
# Update existing subscription (expire_at already naive UTC)
|
||||
existing_sub.end_date = expire_at
|
||||
existing_sub.traffic_limit_gb = traffic_limit_gb
|
||||
existing_sub.traffic_used_gb = traffic_used_gb
|
||||
existing_sub.status = sub_status.value
|
||||
@@ -204,14 +209,11 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
|
||||
f'Updated subscription for email user {user.email}, squads: {connected_squads}, devices: {device_limit}'
|
||||
)
|
||||
else:
|
||||
# Create new subscription
|
||||
# Convert current_time to naive for database storage if needed
|
||||
start_date_naive = current_time.replace(tzinfo=None)
|
||||
end_date_naive = expire_at.replace(tzinfo=None) if expire_at.tzinfo else expire_at
|
||||
# Create new subscription (expire_at and current_time already naive UTC)
|
||||
new_sub = Subscription(
|
||||
user_id=user.id,
|
||||
start_date=start_date_naive,
|
||||
end_date=end_date_naive,
|
||||
start_date=current_time,
|
||||
end_date=expire_at,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
traffic_used_gb=traffic_used_gb,
|
||||
status=sub_status.value,
|
||||
@@ -941,3 +943,152 @@ async def check_is_admin(
|
||||
"""Check if current user is an admin."""
|
||||
is_admin = settings.is_admin(telegram_id=user.telegram_id, email=user.email if user.email_verified else None)
|
||||
return {'is_admin': is_admin}
|
||||
|
||||
|
||||
@router.post('/email/change', response_model=EmailChangeResponse)
|
||||
async def request_email_change(
|
||||
request: EmailChangeRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Request email change.
|
||||
|
||||
Sends a 6-digit verification code to the new email address.
|
||||
User must have a verified email to change it.
|
||||
"""
|
||||
# Check if user has a verified email
|
||||
if not user.email or not user.email_verified:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='You must have a verified email to change it',
|
||||
)
|
||||
|
||||
# Check if new email is the same as current
|
||||
if request.new_email.lower() == user.email.lower():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='New email is the same as current email',
|
||||
)
|
||||
|
||||
# Check if new email is already taken
|
||||
if await is_email_taken(db, request.new_email, exclude_user_id=user.id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='This email is already registered',
|
||||
)
|
||||
|
||||
# Generate verification code
|
||||
code = generate_email_change_code()
|
||||
expires_at = get_email_change_expires_at()
|
||||
expire_minutes = settings.get_cabinet_email_change_code_expire_minutes()
|
||||
|
||||
# Save pending email change
|
||||
await set_email_change_pending(db, user, request.new_email, code, expires_at)
|
||||
|
||||
# Send verification email to new address
|
||||
if email_service.is_configured():
|
||||
lang = user.language or 'ru'
|
||||
|
||||
# Check for admin template override
|
||||
override = await get_rendered_override(
|
||||
'email_change_code',
|
||||
lang,
|
||||
context={
|
||||
'username': user.first_name or '',
|
||||
'code': code,
|
||||
'expire_minutes': str(expire_minutes),
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
custom_subject, custom_body = override if override else (None, None)
|
||||
|
||||
await asyncio.to_thread(
|
||||
email_service.send_email_change_code,
|
||||
to_email=request.new_email,
|
||||
code=code,
|
||||
username=user.first_name,
|
||||
language=lang,
|
||||
custom_subject=custom_subject,
|
||||
custom_body_html=custom_body,
|
||||
)
|
||||
else:
|
||||
# Clear pending change if email service is not configured
|
||||
await clear_email_change_pending(db, user)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail='Email service is not configured',
|
||||
)
|
||||
|
||||
logger.info(f'Email change requested for user {user.id}: {user.email} -> {request.new_email}')
|
||||
|
||||
return EmailChangeResponse(
|
||||
message='Verification code sent to new email',
|
||||
new_email=request.new_email,
|
||||
expires_in_minutes=expire_minutes,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/email/change/verify')
|
||||
async def verify_email_change(
|
||||
request: EmailChangeVerifyRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Verify email change with code.
|
||||
|
||||
Completes the email change process if the code is valid.
|
||||
"""
|
||||
success, message = await verify_and_apply_email_change(db, user, request.code)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=message,
|
||||
)
|
||||
|
||||
return {
|
||||
'message': message,
|
||||
'new_email': user.email,
|
||||
}
|
||||
|
||||
|
||||
@router.post('/email/change/cancel')
|
||||
async def cancel_email_change(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Cancel pending email change.
|
||||
"""
|
||||
if not user.email_change_new:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='No pending email change',
|
||||
)
|
||||
|
||||
await clear_email_change_pending(db, user)
|
||||
|
||||
return {'message': 'Email change cancelled'}
|
||||
|
||||
|
||||
@router.get('/email/change/status')
|
||||
async def get_email_change_status(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
):
|
||||
"""
|
||||
Get pending email change status.
|
||||
"""
|
||||
if not user.email_change_new:
|
||||
return {
|
||||
'pending': False,
|
||||
'new_email': None,
|
||||
'expires_at': None,
|
||||
}
|
||||
|
||||
return {
|
||||
'pending': True,
|
||||
'new_email': user.email_change_new,
|
||||
'expires_at': user.email_change_expires.isoformat() if user.email_change_expires else None,
|
||||
}
|
||||
|
||||
+105
-126
@@ -13,6 +13,7 @@ from app.config import settings
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.models import PaymentMethod, Transaction, User
|
||||
from app.external.cryptobot import CryptoBotService
|
||||
from app.services.payment_method_config_service import get_enabled_methods_for_user
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.payment_verification_service import (
|
||||
SUPPORTED_MANUAL_CHECK_METHODS,
|
||||
@@ -127,134 +128,82 @@ async def get_transactions(
|
||||
)
|
||||
|
||||
|
||||
async def _get_available_payment_methods(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
) -> list[PaymentMethodResponse]:
|
||||
"""Get available payment methods filtered by DB config and user context.
|
||||
@router.get('/payment-methods', response_model=list[PaymentMethodResponse])
|
||||
async def get_payment_methods(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get available payment methods for the current user.
|
||||
|
||||
Combines env-var availability with DB-based admin config (ordering, display conditions).
|
||||
Uses PaymentMethodConfig from database for:
|
||||
- Sort order (sort_order)
|
||||
- Enabled/disabled status (is_enabled)
|
||||
- Display names (display_name with fallback to env)
|
||||
- Min/max amounts (with fallback to env defaults)
|
||||
- Sub-options filtering (sub_options)
|
||||
- User filters (user_type_filter, first_topup_filter, promo_group_filter)
|
||||
"""
|
||||
from app.services.payment_method_config_service import (
|
||||
_get_method_defaults,
|
||||
get_all_configs,
|
||||
# Check if this is user's first topup
|
||||
from sqlalchemy import exists
|
||||
|
||||
has_completed_topup = await db.execute(
|
||||
select(
|
||||
exists().where(
|
||||
Transaction.user_id == user.id,
|
||||
Transaction.type == 'deposit',
|
||||
Transaction.is_completed == True,
|
||||
)
|
||||
)
|
||||
)
|
||||
is_first_topup = not has_completed_topup.scalar()
|
||||
|
||||
configs = await get_all_configs(db)
|
||||
defaults = _get_method_defaults()
|
||||
# Get enabled methods from database config
|
||||
enabled_methods = await get_enabled_methods_for_user(db, user=user, is_first_topup=is_first_topup)
|
||||
|
||||
# Provider availability checks from env vars
|
||||
provider_enabled = {
|
||||
'telegram_stars': settings.TELEGRAM_STARS_ENABLED,
|
||||
'tribute': settings.TRIBUTE_ENABLED and bool(getattr(settings, 'TRIBUTE_DONATE_LINK', '')),
|
||||
'cryptobot': settings.is_cryptobot_enabled(),
|
||||
'heleket': settings.is_heleket_enabled(),
|
||||
'yookassa': settings.is_yookassa_enabled(),
|
||||
'mulenpay': settings.is_mulenpay_enabled(),
|
||||
'pal24': settings.is_pal24_enabled(),
|
||||
'platega': settings.is_platega_enabled(),
|
||||
'wata': settings.is_wata_enabled(),
|
||||
'freekassa': settings.is_freekassa_enabled(),
|
||||
'cloudpayments': settings.is_cloudpayments_enabled(),
|
||||
}
|
||||
# Build response with additional options formatting
|
||||
methods = []
|
||||
for method_data in enabled_methods:
|
||||
method_id = method_data['id']
|
||||
|
||||
# Default options builder (for methods with sub-options)
|
||||
def _build_options(method_id: str, config_sub_options: dict | None) -> list[dict] | None:
|
||||
if method_id == 'yookassa':
|
||||
all_opts = [
|
||||
{'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'},
|
||||
{'id': 'sbp', 'name': '🏦 СБП', 'description': 'Система быстрых платежей (QR)'},
|
||||
]
|
||||
elif method_id == 'pal24':
|
||||
all_opts = [
|
||||
{'id': 'sbp', 'name': '🏦 СБП', 'description': 'Система быстрых платежей'},
|
||||
{'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'},
|
||||
]
|
||||
elif method_id == 'platega':
|
||||
platega_methods = settings.get_platega_active_methods()
|
||||
definitions = settings.get_platega_method_definitions()
|
||||
all_opts = []
|
||||
for method_code in platega_methods:
|
||||
info = definitions.get(method_code, {})
|
||||
all_opts.append(
|
||||
# Format options with descriptions for specific methods
|
||||
options = method_data.get('options')
|
||||
if options:
|
||||
formatted_options = []
|
||||
for opt in options:
|
||||
opt_id = opt['id']
|
||||
opt_name = opt.get('name', opt_id)
|
||||
description = ''
|
||||
|
||||
# Add descriptions based on method and option
|
||||
if method_id in ('yookassa', 'pal24', 'cloudpayments', 'freekassa'):
|
||||
if opt_id == 'card':
|
||||
opt_name = f'💳 {opt_name}'
|
||||
description = 'Банковская карта'
|
||||
elif opt_id == 'sbp':
|
||||
opt_name = f'🏦 {opt_name}'
|
||||
description = 'Система быстрых платежей'
|
||||
elif method_id == 'platega':
|
||||
# Platega options already have descriptions from config
|
||||
definitions = settings.get_platega_method_definitions()
|
||||
info = definitions.get(int(opt_id), {}) if opt_id.isdigit() else {}
|
||||
description = info.get('description') or info.get('name') or ''
|
||||
|
||||
formatted_options.append(
|
||||
{
|
||||
'id': str(method_code),
|
||||
'name': info.get('title') or info.get('name') or f'Platega {method_code}',
|
||||
'description': info.get('description') or info.get('name') or '',
|
||||
'id': opt_id,
|
||||
'name': opt_name,
|
||||
'description': description,
|
||||
}
|
||||
)
|
||||
elif method_id == 'freekassa':
|
||||
all_opts = [
|
||||
{'id': 'sbp', 'name': '🏦 NSPK СБП', 'description': 'Система быстрых платежей'},
|
||||
{'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'},
|
||||
]
|
||||
elif method_id == 'cloudpayments':
|
||||
all_opts = [
|
||||
{'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'},
|
||||
{'id': 'sbp', 'name': '🏦 СБП', 'description': 'Система быстрых платежей'},
|
||||
]
|
||||
else:
|
||||
return None
|
||||
|
||||
if not all_opts:
|
||||
return None
|
||||
|
||||
# Filter by sub_options config from DB
|
||||
if config_sub_options:
|
||||
all_opts = [o for o in all_opts if config_sub_options.get(o['id'], True)]
|
||||
|
||||
return all_opts if all_opts else None
|
||||
|
||||
# User promo group IDs for filtering
|
||||
user_promo_group_ids: set[int] = set()
|
||||
if hasattr(user, 'user_promo_groups') and user.user_promo_groups:
|
||||
for upg in user.user_promo_groups:
|
||||
user_promo_group_ids.add(upg.promo_group_id)
|
||||
if hasattr(user, 'promo_group_id') and user.promo_group_id:
|
||||
user_promo_group_ids.add(user.promo_group_id)
|
||||
|
||||
methods = []
|
||||
for config in configs:
|
||||
mid = config.method_id
|
||||
|
||||
# 1. Check env-var provider availability AND DB admin toggle
|
||||
if not provider_enabled.get(mid, False):
|
||||
continue
|
||||
if not config.is_enabled:
|
||||
continue
|
||||
|
||||
# 2. Check user type filter
|
||||
if config.user_type_filter == 'telegram' and user.auth_type != 'telegram':
|
||||
continue
|
||||
if config.user_type_filter == 'email' and user.auth_type != 'email':
|
||||
continue
|
||||
|
||||
# 3. Check first topup filter
|
||||
if config.first_topup_filter == 'yes' and not user.has_made_first_topup:
|
||||
continue
|
||||
if config.first_topup_filter == 'no' and user.has_made_first_topup:
|
||||
continue
|
||||
|
||||
# 4. Check promo group filter
|
||||
if config.promo_group_filter_mode == 'selected' and config.allowed_promo_groups:
|
||||
allowed_ids = {pg.id for pg in config.allowed_promo_groups}
|
||||
if not user_promo_group_ids.intersection(allowed_ids):
|
||||
continue
|
||||
|
||||
# Build the response
|
||||
method_def = defaults.get(mid, {})
|
||||
display_name = config.display_name or method_def.get('default_display_name', mid)
|
||||
min_amount = config.min_amount_kopeks or method_def.get('default_min', 1000)
|
||||
max_amount = config.max_amount_kopeks or method_def.get('default_max', 10000000)
|
||||
options = _build_options(mid, config.sub_options)
|
||||
options = formatted_options if formatted_options else None
|
||||
|
||||
methods.append(
|
||||
PaymentMethodResponse(
|
||||
id=mid,
|
||||
name=display_name,
|
||||
id=method_id,
|
||||
name=method_data['name'],
|
||||
description=None,
|
||||
min_amount_kopeks=min_amount,
|
||||
max_amount_kopeks=max_amount,
|
||||
min_amount_kopeks=method_data['min_amount_kopeks'],
|
||||
max_amount_kopeks=method_data['max_amount_kopeks'],
|
||||
is_available=True,
|
||||
options=options,
|
||||
)
|
||||
@@ -263,15 +212,6 @@ async def _get_available_payment_methods(
|
||||
return methods
|
||||
|
||||
|
||||
@router.get('/payment-methods', response_model=list[PaymentMethodResponse])
|
||||
async def get_payment_methods(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get available payment methods."""
|
||||
return await _get_available_payment_methods(db, user)
|
||||
|
||||
|
||||
@router.post('/stars-invoice', response_model=StarsInvoiceResponse)
|
||||
async def create_stars_invoice(
|
||||
request: StarsInvoiceRequest,
|
||||
@@ -373,7 +313,7 @@ async def create_topup(
|
||||
):
|
||||
"""Create payment for balance top-up."""
|
||||
# Validate payment method
|
||||
methods = await _get_available_payment_methods(db, user)
|
||||
methods = await get_payment_methods(user=user, db=db)
|
||||
method = next((m for m in methods if m.id == request.payment_method), None)
|
||||
|
||||
if not method or not method.is_available:
|
||||
@@ -700,6 +640,32 @@ async def create_topup(
|
||||
detail='Failed to create FreeKassa payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'kassa_ai':
|
||||
if not settings.is_kassa_ai_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='KassaAI payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_kassa_ai_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create KassaAI payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'tribute':
|
||||
if not settings.TRIBUTE_ENABLED or not settings.TRIBUTE_DONATE_LINK:
|
||||
raise HTTPException(
|
||||
@@ -840,6 +806,17 @@ def _get_status_info(record: PendingPayment) -> tuple[str, str]:
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.KASSA_AI:
|
||||
mapping = {
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'success': ('✅', 'Оплачено'),
|
||||
'paid': ('✅', 'Оплачено'),
|
||||
'canceled': ('❌', 'Отменено'),
|
||||
'failed': ('❌', 'Ошибка'),
|
||||
'expired': ('⌛', 'Истёк'),
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
return '❓', 'Неизвестно'
|
||||
|
||||
|
||||
@@ -868,6 +845,8 @@ def _is_checkable(record: PendingPayment) -> bool:
|
||||
return status in {'pending', 'authorized'}
|
||||
if record.method == PaymentMethod.FREEKASSA:
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
if record.method == PaymentMethod.KASSA_AI:
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
return False
|
||||
|
||||
|
||||
@@ -891,7 +870,7 @@ def _get_payment_url(record: PendingPayment) -> str | None:
|
||||
)
|
||||
elif record.method == PaymentMethod.PLATEGA:
|
||||
payment_url = getattr(payment, 'redirect_url', None) or payment_url
|
||||
elif record.method == PaymentMethod.CLOUDPAYMENTS or record.method == PaymentMethod.FREEKASSA:
|
||||
elif record.method in (PaymentMethod.CLOUDPAYMENTS, PaymentMethod.FREEKASSA, PaymentMethod.KASSA_AI):
|
||||
payment_url = getattr(payment, 'payment_url', None) or payment_url
|
||||
|
||||
return payment_url
|
||||
|
||||
@@ -48,7 +48,9 @@ from ..schemas.subscription import (
|
||||
RenewalOptionResponse,
|
||||
RenewalRequest,
|
||||
ServerInfo,
|
||||
SubscriptionData,
|
||||
SubscriptionResponse,
|
||||
SubscriptionStatusResponse,
|
||||
TariffPurchaseRequest,
|
||||
TrafficPackageResponse,
|
||||
TrafficPurchaseRequest,
|
||||
@@ -66,7 +68,7 @@ def _subscription_to_response(
|
||||
servers: list[ServerInfo] | None = None,
|
||||
tariff_name: str | None = None,
|
||||
traffic_purchases: list[dict[str, Any]] | None = None,
|
||||
) -> SubscriptionResponse:
|
||||
) -> SubscriptionData:
|
||||
"""Convert Subscription model to response."""
|
||||
now = datetime.utcnow()
|
||||
|
||||
@@ -171,7 +173,7 @@ def _subscription_to_response(
|
||||
)
|
||||
|
||||
|
||||
@router.get('', response_model=SubscriptionResponse)
|
||||
@router.get('', response_model=SubscriptionStatusResponse)
|
||||
async def get_subscription(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
@@ -184,10 +186,8 @@ async def get_subscription(
|
||||
fresh_user = await get_user_by_id(db, user.id)
|
||||
|
||||
if not fresh_user or not fresh_user.subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='No subscription found',
|
||||
)
|
||||
# Return 200 with has_subscription: false instead of 404
|
||||
return SubscriptionStatusResponse(has_subscription=False, subscription=None)
|
||||
|
||||
# Load tariff for daily subscription check and tariff name
|
||||
tariff_name = None
|
||||
@@ -241,7 +241,8 @@ async def get_subscription(
|
||||
}
|
||||
)
|
||||
|
||||
return _subscription_to_response(fresh_user.subscription, servers, tariff_name, traffic_purchases_data)
|
||||
subscription_data = _subscription_to_response(fresh_user.subscription, servers, tariff_name, traffic_purchases_data)
|
||||
return SubscriptionStatusResponse(has_subscription=True, subscription=subscription_data)
|
||||
|
||||
|
||||
@router.get('/renewal-options', response_model=list[RenewalOptionResponse])
|
||||
@@ -665,9 +666,35 @@ async def purchase_traffic(
|
||||
|
||||
# Проверяем баланс
|
||||
if user.balance_kopeks < final_price:
|
||||
missing = final_price - user.balance_kopeks
|
||||
|
||||
# Save cart for auto-purchase after balance top-up
|
||||
cart_data = {
|
||||
'cart_mode': 'add_traffic',
|
||||
'subscription_id': subscription.id,
|
||||
'traffic_gb': request.gb,
|
||||
'price_kopeks': final_price,
|
||||
'base_price_kopeks': base_price_kopeks,
|
||||
'discount_percent': traffic_discount_percent,
|
||||
'source': 'cabinet',
|
||||
'description': f'Докупка {request.gb} ГБ трафика',
|
||||
}
|
||||
|
||||
try:
|
||||
await user_cart_service.save_user_cart(user.id, cart_data)
|
||||
logger.info(f'Cart saved for traffic purchase (cabinet) user {user.id}: +{request.gb} GB')
|
||||
except Exception as e:
|
||||
logger.error(f'Error saving cart for traffic purchase (cabinet): {e}')
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail=f'Insufficient balance. Need {final_price / 100:.2f} RUB, have {user.balance_kopeks / 100:.2f} RUB',
|
||||
detail={
|
||||
'code': 'insufficient_funds',
|
||||
'message': f'Недостаточно средств. Не хватает {settings.format_price(missing)}',
|
||||
'missing_amount': missing,
|
||||
'cart_saved': True,
|
||||
'cart_mode': 'add_traffic',
|
||||
},
|
||||
)
|
||||
|
||||
# Формируем описание
|
||||
@@ -723,6 +750,31 @@ async def purchase_traffic(
|
||||
await db.refresh(user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Отправляем уведомление админам
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
old_traffic = subscription.traffic_limit_gb - request.gb
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db=db,
|
||||
user=user,
|
||||
subscription=subscription,
|
||||
update_type='traffic',
|
||||
old_value=old_traffic,
|
||||
new_value=subscription.traffic_limit_gb,
|
||||
price_paid=final_price,
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send admin notification for traffic purchase: {e}')
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': 'Traffic purchased successfully',
|
||||
@@ -754,9 +806,31 @@ async def purchase_devices(
|
||||
|
||||
# Check balance
|
||||
if user.balance_kopeks < total_price:
|
||||
missing = total_price - user.balance_kopeks
|
||||
|
||||
# Сохраняем корзину для автопокупки после пополнения
|
||||
try:
|
||||
cart_data = {
|
||||
'cart_mode': 'add_devices',
|
||||
'devices_to_add': request.devices,
|
||||
'price_kopeks': total_price,
|
||||
'source': 'cabinet',
|
||||
}
|
||||
await user_cart_service.save_user_cart(user.id, cart_data)
|
||||
logger.info(f'Cart saved for device purchase (cabinet /devices) user {user.id}: +{request.devices} devices')
|
||||
except Exception as e:
|
||||
logger.error(f'Error saving cart for device purchase (cabinet /devices): {e}')
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Insufficient balance',
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
'code': 'insufficient_funds',
|
||||
'error': 'Insufficient balance',
|
||||
'required_kopeks': total_price,
|
||||
'current_kopeks': user.balance_kopeks,
|
||||
'missing_kopeks': missing,
|
||||
'cart_saved': True,
|
||||
},
|
||||
)
|
||||
|
||||
# Check max devices limit
|
||||
@@ -770,11 +844,48 @@ async def purchase_devices(
|
||||
detail=f'Maximum device limit is {max_devices}',
|
||||
)
|
||||
|
||||
# Deduct balance and add devices
|
||||
user.balance_kopeks -= total_price
|
||||
# Deduct balance and create transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import PaymentMethod
|
||||
|
||||
await subtract_user_balance(
|
||||
db=db,
|
||||
user=user,
|
||||
amount_kopeks=total_price,
|
||||
description=f'Покупка {request.devices} доп. устройств',
|
||||
create_transaction=True,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
|
||||
# Add devices
|
||||
user.subscription.device_limit = new_devices
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
# Отправляем уведомление админам
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db=db,
|
||||
user=user,
|
||||
subscription=user.subscription,
|
||||
update_type='devices',
|
||||
old_value=current_devices,
|
||||
new_value=new_devices,
|
||||
price_paid=total_price,
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send admin notification for device purchase: {e}')
|
||||
|
||||
return {
|
||||
'message': 'Devices added successfully',
|
||||
@@ -1666,8 +1777,6 @@ async def purchase_tariff(
|
||||
# Save cart for auto-renewal (not for daily tariffs - they have their own charging)
|
||||
if not is_daily_tariff:
|
||||
try:
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
|
||||
cart_data = {
|
||||
'cart_mode': 'extend',
|
||||
'subscription_id': subscription.id,
|
||||
@@ -1828,24 +1937,43 @@ async def purchase_devices(
|
||||
# Check balance
|
||||
if user.balance_kopeks < price_kopeks:
|
||||
missing = price_kopeks - user.balance_kopeks
|
||||
|
||||
# Сохраняем корзину для автопокупки после пополнения
|
||||
try:
|
||||
cart_data = {
|
||||
'cart_mode': 'add_devices',
|
||||
'devices_to_add': request.devices,
|
||||
'price_kopeks': price_kopeks,
|
||||
'source': 'cabinet',
|
||||
}
|
||||
await user_cart_service.save_user_cart(user.id, cart_data)
|
||||
logger.info(f'Cart saved for device purchase (cabinet) user {user.id}: +{request.devices} devices')
|
||||
except Exception as e:
|
||||
logger.error(f'Error saving cart for device purchase (cabinet): {e}')
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
'code': 'insufficient_funds',
|
||||
'error': 'Insufficient balance',
|
||||
'required_kopeks': price_kopeks,
|
||||
'current_kopeks': user.balance_kopeks,
|
||||
'missing_kopeks': missing,
|
||||
'cart_saved': True,
|
||||
},
|
||||
)
|
||||
|
||||
# Deduct balance
|
||||
# Deduct balance and create transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import PaymentMethod
|
||||
|
||||
await subtract_user_balance(
|
||||
db=db,
|
||||
user=user,
|
||||
amount_kopeks=price_kopeks,
|
||||
description=f'Покупка {request.devices} доп. устройств',
|
||||
create_transaction=True,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
|
||||
# Increase device limit
|
||||
@@ -1867,6 +1995,30 @@ async def purchase_devices(
|
||||
|
||||
logger.info(f'User {user.id} purchased {request.devices} devices for {price_kopeks} kopeks')
|
||||
|
||||
# Отправляем уведомление админам
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db=db,
|
||||
user=user,
|
||||
subscription=subscription,
|
||||
update_type='devices',
|
||||
old_value=current_devices,
|
||||
new_value=subscription.device_limit,
|
||||
price_paid=price_kopeks,
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send admin notification for device purchase: {e}')
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': f'Добавлено {request.devices} устройств',
|
||||
@@ -1888,6 +2040,197 @@ async def purchase_devices(
|
||||
)
|
||||
|
||||
|
||||
@router.post('/traffic/save-cart')
|
||||
async def save_traffic_cart(
|
||||
request: TrafficPurchaseRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict[str, bool]:
|
||||
"""Save cart for traffic purchase (for insufficient balance flow)."""
|
||||
from app.utils.pricing_utils import calculate_prorated_price
|
||||
|
||||
await db.refresh(user, ['subscription'])
|
||||
subscription = user.subscription
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='У вас нет активной подписки',
|
||||
)
|
||||
|
||||
if subscription.status not in ['active', 'trial']:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Ваша подписка неактивна',
|
||||
)
|
||||
|
||||
if subscription.is_trial:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Докупка трафика недоступна на пробном периоде',
|
||||
)
|
||||
|
||||
if subscription.traffic_limit_gb == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='У вас уже безлимитный трафик',
|
||||
)
|
||||
|
||||
# Get traffic price from tariff or settings
|
||||
tariff = None
|
||||
base_price_kopeks = 0
|
||||
is_tariff_mode = settings.is_tariffs_mode() and subscription.tariff_id
|
||||
|
||||
if is_tariff_mode:
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if not tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Тариф не найден',
|
||||
)
|
||||
|
||||
if not getattr(tariff, 'traffic_topup_enabled', False):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Докупка трафика недоступна на вашем тарифе',
|
||||
)
|
||||
|
||||
packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {}
|
||||
if request.gb not in packages:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Пакет трафика {request.gb} ГБ недоступен',
|
||||
)
|
||||
base_price_kopeks = packages[request.gb]
|
||||
else:
|
||||
if not settings.is_traffic_topup_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Докупка трафика отключена',
|
||||
)
|
||||
|
||||
packages = settings.get_traffic_packages()
|
||||
matching_pkg = next((pkg for pkg in packages if pkg['gb'] == request.gb and pkg.get('enabled', True)), None)
|
||||
if not matching_pkg:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Недоступный пакет трафика',
|
||||
)
|
||||
base_price_kopeks = matching_pkg['price']
|
||||
|
||||
# Apply promo group discount
|
||||
traffic_discount_percent = 0
|
||||
promo_group = (
|
||||
user.get_primary_promo_group()
|
||||
if hasattr(user, 'get_primary_promo_group')
|
||||
else getattr(user, 'promo_group', None)
|
||||
)
|
||||
if promo_group:
|
||||
apply_to_addons = getattr(promo_group, 'apply_discounts_to_addons', True)
|
||||
if apply_to_addons:
|
||||
traffic_discount_percent = max(0, min(100, int(getattr(promo_group, 'traffic_discount_percent', 0) or 0)))
|
||||
|
||||
if traffic_discount_percent > 0:
|
||||
base_price_kopeks = int(base_price_kopeks * (100 - traffic_discount_percent) / 100)
|
||||
|
||||
# Calculate prorated price
|
||||
final_price, _ = calculate_prorated_price(
|
||||
base_price_kopeks,
|
||||
subscription.end_date,
|
||||
)
|
||||
|
||||
# Save cart for auto-purchase after balance top-up
|
||||
cart_data = {
|
||||
'cart_mode': 'add_traffic',
|
||||
'subscription_id': subscription.id,
|
||||
'traffic_gb': request.gb,
|
||||
'price_kopeks': final_price,
|
||||
'base_price_kopeks': base_price_kopeks,
|
||||
'discount_percent': traffic_discount_percent,
|
||||
'source': 'cabinet',
|
||||
'description': f'Докупка {request.gb} ГБ трафика',
|
||||
}
|
||||
await user_cart_service.save_user_cart(user.id, cart_data)
|
||||
logger.info(f'Cart saved for traffic purchase (cabinet save-cart) user {user.id}: +{request.gb} GB')
|
||||
|
||||
return {'success': True, 'cart_saved': True}
|
||||
|
||||
|
||||
@router.post('/devices/save-cart')
|
||||
async def save_devices_cart(
|
||||
request: DevicePurchaseRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict[str, bool]:
|
||||
"""Save cart for device purchase (for insufficient balance flow)."""
|
||||
await db.refresh(user, ['subscription'])
|
||||
subscription = user.subscription
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='У вас нет активной подписки',
|
||||
)
|
||||
|
||||
if subscription.status not in ['active', 'trial']:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Ваша подписка неактивна',
|
||||
)
|
||||
|
||||
# Get tariff for device price (if exists)
|
||||
tariff = None
|
||||
if subscription.tariff_id:
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
|
||||
# Determine device price and max limit from tariff or settings
|
||||
if tariff and tariff.device_price_kopeks:
|
||||
device_price = tariff.device_price_kopeks
|
||||
max_device_limit = tariff.max_device_limit
|
||||
else:
|
||||
device_price = settings.PRICE_PER_DEVICE
|
||||
max_device_limit = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
|
||||
|
||||
if not device_price or device_price <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Докупка устройств недоступна',
|
||||
)
|
||||
|
||||
# Check max device limit
|
||||
current_devices = subscription.device_limit or 1
|
||||
new_device_count = current_devices + request.devices
|
||||
if max_device_limit and new_device_count > max_device_limit:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Максимальное количество устройств: {max_device_limit}',
|
||||
)
|
||||
|
||||
# Calculate prorated price based on remaining days
|
||||
now = datetime.now(UTC)
|
||||
end_date = subscription.end_date
|
||||
if end_date.tzinfo is None:
|
||||
end_date = end_date.replace(tzinfo=UTC)
|
||||
|
||||
days_left = max(1, (end_date - now).days)
|
||||
total_days = 30
|
||||
|
||||
price_kopeks = int(device_price * request.devices * days_left / total_days)
|
||||
price_kopeks = max(100, price_kopeks) # Minimum 1 ruble
|
||||
|
||||
# Save cart for auto-purchase after balance top-up
|
||||
cart_data = {
|
||||
'cart_mode': 'add_devices',
|
||||
'devices_to_add': request.devices,
|
||||
'price_kopeks': price_kopeks,
|
||||
'source': 'cabinet',
|
||||
}
|
||||
await user_cart_service.save_user_cart(user.id, cart_data)
|
||||
logger.info(f'Cart saved for device purchase (cabinet save-cart) user {user.id}: +{request.devices} devices')
|
||||
|
||||
return {'success': True, 'cart_saved': True}
|
||||
|
||||
|
||||
@router.get('/devices/price')
|
||||
async def get_device_price(
|
||||
devices: int = 1,
|
||||
@@ -3285,6 +3628,11 @@ async def toggle_subscription_pause(
|
||||
new_paused_state = not is_currently_paused
|
||||
user.subscription.is_daily_paused = new_paused_state
|
||||
|
||||
# Сохраняем статус ДО изменения для проверки RemnaWave
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
was_disabled = user.subscription.status == SubscriptionStatus.DISABLED.value
|
||||
|
||||
# If resuming, check balance
|
||||
if not new_paused_state:
|
||||
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
@@ -3300,9 +3648,7 @@ async def toggle_subscription_pause(
|
||||
)
|
||||
|
||||
# Restore ACTIVE status if was DISABLED
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
if user.subscription.status == SubscriptionStatus.DISABLED.value:
|
||||
if was_disabled:
|
||||
user.subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
user.subscription.last_daily_charge_at = datetime.utcnow()
|
||||
user.subscription.end_date = datetime.utcnow() + timedelta(days=1)
|
||||
@@ -3311,14 +3657,15 @@ async def toggle_subscription_pause(
|
||||
await db.refresh(user.subscription)
|
||||
await db.refresh(user)
|
||||
|
||||
# Sync with RemnaWave when resuming
|
||||
if not new_paused_state:
|
||||
# Sync with RemnaWave only when resuming from DISABLED state
|
||||
# При паузе НЕ отключаем - пользователь может пользоваться до конца оплаченного периода
|
||||
# При возобновлении включаем только если подписка была отключена (DISABLED)
|
||||
if not new_paused_state and user.remnawave_uuid and was_disabled:
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
if user.remnawave_uuid:
|
||||
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
|
||||
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
|
||||
except Exception as e:
|
||||
logger.error(f'Error syncing with RemnaWave on resume: {e}')
|
||||
logger.error(f'Error enabling RemnaWave user on resume: {e}')
|
||||
|
||||
if new_paused_state:
|
||||
message = 'Daily subscription paused'
|
||||
|
||||
@@ -374,6 +374,44 @@ async def notify_user_subscription_renewed(
|
||||
)
|
||||
|
||||
|
||||
async def notify_user_devices_purchased(
|
||||
user_id: int,
|
||||
devices_added: int,
|
||||
new_device_limit: int,
|
||||
amount_kopeks: int,
|
||||
) -> None:
|
||||
"""Уведомить пользователя о покупке устройств."""
|
||||
await cabinet_ws_manager.send_to_user(
|
||||
user_id,
|
||||
{
|
||||
'type': 'subscription.devices_purchased',
|
||||
'devices_added': devices_added,
|
||||
'new_device_limit': new_device_limit,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_kopeks / 100,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def notify_user_traffic_purchased(
|
||||
user_id: int,
|
||||
traffic_gb_added: int,
|
||||
new_traffic_limit_gb: int,
|
||||
amount_kopeks: int,
|
||||
) -> None:
|
||||
"""Уведомить пользователя о покупке трафика."""
|
||||
await cabinet_ws_manager.send_to_user(
|
||||
user_id,
|
||||
{
|
||||
'type': 'subscription.traffic_purchased',
|
||||
'traffic_gb_added': traffic_gb_added,
|
||||
'new_traffic_limit_gb': new_traffic_limit_gb,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_kopeks / 100,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Уведомления об автопродлении
|
||||
# ============================================================================
|
||||
|
||||
@@ -118,3 +118,23 @@ class RegisterResponse(BaseModel):
|
||||
message: str = Field(..., description='Success message')
|
||||
email: str = Field(..., description='Email address to verify')
|
||||
requires_verification: bool = Field(True, description='Whether email verification is required')
|
||||
|
||||
|
||||
class EmailChangeRequest(BaseModel):
|
||||
"""Request to initiate email change."""
|
||||
|
||||
new_email: EmailStr = Field(..., description='New email address')
|
||||
|
||||
|
||||
class EmailChangeVerifyRequest(BaseModel):
|
||||
"""Request to verify email change with code."""
|
||||
|
||||
code: str = Field(..., min_length=6, max_length=6, description='6-digit verification code')
|
||||
|
||||
|
||||
class EmailChangeResponse(BaseModel):
|
||||
"""Response for email change initiation."""
|
||||
|
||||
message: str = Field(..., description='Success message')
|
||||
new_email: str = Field(..., description='New email address pending verification')
|
||||
expires_in_minutes: int = Field(..., description='Code expiration time in minutes')
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
"""Pydantic schemas for cabinet broadcasts."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ============ Channel Types ============
|
||||
|
||||
BroadcastChannel = Literal['telegram', 'email', 'both']
|
||||
|
||||
|
||||
# ============ Filters ============
|
||||
|
||||
|
||||
@@ -100,7 +106,7 @@ class BroadcastResponse(BaseModel):
|
||||
|
||||
id: int
|
||||
target_type: str
|
||||
message_text: str
|
||||
message_text: str | None = None
|
||||
has_media: bool
|
||||
media_type: str | None = None
|
||||
media_file_id: str | None = None
|
||||
@@ -115,6 +121,11 @@ class BroadcastResponse(BaseModel):
|
||||
completed_at: datetime | None = None
|
||||
progress_percent: float = 0.0
|
||||
|
||||
# Email/channel fields
|
||||
channel: str = 'telegram' # telegram|email|both
|
||||
email_subject: str | None = None
|
||||
email_html_content: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -142,3 +153,57 @@ class BroadcastPreviewResponse(BaseModel):
|
||||
|
||||
target: str
|
||||
count: int
|
||||
|
||||
|
||||
# ============ Email Filters ============
|
||||
|
||||
|
||||
class EmailFilterItem(BaseModel):
|
||||
"""Single email filter with count."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
count: int
|
||||
group: str | None = None
|
||||
|
||||
|
||||
class EmailFiltersResponse(BaseModel):
|
||||
"""Response with all email filters and their counts."""
|
||||
|
||||
filters: list[EmailFilterItem]
|
||||
total_with_email: int
|
||||
|
||||
|
||||
# ============ Combined Broadcast ============
|
||||
|
||||
|
||||
class CombinedBroadcastCreateRequest(BaseModel):
|
||||
"""Request to create a combined (telegram/email/both) broadcast."""
|
||||
|
||||
channel: BroadcastChannel
|
||||
target: str
|
||||
|
||||
# Telegram-specific fields
|
||||
message_text: str | None = Field(default=None, max_length=4000)
|
||||
selected_buttons: list[str] = Field(default_factory=lambda: ['home'])
|
||||
media: BroadcastMediaRequest | None = None
|
||||
|
||||
# Email-specific fields
|
||||
email_subject: str | None = Field(default=None, max_length=255)
|
||||
email_html_content: str | None = Field(default=None, max_length=100000)
|
||||
|
||||
|
||||
# ============ Email Preview ============
|
||||
|
||||
|
||||
class EmailPreviewRequest(BaseModel):
|
||||
"""Request to preview email broadcast recipients."""
|
||||
|
||||
target: str
|
||||
|
||||
|
||||
class EmailPreviewResponse(BaseModel):
|
||||
"""Preview response for email broadcast."""
|
||||
|
||||
target: str
|
||||
count: int
|
||||
|
||||
@@ -24,7 +24,7 @@ class TrafficPurchaseInfo(BaseModel):
|
||||
progress_percent: float
|
||||
|
||||
|
||||
class SubscriptionResponse(BaseModel):
|
||||
class SubscriptionData(BaseModel):
|
||||
"""User subscription data."""
|
||||
|
||||
id: int
|
||||
@@ -61,6 +61,17 @@ class SubscriptionResponse(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Backward compatibility alias
|
||||
SubscriptionResponse = SubscriptionData
|
||||
|
||||
|
||||
class SubscriptionStatusResponse(BaseModel):
|
||||
"""Response for subscription status endpoint - handles users with and without subscription."""
|
||||
|
||||
has_subscription: bool
|
||||
subscription: SubscriptionData | None = None
|
||||
|
||||
|
||||
class RenewalOptionResponse(BaseModel):
|
||||
"""Available subscription renewal option."""
|
||||
|
||||
|
||||
@@ -336,6 +336,122 @@ class EmailService:
|
||||
|
||||
return self.send_email(to_email, subject, body_html)
|
||||
|
||||
def send_email_change_code(
|
||||
self,
|
||||
to_email: str,
|
||||
code: str,
|
||||
username: str | None = None,
|
||||
language: str = 'ru',
|
||||
custom_subject: str | None = None,
|
||||
custom_body_html: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Send email change verification code.
|
||||
|
||||
Args:
|
||||
to_email: New email address
|
||||
code: 6-digit verification code
|
||||
username: User's name for personalization
|
||||
language: Language code (ru, en, zh, ua)
|
||||
custom_subject: Override subject from admin template
|
||||
custom_body_html: Override body HTML from admin template
|
||||
|
||||
Returns:
|
||||
True if email was sent successfully, False otherwise
|
||||
"""
|
||||
if custom_subject and custom_body_html:
|
||||
return self.send_email(to_email, custom_subject, custom_body_html)
|
||||
|
||||
expire_minutes = settings.get_cabinet_email_change_code_expire_minutes()
|
||||
|
||||
texts = {
|
||||
'ru': {
|
||||
'greeting': f'Здравствуйте{", " + username if username else ""}!',
|
||||
'subject': 'Код подтверждения для смены email',
|
||||
'intro': 'Вы запросили смену email адреса. Используйте код ниже для подтверждения:',
|
||||
'code_label': 'Ваш код подтверждения:',
|
||||
'expires': f'Код действителен в течение {expire_minutes} минут.',
|
||||
'ignore': 'Если вы не запрашивали смену email, просто проигнорируйте это письмо.',
|
||||
'regards': 'С уважением,',
|
||||
},
|
||||
'en': {
|
||||
'greeting': f'Hello{", " + username if username else ""}!',
|
||||
'subject': 'Email change verification code',
|
||||
'intro': 'You requested to change your email address. Use the code below to confirm:',
|
||||
'code_label': 'Your verification code:',
|
||||
'expires': f'This code will expire in {expire_minutes} minutes.',
|
||||
'ignore': "If you didn't request an email change, you can safely ignore this email.",
|
||||
'regards': 'Best regards,',
|
||||
},
|
||||
'zh': {
|
||||
'greeting': f'您好{", " + username if username else ""}!',
|
||||
'subject': '邮箱更换验证码',
|
||||
'intro': '您请求更换邮箱地址。请使用以下验证码确认:',
|
||||
'code_label': '您的验证码:',
|
||||
'expires': f'此验证码将在 {expire_minutes} 分钟后过期。',
|
||||
'ignore': '如果您没有请求更换邮箱,请忽略此邮件。',
|
||||
'regards': '此致,',
|
||||
},
|
||||
'ua': {
|
||||
'greeting': f'Вітаємо{", " + username if username else ""}!',
|
||||
'subject': 'Код підтвердження для зміни email',
|
||||
'intro': 'Ви запросили зміну email адреси. Використовуйте код нижче для підтвердження:',
|
||||
'code_label': 'Ваш код підтвердження:',
|
||||
'expires': f'Код дійсний протягом {expire_minutes} хвилин.',
|
||||
'ignore': 'Якщо ви не запитували зміну email, просто проігноруйте цей лист.',
|
||||
'regards': 'З повагою,',
|
||||
},
|
||||
}
|
||||
|
||||
t = texts.get(language, texts['ru'])
|
||||
|
||||
subject = t['subject']
|
||||
body_html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
|
||||
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
||||
.code-box {{
|
||||
background-color: #f8f9fa;
|
||||
border: 2px solid #007bff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
}}
|
||||
.code {{
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 8px;
|
||||
color: #007bff;
|
||||
font-family: monospace;
|
||||
}}
|
||||
.footer {{ margin-top: 30px; font-size: 12px; color: #666; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>{t['greeting']}</h2>
|
||||
<p>{t['intro']}</p>
|
||||
<div class="code-box">
|
||||
<p>{t['code_label']}</p>
|
||||
<p class="code">{code}</p>
|
||||
</div>
|
||||
<p>{t['expires']}</p>
|
||||
<p>{t['ignore']}</p>
|
||||
<div class="footer">
|
||||
<p>{t['regards']}<br>{self.from_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return self.send_email(to_email, subject, body_html)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
email_service = EmailService()
|
||||
|
||||
@@ -691,6 +691,7 @@ class Settings(BaseSettings):
|
||||
CABINET_EMAIL_VERIFICATION_ENABLED: bool = True
|
||||
CABINET_EMAIL_VERIFICATION_EXPIRE_HOURS: int = 24
|
||||
CABINET_PASSWORD_RESET_EXPIRE_HOURS: int = 1
|
||||
CABINET_EMAIL_CHANGE_CODE_EXPIRE_MINUTES: int = 15 # Email change verification code expiration
|
||||
CABINET_EMAIL_AUTH_ENABLED: bool = True # Enable email registration/login in cabinet
|
||||
CABINET_URL: str = 'https://example.com/cabinet' # Base URL for cabinet (used in verification emails)
|
||||
|
||||
@@ -2498,6 +2499,9 @@ class Settings(BaseSettings):
|
||||
def get_cabinet_password_reset_expire_hours(self) -> int:
|
||||
return max(1, self.CABINET_PASSWORD_RESET_EXPIRE_HOURS)
|
||||
|
||||
def get_cabinet_email_change_code_expire_minutes(self) -> int:
|
||||
return max(1, self.CABINET_EMAIL_CHANGE_CODE_EXPIRE_MINUTES)
|
||||
|
||||
def is_cabinet_email_auth_enabled(self) -> bool:
|
||||
return bool(self.CABINET_EMAIL_AUTH_ENABLED)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import selectinload
|
||||
from app.database.models import (
|
||||
ReferralContest,
|
||||
ReferralContestEvent,
|
||||
ReferralContestVirtualParticipant,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
@@ -918,3 +919,96 @@ async def cleanup_invalid_contest_events(
|
||||
'contest_start': contest_start.isoformat(),
|
||||
'contest_end': contest_end.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ── Виртуальные участники ──────────────────────────────────────────────
|
||||
|
||||
|
||||
async def add_virtual_participant(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
display_name: str,
|
||||
referral_count: int,
|
||||
total_amount_kopeks: int = 0,
|
||||
) -> ReferralContestVirtualParticipant:
|
||||
vp = ReferralContestVirtualParticipant(
|
||||
contest_id=contest_id,
|
||||
display_name=display_name,
|
||||
referral_count=referral_count,
|
||||
total_amount_kopeks=total_amount_kopeks,
|
||||
)
|
||||
db.add(vp)
|
||||
await db.commit()
|
||||
await db.refresh(vp)
|
||||
return vp
|
||||
|
||||
|
||||
async def list_virtual_participants(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
) -> Sequence[ReferralContestVirtualParticipant]:
|
||||
result = await db.execute(
|
||||
select(ReferralContestVirtualParticipant)
|
||||
.where(ReferralContestVirtualParticipant.contest_id == contest_id)
|
||||
.order_by(ReferralContestVirtualParticipant.referral_count.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def delete_virtual_participant(
|
||||
db: AsyncSession,
|
||||
participant_id: int,
|
||||
) -> bool:
|
||||
result = await db.execute(
|
||||
select(ReferralContestVirtualParticipant).where(ReferralContestVirtualParticipant.id == participant_id)
|
||||
)
|
||||
vp = result.scalar_one_or_none()
|
||||
if not vp:
|
||||
return False
|
||||
await db.delete(vp)
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def update_virtual_participant_count(
|
||||
db: AsyncSession,
|
||||
participant_id: int,
|
||||
referral_count: int,
|
||||
) -> ReferralContestVirtualParticipant | None:
|
||||
result = await db.execute(
|
||||
select(ReferralContestVirtualParticipant).where(ReferralContestVirtualParticipant.id == participant_id)
|
||||
)
|
||||
vp = result.scalar_one_or_none()
|
||||
if not vp:
|
||||
return None
|
||||
vp.referral_count = referral_count
|
||||
await db.commit()
|
||||
await db.refresh(vp)
|
||||
return vp
|
||||
|
||||
|
||||
async def get_contest_leaderboard_with_virtual(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
*,
|
||||
limit: int | None = None,
|
||||
) -> list[tuple[str, int, int, bool]]:
|
||||
"""Лидерборд с виртуальными участниками.
|
||||
|
||||
Возвращает список кортежей (display_name, referral_count, total_amount, is_virtual).
|
||||
"""
|
||||
real = await get_contest_leaderboard(db, contest_id)
|
||||
virtual = await list_virtual_participants(db, contest_id)
|
||||
|
||||
merged: list[tuple[str, int, int, bool]] = []
|
||||
for user, score, amount in real:
|
||||
merged.append((user.full_name, score, amount, False))
|
||||
for vp in virtual:
|
||||
merged.append((vp.display_name, vp.referral_count, vp.total_amount_kopeks, True))
|
||||
|
||||
merged.sort(key=lambda x: (-x[1], -x[2]))
|
||||
|
||||
if limit:
|
||||
merged = merged[:limit]
|
||||
|
||||
return merged
|
||||
|
||||
+152
-17
@@ -408,29 +408,33 @@ async def add_user_balance(
|
||||
# Автоматическое возобновление приостановленной суточной подписки
|
||||
try:
|
||||
from app.database.crud.subscription import resume_daily_subscription
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
subscription = user.subscription
|
||||
if subscription and subscription.status == SubscriptionStatus.DISABLED.value:
|
||||
# Проверяем что это суточный тариф
|
||||
is_daily = getattr(subscription, 'is_daily_tariff', False)
|
||||
if is_daily and subscription.tariff:
|
||||
daily_price = getattr(subscription.tariff, 'daily_price_kopeks', 0)
|
||||
# Если баланс достаточный для суточной оплаты - возобновляем
|
||||
if daily_price > 0 and user.balance_kopeks >= daily_price:
|
||||
await resume_daily_subscription(db, subscription)
|
||||
logger.info(
|
||||
f'✅ Автоматически возобновлена суточная подписка {subscription.id} '
|
||||
f'после пополнения баланса (user_id={user.id})'
|
||||
)
|
||||
# Синхронизируем с RemnaWave
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
if is_daily and subscription.tariff_id:
|
||||
# Загружаем тариф явно, чтобы избежать lazy loading
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff:
|
||||
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
# Если баланс достаточный для суточной оплаты - возобновляем
|
||||
if daily_price > 0 and user.balance_kopeks >= daily_price:
|
||||
await resume_daily_subscription(db, subscription)
|
||||
logger.info(
|
||||
f'✅ Автоматически возобновлена суточная подписка {subscription.id} '
|
||||
f'после пополнения баланса (user_id={user.id})'
|
||||
)
|
||||
# Синхронизируем с RemnaWave
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
except Exception as sync_err:
|
||||
logger.warning(f'Не удалось синхронизировать с RemnaWave: {sync_err}')
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
except Exception as sync_err:
|
||||
logger.warning(f'Не удалось синхронизировать с RemnaWave: {sync_err}')
|
||||
except Exception as resume_err:
|
||||
logger.warning(f'Ошибка при попытке возобновить суточную подписку: {resume_err}')
|
||||
|
||||
@@ -686,6 +690,7 @@ async def get_users_list(
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
search: str | None = None,
|
||||
email: str | None = None,
|
||||
status: UserStatus | None = None,
|
||||
order_by_balance: bool = False,
|
||||
order_by_traffic: bool = False,
|
||||
@@ -722,6 +727,9 @@ async def get_users_list(
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
if email:
|
||||
query = query.where(User.email.ilike(f'%{email}%'))
|
||||
|
||||
sort_flags = [
|
||||
order_by_balance,
|
||||
order_by_traffic,
|
||||
@@ -777,7 +785,9 @@ async def get_users_list(
|
||||
return users
|
||||
|
||||
|
||||
async def get_users_count(db: AsyncSession, status: UserStatus | None = None, search: str | None = None) -> int:
|
||||
async def get_users_count(
|
||||
db: AsyncSession, status: UserStatus | None = None, search: str | None = None, email: str | None = None
|
||||
) -> int:
|
||||
query = select(func.count(User.id))
|
||||
|
||||
if status:
|
||||
@@ -803,6 +813,9 @@ async def get_users_count(db: AsyncSession, status: UserStatus | None = None, se
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
if email:
|
||||
query = query.where(User.email.ilike(f'%{email}%'))
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar()
|
||||
|
||||
@@ -1099,3 +1112,125 @@ async def get_user_by_email(db: AsyncSession, email: str) -> User | None:
|
||||
"""Get user by email address."""
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def is_email_taken(db: AsyncSession, email: str, exclude_user_id: int | None = None) -> bool:
|
||||
"""
|
||||
Check if email is already taken by another user.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
email: Email to check
|
||||
exclude_user_id: User ID to exclude from check (for current user)
|
||||
|
||||
Returns:
|
||||
True if email is taken, False otherwise
|
||||
"""
|
||||
query = select(User.id).where(User.email == email)
|
||||
if exclude_user_id:
|
||||
query = query.where(User.id != exclude_user_id)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def set_email_change_pending(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
new_email: str,
|
||||
code: str,
|
||||
expires_at: datetime,
|
||||
) -> User:
|
||||
"""
|
||||
Set pending email change for user.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user: User object
|
||||
new_email: New email address
|
||||
code: 6-digit verification code
|
||||
expires_at: Code expiration datetime
|
||||
|
||||
Returns:
|
||||
Updated User object
|
||||
"""
|
||||
user.email_change_new = new_email
|
||||
user.email_change_code = code
|
||||
user.email_change_expires = expires_at
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(f'Email change pending for user {user.id}: {user.email} -> {new_email}')
|
||||
return user
|
||||
|
||||
|
||||
async def verify_and_apply_email_change(db: AsyncSession, user: User, code: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Verify email change code and apply the change.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user: User object
|
||||
code: Verification code from user
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, message: str)
|
||||
"""
|
||||
if not user.email_change_new or not user.email_change_code:
|
||||
return False, 'No pending email change'
|
||||
|
||||
if user.email_change_expires and datetime.utcnow() > user.email_change_expires:
|
||||
# Clear expired data
|
||||
user.email_change_new = None
|
||||
user.email_change_code = None
|
||||
user.email_change_expires = None
|
||||
await db.commit()
|
||||
return False, 'Verification code has expired'
|
||||
|
||||
if user.email_change_code != code:
|
||||
return False, 'Invalid verification code'
|
||||
|
||||
# Check if new email is still available
|
||||
existing = await get_user_by_email(db, user.email_change_new)
|
||||
if existing and existing.id != user.id:
|
||||
user.email_change_new = None
|
||||
user.email_change_code = None
|
||||
user.email_change_expires = None
|
||||
await db.commit()
|
||||
return False, 'This email is already taken'
|
||||
|
||||
old_email = user.email
|
||||
new_email = user.email_change_new
|
||||
|
||||
# Apply the change
|
||||
user.email = new_email
|
||||
user.email_verified = True
|
||||
user.email_verified_at = datetime.utcnow()
|
||||
user.email_change_new = None
|
||||
user.email_change_code = None
|
||||
user.email_change_expires = None
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(f'Email changed for user {user.id}: {old_email} -> {new_email}')
|
||||
return True, 'Email changed successfully'
|
||||
|
||||
|
||||
async def clear_email_change_pending(db: AsyncSession, user: User) -> None:
|
||||
"""
|
||||
Clear pending email change data.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user: User object
|
||||
"""
|
||||
user.email_change_new = None
|
||||
user.email_change_code = None
|
||||
user.email_change_expires = None
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
logger.info(f'Email change cancelled for user {user.id}')
|
||||
|
||||
+29
-1
@@ -991,6 +991,10 @@ class User(Base):
|
||||
password_reset_token = Column(String(255), nullable=True)
|
||||
password_reset_expires = Column(DateTime, nullable=True)
|
||||
cabinet_last_login = Column(DateTime, nullable=True)
|
||||
# Email change fields
|
||||
email_change_new = Column(String(255), nullable=True) # New email pending verification
|
||||
email_change_code = Column(String(6), nullable=True) # 6-digit verification code
|
||||
email_change_expires = Column(DateTime, nullable=True) # Code expiration
|
||||
broadcasts = relationship('BroadcastHistory', back_populates='admin')
|
||||
referrals = relationship('User', backref='referrer', remote_side=[id], foreign_keys='User.referred_by_id')
|
||||
subscription = relationship('Subscription', back_populates='user', uselist=False)
|
||||
@@ -1548,6 +1552,24 @@ class ReferralContestEvent(Base):
|
||||
)
|
||||
|
||||
|
||||
class ReferralContestVirtualParticipant(Base):
|
||||
__tablename__ = 'referral_contest_virtual_participants'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
contest_id = Column(Integer, ForeignKey('referral_contests.id', ondelete='CASCADE'), nullable=False)
|
||||
display_name = Column(String(255), nullable=False)
|
||||
referral_count = Column(Integer, nullable=False, default=0)
|
||||
total_amount_kopeks = Column(Integer, nullable=False, default=0)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
contest = relationship('ReferralContest')
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<ReferralContestVirtualParticipant id={self.id} name='{self.display_name}' count={self.referral_count}>"
|
||||
)
|
||||
|
||||
|
||||
class ContestTemplate(Base):
|
||||
__tablename__ = 'contest_templates'
|
||||
|
||||
@@ -1844,7 +1866,7 @@ class BroadcastHistory(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
target_type = Column(String(100), nullable=False)
|
||||
message_text = Column(Text, nullable=False)
|
||||
message_text = Column(Text, nullable=True) # Nullable for email-only broadcasts
|
||||
has_media = Column(Boolean, default=False)
|
||||
media_type = Column(String(20), nullable=True)
|
||||
media_file_id = Column(String(255), nullable=True)
|
||||
@@ -1857,6 +1879,12 @@ class BroadcastHistory(Base):
|
||||
admin_name = Column(String(255))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Email broadcast fields
|
||||
channel = Column(String(20), default='telegram', nullable=False) # telegram|email|both
|
||||
email_subject = Column(String(255), nullable=True)
|
||||
email_html_content = Column(Text, nullable=True)
|
||||
|
||||
admin = relationship('User', back_populates='broadcasts')
|
||||
|
||||
|
||||
|
||||
@@ -1731,6 +1731,65 @@ async def create_referral_contest_events_table() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def create_referral_contest_virtual_participants_table() -> bool:
|
||||
table_exists = await check_table_exists('referral_contest_virtual_participants')
|
||||
if table_exists:
|
||||
logger.info('Таблица referral_contest_virtual_participants уже существует')
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
await conn.execute(
|
||||
text("""
|
||||
CREATE TABLE referral_contest_virtual_participants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
contest_id INTEGER NOT NULL,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
referral_count INTEGER NOT NULL DEFAULT 0,
|
||||
total_amount_kopeks INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(contest_id) REFERENCES referral_contests(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
)
|
||||
elif db_type == 'postgresql':
|
||||
await conn.execute(
|
||||
text("""
|
||||
CREATE TABLE referral_contest_virtual_participants (
|
||||
id SERIAL PRIMARY KEY,
|
||||
contest_id INTEGER NOT NULL REFERENCES referral_contests(id) ON DELETE CASCADE,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
referral_count INTEGER NOT NULL DEFAULT 0,
|
||||
total_amount_kopeks INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
)
|
||||
else:
|
||||
await conn.execute(
|
||||
text("""
|
||||
CREATE TABLE referral_contest_virtual_participants (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
contest_id INT NOT NULL,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
referral_count INT NOT NULL DEFAULT 0,
|
||||
total_amount_kopeks INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(contest_id) REFERENCES referral_contests(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
)
|
||||
|
||||
logger.info('✅ Таблица referral_contest_virtual_participants создана')
|
||||
return True
|
||||
except Exception as error:
|
||||
logger.error(f'Ошибка создания таблицы referral_contest_virtual_participants: {error}')
|
||||
return False
|
||||
|
||||
|
||||
async def ensure_referral_contest_summary_columns() -> bool:
|
||||
ok = True
|
||||
for column in ['daily_summary_times', 'last_daily_summary_at']:
|
||||
@@ -3298,6 +3357,54 @@ async def add_media_fields_to_broadcast_history():
|
||||
return False
|
||||
|
||||
|
||||
async def add_email_fields_to_broadcast_history():
|
||||
"""Добавление полей для email-рассылки в broadcast_history."""
|
||||
logger.info('=== ДОБАВЛЕНИЕ ПОЛЕЙ EMAIL В BROADCAST_HISTORY ===')
|
||||
|
||||
email_fields = {
|
||||
'channel': "VARCHAR(20) DEFAULT 'telegram'",
|
||||
'email_subject': 'VARCHAR(255)',
|
||||
'email_html_content': 'TEXT',
|
||||
}
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
# Добавление новых полей
|
||||
for field_name, field_type in email_fields.items():
|
||||
field_exists = await check_column_exists('broadcast_history', field_name)
|
||||
|
||||
if not field_exists:
|
||||
logger.info(f'Добавление поля {field_name} в таблицу broadcast_history')
|
||||
|
||||
alter_sql = f'ALTER TABLE broadcast_history ADD COLUMN {field_name} {field_type}'
|
||||
await conn.execute(text(alter_sql))
|
||||
logger.info(f'✅ Поле {field_name} успешно добавлено')
|
||||
else:
|
||||
logger.info(f'Поле {field_name} уже существует в broadcast_history')
|
||||
|
||||
# Сделать message_text nullable для email-only рассылок
|
||||
try:
|
||||
if db_type == 'postgresql':
|
||||
await conn.execute(text('ALTER TABLE broadcast_history ALTER COLUMN message_text DROP NOT NULL'))
|
||||
logger.info('✅ Колонка message_text теперь nullable')
|
||||
elif db_type == 'mysql':
|
||||
await conn.execute(text('ALTER TABLE broadcast_history MODIFY COLUMN message_text TEXT NULL'))
|
||||
logger.info('✅ Колонка message_text теперь nullable')
|
||||
# SQLite не поддерживает ALTER COLUMN, но там по умолчанию nullable
|
||||
except Exception as e:
|
||||
# Игнорируем если уже nullable или другая ошибка
|
||||
logger.debug(f'message_text nullable: {e}')
|
||||
|
||||
logger.info('✅ Все поля email в broadcast_history готовы')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при добавлении полей email в broadcast_history: {e}')
|
||||
return False
|
||||
|
||||
|
||||
async def add_ticket_reply_block_columns():
|
||||
try:
|
||||
col_perm_exists = await check_column_exists('tickets', 'user_reply_block_permanent')
|
||||
@@ -3434,6 +3541,10 @@ async def add_user_cabinet_columns() -> bool:
|
||||
('password_reset_token', 'VARCHAR(255)', 'VARCHAR(255)', 'VARCHAR(255)'),
|
||||
('password_reset_expires', 'DATETIME', 'TIMESTAMP', 'DATETIME'),
|
||||
('cabinet_last_login', 'DATETIME', 'TIMESTAMP', 'DATETIME'),
|
||||
# Email change fields
|
||||
('email_change_new', 'VARCHAR(255)', 'VARCHAR(255)', 'VARCHAR(255)'),
|
||||
('email_change_code', 'VARCHAR(6)', 'VARCHAR(6)', 'VARCHAR(6)'),
|
||||
('email_change_expires', 'DATETIME', 'TIMESTAMP', 'DATETIME'),
|
||||
]
|
||||
|
||||
try:
|
||||
@@ -6459,6 +6570,12 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning('⚠️ Проблемы с таблицей referral_contest_events')
|
||||
|
||||
virtual_participants_ready = await create_referral_contest_virtual_participants_table()
|
||||
if virtual_participants_ready:
|
||||
logger.info('✅ Таблица referral_contest_virtual_participants готова')
|
||||
else:
|
||||
logger.warning('⚠️ Проблемы с таблицей referral_contest_virtual_participants')
|
||||
|
||||
contest_type_ready = await ensure_referral_contest_type_column()
|
||||
if contest_type_ready:
|
||||
logger.info('✅ Колонка contest_type для referral_contests готова')
|
||||
@@ -6632,6 +6749,13 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning('⚠️ Проблемы с добавлением медиа полей')
|
||||
|
||||
logger.info('=== ДОБАВЛЕНИЕ EMAIL ПОЛЕЙ В BROADCAST_HISTORY ===')
|
||||
email_fields_added = await add_email_fields_to_broadcast_history()
|
||||
if email_fields_added:
|
||||
logger.info('✅ Email поля в broadcast_history готовы')
|
||||
else:
|
||||
logger.warning('⚠️ Проблемы с добавлением email полей')
|
||||
|
||||
logger.info('=== ДОБАВЛЕНИЕ ПОЛЕЙ БЛОКИРОВКИ В TICKETS ===')
|
||||
tickets_block_cols_added = await add_ticket_reply_block_columns()
|
||||
if tickets_block_cols_added:
|
||||
@@ -6986,6 +7110,7 @@ async def check_migration_status():
|
||||
'pinned_messages_start_mode_column': False,
|
||||
'users_last_pinned_column': False,
|
||||
'broadcast_history_media_fields': False,
|
||||
'broadcast_history_email_fields': False,
|
||||
'subscription_duplicates': False,
|
||||
'subscription_conversions_table': False,
|
||||
'subscription_events_table': False,
|
||||
@@ -7133,6 +7258,13 @@ async def check_migration_status():
|
||||
)
|
||||
status['broadcast_history_media_fields'] = media_fields_exist
|
||||
|
||||
email_fields_exist = (
|
||||
await check_column_exists('broadcast_history', 'channel')
|
||||
and await check_column_exists('broadcast_history', 'email_subject')
|
||||
and await check_column_exists('broadcast_history', 'email_html_content')
|
||||
)
|
||||
status['broadcast_history_email_fields'] = email_fields_exist
|
||||
|
||||
pinned_media_columns_exist = (
|
||||
status['pinned_messages_table']
|
||||
and await check_column_exists('pinned_messages', 'media_type')
|
||||
@@ -7185,6 +7317,7 @@ async def check_migration_status():
|
||||
'pinned_messages_start_mode_column': 'Режим отправки закрепа при /start',
|
||||
'users_last_pinned_column': 'Колонка last_pinned_message_id у пользователей',
|
||||
'broadcast_history_media_fields': 'Медиа поля в broadcast_history',
|
||||
'broadcast_history_email_fields': 'Email поля в broadcast_history',
|
||||
'subscription_conversions_table': 'Таблица конверсий подписок',
|
||||
'subscription_events_table': 'Таблица событий подписок',
|
||||
'subscription_duplicates': 'Отсутствие дубликатов подписок',
|
||||
|
||||
Vendored
+1
-1
@@ -91,7 +91,7 @@ class TributeService:
|
||||
amount_kopeks = data.get('amount', 0)
|
||||
telegram_user_id = data.get('telegram_user_id')
|
||||
|
||||
if event_name == 'new_donation':
|
||||
if event_name in ('new_donation', 'recurrent_donation'):
|
||||
status = 'paid'
|
||||
elif event_name == 'cancelled_subscription':
|
||||
status = 'cancelled'
|
||||
|
||||
@@ -9,15 +9,19 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.referral_contest import (
|
||||
add_virtual_participant,
|
||||
create_referral_contest,
|
||||
delete_referral_contest,
|
||||
delete_virtual_participant,
|
||||
get_contest_events_count,
|
||||
get_contest_leaderboard,
|
||||
get_contest_leaderboard_with_virtual,
|
||||
get_referral_contest,
|
||||
get_referral_contests_count,
|
||||
list_referral_contests,
|
||||
list_virtual_participants,
|
||||
toggle_referral_contest,
|
||||
update_referral_contest,
|
||||
update_virtual_participant_count,
|
||||
)
|
||||
from app.keyboards.admin import (
|
||||
get_admin_contests_keyboard,
|
||||
@@ -240,8 +244,10 @@ async def show_contest_details(
|
||||
return
|
||||
|
||||
tz = _ensure_timezone(contest.timezone or settings.TIMEZONE)
|
||||
leaderboard = await get_contest_leaderboard(db, contest.id, limit=5)
|
||||
total_events = await get_contest_events_count(db, contest.id)
|
||||
leaderboard = await get_contest_leaderboard_with_virtual(db, contest.id, limit=5)
|
||||
virtual_list = await list_virtual_participants(db, contest.id)
|
||||
virtual_count = sum(vp.referral_count for vp in virtual_list)
|
||||
total_events = await get_contest_events_count(db, contest.id) + virtual_count
|
||||
|
||||
lines = [
|
||||
f'🏆 <b>{contest.title}</b>',
|
||||
@@ -256,8 +262,9 @@ async def show_contest_details(
|
||||
if leaderboard:
|
||||
lines.append('')
|
||||
lines.append(texts.t('ADMIN_CONTEST_LEADERBOARD_TITLE', '📊 Топ участников:'))
|
||||
for idx, (user, score, _) in enumerate(leaderboard, start=1):
|
||||
lines.append(f'{idx}. {user.full_name} — {score}')
|
||||
for idx, (name, score, _, is_virtual) in enumerate(leaderboard, start=1):
|
||||
virt_mark = ' 👻' if is_virtual else ''
|
||||
lines.append(f'{idx}. {name}{virt_mark} — {score}')
|
||||
|
||||
await callback.message.edit_text(
|
||||
'\n'.join(lines),
|
||||
@@ -427,7 +434,7 @@ async def show_leaderboard(
|
||||
await callback.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'), show_alert=True)
|
||||
return
|
||||
|
||||
leaderboard = await get_contest_leaderboard(db, contest_id, limit=10)
|
||||
leaderboard = await get_contest_leaderboard_with_virtual(db, contest_id, limit=10)
|
||||
if not leaderboard:
|
||||
await callback.answer(texts.t('ADMIN_CONTEST_EMPTY_LEADERBOARD', 'Пока нет участников.'), show_alert=True)
|
||||
return
|
||||
@@ -435,9 +442,9 @@ async def show_leaderboard(
|
||||
lines = [
|
||||
texts.t('ADMIN_CONTEST_LEADERBOARD_TITLE', '📊 Топ участников:'),
|
||||
]
|
||||
for idx, (user, score, _) in enumerate(leaderboard, start=1):
|
||||
user_id_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
lines.append(f'{idx}. {user.full_name} ({user_id_display}) — {score}')
|
||||
for idx, (name, score, _, is_virtual) in enumerate(leaderboard, start=1):
|
||||
virt_mark = ' 👻' if is_virtual else ''
|
||||
lines.append(f'{idx}. {name}{virt_mark} — {score}')
|
||||
|
||||
await callback.message.edit_text(
|
||||
'\n'.join(lines),
|
||||
@@ -676,6 +683,9 @@ async def show_detailed_stats(
|
||||
from app.services.referral_contest_service import referral_contest_service
|
||||
|
||||
stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id)
|
||||
virtual = await list_virtual_participants(db, contest_id)
|
||||
virtual_count = len(virtual)
|
||||
virtual_referrals = sum(vp.referral_count for vp in virtual)
|
||||
|
||||
# Общее сообщение с основной статистикой
|
||||
general_lines = [
|
||||
@@ -693,6 +703,10 @@ async def show_detailed_stats(
|
||||
f' 📥 Пополнения баланса: <b>{stats.get("deposit_total", 0) // 100} руб.</b>',
|
||||
]
|
||||
|
||||
if virtual_count > 0:
|
||||
general_lines.append('')
|
||||
general_lines.append(f'👻 Виртуальных: <b>{virtual_count}</b> (рефералов: {virtual_referrals})')
|
||||
|
||||
await callback.message.edit_text(
|
||||
'\n'.join(general_lines),
|
||||
reply_markup=get_referral_contest_manage_keyboard(
|
||||
@@ -970,6 +984,274 @@ async def debug_contest_transactions(
|
||||
)
|
||||
|
||||
|
||||
# ── Виртуальные участники ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_virtual_participants(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
contest_id = int(callback.data.split('_')[-1])
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
if not contest:
|
||||
await callback.answer('Конкурс не найден.', show_alert=True)
|
||||
return
|
||||
|
||||
vps = await list_virtual_participants(db, contest_id)
|
||||
|
||||
lines = [f'👻 <b>Виртуальные участники</b> — {contest.title}', '']
|
||||
if vps:
|
||||
for vp in vps:
|
||||
lines.append(f'• {vp.display_name} — {vp.referral_count} реф.')
|
||||
else:
|
||||
lines.append('Пока нет виртуальных участников.')
|
||||
|
||||
rows = [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='➕ Добавить',
|
||||
callback_data=f'admin_contest_vp_add_{contest_id}',
|
||||
),
|
||||
],
|
||||
]
|
||||
if vps:
|
||||
for vp in vps:
|
||||
rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f'✏️ {vp.display_name}',
|
||||
callback_data=f'admin_contest_vp_edit_{vp.id}',
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text='🗑',
|
||||
callback_data=f'admin_contest_vp_del_{vp.id}',
|
||||
),
|
||||
]
|
||||
)
|
||||
rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='⬅️ Назад',
|
||||
callback_data=f'admin_contest_view_{contest_id}',
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
'\n'.join(lines),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=rows),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_add_virtual_participant(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
contest_id = int(callback.data.split('_')[-1])
|
||||
await state.set_state(AdminStates.adding_virtual_participant_name)
|
||||
await state.update_data(vp_contest_id=contest_id)
|
||||
await callback.message.edit_text(
|
||||
'👻 Введите отображаемое имя виртуального участника:',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_contest_vp_{contest_id}')],
|
||||
]
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_virtual_participant_name(
|
||||
message: types.Message,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
name = message.text.strip()
|
||||
if not name or len(name) > 200:
|
||||
await message.answer('Имя должно быть от 1 до 200 символов. Попробуйте ещё раз:')
|
||||
return
|
||||
await state.update_data(vp_name=name)
|
||||
await state.set_state(AdminStates.adding_virtual_participant_count)
|
||||
await message.answer(f'Имя: <b>{name}</b>\n\nВведите количество рефералов (число):')
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_virtual_participant_count(
|
||||
message: types.Message,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
try:
|
||||
count = int(message.text.strip())
|
||||
if count < 1:
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
await message.answer('Введите положительное целое число:')
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
contest_id = data['vp_contest_id']
|
||||
display_name = data['vp_name']
|
||||
await state.clear()
|
||||
|
||||
vp = await add_virtual_participant(db, contest_id, display_name, count)
|
||||
await message.answer(
|
||||
f'✅ Виртуальный участник добавлен:\nИмя: <b>{vp.display_name}</b>\nРефералов: <b>{vp.referral_count}</b>',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='👻 К списку', callback_data=f'admin_contest_vp_{contest_id}')],
|
||||
[types.InlineKeyboardButton(text='⬅️ К конкурсу', callback_data=f'admin_contest_view_{contest_id}')],
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_virtual_participant_handler(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
vp_id = int(callback.data.split('_')[-1])
|
||||
|
||||
# Получим contest_id до удаления
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from app.database.models import ReferralContestVirtualParticipant
|
||||
|
||||
result = await db.execute(
|
||||
sa_select(ReferralContestVirtualParticipant).where(ReferralContestVirtualParticipant.id == vp_id)
|
||||
)
|
||||
vp = result.scalar_one_or_none()
|
||||
if not vp:
|
||||
await callback.answer('Участник не найден.', show_alert=True)
|
||||
return
|
||||
|
||||
contest_id = vp.contest_id
|
||||
deleted = await delete_virtual_participant(db, vp_id)
|
||||
if deleted:
|
||||
await callback.answer('✅ Удалён', show_alert=False)
|
||||
else:
|
||||
await callback.answer('Не удалось удалить.', show_alert=True)
|
||||
|
||||
# Вернуться к списку
|
||||
vps = await list_virtual_participants(db, contest_id)
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
|
||||
lines = [f'👻 <b>Виртуальные участники</b> — {contest.title}', '']
|
||||
if vps:
|
||||
for v in vps:
|
||||
lines.append(f'• {v.display_name} — {v.referral_count} реф.')
|
||||
else:
|
||||
lines.append('Пока нет виртуальных участников.')
|
||||
|
||||
rows = [
|
||||
[types.InlineKeyboardButton(text='➕ Добавить', callback_data=f'admin_contest_vp_add_{contest_id}')],
|
||||
]
|
||||
if vps:
|
||||
for v in vps:
|
||||
rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f'✏️ {v.display_name}', callback_data=f'admin_contest_vp_edit_{v.id}'
|
||||
),
|
||||
types.InlineKeyboardButton(text='🗑', callback_data=f'admin_contest_vp_del_{v.id}'),
|
||||
]
|
||||
)
|
||||
rows.append([types.InlineKeyboardButton(text='⬅️ Назад', callback_data=f'admin_contest_view_{contest_id}')])
|
||||
|
||||
await callback.message.edit_text(
|
||||
'\n'.join(lines),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=rows),
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_virtual_participant(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
vp_id = int(callback.data.split('_')[-1])
|
||||
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from app.database.models import ReferralContestVirtualParticipant
|
||||
|
||||
result = await db.execute(
|
||||
sa_select(ReferralContestVirtualParticipant).where(ReferralContestVirtualParticipant.id == vp_id)
|
||||
)
|
||||
vp = result.scalar_one_or_none()
|
||||
if not vp:
|
||||
await callback.answer('Участник не найден.', show_alert=True)
|
||||
return
|
||||
|
||||
await state.set_state(AdminStates.editing_virtual_participant_count)
|
||||
await state.update_data(vp_edit_id=vp_id, vp_edit_contest_id=vp.contest_id)
|
||||
await callback.message.edit_text(
|
||||
f'✏️ <b>{vp.display_name}</b>\n'
|
||||
f'Текущее кол-во рефералов: <b>{vp.referral_count}</b>\n\n'
|
||||
f'Введите новое количество:',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_contest_vp_{vp.contest_id}')],
|
||||
]
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_edit_virtual_participant_count(
|
||||
message: types.Message,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
try:
|
||||
count = int(message.text.strip())
|
||||
if count < 1:
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
await message.answer('Введите положительное целое число:')
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
vp_id = data['vp_edit_id']
|
||||
contest_id = data['vp_edit_contest_id']
|
||||
await state.clear()
|
||||
|
||||
vp = await update_virtual_participant_count(db, vp_id, count)
|
||||
if vp:
|
||||
await message.answer(
|
||||
f'✅ Обновлено: <b>{vp.display_name}</b> — {vp.referral_count} реф.',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='👻 К списку', callback_data=f'admin_contest_vp_{contest_id}')],
|
||||
]
|
||||
),
|
||||
)
|
||||
else:
|
||||
await message.answer('Участник не найден.')
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_contests_menu, F.data == 'admin_contests')
|
||||
dp.callback_query.register(show_referral_contests_menu, F.data == 'admin_contests_referral')
|
||||
@@ -996,3 +1278,11 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.message.register(process_end_date, AdminStates.creating_referral_contest_end)
|
||||
dp.message.register(finalize_contest_creation, AdminStates.creating_referral_contest_time)
|
||||
dp.message.register(process_edit_summary_times, AdminStates.editing_referral_contest_summary_times)
|
||||
|
||||
dp.callback_query.register(start_add_virtual_participant, F.data.startswith('admin_contest_vp_add_'))
|
||||
dp.callback_query.register(delete_virtual_participant_handler, F.data.startswith('admin_contest_vp_del_'))
|
||||
dp.callback_query.register(start_edit_virtual_participant, F.data.startswith('admin_contest_vp_edit_'))
|
||||
dp.callback_query.register(show_virtual_participants, F.data.regexp(r'^admin_contest_vp_\d+$'))
|
||||
dp.message.register(process_virtual_participant_name, AdminStates.adding_virtual_participant_name)
|
||||
dp.message.register(process_virtual_participant_count, AdminStates.adding_virtual_participant_count)
|
||||
dp.message.register(process_edit_virtual_participant_count, AdminStates.editing_virtual_participant_count)
|
||||
|
||||
+36
-28
@@ -1717,7 +1717,8 @@ async def required_sub_channel_check(
|
||||
try:
|
||||
state_data = await state.get_data() or {}
|
||||
|
||||
pending_start_payload = state_data.pop('pending_start_payload', None)
|
||||
# Получаем payload БЕЗ удаления - удалим только после успешной проверки подписки
|
||||
pending_start_payload = state_data.get('pending_start_payload')
|
||||
|
||||
# Если в FSM state нет payload, пробуем получить из Redis (резервный механизм)
|
||||
if not pending_start_payload:
|
||||
@@ -1729,15 +1730,46 @@ async def required_sub_channel_check(
|
||||
pending_start_payload,
|
||||
)
|
||||
|
||||
state_updated = pending_start_payload is not None
|
||||
|
||||
if pending_start_payload:
|
||||
logger.info(
|
||||
"📦 CHANNEL CHECK: Найден сохраненный payload '%s'",
|
||||
pending_start_payload,
|
||||
)
|
||||
|
||||
# Очищаем Redis после получения payload
|
||||
user = db_user
|
||||
if not user:
|
||||
user = await get_user_by_telegram_id(db, query.from_user.id)
|
||||
|
||||
if user and getattr(user, 'language', None):
|
||||
language = user.language
|
||||
elif state_data.get('language'):
|
||||
language = state_data['language']
|
||||
|
||||
texts = get_texts(language)
|
||||
|
||||
chat_member = await bot.get_chat_member(chat_id=settings.CHANNEL_SUB_ID, user_id=query.from_user.id)
|
||||
|
||||
if chat_member.status not in [
|
||||
ChatMemberStatus.MEMBER,
|
||||
ChatMemberStatus.ADMINISTRATOR,
|
||||
ChatMemberStatus.CREATOR,
|
||||
]:
|
||||
# НЕ удаляем payload - пользователь может попробовать снова после подписки
|
||||
logger.info(
|
||||
"📦 CHANNEL CHECK: Подписка не подтверждена, payload '%s' сохранён для следующей попытки",
|
||||
pending_start_payload,
|
||||
)
|
||||
return await query.answer(
|
||||
texts.t('CHANNEL_SUBSCRIBE_REQUIRED_ALERT', '❌ Вы не подписались на канал!'),
|
||||
show_alert=True,
|
||||
)
|
||||
|
||||
# Подписка подтверждена - теперь удаляем payload и обрабатываем его
|
||||
if pending_start_payload:
|
||||
# Удаляем из FSM state
|
||||
state_data.pop('pending_start_payload', None)
|
||||
|
||||
# Очищаем Redis после успешной проверки подписки
|
||||
await delete_pending_payload_from_redis(query.from_user.id)
|
||||
|
||||
# Всегда обновляем referral_code если есть новый payload
|
||||
@@ -1760,32 +1792,8 @@ async def required_sub_channel_check(
|
||||
'🎯 CHANNEL CHECK: Payload интерпретирован как реферальный код',
|
||||
)
|
||||
|
||||
if state_updated:
|
||||
await state.set_data(state_data)
|
||||
|
||||
user = db_user
|
||||
if not user:
|
||||
user = await get_user_by_telegram_id(db, query.from_user.id)
|
||||
|
||||
if user and getattr(user, 'language', None):
|
||||
language = user.language
|
||||
elif state_data.get('language'):
|
||||
language = state_data['language']
|
||||
|
||||
texts = get_texts(language)
|
||||
|
||||
chat_member = await bot.get_chat_member(chat_id=settings.CHANNEL_SUB_ID, user_id=query.from_user.id)
|
||||
|
||||
if chat_member.status not in [
|
||||
ChatMemberStatus.MEMBER,
|
||||
ChatMemberStatus.ADMINISTRATOR,
|
||||
ChatMemberStatus.CREATOR,
|
||||
]:
|
||||
return await query.answer(
|
||||
texts.t('CHANNEL_SUBSCRIBE_REQUIRED_ALERT', '❌ Вы не подписались на канал!'),
|
||||
show_alert=True,
|
||||
)
|
||||
|
||||
if user and user.subscription:
|
||||
subscription = user.subscription
|
||||
if subscription.is_trial and subscription.status == SubscriptionStatus.DISABLED.value:
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.keyboards.inline import (
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.utils.pricing_utils import (
|
||||
apply_percentage_discount,
|
||||
@@ -290,26 +291,54 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
chargeable_devices = additional_devices
|
||||
|
||||
devices_price_per_month = chargeable_devices * price_per_device
|
||||
months_hint = get_remaining_months(subscription.end_date)
|
||||
period_hint_days = months_hint * 30 if months_hint > 0 else None
|
||||
devices_discount_percent = _get_addon_discount_percent_for_user(
|
||||
db_user,
|
||||
'devices',
|
||||
period_hint_days,
|
||||
)
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
)
|
||||
price, charged_months = calculate_prorated_price(
|
||||
discounted_per_month,
|
||||
subscription.end_date,
|
||||
)
|
||||
total_discount = discount_per_month * charged_months
|
||||
|
||||
# Проверяем является ли тариф суточным
|
||||
is_daily_tariff = tariff and getattr(tariff, 'is_daily', False)
|
||||
|
||||
if is_daily_tariff:
|
||||
# Для суточных тарифов считаем по дням (как в кабинете)
|
||||
now = datetime.utcnow()
|
||||
days_left = max(1, (subscription.end_date - now).days)
|
||||
period_hint_days = days_left
|
||||
|
||||
devices_discount_percent = _get_addon_discount_percent_for_user(
|
||||
db_user,
|
||||
'devices',
|
||||
period_hint_days,
|
||||
)
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
)
|
||||
# Цена = месячная_цена * days_left / 30
|
||||
price = int(discounted_per_month * days_left / 30)
|
||||
price = max(100, price) # Минимум 1 рубль
|
||||
total_discount = int(discount_per_month * days_left / 30)
|
||||
period_label = f'{days_left} дн.' if days_left > 1 else '1 день'
|
||||
else:
|
||||
# Для обычных тарифов - по месяцам
|
||||
months_hint = get_remaining_months(subscription.end_date)
|
||||
period_hint_days = months_hint * 30 if months_hint > 0 else None
|
||||
|
||||
devices_discount_percent = _get_addon_discount_percent_for_user(
|
||||
db_user,
|
||||
'devices',
|
||||
period_hint_days,
|
||||
)
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
)
|
||||
price, charged_months = calculate_prorated_price(
|
||||
discounted_per_month,
|
||||
subscription.end_date,
|
||||
)
|
||||
total_discount = discount_per_month * charged_months
|
||||
period_label = f'{charged_months} мес'
|
||||
|
||||
if price > 0 and db_user.balance_kopeks < price:
|
||||
missing_kopeks = price - db_user.balance_kopeks
|
||||
required_text = f'{texts.format_price(price)} (за {charged_months} мес)'
|
||||
required_text = f'{texts.format_price(price)} (за {period_label})'
|
||||
message_text = texts.t(
|
||||
'ADDON_INSUFFICIENT_FUNDS_MESSAGE',
|
||||
(
|
||||
@@ -325,11 +354,28 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
missing=texts.format_price(missing_kopeks),
|
||||
)
|
||||
|
||||
# Сохраняем корзину для автопокупки после пополнения баланса
|
||||
await user_cart_service.save_user_cart(
|
||||
user_id=db_user.id,
|
||||
cart_data={
|
||||
'cart_mode': 'add_devices',
|
||||
'devices_to_add': devices_difference,
|
||||
'price_kopeks': price,
|
||||
},
|
||||
)
|
||||
logger.info(
|
||||
'Сохранена корзина add_devices для пользователя %s: +%s устройств, цена %s коп.',
|
||||
db_user.telegram_id,
|
||||
devices_difference,
|
||||
price,
|
||||
)
|
||||
|
||||
await callback.message.answer(
|
||||
message_text,
|
||||
reply_markup=get_insufficient_balance_keyboard(
|
||||
db_user.language,
|
||||
amount_kopeks=missing_kopeks,
|
||||
has_saved_cart=True,
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
@@ -343,10 +389,10 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
if price > 0:
|
||||
cost_text = texts.t(
|
||||
'DEVICE_CHANGE_EXTRA_COST',
|
||||
'Доплата: {amount} (за {months} мес)',
|
||||
'Доплата: {amount} (за {period})',
|
||||
).format(
|
||||
amount=texts.format_price(price),
|
||||
months=charged_months,
|
||||
period=period_label,
|
||||
)
|
||||
if total_discount > 0:
|
||||
cost_text += texts.t(
|
||||
@@ -949,35 +995,63 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
return
|
||||
|
||||
devices_price_per_month = devices_count * price_per_device
|
||||
months_hint = get_remaining_months(subscription.end_date)
|
||||
period_hint_days = months_hint * 30 if months_hint > 0 else None
|
||||
devices_discount_percent = _get_addon_discount_percent_for_user(
|
||||
db_user,
|
||||
'devices',
|
||||
period_hint_days,
|
||||
)
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
)
|
||||
price, charged_months = calculate_prorated_price(
|
||||
discounted_per_month,
|
||||
subscription.end_date,
|
||||
)
|
||||
total_discount = discount_per_month * charged_months
|
||||
|
||||
# Проверяем является ли тариф суточным
|
||||
is_daily_tariff = tariff and getattr(tariff, 'is_daily', False)
|
||||
|
||||
if is_daily_tariff:
|
||||
# Для суточных тарифов считаем по дням (как в кабинете)
|
||||
now = datetime.utcnow()
|
||||
days_left = max(1, (subscription.end_date - now).days)
|
||||
period_hint_days = days_left
|
||||
|
||||
devices_discount_percent = _get_addon_discount_percent_for_user(
|
||||
db_user,
|
||||
'devices',
|
||||
period_hint_days,
|
||||
)
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
)
|
||||
# Цена = месячная_цена * days_left / 30
|
||||
price = int(discounted_per_month * days_left / 30)
|
||||
price = max(100, price) # Минимум 1 рубль
|
||||
total_discount = int(discount_per_month * days_left / 30)
|
||||
period_label = f'{days_left} дн.' if days_left > 1 else '1 день'
|
||||
else:
|
||||
# Для обычных тарифов - по месяцам
|
||||
months_hint = get_remaining_months(subscription.end_date)
|
||||
period_hint_days = months_hint * 30 if months_hint > 0 else None
|
||||
|
||||
devices_discount_percent = _get_addon_discount_percent_for_user(
|
||||
db_user,
|
||||
'devices',
|
||||
period_hint_days,
|
||||
)
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
)
|
||||
price, charged_months = calculate_prorated_price(
|
||||
discounted_per_month,
|
||||
subscription.end_date,
|
||||
)
|
||||
total_discount = discount_per_month * charged_months
|
||||
period_label = f'{charged_months} мес'
|
||||
|
||||
logger.info(
|
||||
'Добавление %s устройств: %.2f₽/мес × %s мес = %.2f₽ (скидка %.2f₽)',
|
||||
'Добавление %s устройств: %.2f₽/мес × %s = %.2f₽ (скидка %.2f₽)',
|
||||
devices_count,
|
||||
discounted_per_month / 100,
|
||||
charged_months,
|
||||
period_label,
|
||||
price / 100,
|
||||
total_discount / 100,
|
||||
)
|
||||
|
||||
if db_user.balance_kopeks < price:
|
||||
missing_kopeks = price - db_user.balance_kopeks
|
||||
required_text = f'{texts.format_price(price)} (за {charged_months} мес)'
|
||||
required_text = f'{texts.format_price(price)} (за {period_label})'
|
||||
message_text = texts.t(
|
||||
'ADDON_INSUFFICIENT_FUNDS_MESSAGE',
|
||||
(
|
||||
@@ -993,12 +1067,29 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
missing=texts.format_price(missing_kopeks),
|
||||
)
|
||||
|
||||
# Сохраняем корзину для автопокупки после пополнения баланса
|
||||
await user_cart_service.save_user_cart(
|
||||
user_id=db_user.id,
|
||||
cart_data={
|
||||
'cart_mode': 'add_devices',
|
||||
'devices_to_add': devices_count,
|
||||
'price_kopeks': price,
|
||||
},
|
||||
)
|
||||
logger.info(
|
||||
'Сохранена корзина add_devices для пользователя %s: +%s устройств, цена %s коп.',
|
||||
db_user.telegram_id,
|
||||
devices_count,
|
||||
price,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=get_insufficient_balance_keyboard(
|
||||
db_user.language,
|
||||
resume_callback=resume_callback,
|
||||
amount_kopeks=missing_kopeks,
|
||||
has_saved_cart=True,
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
@@ -1007,7 +1098,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
|
||||
try:
|
||||
success = await subtract_user_balance(
|
||||
db, db_user, price, f'Добавление {devices_count} устройств на {charged_months} мес'
|
||||
db, db_user, price, f'Добавление {devices_count} устройств на {period_label}'
|
||||
)
|
||||
|
||||
if not success:
|
||||
@@ -1024,7 +1115,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
user_id=db_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=price,
|
||||
description=f'Добавление {devices_count} устройств на {charged_months} мес',
|
||||
description=f'Добавление {devices_count} устройств на {period_label}',
|
||||
)
|
||||
|
||||
await db.refresh(db_user)
|
||||
@@ -1035,7 +1126,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
f'📱 Добавлено: {devices_count} устройств\n'
|
||||
f'Новый лимит: {subscription.device_limit} устройств\n'
|
||||
)
|
||||
success_text += f'💰 Списано: {texts.format_price(price)} (за {charged_months} мес)'
|
||||
success_text += f'💰 Списано: {texts.format_price(price)} (за {period_label})'
|
||||
if total_discount > 0:
|
||||
success_text += f' (скидка {devices_discount_percent}%: -{texts.format_price(total_discount)})'
|
||||
|
||||
|
||||
@@ -428,7 +428,7 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
|
||||
type=type_text,
|
||||
end_date=format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M'),
|
||||
days_left=max(0, subscription.days_left),
|
||||
traffic_used=texts.format_traffic(subscription.traffic_used_gb),
|
||||
traffic_used=texts.format_traffic(subscription.traffic_used_gb, is_limit=False),
|
||||
traffic_limit=traffic_text,
|
||||
countries_count=len(subscription.connected_squads),
|
||||
devices_used=devices_used,
|
||||
|
||||
@@ -3084,8 +3084,8 @@ async def handle_subscription_settings(callback: types.CallbackQuery, db_user: U
|
||||
|
||||
settings_text = settings_template.format(
|
||||
countries_count=len(subscription.connected_squads),
|
||||
traffic_used=texts.format_traffic(subscription.traffic_used_gb),
|
||||
traffic_limit=texts.format_traffic(subscription.traffic_limit_gb),
|
||||
traffic_used=texts.format_traffic(subscription.traffic_used_gb, is_limit=False),
|
||||
traffic_limit=texts.format_traffic(subscription.traffic_limit_gb, is_limit=True),
|
||||
devices_used=devices_used,
|
||||
devices_limit=devices_limit_display,
|
||||
)
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.keyboards.inline import (
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from app.states import SubscriptionStates
|
||||
from app.utils.pricing_utils import (
|
||||
apply_percentage_discount,
|
||||
@@ -239,7 +240,7 @@ async def handle_reset_traffic(callback: types.CallbackQuery, db_user: User, db:
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'🔄 <b>Сброс трафика</b>\n\n'
|
||||
f'Использовано: {texts.format_traffic(subscription.traffic_used_gb)}\n'
|
||||
f'Использовано: {texts.format_traffic(subscription.traffic_used_gb, is_limit=False)}\n'
|
||||
f'Лимит: {texts.format_traffic(subscription.traffic_limit_gb)}\n\n'
|
||||
f'Стоимость сброса: {texts.format_price(reset_price)}{price_info}{balance_info}\n\n'
|
||||
'После сброса счетчик использованного трафика станет равным 0.',
|
||||
@@ -495,6 +496,24 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
|
||||
|
||||
if db_user.balance_kopeks < price:
|
||||
missing_kopeks = price - db_user.balance_kopeks
|
||||
|
||||
# Save cart for auto-purchase after balance top-up
|
||||
cart_data = {
|
||||
'cart_mode': 'add_traffic',
|
||||
'subscription_id': subscription.id,
|
||||
'traffic_gb': traffic_gb,
|
||||
'price_kopeks': price,
|
||||
'base_price_kopeks': discounted_per_month,
|
||||
'discount_percent': discount_result['percent'],
|
||||
'source': 'bot',
|
||||
'description': f'Докупка {traffic_gb} ГБ трафика',
|
||||
}
|
||||
try:
|
||||
await user_cart_service.save_user_cart(db_user.id, cart_data)
|
||||
logger.info(f'Cart saved for traffic purchase (bot) user {db_user.telegram_id}: +{traffic_gb} GB')
|
||||
except Exception as e:
|
||||
logger.error(f'Error saving cart for traffic purchase (bot): {e}')
|
||||
|
||||
message_text = texts.t(
|
||||
'ADDON_INSUFFICIENT_FUNDS_MESSAGE',
|
||||
(
|
||||
|
||||
@@ -649,6 +649,12 @@ def get_referral_contest_manage_keyboard(
|
||||
callback_data=f'admin_contest_edit_times_{contest_id}',
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text='👻 Виртуальные',
|
||||
callback_data=f'admin_contest_vp_{contest_id}',
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text='🔄 Синхронизация',
|
||||
|
||||
+33
-10
@@ -1924,12 +1924,28 @@ def get_change_devices_keyboard(
|
||||
|
||||
texts = get_texts(language)
|
||||
|
||||
months_multiplier = 1
|
||||
period_text = ''
|
||||
if subscription_end_date:
|
||||
months_multiplier = get_remaining_months(subscription_end_date)
|
||||
if months_multiplier > 1:
|
||||
period_text = f' (за {months_multiplier} мес)'
|
||||
# Проверяем является ли тариф суточным
|
||||
is_daily_tariff = tariff and getattr(tariff, 'is_daily', False)
|
||||
|
||||
# Для суточных тарифов считаем по дням, для обычных - по месяцам
|
||||
if is_daily_tariff and subscription_end_date:
|
||||
# Суточный тариф: цена за оставшиеся дни (обычно 1 день)
|
||||
from datetime import datetime
|
||||
|
||||
now = datetime.utcnow()
|
||||
days_left = max(1, (subscription_end_date - now).days)
|
||||
# Множитель = days_left / 30 (как в кабинете)
|
||||
price_multiplier = days_left / 30
|
||||
period_text = f' (за {days_left} дн.)' if days_left > 1 else ' (за 1 день)'
|
||||
else:
|
||||
# Обычный тариф: цена за оставшиеся месяцы
|
||||
months_multiplier = 1
|
||||
period_text = ''
|
||||
if subscription_end_date:
|
||||
months_multiplier = get_remaining_months(subscription_end_date)
|
||||
if months_multiplier > 1:
|
||||
period_text = f' (за {months_multiplier} мес)'
|
||||
price_multiplier = months_multiplier
|
||||
|
||||
# Используем цену из тарифа если есть, иначе глобальную настройку
|
||||
tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None
|
||||
@@ -1943,7 +1959,12 @@ def get_change_devices_keyboard(
|
||||
|
||||
buttons = []
|
||||
|
||||
max_devices = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else 20
|
||||
# Используем max_device_limit из тарифа если есть, иначе глобальную настройку
|
||||
tariff_max_devices = getattr(tariff, 'max_device_limit', None) if tariff else None
|
||||
if tariff_max_devices and tariff_max_devices > 0:
|
||||
max_devices = tariff_max_devices
|
||||
else:
|
||||
max_devices = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else 20
|
||||
|
||||
start_range = max(1, min(current_devices - 3, max_devices - 6))
|
||||
end_range = min(max_devices + 1, max(current_devices + 4, 7))
|
||||
@@ -1967,10 +1988,12 @@ def get_change_devices_keyboard(
|
||||
price_per_month,
|
||||
discount_percent,
|
||||
)
|
||||
total_price = discounted_per_month * months_multiplier
|
||||
total_price = int(discounted_per_month * price_multiplier)
|
||||
total_price = max(100, total_price) # Минимум 1 рубль
|
||||
price_text = f' (+{total_price // 100}₽{period_text})'
|
||||
if discount_percent > 0 and discount_per_month * months_multiplier > 0:
|
||||
price_text += f' (скидка {discount_percent}%: -{(discount_per_month * months_multiplier) // 100}₽)'
|
||||
total_discount = int(discount_per_month * price_multiplier)
|
||||
if discount_percent > 0 and total_discount > 0:
|
||||
price_text += f' (скидка {discount_percent}%: -{total_discount // 100}₽)'
|
||||
action_text = ''
|
||||
else:
|
||||
price_text = ' (бесплатно)'
|
||||
|
||||
@@ -190,9 +190,15 @@ class Texts:
|
||||
return settings.format_price(kopeks)
|
||||
|
||||
@staticmethod
|
||||
def format_traffic(gb: float) -> str:
|
||||
def format_traffic(gb: float, is_limit: bool = True) -> str:
|
||||
"""Format traffic value.
|
||||
|
||||
Args:
|
||||
gb: Traffic in gigabytes
|
||||
is_limit: If True, 0 means unlimited. If False, 0 means zero used.
|
||||
"""
|
||||
if gb == 0:
|
||||
return '∞ (безлимит)'
|
||||
return '∞ (безлимит)' if is_limit else '0 ГБ'
|
||||
if gb >= 1024:
|
||||
return f'{gb / 1024:.1f} ТБ'
|
||||
return f'{gb:.0f} ГБ'
|
||||
|
||||
@@ -15,7 +15,7 @@ from app.database.crud.campaign import get_campaign_by_start_parameter
|
||||
from app.database.crud.subscription import deactivate_subscription, reactivate_subscription
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import SubscriptionStatus
|
||||
from app.database.models import SubscriptionStatus, UserStatus
|
||||
from app.keyboards.inline import get_channel_sub_keyboard
|
||||
from app.localization.loader import DEFAULT_LANGUAGE
|
||||
from app.localization.texts import get_texts
|
||||
@@ -396,6 +396,14 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
if not user or not user.subscription:
|
||||
return
|
||||
|
||||
# НЕ реактивируем подписку заблокированных пользователей
|
||||
if user.status == UserStatus.BLOCKED.value:
|
||||
logger.info(
|
||||
'🚫 Пропуск реактивации подписки для заблокированного пользователя %s',
|
||||
telegram_id,
|
||||
)
|
||||
return
|
||||
|
||||
subscription = user.subscription
|
||||
|
||||
# Реактивируем только DISABLED подписки
|
||||
|
||||
@@ -4,6 +4,7 @@ import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
@@ -18,6 +19,10 @@ from app.handlers.admin.messages import (
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.cabinet.services.email_service import EmailService
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -25,6 +30,10 @@ VALID_MEDIA_TYPES = {'photo', 'video', 'document'}
|
||||
LARGE_BROADCAST_THRESHOLD = 20_000
|
||||
PROGRESS_UPDATE_STEP = 5_000
|
||||
|
||||
# Email broadcast rate limiting: max 8 emails per second
|
||||
EMAIL_RATE_LIMIT = 8
|
||||
EMAIL_BATCH_SIZE = 50
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BroadcastMediaConfig:
|
||||
@@ -42,6 +51,16 @@ class BroadcastConfig:
|
||||
initiator_name: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmailBroadcastConfig:
|
||||
"""Configuration for email broadcast."""
|
||||
|
||||
target: str
|
||||
email_subject: str
|
||||
email_html_content: str
|
||||
initiator_name: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _BroadcastTask:
|
||||
task: asyncio.Task
|
||||
@@ -473,3 +492,396 @@ class BroadcastService:
|
||||
|
||||
|
||||
broadcast_service = BroadcastService()
|
||||
|
||||
|
||||
class EmailBroadcastService:
|
||||
"""Handles email broadcast execution triggered from the admin web API."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._email_service: EmailService | None = None
|
||||
self._tasks: dict[int, _BroadcastTask] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def set_email_service(self, email_service: EmailService) -> None:
|
||||
"""Set email service instance."""
|
||||
self._email_service = email_service
|
||||
|
||||
def is_running(self, broadcast_id: int) -> bool:
|
||||
"""Check if broadcast is currently running."""
|
||||
task_entry = self._tasks.get(broadcast_id)
|
||||
return bool(task_entry and not task_entry.task.done())
|
||||
|
||||
async def start_broadcast(self, broadcast_id: int, config: EmailBroadcastConfig) -> None:
|
||||
"""Start email broadcast in background."""
|
||||
if self._email_service is None:
|
||||
logger.error('Cannot start email broadcast %s: email service not initialized', broadcast_id)
|
||||
await self._mark_failed(broadcast_id)
|
||||
return
|
||||
|
||||
if not self._email_service.is_configured():
|
||||
logger.error('Cannot start email broadcast %s: SMTP not configured', broadcast_id)
|
||||
await self._mark_failed(broadcast_id)
|
||||
return
|
||||
|
||||
cancel_event = asyncio.Event()
|
||||
|
||||
async with self._lock:
|
||||
if broadcast_id in self._tasks and not self._tasks[broadcast_id].task.done():
|
||||
logger.warning('Email broadcast %s is already running', broadcast_id)
|
||||
return
|
||||
|
||||
task = asyncio.create_task(
|
||||
self._run_broadcast(broadcast_id, config, cancel_event),
|
||||
name=f'email-broadcast-{broadcast_id}',
|
||||
)
|
||||
self._tasks[broadcast_id] = _BroadcastTask(task=task, cancel_event=cancel_event)
|
||||
task.add_done_callback(lambda _: self._tasks.pop(broadcast_id, None))
|
||||
|
||||
async def request_stop(self, broadcast_id: int) -> bool:
|
||||
"""Request to stop a running broadcast."""
|
||||
async with self._lock:
|
||||
task_entry = self._tasks.get(broadcast_id)
|
||||
if not task_entry:
|
||||
return False
|
||||
|
||||
task_entry.cancel_event.set()
|
||||
return True
|
||||
|
||||
async def _run_broadcast(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
config: EmailBroadcastConfig,
|
||||
cancel_event: asyncio.Event,
|
||||
) -> None:
|
||||
"""Execute email broadcast."""
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
try:
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return
|
||||
|
||||
# Update status to in_progress
|
||||
async with AsyncSessionLocal() as session:
|
||||
broadcast = await session.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
logger.error('Broadcast record %s not found', broadcast_id)
|
||||
return
|
||||
|
||||
broadcast.status = 'in_progress'
|
||||
broadcast.sent_count = 0
|
||||
broadcast.failed_count = 0
|
||||
await session.commit()
|
||||
|
||||
# Fetch email recipients
|
||||
recipients = await self._fetch_email_recipients(config.target)
|
||||
|
||||
# Update total count
|
||||
async with AsyncSessionLocal() as session:
|
||||
broadcast = await session.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
logger.error('Broadcast record %s deleted before start', broadcast_id)
|
||||
return
|
||||
|
||||
broadcast.total_count = len(recipients)
|
||||
await session.commit()
|
||||
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return
|
||||
|
||||
if not recipients:
|
||||
logger.info('Email broadcast %s: no recipients found', broadcast_id)
|
||||
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
|
||||
return
|
||||
|
||||
# Send emails with rate limiting
|
||||
sent_count, failed_count, was_cancelled = await self._send_emails(
|
||||
broadcast_id,
|
||||
recipients,
|
||||
config,
|
||||
cancel_event,
|
||||
)
|
||||
|
||||
if was_cancelled:
|
||||
logger.info('Email broadcast %s was cancelled during execution', broadcast_id)
|
||||
return
|
||||
|
||||
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception('Critical error in email broadcast %s: %s', broadcast_id, exc)
|
||||
await self._mark_failed(broadcast_id, sent_count, failed_count)
|
||||
|
||||
async def _fetch_email_recipients(self, target: str) -> list:
|
||||
"""Fetch email recipients based on target filter."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.models import Subscription, SubscriptionStatus, User
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
# Base query: verified email users with active status
|
||||
base_conditions = [
|
||||
User.email.isnot(None),
|
||||
User.email_verified == True,
|
||||
User.status == 'active',
|
||||
]
|
||||
|
||||
if target == 'all_email':
|
||||
# All users with verified email
|
||||
query = select(User).where(*base_conditions)
|
||||
|
||||
elif target == 'email_only':
|
||||
# Only email-registered users (no telegram)
|
||||
query = select(User).where(
|
||||
*base_conditions,
|
||||
User.auth_type == 'email',
|
||||
)
|
||||
|
||||
elif target == 'telegram_with_email':
|
||||
# Telegram users who also have email
|
||||
query = select(User).where(
|
||||
*base_conditions,
|
||||
User.auth_type == 'telegram',
|
||||
User.telegram_id.isnot(None),
|
||||
)
|
||||
|
||||
elif target == 'active_email':
|
||||
# Email users with active subscription
|
||||
query = (
|
||||
select(User)
|
||||
.join(Subscription, User.id == Subscription.user_id)
|
||||
.where(
|
||||
*base_conditions,
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
|
||||
elif target == 'expired_email':
|
||||
# Email users with expired subscription
|
||||
query = (
|
||||
select(User)
|
||||
.join(Subscription, User.id == Subscription.user_id)
|
||||
.where(
|
||||
*base_conditions,
|
||||
Subscription.status.in_(
|
||||
[
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
else:
|
||||
logger.warning('Unknown email target filter: %s', target)
|
||||
return []
|
||||
|
||||
# Load users in batches
|
||||
users: list = []
|
||||
offset = 0
|
||||
batch_size = 1000
|
||||
|
||||
while True:
|
||||
result = await session.execute(query.offset(offset).limit(batch_size))
|
||||
batch = result.scalars().all()
|
||||
|
||||
if not batch:
|
||||
break
|
||||
|
||||
users.extend(batch)
|
||||
offset += batch_size
|
||||
|
||||
return users
|
||||
|
||||
async def _send_emails(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
recipients: list,
|
||||
config: EmailBroadcastConfig,
|
||||
cancel_event: asyncio.Event,
|
||||
) -> tuple[int, int, bool]:
|
||||
"""Send emails with rate limiting."""
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
# Semaphore for rate limiting (max EMAIL_RATE_LIMIT concurrent sends)
|
||||
semaphore = asyncio.Semaphore(EMAIL_RATE_LIMIT)
|
||||
|
||||
async def send_single_email(user) -> bool | None:
|
||||
"""Send single email with rate limiting."""
|
||||
async with semaphore:
|
||||
if cancel_event.is_set():
|
||||
return None
|
||||
|
||||
email = getattr(user, 'email', None)
|
||||
if not email:
|
||||
return None
|
||||
|
||||
# Render template with variables
|
||||
html_content = self._render_template(config.email_html_content, user)
|
||||
subject = self._render_template(config.email_subject, user)
|
||||
|
||||
try:
|
||||
# Run sync email send in executor to not block event loop
|
||||
loop = asyncio.get_event_loop()
|
||||
success = await loop.run_in_executor(
|
||||
None,
|
||||
self._email_service.send_email,
|
||||
email,
|
||||
subject,
|
||||
html_content,
|
||||
)
|
||||
return success
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
'Error sending email broadcast %s to %s: %s',
|
||||
broadcast_id,
|
||||
email,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
# Process in batches
|
||||
for i in range(0, len(recipients), EMAIL_BATCH_SIZE):
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return sent_count, failed_count, True
|
||||
|
||||
batch = recipients[i : i + EMAIL_BATCH_SIZE]
|
||||
tasks = [send_single_email(user) for user in batch]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for result in results:
|
||||
if result is True:
|
||||
sent_count += 1
|
||||
elif result is None:
|
||||
# Skipped (cancelled or no email)
|
||||
pass
|
||||
else:
|
||||
failed_count += 1
|
||||
|
||||
# Update progress periodically
|
||||
processed = sent_count + failed_count
|
||||
if processed % PROGRESS_UPDATE_STEP == 0 or i + EMAIL_BATCH_SIZE >= len(recipients):
|
||||
await self._update_progress(broadcast_id, sent_count, failed_count)
|
||||
|
||||
# Rate limiting delay between batches (ensure ~8 emails/sec)
|
||||
await asyncio.sleep(EMAIL_BATCH_SIZE / EMAIL_RATE_LIMIT)
|
||||
|
||||
return sent_count, failed_count, False
|
||||
|
||||
def _render_template(self, template: str, user) -> str:
|
||||
"""Render template with user variables."""
|
||||
if not template:
|
||||
return template
|
||||
|
||||
# Get user name
|
||||
user_name = getattr(user, 'username', None)
|
||||
if not user_name:
|
||||
user_name = getattr(user, 'first_name', None) or ''
|
||||
if last_name := getattr(user, 'last_name', None):
|
||||
user_name = f'{user_name} {last_name}'.strip()
|
||||
if not user_name:
|
||||
user_name = getattr(user, 'email', '').split('@')[0] if getattr(user, 'email', None) else 'User'
|
||||
|
||||
email = getattr(user, 'email', '') or ''
|
||||
|
||||
# Replace template variables
|
||||
result = template.replace('{{user_name}}', user_name)
|
||||
result = result.replace('{{email}}', email)
|
||||
|
||||
return result
|
||||
|
||||
async def _mark_finished(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
sent_count: int,
|
||||
failed_count: int,
|
||||
*,
|
||||
cancelled: bool,
|
||||
) -> None:
|
||||
"""Mark broadcast as finished."""
|
||||
status = 'cancelled' if cancelled else ('completed' if failed_count == 0 else 'partial')
|
||||
await self._safe_status_update(broadcast_id, sent_count, failed_count, status=status)
|
||||
|
||||
async def _mark_cancelled(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
sent_count: int,
|
||||
failed_count: int,
|
||||
) -> None:
|
||||
"""Mark broadcast as cancelled."""
|
||||
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=True)
|
||||
|
||||
async def _mark_failed(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
sent_count: int = 0,
|
||||
failed_count: int = 0,
|
||||
) -> None:
|
||||
"""Mark broadcast as failed."""
|
||||
await self._safe_status_update(broadcast_id, sent_count, failed_count, status='failed')
|
||||
|
||||
async def _update_progress(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
sent_count: int,
|
||||
failed_count: int,
|
||||
) -> None:
|
||||
"""Update broadcast progress."""
|
||||
await self._safe_status_update(
|
||||
broadcast_id,
|
||||
sent_count,
|
||||
failed_count,
|
||||
status='in_progress',
|
||||
update_completed_at=False,
|
||||
)
|
||||
|
||||
async def _safe_status_update(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
sent_count: int,
|
||||
failed_count: int,
|
||||
*,
|
||||
status: str,
|
||||
update_completed_at: bool = True,
|
||||
) -> None:
|
||||
"""Safely update broadcast status with retry."""
|
||||
attempts = 0
|
||||
|
||||
while attempts < 2:
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
broadcast = await session.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
return
|
||||
|
||||
broadcast.sent_count = sent_count
|
||||
broadcast.failed_count = failed_count
|
||||
broadcast.status = status
|
||||
|
||||
if update_completed_at:
|
||||
broadcast.completed_at = datetime.utcnow()
|
||||
|
||||
await session.commit()
|
||||
return
|
||||
except InterfaceError as exc:
|
||||
attempts += 1
|
||||
logger.warning(
|
||||
'Connection issue updating email broadcast %s: %s. Retry %s/2',
|
||||
broadcast_id,
|
||||
exc,
|
||||
attempts,
|
||||
)
|
||||
await asyncio.sleep(0.2)
|
||||
except SQLAlchemyError:
|
||||
logger.exception('Failed to update email broadcast status %s', broadcast_id)
|
||||
return
|
||||
|
||||
|
||||
email_broadcast_service = EmailBroadcastService()
|
||||
|
||||
@@ -1193,57 +1193,7 @@ class MonitoringService:
|
||||
try:
|
||||
get_texts(user.language)
|
||||
|
||||
# Рассчитываем минимальную цену за подписку с минимальной конфигурацией
|
||||
from app.config import PERIOD_PRICES, settings
|
||||
from app.utils.pricing_utils import apply_percentage_discount
|
||||
|
||||
# Базовая цена за 30 дней
|
||||
base_price_original = PERIOD_PRICES.get(30, settings.PRICE_30_DAYS)
|
||||
|
||||
# Применяем скидку промогруппы для категории "period"
|
||||
promo_group_discount = user.get_promo_discount('period', 30) if user else 0
|
||||
# Применяем пользовательскую промо-скидку (если есть)
|
||||
user_discount_percent = self._get_user_promo_offer_discount_percent(user)
|
||||
|
||||
# Общая скидка - максимальная из промогруппы и пользовательской
|
||||
total_discount_percent = max(promo_group_discount, user_discount_percent)
|
||||
|
||||
base_price, _ = apply_percentage_discount(base_price_original, total_discount_percent)
|
||||
|
||||
# Добавляем цену за трафик (если фиксированный трафик включён)
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_price = settings.get_traffic_price(settings.get_fixed_traffic_limit())
|
||||
# Применяем скидки на трафик
|
||||
traffic_discount = user.get_promo_discount('traffic', 30) if user else 0
|
||||
traffic_price, _ = apply_percentage_discount(traffic_price, traffic_discount)
|
||||
else:
|
||||
traffic_price = 0 # Трафик не фиксирован, цена включена в базовую
|
||||
|
||||
# Добавляем цену за серверы (предполагаем минимум 1 сервер по минимальной цене)
|
||||
# Вместо сложного запроса к БД, используем настройки
|
||||
# Для минимальной конфигурации - один сервер с минимальной ценой
|
||||
min_server_price = getattr(settings, 'MIN_SERVER_PRICE', 0) or 0
|
||||
if min_server_price == 0:
|
||||
# Если нет явной минимальной цены, используем базовую цену
|
||||
# В реальных условиях цена сервера будет определяться в ходе оформления подписки
|
||||
min_server_price = 0
|
||||
|
||||
# Добавляем цену за устройства (если больше базового лимита)
|
||||
# В минимальной конфигурации - базовый лимит, без доп. устройств
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
additional_devices = max(0, device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
additional_devices * settings.PRICE_PER_DEVICE
|
||||
|
||||
# Для простоты и правильной работы без обращения к БД, рассчитываем минимальную цену как:
|
||||
# базовая цена + минимальная цена за трафик (если есть фиксированный)
|
||||
min_server_price = 0 # для минимальной конфигурации с 1 сервером используем 0 или минимальную известную
|
||||
|
||||
# Попробуем получить минимальную цену сервера из настроек или используем подходящее значение
|
||||
# Находим минимальную возможную цену из возможных цен серверов
|
||||
# В упрощенном варианте используем базовую конфигурацию: базовая цена + трафик
|
||||
min_total_price = base_price + traffic_price
|
||||
|
||||
message = f"""
|
||||
message = """
|
||||
🎁 <b>Тестовая подписка скоро закончится!</b>
|
||||
|
||||
Ваша тестовая подписка истекает через 2 часа.
|
||||
@@ -1251,12 +1201,6 @@ class MonitoringService:
|
||||
💎 <b>Не хотите остаться без VPN?</b>
|
||||
Переходите на полную подписку!
|
||||
|
||||
🔥 <b>Специальное предложение:</b>
|
||||
• 30 дней всего за {settings.format_price(min_total_price)}
|
||||
• Безлимитный трафик
|
||||
• Все серверы доступны
|
||||
• Скорость до 1ГБит/сек
|
||||
|
||||
⚡️ Успейте оформить до окончания тестового периода!
|
||||
"""
|
||||
|
||||
|
||||
@@ -132,6 +132,28 @@ class PaymentCommonMixin:
|
||||
payment_method_title: str | None = None,
|
||||
) -> None:
|
||||
"""Отправляет пользователю уведомление об успешном платеже."""
|
||||
# Lazy import to avoid circular dependency
|
||||
from app.cabinet.routes.websocket import notify_user_balance_topup
|
||||
|
||||
# Send WebSocket notification to cabinet frontend (works for both Telegram and email-only users)
|
||||
user_id = getattr(user, 'id', None) if user else None
|
||||
if user_id:
|
||||
try:
|
||||
# Get new balance from user
|
||||
new_balance = getattr(user, 'balance_kopeks', 0)
|
||||
await notify_user_balance_topup(
|
||||
user_id=user_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
new_balance_kopeks=new_balance,
|
||||
description=payment_method_title or '',
|
||||
)
|
||||
except Exception as ws_error:
|
||||
logger.warning(
|
||||
'Не удалось отправить WS уведомление о пополнении баланса для user_id=%s: %s',
|
||||
user_id,
|
||||
ws_error,
|
||||
)
|
||||
|
||||
if not getattr(self, 'bot', None):
|
||||
# Если бот не передан (например, внутри фоновых задач), уведомление пропускаем.
|
||||
return
|
||||
|
||||
@@ -12,6 +12,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
@@ -424,6 +428,54 @@ class HeleketPaymentMixin:
|
||||
else:
|
||||
logger.info(f'Пропуск Telegram-уведомления Heleket для email-пользователя {user.id}')
|
||||
|
||||
# Автопокупка из сохранённой корзины и умная автоактивация
|
||||
try:
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
auto_purchase_success = False
|
||||
if has_saved_cart:
|
||||
try:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, 'bot', None),
|
||||
)
|
||||
except Exception as auto_error:
|
||||
logger.error(
|
||||
'Ошибка автоматической покупки подписки для пользователя %s: %s',
|
||||
user.id,
|
||||
auto_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
await auto_activate_subscription_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, 'bot', None),
|
||||
topup_amount=amount_kopeks,
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
'Ошибка умной автоактивации для пользователя %s: %s',
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Ошибка при работе с автоактивацией для пользователя %s: %s',
|
||||
user.id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return updated_payment
|
||||
|
||||
async def process_heleket_webhook(
|
||||
|
||||
@@ -109,6 +109,13 @@ def _get_method_defaults() -> dict:
|
||||
{'id': 'sbp', 'name': 'СБП'},
|
||||
],
|
||||
},
|
||||
'kassa_ai': {
|
||||
'default_display_name': settings.get_kassa_ai_display_name(),
|
||||
'is_configured': settings.is_kassa_ai_enabled(),
|
||||
'default_min': settings.KASSA_AI_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.KASSA_AI_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -146,6 +153,7 @@ DEFAULT_METHOD_ORDER = [
|
||||
'wata',
|
||||
'freekassa',
|
||||
'cloudpayments',
|
||||
'kassa_ai',
|
||||
]
|
||||
|
||||
|
||||
@@ -253,7 +261,7 @@ async def update_config(
|
||||
config.allowed_promo_groups = groups
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(config, attribute_names=['allowed_promo_groups'])
|
||||
await db.refresh(config)
|
||||
return config
|
||||
|
||||
|
||||
@@ -272,3 +280,108 @@ async def get_all_promo_groups(db: AsyncSession) -> list[PromoGroup]:
|
||||
"""Get all promo groups for the filter selector."""
|
||||
result = await db.execute(select(PromoGroup).order_by(PromoGroup.priority.desc(), PromoGroup.name))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
# ============ User-facing methods ============
|
||||
|
||||
|
||||
async def get_enabled_methods_for_user(
|
||||
db: AsyncSession,
|
||||
user: 'User | None' = None,
|
||||
is_first_topup: bool | None = None,
|
||||
) -> list[dict]:
|
||||
"""Get payment methods available for a specific user.
|
||||
|
||||
Applies all filters from PaymentMethodConfig:
|
||||
- is_enabled
|
||||
- is_provider_configured (from env)
|
||||
- user_type_filter
|
||||
- first_topup_filter
|
||||
- promo_group_filter
|
||||
|
||||
Returns list of dicts with method info ready for API response.
|
||||
"""
|
||||
from app.database.models import UserPromoGroup
|
||||
|
||||
configs = await get_all_configs(db)
|
||||
defaults = _get_method_defaults()
|
||||
|
||||
result = []
|
||||
|
||||
for config in configs:
|
||||
method_id = config.method_id
|
||||
method_def = defaults.get(method_id, {})
|
||||
|
||||
# Skip if not enabled in admin panel
|
||||
if not config.is_enabled:
|
||||
continue
|
||||
|
||||
# Skip if provider not configured in env
|
||||
if not method_def.get('is_configured', False):
|
||||
continue
|
||||
|
||||
# Apply user_type_filter
|
||||
if user and config.user_type_filter != 'all':
|
||||
if config.user_type_filter == 'telegram' and not user.telegram_id:
|
||||
continue
|
||||
if config.user_type_filter == 'email' and not getattr(user, 'email', None):
|
||||
continue
|
||||
|
||||
# Apply first_topup_filter
|
||||
if config.first_topup_filter != 'any' and is_first_topup is not None:
|
||||
if config.first_topup_filter == 'yes' and not is_first_topup:
|
||||
continue
|
||||
if config.first_topup_filter == 'no' and is_first_topup:
|
||||
continue
|
||||
|
||||
# Apply promo_group_filter
|
||||
if config.promo_group_filter_mode == 'selected' and user:
|
||||
allowed_group_ids = {pg.id for pg in config.allowed_promo_groups}
|
||||
if allowed_group_ids:
|
||||
# Get user's promo groups
|
||||
user_groups_result = await db.execute(
|
||||
select(UserPromoGroup.promo_group_id).where(UserPromoGroup.user_id == user.id)
|
||||
)
|
||||
user_group_ids = set(user_groups_result.scalars().all())
|
||||
|
||||
# Check if user has at least one allowed group
|
||||
if not user_group_ids.intersection(allowed_group_ids):
|
||||
continue
|
||||
|
||||
# Build display name
|
||||
display_name = config.display_name or method_def.get('default_display_name', method_id)
|
||||
|
||||
# Build min/max amounts (DB overrides env defaults)
|
||||
min_amount = (
|
||||
config.min_amount_kopeks if config.min_amount_kopeks is not None else method_def.get('default_min', 1000)
|
||||
)
|
||||
max_amount = (
|
||||
config.max_amount_kopeks
|
||||
if config.max_amount_kopeks is not None
|
||||
else method_def.get('default_max', 10000000)
|
||||
)
|
||||
|
||||
# Build options (filter by sub_options config)
|
||||
options = None
|
||||
available_sub_options = method_def.get('available_sub_options')
|
||||
if available_sub_options and config.sub_options:
|
||||
enabled_options = []
|
||||
for opt in available_sub_options:
|
||||
opt_id = opt['id']
|
||||
if config.sub_options.get(opt_id, True):
|
||||
enabled_options.append(opt)
|
||||
if enabled_options:
|
||||
options = enabled_options
|
||||
|
||||
result.append(
|
||||
{
|
||||
'id': method_id,
|
||||
'name': display_name,
|
||||
'min_amount_kopeks': min_amount,
|
||||
'max_amount_kopeks': max_amount,
|
||||
'options': options,
|
||||
'sort_order': config.sort_order,
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.database.models import (
|
||||
CryptoBotPayment,
|
||||
FreekassaPayment,
|
||||
HeleketPayment,
|
||||
KassaAiPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PaymentMethod,
|
||||
@@ -109,6 +110,8 @@ def method_display_name(method: PaymentMethod) -> str:
|
||||
return 'CloudPayments'
|
||||
if method == PaymentMethod.FREEKASSA:
|
||||
return 'Freekassa'
|
||||
if method == PaymentMethod.KASSA_AI:
|
||||
return settings.get_kassa_ai_display_name()
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
return 'Telegram Stars'
|
||||
return method.value
|
||||
@@ -133,6 +136,8 @@ def _method_is_enabled(method: PaymentMethod) -> bool:
|
||||
return settings.is_cloudpayments_enabled()
|
||||
if method == PaymentMethod.FREEKASSA:
|
||||
return settings.is_freekassa_enabled()
|
||||
if method == PaymentMethod.KASSA_AI:
|
||||
return settings.is_kassa_ai_enabled()
|
||||
return False
|
||||
|
||||
|
||||
@@ -356,6 +361,13 @@ def _is_freekassa_pending(payment: FreekassaPayment) -> bool:
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
|
||||
|
||||
def _is_kassa_ai_pending(payment: KassaAiPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or '').lower()
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
|
||||
|
||||
def _parse_cryptobot_amount_kopeks(payment: CryptoBotPayment) -> int:
|
||||
payload = payment.payload or ''
|
||||
match = re.search(r'_(\d+)$', payload)
|
||||
@@ -648,6 +660,31 @@ async def _fetch_freekassa_payments(db: AsyncSession, cutoff: datetime) -> list[
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_kassa_ai_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(KassaAiPayment)
|
||||
.options(selectinload(KassaAiPayment.user))
|
||||
.where(KassaAiPayment.created_at >= cutoff)
|
||||
.order_by(desc(KassaAiPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_kassa_ai_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.KASSA_AI,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_stars_transactions(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(Transaction)
|
||||
@@ -694,6 +731,7 @@ async def list_recent_pending_payments(
|
||||
await _fetch_cryptobot_payments(db, cutoff),
|
||||
await _fetch_cloudpayments_payments(db, cutoff),
|
||||
await _fetch_freekassa_payments(db, cutoff),
|
||||
await _fetch_kassa_ai_payments(db, cutoff),
|
||||
await _fetch_stars_transactions(db, cutoff),
|
||||
)
|
||||
|
||||
@@ -848,6 +886,20 @@ async def get_payment_record(
|
||||
is_paid=bool(payment.is_paid),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.KASSA_AI:
|
||||
payment = await db.get(KassaAiPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=['user'])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
transaction = await db.get(Transaction, local_payment_id)
|
||||
if not transaction:
|
||||
|
||||
@@ -12,10 +12,11 @@ from app.config import settings
|
||||
from app.database.crud.referral_contest import (
|
||||
add_contest_event,
|
||||
get_contest_events_count,
|
||||
get_contest_leaderboard,
|
||||
get_contest_leaderboard_with_virtual,
|
||||
get_contests_for_events,
|
||||
get_contests_for_summaries,
|
||||
get_referrer_score,
|
||||
list_virtual_participants,
|
||||
mark_daily_summary_sent,
|
||||
mark_final_summary_sent,
|
||||
)
|
||||
@@ -173,8 +174,10 @@ class ReferralContestService:
|
||||
day_start_utc = day_start_local.astimezone(UTC).replace(tzinfo=None)
|
||||
day_end_utc = day_end_local.astimezone(UTC).replace(tzinfo=None)
|
||||
|
||||
leaderboard = list(await get_contest_leaderboard(db, contest.id))
|
||||
total_events = await get_contest_events_count(db, contest.id)
|
||||
leaderboard = await get_contest_leaderboard_with_virtual(db, contest.id)
|
||||
virtual_participants = await list_virtual_participants(db, contest.id)
|
||||
virtual_count = sum(vp.referral_count for vp in virtual_participants)
|
||||
total_events = await get_contest_events_count(db, contest.id) + virtual_count
|
||||
today_events = await get_contest_events_count(
|
||||
db,
|
||||
contest.id,
|
||||
@@ -269,7 +272,7 @@ class ReferralContestService:
|
||||
self,
|
||||
*,
|
||||
contest: ReferralContest,
|
||||
leaderboard: Sequence[tuple[User, int, int]],
|
||||
leaderboard: Sequence[tuple[str, int, int, bool]],
|
||||
total_events: int,
|
||||
today_events: int,
|
||||
is_final: bool,
|
||||
@@ -293,10 +296,9 @@ class ReferralContestService:
|
||||
]
|
||||
|
||||
if leaderboard:
|
||||
for idx, (user, score, _) in enumerate(leaderboard[:5], start=1):
|
||||
name = user.full_name
|
||||
user_id_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
lines.append(f'{idx}. {name} ({user_id_display}) — {score}')
|
||||
for idx, (name, score, _, is_virtual) in enumerate(leaderboard[:5], start=1):
|
||||
virt_mark = ' 👻' if is_virtual else ''
|
||||
lines.append(f'{idx}. {name}{virt_mark} — {score}')
|
||||
else:
|
||||
lines.append('Пока нет участников.')
|
||||
|
||||
@@ -318,7 +320,7 @@ class ReferralContestService:
|
||||
self,
|
||||
*,
|
||||
contest: ReferralContest,
|
||||
leaderboard: Sequence[tuple[User, int, int]],
|
||||
leaderboard: Sequence[tuple[str, int, int, bool]],
|
||||
total_events: int,
|
||||
today_events: int,
|
||||
is_final: bool,
|
||||
@@ -346,8 +348,8 @@ class ReferralContestService:
|
||||
]
|
||||
|
||||
if leaderboard:
|
||||
for idx, (user, score, _) in enumerate(leaderboard[:5], start=1):
|
||||
lines.append(f'{idx}. {user.full_name} — {score}')
|
||||
for idx, (name, score, _, _is_virtual) in enumerate(leaderboard[:5], start=1):
|
||||
lines.append(f'{idx}. {name} — {score}')
|
||||
else:
|
||||
lines.append('Пока нет участников.')
|
||||
|
||||
|
||||
@@ -236,6 +236,18 @@ class RemnaWaveService:
|
||||
"""Возвращает текущее время в UTC без привязки к часовому поясу."""
|
||||
return datetime.now(self._utc_timezone).replace(tzinfo=None)
|
||||
|
||||
def _local_to_utc(self, local_dt: datetime) -> datetime:
|
||||
"""Конвертирует naive локальную дату (в таймзоне панели/бота) в naive UTC.
|
||||
|
||||
Используется для корректного сравнения дат из БД с датами из RemnaWave.
|
||||
"""
|
||||
if local_dt.tzinfo is not None:
|
||||
# Уже есть tzinfo - конвертируем напрямую
|
||||
return local_dt.astimezone(self._utc_timezone).replace(tzinfo=None)
|
||||
# Naive datetime - интерпретируем как локальное время панели
|
||||
local_aware = local_dt.replace(tzinfo=self._panel_timezone)
|
||||
return local_aware.astimezone(self._utc_timezone).replace(tzinfo=None)
|
||||
|
||||
def _parse_remnawave_date(self, date_str: str) -> datetime:
|
||||
if not date_str:
|
||||
return self._now_utc() + timedelta(days=30)
|
||||
@@ -253,14 +265,14 @@ class RemnaWaveService:
|
||||
|
||||
parsed_date = datetime.fromisoformat(cleaned_date)
|
||||
|
||||
# Панель RemnaWave всегда отдаёт время в UTC
|
||||
# Если есть tzinfo - конвертируем в UTC, иначе считаем что уже UTC
|
||||
if parsed_date.tzinfo is not None:
|
||||
localized = parsed_date.astimezone(self._panel_timezone)
|
||||
utc_normalized = parsed_date.astimezone(self._utc_timezone).replace(tzinfo=None)
|
||||
else:
|
||||
localized = parsed_date.replace(tzinfo=self._panel_timezone)
|
||||
utc_normalized = parsed_date
|
||||
|
||||
utc_normalized = localized.astimezone(self._utc_timezone).replace(tzinfo=None)
|
||||
|
||||
logger.debug(f'Успешно распарсена дата: {date_str} -> {utc_normalized} (нормализовано в UTC)')
|
||||
logger.debug(f'Успешно распарсена дата: {date_str} -> {utc_normalized} (UTC)')
|
||||
return utc_normalized
|
||||
|
||||
except Exception as e:
|
||||
@@ -268,27 +280,33 @@ class RemnaWaveService:
|
||||
return self._now_utc() + timedelta(days=30)
|
||||
|
||||
def _safe_expire_at_for_panel(self, expire_at: datetime | None) -> datetime:
|
||||
"""Гарантирует, что дата окончания не в прошлом для панели."""
|
||||
"""Гарантирует, что дата окончания не в прошлом для панели.
|
||||
|
||||
Принимает naive UTC datetime, возвращает naive datetime в таймзоне панели.
|
||||
"""
|
||||
|
||||
now = self._now_utc()
|
||||
minimum_expire = now + timedelta(minutes=1)
|
||||
|
||||
if not expire_at:
|
||||
return minimum_expire
|
||||
result = minimum_expire
|
||||
else:
|
||||
normalized_expire = expire_at
|
||||
if normalized_expire.tzinfo is not None:
|
||||
normalized_expire = normalized_expire.replace(tzinfo=None)
|
||||
|
||||
normalized_expire = expire_at
|
||||
if normalized_expire.tzinfo is not None:
|
||||
normalized_expire = normalized_expire.replace(tzinfo=None)
|
||||
if normalized_expire < minimum_expire:
|
||||
logger.debug(
|
||||
'⚙️ Коррекция даты истечения (%s) до минимально допустимой (%s) для панели',
|
||||
normalized_expire,
|
||||
minimum_expire,
|
||||
)
|
||||
result = minimum_expire
|
||||
else:
|
||||
result = normalized_expire
|
||||
|
||||
if normalized_expire < minimum_expire:
|
||||
logger.debug(
|
||||
'⚙️ Коррекция даты истечения (%s) до минимально допустимой (%s) для панели',
|
||||
normalized_expire,
|
||||
minimum_expire,
|
||||
)
|
||||
return minimum_expire
|
||||
|
||||
return normalized_expire
|
||||
# Панель RemnaWave ожидает время в UTC
|
||||
return result
|
||||
|
||||
def _safe_panel_expire_date(self, panel_user: dict[str, Any]) -> datetime:
|
||||
"""Парсит дату окончания подписки пользователя панели для сравнения."""
|
||||
@@ -1139,7 +1157,7 @@ class RemnaWaveService:
|
||||
'status': user_obj.status.value,
|
||||
'telegramId': user_obj.telegram_id,
|
||||
'email': user_obj.email, # Email для синхронизации email-only пользователей
|
||||
'expireAt': user_obj.expire_at.isoformat() + 'Z',
|
||||
'expireAt': user_obj.expire_at.replace(tzinfo=None).isoformat(),
|
||||
'trafficLimitBytes': user_obj.traffic_limit_bytes,
|
||||
'usedTrafficBytes': user_obj.used_traffic_bytes,
|
||||
'hwidDeviceLimit': user_obj.hwid_device_limit,
|
||||
@@ -1283,20 +1301,24 @@ class RemnaWaveService:
|
||||
logger.info(f'🔄 Обновлены поля {updated_fields} для пользователя {telegram_id}')
|
||||
await db.flush() # Сохраняем изменения без коммита
|
||||
|
||||
# Проверяем, есть ли у пользователя подписка, загруженная с пользователем
|
||||
if hasattr(db_user, 'subscription') and db_user.subscription:
|
||||
# Используем уже загруженную подписку
|
||||
await self._update_subscription_from_panel_data(db, db_user, panel_user)
|
||||
else:
|
||||
# Если подписки нет, создаем новую
|
||||
await self._create_subscription_from_panel_data(db, db_user, panel_user)
|
||||
|
||||
# Обновляем UUID ДО операций с подпиской, чтобы избежать
|
||||
# greenlet_spawn ошибки при доступе к атрибутам после flush
|
||||
_, uuid_mutation = self._ensure_user_remnawave_uuid(
|
||||
db_user,
|
||||
panel_user.get('uuid'),
|
||||
bot_users_by_uuid,
|
||||
)
|
||||
|
||||
# Используем async запрос вместо доступа к relationship,
|
||||
# чтобы избежать lazy-load в async контексте
|
||||
from app.database.crud.subscription import get_subscription_by_user_id as _get_sub
|
||||
|
||||
existing_sub = await _get_sub(db, db_user.id)
|
||||
if existing_sub:
|
||||
await self._update_subscription_from_panel_data(db, db_user, panel_user)
|
||||
else:
|
||||
await self._create_subscription_from_panel_data(db, db_user, panel_user)
|
||||
|
||||
stats['updated'] += 1
|
||||
logger.debug(f'✅ Обновлён пользователь {telegram_id}')
|
||||
|
||||
@@ -1369,8 +1391,12 @@ class RemnaWaveService:
|
||||
if panel_uuid and not db_user.remnawave_uuid:
|
||||
db_user.remnawave_uuid = panel_uuid
|
||||
|
||||
# Обновляем или создаем подписку
|
||||
if hasattr(db_user, 'subscription') and db_user.subscription:
|
||||
# Используем async запрос вместо доступа к relationship,
|
||||
# чтобы избежать lazy-load (greenlet_spawn) в async контексте
|
||||
from app.database.crud.subscription import get_subscription_by_user_id as _get_sub_email
|
||||
|
||||
existing_sub = await _get_sub_email(db, db_user.id)
|
||||
if existing_sub:
|
||||
await self._update_subscription_from_panel_data(db, db_user, panel_user)
|
||||
else:
|
||||
await self._create_subscription_from_panel_data(db, db_user, panel_user)
|
||||
@@ -1637,18 +1663,9 @@ class RemnaWaveService:
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
# Сначала пытаемся использовать уже загруженную подписку, если она есть
|
||||
subscription = None
|
||||
try:
|
||||
# Проверяем, что подписка уже загружена (была загружена через selectinload)
|
||||
if hasattr(user, 'subscription') and user.subscription:
|
||||
subscription = user.subscription
|
||||
else:
|
||||
# В противном случае, получаем подписку через CRUD метод
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
except:
|
||||
# Если не удалось получить подписку через ленивую загрузку
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
# Всегда используем async CRUD запрос для получения подписки,
|
||||
# чтобы избежать lazy-load (greenlet_spawn) в async контексте
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
if not subscription:
|
||||
await self._create_subscription_from_panel_data(db, user, panel_user)
|
||||
@@ -1658,25 +1675,36 @@ class RemnaWaveService:
|
||||
expire_at_str = panel_user.get('expireAt', '')
|
||||
|
||||
if expire_at_str:
|
||||
# expire_at приходит в UTC (naive) из _parse_remnawave_date
|
||||
expire_at = self._parse_remnawave_date(expire_at_str)
|
||||
|
||||
# Конвертируем локальную дату из БД в UTC для корректного сравнения
|
||||
# subscription.end_date хранится в локальной таймзоне (MSK)
|
||||
local_end_date_utc = self._local_to_utc(subscription.end_date)
|
||||
|
||||
# КРИТИЧНО: НЕ перезаписываем end_date если локальная дата ПОЗЖЕ
|
||||
# Это защищает от ситуации когда подписка была продлена в боте,
|
||||
# но RemnaWave ещё не получил обновление или вернул старую дату
|
||||
time_diff = abs((subscription.end_date - expire_at).total_seconds())
|
||||
time_diff = abs((local_end_date_utc - expire_at).total_seconds())
|
||||
if time_diff > 60:
|
||||
if expire_at > subscription.end_date:
|
||||
if expire_at > local_end_date_utc:
|
||||
# RemnaWave имеет более позднюю дату - обновляем
|
||||
subscription.end_date = expire_at
|
||||
# Конвертируем UTC обратно в локальное время для сохранения в БД
|
||||
new_end_date_local = (
|
||||
expire_at.replace(tzinfo=self._utc_timezone)
|
||||
.astimezone(self._panel_timezone)
|
||||
.replace(tzinfo=None)
|
||||
)
|
||||
logger.info(
|
||||
f'✅ Sync: обновлена end_date для user {getattr(user, "telegram_id", "?")}: '
|
||||
f'{subscription.end_date} -> {expire_at} (разница: {time_diff:.0f}с)'
|
||||
f'{subscription.end_date} -> {new_end_date_local} (разница: {time_diff:.0f}с)'
|
||||
)
|
||||
subscription.end_date = new_end_date_local
|
||||
else:
|
||||
# Локальная дата позже - НЕ перезаписываем, логируем предупреждение
|
||||
logger.warning(
|
||||
f'⚠️ Sync: пропускаем обновление end_date для user {getattr(user, "telegram_id", "?")}: '
|
||||
f'локальная дата ({subscription.end_date}) позже чем в RemnaWave ({expire_at})'
|
||||
# Локальная дата позже - НЕ перезаписываем
|
||||
logger.debug(
|
||||
f'⏭️ Sync: end_date для user {getattr(user, "telegram_id", "?")} актуальна: '
|
||||
f'локальная ({subscription.end_date} / UTC: {local_end_date_utc}) >= RemnaWave ({expire_at} UTC)'
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
@@ -1685,18 +1713,21 @@ class RemnaWaveService:
|
||||
)
|
||||
|
||||
current_time = self._now_utc()
|
||||
if panel_status == 'ACTIVE' and subscription.end_date > current_time:
|
||||
# Конвертируем end_date в UTC для корректного сравнения с current_time
|
||||
end_date_utc = self._local_to_utc(subscription.end_date)
|
||||
|
||||
if panel_status == 'ACTIVE' and end_date_utc > current_time:
|
||||
new_status = SubscriptionStatus.ACTIVE.value
|
||||
elif panel_status == 'DISABLED':
|
||||
new_status = SubscriptionStatus.DISABLED.value
|
||||
elif subscription.end_date <= current_time:
|
||||
elif end_date_utc <= current_time:
|
||||
# КРИТИЧНО: НЕ деактивируем если текущий статус ACTIVE
|
||||
# Это защищает от race condition когда sync использует старую end_date из памяти,
|
||||
# а реальная end_date уже обновлена продлением
|
||||
if subscription.status == SubscriptionStatus.ACTIVE.value:
|
||||
logger.warning(
|
||||
f'⚠️ Sync: пропускаем деактивацию подписки user {getattr(user, "telegram_id", "?")}: '
|
||||
f'статус ACTIVE, end_date в памяти ({subscription.end_date}) <= now. '
|
||||
f'статус ACTIVE, end_date ({subscription.end_date} / UTC: {end_date_utc}) <= now ({current_time}). '
|
||||
f'Деактивация будет выполнена через middleware с буфером.'
|
||||
)
|
||||
new_status = subscription.status # Сохраняем текущий статус
|
||||
@@ -2493,13 +2524,15 @@ class RemnaWaveService:
|
||||
issues_fixed = 0
|
||||
|
||||
current_time = self._now_utc()
|
||||
# Конвертируем end_date в UTC для корректного сравнения
|
||||
end_date_utc = self._local_to_utc(subscription.end_date)
|
||||
# Добавляем буфер 5 минут для защиты от race condition при продлении
|
||||
expiry_buffer = timedelta(minutes=5)
|
||||
if (
|
||||
subscription.end_date + expiry_buffer <= current_time
|
||||
end_date_utc + expiry_buffer <= current_time
|
||||
and subscription.status == SubscriptionStatus.ACTIVE.value
|
||||
):
|
||||
time_since_expiry = current_time - subscription.end_date
|
||||
time_since_expiry = current_time - end_date_utc
|
||||
logger.warning(
|
||||
f'🔧 fix_data_issues: деактивируем подписку {subscription.id} '
|
||||
f'(user={user.telegram_id}), просрочена на {time_since_expiry}'
|
||||
|
||||
@@ -347,6 +347,9 @@ async def _auto_extend_subscription(
|
||||
*,
|
||||
bot: Bot | None = None,
|
||||
) -> bool:
|
||||
# Lazy import to avoid circular dependency
|
||||
from app.cabinet.routes.websocket import notify_user_subscription_renewed
|
||||
|
||||
try:
|
||||
prepared = await _prepare_auto_extend_context(db, user, cart_data)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
@@ -559,6 +562,20 @@ async def _auto_extend_subscription(
|
||||
_format_user_id(user),
|
||||
)
|
||||
|
||||
# Send WebSocket notification to cabinet frontend
|
||||
try:
|
||||
await notify_user_subscription_renewed(
|
||||
user_id=user.id,
|
||||
new_expires_at=new_end_date.isoformat() if new_end_date else '',
|
||||
amount_kopeks=prepared.price_kopeks,
|
||||
)
|
||||
except Exception as ws_error:
|
||||
logger.warning(
|
||||
'⚠️ Автопокупка: не удалось отправить WS уведомление о продлении для %s: %s',
|
||||
_format_user_id(user),
|
||||
ws_error,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -570,6 +587,11 @@ async def _auto_purchase_tariff(
|
||||
bot: Bot | None = None,
|
||||
) -> bool:
|
||||
"""Автоматическая покупка периодного тарифа из сохранённой корзины."""
|
||||
# Lazy imports to avoid circular dependency
|
||||
from app.cabinet.routes.websocket import (
|
||||
notify_user_subscription_activated,
|
||||
notify_user_subscription_renewed,
|
||||
)
|
||||
from app.database.crud.server_squad import get_all_server_squads
|
||||
from app.database.crud.subscription import (
|
||||
create_paid_subscription,
|
||||
@@ -814,6 +836,29 @@ async def _auto_purchase_tariff(
|
||||
_format_user_id(user),
|
||||
)
|
||||
|
||||
# Send WebSocket notification to cabinet frontend
|
||||
try:
|
||||
if existing_subscription:
|
||||
# Renewal of existing subscription
|
||||
await notify_user_subscription_renewed(
|
||||
user_id=user.id,
|
||||
new_expires_at=subscription.end_date.isoformat() if subscription.end_date else '',
|
||||
amount_kopeks=final_price,
|
||||
)
|
||||
else:
|
||||
# New subscription activation
|
||||
await notify_user_subscription_activated(
|
||||
user_id=user.id,
|
||||
expires_at=subscription.end_date.isoformat() if subscription.end_date else '',
|
||||
tariff_name=tariff.name,
|
||||
)
|
||||
except Exception as ws_error:
|
||||
logger.warning(
|
||||
'⚠️ Автопокупка тарифа: не удалось отправить WS уведомление для %s: %s',
|
||||
_format_user_id(user),
|
||||
ws_error,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -827,6 +872,11 @@ async def _auto_purchase_daily_tariff(
|
||||
"""Автоматическая покупка суточного тарифа из сохранённой корзины."""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Lazy imports to avoid circular dependency
|
||||
from app.cabinet.routes.websocket import (
|
||||
notify_user_subscription_activated,
|
||||
notify_user_subscription_renewed,
|
||||
)
|
||||
from app.database.crud.server_squad import get_all_server_squads
|
||||
from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
@@ -1051,6 +1101,458 @@ async def _auto_purchase_daily_tariff(
|
||||
_format_user_id(user),
|
||||
)
|
||||
|
||||
# Send WebSocket notification to cabinet frontend
|
||||
try:
|
||||
if existing_subscription:
|
||||
# Renewal/upgrade of existing subscription
|
||||
await notify_user_subscription_renewed(
|
||||
user_id=user.id,
|
||||
new_expires_at=subscription.end_date.isoformat() if subscription.end_date else '',
|
||||
amount_kopeks=daily_price,
|
||||
)
|
||||
else:
|
||||
# New subscription activation
|
||||
await notify_user_subscription_activated(
|
||||
user_id=user.id,
|
||||
expires_at=subscription.end_date.isoformat() if subscription.end_date else '',
|
||||
tariff_name=tariff.name,
|
||||
)
|
||||
except Exception as ws_error:
|
||||
logger.warning(
|
||||
'⚠️ Автопокупка суточного тарифа: не удалось отправить WS уведомление для %s: %s',
|
||||
_format_user_id(user),
|
||||
ws_error,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _auto_add_devices(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
cart_data: dict,
|
||||
*,
|
||||
bot: Bot | None = None,
|
||||
) -> bool:
|
||||
"""Auto-purchase devices from saved cart after balance topup."""
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import PaymentMethod
|
||||
|
||||
devices_to_add = _safe_int(cart_data.get('devices_to_add'))
|
||||
price_kopeks = _safe_int(cart_data.get('price_kopeks'))
|
||||
|
||||
if devices_to_add <= 0 or price_kopeks <= 0:
|
||||
logger.warning(
|
||||
'🔁 Автопокупка устройств: некорректные данные корзины для пользователя %s (devices=%s, price=%s)',
|
||||
_format_user_id(user),
|
||||
devices_to_add,
|
||||
price_kopeks,
|
||||
)
|
||||
return False
|
||||
|
||||
# Проверяем баланс
|
||||
if user.balance_kopeks < price_kopeks:
|
||||
logger.info(
|
||||
'🔁 Автопокупка устройств: у пользователя %s недостаточно средств (%s < %s)',
|
||||
_format_user_id(user),
|
||||
user.balance_kopeks,
|
||||
price_kopeks,
|
||||
)
|
||||
return False
|
||||
|
||||
# Проверяем подписку
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
if not subscription:
|
||||
logger.warning(
|
||||
'🔁 Автопокупка устройств: у пользователя %s нет подписки',
|
||||
_format_user_id(user),
|
||||
)
|
||||
await user_cart_service.delete_user_cart(user.id)
|
||||
return False
|
||||
|
||||
if subscription.status not in ('active', 'trial', 'ACTIVE', 'TRIAL'):
|
||||
logger.warning(
|
||||
'🔁 Автопокупка устройств: подписка пользователя %s не активна (status=%s)',
|
||||
_format_user_id(user),
|
||||
subscription.status,
|
||||
)
|
||||
await user_cart_service.delete_user_cart(user.id)
|
||||
return False
|
||||
|
||||
# Списываем баланс
|
||||
description = f'Покупка {devices_to_add} доп. устройств'
|
||||
try:
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
price_kopeks,
|
||||
description,
|
||||
create_transaction=True,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
if not success:
|
||||
logger.warning(
|
||||
'❌ Автопокупка устройств: не удалось списать баланс пользователя %s',
|
||||
_format_user_id(user),
|
||||
)
|
||||
return False
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'❌ Автопокупка устройств: ошибка списания баланса пользователя %s: %s',
|
||||
_format_user_id(user),
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
# Добавляем устройства
|
||||
old_device_limit = subscription.device_limit or 1
|
||||
subscription.device_limit = old_device_limit + devices_to_add
|
||||
|
||||
try:
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'❌ Автопокупка устройств: ошибка сохранения подписки пользователя %s: %s',
|
||||
_format_user_id(user),
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
# Синхронизация с RemnaWave
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
'⚠️ Автопокупка устройств: не удалось обновить Remnawave для пользователя %s: %s',
|
||||
_format_user_id(user),
|
||||
error,
|
||||
)
|
||||
|
||||
# Очищаем корзину (транзакция уже создана в subtract_user_balance)
|
||||
await user_cart_service.delete_user_cart(user.id)
|
||||
|
||||
logger.info(
|
||||
'✅ Автопокупка устройств: пользователь %s добавил %s устройств (было %s, стало %s) за %s коп.',
|
||||
_format_user_id(user),
|
||||
devices_to_add,
|
||||
old_device_limit,
|
||||
subscription.device_limit,
|
||||
price_kopeks,
|
||||
)
|
||||
|
||||
# WebSocket уведомление для кабинета
|
||||
try:
|
||||
from app.cabinet.routes.websocket import notify_user_devices_purchased
|
||||
|
||||
await notify_user_devices_purchased(
|
||||
user_id=user.id,
|
||||
devices_added=devices_to_add,
|
||||
new_device_limit=subscription.device_limit,
|
||||
amount_kopeks=price_kopeks,
|
||||
)
|
||||
except Exception as ws_error:
|
||||
logger.warning(
|
||||
'⚠️ Автопокупка устройств: не удалось отправить WebSocket уведомление: %s',
|
||||
ws_error,
|
||||
)
|
||||
|
||||
# Уведомление пользователю
|
||||
if bot and user.telegram_id:
|
||||
texts = get_texts(getattr(user, 'language', 'ru'))
|
||||
try:
|
||||
message = texts.t(
|
||||
'AUTO_PURCHASE_DEVICES_SUCCESS',
|
||||
(
|
||||
'✅ <b>Устройства добавлены автоматически!</b>\n\n'
|
||||
'📱 Добавлено: {devices_to_add} устройств\n'
|
||||
'📊 Новый лимит: {new_limit} устройств\n'
|
||||
'💰 Списано: {price}'
|
||||
),
|
||||
).format(
|
||||
devices_to_add=devices_to_add,
|
||||
new_limit=subscription.device_limit,
|
||||
price=texts.format_price(price_kopeks),
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'),
|
||||
callback_data='menu_subscription',
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '🏠 Главное меню'),
|
||||
callback_data='back_to_menu',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
'⚠️ Автопокупка устройств: не удалось уведомить пользователя %s: %s',
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
|
||||
# Уведомление админам
|
||||
if bot:
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
'devices',
|
||||
old_device_limit,
|
||||
subscription.device_limit,
|
||||
price_kopeks,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
'⚠️ Автопокупка устройств: не удалось уведомить админов: %s',
|
||||
error,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _auto_add_traffic(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
cart_data: dict,
|
||||
*,
|
||||
bot: Bot | None = None,
|
||||
) -> bool:
|
||||
"""Auto-purchase traffic from saved cart after balance topup."""
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
|
||||
from app.database.crud.subscription import add_subscription_traffic, get_subscription_by_user_id
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import PaymentMethod
|
||||
|
||||
traffic_gb = _safe_int(cart_data.get('traffic_gb'))
|
||||
price_kopeks = _safe_int(cart_data.get('price_kopeks'))
|
||||
|
||||
if traffic_gb <= 0 or price_kopeks <= 0:
|
||||
logger.warning(
|
||||
'🔁 Автопокупка трафика: некорректные данные корзины для пользователя %s (traffic_gb=%s, price=%s)',
|
||||
_format_user_id(user),
|
||||
traffic_gb,
|
||||
price_kopeks,
|
||||
)
|
||||
return False
|
||||
|
||||
# Verify balance
|
||||
if user.balance_kopeks < price_kopeks:
|
||||
logger.info(
|
||||
'🔁 Автопокупка трафика: у пользователя %s недостаточно средств (%s < %s)',
|
||||
_format_user_id(user),
|
||||
user.balance_kopeks,
|
||||
price_kopeks,
|
||||
)
|
||||
return False
|
||||
|
||||
# Verify subscription
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
if not subscription:
|
||||
logger.warning(
|
||||
'🔁 Автопокупка трафика: у пользователя %s нет подписки',
|
||||
_format_user_id(user),
|
||||
)
|
||||
await user_cart_service.delete_user_cart(user.id)
|
||||
return False
|
||||
|
||||
if subscription.status not in ('active', 'trial', 'ACTIVE', 'TRIAL'):
|
||||
logger.warning(
|
||||
'🔁 Автопокупка трафика: подписка пользователя %s не активна (status=%s)',
|
||||
_format_user_id(user),
|
||||
subscription.status,
|
||||
)
|
||||
await user_cart_service.delete_user_cart(user.id)
|
||||
return False
|
||||
|
||||
if subscription.is_trial:
|
||||
logger.warning(
|
||||
'🔁 Автопокупка трафика: у пользователя %s пробная подписка',
|
||||
_format_user_id(user),
|
||||
)
|
||||
await user_cart_service.delete_user_cart(user.id)
|
||||
return False
|
||||
|
||||
if subscription.traffic_limit_gb == 0:
|
||||
logger.warning(
|
||||
'🔁 Автопокупка трафика: у пользователя %s уже безлимитный трафик',
|
||||
_format_user_id(user),
|
||||
)
|
||||
await user_cart_service.delete_user_cart(user.id)
|
||||
return False
|
||||
|
||||
# Deduct balance
|
||||
description = f'Докупка {traffic_gb} ГБ трафика'
|
||||
try:
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
price_kopeks,
|
||||
description,
|
||||
create_transaction=True,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
if not success:
|
||||
logger.warning(
|
||||
'❌ Автопокупка трафика: не удалось списать баланс пользователя %s',
|
||||
_format_user_id(user),
|
||||
)
|
||||
return False
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'❌ Автопокупка трафика: ошибка списания баланса пользователя %s: %s',
|
||||
_format_user_id(user),
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
# Add traffic
|
||||
old_traffic_limit = subscription.traffic_limit_gb or 0
|
||||
try:
|
||||
await add_subscription_traffic(db, subscription, traffic_gb)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'❌ Автопокупка трафика: ошибка добавления трафика пользователю %s: %s',
|
||||
_format_user_id(user),
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
# Sync with RemnaWave
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
'⚠️ Автопокупка трафика: не удалось обновить Remnawave для пользователя %s: %s',
|
||||
_format_user_id(user),
|
||||
error,
|
||||
)
|
||||
|
||||
# Clear cart (transaction already created in subtract_user_balance)
|
||||
await user_cart_service.delete_user_cart(user.id)
|
||||
|
||||
logger.info(
|
||||
'✅ Автопокупка трафика: пользователь %s добавил %s ГБ (было %s, стало %s) за %s коп.',
|
||||
_format_user_id(user),
|
||||
traffic_gb,
|
||||
old_traffic_limit,
|
||||
subscription.traffic_limit_gb,
|
||||
price_kopeks,
|
||||
)
|
||||
|
||||
# WebSocket notification for cabinet
|
||||
try:
|
||||
from app.cabinet.routes.websocket import notify_user_traffic_purchased
|
||||
|
||||
await notify_user_traffic_purchased(
|
||||
user_id=user.id,
|
||||
traffic_gb_added=traffic_gb,
|
||||
new_traffic_limit_gb=subscription.traffic_limit_gb or 0,
|
||||
amount_kopeks=price_kopeks,
|
||||
)
|
||||
except Exception as ws_error:
|
||||
logger.warning(
|
||||
'⚠️ Автопокупка трафика: не удалось отправить WebSocket уведомление: %s',
|
||||
ws_error,
|
||||
)
|
||||
|
||||
# User notification
|
||||
if bot and user.telegram_id:
|
||||
texts = get_texts(getattr(user, 'language', 'ru'))
|
||||
try:
|
||||
message = texts.t(
|
||||
'AUTO_PURCHASE_TRAFFIC_SUCCESS',
|
||||
(
|
||||
'✅ <b>Трафик добавлен автоматически!</b>\n\n'
|
||||
'📈 Добавлено: {traffic_gb} ГБ\n'
|
||||
'📊 Новый лимит: {new_limit} ГБ\n'
|
||||
'💰 Списано: {price}'
|
||||
),
|
||||
).format(
|
||||
traffic_gb=traffic_gb,
|
||||
new_limit=subscription.traffic_limit_gb,
|
||||
price=texts.format_price(price_kopeks),
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'),
|
||||
callback_data='menu_subscription',
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '🏠 Главное меню'),
|
||||
callback_data='back_to_menu',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
'⚠️ Автопокупка трафика: не удалось уведомить пользователя %s: %s',
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
|
||||
# Admin notification
|
||||
if bot:
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
'traffic',
|
||||
old_traffic_limit,
|
||||
subscription.traffic_limit_gb,
|
||||
price_kopeks,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
'⚠️ Автопокупка трафика: не удалось уведомить админов: %s',
|
||||
error,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -1061,6 +1563,14 @@ async def auto_purchase_saved_cart_after_topup(
|
||||
bot: Bot | None = None,
|
||||
) -> bool:
|
||||
"""Attempts to automatically purchase a subscription from a saved cart."""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Lazy imports to avoid circular dependency
|
||||
from app.cabinet.routes.websocket import (
|
||||
notify_user_subscription_activated,
|
||||
notify_user_subscription_renewed,
|
||||
)
|
||||
from app.database.crud.transaction import get_user_transactions
|
||||
|
||||
if not settings.is_auto_purchase_after_topup_enabled():
|
||||
return False
|
||||
@@ -1076,6 +1586,33 @@ async def auto_purchase_saved_cart_after_topup(
|
||||
|
||||
cart_mode = cart_data.get('cart_mode') or cart_data.get('mode')
|
||||
|
||||
# Защита от race condition: если подписка была куплена/продлена в последние 60 секунд,
|
||||
# пропускаем автопокупку чтобы избежать двойного списания
|
||||
if cart_mode in ('extend', 'tariff_purchase', 'daily_tariff_purchase'):
|
||||
try:
|
||||
recent_transactions = await get_user_transactions(db, user.id, limit=1)
|
||||
if recent_transactions:
|
||||
last_tx = recent_transactions[0]
|
||||
if (
|
||||
last_tx.type == TransactionType.SUBSCRIPTION_PAYMENT
|
||||
and last_tx.created_at
|
||||
and (datetime.utcnow() - last_tx.created_at) < timedelta(seconds=60)
|
||||
):
|
||||
logger.info(
|
||||
'🔁 Автопокупка: пропускаем для пользователя %s - подписка уже куплена %s секунд назад',
|
||||
_format_user_id(user),
|
||||
(datetime.utcnow() - last_tx.created_at).total_seconds(),
|
||||
)
|
||||
# Очищаем корзину чтобы не срабатывало повторно
|
||||
await user_cart_service.delete_user_cart(user.id)
|
||||
return False
|
||||
except Exception as check_error:
|
||||
logger.warning(
|
||||
'🔁 Автопокупка: ошибка проверки последней транзакции для %s: %s',
|
||||
_format_user_id(user),
|
||||
check_error,
|
||||
)
|
||||
|
||||
# Обработка продления подписки
|
||||
if cart_mode == 'extend':
|
||||
return await _auto_extend_subscription(db, user, cart_data, bot=bot)
|
||||
@@ -1088,6 +1625,14 @@ async def auto_purchase_saved_cart_after_topup(
|
||||
if cart_mode == 'daily_tariff_purchase':
|
||||
return await _auto_purchase_daily_tariff(db, user, cart_data, bot=bot)
|
||||
|
||||
# Обработка докупки устройств
|
||||
if cart_mode == 'add_devices':
|
||||
return await _auto_add_devices(db, user, cart_data, bot=bot)
|
||||
|
||||
# Обработка докупки трафика
|
||||
if cart_mode == 'add_traffic':
|
||||
return await _auto_add_traffic(db, user, cart_data, bot=bot)
|
||||
|
||||
try:
|
||||
prepared = await _prepare_auto_purchase(db, user, cart_data)
|
||||
except PurchaseValidationError as error:
|
||||
@@ -1243,6 +1788,29 @@ async def auto_purchase_saved_cart_after_topup(
|
||||
_format_user_id(user),
|
||||
)
|
||||
|
||||
# Send WebSocket notification to cabinet frontend
|
||||
try:
|
||||
if was_trial_conversion:
|
||||
# Trial conversion = activation
|
||||
await notify_user_subscription_activated(
|
||||
user_id=user.id,
|
||||
expires_at=subscription.end_date.isoformat() if subscription and subscription.end_date else '',
|
||||
tariff_name='',
|
||||
)
|
||||
else:
|
||||
# Regular purchase = renewal or new activation
|
||||
await notify_user_subscription_renewed(
|
||||
user_id=user.id,
|
||||
new_expires_at=subscription.end_date.isoformat() if subscription and subscription.end_date else '',
|
||||
amount_kopeks=pricing.final_total,
|
||||
)
|
||||
except Exception as ws_error:
|
||||
logger.warning(
|
||||
'⚠️ Автопокупка: не удалось отправить WS уведомление для %s: %s',
|
||||
_format_user_id(user),
|
||||
ws_error,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -1273,6 +1841,11 @@ async def auto_activate_subscription_after_topup(
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
# Lazy imports to avoid circular dependency
|
||||
from app.cabinet.routes.websocket import (
|
||||
notify_user_subscription_activated,
|
||||
notify_user_subscription_renewed,
|
||||
)
|
||||
from app.database.crud.server_squad import get_available_server_squads, get_server_ids_by_uuids
|
||||
from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id
|
||||
from app.database.crud.transaction import create_transaction
|
||||
@@ -1397,6 +1970,20 @@ async def auto_activate_subscription_after_topup(
|
||||
best_price,
|
||||
)
|
||||
|
||||
# Send WebSocket notification to cabinet frontend
|
||||
try:
|
||||
await notify_user_subscription_renewed(
|
||||
user_id=user.id,
|
||||
new_expires_at=result.subscription.end_date.isoformat() if result.subscription.end_date else '',
|
||||
amount_kopeks=best_price,
|
||||
)
|
||||
except Exception as ws_error:
|
||||
logger.warning(
|
||||
'⚠️ Автоактивация: не удалось отправить WS уведомление о продлении для %s: %s',
|
||||
_format_user_id(user),
|
||||
ws_error,
|
||||
)
|
||||
|
||||
# Уведомление пользователю (только для Telegram-пользователей)
|
||||
if bot and user.telegram_id:
|
||||
try:
|
||||
@@ -1475,6 +2062,20 @@ async def auto_activate_subscription_after_topup(
|
||||
best_price,
|
||||
)
|
||||
|
||||
# Send WebSocket notification to cabinet frontend
|
||||
try:
|
||||
await notify_user_subscription_activated(
|
||||
user_id=user.id,
|
||||
expires_at=new_subscription.end_date.isoformat() if new_subscription.end_date else '',
|
||||
tariff_name='',
|
||||
)
|
||||
except Exception as ws_error:
|
||||
logger.warning(
|
||||
'⚠️ Автоактивация: не удалось отправить WS уведомление об активации для %s: %s',
|
||||
_format_user_id(user),
|
||||
ws_error,
|
||||
)
|
||||
|
||||
# Уведомление пользователю (только для Telegram-пользователей)
|
||||
if bot and user.telegram_id:
|
||||
try:
|
||||
@@ -1542,9 +2143,11 @@ async def auto_activate_subscription_after_topup(
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return (False, False)
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ['auto_activate_subscription_after_topup', 'auto_purchase_saved_cart_after_topup']
|
||||
|
||||
@@ -446,6 +446,11 @@ class SubscriptionService:
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
# "User already disabled" - считаем успехом
|
||||
if 'already disabled' in error_msg:
|
||||
logger.info(f'✅ RemnaWave пользователь {user_uuid} уже отключен')
|
||||
return True
|
||||
logger.error(f'Ошибка отключения RemnaWave пользователя: {e}')
|
||||
return False
|
||||
|
||||
@@ -458,6 +463,11 @@ class SubscriptionService:
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
# "User already enabled" - считаем успехом
|
||||
if 'already enabled' in error_msg:
|
||||
logger.info(f'✅ RemnaWave пользователь {user_uuid} уже включен')
|
||||
return True
|
||||
logger.error(f'Ошибка включения RemnaWave пользователя: {e}')
|
||||
return False
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ class UserCartService:
|
||||
"""
|
||||
client = self._get_redis_client()
|
||||
if client is None:
|
||||
logger.warning(f'🛒 Redis недоступен, корзина пользователя {user_id} НЕ сохранена')
|
||||
return False
|
||||
|
||||
try:
|
||||
@@ -59,10 +60,11 @@ class UserCartService:
|
||||
json_data = json.dumps(cart_data, ensure_ascii=False)
|
||||
effective_ttl = ttl if ttl is not None else settings.CART_TTL_SECONDS
|
||||
await client.setex(key, effective_ttl, json_data)
|
||||
logger.debug(f'Корзина пользователя {user_id} сохранена в Redis')
|
||||
cart_mode = cart_data.get('cart_mode', 'unknown')
|
||||
logger.info(f'🛒 Корзина пользователя {user_id} сохранена в Redis (mode={cart_mode}, ttl={effective_ttl}s)')
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка сохранения корзины пользователя {user_id}: {e}')
|
||||
logger.error(f'🛒 Ошибка сохранения корзины пользователя {user_id}: {e}')
|
||||
return False
|
||||
|
||||
async def get_user_cart(self, user_id: int) -> dict[str, Any] | None:
|
||||
@@ -127,14 +129,17 @@ class UserCartService:
|
||||
"""
|
||||
client = self._get_redis_client()
|
||||
if client is None:
|
||||
logger.warning(f'🛒 Redis недоступен, проверка корзины пользователя {user_id} невозможна')
|
||||
return False
|
||||
|
||||
try:
|
||||
key = f'user_cart:{user_id}'
|
||||
exists = await client.exists(key)
|
||||
return bool(exists)
|
||||
result = bool(exists)
|
||||
logger.info(f'🛒 Проверка корзины пользователя {user_id}: {"найдена" if result else "не найдена"}')
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка проверки наличия корзины пользователя {user_id}: {e}')
|
||||
logger.error(f'🛒 Ошибка проверки наличия корзины пользователя {user_id}: {e}')
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ from app.database.models import (
|
||||
User,
|
||||
UserMessage,
|
||||
UserStatus,
|
||||
WataPayment,
|
||||
WelcomeText,
|
||||
YooKassaPayment,
|
||||
)
|
||||
@@ -989,6 +990,17 @@ class UserService:
|
||||
except Exception as e:
|
||||
logger.error(f'❌ Ошибка удаления подписки: {e}')
|
||||
|
||||
try:
|
||||
wata_payments_result = await db.execute(select(WataPayment).where(WataPayment.user_id == user_id))
|
||||
wata_payments = wata_payments_result.scalars().all()
|
||||
|
||||
if wata_payments:
|
||||
logger.info(f'🔄 Удаляем {len(wata_payments)} Wata платежей')
|
||||
await db.execute(delete(WataPayment).where(WataPayment.user_id == user_id))
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f'❌ Ошибка удаления Wata платежей: {e}')
|
||||
|
||||
try:
|
||||
await db.execute(delete(User).where(User.id == user_id))
|
||||
await db.commit()
|
||||
|
||||
@@ -115,6 +115,9 @@ class AdminStates(StatesGroup):
|
||||
creating_referral_contest_end = State()
|
||||
creating_referral_contest_time = State()
|
||||
editing_referral_contest_summary_times = State()
|
||||
adding_virtual_participant_name = State()
|
||||
adding_virtual_participant_count = State()
|
||||
editing_virtual_participant_count = State()
|
||||
editing_daily_contest_field = State()
|
||||
editing_daily_contest_value = State()
|
||||
|
||||
|
||||
@@ -34,6 +34,18 @@ def get_local_timezone() -> ZoneInfo:
|
||||
return ZoneInfo('UTC')
|
||||
|
||||
|
||||
def panel_datetime_to_naive_utc(dt: datetime) -> datetime:
|
||||
"""Convert a panel datetime to naive UTC.
|
||||
|
||||
Panel API returns local time with a misleading UTC offset (+00:00 / Z).
|
||||
This strips the offset, interprets the raw value as panel-local time,
|
||||
then converts to naive UTC for database storage.
|
||||
"""
|
||||
naive = dt.replace(tzinfo=None)
|
||||
localized = naive.replace(tzinfo=get_local_timezone())
|
||||
return localized.astimezone(ZoneInfo('UTC')).replace(tzinfo=None)
|
||||
|
||||
|
||||
def to_local_datetime(dt: datetime | None) -> datetime | None:
|
||||
"""Convert a datetime value to the configured local timezone."""
|
||||
|
||||
|
||||
@@ -287,6 +287,12 @@ async def main():
|
||||
traffic_monitoring_scheduler.set_bot(bot)
|
||||
daily_subscription_service.set_bot(bot)
|
||||
|
||||
# Initialize email broadcast service
|
||||
from app.cabinet.services.email_service import email_service
|
||||
from app.services.broadcast_service import email_broadcast_service
|
||||
|
||||
email_broadcast_service.set_email_service(email_service)
|
||||
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
async with timeline.stage(
|
||||
|
||||
@@ -1,658 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"additionalLocales": [
|
||||
"ru",
|
||||
"zh",
|
||||
"fa"
|
||||
],
|
||||
"branding": {
|
||||
"name": "Subscription",
|
||||
"logoUrl": "https://raw.githubusercontent.com/Fr1ngg/remnawave-bedolaga-telegram-bot/bf0c1ce711a26fa2f24559e7e4443820e68d758b/assets/bedolaga_app3.svg",
|
||||
"supportUrl": "https://t.me"
|
||||
}
|
||||
},
|
||||
"platforms": {
|
||||
"ios": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
|
||||
"buttonText": {
|
||||
"en": "Open in App Store [EU]",
|
||||
"fa": "باز کردن در App Store [EU]",
|
||||
"ru": "Открыть в App Store [EU]",
|
||||
"zh": "在 App Store 中打开 [EU]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
|
||||
"buttonText": {
|
||||
"en": "Open in App Store [RU]",
|
||||
"fa": "باز کردن در App Store [RU]",
|
||||
"ru": "Открыть в App Store [RU]",
|
||||
"zh": "在 App Store 中打开 [RU]"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
|
||||
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
|
||||
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
|
||||
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below — the app will open and the subscription will be added automatically",
|
||||
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
|
||||
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
|
||||
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "streisand",
|
||||
"name": "Streisand",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "streisand://import/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/ru/app/streisand/id6450534064",
|
||||
"buttonText": {
|
||||
"en": "Open in App Store",
|
||||
"fa": "باز کردن در App Store",
|
||||
"ru": "Открыть в App Store",
|
||||
"zh": "在 App Store 中打开"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
|
||||
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
|
||||
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
|
||||
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below — the app will open and the subscription will be added automatically",
|
||||
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
|
||||
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
|
||||
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shadowrocket",
|
||||
"name": "Shadowrocket",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "sub://",
|
||||
"isNeedBase64Encoding": true,
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/ru/app/shadowrocket/id932747118",
|
||||
"buttonText": {
|
||||
"en": "Open in App Store",
|
||||
"fa": "باز کردن در App Store",
|
||||
"ru": "Открыть в App Store",
|
||||
"zh": "在 App Store 中打开"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
|
||||
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
|
||||
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
|
||||
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below — the app will open and the subscription will be added automatically",
|
||||
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
|
||||
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
|
||||
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"android": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy",
|
||||
"buttonText": {
|
||||
"en": "Open in Google Play",
|
||||
"fa": "باز کردن در Google Play",
|
||||
"ru": "Открыть в Google Play",
|
||||
"zh": "在 Google Play 中打开"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk",
|
||||
"buttonText": {
|
||||
"en": "Download APK",
|
||||
"fa": "دانلود APK",
|
||||
"ru": "Скачать APK",
|
||||
"zh": "下载 APK"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"fa": "صفحه را در Google Play باز کنید و برنامه را نصب کنید. یا برنامه را مستقیماً از فایل APK نصب کنید، اگر Google Play کار نمی کند.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение. Или установите приложение из APK файла напрямую, если Google Play не работает.",
|
||||
"zh": "在 Google Play 中打开页面并安装应用。如果 Google Play 无法使用,也可以直接从 APK 文件安装应用。"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
|
||||
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
|
||||
"zh": "点击下方按钮添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"fa": "برنامه را باز کنید و به سرور متصل شوید",
|
||||
"ru": "Откройте приложение и подключитесь к серверу",
|
||||
"zh": "打开应用并连接到服务器"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "clash-meta",
|
||||
"name": "Clash Meta",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "clash://install-config?url=",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://github.com/MetaCubeX/ClashMetaForAndroid/releases/download/v2.11.7/cmfa-2.11.7-meta-universal-release.apk",
|
||||
"buttonText": {
|
||||
"en": "Download APK",
|
||||
"fa": "دانلود APK",
|
||||
"ru": "Скачать APK",
|
||||
"zh": "下载 APK"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://f-droid.org/packages/com.github.metacubex.clash.meta/",
|
||||
"buttonText": {
|
||||
"en": "Open in F-Droid",
|
||||
"fa": "در F-Droid باز کنید",
|
||||
"ru": "Открыть в F-Droid",
|
||||
"zh": "在 F-Droid 中打开"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Download and install Clash Meta APK",
|
||||
"fa": "دانلود و نصب Clash Meta APK",
|
||||
"ru": "Скачайте и установите Clash Meta APK",
|
||||
"zh": "下载并安装 Clash Meta APK"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Tap the button to import configuration",
|
||||
"fa": "برای وارد کردن پیکربندی روی دکمه ضربه بزنید",
|
||||
"ru": "Нажмите кнопку, чтобы импортировать конфигурацию",
|
||||
"zh": "点击按钮导入配置"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open Clash Meta and tap on Connect",
|
||||
"fa": "Clash Meta را باز کنید و روی اتصال ضربه بزنید",
|
||||
"ru": "Откройте Clash Meta и нажмите Подключиться",
|
||||
"zh": "打开 Clash Meta 并点击连接"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"macos": [
|
||||
{
|
||||
"id": "clash-verge",
|
||||
"name": "Clash Verge",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "clash://install-config?url=",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64-setup.exe",
|
||||
"buttonText": {
|
||||
"en": "Windows",
|
||||
"fa": "ویندوز",
|
||||
"ru": "Windows",
|
||||
"zh": "Windows"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS (Intel)",
|
||||
"fa": "مک (اینتل)",
|
||||
"ru": "macOS (Intel)",
|
||||
"zh": "macOS (Intel)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_aarch64.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS (Apple Silicon)",
|
||||
"fa": "مک (Apple Silicon)",
|
||||
"ru": "macOS (Apple Silicon)",
|
||||
"zh": "macOS (Apple Silicon)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases",
|
||||
"buttonText": {
|
||||
"en": "Linux",
|
||||
"fa": "لینوکس",
|
||||
"ru": "Linux",
|
||||
"zh": "Linux"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Choose the version for your device, click the button below and install the app.",
|
||||
"fa": "نسخه مناسب برای دستگاه خود را انتخاب کنید، دکمه زیر را فشار دهید و برنامه را نصب کنید",
|
||||
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение.",
|
||||
"zh": "选择适合您设备的版本,点击下方按钮并安装应用。"
|
||||
}
|
||||
},
|
||||
"additionalBeforeAddSubscriptionStep": {
|
||||
"buttons": [],
|
||||
"description": {
|
||||
"en": "After launching the app, you can change the language in settings. In the left panel, find the gear icon, then navigate to Verge 设置 and select 语言设置.",
|
||||
"fa": "پس از راهاندازی برنامه، میتوانید زبان را در تنظیمات تغییر دهید. در پنل سمت چپ، نماد چرخ دنده را پیدا کنید، سپس به Verge 设置 بروید و 语言设置 را انتخاب کنید.",
|
||||
"ru": "После запуска приложения вы можете сменить язык в настройках. В левой панели найдите иконку шестеренки, далее ориентируйтесь на Verge 设置 и выберите пункт 语言设置.",
|
||||
"zh": "启动应用后,您可以在设置中更改语言。在左侧面板找到齿轮图标,然后导航到 Verge 设置并选择语言设置。"
|
||||
},
|
||||
"title": {
|
||||
"en": "Change language",
|
||||
"fa": "تغییر زبان",
|
||||
"ru": "Смена языка",
|
||||
"zh": "更改语言"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
|
||||
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
|
||||
"zh": "点击下方按钮添加订阅"
|
||||
}
|
||||
},
|
||||
"additionalAfterAddSubscriptionStep": {
|
||||
"buttons": [],
|
||||
"title": {
|
||||
"en": "If the subscription is not added",
|
||||
"fa": "اگر اشتراک در برنامه نصب نشده است",
|
||||
"ru": "Если подписка не добавилась",
|
||||
"zh": "如果订阅未添加"
|
||||
},
|
||||
"description": {
|
||||
"en": "If nothing happens after clicking the button, add the subscription manually. Click the Get Link button in the top right corner of this page, copy the link. In Clash Verge, go to the Profiles section and paste the link in the text field, then click the Import button.",
|
||||
"fa": "اگر پس از کلیک روی دکمه اتفاقی نیفتاد، اشتراک را به صورت دستی اضافه کنید. در گوشه بالا سمت راست این صفحه روی دکمه دریافت لینک کلیک کنید، لینک را کپی کنید. در Clash Verge به بخش پروفایلها بروید و لینک را در فیلد متنی وارد کنید، سپس روی دکمه وارد کردن کلیک کنید.",
|
||||
"ru": "Если после нажатия на кнопку ничего не произошло, добавьте подписку вручную. Нажмите на этой страницу кнопку Получить ссылку в правом верхнем углу, скопируйте ссылку. В Clash Verge перейдите в раздел Профили и вставьте ссылку в текстовое поле, затем нажмите на кнопку Импорт.",
|
||||
"zh": "如果点击按钮后没有反应,请手动添加订阅。点击此页面右上角的获取链接按钮,复制链接。在 Clash Verge 中,转到配置文件部分,将链接粘贴到文本字段中,然后点击导入按钮。"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "You can select a server in the Proxy section, and enable VPN in the Settings section. Set the TUN Mode switch to ON.",
|
||||
"fa": "میتوانید در بخش پروکسی سرور را انتخاب کنید و در بخش تنظیمات VPN را فعال کنید. کلید TUN Mode را در حالت روشن قرار دهید.",
|
||||
"ru": "Выбрать сервер можно в разделе Прокси, включить VPN можно в разделе Настройки. Установите переключатель TUN Mode в положение ВКЛ.",
|
||||
"zh": "您可以在代理部分选择服务器,在设置部分启用 VPN。将 TUN 模式开关设置为开启。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "hiddify",
|
||||
"name": "Hiddify",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "hiddify://import/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Windows-Setup-x64.exe",
|
||||
"buttonText": {
|
||||
"en": "Windows",
|
||||
"fa": "ویندوز",
|
||||
"ru": "Windows",
|
||||
"zh": "Windows"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-MacOS.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS",
|
||||
"fa": "مک",
|
||||
"ru": "macOS",
|
||||
"zh": "macOS"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Linux-x64.AppImage",
|
||||
"buttonText": {
|
||||
"en": "Linux",
|
||||
"fa": "لینوکس",
|
||||
"ru": "Linux",
|
||||
"zh": "Linux"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. If needed, select a different server in the Proxy section",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. در صورت نیاز، سرور دیگری را در بخش پروکسی انتخاب کنید",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. При необходимости выберите другой сервер в разделе Прокси.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接 VPN。如有需要,可在代理部分选择不同的服务器"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
|
||||
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
|
||||
"zh": "点击下方按钮添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, select a different server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"windows": [
|
||||
{
|
||||
"id": "clash-verge",
|
||||
"name": "Clash Verge",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "clash://install-config?url=",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64-setup.exe",
|
||||
"buttonText": {
|
||||
"en": "Windows",
|
||||
"fa": "ویندوز",
|
||||
"ru": "Windows",
|
||||
"zh": "Windows"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS (Intel)",
|
||||
"fa": "مک (اینتل)",
|
||||
"ru": "macOS (Intel)",
|
||||
"zh": "macOS (Intel)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_aarch64.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS (Apple Silicon)",
|
||||
"fa": "مک (Apple Silicon)",
|
||||
"ru": "macOS (Apple Silicon)",
|
||||
"zh": "macOS (Apple Silicon)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases",
|
||||
"buttonText": {
|
||||
"en": "Linux",
|
||||
"fa": "لینوکس",
|
||||
"ru": "Linux",
|
||||
"zh": "Linux"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Choose the version for your device, click the button below and install the app.",
|
||||
"fa": "نسخه مناسب برای دستگاه خود را انتخاب کنید، دکمه زیر را فشار دهید و برنامه را نصب کنید",
|
||||
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение.",
|
||||
"zh": "选择适合您设备的版本,点击下方按钮并安装应用。"
|
||||
}
|
||||
},
|
||||
"additionalBeforeAddSubscriptionStep": {
|
||||
"buttons": [],
|
||||
"description": {
|
||||
"en": "After launching the app, you can change the language in settings. In the left panel, find the gear icon, then navigate to Verge 设置 and select 语言设置.",
|
||||
"fa": "پس از راهاندازی برنامه، میتوانید زبان را در تنظیمات تغییر دهید. در پنل سمت چپ، نماد چرخ دنده را پیدا کنید، سپس به Verge 设置 بروید و 语言设置 را انتخاب کنید.",
|
||||
"ru": "После запуска приложения вы можете сменить язык в настройках. В левой панели найдите иконку шестеренки, далее ориентируйтесь на Verge 设置 и выберите пункт 语言设置.",
|
||||
"zh": "启动应用后,您可以在设置中更改语言。在左侧面板找到齿轮图标,然后导航到 Verge 设置并选择语言设置。"
|
||||
},
|
||||
"title": {
|
||||
"en": "Change language",
|
||||
"fa": "تغییر زبان",
|
||||
"ru": "Смена языка",
|
||||
"zh": "更改语言"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
|
||||
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
|
||||
"zh": "点击下方按钮添加订阅"
|
||||
}
|
||||
},
|
||||
"additionalAfterAddSubscriptionStep": {
|
||||
"buttons": [],
|
||||
"title": {
|
||||
"en": "If the subscription is not added",
|
||||
"fa": "اگر اشتراک در برنامه نصب نشده است",
|
||||
"ru": "Если подписка не добавилась",
|
||||
"zh": "如果订阅未添加"
|
||||
},
|
||||
"description": {
|
||||
"en": "If nothing happens after clicking the button, add the subscription manually. Click the Get Link button in the top right corner of this page, copy the link. In Clash Verge, go to the Profiles section and paste the link in the text field, then click the Import button.",
|
||||
"fa": "اگر پس از کلیک روی دکمه اتفاقی نیفتاد، اشتراک را به صورت دستی اضافه کنید. در گوشه بالا سمت راست این صفحه روی دکمه دریافت لینک کلیک کنید، لینک را کپی کنید. در Clash Verge به بخش پروفایلها بروید و لینک را در فیلد متنی وارد کنید، سپس روی دکمه وارد کردن کلیک کنید.",
|
||||
"ru": "Если после нажатия на кнопку ничего не произошло, добавьте подписку вручную. Нажмите на этой страницу кнопку Получить ссылку в правом верхнем углу, скопируйте ссылку. В Clash Verge перейдите в раздел Профили и вставьте ссылку в текстовое поле, затем нажмите на кнопку Импорт.",
|
||||
"zh": "如果点击按钮后没有反应,请手动添加订阅。点击此页面右上角的获取链接按钮,复制链接。在 Clash Verge 中,转到配置文件部分,将链接粘贴到文本字段中,然后点击导入按钮。"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "You can select a server in the Proxy section, and enable VPN in the Settings section. Set the TUN Mode switch to ON.",
|
||||
"fa": "میتوانید در بخش پروکسی سرور را انتخاب کنید و در بخش تنظیمات VPN را فعال کنید. کلید TUN Mode را در حالت روشن قرار دهید.",
|
||||
"ru": "Выبрать сервер можно в разделе Прокси, включить VPN можно в разделе Настройки. Установите переключатель TUN Mode в положение ВКЛ.",
|
||||
"zh": "您可以在代理部分选择服务器,在设置部分启用 VPN。将 TUN 模式开关设置为开启。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "hiddify",
|
||||
"name": "Hiddify",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "hiddify://import/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Windows-Setup-x64.exe",
|
||||
"buttonText": {
|
||||
"en": "Windows",
|
||||
"fa": "ویندوز",
|
||||
"ru": "Windows",
|
||||
"zh": "Windows"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-MacOS.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS",
|
||||
"fa": "مک",
|
||||
"ru": "macOS",
|
||||
"zh": "macOS"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Linux-x64.AppImage",
|
||||
"buttonText": {
|
||||
"en": "Linux",
|
||||
"fa": "لینوکس",
|
||||
"ru": "Linux",
|
||||
"zh": "Linux"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. If needed, select a different server in the Proxy section",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. در صورت نیاز، سرور دیگری را در بخش پروکسی انتخاب کنید",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. При необходимости выберите другой сервер в разделе Прокси.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接 VPN。如有需要,可在代理部分选择不同的服务器"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
|
||||
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
|
||||
"zh": "点击下方按钮添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, select a different server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"linux": [],
|
||||
"androidTV": [
|
||||
{
|
||||
"id": "new-app-androidtv-1760203310792",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
|
||||
"buttonText": {
|
||||
"en": "Google Play",
|
||||
"ru": "Button TextGoogle Play",
|
||||
"zh": "Button Text",
|
||||
"fa": "Button Text"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"appleTV": [
|
||||
{
|
||||
"id": "new-app-appletv-1760203488851",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
|
||||
"buttonText": {
|
||||
"en": "Google Play",
|
||||
"ru": "Google Play",
|
||||
"zh": "Button Text",
|
||||
"fa": "Button Text"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
-21531
File diff suppressed because it is too large
Load Diff
@@ -1,282 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Оплата не прошла</title>
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@700&display=swap');
|
||||
|
||||
:root {
|
||||
--cyber-grid-color: rgba(0, 200, 255, 0.1);
|
||||
--cyber-line-color: rgba(0, 255, 200, 0.1);
|
||||
--brand-neon-color: #00aaff;
|
||||
--error-neon-color: #ff4d4d;
|
||||
--bg-color: #1a1a2e;
|
||||
--container-bg-color: rgba(20, 20, 35, 0.85);
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #c0c0d0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
margin: 0;
|
||||
|
||||
/* [!] ИСПРАВЛЕНИЕ: Уменьшен горизонтальный отступ для body */
|
||||
padding: 20px 15px;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
/* [!] ИСПРАВЛЕНИЕ: height -> min-height для лучшей совместимости */
|
||||
min-height: 100vh;
|
||||
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body::before,
|
||||
body::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
body::before {
|
||||
background:
|
||||
linear-gradient(to right, var(--cyber-grid-color) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, var(--cyber-grid-color) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
animation: gridScroll 20s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes gridScroll {
|
||||
from { background-position: 0 0; }
|
||||
to { background-position: -50px -50px; }
|
||||
}
|
||||
|
||||
body::after {
|
||||
background:
|
||||
linear-gradient(45deg, transparent 49%, var(--cyber-line-color) 50%, transparent 51%),
|
||||
linear-gradient(-45deg, transparent 49%, var(--cyber-line-color) 50%, transparent 51%);
|
||||
background-size: 100% 100%;
|
||||
animation: cyberScan 10s linear infinite alternate;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
@keyframes cyberScan {
|
||||
0% { background-position: 0% 0%; }
|
||||
100% { background-position: 100% 100%; }
|
||||
}
|
||||
|
||||
.container {
|
||||
background-color: var(--container-bg-color);
|
||||
border-radius: 16px;
|
||||
|
||||
/* [!] ИСПРАВЛЕНИЕ: Уменьшен горизонтальный отступ для .container */
|
||||
padding: 30px 25px;
|
||||
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
animation: fadeInScale 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.2s forwards;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border: 1px solid rgba(0, 200, 255, 0.2);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
@keyframes fadeInScale {
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
margin: 0 auto 25px auto;
|
||||
line-height: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-family: 'Orbitron', sans-serif;
|
||||
font-size: 48px;
|
||||
color: var(--brand-neon-color);
|
||||
text-shadow: 0 0 10px var(--brand-neon-color), 0 0 20px var(--brand-neon-color);
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
animation: logoSlideIn 0.8s ease-out 0.4s forwards, logoPulse 2s infinite ease-in-out 1.5s forwards;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
@keyframes logoSlideIn {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes logoPulse {
|
||||
0% { transform: scale(1); text-shadow: 0 0 10px var(--brand-neon-color), 0 0 20px var(--brand-neon-color); }
|
||||
50% { transform: scale(1.02); text-shadow: 0 0 15px var(--brand-neon-color), 0 0 25px var(--brand-neon-color), 0 0 35px var(--brand-neon-color); }
|
||||
100% { transform: scale(1); text-shadow: 0 0 10px var(--brand-neon-color), 0 0 20px var(--brand-neon-color); }
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 20px auto;
|
||||
}
|
||||
|
||||
.error-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
filter: drop-shadow(0 0 8px rgba(255, 77, 77, 0.7));
|
||||
}
|
||||
|
||||
.error-circle {
|
||||
stroke-dasharray: 166;
|
||||
stroke-dashoffset: 166;
|
||||
stroke-width: 3;
|
||||
stroke-miterlimit: 10;
|
||||
stroke: var(--error-neon-color);
|
||||
fill: none;
|
||||
animation: stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) 0.5s forwards;
|
||||
}
|
||||
|
||||
.error-line {
|
||||
transform-origin: 50% 50%;
|
||||
stroke-dasharray: 48;
|
||||
stroke-dashoffset: 48;
|
||||
stroke-width: 4;
|
||||
stroke: var(--error-neon-color);
|
||||
fill: none;
|
||||
animation: stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 1s forwards;
|
||||
}
|
||||
|
||||
@keyframes stroke {
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 15px;
|
||||
color: var(--text-primary);
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.info-text, .instruction-text {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 15px;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.instruction-text {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--bg-color);
|
||||
background-color: var(--brand-neon-color);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 0 15px rgba(0, 170, 255, 0.6);
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
background-color: #00c0ff;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0 20px rgba(0, 170, 255, 0.8), 0 0 30px rgba(0, 170, 255, 0.4);
|
||||
}
|
||||
.cta-button:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 0 10px rgba(0, 170, 255, 0.5);
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<div class="logo-container">
|
||||
<span class="logo-text">Bedolaga</span>
|
||||
</div>
|
||||
|
||||
<div class="error-icon">
|
||||
<svg class="error-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52">
|
||||
<circle class="error-circle" cx="26" cy="26" r="25" fill="none"/>
|
||||
<path class="error-line" fill="none" d="M16 16 36 36"/>
|
||||
<path class="error-line" fill="none" d="M36 16 16 36"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1>Оплата не прошла</h1>
|
||||
|
||||
<p class="info-text">
|
||||
К сожалению, не удалось обработать ваш платеж. Средства не были списаны.
|
||||
</p>
|
||||
|
||||
<p class="instruction-text">
|
||||
Пожалуйста, вернитесь в бота, проверьте данные и попробуйте еще раз.
|
||||
</p>
|
||||
|
||||
<button id="close-btn" class="cta-button">
|
||||
Вернуться в бота
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
try {
|
||||
if (window.Telegram && window.Telegram.WebApp) {
|
||||
Telegram.WebApp.ready();
|
||||
var closeButton = document.getElementById('close-btn');
|
||||
closeButton.addEventListener('click', function() {
|
||||
Telegram.WebApp.close();
|
||||
});
|
||||
} else {
|
||||
console.warn('Telegram WebApp JS not loaded or not in TWA environment.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
@@ -1,295 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Оплата прошла успешно</title>
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@700&display=swap');
|
||||
|
||||
:root {
|
||||
--cyber-grid-color: rgba(0, 200, 255, 0.1);
|
||||
--cyber-line-color: rgba(0, 255, 200, 0.1);
|
||||
--brand-neon-color: #00aaff;
|
||||
--success-neon-color: #00e676;
|
||||
--bg-color: #1a1a2e;
|
||||
--container-bg-color: rgba(20, 20, 35, 0.85);
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #c0c0d0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
margin: 0;
|
||||
padding: 20px 15px; /* Исправлено для мобильных */
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh; /* Исправлено для мобильных */
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body::before,
|
||||
body::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
body::before {
|
||||
background:
|
||||
linear-gradient(to right, var(--cyber-grid-color) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, var(--cyber-grid-color) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
animation: gridScroll 20s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes gridScroll {
|
||||
from { background-position: 0 0; }
|
||||
to { background-position: -50px -50px; }
|
||||
}
|
||||
|
||||
body::after {
|
||||
background:
|
||||
linear-gradient(45deg, transparent 49%, var(--cyber-line-color) 50%, transparent 51%),
|
||||
linear-gradient(-45deg, transparent 49%, var(--cyber-line-color) 50%, transparent 51%);
|
||||
background-size: 100% 100%;
|
||||
animation: cyberScan 10s linear infinite alternate;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
@keyframes cyberScan {
|
||||
0% { background-position: 0% 0%; }
|
||||
100% { background-position: 100% 100%; }
|
||||
}
|
||||
|
||||
.container {
|
||||
background-color: var(--container-bg-color);
|
||||
border-radius: 16px;
|
||||
padding: 30px 25px; /* Исправлено для мобильных */
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
animation: fadeInScale 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.2s forwards;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border: 1px solid rgba(0, 200, 255, 0.2);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
@keyframes fadeInScale {
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
margin: 0 auto 25px auto;
|
||||
line-height: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-family: 'Orbitron', sans-serif;
|
||||
font-size: 48px;
|
||||
color: var(--brand-neon-color);
|
||||
text-shadow: 0 0 10px var(--brand-neon-color), 0 0 20px var(--brand-neon-color);
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
animation: logoSlideIn 0.8s ease-out 0.4s forwards, logoPulse 2s infinite ease-in-out 1.5s forwards;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
@keyframes logoSlideIn {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes logoPulse {
|
||||
0% { transform: scale(1); text-shadow: 0 0 10px var(--brand-neon-color), 0 0 20px var(--brand-neon-color); }
|
||||
50% { transform: scale(1.02); text-shadow: 0 0 15px var(--brand-neon-color), 0 0 25px var(--brand-neon-color), 0 0 35px var(--brand-neon-color); }
|
||||
100% { transform: scale(1); text-shadow: 0 0 10px var(--brand-neon-color), 0 0 20px var(--brand-neon-color); }
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 20px auto;
|
||||
}
|
||||
|
||||
.success-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
filter: drop-shadow(0 0 8px rgba(0, 230, 118, 0.7));
|
||||
}
|
||||
|
||||
.checkmark__circle {
|
||||
stroke-dasharray: 166;
|
||||
stroke-dashoffset: 166;
|
||||
stroke-width: 3;
|
||||
stroke-miterlimit: 10;
|
||||
stroke: var(--success-neon-color);
|
||||
fill: none;
|
||||
/* [!] Анимация галочки (круга) начинается с задержкой 0.5с */
|
||||
animation: stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) 0.5s forwards;
|
||||
}
|
||||
|
||||
.checkmark__check {
|
||||
transform-origin: 50% 50%;
|
||||
stroke-dasharray: 48;
|
||||
stroke-dashoffset: 48;
|
||||
stroke-width: 4;
|
||||
stroke: var(--success-neon-color);
|
||||
fill: none;
|
||||
animation: stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 1s forwards;
|
||||
}
|
||||
|
||||
@keyframes stroke {
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 15px;
|
||||
color: var(--text-primary);
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.info-text, .instruction-text {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 15px;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.instruction-text {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--bg-color);
|
||||
background-color: var(--brand-neon-color);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 0 15px rgba(0, 170, 255, 0.6);
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
background-color: #00c0ff;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0 20px rgba(0, 170, 255, 0.8), 0 0 30px rgba(0, 170, 255, 0.4);
|
||||
}
|
||||
.cta-button:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 0 10px rgba(0, 170, 255, 0.5);
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<audio id="success-sound" src="/sound.mp3" preload="auto"></audio>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<div class="logo-container">
|
||||
<span class="logo-text">Bedolaga</span>
|
||||
</div>
|
||||
|
||||
<div class="success-icon">
|
||||
<svg class="checkmark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52">
|
||||
<circle class="checkmark__circle" cx="26" cy="26" r="25" fill="none"/>
|
||||
<path class="checkmark__check" fill="none" d="M14.1 27.2l7.1 7.2 16.7-16.8"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1>Оплата прошла успешно!</h1>
|
||||
|
||||
<p class="info-text">
|
||||
Ваша подписка на VPN активирована. Наслаждайтесь безопасным и анонимным доступом в Интернет.
|
||||
</p>
|
||||
|
||||
<p class="instruction-text">
|
||||
Теперь вы можете вернуться в бота, чтобы получить доступ к вашим настройкам и управлять подпиской.
|
||||
</p>
|
||||
|
||||
<button id="close-btn" class="cta-button">
|
||||
Вернуться в бота
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
try {
|
||||
if (window.Telegram && window.Telegram.WebApp) {
|
||||
Telegram.WebApp.ready();
|
||||
var closeButton = document.getElementById('close-btn');
|
||||
closeButton.addEventListener('click', function() {
|
||||
Telegram.WebApp.close();
|
||||
});
|
||||
} else {
|
||||
console.warn('Telegram WebApp JS not loaded or not in TWA environment.');
|
||||
}
|
||||
|
||||
// --- [!] 2. КОД ДЛЯ ВОСПРОИЗВЕДЕНИЯ ЗВУКА [!] ---
|
||||
|
||||
// Находим аудиофайл
|
||||
const audio = document.getElementById('success-sound');
|
||||
|
||||
// Устанавливаем таймер. Анимация круга (.checkmark__circle)
|
||||
// имеет задержку 0.5с (500мс). Мы запускаем звук в тот же момент.
|
||||
setTimeout(() => {
|
||||
// Пытаемся проиграть звук
|
||||
var playPromise = audio.play();
|
||||
|
||||
if (playPromise !== undefined) {
|
||||
playPromise.catch(error => {
|
||||
// Блокировка авто-воспроизведения - это нормально.
|
||||
console.warn('Audio autoplay blocked:', error);
|
||||
});
|
||||
}
|
||||
}, 500); // 500 миллисекунд = 0.5с
|
||||
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,703 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#2481cc">
|
||||
<title>Connecting to VPN...</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
:root {
|
||||
--tg-theme-bg-color: #ffffff;
|
||||
--tg-theme-text-color: #000000;
|
||||
--tg-theme-hint-color: #999999;
|
||||
--tg-theme-link-color: #2481cc;
|
||||
--tg-theme-button-color: #2481cc;
|
||||
--tg-theme-button-text-color: #ffffff;
|
||||
--tg-theme-secondary-bg-color: #f0f0f0;
|
||||
|
||||
--primary: var(--tg-theme-button-color);
|
||||
--primary-rgb: 36, 129, 204;
|
||||
--text-primary: var(--tg-theme-text-color);
|
||||
--text-secondary: var(--tg-theme-hint-color);
|
||||
--bg-primary: var(--tg-theme-bg-color);
|
||||
--bg-secondary: var(--tg-theme-secondary-bg-color);
|
||||
--border-color: rgba(0, 0, 0, 0.08);
|
||||
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
--radius-sm: 8px;
|
||||
--radius: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 20px;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--info: #3b82f6;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: rgba(30, 41, 59, 0.85);
|
||||
--text-primary: #f8fafc;
|
||||
--text-secondary: #94a3b8;
|
||||
--border-color: rgba(148, 163, 184, 0.25);
|
||||
--shadow-sm: 0 2px 8px rgba(2, 6, 23, 0.3);
|
||||
--shadow-md: 0 4px 16px rgba(2, 6, 23, 0.45);
|
||||
--shadow-lg: 0 8px 32px rgba(2, 6, 23, 0.55);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
line-height: 1.6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
to { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
.animate-in {
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Language Switcher */
|
||||
.top-bar {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.language-select {
|
||||
padding: 8px 32px 8px 12px;
|
||||
border-radius: var(--radius);
|
||||
border: 2px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.language-select:hover {
|
||||
border-color: var(--primary);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* Theme Toggle */
|
||||
.theme-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius);
|
||||
border: 2px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
border-color: var(--primary);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 16px;
|
||||
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.7));
|
||||
border-radius: var(--radius-xl);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--shadow-md);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.logo-container::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: linear-gradient(45deg, transparent, rgba(255, 255, 255, 0.3), transparent);
|
||||
transform: rotate(45deg);
|
||||
animation: shimmer 3s infinite;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 40px;
|
||||
color: white;
|
||||
z-index: 1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.7));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 15px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Main Card */
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 32px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Error State */
|
||||
.card.error {
|
||||
border: 2px solid var(--danger);
|
||||
background: linear-gradient(135deg, var(--bg-secondary), rgba(239, 68, 68, 0.05));
|
||||
}
|
||||
|
||||
.card.error .logo-container {
|
||||
background: linear-gradient(135deg, var(--danger), rgba(239, 68, 68, 0.7));
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid var(--bg-secondary);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Timer */
|
||||
.timer-container {
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
margin: 24px 0;
|
||||
border: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.timer-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.timer-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.timer-number {
|
||||
font-size: 48px;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.7));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.timer-unit {
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary), rgba(var(--primary-rgb), 0.6));
|
||||
border-radius: 3px;
|
||||
transition: width 1s linear;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent);
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
/* Status Text */
|
||||
.status-text {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.status-description {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Button */
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 16px 24px;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translate(-50%, -50%);
|
||||
transition: width 0.6s, height 0.6s;
|
||||
}
|
||||
|
||||
.btn:active::before {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.8));
|
||||
color: white;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
/* Hidden */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Mobile Optimizations */
|
||||
@media (max-width: 480px) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.timer-number {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark Mode Adjustments */
|
||||
:root[data-theme="dark"] .timer-container {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .btn-primary {
|
||||
box-shadow: 0 10px 30px rgba(37, 99, 235, 0.45);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Top Bar -->
|
||||
<div class="top-bar">
|
||||
<select id="languageSelect" class="language-select">
|
||||
<option value="en">🇬🇧 English</option>
|
||||
<option value="ru">🇷🇺 Русский</option>
|
||||
</select>
|
||||
<button class="theme-toggle" id="themeToggle">
|
||||
<svg class="icon-sun" width="20" height="20" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 18a6 6 0 100-12 6 6 0 000 12zM12 2v2M12 20v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M2 12h2M20 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||
</svg>
|
||||
<svg class="icon-moon" width="20" height="20" fill="currentColor" viewBox="0 0 24 24" style="display: none;">
|
||||
<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<div class="header animate-in">
|
||||
<div class="logo-container">
|
||||
<div class="logo-icon" id="appIcon">⚡</div>
|
||||
</div>
|
||||
<div class="logo" data-i18n="app.name">VPN</div>
|
||||
<div class="subtitle" id="appSubtitle" data-i18n="app.connecting">Connecting to VPN...</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Card -->
|
||||
<div class="card animate-in" id="mainCard">
|
||||
<div class="loading" id="loadingState">
|
||||
<div class="spinner"></div>
|
||||
<div class="loading-text" id="statusText" data-i18n="status.connecting">Establishing secure connection...</div>
|
||||
|
||||
<div class="timer-container" id="timerContainer">
|
||||
<div class="timer-label" data-i18n="timer.label">Redirecting in</div>
|
||||
<div class="timer-display">
|
||||
<span class="timer-number" id="timer">10</span>
|
||||
<span class="timer-unit" data-i18n="timer.seconds">seconds</span>
|
||||
</div>
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" id="progressFill"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="status-description" data-i18n="status.manual">If nothing happens, click the button below.</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State (hidden by default) -->
|
||||
<div class="error-state hidden" id="errorState">
|
||||
<div class="status-text error-text" data-i18n="error.title">Connection Error</div>
|
||||
<p class="status-description" data-i18n="error.description">The connection link is missing or invalid.</p>
|
||||
</div>
|
||||
|
||||
<!-- Action Button -->
|
||||
<a class="btn btn-primary" id="actionButton" href="#" target="_blank" rel="noopener">
|
||||
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
<span data-i18n="button.connect">Connect to VPN</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// App schemes configuration
|
||||
const appSchemes = [
|
||||
{ scheme: 'happ://', id: 'happ', icon: 'H', name: { en: 'Happ', ru: 'Happ' } },
|
||||
{ scheme: 'flclash://', id: 'flclash', icon: 'F', name: { en: 'FlClash', ru: 'FlClash' } },
|
||||
{ scheme: 'clash://', id: 'clash-meta', icon: 'C', name: { en: 'Clash Meta', ru: 'Clash Meta' } },
|
||||
{ scheme: 'sing-box://', id: 'sing-box', icon: 'S', name: { en: 'Sing-box', ru: 'Sing-box' } },
|
||||
{ scheme: 'v2rayng://', id: 'v2rayng', icon: 'V', name: { en: 'v2rayNG', ru: 'v2rayNG' } },
|
||||
{ scheme: 'sub://', id: 'shadowrocket', icon: 'R', name: { en: 'Shadowrocket', ru: 'Shadowrocket' } },
|
||||
{ scheme: 'hiddify://', id: 'hiddify', icon: 'H', name: { en: 'Hiddify', ru: 'Hiddify' } }
|
||||
];
|
||||
|
||||
// Translations
|
||||
const translations = {
|
||||
en: {
|
||||
'app.name': 'VPN',
|
||||
'app.connecting': 'Connecting to VPN...',
|
||||
'status.connecting': 'Establishing secure connection...',
|
||||
'timer.label': 'Redirecting in',
|
||||
'timer.seconds': 'seconds',
|
||||
'status.manual': 'If nothing happens, click the button below.',
|
||||
'button.connect': 'Connect to VPN',
|
||||
'error.title': 'Connection Error',
|
||||
'error.description': 'The connection link is missing or invalid. Please contact support.'
|
||||
},
|
||||
ru: {
|
||||
'app.name': 'VPN',
|
||||
'app.connecting': 'Подключение к VPN...',
|
||||
'status.connecting': 'Установка безопасного соединения...',
|
||||
'timer.label': 'Перенаправление через',
|
||||
'timer.seconds': 'секунд',
|
||||
'status.manual': 'Если ничего не происходит, нажмите кнопку ниже.',
|
||||
'button.connect': 'Подключиться к VPN',
|
||||
'error.title': 'Ошибка подключения',
|
||||
'error.description': 'Ссылка для подключения отсутствует или недействительна. Обратитесь в поддержку.'
|
||||
}
|
||||
};
|
||||
|
||||
// Global variables
|
||||
let currentLanguage = 'en';
|
||||
let currentTheme = 'light';
|
||||
let timerValue = 10;
|
||||
let timerId = null;
|
||||
let redirectTo = '';
|
||||
let appInfo = null;
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadSettings();
|
||||
|
||||
// Get redirect URL from URL parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
redirectTo = urlParams.get('redirect_to') || '';
|
||||
|
||||
// Detect app from URL scheme
|
||||
if (redirectTo) {
|
||||
appInfo = appSchemes.find(a => redirectTo.startsWith(a.scheme));
|
||||
}
|
||||
|
||||
updateUI();
|
||||
|
||||
if (redirectTo) {
|
||||
startTimer();
|
||||
} else {
|
||||
showError();
|
||||
}
|
||||
});
|
||||
|
||||
// Load saved settings
|
||||
function loadSettings() {
|
||||
const savedTheme = localStorage.getItem('remnawave-miniapp-theme') || 'light';
|
||||
const savedLang = localStorage.getItem('remnawave-miniapp-language') || getBrowserLanguage();
|
||||
|
||||
currentTheme = savedTheme;
|
||||
currentLanguage = savedLang;
|
||||
|
||||
applyTheme(savedTheme);
|
||||
document.getElementById('languageSelect').value = currentLanguage;
|
||||
}
|
||||
|
||||
// Get browser language
|
||||
function getBrowserLanguage() {
|
||||
const lang = navigator.language || navigator.userLanguage;
|
||||
if (lang.startsWith('ru')) return 'ru';
|
||||
return 'en';
|
||||
}
|
||||
|
||||
// Apply theme
|
||||
function applyTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
updateThemeToggle();
|
||||
}
|
||||
|
||||
// Update theme toggle icon
|
||||
function updateThemeToggle() {
|
||||
const sunIcon = document.querySelector('.icon-sun');
|
||||
const moonIcon = document.querySelector('.icon-moon');
|
||||
|
||||
if (currentTheme === 'dark') {
|
||||
sunIcon.style.display = 'none';
|
||||
moonIcon.style.display = 'block';
|
||||
} else {
|
||||
sunIcon.style.display = 'block';
|
||||
moonIcon.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle theme
|
||||
document.getElementById('themeToggle').addEventListener('click', function() {
|
||||
currentTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
localStorage.setItem('remnawave-miniapp-theme', currentTheme);
|
||||
applyTheme(currentTheme);
|
||||
});
|
||||
|
||||
// Change language
|
||||
document.getElementById('languageSelect').addEventListener('change', function(e) {
|
||||
currentLanguage = e.target.value;
|
||||
localStorage.setItem('remnawave-miniapp-language', currentLanguage);
|
||||
updateUI();
|
||||
});
|
||||
|
||||
// Get translation
|
||||
function t(key) {
|
||||
return translations[currentLanguage]?.[key] || translations.en[key] || key;
|
||||
}
|
||||
|
||||
// Update UI with translations
|
||||
function updateUI() {
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
el.textContent = t(key);
|
||||
});
|
||||
|
||||
// Update app-specific info
|
||||
if (appInfo && redirectTo) {
|
||||
const appName = appInfo.name[currentLanguage] || appInfo.name.en;
|
||||
document.getElementById('appIcon').textContent = appInfo.icon;
|
||||
document.getElementById('appSubtitle').textContent = `${t('app.connecting').replace('VPN', appName)}`;
|
||||
|
||||
const statusText = document.getElementById('statusText');
|
||||
if (statusText) {
|
||||
statusText.textContent = `${t('status.connecting').replace('...', '')} ${appName}...`;
|
||||
}
|
||||
}
|
||||
|
||||
// Update action button
|
||||
const actionButton = document.getElementById('actionButton');
|
||||
if (actionButton && redirectTo) {
|
||||
actionButton.href = redirectTo;
|
||||
}
|
||||
}
|
||||
|
||||
// Start countdown timer
|
||||
function startTimer() {
|
||||
if (!redirectTo || timerId) return;
|
||||
|
||||
const timerEl = document.getElementById('timer');
|
||||
const progressEl = document.getElementById('progressFill');
|
||||
const totalTime = 10;
|
||||
|
||||
timerEl.textContent = timerValue;
|
||||
|
||||
timerId = setInterval(() => {
|
||||
timerValue--;
|
||||
timerEl.textContent = timerValue;
|
||||
|
||||
// Update progress bar
|
||||
const progress = ((totalTime - timerValue) / totalTime) * 100;
|
||||
progressEl.style.width = `${100 - progress}%`;
|
||||
|
||||
if (timerValue <= 0) {
|
||||
clearInterval(timerId);
|
||||
window.location.href = redirectTo;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Show error state
|
||||
function showError() {
|
||||
document.getElementById('loadingState').classList.add('hidden');
|
||||
document.getElementById('errorState').classList.remove('hidden');
|
||||
document.getElementById('actionButton').classList.add('hidden');
|
||||
document.getElementById('mainCard').classList.add('error');
|
||||
document.getElementById('appIcon').textContent = '!';
|
||||
|
||||
// Update subtitle
|
||||
document.getElementById('appSubtitle').textContent = t('error.title');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user