diff --git a/.dockerignore b/.dockerignore index f4ce90b..54ede4e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,9 +13,10 @@ frontend/node_modules/ frontend/dist/ frontend/.vite/ -# DEV-вход (по нику) физически НЕ попадает в прод-образ +# DEV-код физически НЕ попадает в прод-образ (вход по нику, удаление аккаунтов) backend/app/auth/dev_stub.py backend/app/routers/dev_auth.py +backend/app/routers/dev_admin.py # Тесты и dev-манифест зависимостей в прод-образе не нужны (ставим из requirements.txt) backend/tests/ diff --git a/Dockerfile b/Dockerfile index 8dc3137..bf58edc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,7 +37,7 @@ COPY --from=frontend /app/frontend/dist /app/static RUN sed -i 's/\r$//' /app/entrypoint.sh \ && chmod +x /app/entrypoint.sh \ && useradd --create-home --uid 10001 appuser \ - && mkdir -p /data/uploads \ + && mkdir -p /data/uploads /data/achievements \ && chown -R appuser:appuser /app /data USER appuser diff --git a/backend/alembic/versions/0004_user_profile.py b/backend/alembic/versions/0004_user_profile.py new file mode 100644 index 0000000..0a06257 --- /dev/null +++ b/backend/alembic/versions/0004_user_profile.py @@ -0,0 +1,47 @@ +"""Кастомизация профиля: о себе (bio) и аватар (avatar_path). + +Идемпотентна: на свежей БД (0001 создаёт схему из актуальных моделей через create_all) +столбцы уже есть → no-op; на существующей БД — добавляет столбцы. render_as_batch включён +в env.py (нужен для ALTER в SQLite). + +Revision ID: 0004_user_profile +Revises: 0003_match_lifecycle +Create Date: 2026-06-17 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from sqlalchemy import inspect + +from alembic import op + +revision: str = "0004_user_profile" +down_revision: Union[str, None] = "0003_match_lifecycle" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + insp = inspect(bind) + cols = {c["name"] for c in insp.get_columns("users")} + + add = [] + if "bio" not in cols: + add.append(sa.Column("bio", sa.Text(), nullable=True)) + if "avatar_path" not in cols: + add.append(sa.Column("avatar_path", sa.String(255), nullable=True)) + + if add: + with op.batch_alter_table("users") as b: + for col in add: + b.add_column(col) + + +def downgrade() -> None: + with op.batch_alter_table("users") as b: + for name in ("avatar_path", "bio"): + try: + b.drop_column(name) + except Exception: # noqa: BLE001 + pass diff --git a/backend/alembic/versions/0005_achievements.py b/backend/alembic/versions/0005_achievements.py new file mode 100644 index 0000000..cb97d89 --- /dev/null +++ b/backend/alembic/versions/0005_achievements.py @@ -0,0 +1,61 @@ +"""Задел под титулы/ачивки: users.title_achievement_slug + таблица user_achievements. + +Сами определения ачивок хранятся файлами (settings.achievements_dir), в БД — только +будущая выдача игрокам. Идемпотентна (как 0003/0004): на свежей БД (0001 = create_all из +актуальных моделей) столбец/таблица уже есть → no-op; на существующей — добавляет. + +Revision ID: 0005_achievements +Revises: 0004_user_profile +Create Date: 2026-06-17 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from sqlalchemy import inspect + +from alembic import op + +revision: str = "0005_achievements" +down_revision: Union[str, None] = "0004_user_profile" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + insp = inspect(bind) + + cols = {c["name"] for c in insp.get_columns("users")} + if "title_achievement_slug" not in cols: + with op.batch_alter_table("users") as b: + b.add_column(sa.Column("title_achievement_slug", sa.String(64), nullable=True)) + + if "user_achievements" not in insp.get_table_names(): + op.create_table( + "user_achievements", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "user_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("achievement_slug", sa.String(64), nullable=False), + sa.Column("earned_at", sa.DateTime(), nullable=False), + sa.UniqueConstraint("user_id", "achievement_slug", name="uq_user_achievement"), + ) + op.create_index( + "ix_user_achievements_user_id", "user_achievements", ["user_id"] + ) + + +def downgrade() -> None: + bind = op.get_bind() + insp = inspect(bind) + if "user_achievements" in insp.get_table_names(): + op.drop_table("user_achievements") + with op.batch_alter_table("users") as b: + try: + b.drop_column("title_achievement_slug") + except Exception: # noqa: BLE001 + pass diff --git a/backend/app/auth/login.py b/backend/app/auth/login.py index 9a54a6e..5601f08 100644 --- a/backend/app/auth/login.py +++ b/backend/app/auth/login.py @@ -15,22 +15,30 @@ from app.models import User from app.services import audit_service, user_service -def login_with_identity( - session: Session, response: Response, request: Request, identity: ExternalIdentity +def establish_session( + session: Session, response: Response, request: Request, user: User, provider: str ) -> User: - user = user_service.get_or_create_from_identity(session, identity) + """Открыть сессию уже найденному/созданному пользователю (cookie + аудит).""" if not user.is_active: raise ForbiddenError("Аккаунт отключён администратором.", code="ACCOUNT_DISABLED") - security.set_user_session(response, user.id, identity.provider) # type: ignore[arg-type] + security.set_user_session(response, user.id, provider) # type: ignore[arg-type] audit_service.record( session, actor_id=user.id, action="login", entity_type="user", entity_id=user.id, - payload={"provider": identity.provider}, + payload={"provider": provider}, ip=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), ) session.commit() return user + + +def login_with_identity( + session: Session, response: Response, request: Request, identity: ExternalIdentity +) -> User: + """Вход с авто-созданием (dev-вход по нику): ник подбирается автоматически.""" + user = user_service.get_or_create_from_identity(session, identity) + return establish_session(session, response, request, user, identity.provider) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 59b8a45..e73c3aa 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -45,6 +45,10 @@ class Settings(BaseSettings): dev_upload_dir: str = "./data/dev/uploads" prod_upload_dir: str = "/data/uploads" + # Каталог ачивок (папка на ачивку: meta.json, condition.py, icon.*), раздельно. + dev_achievements_dir: str = "./data/dev/achievements" + prod_achievements_dir: str = "/data/achievements" + # JWT / cookie secret_key: str = "change-me-dev-secret-not-for-production" jwt_algorithm: str = "HS256" @@ -97,6 +101,10 @@ class Settings(BaseSettings): def upload_dir(self) -> str: return self.dev_upload_dir if self.is_development else self.prod_upload_dir + @property + def achievements_dir(self) -> str: + return self.dev_achievements_dir if self.is_development else self.prod_achievements_dir + @property def cookie_secure(self) -> bool: """Secure-cookie нужен везде, где снаружи HTTPS (домен). Исключение — diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py index 5541f74..b82911a 100644 --- a/backend/app/core/errors.py +++ b/backend/app/core/errors.py @@ -94,6 +94,20 @@ class NicknameTakenError(ConflictError): 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" diff --git a/backend/app/main.py b/backend/app/main.py index af78510..f250ed1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -15,7 +15,7 @@ from starlette.middleware.base import BaseHTTPMiddleware from app.core import security from app.core.config import settings from app.core.errors import AppError, app_error_handler -from app.routers import admin, auth, groups, matches, reference, stats, users +from app.routers import achievements, admin, auth, groups, matches, reference, stats, users # Каталог со сборкой фронта (в Docker — backend/static; локально может отсутствовать). _STATIC_DIR = Path(os.getenv("STATIC_DIR", str(Path(__file__).resolve().parent.parent / "static"))) @@ -107,19 +107,20 @@ def create_app() -> FastAPI: # API-роутеры под /api. api_routers = [auth.router, users.router, groups.router, matches.router, - reference.router, stats.router, admin.router] + reference.router, stats.router, achievements.router, admin.router] for r in api_routers: app.include_router(r, prefix="/api") - # DEV-вход (по нику) — только в development и только если код физически есть - # (в test/prod-образе dev_auth/dev_stub исключены, импорт просто не выполнится). + # DEV-роутеры (вход по нику, жёсткое удаление аккаунтов) — только в development + # и только если код физически есть (в test/prod-образе dev_*-файлы исключены + # .dockerignore, импорт просто не выполнится). if settings.is_development: - try: - from app.routers import dev_auth - - app.include_router(dev_auth.router, prefix="/api") - except ImportError: - pass + for mod_name in ("dev_auth", "dev_admin"): + try: + mod = __import__(f"app.routers.{mod_name}", fromlist=["router"]) + app.include_router(mod.router, prefix="/api") + except ImportError: + pass @app.get("/api/health", tags=["meta"]) def health() -> dict: diff --git a/backend/app/models.py b/backend/app/models.py index 1ae1415..2b635c3 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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", @@ -95,6 +98,9 @@ class User(SQLModel, table=True): ) ) is_active: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="1")) + # Выбранный титул (slug ачивки), отображаемый под ником. Задел: пока всегда NULL + # (выдача ачивок игрокам — следующий этап). + title_achievement_slug: str | None = Field(sa_column=Column(String(64), nullable=True)) created_at: datetime = Field(default_factory=_utcnow, nullable=False) updated_at: datetime = Field( default_factory=_utcnow, @@ -103,6 +109,26 @@ class User(SQLModel, table=True): ) +class UserAchievement(SQLModel, table=True): + """Полученные игроком ачивки. Задел под будущую выдачу — сейчас не заполняется. + + Ссылается на ачивку по slug (источник правды — папки на диске, не таблица БД).""" + + __tablename__ = "user_achievements" + __table_args__ = ( + UniqueConstraint("user_id", "achievement_slug", name="uq_user_achievement"), + ) + + id: int | None = Field(default=None, primary_key=True) + user_id: int = Field( + sa_column=Column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + ) + achievement_slug: str = Field(sa_column=Column(String(64), nullable=False)) + earned_at: datetime = Field(default_factory=_utcnow, nullable=False) + + class AuthIdentity(SQLModel, table=True): __tablename__ = "auth_identity" __table_args__ = ( diff --git a/backend/app/routers/achievements.py b/backend/app/routers/achievements.py new file mode 100644 index 0000000..b3331e7 --- /dev/null +++ b/backend/app/routers/achievements.py @@ -0,0 +1,27 @@ +"""Публичное чтение ачивок: список и иконки (для отображения; выдача — на будущее).""" +from __future__ import annotations + +from fastapi import APIRouter, Depends +from fastapi.responses import FileResponse + +from app.auth.deps import get_current_user +from app.models import User +from app.schemas import api as s +from app.services import achievement_service + +router = APIRouter(prefix="/achievements", tags=["achievements"]) + + +@router.get("", response_model=list[s.AchievementRead]) +def list_achievements(_user: User = Depends(get_current_user)) -> list[dict]: + return achievement_service.list_achievements() + + +# Иконка — без auth: косметический ассет, нужен и игроку, и в админ-панели (cookie +# админа scoped на /api/admin и сюда не отправляется). Отдаётся по известному slug. +@router.get("/{slug}/icon") +def get_icon(slug: str) -> FileResponse: + path, media_type = achievement_service.icon_file(slug) + return FileResponse( + path, media_type=media_type, headers={"Cache-Control": "public, max-age=3600"} + ) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index ba64fbf..3d3cecf 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -1,7 +1,7 @@ """Админ-роутер: отдельный вход (логин+пароль) и управление сущностями.""" from __future__ import annotations -from fastapi import APIRouter, Depends, Query, Request, Response +from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFile from sqlmodel import Session from app.auth.deps import get_current_admin @@ -11,7 +11,15 @@ from app.db.session import get_session from app.models import User from app.routers.matches import build_match_read from app.schemas import api as s -from app.services import admin_service, audit_service, faction_service, match_service +from app.services import ( + achievement_service, + admin_service, + audit_service, + faction_service, + match_service, +) + +_ACHIEVEMENT_ICON_MAX_BYTES = 2 * 1024 * 1024 # 2 МБ from app.services.match_service import ParticipantInput router = APIRouter(prefix="/admin", tags=["admin"]) @@ -104,20 +112,9 @@ def update_user( ) -@router.delete("/users/{user_id}", response_model=s.OkResponse) -def delete_user( - user_id: int, - request: Request, - session: Session = Depends(get_session), - admin: User = Depends(get_current_admin), -) -> s.OkResponse: - admin_service.delete_user(session, user_id) - audit_service.record( - session, actor_id=admin.id, action="delete", entity_type="user", entity_id=user_id, - ip=request.client.host if request.client else None, - ) - session.commit() - return s.OkResponse() +# Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в +# routers/dev_admin.py (исключён из прод/тест-образа). В проде аккаунт только +# отключается (PATCH is_active), удалять нельзя. # ─── Группы ────────────────────────────────────────────────────────────────── @@ -295,6 +292,82 @@ def delete_match( return s.OkResponse() +# ─── Ачивки (определения; выдача игрокам — на будущее) ──────────────────────── + +@router.get("/achievements", response_model=list[s.AchievementRead]) +def list_achievements( + _admin: User = Depends(get_current_admin), +) -> list[dict]: + return achievement_service.list_achievements() + + +@router.post("/achievements", response_model=s.AchievementRead) +def create_achievement( + body: s.AchievementCreate, + request: Request, + session: Session = Depends(get_session), + admin: User = Depends(get_current_admin), +) -> dict: + ach = achievement_service.create(body.name, body.description, body.condition) + audit_service.record( + session, actor_id=admin.id, action="create", entity_type="achievement", + payload={"slug": ach["slug"], "name": ach["name"]}, + ip=request.client.host if request.client else None, + ) + session.commit() + return ach + + +@router.patch("/achievements/{slug}", response_model=s.AchievementRead) +def update_achievement( + slug: str, + body: s.AchievementUpdate, + request: Request, + session: Session = Depends(get_session), + admin: User = Depends(get_current_admin), +) -> dict: + ach = achievement_service.update( + slug, name=body.name, description=body.description, condition=body.condition + ) + audit_service.record( + session, actor_id=admin.id, action="update", entity_type="achievement", + payload={"slug": slug}, ip=request.client.host if request.client else None, + ) + session.commit() + return ach + + +@router.put("/achievements/{slug}/icon", response_model=s.AchievementRead) +def upload_achievement_icon( + slug: str, + file: UploadFile = File(...), + _admin: User = Depends(get_current_admin), +) -> dict: + from app.core.errors import ValidationError + + content = file.file.read(_ACHIEVEMENT_ICON_MAX_BYTES + 1) + if len(content) > _ACHIEVEMENT_ICON_MAX_BYTES: + raise ValidationError("Файл слишком большой (макс. 2 МБ).") + ext = achievement_service.validate_icon(content) + return achievement_service.set_icon(slug, content, ext) + + +@router.delete("/achievements/{slug}", response_model=s.OkResponse) +def delete_achievement( + slug: str, + request: Request, + session: Session = Depends(get_session), + admin: User = Depends(get_current_admin), +) -> s.OkResponse: + achievement_service.delete(slug) + audit_service.record( + session, actor_id=admin.id, action="delete", entity_type="achievement", + payload={"slug": slug}, ip=request.client.host if request.client else None, + ) + session.commit() + return s.OkResponse() + + # ─── Журнал аудита ─────────────────────────────────────────────────────────── @router.get("/audit-logs", response_model=s.AuditLogList) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index c7807dc..d3c0ba2 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -7,14 +7,16 @@ from __future__ import annotations from fastapi import APIRouter, Depends, Request, Response from sqlmodel import Session -from app.auth.login import login_with_identity +from app.auth.login import establish_session from app.auth.registry import enabled_methods from app.auth.telegram import TelegramProvider from app.core import security from app.core.config import settings +from app.core.errors import TelegramNicknameRequiredError from app.db.session import get_session from app.routers.users import build_me from app.schemas import api as s +from app.services import user_service router = APIRouter(prefix="/auth", tags=["auth"]) @@ -34,8 +36,44 @@ def telegram_login( response: Response, session: Session = Depends(get_session), ) -> s.MeRead: + """Вход/регистрация через Telegram. + + Возвращающийся пользователь (личность уже есть) — просто входит. Первый вход — + регистрация под тегом Telegram; если тег занят/некорректен, отдаём 409 + TELEGRAM_NICKNAME_REQUIRED, и фронт повторяет регистрацию через /telegram/register + с выбранным ником. + """ identity = TelegramProvider().authenticate(body.model_dump()) - user = login_with_identity(session, response, request, identity) + user = user_service.find_by_identity(session, identity) + if user is None: + suggested = (identity.suggested_nickname or "").strip() + if not user_service.nickname_format_ok(suggested) or not user_service.nickname_available( + session, suggested + ): + raise TelegramNicknameRequiredError(suggested) + user = user_service.register_from_identity(session, identity, suggested) + establish_session(session, response, request, user, identity.provider) + return build_me(session, user) + + +@router.post("/telegram/register", response_model=s.MeRead) +def telegram_register( + body: s.TelegramRegister, + request: Request, + response: Response, + session: Session = Depends(get_session), +) -> s.MeRead: + """Завершение регистрации через Telegram с выбранным ником. + + Подпись виджета проверяется заново (поле nickname в HMAC не входит), поэтому личность + доверенная — подделать чужой telegram_id нельзя. Если пользователь уже успел + зарегистрироваться (гонка) — просто входим. + """ + identity = TelegramProvider().authenticate(body.model_dump(exclude={"nickname"})) + user = user_service.find_by_identity(session, identity) + if user is None: + user = user_service.register_from_identity(session, identity, body.nickname) + establish_session(session, response, request, user, identity.provider) return build_me(session, user) diff --git a/backend/app/routers/dev_admin.py b/backend/app/routers/dev_admin.py new file mode 100644 index 0000000..dd8130d --- /dev/null +++ b/backend/app/routers/dev_admin.py @@ -0,0 +1,69 @@ +"""DEV-ТОЛЬКО роутер: жёсткое удаление аккаунта игрока. + +Этот файл ФИЗИЧЕСКИ исключён из прод/тест-образа (.dockerignore), а роутер +подключается лишь когда APP_ENV == development (см. app/main.py). На фронте кнопка +удаления вырезается из прод-сборки тришейкингом (import.meta.env.DEV). Так +возможность удаления не попадает ни в прод, ни в тест — там аккаунт можно только +отключить (PATCH is_active). + +Семантика («вычёркивание из партий»): аккаунт удаляется, а партии сохраняются — +строки участия игрока удаляются (player_count партий НЕ пересчитывается, поэтому +их статистика может стать несогласованной — осознанный выбор). Созданные им партии +и группы в его владении переназначаются на админа (FK RESTRICT не даёт удалить +пользователя, а сами партии/группы мы сохраняем). +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request +from sqlmodel import Session, select + +from app.auth.deps import get_current_admin +from app.core.errors import NotFoundError, ValidationError +from app.db.session import get_session +from app.models import Group, Match, MatchParticipant, User +from app.schemas import api as s +from app.services import audit_service + +router = APIRouter(prefix="/admin/dev", tags=["admin-dev"]) + + +@router.delete("/users/{user_id}", response_model=s.OkResponse) +def delete_user_hard( + user_id: int, + request: Request, + session: Session = Depends(get_session), + admin: User = Depends(get_current_admin), +) -> s.OkResponse: + user = session.get(User, user_id) + if user is None: + raise NotFoundError("Пользователь не найден.") + if user.role == "admin": + raise ValidationError("Нельзя удалить администратора.") + nickname = user.nickname # сохраняем до удаления (для аудита) + + # 1. Вычёркиваем игрока из всех партий (его строки участия). + for part in session.exec( + select(MatchParticipant).where(MatchParticipant.user_id == user_id) + ).all(): + session.delete(part) + # 2. Партии и группы, где он создатель/владелец, переназначаем на админа. + for match in session.exec(select(Match).where(Match.created_by == user_id)).all(): + match.created_by = admin.id # type: ignore[assignment] + session.add(match) + for group in session.exec(select(Group).where(Group.owner_id == user_id)).all(): + group.owner_id = admin.id # type: ignore[assignment] + session.add(group) + session.flush() + # 3. group_members и auth_identity уйдут каскадом, audit_log.actor_id → NULL. + session.delete(user) + audit_service.record( + session, + actor_id=admin.id, + action="delete", + entity_type="user", + entity_id=user_id, + payload={"hard": True, "nickname": nickname}, + ip=request.client.host if request.client else None, + ) + session.commit() + return s.OkResponse() diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index c399f1e..a7f20b5 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -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), diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index bd90d8c..9e5f466 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -29,6 +29,12 @@ class TelegramAuthPayload(BaseModel): photo_url: str | None = None +class TelegramRegister(TelegramAuthPayload): + # Регистрация через Telegram с явно выбранным ником (когда тег занят/некорректен). + # Подпись виджета проверяется по тем же полям; nickname в HMAC не входит. + nickname: str + + class DevLogin(BaseModel): nickname: str @@ -82,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): @@ -92,6 +100,10 @@ class NicknameUpdate(BaseModel): nickname: str +class ProfileUpdate(BaseModel): + bio: str | None = None + + class ActiveGroupUpdate(BaseModel): group_id: int | None = None @@ -231,6 +243,7 @@ class LeaderboardEntry(OverallStats): user_id: int nickname: str rank: int | None = None + avatar_url: str | None = None class Leaderboard(BaseModel): @@ -267,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 @@ -388,6 +410,27 @@ class AdminMatchRead(BaseModel): created_at: str +class AchievementRead(BaseModel): + slug: str + name: str + description: str = "" + condition: str = "" # текст condition.py (задел; не исполняется) + has_condition: bool = False + icon_url: str | None = None + + +class AchievementCreate(BaseModel): + name: str + description: str | None = None + condition: str | None = None + + +class AchievementUpdate(BaseModel): + name: str | None = None + description: str | None = None + condition: str | None = None + + class AuditLogItem(BaseModel): id: int actor_id: int | None = None diff --git a/backend/app/services/achievement_service.py b/backend/app/services/achievement_service.py new file mode 100644 index 0000000..c01ec48 --- /dev/null +++ b/backend/app/services/achievement_service.py @@ -0,0 +1,194 @@ +"""Ачивки: определения хранятся файлами на диске (settings.achievements_dir). + +Папка на ачивку: achievements// + meta.json — {"name", "description", "created_at"} + condition.py — скрипт-условие выдачи (ЗАДЕЛ: только хранится как текст, НЕ исполняется) + icon. — картинка-герб (png/jpg/webp), опционально + +Источник правды — файлы. В БД (user_achievements / users.title_achievement_slug) — только +будущая выдача игрокам (сейчас не используется). Выдача и исполнение condition.py — следующий +этап; здесь админ лишь создаёт/именует/описывает ачивки. +""" +from __future__ import annotations + +import json +import re +import shutil +from datetime import datetime, timezone +from pathlib import Path + +from app.core.config import settings +from app.core.errors import ConflictError, NotFoundError, ValidationError +from app.services.user_service import avatar_media_type, sniff_image_ext + +_CONDITION_FILE = "condition.py" +_META_FILE = "meta.json" +_CONDITION_PLACEHOLDER = ( + "# Условие выдачи ачивки (ЗАДЕЛ на будущее — пока не исполняется).\n" + "# Здесь будет описана логика автоматической выдачи этого титула игроку.\n" +) + +# Транслитерация для slug из русского названия. +_TRANSLIT = { + "а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "e", "ж": "zh", + "з": "z", "и": "i", "й": "i", "к": "k", "л": "l", "м": "m", "н": "n", "о": "o", + "п": "p", "р": "r", "с": "s", "т": "t", "у": "u", "ф": "f", "х": "h", "ц": "ts", + "ч": "ch", "ш": "sh", "щ": "sch", "ъ": "", "ы": "y", "ь": "", "э": "e", "ю": "yu", + "я": "ya", +} + + +def _root() -> Path: + return Path(settings.achievements_dir) + + +def _slugify(name: str) -> str: + text = "".join(_TRANSLIT.get(ch, ch) for ch in (name or "").strip().lower()) + slug = re.sub(r"[^a-z0-9]+", "-", text).strip("-") + return slug[:48] + + +def _dir(slug: str) -> Path: + return _root() / slug + + +def _icon_path(slug: str) -> Path | None: + folder = _dir(slug) + if not folder.is_dir(): + return None + for f in sorted(folder.glob("icon.*")): + if f.is_file(): + return f + return None + + +def _read(slug: str) -> dict: + folder = _dir(slug) + meta_file = folder / _META_FILE + if not meta_file.is_file(): + raise NotFoundError("Ачивка не найдена.") + meta = json.loads(meta_file.read_text(encoding="utf-8")) + condition = "" + cond_file = folder / _CONDITION_FILE + if cond_file.is_file(): + condition = cond_file.read_text(encoding="utf-8") + icon = _icon_path(slug) + icon_url = None + if icon is not None: + version = int(icon.stat().st_mtime) + icon_url = f"/api/achievements/{slug}/icon?v={version}" + return { + "slug": slug, + "name": meta.get("name", slug), + "description": meta.get("description", ""), + "condition": condition, + "has_condition": bool(condition.strip()), + "icon_url": icon_url, + } + + +def list_achievements() -> list[dict]: + root = _root() + if not root.is_dir(): + return [] + out = [] + for folder in sorted(root.iterdir()): + if folder.is_dir() and (folder / _META_FILE).is_file(): + out.append(_read(folder.name)) + return out + + +def get(slug: str) -> dict: + return _read(slug) + + +def _validate_name(name: str) -> str: + name = (name or "").strip() + if not (1 <= len(name) <= 64): + raise ValidationError("Название ачивки: 1–64 символа.") + return name + + +def create(name: str, description: str | None, condition: str | None) -> dict: + name = _validate_name(name) + slug = _slugify(name) + if not slug: + raise ValidationError("Не удалось сформировать идентификатор из названия.") + folder = _dir(slug) + if folder.exists(): + raise ConflictError("Ачивка с таким названием уже существует.") + folder.mkdir(parents=True, exist_ok=True) + _write_meta(slug, name, (description or "").strip()) + (folder / _CONDITION_FILE).write_text( + condition if condition else _CONDITION_PLACEHOLDER, encoding="utf-8" + ) + return _read(slug) + + +def update( + slug: str, + *, + name: str | None = None, + description: str | None = None, + condition: str | None = None, +) -> dict: + folder = _dir(slug) + meta_file = folder / _META_FILE + if not meta_file.is_file(): + raise NotFoundError("Ачивка не найдена.") + meta = json.loads(meta_file.read_text(encoding="utf-8")) + # slug не меняем (он стабильный идентификатор/путь), даже если правят название. + new_name = _validate_name(name) if name is not None else meta.get("name", slug) + new_desc = description.strip() if description is not None else meta.get("description", "") + _write_meta(slug, new_name, new_desc, created_at=meta.get("created_at")) + if condition is not None: + (folder / _CONDITION_FILE).write_text(condition, encoding="utf-8") + return _read(slug) + + +def set_icon(slug: str, content: bytes, ext: str) -> dict: + folder = _dir(slug) + if not (folder / _META_FILE).is_file(): + raise NotFoundError("Ачивка не найдена.") + old = _icon_path(slug) + if old is not None: + try: + old.unlink() + except OSError: + pass + (folder / f"icon.{ext}").write_bytes(content) + return _read(slug) + + +def icon_file(slug: str) -> tuple[Path, str]: + """Путь к файлу иконки и его media_type; NotFound, если иконки нет.""" + icon = _icon_path(slug) + if icon is None: + raise NotFoundError("Иконка не найдена.") + return icon, avatar_media_type(icon.suffix.lstrip(".")) + + +def delete(slug: str) -> None: + folder = _dir(slug) + if not folder.is_dir(): + raise NotFoundError("Ачивка не найдена.") + shutil.rmtree(folder) + + +def validate_icon(content: bytes) -> str: + """Проверяет, что это поддерживаемая картинка, и возвращает расширение.""" + ext = sniff_image_ext(content) + if ext is None: + raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.") + return ext + + +def _write_meta(slug: str, name: str, description: str, created_at: str | None = None) -> None: + meta = { + "name": name, + "description": description, + "created_at": created_at or datetime.now(timezone.utc).isoformat(), + } + (_dir(slug) / _META_FILE).write_text( + json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8" + ) diff --git a/backend/app/services/admin_service.py b/backend/app/services/admin_service.py index 2c1c3d2..4616ea1 100644 --- a/backend/app/services/admin_service.py +++ b/backend/app/services/admin_service.py @@ -5,10 +5,16 @@ from typing import Any from sqlmodel import Session, select -from app.core.errors import InvalidCredentialsError, NotFoundError, ValidationError +from app.core.errors import ( + InvalidCredentialsError, + NicknameTakenError, + NotFoundError, + ValidationError, +) from app.core.security import verify_password from app.core.timeutil import iso_utc from app.models import AuditLog, Faction, Group, Match, MatchParticipant, User +from app.services import user_service def authenticate_admin(session: Session, username: str, password: str) -> User: @@ -39,7 +45,14 @@ def update_user(session: Session, user_id: int, *, nickname: str | None = None, if user is None: raise NotFoundError("Пользователь не найден.") if nickname is not None: - user.nickname = nickname.strip() + nickname = nickname.strip() + # Та же валидация, что и для самостоятельной смены ника игроком: + # корректный формат и уникальность (иначе занятый ник упал бы в 500). + if not user_service.nickname_format_ok(nickname): + raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_") + if not user_service.nickname_available(session, nickname, exclude_user_id=user_id): + raise NicknameTakenError() + user.nickname = nickname if is_active is not None: user.is_active = is_active session.add(user) @@ -48,12 +61,8 @@ def update_user(session: Session, user_id: int, *, nickname: str | None = None, return user -def delete_user(session: Session, user_id: int) -> None: - user = session.get(User, user_id) - if user is None: - raise NotFoundError("Пользователь не найден.") - session.delete(user) - session.commit() +# Жёсткое удаление пользователя — dev-only, в services/admin_service нет намеренно: +# логика вынесена в routers/dev_admin.py (файл исключён из прод/тест-образа). # ─── Группы ────────────────────────────────────────────────────────────────── diff --git a/backend/app/services/stats_service.py b/backend/app/services/stats_service.py index e196723..5764e81 100644 --- a/backend/app/services/stats_service.py +++ b/backend/app/services/stats_service.py @@ -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() diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 9295eb5..77907e2 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -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: @@ -25,6 +34,11 @@ def nickname_available(session: Session, nickname: str, exclude_user_id: int | N return existing is None or existing.id == exclude_user_id +def nickname_format_ok(nickname: str) -> bool: + """Ник подходит по формату (2–64, буквы/цифры/пробел/.-_).""" + return bool(_NICK_RE.match((nickname or "").strip())) + + def _unique_nickname(session: Session, base: str) -> str: base = (base or "Игрок").strip()[:60] or "Игрок" candidate = base @@ -35,18 +49,24 @@ def _unique_nickname(session: Session, base: str) -> str: return candidate -def get_or_create_from_identity(session: Session, identity: ExternalIdentity) -> User: +def find_by_identity(session: Session, identity: ExternalIdentity) -> User | None: + """Существующий пользователь по внешней личности (provider+external_id). + + Для Telegram external_id — это НЕИЗМЕНЯЕМЫЙ числовой id аккаунта (не @тег), поэтому + вход с любого устройства/после смены тега ведёт к одному и тому же пользователю. + """ link = session.exec( select(AuthIdentity).where( AuthIdentity.provider == identity.provider, AuthIdentity.external_id == identity.external_id, ) ).first() - if link is not None: - return get_user(session, link.user_id) + return get_user(session, link.user_id) if link is not None else None + +def _create_from_identity(session: Session, identity: ExternalIdentity, nickname: str) -> User: user = User( - nickname=_unique_nickname(session, identity.suggested_nickname or identity.external_id), + nickname=nickname, role="player", auth_provider=identity.provider, telegram_id=identity.telegram_id, @@ -65,6 +85,31 @@ def get_or_create_from_identity(session: Session, identity: ExternalIdentity) -> return user +def get_or_create_from_identity(session: Session, identity: ExternalIdentity) -> User: + """Найти по личности или создать, авто-подбирая уникальный ник (dev-вход по нику).""" + user = find_by_identity(session, identity) + if user is not None: + return user + nickname = _unique_nickname(session, identity.suggested_nickname or identity.external_id) + return _create_from_identity(session, identity, nickname) + + +def register_from_identity( + session: Session, identity: ExternalIdentity, nickname: str +) -> User: + """Создать пользователя с ЯВНО выбранным ником (регистрация через Telegram). + + В отличие от get_or_create — ник не подбирается автоматически: занятый/неподходящий + ник вызывает ошибку, чтобы пользователь выбрал другой. + """ + nickname = (nickname or "").strip() + if not nickname_format_ok(nickname): + raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_") + if not nickname_available(session, nickname): + raise NicknameTakenError() + return _create_from_identity(session, identity, nickname) + + def update_nickname(session: Session, user: User, new_nickname: str) -> User: new_nickname = (new_nickname or "").strip() if not _NICK_RE.match(new_nickname): @@ -92,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), + } diff --git a/backend/tests/test_achievements.py b/backend/tests/test_achievements.py new file mode 100644 index 0000000..d940ded --- /dev/null +++ b/backend/tests/test_achievements.py @@ -0,0 +1,113 @@ +"""Ачивки: админ создаёт/правит/удаляет, грузит иконку; condition хранится как текст.""" +from __future__ import annotations + +import json +from pathlib import Path + +from fastapi.testclient import TestClient + +from tests.conftest import csrf_headers + +PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64 + + +def _admin(client: TestClient, make_admin) -> None: + make_admin("admin", "secret123") + r = client.post( + "/api/admin/auth/login", + json={"username": "admin", "password": "secret123"}, + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + + +def _use_tmp_achievements(monkeypatch, tmp_path) -> Path: + from app.core.config import settings + + monkeypatch.setattr(settings, "dev_achievements_dir", str(tmp_path)) + return tmp_path + + +def test_create_list_and_files(client: TestClient, make_admin, monkeypatch, tmp_path): + root = _use_tmp_achievements(monkeypatch, tmp_path) + _admin(client, make_admin) + + r = client.post( + "/api/admin/achievements", + json={"name": "Великий полководец", "description": "10 побед подряд"}, + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + ach = r.json() + slug = ach["slug"] + assert slug and ach["name"] == "Великий полководец" + + # На диске создана папка с meta.json и condition.py (задел, не исполняется). + folder = root / slug + assert (folder / "meta.json").is_file() + assert (folder / "condition.py").is_file() + meta = json.loads((folder / "meta.json").read_text(encoding="utf-8")) + assert meta["name"] == "Великий полководец" + + # Виден в админском списке (редактор использует именно его). + assert any(a["slug"] == slug for a in client.get("/api/admin/achievements").json()) + + +def test_duplicate_name_conflicts(client: TestClient, make_admin, monkeypatch, tmp_path): + _use_tmp_achievements(monkeypatch, tmp_path) + _admin(client, make_admin) + body = {"name": "Берсерк"} + assert client.post("/api/admin/achievements", json=body, headers=csrf_headers(client)).status_code == 200 + r = client.post("/api/admin/achievements", json=body, headers=csrf_headers(client)) + assert r.status_code == 409, r.text + + +def test_icon_upload_and_serve(client: TestClient, make_admin, monkeypatch, tmp_path): + _use_tmp_achievements(monkeypatch, tmp_path) + _admin(client, make_admin) + slug = client.post( + "/api/admin/achievements", json={"name": "Герой"}, headers=csrf_headers(client) + ).json()["slug"] + + # Не картинка → 422. + bad = client.put( + f"/api/admin/achievements/{slug}/icon", + files={"file": ("x.txt", b"nope", "text/plain")}, + headers=csrf_headers(client), + ) + assert bad.status_code == 422, bad.text + + # Валидный PNG → icon_url, файл отдаётся. + ok = client.put( + f"/api/admin/achievements/{slug}/icon", + files={"file": ("h.png", PNG, "image/png")}, + headers=csrf_headers(client), + ) + assert ok.status_code == 200 and ok.json()["icon_url"], ok.text + g = client.get(f"/api/achievements/{slug}/icon") + assert g.status_code == 200 and g.content == PNG + + +def test_update_and_delete(client: TestClient, make_admin, monkeypatch, tmp_path): + _use_tmp_achievements(monkeypatch, tmp_path) + _admin(client, make_admin) + slug = client.post( + "/api/admin/achievements", json={"name": "Стратег"}, headers=csrf_headers(client) + ).json()["slug"] + + # Правка описания и текста условия (condition хранится как текст). + r = client.patch( + f"/api/admin/achievements/{slug}", + json={"description": "Победа по целям", "condition": "wins_by_objectives >= 5"}, + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + assert r.json()["description"] == "Победа по целям" + assert r.json()["has_condition"] is True + assert "wins_by_objectives" in r.json()["condition"] + + # Удаление. + assert client.delete( + f"/api/admin/achievements/{slug}", headers=csrf_headers(client) + ).status_code == 200 + assert all(a["slug"] != slug for a in client.get("/api/admin/achievements").json()) diff --git a/backend/tests/test_admin_extra.py b/backend/tests/test_admin_extra.py index e52ce61..0e75643 100644 --- a/backend/tests/test_admin_extra.py +++ b/backend/tests/test_admin_extra.py @@ -106,6 +106,87 @@ def test_admin_edit_match_and_filters(client: TestClient, make_admin): assert bp["faction_name"] == "Хаоситы" +def test_admin_rename_account(client: TestClient, make_admin): + """Админ переименовывает аккаунт: валидируется формат и уникальность ника.""" + a = login(client, "Старое") + client.post("/api/auth/dev/users", json={"nickname": "Занятое"}, headers=csrf_headers(client)) + _admin_login(client, make_admin) + + r = client.patch( + f"/api/admin/users/{a['id']}", json={"nickname": "Новое"}, headers=csrf_headers(client) + ) + assert r.status_code == 200, r.text + assert r.json()["nickname"] == "Новое" + + # Занятый ник → 409, некорректный формат → 422. + r = client.patch( + f"/api/admin/users/{a['id']}", json={"nickname": "Занятое"}, headers=csrf_headers(client) + ) + assert r.status_code == 409 and r.json()["error"]["code"] == "NICKNAME_TAKEN", r.text + r = client.patch( + f"/api/admin/users/{a['id']}", json={"nickname": "x"}, headers=csrf_headers(client) + ) + assert r.status_code == 422, r.text + + +def test_dev_delete_account_keeps_matches(client: TestClient, make_admin): + """DEV-удаление: аккаунт удаляется, партии живут (игрок вычёркивается), + его созданные партии и группы переназначаются на админа.""" + me = login(client, "Аня") # владелец группы и создатель партии + gid = client.post( + "/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client) + ).json()["id"] + client.post("/api/auth/dev/users", json={"nickname": "Боря"}, headers=csrf_headers(client)) + b = client.post( + f"/api/groups/{gid}/members", json={"nickname": "Боря"}, headers=csrf_headers(client) + ).json()["user_id"] + factions = {f["code"]: f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()} + mid = create_finished_match( + client, + gid, + [ + {"user_id": me["id"], "faction_id": factions["orks"], "place": 1}, + {"user_id": b, "faction_id": factions["eldar"], "place": 2}, + ], + )["id"] + + admin_id = make_admin("admin", "secret123") + r = client.post( + "/api/admin/auth/login", + json={"username": "admin", "password": "secret123"}, + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + + # Удаляем участника Борю → партия жива, его строка участия вычеркнута. + r = client.delete(f"/api/admin/dev/users/{b}", headers=csrf_headers(client)) + assert r.status_code == 200, r.text + assert all(u["id"] != b for u in client.get("/api/admin/users").json()) + detail = client.get(f"/api/admin/matches/{mid}").json() + assert [p["user_id"] for p in detail["participants"]] == [me["id"]] # Боря вычеркнут + assert detail["player_count"] == 2 # player_count НЕ пересчитывается (осознанно) + + # Удаляем создателя/владельца Аню → партия и группа сохраняются, переходят к админу. + r = client.delete(f"/api/admin/dev/users/{me['id']}", headers=csrf_headers(client)) + assert r.status_code == 200, r.text + assert client.get(f"/api/admin/matches/{mid}").json()["created_by"] == admin_id + grp = next(g for g in client.get("/api/admin/groups").json() if g["id"] == gid) + assert grp["owner_id"] == admin_id + + +def test_dev_delete_account_rejects_admin(client: TestClient, make_admin): + """Админа удалить нельзя.""" + admin_id = make_admin("admin", "secret123") + r = client.post( + "/api/admin/auth/login", + json={"username": "admin", "password": "secret123"}, + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + r = client.delete(f"/api/admin/dev/users/{admin_id}", headers=csrf_headers(client)) + assert r.status_code == 422, r.text + + def test_admin_rename_faction_system_wide(client: TestClient, make_admin): me = login(client, "Кто-то") gid = client.post( diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 94f9fca..810ad4f 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -79,3 +79,64 @@ def test_telegram_login_bad_signature_rejected(client: TestClient, monkeypatch): r = client.post("/api/auth/telegram", json=payload) assert r.status_code == 401 _ = csrf_headers # (для единообразия импорта) + + +def test_telegram_same_id_no_duplicate_account(client: TestClient, monkeypatch): + """Вход с двух устройств / после смены тега — один аккаунт (ключ = числовой id).""" + from app.core.config import settings + + monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN") + r1 = client.post("/api/auth/telegram", json=_telegram_payload("TEST_BOT_TOKEN", username="firsttag")) + assert r1.status_code == 200, r1.text + + client.cookies.clear() # «другое устройство» — без сессии + # Тот же telegram id (777), но другой тег и свежий auth_date. + r2 = client.post("/api/auth/telegram", json=_telegram_payload("TEST_BOT_TOKEN", username="changedtag")) + assert r2.status_code == 200, r2.text + assert r2.json()["id"] == r1.json()["id"] # тот же пользователь + assert r2.json()["nickname"] == r1.json()["nickname"] # ник не пересоздаётся + + +def test_telegram_register_nickname_collision_then_pick(client: TestClient, engine, monkeypatch): + """Первый вход через ТГ, тег совпал с чужим ником → просим выбрать ник → регистрируем.""" + from sqlmodel import Session + + from app.core.config import settings + from app.models import User + + monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN") + with Session(engine) as s: # существующий игрок с ником = тегу из payload (ivan_tg) + s.add(User(nickname="ivan_tg", role="player", auth_provider="stub")) + s.commit() + + payload = _telegram_payload("TEST_BOT_TOKEN") # id=777, username=ivan_tg + r = client.post("/api/auth/telegram", json=payload) + assert r.status_code == 409, r.text + err = r.json()["error"] + assert err["code"] == "TELEGRAM_NICKNAME_REQUIRED" + assert err["details"]["suggested"] == "ivan_tg" + + # Повтор с выбранным свободным ником (та же подпись виджета). + r2 = client.post("/api/auth/telegram/register", json={**payload, "nickname": "Иван-новый"}) + assert r2.status_code == 200, r2.text + assert r2.json()["nickname"] == "Иван-новый" + assert r2.json()["telegram_id"] == 777 + + +def test_telegram_register_rejects_taken_nickname(client: TestClient, engine, monkeypatch): + """На шаге выбора ника всё ещё занятый ник отклоняется (NICKNAME_TAKEN).""" + from sqlmodel import Session + + from app.core.config import settings + from app.models import User + + monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN") + with Session(engine) as s: + s.add(User(nickname="ivan_tg", role="player", auth_provider="stub")) + s.add(User(nickname="занятый", role="player", auth_provider="stub")) + s.commit() + + payload = _telegram_payload("TEST_BOT_TOKEN") + r = client.post("/api/auth/telegram/register", json={**payload, "nickname": "занятый"}) + assert r.status_code == 409, r.text + assert r.json()["error"]["code"] == "NICKNAME_TAKEN" diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py new file mode 100644 index 0000000..3615eba --- /dev/null +++ b/backend/tests/test_profile.py @@ -0,0 +1,129 @@ +"""Профиль: «о себе» (bio), аватар (загрузка/отдача/удаление), публичный профиль.""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from tests.conftest import create_finished_match, csrf_headers, login + +# Минимальный «PNG»: достаточно сигнатуры — сервер не декодирует, только сниффит тип. +PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64 + + +def _use_tmp_uploads(monkeypatch, tmp_path) -> None: + from app.core.config import settings + + monkeypatch.setattr(settings, "dev_upload_dir", str(tmp_path)) + + +def _finished_match_for(client: TestClient, me: dict) -> int: + """Группа + второй игрок + одна завершённая партия (чтобы me попал в лидерборд).""" + exps = [e["id"] for e in client.get("/api/expansions").json()] + gid = client.post( + "/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client) + ).json()["id"] + client.post("/api/auth/dev/users", json={"nickname": "Соперник"}, headers=csrf_headers(client)) + p2 = client.post( + f"/api/groups/{gid}/members", json={"nickname": "Соперник"}, headers=csrf_headers(client) + ).json()["user_id"] + fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()] + create_finished_match( + client, + gid, + [ + {"user_id": me["id"], "faction_id": fids[0], "place": 1}, + {"user_id": p2, "faction_id": fids[1], "place": 2}, + ], + ) + return gid + + +def test_update_bio(client: TestClient): + login(client, "Игрок") + r = client.patch("/api/users/me/profile", json={"bio": "Люблю орков"}, headers=csrf_headers(client)) + assert r.status_code == 200, r.text + assert r.json()["bio"] == "Люблю орков" + # Слишком длинное «о себе» → 422. + r = client.patch( + "/api/users/me/profile", json={"bio": "x" * 501}, headers=csrf_headers(client) + ) + assert r.status_code == 422, r.text + + +def test_avatar_upload_get_delete(client: TestClient, monkeypatch, tmp_path): + _use_tmp_uploads(monkeypatch, tmp_path) + me = login(client, "Аватарыч") + + # Загрузка валидного PNG → avatar_url выставлен. + r = client.put( + "/api/users/me/avatar", + files={"file": ("a.png", PNG, "image/png")}, + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + avatar_url = r.json()["avatar_url"] + assert avatar_url and "/avatar?v=" in avatar_url + + # Отдача файла. + g = client.get(f"/api/users/{me['id']}/avatar") + assert g.status_code == 200, g.text + assert g.content == PNG + assert g.headers["content-type"] == "image/png" + + # Удаление → avatar_url пропадает, файл больше не отдаётся. + r = client.delete("/api/users/me/avatar", headers=csrf_headers(client)) + assert r.status_code == 200 and r.json()["avatar_url"] is None, r.text + assert client.get(f"/api/users/{me['id']}/avatar").status_code == 404 + + +def test_avatar_rejects_non_image_and_oversize(client: TestClient, monkeypatch, tmp_path): + _use_tmp_uploads(monkeypatch, tmp_path) + login(client, "Игрок") + + r = client.put( + "/api/users/me/avatar", + files={"file": ("x.txt", b"not an image", "text/plain")}, + headers=csrf_headers(client), + ) + assert r.status_code == 422, r.text + + big = PNG + b"\x00" * (2 * 1024 * 1024 + 10) + r = client.put( + "/api/users/me/avatar", + files={"file": ("big.png", big, "image/png")}, + headers=csrf_headers(client), + ) + assert r.status_code == 422, r.text + + +def test_public_profile(client: TestClient): + me = login(client, "Публичный") + client.patch("/api/users/me/profile", json={"bio": "Привет"}, headers=csrf_headers(client)) + + r = client.get(f"/api/users/{me['id']}/profile") + assert r.status_code == 200, r.text + body = r.json() + assert body["nickname"] == "Публичный" + assert body["bio"] == "Привет" + assert body["avatar_url"] is None + assert "overall" in body["stats"] + + assert client.get("/api/users/999999/profile").status_code == 404 + + +def test_leaderboard_includes_avatar_url(client: TestClient, monkeypatch, tmp_path): + _use_tmp_uploads(monkeypatch, tmp_path) + me = login(client, "Топчик") + _finished_match_for(client, me) + + def entry_for(uid: int) -> dict: + board = client.get("/api/stats/leaderboard").json() + return next(e for e in board["entries"] + board["provisional"] if e["user_id"] == uid) + + assert entry_for(me["id"])["avatar_url"] is None # без аватара — null + + client.put( + "/api/users/me/avatar", + files={"file": ("a.png", PNG, "image/png")}, + headers=csrf_headers(client), + ) + assert "/avatar?v=" in entry_for(me["id"])["avatar_url"] # после загрузки — ссылка diff --git a/docker-compose.test.yml b/docker-compose.test.yml index a306d57..a894668 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -23,6 +23,7 @@ services: volumes: - db-data-test:/data # изолированные тестовые данные - uploads-data-test:/data/uploads + - achievements-data-test:/data/achievements # определения ачивок (файлы) healthcheck: test: - CMD @@ -58,3 +59,4 @@ services: volumes: db-data-test: uploads-data-test: + achievements-data-test: diff --git a/docker-compose.yml b/docker-compose.yml index db82efe..c16c27c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,7 @@ services: volumes: - db-data:/data # БД SQLite + WAL-сайдкары - uploads-data:/data/uploads # задел под фото/видео + - achievements-data:/data/achievements # определения ачивок (файлы) healthcheck: test: - CMD @@ -56,3 +57,4 @@ services: volumes: db-data: uploads-data: + achievements-data: diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts index c950f57..21c0625 100644 --- a/frontend/src/api/queryKeys.ts +++ b/frontend/src/api/queryKeys.ts @@ -6,6 +6,7 @@ export const qk = { expansions: ["expansions"] as const, factions: ["factions"] as const, myStats: ["myStats"] as const, + publicProfile: (id: number) => ["publicProfile", id] as const, groups: ["groups"] as const, group: (id: number) => ["group", id] as const, groupFactions: (id: number) => ["group", id, "factions"] as const, @@ -19,4 +20,5 @@ export const qk = { adminGroups: ["adminGroups"] as const, adminMatches: ["adminMatches"] as const, adminLogs: ["adminLogs"] as const, + adminAchievements: ["adminAchievements"] as const, }; diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index d26fd50..6f6c422 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -30,7 +30,15 @@ export interface paths { }; get?: never; put?: never; - /** Telegram Login */ + /** + * Telegram Login + * @description Вход/регистрация через Telegram. + * + * Возвращающийся пользователь (личность уже есть) — просто входит. Первый вход — + * регистрация под тегом Telegram; если тег занят/некорректен, отдаём 409 + * TELEGRAM_NICKNAME_REQUIRED, и фронт повторяет регистрацию через /telegram/register + * с выбранным ником. + */ post: operations["telegram_login_api_auth_telegram_post"]; delete?: never; options?: never; @@ -38,6 +46,30 @@ export interface paths { patch?: never; trace?: never; }; + "/api/auth/telegram/register": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Telegram Register + * @description Завершение регистрации через Telegram с выбранным ником. + * + * Подпись виджета проверяется заново (поле nickname в HMAC не входит), поэтому личность + * доверенная — подделать чужой telegram_id нельзя. Если пользователь уже успел + * зарегистрироваться (гонка) — просто входим. + */ + post: operations["telegram_register_api_auth_telegram_register_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/auth/logout": { parameters: { query?: never; @@ -73,6 +105,75 @@ export interface paths { patch: operations["update_me_api_users_me_patch"]; trace?: never; }; + "/api/users/me/profile": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Update My Profile */ + patch: operations["update_my_profile_api_users_me_profile_patch"]; + trace?: never; + }; + "/api/users/me/avatar": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Upload My Avatar */ + put: operations["upload_my_avatar_api_users_me_avatar_put"]; + post?: never; + /** Delete My Avatar */ + delete: operations["delete_my_avatar_api_users_me_avatar_delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/{user_id}/avatar": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get User Avatar */ + get: operations["get_user_avatar_api_users__user_id__avatar_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/{user_id}/profile": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get User Profile */ + get: operations["get_user_profile_api_users__user_id__profile_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/users/me/stats": { parameters: { query?: never; @@ -408,6 +509,40 @@ export interface paths { patch?: never; trace?: never; }; + "/api/achievements": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Achievements */ + get: operations["list_achievements_api_achievements_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/achievements/{slug}/icon": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Icon */ + get: operations["get_icon_api_achievements__slug__icon_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/auth/login": { parameters: { query?: never; @@ -486,8 +621,7 @@ export interface paths { get?: never; put?: never; post?: never; - /** Delete User */ - delete: operations["delete_user_api_admin_users__user_id__delete"]; + delete?: never; options?: never; head?: never; /** Update User */ @@ -598,6 +732,59 @@ export interface paths { patch: operations["rename_faction_api_admin_factions__faction_id__patch"]; trace?: never; }; + "/api/admin/achievements": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Achievements */ + get: operations["list_achievements_api_admin_achievements_get"]; + put?: never; + /** Create Achievement */ + post: operations["create_achievement_api_admin_achievements_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/achievements/{slug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete Achievement */ + delete: operations["delete_achievement_api_admin_achievements__slug__delete"]; + options?: never; + head?: never; + /** Update Achievement */ + patch: operations["update_achievement_api_admin_achievements__slug__patch"]; + trace?: never; + }; + "/api/admin/achievements/{slug}/icon": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Upload Achievement Icon */ + put: operations["upload_achievement_icon_api_admin_achievements__slug__icon_put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/audit-logs": { parameters: { query?: never; @@ -650,6 +837,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/dev/users/{user_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete User Hard */ + delete: operations["delete_user_hard_api_admin_dev_users__user_id__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/health": { parameters: { query?: never; @@ -671,6 +875,48 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + /** AchievementCreate */ + AchievementCreate: { + /** Name */ + name: string; + /** Description */ + description?: string | null; + /** Condition */ + condition?: string | null; + }; + /** AchievementRead */ + AchievementRead: { + /** Slug */ + slug: string; + /** Name */ + name: string; + /** + * Description + * @default + */ + description: string; + /** + * Condition + * @default + */ + condition: string; + /** + * Has Condition + * @default false + */ + has_condition: boolean; + /** Icon Url */ + icon_url?: string | null; + }; + /** AchievementUpdate */ + AchievementUpdate: { + /** Name */ + name?: string | null; + /** Description */ + description?: string | null; + /** Condition */ + condition?: string | null; + }; /** ActiveGroupUpdate */ ActiveGroupUpdate: { /** Group Id */ @@ -795,6 +1041,16 @@ export interface components { /** Telegram Bot Username */ telegram_bot_username?: string | null; }; + /** Body_upload_achievement_icon_api_admin_achievements__slug__icon_put */ + Body_upload_achievement_icon_api_admin_achievements__slug__icon_put: { + /** File */ + file: string; + }; + /** Body_upload_my_avatar_api_users_me_avatar_put */ + Body_upload_my_avatar_api_users_me_avatar_put: { + /** File */ + file: string; + }; /** DevLogin */ DevLogin: { /** Nickname */ @@ -1049,6 +1305,8 @@ export interface components { nickname: string; /** Rank */ rank?: number | null; + /** Avatar Url */ + avatar_url?: string | null; }; /** MatchCreate */ MatchCreate: { @@ -1224,6 +1482,10 @@ export interface components { telegram_id?: number | null; /** Active Group Id */ active_group_id?: number | null; + /** Bio */ + bio?: string | null; + /** Avatar Url */ + avatar_url?: string | null; /** * Groups * @default [] @@ -1315,6 +1577,23 @@ export interface components { */ recent_form: components["schemas"]["RecentFormItem"][]; }; + /** ProfileUpdate */ + ProfileUpdate: { + /** Bio */ + bio?: string | null; + }; + /** PublicProfile */ + PublicProfile: { + /** User Id */ + user_id: number; + /** Nickname */ + nickname: string; + /** Bio */ + bio?: string | null; + /** Avatar Url */ + avatar_url?: string | null; + stats: components["schemas"]["ProfileStats"]; + }; /** RandomizeRequest */ RandomizeRequest: { /** Group Id */ @@ -1369,6 +1648,27 @@ export interface components { } & { [key: string]: unknown; }; + /** TelegramRegister */ + TelegramRegister: { + /** Id */ + id: number; + /** Auth Date */ + auth_date: number; + /** Hash */ + hash: string; + /** First Name */ + first_name?: string | null; + /** Last Name */ + last_name?: string | null; + /** Username */ + username?: string | null; + /** Photo Url */ + photo_url?: string | null; + /** Nickname */ + nickname: string; + } & { + [key: string]: unknown; + }; /** UserRead */ UserRead: { /** Id */ @@ -1383,6 +1683,10 @@ export interface components { telegram_id?: number | null; /** Active Group Id */ active_group_id?: number | null; + /** Bio */ + bio?: string | null; + /** Avatar Url */ + avatar_url?: string | null; }; /** ValidationError */ ValidationError: { @@ -1459,6 +1763,39 @@ export interface operations { }; }; }; + telegram_register_api_auth_telegram_register_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TelegramRegister"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; logout_api_auth_logout_post: { parameters: { query?: never; @@ -1532,6 +1869,154 @@ export interface operations { }; }; }; + update_my_profile_api_users_me_profile_patch: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ProfileUpdate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + upload_my_avatar_api_users_me_avatar_put: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["Body_upload_my_avatar_api_users_me_avatar_put"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_my_avatar_api_users_me_avatar_delete: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeRead"]; + }; + }; + }; + }; + get_user_avatar_api_users__user_id__avatar_get: { + parameters: { + query?: never; + header?: never; + path: { + user_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_user_profile_api_users__user_id__profile_get: { + parameters: { + query?: never; + header?: never; + path: { + user_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublicProfile"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; my_stats_api_users_me_stats_get: { parameters: { query?: never; @@ -2289,6 +2774,57 @@ export interface operations { }; }; }; + list_achievements_api_achievements_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AchievementRead"][]; + }; + }; + }; + }; + get_icon_api_achievements__slug__icon_get: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; admin_login_api_admin_auth_login_post: { parameters: { query?: never; @@ -2393,37 +2929,6 @@ export interface operations { }; }; }; - delete_user_api_admin_users__user_id__delete: { - parameters: { - query?: never; - header?: never; - path: { - user_id: number; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["OkResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; update_user_api_admin_users__user_id__patch: { parameters: { query?: never; @@ -2697,6 +3202,160 @@ export interface operations { }; }; }; + list_achievements_api_admin_achievements_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AchievementRead"][]; + }; + }; + }; + }; + create_achievement_api_admin_achievements_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AchievementCreate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AchievementRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_achievement_api_admin_achievements__slug__delete: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OkResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_achievement_api_admin_achievements__slug__patch: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AchievementUpdate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AchievementRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + upload_achievement_icon_api_admin_achievements__slug__icon_put: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["Body_upload_achievement_icon_api_admin_achievements__slug__icon_put"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AchievementRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; audit_logs_api_admin_audit_logs_get: { parameters: { query?: { @@ -2817,6 +3476,37 @@ export interface operations { }; }; }; + delete_user_hard_api_admin_dev_users__user_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + user_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OkResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; health_api_health_get: { parameters: { query?: never; diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 6fdabde..99ab836 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -9,7 +9,10 @@ import { GroupSettingsPage } from "../pages/GroupSettingsPage"; import { HomePage } from "../pages/HomePage"; import { LoginPage } from "../pages/LoginPage"; import { MatchDetailPage } from "../pages/MatchDetailPage"; +import { OverallStatsPage } from "../pages/OverallStatsPage"; +import { PublicProfilePage } from "../pages/PublicProfilePage"; import { AdminAccountsPage } from "../pages/admin/AdminAccountsPage"; +import { AdminAchievementsPage } from "../pages/admin/AdminAchievementsPage"; import { AdminFactionsPage } from "../pages/admin/AdminFactionsPage"; import { AdminLayout } from "../pages/admin/AdminLayout"; import { AdminLogsPage } from "../pages/admin/AdminLogsPage"; @@ -37,6 +40,8 @@ export const router = createBrowserRouter([ ), }, { path: "match/:matchId", element: }, + { path: "stats", element: }, + { path: "u/:userId", element: }, { path: "group", element: }, { path: "group/settings", @@ -61,6 +66,7 @@ export const router = createBrowserRouter([ { path: "records", element: }, { path: "accounts", element: }, { path: "factions", element: }, + { path: "achievements", element: }, { path: "logs", element: }, ], }, diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 0dcea79..1fed4ed 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -8,12 +8,15 @@ const TITLES: Record = { "/group/settings": "Настройки группы", "/account": "Аккаунт", "/match/new": "Новая партия", + "/stats": "Общая статистика", }; export function AppShell() { const location = useLocation(); const navigate = useNavigate(); - const title = TITLES[location.pathname] ?? "Forbidden Stars"; + const title = + TITLES[location.pathname] ?? + (location.pathname.startsWith("/u/") ? "Профиль игрока" : "Forbidden Stars"); const isHome = location.pathname === "/"; return ( diff --git a/frontend/src/components/Avatar.tsx b/frontend/src/components/Avatar.tsx new file mode 100644 index 0000000..b18e583 --- /dev/null +++ b/frontend/src/components/Avatar.tsx @@ -0,0 +1,52 @@ +import type { CSSProperties } from "react"; + +/** + * Чисто презентационный аватар: картинка по url или инициал ника как заглушка. + * Используется и в своём, и в чужом профиле, и в списках (общий примитив без логики). + */ +export function Avatar({ + url, + nickname, + size = 40, +}: { + url?: string | null; + nickname: string; + size?: number; +}) { + const box: CSSProperties = { + width: size, + height: size, + borderRadius: "50%", + flexShrink: 0, + }; + if (url) { + return ( + {nickname} + ); + } + const initial = (nickname.trim()[0] ?? "?").toUpperCase(); + return ( +
+ {initial} +
+ ); +} diff --git a/frontend/src/components/Leaderboard.tsx b/frontend/src/components/Leaderboard.tsx index 66bea59..b1a803d 100644 --- a/frontend/src/components/Leaderboard.tsx +++ b/frontend/src/components/Leaderboard.tsx @@ -1,4 +1,7 @@ +import { Link } from "react-router-dom"; + import type { LeaderboardEntry } from "../domain/types"; +import { Avatar } from "./Avatar"; function pct(v: number | null | undefined): string { return v == null ? "—" : `${Math.round(v * 100)}%`; @@ -10,8 +13,16 @@ function Row({ entry }: { entry: LeaderboardEntry }) {
{entry.rank ?? "—"}
+
-
{entry.nickname}
+ e.stopPropagation()} + style={{ fontWeight: 600, color: "inherit" }} + > + {entry.nickname} +
Игр: {entry.games} Побед: {entry.wins} diff --git a/frontend/src/components/ProfileStatsCard.tsx b/frontend/src/components/ProfileStatsCard.tsx index 2f9022f..63f7a05 100644 --- a/frontend/src/components/ProfileStatsCard.tsx +++ b/frontend/src/components/ProfileStatsCard.tsx @@ -4,11 +4,17 @@ function pct(v: number | null | undefined): string { return v == null ? "—" : `${Math.round(v * 100)}%`; } -export function ProfileStatsCard({ stats }: { stats: ProfileStats }) { +export function ProfileStatsCard({ + stats, + title = "Моя статистика", +}: { + stats: ProfileStats; + title?: string; +}) { const o = stats.overall; return (
-

Моя статистика

+

{title}

Игр
{o.games} diff --git a/frontend/src/components/TelegramLoginButton.tsx b/frontend/src/components/TelegramLoginButton.tsx index a18f9c3..1fb401f 100644 --- a/frontend/src/components/TelegramLoginButton.tsx +++ b/frontend/src/components/TelegramLoginButton.tsx @@ -28,9 +28,18 @@ export function TelegramLoginButton({ onAuth: (user: TelegramUser) => void; }) { const ref = useRef(null); + // onAuth держим в ref, чтобы он НЕ был в зависимостях эффекта: иначе новая инлайн- + // функция на каждый рендер пересоздавала бы виджет (а cleanup сносит его innerHTML + // в момент отрисовки — в dev со StrictMode кнопка из-за этого не появлялась). + const onAuthRef = useRef(onAuth); + onAuthRef.current = onAuth; useEffect(() => { - window.__fsTelegramAuth = (user) => onAuth(user); + window.__fsTelegramAuth = (user) => onAuthRef.current(user); + const node = ref.current; + if (!node) return; + node.innerHTML = ""; // на случай повторного монтирования (StrictMode) + const script = document.createElement("script"); script.src = "https://telegram.org/js/telegram-widget.js?22"; script.async = true; @@ -39,14 +48,12 @@ export function TelegramLoginButton({ script.setAttribute("data-userpic", "false"); script.setAttribute("data-request-access", "write"); script.setAttribute("data-onauth", "__fsTelegramAuth(user)"); + node.appendChild(script); - const node = ref.current; - node?.appendChild(script); return () => { - if (node) node.innerHTML = ""; - delete window.__fsTelegramAuth; + node.innerHTML = ""; }; - }, [botUsername, onAuth]); + }, [botUsername]); // только смена бота пересоздаёт виджет return
; } diff --git a/frontend/src/domain/types.ts b/frontend/src/domain/types.ts index 98e6b6f..1372b52 100644 --- a/frontend/src/domain/types.ts +++ b/frontend/src/domain/types.ts @@ -15,6 +15,7 @@ export type MatchListItem = S["MatchListItem"]; export type Leaderboard = S["Leaderboard"]; export type LeaderboardEntry = S["LeaderboardEntry"]; export type ProfileStats = S["ProfileStats"]; +export type PublicProfile = S["PublicProfile"]; export type GroupStats = S["GroupStats"]; export type HomeResponse = S["HomeResponse"]; export type HomeInProgressMatch = S["HomeInProgressMatch"]; @@ -25,6 +26,7 @@ export type AdminUserRead = S["AdminUserRead"]; export type AdminGroupRead = S["AdminGroupRead"]; export type AdminMatchRead = S["AdminMatchRead"]; export type AuditLogList = S["AuditLogList"]; +export type AchievementRead = S["AchievementRead"]; export type ParticipantInput = S["ParticipantInput"]; export type MatchUpdate = S["MatchUpdate"]; export type MatchCreate = S["MatchCreate"]; diff --git a/frontend/src/hooks/admin.ts b/frontend/src/hooks/admin.ts index ad9c882..24ad51f 100644 --- a/frontend/src/hooks/admin.ts +++ b/frontend/src/hooks/admin.ts @@ -1,9 +1,14 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { api, unwrap } from "../api/client"; +import { ApiError, api, unwrap } from "../api/client"; import { qk } from "../api/queryKeys"; import type { AdminMe, MatchUpdate } from "../domain/types"; +function readCsrfToken(): string | null { + const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/); + return m ? decodeURIComponent(m[1]) : null; +} + export function useAdminMe() { return useQuery({ queryKey: qk.adminMe, @@ -163,3 +168,84 @@ export function useAdminUpdateUser() { onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminUsers }), }); } + +// ─── Ачивки (редактор; выдача игрокам — на будущее) ────────────────────────── + +interface AchievementInput { + name?: string; + description?: string; + condition?: string; +} + +export function useAdminAchievements() { + return useQuery({ + queryKey: qk.adminAchievements, + queryFn: async () => unwrap(await api.GET("/api/admin/achievements")), + }); +} + +export function useCreateAchievement() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body: AchievementInput & { name: string }) => + unwrap(await api.POST("/api/admin/achievements", { body })), + onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }), + }); +} + +export function useUpdateAchievement() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (args: { slug: string; body: AchievementInput }) => + unwrap( + await api.PATCH("/api/admin/achievements/{slug}", { + params: { path: { slug: args.slug } }, + body: args.body, + }), + ), + onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }), + }); +} + +export function useDeleteAchievement() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (slug: string) => + unwrap( + await api.DELETE("/api/admin/achievements/{slug}", { + params: { path: { slug } }, + }), + ), + onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }), + }); +} + +// Иконка ачивки — multipart отдельным fetch (как аватар), с CSRF из cookie. +export function useUploadAchievementIcon() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ slug, file }: { slug: string; file: File }) => { + const form = new FormData(); + form.append("file", file, file.name); + const csrf = readCsrfToken(); + const base = import.meta.env.VITE_API_BASE_URL || ""; + const r = await fetch(`${base}/api/admin/achievements/${slug}/icon`, { + method: "PUT", + body: form, + credentials: "include", + headers: csrf ? { "X-CSRF-Token": csrf } : {}, + }); + if (!r.ok) { + let env: { code?: string; message?: string } | undefined; + try { + env = ((await r.json()) as { error?: typeof env }).error; + } catch { + /* тело без JSON */ + } + throw new ApiError(env?.message || "Не удалось загрузить", env?.code || "ERROR", r.status); + } + return await r.json(); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }), + }); +} diff --git a/frontend/src/hooks/auth.ts b/frontend/src/hooks/auth.ts index 9948e11..aef11de 100644 --- a/frontend/src/hooks/auth.ts +++ b/frontend/src/hooks/auth.ts @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { api, unwrap } from "../api/client"; +import { ApiError, api, unwrap } from "../api/client"; import { qk } from "../api/queryKeys"; import type { AuthConfig, Me } from "../domain/types"; @@ -44,6 +44,8 @@ export function useStubLogin() { } // Вход через Telegram Login Widget: payload виджета проверяется на сервере по HMAC. +// При первом входе, если тег занят, бэкенд отдаёт 409 TELEGRAM_NICKNAME_REQUIRED — +// тогда вызывается useTelegramRegister с выбранным ником (тот же payload виджета). export function useTelegramLogin() { const qc = useQueryClient(); return useMutation({ @@ -56,6 +58,20 @@ export function useTelegramLogin() { }); } +// Регистрация через Telegram с выбранным ником (когда тег занят/некорректен). +// payload — те же данные виджета + nickname; подпись проверяется заново на сервере. +export function useTelegramRegister() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (payload: Record) => + unwrap(await api.POST("/api/auth/telegram/register", { body: payload as never })), + onSuccess: (me) => { + qc.setQueryData(qk.me, me); + qc.invalidateQueries(); + }, + }); +} + export function useCreateDevUser() { const qc = useQueryClient(); return useMutation({ @@ -88,6 +104,105 @@ export function useUpdateNickname() { }); } +// «О себе». +export function useUpdateBio() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (bio: string): Promise => + unwrap(await api.PATCH("/api/users/me/profile", { body: { bio } })), + onSuccess: (me) => { + qc.setQueryData(qk.me, me); + qc.invalidateQueries({ queryKey: qk.home }); + }, + }); +} + +// Аватар. Картинку уменьшаем на клиенте (≤512px) и грузим multipart'ом отдельным +// fetch (openapi-fetch неудобен для файлов); CSRF-токен из cookie ставим вручную. +function readCsrfToken(): string | null { + const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/); + return m ? decodeURIComponent(m[1]) : null; +} + +async function resizeImage(file: File, max = 512): Promise { + try { + const dataUrl = await new Promise((res, rej) => { + const fr = new FileReader(); + fr.onload = () => res(fr.result as string); + fr.onerror = () => rej(fr.error); + fr.readAsDataURL(file); + }); + const img = await new Promise((res, rej) => { + const i = new Image(); + i.onload = () => res(i); + i.onerror = () => rej(new Error("image load failed")); + i.src = dataUrl; + }); + let { width, height } = img; + if (width > max || height > max) { + const scale = Math.min(max / width, max / height); + width = Math.round(width * scale); + height = Math.round(height * scale); + } + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) return file; + ctx.drawImage(img, 0, 0, width, height); + const blob = await new Promise((res) => + canvas.toBlob(res, "image/jpeg", 0.85), + ); + return blob ?? file; + } catch { + return file; // не вышло уменьшить — отправим как есть (сервер проверит тип/размер) + } +} + +export function useUploadAvatar() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (file: File): Promise => { + const blob = await resizeImage(file); + const form = new FormData(); + form.append("file", blob, "avatar.jpg"); + const csrf = readCsrfToken(); + const base = import.meta.env.VITE_API_BASE_URL || ""; + const r = await fetch(`${base}/api/users/me/avatar`, { + method: "PUT", + body: form, + credentials: "include", + headers: csrf ? { "X-CSRF-Token": csrf } : {}, + }); + if (!r.ok) { + let env: { code?: string; message?: string; details?: unknown } | undefined; + try { + env = ((await r.json()) as { error?: typeof env }).error; + } catch { + /* тело без JSON */ + } + throw new ApiError(env?.message || "Не удалось загрузить", env?.code || "ERROR", r.status, env?.details); + } + return (await r.json()) as Me; + }, + onSuccess: (me) => { + qc.setQueryData(qk.me, me); + qc.invalidateQueries(); + }, + }); +} + +export function useDeleteAvatar() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (): Promise => unwrap(await api.DELETE("/api/users/me/avatar")), + onSuccess: (me) => { + qc.setQueryData(qk.me, me); + qc.invalidateQueries(); + }, + }); +} + export function useSetActiveGroup() { const qc = useQueryClient(); return useMutation({ diff --git a/frontend/src/hooks/stats.ts b/frontend/src/hooks/stats.ts index 169bfe5..a02ed98 100644 --- a/frontend/src/hooks/stats.ts +++ b/frontend/src/hooks/stats.ts @@ -24,3 +24,16 @@ export function useMyStats() { queryFn: async () => unwrap(await api.GET("/api/users/me/stats")), }); } + +// Публичный профиль другого игрока (read-only). +export function usePublicProfile(userId: number) { + return useQuery({ + queryKey: qk.publicProfile(userId), + queryFn: async () => + unwrap( + await api.GET("/api/users/{user_id}/profile", { + params: { path: { user_id: userId } }, + }), + ), + }); +} diff --git a/frontend/src/pages/AccountPage.tsx b/frontend/src/pages/AccountPage.tsx index fcf2a90..bae0b26 100644 --- a/frontend/src/pages/AccountPage.tsx +++ b/frontend/src/pages/AccountPage.tsx @@ -1,26 +1,42 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { ApiError } from "../api/client"; +import { Avatar } from "../components/Avatar"; import { GroupSwitcher } from "../components/GroupSwitcher"; import { ProfileStatsCard } from "../components/ProfileStatsCard"; import { Spinner } from "../components/Spinner"; import { useToast } from "../context/ToastContext"; -import { useLogout, useMe, useUpdateNickname } from "../hooks/auth"; +import { + useDeleteAvatar, + useLogout, + useMe, + useUpdateBio, + useUpdateNickname, + useUploadAvatar, +} from "../hooks/auth"; import { useMyStats } from "../hooks/stats"; export function AccountPage() { const { data: me, isLoading } = useMe(); const { data: stats } = useMyStats(); const updateNick = useUpdateNickname(); + const updateBio = useUpdateBio(); + const uploadAvatar = useUploadAvatar(); + const deleteAvatar = useDeleteAvatar(); const logout = useLogout(); const toast = useToast(); const navigate = useNavigate(); + const fileRef = useRef(null); const [nickname, setNickname] = useState(""); + const [bio, setBio] = useState(null); if (isLoading || !me) return ; - const save = async () => { + // bio редактируется локально; до первого ввода берём значение из профиля. + const bioValue = bio ?? me.bio ?? ""; + + const saveNick = async () => { const nick = nickname.trim(); if (!nick) return; try { @@ -32,6 +48,36 @@ export function AccountPage() { } }; + const saveBio = async () => { + try { + await updateBio.mutateAsync(bioValue.trim()); + toast.show("Сохранено"); + } catch (e) { + toast.show(e instanceof ApiError ? e.message : "Ошибка"); + } + }; + + const onPickFile = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ""; // позволяем выбрать тот же файл повторно + if (!file) return; + try { + await uploadAvatar.mutateAsync(file); + toast.show("Аватар обновлён"); + } catch (err) { + toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить"); + } + }; + + const removeAvatar = async () => { + try { + await deleteAvatar.mutateAsync(); + toast.show("Аватар удалён"); + } catch (e) { + toast.show(e instanceof ApiError ? e.message : "Ошибка"); + } + }; + const doLogout = async () => { await logout.mutateAsync().catch(() => {}); navigate("/login", { replace: true }); @@ -41,15 +87,64 @@ export function AccountPage() {

Профиль

-
- Никнейм - {me.nickname} +
+ +
+ {me.nickname} +
+ + {me.avatar_url && ( + + )} +
+ +
+ +
+ +