Переносимые аккаунты
Как один игровой аккаунт живёт под несколькими логинами (устройство, 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()-- одна пара-логин принадлежит ровно одному аккаунту
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 — недоказанный логин считается форжабельным).
| Класс | Провайдеры | Верификация | Роль |
|---|---|---|---|
| strong | fb_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) в аккаунт:
- Поиск identity.
JOIN user_identitiesпо паре → если нашли, это её аккаунт. - Легаси-фолбэк (self-heal). Если строки identity нет, но
users.display == (api_type, api_uid)(запись до миграции) — аккаунт найден, а его строка identity достраивается. - Создание. Иначе
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_linked | no-op |
| На другом аккаунте с прогрессом | conflict | возвращается сводка другого аккаунта; игрок решает |
| Свободный strong-логин на аккаунте, где ещё есть weak-фолбэк | attached + secured | см. Защита |
| weak-логин (гость / стор) → аккаунт, где уже есть strong | ошибка identity.unauthorized_link | отказ — нельзя заново открыть форжабельный backdoor на защищённом аккаунте |
| Гонка: два устройства цепляют одну свободную пару | re-resolve | идемпотентная вставка (ON CONFLICT DO NOTHING); проигравший перечитывает истинное состояние |
Disconnect
Disconnect только удаляет строку identity — второй аккаунт не создаётся; отцепленная пара просто становится свободной.
| Отвязываем | Исход | Эффект |
|---|---|---|
| Обычную (не-display) identity | disconnected | строка удалена; токен тот же |
| 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_signin | strong | переносимый | Google OIDC id_token → Google JWKS (oauth2/v3/certs); audience = OAuth client id(s) игры |
apple_signin | strong | переносимый | Apple OIDC id_token → Apple JWKS; audience = bundle/service id |
game_center | strong | экосистемо-заперт (iOS) | подпись GameKit RSA-SHA256 по playerID + bundleID + timestamp + salt, проверяется сертификатом Apple (грузится только с хоста apple.com) |
play_games | strong | экосистемо-заперт (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 удаления данных Facebook | POST /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— acpusers/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 |
9xx | unauthorized / эфемерные (гость); также safe-delete диапазон | weak |
| стор-id (напр. app_store / play_market / amazon) | сторовая идентичность (IAP + слабый логин) | weak |
| прочее | per-portal платформы | по провайдеру |
Числовые коды назначаются per-environment в конфиге; диапазоны выше зарезервированы и распознаются в domain/shared/api_type_range.go.
