Бот берёт тексты из админки, не дожидаясь перезапуска
Шаблоны из message_templates читаются через кеш на 45 секунд (TEMPLATE_CACHE_TTL), поэтому правка в админке доезжает до следующего сообщения или рассылки сама. Если база недоступна или таблицы ещё нет, бот работает на текстах из кода, а неудачный запрос не повторяется на каждом сообщении — админка не может уронить бота. Расписания рассылок теперь ссылаются на ключи шаблонов, а не на строки, так что тексты рассылок тоже редактируются.
This commit is contained in:
parent
6e3209a339
commit
1e4e905f24
|
|
@ -43,5 +43,9 @@ ANALYTICS_BATCH_SIZE=200
|
||||||
ANALYTICS_FLUSH_INTERVAL=5.0
|
ANALYTICS_FLUSH_INTERVAL=5.0
|
||||||
ANALYTICS_BUFFER_MAX=5000
|
ANALYTICS_BUFFER_MAX=5000
|
||||||
|
|
||||||
|
# Шаблоны сообщений: сколько секунд бот держит тексты из БД в памяти.
|
||||||
|
# Правка в админке применяется без перезапуска в течение этого времени.
|
||||||
|
TEMPLATE_CACHE_TTL=45
|
||||||
|
|
||||||
# Админка (FastAPI, Блок 4). Порт на loopback хоста для docker compose.
|
# Админка (FastAPI, Блок 4). Порт на loopback хоста для docker compose.
|
||||||
ADMIN_HTTP_PORT=8000
|
ADMIN_HTTP_PORT=8000
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,9 @@ APP_ENV=prod python -m bot # prod
|
||||||
Раздел «Сообщения» читает и правит шаблоны из `bot.message_templates`, каждое изменение
|
Раздел «Сообщения» читает и правит шаблоны из `bot.message_templates`, каждое изменение
|
||||||
пишется в `bot.message_template_history` (аудит и материал для отката). Стартовые тексты —
|
пишется в `bot.message_template_history` (аудит и материал для отката). Стартовые тексты —
|
||||||
`sql/V15__seed_message_templates.sql` (генерируются из `common/texts`, тест сверяет их).
|
`sql/V15__seed_message_templates.sql` (генерируются из `common/texts`, тест сверяет их).
|
||||||
Предпросмотр использует тот же `render()`, что и бот. Бот пока читает тексты из кода —
|
Бот читает тексты из БД через TTL-кеш (`TEMPLATE_CACHE_TTL`, по умолчанию 45 с), поэтому
|
||||||
переключение на БД в шаге 4.5.
|
правка применяется без перезапуска; при недоступной БД бот использует тексты из кода.
|
||||||
|
Предпросмотр использует тот же `render()`, что и бот.
|
||||||
|
|
||||||
## Аналитика (ClickHouse)
|
## Аналитика (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`).
|
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
|
||||||
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
|
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
|
||||||
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
|
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,9 @@
|
||||||
<span class="font-mono">{tag}</span> на тестовых данных.
|
<span class="font-mono">{tag}</span> на тестовых данных.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
|
||||||
Правки пока не влияют на бота: он читает тексты из кода. Подключение шаблонов из БД —
|
Изменения применяются ботом без перезапуска — в течение <span class="font-mono">TEMPLATE_CACHE_TTL</span>
|
||||||
следующий шаг Блока 4 (4.5).
|
секунд (по умолчанию 45). Если БД недоступна, бот использует тексты из кода.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 overflow-x-auto rounded-lg border border-slate-200 bg-white">
|
<div class="mt-6 overflow-x-auto rounded-lg border border-slate-200 bg-white">
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,9 @@ from aiogram.types import Message
|
||||||
from bot.services import access, stats, subscriptions
|
from bot.services import access, stats, subscriptions
|
||||||
from bot.services import users as users_service
|
from bot.services import users as users_service
|
||||||
from common.config import get_settings
|
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.render import format_tops, format_user_list, render
|
||||||
|
from common.texts.store import get_text
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = Router(name="commands")
|
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": format_user_list([{"username": n.username, "nick": n.nick} for n in nicks]),
|
||||||
"users_list_count": len(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))
|
@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:
|
async def help_handler(message: Message) -> None:
|
||||||
if not await access.check_user(message):
|
if not await access.check_user(message):
|
||||||
return
|
return
|
||||||
await message.answer(info.MSG_HELP)
|
await message.answer(await get_text("info.help"))
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("инфо", ignore_case=True))
|
@router.message(Command("инфо", ignore_case=True))
|
||||||
|
|
@ -108,7 +109,7 @@ async def info_handler(message: Message) -> None:
|
||||||
"msg_count": user_info.msg_count,
|
"msg_count": user_info.msg_count,
|
||||||
"eggs": user_info.eggs,
|
"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))
|
@router.message(Command("рейтинг", ignore_case=True))
|
||||||
|
|
@ -117,7 +118,7 @@ async def top_handler(message: Message) -> None:
|
||||||
return
|
return
|
||||||
tops = await stats.top_users()
|
tops = await stats.top_users()
|
||||||
context = {"tops": format_tops([{"nick": t.nick, "count": t.count} for t in tops])}
|
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))
|
@router.message(Command("отписка", ignore_case=True))
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,9 @@ from aiogram.types import Message
|
||||||
from bot.services import access, alerts
|
from bot.services import access, alerts
|
||||||
from bot.services import economy as economy_service
|
from bot.services import economy as economy_service
|
||||||
from common.config import get_settings
|
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.render import render
|
||||||
|
from common.texts.store import get_text
|
||||||
|
|
||||||
router = Router(name="economy")
|
router = Router(name="economy")
|
||||||
|
|
||||||
|
|
@ -33,10 +34,10 @@ async def purification_handler(message: Message) -> None:
|
||||||
result = await economy_service.purify(message.from_user.id)
|
result = await economy_service.purify(message.from_user.id)
|
||||||
if not result.success:
|
if not result.success:
|
||||||
context = {"h": result.hours, "m": result.minutes, "s": result.seconds}
|
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:
|
else:
|
||||||
context = {"money_add": result.money_add, "exp_add": result.exp_add}
|
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(
|
await alerts.notify_purification(
|
||||||
message.bot,
|
message.bot,
|
||||||
username=message.from_user.username,
|
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)
|
result = await economy_service.collect_eggs(message.from_user.id)
|
||||||
if not result.success:
|
if not result.success:
|
||||||
context = {"h": result.hours, "m": result.minutes, "s": result.seconds}
|
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:
|
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()
|
@router.message()
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from common.analytics import EventType, record
|
||||||
from common.config import get_settings
|
from common.config import get_settings
|
||||||
from common.texts import system, welcome
|
from common.texts import system, welcome
|
||||||
from common.texts.render import render
|
from common.texts.render import render
|
||||||
|
from common.texts.store import get_text
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = Router(name="member_events")
|
router = Router(name="member_events")
|
||||||
|
|
@ -54,14 +55,14 @@ async def welcome_handler(event: ChatMemberUpdated) -> None:
|
||||||
await event.bot.send_photo(
|
await event.bot.send_photo(
|
||||||
chat_id=event.chat.id,
|
chat_id=event.chat.id,
|
||||||
photo=FSInputFile("images/welcome_image.jpg"),
|
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(
|
await event.bot.send_message(
|
||||||
chat_id=event.chat.id,
|
chat_id=event.chat.id,
|
||||||
text=welcome.MSG_WELCOME_TEXT2,
|
text=await get_text("welcome.text2"),
|
||||||
reply_markup=builder.as_markup(),
|
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))
|
@router.chat_member(ChatMemberUpdatedFilter(member_status_changed=LEAVE_TRANSITION))
|
||||||
|
|
|
||||||
|
|
@ -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.keyboards import get_keyboard_donat, get_keyboard_main, get_keyboard_title
|
||||||
from bot.services import access
|
from bot.services import access
|
||||||
from bot.services import shop as shop_service
|
from bot.services import shop as shop_service
|
||||||
from common.texts import shop as shop_texts
|
|
||||||
from common.texts import system
|
from common.texts import system
|
||||||
from common.texts.render import render
|
from common.texts.render import render
|
||||||
|
from common.texts.store import get_text
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = Router(name="shop")
|
router = Router(name="shop")
|
||||||
|
|
@ -26,16 +26,16 @@ async def shop_handler(message: Message) -> None:
|
||||||
if message.chat.type != "private":
|
if message.chat.type != "private":
|
||||||
await message.answer(system.MSG_SHOP_PRIVATE_ONLY)
|
await message.answer(system.MSG_SHOP_PRIVATE_ONLY)
|
||||||
return
|
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:
|
async def _render_chapter(chapter: str) -> tuple[str, object] | None:
|
||||||
if chapter == "main":
|
if chapter == "main":
|
||||||
return shop_texts.MSG_SHOP_MAIN, get_keyboard_main()
|
return await get_text("shop.main"), get_keyboard_main()
|
||||||
if chapter == "title":
|
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":
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
"""Планировщик рассылок.
|
"""Планировщик рассылок.
|
||||||
|
|
||||||
Исполняется внутри процесса бота. Расписание пока задано в коде (данные, а не
|
Исполняется внутри процесса бота. Время и дни недели пока заданы в коде
|
||||||
магические числа); в Блоке 4 переедет в таблицу ``schedules`` и будет
|
(данные, а не магические числа); в Блоке 4 они переедут в таблицу ``schedules``
|
||||||
редактироваться через админку.
|
и будут редактироваться через админку. Сами тексты уже берутся из БД
|
||||||
|
(``bot.message_templates``) через ``common.texts.store``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -17,7 +18,7 @@ from aiogram import Bot
|
||||||
from bot.services import users as users_service
|
from bot.services import users as users_service
|
||||||
from common.config import get_settings
|
from common.config import get_settings
|
||||||
from common.texts import format_usernames, render
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -34,7 +35,7 @@ class ScheduledMessage:
|
||||||
|
|
||||||
hour: int
|
hour: int
|
||||||
minute: int
|
minute: int
|
||||||
template: str
|
template_key: str
|
||||||
weekdays: tuple[int, ...] | None = field(default=None)
|
weekdays: tuple[int, ...] | None = field(default=None)
|
||||||
|
|
||||||
def matches(self, now: datetime) -> bool:
|
def matches(self, now: datetime) -> bool:
|
||||||
|
|
@ -44,12 +45,12 @@ class ScheduledMessage:
|
||||||
|
|
||||||
|
|
||||||
SCHEDULES: tuple[ScheduledMessage, ...] = (
|
SCHEDULES: tuple[ScheduledMessage, ...] = (
|
||||||
ScheduledMessage(16, 30, notify_texts.MSG_BLAGO_30),
|
ScheduledMessage(16, 30, "notify.blago_30"),
|
||||||
ScheduledMessage(16, 45, notify_texts.MSG_BLAGO_45),
|
ScheduledMessage(16, 45, "notify.blago_45"),
|
||||||
ScheduledMessage(16, 55, notify_texts.MSG_BLAGO_55),
|
ScheduledMessage(16, 55, "notify.blago_55"),
|
||||||
ScheduledMessage(17, 0, notify_texts.MSG_BLAGO),
|
ScheduledMessage(17, 0, "notify.blago"),
|
||||||
ScheduledMessage(15, 0, notify_texts.MSG_VKLAD, weekdays=(4,)), # пятница
|
ScheduledMessage(15, 0, "notify.vklad", weekdays=(4,)), # пятница
|
||||||
ScheduledMessage(14, 0, notify_texts.MSG_VKLAD, weekdays=(6,)), # воскресенье
|
ScheduledMessage(14, 0, "notify.vklad", weekdays=(6,)), # воскресенье
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -64,7 +65,8 @@ async def _tick(bot: Bot, now: datetime) -> None:
|
||||||
for schedule in SCHEDULES:
|
for schedule in SCHEDULES:
|
||||||
if schedule.matches(now):
|
if schedule.matches(now):
|
||||||
subscribers = await users_service.list_subscribers()
|
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:
|
async def run_notifications(bot: Bot) -> None:
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,10 @@ class Settings(BaseSettings):
|
||||||
analytics_flush_interval: float = 5.0
|
analytics_flush_interval: float = 5.0
|
||||||
analytics_buffer_max: int = 5000
|
analytics_buffer_max: int = 5000
|
||||||
|
|
||||||
|
#: Сколько секунд бот держит шаблоны сообщений в памяти (правки из админки
|
||||||
|
#: применяются без перезапуска). 0 — читать БД при каждом обращении.
|
||||||
|
template_cache_ttl: float = 45.0
|
||||||
|
|
||||||
@field_validator("chat_id", "alert_chat_id", mode="before")
|
@field_validator("chat_id", "alert_chat_id", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _empty_int_to_zero(cls, value: object) -> object:
|
def _empty_int_to_zero(cls, value: object) -> object:
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,14 @@ from typing import Any
|
||||||
from common.db.pool import get_pool
|
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 def list_templates() -> list[dict[str, Any]]:
|
||||||
"""Вернуть список шаблонов без текста (для таблицы в админке)."""
|
"""Вернуть список шаблонов без текста (для таблицы в админке)."""
|
||||||
async with get_pool().connection() as conn:
|
async with get_pool().connection() as conn:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -7,4 +7,4 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
__version__ = "0.4.1"
|
__version__ = "0.4.2"
|
||||||
|
|
|
||||||
|
|
@ -43,3 +43,6 @@ CLICKHOUSE_HTTP_PORT=8123
|
||||||
# Админка (FastAPI). Порт на loopback хоста; у test и prod должен различаться.
|
# Админка (FastAPI). Порт на loopback хоста; у test и prod должен различаться.
|
||||||
# Наружу пока не публикуется (домен/TLS — Блок 2.4).
|
# Наружу пока не публикуется (домен/TLS — Блок 2.4).
|
||||||
ADMIN_HTTP_PORT=8000
|
ADMIN_HTTP_PORT=8000
|
||||||
|
|
||||||
|
# Шаблоны сообщений: TTL кеша бота (правки из админки без перезапуска).
|
||||||
|
TEMPLATE_CACHE_TTL=45
|
||||||
|
|
|
||||||
|
|
@ -41,3 +41,6 @@ CLICKHOUSE_HTTP_PORT=8124
|
||||||
# Админка (FastAPI). Порт на loopback хоста; у test и prod должен различаться.
|
# Админка (FastAPI). Порт на loopback хоста; у test и prod должен различаться.
|
||||||
# Наружу пока не публикуется (домен/TLS — Блок 2.4).
|
# Наружу пока не публикуется (домен/TLS — Блок 2.4).
|
||||||
ADMIN_HTTP_PORT=8001
|
ADMIN_HTTP_PORT=8001
|
||||||
|
|
||||||
|
# Шаблоны сообщений: TTL кеша бота (правки из админки без перезапуска).
|
||||||
|
TEMPLATE_CACHE_TTL=45
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -7,4 +7,4 @@ from common import __version__
|
||||||
|
|
||||||
def test_version_is_set():
|
def test_version_is_set():
|
||||||
assert __version__
|
assert __version__
|
||||||
assert __version__ == "0.4.1"
|
assert __version__ == "0.4.2"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue