Уведомления: непрочитанные не протухают — игрок увидит их при любом заходе
Раньше уведомления удалялись через 72 часа после создания независимо от прочтения: кто не зашёл за 3 дня, не видел их вовсе. Теперь по времени чистятся только прочитанные (72ч после прочтения), а от роста у незаходящих защищает кап MAX_PER_USER=100 — при создании удаляются старейшие сверх лимита. Механика доставки не менялась: запись в БД + pull при заходе, SSE лишь ускоряет обновление открытых вкладок. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+3
-3
@@ -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
|
||||
|
||||
@@ -344,7 +344,8 @@ class Notification(SQLModel, table=True):
|
||||
"""Персистентное уведомление игроку (приглашение, старт/финиш партии, титул и т.д.).
|
||||
|
||||
Текст (`title`/`body`, RU) и ссылку (`link` — относительный SPA-путь) рендерит сервер —
|
||||
фронт лишь отображает. Хранятся 72 часа; протухшие чистятся фоном и при чтении списка."""
|
||||
фронт лишь отображает. Непрочитанные хранятся до прочтения (кап на пользователя),
|
||||
прочитанные чистятся спустя 72 часа — фоном и при чтении списка."""
|
||||
|
||||
__tablename__ = "notifications"
|
||||
__table_args__ = (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Уведомления игрока: список (pull) и отметка прочитанными.
|
||||
|
||||
Появление в реальном времени обеспечивает SSE-сигнал `{type:"notifications"}` — по нему клиент
|
||||
перезапрашивает этот список. Хранение — 72 часа; протухшие чистятся при чтении (и фоном)."""
|
||||
перезапрашивает этот список. Непрочитанные хранятся до прочтения; прочитанные чистятся
|
||||
спустя 72 часа (при чтении и фоном)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user