Skip to content

Переносимые аккаунты

Как один игровой аккаунт живёт под несколькими логинами (устройство, Facebook, Google/Apple, сторы, встроенные платформы), как логины связываются, защищаются, сливаются и удаляются, и как это выглядит в операторской панели.

Обзор

Раньше игрок в MARV был логином: строка users ключевалась парой (api_type, api_uid), поэтому Facebook-логин и логин с устройства были двумя разными аккаунтами. Переносимые аккаунты разрывают эту связь: один аккаунт (один прогресс) достижим через несколько логинов, а прогресс следует за аккаунтом между устройствами и экосистемами.

Модель (внутреннее имя — A3) сохраняет существующую строку users как аккаунт и добавляет одну тонкую таблицу user_identities, которая сопоставляет каждую пару-логин с user.id. Ничто в аккаунте больше не ключуется парой — всё внутреннее ключуется по user.id; пара остаётся только как отображение (display), измерение (dimension: ремоут-конфиг, подбор, стор-валидатор, платформенная почта) и история.

Ключевые инварианты

  • user.idвнутренний: это цель внешних ключей, он никогда не уходит на клиент.
  • Публичная идентичность, которую видит клиент, — по-прежнему пара (api_type, api_uid); для существующих клиентов контракт не изменился.
  • Стабильный непрозрачный public_id (UUID) — внешний идентификатор аккаунта для аналитики, других сервисов и саппорта.

Модель идентичности — три слоя

СлойЗначениеКомуСтабильность
Внутреннийuser.id (bigint PK)только БД / FK, никогда на проводевечный, приватный
Внешнийpublic_id (UUID)аналитика, другие сервисы, саппорт, GDPRвечный для аккаунта
Display / dimension(api_type, api_uid)UI, валидатор покупок, история по провайдерамменяется (secure / promote / disconnect)

Три роли api_type разведены намеренно: на user.id ушла только identity; dimension (ремоут-конфиг, подбор, IAP-валидатор, платформенная почта, партиция кэша, стратификация экспериментов) и display / история по-прежнему ключуются парой.

Модель данных

users
├── id            BIGSERIAL PRIMARY KEY           -- аккаунт; внутренний
├── public_id     UUID NOT NULL DEFAULT gen_random_uuid()  -- внешний, UNIQUE
├── api_type      INT                             -- display-пара (dimension/история)
├── api_uid       VARCHAR                         -- display-пара
├── data          JSONB                           -- игровой прогресс
├── ... (version_vector, banned, language_code, timestamps, ...)

user_identities
├── id            BIGSERIAL PRIMARY KEY
├── user_id       BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE
├── api_type      INT NOT NULL
├── api_uid       VARCHAR NOT NULL
├── last_login_at TIMESTAMPTZ
└── created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
sql
-- одна пара-логин принадлежит ровно одному аккаунту
CREATE UNIQUE INDEX idx_uniq_user_identities_api_type_api_uid ON user_identities (api_type, api_uid);
CREATE INDEX        idx_user_identities_user_id               ON user_identities (user_id);
-- внешний id аккаунта
CREATE UNIQUE INDEX idx_uniq_users_public_id                  ON users (public_id);

public_id принадлежит БД: DEFAULT gen_random_uuid() (UUIDv4, 122 бита энтропии) плюс UNIQUE-индекс — жёсткую гарантию от коллизий даёт именно констрейнт, а не версия UUID. Поле read-only на уровне ORM, поэтому Save не может его перезаписать или обнулить.

У каждого аккаунта есть display-пара в users.api_type / api_uid — пара, которая показывается и используется как dimension. Все per-account игровые таблицы (миры, соперники, event-результаты, почта, трафик-флоу, стрим-инпуты, членство в кланах, транзакции, заказы, …) ре-кеены на user.id с ON DELETE CASCADE; пара остаётся на этих строках как денормализованное отображение/история.

Классы логинов

Провайдер логина классифицируется бинарно — либо токен проверяется провайдером (strong), либо нет (weak). Среднего уровня нет: либо учётные данные сверяются с тем, чего у клиента нет, либо нет. Класс — это свойство самой платформы (LoginClass() у каждого провайдера), а не список в одном месте; базовая платформа по умолчанию weak (default-deny — недоказанный логин считается форжабельным).

