Skip to content

Latest commit

 

History

History
1271 lines (987 loc) · 108 KB

File metadata and controls

1271 lines (987 loc) · 108 KB

Архитектура: сайт с кейсами CS2

English version

Документ отвечает на вопрос «что вообще используют такие сайты» и фиксирует решения, принятые для этого проекта.


1. Что реально используют easydrop / case-battle / csgoroll и подобные

Публичного исходного кода у них нет, но стек уверенно читается по 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 + крипта карточные эквайеры такие проекты не любят, крипта почти всегда есть

Почему Steam-часть — это Node, а не Python

Это главный технический факт, который определяет стек. Торговля предметами идёт не через официальный Web API (он умеет только читать инвентарь и историю), а через эмуляцию клиента Steam и внутренние эндпоинты steamcommunity.com. Живой, поддерживаемый набор библиотек для этого существует ровно один — экосистема DoctorMcKay под Node:

  • steam-user — логин в Steam как клиент, сессии, refresh-токены
  • steamcommunity — куки/сессия steamcommunity.com, подтверждения мобильника
  • steam-tradeoffer-manager — создание, отслеживание и подтверждение трейд-офферов
  • steam-totp — генерация Steam Guard кодов и confirmation-ключей из shared_secret
  • globaloffensive — GC-соединение CS2: float, паттерн, стикеры, инспекция

Python-аналоги (steam, steampy) существуют, но заметно отстают: реже чинятся после изменений на стороне Valve, слабее покрыты подтверждениями мобильного аутентификатора. Для проекта, где бот-ферма — это касса, отставание на неделю после очередного изменения Valve означает неделю без выводов.

Вывод: торговый слой обязан быть на Node. Отсюда выбор — писать весь бэкенд на TypeScript, чтобы не тащить два языка, две модели данных и мост между ними.


2. Принятый стек

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.

Почему NestJS, а не голый Express/Fastify

Проект по составу — это CRM плюс игровое ядро: десятки модулей, роли, гварды, валидация, очереди, вебсокеты, крон. Nest даёт DI и модульность из коробки, что на горизонте года экономит больше, чем стоит его оверхед. Работает поверх Fastify, так что по RPS разница с чистым Fastify — единицы процентов.

Почему не Go

Go выиграл бы по RPS на батлах и вебсокетах, но: бот-ферма всё равно осталась бы на Node, то есть два языка; порог входа выше; для нагрузок уровня MVP (до ~5k одновременных сокетов на инстанс) Node с Fastify не является узким местом — им становится Postgres. Если игровое ядро упрётся, его можно вынести в Go отдельным сервисом позже, границы модулей это позволяют.


3. Домен и модель данных

