На нагрузочном тесте 31.07.2026 около 70 человек заходили одновременно. Вход развалился: p95 `/api/v1/auth/token` — 7.28 с, p95 `guest-join` — 7.06 с, в БД 33 соединения `idle in transaction` при ОДНОМ активном запросе. Люди попадали внутрь с пятой-десятой попытки, часть не попала вовсе. Медиа при этом работало штатно: 30 участников с 27 камерами в следующем окне прошли без единого лага. Причина — argon2 считался синхронно внутри async-обработчика. Замер на боевом сервере: 95–155 мс на одну проверку, и всё это время event loop процесса стоит целиком. Транзакция БД к тому моменту уже открыта (`get_by_email` сделал SELECT), поэтому соединение висело без работы, пул из 40 выбирался, и отказы получали совершенно посторонние ручки — включая вход в конференцию, где никакого пароля не проверялось. Что изменилось: - `hash_password`/`verify_password` стали асинхронными и считаются в пуле потоков (`asyncio.to_thread`). argon2-cffi освобождает GIL, поэтому проверки идут по-настоящему параллельно; - параметры argon2id заменены с дефолтов библиотеки (t=3, m=64 МБ, p=4) на рекомендацию OWASP (t=2, m=19 МБ, p=1): 95 мс → 42 мс. Отдельно важен `parallelism`: при p=4 одна проверка пароля занимала все четыре ядра сервера — те же, на которых работает LiveKit; - добавлен `needs_rehash`: существующие хэши проверяются как прежде (параметры зашиты в саму строку) и лениво перевыпускаются при первом успешном входе. Расчёт по замерам: пачка из 70 логинов — 6.7–10.9 с блокировки против ~0.36 с без неё. Тесты: event loop продолжает тикать во время проверки; 8 параллельных проверок укладываются заметно быстрее восьми последовательных; хэш со старыми параметрами принимается и перевыпускается при входе.
146 lines
5.8 KiB
Python
146 lines
5.8 KiB
Python
"""Роутер профиля текущего пользователя, аватара и списка пользователей."""
|
||
|
||
from pathlib import Path
|
||
from typing import Annotated
|
||
|
||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from api.deps import get_current_user
|
||
from core.config import get_settings
|
||
from core.db import get_session
|
||
from core.security import hash_password, verify_password
|
||
from models.user import User
|
||
from repositories.users import UserRepository
|
||
from schemas.auth import PasswordChangeIn, ProfileUpdateIn, UserListItemOut, UserProfileOut
|
||
from services.avatars import AvatarInvalidTypeError, AvatarTooLargeError, avatar_url
|
||
from services.profile import (
|
||
TeamNotFoundError,
|
||
clear_avatar,
|
||
resolve_team_name,
|
||
update_profile_fields,
|
||
)
|
||
from services.profile import set_avatar as _set_avatar
|
||
|
||
router = APIRouter(prefix="/api/v1/users", tags=["users"])
|
||
|
||
# Число совпадений, возвращаемых поиском по `q` (пикер участников).
|
||
SEARCH_LIMIT = 20
|
||
|
||
|
||
@router.get("/me", response_model=UserProfileOut)
|
||
async def read_current_user(
|
||
user: Annotated[User, Depends(get_current_user)],
|
||
session: Annotated[AsyncSession, Depends(get_session)],
|
||
) -> UserProfileOut:
|
||
"""Вернуть профиль текущего аутентифицированного пользователя."""
|
||
return await _to_profile_out(session, user)
|
||
|
||
|
||
@router.patch("/me", response_model=UserProfileOut)
|
||
async def update_current_user(
|
||
data: ProfileUpdateIn,
|
||
user: Annotated[User, Depends(get_current_user)],
|
||
session: Annotated[AsyncSession, Depends(get_session)],
|
||
) -> UserProfileOut:
|
||
"""Изменить ФИО и/или команду текущего пользователя; email — read-only."""
|
||
try:
|
||
await update_profile_fields(
|
||
session,
|
||
user,
|
||
name_user=data.name_user,
|
||
team_id=data.team_id,
|
||
team_id_is_set="team_id" in data.model_fields_set,
|
||
)
|
||
except TeamNotFoundError as exc:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="team_not_found") from exc
|
||
await session.commit()
|
||
return await _to_profile_out(session, user)
|
||
|
||
|
||
@router.post("/me/avatar", response_model=UserProfileOut)
|
||
async def upload_current_user_avatar(
|
||
user: Annotated[User, Depends(get_current_user)],
|
||
session: Annotated[AsyncSession, Depends(get_session)],
|
||
file: Annotated[UploadFile, File()],
|
||
) -> UserProfileOut:
|
||
"""Загрузить аватар текущего пользователя (jpeg/png/webp, до 2 МБ)."""
|
||
try:
|
||
await _set_avatar(_media_root(), user, file)
|
||
except AvatarTooLargeError as exc:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail="avatar_too_large"
|
||
) from exc
|
||
except AvatarInvalidTypeError as exc:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail="avatar_invalid_type"
|
||
) from exc
|
||
await session.commit()
|
||
return await _to_profile_out(session, user)
|
||
|
||
|
||
@router.delete("/me/avatar", status_code=status.HTTP_204_NO_CONTENT)
|
||
async def delete_current_user_avatar(
|
||
user: Annotated[User, Depends(get_current_user)],
|
||
session: Annotated[AsyncSession, Depends(get_session)],
|
||
) -> None:
|
||
"""Удалить аватар текущего пользователя."""
|
||
clear_avatar(_media_root(), user)
|
||
await session.commit()
|
||
|
||
|
||
@router.post("/me/password", status_code=status.HTTP_204_NO_CONTENT)
|
||
async def change_current_user_password(
|
||
data: PasswordChangeIn,
|
||
user: Annotated[User, Depends(get_current_user)],
|
||
session: Annotated[AsyncSession, Depends(get_session)],
|
||
) -> None:
|
||
"""Сменить пароль текущего пользователя.
|
||
|
||
Refresh-сессии сознательно НЕ отзываются — отзыв всех сессий появится
|
||
вместе со сбросом пароля по email (v0.1.0, см. ADR-005
|
||
`docs/architecture/adr/005-password-reset-deferred.md`).
|
||
"""
|
||
if not await verify_password(data.current_password, user.password_hash):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_current_password"
|
||
)
|
||
user.password_hash = await hash_password(data.new_password)
|
||
await session.commit()
|
||
|
||
|
||
@router.get("", response_model=list[UserListItemOut])
|
||
async def list_users(
|
||
user: Annotated[User, Depends(get_current_user)],
|
||
session: Annotated[AsyncSession, Depends(get_session)],
|
||
q: Annotated[str | None, Query()] = None,
|
||
) -> list[UserListItemOut]:
|
||
"""Пикер участников конференции: без `q` — полный список; с `q` — поиск имя/email."""
|
||
media_root = _media_root()
|
||
users = await UserRepository(session).search(q=q, limit=SEARCH_LIMIT)
|
||
return [
|
||
UserListItemOut(
|
||
id=u.id, display_name=u.name_user, avatar_url=avatar_url(media_root, u.avatar_path)
|
||
)
|
||
for u in users
|
||
]
|
||
|
||
|
||
def _media_root() -> Path:
|
||
"""Каталог загруженных медиа-файлов (см. `core/config.py::Settings.media_root`)."""
|
||
return Path(get_settings().media_root)
|
||
|
||
|
||
async def _to_profile_out(session: AsyncSession, user: User) -> UserProfileOut:
|
||
"""Собрать `UserProfileOut` — общая сборка для своего профиля и карточки в админке."""
|
||
team_name = await resolve_team_name(session, user.team_id)
|
||
return UserProfileOut(
|
||
id=user.id,
|
||
email=user.email,
|
||
name_user=user.name_user,
|
||
role=user.role,
|
||
avatar_url=avatar_url(_media_root(), user.avatar_path),
|
||
team_id=user.team_id,
|
||
team_name=team_name,
|
||
)
|