КлассПровайдерыВерификацияРоль
strongfb_login (S2S), vk / ok / fb-canvas / tg / yandex / beeline / exe / fs / gm / mm / jest (секрет провайдера на сервере), crazy_games / play_deck / ms_start (RSA public key), mobage (RSA cert), google_signin / apple_signin (OIDC/JWKS), game_center (подпись GameKit), play_games (server auth-code)против учётных данных, которых нет у клиента (OAuth/JWKS, server-to-server, либо собственный секрет/ключ/сертификат провайдера, хранящийся на сервере)может якорить аккаунт и запускает защиту (secure)
weak (по умолчанию)unauthorized (гость, без проверки), default (игровой общий секрет → shipped в клиенте; orbit — легаси, в реестре платформ не зарегистрирован), dr / esi / ms_store (нет проверки логина), все мобильные сторы (app_store / play_market / amazon / galaxy_store / myket / bazaar / ru_store — только чек покупки, логин client-asserted) и любой провайдер, не доказавший strongигровой общий секрет (форжабельно), нет проверки, либо стор-логин с client-asserted idне якорит; отцепляется при защите аккаунта сильным логином; нельзя привязать к уже защищённому аккаунту

Переносимые якоря

Сильные логины делятся ещё по признаку «можно ли восстановить аккаунт с любого устройства/OS» (IsPortableAnchor()). Инвариант из кода:

Переносимый якорь = любой strong-логин, КРОМЕ контекстно-запертых Game Center, Play Games и FB Canvas (fb).

  • Переносимые — кросс-OS. Это все strong-провайдеры, у которых IsPortableAnchor() = true: FB Login (fb_login), VK, OK, Telegram, Yandex, beeline, exe, fs, gm, mm, jest, CrazyGames, PlayDeck, MSStart, Mobage, а также Google / Apple sign-in. Переносимый якорь восстанавливает аккаунт откуда угодно.
  • Контекстно-запертыGame Center (только iOS), Play Games (только Android) и FB Canvas (fb — веб-контекст Facebook Canvas). Сильные, но привязаны к своей OS-экосистеме или веб-контексту, поэтому не переопределяют IsPortableAnchor (остаётся дефолтный false): переиспользовать их для восстановления с произвольного устройства нельзя.

Только при наличии переносимого якоря аккаунт может безопасно сбросить слабые фолбэк-логины — см. Защита и анти-локаут.

Резолв входа

/v2/login и GetOrCreate резолвят входящую пару (api_type, api_uid) в аккаунт:

  1. Поиск identity. JOIN user_identities по паре → если нашли, это её аккаунт.
  2. Легаси-фолбэк (self-heal). Если строки identity нет, но users.display == (api_type, api_uid) (запись до миграции) — аккаунт найден, а его строка identity достраивается.
  3. Создание. Иначе FirstOrCreate заводит новую строку users плюс её identity. Свободная пара = свежий пустой аккаунт.

Поскольку display-пара self-heal'ится, отвязка display-логина обязана сначала промоутнуть display на другой identity (иначе фолбэк воскресил бы удалённый логин) — см. Disconnect.

Identity API (/v2/identities)

Логин остаётся прежним; управление связями — неймспейс /v2/identities. Каждая мутирующая операция сначала верифицирует токен подключаемого логина его же провайдером (как в логине). user.id никогда не возвращается.

ЭндпоинтНазначениеИсходы
POST /v2/identities/listсписок привязанных логинов аккаунтасписок пар
POST /v2/identities/connectпривязать новый логин к текущему аккаунтуattached, already_linked, conflict, secured (→ перевыпуск), ошибка
POST /v2/identities/resolve-conflictслить два аккаунта, выбрав какой сейв оставитьresolved, keep-cloud (→ перевыпуск)
POST /v2/identities/disconnectотвязать логинdisconnected, удалён display (→ перевыпуск), ошибка

Три операции могут сменить display-пару аккаунта, а сессионный JWT привязан к ней — в этих ответах есть свежие token + expires_at, и клиент обязан заменить сессионный токен: (а) connect, защитивший гостевой аккаунт сильным логином; (б) resolve-conflict с keep-cloud; (в) disconnect, удаливший текущую display-пару.

Connect

Ситуация подключаемой парыИсходЭффект
Свободна (ни на одном аккаунте)attachedдобавляется как ещё одна identity; display не меняется
Уже на этом аккаунтеalready_linkedno-op
На другом аккаунте с прогрессомconflictвозвращается сводка другого аккаунта; игрок решает
Свободный strong-логин на аккаунте, где ещё есть weak-фолбэкattached + securedсм. Защита
weak-логин (гость / стор) → аккаунт, где уже есть strongошибка identity.unauthorized_linkотказ — нельзя заново открыть форжабельный backdoor на защищённом аккаунте
Гонка: два устройства цепляют одну свободную паруre-resolveидемпотентная вставка (ON CONFLICT DO NOTHING); проигравший перечитывает истинное состояние

