Документ отвечает на вопрос «что вообще используют такие сайты» и фиксирует решения, принятые для этого проекта.
Публичного исходного кода у них нет, но стек уверенно читается по HTTP-заголовкам, бандлам фронта, форматам WebSocket-кадров и по тому, какие библиотеки вообще существуют под задачу. Картина по индустрии:
| Слой | Что обычно стоит | Почему именно это |
|---|---|---|
| Фронт | Next.js / Nuxt, реже чистый Vue SPA | SEO на страницах кейсов + быстрый TTFB, дальше приложение живёт как SPA на WebSocket |
| API | Node.js (NestJS/Express) или Go, реже PHP-легаси | см. пункт про Steam-библиотеки ниже |
| Реалтайм | WebSocket + Redis Pub/Sub | лента дропов, батлы, баланс — всё пушится, а не поллится |
| БД | PostgreSQL | нужны транзакции и деньги; MySQL встречается в старых проектах |
| Кэш/очереди | Redis + BullMQ (Node) / Asynq (Go) | троттлинг, локи, очередь трейд-офферов, идемпотентность |
| Steam-боты | всегда Node.js | см. ниже |
| Инфра | Docker, Nginx/Traefik, Cloudflare | Cloudflare тут не «для скорости», а как обязательный анти-DDoS |
| Платежи | локальные PSP + крипта | карточные эквайеры такие проекты не любят, крипта почти всегда есть |
Это главный технический факт, который определяет стек. Торговля предметами идёт не
через официальный Web API (он умеет только читать инвентарь и историю), а через
эмуляцию клиента Steam и внутренние эндпоинты steamcommunity.com. Живой,
поддерживаемый набор библиотек для этого существует ровно один — экосистема
DoctorMcKay под Node:
steam-user— логин в Steam как клиент, сессии, refresh-токеныsteamcommunity— куки/сессияsteamcommunity.com, подтверждения мобильникаsteam-tradeoffer-manager— создание, отслеживание и подтверждение трейд-офферовsteam-totp— генерация Steam Guard кодов и confirmation-ключей изshared_secretglobaloffensive— GC-соединение CS2: float, паттерн, стикеры, инспекция
Python-аналоги (steam, steampy) существуют, но заметно отстают: реже
чинятся после изменений на стороне Valve, слабее покрыты подтверждениями
мобильного аутентификатора. Для проекта, где бот-ферма — это касса, отставание
на неделю после очередного изменения Valve означает неделю без выводов.
Вывод: торговый слой обязан быть на Node. Отсюда выбор — писать весь бэкенд на TypeScript, чтобы не тащить два языка, две модели данных и мост между ними.
apps/web Next.js 15 (App Router), React 19, Tailwind, Zustand, socket.io-client
apps/api NestJS 11 на Fastify, Prisma 6, Postgres 16, Redis 7, BullMQ, socket.io
apps/bot Node-воркер: потребитель очереди выводов — покупки на маркете и ферма Steam-ботов
packages/shared общие типы, zod-схемы, переводы, provably fair (используется и API, и фронтом)Один язык на весь проект, общие типы между фронтом и бэком, единый tsconfig.
Проект по составу — это CRM плюс игровое ядро: десятки модулей, роли, гварды, валидация, очереди, вебсокеты, крон. Nest даёт DI и модульность из коробки, что на горизонте года экономит больше, чем стоит его оверхед. Работает поверх Fastify, так что по RPS разница с чистым Fastify — единицы процентов.
Go выиграл бы по RPS на батлах и вебсокетах, но: бот-ферма всё равно осталась бы на Node, то есть два языка; порог входа выше; для нагрузок уровня MVP (до ~5k одновременных сокетов на инстанс) Node с Fastify не является узким местом — им становится Postgres. Если игровое ядро упрётся, его можно вынести в Go отдельным сервисом позже, границы модулей это позволяют.
Ключевые сущности (полная схема — apps/api/prisma/schema.prisma):
- User — привязан к
steamId64, хранит баланс, роль, статус блокировки - Item — справочник предметов CS2:
marketHashName, редкость, тип, цена - CaseCategory — полка каталога («бесплатные кейсы», «по раритетности», «коллекции»). Данные, а не enum: группировка — это витрина, оператор заводит полку под праздник и убирает её после, и ни то ни другое не должно быть деплоем
- Case — кейс: цена, картинка, активность, базовое и английское название, описание на обоих языках, полка, на которой он стоит, и — для бесплатного кейса — порог пополнения и лимит открытий, которые заменяют ему цену
- CaseItem — предмет внутри кейса с тикет-диапазоном (
rangeFrom..rangeTo) - CaseOpening — факт открытия: сид-пара, nonce, ролл, выпавший предмет
- InventoryItem — предмет на аккаунте пользователя, конечный автомат состояний
- Transaction — журнал всех движений по балансу, единственный источник правды о деньгах
- Upgrade — апгрейд: ставка, цель, шанс, ролл, исход
- Contract — обмен нескольких предметов на один: ставка, решённая таблица исходов, ролл, награда
- DailyBonus — одно вращение колеса: сектор, ролл и признак того, потрачен ли он
- Battle / BattleCase / BattlePlayer — кейс-батл: сама договорённость, набор
кейсов с зафиксированными ценами и места. Что именно выпало, лежит в строках
CaseOpening, которые ссылаются на батл - Giveaway / GiveawayEntry — скин, разыгрываемый среди участников, с тем же сид-аппаратом, что и открытие кейса. Хеш сида публикуется, пока приём открыт, а сам сид — только после; клиентский сид это хеш списка участников, поэтому последствия сида нельзя узнать до закрытия приёма
- PromoCode / PromoRedemption — акция на пополнение и каждое её использование
- Referral — кто кого пригласил; пишется один раз и не переписывается
- ReferralEarning — одно начисление комиссии, вне журнала, пока его не забрали
- Setting — настройка рантайма, ключ из общего реестра
- Payment — одна попытка положить деньги на баланс. Строка появляется до того, как игрок дойдёт до кассы, и переживает всё, что там случится: провайдер, подтвердивший дважды — или подтвердивший то, чего сайт не знает, — встречает запись, которая уже знает своё состояние
- ItemDeposit / ItemDepositItem — скины, отданные сайту за баланс: зеркало вывода, с ценой, зафиксированной на заявке, чтобы движение рынка, пока обмен ждёт в Steam, не меняло обещанного
- KycApplication / KycDocument — проверка личности и её документы. Одна строка на игрока, а не на попытку: операционно важно текущее состояние. Байты документов намеренно не в Postgres — скан в колонке это скан в каждом бэкапе, реплике и логе медленных запросов
- Withdrawal — заявка на вывод; несёт канал, которым исполняется
- WithdrawalItem — строка заявки: что именно просили, записывается один раз
- MarketAccount — один ключ market.csgo.com, через который сайт покупает
- MarketPurchase — один скин, купленный на market.csgo.com под один выводимый предмет
- SteamBot — бот фермы: статус, ёмкость инвентаря, лимиты
- ServerSeed / ClientSeed — provably fair
- AuditLog — все действия админов
Все суммы — целые числа в копейках/центах (Int), никогда не Float.
Каждое изменение баланса создаёт запись в Transaction; баланс в User — это
денормализованный кэш, который обязан сходиться с суммой транзакций. Сверка —
ночной крон, расхождение = алерт.
Списание баланса делается условным апдейтом, а не «прочитал → посчитал → записал»:
UPDATE users SET balance = balance - $1 WHERE id = $2 AND balance >= $1Ноль затронутых строк = недостаточно средств. Это исключает гонку при параллельных открытиях с двух вкладок — классический способ увести сайт в минус.
Схема стандартная для индустрии, проверяется пользователем вручную.
- Сервер генерирует
serverSeed(32 случайных байта), публикует толькоsha256(serverSeed). Сид активен, пока пользователь его не сменит. - Пользователь задаёт
clientSeed(или получает случайный) и может менять его когда угодно. - У пары сидов есть счётчик
nonce, инкрементируется на каждое открытие. - Ролл:
hmac = HMAC_SHA256(key = serverSeed, message = `${clientSeed}:${nonce}`)
roll = parseInt(hmac.slice(0, 8), 16) % 1_000_000- Выпадает тот
CaseItem, чей диапазон накрываетroll. - При смене серверного сида старый раскрывается целиком — любой прошлый ролл пересчитывается и сверяется с опубликованным хэшем.
Тикет-пространство — ровно 1_000_000. Диапазоны предметов в кейсе обязаны
покрывать [0, 999999] без дыр и пересечений; это проверяется при сохранении кейса
в админке и отдельным тестом. Такая форма (диапазоны, а не веса) выбрана потому,
что её тривиально проверить вручную и она не зависит от порядка перебора предметов.
Важно: serverSeed не должен зависеть от предмета или от пользователя, и решение о
дропе не имеет права смотреть на баланс игрока. Любая «докрутка» ломает
проверяемость — если нужна экономика, она настраивается диапазонами, а не хаком
ролла.
RTP кейса = Σ(шанс предмета × цена предмета) / цена кейса. Считается из тикет-диапазонов
и текущих цен предметов, показывается в админке при каждом редактировании кейса.
Рабочий коридор — 85–95%; выше 100% кейс убыточен, ниже 80% его не покупают.
Жёсткий потолок — 98%: выше сервер просто не даст сохранить кейс.
Требование «кейс должен быть прибыльным» — это одно уравнение на N неизвестных, решений бесконечно много. Нужна модель, и принята самая естественная для кейсов: чем предмет дороже, тем он реже.
Формально: вес предмета w_i = price_i^(-k), шанс s_i = w_i / Σw. При k = 0
все предметы равновероятны, с ростом k дешёвые вытесняют дорогих. Ожидаемая
отдача монотонно убывает по k, поэтому нужный показатель ищется двоичным
поиском под целевой RTP (packages/shared/src/balancing.ts).
Достижимый диапазон RTP жёстко ограничен ценами крайних предметов: ниже цены самого дешёвого отдача не опустится, выше цены самого дорогого не поднимется — ни при каком распределении. Если цель вне этого коридора, конструктор не подгоняет молча, а объясняет, что менять: цену кейса или состав.
Обратная задача — «есть лут-таблица, какой должна быть цена кейса» —
решается тривиально: price = ожидаемая стоимость дропа / целевой RTP.
Так и собирается кейс на практике: сначала состав, потом цена.
Цены предметов плавают. Кейс, собранный при RTP 92%, через месяц может уехать в 105% после скачка цены ножа. Поэтому:
- почасовой джоб тянет цены из маркета Steam для предметов активных кейсов,
- после каждой переоценки пересчитывается RTP всех активных кейсов,
- выход за коридор пишется в лог как предупреждение, выход за 98% — как ошибка.
Строчка в логе — это сигнал, а не починка: дрейф остаётся незамеченным, пока
лог никто не прочитает. Чинит его pnpm --filter @caseforge/api rebalance-cases
— он пересчитывает RTP каждого кейса по сегодняшним ценам и переразлагает те,
что вышли из коридора. Двигает шансы и никогда не цену: цена — решение
оператора, которое игрок мог видеть вчера, а шансы — производная величина,
которая и так уже была неверной. Кейс, который на текущей цене не решается,
попадает в отчёт, а не переоценивается: это решение о том, чем кейс является.
Сохраняет он через тот же путь, что и админка, поэтому диапазоны
перепроверяются, пишется запись аудита и сбрасывается кэш каталога.
Бесплатных кейсов всё это не касается. RTP — это отдача на единицу ставки, а ставки нет: отношение обращается в деление на ноль. Бесплатный кейс вместо цены ограничен порогом пополнения и лимитом открытий за скользящие 24 часа — и то и другое проверяется при открытии, а не при сохранении, — и не хранит кэшированного RTP вовсе, вместо нуля, который читался бы как кейс, который ничего не выплачивает.
Отдельно отслеживаются неподтверждённые цены: предмет, чью цену Steam ни разу не отдал, несёт выдуманное число, но участвует в расчёте RTP наравне с остальными. Такие предметы помечаются в CRM.
Тот же джоб добирает недостающие картинки: у предметов, заведённых не через импорт (сид, ручная вставка), их нет, а без картинки витрина выглядит сломанной. Кейсу без своей картинки подставляется изображение самого дорогого предмета — именно им кейс и продаётся. Явно заданная картинка при этом не трогается.
Игрок ставит свой скин против более дорогого. Шанс выводится из цен, а не
назначается: chance = stakeValue / targetValue × UPGRADE_RTP, где RTP тот же,
что у кейсов. При таком определении матожидание апгрейда равно ставке,
умноженной на RTP, при любом множителе — экономика апгрейда автоматически
совпадает с экономикой кейсов, и её не нужно балансировать отдельно.
Исход берётся из того же ролла, что и открытие кейса: roll < chance × TICKET_SPACE
по той же паре сидов и общему счётчику nonce. Это не экономия кода, а свойство
продукта: у пользователя одна страница честности на все механики, и апгрейд
проверяется тем же способом, что и дроп.
Ставка списывается независимо от исхода — это ставка, а не залог. Списание идёт
условным updateMany по статусу AVAILABLE: если предмет за это время ушёл
в продажу или в вывод, апгрейд не состоится, а не выдаст предмет дважды.
Границы, за которыми апгрейд запрещён: множитель ниже 1.05 (размен с комиссией под видом улучшения), шанс выше 85% (гарантированный размен) и разрыв в цене, при котором шанс падает ниже 0.5%.
Контракт принимает от 3 до 10 предметов и возвращает ровно один. В отличие от
апгрейда выигрышной и проигрышной ветки нет: контракт выплачивает всегда, вопрос
только в том, что именно. Награда берётся из пула предметов каталога с ценой от
0.1x до 5x вложенной суммы, а исход определяет тот же ролл, что и открытие
кейса, — по той же сид-паре и общему счётчику nonce.
Веса пула не назначаются руками. Ручная настройка сделала бы контракт второй
экономикой рядом с кейсами, со своей математикой, и любая переоценка каталога
незаметно сдвигала бы маржу. Вместо этого веса подбираются так, чтобы
матожидание награды равнялось stakeValue × CONTRACT_RTP — тот же RTP, по
которому работают кейсы и апгрейд.
Веса задаются экспоненциальным наклоном w_i ∝ exp(-alpha × u_i), где u_i —
цена исхода в логарифмической шкале, нормированной на [0, 1] по всему пулу.
Alpha ищется бисекцией: матожидание монотонно убывает по alpha — производная
равна минус дисперсии наклонённого распределения, — поэтому одного бракета
достаточно. При alpha = 0 пул был бы равномерным; положительная alpha смещает
вес к дешёвым исходам, и именно туда решение и приходит, так как цель лежит ниже
середины диапазона.
Следствие, которое стоит проговорить: маржа здесь — константа системы, а не свойство каталога. Какие бы предметы ни оказались в ценовом коридоре, солвер выведет матожидание на одно и то же число.
Дробные веса превращаются в целые тикеты методом наибольшего остатка, поэтому
диапазоны покрывают [0, TICKET_SPACE - 1] ровно — тот же инвариант, который
validateTicketRanges проверяет у кейса. Исход, которому досталось бы ноль
тикетов, выиграть нельзя, а рисовать его на ленте было бы обманом, поэтому он
выбрасывается и пул пересчитывается по оставшимся.
Решённая таблица складывается в строку контракта как JSON — id предмета, цена и тикет-диапазон каждого исхода. Пул строится по живому каталогу, и восстановить его позже из цен, которые с тех пор сдвинулись, нельзя: без снимка ролл остался бы воспроизводимым, но перестал бы что-либо означать.
Вложенные предметы списываются при любом исходе — тем же условным updateMany
по status = AVAILABLE, что и в апгрейде: предмет, успевший уйти в продажу или
вывод, не проходит захват, и контракт не заключается.
Контракт отклоняется, а не играется на плохих шансах, если каталог внутри коридора слишком редкий или в нём нет ничего выше выплаты, к которой таблица должна усредниться.
Одно вращение раз в 24 часа: деньги, скидка, бесплатное открытие или скин.
Колесо — тикет-таблица как у кейса, роллится по той же паре сидов и общему
счётчику nonce. Это единственная таблица в проекте, которая действительно
задана руками, а не решена, и причина в том, что солверу нужно одно число для
оптимизации: с призами настолько разными, как начисление в рублях и нож, такого
числа нет. Ограничена вместо этого стоимость — wheelGrantCost() суммирует
сектора, которые что-то отдают, и тест падает, если перенастройка выведет её за
потолок.
Сектора рисуются ровно по тем тикет-диапазонам, по которым роллятся, поэтому приз с тремя процентами занимает три процента обода. Колесо, нарисованное равными дольками поверх неравных шансов, — самая старая ложь в жанре.
Скользящие сутки, а не календарные: календарный сброс дарит тем, кто живёт в удачном часовом поясе, два вращения с разницей в несколько часов и превращает полночь в пик нагрузки, которого никто не просил.
Захватывается условным UPDATE по users.lastBonusAt, а не читается и потом
пишется. Между чтением и записью два одновременных запроса проходят проверку оба,
и игрок крутит дважды за сутки; ноль затронутых строк означает, что вращение уже
забрал кто-то другой. Колонка денормализована именно ради этого — выводить
последний спин из таблицы бонусов было бы аккуратнее и нельзя было бы захватить
одним запросом.
Деньги и скины начисляются внутри транзакции спина, и строка рождается уже
погашенной. Начисление на баланс идёт через Transaction, как любое другое
движение, поэтому ночная сверка по-прежнему сходится.
Скидка и бесплатное открытие — ваучеры: строка остаётся открытой, пока её не
потратит открытие кейса. Трата происходит внутри транзакции самого открытия, до
списания, — если разрешать ваучер раньше, параллельное открытие успеет забрать
его в промежутке, и это открытие получит скидку впустую. Захват — тот же условный
updateMany, что и в инвентаре, поэтому ваучер нельзя потратить дважды.
Когда открытых ваучеров несколько, открытие берёт тот, что экономит больше именно на этой корзине. «Первым пришёл, первым ушёл» проще и хуже: он сжигает скидку в 50% на самом дешёвом кейсе каталога, пока бесплатное открытие ждёт позади. Выбранный ваучер возвращается в ответе, потому что награда, исчезнувшая вместе с тихо изменившейся ценой, неотличима от бага.
Сектор FREE_ITEM должен превратиться в конкретный скин, и этот выбор делается
из того же ролла, который выбрал сектор, а не из второго, скрытого розыгрыша —
список кандидатов упорядочен по id, что стабильно в отличие от сортировки по
плывущей цене. Если в каталоге нет ничего в пределах потолка, сектор выплачивается
деньгами: не выдать вообще ничего — единственный исход, которого у колеса быть не
должно.
Несколько игроков покупают один и тот же набор кейсов и открывают его одновременно; все выпавшие предметы забирает один из них. В обычном режиме — тот, у кого сумма дропов больше, в безумном — тот, у кого меньше: те же кейсы, обратная цель.
Каждый платит полную стоимость набора. Никому не делается скидки за то, что он играет против другого, а значит маржа сайта ровно такая же, как если бы те же кейсы открывали поодиночке: ожидаемая отдача — это взвешенный RTP кейсов в батле, а банк перемещается только между игроками. Батл — это перераспределение, а не вторая экономика.
Ниоткуда нового. Каждый дроп в батле — обычная строка CaseOpening, ролл
считается из пары сидов открывающего игрока и его собственного блока nonce,
точно так же, как в одиночном открытии. В строке добавляются battleId и
battleRound — и больше ничего, чего не было бы у одиночного открытия.
Общий сид на весь батл — очевидная альтернатива и неверный размен. Сайт уже публикует обязательство по каждому игроку и раскрывает его при смене сида; вторая схема рядом была бы вторым местом, где можно ошибиться, и не дала бы игроку ничего сверх того, что у него уже есть: каждый проверяет свои дропы в своём профиле той же арифметикой, что и любой другой дроп, а суммы — это сложение чисел, которые уже записаны.
Побочный эффект: батлы не требуют особого случая больше нигде. Отчёт по RTP, маржа по кейсу, живая лента дропов и история открытий игрока подхватывают их, не зная, что батлы вообще существуют.
Место занимается условным UPDATE денормализованного счётчика:
UPDATE battles SET "filledSlots" = "filledSlots" + 1
WHERE id = $1 AND status = 'WAITING' AND "filledSlots" < slots
RETURNING "filledSlots"RETURNING возвращает собственный номер места игрока, а ноль строк означает, что
последнее место занял кто-то другой. Посчитать места и потом вставить строку
нельзя: два одновременных захода увидят одно и то же свободное место. Списание
за место — тот же условный UPDATE, что и при открытии кейса, и в той же
транзакции: батл, который заполнился, пока игрок заходил, откатывает списание, а
не возвращает деньги потом.
Последнее занятое место играет батл прямо в той же транзакции. Четыре места по тридцать раундов — это сто двадцать открытий, сто двадцать строк инвентаря и резервирование сида на каждого игрока: самая крупная запись на сайте, из-за которой её таймаут поднят. Разбить её нельзя: батл, который мог бы рассчитать одних игроков и не рассчитать других, не имеет смысла.
Идентификаторы открытий и предметов инвентаря генерируются заранее, чтобы каждая
сторона ушла одним createMany, а не круговым запросом на строку, пока
транзакция держит блокировки. Сиды резервируются в фиксированном порядке по
игрокам: два батла, заполнившихся в один момент, могут иметь общего игрока, и
одинаковый порядок — это то, что не даёт их транзакциям взять блокировки
навстречу друг другу.
Каждый предмет создаётся в инвентаре победителя, но продолжает ссылаться на
открытие, которое его выкатило. userId открытия остаётся тем, кто крутил, и
этот зазор между крутившим и владельцем — ровно то, чем батл и является;
записать только одно из двух значило бы потерять либо происхождение, либо
владение.
Два места, открывающие одни и те же кейсы, могут дать одинаковую сумму, а на дешёвых кейсах это случается достаточно часто, чтобы понадобилось правило. Правило — внезапная смерть по лучшему одиночному дропу (в безумном режиме — по худшему), а если совпало и это, побеждает то место, которое зашло раньше. Оба шага — функции от дропов, которые уже записаны, поэтому исход воспроизводим по сохранённым роллам; новый случайный бросок таким не был бы.
Батл, ждущий второго игрока, держит деньги хоста, а хост вполне мог закрыть
вкладку. Уборщик отменяет всё, что прождало дольше настроенного окна, и
возвращает каждое место полностью, через журнал. Переход занимается условным
UPDATE из WAITING — именно это не даёт возврату обогнать расчёт: батл,
заполнившийся секунду назад, уже играется, и уборщик не должен уметь вернуть его
ставки из-под него. Выключение батлов в настройках заставляет тот же уборщик
разобрать лобби целиком — ровно этого и хочет оператор, щёлкающий этим
переключателем во время инцидента.
Steam не поддерживает OAuth — только OpenID 2.0. Флоу: редирект на
steamcommunity.com/openid/login, возврат с параметрами, обязательная серверная
верификация через check_authentication (без неё параметры подделываются
тривиально). Из ответа достаём steamId64 и выдаём собственную пару JWT.
Важно: OpenID сообщает только SteamID64 — ни ника, ни аватара в ответе нет, их надо дочитывать отдельно. Источников два:
GetPlayerSummaries (Web API) |
/profiles/<id>?xml=1 |
|
|---|---|---|
| Ключ | нужен STEAM_API_KEY |
не нужен |
| Отдаёт | ник, аватар, дата регистрации, статус | то же самое |
| Лимиты | 100k запросов в день на ключ | как у обычной страницы |
Основной путь — Web API, запасной — XML. XML здесь не костыль для разработки:
если ключ протух или Web API лежит, пользователь всё равно увидит свой ник
и аватар вместо user_123456. Разбор XML требует аккуратности — внутри профиля
есть вложенные списки друзей со своими <steamID> и <avatarFull>, и наивный
поиск первого совпадения подставит чужую аватарку.
Ключевой момент: Steam не отдаёт email ни одним из способов. Он никогда не будет известен, если пользователь не введёт его вручную. Все сценарии восстановления доступа строятся вокруг Steam-аккаунта.
Пара JWT — короткий access и длинный refresh: access несёт id и роль и проверяется на каждом запросе без похода в базу, поэтому он обязан быстро истекать, иначе бан или смена роли не вступят в силу. Refresh лежит в httpOnly-куке месяц и является единственным, чем можно выпустить новый access.
Это разделение работает, только если refresh кто-то тратит. Браузер делает это прозрачно: 401 от любого вызова запускает один refresh и один повтор исходного запроса, и сессию заканчивает именно неудавшийся refresh, а не штатное истечение через 15 минут. Параллельные 401 делят один refresh в полёте: загрузка страницы поднимает несколько запросов сразу, и если каждый начнёт свой refresh, кука провернётся несколько раз, а проигравшие гонку повторят запрос с токеном, который уже заменён. Со стороны игрока это неотличимо от случайного разлогина.
Вебсокет читает токен только на хендшейке, поэтому берёт его через колбэк и переподключается при смене токена; зафиксированный при загрузке страницы токен ломал бы любое переподключение после первого истечения.
Предмет на аккаунте — это строка со статусом, и строка никогда не удаляется:
AVAILABLE → SOLD | WITHDRAWN | LOCKED | UPGRADED | CONTRACTED
Действия доступны только из AVAILABLE. LOCKED принадлежит выводу в процессе,
остальные три терминальны и составляют историю, которую интерфейс показывает
отдельной вкладкой. Удалять строку было бы проще и неверно дважды: продавший нож
игрок всё ещё хочет видеть, сколько за него получил, а поддержка не может
разобрать жалобу на предмет, исчезнувший из интерфейса в момент списания.
Каждый переход — условный updateMany по status = AVAILABLE, который
одновременно и проверка, и захват. Предмет, успевший уйти в продажу, вывод или
контракт, просто выпадает из затронутых строк, и расхождение между количеством
затронутых строк и числом запрошенных id — это то, на чём вызов падает. Ничего
здесь не опирается на предварительное чтение строки.
Продажа всего собирает свой набор внутри транзакции по тому же фильтру, который показывал интерфейс, а не по списку id, загруженному браузером. Список id ограничил бы операцию той страницей, что была открыта, и проигрывал бы гонку любым изменениям в промежутке; фильтр — нет. Сумма возвращается с сервера по той же причине: диалог подтверждения способен назвать лишь её оценку.
Вывод из инвентаря запускает покупку. Предмет уходит в LOCKED, заявка
встаёт в очередь; WITHDRAWN он становится только когда маркет подтвердит, что
продавец отдал скин, и возвращается в AVAILABLE, если покупка не удалась.
Поэтому интерфейс не может сказать «выведено» в момент нажатия — он говорит, что
заявка создана, и это единственное, что на тот момент правда. Механика — раздел 7.6.
Фильтры. Две оси: вкладка (доступные, в выводе, история, все) и ценовой диапазон. Границы диапазонов заданы в базовой валюте, а не в той, что игрок сейчас отображает. Диапазон, плавающий вместе с курсом, пересортировывал бы инвентарь при каждом обновлении курсов, и предмет мог бы в понедельник лежать в одном диапазоне, а во вторник в другом, вообще не изменившись в цене. Подписи рендерятся обычным денежным форматтером, поэтому границы всё равно читаются в выбранной валюте — круглыми в базовой и пересчитанными в остальных.
Источник цен и изображений — Community Market, и у него два разных эндпоинта с разными свойствами:
market/search/render |
market/priceoverview |
|
|---|---|---|
| Отдаёт | имя, картинку, редкость, число лотов | только цену |
| Валюта | всегда доллары, параметр currency игнорируется |
уважает currency |
| Запрос | ключевые слова | точный market_hash_name |
Отсюда разделение: поиск используется для подбора предметов и метаданных,
цена в валюте проекта всегда берётся через priceoverview.
Три вещи, на которых легко обжечься:
- Поиск не принимает
market_hash_name. Символ|и скобки износа дают ноль результатов даже для предметов, которые точно продаются. Имя приходится нормализовать в ключевые слова (toSearchQuery). - Ножи и перчатки идут с префиксом
★.Karambit | Doppler (Factory New)для Steam не существует, существует★ Karambit | Doppler (Factory New). - Берётся медиана, а не минимум.
lowest_priceдёргается одиночными демпинговыми лотами; кейс, посчитанный по ней, недооценивает предметы.
Частота запросов режется примерно на 20 в минуту, дальше 429 и бан на несколько минут. Поэтому обращения сериализуются с интервалом 3.5 с, результаты поиска кэшируются на час, цены — на полчаса, а 429 переводит сервис в паузу на 5 минут. Кэшируется и отрицательный результат: предмет без лотов иначе дёргал бы Steam на каждой синхронизации.
Хранение и расчёты остаются в базовой валюте. Переключатель валюты конвертирует только на отрисовке, по курсу из публичной ежедневной выгрузки ЦБ РФ — без ключа, с обновлением раз в шесть часов, кэшем в Redis и откатом на прежнее значение, если выгрузка недоступна. Ничего не переоценивается, и ни одна запись журнала не содержит конвертированную сумму: принять курс отображения за расчётный — это ровно тот путь, на котором сайт начинает продавать предметы ниже себестоимости после движения валюты.
Пользователь вставляет свою trade URL, из неё парсятся partner и token.
Валидируется формой, а фактически — первым же оффером.
Их два, выбор — настройкой withdrawals.provider, и он проставляется на
заявке в момент её создания. Если бы канал читался из настроек при обработке,
переключение оставило бы заявки наполовину исполненными одним каналом и
опрашиваемыми другим.
MARKET (по умолчанию). Сайт не держит инвентаря. Каждый выводимый предмет
покупается на market.csgo.com, и покупающий аккаунт указывает получателем
partner/token игрока, так что продавец отдаёт скин напрямую. Сайт несёт цену,
а не склад: деньги не заморожены в скинах, нет потолка по слотам и нет предметов,
которые невозможно выдать, потому что их нет ни у одного бота.
Аккаунтов несколько, а не один, из-за лимита: маркет удаляет API-ключ, превысивший пять запросов в секунду. Это не троттлинг, от которого можно отступить, — ключ перестаёт существовать, а вместе с ним и возможность спросить о любой сделанной им покупке. Поэтому клиент держится заметно ниже лимита, что ограничивает ключ примерно четырьмя запросами в секунду, а один вывод стоит поиска, покупки и опроса каждые несколько секунд, пока продавец не отдаст предмет. Дополнительные ключи поднимают потолок, потому что лимит считается по ключу, — и у каждого аккаунта свой клиент со своей очередью: общая очередь троттлила бы их совместно и вернула бы ровно тот потолок, ради снятия которого покупался второй ключ.
Аккаунты не взаимозаменяемы задним числом, и именно это определяет форму кода.
get-buy-info-by-custom-id отвечает за тот ключ, которым покупали, а про все
чужие покупки говорит, что не знает их; и деньги ушли с баланса того аккаунта.
Поэтому покупка привязывается к аккаунту до вызова buy-for, опрос
группируется по аккаунтам, а ретрай возвращается к тому ключу, который делал
первую попытку. Спросить другим ключом — получить честное «не знаю такой» про
оплаченную покупку, и тогда ретрай купит скин второй раз.
Ключи шифруются под BOT_SECRETS_KEY и расшифровываются только в воркере, как и
секреты Steam-ботов. У бэк-офиса ключей нет вовсе: воркер пишет снимки баланса и
здоровья, панель их читает.
BOTS (раздел 7.5.1). Ферма Steam-аккаунтов, держащих инвентарь. Оставлен потому, что оператора, уже вложившегося в ферму, незачем заставлять мигрировать, и потому что собственный инвентарь — единственный способ выдать то, чего на маркете не продают.
Один бот = один Steam-аккаунт с включённым мобильным аутентификатором и
shared_secret/identity_secret (нужны для автоподтверждения офферов).
Ограничения, вокруг которых строится вся логика вывода:
- инвентарь Steam — 1000 слотов, поэтому ботов нужно несколько и нужен роутинг заявки к боту, у которого предмет есть;
- трейд-холд 7 дней, если у получателя нет мобильного аутентификатора или он включён недавно, — проверяется ДО отправки оффера, иначе предмет зависает в эскроу;
- бот, только что сменивший пароль или устройство, уходит в холд сам;
- Valve режет частоту офферов — очередь с троттлингом обязательна.
Секреты ботов (shared_secret, identity_secret, пароли) не лежат в БД в открытом
виде: в проде — внешнее хранилище секретов, в dev — зашифрованы ключом из окружения.
заявка → валидация (трейд-ссылка, лимиты, антифрод)
→ предметы → LOCKED, строка Withdrawal, задача BullMQ
→ MARKET: по одной MarketPurchase на предмет
search-item-by-hash-name → buy-for(partner, token, потолок цены)
опрос get-list-buy-info-by-custom-id → stage 2 или 5
→ BOTS: бот, у которого есть все предметы → оффер → автоподтверждение → опрос
→ доставлено → WITHDRAWN, не удалось → обратно в AVAILABLEИдемпотентность. На канале ботов джоб идемпотентен по withdrawalId: ретрай
не создаёт второй оффер. На канале маркета этого мало, потому что единица денег —
покупка, а не заявка. Каждая строка MarketPurchase уникальна по своему предмету
инвентаря и уходит в маркет как custom_id, поэтому ретрай после потерянного
ответа сначала спрашивает у маркета, что случилось с этим id. Без этого разрыв
связи между списанием и ответом покупает скин дважды и платит за оба.
Заявка больше не атомарна. Три предмета — три продавца, каждый может отвалиться
сам по себе, поэтому статус заявки вычисляется из её строк при каждом движении
покупки — отсюда PARTIAL. Строки лежат в отдельной таблице, а не выводятся из
InventoryItem.withdrawalId: эта колонка говорит, какая заявка держит предмет
сейчас, и обнуляется при возврате. История по ней показала бы пустую заявку у
той, что вернула всё, и COMPLETED у той, что вернула один предмет из трёх. Правило, которое это вычисление держит, одностороннее:
предмет возвращается в инвентарь игрока, только когда точно известно, что покупки
не было. Незавершённая покупка держит предмет заблокированным, а оплаченная
никогда не списывается по таймеру. Деньги уже ушли, продавец ещё может отдать скин;
сайт говорит об этом оператору, а не гадает — и дешёвая догадка «вернуть предмет»
как раз и есть та, что выдаёт скин дважды.
Цена. Потолок на предмет — max(текущая цена в каталоге, начисленная цена),
поднятый на withdrawals.market.maxOverpayBps. Текущая, а не начисленная, потому
что игрок владеет предметом, а не суммой, и подорожавший вдвое скин обязан
оставаться выводимым; начисленная как нижняя граница — чтобы предмет, которому
оператор занизил цену, не стал невыводимым. Выше потолка покупка отклоняется и
предмет возвращается: это отказ, а не убыток.
Единицы. Маркет считает рубли в копейках, а доллары и евро — в тысячных, и отдаёт баланс и уплаченную сумму дробными числами в целых единицах. Все три пересчёта живут в одном модуле с тестами, а аккаунт в валюте, отличной от расчётной, отклоняется, а не пересчитывается по отображаемому курсу (см. 7.3).
До десяти кейсов за раз. Вся партия — одна транзакция: либо списываются деньги
за все кейсы и выдаются все предметы, либо не происходит ничего, включая
счётчик nonce.
Ключевая деталь — резервирование nonce. Инкрементировать его в цикле по одному
нельзя: параллельный апгрейд или открытие из другой вкладки вклинится в середину
и заберёт номер из нашей партии, а nonce обязан быть непрерывным, иначе
история перестанет пересчитываться подряд. Поэтому блок номеров захватывается
одним UPDATE ... SET nonce = nonce + N RETURNING nonce, и партия занимает
последние N номеров перед возвращённым.
В журнал транзакций пишется одна запись на партию, а не десять: сверка смотрит на сумму, а десять строк за один клик только зашумили бы историю.
Рейт-лимит считается в кейсах, а не в запросах — одна кнопка «×10» это десять открытий, и защита от скриптов должна видеть их так же.
socket.io с адаптером на Redis, чтобы несколько инстансов API видели общие комнаты.
Каналы:
drops:live— глобальная лента дропов (только редкие, иначе на пике это флуд)user:{id}— личное: баланс, статус вывода, инвентарьbattles:events— изменения батлов, рассылаются всем
События батлов намеренно не батчатся и не разложены по комнатам на батл. Они редкие — создание, занятое место, расчёт, — а лобби одно и общее: место должно исчезнуть сразу во всех браузерах, и триста миллисекунд буфера лишь показали бы занятое место свободным. Страница батла фильтрует рассылку по id и перечитывает батл, когда услышит про свой.
На пике лента дропов — самый горячий канал. Он агрегируется: события копятся в буфер и рассылаются пачкой раз в ~300 мс, а не по одному. Это разница между 50к и 2к сообщений в секунду при том же пользовательском ощущении.
Профиль трафика у таких сайтов резко неравномерный: обычный день, а затем
20-кратный пик на розыгрыше у стримера. Считать надо по пику — и план должен
запускаться: всё, что ниже, лежит в репозитории, а не в будущем тикете, за
pnpm infra:up:scale и измеряется через pnpm test:load.
PgBouncer в transaction-режиме. Node держит пул соединений на процесс,
поэтому max_connections в Postgres расходуется как число инстансов API,
умноженное на размер их пула, и заканчивается задолго до того, как база реально
занята. Transaction-пулинг привязывает серверное соединение к транзакции, а не к
клиенту, и превращает тысячу простаивающих соединений приложения в пару десятков
настоящих.
Чтобы Prisma за ним жила, должны сойтись две вещи. Подготовленные выражения
живут в рамках сессии, а transaction-пулер выдаёт каждый раз другую: pgbouncer
отслеживает их с версии 1.21, поэтому на пулере выставлен
max_prepared_statements, а в URL — ?pgbouncer=true как второй пояс к тем же
подтяжкам. И миграции через пулер ходить не должны вовсе — они открывают долгие
сессии, создают типы и берут блокировки, которые transaction-пулинг не переносит
между запросами, — поэтому в схеме объявлен directUrl, куда и смотрит
DIRECT_DATABASE_URL. По умолчанию он равен DATABASE_URL, так что установка на
одной машине не настраивает ничего.
connection_limit в URL за пулером стоит указывать явно: небольшой пул на
инстанс — это и есть весь смысл, а дефолт Prisma «ядер × 2 + 1» на процесс — нет.
Streaming-standby и второй клиент Prisma, который смотрит на него. Если
REPLICA_DATABASE_URL не задан, читающий клиент — это и есть основной, поэтому
ни одна ветка кода не зависит от конфигурации, а одной машине она не нужна.
Правило использования нельзя выразить типом, поэтому это правило: реплика отдаёт то, что читает оператор или зритель, и никогда то, что спрашивающий игрок только что записал. Лаг репликации маленький, но настоящий, и чтение через него покажет игроку инвентарь без предмета, который он только что выиграл. Через реплику читают: все отчёты и списки CRM, публичный каталог, лобби батлов, лента дропов. На основной базе остаются: всё, что внутри транзакции, инвентарь, баланс и отдельный батл — игроку, который только что занял место, отдают именно его.
pnpm infra:replica:init готовит работающую основную базу к standby: в
стандартном контейнере postgres нет ни роли для репликации, ни строки
pg_hba, которая её пустит, и добавить нужно обе, не пересоздавая базу, в
которой уже лежит наполненный каталог. pg_hba требует явной записи
replication — это единственное место, где all в колонке базы не означает
«все».
Каталог — самый запрашиваемый запрос на сайте, и ответ у него одинаковый для всех, поэтому он кэшируется в Redis. Инвалидация — через счётчик версии, а не удаление ключей: счётчик входит в каждый ключ пространства, инкремент обесценивает всё пространство разом, а поскольку счётчик лежит в Redis, инкремент видят все инстансы API. Его дёргают сохранение кейса в админке, пересчёт RTP и часовая синхронизация цен; TTL — лишь страховка на случай инкремента, который не дошёл.
Путь открытия кейса этот кэш не читает. Он берёт кейс внутри своей транзакции, потому что устаревшая цена там — это устаревшая сумма денег.
У ленты дропов свой бэклог — список в Redis, который пишется по мере дропов. Каждый подключившийся сокет спрашивает последние дропы, и отвечать на это из Postgres — это джойн по четырём таблицам на соединение; в шторме переподключений после деплоя это самая бессмысленная нагрузка на базу на всём сайте. База читается только для прогрева списка.
Настройки кэшируются в процессе на пятнадцать секунд, а запись сбрасывает кэш локально: оператор видит своё изменение сразу, остальные инстансы — в пределах TTL.
Значение имеют индексы на чтениях по тикетам и ценам, а не на журнале: пул
наград контракта и сектор бесплатного скина в колесе оба спрашивают активные
предметы внутри ценового коридора, который может отличаться в пятьдесят раз, и
без (isActive, marketPrice) планировщик обходит весь каталог на каждом
предпросмотре контракта. У case_openings свои составные индексы под агрегаты
отчётов и один под представление батла.
GET /api/health — то, что опрашивает балансировщик, и то, что оператор
открывает первым. Он сообщает доступность и задержку Postgres и Redis, а также
лаг проигрывания реплики в секундах: за балансировщиком интересен не мёртвый
процесс — он и сам перестаёт отвечать, — а инстанс, который бодро отвечает, пока
его реплика отстала на час. «Degraded» едет в теле ответа, а не в статус-коде:
отставшая реплика — повод посмотреть, а не повод вывести инстанс из ротации.
pnpm test:load — генератор без зависимостей, лежащий в репозитории, со
сценариями самого сайта, а не абстрактным «долбить / в 100 потоков»:
| Сценарий | Что делает |
|---|---|
browse |
только публичные чтения — каталог, страница кейса, лобби, конфиг, health |
open |
путь записи: по одному открытию кейса на запрос, от одноразовых игроков |
battles |
двое одноразовых игроков создают и заполняют батл на два раунда |
mixed |
четыре части browse на одну часть open |
Он печатает p50/p90/p99 и отдельно каждый не-2xx статус, и это важнее, чем звучит: прогон, в котором треть запросов — 429, измеряет рейт-лимит, а прочитать его как задержку — это ровно тот способ, которым делают вывод, что сайт быстрый, когда он отказывается работать. Одноразовые игроки и всё, что они успели сделать, удаляются после прогона.
Чем эти числа не являются: ёмкостью. Генератор делит машину с API и базой и конкурирует с тем, что измеряет, поэтому числа сравнительные — до и после изменения, — а настоящая цифра требует нагрузки, поданной с другой машины.
Что откладывается до реальных цифр: шардирование, вынос игрового ядра в Go,
партиционирование case_openings (понадобится примерно после 50–100 млн строк).
Отдельный раздел приложения под ролевым доступом (ADMIN, SUPPORT, ANALYST).
- Дашборд — GGR, депозиты, выводы, онлайн, конверсия, маржа по кейсам
- Кейсы — конструктор: поиск предметов по маркету Steam с картинками и ценами, картинка кейса, автоподбор шансов под целевой RTP, подбор цены кейса, live-вердикт по марже, валидация покрытия тикет-диапазонов
- Предметы — справочник, цены, ручной оверрайд цены
- Пользователи — баланс, история, ручная корректировка баланса (всегда через
Transaction+AuditLog), блокировка - Выводы — очередь заявок, ручное вмешательство, повторная отправка оффера
- Маркет — баланс и проверки покупающего аккаунта, количество покупок по статусам, во что обошлась доставка против авторизованного потолка, и покупки, оплаченные, но не доставленные. Только чтение: деньги тратит один воркер
- Боты — статус фермы, инвентари, ошибки логина, холды
- Отчёты — выгрузки по периодам, когорты, топ игроков
- Настройки — фичефлаги, лимиты, тексты, промокоды
Два правила без исключений: любое админское действие пишется в AuditLog с
before/after, и ни одно из них не меняет баланс напрямую в обход Transaction.
Панель только на английском. Переводить внутренний инструмент значит удвоить поддержку ради аудитории в несколько человек.
Всё, что оператор может изменить без деплоя, объявлено один раз в
packages/shared/src/settings.ts — со своей группой, типом, границами и
значением по умолчанию. Админка рисует форму из этого реестра, а не из полей,
написанных под каждую настройку, поэтому добавить ручку — это строка в одном
файле и больше ничего. Альтернатива — свой контрол, свой валидатор и свой
читатель на каждую настройку — это три места, которые надо держать в
согласии, и причина, по которой у большинства бэк-офисов есть настройки,
существующие в базе и нигде в интерфейсе.
Значения кэшируются в API на пятнадцать секунд. Настройки читаются почти на каждом запросе — рейт-лимит, комиссия продажи, колесо, — и запрос на каждое чтение данных, меняющихся пару раз в месяц, чистая трата. Запись сразу обновляет пишущий инстанс, поэтому оператор видит эффект, пока ещё смотрит в панель; остальные инстансы подхватывают в пределах TTL.
Сохранённое значение, которое не распарсилось, откатывается к умолчанию и логируется, а не бросает исключение. Панель валидирует на входе — именно там плохое значение и надо отвергать громко; на этапе чтения отказ обслуживать сайт не лучше, чем обслуживать его со значением по умолчанию.
TICKET_SPACE, алгоритм сидов и форма ролла. Это не параметры тюнинга, а
условия обещания: каждое прошлое открытие опубликовано против них, и именно их
игрок пересчитывает, когда проверяет дроп. Оператор, способный их править, мог бы
сделать так, что вчерашние дропы перестанут сходиться, — а это не настройка, а
способ сломать единственное утверждение, которое сайт делает.
Тикет-диапазоны кейсов — того же рода, и правятся там, где им место: в конструкторе кейсов, через валидацию, отвергающую кейс, диапазоны которого не покрывают пространство.
Код добавляет к пополнению, а не удешевляет его. Когда за кнопкой появится настоящий платёжный провайдер, списанная сумма обязана совпадать с той, о которой провайдеру сообщили, и код, меняющий её, рассинхронизировал бы их; начисление сверху оставляет акцию целиком на нашей стороне сделки.
Начисление — вторая строка журнала, а не увеличенная строка депозита. То, что игрок оплатил, и то, что дала акция, — разные деньги, и отчёт, который их не разделяет, не может сказать, сколько акция стоила.
Два лимита, и держатся они по-разному, потому что по-разному гоняются. Лимит на
игрока — подсчёт строк погашений внутри транзакции пополнения. Общий лимит —
условный UPDATE по денормализованному счётчику: подсчёт строк с последующей
вставкой позволяет двум одновременным пополнениям увидеть последнее
использование свободным и забрать его обоим. Источник правды остаётся в строках;
счётчик существует, чтобы лимит можно было захватить одним запросом.
Коды деактивируются, а не удаляются. На них ссылаются погашения, и игрок, спрашивающий, почему сдвинулся его баланс, заслуживает строки, которая это всё ещё объясняет.
Игрок раздаёт ссылку, тот, кто по ней пришёл, привязывается к нему при регистрации, и доля от того, что он потом тратит, начисляется пригласившему.
Приглашение приходит как ?ref=КОД на любую страницу, и зашедший ещё не
авторизован — сейчас его отправят в Steam и обратно, а это выбрасывает всё, что
держала страница. Поэтому код паркуется в браузере и применяется отдельным
вызовом сразу после входа.
Значит, ручка остаётся открытой и потом, и защищает её не время, а история: код отклоняется, как только на аккаунте появилась хоть какая-то активность. Игрок, который уже открывал кейсы или двигал деньги, — не чей-то новый реферал, и без этого правила состоявшийся аккаунт можно было бы приписать тому, кто попросил последним. Сверх того: своим кодом воспользоваться нельзя, а регистрация с того же адреса, что и у пригласившего, помечается на привязке — не отклоняется, потому что общий адрес бывает у квартиры и у мобильного оператора, но остаётся на виду у оператора.
Один пригласивший на игрока, и это держит уникальный refereeId, а не проверка:
две одновременные заявки обе пройдут проверку, и решает только ограничение.
Комиссия копится строками ReferralEarning, которых нет ни на одном балансе и
нет в журнале, пока их не заберут. Комиссия, начисляемая сразу, была бы строкой
Transaction на каждое открытие каждого приглашённого, а журнал — это то, что
обходит ночная сверка; накопление отдельными строками и выплата одной записью
делают журнал пропорциональным числу выплат, а не числу дропов.
Ставка хранится в самой строке. Это настройка, и оператор, который её понизил, не должен переписывать уже заработанное.
Возвращённое место в батле забирает своё начисление с собой — иначе создание и отмена батлов были бы бесплатным способом заплатить пригласившему. Удаляются только неоплаченные строки: уже выплаченное начисление — это деньги на чьём-то балансе, и отобрать их значило бы списать то, на что владелец не соглашался.
Накопленные строки забираются одним UPDATE ... RETURNING, и минимум проверяется
по тому, что вернулось, а не по SUM, прочитанной заранее. Другой порядок
выплачивает не ту сумму, которую проверил, всякий раз, когда между чтением и
записью падает новое начисление, — а для пригласившего с активными рефералами это
как раз норма. Отказ бросает исключение, которое откатывает захват и оставляет
накопленное как было.
Сама выплата — одна запись REFERRAL в журнале: это единственная точка, в
которой накопленная комиссия становится деньгами.
Как называется сайт, какого он цвета, какие разделы существуют и что написано на баннере — это настройки, которые отдаются браузеру в рантайме. Альтернатива — править классы Tailwind и передеплоивать — делает внешний вид задачей разработчика, хотя это не она.
Работает это потому, что стили и так построены на токенах. Каждый компонент
читает --accent, --surface-raised, --radius; настройки оформления
превращаются в эти CSS-переменные одной функцией в общем пакете, и сервер
подставляет их в корень документа. Ни один компонент не знает, что цвет
настраиваемый, — именно поэтому на настройку реагируют все.
Два правила не дают этому стать вторым способом сломать сайт:
Пусто — значит как в сборке. Пустой заголовок — это не пустой заголовок на странице: фронт берёт переведённую строку из словаря. Поэтому оператор заполняет только то, что хочет изменить, ненастроенный сайт выглядит ровно как из коробки — на двух языках, — а наполовину заполненная форма никогда не даёт наполовину пустую страницу.
Оформление не дотягивается до шансов. Палитра и баннер живут в своей группе. Тикет-пространство, RTP и колесо — в своих, где изменение меняет игру, а не её раскраску.
Цвета хранятся в hex — в этом формате работает поле выбора цвета и в нём пишут
брендбуки, — и переводятся в тройку H S% L%, которую ждут стили. Этот перевод —
покрытая тестами функция, а не формат, который панель случайно пишет: токены
хранятся голыми компонентами именно ради hsl(var(--accent) / 0.14), и значение
не той формы уронило бы всю палитру.
/admin/appearance рисует те же настройки, что и общая форма, но сгруппированные
так, как человек думает о странице, и с двумя предпросмотрами. Панель рядом с
полями рисуется из черновика и реагирует на пипетку мгновенно; iframe под ней
показывает сохранённое состояние — и это честно: это сайт таким, каким его
получит посетитель. Готовые палитры существуют по той же причине, по которой
вообще нужна отправная точка: дюжина цветовых полей без неё — это способ прийти
к нечитаемому сайту.
Метаданные тоже следуют за настройками. Заголовок и описание страницы генерируются на сервере, поэтому переименованный сайт переименован во всех ссылках, которыми потом делятся, а не только в шапке.
Разделение, из которого следует всё остальное: база уже является аналитическим хранилищем всего, что двигало деньги. Открытия, пополнения, батлы, рефералы и выводы — это строки с суммами и временем, и отчёт по ним — это запрос. Продублировать их событиями значило бы завести второй набор чисел, который может разойтись с журналом, — а когда отчёт расходится с журналом, доверие теряет отчёт.
Поэтому поток событий несёт только то, на что база ответить не может: что человек пришёл, откуда, что смотрел и где остановился. Шесть типов событий, не больше, и каждый — то, чего нет ни в одной таблице: просмотр страницы, нажатие кнопки Steam, открытие окна пополнения, попытка ввести промокод, клик, с которого начинается батл, переход по приглашению.
Встречаются они в воронке: верх из событий, низ из журнала.
посетители → регистрации → пополнили → открыли → вывели
(события) (users) (журнал) (openings) (withdrawals)Ни IP, ни user-agent. Поток — о поведении, а не о личности, и ни одно из этих полей не отвечает на вопрос о поведении лучше, чем грубое «компьютер / телефон / планшет», — зато оба превратили бы отчёт о трафике в хранилище персональных данных. Класс устройства выводится из заголовка запроса и хранится вместо него.
Дневные метрики сводятся раз в ночь в одну строку на метрику на день: месяц истории становится дешёвым для чтения и переживает чистку сырых событий. Сегодняшняя строка пересчитывается по запросу — оператор, который следит за акцией, не может ждать до завтра.
Заголовочные числа при этом не читаются из этих строк. Сумма дневных уников — это не уник: человек, зашедший в понедельник и во вторник, — это два дневных посетителя и один посетитель, и KPI, который сложил бы график, завышал бы каждого вернувшегося. Поэтому плитки считаются по всему диапазону честными distinct, а графики берутся из свёрток. Два пути, каждый верен для своего вопроса.
pnpm test:smoke:analytics фиксирует главный шов: что wagered в отчёте в
точности равен сумме case_openings.casePrice, и что дважды пересчитанная
свёртка ничего не удваивает.
Дорожная карта решается по GGR на механику, а его нельзя посчитать в кликах: кейсы и батлы — это ставка минус выпавшее, апгрейд — ставка минус выигранные цели, контракт — вложенное минус награда, а колесо только тратит деньги, для этого оно и есть. Каждая строка измеряется в своих терминах, и колонка об этом говорит: одна формула на все пять была бы неверна четырежды.
Сейчас за кнопкой «Пополнить» стоит заглушка — сервер начисляет введённую сумму
без всякой оплаты. Она существует, чтобы игровой цикл можно было гонять до
подключения платежей, и выключается флагом ENABLE_STUB_DEPOSITS; в production
выключена по умолчанию, потому что это буквально кнопка «нарисовать себе денег».
Настоящее пополнение устроено иначе, и разница принципиальная:
- создаётся счёт у платёжного провайдера, пользователь уходит на его страницу;
- баланс меняется только по вебхуку об успешной оплате, никогда по возврату пользователя на сайт — возврат подделывается тривиально;
- обработка вебхука идемпотентна по идентификатору платежа: PSP штатно присылает один и тот же вебхук несколько раз;
- подпись вебхука проверяется до всякой записи в БД.
Что уже сделано правильно и переживёт замену заглушки: начисление идёт через
Transaction, а не прямым UPDATE баланса, поэтому ночная сверка остаётся
корректной, и в отчётах депозит виден как депозит.
Инъекции. Все обращения к базе идут через Prisma. Три десятка «сырых» — это
tagged templates, которые Prisma отправляет параметризованным запросом, так что
значение физически не может стать синтаксисом. Небезопасных вариантов
($queryRawUnsafe, $executeRawUnsafe, Prisma.raw) в репозитории нет ни
одного — и это стоит сохранить: только через них строка вообще способна попасть
в SQL конкатенацией.
Вход. Каждое тело разбирается zod-схемой до того, как его увидит обработчик,
каждый :id проходит через ParseUUIDPipe, пагинация ограничена сотней строк
на страницу. Суммы читаются из базы, а не из запроса: клиент присылает id и
количество, а сколько это стоит — выясняется на сервере.
Объём. Общий потолок на весь API, по адресу и в Redis, чтобы он означал одно
и то же на любом инстансе: RATE_LIMIT_MAX запросов за RATE_LIMIT_WINDOW_SEC
секунд, по умолчанию 300 в минуту. Health-check исключён, а недоступный Redis
пропускает запросы, а не отклоняет их — ограничитель, падающий «в закрытую», это
отказ сайта. Под ним лежат более узкие лимиты по фичам: открытие кейсов на
аккаунт (limits.openPerWindow в админке) и аналитика на адрес.
Размер тела. Мегабайт везде — уже с запасом для API, у которого самый
большой обычный запрос это объект настроек. Единственное исключение —
POST /api/kyc, поднятый до двенадцати: документы приходят в base64, а снятый
телефоном паспорт весит несколько мегабайт ещё до того, как кодирование добавит
треть. Поднять лимит везде значило бы разрешить любому вызывающему заставлять
сервер держать в памяти по двенадцать мегабайт.
Заголовки. Helmet на каждом ответе: default-src 'none' и
frame-ancestors 'none' (API не отдаёт страниц и не предназначен для фрейма),
nosniff, HSTS, no-referrer. CORS — явный список из CORS_ORIGINS, а не
отражение пришедшего Origin.
Адреса. TRUST_PROXY по умолчанию выключен. Если API открыт напрямую,
X-Forwarded-For пишет только тот, кто звонит, — доверять заголовку значит
разрешить каждому выбирать себе адрес, то есть свежую корзину лимита на каждый
запрос и записи в аудите под выдуманными адресами. Включать, когда соединения
терминирует обратный прокси и заголовок ставит он сам.
Вход в аккаунт. Только Steam OpenID: пароля в системе нет вообще, поэтому
подбирать нечего. Ответ Steam не принимается на веру — он отправляется обратно
на steamcommunity.com с check_authentication, засчитывается только
подтверждённое is_valid:true, и уже после этого claimed_id должен точно
совпасть с https://steamcommunity.com/openid/id/<17 цифр>.
Сессии. Access-токен — bearer HS256 на 15 минут, refresh — httpOnly-кука с
SameSite=Lax и Secure в проде. Все остальные ручки читают заголовок
Authorization, который чужая страница поставить не может, а единственный маршрут
с кукой — POST, который SameSite=Lax кросс-сайтом не отправляет; CSRF защищать
здесь попросту не от чего. Бан читается из базы при каждом действии с деньгами,
а не из токена, поэтому срабатывает сразу; роль едет в токене, так что
понижение вступает в силу в течение пятнадцати минут.
Владение. Каждый маршрут с id сверяет его с вызывающим —
findFirst({ where: { id, userId } }) — или стоит за guard'ом ролей. Строже
всего с документами: они отдаются через API, а не со статического пути, только
ADMIN/SUPPORT, путь на диске берётся из строки в базе (то есть в запросе нет
пути, по которому можно было бы уйти вверх), файл пишется с правами 0600,
ответ идёт с Cache-Control: no-store.
Деньги на входе. Вебхук платежей проверяет HMAC по сырому телу до разбора JSON, а зачисление — условный апдейт, поэтому повторно присланный колбэк не зачисляет ничего.
Разметка. Ни dangerouslySetInnerHTML, ни innerHTML, ни eval — любая
строка от игрока (ник, промокод) проходит через React, который её экранирует.
Секреты. Оба JWT-секрета обязательны и не короче 32 символов, значений по
умолчанию у них нет. .env в gitignore, в репозитории только .env.example.
Ограничитель считает запросы внутри процесса — правильное место, чтобы
остановить скрипт, и неправильное, чтобы остановить флуд: трафик, способный
забить канал, к моменту, когда его видит Node, уже пришёл. Поглощает такое CDN
или прокси перед приложением — и как только он появится, нужно выставить
TRUST_PROXY=true, чтобы лимит считал настоящие адреса, а не адрес прокси.
Две вещи сознательно не ограничены. WebSocket-соединения: у гейтвеев вообще нет обработчиков сообщений от клиента, сокет умеет только принимать, — но память он всё равно занимает. И ответ каталога, пара мегабайт JSON; он лежит в Redis, так что цена — трафик, а не работа базы.
- мультиаккаунты: корреляция по адресу, отпечатку, trade-URL и платёжному средству
- лимиты на вывод: суточные и отдельное правило для первого вывода после первого пополнения
- риск чарджбэка: придерживать вывод, пока пополнение не «отстоялось»
- возраст Steam-аккаунта и уровень профиля — отсекают одноразовые аккаунты
- рефералка сверх уже проверяемого (код привязывается только к аккаунту без истории, регистрация с адреса пригласившего помечается)
Отдельно — юридическая часть. Покупка кейса за реальные деньги в большинстве
юрисдикций регулируется как азартная игра, и требования (лицензия, KYC, проверка
возраста, ограничения по странам) зависят от того, где зарегистрирована компания
и откуда идёт трафик. Это решается до запуска платежей, а не после — геоблок и
KYC закладываются заранее (поля верификации на User уже есть).
- Этап 1 (сделано). Монорепо, docker-compose, схема БД, Steam OpenID, provably fair, открытие кейса с анимацией и партиями, апгрейд, контракты, инвентарь, лента дропов, локализация RU/EN с переключателем валюты, CRM с конструктором кейсов и синхронизацией цен из Steam.
- Этап 2 (сделано). Ферма ботов, реальные выводы через market.csgo.com и через собственных ботов, а также депозит скинами в обратную сторону.
- Этап 3 (сделано). Кейс-батлы, промокоды, реферальная система.
- Этап 4 (сделано, с тремя названными ограничениями). Платежи за портом
провайдера, проверка личности с разбором оператором, отчёты и выгрузки CRM.
Что в него не входит, лучше сказать прямо, чем оставить на самостоятельное
обнаружение:
- нет боевого платёжного провайдера — порт поставляется с подписанным демо-адаптером, а настоящий это адаптер плюс договор с банком, не переделка;
- нет стороннего сервиса верификации — провайдер это договор, соглашение об обработке данных и плата за проверку, и такое кодовая база не решает;
- нет шифрования документов на диске и регламента хранения — и то и другое свойства тома и юрисдикции.
- Этап 5 (сделано). Нагрузочное тестирование, PgBouncer, read-реплика, кэширование и health-проверки — раздел 10.
Вне дорожной карты, потому что понадобились раньше четвёртого этапа: конструктор оформления (раздел 11d) и аналитический контур (раздел 11e). Позже и по той же причине добавились: полки каталога с описаниями кейсов, бесплатные кейсы с порогом пополнения, лента дропов над шапкой, публичные профили игроков и импортёр, превращающий файл-выгрузку в рабочий каталог.
По дорожной карте — ничего. Остался короткий список того, чего карта никогда не касалась, и ничто из этого не мешает сайту работать:
- Нет страницы управления полками каталога. Их заводит импортёр и API, кейс можно перенести между ними в конструкторе, но переименовать или переставить полку интерфейсом нельзя. Переводы для такой страницы уже лежат в словаре — это и есть примета.
- Имена предметов хранятся на одном языке. В
Item.nameлежит то, что читал импорт, создавший строку: английский для демо-каталога, русский для всего, что принёс импортёр выгрузки. Сетка состава кейса показываетmarket_hash_nameи не задета; смешение видно в инвентаре. weaponTypeпуст у всего, что импортировано по-русски. Поле пишется и никем не читается, поэтому сегодня это ничего не стоит — и потребует правки раньше, чем что-нибудь начнёт его читать.- Тринадцать кейсов сидят ниже рабочего коридора. Это не убыток: они слишком
скупые, а не убыточные, и
rebalance-casesчинит их одним прогоном. Не тронул потому, что повышать отдачу кейса — решение оператора.