feat(bot): модель данных, миграции Alembic, FSM в БД, TTL-чистка

- app/db/models.py: 13 таблиц (ARCHITECTURE §2.10); перечисления — VARCHAR + CHECK,
  JSON → JSONB на Postgres, BIGINT/INTEGER-ключи для SQLite
- миграция 0001 (автогенерация + ручная чистка: импорт sa.Text, дубли CHECK)
- миграции применяются при старте бота программно (app.db.migrate), script_location из пакета
- SqlStorage: FSM aiogram поверх fsm_state — состояние переживает рестарт (перенесено из #4)
- cleanup_loop: истёкшие черновики, снимки контейнеров и попытки входа старше суток
- тесты: upgrade/downgrade, дрейф моделей и миграций, CHECK, CASCADE, FSM, чистка

Refs #5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 18:45:16 +03:00
co-authored by Claude Opus 5.5
parent b70797c805
commit 282920e06b
24 changed files with 1071 additions and 16 deletions
+6 -4
View File
@@ -1,8 +1,10 @@
"""FSM storage factory. In-memory for now; Postgres-backed storage that survives restarts arrives with the DB (#5)."""
"""FSM storage factory: dialog state lives in the DB and survives restarts (SPEC M-4)."""
from aiogram.fsm.storage.base import BaseStorage
from aiogram.fsm.storage.memory import MemoryStorage
from app.bot.fsm_storage import SqlStorage
from app.db.session import SessionMaker
def make_storage() -> BaseStorage:
return MemoryStorage()
def make_storage(sessions: SessionMaker) -> BaseStorage:
return SqlStorage(sessions)
+54
View File
@@ -0,0 +1,54 @@
"""aiogram FSM storage on top of the `fsm_state` table — dialog state survives bot restarts (SPEC M-4)."""
from collections.abc import Mapping
from typing import Any
from aiogram.fsm.state import State
from aiogram.fsm.storage.base import BaseStorage, StateType, StorageKey
from app.db.models import FsmState
from app.db.session import SessionMaker
def key_to_str(key: StorageKey) -> str:
parts = (key.bot_id, key.chat_id, key.user_id, key.thread_id, key.business_connection_id, key.destiny)
return ":".join("" if p is None else str(p) for p in parts)
class SqlStorage(BaseStorage):
def __init__(self, sessions: SessionMaker) -> None:
self._sessions = sessions
async def set_state(self, key: StorageKey, state: StateType = None) -> None:
value = state.state if isinstance(state, State) else state
await self._write(key, state=value)
async def get_state(self, key: StorageKey) -> str | None:
async with self._sessions() as session:
row = await session.get(FsmState, key_to_str(key))
return row.state if row else None
async def set_data(self, key: StorageKey, data: Mapping[str, Any]) -> None:
await self._write(key, data=dict(data))
async def get_data(self, key: StorageKey) -> dict[str, Any]:
async with self._sessions() as session:
row = await session.get(FsmState, key_to_str(key))
return dict(row.data) if row else {}
async def close(self) -> None:
pass # the engine is owned and disposed by the app
async def _write(self, key: StorageKey, **fields: Any) -> None:
async with self._sessions.begin() as session:
row = await session.get(FsmState, key_to_str(key))
state = fields.get("state", row.state if row else None)
data = fields.get("data", row.data if row else {})
if state is None and not data:
if row is not None:
await session.delete(row) # empty state: keep the table small
return
if row is None:
session.add(FsmState(key=key_to_str(key), state=state, data=data))
else:
row.state, row.data = state, data