Ключевые сущности (полная схема — 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

Ноль затронутых строк = недостаточно средств. Это исключает гонку при параллельных открытиях с двух вкладок — классический способ увести сайт в минус.


4. Provably fair

Схема стандартная для индустрии, проверяется пользователем вручную.

  1. Сервер генерирует serverSeed (32 случайных байта), публикует только sha256(serverSeed). Сид активен, пока пользователь его не сменит.
  2. Пользователь задаёт clientSeed (или получает случайный) и может менять его когда угодно.
  3. У пары сидов есть счётчик nonce, инкрементируется на каждое открытие.
  4. Ролл:
hmac = HMAC_SHA256(key = serverSeed, message = `${clientSeed}:${nonce}`)
roll = parseInt(hmac.slice(0, 8), 16) % 1_000_000
  1. Выпадает тот CaseItem, чей диапазон накрывает roll.
  2. При смене серверного сида старый раскрывается целиком — любой прошлый ролл пересчитывается и сверяется с опубликованным хэшем.

Тикет-пространство — ровно 1_000_000. Диапазоны предметов в кейсе обязаны покрывать [0, 999999] без дыр и пересечений; это проверяется при сохранении кейса в админке и отдельным тестом. Такая форма (диапазоны, а не веса) выбрана потому, что её тривиально проверить вручную и она не зависит от порядка перебора предметов.

Важно: serverSeed не должен зависеть от предмета или от пользователя, и решение о дропе не имеет права смотреть на баланс игрока. Любая «докрутка» ломает проверяемость — если нужна экономика, она настраивается диапазонами, а не хаком ролла.


5. Экономика и RTP

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.

Тот же джоб добирает недостающие картинки: у предметов, заведённых не через импорт (сид, ручная вставка), их нет, а без картинки витрина выглядит сломанной. Кейсу без своей картинки подставляется изображение самого дорогого предмета — именно им кейс и продаётся. Явно заданная картинка при этом не трогается.


6. Апгрейд

Игрок ставит свой скин против более дорогого. Шанс выводится из цен, а не назначается: chance = stakeValue / targetValue × UPGRADE_RTP, где RTP тот же, что у кейсов. При таком определении матожидание апгрейда равно ставке, умноженной на RTP, при любом множителе — экономика апгрейда автоматически совпадает с экономикой кейсов, и её не нужно балансировать отдельно.

Исход берётся из того же ролла, что и открытие кейса: roll < chance × TICKET_SPACE по той же паре сидов и общему счётчику nonce. Это не экономия кода, а свойство продукта: у пользователя одна страница честности на все механики, и апгрейд проверяется тем же способом, что и дроп.

Ставка списывается независимо от исхода — это ставка, а не залог. Списание идёт условным updateMany по статусу AVAILABLE: если предмет за это время ушёл в продажу или в вывод, апгрейд не состоится, а не выдаст предмет дважды.

Границы, за которыми апгрейд запрещён: множитель ниже 1.05 (размен с комиссией под видом улучшения), шанс выше 85% (гарантированный размен) и разрыв в цене, при котором шанс падает ниже 0.5%.


6a. Контракты

Контракт принимает от 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, что и в апгрейде: предмет, успевший уйти в продажу или вывод, не проходит захват, и контракт не заключается.

Контракт отклоняется, а не играется на плохих шансах, если каталог внутри коридора слишком редкий или в нём нет ничего выше выплаты, к которой таблица должна усредниться.


6b. Колесо ежедневного бонуса

Одно вращение раз в 24 часа: деньги, скидка, бесплатное открытие или скин.

Колесо — тикет-таблица как у кейса, роллится по той же паре сидов и общему счётчику nonce. Это единственная таблица в проекте, которая действительно задана руками, а не решена, и причина в том, что солверу нужно одно число для оптимизации: с призами настолько разными, как начисление в рублях и нож, такого числа нет. Ограничена вместо этого стоимость — wheelGrantCost() суммирует сектора, которые что-то отдают, и тест падает, если перенастройка выведет её за потолок.

Сектора рисуются ровно по тем тикет-диапазонам, по которым роллятся, поэтому приз с тремя процентами занимает три процента обода. Колесо, нарисованное равными дольками поверх неравных шансов, — самая старая ложь в жанре.

Кулдаун

Скользящие сутки, а не календарные: календарный сброс дарит тем, кто живёт в удачном часовом поясе, два вращения с разницей в несколько часов и превращает полночь в пик нагрузки, которого никто не просил.

Захватывается условным UPDATE по users.lastBonusAt, а не читается и потом пишется. Между чтением и записью два одновременных запроса проходят проверку оба, и игрок крутит дважды за сутки; ноль затронутых строк означает, что вращение уже забрал кто-то другой. Колонка денормализована именно ради этого — выводить последний спин из таблицы бонусов было бы аккуратнее и нельзя было бы захватить одним запросом.

Мгновенные призы и ваучеры

Деньги и скины начисляются внутри транзакции спина, и строка рождается уже погашенной. Начисление на баланс идёт через Transaction, как любое другое движение, поэтому ночная сверка по-прежнему сходится.

Скидка и бесплатное открытие — ваучеры: строка остаётся открытой, пока её не потратит открытие кейса. Трата происходит внутри транзакции самого открытия, до списания, — если разрешать ваучер раньше, параллельное открытие успеет забрать его в промежутке, и это открытие получит скидку впустую. Захват — тот же условный updateMany, что и в инвентаре, поэтому ваучер нельзя потратить дважды.

Когда открытых ваучеров несколько, открытие берёт тот, что экономит больше именно на этой корзине. «Первым пришёл, первым ушёл» проще и хуже: он сжигает скидку в 50% на самом дешёвом кейсе каталога, пока бесплатное открытие ждёт позади. Выбранный ваучер возвращается в ответе, потому что награда, исчезнувшая вместе с тихо изменившейся ценой, неотличима от бага.

Приз-скин

Сектор FREE_ITEM должен превратиться в конкретный скин, и этот выбор делается из того же ролла, который выбрал сектор, а не из второго, скрытого розыгрыша — список кандидатов упорядочен по id, что стабильно в отличие от сортировки по плывущей цене. Если в каталоге нет ничего в пределах потолка, сектор выплачивается деньгами: не выдать вообще ничего — единственный исход, которого у колеса быть не должно.


6c. Кейс-батлы

Несколько игроков покупают один и тот же набор кейсов и открывают его одновременно; все выпавшие предметы забирает один из них. В обычном режиме — тот, у кого сумма дропов больше, в безумном — тот, у кого меньше: те же кейсы, обратная цель.

Каждый платит полную стоимость набора. Никому не делается скидки за то, что он играет против другого, а значит маржа сайта ровно такая же, как если бы те же кейсы открывали поодиночке: ожидаемая отдача — это взвешенный 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 — именно это не даёт возврату обогнать расчёт: батл, заполнившийся секунду назад, уже играется, и уборщик не должен уметь вернуть его ставки из-под него. Выключение батлов в настройках заставляет тот же уборщик разобрать лобби целиком — ровно этого и хочет оператор, щёлкающий этим переключателем во время инцидента.


7. Steam-интеграция

7.1 Авторизация и профиль

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-аккаунта.

7.1a Сессии

Пара JWT — короткий access и длинный refresh: access несёт id и роль и проверяется на каждом запросе без похода в базу, поэтому он обязан быстро истекать, иначе бан или смена роли не вступят в силу. Refresh лежит в httpOnly-куке месяц и является единственным, чем можно выпустить новый access.

Это разделение работает, только если refresh кто-то тратит. Браузер делает это прозрачно: 401 от любого вызова запускает один refresh и один повтор исходного запроса, и сессию заканчивает именно неудавшийся refresh, а не штатное истечение через 15 минут. Параллельные 401 делят один refresh в полёте: загрузка страницы поднимает несколько запросов сразу, и если каждый начнёт свой refresh, кука провернётся несколько раз, а проигравшие гонку повторят запрос с токеном, который уже заменён. Со стороны игрока это неотличимо от случайного разлогина.

Вебсокет читает токен только на хендшейке, поэтому берёт его через колбэк и переподключается при смене токена; зафиксированный при загрузке страницы токен ломал бы любое переподключение после первого истечения.


7a. Жизненный цикл инвентаря

Предмет на аккаунте — это строка со статусом, и строка никогда не удаляется:

AVAILABLE → SOLD | WITHDRAWN | LOCKED | UPGRADED | CONTRACTED

Действия доступны только из AVAILABLE. LOCKED принадлежит выводу в процессе, остальные три терминальны и составляют историю, которую интерфейс показывает отдельной вкладкой. Удалять строку было бы проще и неверно дважды: продавший нож игрок всё ещё хочет видеть, сколько за него получил, а поддержка не может разобрать жалобу на предмет, исчезнувший из интерфейса в момент списания.

Каждый переход — условный updateMany по status = AVAILABLE, который одновременно и проверка, и захват. Предмет, успевший уйти в продажу, вывод или контракт, просто выпадает из затронутых строк, и расхождение между количеством затронутых строк и числом запрошенных id — это то, на чём вызов падает. Ничего здесь не опирается на предварительное чтение строки.

Продажа всего собирает свой набор внутри транзакции по тому же фильтру, который показывал интерфейс, а не по списку id, загруженному браузером. Список id ограничил бы операцию той страницей, что была открыта, и проигрывал бы гонку любым изменениям в промежутке; фильтр — нет. Сумма возвращается с сервера по той же причине: диалог подтверждения способен назвать лишь её оценку.

Вывод из инвентаря запускает покупку. Предмет уходит в LOCKED, заявка встаёт в очередь; WITHDRAWN он становится только когда маркет подтвердит, что продавец отдал скин, и возвращается в AVAILABLE, если покупка не удалась. Поэтому интерфейс не может сказать «выведено» в момент нажатия — он говорит, что заявка создана, и это единственное, что на тот момент правда. Механика — раздел 7.6.

Фильтры. Две оси: вкладка (доступные, в выводе, история, все) и ценовой диапазон. Границы диапазонов заданы в базовой валюте, а не в той, что игрок сейчас отображает. Диапазон, плавающий вместе с курсом, пересортировывал бы инвентарь при каждом обновлении курсов, и предмет мог бы в понедельник лежать в одном диапазоне, а во вторник в другом, вообще не изменившись в цене. Подписи рендерятся обычным денежным форматтером, поэтому границы всё равно читаются в выбранной валюте — круглыми в базовой и пересчитанными в остальных.


7.2 Маркет: цены и картинки

Источник цен и изображений — Community Market, и у него два разных эндпоинта с разными свойствами:

market/search/render market/priceoverview
Отдаёт имя, картинку, редкость, число лотов только цену
Валюта всегда доллары, параметр currency игнорируется уважает currency
Запрос ключевые слова точный market_hash_name

Отсюда разделение: поиск используется для подбора предметов и метаданных, цена в валюте проекта всегда берётся через priceoverview.

Три вещи, на которых легко обжечься:

  1. Поиск не принимает market_hash_name. Символ | и скобки износа дают ноль результатов даже для предметов, которые точно продаются. Имя приходится нормализовать в ключевые слова (toSearchQuery).
  2. Ножи и перчатки идут с префиксом ★. Karambit | Doppler (Factory New) для Steam не существует, существует ★ Karambit | Doppler (Factory New).
  3. Берётся медиана, а не минимум. lowest_price дёргается одиночными демпинговыми лотами; кейс, посчитанный по ней, недооценивает предметы.

Частота запросов режется примерно на 20 в минуту, дальше 429 и бан на несколько минут. Поэтому обращения сериализуются с интервалом 3.5 с, результаты поиска кэшируются на час, цены — на полчаса, а 429 переводит сервис в паузу на 5 минут. Кэшируется и отрицательный результат: предмет без лотов иначе дёргал бы Steam на каждой синхронизации.

7.3 Валюта отображения

Хранение и расчёты остаются в базовой валюте. Переключатель валюты конвертирует только на отрисовке, по курсу из публичной ежедневной выгрузки ЦБ РФ — без ключа, с обновлением раз в шесть часов, кэшем в Redis и откатом на прежнее значение, если выгрузка недоступна. Ничего не переоценивается, и ни одна запись журнала не содержит конвертированную сумму: принять курс отображения за расчётный — это ровно тот путь, на котором сайт начинает продавать предметы ниже себестоимости после движения валюты.

7.4 Трейд-ссылка

Пользователь вставляет свою trade URL, из неё парсятся partner и token. Валидируется формой, а фактически — первым же оффером.

7.5 Каналы доставки

Их два, выбор — настройкой 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-аккаунтов, держащих инвентарь. Оставлен потому, что оператора, уже вложившегося в ферму, незачем заставлять мигрировать, и потому что собственный инвентарь — единственный способ выдать то, чего на маркете не продают.

7.5.1 Ферма ботов

Один бот = один Steam-аккаунт с включённым мобильным аутентификатором и shared_secret/identity_secret (нужны для автоподтверждения офферов). Ограничения, вокруг которых строится вся логика вывода:

  • инвентарь Steam — 1000 слотов, поэтому ботов нужно несколько и нужен роутинг заявки к боту, у которого предмет есть;
  • трейд-холд 7 дней, если у получателя нет мобильного аутентификатора или он включён недавно, — проверяется ДО отправки оффера, иначе предмет зависает в эскроу;
  • бот, только что сменивший пароль или устройство, уходит в холд сам;
  • Valve режет частоту офферов — очередь с троттлингом обязательна.

Секреты ботов (shared_secret, identity_secret, пароли) не лежат в БД в открытом виде: в проде — внешнее хранилище секретов, в dev — зашифрованы ключом из окружения.

7.6 Вывод предмета

заявка → валидация (трейд-ссылка, лимиты, антифрод)
       → предметы → 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).


