Уведомления: persistent + live (push-to-invalidate → pull)

Новая система уведомлений поверх готовой SSE-шины: события пишутся в БД,
живут 72 часа и чистятся (при чтении списка + фоновой задачей), всплывают
сверху экрана в момент прихода, доступны через колокольчик в правом верхнем
углу (бейдж непрочитанных + панель).

Типы: приглашение в группу (→ /group), старт/финиш партии участникам кроме
инициатора (→ /match/{id}). Титулы — готовый хелпер-задел (не подключён, т.к.
выдача титулов игрокам ещё не реализована).

Бэкенд: модель Notification + миграция 0008 (идемпотентная), notification_service,
notify.notifications_changed, роутер /api/notifications (GET + /read), триггеры
в groups/matches, фоновая чистка в lifespan, защита hub.publish от закрытого loop.
Фронт: useNotifications/useMarkNotificationsRead, NotificationBell/Panel/Toaster,
перекомпоновка top-bar, стили; useServerEvents знает тип notifications.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 15:16:40 +03:00
co-authored by Claude Opus 4.8
parent ef5c242e16
commit f4286ffa6c
20 changed files with 939 additions and 13 deletions
@@ -0,0 +1,55 @@
"""Уведомления: таблица notifications (персистентные уведомления игроку).
Идемпотентна (как 0006/0007): на свежей БД таблицу создаёт 0001 (create_all); на существующей —
create_table здесь.
Revision ID: 0008_notifications
Revises: 0007_match_attachments
Create Date: 2026-06-26
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
revision: str = "0008_notifications"
down_revision: Union[str, None] = "0007_match_attachments"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
if "notifications" in insp.get_table_names():
return
op.create_table(
"notifications",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("type", sa.String(32), nullable=False),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("body", sa.Text(), nullable=True),
sa.Column("link", sa.String(255), nullable=True),
sa.Column("read_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
)
op.create_index("ix_notifications_user_id", "notifications", ["user_id"])
op.create_index("ix_notifications_created_at", "notifications", ["created_at"])
op.create_index(
"ix_notifications_user_created", "notifications", ["user_id", "created_at"]
)
def downgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
if "notifications" in insp.get_table_names():
op.drop_table("notifications")
+5 -2
View File
@@ -37,11 +37,14 @@ class EventHub:
def publish(self, user_ids: Iterable[int], event: dict[str, Any]) -> None:
"""Доставить событие подписчикам с указанными user_id (потокобезопасно)."""
loop = self._loop
if loop is None:
if loop is None or loop.is_closed():
return
ids = [uid for uid in user_ids if uid is not None]
if ids:
loop.call_soon_threadsafe(self._fanout, ids, event)
try:
loop.call_soon_threadsafe(self._fanout, ids, event)
except RuntimeError:
pass # loop закрылся между проверкой и вызовом (напр. при shutdown)
def _fanout(self, ids: list[int], event: dict[str, Any]) -> None:
# Выполняется на потоке loop'а — доступ к _subs безопасен.
+33 -2
View File
@@ -22,6 +22,7 @@ from app.routers import (
groups,
invitations,
matches,
notifications,
reference,
stats,
users,
@@ -71,6 +72,31 @@ class CSRFMiddleware:
await self.app(scope, receive, send)
_NOTIFICATIONS_PURGE_INTERVAL = 3600 # раз в час чистим протухшие уведомления (>72ч)
async def _notifications_purge_loop() -> None:
"""Фоновая чистка протухших уведомлений (single-worker безопасно). Чтобы удалялись
«отовсюду» даже у неактивных пользователей (помимо очистки при чтении списка)."""
import asyncio
from app.db.session import Session, engine
from app.services import notification_service
def _purge_once() -> None:
with Session(engine) as session:
notification_service.purge_expired(session)
while True:
try:
await asyncio.sleep(_NOTIFICATIONS_PURGE_INTERVAL)
await asyncio.to_thread(_purge_once)
except asyncio.CancelledError:
break
except Exception as exc: # noqa: BLE001
logging.getLogger("fs").warning("Чистка уведомлений пропущена: %s", exc)
@asynccontextmanager
async def _lifespan(_app: FastAPI):
# SSE-шина публикует из sync-роутеров в этот event-loop — сохраняем ссылку (все окружения).
@@ -91,7 +117,12 @@ async def _lifespan(_app: FastAPI):
logging.getLogger("fs").warning(
"Стартовый bootstrap пропущен (примените миграции): %s", exc
)
yield
purge_task = asyncio.create_task(_notifications_purge_loop())
try:
yield
finally:
purge_task.cancel()
def create_app() -> FastAPI:
@@ -135,7 +166,7 @@ def create_app() -> FastAPI:
# API-роутеры под /api.
api_routers = [auth.router, users.router, groups.router, invitations.router,
matches.router, reference.router, stats.router, achievements.router,
events.router, admin.router]
events.router, notifications.router, admin.router]
for r in api_routers:
app.include_router(r, prefix="/api")
+27
View File
@@ -336,6 +336,33 @@ class MatchAttachment(SQLModel, table=True):
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
# ─── Уведомления ─────────────────────────────────────────────────────────────
class Notification(SQLModel, table=True):
"""Персистентное уведомление игроку (приглашение, старт/финиш партии, титул и т.д.).
Текст (`title`/`body`, RU) и ссылку (`link` — относительный SPA-путь) рендерит сервер —
фронт лишь отображает. Хранятся 72 часа; протухшие чистятся фоном и при чтении списка."""
__tablename__ = "notifications"
__table_args__ = (
Index("ix_notifications_user_created", "user_id", "created_at"),
)
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(
sa_column=Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
)
type: str = Field(sa_column=Column(String(32), nullable=False))
title: str = Field(sa_column=Column(String(255), nullable=False))
body: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
link: str | None = Field(default=None, sa_column=Column(String(255), nullable=True))
read_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True))
created_at: datetime = Field(default_factory=_utcnow, nullable=False, index=True)
# ─── Журнал аудита ───────────────────────────────────────────────────────────
class AuditLog(SQLModel, table=True):
+4
View File
@@ -14,6 +14,7 @@ from app.services import (
group_service,
invitation_service,
membership_service,
notification_service,
notify,
stats_service,
)
@@ -155,6 +156,9 @@ def invite_member(
)
session.commit()
notify.invitations_changed(invited.id) # type: ignore[arg-type] # живое появление у получателя
notification_service.invited_to_group(
session, invited.id, group.name, user.nickname # type: ignore[arg-type]
)
return s.InvitationRead(
id=inv.id, # type: ignore[arg-type]
group_id=group_id,
+3
View File
@@ -16,6 +16,7 @@ from app.services import (
audit_service,
group_service,
match_service,
notification_service,
notify,
user_service,
)
@@ -121,6 +122,7 @@ def start_match(
)
session.commit()
notify.match_changed(session, match)
notification_service.match_started(session, match, user.id) # type: ignore[arg-type]
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
@@ -160,6 +162,7 @@ def finish_match(
)
session.commit()
notify.match_changed(session, match)
notification_service.match_finished(session, match, user.id) # type: ignore[arg-type]
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
+34
View File
@@ -0,0 +1,34 @@
"""Уведомления игрока: список (pull) и отметка прочитанными.
Появление в реальном времени обеспечивает SSE-сигнал `{type:"notifications"}` — по нему клиент
перезапрашивает этот список. Хранение — 72 часа; протухшие чистятся при чтении (и фоном)."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlmodel import Session
from app.auth.deps import get_current_user
from app.db.session import get_session
from app.models import User
from app.schemas import api as s
from app.services import notification_service
router = APIRouter(prefix="/notifications", tags=["notifications"])
@router.get("", response_model=s.NotificationList)
def my_notifications(
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> dict:
return notification_service.list_for_user(session, user.id) # type: ignore[arg-type]
@router.post("/read", response_model=s.OkResponse)
def mark_read(
body: s.NotificationMarkRead,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.OkResponse:
notification_service.mark_read(session, user.id, body.ids) # type: ignore[arg-type]
return s.OkResponse()
+21
View File
@@ -157,6 +157,27 @@ class InvitationRead(BaseModel):
created_at: str
# ─── Уведомления ───────────────────────────────────────────────────────────────
class NotificationRead(BaseModel):
id: int
type: str
title: str
body: str | None = None
link: str | None = None
read_at: str | None = None
created_at: str
class NotificationList(BaseModel):
items: list[NotificationRead]
unread_count: int
class NotificationMarkRead(BaseModel):
ids: list[int] | None = None
# ─── Партии ──────────────────────────────────────────────────────────────────
class RandomizeRequest(BaseModel):
@@ -0,0 +1,190 @@
"""Персистентные уведомления игрокам (приглашения, старт/финиш партии, титулы).
Запись в БД + живой сигнал по SSE (`notify.notifications_changed`) — клиент по сигналу
подтягивает список (`GET /api/notifications`). Текст (RU) и ссылку рендерим здесь, на сервере.
Хранение — 72 часа; протухшие удаляются при чтении списка и фоновой задачей.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlmodel import Session, select
from app.core.timeutil import iso_utc
from app.models import Group, Match, MatchParticipant, Notification
from app.services import notify
RETENTION_HOURS = 72
def _now() -> datetime:
return datetime.now(timezone.utc)
def _cutoff() -> datetime:
# Наивный UTC — сопоставимо с тем, как SQLite отдаёт сохранённые datetime.
return _now().replace(tzinfo=None) - timedelta(hours=RETENTION_HOURS)
# ─── Базовые операции ─────────────────────────────────────────────────────────
def create_for(
session: Session,
user_ids,
*,
type: str,
title: str,
body: str | None = None,
link: str | None = None,
) -> list[Notification]:
"""Создать одно уведомление каждому из user_ids (один commit) и пушнуть им SSE-сигнал."""
ids = [uid for uid in dict.fromkeys(user_ids) if uid is not None]
if not ids:
return []
rows = [
Notification(user_id=uid, type=type, title=title, body=body, link=link) for uid in ids
]
session.add_all(rows)
session.commit()
for uid in ids:
notify.notifications_changed(uid)
return rows
def create(
session: Session,
user_id: int,
*,
type: str,
title: str,
body: str | None = None,
link: str | None = None,
) -> Notification | None:
rows = create_for(session, [user_id], type=type, title=title, body=body, link=link)
return rows[0] if rows else None
def purge_expired(session: Session) -> int:
"""Удалить уведомления старше RETENTION_HOURS. Возвращает число удалённых."""
rows = session.exec(
select(Notification).where(Notification.created_at < _cutoff())
).all()
for row in rows:
session.delete(row)
if rows:
session.commit()
return len(rows)
def list_for_user(session: Session, user_id: int) -> dict:
"""Свежие (<72ч) уведомления пользователя + число непрочитанных. Чистит протухшие."""
purge_expired(session)
rows = session.exec(
select(Notification)
.where(Notification.user_id == user_id)
.order_by(Notification.created_at.desc())
).all()
items = [
{
"id": n.id,
"type": n.type,
"title": n.title,
"body": n.body,
"link": n.link,
"read_at": iso_utc(n.read_at),
"created_at": iso_utc(n.created_at),
}
for n in rows
]
unread = sum(1 for n in rows if n.read_at is None)
return {"items": items, "unread_count": unread}
def mark_read(session: Session, user_id: int, ids: list[int] | None = None) -> int:
"""Отметить прочитанными все непрочитанные пользователя (или указанные по id)."""
query = select(Notification).where(
Notification.user_id == user_id, Notification.read_at.is_(None)
)
if ids:
query = query.where(Notification.id.in_(ids))
rows = session.exec(query).all()
now = _now()
for row in rows:
row.read_at = now
session.add(row)
if rows:
session.commit()
return len(rows)
# ─── Типовые хелперы под триггеры ──────────────────────────────────────────────
def _match_participant_ids(session: Session, match_id: int) -> list[int]:
return list(
session.exec(
select(MatchParticipant.user_id).where(MatchParticipant.match_id == match_id)
).all()
)
def _group_name(session: Session, group_id: int) -> str | None:
group = session.get(Group, group_id)
return group.name if group else None
def invited_to_group(
session: Session, user_id: int, group_name: str, inviter_nickname: str | None = None
) -> None:
create(
session,
user_id,
type="invite",
title=f"Приглашение в группу «{group_name}»",
body=f"пригласил: {inviter_nickname}" if inviter_nickname else None,
link="/group",
)
def match_started(session: Session, match: Match, actor_id: int) -> None:
ids = [uid for uid in _match_participant_ids(session, match.id) if uid != actor_id] # type: ignore[arg-type]
name = _group_name(session, match.group_id)
create_for(
session,
ids,
type="match_started",
title="Началась партия",
body=f"Группа «{name}»" if name else None,
link=f"/match/{match.id}",
)
def match_finished(session: Session, match: Match, actor_id: int) -> None:
ids = [uid for uid in _match_participant_ids(session, match.id) if uid != actor_id] # type: ignore[arg-type]
name = _group_name(session, match.group_id)
create_for(
session,
ids,
type="match_finished",
title="Партия завершена",
body=f"Группа «{name}»" if name else None,
link=f"/match/{match.id}",
)
def title_earned(session: Session, user_id: int, achievement_slug: str) -> None:
"""ЗАДЕЛ: уведомление о полученном титуле. Пока нигде не вызывается — заработает,
когда появится механизм выдачи титулов игрокам."""
name = achievement_slug
try:
from app.services import achievement_service
name = achievement_service.get(achievement_slug).get("name", achievement_slug)
except Exception: # noqa: BLE001
pass
create(
session,
user_id,
type="title",
title=f"Вы получили титул: {name}",
link="/account",
)
+5
View File
@@ -44,3 +44,8 @@ def group_changed(session: Session, group_id: int, extra_user_ids: list[int] | N
def invitations_changed(user_id: int) -> None:
"""У пользователя изменился список приглашений."""
hub.publish([user_id], {"type": "invitations"})
def notifications_changed(user_id: int) -> None:
"""У пользователя появилось/изменилось уведомление — пусть подтянет список."""
hub.publish([user_id], {"type": "notifications"})
+158
View File
@@ -0,0 +1,158 @@
"""Уведомления: сервис (create/list/mark_read/purge) и триггеры (инвайт, старт/финиш партии)."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from fastapi.testclient import TestClient
from sqlmodel import Session
from app.models import Notification, User
from app.services import notification_service
from tests.conftest import (
add_group_member,
csrf_headers,
finish_match,
login,
start_match,
)
def _group(client: TestClient, name: str = "Группа") -> int:
return client.post(
"/api/groups", json={"name": name, "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"]
def _dev_user(client: TestClient, nick: str) -> None:
client.post("/api/auth/dev/users", json={"nickname": nick}, headers=csrf_headers(client))
def _make_user(engine, nick: str) -> int:
with Session(engine) as s:
u = User(nickname=nick, role="player", auth_provider="stub")
s.add(u)
s.commit()
s.refresh(u)
return u.id # type: ignore[return-value]
# ─── Сервис ────────────────────────────────────────────────────────────────────
def test_create_list_mark_read(engine):
uid = _make_user(engine, "Алиса")
with Session(engine) as s:
notification_service.create(s, uid, type="test", title="Привет", link="/account")
data = notification_service.list_for_user(s, uid)
assert data["unread_count"] == 1
assert len(data["items"]) == 1
assert data["items"][0]["title"] == "Привет"
assert data["items"][0]["link"] == "/account"
assert data["items"][0]["read_at"] is None
marked = notification_service.mark_read(s, uid)
assert marked == 1
after = notification_service.list_for_user(s, uid)
assert after["unread_count"] == 0
assert after["items"][0]["read_at"] is not None
def test_purge_expired(engine):
uid = _make_user(engine, "Боб")
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(n)
s.commit()
removed = notification_service.purge_expired(s)
assert removed == 1
assert notification_service.list_for_user(s, uid)["items"] == []
def test_notifications_require_auth(client: TestClient):
assert client.get("/api/notifications").status_code == 401
# ─── Триггеры через HTTP ─────────────────────────────────────────────────────────
def test_invitation_creates_notification(client: TestClient):
login(client, "Овнер")
gid = _group(client)
_dev_user(client, "Гость")
r = client.post(
f"/api/groups/{gid}/invitations", json={"nickname": "Гость"}, headers=csrf_headers(client)
)
assert r.status_code == 200, r.text
login(client, "Гость")
data = client.get("/api/notifications").json()
invites = [n for n in data["items"] if n["type"] == "invite"]
assert len(invites) == 1
assert invites[0]["link"] == "/group"
assert "Группа" in invites[0]["title"]
assert data["unread_count"] >= 1
def test_match_notifications_exclude_actor(client: TestClient, engine):
me = login(client, "Капитан")
gid = _group(client)
member_id = add_group_member(engine, gid, "Боец")
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
started = start_match(
client,
gid,
[
{"user_id": me["id"], "faction_id": fids[0]},
{"user_id": member_id, "faction_id": fids[1]},
],
)
assert started.status_code == 200, started.text
mid = started.json()["id"]
# Инициатор (Капитан) себе уведомление не шлёт.
mine = client.get("/api/notifications").json()
assert all(n["type"] != "match_started" for n in mine["items"])
# Участник получает уведомление о старте со ссылкой на партию.
login(client, "Боец")
his = client.get("/api/notifications").json()
started_notifs = [n for n in his["items"] if n["type"] == "match_started"]
assert len(started_notifs) == 1
assert started_notifs[0]["link"] == f"/match/{mid}"
# Финиш (инициатор снова Капитан) → участник получает уведомление о завершении.
login(client, "Капитан")
fin = finish_match(
client,
mid,
[{"user_id": me["id"], "place": 1}, {"user_id": member_id, "place": 2}],
)
assert fin.status_code == 200, fin.text
login(client, "Боец")
his2 = client.get("/api/notifications").json()
assert any(
n["type"] == "match_finished" and n["link"] == f"/match/{mid}" for n in his2["items"]
)
def test_mark_read_endpoint(client: TestClient):
login(client, "Овнер2")
gid = _group(client)
_dev_user(client, "Гость2")
client.post(
f"/api/groups/{gid}/invitations", json={"nickname": "Гость2"}, headers=csrf_headers(client)
)
login(client, "Гость2")
assert client.get("/api/notifications").json()["unread_count"] >= 1
r = client.post("/api/notifications/read", json={}, headers=csrf_headers(client))
assert r.status_code == 200, r.text
assert client.get("/api/notifications").json()["unread_count"] == 0