PUT /api/users/me/password задаёт или меняет пароль. Первый раз текущий пароль
не нужен: так его задают аккаунты из Telegram и все, кто появился до паролей.
Если пароль уже есть, нужен текущий. Иначе оставленная открытой сессия позволила
бы отобрать аккаунт насовсем, поэтому подбор текущего тоже ограничен: 5 неудач
на аккаунт за 15 минут. Ошибка 403 WRONG_CURRENT_PASSWORD, а не 401, чтобы фронт
не принял её за истёкшую сессию.
POST /api/users/me/telegram привязывает Telegram к аккаунту, созданному по
паролю. Подпись виджета проверяется так же, как при входе, ник не меняется.
Связка пишется в auth_identity, как при регистрации через Telegram, поэтому
следующий вход через Telegram попадает в этот аккаунт. Telegram, привязанный к
другому аккаунту, даёт 409 TELEGRAM_TAKEN, повторная привязка — 409
TELEGRAM_ALREADY_LINKED.
PUT /api/admin/users/{id}/password — способ восстановить забытый пароль: почту
приложение не хранит. Работает только для игроков, пароль админа по-прежнему
задаётся в .env. В аудит пишется только факт смены, без пароля. Сборка
AdminUserRead вынесена в хелпер, в ответе появилось has_password.
#24
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
177 lines
5.2 KiB
Python
177 lines
5.2 KiB
Python
"""Доменные исключения и единый конверт ошибок API.
|
|
|
|
Формат ответа: {"error": {"code": "...", "message": "...", "details": {...}}}
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from fastapi import Request
|
|
from fastapi.responses import JSONResponse
|
|
|
|
|
|
class AppError(Exception):
|
|
"""Базовая ошибка приложения со стабильным машинным кодом."""
|
|
|
|
status_code: int = 400
|
|
code: str = "BAD_REQUEST"
|
|
|
|
def __init__(
|
|
self,
|
|
message: str,
|
|
*,
|
|
code: str | None = None,
|
|
status_code: int | None = None,
|
|
details: dict[str, Any] | None = None,
|
|
) -> None:
|
|
super().__init__(message)
|
|
self.message = message
|
|
if code is not None:
|
|
self.code = code
|
|
if status_code is not None:
|
|
self.status_code = status_code
|
|
self.details = details
|
|
|
|
def to_response(self) -> JSONResponse:
|
|
return JSONResponse(
|
|
status_code=self.status_code,
|
|
content={
|
|
"error": {
|
|
"code": self.code,
|
|
"message": self.message,
|
|
"details": self.details,
|
|
}
|
|
},
|
|
)
|
|
|
|
|
|
class NotFoundError(AppError):
|
|
status_code = 404
|
|
code = "NOT_FOUND"
|
|
|
|
|
|
class ForbiddenError(AppError):
|
|
status_code = 403
|
|
code = "FORBIDDEN"
|
|
|
|
|
|
class ValidationError(AppError):
|
|
status_code = 422
|
|
code = "VALIDATION_ERROR"
|
|
|
|
|
|
class ConflictError(AppError):
|
|
status_code = 409
|
|
code = "CONFLICT"
|
|
|
|
|
|
class AuthError(AppError):
|
|
status_code = 401
|
|
code = "UNAUTHORIZED"
|
|
|
|
|
|
# ─── Конкретные ошибки со стабильными кодами для фронта ───────────────────────
|
|
|
|
class NoGroupError(AppError):
|
|
status_code = 409
|
|
code = "NO_GROUP"
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__("Вы не состоите ни в одной группе.")
|
|
|
|
|
|
class NotGroupMemberError(ForbiddenError):
|
|
code = "NOT_GROUP_MEMBER"
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__("Вы не являетесь участником этой группы.")
|
|
|
|
|
|
class NicknameTakenError(ConflictError):
|
|
code = "NICKNAME_TAKEN"
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__("Никнейм уже занят.")
|
|
|
|
|
|
class TelegramNicknameRequiredError(ConflictError):
|
|
"""Первый вход через Telegram, но предлагаемый ник (тег) занят/некорректен —
|
|
фронт должен показать выбор ника и повторить регистрацию через /telegram/register.
|
|
`suggested` идёт в details для предзаполнения поля."""
|
|
|
|
code = "TELEGRAM_NICKNAME_REQUIRED"
|
|
|
|
def __init__(self, suggested: str, message: str | None = None) -> None:
|
|
super().__init__(
|
|
message or f"Ник «{suggested}» уже занят — выберите другой.",
|
|
details={"suggested": suggested},
|
|
)
|
|
|
|
|
|
class FactionNotAvailableError(ValidationError):
|
|
code = "FACTION_NOT_AVAILABLE"
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__("Фракция недоступна выбранной группе.")
|
|
|
|
|
|
class DuplicateParticipantError(ValidationError):
|
|
code = "DUPLICATE_PARTICIPANT"
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__("Игрок или фракция повторяются в партии.")
|
|
|
|
|
|
class InvalidRankingError(ValidationError):
|
|
code = "INVALID_RANKING"
|
|
|
|
def __init__(self, message: str = "Некорректная расстановка мест.") -> None:
|
|
super().__init__(message)
|
|
|
|
|
|
class InvalidCredentialsError(AuthError):
|
|
code = "INVALID_CREDENTIALS"
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__("Неверный логин или пароль.")
|
|
|
|
|
|
class TooManyAttemptsError(AppError):
|
|
"""Слишком много неудачных попыток ввода пароля. retry_after — через сколько секунд
|
|
ближайшая попытка снова будет принята."""
|
|
|
|
status_code = 429
|
|
code = "TOO_MANY_ATTEMPTS"
|
|
|
|
def __init__(self, retry_after: int) -> None:
|
|
minutes = max(1, -(-retry_after // 60))
|
|
super().__init__(
|
|
f"Слишком много неудачных попыток. Повторите через {minutes} мин.",
|
|
details={"retry_after": retry_after},
|
|
)
|
|
|
|
|
|
class WrongCurrentPasswordError(ForbiddenError):
|
|
code = "WRONG_CURRENT_PASSWORD"
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__("Текущий пароль неверен.")
|
|
|
|
|
|
class TelegramAlreadyLinkedError(ConflictError):
|
|
code = "TELEGRAM_ALREADY_LINKED"
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__("К аккаунту уже привязан Telegram.")
|
|
|
|
|
|
class TelegramTakenError(ConflictError):
|
|
code = "TELEGRAM_TAKEN"
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__("Этот Telegram уже привязан к другому аккаунту.")
|
|
|
|
|
|
async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse:
|
|
return exc.to_response()
|