8. Открытие партии кейсов

До десяти кейсов за раз. Вся партия — одна транзакция: либо списываются деньги за все кейсы и выдаются все предметы, либо не происходит ничего, включая счётчик nonce.

Ключевая деталь — резервирование nonce. Инкрементировать его в цикле по одному нельзя: параллельный апгрейд или открытие из другой вкладки вклинится в середину и заберёт номер из нашей партии, а nonce обязан быть непрерывным, иначе история перестанет пересчитываться подряд. Поэтому блок номеров захватывается одним UPDATE ... SET nonce = nonce + N RETURNING nonce, и партия занимает последние N номеров перед возвращённым.

В журнал транзакций пишется одна запись на партию, а не десять: сверка смотрит на сумму, а десять строк за один клик только зашумили бы историю.

Рейт-лимит считается в кейсах, а не в запросах — одна кнопка «×10» это десять открытий, и защита от скриптов должна видеть их так же.


9. Реалтайм

socket.io с адаптером на Redis, чтобы несколько инстансов API видели общие комнаты.

Каналы:

  • drops:live — глобальная лента дропов (только редкие, иначе на пике это флуд)
  • user:{id} — личное: баланс, статус вывода, инвентарь
  • battles:events — изменения батлов, рассылаются всем

События батлов намеренно не батчатся и не разложены по комнатам на батл. Они редкие — создание, занятое место, расчёт, — а лобби одно и общее: место должно исчезнуть сразу во всех браузерах, и триста миллисекунд буфера лишь показали бы занятое место свободным. Страница батла фильтрует рассылку по id и перечитывает батл, когда услышит про свой.

