Profile
1. Summary
Goal: Центральная точка данных пользователя — балансы, статистика, прогресс. Единый источник правды для состояния аккаунта во всех частях приложения.
User Value: Личный кабинет с полной информацией об активности, балансах, достижениях и готовности к выводу скинов.
2. Business Logic
Types of Data
- Balances
- Statistics
- Steam Integration
Scrap — основная внутриигровая валюта
- Источники: кейсы, спины, квесты, ачивменты, рефералы
- Трата: платные кейсы, платные спины
- Денормализация:
scrapFromQuizzes,scrapFromCases,scrapFromSpinsи др.
XP (Experience) — метрика прогресса в сезоне
- Только растёт (никогда не тратится)
- Сбрасывается при завершении сезона
- Влияет на позицию в рейтинге
Streak Points (SP) — валюта лояльности
- Источники: ежедневные входы, milestone бонусы
- Трата: билеты в розыгрышах, Streak кейсы
Quiz Statistics — агрегация по категориям
- Группировка QuizResult по category
- Расчёт accuracy:
(correct / total) * 100 - Сортировка по 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, milestones, quests, achievements, topBonuses, raffleWins
- Траты: casesOpened, spinsBought, raffleTickets
Trade URL — ссылка для вывода скинов
- Валидация формата Steam Trade URL
- Автоматическая верификация SteamID
- Блокировка изменений при активных выводах
Verification — два типа верификации
- Автоматическая: API проверка библиотеки, возраста аккаунта
- Ручная: одобрение администратором для edge cases
Withdraw Readiness — 7 точек проверки перед выводом
Profile Data Assembly
При запросе профиля (GET /api/users/profile) происходит сборка данных:
1. Fetch User Data
- Загрузка 50+ полей из User модели
- Включая все денормализованные счётчики
2. Check and Update Streak
StreakService.checkAndUpdateStreak()вызывается при каждом запросе- Автоматическое обновление
dailyLoginStreakесли прошёл день
3. Ensure Season Participation
- Upsert записи
UserSeasonStatsдля активного сезона - Создаёт участие автоматически при первом входе в сезон
4. Calculate Derived Fields
avatar— URL из static сервераnextDailyCaseAvailable— время следующего бесплатного кейса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 - URL для клиента:
https://static.goloot.online/images/avatars/{telegramId}.jpg - 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)
- Результат: автоматическая верификация или необходимость ручной
Ручная верификация:
- Админ просматривает заявку через тикет
- Одобряет или отклоняет с комментарием
steamManuallyVerified= true при одобрении
Withdraw Readiness Checks
7-точечная проверка готовности к выводу:
| # | Проверка | Описание |
|---|---|---|
| 1 | hasTradeUrl | Trade URL установлен |
| 2 | isSteamVerified | Steam аккаунт верифицирован (авто или ручной) |
| 3 | isInventoryPublic | Инвентарь Steam публичный |
| 4 | noActiveWithdrawal | Нет активного вывода в процессе |
| 5 | itemExists | Предмет существует в инвентаре |
| 6 | itemWithdrawable | Тип предмета поддерживает вывод (SKIN) |
| 7 | botAvailable | Бот с предметом доступен |
Protection
| Действие | Rate Limit | Auth | Validation |
|---|---|---|---|
| Get profile | general (100/min) | Telegram | GetProfileSchema |
| Get stats | general (100/min) | Telegram | GetStatsSchema |
| Submit feedback | feedback (1/min) | Telegram | FeedbackSchema |
| Set trade URL | general (100/min) | Telegram | UpdateTradeUrlSchema |
| Delete trade URL | general (100/min) | Telegram | DeleteTradeUrlSchema |
| Get verification | general (100/min) | Telegram | GetVerificationSchema |
| Get withdraw readiness | general (100/min) | Telegram | GetWithdrawReadinessSchema |
| Get Rust status | general (100/min) | Telegram | GetRustOnlineStatusSchema |
См. Security Matrix для полного обзора защит.
Edge Cases
| Ситуация | Поведение | UI |
|---|---|---|
| Активный вывод | Блок изменения Trade URL | Сообщение "Дождитесь завершения вывода" |
| Steam не верифицирован | Withdraw Readiness = false | Кнопка вывода неактивна |
| Streak shield использован | streakInfo в ответе | Показ информации о защите |
| Первый вход в сезон | Auto-create UserSeasonStats | Прозрачно для пользователя |
| Feedback rate limit | HTTP 429 | Toast "Подождите N секунд" |
3. ADR (Architectural Decisions)
Почему денормализованные поля на User?
Проблема: Подсчёт статистики из истории (QuizResult, CaseOpening, etc.) слишком медленный для каждого запроса профиля.
Решение: 30+ денормализованных полей на User модели:
scrapFromQuizzes,scrapFromCases,scrapFromSpinsxpFromQuests,xpFromAchievements,xpFromReferralsquizzesCompleted,correctAnswers,casesOpened
Альтернативы (отклонены):
- Real-time агрегация — O(n) на каждый запрос, неприемлемо
- Redis cache — сложность инвалидации, eventual consistency
- Materialized views — дополнительная нагрузка на PostgreSQL
Последствия:
- Быстрые чтения (O(1) вместо O(n))
- Каждая операция должна атомарно обновлять счётчики
- Риск drift при ошибках — нужны reconciliation механизмы
Почему 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 по сезонам (для агрегации)
Почему не только 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, scrapFrom* | scrap, scrapFrom* | updateSeasonScrap() |
xp, xpFrom* | xp, xpFrom* | 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) |
Правило для разработчиков:
// НЕПРАВИЛЬНО — обновляем только 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)работает корректно - Требуется дисциплина: не забывать синхронизировать
- Риск drift при ошибках — нужен периодический reconciliation
Утилита updateSeasonStats
Путь: backend/src/domains/seasons/utils/season-stats-updater.ts
await updateSeasonStats(tx, userId, {
incrementFields: {
friendsInvited: 1,
casesOpened: 1
}
});
Автоматически:
- Находит активный сезон
- Upsert в UserSeasonStats
- Инкрементирует указанные поля
Почему 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/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/inventory/services/withdraw-readiness.service.ts | Проверка готовности к выводу |
| Routes | backend/src/domains/users/routes/profile.routes.ts | API endpoints |
| Schemas | backend/src/domains/users/schemas/profile.schemas.ts | Request/Response validation |
5. Database Schema
Models
| Модель | Описание | Ключевые поля |
|---|---|---|
| User | Основная модель (116 полей) | telegramId, scrap, xp, level, балансы, стрики |
| UserSeasonStats | Статистика по сезонам | userId, seasonId, XP/Scrap breakdown |
| UserCategoryStreak | Стрики по категориям квизов | userId, category, currentStreak, bestStreak |
User Key Fields (subset)
| Категория | Поля |
|---|---|
| Identity | id, telegramId, username, firstName |
| Balances | scrap, xp, level, streakPoints |
| Scrap Sources | scrapFromQuizzes, scrapFromCases, scrapFromSpins, scrapFromReferrals |
| XP Sources | xpFromQuests, xpFromAchievements, xpFromStreaks, xpFromReferrals |
| Streaks | dailyLoginStreak, bestDailyLoginStreak, correctAnswersStreak |
| Steam | steamTradeUrl, steamId, steamVerified, steamManuallyVerified |
| Status | isActive, isBanned, isPremium |
См. 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 | → |
| POST | /api/users/feedback | Отправка фидбека | → |
| 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.goloot.online/avatars/user-id.jpg",
"nextDailyCaseAvailable": "2024-01-16T10:00:00Z",
"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 | Отмена активных выводов → status=FAILED |
| 2 | Удаление инвентаря |
| 3 | Удаление истории кейсов |
| 4 | Удаление результатов квизов и category streaks |
| 5 | Удаление результатов спинов |
| 6 | Удаление истории крафтов |
| 7 | Удаление достижений |
| 8 | Удаление сезонной статистики |
| 9 | Удаление feed events |
| 10 | Удаление bot interactions |
| 11 | Маркировка referral sessions как USER_RESET |
| 12 | Обнуление всех балансов на User |
Сохраняется:
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 — обзор защит