Находки прохода /code-review high по backend/app: - achievement_service: slug из URL шёл в путь без проверки, из-за чего DELETE /api/admin/achievements/%2E%2E удалял rmtree'ом родительскую папку каталога ачивок (в проде это /data — БД, uploads, ачивки целиком). - match_service/attachment_service: версия партии = updated_at, но onupdate срабатывает лишь при реальном UPDATE строки matches. Правка одних участников и работа с вложениями его не вызывали, и оптимистичная блокировка молча пропускала конкурентную запись — бампаем updated_at явно. - admin_service: удаление группы с партиями упиралось в RESTRICT и уходило наружу голым 500; теперь понятная ошибка. Админское удаление партии не чистило файлы вложений с тома — они оставались навсегда. - user_service: при повторной загрузке аватара с тем же расширением avatar_path не менялся, updated_at не двигался, и кэш-бастер оставлял старую картинку до часа. Плюс версия считалась из наивного времени как из локального и разъезжалась с лидербордом, где то же поле считает SQL. - membership_service: единственный владелец мог разжаловать сам себя и группа оставалась без владельца навсегда. - notification_service: mark_read не слал SSE-сигнал, и бейдж непрочитанных на других устройствах висел до перезагрузки. - routers/admin: created_at после правки пользователя отдавался без смещения, и дата «создан» прыгала на часовой пояс до следующего обновления списка. #8 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0186Fk74jkkszahEHSjBzTjD
217 lines
8.3 KiB
Python
217 lines
8.3 KiB
Python
"""Персистентные уведомления игрокам (приглашения, старт/финиш партии, титулы).
|
|
|
|
Запись в БД + живой сигнал по SSE (`notify.notifications_changed`) — клиент по сигналу
|
|
подтягивает список (`GET /api/notifications`). Текст (RU) и ссылку рендерим здесь, на сервере.
|
|
Хранение: непрочитанные живут до прочтения (игрок должен увидеть их, когда бы ни зашёл),
|
|
прочитанные удаляются спустя RETENTION_HOURS после прочтения — при чтении списка и фоновой
|
|
задачей. От роста у незаходящих защищает кап MAX_PER_USER при создании.
|
|
"""
|
|
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 # сколько живёт ПРОЧИТАННОЕ уведомление после прочтения
|
|
MAX_PER_USER = 100 # кап на пользователя: старейшие сверх него удаляются при создании
|
|
|
|
|
|
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.flush()
|
|
for uid in ids:
|
|
_cap_user(session, uid)
|
|
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 _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 после прочтения.
|
|
|
|
Непрочитанные по времени не протухают — иначе игрок, не заходивший дольше окна
|
|
хранения, не увидел бы их вовсе. Возвращает число удалённых."""
|
|
rows = session.exec(
|
|
select(Notification).where(
|
|
Notification.read_at.is_not(None), Notification.read_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:
|
|
"""Уведомления пользователя + число непрочитанных. Попутно чистит давно прочитанные."""
|
|
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()
|
|
# Счётчик непрочитанных изменился — толкаем тот же сигнал, что и create_for,
|
|
# иначе вкладка на другом устройстве держит устаревший бейдж до перезагрузки.
|
|
notify.notifications_changed(user_id)
|
|
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",
|
|
)
|