Disconnect

Disconnect только удаляет строку identity — второй аккаунт не создаётся; отцепленная пара просто становится свободной.

ОтвязываемИсходЭффект
Обычную (не-display) identitydisconnectedстрока удалена; токен тот же
Display-паруdisconnected + перевыпускdisplay промоутится на оставшуюся identity (приоритет — переносимый якорь, затем любой strong), сессия перевыпускается — иначе логин воскреснет через self-heal
Последнюю identityошибка identity.last_identityотказ — аккаунт всегда должен быть достижим
Последний strong при живом weak (гостевом) логинеошибка identity.last_strongотказ — нельзя оставить аккаунт только на форжабельном логине

Resolve-conflict (слияние)

Конфликт = подключаемая пара уже принадлежит аккаунту B. Игрок выбирает, какой сейв выживет; победитель поглощает проигравшего, проигравший удаляется (его сейв сперва архивируется).

  • keep: local — выигрывает текущий аккаунт (A); B вливается в A. Display не меняется (кроме случая, когда A вобрал гостя и потребовался re-secure).
  • keep: cloud — выигрывает другой аккаунт (B); сессия переезжает на B, display = B, токен перевыпускается.

Всё, чем владеет проигравший, переезжает к победителю в одной транзакции (ReassignAccountData): идентичности; per-account уникальные таблицы winner-wins на конфликте (миры, event-результаты, прочтения почты, трафик-флоу, стрим-инпуты); соперник и членство в клане (один на аккаунт — сохраняется, если у победителя уже есть); заявки в кланы; колонки account_id в war-снапшотах; сообщения; коммерческий леджер (транзакции, заказы). Сейв проигравшего архивируется в user_backups перед удалением. Если после слияния у выжившего оказались и strong, и weak — он пере-защищается (weak отцепляется).

Раздвоение обратимо: войти в исходный аккаунт, connect отколотую пару → это конфликт → resolve-conflict → слияние.

Защита и анти-локаут

Когда к аккаунту, где ещё есть weak-фолбэк, подключается strong-логин, secureAccount промоутит display на strong-логин и отцепляет форжабельные фолбэки — так форж гостевого/сторового логина больше не достанет аккаунт.

Анти-локаут: фолбэк сбрасывается только если аккаунт остаётся восстановимым — либо остаётся переносимый strong, либо есть ≥2 strong-логина. Аккаунт, заякоренный единственным (возможно, экосистемо-запертым) strong-логином (например, только Game Center), сохраняет weak-фолбэк, чтобы потеря этого одного логина не заперла игрока при переходе между экосистемами; тогда сервер отдаёт needs_portable_anchor: true.

