From 1e4e905f24be65287f51f131daf3f845a92ecf7c Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Mon, 19 Oct 2026 03:37:58 +0500 Subject: [PATCH] =?UTF-8?q?=D0=91=D0=BE=D1=82=20=D0=B1=D0=B5=D1=80=D1=91?= =?UTF-8?q?=D1=82=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D1=8B=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA=D0=B8,=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B6=D0=B8=D0=B4=D0=B0=D1=8F=D1=81=D1=8C=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Шаблоны из message_templates читаются через кеш на 45 секунд (TEMPLATE_CACHE_TTL), поэтому правка в админке доезжает до следующего сообщения или рассылки сама. Если база недоступна или таблицы ещё нет, бот работает на текстах из кода, а неудачный запрос не повторяется на каждом сообщении — админка не может уронить бота. Расписания рассылок теперь ссылаются на ключи шаблонов, а не на строки, так что тексты рассылок тоже редактируются. --- .env.example | 4 ++ README.md | 7 +- admin/templates/messages/list.html | 6 +- bot/handlers/commands.py | 11 +-- bot/handlers/economy.py | 11 +-- bot/handlers/member_events.py | 7 +- bot/handlers/shop.py | 10 +-- bot/services/notifications.py | 26 +++---- common/config.py | 4 ++ common/repositories/templates_repo.py | 8 +++ common/texts/store.py | 98 +++++++++++++++++++++++++++ common/version.py | 2 +- env/prod.env.example | 3 + env/test.env.example | 3 + tests/test_store.py | 93 +++++++++++++++++++++++++ tests/test_version.py | 2 +- 16 files changed, 257 insertions(+), 38 deletions(-) create mode 100644 common/texts/store.py create mode 100644 tests/test_store.py diff --git a/.env.example b/.env.example index 3c5fab6..977fa36 100644 --- a/.env.example +++ b/.env.example @@ -43,5 +43,9 @@ ANALYTICS_BATCH_SIZE=200 ANALYTICS_FLUSH_INTERVAL=5.0 ANALYTICS_BUFFER_MAX=5000 +# Шаблоны сообщений: сколько секунд бот держит тексты из БД в памяти. +# Правка в админке применяется без перезапуска в течение этого времени. +TEMPLATE_CACHE_TTL=45 + # Админка (FastAPI, Блок 4). Порт на loopback хоста для docker compose. ADMIN_HTTP_PORT=8000 diff --git a/README.md b/README.md index facf34b..fb7c7f8 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,9 @@ APP_ENV=prod python -m bot # prod Раздел «Сообщения» читает и правит шаблоны из `bot.message_templates`, каждое изменение пишется в `bot.message_template_history` (аудит и материал для отката). Стартовые тексты — `sql/V15__seed_message_templates.sql` (генерируются из `common/texts`, тест сверяет их). -Предпросмотр использует тот же `render()`, что и бот. Бот пока читает тексты из кода — -переключение на БД в шаге 4.5. +Бот читает тексты из БД через TTL-кеш (`TEMPLATE_CACHE_TTL`, по умолчанию 45 с), поэтому +правка применяется без перезапуска; при недоступной БД бот использует тексты из кода. +Предпросмотр использует тот же `render()`, что и бот. ## Аналитика (ClickHouse) @@ -121,7 +122,7 @@ make backup E=test # на домашнем сервере: ./scripts/clickho ## Версионирование -- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.4.1`. +- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.4.2`. - Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`). - Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3). - Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`. diff --git a/admin/templates/messages/list.html b/admin/templates/messages/list.html index 29b7c26..e6bed58 100644 --- a/admin/templates/messages/list.html +++ b/admin/templates/messages/list.html @@ -13,9 +13,9 @@ {tag} на тестовых данных.

