Accounts/ Email search + unlink/ Symmetric identity merge/ Schema v19 keys PK/ Web-app UX/ Setup wizard/ Post-payment flow
This commit is contained in:
+38
-1
@@ -2,7 +2,8 @@ import asyncio
|
||||
import os
|
||||
from time import perf_counter
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
@@ -105,6 +106,42 @@ async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
from api.depends import get_session as _get_session, verify_identity_admin as _verify_admin
|
||||
|
||||
|
||||
@app.get("/api/health/detailed", include_in_schema=False)
|
||||
async def health_detailed(
|
||||
session: AsyncSession = Depends(_get_session),
|
||||
_identity=Depends(_verify_admin),
|
||||
):
|
||||
import time
|
||||
from sqlalchemy import text
|
||||
from core.redis_cache import _get_redis
|
||||
|
||||
checks: dict[str, object] = {"status": "ok", "timestamp": int(time.time())}
|
||||
|
||||
try:
|
||||
await session.execute(text("SELECT 1"))
|
||||
checks["db"] = {"ok": True}
|
||||
except Exception as e:
|
||||
checks["db"] = {"ok": False, "error": str(e)[:200]}
|
||||
checks["status"] = "degraded"
|
||||
|
||||
try:
|
||||
client = await _get_redis()
|
||||
if client is not None:
|
||||
await client.ping()
|
||||
checks["redis"] = {"ok": True}
|
||||
else:
|
||||
checks["redis"] = {"ok": False, "error": "unavailable"}
|
||||
checks["status"] = "degraded"
|
||||
except Exception as e:
|
||||
checks["redis"] = {"ok": False, "error": str(e)[:200]}
|
||||
checks["status"] = "degraded"
|
||||
|
||||
return checks
|
||||
|
||||
|
||||
app.include_router(api_router)
|
||||
|
||||
_web_uploads_dir = "static/web_uploads"
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import (
|
||||
bind_identity_actor,
|
||||
get_session,
|
||||
set_is_admin_cookie,
|
||||
verify_identity_token,
|
||||
)
|
||||
from api.v2.routes.auth._common import _client_ip
|
||||
@@ -46,7 +47,16 @@ async def link_email_send_code(
|
||||
)
|
||||
existing = await idb.get_identity_by_email(session, email_norm)
|
||||
if existing and existing.id != identity.id:
|
||||
raise HTTPException(status_code=400, detail="Не удалось привязать email")
|
||||
our_tg = identity.tg_id
|
||||
their_tg = existing.tg_id
|
||||
can_merge = their_tg is None or (
|
||||
our_tg is not None and int(their_tg) == int(our_tg)
|
||||
)
|
||||
if not can_merge:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Этот email уже привязан к другому аккаунту",
|
||||
)
|
||||
ip = _client_ip(request)
|
||||
if not await email_link_code.try_consume_ip_budget(ip):
|
||||
raise HTTPException(
|
||||
@@ -86,6 +96,7 @@ async def link_email_send_code(
|
||||
async def link_email_confirm(
|
||||
body: LinkEmailConfirmRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
@@ -111,4 +122,5 @@ async def link_email_confirm(
|
||||
detail="Этот email уже привязан к другой идентичности",
|
||||
)
|
||||
await bind_identity_actor(request, session, result)
|
||||
set_is_admin_cookie(response, result, request)
|
||||
return IdentityResponse.model_validate(result)
|
||||
|
||||
@@ -84,6 +84,7 @@ async def login_telegram_webapp(
|
||||
async def link_telegram(
|
||||
body: LinkTelegramRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
@@ -98,4 +99,5 @@ async def link_telegram(
|
||||
detail="Этот Telegram уже привязан к другой идентичности",
|
||||
)
|
||||
await bind_identity_actor(request, session, result)
|
||||
set_is_admin_cookie(response, result, request)
|
||||
return IdentityResponse.model_validate(result)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import hashlib
|
||||
import re
|
||||
import uuid
|
||||
|
||||
@@ -22,6 +23,7 @@ from api.v2.schemas.web import (
|
||||
from database.models import (
|
||||
WebBlock,
|
||||
WebCustomElementBuild,
|
||||
WebErrorReport,
|
||||
WebFlow,
|
||||
WebFlowEvent,
|
||||
WebPage,
|
||||
@@ -712,3 +714,159 @@ async def get_flow_funnel(
|
||||
) if prev_entered > 0 else 0
|
||||
|
||||
return {"flowId": flow_id, "days": days, "funnel": funnel}
|
||||
|
||||
|
||||
# ── Error aggregation (in-house Sentry) ──
|
||||
|
||||
|
||||
def _error_signature(name: str, message: str, stack: str | None, url: str | None) -> str:
|
||||
"""Группировочная подпись: name + первая stack-frame + pathname."""
|
||||
first_frame = ""
|
||||
if stack:
|
||||
for line in stack.split("\n"):
|
||||
s = line.strip()
|
||||
if s.startswith("at ") or "webpack-internal" in s or ".tsx:" in s or ".ts:" in s or ".js:" in s:
|
||||
first_frame = s[:200]
|
||||
break
|
||||
pathname = ""
|
||||
if url:
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
pathname = urlparse(url).path[:100]
|
||||
except Exception:
|
||||
pass
|
||||
key = f"{name}|{first_frame}|{pathname}|{message[:120]}"
|
||||
return hashlib.sha256(key.encode("utf-8")).hexdigest()[:32]
|
||||
|
||||
|
||||
class ErrorReportIngest(BaseModel):
|
||||
name: str = ""
|
||||
message: str
|
||||
stack: str | None = None
|
||||
url: str | None = None
|
||||
userAgent: str | None = None
|
||||
identityId: str | None = None
|
||||
tag: str | None = None
|
||||
context: dict | None = None
|
||||
|
||||
|
||||
@router.post("/error-reports")
|
||||
async def ingest_error_report(
|
||||
body: ErrorReportIngest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
from core.redis_cache import cache_incr_checked
|
||||
from api.v2.routes.auth._fallback_limiter import check_and_increment
|
||||
ip = (request.client.host if request.client else "") or "unknown"
|
||||
count, redis_ok = await cache_incr_checked(f"error_report_rate:{ip}", 60)
|
||||
if not redis_ok:
|
||||
count = check_and_increment(f"error_report_rate:{ip}", 30, 60)
|
||||
if count > 30:
|
||||
raise HTTPException(status_code=429, detail="Too many error reports")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
signature = _error_signature(body.name, body.message, body.stack, body.url)
|
||||
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(WebErrorReport).where(WebErrorReport.signature == signature)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
existing.count += 1
|
||||
existing.last_seen_at = datetime.now(timezone.utc)
|
||||
existing.resolved = False
|
||||
if body.context:
|
||||
existing.last_context = body.context
|
||||
if body.identityId:
|
||||
existing.last_identity_id = body.identityId
|
||||
return {"ok": True, "id": existing.id, "count": existing.count, "deduplicated": True}
|
||||
|
||||
report = WebErrorReport(
|
||||
id=str(uuid.uuid4()),
|
||||
signature=signature,
|
||||
error_name=body.name[:255] if body.name else "",
|
||||
error_message=body.message[:4000] if body.message else "",
|
||||
stack=body.stack[:16000] if body.stack else None,
|
||||
url=body.url[:500] if body.url else None,
|
||||
user_agent=body.userAgent[:500] if body.userAgent else None,
|
||||
tag=body.tag[:64] if body.tag else None,
|
||||
last_identity_id=body.identityId[:36] if body.identityId else None,
|
||||
last_context=body.context,
|
||||
count=1,
|
||||
resolved=False,
|
||||
)
|
||||
session.add(report)
|
||||
return {"ok": True, "id": report.id, "count": 1, "deduplicated": False}
|
||||
|
||||
|
||||
@router.get("/error-reports")
|
||||
async def list_error_reports(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
resolved: bool | None = Query(None),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
q = select(WebErrorReport).order_by(WebErrorReport.last_seen_at.desc())
|
||||
if resolved is not None:
|
||||
q = q.where(WebErrorReport.resolved == resolved)
|
||||
q = q.offset(offset).limit(limit)
|
||||
rows = (await session.execute(q)).scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"signature": r.signature,
|
||||
"errorName": r.error_name,
|
||||
"errorMessage": r.error_message,
|
||||
"stack": r.stack,
|
||||
"url": r.url,
|
||||
"userAgent": r.user_agent,
|
||||
"tag": r.tag,
|
||||
"lastIdentityId": r.last_identity_id,
|
||||
"lastContext": r.last_context,
|
||||
"count": r.count,
|
||||
"resolved": r.resolved,
|
||||
"firstSeenAt": r.first_seen_at.isoformat() if r.first_seen_at else None,
|
||||
"lastSeenAt": r.last_seen_at.isoformat() if r.last_seen_at else None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
class ErrorReportPatch(BaseModel):
|
||||
resolved: bool | None = None
|
||||
|
||||
|
||||
@router.patch("/error-reports/{report_id}")
|
||||
async def update_error_report(
|
||||
report_id: str,
|
||||
body: ErrorReportPatch,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
report = await session.get(WebErrorReport, report_id)
|
||||
if not report:
|
||||
raise HTTPException(404, "Not found")
|
||||
if body.resolved is not None:
|
||||
report.resolved = body.resolved
|
||||
return {"ok": True, "resolved": report.resolved}
|
||||
|
||||
|
||||
@router.delete("/error-reports/{report_id}")
|
||||
async def delete_error_report(
|
||||
report_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
report = await session.get(WebErrorReport, report_id)
|
||||
if not report:
|
||||
raise HTTPException(404, "Not found")
|
||||
await session.delete(report)
|
||||
return {"ok": True}
|
||||
|
||||
@@ -56,8 +56,8 @@ class LoginResponse(BaseModel):
|
||||
class SendLoginCodeRequest(BaseModel):
|
||||
email: str = Field(..., min_length=1)
|
||||
allow_register: bool = Field(
|
||||
default=False,
|
||||
description="Если true и email новый — создать идентичность и отправить код (гостевой вход с сайта)",
|
||||
default=True,
|
||||
description="Если true и email новый — создать идентичность и отправить код (passwordless flow)",
|
||||
)
|
||||
turnstile_token: str | None = Field(default=None, description="Cloudflare Turnstile CAPTCHA token")
|
||||
|
||||
|
||||
+183
-48
@@ -93,7 +93,7 @@ async def issue_token_for_identity(session: AsyncSession, identity: Identity) ->
|
||||
token = generate_token()
|
||||
identity.api_token_hash = await run_io(hash_token, token)
|
||||
identity.token_issued_at = datetime.utcnow()
|
||||
await session.refresh(identity)
|
||||
await session.flush()
|
||||
return token
|
||||
|
||||
|
||||
@@ -210,59 +210,31 @@ async def ensure_billing_user_for_identity(session: AsyncSession, identity: Iden
|
||||
return int(new_u.id)
|
||||
|
||||
|
||||
async def merge_billing_user_into_telegram(session: AsyncSession, identity_id: str, telegram_tg_id: int) -> None:
|
||||
async def _transfer_user_data(
|
||||
session: AsyncSession,
|
||||
src_uid: int,
|
||||
dst_uid: int,
|
||||
dst_tg: int | None,
|
||||
dst_identity_id: str,
|
||||
) -> None:
|
||||
"""Переносит все данные с src User на dst User. Dedup там, где есть уникальные ключи."""
|
||||
from database.models import (
|
||||
AuditEvent,
|
||||
BlockedUser,
|
||||
CouponUsage,
|
||||
Gift,
|
||||
GiftUsage,
|
||||
Key,
|
||||
ManualBan,
|
||||
Notification,
|
||||
Payment,
|
||||
Referral,
|
||||
ScheduledBroadcast,
|
||||
TemporaryData,
|
||||
WebNotification,
|
||||
WebPushSubscription,
|
||||
)
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from database.users import invalidate_balance_cache, invalidate_profile_cache, update_balance
|
||||
|
||||
res = await session.execute(select(User).where(User.identity_id == identity_id))
|
||||
rows = res.scalars().all()
|
||||
if not rows:
|
||||
return
|
||||
billing = rows[0]
|
||||
src_uid = int(billing.id)
|
||||
dst_tg = int(telegram_tg_id)
|
||||
if billing.tg_id is not None and int(billing.tg_id) > 0:
|
||||
return
|
||||
|
||||
dst_u = await resolve_user_optional(session, dst_tg)
|
||||
if dst_u is None:
|
||||
new_u = User(
|
||||
tg_id=dst_tg,
|
||||
identity_id=identity_id,
|
||||
username=billing.username,
|
||||
first_name=billing.first_name,
|
||||
last_name=billing.last_name,
|
||||
language_code=billing.language_code,
|
||||
is_bot=billing.is_bot or False,
|
||||
balance=float(billing.balance or 0.0),
|
||||
trial=int(billing.trial or 0),
|
||||
preferred_currency=billing.preferred_currency or "RUB",
|
||||
source_code=billing.source_code,
|
||||
)
|
||||
session.add(new_u)
|
||||
await session.flush()
|
||||
dst_uid = int(new_u.id)
|
||||
else:
|
||||
dst_uid = int(dst_u.id)
|
||||
bal = float(billing.balance or 0.0)
|
||||
if bal:
|
||||
await update_balance(session, dst_uid, bal)
|
||||
st = int(billing.trial or 0)
|
||||
dt_r = await session.execute(select(User.trial).where(User.id == dst_uid))
|
||||
dt_val = dt_r.scalar_one_or_none()
|
||||
if dt_val is not None and st > int(dt_val or 0):
|
||||
await session.execute(update(User).where(User.id == dst_uid).values(trial=st))
|
||||
from database.users import invalidate_balance_cache, invalidate_profile_cache
|
||||
|
||||
await session.execute(update(Key).where(Key.user_id == src_uid).values(user_id=dst_uid))
|
||||
await session.execute(update(Payment).where(Payment.user_id == src_uid).values(user_id=dst_uid))
|
||||
@@ -330,10 +302,41 @@ async def merge_billing_user_into_telegram(session: AsyncSession, identity_id: s
|
||||
update(Referral).where(Referral.referrer_user_id == src_uid).values(referrer_user_id=dst_uid)
|
||||
)
|
||||
|
||||
await session.execute(
|
||||
update(WebPushSubscription).where(WebPushSubscription.user_id == src_uid).values(user_id=dst_uid)
|
||||
)
|
||||
await session.execute(
|
||||
update(WebNotification).where(WebNotification.user_id == src_uid).values(user_id=dst_uid)
|
||||
)
|
||||
|
||||
dst_ban = (await session.execute(select(ManualBan).where(ManualBan.user_id == dst_uid))).scalar_one_or_none()
|
||||
src_ban = (await session.execute(select(ManualBan).where(ManualBan.user_id == src_uid))).scalar_one_or_none()
|
||||
if src_ban is not None and dst_ban is None:
|
||||
session.add(ManualBan(
|
||||
user_id=dst_uid,
|
||||
tg_id=dst_tg,
|
||||
banned_at=src_ban.banned_at,
|
||||
reason=src_ban.reason,
|
||||
banned_by=src_ban.banned_by,
|
||||
until=src_ban.until,
|
||||
))
|
||||
|
||||
dst_block = (await session.execute(select(BlockedUser).where(BlockedUser.user_id == dst_uid))).scalar_one_or_none()
|
||||
src_block = (await session.execute(select(BlockedUser).where(BlockedUser.user_id == src_uid))).scalar_one_or_none()
|
||||
if src_block is not None and dst_block is None:
|
||||
session.add(BlockedUser(user_id=dst_uid, tg_id=dst_tg))
|
||||
|
||||
if dst_tg is not None:
|
||||
await session.execute(
|
||||
update(AuditEvent)
|
||||
.where(AuditEvent.actor_identity_id == dst_identity_id, AuditEvent.actor_tg_id.is_(None))
|
||||
.values(actor_tg_id=dst_tg)
|
||||
)
|
||||
|
||||
await refresh_tg_mirrors_for_user(session, dst_uid)
|
||||
|
||||
await session.execute(delete(User).where(User.id == src_uid))
|
||||
await session.execute(update(User).where(User.id == dst_uid).values(identity_id=identity_id))
|
||||
await session.execute(update(User).where(User.id == dst_uid).values(identity_id=dst_identity_id))
|
||||
|
||||
await invalidate_balance_cache(src_uid)
|
||||
await invalidate_profile_cache(src_uid)
|
||||
@@ -341,6 +344,53 @@ async def merge_billing_user_into_telegram(session: AsyncSession, identity_id: s
|
||||
await invalidate_profile_cache(dst_uid)
|
||||
|
||||
|
||||
async def merge_billing_user_into_telegram(session: AsyncSession, identity_id: str, telegram_tg_id: int) -> None:
|
||||
from database.models import User as _User # noqa: F401
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from database.users import update_balance
|
||||
|
||||
res = await session.execute(select(User).where(User.identity_id == identity_id))
|
||||
rows = res.scalars().all()
|
||||
if not rows:
|
||||
return
|
||||
billing = rows[0]
|
||||
src_uid = int(billing.id)
|
||||
dst_tg = int(telegram_tg_id)
|
||||
if billing.tg_id is not None and int(billing.tg_id) > 0:
|
||||
return
|
||||
|
||||
dst_u = await resolve_user_optional(session, dst_tg)
|
||||
if dst_u is None:
|
||||
new_u = User(
|
||||
tg_id=dst_tg,
|
||||
identity_id=identity_id,
|
||||
username=billing.username,
|
||||
first_name=billing.first_name,
|
||||
last_name=billing.last_name,
|
||||
language_code=billing.language_code,
|
||||
is_bot=billing.is_bot or False,
|
||||
balance=float(billing.balance or 0.0),
|
||||
trial=int(billing.trial or 0),
|
||||
preferred_currency=billing.preferred_currency or "RUB",
|
||||
source_code=billing.source_code,
|
||||
)
|
||||
session.add(new_u)
|
||||
await session.flush()
|
||||
dst_uid = int(new_u.id)
|
||||
else:
|
||||
dst_uid = int(dst_u.id)
|
||||
bal = float(billing.balance or 0.0)
|
||||
if bal:
|
||||
await update_balance(session, dst_uid, bal)
|
||||
st = int(billing.trial or 0)
|
||||
dt_r = await session.execute(select(User.trial).where(User.id == dst_uid))
|
||||
dt_val = dt_r.scalar_one_or_none()
|
||||
if dt_val is not None and st > int(dt_val or 0):
|
||||
await session.execute(update(User).where(User.id == dst_uid).values(trial=st))
|
||||
|
||||
await _transfer_user_data(session, src_uid, dst_uid, dst_tg, identity_id)
|
||||
|
||||
|
||||
async def resolve_tg_id(session: AsyncSession, identity_id: str) -> int | None:
|
||||
"""По identity_id возвращает внутренний user id (users.id) для биллинга и ключей."""
|
||||
identity = await get_identity_by_id(session, identity_id)
|
||||
@@ -350,7 +400,12 @@ async def resolve_tg_id(session: AsyncSession, identity_id: str) -> int | None:
|
||||
|
||||
|
||||
async def attach_email(session: AsyncSession, identity_id: str, email: str) -> Identity | None:
|
||||
"""Привязывает email к идентичности."""
|
||||
"""Привязывает email к идентичности.
|
||||
|
||||
Если email уже занят другой identity — пытаемся смёржить.
|
||||
Условия мёрджа: у занимающей identity нет tg_id ИЛИ tg_id совпадает с нашим.
|
||||
Иначе — возврат None (email принадлежит другому человеку, не отдаём).
|
||||
"""
|
||||
identity = await get_identity_by_id(session, identity_id)
|
||||
if not identity:
|
||||
return None
|
||||
@@ -359,20 +414,62 @@ async def attach_email(session: AsyncSession, identity_id: str, email: str) -> I
|
||||
return identity
|
||||
existing = await get_identity_by_email(session, email_clean)
|
||||
if existing and existing.id != identity_id:
|
||||
return None
|
||||
our_tg = identity.tg_id
|
||||
their_tg = existing.tg_id
|
||||
can_merge = their_tg is None or (our_tg is not None and int(their_tg) == int(our_tg))
|
||||
if not can_merge:
|
||||
return None
|
||||
|
||||
src_user = (
|
||||
await session.execute(select(User).where(User.identity_id == existing.id))
|
||||
).scalars().first()
|
||||
dst_uid = await ensure_billing_user_for_identity(session, identity)
|
||||
dst_tg = int(identity.tg_id) if identity.tg_id is not None else None
|
||||
|
||||
if src_user is not None and int(src_user.id) != int(dst_uid):
|
||||
from database.users import update_balance
|
||||
|
||||
src_uid = int(src_user.id)
|
||||
bal = float(src_user.balance or 0.0)
|
||||
if bal:
|
||||
await update_balance(session, dst_uid, bal)
|
||||
src_trial = int(src_user.trial or 0)
|
||||
dst_trial_val = await session.scalar(select(User.trial).where(User.id == dst_uid))
|
||||
if dst_trial_val is not None and src_trial > int(dst_trial_val or 0):
|
||||
await session.execute(update(User).where(User.id == dst_uid).values(trial=src_trial))
|
||||
await _transfer_user_data(session, src_uid, dst_uid, dst_tg, identity_id)
|
||||
|
||||
existing.email = None
|
||||
await session.flush()
|
||||
await session.execute(delete(Identity).where(Identity.id == existing.id))
|
||||
|
||||
identity.email = email_clean
|
||||
await session.refresh(identity)
|
||||
return identity
|
||||
|
||||
|
||||
async def attach_telegram(session: AsyncSession, identity_id: str, tg_id: int) -> Identity | None:
|
||||
"""Привязывает Telegram (tg_id) к идентичности и связывает User с identity. Если tg_id в admins — выставляет is_admin."""
|
||||
"""Привязывает Telegram (tg_id) к идентичности и связывает User с identity.
|
||||
|
||||
Если tg_id уже висит на другой identity — пытаемся смёржить.
|
||||
Условия мёрджа: у занимающей identity нет email ИЛИ email совпадает с нашим.
|
||||
Иначе — возврат None (TG принадлежит другому человеку).
|
||||
"""
|
||||
identity = await get_identity_by_id(session, identity_id)
|
||||
if not identity:
|
||||
return None
|
||||
existing = await get_identity_by_tg_id(session, tg_id)
|
||||
if existing and existing.id != identity_id:
|
||||
return None
|
||||
our_email = (str(identity.email).strip().lower() if identity.email else None)
|
||||
their_email = (str(existing.email).strip().lower() if existing.email else None)
|
||||
can_merge = their_email is None or (our_email is not None and their_email == our_email)
|
||||
if not can_merge:
|
||||
return None
|
||||
existing.email = None
|
||||
existing.tg_id = None
|
||||
await session.flush()
|
||||
await session.execute(delete(Identity).where(Identity.id == existing.id))
|
||||
await session.flush()
|
||||
await merge_billing_user_into_telegram(session, identity_id, tg_id)
|
||||
identity = await get_identity_by_id(session, identity_id)
|
||||
if not identity:
|
||||
@@ -386,6 +483,44 @@ async def attach_telegram(session: AsyncSession, identity_id: str, tg_id: int) -
|
||||
return identity
|
||||
|
||||
|
||||
async def detach_email(session: AsyncSession, identity_id: str) -> Identity | None:
|
||||
"""Отвязывает email от identity. Возвращает None если у identity не осталось
|
||||
ни одного канала (email + tg_id оба пустые) — в этом случае отвязка запрещена,
|
||||
иначе identity станет orphan.
|
||||
"""
|
||||
identity = await get_identity_by_id(session, identity_id)
|
||||
if not identity:
|
||||
return None
|
||||
if identity.email is None:
|
||||
return identity
|
||||
if identity.tg_id is None:
|
||||
return None
|
||||
identity.email = None
|
||||
identity.email_verified = False
|
||||
identity.password_hash = None
|
||||
await session.refresh(identity)
|
||||
return identity
|
||||
|
||||
|
||||
async def detach_telegram(session: AsyncSession, identity_id: str) -> Identity | None:
|
||||
"""Отвязывает Telegram от identity. Запрещено если это единственный канал."""
|
||||
identity = await get_identity_by_id(session, identity_id)
|
||||
if not identity:
|
||||
return None
|
||||
if identity.tg_id is None:
|
||||
return identity
|
||||
if identity.email is None:
|
||||
return None
|
||||
old_tg = int(identity.tg_id)
|
||||
identity.tg_id = None
|
||||
identity.is_admin = False
|
||||
await session.execute(
|
||||
update(User).where(User.identity_id == identity_id, User.tg_id == old_tg).values(tg_id=None)
|
||||
)
|
||||
await session.refresh(identity)
|
||||
return identity
|
||||
|
||||
|
||||
async def get_or_create_identity_for_tg(session: AsyncSession, tg_id: int) -> Identity:
|
||||
"""Для tg_id возвращает существующую идентичность или создаёт новую и привязывает User."""
|
||||
identity = await get_identity_by_tg_id(session, tg_id)
|
||||
|
||||
@@ -1060,6 +1060,39 @@ async def _migration_v15_recover_orphan_users(conn: AsyncConnection) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def _migration_v18_web_error_reports(conn: AsyncConnection) -> None:
|
||||
logger.info("[schema_upgrade] v18: таблица web_error_reports")
|
||||
await _exec_ignore(
|
||||
conn,
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS web_error_reports (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
signature VARCHAR(128) NOT NULL UNIQUE,
|
||||
error_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
error_message TEXT NOT NULL DEFAULT '',
|
||||
stack TEXT,
|
||||
url TEXT,
|
||||
user_agent TEXT,
|
||||
tag VARCHAR(64),
|
||||
last_identity_id VARCHAR(36),
|
||||
last_context JSONB,
|
||||
count INTEGER NOT NULL DEFAULT 1,
|
||||
resolved BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
)
|
||||
await _exec_ignore(
|
||||
conn,
|
||||
"CREATE INDEX IF NOT EXISTS ix_web_error_reports_signature ON web_error_reports (signature)",
|
||||
)
|
||||
await _exec_ignore(
|
||||
conn,
|
||||
"CREATE INDEX IF NOT EXISTS ix_web_error_reports_resolved_last ON web_error_reports (resolved, last_seen_at)",
|
||||
)
|
||||
|
||||
|
||||
async def _migration_v16b_web_flow_events(conn: AsyncConnection) -> None:
|
||||
logger.info("[schema_upgrade] v17: таблица web_flow_events")
|
||||
|
||||
@@ -1132,6 +1165,42 @@ async def _migration_v16_custom_element_builds(conn: AsyncConnection) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def _migration_v19_keys_tg_id_nullable(conn: AsyncConnection) -> None:
|
||||
"""Снимает NOT NULL с keys.tg_id и переносит PK на (user_id, client_id).
|
||||
|
||||
Старый PK (tg_id, client_id) не позволяет создавать подписки для web-only
|
||||
пользователей, у которых tg_id=NULL. user_id у ключа есть всегда (FK на
|
||||
users.id), поэтому делаем его новым компонентом PK.
|
||||
"""
|
||||
logger.info("[schema_upgrade] v19: keys.tg_id nullable, PK на (user_id, client_id)")
|
||||
|
||||
if not await _table_exists(conn, "keys"):
|
||||
return
|
||||
|
||||
if not await _column_exists(conn, "keys", "user_id"):
|
||||
logger.warning("[schema_upgrade] v19: keys.user_id не найден, пропуск")
|
||||
return
|
||||
|
||||
await _exec_ignore(
|
||||
conn,
|
||||
"""
|
||||
UPDATE keys k SET user_id = u.id
|
||||
FROM users u
|
||||
WHERE k.user_id IS NULL AND k.tg_id IS NOT NULL AND u.tg_id = k.tg_id
|
||||
""",
|
||||
)
|
||||
|
||||
if await _column_has_nulls(conn, "keys", "user_id"):
|
||||
logger.warning("[schema_upgrade] v19: в keys остались строки с user_id=NULL, пропуск смены PK")
|
||||
return
|
||||
|
||||
await _drop_pk(conn, "keys")
|
||||
await _exec_ignore(conn, 'ALTER TABLE "keys" ALTER COLUMN "user_id" SET NOT NULL')
|
||||
await _exec_ignore(conn, 'ALTER TABLE "keys" ALTER COLUMN "tg_id" DROP NOT NULL')
|
||||
await _exec_ignore(conn, 'ALTER TABLE "keys" ADD PRIMARY KEY (user_id, client_id)')
|
||||
await _exec_ignore(conn, 'CREATE INDEX IF NOT EXISTS ix_keys_tg_id ON "keys" (tg_id)')
|
||||
|
||||
|
||||
_MIGRATIONS = [
|
||||
(1, "Добавление users.id", _migration_v1_add_users_id),
|
||||
(2, "Добавление user_id колонок", _migration_v2_add_user_id_columns),
|
||||
@@ -1150,6 +1219,8 @@ _MIGRATIONS = [
|
||||
(15, "Восстановление orphan tg_ids в users", _migration_v15_recover_orphan_users),
|
||||
(16, "Таблица custom element builds", _migration_v16_custom_element_builds),
|
||||
(17, "Таблица flow analytics events", _migration_v16b_web_flow_events),
|
||||
(18, "Таблица web_error_reports", _migration_v18_web_error_reports),
|
||||
(19, "keys.tg_id nullable, PK на (user_id, client_id)", _migration_v19_keys_tg_id_nullable),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from .users import BlockedUser, ManualBan, TemporaryData, TrackingSource, User
|
||||
from .web import (
|
||||
WebBlock,
|
||||
WebCustomElementBuild,
|
||||
WebErrorReport,
|
||||
WebFlow,
|
||||
WebFlowEvent,
|
||||
WebNotification,
|
||||
|
||||
@@ -6,9 +6,9 @@ from ._base import Base, DictLikeMixin
|
||||
class Key(DictLikeMixin, Base):
|
||||
__tablename__ = "keys"
|
||||
|
||||
tg_id = Column(BigInteger, ForeignKey("users.tg_id"), primary_key=True, nullable=False, index=True)
|
||||
user_id = Column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
user_id = Column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True, nullable=False, index=True)
|
||||
client_id = Column(String, primary_key=True)
|
||||
tg_id = Column(BigInteger, ForeignKey("users.tg_id"), nullable=True, index=True)
|
||||
email = Column(String, unique=True)
|
||||
created_at = Column(BigInteger)
|
||||
expiry_time = Column(BigInteger)
|
||||
|
||||
@@ -104,6 +104,29 @@ class WebNotification(DictLikeMixin, Base):
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class WebErrorReport(DictLikeMixin, Base):
|
||||
__tablename__ = "web_error_reports"
|
||||
__table_args__ = (
|
||||
Index("ix_web_error_reports_signature", "signature"),
|
||||
Index("ix_web_error_reports_resolved_last", "resolved", "last_seen_at"),
|
||||
)
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
signature = Column(String(128), nullable=False, unique=True)
|
||||
error_name = Column(String(255), nullable=False, default="")
|
||||
error_message = Column(Text, nullable=False, default="")
|
||||
stack = Column(Text, nullable=True)
|
||||
url = Column(Text, nullable=True)
|
||||
user_agent = Column(Text, nullable=True)
|
||||
tag = Column(String(64), nullable=True)
|
||||
last_identity_id = Column(String(36), nullable=True)
|
||||
last_context = Column(JSONB, nullable=True)
|
||||
count = Column(Integer, nullable=False, default=1)
|
||||
resolved = Column(Boolean, nullable=False, default=False)
|
||||
first_seen_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
last_seen_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class WebFlowEvent(DictLikeMixin, Base):
|
||||
__tablename__ = "web_flow_events"
|
||||
__table_args__ = (
|
||||
|
||||
@@ -16,12 +16,16 @@ from database.models import (
|
||||
CouponUsage,
|
||||
Gift,
|
||||
GiftUsage,
|
||||
Identity,
|
||||
Key,
|
||||
Notification,
|
||||
Payment,
|
||||
Referral,
|
||||
ScheduledBroadcast,
|
||||
TemporaryData,
|
||||
User,
|
||||
WebNotification,
|
||||
WebPushSubscription,
|
||||
)
|
||||
from logger import logger
|
||||
|
||||
@@ -315,10 +319,13 @@ async def delete_user_data(session: AsyncSession, legacy_user_ref: int):
|
||||
return
|
||||
uid = u.id
|
||||
|
||||
identity_id = u.identity_id
|
||||
|
||||
await session.execute(delete(Notification).where(Notification.user_id == uid))
|
||||
await session.execute(
|
||||
delete(GiftUsage).where(GiftUsage.gift_id.in_(select(Gift.gift_id).where(Gift.sender_user_id == uid)))
|
||||
)
|
||||
await session.execute(delete(GiftUsage).where(GiftUsage.user_id == uid))
|
||||
await session.execute(delete(Gift).where(Gift.sender_user_id == uid))
|
||||
await session.execute(update(Gift).where(Gift.recipient_user_id == uid).values(recipient_user_id=None))
|
||||
await session.execute(delete(Payment).where(Payment.user_id == uid))
|
||||
@@ -343,7 +350,24 @@ async def delete_user_data(session: AsyncSession, legacy_user_ref: int):
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
await session.execute(delete(WebPushSubscription).where(WebPushSubscription.user_id == uid))
|
||||
await session.execute(delete(WebNotification).where(WebNotification.user_id == uid))
|
||||
await session.execute(
|
||||
update(ScheduledBroadcast)
|
||||
.where(ScheduledBroadcast.created_by_user_id == uid)
|
||||
.values(created_by_user_id=None)
|
||||
)
|
||||
|
||||
await session.execute(delete(User).where(User.id == uid))
|
||||
|
||||
if identity_id:
|
||||
still_linked = await session.scalar(
|
||||
select(func.count()).select_from(User).where(User.identity_id == identity_id)
|
||||
)
|
||||
if not still_linked:
|
||||
await session.execute(delete(Identity).where(Identity.id == identity_id))
|
||||
|
||||
logger.info(f"[DB] Данные пользователя id={uid} полностью удалены")
|
||||
|
||||
|
||||
|
||||
@@ -37,6 +37,8 @@ async def build_user_edit_kb(
|
||||
key_records: list,
|
||||
is_banned: bool = False,
|
||||
admin_role: str | None = None,
|
||||
has_email: bool = False,
|
||||
has_tg: bool = False,
|
||||
) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
current_time = datetime.now(tz=timezone.utc)
|
||||
@@ -96,6 +98,23 @@ async def build_user_edit_kb(
|
||||
)
|
||||
)
|
||||
|
||||
unlink_buttons = []
|
||||
if has_email and has_tg:
|
||||
unlink_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="📧 Отвязать email",
|
||||
callback_data=AdminUserEditorCallback(action="users_unlink_email", tg_id=tg_id).pack(),
|
||||
)
|
||||
)
|
||||
unlink_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="✈️ Отвязать TG",
|
||||
callback_data=AdminUserEditorCallback(action="users_unlink_tg", tg_id=tg_id).pack(),
|
||||
)
|
||||
)
|
||||
if unlink_buttons:
|
||||
builder.row(*unlink_buttons)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="❌ Удалить",
|
||||
|
||||
@@ -19,7 +19,7 @@ from database import (
|
||||
get_key_details,
|
||||
update_trial,
|
||||
)
|
||||
from database.models import Admin, Key, ManualBan, Payment, Referral, User
|
||||
from database.models import Admin, Identity, Key, ManualBan, Payment, Referral, User
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from filters.admin import IsAdminFilter
|
||||
from handlers.utils import sanitize_key_name
|
||||
@@ -50,9 +50,10 @@ router = Router()
|
||||
async def handle_search_user(callback_query: CallbackQuery, state: FSMContext):
|
||||
text = (
|
||||
"<b>🔍 Поиск пользователя</b>"
|
||||
"\n\n📌 Введите ID, Username или перешлите сообщение пользователя."
|
||||
"\n\n📌 Введите ID, Username, Email или перешлите сообщение пользователя."
|
||||
"\n\n🆔 ID - числовой айди"
|
||||
"\n📝 Username - юзернейм пользователя"
|
||||
"\n📧 Email - почта веб-кабинета"
|
||||
"\n\n<i>✉️ Для поиска, вы можете просто переслать сообщение от пользователя.</i>"
|
||||
)
|
||||
|
||||
@@ -106,10 +107,38 @@ async def handle_user_data_input(message: Message, state: FSMContext, session: A
|
||||
await message.answer(text="🚫 Пожалуйста, отправьте текстовое сообщение.", reply_markup=kb)
|
||||
return
|
||||
|
||||
if message.text.isdigit():
|
||||
tg_id = int(message.text)
|
||||
raw = message.text.strip()
|
||||
|
||||
if raw.isdigit():
|
||||
tg_id = int(raw)
|
||||
elif "@" in raw and "." in raw.split("@", 1)[-1]:
|
||||
email = raw.lower()
|
||||
ident = (await session.execute(
|
||||
select(Identity).where(func.lower(Identity.email) == email).limit(1)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if ident is None:
|
||||
await message.answer(
|
||||
text="🚫 Пользователь с указанным Email не найден!",
|
||||
reply_markup=kb,
|
||||
)
|
||||
return
|
||||
|
||||
if ident.tg_id is not None:
|
||||
tg_id = ident.tg_id
|
||||
else:
|
||||
user_id = (await session.execute(
|
||||
select(User.id).where(User.identity_id == ident.id).limit(1)
|
||||
)).scalar_one_or_none()
|
||||
if user_id is None:
|
||||
await message.answer(
|
||||
text=f"🚫 Веб-аккаунт <code>{ident.email}</code> не имеет биллинг-профиля.",
|
||||
reply_markup=kb,
|
||||
)
|
||||
return
|
||||
tg_id = user_id
|
||||
else:
|
||||
username = message.text.strip().lstrip("@").replace("https://t.me/", "")
|
||||
username = raw.lstrip("@").replace("https://t.me/", "")
|
||||
|
||||
stmt = (
|
||||
select(User.tg_id)
|
||||
@@ -364,6 +393,12 @@ async def process_user_search(
|
||||
)
|
||||
return
|
||||
uid = u.id
|
||||
real_tg_id = u.tg_id
|
||||
identity_email = None
|
||||
if u.identity_id:
|
||||
identity_email = await session.scalar(
|
||||
select(Identity.email).where(Identity.id == u.identity_id)
|
||||
)
|
||||
|
||||
stmt_user = select(User.username, User.balance, User.created_at, User.updated_at, User.trial).where(User.id == uid)
|
||||
result_user = await session.execute(stmt_user)
|
||||
@@ -440,7 +475,8 @@ async def process_user_search(
|
||||
ban_reason = ban_record.reason
|
||||
|
||||
body = Text(
|
||||
f"🆔 ID: {tg_id}\n",
|
||||
f"🆔 TG ID: {real_tg_id if real_tg_id is not None else '—'}\n",
|
||||
f"📧 Email: {identity_email if identity_email else '—'}\n",
|
||||
f"📄 Логин: @{username}\n" if username else "📄 Логин: —\n",
|
||||
f"📅 Дата регистрации: {created_at_str}\n",
|
||||
f"🏃 Дата активности: {updated_at_str}\n",
|
||||
@@ -467,7 +503,16 @@ async def process_user_search(
|
||||
if effective_actor_tg_id is not None:
|
||||
admin_role = await session.scalar(select(Admin.role).where(Admin.tg_id == effective_actor_tg_id))
|
||||
|
||||
kb = await build_user_edit_kb(tg_id, key_records, is_banned=is_banned, admin_role=admin_role)
|
||||
has_email = identity_email is not None and str(identity_email).strip() != ""
|
||||
has_tg = real_tg_id is not None
|
||||
kb = await build_user_edit_kb(
|
||||
tg_id,
|
||||
key_records,
|
||||
is_banned=is_banned,
|
||||
admin_role=admin_role,
|
||||
has_email=has_email,
|
||||
has_tg=has_tg,
|
||||
)
|
||||
|
||||
if edit:
|
||||
try:
|
||||
@@ -496,3 +541,84 @@ async def handle_users_editor(
|
||||
edit=callback_data.edit,
|
||||
actor_tg_id=callback.from_user.id,
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_identity_for_user(session: AsyncSession, legacy_ref: int) -> Identity | None:
|
||||
u = await resolve_user_optional(session, legacy_ref)
|
||||
if u is None or not u.identity_id:
|
||||
return None
|
||||
return await session.scalar(select(Identity).where(Identity.id == u.identity_id))
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminUserEditorCallback.filter(F.action == "users_unlink_email"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_unlink_email(
|
||||
callback: CallbackQuery,
|
||||
callback_data: AdminUserEditorCallback,
|
||||
session: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
from database.identities import detach_email
|
||||
|
||||
identity = await _resolve_identity_for_user(session, callback_data.tg_id)
|
||||
if identity is None:
|
||||
await callback.answer("Нет привязанной identity", show_alert=True)
|
||||
return
|
||||
if identity.email is None:
|
||||
await callback.answer("Email уже не привязан", show_alert=True)
|
||||
return
|
||||
if identity.tg_id is None:
|
||||
await callback.answer("Нельзя отвязать email — это единственный способ входа", show_alert=True)
|
||||
return
|
||||
result = await detach_email(session, identity.id)
|
||||
if result is None:
|
||||
await callback.answer("Не удалось отвязать email", show_alert=True)
|
||||
return
|
||||
await callback.answer("Email отвязан", show_alert=False)
|
||||
await process_user_search(
|
||||
callback.message,
|
||||
state=state,
|
||||
session=session,
|
||||
tg_id=callback_data.tg_id,
|
||||
edit=True,
|
||||
actor_tg_id=callback.from_user.id,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminUserEditorCallback.filter(F.action == "users_unlink_tg"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_unlink_tg(
|
||||
callback: CallbackQuery,
|
||||
callback_data: AdminUserEditorCallback,
|
||||
session: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
from database.identities import detach_telegram
|
||||
|
||||
identity = await _resolve_identity_for_user(session, callback_data.tg_id)
|
||||
if identity is None:
|
||||
await callback.answer("Нет привязанной identity", show_alert=True)
|
||||
return
|
||||
if identity.tg_id is None:
|
||||
await callback.answer("Telegram уже не привязан", show_alert=True)
|
||||
return
|
||||
if identity.email is None:
|
||||
await callback.answer("Нельзя отвязать TG — нет email для входа", show_alert=True)
|
||||
return
|
||||
result = await detach_telegram(session, identity.id)
|
||||
if result is None:
|
||||
await callback.answer("Не удалось отвязать Telegram", show_alert=True)
|
||||
return
|
||||
await callback.answer("Telegram отвязан", show_alert=False)
|
||||
await process_user_search(
|
||||
callback.message,
|
||||
state=state,
|
||||
session=session,
|
||||
tg_id=callback_data.tg_id,
|
||||
edit=True,
|
||||
actor_tg_id=callback.from_user.id,
|
||||
)
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,32 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
BOT_WORKERS="${BOT_WORKERS:-1}"
|
||||
BASE="${LB_PORT:-3001}"
|
||||
PYTHON="${PYTHON:-/app/venv/bin/python}"
|
||||
|
||||
if [ "$BOT_WORKERS" -le 1 ]; then
|
||||
exec "$PYTHON" main.py
|
||||
fi
|
||||
|
||||
PORTS=""
|
||||
i=0
|
||||
while [ $i -lt "$BOT_WORKERS" ]; do
|
||||
p=$((BASE + i + 1))
|
||||
[ -n "$PORTS" ] && PORTS="$PORTS,"
|
||||
PORTS="${PORTS}${p}"
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
export WORKER_PORTS="$PORTS"
|
||||
export LB_PORT="$BASE"
|
||||
export LB_HOST="${LB_HOST:-0.0.0.0}"
|
||||
|
||||
i=0
|
||||
while [ $i -lt "$BOT_WORKERS" ]; do
|
||||
export WEBAPP_PORT=$((BASE + i + 1))
|
||||
"$PYTHON" main.py &
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
exec "$PYTHON" scripts/load_balancer.py
|
||||
@@ -452,7 +452,12 @@ async def create_vpn_key_headless(
|
||||
await update_trial(session, tg_id, 1)
|
||||
|
||||
if price_to_charge and not skip_balance_charge:
|
||||
logger.info(f"[Key Creation] Списание с баланса user={tg_id}: -{price_to_charge} ₽")
|
||||
await update_balance(session, tg_id, -int(price_to_charge))
|
||||
elif skip_balance_charge:
|
||||
logger.info(f"[Key Creation] Пропуск списания (skip_balance_charge) user={tg_id}")
|
||||
else:
|
||||
logger.info(f"[Key Creation] Списание не требуется (price=0) user={tg_id}")
|
||||
|
||||
return CreatedVpnKey(
|
||||
client_id=client_id,
|
||||
|
||||
Reference in New Issue
Block a user