На пике лента дропов — самый горячий канал. Он агрегируется: события копятся в буфер и рассылаются пачкой раз в ~300 мс, а не по одному. Это разница между 50к и 2к сообщений в секунду при том же пользовательском ощущении.


10. Нагрузка

Профиль трафика у таких сайтов резко неравномерный: обычный день, а затем 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» на процесс — нет.

Read-реплика

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 свои составные индексы под агрегаты отчётов и один под представление батла.

Health

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 млн строк).


11. CRM / админка

Отдельный раздел приложения под ролевым доступом (ADMIN, SUPPORT, ANALYST).

  • Дашборд — GGR, депозиты, выводы, онлайн, конверсия, маржа по кейсам
  • Кейсы — конструктор: поиск предметов по маркету Steam с картинками и ценами, картинка кейса, автоподбор шансов под целевой RTP, подбор цены кейса, live-вердикт по марже, валидация покрытия тикет-диапазонов
  • Предметы — справочник, цены, ручной оверрайд цены
  • Пользователи — баланс, история, ручная корректировка баланса (всегда через Transaction + AuditLog), блокировка
  • Выводы — очередь заявок, ручное вмешательство, повторная отправка оффера
  • Маркет — баланс и проверки покупающего аккаунта, количество покупок по статусам, во что обошлась доставка против авторизованного потолка, и покупки, оплаченные, но не доставленные. Только чтение: деньги тратит один воркер
  • Боты — статус фермы, инвентари, ошибки логина, холды
  • Отчёты — выгрузки по периодам, когорты, топ игроков
  • Настройки — фичефлаги, лимиты, тексты, промокоды

