Кастомизация профиля (о себе, аватар) и просмотр профилей других игроков + общая статистика

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 17:27:24 +03:00
co-authored by Claude Opus 4.8
parent b302ec769a
commit 96daddae05
22 changed files with 1106 additions and 23 deletions
+3
View File
@@ -80,6 +80,9 @@ class User(SQLModel, table=True):
sa_column=Column(BigInteger, nullable=True, unique=True)
)
nickname: str = Field(sa_column=Column(String(64), nullable=False, unique=True))
bio: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
# Относительный путь файла аватара (под settings.upload_dir); наличие = есть аватар.
avatar_path: str | None = Field(default=None, sa_column=Column(String(255), nullable=True))
role: str = Field(default="player", sa_column=Column(String(16), nullable=False, index=True, server_default="player"))
auth_provider: str = Field(
default="stub",
+82 -1
View File
@@ -1,10 +1,12 @@
"""Роутер текущего пользователя."""
from __future__ import annotations
from fastapi import APIRouter, Depends, Query, Request
from fastapi import APIRouter, Depends, File, Query, Request, UploadFile
from fastapi.responses import FileResponse
from sqlmodel import Session
from app.auth.deps import get_current_user
from app.core.errors import NotFoundError, ValidationError
from app.db.session import get_session
from app.models import User
from app.schemas import api as s
@@ -12,6 +14,8 @@ from app.services import audit_service, group_service, stats_service, user_servi
router = APIRouter(prefix="/users", tags=["users"])
_AVATAR_MAX_BYTES = 2 * 1024 * 1024 # 2 МБ
def build_me(session: Session, user: User) -> s.MeRead:
groups = [
@@ -25,6 +29,8 @@ def build_me(session: Session, user: User) -> s.MeRead:
auth_provider=user.auth_provider,
telegram_id=user.telegram_id,
active_group_id=user.active_group_id,
bio=user.bio,
avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
groups=groups,
)
@@ -63,9 +69,84 @@ def update_me(
auth_provider=user.auth_provider,
telegram_id=user.telegram_id,
active_group_id=user.active_group_id,
bio=user.bio,
avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
)
@router.patch("/me/profile", response_model=s.MeRead)
def update_my_profile(
body: s.ProfileUpdate,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.MeRead:
user_service.update_bio(session, user, body.bio)
audit_service.record(
session,
actor_id=user.id,
action="update",
entity_type="user",
entity_id=user.id,
payload={"bio": True},
ip=request.client.host if request.client else None,
)
session.commit()
return build_me(session, user)
@router.put("/me/avatar", response_model=s.MeRead)
def upload_my_avatar(
file: UploadFile = File(...),
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.MeRead:
content = file.file.read(_AVATAR_MAX_BYTES + 1)
if len(content) > _AVATAR_MAX_BYTES:
raise ValidationError("Файл слишком большой (макс. 2 МБ).")
ext = user_service.sniff_image_ext(content)
if ext is None:
raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.")
user_service.set_avatar(session, user, content, ext)
return build_me(session, user)
@router.delete("/me/avatar", response_model=s.MeRead)
def delete_my_avatar(
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.MeRead:
user_service.clear_avatar(session, user)
return build_me(session, user)
@router.get("/{user_id}/avatar")
def get_user_avatar(
user_id: int,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> FileResponse:
target = session.get(User, user_id)
path = user_service.avatar_abs_path(target.avatar_path) if target else None
if path is None or not path.exists():
raise NotFoundError("Аватар не найден.")
ext = path.suffix.lstrip(".")
return FileResponse(
path,
media_type=user_service.avatar_media_type(ext),
headers={"Cache-Control": "public, max-age=3600"},
)
@router.get("/{user_id}/profile", response_model=s.PublicProfile)
def get_user_profile(
user_id: int,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> dict:
return user_service.public_profile(session, user_id)
@router.get("/me/stats", response_model=s.ProfileStats)
def my_stats(
session: Session = Depends(get_session),
+16
View File
@@ -88,6 +88,8 @@ class UserRead(BaseModel):
auth_provider: str
telegram_id: int | None = None
active_group_id: int | None = None
bio: str | None = None
avatar_url: str | None = None
class MeRead(UserRead):
@@ -98,6 +100,10 @@ class NicknameUpdate(BaseModel):
nickname: str
class ProfileUpdate(BaseModel):
bio: str | None = None
class ActiveGroupUpdate(BaseModel):
group_id: int | None = None
@@ -237,6 +243,7 @@ class LeaderboardEntry(OverallStats):
user_id: int
nickname: str
rank: int | None = None
avatar_url: str | None = None
class Leaderboard(BaseModel):
@@ -273,6 +280,15 @@ class ProfileStats(BaseModel):
recent_form: list[RecentFormItem] = []
class PublicProfile(BaseModel):
# Профиль другого игрока (read-only): шапка + глобальная статистика.
user_id: int
nickname: str
bio: str | None = None
avatar_url: str | None = None
stats: ProfileStats
class FactionMeta(BaseModel):
faction_id: int
code: str
+8 -1
View File
@@ -49,6 +49,10 @@ def _round(value: Any, ndigits: int) -> float | None:
def _normalize(row: dict) -> dict:
# avatar_url мирроринг user_service.avatar_url_for: версия = epoch(updated_at) из SQL.
avatar_url = None
if row.get("avatar_path"):
avatar_url = f"/api/users/{row['user_id']}/avatar?v={int(row.get('avatar_version') or 0)}"
return {
"user_id": row["user_id"],
"nickname": row["nickname"],
@@ -57,6 +61,7 @@ def _normalize(row: dict) -> dict:
"win_rate": _round(row["win_rate"] or 0.0, 4),
"avg_place": _round(row["avg_place"], 2),
"score": _round(row["score"], 1),
"avatar_url": avatar_url,
}
@@ -65,6 +70,8 @@ def _leaderboard_rows(session: Session, group_id: int | None) -> list[dict]:
sql = f"""
{SCORED_CTE}
SELECT u.id AS user_id, u.nickname AS nickname,
u.avatar_path AS avatar_path,
CAST(strftime('%s', u.updated_at) AS INTEGER) AS avatar_version,
COUNT(*) AS games,
SUM(s.is_win) AS wins,
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
@@ -73,7 +80,7 @@ def _leaderboard_rows(session: Session, group_id: int | None) -> list[dict]:
FROM scored s
JOIN users u ON u.id = s.user_id
{where}
GROUP BY u.id, u.nickname
GROUP BY u.id, u.nickname, u.avatar_path, u.updated_at
"""
params = {"gid": group_id} if group_id is not None else {}
result = session.execute(text(sql), params).mappings().all()
+105 -1
View File
@@ -1,15 +1,24 @@
"""Пользователи: создание из внешней личности, ник, активная группа."""
"""Пользователи: создание из внешней личности, ник, активная группа, профиль."""
from __future__ import annotations
import os
import re
from datetime import datetime
from pathlib import Path
from sqlmodel import Session, select
from app.auth.provider import ExternalIdentity
from app.core.config import settings
from app.core.errors import NicknameTakenError, NotFoundError, ValidationError
from app.models import AuthIdentity, GroupMember, User
_NICK_RE = re.compile(r"^[\w .\-]{2,64}$", re.UNICODE)
_BIO_MAX = 500
# Аватары: файл на диске под settings.upload_dir/avatars, путь относительный.
_AVATAR_SUBDIR = "avatars"
_IMAGE_MEDIA = {"png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
def get_user(session: Session, user_id: int) -> User:
@@ -128,3 +137,98 @@ def set_active_group(session: Session, user: User, group_id: int | None) -> User
session.commit()
session.refresh(user)
return user
# ─── Профиль: «о себе», аватар ────────────────────────────────────────────────
def avatar_url_for(user_id: int, avatar_path: str | None, updated_at: datetime | None) -> str | None:
"""URL аватара с кэш-бастингом по updated_at. None, если аватара нет.
Один источник для build_me, публичного профиля и лидерборда — чтобы клиент
подтягивал новую картинку после смены (файл перезаписывается по тому же пути)."""
if not avatar_path:
return None
version = int(updated_at.timestamp()) if updated_at else 0
return f"/api/users/{user_id}/avatar?v={version}"
def update_bio(session: Session, user: User, bio: str | None) -> User:
bio = (bio or "").strip()
if len(bio) > _BIO_MAX:
raise ValidationError(f"«О себе»: не более {_BIO_MAX} символов.")
user.bio = bio or None
session.add(user)
session.commit()
session.refresh(user)
return user
def sniff_image_ext(content: bytes) -> str | None:
"""Расширение по магическим байтам (PNG/JPEG/WebP), без Pillow. None — не картинка."""
if content.startswith(b"\x89PNG\r\n\x1a\n"):
return "png"
if content.startswith(b"\xff\xd8\xff"):
return "jpg"
if len(content) >= 12 and content[0:4] == b"RIFF" and content[8:12] == b"WEBP":
return "webp"
return None
def avatar_media_type(ext: str) -> str:
return _IMAGE_MEDIA.get(ext, "application/octet-stream")
def avatar_abs_path(avatar_path: str | None) -> Path | None:
"""Абсолютный путь файла аватара (или None), для отдачи через FileResponse."""
if not avatar_path:
return None
return Path(settings.upload_dir) / avatar_path
def set_avatar(session: Session, user: User, content: bytes, ext: str) -> User:
avatars_dir = Path(settings.upload_dir) / _AVATAR_SUBDIR
avatars_dir.mkdir(parents=True, exist_ok=True)
# Удаляем прежний файл (мог быть с другим расширением), затем пишем новый.
old = avatar_abs_path(user.avatar_path)
if old and old.exists():
try:
old.unlink()
except OSError:
pass
rel = f"{_AVATAR_SUBDIR}/{user.id}.{ext}"
(Path(settings.upload_dir) / rel).write_bytes(content)
user.avatar_path = rel
session.add(user)
session.commit()
session.refresh(user)
return user
def clear_avatar(session: Session, user: User) -> User:
path = avatar_abs_path(user.avatar_path)
if path and path.exists():
try:
path.unlink()
except OSError:
pass
user.avatar_path = None
session.add(user)
session.commit()
session.refresh(user)
return user
def public_profile(session: Session, user_id: int) -> dict:
"""Публичный профиль игрока: ник/«о себе»/аватар + глобальная статистика."""
from app.services import stats_service # ленивый импорт (избегаем цикла)
user = session.get(User, user_id)
if user is None:
raise NotFoundError("Пользователь не найден.")
return {
"user_id": user.id,
"nickname": user.nickname,
"bio": user.bio,
"avatar_url": avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
"stats": stats_service.profile_stats(session, user_id, group_id=None),
}