needs_portable_anchor возвращается в /v2/login и в каждом ответе /v2/identities/*. true означает, что у аккаунта ещё нет переносимого якоря; клиент использует это, чтобы в подходящий момент предложить привязать FB / Google / Apple. Как только переносимый якорь привязан, фолбэк отцепляется, и флаг гаснет.

Встроенные платформы с переходом «аноним → авторизован» (Yandex, CrazyGames, MS Start, …) ложатся сюда напрямую. Анонимная игра использует weak-логин (например, Yandex unauthorized, api_type 925); при входе клиент делает connect сильного логина платформы (например, Yandex авторизованный, api_type 25) к текущей сессии — это защищает гостевой аккаунт и сохраняет прогресс. Свежий логин вместо connect создал бы второй аккаунт (раскол). Серверного кода не требует — это обычный флоу connect/secure.

Sync-логины (блок api_type 7xx)

Переносимые, провайдер-верифицируемые логины, которые несут один прогресс между устройствами и экосистемами, живут в зарезервированном блоке api_type 7xx (держится вне низкого диапазона, чтобы пул под обычные per-portal платформы оставался большим). Числовые коды по-прежнему назначаются per-environment в конфиге; блок описан в domain/shared/api_type_range.go.

ПровайдерКлассЯкорьСерверная верификация
google_signinstrongпереносимыйGoogle OIDC id_token → Google JWKS (oauth2/v3/certs); audience = OAuth client id(s) игры
apple_signinstrongпереносимыйApple OIDC id_token → Apple JWKS; audience = bundle/service id
game_centerstrongэкосистемо-заперт (iOS)подпись GameKit RSA-SHA256 по playerID + bundleID + timestamp + salt, проверяется сертификатом Apple (грузится только с хоста apple.com)
play_gamesstrongэкосистемо-заперт (Android)одноразовый server auth code обменивается на токен в OAuth2-эндпоинте Google (client id/secret на сервере) → Play Games API players/me → player id

Google и Apple используют общий верификатор clients/oidc (JWKS-кэш, RS256, проверки issuer + audience + expiry). Все четыре мобильные (IsMobile() = true). Subject токена / player id сверяется с заголовком api_uid (анти-импёрсонация).

public_id — внешний id аккаунта

public_id (UUID) — стабильный непрозрачный идентификатор аккаунта вне MARV: аналитика, другие HG-сервисы, саппорт, сквозное удаление. Возвращается в ответе /v2/login и несётся в сессионном JWT (pid). Внешние системы должны ключеваться по public_id, не по паре (она не стабильна — меняется при secure / promote / disconnect) и не по user.id (внутренний, не на проводе).

public_idна аккаунт: два несмерженных аккаунта одного человека имеют два id (это корректно — до объединения это два аккаунта). При слиянии выживший сохраняет свой public_id; public_id проигравшего просто исчезает вместе с его строкой. Сшивку исторических внешних событий проигравшего с выжившим делает merge-событие на стороне аналитики — серверной alias-таблицы под это MARV не держит.

Комплаенс и удаление аккаунта

Удаление строки users каскадом сносит ре-кеенные per-account таблицы и идентичности. AccountDataRepository.DeleteAccount оборачивает это в одну транзакцию и дополнительно:

  • чистит pair-keyed бэкапы (user_backups, world_backups — без FK) для каждого логина, которым владел аккаунт;
  • удаляет experiment_assignments аккаунта (без FK);
  • обнуляет war-снапшот-указатели (clan_war_*.account_id), сохраняя pair-frozen строки истории;
  • сохраняет коммерческий леджер (транзакции, заказы) — у финансовых записей есть законное основание бухучёта.

Две точки входа используют это:

ПоверхностьЭндпоинтПоведение
Удаление операторомPOST acp users/delete (бит доступа User (AccessUser, 8); аудит)резолвит игрока и удаляет аккаунт
Callback удаления данных FacebookPOST /ext/{api_type}/fb/data_deletionсм. ниже

ForgetLogin — удаление одного логина (используется FB-колбэком): если это единственная identity аккаунта, удаляется весь аккаунт; иначе логин отцепляется (display промоутится, если это была display-пара), а аккаунт живёт под остальными логинами. В отличие от Disconnect, гард «последний strong» не применяется — запрос на удаление важнее.

Webhook удаления данных Facebook. Он верифицирует signed_request Facebook (HMAC-SHA256 секретом приложения), резолвит пару (api_type, fb_user_id)ForgetLogin (единственный FB → удалить аккаунт, иначе отвязать FB и оставить аккаунт) и возвращает настоящий status url (тот же эндпоинт по GET) плюс confirmation_code. Любой сбой возвращает 5xx, чтобы Facebook ретраил — никогда ложного «удалено»; отсутствие аккаунта = идемпотентный OK.

Операторская поверхность (ACP)

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

  • Поиск по public_id — acp users/search принимает public_id (или пару); объект user содержит public_id.
  • Связанные логины — acp users/identities (read-only, роль не требуется) резолвит аккаунт (по паре или public_id) и возвращает каждую identity, обогащённую login_class (strong / weak), portable, is_display, last_login_at, плюс подсказку уровня аккаунта needs_portable_anchor.
  • Отвязка — acp users/identities/unlink (бит доступа User, 8; аудит) отцепляет логин; удаляемая пара сама резолвит свой аккаунт, и применяются те же гарды Disconnect (last-identity / last-strong → 422).

В UI ACP профиль игрока аккаунто-центричен: в шапке — display-логин с public_id моноширинным подзаголовком, а панель «Способы входа» перечисляет связанные логины с бейджами класса / переносимости / display и per-login-действием отвязки, закрытым битом доступа User.

Диапазоны api_type

ДиапазонСмыслКласс
7xxпереносимые sync-логины (fb_login / google_signin / apple_signin / game_center / play_games)strong
9xxunauthorized / эфемерные (гость); также safe-delete диапазонweak
стор-id (напр. app_store / play_market / amazon)сторовая идентичность (IAP + слабый логин)weak
прочееper-portal платформыпо провайдеру

Числовые коды назначаются per-environment в конфиге; диапазоны выше зарезервированы и распознаются в domain/shared/api_type_range.go.