Два правила без исключений: любое админское действие пишется в AuditLog с before/after, и ни одно из них не меняет баланс напрямую в обход Transaction.

Панель только на английском. Переводить внутренний инструмент значит удвоить поддержку ради аудитории в несколько человек.


11a. Настройки в рантайме

Всё, что оператор может изменить без деплоя, объявлено один раз в packages/shared/src/settings.ts — со своей группой, типом, границами и значением по умолчанию. Админка рисует форму из этого реестра, а не из полей, написанных под каждую настройку, поэтому добавить ручку — это строка в одном файле и больше ничего. Альтернатива — свой контрол, свой валидатор и свой читатель на каждую настройку — это три места, которые надо держать в согласии, и причина, по которой у большинства бэк-офисов есть настройки, существующие в базе и нигде в интерфейсе.

Значения кэшируются в API на пятнадцать секунд. Настройки читаются почти на каждом запросе — рейт-лимит, комиссия продажи, колесо, — и запрос на каждое чтение данных, меняющихся пару раз в месяц, чистая трата. Запись сразу обновляет пишущий инстанс, поэтому оператор видит эффект, пока ещё смотрит в панель; остальные инстансы подхватывают в пределах TTL.

Сохранённое значение, которое не распарсилось, откатывается к умолчанию и логируется, а не бросает исключение. Панель валидирует на входе — именно там плохое значение и надо отвергать громко; на этапе чтения отказ обслуживать сайт не лучше, чем обслуживать его со значением по умолчанию.

