Уведомления: непрочитанные не протухают — игрок увидит их при любом заходе

Раньше уведомления удалялись через 72 часа после создания независимо от
прочтения: кто не зашёл за 3 дня, не видел их вовсе. Теперь по времени
чистятся только прочитанные (72ч после прочтения), а от роста у
незаходящих защищает кап MAX_PER_USER=100 — при создании удаляются
старейшие сверх лимита. Механика доставки не менялась: запись в БД +
pull при заходе, SSE лишь ускоряет обновление открытых вкладок.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 04:08:00 +03:00
co-authored by Claude Fable 5
parent 1dbff7ae58
commit 5c3aa6ba69
5 changed files with 71 additions and 20 deletions
+3 -3
View File
@@ -72,12 +72,12 @@ class CSRFMiddleware:
await self.app(scope, receive, send) await self.app(scope, receive, send)
_NOTIFICATIONS_PURGE_INTERVAL = 3600 # раз в час чистим протухшие уведомления (>72ч) _NOTIFICATIONS_PURGE_INTERVAL = 3600 # раз в час чистим давно прочитанные уведомления
async def _notifications_purge_loop() -> None: async def _notifications_purge_loop() -> None:
"""Фоновая чистка протухших уведомлений (single-worker безопасно). Чтобы удалялись """Фоновая чистка давно прочитанных уведомлений (single-worker безопасно). Чтобы
«отовсюду» даже у неактивных пользователей (помимо очистки при чтении списка).""" удалялись «отовсюду» даже у неактивных пользователей (помимо очистки при чтении)."""
import asyncio import asyncio
from app.db.session import Session, engine from app.db.session import Session, engine
+2 -1
View File
@@ -344,7 +344,8 @@ class Notification(SQLModel, table=True):
"""Персистентное уведомление игроку (приглашение, старт/финиш партии, титул и т.д.). """Персистентное уведомление игроку (приглашение, старт/финиш партии, титул и т.д.).
Текст (`title`/`body`, RU) и ссылку (`link` — относительный SPA-путь) рендерит сервер — Текст (`title`/`body`, RU) и ссылку (`link` — относительный SPA-путь) рендерит сервер —
фронт лишь отображает. Хранятся 72 часа; протухшие чистятся фоном и при чтении списка.""" фронт лишь отображает. Непрочитанные хранятся до прочтения (кап на пользователя),
прочитанные чистятся спустя 72 часа — фоном и при чтении списка."""
__tablename__ = "notifications" __tablename__ = "notifications"
__table_args__ = ( __table_args__ = (
+2 -1
View File
@@ -1,7 +1,8 @@
"""Уведомления игрока: список (pull) и отметка прочитанными. """Уведомления игрока: список (pull) и отметка прочитанными.
Появление в реальном времени обеспечивает SSE-сигнал `{type:"notifications"}` — по нему клиент Появление в реальном времени обеспечивает SSE-сигнал `{type:"notifications"}` — по нему клиент
перезапрашивает этот список. Хранение — 72 часа; протухшие чистятся при чтении (и фоном).""" перезапрашивает этот список. Непрочитанные хранятся до прочтения; прочитанные чистятся
спустя 72 часа (при чтении и фоном)."""
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
+28 -5
View File
@@ -2,7 +2,9 @@
Запись в БД + живой сигнал по SSE (`notify.notifications_changed`) — клиент по сигналу Запись в БД + живой сигнал по SSE (`notify.notifications_changed`) — клиент по сигналу
подтягивает список (`GET /api/notifications`). Текст (RU) и ссылку рендерим здесь, на сервере. подтягивает список (`GET /api/notifications`). Текст (RU) и ссылку рендерим здесь, на сервере.
Хранение — 72 часа; протухшие удаляются при чтении списка и фоновой задачей. Хранение: непрочитанные живут до прочтения (игрок должен увидеть их, когда бы ни зашёл),
прочитанные удаляются спустя RETENTION_HOURS после прочтения — при чтении списка и фоновой
задачей. От роста у незаходящих защищает кап MAX_PER_USER при создании.
""" """
from __future__ import annotations 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.models import Group, Match, MatchParticipant, Notification
from app.services import notify from app.services import notify
RETENTION_HOURS = 72 RETENTION_HOURS = 72 # сколько живёт ПРОЧИТАННОЕ уведомление после прочтения
MAX_PER_USER = 100 # кап на пользователя: старейшие сверх него удаляются при создании
def _now() -> datetime: 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 Notification(user_id=uid, type=type, title=title, body=body, link=link) for uid in ids
] ]
session.add_all(rows) session.add_all(rows)
session.flush()
for uid in ids:
_cap_user(session, uid)
session.commit() session.commit()
for uid in ids: for uid in ids:
notify.notifications_changed(uid) notify.notifications_changed(uid)
@@ -64,10 +70,27 @@ def create(
return rows[0] if rows else None 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: def purge_expired(session: Session) -> int:
"""Удалить уведомления старше RETENTION_HOURS. Возвращает число удалённых.""" """Удалить ПРОЧИТАННЫЕ уведомления спустя RETENTION_HOURS после прочтения.
Непрочитанные по времени не протухают — иначе игрок, не заходивший дольше окна
хранения, не увидел бы их вовсе. Возвращает число удалённых."""
rows = session.exec( rows = session.exec(
select(Notification).where(Notification.created_at < _cutoff()) select(Notification).where(
Notification.read_at.is_not(None), Notification.read_at < _cutoff()
)
).all() ).all()
for row in rows: for row in rows:
session.delete(row) session.delete(row)
@@ -77,7 +100,7 @@ def purge_expired(session: Session) -> int:
def list_for_user(session: Session, user_id: int) -> dict: def list_for_user(session: Session, user_id: int) -> dict:
"""Свежие (<72ч) уведомления пользователя + число непрочитанных. Чистит протухшие.""" """Уведомления пользователя + число непрочитанных. Попутно чистит давно прочитанные."""
purge_expired(session) purge_expired(session)
rows = session.exec( rows = session.exec(
select(Notification) select(Notification)
+36 -10
View File
@@ -56,23 +56,49 @@ def test_create_list_mark_read(engine):
assert after["items"][0]["read_at"] is not None 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, "Боб") uid = _make_user(engine, "Боб")
with Session(engine) as s: old = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(
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 hours=notification_service.RETENTION_HOURS + 1
) )
s.add(n) with Session(engine) as s:
# Старое непрочитанное, старое давно прочитанное и свежепрочитанное.
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.commit() s.commit()
removed = notification_service.purge_expired(s) removed = notification_service.purge_expired(s)
assert removed == 1 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): def test_notifications_require_auth(client: TestClient):