diff --git a/backend/app/main.py b/backend/app/main.py index 6b53aaa..46bccc4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -72,12 +72,12 @@ class CSRFMiddleware: await self.app(scope, receive, send) -_NOTIFICATIONS_PURGE_INTERVAL = 3600 # раз в час чистим протухшие уведомления (>72ч) +_NOTIFICATIONS_PURGE_INTERVAL = 3600 # раз в час чистим давно прочитанные уведомления async def _notifications_purge_loop() -> None: - """Фоновая чистка протухших уведомлений (single-worker безопасно). Чтобы удалялись - «отовсюду» даже у неактивных пользователей (помимо очистки при чтении списка).""" + """Фоновая чистка давно прочитанных уведомлений (single-worker безопасно). Чтобы + удалялись «отовсюду» даже у неактивных пользователей (помимо очистки при чтении).""" import asyncio from app.db.session import Session, engine diff --git a/backend/app/models.py b/backend/app/models.py index adb9f01..478d619 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -344,7 +344,8 @@ class Notification(SQLModel, table=True): """Персистентное уведомление игроку (приглашение, старт/финиш партии, титул и т.д.). Текст (`title`/`body`, RU) и ссылку (`link` — относительный SPA-путь) рендерит сервер — - фронт лишь отображает. Хранятся 72 часа; протухшие чистятся фоном и при чтении списка.""" + фронт лишь отображает. Непрочитанные хранятся до прочтения (кап на пользователя), + прочитанные чистятся спустя 72 часа — фоном и при чтении списка.""" __tablename__ = "notifications" __table_args__ = ( diff --git a/backend/app/routers/notifications.py b/backend/app/routers/notifications.py index 080ce2a..1f46b28 100644 --- a/backend/app/routers/notifications.py +++ b/backend/app/routers/notifications.py @@ -1,7 +1,8 @@ """Уведомления игрока: список (pull) и отметка прочитанными. Появление в реальном времени обеспечивает SSE-сигнал `{type:"notifications"}` — по нему клиент -перезапрашивает этот список. Хранение — 72 часа; протухшие чистятся при чтении (и фоном).""" +перезапрашивает этот список. Непрочитанные хранятся до прочтения; прочитанные чистятся +спустя 72 часа (при чтении и фоном).""" from __future__ import annotations from fastapi import APIRouter, Depends diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index 2c646b1..cd3c80d 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -2,7 +2,9 @@ Запись в БД + живой сигнал по SSE (`notify.notifications_changed`) — клиент по сигналу подтягивает список (`GET /api/notifications`). Текст (RU) и ссылку рендерим здесь, на сервере. -Хранение — 72 часа; протухшие удаляются при чтении списка и фоновой задачей. +Хранение: непрочитанные живут до прочтения (игрок должен увидеть их, когда бы ни зашёл), +прочитанные удаляются спустя RETENTION_HOURS после прочтения — при чтении списка и фоновой +задачей. От роста у незаходящих защищает кап MAX_PER_USER при создании. """ from __future__ import annotations @@ -14,7 +16,8 @@ from app.core.timeutil import iso_utc from app.models import Group, Match, MatchParticipant, Notification from app.services import notify -RETENTION_HOURS = 72 +RETENTION_HOURS = 72 # сколько живёт ПРОЧИТАННОЕ уведомление после прочтения +MAX_PER_USER = 100 # кап на пользователя: старейшие сверх него удаляются при создании def _now() -> datetime: @@ -45,6 +48,9 @@ def create_for( Notification(user_id=uid, type=type, title=title, body=body, link=link) for uid in ids ] session.add_all(rows) + session.flush() + for uid in ids: + _cap_user(session, uid) session.commit() for uid in ids: notify.notifications_changed(uid) @@ -64,10 +70,27 @@ def create( return rows[0] if rows else None +def _cap_user(session: Session, user_id: int) -> None: + """Удалить старейшие уведомления пользователя сверх MAX_PER_USER (без commit).""" + extra = session.exec( + select(Notification) + .where(Notification.user_id == user_id) + .order_by(Notification.created_at.desc(), Notification.id.desc()) + .offset(MAX_PER_USER) + ).all() + for row in extra: + session.delete(row) + + def purge_expired(session: Session) -> int: - """Удалить уведомления старше RETENTION_HOURS. Возвращает число удалённых.""" + """Удалить ПРОЧИТАННЫЕ уведомления спустя RETENTION_HOURS после прочтения. + + Непрочитанные по времени не протухают — иначе игрок, не заходивший дольше окна + хранения, не увидел бы их вовсе. Возвращает число удалённых.""" rows = session.exec( - select(Notification).where(Notification.created_at < _cutoff()) + select(Notification).where( + Notification.read_at.is_not(None), Notification.read_at < _cutoff() + ) ).all() for row in rows: session.delete(row) @@ -77,7 +100,7 @@ def purge_expired(session: Session) -> int: def list_for_user(session: Session, user_id: int) -> dict: - """Свежие (<72ч) уведомления пользователя + число непрочитанных. Чистит протухшие.""" + """Уведомления пользователя + число непрочитанных. Попутно чистит давно прочитанные.""" purge_expired(session) rows = session.exec( select(Notification) diff --git a/backend/tests/test_notifications.py b/backend/tests/test_notifications.py index f76b119..fc10232 100644 --- a/backend/tests/test_notifications.py +++ b/backend/tests/test_notifications.py @@ -56,23 +56,49 @@ def test_create_list_mark_read(engine): assert after["items"][0]["read_at"] is not None -def test_purge_expired(engine): +def test_purge_removes_only_long_read(engine): + """Протухают лишь ПРОЧИТАННЫЕ (спустя 72ч после прочтения): непрочитанное должно + дождаться игрока, сколько бы тот ни отсутствовал.""" uid = _make_user(engine, "Боб") + old = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta( + hours=notification_service.RETENTION_HOURS + 1 + ) with Session(engine) as s: - n = Notification(user_id=uid, type="test", title="Старое") - s.add(n) - s.commit() - s.refresh(n) - # Состарим запись за пределы окна хранения. - n.created_at = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta( - hours=notification_service.RETENTION_HOURS + 1 + # Старое непрочитанное, старое давно прочитанное и свежепрочитанное. + s.add(Notification(user_id=uid, type="test", title="Непрочитанное", created_at=old)) + s.add( + Notification( + user_id=uid, type="test", title="Давно прочитанное", created_at=old, read_at=old + ) + ) + s.add( + Notification( + user_id=uid, + type="test", + title="Свежепрочитанное", + created_at=old, + read_at=datetime.now(timezone.utc).replace(tzinfo=None), + ) ) - s.add(n) s.commit() removed = notification_service.purge_expired(s) assert removed == 1 - assert notification_service.list_for_user(s, uid)["items"] == [] + titles = {n["title"] for n in notification_service.list_for_user(s, uid)["items"]} + assert titles == {"Непрочитанное", "Свежепрочитанное"} + + +def test_cap_per_user(engine): + """Кап MAX_PER_USER: при создании сверх лимита удаляются старейшие.""" + uid = _make_user(engine, "Копитель") + with Session(engine) as s: + for i in range(notification_service.MAX_PER_USER + 5): + notification_service.create(s, uid, type="test", title=f"n{i}") + data = notification_service.list_for_user(s, uid) + assert len(data["items"]) == notification_service.MAX_PER_USER + titles = {n["title"] for n in data["items"]} + assert "n0" not in titles # старейшие срезаны + assert f"n{notification_service.MAX_PER_USER + 4}" in titles # новейшее на месте def test_notifications_require_auth(client: TestClient):