Profile
1. Summary
Goal: Центральная точка данных пользователя — балансы, статистика, прогресс. Единый источник правды для состояния аккаунта во всех частях приложения.
User Value: Личный кабинет с полной информацией об активности, балансах, достижениях и готовности к выводу скинов.
2. Business Logic
Types of Data
- Balances
- Statistics
- Steam Integration
Scrap — основная внутриигровая валюта
- Источники: кейсы, спины, квесты, ачивменты, рефералы
- Трата: платные кейсы, платные спины
- Баланс
scrapна User (O(1) чтение), breakdown по источникам — только вUserSeasonStats
XP (Experience) — метрика прогресса в сезоне
- Только растёт (никогда не тратится)
- Сбрасывается при завершении сезона
- Влияет на позицию в рейтинге
Streak Points (SP) — валюта лояльности
- Источники: ежедневные входы, квесты, достижения, топ-бонусы
- Трата: билеты в розыгрышах, Streak кейсы
Quiz Statistics — агрегация по категориям
- Группировка QuizResult по category
- Расчёт accuracy:
Math.round((correct / total) * 1000) / 10(округление до 1 знака после запятой) - Сортировка по accuracy (descending)
All-time Statistics — сумма через все сезоны
- SUM: XP, Scrap earned, quizzes completed, cases opened
- MAX: лучшие стрики (login, correct answers, diverse category)
friendsInvited— хранится на User (не сбрасывается)
Streak Points Statistics — breakdown по источникам
- Источники: dailyRewards, quests, achievements, topBonuses, raffleWins
- Траты: casesOpened, spinsBought, raffleTickets
Trade URL — ссылка для вывода скинов
- Валидация формата Steam Trade URL
- Автоматическая верификация SteamID
- Блокировка изменений при активных выводах
Verification — два типа верификации
- Автоматическая: API проверка библиотеки, возраста аккаунта
- Ручная: одобрение администратором для edge cases
Withdraw Readiness — 6 точек проверки перед выводом
Profile Data Assembly
При запросе профиля (GET /api/users/profile) происходит сборка данных:
1. Fetch User Data
- Загрузка полей из User модели (балансы, стрики, статусы)
- Агрегатные балансы (
scrap,xp,level,streakPoints) — O(1) чтение
2. Check and Update Streak
StreakService.checkAndUpdateStreak()вызывается при каждом запросе- Автоматическое обновление
dailyLoginStreakесли прошёл день
3. Ensure Season Participation
- Upsert записи
UserSeasonStatsдля активного сезона - Создаёт участие автоматически при первом входе в сезон
4. Calculate Derived Fields
avatar— URL из static сервераactiveBuffs— список активных баффов черезBuffServicestreakInfo— информация о использовании щитов (если применимо)
Запрос профиля — не чистый read! Происходит обновление стрика и создание записи сезона. Это сделано для UX: пользователь не должен явно "начинать день".
Avatar Caching System
Аватарки пользователей кэшируются на static сервере вместо прямого использования Telegram URL.
Проблемы которые решает:
| Проблема | Описание |
|---|---|
| Временные URL | Telegram Bot API возвращает URL действительные ~1 час |
| Утечка токена | Прямой URL содержит токен бота — нельзя отдавать на frontend |
| Производительность | Запрос к Telegram API на каждый показ аватара — медленно |
Как работает:
Хранение:
- Путь на диске:
/static/images/avatars/{telegramId}.jpg - В БД (
User.avatarUrl): relative path/images/avatars/{telegramId}.jpg?v={mtime} - URL для клиента:
{STATIC_URL}/images/avatars/{telegramId}.jpg(prepend черезnormalizeImageUrl()при чтении) - TTL кэша: 24 часа (после этого перезагружается с Telegram)
Почему 24 часа?
| TTL | Плюсы | Минусы |
|---|---|---|
| 1 час | Быстрое обновление | Много запросов к Telegram API |
| 24 часа ✓ | Баланс свежести и нагрузки | Аватар обновится максимум через сутки |
| 7 дней | Минимум запросов | Слишком устаревшие аватары |
Люди редко меняют аватарки. Сутки — разумный компромисс.
Где используется:
profile.photoUrl— аватар текущего пользователяleaderboard[].avatarUrl— аватары топ-10 в рейтинге- Feed events — аватары в ленте активности
Файлы:
- Service:
backend/src/domains/users/services/avatar-cache.service.ts - URL Builder:
backend/src/common/constants/url.constants.ts→buildAvatarUrl()
Level System
Прогрессивная формула: каждый уровень требует больше XP, чем предыдущий.
Детали расчёта уровня
Уровень рассчитывается на основе накопленного XP с прогрессивно растущими требованиями.
| Уровень | Требуется XP |
|---|---|
| 1 | 0 |
| 2 | 100 |
| 3 | 250 |
| ... | ... |
Уровень влияет на:
- Визуальное отображение в профиле
- Возможные будущие разблокировки
Steam Verification Flow
Установка Trade URL:
- Пользователь вводит Steam Trade URL
- Backend валидирует формат URL
- Извлекается SteamID из URL
- Вызывается SteamVerificationService для проверки:
- Существование аккаунта
- Возраст аккаунта (
steamAccountCreatedAt) - Стоимость библиотеки (
steamLibraryValueUsd) - Количество игр (
steamGamesCount)
- Результат: автоматическая верификация или необходимость ручной
Несмотря на название поля steamLibraryValueUsd, фактическая проверка стоимости библиотеки выполняется в рублях (регион cc=ru). Порог верификации: MIN_LIBRARY_VALUE_RUB: 1000 (1000 руб.). Константа определена в steam-verification.types.ts.
Ручная верификация:
- Админ просматривает заявку через тикет
- Одобряет или отклоняет с комментарием
steamManuallyVerified= true при одобрении
Withdraw Readiness Checks
6-точечная проверка готовности к выводу:
| # | Проверка | Описание |
|---|---|---|
| 1 | hasTradeUrl | Trade URL установлен |
| 2 | isSteamVerified | Steam аккаунт верифицирован (авто или ручной) |
| 3 | isInventoryPublic | Инвентарь Steam публичный |
| 4 | noActiveWithdrawal | Нет активного вывода в процессе |
| 5 | userHasItem | Предмет существует в инвентаре пользователя |
| 6 | itemAvailableOnBot | Предмет доступен на боте (tradable + marketable) |
Protection
| Действие | Rate Limit | Auth | Validation |
|---|---|---|---|
| Get profile | general (100/min) | Telegram | UserProfileGetSchema |
| Get stats | general (100/min) | Telegram | UserStatsSchema |
| Update profile | general (100/min) | — (нет telegramAuth) | UserProfileUpdateSchema |
| Set trade URL | general (100/min) | Telegram | UpdateSteamTradeUrlSchema |
| Delete trade URL | general (100/min) | Telegram | DeleteSteamTradeUrlSchema |
| Get verification | general (100/min) | Telegram | GetVerificationStatusSchema |
| Get withdraw readiness | general (100/min) | Telegram | GetWithdrawReadinessSchema |
| Get Rust status | general (100/min) | Telegram | GetRustOnlineStatusSchema |
См. Security Matrix для полного обзора защит.
Edge Cases
| Ситуация | Поведение | UI |
|---|---|---|
| Активный вывод | Блок изменения Trade URL | Сообщение "Дождитесь завершения вывода" |
| Удаление Trade URL с активной Rust сессией | Сессии закрываются, кэш инвалидируется, прогресс квестов сохранён | Через ≤60 сек plugin увидит пустые квесты |
| Steam не верифицирован | Withdraw Readiness = false | Кнопка вывода неактивна |
| Streak shield использован | streakInfo в ответе | Показ информации о защите |
| Первый вход в сезон | Auto-create UserSeasonStats | Прозрачно для пользователя |
| Клик на badge звания в header | Открывает TitlesModal | Кликабельный badge под username: иконка Award + текст звания + chevron |
| Клик на "Промокод" в List Card | Collapsible секция раскрывается inline | Chevron → collapse arrow, под item появляется PromoCodeInput с анимацией max-height |
| Нет активного звания | Fallback текст | Badge показывает "Путник" |
| Сезонный ранг отсутствует | Нет XP в сезоне | Текст "Рейтинг появится после получения XP" вместо ранга |
3. ADR (Architectural Decisions)
Почему денормализованные поля на User?
Проблема: Подсчёт статистики из истории (QuizResult, CaseOpening, etc.) слишком медленный для каждого запроса профиля.
Решение: Денормализованные агрегатные поля на User модели:
- Балансы:
scrap,xp,level,streakPoints - Счётчики:
quizzesCompleted,correctAnswers,casesOpened,friendsInvited
Ранее User содержал 17 полей-breakdown (scrapFromQuizzes, xpFromQuests, scrapSpent и др.), дублирующих данные из UserSeasonStats. Все reads для all-time статистики уже использовали SUM(USS), а поля на User создавали риск рассинхрона и лишние writes. Удалены в рамках рефакторинга (commit e48332834). Breakdown по источникам теперь хранится только в UserSeasonStats.
Альтернативы (отклонены):
- Real-time агрегация — O(n) на каждый запрос, неприемлемо
- Redis cache — сложность инвалидации, eventual consistency
- Materialized views — дополнительная нагрузка на PostgreSQL
Последствия:
- Быстрые чтения (O(1) для балансов)
- Breakdown по источникам — через
SUM(UserSeasonStats) - Каждая операция должна атомарно обновлять USS
Почему UserSeasonStats отдельно от User?
Проблема: Нужна статистика, которая сбрасывается между сезонами, но all-time данные должны сохраняться.
Решение: Отдельная таблица UserSeasonStats с compound unique (userId, seasonId).
Последствия:
- All-time статистика = SUM/MAX через все записи UserSeasonStats
- Сезонный сброс = создание новой записи без удаления старых
Dual-Write Pattern: User + UserSeasonStats
При изменении баланса или счётчика на User ОБЯЗАТЕЛЬНО обновлять соответствующее поле в UserSeasonStats.
Проблема: Нужны быстрые O(1) чтения балансов (scrap, streakPoints) и счётчиков, но также нужна сезонная агрегация для All-Time статистики.
Решение: Dual-write в обе таблицы:
User = Materialized View (агрегатные балансы, O(1) чтение)
UserSeasonStats = Source of Truth (breakdown по источникам + сезонная агрегация)
Почему не только UserSeasonStats?
| Операция | Только UserSeasonStats | Dual-write (текущий) |
|---|---|---|
| Проверка баланса | aggregate() ~10-50ms | user.scrap ~1-5ms |
| Отображение профиля | aggregate на каждый запрос | прямое чтение |
| Покупка кейса | aggregate → check → update | read → check → update |
Альтернативы (отклонены):
- Только UserSeasonStats — медленные агрегации на каждый запрос баланса
- Redis cache — сложность инвалидации, eventual consistency
- Computed columns — PostgreSQL не поддерживает cross-table computed
Синхронизируемые поля:
| User поле | UserSeasonStats поле | Утилита |
|---|---|---|
scrap | scrap, scrapFrom{Quizzes,Tasks,...} | updateSeasonScrap() |
xp | xp, xpFrom{Tasks,Achievements,...} | updateSeasonXP() |
friendsInvited | friendsInvited | updateSeasonStats() |
achievementsUnlocked | achievementsUnlocked | updateSeasonStats() |
casesOpened | casesOpened | updateSeasonStats() |
dailyCasesOpened | dailyCasesOpened | updateSeasonStats() |
dailySpinsUsed | dailySpinsUsed | updateSeasonStats() |
itemsCrafted | itemsCrafted | updateSeasonStats() |
itemsSalvaged | itemsSalvaged | updateSeasonStats() |
streakPointsTotal | streakPointsEarned | updateSeasonStats() |
streakPointsSpent | streakPointsSpent | updateSeasonStats() |
bestDailyLoginStreak | bestDailyLoginStreak | direct upsert (MAX) |
Source-breakdown поля (scrapFrom{Source}, xpFrom{Source}, scrapSpent{Source}) хранятся только в UserSeasonStats. User содержит только агрегатные балансы (scrap, xp). All-Time статистика читается через SUM(USS).
Правило для разработчиков:
// НЕПРАВИЛЬНО — обновляем только User
await tx.user.update({
where: { id: userId },
data: { friendsInvited: { increment: 1 } }
});
// ПРАВИЛЬНО — обновляем User + UserSeasonStats
await tx.user.update({
where: { id: userId },
data: { friendsInvited: { increment: 1 } }
});
await updateSeasonStats(tx, userId, {
incrementFields: { friendsInvited: 1 }
});
Последствия:
- Быстрые O(1) чтения балансов и счётчиков
- All-Time =
SUM(UserSeasonStats)работает корректно - Breakdown по источникам — единственный source of truth в USS
- Требуется дисциплина: не забывать синхронизировать
Утилита updateSeasonStats
Путь: backend/src/domains/seasons/utils/season-stats-updater.ts
await updateSeasonStats(tx, userId, {
incrementFields: {
friendsInvited: 1,
casesOpened: 1
}
});
Автоматически:
- Находит активный сезон
- Upsert в UserSeasonStats
- Инкрементирует указанные поля
Почему консолидация карточек профиля (8→5)?
Проблема: ProfileScreen содержал 8 одинаковых glass-card карточек без визуальной иерархии — всё выглядело одинаково важным. Три идентичных list-item карточки внизу (Звание, Достижения, История) были overkill, промокод занимал целую карточку ради редко используемой функции, сезонный ранг дублировался (badge на аватаре + отдельная карточка).
Решение: Сокращение до 5 карточек с чёткой иерархией:
- Header Card — аватар, имя, звание (inline badge), XP, streak/invite, boost pass, buffs
- Steam Trade URL — без изменений
- Quick Actions — 4 кнопки (без изменений)
- Season Card — компактный (ранг + подсказка в одну строку)
- List Card — Достижения / История / Промокод (collapsible inline)
Альтернативы (отклонены):
- Оставить 8 карточек с визуальной дифференциацией — не решает проблему скролла
- Tabs / accordion для всего — усложняет навигацию
Последствия:
- Меньше скролла, чёткая иерархия информации
- Звание стало частью идентичности пользователя (inline в header)
- Промокод не занимает отдельную карточку — раскрывается inline при необходимости
Почему Streak Update на Read?
Проблема: Пользователь не должен явно "чекиниться" каждый день.
Решение: checkAndUpdateStreak() вызывается при GET /profile.
Альтернативы (отклонены):
- Отдельный endpoint
/checkin— лишний шаг для пользователя - Cron job — сложность с timezone, нагрузка пиком в полночь
Последствия:
- Read endpoint имеет side effects (не REST pure)
- Гарантированное обновление при первом входе за день
- Streak Shield автоматически применяется при пропуске
4. Architecture
Profile Data Flow
Key Components
| Компонент | Путь | Описание |
|---|---|---|
| ProfileController | backend/src/domains/users/controllers/user-profile.controller.ts | HTTP handlers для профиля |
| StatisticsService | backend/src/domains/users/services/statistics.service.ts | Агрегация статистики |
| StreakService | backend/src/domains/streaks/services/streak.service.ts | Управление стриками |
| BuffService | backend/src/domains/buffs/services/buff.service.ts | Активные баффы |
| SteamVerificationService | backend/src/domains/steam-verification/services/steam-verification.service.ts | Верификация Steam |
| WithdrawReadinessService | backend/src/domains/steam-trade-bot/services/withdraw-readiness.service.ts | Проверка готовности к выводу |
| Routes | backend/src/domains/users/routes/user-profile.routes.ts | API endpoints |
| Schemas (profile) | backend/src/domains/users/schemas/user-profile.schemas.ts | Request/Response validation для профиля |
| Schemas (integrations) | backend/src/domains/users/schemas/user-integrations.schemas.ts | Steam, verification, readiness, statistics schemas |
| ProfileScreen | frontend/src/components/screens/ProfileScreen.tsx | UI экрана профиля |
ProfileScreen Layout
5 карточек сверху вниз:
| # | Карточка | Содержимое |
|---|---|---|
| 1 | Header Card | Аватар, имя, @username, inline badge звания (кликабельный → TitlesModal), XP прогресс, streak/invite, boost pass, buffs |
| 2 | Steam Trade URL | Ввод/отображение Trade URL |
| 3 | Quick Actions | Сетка 2×2 кнопок быстрых действий |
| 4 | Season Card | Compact: название сезона + день, ранг + подсказка в одну строку |
| 5 | List Card | Достижения (Trophy) / История (Clock) / Промокод (Gift, collapsible inline) — разделены border-t |
5. Database Schema
Models
| Модель | Описание | Ключевые поля |
|---|---|---|
| User | Основная модель | telegramId, scrap, xp, level, балансы, стрики |
| UserSeasonStats | Статистика по сезонам | userId, seasonId, XP/Scrap breakdown |
User Key Fields (subset)
| Категория | Поля |
|---|---|
| Identity | id, telegramId, username, firstName |
| Balances | scrap, xp, level, streakPoints |
| Counters | quizzesCompleted, correctAnswers, casesOpened, friendsInvited |
| Streaks | dailyLoginStreak, bestDailyLoginStreak |
| Steam | steamTradeUrl, steamId, steamVerified, steamManuallyVerified |
| Status | isActive, isBanned, isPremium |
Поля scrapFrom{Source}, xpFrom{Source}, scrapSpent{Source} хранятся только в UserSeasonStats. All-time статистика читается через SUM(USS) в GET /api/users/statistics/all-time.
См. SCHEMA_GUIDE для полного списка полей User (строки 14-193).
Relationships
6. API Endpoints
User API
| Метод | Эндпоинт | Описание | Docs |
|---|---|---|---|
| GET | /api/users/profile | Полный профиль с балансами | → |
| GET | /api/users/stats | Базовая информация профиля (имя, дата регистрации, биндов, избранного) | → |
| GET | /api/users/statistics/categories | Статистика квизов по категориям | → |
| GET | /api/users/statistics/all-time | All-time статистика | → |
| GET | /api/users/statistics/streak-points | Streak Points breakdown | → |
| PUT | /api/users/profile | Обновление профиля пользователя | → |
| PUT | /api/users/steam/trade-url | Установка Steam Trade URL | → |
| DELETE | /api/users/steam/trade-url | Удаление Steam Trade URL | → |
| GET | /api/users/verification-status | Статус Steam верификации | → |
| GET | /api/users/withdraw-readiness | Готовность к выводу | → |
| GET | /api/users/rust-online-status | Онлайн статус в Rust | → |
Response Examples
GET /api/users/profile
{
"success": true,
"data": {
"id": "user-id",
"telegramId": 123456789,
"firstName": "Alex",
"scrap": 1500,
"xp": 2300,
"level": 5,
"streakPoints": 450,
"dailyLoginStreak": 7,
"avatar": "https://static.example.com/images/avatars/123456789.jpg",
"activeBuffs": [
{ "type": "XP_BUFF", "multiplier": 1.5, "expiresAt": "..." }
],
"streakInfo": null
}
}
GET /api/users/statistics/categories
{
"success": true,
"data": [
{ "category": "weapons", "displayName": "Оружие", "total": 50, "correct": 42, "accuracy": 84.0 },
{ "category": "maps", "displayName": "Карты", "total": 30, "correct": 21, "accuracy": 70.0 }
]
}
7. Account Reset (Internal)
Эта секция описывает внутренние механизмы сброса аккаунта.
Partial Reset (UserDataResetService)
Частичный сброс данных пользователя с фокусом на referral систему:
- Деактивация исходящих referral sessions →
state=USER_RESET - Деактивация входящих referral sessions →
state=USER_RESET - Пересчёт лимитов для затронутых рефереров
- Установка
isBanned=true
Сохраняется: Балансы, инвентарь, история (для аналитики).
Full Reset (UserFullResetService)
Полная очистка по команде /stop в Telegram боте:
| Шаг | Операция |
|---|---|
| 1 | Отмена активных выводов (PENDING, PROCESSING, SENT) → status=FAILED |
| 2 | Удаление инвентаря |
| 3 | Удаление истории кейсов + статистики кейсов (userCaseStats) |
| 4 | Удаление результатов квизов |
| 5 | Удаление результатов спинов |
| 6 | Удаление истории крафтов |
| 7 | Удаление достижений |
| 8 | Удаление сезонной статистики |
| 9 | Удаление feed events |
| 10 | Удаление bot interactions |
| 11 | Маркировка referral sessions как USER_RESET (исходящие + входящие) |
| 12 | Обнуление полей на User (см. детали ниже) |
Детали шага 12: какие поля обнуляются
Обнуляется подмножество полей User, а не все поля:
| Группа | Поля |
|---|---|
| Балансы | scrap, xp, level (→ 1) |
| Quiz Stats | quizzesCompleted, correctAnswers, incorrectAnswers |
| Streaks | dailyLoginStreak, bestDailyLoginStreak |
| Daily Rewards | lastDailyCase (→ null), lastDailySpin (→ null) |
| Status | botStatus → BLOCKED |
НЕ обнуляются (остаются как есть):
questsCompleted, achievementsUnlocked, friendsInvited, casesOpened, dailyCasesOpened, dailySpinsUsed, itemsSalvaged, itemsCrafted, streakPoints, streakPointsTotal, streakPointsSpent, passiveIncomeBalance
Сохраняется:
referral_codes— пользователь может снова пригласить друзей после /startuser_feedback— ценно для бизнеса- Analytics данные (banner/push interactions)
Технические детали:
- Выполняется в транзакции с 30s timeout
botStatus=BLOCKEDпосле сброса- Extensive logging для audit trail
8. Related
- Profile State Management — фронтенд архитектура: React Query как sole source of truth
- Settings — настройки пользователя
- Onboarding — регистрация и активация
- Streaks — система лояльности
- Buffs — временные бонусы
- Seasons — сезонный рейтинг
- Feedback — система обратной связи
- Steam Trade — вывод скинов
- Security Matrix — обзор защит