Что намеренно не настраивается

TICKET_SPACE, алгоритм сидов и форма ролла. Это не параметры тюнинга, а условия обещания: каждое прошлое открытие опубликовано против них, и именно их игрок пересчитывает, когда проверяет дроп. Оператор, способный их править, мог бы сделать так, что вчерашние дропы перестанут сходиться, — а это не настройка, а способ сломать единственное утверждение, которое сайт делает.

Тикет-диапазоны кейсов — того же рода, и правятся там, где им место: в конструкторе кейсов, через валидацию, отвергающую кейс, диапазоны которого не покрывают пространство.


11b. Промокоды

Код добавляет к пополнению, а не удешевляет его. Когда за кнопкой появится настоящий платёжный провайдер, списанная сумма обязана совпадать с той, о которой провайдеру сообщили, и код, меняющий её, рассинхронизировал бы их; начисление сверху оставляет акцию целиком на нашей стороне сделки.

Начисление — вторая строка журнала, а не увеличенная строка депозита. То, что игрок оплатил, и то, что дала акция, — разные деньги, и отчёт, который их не разделяет, не может сказать, сколько акция стоила.

Два лимита, и держатся они по-разному, потому что по-разному гоняются. Лимит на игрока — подсчёт строк погашений внутри транзакции пополнения. Общий лимит — условный UPDATE по денормализованному счётчику: подсчёт строк с последующей вставкой позволяет двум одновременным пополнениям увидеть последнее использование свободным и забрать его обоим. Источник правды остаётся в строках; счётчик существует, чтобы лимит можно было захватить одним запросом.

Коды деактивируются, а не удаляются. На них ссылаются погашения, и игрок, спрашивающий, почему сдвинулся его баланс, заслуживает строки, которая это всё ещё объясняет.


11c. Рефералы

Игрок раздаёт ссылку, тот, кто по ней пришёл, привязывается к нему при регистрации, и доля от того, что он потом тратит, начисляется пригласившему.

Привязка

Приглашение приходит как ?ref=КОД на любую страницу, и зашедший ещё не авторизован — сейчас его отправят в Steam и обратно, а это выбрасывает всё, что держала страница. Поэтому код паркуется в браузере и применяется отдельным вызовом сразу после входа.

