Находки прохода /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
103 lines
3.5 KiB
Python
103 lines
3.5 KiB
Python
"""Вложения партии (фото). Файлы — на томе settings.upload_dir, в БД метаданные.
|
|
|
|
Общий сервис для игрового и админского путей (роутеры лишь строят URL с нужным префиксом).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
from sqlmodel import Session, select
|
|
|
|
from app.core.config import settings
|
|
from app.core.errors import ConflictError, NotFoundError
|
|
from app.core.timeutil import utcnow
|
|
from app.models import Match, MatchAttachment, User
|
|
|
|
MAX_ATTACHMENTS = 10
|
|
_SUBDIR = "matches"
|
|
|
|
|
|
def list_for_match(session: Session, match_id: int) -> list[MatchAttachment]:
|
|
return list(
|
|
session.exec(
|
|
select(MatchAttachment)
|
|
.where(MatchAttachment.match_id == match_id)
|
|
.order_by(MatchAttachment.created_at, MatchAttachment.id)
|
|
).all()
|
|
)
|
|
|
|
|
|
def count(session: Session, match_id: int) -> int:
|
|
return len(
|
|
session.exec(
|
|
select(MatchAttachment.id).where(MatchAttachment.match_id == match_id)
|
|
).all()
|
|
)
|
|
|
|
|
|
def file_path(att: MatchAttachment) -> Path:
|
|
return Path(settings.upload_dir) / att.storage_path
|
|
|
|
|
|
def _touch(session: Session, match: Match) -> None:
|
|
"""Двинуть версию партии: набор вложений виден в MatchRead, а сама строка matches
|
|
при работе с ними не меняется — без этого оптимистичная блокировка проспала бы
|
|
конкурентную правку."""
|
|
match.updated_at = utcnow()
|
|
session.add(match)
|
|
|
|
|
|
def add_photo(
|
|
session: Session, match: Match, user: User, content: bytes, ext: str, mime: str
|
|
) -> MatchAttachment:
|
|
if count(session, match.id) >= MAX_ATTACHMENTS: # type: ignore[arg-type]
|
|
raise ConflictError(f"Можно прикрепить не более {MAX_ATTACHMENTS} файлов.")
|
|
att = MatchAttachment(
|
|
match_id=match.id, # type: ignore[arg-type]
|
|
uploaded_by=user.id,
|
|
kind="photo",
|
|
storage_path="",
|
|
mime_type=mime,
|
|
size_bytes=len(content),
|
|
)
|
|
session.add(att)
|
|
session.flush() # получаем id для имени файла
|
|
rel = f"{_SUBDIR}/{match.id}/{att.id}.{ext}"
|
|
abs_path = Path(settings.upload_dir) / rel
|
|
abs_path.parent.mkdir(parents=True, exist_ok=True)
|
|
abs_path.write_bytes(content)
|
|
att.storage_path = rel
|
|
session.add(att)
|
|
_touch(session, match)
|
|
session.commit()
|
|
session.refresh(att)
|
|
return att
|
|
|
|
|
|
def get_for_match(session: Session, match_id: int, att_id: int) -> MatchAttachment:
|
|
att = session.get(MatchAttachment, att_id)
|
|
if att is None or att.match_id != match_id:
|
|
raise NotFoundError("Вложение не найдено.")
|
|
return att
|
|
|
|
|
|
def delete(session: Session, match: Match, att_id: int) -> None:
|
|
att = get_for_match(session, match.id, att_id) # type: ignore[arg-type]
|
|
path = file_path(att)
|
|
if path.exists():
|
|
try:
|
|
path.unlink()
|
|
except OSError:
|
|
pass
|
|
session.delete(att)
|
|
_touch(session, match)
|
|
session.commit()
|
|
|
|
|
|
def delete_match_files(match_id: int) -> None:
|
|
"""Удалить всю папку медиа партии (при удалении партии; строки БД уйдут каскадом)."""
|
|
folder = Path(settings.upload_dir) / _SUBDIR / str(match_id)
|
|
if folder.is_dir():
|
|
shutil.rmtree(folder, ignore_errors=True)
|