middleware session rollback / WATA pending payment

This commit is contained in:
Capybara-z
2026-05-06 20:47:53 +00:00
parent aa086a57d2
commit 06eabd8f26
13 changed files with 257 additions and 18 deletions
+27 -1
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@@ -84,3 +84,29 @@ async def read_all_notifications(
):
count = await wn_db.mark_all_read_for_identity(session, identity.id)
return {"ok": True, "updated": count}
@router.post("/notifications/{notification_id}/read", tags=["Notifications"])
async def read_one_notification(
notification_id: str = Path(..., min_length=1, max_length=64),
session: AsyncSession = Depends(get_session),
identity: Identity = Depends(verify_identity_token),
):
"""Пометить одно уведомление прочитанным. 404 если не найдено или не принадлежит юзеру."""
ok = await wn_db.mark_one_read_for_identity(session, identity.id, notification_id)
if not ok:
raise HTTPException(status_code=404, detail="Уведомление не найдено")
return {"ok": True}
@router.delete("/notifications/{notification_id}", tags=["Notifications"])
async def delete_one_notification(
notification_id: str = Path(..., min_length=1, max_length=64),
session: AsyncSession = Depends(get_session),
identity: Identity = Depends(verify_identity_token),
):
"""Удалить одно уведомление. 404 если не найдено или не принадлежит юзеру."""
ok = await wn_db.delete_one_for_identity(session, identity.id, notification_id)
if not ok:
raise HTTPException(status_code=404, detail="Уведомление не найдено")
return {"ok": True}
+19 -1
View File
@@ -11,6 +11,7 @@ from typing import Any
from aiogram.types import CallbackQuery, InlineQuery, Message, TelegramObject, User
from fastapi import Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database.audit import (
@@ -1092,6 +1093,20 @@ async def drain_audit_redis_to_db(session_factory: Any) -> int:
session,
[str(rec.get("request_id")) for rec in batch if rec.get("request_id")],
)
identity_ids_in_batch = {
str(rec.get("actor_identity_id"))
for rec in batch
if rec.get("actor_identity_id")
}
if identity_ids_in_batch:
from database.models import Identity
rows = await session.execute(
select(Identity.id).where(Identity.id.in_(identity_ids_in_batch))
)
existing_identity_ids = {row[0] for row in rows.all()}
else:
existing_identity_ids = set()
inserted_count = 0
seen_batch_request_ids: set[str] = set()
for rec in batch:
@@ -1100,6 +1115,9 @@ async def drain_audit_redis_to_db(session_factory: Any) -> int:
continue
if request_id:
seen_batch_request_ids.add(request_id)
actor_identity_id = rec.get("actor_identity_id")
if actor_identity_id and actor_identity_id not in existing_identity_ids:
actor_identity_id = None
created = rec.get("created_at")
if isinstance(created, str):
try:
@@ -1115,7 +1133,7 @@ async def drain_audit_redis_to_db(session_factory: Any) -> int:
event_type=rec.get("event_type", "telegram_access"),
channel=rec.get("channel", "telegram"),
path_or_handler=rec.get("path_or_handler") or "telegram",
actor_identity_id=rec.get("actor_identity_id"),
actor_identity_id=actor_identity_id,
actor_tg_id=rec.get("actor_tg_id"),
entity_type=rec.get("entity_type"),
entity_id=rec.get("entity_id"),
+4
View File
@@ -76,3 +76,7 @@ def get_site_url() -> str:
def is_web_enabled() -> bool:
return bool(WEB_CONFIG.get("WEB_ENABLED", False))
def is_email_binding_enabled() -> bool:
return bool(WEB_CONFIG.get("EMAIL_BINDING_ENABLED", False))
+35
View File
@@ -110,6 +110,41 @@ async def mark_all_read_for_identity(
return result.rowcount
async def mark_one_read_for_identity(
session: AsyncSession,
identity_id: str,
notification_id: str,
) -> bool:
"""Mark single notification as read. Returns True if updated."""
result = await session.execute(
update(WebNotification)
.where(
WebNotification.identity_id == identity_id,
WebNotification.id == notification_id,
)
.values(read=True)
)
return (result.rowcount or 0) > 0
async def delete_one_for_identity(
session: AsyncSession,
identity_id: str,
notification_id: str,
) -> bool:
"""Delete single notification owned by identity. Returns True if deleted."""
from sqlalchemy import delete as sql_delete
result = await session.execute(
sql_delete(WebNotification)
.where(
WebNotification.identity_id == identity_id,
WebNotification.id == notification_id,
)
)
return (result.rowcount or 0) > 0
async def resolve_identity_id_by_tg_id(
session: AsyncSession,
tg_id: int,
+2
View File
@@ -7,6 +7,7 @@ from .captcha import router as captcha_router
from .chat_member import router as chat_member_router
from .coupons import router as coupons_router
from .donate import router as donate_router
from .email_binding import router as email_binding_router
from .instructions import router as instructions_router
from .keys import router as keys_router
from .notifications import router as notifications_router
@@ -33,4 +34,5 @@ router.include_routers(
admin_router,
refferal_router,
tariff_router,
email_binding_router,
)
+30 -2
View File
@@ -35,6 +35,13 @@ def build_settings_web_kb() -> InlineKeyboardBuilder:
callback_data=AdminPanelCallback(action="settings_web_url").pack(),
)
)
email_binding = bool(WEB_CONFIG.get("EMAIL_BINDING_ENABLED", False))
builder.row(
InlineKeyboardButton(
text=f"{'' if email_binding else ''} Привязка почты {'включена' if email_binding else 'выключена'}",
callback_data=AdminPanelCallback(action="settings_web_email_binding_toggle").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="🔄 Сбросить сайт к исходнику",
@@ -49,12 +56,16 @@ def build_settings_web_kb() -> InlineKeyboardBuilder:
def _web_settings_text() -> str:
enabled = bool(WEB_CONFIG.get("WEB_ENABLED", False))
url = str(WEB_CONFIG.get("SITE_URL") or "не указан")
email_binding = bool(WEB_CONFIG.get("EMAIL_BINDING_ENABLED", False))
return (
"<b>🌐 Настройки веб-сайта</b>\n\n"
f"Статус: {'✅ Включён' if enabled else '❌ Выключен'}\n"
f"URL: <code>{url}</code>\n\n"
f"URL: <code>{url}</code>\n"
f"Привязка почты: {'✅ Включена' if email_binding else '❌ Выключена'}\n\n"
"Сайт может работать на отдельном домене и сервере.\n"
"При выключении кнопка «Личный кабинет» скрывается из бота."
"При выключении кнопка «Личный кабинет» скрывается из бота.\n"
"Привязка почты — кнопка в боте, через которую пользователь указывает email "
"для входа на сайт на случай проблем с Telegram."
)
@@ -84,6 +95,23 @@ async def toggle_web_enabled(callback: CallbackQuery) -> None:
)
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_web_email_binding_toggle"))
async def toggle_email_binding(callback: CallbackQuery) -> None:
current = bool(WEB_CONFIG.get("EMAIL_BINDING_ENABLED", False))
new_config = dict(WEB_CONFIG)
new_config["EMAIL_BINDING_ENABLED"] = not current
async with async_session_maker() as session:
await update_web_config(session, new_config)
status = "✅ Привязка почты включена" if new_config["EMAIL_BINDING_ENABLED"] else "❌ Привязка почты выключена"
await callback.answer(status, show_alert=True)
await callback.message.edit_text(
text=_web_settings_text(),
reply_markup=build_settings_web_kb().as_markup(),
)
@router.callback_query(AdminPanelCallback.filter(F.action == "settings_web_url"))
async def prompt_web_url(callback: CallbackQuery, state: FSMContext) -> None:
current = str(WEB_CONFIG.get("SITE_URL") or "")
+90
View File
@@ -0,0 +1,90 @@
import re
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from core.settings.web_config import is_email_binding_enabled
from database.identities import get_identity_by_email, get_or_create_identity_for_tg
from handlers.buttons import BACK
from handlers.utils import edit_or_send_message
from logger import logger
router = Router(name="email_binding")
EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
class EmailBindingState(StatesGroup):
waiting_for_email = State()
@router.callback_query(F.data == "bind_email")
async def prompt_email(callback: CallbackQuery, state: FSMContext, session) -> None:
if not is_email_binding_enabled():
await callback.answer("Привязка почты отключена", show_alert=True)
return
identity = await get_or_create_identity_for_tg(session, callback.from_user.id)
if identity.email:
await callback.answer("Почта уже привязана", show_alert=True)
return
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data="profile"))
await edit_or_send_message(
target_message=callback.message,
text=(
"📧 <b>Привязка почты</b>\n\n"
"Укажите email — он понадобится для входа на сайт, "
"если возникнут проблемы с Telegram.\n\n"
"Отправьте адрес сообщением."
),
reply_markup=builder.as_markup(),
)
await state.set_state(EmailBindingState.waiting_for_email)
await callback.answer()
@router.message(EmailBindingState.waiting_for_email)
async def receive_email(message: Message, state: FSMContext, session) -> None:
raw = (message.text or "").strip().lower()
if not EMAIL_RE.match(raw) or len(raw) > 255:
await message.answer("❌ Неверный формат email. Попробуйте ещё раз.")
return
existing = await get_identity_by_email(session, raw)
if existing and existing.tg_id and existing.tg_id != message.from_user.id:
await message.answer("❌ Этот email уже занят другим пользователем.")
return
identity = await get_or_create_identity_for_tg(session, message.from_user.id)
if identity.email:
await state.clear()
await message.answer("ℹ️ Почта уже была привязана.")
return
if existing and existing.id != identity.id:
logger.warning(
"email_binding: identity collision tg_id=%s wants email=%s already on identity_id=%s",
message.from_user.id,
raw,
existing.id,
)
await message.answer("❌ Этот email уже занят. Используйте другой.")
return
identity.email = raw
await session.flush()
await state.clear()
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="👤 В кабинет", callback_data="profile"))
await message.answer(
f"✅ Почта <code>{raw}</code> привязана.",
reply_markup=builder.as_markup(),
)
+22 -10
View File
@@ -19,7 +19,7 @@ from config import (
WATA_SUCCESS_URL,
)
from core.bootstrap import PAYMENTS_CONFIG
from database import register_pending_payment
from database import add_payment, async_session_maker
from database.models import User
from handlers.buttons import BACK, PAY_2, WATA_INT, WATA_RU
from handlers.payments.keyboards import (
@@ -438,15 +438,27 @@ async def generate_wata_payment_link(
logger.error(f"[WATA] В ответе нет поля url: {resp_json}")
return None
await register_pending_payment(
payment_id=unique_order_id,
tg_id=tg_id,
amount=float(int(amount)),
payment_system="wata",
currency="RUB",
metadata=pending_metadata,
original_amount=pending_original_amount,
)
async with async_session_maker() as db_session:
try:
await add_payment(
session=db_session,
tg_id=tg_id,
amount=float(int(amount)),
payment_system="wata",
status="pending",
currency="RUB",
payment_id=unique_order_id,
metadata=pending_metadata,
original_amount=pending_original_amount,
)
await db_session.commit()
except Exception as e:
logger.error(
f"[WATA] Не удалось записать pending платёж в БД "
f"(order_id={unique_order_id}, tg_id={tg_id}): {e}"
)
await db_session.rollback()
return None
logger.info(
f"[WATA] Ссылка создана: tg_id={tg_id}, order_id={unique_order_id}, "
f"rub_amount={amount}, api_amount={api_amount} {currency}"
+8 -1
View File
@@ -108,7 +108,7 @@ async def process_callback_view_profile(
builder = InlineKeyboardBuilder()
from core.settings.web_config import get_site_url, is_web_enabled
from core.settings.web_config import get_site_url, is_email_binding_enabled, is_web_enabled
if is_web_enabled():
site_url = get_site_url()
@@ -116,6 +116,13 @@ async def process_callback_view_profile(
webapp_url = f"{site_url}/dashboard"
builder.row(InlineKeyboardButton(text="🌐 Личный кабинет", web_app=WebAppInfo(url=webapp_url)))
if is_email_binding_enabled():
from database.identities import get_identity_by_tg_id
identity = await get_identity_by_tg_id(session, chat_id)
if not (identity and identity.email):
builder.row(InlineKeyboardButton(text="📧 Привязать почту", callback_data="bind_email"))
trial_time_disabled = bool(MODES_CONFIG.get("TRIAL_TIME_DISABLED", TRIAL_TIME_DISABLE))
if key_count > 0:
+7 -1
View File
@@ -26,5 +26,11 @@ class ActorMiddleware(BaseMiddleware):
):
data["actor"] = await resolve_actor_from_legacy_ref(session, int(from_user.id))
except Exception as error:
logger.error(f"[ActorMiddleware] Ошибка резолва actor: {error}")
logger.error(f"[ActorMiddleware] Ошибка резолва actor: {error}", exc_info=True)
_session = data.get("session")
if _session is not None:
try:
await _session.rollback()
except Exception:
pass
return await handler(event, data)
+5
View File
@@ -68,4 +68,9 @@ class AdminMiddleware(BaseMiddleware):
await cache_set(cache_key("admin_access", user_id), bool(is_admin), _ADMIN_CACHE_TTL)
return is_admin
except Exception:
if session is not None:
try:
await session.rollback()
except Exception:
pass
return False
+7 -1
View File
@@ -34,7 +34,13 @@ class UserMiddleware(BaseMiddleware):
if db_user:
data["user"] = db_user
except Exception as e:
logger.error(f"Ошибка при обработке пользователя: {e}")
logger.error(f"Ошибка при обработке пользователя: {e}", exc_info=True)
_session = data.get("session")
if _session is not None:
try:
await _session.rollback()
except Exception:
pass
return await handler(event, data)
async def _process_user(self, user: User, session: AsyncSession) -> dict | None:
+1 -1
View File
@@ -92,4 +92,4 @@ def get_git_commit_number() -> str:
def get_version() -> str:
return f"v.6-b2704260038 {get_git_commit_number()}"
return f"v.6-b0505261115 {get_git_commit_number()}"