Значит, ручка остаётся открытой и потом, и защищает её не время, а история: код отклоняется, как только на аккаунте появилась хоть какая-то активность. Игрок, который уже открывал кейсы или двигал деньги, — не чей-то новый реферал, и без этого правила состоявшийся аккаунт можно было бы приписать тому, кто попросил последним. Сверх того: своим кодом воспользоваться нельзя, а регистрация с того же адреса, что и у пригласившего, помечается на привязке — не отклоняется, потому что общий адрес бывает у квартиры и у мобильного оператора, но остаётся на виду у оператора.

Один пригласивший на игрока, и это держит уникальный refereeId, а не проверка: две одновременные заявки обе пройдут проверку, и решает только ограничение.

Начисление, и почему это не журнал

Комиссия копится строками ReferralEarning, которых нет ни на одном балансе и нет в журнале, пока их не заберут. Комиссия, начисляемая сразу, была бы строкой Transaction на каждое открытие каждого приглашённого, а журнал — это то, что обходит ночная сверка; накопление отдельными строками и выплата одной записью делают журнал пропорциональным числу выплат, а не числу дропов.

Ставка хранится в самой строке. Это настройка, и оператор, который её понизил, не должен переписывать уже заработанное.

Возвращённое место в батле забирает своё начисление с собой — иначе создание и отмена батлов были бы бесплатным способом заплатить пригласившему. Удаляются только неоплаченные строки: уже выплаченное начисление — это деньги на чьём-то балансе, и отобрать их значило бы списать то, на что владелец не соглашался.

Выплата

Накопленные строки забираются одним UPDATE ... RETURNING, и минимум проверяется по тому, что вернулось, а не по SUM, прочитанной заранее. Другой порядок выплачивает не ту сумму, которую проверил, всякий раз, когда между чтением и записью падает новое начисление, — а для пригласившего с активными рефералами это как раз норма. Отказ бросает исключение, которое откатывает захват и оставляет накопленное как было.

Сама выплата — одна запись REFERRAL в журнале: это единственная точка, в которой накопленная комиссия становится деньгами.


11d. Оформление как настройка

Как называется сайт, какого он цвета, какие разделы существуют и что написано на баннере — это настройки, которые отдаются браузеру в рантайме. Альтернатива — править классы Tailwind и передеплоивать — делает внешний вид задачей разработчика, хотя это не она.

Работает это потому, что стили и так построены на токенах. Каждый компонент читает --accent, --surface-raised, --radius; настройки оформления превращаются в эти CSS-переменные одной функцией в общем пакете, и сервер подставляет их в корень документа. Ни один компонент не знает, что цвет настраиваемый, — именно поэтому на настройку реагируют все.

Два правила не дают этому стать вторым способом сломать сайт:

Пусто — значит как в сборке. Пустой заголовок — это не пустой заголовок на странице: фронт берёт переведённую строку из словаря. Поэтому оператор заполняет только то, что хочет изменить, ненастроенный сайт выглядит ровно как из коробки — на двух языках, — а наполовину заполненная форма никогда не даёт наполовину пустую страницу.

Оформление не дотягивается до шансов. Палитра и баннер живут в своей группе. Тикет-пространство, RTP и колесо — в своих, где изменение меняет игру, а не её раскраску.

Цвета хранятся в hex — в этом формате работает поле выбора цвета и в нём пишут брендбуки, — и переводятся в тройку H S% L%, которую ждут стили. Этот перевод — покрытая тестами функция, а не формат, который панель случайно пишет: токены хранятся голыми компонентами именно ради hsl(var(--accent) / 0.14), и значение не той формы уронило бы всю палитру.

Конструктор

/admin/appearance рисует те же настройки, что и общая форма, но сгруппированные так, как человек думает о странице, и с двумя предпросмотрами. Панель рядом с полями рисуется из черновика и реагирует на пипетку мгновенно; iframe под ней показывает сохранённое состояние — и это честно: это сайт таким, каким его получит посетитель. Готовые палитры существуют по той же причине, по которой вообще нужна отправная точка: дюжина цветовых полей без неё — это способ прийти к нечитаемому сайту.

Метаданные тоже следуют за настройками. Заголовок и описание страницы генерируются на сервере, поэтому переименованный сайт переименован во всех ссылках, которыми потом делятся, а не только в шапке.