-
- Правки пока не влияют на бота: он читает тексты из кода. Подключение шаблонов из БД — - следующий шаг Блока 4 (4.5). +
+ Изменения применяются ботом без перезапуска — в течение TEMPLATE_CACHE_TTL + секунд (по умолчанию 45). Если БД недоступна, бот использует тексты из кода.
diff --git a/bot/handlers/commands.py b/bot/handlers/commands.py index d663b5a..cb4b4e2 100644 --- a/bot/handlers/commands.py +++ b/bot/handlers/commands.py @@ -12,8 +12,9 @@ from aiogram.types import Message from bot.services import access, stats, subscriptions from bot.services import users as users_service from common.config import get_settings -from common.texts import economy, info, system +from common.texts import system from common.texts.render import format_tops, format_user_list, render +from common.texts.store import get_text logger = logging.getLogger(__name__) router = Router(name="commands") @@ -61,7 +62,7 @@ async def list_nicks_handler(message: Message) -> None: "users_list": format_user_list([{"username": n.username, "nick": n.nick} for n in nicks]), "users_list_count": len(nicks), } - await message.answer(render(economy.MSG_NICK_LIST, context)) + await message.answer(render(await get_text("economy.nick_list"), context)) @router.message(Command("кто", ignore_case=True)) @@ -83,7 +84,7 @@ async def get_nick_handler(message: Message) -> None: async def help_handler(message: Message) -> None: if not await access.check_user(message): return - await message.answer(info.MSG_HELP) + await message.answer(await get_text("info.help")) @router.message(Command("инфо", ignore_case=True)) @@ -108,7 +109,7 @@ async def info_handler(message: Message) -> None: "msg_count": user_info.msg_count, "eggs": user_info.eggs, } - await message.answer(render(info.MSG_INFO, context)) + await message.answer(render(await get_text("info.profile"), context)) @router.message(Command("рейтинг", ignore_case=True)) @@ -117,7 +118,7 @@ async def top_handler(message: Message) -> None: return tops = await stats.top_users() context = {"tops": format_tops([{"nick": t.nick, "count": t.count} for t in tops])} - await message.answer(render(economy.MSG_TOP, context)) + await message.answer(render(await get_text("economy.top"), context)) @router.message(Command("отписка", ignore_case=True)) diff --git a/bot/handlers/economy.py b/bot/handlers/economy.py index 169c4cd..d6bbf32 100644 --- a/bot/handlers/economy.py +++ b/bot/handlers/economy.py @@ -12,8 +12,9 @@ from aiogram.types import Message from bot.services import access, alerts from bot.services import economy as economy_service from common.config import get_settings -from common.texts import economy, system +from common.texts import system from common.texts.render import render +from common.texts.store import get_text router = Router(name="economy") @@ -33,10 +34,10 @@ async def purification_handler(message: Message) -> None: result = await economy_service.purify(message.from_user.id) if not result.success: context = {"h": result.hours, "m": result.minutes, "s": result.seconds} - await message.answer(render(economy.MSG_NO_PURIFICATION, context)) + await message.answer(render(await get_text("economy.no_purification"), context)) else: context = {"money_add": result.money_add, "exp_add": result.exp_add} - await message.answer(render(economy.MSG_PURIFICATION, context)) + await message.answer(render(await get_text("economy.purification"), context)) await alerts.notify_purification( message.bot, username=message.from_user.username, @@ -58,9 +59,9 @@ async def eggs_handler(message: Message) -> None: result = await economy_service.collect_eggs(message.from_user.id) if not result.success: context = {"h": result.hours, "m": result.minutes, "s": result.seconds} - await message.answer(render(economy.MSG_NO_EGGS, context)) + await message.answer(render(await get_text("economy.no_eggs"), context)) else: - await message.answer(render(economy.MSG_EGGS, {"eggs": result.eggs})) + await message.answer(render(await get_text("economy.eggs"), {"eggs": result.eggs})) @router.message() diff --git a/bot/handlers/member_events.py b/bot/handlers/member_events.py index 3357c06..a1ef09f 100644 --- a/bot/handlers/member_events.py +++ b/bot/handlers/member_events.py @@ -15,6 +15,7 @@ from common.analytics import EventType, record from common.config import get_settings from common.texts import system, welcome from common.texts.render import render +from common.texts.store import get_text logger = logging.getLogger(__name__) router = Router(name="member_events") @@ -54,14 +55,14 @@ async def welcome_handler(event: ChatMemberUpdated) -> None: await event.bot.send_photo( chat_id=event.chat.id, photo=FSInputFile("images/welcome_image.jpg"), - caption=render(welcome.MSG_WELCOME_TEXT, {"username": user.username}), + caption=render(await get_text("welcome.text"), {"username": user.username}), ) await event.bot.send_message( chat_id=event.chat.id, - text=welcome.MSG_WELCOME_TEXT2, + text=await get_text("welcome.text2"), reply_markup=builder.as_markup(), ) - await event.bot.send_message(chat_id=event.chat.id, text=welcome.MSG_WELCOME_TEXT3) + await event.bot.send_message(chat_id=event.chat.id, text=await get_text("welcome.text3")) @router.chat_member(ChatMemberUpdatedFilter(member_status_changed=LEAVE_TRANSITION)) diff --git a/bot/handlers/shop.py b/bot/handlers/shop.py index ebdefcf..a249be9 100644 --- a/bot/handlers/shop.py +++ b/bot/handlers/shop.py @@ -11,9 +11,9 @@ from aiogram.types import CallbackQuery, Message from bot.keyboards import get_keyboard_donat, get_keyboard_main, get_keyboard_title from bot.services import access from bot.services import shop as shop_service -from common.texts import shop as shop_texts from common.texts import system from common.texts.render import render +from common.texts.store import get_text logger = logging.getLogger(__name__) router = Router(name="shop") @@ -26,16 +26,16 @@ async def shop_handler(message: Message) -> None: if message.chat.type != "private": await message.answer(system.MSG_SHOP_PRIVATE_ONLY) return - await message.answer(shop_texts.MSG_SHOP_MAIN, reply_markup=get_keyboard_main()) + await message.answer(await get_text("shop.main"), reply_markup=get_keyboard_main()) async def _render_chapter(chapter: str) -> tuple[str, object] | None: if chapter == "main": - return shop_texts.MSG_SHOP_MAIN, get_keyboard_main() + return await get_text("shop.main"), get_keyboard_main() if chapter == "title": - return shop_texts.MSG_SHOP_TITLE, await get_keyboard_title() + return await get_text("shop.title"), await get_keyboard_title() if chapter == "donat": - return shop_texts.MSG_SHOP_DONAT, await get_keyboard_donat() + return await get_text("shop.donat"), await get_keyboard_donat() return None diff --git a/bot/services/notifications.py b/bot/services/notifications.py index 1645410..27851d1 100644 --- a/bot/services/notifications.py +++ b/bot/services/notifications.py @@ -1,8 +1,9 @@ """Планировщик рассылок. -Исполняется внутри процесса бота. Расписание пока задано в коде (данные, а не -магические числа); в Блоке 4 переедет в таблицу ``schedules`` и будет -редактироваться через админку. +Исполняется внутри процесса бота. Время и дни недели пока заданы в коде +(данные, а не магические числа); в Блоке 4 они переедут в таблицу ``schedules`` +и будут редактироваться через админку. Сами тексты уже берутся из БД +(``bot.message_templates``) через ``common.texts.store``. """ from __future__ import annotations @@ -17,7 +18,7 @@ from aiogram import Bot from bot.services import users as users_service from common.config import get_settings from common.texts import format_usernames, render -from common.texts import notifications as notify_texts +from common.texts.store import get_text logger = logging.getLogger(__name__) @@ -34,7 +35,7 @@ class ScheduledMessage: hour: int minute: int - template: str + template_key: str weekdays: tuple[int, ...] | None = field(default=None) def matches(self, now: datetime) -> bool: @@ -44,12 +45,12 @@ class ScheduledMessage: SCHEDULES: tuple[ScheduledMessage, ...] = ( - ScheduledMessage(16, 30, notify_texts.MSG_BLAGO_30), - ScheduledMessage(16, 45, notify_texts.MSG_BLAGO_45), - ScheduledMessage(16, 55, notify_texts.MSG_BLAGO_55), - ScheduledMessage(17, 0, notify_texts.MSG_BLAGO), - ScheduledMessage(15, 0, notify_texts.MSG_VKLAD, weekdays=(4,)), # пятница - ScheduledMessage(14, 0, notify_texts.MSG_VKLAD, weekdays=(6,)), # воскресенье + ScheduledMessage(16, 30, "notify.blago_30"), + ScheduledMessage(16, 45, "notify.blago_45"), + ScheduledMessage(16, 55, "notify.blago_55"), + ScheduledMessage(17, 0, "notify.blago"), + ScheduledMessage(15, 0, "notify.vklad", weekdays=(4,)), # пятница + ScheduledMessage(14, 0, "notify.vklad", weekdays=(6,)), # воскресенье ) @@ -64,7 +65,8 @@ async def _tick(bot: Bot, now: datetime) -> None: for schedule in SCHEDULES: if schedule.matches(now): subscribers = await users_service.list_subscribers() - await _send_broadcast(bot, schedule.template, subscribers) + template = await get_text(schedule.template_key) + await _send_broadcast(bot, template, subscribers) async def run_notifications(bot: Bot) -> None: diff --git a/common/config.py b/common/config.py index 92e54c9..d23805a 100644 --- a/common/config.py +++ b/common/config.py @@ -66,6 +66,10 @@ class Settings(BaseSettings): analytics_flush_interval: float = 5.0 analytics_buffer_max: int = 5000 + #: Сколько секунд бот держит шаблоны сообщений в памяти (правки из админки + #: применяются без перезапуска). 0 — читать БД при каждом обращении. + template_cache_ttl: float = 45.0 + @field_validator("chat_id", "alert_chat_id", mode="before") @classmethod def _empty_int_to_zero(cls, value: object) -> object: diff --git a/common/repositories/templates_repo.py b/common/repositories/templates_repo.py index 3b97a71..476bbff 100644 --- a/common/repositories/templates_repo.py +++ b/common/repositories/templates_repo.py @@ -11,6 +11,14 @@ from typing import Any from common.db.pool import get_pool +async def load_texts() -> dict[str, str]: + """Актуальные тексты включённых шаблонов (для кеша бота в ``common.texts.store``).""" + async with get_pool().connection() as conn: + cur = await conn.execute("SELECT key, text FROM bot.message_templates WHERE enabled ORDER BY key") + rows = await cur.fetchall() + return {row["key"]: row["text"] for row in rows} + + async def list_templates() -> list[dict[str, Any]]: """Вернуть список шаблонов без текста (для таблицы в админке).""" async with get_pool().connection() as conn: diff --git a/common/texts/store.py b/common/texts/store.py new file mode 100644 index 0000000..b761f78 --- /dev/null +++ b/common/texts/store.py @@ -0,0 +1,98 @@ +"""Шаблоны сообщений из БД с TTL-кешем (Блок 4.5). + +Бот читает тексты из ``bot.message_templates``: правка в админке применяется +без перезапуска — в течение ``TEMPLATE_CACHE_TTL`` секунд. Если БД недоступна +(или таблицы ещё нет), используются тексты по умолчанию из ``common.texts.TEMPLATES``. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from collections.abc import Awaitable, Callable + +from common.config import get_settings +from common.texts import TEMPLATES + +logger = logging.getLogger(__name__) + +#: Загрузчик «ключ -> текст» (по умолчанию — из Postgres). +Loader = Callable[[], Awaitable[dict[str, str]]] + + +async def _load_from_db() -> dict[str, str]: + # Импорт внутри функции: не тянем слой БД на этапе импорта текстов. + from common.repositories.templates_repo import load_texts + + return await load_texts() + + +class TemplateStore: + """Кеш шаблонов с временем жизни; при ошибке БД — тексты по умолчанию.""" + + def __init__(self, loader: Loader | None = None, ttl: float | None = None) -> None: + self._loader = loader or _load_from_db + self._ttl = ttl + self._texts: dict[str, str] = {} + self._loaded_at: float | None = None + self._lock = asyncio.Lock() + + @property + def ttl(self) -> float: + """TTL кеша в секундах (из настроек, если не задан явно).""" + if self._ttl is None: + self._ttl = get_settings().template_cache_ttl + return max(self._ttl, 0.0) + + async def get(self, key: str) -> str: + """Вернуть текст шаблона (из БД или из кода, если его там нет).""" + await self._ensure_fresh() + text = self._texts.get(key) + if text is None: + return TEMPLATES.get(key, "") + return text + + def clear(self) -> None: + """Сбросить кеш: следующий ``get`` перечитает БД.""" + self._loaded_at = None + + async def _ensure_fresh(self) -> None: + if self._loaded_at is not None and time.monotonic() - self._loaded_at < self.ttl: + return + + async with self._lock: + # Пока ждали лок, кеш мог обновить другой вызов. + if self._loaded_at is not None and time.monotonic() - self._loaded_at < self.ttl: + return + + try: + texts = await self._loader() + except Exception: + logger.warning( + "Не удалось прочитать шаблоны из БД — использую тексты по умолчанию", + exc_info=True, + ) + # Не долбим БД каждым сообщением: следующая попытка через TTL. + self._loaded_at = time.monotonic() + return + + self._texts = texts + self._loaded_at = time.monotonic() + logger.debug("Шаблоны обновлены из БД: %s", len(texts)) + + +_store: TemplateStore | None = None + + +def get_template_store() -> TemplateStore: + """Вернуть общий кеш шаблонов процесса.""" + global _store + if _store is None: + _store = TemplateStore() + return _store + + +async def get_text(key: str) -> str: + """Короткий доступ к шаблону: ``await get_text("info.help")``.""" + return await get_template_store().get(key) diff --git a/common/version.py b/common/version.py index 4a60f38..984dc40 100644 --- a/common/version.py +++ b/common/version.py @@ -7,4 +7,4 @@ from __future__ import annotations -__version__ = "0.4.1" +__version__ = "0.4.2" diff --git a/env/prod.env.example b/env/prod.env.example index 52b8c13..a5f0333 100644 --- a/env/prod.env.example +++ b/env/prod.env.example @@ -43,3 +43,6 @@ CLICKHOUSE_HTTP_PORT=8123 # Админка (FastAPI). Порт на loopback хоста; у test и prod должен различаться. # Наружу пока не публикуется (домен/TLS — Блок 2.4). ADMIN_HTTP_PORT=8000 + +# Шаблоны сообщений: TTL кеша бота (правки из админки без перезапуска). +TEMPLATE_CACHE_TTL=45 diff --git a/env/test.env.example b/env/test.env.example index ef897f6..88140e2 100644 --- a/env/test.env.example +++ b/env/test.env.example @@ -41,3 +41,6 @@ CLICKHOUSE_HTTP_PORT=8124 # Админка (FastAPI). Порт на loopback хоста; у test и prod должен различаться. # Наружу пока не публикуется (домен/TLS — Блок 2.4). ADMIN_HTTP_PORT=8001 + +# Шаблоны сообщений: TTL кеша бота (правки из админки без перезапуска). +TEMPLATE_CACHE_TTL=45 diff --git a/tests/test_store.py b/tests/test_store.py new file mode 100644 index 0000000..911fcb7 --- /dev/null +++ b/tests/test_store.py @@ -0,0 +1,93 @@ +"""Тесты TTL-кеша шаблонов из БД (Блок 4.5): кеш, фолбэк и ошибки БД.""" + +from __future__ import annotations + +from common.texts import TEMPLATES +from common.texts.store import TemplateStore + + +async def test_uses_db_values() -> None: + async def loader() -> dict[str, str]: + return {"info.help": "текст из БД"} + + store = TemplateStore(loader=loader, ttl=60) + + assert await store.get("info.help") == "текст из БД" + + +async def test_falls_back_to_code_for_missing_key() -> None: + async def loader() -> dict[str, str]: + return {"info.help": "текст из БД"} + + store = TemplateStore(loader=loader, ttl=60) + + assert await store.get("economy.top") == TEMPLATES["economy.top"] + + +async def test_falls_back_when_db_unavailable() -> None: + async def loader() -> dict[str, str]: + raise RuntimeError("БД недоступна") + + store = TemplateStore(loader=loader, ttl=60) + + assert await store.get("info.help") == TEMPLATES["info.help"] + + +async def test_caches_within_ttl() -> None: + calls = 0 + + async def loader() -> dict[str, str]: + nonlocal calls + calls += 1 + return {"info.help": f"v{calls}"} + + store = TemplateStore(loader=loader, ttl=60) + + assert await store.get("info.help") == "v1" + assert await store.get("info.help") == "v1" + assert calls == 1 + + +async def test_reloads_when_cache_expired() -> None: + calls = 0 + + async def loader() -> dict[str, str]: + nonlocal calls + calls += 1 + return {"info.help": f"v{calls}"} + + store = TemplateStore(loader=loader, ttl=0) + + assert await store.get("info.help") == "v1" + assert await store.get("info.help") == "v2" + + +async def test_clear_forces_reload() -> None: + calls = 0 + + async def loader() -> dict[str, str]: + nonlocal calls + calls += 1 + return {"info.help": f"v{calls}"} + + store = TemplateStore(loader=loader, ttl=600) + assert await store.get("info.help") == "v1" + + store.clear() + + assert await store.get("info.help") == "v2" + + +async def test_failed_load_is_not_retried_on_every_call() -> None: + calls = 0 + + async def loader() -> dict[str, str]: + nonlocal calls + calls += 1 + raise RuntimeError("БД недоступна") + + store = TemplateStore(loader=loader, ttl=60) + + assert await store.get("info.help") == TEMPLATES["info.help"] + assert await store.get("info.help") == TEMPLATES["info.help"] + assert calls == 1 diff --git a/tests/test_version.py b/tests/test_version.py index ba9616f..5585db3 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -7,4 +7,4 @@ from common import __version__ def test_version_is_set(): assert __version__ - assert __version__ == "0.4.1" + assert __version__ == "0.4.2"