Уведомления: непрочитанные не протухают — игрок увидит их при любом заходе
Раньше уведомления удалялись через 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)
|
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
|
||||||
|
|||||||
@@ -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__ = (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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, "Боб")
|
||||||
|
old = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(
|
||||||
|
hours=notification_service.RETENTION_HOURS + 1
|
||||||
|
)
|
||||||
with Session(engine) as s:
|
with Session(engine) as s:
|
||||||
n = Notification(user_id=uid, type="test", title="Старое")
|
# Старое непрочитанное, старое давно прочитанное и свежепрочитанное.
|
||||||
s.add(n)
|
s.add(Notification(user_id=uid, type="test", title="Непрочитанное", created_at=old))
|
||||||
s.commit()
|
s.add(
|
||||||
s.refresh(n)
|
Notification(
|
||||||
# Состарим запись за пределы окна хранения.
|
user_id=uid, type="test", title="Давно прочитанное", created_at=old, read_at=old
|
||||||
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,
|
||||||
|
read_at=datetime.now(timezone.utc).replace(tzinfo=None),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
s.add(n)
|
|
||||||
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):
|
||||||
|
|||||||
Reference in New Issue
Block a user