11e. Аналитический контур

Разделение, из которого следует всё остальное: база уже является аналитическим хранилищем всего, что двигало деньги. Открытия, пополнения, батлы, рефералы и выводы — это строки с суммами и временем, и отчёт по ним — это запрос. Продублировать их событиями значило бы завести второй набор чисел, который может разойтись с журналом, — а когда отчёт расходится с журналом, доверие теряет отчёт.

Поэтому поток событий несёт только то, на что база ответить не может: что человек пришёл, откуда, что смотрел и где остановился. Шесть типов событий, не больше, и каждый — то, чего нет ни в одной таблице: просмотр страницы, нажатие кнопки Steam, открытие окна пополнения, попытка ввести промокод, клик, с которого начинается батл, переход по приглашению.

Встречаются они в воронке: верх из событий, низ из журнала.

посетители →  регистрации →  пополнили  →  открыли  →  вывели
(события)     (users)        (журнал)      (openings) (withdrawals)

Что не собирается

Ни IP, ни user-agent. Поток — о поведении, а не о личности, и ни одно из этих полей не отвечает на вопрос о поведении лучше, чем грубое «компьютер / телефон / планшет», — зато оба превратили бы отчёт о трафике в хранилище персональных данных. Класс устройства выводится из заголовка запроса и хранится вместо него.

Свёртки, и почему заголовок — не сумма графика

Дневные метрики сводятся раз в ночь в одну строку на метрику на день: месяц истории становится дешёвым для чтения и переживает чистку сырых событий. Сегодняшняя строка пересчитывается по запросу — оператор, который следит за акцией, не может ждать до завтра.

Заголовочные числа при этом не читаются из этих строк. Сумма дневных уников — это не уник: человек, зашедший в понедельник и во вторник, — это два дневных посетителя и один посетитель, и KPI, который сложил бы график, завышал бы каждого вернувшегося. Поэтому плитки считаются по всему диапазону честными distinct, а графики берутся из свёрток. Два пути, каждый верен для своего вопроса.

pnpm test:smoke:analytics фиксирует главный шов: что wagered в отчёте в точности равен сумме case_openings.casePrice, и что дважды пересчитанная свёртка ничего не удваивает.

Маржа по механикам

Дорожная карта решается по GGR на механику, а его нельзя посчитать в кликах: кейсы и батлы — это ставка минус выпавшее, апгрейд — ставка минус выигранные цели, контракт — вложенное минус награда, а колесо только тратит деньги, для этого оно и есть. Каждая строка измеряется в своих терминах, и колонка об этом говорит: одна формула на все пять была бы неверна четырежды.


12. Деньги на входе: пополнение

Сейчас за кнопкой «Пополнить» стоит заглушка — сервер начисляет введённую сумму без всякой оплаты. Она существует, чтобы игровой цикл можно было гонять до подключения платежей, и выключается флагом ENABLE_STUB_DEPOSITS; в production выключена по умолчанию, потому что это буквально кнопка «нарисовать себе денег».

Настоящее пополнение устроено иначе, и разница принципиальная:

  1. создаётся счёт у платёжного провайдера, пользователь уходит на его страницу;
  2. баланс меняется только по вебхуку об успешной оплате, никогда по возврату пользователя на сайт — возврат подделывается тривиально;
  3. обработка вебхука идемпотентна по идентификатору платежа: PSP штатно присылает один и тот же вебхук несколько раз;
  4. подпись вебхука проверяется до всякой записи в БД.

Что уже сделано правильно и переживёт замену заглушки: начисление идёт через Transaction, а не прямым UPDATE баланса, поэтому ночная сверка остаётся корректной, и в отчётах депозит виден как депозит.


13. Антифрод и безопасность

Что в коде сейчас

Инъекции. Все обращения к базе идут через 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 уже есть).


14. Дорожная карта

  • Этап 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 чинит их одним прогоном. Не тронул потому, что повышать отдачу кейса — решение оператора.